Files
knowledge-governance/internal/httpapi/library_flow_test.go
T
姚章浩 7b8cc0384b
build / build (push) Successful in 1m19s
feat(library): 生产发布授权与同步/恢复编排,worker 接线
为什么:同步入口此前只会拒绝;授权、预备份、四类写入、验证与失败恢复都没有代码。
本轮把编排与授权实现出来,真实生产执行仍按授权闸口关闭。

- internal/libraryrelease:预备份 → 数据库/资源/索引/Wiki → 验证 → 失败恢复;
  授权过期/目标不符/快照不符/参数变化在动生产前拒绝;恢复失败单独上报。
- 迁移 000067 library_release_authorizations(目标+快照+参数摘要+有效期,可撤销)。
- POST/GET /api/v1/library/authorizations 与 /{id}/revoke,仅管理员签发,5–1440 分钟。
- worker library.sync 接编排:先闸口再授权,阶段落库,只有编排成功才标成功。
- 生产目标适配器未配置时显式失败(不返回零值状态),预备份存储同。
- overview 返回授权状态,页面"生产保护与恢复"显示签发人与有效期。
- 验收矩阵区分"已实现·隔离测试通过"与"真实执行未授权"。
2026-10-28 15:11:27 +08:00

316 lines
12 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
)
// 生产闸口与整库状态契约的针对性测试:不做数据库集成,只验证
// "证据不齐就不建作业" 与 "三类版本不互相顶替" 这两条硬规则。
// 复用既有 fakeJobStore 提供 Store 的其余方法,只覆盖整库作业这几个。
type fakeLibraryStore struct {
fakeJobStore
grant *governance.LibraryReleaseAuthorization
versions []governance.LibraryVersion
jobs []governance.LibraryJob
createN int
lastJob governance.LibraryJob
lastState governance.LibraryJobState
}
func (f *fakeLibraryStore) CreateLibraryJob(_ context.Context, job governance.LibraryJob, _ []string, _ time.Time) (governance.LibraryJob, bool, error) {
f.createN++
f.lastJob = job
return job, false, nil
}
func (f *fakeLibraryStore) LibraryJob(_ context.Context, id string) (governance.LibraryJob, error) {
return governance.LibraryJob{ID: id}, nil
}
func (f *fakeLibraryStore) ListLibraryJobs(_ context.Context, scope string, _ int) ([]governance.LibraryJob, error) {
result := make([]governance.LibraryJob, 0, len(f.jobs))
for _, job := range f.jobs {
if job.Scope == scope {
result = append(result, job)
}
}
return result, nil
}
func (f *fakeLibraryStore) RecordLibraryVersion(_ context.Context, version governance.LibraryVersion) error {
f.versions = append(f.versions, version)
return nil
}
func (f *fakeLibraryStore) LibraryVersions(_ context.Context, scope string, _ int) ([]governance.LibraryVersion, error) {
result := make([]governance.LibraryVersion, 0, len(f.versions))
for _, version := range f.versions {
if version.Scope == scope {
result = append(result, version)
}
}
return result, nil
}
func (f *fakeLibraryStore) UpdateLibraryStage(_ context.Context, _, _, _ string, _, _, _, _ *int64, _ string, _ time.Time) error {
return nil
}
func (f *fakeLibraryStore) AdvanceLibraryJob(_ context.Context, _ string, state governance.LibraryJobState, _, _, _, _, _ string, _ time.Time) error {
f.lastState = state
return nil
}
func (f *fakeLibraryStore) CreateLibraryAuthorization(_ context.Context, item governance.LibraryReleaseAuthorization) error {
f.grant = &item
return nil
}
func (f *fakeLibraryStore) ActiveLibraryAuthorization(_ context.Context, scope, snapshotID, digest string, now time.Time) (governance.LibraryReleaseAuthorization, bool, error) {
if f.grant == nil || f.grant.Scope != scope || f.grant.SnapshotID != snapshotID || f.grant.ParamsDigest != digest {
return governance.LibraryReleaseAuthorization{}, false, nil
}
if !f.grant.ValidAt(now) {
return governance.LibraryReleaseAuthorization{}, false, nil
}
return *f.grant, true, nil
}
func (f *fakeLibraryStore) LibraryAuthorizations(context.Context, string, int) ([]governance.LibraryReleaseAuthorization, error) {
if f.grant == nil {
return nil, nil
}
return []governance.LibraryReleaseAuthorization{*f.grant}, nil
}
func (f *fakeLibraryStore) RevokeLibraryAuthorization(context.Context, string, time.Time) error {
return nil
}
func (f *fakeLibraryStore) AttachLibraryQueueJob(_ context.Context, _, _ string, _ time.Time) error {
return nil
}
func (f *fakeLibraryStore) CancelLibraryJob(_ context.Context, _, _ string, _ time.Time) error {
f.lastState = governance.LibraryJobCancelled
return nil
}
func TestLibrarySyncGateRequiresEveryPieceOfEvidence(t *testing.T) {
known := libraryKnownView{State: "known", Version: &libraryVersionView{ID: "v1"}}
cases := []struct {
name string
config Config
production libraryKnownView
prebackup libraryKnownView
target string
allowed bool
reason string
}{
{
name: "default installation is closed",
config: Config{},
production: libraryKnownView{State: "unknown"},
prebackup: libraryKnownView{State: "unknown"},
target: "snap-1",
allowed: false,
reason: "生产写入闸口关闭",
},
{
name: "flags on but restore evidence missing",
config: Config{ProductionReleaseRequestsEnabled: true,
ProductionWeKnoraBaseURL: "http://prod.invalid"},
production: known,
prebackup: known,
target: "snap-1",
allowed: false,
reason: "没有已核验的生产恢复演练证据",
},
{
name: "capacity budget missing blocks even when everything else is present",
config: Config{ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
ProductionWeKnoraBaseURL: "http://prod.invalid"},
production: known,
prebackup: known,
target: "snap-1",
allowed: false,
reason: "容量预算未设置",
},
{
name: "everything present",
config: Config{ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
ProductionWeKnoraBaseURL: "http://prod.invalid", LibraryCapacityBudgetBytes: 1 << 40},
production: known,
prebackup: known,
target: "snap-1",
allowed: true,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
gate := librarySyncGateWithGrant(testCase.config, testCase.production, testCase.prebackup, testCase.target, true)
if gate.SyncAllowed != testCase.allowed {
t.Fatalf("sync_allowed = %v, want %v (%v)", gate.SyncAllowed, testCase.allowed, gate.Reasons)
}
if testCase.reason != "" {
joined := strings.Join(gate.Reasons, "|")
if !strings.Contains(joined, testCase.reason) {
t.Fatalf("reasons %v missing %q", gate.Reasons, testCase.reason)
}
}
})
}
}
// 闸口关闭时 POST /api/v1/library/syncs 必须直接拒绝,并且**不创建**作业:
// 不能留下一个永远失败的空作业,也不能让按钮看起来接通了。
func TestLibrarySyncEnqueueRefusesWithoutCreatingJob(t *testing.T) {
store := &fakeLibraryStore{}
config := Config{Store: store}
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs",
strings.NewReader(`{"scope":"internal","target_snapshot_id":"snap-1"}`))
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, request, config)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", recorder.Code)
}
if store.createN != 0 {
t.Fatalf("created %d jobs while gate is closed", store.createN)
}
var body struct {
Code string `json:"code"`
Reasons []string `json:"reasons"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Code != "library_sync_blocked" || len(body.Reasons) == 0 {
t.Fatalf("unexpected refusal body: %s", recorder.Body.String())
}
}
// 即使闸口打开,缺少与目标快照绑定的预备份也必须拒绝。
func TestLibrarySyncEnqueueRequiresPrebackupForTarget(t *testing.T) {
store := &fakeLibraryStore{versions: []governance.LibraryVersion{{
ID: "ver-prod-1", Scope: "internal", Kind: governance.LibraryVersionProductionEffective,
SnapshotID: "prod-snap", VersionLabel: "prod-2026.10", CreatedAt: time.Now().UTC(),
}}}
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
ProductionWeKnoraBaseURL: "http://prod.invalid"}
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs",
strings.NewReader(`{"scope":"internal","target_snapshot_id":"snap-1"}`))
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, request, config)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "预备份") {
t.Fatalf("refusal must name the missing prebackup: %s", recorder.Body.String())
}
if store.createN != 0 {
t.Fatalf("created %d jobs without a prebackup", store.createN)
}
}
// 证据齐全 + 有效授权时才会真正建作业并入队;缺授权一律拒绝且不建作业。
func TestLibrarySyncEnqueueCreatesJobOnlyWithGrant(t *testing.T) {
target := "snap-1"
digest, err := librarySyncParamsDigest("internal", target)
if err != nil {
t.Fatal(err)
}
store := &fakeLibraryStore{versions: []governance.LibraryVersion{
{ID: "ver-prod", Scope: "internal", Kind: governance.LibraryVersionProductionEffective,
SnapshotID: "prod-snap", VersionLabel: "prod-2026.10", CreatedAt: time.Now().UTC()},
{ID: "ver-backup", Scope: "internal", Kind: governance.LibraryVersionProductionPrebackup,
SnapshotID: target, CreatedAt: time.Now().UTC()},
}}
store.enqueueEchoRequestedID = true
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
ProductionWeKnoraBaseURL: "http://prod.invalid", LibraryCapacityBudgetBytes: 1 << 40}
body := `{"scope":"internal","target_snapshot_id":"` + target + `"}`
// 先没有授权
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs", strings.NewReader(body)), config)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("without grant status=%d body=%s", recorder.Code, recorder.Body.String())
}
if store.createN != 0 {
t.Fatal("must not create a job without a grant")
}
// 补上绑定同一目标与快照的授权
now := time.Now().UTC()
store.grant = &governance.LibraryReleaseAuthorization{ID: "auth-1", Scope: "internal", SnapshotID: target,
ParamsDigest: digest, AuthorizedBy: "admin", ExpiresAt: now.Add(30 * time.Minute), CreatedAt: now}
recorder = httptest.NewRecorder()
librarySyncEnqueue(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs", strings.NewReader(body)), config)
if recorder.Code != http.StatusAccepted {
t.Fatalf("with grant status=%d body=%s", recorder.Code, recorder.Body.String())
}
if store.createN != 1 {
t.Fatalf("created=%d, want 1", store.createN)
}
if store.enqueued.Type != "library.sync" {
t.Fatalf("enqueued type=%q, want library.sync", store.enqueued.Type)
}
if !strings.Contains(string(store.enqueued.Payload), store.lastJob.ID) {
t.Fatalf("queue payload must carry the library job id: %s", store.enqueued.Payload)
}
}
// 三类版本必须分开读:production_effective 才是生产当前,测试封存不能顶替。
func TestLibraryOverviewKeepsVersionKindsSeparate(t *testing.T) {
now := time.Now().UTC()
store := &fakeLibraryStore{versions: []governance.LibraryVersion{
{ID: "ver-test", Scope: "internal", Kind: governance.LibraryVersionTestSealed, SnapshotID: "snap-1", CreatedAt: now},
{ID: "ver-prod", Scope: "internal", Kind: governance.LibraryVersionProductionEffective,
SnapshotID: "prod-snap", VersionLabel: "prod-2026.10", CreatedAt: now},
{ID: "ver-backup-other", Scope: "internal", Kind: governance.LibraryVersionProductionPrebackup,
SnapshotID: "other-snap", CreatedAt: now},
}}
config := Config{Store: store, LibraryArchiveRoot: t.TempDir()}
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/overview?scope=internal", nil)
recorder := httptest.NewRecorder()
libraryOverviewHandler(recorder, request, config)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d: %s", recorder.Code, recorder.Body.String())
}
var body struct {
Production libraryKnownView `json:"production"`
Prebackup libraryKnownView `json:"prebackup"`
Diff struct {
State string `json:"state"`
} `json:"diff"`
Gate struct {
SyncAllowed bool `json:"sync_allowed"`
} `json:"gate"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode overview: %v", err)
}
if body.Production.State != "known" || body.Production.Version == nil || body.Production.Version.ID != "ver-prod" {
t.Fatalf("production must be the effective version, got %+v", body.Production)
}
// 预备份只对匹配目标快照的那一份生效;这里目标为空(归档不可用),所以是未知。
if body.Prebackup.State != "unknown" {
t.Fatalf("prebackup must stay unknown without a matching target snapshot: %+v", body.Prebackup)
}
if body.Diff.State != "unknown" {
t.Fatalf("diff must be unknown until the comparison is wired, got %q", body.Diff.State)
}
if body.Gate.SyncAllowed {
t.Fatal("gate must stay closed")
}
}