@allbluecn/web-app 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/package.json +26 -11
  2. package/public/favicon.ico +0 -0
  3. package/public/logo/allblue-logo-app-icon.svg +7 -0
  4. package/public/logo/allblue-logo-dark-square.svg +4 -0
  5. package/public/logo/allblue-logo-dark.svg +4 -0
  6. package/public/logo/allblue-logo-light-square.svg +4 -0
  7. package/public/logo/allblue-logo-light.svg +4 -0
  8. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  9. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  10. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  11. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  12. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  13. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  14. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  15. package/src/__tests__/setup.ts +9 -0
  16. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  17. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  18. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  19. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  20. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  21. package/src/components/error/global-error-boundary.tsx +2 -2
  22. package/src/components/knowledge/general-groups.ts +15 -0
  23. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  24. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  25. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  26. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  27. package/src/components/layout/sidebar-03/nav-secondary.tsx +57 -47
  28. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  29. package/src/components/mdx.tsx +9 -0
  30. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  31. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  32. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  33. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  34. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  35. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  36. package/src/components/story/relations/general-groups.ts +1 -15
  37. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  38. package/src/components/story/story-list-view.tsx +7 -1
  39. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  40. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  41. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  42. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  43. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  44. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  45. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  46. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  47. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  48. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  49. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  50. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  51. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  52. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  53. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  54. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  55. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  56. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  57. package/src/lib/__tests__/pgvector.test.ts +2 -2
  58. package/src/lib/changelog/sdk-versions.ts +21 -21
  59. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  60. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  61. package/src/lib/fetch-json.ts +23 -0
  62. package/src/lib/fumadocs-i18n.ts +7 -0
  63. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  64. package/src/lib/layout.shared.tsx +40 -0
  65. package/src/lib/sanitize.ts +14 -1
  66. package/src/lib/session.ts +9 -1
  67. package/src/lib/source.ts +15 -0
  68. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  69. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  70. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  71. package/src/routeTree.gen.ts +65 -0
  72. package/src/routes/$lang/docs/$/route.lazy.tsx +62 -0
  73. package/src/routes/$lang/docs/$/route.tsx +52 -0
  74. package/src/routes/-tests/docs-redirect.test.ts +45 -0
  75. package/src/routes/__root.tsx +13 -7
  76. package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
  77. package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
  78. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  79. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  80. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  81. package/src/routes/_nav/route.tsx +21 -0
  82. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  83. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  84. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  85. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  86. package/src/routes/api/search.ts +13 -0
  87. package/src/routes/docs.tsx +9 -0
  88. package/src/server/__tests__/agent-crud.test.ts +4 -4
  89. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  90. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  91. package/src/server/__tests__/permissions.test.ts +1 -3
  92. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  93. package/src/server/__tests__/skill-crud.test.ts +3 -3
  94. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  95. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  96. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  97. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  98. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  99. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  100. package/src/server/ai-gateway/index.ts +5 -0
  101. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  102. package/src/server/ai-gateway/llm-fence.ts +82 -0
  103. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  104. package/src/server/ai-gateway/story-prompt.ts +4 -2
  105. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  106. package/src/server/ai-gateway/tools/server.ts +97 -54
  107. package/src/server/builtin-project.ts +21 -0
  108. package/src/server/email/sender.ts +8 -2
  109. package/src/server/fetch-guard.ts +170 -0
  110. package/src/server/middlewares/security-headers.ts +113 -0
  111. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  112. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  114. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  115. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  116. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  117. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  118. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  119. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  120. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  121. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  122. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  123. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  124. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  125. package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
  126. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  127. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  128. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  129. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  130. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  131. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  132. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  133. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  134. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  135. package/src/server/serverFns/kb-doc-search.ts +111 -0
  136. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  137. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  138. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  139. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  140. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  141. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  142. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  143. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  144. package/src/server/serverFns/story/kb-general.ts +30 -17
  145. package/src/server/serverFns/story/story-assoc.ts +13 -103
  146. package/src/server/serverFns/story/story-crud.ts +2 -5
  147. package/src/server/serverFns/story/story-groups.ts +4 -13
  148. package/src/server/serverFns/team.ts +1 -1
  149. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  150. package/src/server/serverFns/workspace-kb.ts +403 -0
  151. package/src/server/serverFns/workspace.ts +4 -1
  152. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  153. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  154. package/src/shared/ai-cli/adapter.ts +2 -2
  155. package/src/shared/ai-cli/cli-detector.ts +18 -9
  156. package/src/start.ts +2 -1
  157. package/src/styles/globals.css +116 -0
  158. package/src/types/jest-dom-vitest.d.ts +8 -7
  159. package/vite.shared.ts +18 -1
  160. package/public/file.svg +0 -1
  161. package/public/globe.svg +0 -1
  162. package/public/window.svg +0 -1
  163. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  164. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  165. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  166. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  167. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  168. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  169. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,294 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ // story 类型工具纯函数覆盖补全:castStoryUsers / castStoryUsersLight / castUserBrief /
