ci / vet-and-test (pull_request) Failing after 46s
Why: The U3 migration must replace the target schema seed with the source directory revision and cursor key. Evidence: Isolated PostgreSQL TestMigrateEndToEndAndIdempotent passed with field-by-field source and target comparison. Impact: Migration regressions that lose directory cursor identity now fail the existing end-to-end test. Refs: IAM U3 Stage 7; docs/proposals/2026-10-14-iam-u3-account-contract-and-plan.md §10
211 lines
8.3 KiB
Go
211 lines
8.3 KiB
Go
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"agent-system-backend/internal/store"
|
||
)
|
||
|
||
// 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, ¬e); 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 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 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)
|
||
}
|
||
}
|