b4add6c9b0
B1-R1: 演练写集改为受限 restore-drill(V2 信封/contract 1/schema 摘要,零对象、零索引、零本地映射,来源身份必填)。B2-R1: verify 作业先建不可变 operation 并携带 operation_id,同 job recover 的网关 claim 同时携带原 operation 与 recovery 身份。B5-R1: 前端按 scope 世代与动作序号丢弃迟到响应,不污染新 scope 或父级旧回调。harness 的 recovery 改由生产 jobs.Worker.RunOnce 消费并读回 jobs 终态;原失败窗口作业保持 failed,不改写历史。
278 lines
12 KiB
Go
278 lines
12 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
)
|
||
|
||
// 恢复验证的页面契约:
|
||
// - 只有管理员能发起(它会停写入者并在目标上写 canary);
|
||
// - 闸口关闭 / 维护中时直接拒绝,不投一个 worker 必然失败的作业;
|
||
// - 证据只由真实执行写入:没有证据 ⇒ verified=false,过期/换目标 ⇒ 失效。
|
||
//
|
||
// 这里不模拟"真跑一次恢复"(那需要真实目标);执行体本身在 internal/libraryverify,
|
||
// 由库内 CLI 与 worker 共用,属于隔离 fixture 之外的集成范围。
|
||
|
||
type verificationStore struct {
|
||
fakeLibraryStore
|
||
verifications []governance.LibraryRestoreVerification
|
||
}
|
||
|
||
func (s *verificationStore) LatestLibraryRestoreVerification(_ context.Context, scope string) (governance.LibraryRestoreVerification, bool, error) {
|
||
for _, item := range s.verifications {
|
||
if item.Scope == scope {
|
||
return item, true, nil
|
||
}
|
||
}
|
||
return governance.LibraryRestoreVerification{}, false, nil
|
||
}
|
||
|
||
func (s *verificationStore) LibraryRestoreVerifications(_ context.Context, scope string, limit int) ([]governance.LibraryRestoreVerification, error) {
|
||
items := make([]governance.LibraryRestoreVerification, 0, len(s.verifications))
|
||
for _, item := range s.verifications {
|
||
if item.Scope == scope {
|
||
items = append(items, item)
|
||
}
|
||
}
|
||
if limit > 0 && len(items) > limit {
|
||
items = items[:limit]
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func verifyRequest(body, role, username string) *http.Request {
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/verifications", strings.NewReader(body))
|
||
request.Header.Set("Content-Type", "application/json")
|
||
ctx := context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: username, Role: role})
|
||
return request.WithContext(ctx)
|
||
}
|
||
|
||
func TestLibraryVerifyRequiresAdmin(t *testing.T) {
|
||
store := &verificationStore{}
|
||
recorder := httptest.NewRecorder()
|
||
libraryVerifyEnqueue(recorder, verifyRequest(`{"scope":"internal"}`, roleEngineer, "eng"),
|
||
Config{Store: store, ProductionReleaseRequestsEnabled: true})
|
||
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 verify job")
|
||
}
|
||
}
|
||
|
||
func TestLibraryVerifyRefusesWhenWriteGateClosed(t *testing.T) {
|
||
store := &verificationStore{}
|
||
recorder := httptest.NewRecorder()
|
||
libraryVerifyEnqueue(recorder, verifyRequest(`{"scope":"internal"}`, roleAdmin, "admin"),
|
||
Config{Store: store, ProductionReleaseRequestsEnabled: false})
|
||
if recorder.Code != http.StatusServiceUnavailable {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
body := decodeBody(t, recorder)
|
||
if body["code"] != "library_verify_blocked" {
|
||
t.Fatalf("body=%v", body)
|
||
}
|
||
if store.createN != 0 {
|
||
t.Fatal("closed gate must not create a verify job")
|
||
}
|
||
}
|
||
|
||
func TestLibraryVerifyEnqueuesVerifyJobWithCanaryStages(t *testing.T) {
|
||
store := &verificationStore{}
|
||
store.enqueueEchoRequestedID = true
|
||
recorder := httptest.NewRecorder()
|
||
libraryVerifyEnqueue(recorder, verifyRequest(`{"scope":"internal"}`, roleAdmin, "admin"),
|
||
Config{Store: store, ProductionReleaseRequestsEnabled: true})
|
||
if recorder.Code != http.StatusAccepted {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if store.lastJob.Kind != governance.LibraryJobVerify || store.lastJob.Scope != "internal" {
|
||
t.Fatalf("job=%#v", store.lastJob)
|
||
}
|
||
if !strings.HasPrefix(store.lastJob.ID, "libverify-internal-") {
|
||
t.Fatalf("job id=%q", store.lastJob.ID)
|
||
}
|
||
// B2-R1:verify 作业必须带不可变 operation,worker 的 owned attempt / 恢复派发
|
||
// 身份都从它取;没有 operation 的作业在真实恢复时会被 fail closed。
|
||
if store.lastJob.OperationID == "" || store.lastJob.OperationID != store.lastOperation.ID {
|
||
t.Fatalf("verify job must carry its operation: job=%#v op=%#v", store.lastJob, store.lastOperation)
|
||
}
|
||
if store.lastOperation.Kind != governance.LibraryJobVerify || store.lastOperation.Scope != "internal" {
|
||
t.Fatalf("verify operation identity=%#v", store.lastOperation)
|
||
}
|
||
// 阶段必须包含 canary:没有"写 canary 证明校验有效"这一步,验证就只是自证。
|
||
want := governance.VerifyStages()
|
||
if len(want) != 5 || want[2] != "canary" || want[3] != "restore" || want[4] != "finalize" {
|
||
t.Fatalf("verify stages=%v", want)
|
||
}
|
||
}
|
||
|
||
func TestLibraryVerificationsWithoutEvidenceIsNotVerified(t *testing.T) {
|
||
store := &verificationStore{}
|
||
recorder := httptest.NewRecorder()
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil)
|
||
libraryVerifications(recorder, request, Config{Store: store})
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
body := decodeBody(t, recorder)
|
||
if body["verified"] != false {
|
||
t.Fatalf("verified must be false without evidence: %v", body)
|
||
}
|
||
if reason, _ := body["reason"].(string); !strings.Contains(reason, "还没有对当前目标做过恢复验证") {
|
||
t.Fatalf("reason=%q", reason)
|
||
}
|
||
}
|
||
|
||
func TestLibraryVerificationsWithPassedEvidenceIsVerified(t *testing.T) {
|
||
store := &verificationStore{verifications: []governance.LibraryRestoreVerification{{
|
||
ID: "libverify-internal-job1", Scope: "internal", Status: governance.LibraryVerificationPassed,
|
||
SnapshotID: "snap-1", JobID: "job1", StartedAt: time.Now().UTC().Add(-time.Minute),
|
||
FinishedAt: time.Now().UTC(),
|
||
}}}
|
||
recorder := httptest.NewRecorder()
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil)
|
||
libraryVerifications(recorder, request, Config{Store: store})
|
||
body := decodeBody(t, recorder)
|
||
if body["verified"] != true {
|
||
t.Fatalf("verified must be true with passed evidence: %v", body)
|
||
}
|
||
}
|
||
|
||
func TestLibraryVerificationsFailedEvidenceIsNotVerified(t *testing.T) {
|
||
store := &verificationStore{verifications: []governance.LibraryRestoreVerification{{
|
||
ID: "libverify-internal-job2", Scope: "internal", Status: governance.LibraryVerificationFailed,
|
||
SnapshotID: "snap-2", JobID: "job2", Failure: "恢复后内容仍与预备份不一致",
|
||
StartedAt: time.Now().UTC().Add(-time.Minute), FinishedAt: time.Now().UTC(),
|
||
}}}
|
||
recorder := httptest.NewRecorder()
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil)
|
||
libraryVerifications(recorder, request, Config{Store: store})
|
||
body := decodeBody(t, recorder)
|
||
if body["verified"] != false {
|
||
t.Fatalf("failed evidence must not verify: %v", body)
|
||
}
|
||
if reason, _ := body["reason"].(string); !strings.Contains(reason, "恢复后内容仍与预备份不一致") {
|
||
t.Fatalf("reason=%q", reason)
|
||
}
|
||
}
|
||
|
||
// 同步闸口必须消费同一 target 的证据:有 passed 证据即放行,
|
||
// 只有失败证据时仍然拒绝并给出失败原因(不是笼统"没有证据")。
|
||
// 审核 B3 反例(GPT-6 overlay)转成通过断言:env=true 不能覆盖明确失败或读取失败。
|
||
type evidenceReadErrorStore struct{ verificationStore }
|
||
|
||
func (*evidenceReadErrorStore) LatestLibraryRestoreVerification(context.Context, string) (governance.LibraryRestoreVerification, bool, error) {
|
||
return governance.LibraryRestoreVerification{}, false, errors.New("evidence lookup failed")
|
||
}
|
||
|
||
func TestRestoreGateFailedEvidenceBeatsEnv(t *testing.T) {
|
||
store := &verificationStore{verifications: []governance.LibraryRestoreVerification{{
|
||
Scope: "internal", Status: governance.LibraryVerificationFailed, Failure: "restore mismatch",
|
||
FinishedAt: time.Now().UTC(),
|
||
}}}
|
||
config := Config{Store: store, ProductionBackupRestoreVerified: true}.withRestoreEvidence(context.Background(), "internal")
|
||
reasons := restoreGateReasons(config)
|
||
if len(reasons) == 0 || !strings.Contains(strings.Join(reasons, ";"), "restore mismatch") {
|
||
t.Fatalf("failed evidence must beat env=true: %v", reasons)
|
||
}
|
||
}
|
||
|
||
func TestRestoreGateUnreadableEvidenceBeatsEnv(t *testing.T) {
|
||
config := Config{Store: &evidenceReadErrorStore{}, ProductionBackupRestoreVerified: true}.
|
||
withRestoreEvidence(context.Background(), "internal")
|
||
reasons := restoreGateReasons(config)
|
||
if len(reasons) == 0 || !strings.Contains(strings.Join(reasons, ";"), "读取恢复验证证据失败") {
|
||
t.Fatalf("unreadable evidence must beat env=true: %v", reasons)
|
||
}
|
||
}
|
||
|
||
// env 兜底只允许在"明确没有证据行"时生效。
|
||
func TestRestoreGateEnvFallbackOnlyWhenEvidenceMissing(t *testing.T) {
|
||
config := Config{Store: &verificationStore{}, ProductionBackupRestoreVerified: true}.
|
||
withRestoreEvidence(context.Background(), "internal")
|
||
if reasons := restoreGateReasons(config); len(reasons) != 0 {
|
||
t.Fatalf("explicit not-found with legacy env must still allow: %v", reasons)
|
||
}
|
||
}
|
||
|
||
// 审核 B4:GET 必须从真实业务行投影在飞验证作业,而不是永远 running=false。
|
||
func TestLibraryVerificationsProjectsActiveVerifyJob(t *testing.T) {
|
||
store := &verificationStore{}
|
||
store.jobs = []governance.LibraryJob{{
|
||
ID: "verify-running", Scope: "internal", Kind: governance.LibraryJobVerify,
|
||
State: governance.LibraryJobRunning, Stage: "restore", CreatedAt: time.Now().UTC(),
|
||
}}
|
||
recorder := httptest.NewRecorder()
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil)
|
||
libraryVerifications(recorder, request, Config{Store: store})
|
||
body := decodeBody(t, recorder)
|
||
if body["running"] != true {
|
||
t.Fatalf("running verify job must be projected: %v", body)
|
||
}
|
||
if body["job_id"] != "verify-running" {
|
||
t.Fatalf("job_id=%v", body["job_id"])
|
||
}
|
||
if body["stage"] != "restore" {
|
||
t.Fatalf("stage=%v", body["stage"])
|
||
}
|
||
}
|
||
|
||
// 终态作业不是"在飞":页面不能一直显示执行中。
|
||
func TestLibraryVerificationsIgnoresTerminalVerifyJob(t *testing.T) {
|
||
store := &verificationStore{}
|
||
store.jobs = []governance.LibraryJob{{
|
||
ID: "verify-done", Scope: "internal", Kind: governance.LibraryJobVerify,
|
||
State: governance.LibraryJobSucceeded, CreatedAt: time.Now().UTC(),
|
||
}}
|
||
recorder := httptest.NewRecorder()
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil)
|
||
libraryVerifications(recorder, request, Config{Store: store})
|
||
body := decodeBody(t, recorder)
|
||
if body["running"] != false {
|
||
t.Fatalf("terminal verify job must not be projected as running: %v", body)
|
||
}
|
||
}
|
||
|
||
func TestLibrarySyncGateConsumesRestoreEvidence(t *testing.T) {
|
||
base := Config{ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: false,
|
||
LibraryProductionDatabaseURL: "postgres://unused.invalid/db", LibraryCapacityBudgetBytes: 1 << 40}
|
||
base.Store = &verificationStore{verifications: []governance.LibraryRestoreVerification{{
|
||
Scope: "internal", Status: governance.LibraryVerificationPassed, SnapshotID: "snap-1",
|
||
JobID: "job1", FinishedAt: time.Now().UTC(),
|
||
}}}
|
||
allowed := librarySyncGateWithGrant(base.withRestoreEvidence(context.Background(), "internal"),
|
||
libraryKnownView{State: "known"}, libraryKnownView{State: "unknown"}, "snap-1", true)
|
||
if !allowed.SyncAllowed {
|
||
t.Fatalf("gate should allow with passed evidence: %v", allowed.Reasons)
|
||
}
|
||
noEvidence := Config{ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: false,
|
||
LibraryProductionDatabaseURL: "postgres://unused.invalid/db", LibraryCapacityBudgetBytes: 1 << 40,
|
||
Store: &verificationStore{}}.withRestoreEvidence(context.Background(), "internal")
|
||
blocked := librarySyncGateWithGrant(noEvidence,
|
||
libraryKnownView{State: "known"}, libraryKnownView{State: "unknown"}, "snap-1", true)
|
||
if blocked.SyncAllowed {
|
||
t.Fatalf("gate without env and without evidence must block: %v", blocked.Reasons)
|
||
}
|
||
if len(blocked.Reasons) == 0 || !strings.Contains(strings.Join(blocked.Reasons, ";"), "恢复验证") {
|
||
t.Fatalf("blocked reasons=%v", blocked.Reasons)
|
||
}
|
||
failedConfig := Config{ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: false,
|
||
LibraryProductionDatabaseURL: "postgres://unused.invalid/db", LibraryCapacityBudgetBytes: 1 << 40,
|
||
Store: &verificationStore{verifications: []governance.LibraryRestoreVerification{{
|
||
Scope: "internal", Status: governance.LibraryVerificationFailed, SnapshotID: "snap-2",
|
||
JobID: "job2", Failure: "canary 后校验仍通过", FinishedAt: time.Now().UTC(),
|
||
}}}}
|
||
reason := strings.Join(restoreGateReasons(failedConfig.withRestoreEvidence(context.Background(), "internal")), ";")
|
||
if !strings.Contains(reason, "canary 后校验仍通过") {
|
||
t.Fatalf("失败证据的失败原因必须出现在闸口:%q", reason)
|
||
}
|
||
}
|