a6406209bd
build / build (push) Successful in 1m18s
librestore-<scope>-<时间戳.微秒> 带 ".",冻结协调器的 job 白名单只收 [A-Za-z0-9_-],所以每次恢复都在开窗口时被拒(invalid freeze request), 作业随后被接管扫成失败。改纳秒整数,并加测试锁住该约束。 试过:make verify 15 项全绿;真实恢复复现该错误后修复。
116 lines
4.9 KiB
Go
116 lines
4.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
func restoreRequest(body, role string) *http.Request {
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/restores", strings.NewReader(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
ctx := context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: role})
|
|
return request.WithContext(ctx)
|
|
}
|
|
|
|
// 恢复是写生产:非管理员直接拒绝,任何授权都不会被创建。
|
|
func TestLibraryRestoreRequiresAdmin(t *testing.T) {
|
|
store := &fakeLibraryStore{}
|
|
recorder := httptest.NewRecorder()
|
|
libraryRestoreEnqueue(recorder, restoreRequest(`{"scope":"internal","snapshot_id":"snap-1"}`, roleEngineer), Config{Store: store})
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if store.createN != 0 {
|
|
t.Fatal("non-admin must not create a restore job")
|
|
}
|
|
}
|
|
|
|
// 闸口关闭 / 缺恢复演练证据 / 缺该快照授权:逐条拒绝且不建作业。
|
|
func TestLibraryRestoreRefusesWithoutGatesAndAuthorization(t *testing.T) {
|
|
store := &fakeLibraryStore{}
|
|
recorder := httptest.NewRecorder()
|
|
libraryRestoreEnqueue(recorder, restoreRequest(`{"scope":"internal","snapshot_id":"snap-1"}`, roleAdmin), Config{Store: store})
|
|
if recorder.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
body := recorder.Body.String()
|
|
for _, want := range []string{"生产写入闸口关闭", "恢复演练证据", "恢复授权"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("refusal must name %q: %s", want, body)
|
|
}
|
|
}
|
|
if store.createN != 0 {
|
|
t.Fatal("must not create a restore job while blocked")
|
|
}
|
|
}
|
|
|
|
// 授权必须绑定"恢复到这个快照":同步授权不能顶替。
|
|
func TestLibraryRestoreRequiresSnapshotBoundGrant(t *testing.T) {
|
|
store := &fakeLibraryStore{}
|
|
syncDigest, err := librarySyncParamsDigest("internal", "snap-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
store.grant = &governance.LibraryReleaseAuthorization{ID: "a", Scope: "internal", SnapshotID: "snap-1",
|
|
ParamsDigest: syncDigest, ExpiresAt: now.Add(time.Hour), CreatedAt: now}
|
|
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true}
|
|
recorder := httptest.NewRecorder()
|
|
libraryRestoreEnqueue(recorder, restoreRequest(`{"scope":"internal","snapshot_id":"snap-1"}`, roleAdmin), config)
|
|
if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "恢复授权") {
|
|
t.Fatalf("sync grant must not authorize a restore: %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if store.createN != 0 {
|
|
t.Fatal("must not create a restore job with a sync-only grant")
|
|
}
|
|
}
|
|
|
|
// 续作只对失败/取消的同步与恢复作业开放;封存作业走封存入口。
|
|
func TestLibraryJobResumeRejectsNonFailedJobs(t *testing.T) {
|
|
store := &fakeLibraryStore{jobs: []governance.LibraryJob{{ID: "job-1", Scope: "internal",
|
|
Kind: governance.LibraryJobSync, State: governance.LibraryJobSucceeded}}}
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/jobs/job-1/resume", nil)
|
|
request.SetPathValue("id", "job-1")
|
|
request = request.WithContext(context.WithValue(request.Context(), sessionKey{},
|
|
sessionIdentity{Username: "admin", Role: roleAdmin}))
|
|
recorder := httptest.NewRecorder()
|
|
libraryJobResume(recorder, request, Config{Store: store})
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
// 恢复作业 id 会作为维护窗口的 job 标识落进 freeze journal,协调器只接受
|
|
// [A-Za-z0-9_-]。带 "." 的时间戳曾经让每次恢复都在开窗口时被拒(invalid freeze request)。
|
|
func TestLibraryRestoreJobIDIsAFreezeSafeIdentifier(t *testing.T) {
|
|
store := &fakeLibraryStore{fakeJobStore: fakeJobStore{enqueueEchoRequestedID: true}}
|
|
digest, err := libraryRestoreParamsDigest("internal", "snap-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
store.grant = &governance.LibraryReleaseAuthorization{ID: "a", Scope: "internal", SnapshotID: "snap-1",
|
|
ParamsDigest: digest, ExpiresAt: now.Add(time.Hour), CreatedAt: now}
|
|
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true}
|
|
recorder := httptest.NewRecorder()
|
|
libraryRestoreEnqueue(recorder, restoreRequest(`{"scope":"internal","snapshot_id":"snap-1"}`, roleAdmin), config)
|
|
if recorder.Code != http.StatusAccepted {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
id := store.lastJob.ID
|
|
if id == "" || len(id) > 128 {
|
|
t.Fatalf("作业 id 形状不对:%q", id)
|
|
}
|
|
for _, r := range id {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
|
|
t.Fatalf("作业 id 含维护窗口不接受的字符 %q:%q", r, id)
|
|
}
|
|
}
|
|
}
|