Files
knowledge-governance/internal/httpapi/library_verify_test.go
T
姚章浩 b4add6c9b0 fix(library): 收口 B1-R1/B2-R1/B5-R1 并让恢复链经过正式队列终态
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,不改写历史。
2026-11-03 13:02:42 +08:00

278 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}