20
+ // toActivityItem / aggregateReactions / toValidDate / toDateOnly / buildStoryTree /
21
+ // checkDescendant。纯函数无 serverFn,仅 mock Prisma。关键断言:null 安全、空数组
22
+ // 短路、树构建深度与后代判定边界。
23
+ import { beforeEach, describe, expect, it, vi } from "vitest";
24
+
25
+ const M = vi.hoisted(() => ({
26
+ storyCount: vi.fn(),
27
+ storyFindUnique: vi.fn(),
28
+ }));
29
+
30
+ vi.mock("@allbluecn/database", () => ({
31
+ prisma: { story: { count: M.storyCount, findUnique: M.storyFindUnique } },
32
+ Prisma: {},
33
+ }));
34
+
35
+ import {
36
+ type StoryListRowLight,
37
+ ACTIVITY_SELECT,
38
+ USER_BRIEF_SELECT,
39
+ aggregateReactions,
40
+ buildStoryTree,
41
+ castStoryUsers,
42
+ castStoryUsersLight,
43
+ castUserBrief,
44
+ checkDescendant,
45
+ toDateOnly,
46
+ toActivityItem,
47
+ toValidDate,
48
+ } from "../story/types";
49
+
50
+ const T_CREATED = new Date("2026-01-01T12:00:00Z");
51
+ const T_UPDATED = new Date("2026-01-02T12:00:00Z");
52
+ const T_START = new Date("2026-03-05T12:00:00Z");
53
+
54
+ const userBrief = (over: Record<string, unknown> = {}) => ({
55
+ id: "u-creator",
56
+ username: "alice",
57
+ email: "alice@example.com",
58
+ avatarConfig: null,
59
+ bgShape: null,
60
+ bgColor: null,
61
+ ...over,
62
+ });
63
+ /** STORY_LIST_SELECT 完整形状(含 parent/assignee/updatedBy/labels) */
64
+ const storyFull = (over: Record<string, unknown> = {}) => ({
65
+ id: "st-1",
66
+ identifier: "STORY-1",
67
+ sequenceId: 3,
68
+ name: "需求A",
69
+ description: "{\"html\":\"<p>x</p>\"}",
70
+ state: "todo",
71
+ priority: "high",
72
+ archived: false,
73
+ startDate: T_START,
74
+ targetDate: null,
75
+ boardOrder: 1,
76
+ projectId: "pr-1",
77
+ parentId: "st-0",
78
+ parent: { id: "st-0", name: "父需求", state: "done", identifier: "STORY-0" },
79
+ assignee: userBrief({ id: "u-assignee", username: "bob" }),
80
+ createdBy: userBrief(),
81
+ updatedBy: userBrief({ id: "u-updater", username: "carol" }),
82
+ labels: [{ label: { id: "lb-1", name: "P0", color: "#ff0000" } }],
83
+ createdAt: T_CREATED,
84
+ updatedAt: T_UPDATED,
85
+ ...over,
86
+ });
87
+
88
+ beforeEach(() => vi.clearAllMocks());
89
+
90
+ describe("castStoryUsers", () => {
91
+ it("序列化日期、映射 labels 并把 parent 转成 UI 形状", () => {
92
+ const res = castStoryUsers(storyFull());
93
+ expect(res).toMatchObject({
94
+ id: "st-1",
95
+ identifier: "STORY-1",
96
+ sequenceId: 3,
97
+ state: "todo",
98
+ priority: "high",
99
+ archived: false,
100
+ startDate: T_START.toISOString(),
101
+ targetDate: null,
102
+ createdAt: T_CREATED.toISOString(),
103
+ updatedAt: T_UPDATED.toISOString(),
104
+ parent: { id: "st-0", title: "父需求", status: "done", identifier: "STORY-0" },
105
+ labels: [{ id: "lb-1", name: "P0", color: "#ff0000" }],
106
+ });
107
+ expect(res.assignee).toMatchObject({ id: "u-assignee", username: "bob", avatarConfig: null });
108
+ expect(res.createdBy).toMatchObject({ id: "u-creator", username: "alice", avatarConfig: null });
109
+ expect(res.updatedBy).toMatchObject({ id: "u-updater", username: "carol", avatarConfig: null });
110
+ });
111
+
112
+ it("无父需求/负责人/更新人时对应字段为 null", () => {
113
+ const res = castStoryUsers(
114
+ storyFull({ parent: null, assignee: null, updatedBy: null, labels: [], startDate: null }),
115
+ );
116
+ expect(res.parent).toBeNull();
117
+ expect(res.assignee).toBeNull();
118
+ expect(res.updatedBy).toBeNull();
119
+ expect(res.labels).toEqual([]);
120
+ expect(res.startDate).toBeNull();
121
+ });
122
+
123
+ it("头像配置保留非空 JSON", () => {
124
+ const cfg = { bgShape: "blob", bgColor: "#0000ff", parts: [] };
125
+ const res = castStoryUsers(storyFull({ assignee: userBrief({ avatarConfig: cfg }) }));
126
+ expect(res.assignee.avatarConfig).toEqual(cfg);
127
+ });
128
+ });
129
+
130
+ describe("castStoryUsersLight", () => {
131
+ it("强制置空 description 并做同样的用户序列化", () => {
132
+ const res = castStoryUsersLight(storyFull() as unknown as StoryListRowLight);
133
+ expect(res.description).toBeNull();
134
+ expect(res.startDate).toBe(T_START.toISOString());
135
+ expect(res.createdAt).toBe(T_CREATED.toISOString());
136
+ expect(res.parent).toEqual({ id: "st-0", title: "父需求", status: "done", identifier: "STORY-0" });
137
+ expect(res.labels).toEqual([{ id: "lb-1", name: "P0", color: "#ff0000" }]);
138
+ });
139
+
140
+ it("无父需求/负责人时为 null", () => {
141
+ const res = castStoryUsersLight(storyFull({ parent: null, assignee: null }) as unknown as StoryListRowLight);
142
+ expect(res.parent).toBeNull();
143
+ expect(res.assignee).toBeNull();
144
+ expect(res.description).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe("castUserBrief", () => {
149
+ it("透传字段并把空头像配置归一为 null", () => {
150
+ const res = castUserBrief(userBrief());
151
+ expect(res).toEqual(userBrief());
152
+ expect(castUserBrief(userBrief({ avatarConfig: undefined })).avatarConfig).toBeNull();
153
+ });
154
+ });
155
+
156
+ describe("toActivityItem", () => {
157
+ it("序列化 createdAt 并转换 user brief", () => {
158
+ const res = toActivityItem({
159
+ id: "act-1",
160
+ action: "created",
161
+ field: "state",
162
+ oldValue: "backlog",
163
+ newValue: "todo",
164
+ isAI: true,
165
+ createdAt: T_UPDATED,
166
+ user: userBrief(),
167
+ });
168
+ expect(res).toEqual({
169
+ id: "act-1",
170
+ action: "created",
171
+ field: "state",
172
+ oldValue: "backlog",
173
+ newValue: "todo",
174
+ isAI: true,
175
+ createdAt: T_UPDATED.toISOString(),
176
+ user: userBrief(),
177
+ });
178
+ });
179
+ });
180
+
181
+ describe("aggregateReactions", () => {
182
+ it("按 emoji 分组并标记当前用户是否已反应", () => {
183
+ const res = aggregateReactions(
184
+ [
185
+ { emoji: "👍", userId: "u1" },
186
+ { emoji: "👍", userId: "u2" },
187
+ { emoji: "🎉", userId: "u2" },
188
+ ],
189
+ "u1",
190
+ );
191
+ expect(res).toEqual([
192
+ { emoji: "👍", count: 2, userIds: ["u1", "u2"], reacted: true },
193
+ { emoji: "🎉", count: 1, userIds: ["u2"], reacted: false },
194
+ ]);
195
+ });
196
+
197
+ it("无反应时返回空数组", () => {
198
+ expect(aggregateReactions([], "u1")).toEqual([]);
199
+ });
200
+ });
201
+
202
+ describe("toValidDate / toDateOnly", () => {
203
+ it("合法字符串解析为 Date,非法返回 null", () => {
204
+ expect(toValidDate("2026-03-05")).toBeInstanceOf(Date);
205
+ expect(toValidDate("not-a-date")).toBeNull();
206
+ });
207
+
208
+ it("日期格式化为 YYYY-MM-DD(月日补零,跨时区不变)", () => {
209
+ expect(toDateOnly(new Date("2026-03-05T12:00:00Z"))).toBe("2026-03-05");
210
+ expect(toDateOnly(new Date("2026-01-09T12:00:00Z"))).toBe("2026-01-09");
211
+ expect(toDateOnly(new Date("2026-12-31T12:00:00Z"))).toBe("2026-12-31");
212
+ });
213
+ });
214
+
215
+ describe("buildStoryTree", () => {
216
+ it("递归构建节点并逐级 depth+1,count 查询按 parentId 下钻", async () => {
217
+ M.storyCount.mockImplementation(({ where }: { where: { parentId: string } }) => {
218
+ const counts: Record<string, number> = { "st-1": 2, "st-2": 1 };
219
+ return Promise.resolve(counts[where.parentId] ?? 0);
220
+ });
221
+
222
+ const res = await buildStoryTree({
223
+ id: "st-1",
224
+ name: "根",
225
+ state: "todo",
226
+ parentId: null,
227
+ children: [
228
+ { id: "st-2", name: "子1", state: "backlog", parentId: "st-1", children: [
229
+ { id: "st-4", name: "孙1", state: "backlog", parentId: "st-2", children: [] },
230
+ ] },
231
+ { id: "st-3", name: "子2", state: "backlog", parentId: "st-1", children: [] },
232
+ ],
233
+ });
234
+
235
+ expect(res).toMatchObject({ id: "st-1", title: "根", status: "todo", depth: 0, childrenCount: 2, hasChildren: true });
236
+ expect(res.children).toBeDefined();
237
+ const children = res.children ?? [];
238
+ expect(children).toHaveLength(2);
239
+ expect(children[0]).toMatchObject({ id: "st-2", depth: 1, childrenCount: 1, hasChildren: true });
240
+ expect(children[0]?.children).toHaveLength(1);
241
+ expect(children[0]?.children?.[0]).toMatchObject({ id: "st-4", depth: 2, childrenCount: 0, hasChildren: false });
242
+ expect(children[1]).toMatchObject({ id: "st-3", depth: 1, childrenCount: 0, hasChildren: false });
243
+ expect(M.storyCount).toHaveBeenCalledWith({ where: { parentId: "st-1" } });
244
+ });
245
+
246
+ it("无 children 字段时不返回 children 键", async () => {
247
+ M.storyCount.mockResolvedValue(0);
248
+ const res = await buildStoryTree({ id: "st-9", name: "叶子", state: "done", parentId: null });
249
+ expect(res).toEqual({
250
+ id: "st-9", title: "叶子", status: "done", parentId: null,
251
+ depth: 0, hasChildren: false, childrenCount: 0,
252
+ });
253
+ // children 字段存在但值为 undefined(三元表达式显式赋值)
254
+ expect(res.children).toBeUndefined();
255
+ });
256
+ });
257
+
258
+ describe("checkDescendant", () => {
259
+ it("沿 parentId 链向上回溯,命中祖先返回 true(覆盖多级链)", async () => {
260
+ const chain: Record<string, string> = { "st-3": "st-2", "st-2": "st-1" };
261
+ M.storyFindUnique.mockImplementation(({ where }: { where: { id: string } }) =>
262
+ Promise.resolve(chain[where.id] ? { parentId: chain[where.id] } : null),
263
+ );
264
+
265
+ await expect(checkDescendant("st-1", "st-3")).resolves.toBe(true);
266
+ expect(M.storyFindUnique).toHaveBeenCalledWith({ where: { id: "st-3" }, select: { parentId: true } });
267
+ expect(M.storyFindUnique).toHaveBeenCalledWith({ where: { id: "st-2" }, select: { parentId: true } });
268
+ });
269
+
270
+ it("回溯到根仍未命中时返回 false", async () => {
271
+ const chain: Record<string, string> = { "st-3": "st-2", "st-2": "st-1" };
272
+ M.storyFindUnique.mockImplementation(({ where }: { where: { id: string } }) =>
273
+ Promise.resolve(chain[where.id] ? { parentId: chain[where.id] } : null),
274
+ );
275
+ await expect(checkDescendant("st-9", "st-3")).resolves.toBe(false);
276
+ });
277
+
278
+ it("目标为顶层(无父)时直接返回 false", async () => {
279
+ M.storyFindUnique.mockResolvedValue({ parentId: null });
280
+ await expect(checkDescendant("st-1", "st-top")).resolves.toBe(false);
281
+ });
282
+ });
283
+
284
+ describe("select 常量契约", () => {
285
+ it("USER_BRIEF_SELECT 与 ACTIVITY_SELECT 字段稳定(防止字段漂移)", () => {
286
+ expect(USER_BRIEF_SELECT).toEqual({
287
+ id: true, username: true, email: true, avatarConfig: true, bgShape: true, bgColor: true,
288
+ });
289
+ expect(ACTIVITY_SELECT).toEqual({
290
+ id: true, action: true, field: true, oldValue: true, newValue: true, isAI: true,
291
+ createdAt: true, user: { select: USER_BRIEF_SELECT },
292
+ });
293
+ });
294
+ });
@@ -62,7 +62,7 @@ describe('acceptTeamInvitationFn', () => {
62
62
  email: 'b@x.com', expiresAt: new Date(Date.now() - 1000), ownerId: 'owner1',
63
63
  } as never)
64
64
  await expect(acceptTeamInvitationFn({ data: { token: 't1' } }))
65
- .rejects.toMatchObject({ message: expect.stringContaining('过期') })
65
+ .rejects.toThrow('过期')
66
66
  })
67
67
 
68
68
  it('已在团队 → 拒绝', async () => {
@@ -72,7 +72,7 @@ describe('acceptTeamInvitationFn', () => {
72
72
  } as never)
73
73
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1' } as never)
74
74
  await expect(acceptTeamInvitationFn({ data: { token: 't1' } }))
75
- .rejects.toMatchObject({ message: expect.stringContaining('已加入') })
75
+ .rejects.toThrow('已加入')
76
76
  })
77
77
 
78
78
  it('link 邀请任意登录用户申请 → 创建 TeamJoinRequest,不占用邀请行', async () => {
@@ -101,7 +101,7 @@ describe('acceptTeamInvitationFn', () => {
101
101
  } as never)
102
102
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1', ownerId: 'u2' } as never)
103
103
  await expect(acceptTeamInvitationFn({ data: { token: 't1', userId: null } }))
104
- .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
104
+ .rejects.toThrow('已加入团队')
105
105
  })
106
106
 
107
107
  it('email 邀请邮箱不匹配 → 拒绝', async () => {
@@ -111,7 +111,7 @@ describe('acceptTeamInvitationFn', () => {
111
111
  } as never)
112
112
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
113
113
  await expect(acceptTeamInvitationFn({ data: { token: 't1' } }))
114
- .rejects.toMatchObject({ message: expect.stringContaining('邮箱') })
114
+ .rejects.toThrow('邮箱')
115
115
  })
116
116
 
117
117
  it('getTeamInvitationByTokenFn 正常返回', async () => {
@@ -153,7 +153,7 @@ describe('approveTeamInvitationFn', () => {
153
153
  expiresAt: new Date(Date.now() + 60000), createdAt: new Date(),
154
154
  } as never)
155
155
  await expect(approveTeamInvitationFn({ data: { invitationId: 'inv1', seatType: 'member' } }))
156
- .rejects.toMatchObject({ message: expect.stringContaining('approveTeamJoinRequestFn') })
156
+ .rejects.toThrow('approveTeamJoinRequestFn')
157
157
  })
158
158
 
159
159
  it('acceptedBy 为 null 时用 applicantEmail 回填申请人', async () => {
@@ -186,6 +186,6 @@ describe('approveTeamInvitationFn', () => {
186
186
  expiresAt: new Date(Date.now() - 1000), createdAt: new Date(),
187
187
  } as never)
188
188
  await expect(approveTeamInvitationFn({ data: { invitationId: 'inv1', seatType: 'member' } }))
189
- .rejects.toMatchObject({ message: expect.stringContaining('过期') })
189
+ .rejects.toThrow('过期')
190
190
  })
191
191
  })
