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.
594 lines
19 KiB
Go
594 lines
19 KiB
Go
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
|
||
}
|