Files
Agent-System-Backend/internal/store/store_test.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

594 lines
19 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 store
import (
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
core "agent-system-core"
)
// openTestStore 双档测试入口:设 AGENT_PG_TEST_DSN 时对 PostgreSQL 档逐用例建独立
// schema 运行(双档 CI 验证,D16);默认嵌入式 SQLite 档。
func openTestStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// 每用例独立 schema(随机名),测试后级联清理,避免跨用例/跨运行干扰。
schema := "t_" + strings.ToLower(newID("s")) + "_" + strconv.Itoa(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
// pg_trgm 预装到 public(幂等):v4 迁移的 CREATE EXTENSION IF NOT EXISTS
// 会装进当时 search_path 的首个 schema(= 用例 schema),随 DROP SCHEMA
// CASCADE 被连带删除——并行用例间形成安装/删除竞态,gin_trgm_ops 随机
// 缺失。预装到 public 后 v4 的 CREATE EXTENSION 恒为 no-op,操作符经
// search_path 的 public 段稳定可见(与 svc.sh/CI 预装约定一致)。
ensurePGTrgm(t, admin)
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := OpenPostgres(withTestSearchPath(dsn, schema), filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// ensurePGTrgm 在测试库 public 幂等安装 pg_trgm;并发首装的目录唯一冲突
// 重试一次后放弃(下一个用例会再命中幂等分支)。
func ensurePGTrgm(t *testing.T, admin *sql.DB) {
t.Helper()
for attempt := 0; attempt < 2; attempt++ {
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err == nil {
return
} else if attempt == 1 {
t.Fatalf("preinstall pg_trgm: %v", err)
}
}
}
// withTestSearchPath 把用例 schema 放到 search_path 最前,并保留 public,
// 以便看到已安装在 public 的 pg_trgm。svc.sh 给出的是无查询串的 URL;
// 键值对 DSN(不含 ://)用空格追加。
func withTestSearchPath(dsn, schema string) string {
path := schema + ",public"
if !strings.Contains(dsn, "://") {
return dsn + " search_path=" + path
}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
return dsn + sep + "search_path=" + path
}
func isSQLite(st *Store) bool { return st.Dialect() == "sqlite" }
// 迁移验收:全量应用 → 状态 1..4 → 回滚到 1(老库形态)→ 再次前进(既有部署升级路径)。
func TestMigrationUpgradesExistingDatabase(t *testing.T) {
st := openTestStore(t)
got, err := st.MigrationStatus()
if err != nil {
t.Fatal(err)
}
if len(got) != len(migrations) {
t.Fatalf("expected versions [1..%d], got %v", len(migrations), got)
}
// 老库形态(v2 前):events 全局自增、settings 单列主键。
if err := st.MigrateDown(1); err != nil {
t.Fatalf("migrate down: %v", err)
}
var seqDefault any
if err := st.db.QueryRow(`SELECT COALESCE((SELECT MAX(seq) FROM events), 0)`).Scan(&seqDefault); err != nil {
t.Fatalf("old events table: %v", err)
}
// 写一行老形态数据再前进:升级必须保留数据。
now := nowISO()
if _, err := st.db.Exec(st.q(`INSERT INTO projects(id,name,settings,created_at,updated_at) VALUES('prj_old','老项目','{}',?,?)`), now, now); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`INSERT INTO settings(scope,"values") VALUES('user','{"a":1}')`)); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`INSERT INTO events(conversation_id,run_id,ts,v,type,payload) VALUES('c1','r1',?,1,'run.phase','{}')`), now); err != nil {
t.Fatal(err)
}
// 重新前进(走真实升级路径)。
if err := st.migrate(); err != nil {
t.Fatalf("re-migrate: %v", err)
}
if p, err := st.GetProject("", "prj_old"); err != nil || p.Name != "老项目" {
t.Fatalf("project survived upgrade: %v %v", p, err)
}
if m, err := st.GetSettingsScope("", "user"); err != nil || len(m) != 1 {
t.Fatalf("settings survived upgrade: %v %v", m, err)
}
// 事件重编号为会话内连续 seq。
evs, err := st.EventsAfter("c1", 0, 10)
if err != nil || len(evs) != 1 || evs[0].Seq != 1 {
t.Fatalf("event renumbered to per-conversation seq: %v %v", evs, err)
}
}
// 租户隔离(负例):跨租户读写一律 NotFound,共享知识(owner=”)双方可见。
func TestTenantIsolation(t *testing.T) {
st := openTestStore(t)
a, b := "alice", "bob"
pa, err := st.CreateProject(a, "A的项目")
if err != nil {
t.Fatal(err)
}
if _, err := st.GetProject(b, pa.ID); err != ErrNotFound {
t.Fatalf("cross-tenant project read must be NotFound, got %v", err)
}
if err := st.RenameProject(b, pa.ID, "劫持"); err != ErrNotFound {
t.Fatalf("cross-tenant project write must be NotFound, got %v", err)
}
ca, err := st.CreateConversation(a, pa.ID, "A的会话", "")
if err != nil {
t.Fatal(err)
}
if _, err := st.GetConversation(b, ca.ID); err != ErrNotFound {
t.Fatalf("cross-tenant conversation read must be NotFound, got %v", err)
}
if _, err := st.CreateConversation(b, pa.ID, "越权会话", ""); err != ErrNotFound {
t.Fatalf("conversation in foreign project must be NotFound, got %v", err)
}
art := core.Artifact{Owner: a, ProjectID: pa.ID, Name: "a.txt", Kind: "data"}
if err := st.CreateArtifact(&art, []byte("x")); err != nil {
t.Fatal(err)
}
if _, err := st.GetArtifact(b, art.ID); err != ErrNotFound {
t.Fatalf("cross-tenant artifact read must be NotFound, got %v", err)
}
if _, err := st.ArtifactContent(b, art.ID); err != ErrNotFound {
t.Fatalf("cross-tenant artifact content must be NotFound, got %v", err)
}
// 密钥与设置隔离。
// This legacy integration identity is a fixture, separate from formal key
// issuance. Creating a project above does not implicitly create an account.
if err := st.EnsureUser(a, ""); err != nil {
t.Fatal(err)
}
keyA, err := st.CreateAPIKey(a, "ka")
if err != nil {
t.Fatal(err)
}
if owner, ok := st.AuthOwner(keyA); !ok || owner != a {
t.Fatalf("auth owner: %q %v", owner, ok)
}
if _, ok := st.AuthOwner("ask_nonexistent"); ok {
t.Fatal("unknown key must not authenticate")
}
if err := st.MergeSettingsScope(a, "user", map[string]any{"x": 1}); err != nil {
t.Fatal(err)
}
if m, err := st.GetSettingsScope(b, "user"); err != nil || len(m) != 0 {
t.Fatalf("settings must be tenant-scoped, got %v %v", m, err)
}
// 共享知识:owner='' 对双方可见。
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "", Title: "共享", Chunk: "共享知识内容"}); err != nil {
t.Fatal(err)
}
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: a, Title: "A私有", Chunk: "A 私有知识内容"}); err != nil {
t.Fatal(err)
}
hitsB, err := st.SearchKnowledge(b, core.RetrievalQuery{Text: "共享"})
if err != nil {
t.Fatal(err)
}
if len(hitsB) == 0 {
t.Fatal("shared knowledge must be visible to bob")
}
hitsB2, err := st.SearchKnowledge(b, core.RetrievalQuery{Text: "私有"})
if err != nil {
t.Fatal(err)
}
if len(hitsB2) != 0 {
t.Fatal("alice's private knowledge must not be visible to bob")
}
}
// 并发验收:同会话并发追加消息,seq 唯一且连续(先查后写竞态的约束兜底)。
func TestConcurrentAppendMessageUniqueSeq(t *testing.T) {
st := openTestStore(t)
p, _ := st.CreateProject("o", "p")
c, _ := st.CreateConversation("o", p.ID, "c", "")
const n = 24
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m := core.Message{ConversationID: c.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "m"}}}
if err := st.AppendMessage(&m); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("concurrent append: %v", err)
}
msgs, err := st.ListMessages(c.ID)
if err != nil {
t.Fatal(err)
}
if len(msgs) != n {
t.Fatalf("expected %d messages, got %d", n, len(msgs))
}
}
// 并发验收:同 scope 并发合并设置,键值不丢失(读改写事务 + 锁)。
func TestConcurrentSettingsMergeNoLostUpdate(t *testing.T) {
st := openTestStore(t)
const writers = 8
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = st.MergeSettingsScope("o", "user", map[string]any{string(rune('a' + i)): i})
}(i)
}
wg.Wait()
m, err := st.GetSettingsScope("o", "user")
if err != nil {
t.Fatal(err)
}
if len(m) != writers {
t.Fatalf("lost update: expected %d keys, got %d (%v)", writers, len(m), m)
}
}
// 批量事件:连续 seq 分配 + 游标重放。
func TestAppendEventsBatchSeq(t *testing.T) {
st := openTestStore(t)
mk := func(t2 string) core.StreamEvent {
return core.StreamEvent{ConversationID: "c1", RunID: "r1", TS: nowISO(), V: 1, Type: core.StreamEventType(t2), Payload: []byte(`{}`)}
}
batch := []core.StreamEvent{mk("message.delta"), mk("message.delta"), mk("run.completed")}
if _, err := st.AppendEvents("c1", batch); err != nil {
t.Fatal(err)
}
for i, want := range []int64{1, 2, 3} {
if batch[i].Seq != want {
t.Fatalf("seq[%d] = %d, want %d", i, batch[i].Seq, want)
}
}
// 第二批接续。
b2 := []core.StreamEvent{mk("message.delta")}
if _, err := st.AppendEvents("c1", b2); err != nil {
t.Fatal(err)
}
if b2[0].Seq != 4 {
t.Fatalf("second batch seq = %d, want 4", b2[0].Seq)
}
evs, err := st.EventsAfter("c1", 2, 10)
if err != nil || len(evs) != 2 {
t.Fatalf("replay after seq=2: %v %v", evs, err)
}
if last, _ := st.LastEventSeq("c1"); last != 4 {
t.Fatalf("last seq = %d", last)
}
// 独立会话互不影响(会话内 seq 各自从 1 开始)。
b3 := []core.StreamEvent{mk("run.phase")}
if _, err := st.AppendEvents("c2", b3); err != nil {
t.Fatal(err)
}
if b3[0].Seq != 1 {
t.Fatalf("per-conversation seq expected, got %d", b3[0].Seq)
}
}
// HITL 原子 resolve:并发/重复 resolve 只成功一次。
func TestHITLResolveAtomic(t *testing.T) {
st := openTestStore(t)
card := core.HITLCard{ID: "hitl_1", RunID: "r1", ConversationID: "c1", Question: "q", Kind: core.HITLKindChoice}
if err := st.SaveHITLCard("o", &card); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
ok := make(chan bool, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := st.ResolveHITLCard("o", "hitl_1", core.HITLResolvedPayload{Kind: "choice", Answer: "x"})
ok <- err == nil
}()
}
wg.Wait()
succeeded := 0
for i := 0; i < 2; i++ {
if <-ok {
succeeded++
}
}
if succeeded != 1 {
t.Fatalf("double resolve must succeed exactly once, got %d", succeeded)
}
}
// 备份验收:VACUUM INTO 产出可打开的一致性副本。
func TestBackup(t *testing.T) {
st := openTestStore(t)
p, _ := st.CreateProject("o", "p")
dest := filepath.Join(t.TempDir(), "backup.db")
err := st.Backup(dest)
if !isSQLite(st) {
// PG 档:备份走 pg_dump(运维路径),接口返回指引错误。
if err == nil || !strings.Contains(err.Error(), "pg_dump") {
t.Fatalf("PG tier backup must return guidance error, got %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
// 打开副本验证数据完整。
cp, err := OpenSQLite(dest)
if err != nil {
t.Fatalf("open backup: %v", err)
}
defer cp.Close()
if _, err := cp.GetProject("o", p.ID); err != nil {
t.Fatalf("backup content: %v", err)
}
}
// 知识批量导入为单事务:全部成功(半批失败回滚由事务保证)。
func TestAddKnowledgeBatch(t *testing.T) {
st := openTestStore(t)
docs := []core.KnowledgeDoc{
{Owner: "o", Title: "t1", Chunk: "alpha beta"},
{Owner: "o", Title: "t2", Chunk: "gamma delta"},
}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
hits, err := st.SearchKnowledge("o", core.RetrievalQuery{Text: "alpha"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("FTS must find alpha")
}
// 中文子串走 LIKE 兜底。
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "o", Title: "中文", Chunk: "知识库检索中文兜底路径"}); err != nil {
t.Fatal(err)
}
hits, err = st.SearchKnowledge("o", core.RetrievalQuery{Text: "中文兜底"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("CJK substring fallback must hit")
}
}
// 工件原子性:创建失败不留悬空行;内容更新走临时文件。
func TestArtifactAtomicity(t *testing.T) {
st := openTestStore(t)
art := core.Artifact{Owner: "o", ProjectID: "p", Name: "a.txt", Kind: "data"}
if err := st.CreateArtifact(&art, []byte("hello")); err != nil {
t.Fatal(err)
}
if c, err := st.ArtifactContent("o", art.ID); err != nil || string(c) != "hello" {
t.Fatalf("content: %q %v", c, err)
}
if err := st.UpdateArtifactContent("o", art.ID, []byte("world")); err != nil {
t.Fatal(err)
}
if c, _ := st.ArtifactContent("o", art.ID); string(c) != "world" {
t.Fatal("update lost")
}
// 删除联动。
if err := st.DeleteArtifact("o", art.ID); err != nil {
t.Fatal(err)
}
if _, err := st.GetArtifact("o", art.ID); err != ErrNotFound {
t.Fatalf("deleted artifact: %v", err)
}
}
// 画像租户边界:builtin 共享,用户画像隔离。
func TestProfileTenantScope(t *testing.T) {
st := openTestStore(t)
if err := st.SaveProfile(core.AgentProfile{ID: "default", Builtin: true, SystemPrompt: "s", Name: "默认"}); err != nil {
t.Fatal(err)
}
if err := st.SaveProfile(core.AgentProfile{ID: "prof_alice", Owner: "alice", SystemPrompt: "s", Name: "A"}); err != nil {
t.Fatal(err)
}
la, _ := st.ListProfiles("alice")
if len(la) != 2 { // builtin + alice 自己
t.Fatalf("alice profiles = %d", len(la))
}
lb, _ := st.ListProfiles("bob")
if len(lb) != 1 { // 仅 builtin
t.Fatalf("bob profiles = %d", len(lb))
}
if _, err := st.GetProfile("bob", "prof_alice"); err != ErrNotFound {
t.Fatalf("cross-tenant profile: %v", err)
}
}
// TestConversationFlagsAndTaskList 方案 A R1:置顶/归档标记与跨项目任务列表(租户隔离负例)。
func TestConversationFlagsAndTaskList(t *testing.T) {
st := openTestStore(t)
a, b := "alice", "bob"
pa, err := st.CreateProject(a, "甲")
if err != nil {
t.Fatal(err)
}
pb, err := st.CreateProject(a, "乙")
if err != nil {
t.Fatal(err)
}
ca, err := st.CreateConversation(a, pa.ID, "t1", "")
if err != nil {
t.Fatal(err)
}
cb, err := st.CreateConversation(a, pb.ID, "t2", "")
if err != nil {
t.Fatal(err)
}
yes := true
// 置顶跨项目列表排首;归档默认隐藏。
if err := st.SetConversationFlags(a, cb.ID, &yes, nil, nil, nil); err != nil {
t.Fatal(err)
}
tasks, err := st.ListTasks(a, false)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 || tasks[0].ID != cb.ID || !tasks[0].Pinned {
t.Fatalf("pinned task must sort first, got %+v", tasks)
}
if err := st.SetConversationFlags(a, ca.ID, nil, &yes, nil, nil); err != nil {
t.Fatal(err)
}
tasks, _ = st.ListTasks(a, false)
if len(tasks) != 1 || tasks[0].ID != cb.ID {
t.Fatalf("archived must be hidden by default, got %+v", tasks)
}
tasks, _ = st.ListTasks(a, true)
if len(tasks) != 2 {
t.Fatalf("includeArchived must return all, got %d", len(tasks))
}
// 跨租户标记写入一律 NotFound。
no := false
if err := st.SetConversationFlags(b, ca.ID, &no, nil, nil, nil); err != ErrNotFound {
t.Fatalf("cross-tenant flag write must be NotFound, got %v", err)
}
if got, _ := st.ListTasks(b, true); len(got) != 0 {
t.Fatalf("task list must be tenant-scoped, got %d", len(got))
}
// 跨会话标记(提案 2026-09-30-cross-session-reading):新列默认出站开/非私有,
// 可写可读;跨租户写 NotFound。
conv, err := st.GetConversation(a, ca.ID)
if err != nil {
t.Fatal(err)
}
if !conv.AllowCrossRead || conv.CrossPrivate {
t.Fatalf("new conversations must default allowCrossRead=true crossPrivate=false, got %+v", conv)
}
if err := st.SetConversationFlags(a, ca.ID, nil, nil, &no, &yes); err != nil {
t.Fatal(err)
}
got, err := st.GetConversation(a, ca.ID)
if err != nil {
t.Fatal(err)
}
if got.AllowCrossRead || !got.CrossPrivate {
t.Fatalf("cross flags must persist, got %+v", got)
}
if err := st.SetConversationFlags(b, ca.ID, nil, nil, &yes, nil); err != ErrNotFound {
t.Fatalf("cross-tenant cross-flag write must be NotFound, got %v", err)
}
}
// TestAdminAccountsAndSessions 管理员发放制(R2):建号/哈希/首登改密标志/会话生命周期。
func TestAdminAccountsAndSessions(t *testing.T) {
st := openTestStore(t)
hash, err := HashPassword("initial-pass-1")
if err != nil {
t.Fatal(err)
}
if !VerifyPassword(hash, "initial-pass-1") || VerifyPassword(hash, "wrong") {
t.Fatal("password hash/verify broken")
}
if VerifyPassword(HashPasswordMust(t, "dup"), "dup2") {
t.Fatal("different passwords must not collide")
}
if err := st.CreateAccount("admin", "管理员", hash, true); err != nil {
t.Fatal(err)
}
// owner 重复 → Conflict。
if err := st.CreateAccount("admin", "", hash, false); err != ErrConflict {
t.Fatalf("duplicate owner must conflict, got %v", err)
}
st0, ok := st.VerifyLogin("admin", "initial-pass-1")
if !ok || !st0.MustChangePassword || !st0.IsAdmin {
t.Fatalf("initial login state: %+v ok=%v", st0, ok)
}
// 错误密码拒绝。
if _, ok := st.VerifyLogin("admin", "nope"); ok {
t.Fatal("wrong password must fail")
}
// 会话:建 → 验 → 改密后吊销其余。
tok, err := st.CreateSession("admin")
if err != nil {
t.Fatal(err)
}
if owner, ok := st.SessionOwner(tok); !ok || owner != "admin" {
t.Fatalf("session validate: %q %v", owner, ok)
}
hash2 := HashPasswordMust(t, "changed-pass-2")
if err := st.SetUserPassword("admin", hash2); err != nil {
t.Fatal(err)
}
if _, ok := st.SessionOwner(tok); ok {
t.Fatal("password reset must revoke prior session")
}
if err := st.ClearMustChangePassword("admin"); err != nil {
t.Fatal(err)
}
st1, _ := st.GetUserAuthState("admin")
if st1.MustChangePassword {
t.Fatal("must_change should clear after self-change")
}
tok, err = st.CreateSession("admin")
if err != nil {
t.Fatal(err)
}
st.DeleteUserSessions("admin", tok)
if _, ok := st.SessionOwner(tok); !ok {
t.Fatal("kept session (except-path) must survive")
}
st.DeleteUserSessions("admin", "")
if _, ok := st.SessionOwner(tok); ok {
t.Fatal("full delete must remove sessions")
}
// 管理员列表可见。
users, err := st.ListUsersAdmin()
if err != nil || len(users) == 0 {
t.Fatalf("admin list: %v %d", err, len(users))
}
}
func HashPasswordMust(t *testing.T, pw string) string {
t.Helper()
h, err := HashPassword(pw)
if err != nil {
t.Fatal(err)
}
return h
}