Files
knowledge-governance/internal/httpapi/library_intent_test.go
姚章浩 8febe12d7d test(library): 隔离数据契约与 r11 身份自然键矩阵
真实本机 PG/ES/MinIO:v071 目录镜像 10 模式、结构引用/游标 15 模式、
8 表×2 外部范围身份矩阵、13 模式自然键(含软删除与合法同 parent 正例)、
备份期间三注入、收尾故障与授权撤销两个真实治理 PG 用例。

需隔离服务的用例在 test-scope.json 显式声明:普通 CI 跳过不算通过。
2026-11-01 11:35:09 +08:00

135 lines
6.6 KiB
Go
Raw Permalink 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 (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
)
// F01:同一个目标快照的**新意图**必须真的执行,不能被"这个快照曾经成功过"吞掉。
//
// 现场形状:同步 B 成功 → 恢复 A → 再次同步 B。第三次请求是一次合法意图,
// 旧实现返回第一次的成功作业(202, 不再并入队),用户要的状态没实现。
//
// 判据:换 operation_key ⇒ 新 operation(ID 不同、作业幂等键不同);
// 同一个 operation_key ⇒ 同一 operation(重放)。
func TestLibrarySyncNewIntentReExecutesInsteadOfReusingHistory(t *testing.T) {
root := libraryFixtureRoot(t)
target := sealSharedVersions(t, root, 1, 4)[0]
digest, err := librarySyncParamsDigest("internal", target)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
newStore := func() *fakeLibraryStore {
store := &fakeLibraryStore{
current: &governance.LibraryVersion{ID: "ver-prod", Scope: "internal",
Kind: governance.LibraryVersionProductionEffective, SnapshotID: "prod-snap",
VersionLabel: "prod-2026.10", CreatedAt: now.Add(-72 * time.Hour)},
}
store.enqueueEchoRequestedID = true
store.grant = &governance.LibraryReleaseAuthorization{ID: "auth-1", Scope: "internal", SnapshotID: target,
ParamsDigest: digest, AuthorizedBy: "admin", ExpiresAt: now.Add(30 * time.Minute), CreatedAt: now}
return store
}
config := func(store *fakeLibraryStore) Config {
c := Config{Store: store, LibraryArchiveRoot: root, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
LibraryProductionDatabaseURL: "postgres://prod.invalid/db", LibraryProductionInternalKB: "kb-int",
LibraryCapacityBudgetBytes: 1 << 40}
bindSourceFixture(t, &c, store, &target)
return c
}
post := func(store *fakeLibraryStore, body string) *httptest.ResponseRecorder {
c := config(store)
id, _ := configuredLibrarySource(c, "internal").ID()
body = sourceRequestBody(t, body, id)
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs",
strings.NewReader(body)), c)
return recorder
}
withKey := `{"scope":"internal","target_snapshot_id":"` + target + `","operation_key":"%s"}`
// 第一次意图:建作业。
first := newStore()
if recorder := post(first, strings.Replace(withKey, "%s", "intent-1", 1)); recorder.Code != http.StatusAccepted {
t.Fatalf("首次发布会话 status=%d body=%s", recorder.Code, recorder.Body.String())
}
firstOperation := first.lastOperation.ID
if firstOperation == "" || first.lastJob.OperationID != firstOperation {
t.Fatalf("作业必须挂在意图上:job.operation=%q intent=%q", first.lastJob.OperationID, firstOperation)
}
// 同一次意图重放(双击/超时重发):同一个 operation,不产生第二条意图。
replay := newStore()
if recorder := post(replay, strings.Replace(withKey, "%s", "intent-1", 1)); recorder.Code != http.StatusAccepted {
t.Fatalf("重放 status=%d body=%s", recorder.Code, recorder.Body.String())
}
if replay.lastOperation.ID != firstOperation {
t.Fatalf("同一意图键得到了不同 operation:%q vs %q", replay.lastOperation.ID, firstOperation)
}
// 新意图(同一快照、不同键):必须是新 operation ⇒ 新作业幂等键 ⇒ 真的执行。
second := newStore()
if recorder := post(second, strings.Replace(withKey, "%s", "intent-2", 1)); recorder.Code != http.StatusAccepted {
t.Fatalf("新意图 status=%d body=%s", recorder.Code, recorder.Body.String())
}
if second.lastOperation.ID == firstOperation {
t.Fatal("同一快照的新意图必须得到新 operation:否则历史成功会吞掉本次请求(F01)")
}
if second.lastJob.IdempotencyKey == first.lastJob.IdempotencyKey {
t.Fatal("新意图的作业幂等键与历史作业相同:CreateLibraryJob 会返回旧作业,什么也不执行")
}
if second.createN != 1 {
t.Fatalf("新意图必须建作业并执行,created=%d", second.createN)
}
}
// 同一个意图键配上不同快照 = 冲突,不能悄悄共享谱系。
func TestLibrarySyncIntentKeyReusedWithAnotherSnapshotConflicts(t *testing.T) {
root := libraryFixtureRoot(t)
target := sealSharedVersions(t, root, 1, 4)[0]
digest, _ := librarySyncParamsDigest("internal", target)
now := time.Now().UTC()
store := &fakeLibraryStore{current: &governance.LibraryVersion{ID: "ver-prod", Scope: "internal",
Kind: governance.LibraryVersionProductionEffective, SnapshotID: "prod-snap", CreatedAt: now.Add(-time.Hour)}}
store.grant = &governance.LibraryReleaseAuthorization{ID: "auth-1", Scope: "internal", SnapshotID: target,
ParamsDigest: digest, AuthorizedBy: "admin", ExpiresAt: now.Add(time.Hour), CreatedAt: now}
// 预先登记一条"同键但不同快照"的意图。
if _, _, err := store.EnsureLibraryOperation(nil, governance.LibraryOperation{
ID: "libop-x", Scope: "internal", Kind: governance.LibraryJobSync, TargetSnapshotID: "other-snap",
RequestDigest: "different", OperationKey: target + "#intent-1", CreatedAt: now}); err != nil {
t.Fatal(err)
}
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
LibraryProductionDatabaseURL: "postgres://prod.invalid/db", LibraryProductionInternalKB: "kb-int",
LibraryCapacityBudgetBytes: 1 << 40}
config.LibraryArchiveRoot = root
sourceID := bindSourceFixture(t, &config, store, &target)
body := sourceRequestBody(t, `{"scope":"internal","target_snapshot_id":"`+target+`","operation_key":"intent-1"}`, sourceID)
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs", strings.NewReader(body)), config)
if recorder.Code != http.StatusConflict {
t.Fatalf("同键不同内容必须 409,status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
// F14:通用 queue 入口不许创建整库保留类型——那里的角色检查替代不了发布准入。
func TestCreateJobRejectsReservedLibraryTypes(t *testing.T) {
store := &fakeJobStore{}
config := Config{Store: store}
body := `{"type":"library.baseline","payload":{"scope":"internal"},"idempotency_key":"k-1"}`
recorder := httptest.NewRecorder()
createJob(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)), config)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("保留类型必须被通用入口拒绝:status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "发布管理专用入口") {
t.Fatalf("拒绝原因必须指向专用入口:%s", recorder.Body.String())
}
}