Files
Agent-System-Backend/cmd/storage-migrate/integration_test.go
T
weichen 136e93bf1d Combine project context and audit with account main
Why: X3 must share one Backend surface with the X1 context and X2 audit candidates while current main now owns account migrations 32-34.

Evidence: go test -p 1 ./... and go build ./... passed on the merged tree; isolated PostgreSQL project audit, migration, and work-context tests passed against a temporary instance. Default parallel go test ./... had only the unrelated sandbox orphan timing test fail; its isolated ten-run replay passed.

Impact: Preserve account migrations 32-34, place X1/X2 migrations at 35-43, and retain account-effective write guards alongside project audit/context behavior. X3 observer remains a contract only.

Refs: TT-C01-TT-C04, TT-A01-TT-A04, TT-N03, TT-R01; combination probe d9b00c2
2026-10-14 15:41:11 +00:00

379 lines
16 KiB
Go
Raw 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 main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func TestMigrateTrustedContextManifestWithoutRawScope(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
src, err := store.OpenSQLite(fixture)
if err != nil {
t.Fatal(err)
}
m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "model-attempt", InputSHA256: "request-hash", Delivery: "attempted",
Included: []extension.ProjectContextManifestEntry{{SourceID: "source-a", Revision: "r1", SourceHash: "ha", InputSHA256: "input-hash"}}}
if err := src.AppendContextManifestWithScope("alice", "r1", m, store.ContextManifestScope{Bound: true}); err != nil {
t.Fatal(err)
}
m.Delivery = "responded"
if err := src.AppendContextManifestWithScope("alice", "r1", m, store.ContextManifestScope{Bound: true}); err != nil {
t.Fatal(err)
}
task, err := src.CreateWorkTask("alice", "rdm", "p1", "c1", "document", "Prepare document")
if err != nil {
t.Fatal(err)
}
stepHash := strings.Repeat("a", 64)
if _, err := src.DB().Exec(`INSERT INTO workflow_step_sources(run_id,step_id,owner,project_id,step_attempt_id,output_sha256,attempt_id,authorization_epoch,publication_epoch,watermark,version,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
"r1", "draft", "alice", "p1", "step-attempt", stepHash, "model-attempt", "auth-1", "pub-1", "water-1", "v1", "2026-10-14T00:00:00Z"); err != nil {
t.Fatal(err)
}
if err := src.Close(); err != nil {
t.Fatal(err)
}
snapshot := filepath.Join(t.TempDir(), "snapshot.db")
if err := cmdBackup([]string{"--source", fixture, "--out", snapshot}); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", snapshot, "--target", target, "--migration-id", "context-copy"}); err != nil {
t.Fatal(err)
}
pg, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer pg.Close()
receipts, err := pg.ListContextManifests("alice", "r1")
if err != nil || len(receipts) != 2 || len(receipts[1].Included) != 1 || receipts[1].Included[0].SourceID != "source-a" {
t.Fatalf("content-free model receipts not copied: count=%d err=%v", len(receipts), err)
}
workScope, err := pg.WorkTaskScope("alice", "rdm", task.ID)
if err != nil || workScope.ProjectID != "p1" || workScope.ConversationID != "c1" {
t.Fatalf("trusted work task binding not copied: scope=%+v err=%v", workScope, err)
}
stepSource, err := pg.WorkflowStepSource("alice", "r1", "draft")
if err != nil || stepSource.StepAttemptID != "step-attempt" || stepSource.AttemptID != "model-attempt" || stepSource.ProjectID != "p1" {
t.Fatalf("workflow step source not copied: source=%+v err=%v", stepSource, err)
}
var rawColumns int
if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 {
t.Fatalf("migrated PG schema has raw prompt columns: count=%d err=%v", rawColumns, err)
}
}
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
func pgSchemaDSN(t *testing.T) (string, func()) {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖")
}
schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
cleanup := func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
}
return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup
}
func TestMigrateEndToEndAndIdempotent(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
src, err := sql.Open("sqlite", fixture)
if err != nil {
t.Fatal(err)
}
defer src.Close()
var sourcePublic, sourceStaff, sourcePrivate, sourceSecret string
var sourceKeyVersion int64
if err := src.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(
&sourcePublic, &sourceStaff, &sourcePrivate, &sourceKeyVersion, &sourceSecret,
); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("首次迁移:%v", err)
}
// 幂等重跑(同身份):表级 done 对账,零重复行。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("幂等重跑:%v", err)
}
// 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、
// 复合主键事件序。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
db := st.DB()
var targetPublic, targetStaff, targetPrivate, targetSecret string
var targetKeyVersion int64
if err := db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(
&targetPublic, &targetStaff, &targetPrivate, &targetKeyVersion, &targetSecret,
); err != nil {
t.Fatal(err)
}
if targetPublic != sourcePublic || targetStaff != sourceStaff || targetPrivate != sourcePrivate || targetKeyVersion != sourceKeyVersion || targetSecret != sourceSecret {
t.Fatal("迁移后的目录版本或游标密钥与源库不一致")
}
var pinned, archived bool
if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived {
t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err)
}
var title, note string
if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, &note); err != nil || !strings.Contains(note, "注") {
t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err)
}
var vec []byte
if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 {
t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err)
}
var ended sql.NullString
if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid {
t.Fatalf("NULL 保留:ended=%v err=%v", ended, err)
}
var seqMax int
if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 {
t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err)
}
// 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
}
var linkedResource string
if err := db.QueryRow(`SELECT resource_id FROM project_audit_links WHERE host_audit_offset=7 AND source_id='commit-1'`).Scan(&linkedResource); err != nil || linkedResource != "doc-1" {
t.Fatalf("审计链接迁移丢失: resource=%q err=%v", linkedResource, err)
}
var newLinkID int64
if err := db.QueryRow(`INSERT INTO project_audit_links(host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
VALUES (7,'p1','alice','domain-commit','commit-2','op-1','document','doc-2','','metadata','outbox-2','2026-10-11T03:00:01Z') RETURNING id`).Scan(&newLinkID); err != nil || newLinkID <= 3 {
t.Fatalf("审计链接序列未推进: id=%d err=%v", newLinkID, err)
}
// 报告不含秘密值。
var report string
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
t.Fatalf("报告泄密:err=%v", err)
}
}
func TestCommandAuditExclusiveCopyRollsBackAndNeverRewindsSequence(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
src, err := openSourceRO(fixture)
if err != nil {
t.Fatal(err)
}
defer src.Close()
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
db := st.DB()
if _, err := db.Exec(controlDDL); err != nil {
t.Fatal(err)
}
cols, pk, err := targetColumns(db, "command_audit")
if err != nil {
t.Fatal(err)
}
bools, err := boolColumns(db, "command_audit", cols)
if err != nil {
t.Fatal(err)
}
count, checksum, err := sourceScan(src, "command_audit", cols, pk, bools)
if err != nil {
t.Fatal(err)
}
blocker, err := db.Begin()
if err != nil {
t.Fatal(err)
}
var existing int
if err := blocker.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&existing); err != nil {
t.Fatal(err)
}
timeoutDB, err := sql.Open("pgx", store.AppendPGRuntimeParam(target, "lock_timeout", "100"))
if err != nil {
t.Fatal(err)
}
if _, err := migrateCommandAudit(timeoutDB, src, "retry-audit", cols, pk, bools, count, checksum); err == nil || !strings.Contains(err.Error(), "lock timeout") {
t.Fatalf("exclusive copy did not fail on a held reader: %v", err)
}
timeoutDB.Close()
if err := blocker.Rollback(); err != nil {
t.Fatal(err)
}
if _, err := migrateCommandAudit(db, src, "retry-audit", cols, pk, bools, count+1, checksum); err == nil {
t.Fatal("mismatched copy committed despite in-transaction target count check")
}
var rows, done int
if err := db.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&rows); err != nil || rows != 0 {
t.Fatalf("failed copy left rows: %d %v", rows, err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_table_state WHERE table_name='command_audit'`).Scan(&done); err != nil || done != 0 {
t.Fatalf("failed copy recorded done: %d %v", done, err)
}
var enabled string
if err := db.QueryRow(`SELECT tgenabled FROM pg_trigger WHERE tgrelid='command_audit'::regclass AND tgname='project_audit_insert_guard_v1'`).Scan(&enabled); err != nil || enabled != "A" {
t.Fatalf("failed copy left guard disabled: %q %v", enabled, err)
}
var seq string
if err := db.QueryRow(`SELECT pg_get_serial_sequence('command_audit','id')`).Scan(&seq); err != nil {
t.Fatal(err)
}
// setval is non-transactional; an interrupted offline attempt can leave a
// forward gap even though its rows and trigger-state changes roll back.
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(`ALTER TABLE command_audit DISABLE TRIGGER project_audit_insert_guard_v1`); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(`SELECT setval($1::regclass,50,true)`, seq); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if _, err := migrateCommandAudit(db, src, "retry-audit", cols, pk, bools, count, checksum); err != nil {
t.Fatalf("retry after rollback: %v", err)
}
var last int64
if err := db.QueryRow(`SELECT last_value FROM ` + seq).Scan(&last); err != nil || last < 50 {
t.Fatalf("copy rewound sequence: last=%d err=%v", last, err)
}
var nextID int64
if err := db.QueryRow(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload)
VALUES ('2026-10-14T00:00:00Z','after-copy','','','committed','','','alice','{}') RETURNING id`).Scan(&nextID); err != nil || nextID <= 50 {
t.Fatalf("post-copy ID did not advance: id=%d err=%v", nextID, err)
}
}
func TestMigrateResumeAfterInterrupt(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatal(err)
}
// 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending),
// 重跑只重做该表,终态校验仍全绿。
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatalf("断点续跑:%v", err)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n)
}
}
func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 非空目标(人工数据、无迁移身份):拒绝。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
st.Close()
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil {
t.Fatal(err)
}
err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"})
if err == nil || !strings.Contains(err.Error(), "非空目标") {
t.Fatalf("非空目标应拒绝:%v", err)
}
if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil {
t.Fatal(err)
}
// 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil {
t.Fatal(err)
}
other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"})
if err == nil || !strings.Contains(err.Error(), "快照不同") {
t.Fatalf("换快照应拒绝:%v", err)
}
// 其他 migration_id 已存在:拒绝。
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"})
if err == nil || !strings.Contains(err.Error(), "其他迁移记录") {
t.Fatalf("他身份应拒绝:%v", err)
}
}
func TestMigrateBlocksUnknownOwner(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 在夹具里造一个 users 之外的 owner 行(直接写源库)。
srcPath := fixture
st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写)
if err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil {
t.Fatal(err)
}
st.Close()
err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"})
if err == nil || !strings.Contains(err.Error(), "未知 owner") {
t.Fatalf("未知 owner 应阻塞:%v", err)
}
}