dcdc8d1ddd
全栈知识治理平台首次入库(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 干净
159 lines
7.8 KiB
Go
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
|
|
}
|