@@ -237,7 +237,7 @@ describe('deleteTeamInvitationFn', () => {
237
237
 
238
238
  await expect(
239
239
  deleteTeamInvitationFn({ data: { invitationId: 'inv2' } }),
240
- ).rejects.toMatchObject({ message: expect.stringContaining('不可删除') })
240
+ ).rejects.toThrow('不可删除')
241
241
  expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
242
242
  })
243
243
 
@@ -248,7 +248,7 @@ describe('deleteTeamInvitationFn', () => {
248
248
 
249
249
  await expect(
250
250
  deleteTeamInvitationFn({ data: { invitationId: 'inv3' } }),
251
- ).rejects.toMatchObject({ message: '邀请不存在' })
251
+ ).rejects.toThrow(new Error('邀请不存在'))
252
252
  expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
253
253
  })
254
254
  })
@@ -273,7 +273,7 @@ describe('deleteTeamJoinRequestFn', () => {
273
273
 
274
274
  await expect(
275
275
  deleteTeamJoinRequestFn({ data: { requestId: 'jrX' } }),
276
- ).rejects.toMatchObject({ message: '申请不存在' })
276
+ ).rejects.toThrow(new Error('申请不存在'))
277
277
  expect(prisma.teamJoinRequest.delete).not.toHaveBeenCalled()
278
278
  })
