package store import ( "errors" "testing" ) // 临时会话 store 契约(U1):一个 scratch 项目 + 恰好一个私有会话, // 项目列表不出现它,会话列表仍然出现它,标记不可被客户端写入改动。 func scratchAccountFixture(t *testing.T, st *Store, owner string, admin bool) { t.Helper() if err := st.CreateAccount(owner, owner, "", admin); err != nil { t.Fatal(err) } } func TestScratchSessionCreatesOnePrivateConversationUnderScratchProject(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatalf("create scratch session: %v", err) } if !IsScratchProject(project.Settings) { t.Fatalf("project is not marked scratch: %+v", project.Settings) } if string(project.Owner) != "alice" || project.ID == "" { t.Fatalf("unexpected project: %+v", project) } if string(conversation.ProjectID) != string(project.ID) || string(conversation.Owner) != "alice" { t.Fatalf("conversation is not under the scratch project: %+v", conversation) } if !conversation.CrossPrivate || !conversation.AllowCrossRead { t.Fatalf("scratch conversation must be private and cross-readable: %+v", conversation) } // 读回的项目行带着同一个标记,判定只有 IsScratchProject 一个来源。 stored, err := st.GetProject("alice", string(project.ID)) if err != nil || !IsScratchProject(stored.Settings) { t.Fatalf("stored project marker: %+v %v", stored.Settings, err) } } func TestScratchProjectsStayOutOfProjectListsButNotConversationLists(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) scratchAccountFixture(t, st, "bob", false) normal, err := st.CreateProject("alice", "普通项目") if err != nil { t.Fatal(err) } project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } // 他人的 scratch 也不进全量候选(共享适配器枚举)。 if _, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false); err != nil { t.Fatal(err) } projects, err := st.ListProjects("alice") if err != nil { t.Fatal(err) } if len(projects) != 1 || projects[0].ID != normal.ID { t.Fatalf("ListProjects must hide scratch projects: %+v", projects) } all, err := st.ListAllProjects() if err != nil { t.Fatal(err) } if len(all) != 1 || all[0].ID != normal.ID { t.Fatalf("ListAllProjects must hide scratch projects: %+v", all) } // 侧栏任务列表仍然要能看到临时会话。 tasks, err := st.ListTasks("alice", false) if err != nil { t.Fatal(err) } found := false for _, task := range tasks { if task.ID == conversation.ID { found = true } } if !found { t.Fatalf("ListTasks dropped the scratch conversation: %+v", tasks) } conversations, err := st.ListConversations("alice", string(project.ID)) if err != nil || len(conversations) != 1 || conversations[0].ID != conversation.ID { t.Fatalf("ListConversations under scratch: %+v %v", conversations, err) } } func TestScratchProjectRefusesSecondConversationOnEveryStoreCreatePath(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } // account-checked 路径由服务端共用的会话创建入口(createPrivateConversation, // 覆盖 POST /api/projects/{id}/conversations 与 /new)先行拒绝,见 // internal/server/scratch_session_test.go;这里的 store 契约覆盖本文件内 // 的两个创建函数。 for name, create := range map[string]func() error{ "legacy": func() error { _, err := st.CreateConversation("alice", string(project.ID), "第二个", "") return err }, "shared": func() error { _, err := st.CreateConversationInProject("alice", string(project.ID), "第二个", "") return err }, } { if err := create(); !errors.Is(err, ErrScratchSessionSingle) { t.Fatalf("%s path allowed a second conversation: %v", name, err) } } conversations, err := st.ListConversations("alice", string(project.ID)) if err != nil || len(conversations) != 1 { t.Fatalf("refused create still wrote a row: %+v %v", conversations, err) } // 普通项目不受影响。 normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) if err != nil { t.Fatal(err) } if _, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "正常会话", "", false, true); err != nil { t.Fatalf("normal project lost conversation create: %v", err) } } func TestScratchHostMarkerIsServerOwned(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) scratchAccountFixture(t, st, "bob", false) normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) if err != nil { t.Fatal(err) } project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } // 普通项目不能被加上标记(两种写入路径都不行)。 for name, write := range map[string]func(id string) error{ "legacy": func(id string) error { return st.UpdateProjectSettings("alice", id, ScratchHostSettings()) }, "account-checked": func(id string) error { return st.UpdateProjectSettingsAccountChecked("alice", id, ScratchHostSettings(), false) }, } { if err := write(string(normal.ID)); !errors.Is(err, ErrScratchMarkerReserved) { t.Fatalf("%s path added the scratch marker: %v", name, err) } } // scratch 项目也不能被摘掉标记。 cleared := map[string]any{"host": map[string]any{"kind": "normal"}} if err := st.UpdateProjectSettings("alice", string(project.ID), cleared); !errors.Is(err, ErrScratchMarkerReserved) { t.Fatalf("scratch marker cleared: %v", err) } if err := st.UpdateProjectSettingsAccountChecked("alice", string(project.ID), map[string]any{"host": nil}, false); !errors.Is(err, ErrScratchMarkerReserved) { t.Fatalf("scratch marker dropped by null: %v", err) } // 不改变标记的普通写入照旧放行。 if err := st.UpdateProjectSettings("alice", string(project.ID), map[string]any{"note": "保留标记"}); err != nil { t.Fatalf("unrelated settings write refused: %v", err) } kept, err := st.GetProject("alice", string(project.ID)) if err != nil || !IsScratchProject(kept.Settings) || kept.Settings["note"] != "保留标记" { t.Fatalf("scratch project settings after unrelated write: %+v %v", kept.Settings, err) } if err := st.UpdateProjectSettings("alice", string(normal.ID), map[string]any{"note": "普通"}); err != nil { t.Fatalf("normal project settings write refused: %v", err) } } func TestScratchSessionCreateFollowsAccountState(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "admin", true) scratchAccountFixture(t, st, "alice", false) if _, _, err := st.CreateScratchSessionAccountChecked("missing", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) { t.Fatalf("unknown actor created a scratch session: %v", err) } account, err := st.GetAccountByOwner("alice") if err != nil { t.Fatal(err) } if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { t.Fatal(err) } if _, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) { t.Fatalf("disabled account created a scratch session: %v", err) } projects, err := st.ListProjects("alice") if err != nil || len(projects) != 0 { t.Fatalf("refused create left a project row: %+v %v", projects, err) } } func TestScratchSessionTransactionLeavesNothingOnFailure(t *testing.T) { st := openTestStore(t) // 缺账号时事务回滚,项目与会话都不落库。 if _, _, err := st.CreateScratchSessionAccountChecked("ghost", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) { t.Fatalf("ghost actor: %v", err) } all, err := st.ListAllProjects() if err != nil || len(all) != 0 { t.Fatalf("rolled back create left rows: %+v %v", all, err) } } // ---- U2:隔离门禁的 store 契约 ---- // IsScratchProjectID / ScratchProjectDecision 是所有门禁唯一的判定来源: // 属主放行,其他人 NotFound,普通项目不接管(交回原判定面)。 func TestScratchProjectLookupAndOwnershipDecision(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) scratchAccountFixture(t, st, "bob", false) project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) if err != nil { t.Fatal(err) } for _, tc := range []struct { name string projectID string scratch bool err error }{ {"scratch", string(project.ID), true, nil}, {"normal", string(normal.ID), false, nil}, {"empty", "", false, nil}, {"missing", "prj_missing", false, ErrNotFound}, } { got, err := st.IsScratchProjectID(tc.projectID) if got != tc.scratch || !errors.Is(err, tc.err) { t.Fatalf("%s: IsScratchProjectID = %v, %v (want %v, %v)", tc.name, got, err, tc.scratch, tc.err) } } // 属主:门禁接管并放行(不查 RDM)。 handled, err := st.ScratchProjectDecision(string(project.ID), "alice") if !handled || err != nil { t.Fatalf("scratch owner decision = %v, %v", handled, err) } // 其他人:NotFound——临时会话不是共享资源。 if handled, err := st.ScratchProjectDecision(string(project.ID), "bob"); !handled || !errors.Is(err, ErrNotFound) { t.Fatalf("scratch non-owner decision = %v, %v", handled, err) } // 普通项目与空项目 id 都不接管,原路径继续。 if handled, err := st.ScratchProjectDecision(string(normal.ID), "bob"); handled || err != nil { t.Fatalf("normal project decision = %v, %v", handled, err) } if handled, err := st.ScratchProjectDecision("", "bob"); handled || err != nil { t.Fatalf("empty project decision = %v, %v", handled, err) } // 读不到的项目不按临时会话处理(不因此放宽任何门禁)。 if handled, err := st.ScratchProjectDecision("prj_missing", "alice"); handled || err != nil { t.Fatalf("missing project decision = %v, %v", handled, err) } _ = conversation } // ScratchProjectIDs 只报属主自己的 scratch 项目:列表响应靠它给会话打标, // 所以普通项目与他人临时会话都不在里面。 func TestScratchProjectIDsCoverOnlyOwnScratchProjects(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) scratchAccountFixture(t, st, "bob", false) scratch, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } second, _, err := st.CreateScratchSessionAccountChecked("alice", "方案草稿", "方案草稿", "", false) if err != nil { t.Fatal(err) } normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) if err != nil { t.Fatal(err) } other, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } ids, err := st.ScratchProjectIDs("alice") if err != nil { t.Fatal(err) } if len(ids) != 2 || !ids[string(scratch.ID)] || !ids[string(second.ID)] { t.Fatalf("alice scratch ids = %+v", ids) } if ids[string(normal.ID)] { t.Fatalf("normal project marked scratch: %+v", ids) } // 租户边界:他人的临时会话不出现在 alice 的集合里。 if ids[string(other.ID)] { t.Fatalf("other owner's scratch project leaked: %+v", ids) } others, err := st.ScratchProjectIDs("bob") if err != nil { t.Fatal(err) } if len(others) != 1 || !others[string(other.ID)] { t.Fatalf("bob scratch ids = %+v", others) } // 没有项目的人拿到空集合而不是 nil 之外的东西(判空与遍历都安全)。 if empty, err := st.ScratchProjectIDs("carol"); err != nil || len(empty) != 0 { t.Fatalf("empty scratch ids = %+v, %v", empty, err) } } // 删掉临时会话的唯一会话即删掉它的 scratch 项目(不留空壳);普通项目照旧。 func TestDeletingScratchConversationDeletesScratchProject(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) scratchAccountFixture(t, st, "bob", false) project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } // 他人删除:仍然 NotFound,且临时会话原样存在。 if err := st.DeleteConversationAccountChecked("bob", string(conversation.ID), false); !errors.Is(err, ErrNotFound) { t.Fatalf("non-owner delete = %v", err) } if _, err := st.ProjectMetadata(string(project.ID)); err != nil { t.Fatalf("non-owner delete removed the scratch project: %v", err) } if err := st.DeleteConversationAccountChecked("alice", string(conversation.ID), false); err != nil { t.Fatalf("owner delete: %v", err) } if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) { t.Fatalf("scratch project survived its only conversation: %v", err) } if scratch, err := st.IsScratchProjectID(string(project.ID)); scratch || !errors.Is(err, ErrNotFound) { t.Fatalf("deleted scratch project still answers as scratch: %v %v", scratch, err) } // 普通项目的会话删除不动项目。 normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) if err != nil { t.Fatal(err) } conv, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "会话", "", false, true) if err != nil { t.Fatal(err) } if err := st.DeleteConversationAccountChecked("alice", string(conv.ID), false); err != nil { t.Fatal(err) } if _, err := st.ProjectMetadata(string(normal.ID)); err != nil { t.Fatalf("normal project removed with its conversation: %v", err) } } // legacy DeleteConversation 是同一条语义:删会话即删临时会话外壳。 func TestLegacyDeleteConversationCascadesScratchProject(t *testing.T) { st := openTestStore(t) scratchAccountFixture(t, st, "alice", false) project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } if err := st.DeleteConversation("alice", string(conversation.ID)); err != nil { t.Fatalf("legacy delete: %v", err) } if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) { t.Fatalf("legacy delete left the scratch shell: %v", err) } }