Files
knowledge-governance/internal/platform/postgres/release_executions_test.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环):

- 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口)
- 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入
  parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截
- 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过):
  * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id)
  * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配
  * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数
  * 回滚 job payload 支持 prior_execution_id(strict 解码)
- 三通道发布+回滚演练实测:publish 4/4 verified,检索命中,
  rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false
- 前端 A·Data-console 风格全站统一,120 用例通过
- 门禁:go vet / go test 全包通过,gofmt 干净
2026-09-30 10:54:06 +08:00

159 lines
7.8 KiB
Go

package postgres
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
func TestAuthorizeReleaseBatchAtomicallyEnqueuesProductionExecution(t *testing.T) {
now := time.Date(2026, 9, 6, 5, 0, 0, 0, time.UTC)
db := &recordingDB{row: releaseExecutionRow{
id: "rx-safe", batchID: "rb-safe", operation: "publish", state: "pending",
target: "production", authorizedBy: "release-owner", authorizationCode: "production_release_authorized",
jobID: "job-safe", itemCount: 2, createdAt: now,
}}
execution, err := NewStore(db).AuthorizeReleaseBatch(context.Background(), governance.AuthorizeReleaseBatchRequest{
BatchID: "rb-safe", AuthorizedBy: "release-owner", AuthorizationCode: "production_release_authorized", Now: now,
})
if err != nil {
t.Fatal(err)
}
if execution.ID != "rx-safe" || execution.Operation != governance.ReleaseOperationPublish || execution.State != governance.ReleaseExecutionPending || execution.JobID != "job-safe" || execution.ItemCount != 2 {
t.Fatalf("execution = %#v", execution)
}
sql := strings.ToLower(db.queryRowSQL)
for _, required := range []string{
"state = 'sealed'", "release_ready", "count(*) filter",
"insert into jobs", "'release.publish'", "insert into release_executions",
"'production'", "on conflict (idempotency_key)", "returning",
"release_delete_ready", "item.action = 'upsert'", "item.action = 'delete'",
} {
if !strings.Contains(sql, required) {
t.Fatalf("authorize release SQL missing %q", required)
}
}
for _, forbidden := range []string{"api_key", "password", "secret", "production_database_url"} {
if strings.Contains(sql, forbidden) {
t.Fatalf("authorize release SQL contains forbidden credential field %q", forbidden)
}
}
}
func TestAuthorizeReleaseBatchRejectsInvalidAuthorization(t *testing.T) {
tests := []governance.AuthorizeReleaseBatchRequest{
{BatchID: "rb-safe", AuthorizedBy: "owner", AuthorizationCode: "wrong", Now: time.Now()},
{BatchID: "rb-safe", AuthorizedBy: "bad\nowner", AuthorizationCode: "production_release_authorized", Now: time.Now()},
}
for _, request := range tests {
if _, err := NewStore(&recordingDB{}).AuthorizeReleaseBatch(context.Background(), request); !errors.Is(err, ErrInvalidReleaseAuthorization) {
t.Fatalf("error = %v", err)
}
}
}
func TestAuthorizeReleaseBatchRejectsStaleSealGate(t *testing.T) {
_, err := NewStore(&recordingDB{row: sourceScanErrorRow{err: pgx.ErrNoRows}}).AuthorizeReleaseBatch(context.Background(), governance.AuthorizeReleaseBatchRequest{
BatchID: "rb-safe", AuthorizedBy: "owner", AuthorizationCode: "production_release_authorized", Now: time.Now(),
})
if !errors.Is(err, ErrReleaseAuthorizationGateConflict) {
t.Fatalf("error = %v", err)
}
}
func TestAuthorizeReleaseRollbackRequiresVerifiedPriorItems(t *testing.T) {
now := time.Date(2026, 9, 6, 6, 0, 0, 0, time.UTC)
db := &recordingDB{row: releaseExecutionRow{
id: "rx-rollback", batchID: "rb-safe", operation: "rollback", state: "pending",
target: "production", authorizedBy: "release-owner", authorizationCode: "production_rollback_authorized",
jobID: "job-rollback", itemCount: 2, createdAt: now,
}}
execution, err := NewStore(db).AuthorizeReleaseRollback(context.Background(), governance.AuthorizeReleaseRollbackRequest{
PriorExecutionID: "rx-publish", AuthorizedBy: "release-owner",
AuthorizationCode: "production_rollback_authorized", Now: now,
})
if err != nil {
t.Fatal(err)
}
if execution.Operation != governance.ReleaseOperationRollback || execution.ID != "rx-rollback" {
t.Fatalf("execution=%#v", execution)
}
sql := strings.ToLower(db.queryRowSQL)
for _, required := range []string{
"operation = 'publish'", "state in ('completed', 'failed')", "item.state = 'verified'", "count(*)::integer",
"'release.rollback'", "insert into jobs", "insert into release_executions", "prior_execution_id",
} {
if !strings.Contains(sql, required) {
t.Fatalf("rollback authorization SQL missing %q", required)
}
}
}
func TestScheduleAutomaticRollbackUsesOnlyVerifiedItemsFromFailedPublish(t *testing.T) {
now := time.Date(2026, 9, 10, 2, 0, 0, 0, time.UTC)
db := &recordingDB{row: releaseExecutionRow{
id: "rx-auto", batchID: "rb-safe", operation: "rollback", state: "pending", priorExecutionID: "rx-failed",
target: "production", authorizedBy: "system:auto-rollback", authorizationCode: "automatic_rollback_after_failure",
jobID: "job-auto", itemCount: 1, createdAt: now,
}}
if err := NewStore(db).ScheduleAutomaticRollback(context.Background(), "rx-failed", now); err != nil {
t.Fatal(err)
}
sql := strings.ToLower(db.queryRowSQL)
for _, required := range []string{"state = 'failed'", "item.state = 'verified'", "count(*)::integer", "'system:auto-rollback'", "'automatic_rollback_after_failure'", "'release.rollback'"} {
if !strings.Contains(sql, required) {
t.Fatalf("automatic rollback SQL missing %q", required)
}
}
}
func TestAuthorizeReleaseRollbackRejectsInvalidAuthorization(t *testing.T) {
_, err := NewStore(&recordingDB{}).AuthorizeReleaseRollback(context.Background(), governance.AuthorizeReleaseRollbackRequest{
PriorExecutionID: "rx-publish", AuthorizedBy: "release-owner", AuthorizationCode: "wrong", Now: time.Now(),
})
if !errors.Is(err, ErrInvalidReleaseAuthorization) {
t.Fatalf("error=%v", err)
}
}
func TestRetryReleaseExecutionResetsOnlyFailedItemsAndJob(t *testing.T) {
now := time.Date(2026, 9, 6, 17, 0, 0, 0, time.UTC)
db := &recordingDB{row: releaseExecutionRow{id: "rx-safe", batchID: "rb-safe", operation: "publish", state: "pending", target: "production", authorizedBy: "release-owner", authorizationCode: "production_release_authorized", jobID: "job-safe", itemCount: 2, completedCount: 1, createdAt: now}}
store := NewStore(db)
execution, err := store.RetryReleaseExecution(context.Background(), governance.RetryReleaseExecutionRequest{ExecutionID: "rx-safe", Now: now})
if err != nil {
t.Fatal(err)
}
if execution.ID != "rx-safe" || execution.State != governance.ReleaseExecutionPending || execution.CompletedItemCount != 1 || execution.FailedItemCount != 0 {
t.Fatalf("execution=%#v", execution)
}
sql := strings.ToLower(db.queryRowSQL)
for _, required := range []string{"release_ready as", "release_delete_ready", "item.action = 'delete'", "update release_item_executions", "state = 'pending'", "and item.state = 'failed'", "update jobs", "attempt = 0", "lease_until = null", "update release_executions", "rollback.prior_execution_id = execution.id"} {
if !strings.Contains(sql, required) {
t.Fatalf("SQL missing %q: %s", required, sql)
}
}
}
type releaseExecutionRow struct {
id, batchID, operation, priorExecutionID, state, target, authorizedBy, authorizationCode, jobID, manifestSHA, errorCode string
itemCount, batchSize, currentBatch, totalBatches, completedCount, failedCount int
createdAt time.Time
startedAt, completedAt *time.Time
}
func (r releaseExecutionRow) Scan(dest ...any) error {
*dest[0].(*string), *dest[1].(*string), *dest[2].(*string), *dest[3].(*string), *dest[4].(*string) = r.id, r.batchID, r.operation, r.priorExecutionID, r.state
*dest[5].(*string), *dest[6].(*string), *dest[7].(*string), *dest[8].(*string) = r.target, r.authorizedBy, r.authorizationCode, r.jobID
*dest[9].(*string), *dest[10].(*int), *dest[11].(*int), *dest[12].(*int), *dest[13].(*int) = r.manifestSHA, r.itemCount, r.batchSize, r.currentBatch, r.totalBatches
*dest[14].(*int), *dest[15].(*int), *dest[16].(*string) = r.completedCount, r.failedCount, r.errorCode
*dest[17].(*time.Time), *dest[18].(**time.Time), *dest[19].(**time.Time) = r.createdAt, r.startedAt, r.completedAt
return nil
}