279
279
  })
@@ -66,19 +66,19 @@ describe('applyTeamInvitationFn(link 渠道)', () => {
66
66
  it('链接不存在 → 拒绝', async () => {
67
67
  vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(null as never)
68
68
  await expect(applyTeamInvitationFn({ data: { token: 'tX', email: 'a@x.com' } }))
69
- .rejects.toMatchObject({ message: expect.stringContaining('不存在') })
69
+ .rejects.toThrow('不存在')
70
70
  })
71
71
 
72
72
  it('链接过期 → 拒绝', async () => {
73
73
  vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
74
74
  await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'a@x.com' } }))
75
- .rejects.toMatchObject({ message: expect.stringContaining('过期') })
75
+ .rejects.toThrow('过期')
76
76
  })
77
77
 
78
78
  it('邮箱是 owner 自己 → 拒绝(不能加入自己的团队)', async () => {
79
79
  vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
80
80
  await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'owner@x.com' } }))
81
- .rejects.toMatchObject({ message: expect.stringContaining('自己的团队') })
81
+ .rejects.toThrow('自己的团队')
82
82
  })
83
83
 
84
84
  it('邮箱已注册且该账号已加入团队 → 拒绝', async () => {
@@ -86,7 +86,7 @@ describe('applyTeamInvitationFn(link 渠道)', () => {
86
86
  vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
87
87
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1' } as never)
88
88
  await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'taken@x.com' } }))
