8febe12d7d
真实本机 PG/ES/MinIO:v071 目录镜像 10 模式、结构引用/游标 15 模式、 8 表×2 外部范围身份矩阵、13 模式自然键(含软删除与合法同 parent 正例)、 备份期间三注入、收尾故障与授权撤销两个真实治理 PG 用例。 需隔离服务的用例在 test-scope.json 显式声明:普通 CI 跳过不算通过。
135 lines
6.6 KiB
Go
135 lines
6.6 KiB
Go
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())
|
||
}
|
||
}
|