89
- .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
89
+ .rejects.toThrow('已加入团队')
90
90
  })
91
91
 
92
92
  it('新邮箱 → 创建 TeamJoinRequest,且不改 TeamInvitation 状态', async () => {
@@ -128,14 +128,14 @@ describe('approveTeamJoinRequestFn', () => {
128
128
  it('申请不存在或非本人 → 拒绝', async () => {
129
129
  vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
130
130
  await expect(approveTeamJoinRequestFn({ data: { requestId: 'jrX', seatType: 'member' } }))
131
- .rejects.toMatchObject({ message: expect.stringContaining('申请不存在') })
131
+ .rejects.toThrow('申请不存在')
132
132
  })
133
133
 
134
134
  it('申请人已加入其他团队 → 友好拒绝', async () => {
135
135
  vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
136
136
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'mX' } as never)
137
137
  await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
138
- .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
138
+ .rejects.toThrow('已加入团队')
139
139
  })
140
140
 
141
141
  it('链接已过期 → 拒绝并同步失效申请', async () => {
@@ -144,7 +144,7 @@ describe('approveTeamJoinRequestFn', () => {
144
144
  } as never)
145
145
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
146
146
  await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
147
- .rejects.toMatchObject({ message: expect.stringContaining('过期') })
147
+ .rejects.toThrow('过期')
148
148
  expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
149
149
  data: expect.objectContaining({ status: 'invalidated' }),
150
150
  }))
@@ -202,7 +202,7 @@ describe('rejectTeamJoinRequestFn', () => {
202
202
  it('非 pending 状态 → 拒绝操作', async () => {
203
203
  vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, status: 'approved' } as never)
204
204
  await expect(rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } }))
205
- .rejects.toMatchObject({ message: expect.stringContaining('待审批') })
205
+ .rejects.toThrow('待审批')
206
206
  })
207
207
  })
208
208
 
@@ -102,7 +102,7 @@ describe('updateSeatsFn', () => {
102
102
  mockedSubFindUnique.mockResolvedValue(null)
103
103
  await expect(
104
104
  updateSeatsFn({ data: { addonSeats: 3 } }),
105
- ).rejects.toMatchObject({ message: expect.stringContaining('升级') })
105
+ ).rejects.toThrow('升级')
106
106
  expect(fakeBilling.updateSeats).not.toHaveBeenCalled()
107
107
  })
108
108
 
@@ -120,7 +120,7 @@ describe('updateSeatsFn', () => {
120
120
  )
121
121
  await expect(
122
122
  updateSeatsFn({ data: { addonSeats: 3 } }),
123
- ).rejects.toMatchObject({ message: expect.stringContaining('不限量') })
123
+ ).rejects.toThrow('不限量')
124
124
  })
125
125
 
126
126
  it('降购低于工作空间数被 floor 拦截', async () => {
@@ -128,7 +128,7 @@ describe('updateSeatsFn', () => {
128
128
  mockedWorkspaceCount.mockResolvedValue(7)
129
129
  await expect(
130
130
  updateSeatsFn({ data: { addonSeats: 1 } }),
131
- ).rejects.toMatchObject({ message: expect.stringContaining('工作空间数') })
131
+ ).rejects.toThrow('工作空间数')
132
132
  })
133
133
 
134
134
  it('合法加购:透传 billing.updateSeats 并返回其结果', async () => {
@@ -166,6 +166,6 @@ describe('updateSeatsFn', () => {
166
166
  )
167
167
  await expect(
168
168
  updateSeatsFn({ data: { addonSeats: 3 } }),
169
- ).rejects.toMatchObject({ message: expect.stringContaining('未开通席位加购') })
169
+ ).rejects.toThrow('未开通席位加购')
170
170
  })
171
171
  })
@@ -0,0 +1,160 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // IDOR 越权矩阵(workspace-kb 域样板):锁定 requireWorkspaceMember 归属校验行为。
19
+ // 放行态:owner 本人 / active 成员。拒绝态:非成员 / 仅 pending / workspace 不存在 / 未登录。
20
+ // 关键断言:越权失败时必须零资源查询(不触达 workspaceKnowledgeBase、knowledgeBase)。
21
+ // mock 骨架与 workspace-kb.test.ts 一致,可被其它 serverFn 域直接复制。
22
+
23
+ import { describe, it, expect, vi, beforeEach } from "vitest";
24
+
25
+ vi.mock("@tanstack/react-start", () => ({
26
+ createServerFn: (config: any) => {
27
+ const api: any = {
28
+ _validator: undefined,
29
+ validator(schema: any) {
30
+ this._validator = schema;
31
+ return this;
32
+ },
33
+ handler(fn: Function) {
34
+ return async (args: any) => {
35
+ let data = args?.data;
36
+ if (this._validator && data !== undefined) {
37
+ data = this._validator.parse ? this._validator.parse(data) : data;
38
+ }
39
+ return fn({ data });
40
+ };
41
+ },
42
+ };
43
+ return api;
44
+ },
45
+ }));
46
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
47
+
48
+ const mocks = vi.hoisted(() => ({
49
+ currentSession: vi.fn(),
50
+ wsFindUnique: vi.fn(),
51
+ wkbFindMany: vi.fn(),
52
+ kbFindMany: vi.fn(),
53
+ }));
54
+
55
+ vi.mock("@allbluecn/database", () => {
56
+ const prisma = {
57
+ workspace: { findUnique: mocks.wsFindUnique },
58
+ workspaceKnowledgeBase: { findMany: mocks.wkbFindMany },
59
+ knowledgeBase: { findMany: mocks.kbFindMany },
60
+ };
61
+ return { prisma, Prisma: {} };
62
+ });
63
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: mocks.currentSession }));
64
+
65
+ import { listWorkspaceAvailableKbsFn } from "../workspace-kb";
66
+
67
+ const OWNER = "user-owner-1";
68
+ const OTHER_USER = "user-attacker-2";
69
+ const WS_ID = "ws-target-1";
70
+
71
+ // 构造 workspace 归属查询结果(与 requireWorkspaceMember 的 select 形状一致)。
72
+ // activeMemberIds 是 status="active" 过滤后的成员 id 列表;pending 成员不出现在其中。
73
+ const wsOf = (ownerId: string, activeMemberIds: string[]) => ({
74
+ ownerId,
75
+ members: activeMemberIds.map((userId) => ({ id: `m-${userId}` })),
76
+ });
77
+
78
+ // 资源查询:归属校验通过后才会触达的下游查询
79
+ const resourceQueries = [mocks.wkbFindMany, mocks.kbFindMany];
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ mocks.currentSession.mockResolvedValue({ user: { id: OWNER } });
84
+ mocks.wkbFindMany.mockResolvedValue([]);
85
+ mocks.kbFindMany.mockResolvedValue([]);
86
+ });
87
+
88
+ describe("IDOR 越权矩阵:listWorkspaceAvailableKbsFn", () => {
89
+ it("owner 访问自己的 workspace 放行", async () => {
90
+ mocks.currentSession.mockResolvedValue({ user: { id: OWNER } });
91
+ mocks.wsFindUnique.mockResolvedValue(wsOf(OWNER, []));
92
+ const res = await listWorkspaceAvailableKbsFn({ data: { workspaceId: WS_ID } });
93
+ expect(res).toBeTruthy();
94
+ expect(res).toBeInstanceOf(Array);
95
+ });
96
+
97
+ it("active 成员访问非自己创建的 workspace 放行", async () => {
98
+ mocks.currentSession.mockResolvedValue({ user: { id: OTHER_USER } });
99
+ mocks.wsFindUnique.mockResolvedValue(wsOf(OWNER, [OTHER_USER]));
100
+ const res = await listWorkspaceAvailableKbsFn({ data: { workspaceId: WS_ID } });
101
+ expect(res).toBeTruthy();
102
+ });
103
+
104
+ // 注意:pending 与非成员在本函数视角下查询结果相同(active 成员均为空),
105
+ // 二者区分力不来自 mock 数据形态,而来自下方对 wsFindUnique 查询形状
106
+ // (status: "active")的断言——新增域的样板请一并复制该形状断言。
107
+ it.each([
108
+ [
109
+ "非成员被拒",
110
+ wsOf(OWNER, []),
111
+ /仅工作空间成员可访问/,
112
+ ],
113
+ [
114
+ "仅 pending 成员(未激活)被拒",
115
+ wsOf(OWNER, []),
116
+ /仅工作空间成员可访问/,
117
+ ],
118
+ ["workspace 不存在被拒", null, /工作空间不存在/],
119
+ ] as Array<[string, unknown, RegExp]>)(
120
+ "%s",
121
+ async (_name, ws, expectedError) => {
122
+ mocks.currentSession.mockResolvedValue({ user: { id: OTHER_USER } });
123
+ mocks.wsFindUnique.mockResolvedValue(ws);
124
+ await expect(
125
+ listWorkspaceAvailableKbsFn({ data: { workspaceId: WS_ID } }),
126
+ ).rejects.toThrow(expectedError);
127
+ for (const query of resourceQueries) {
128
+ expect(query).not.toHaveBeenCalled();
129
+ }
130
+ },
131
+ );
132
+
133
+ it("非成员被拒时成员查询严格按 active 过滤(pending 不计入放行)", async () => {
134
+ mocks.currentSession.mockResolvedValue({ user: { id: OTHER_USER } });
135
+ mocks.wsFindUnique.mockResolvedValue(wsOf(OWNER, []));
136
+ await expect(
137
+ listWorkspaceAvailableKbsFn({ data: { workspaceId: WS_ID } }),
138
+ ).rejects.toThrow(/仅工作空间成员可访问/);
139
+ expect(mocks.wsFindUnique).toHaveBeenCalledWith({
140
+ where: { id: WS_ID },
141
+ select: {
142
+ ownerId: true,
143
+ members: { where: { userId: OTHER_USER, status: "active" }, select: { id: true } },
144
+ },
145
+ });
146
+ });
147
+
148
+ it("未登录直接拒绝", async () => {
149
+ mocks.currentSession.mockRejectedValue(new Error("UNAUTHORIZED"));
150
+ mocks.wsFindUnique.mockResolvedValue(wsOf(OWNER, []));
151
+ await expect(
152
+ listWorkspaceAvailableKbsFn({ data: { workspaceId: WS_ID } }),
153
+ ).rejects.toThrow("UNAUTHORIZED");
154
+ // requireAuth 先于 requireWorkspaceMember:未登录时连归属查询都不得发起
155
+ expect(mocks.wsFindUnique).not.toHaveBeenCalled();
156
+ for (const query of resourceQueries) {
157
+ expect(query).not.toHaveBeenCalled();
158
+ }
159
+ });
160
+ });