@allbluecn/web-app 0.10.3 → 0.11.1

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 (246) hide show
  1. package/package.json +24 -11
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-app-icon.svg +7 -0
  6. package/public/logo/allblue-logo-dark-square.svg +4 -0
  7. package/public/logo/allblue-logo-dark.png +0 -0
  8. package/public/logo/allblue-logo-dark.svg +4 -0
  9. package/public/logo/allblue-logo-light-square.svg +4 -0
  10. package/public/logo/allblue-logo-light.png +0 -0
  11. package/public/logo/allblue-logo-light.svg +4 -0
  12. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  13. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  14. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  15. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  16. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  17. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  18. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  19. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  20. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  21. package/src/__tests__/setup.ts +9 -0
  22. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  23. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  24. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  25. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  26. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  27. package/src/components/auth/icon-input.tsx +9 -1
  28. package/src/components/auth/password-input.tsx +9 -1
  29. package/src/components/changelog/changelog-page.tsx +44 -1
  30. package/src/components/error/global-error-boundary.tsx +2 -2
  31. package/src/components/forms/app-field.tsx +3 -1
  32. package/src/components/knowledge/general-groups.ts +15 -0
  33. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  34. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  35. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  36. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  37. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  38. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  39. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  40. package/src/components/notifications/settings-notifications.tsx +41 -1
  41. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  42. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  43. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  44. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  45. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  46. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  47. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  48. package/src/components/story/relations/general-groups.ts +1 -15
  49. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  50. package/src/components/story/story-list-view.tsx +7 -1
  51. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  52. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  53. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  54. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  55. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  56. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  57. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  58. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  59. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  60. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  61. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  62. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  63. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  64. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  65. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  66. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  67. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  68. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  69. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  70. package/src/lib/__tests__/pgvector.test.ts +2 -2
  71. package/src/lib/app-version.ts +1 -1
  72. package/src/lib/auth/auth.config.ts +123 -99
  73. package/src/lib/changelog/sdk-versions.ts +21 -21
  74. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  75. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  76. package/src/lib/edition-desktop.ts +22 -0
  77. package/src/lib/fetch-json.ts +23 -0
  78. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  79. package/src/lib/sanitize.ts +14 -1
  80. package/src/lib/session.ts +10 -2
  81. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  82. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  83. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  84. package/src/routeTree.gen.ts +114 -0
  85. package/src/routes/__root.tsx +20 -3
  86. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  87. package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
  88. package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
  89. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  90. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  91. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  92. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  93. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  94. package/src/routes/_login/login/route.lazy.tsx +4 -2
  95. package/src/routes/_login/register/route.lazy.tsx +28 -17
  96. package/src/routes/_login/register/route.tsx +8 -1
  97. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  98. package/src/routes/_login/schemas/-auth.ts +30 -7
  99. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  100. package/src/routes/_login/verify-email/route.tsx +28 -0
  101. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  102. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  103. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  104. package/src/routes/_nav/route.tsx +26 -0
  105. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  106. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  107. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  108. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  109. package/src/routes/_public/index/route.tsx +2 -1
  110. package/src/routes/api/auth/desktop-callback.ts +2 -2
  111. package/src/routes/api/billing-webhook.ts +4 -0
  112. package/src/routes/api/cron/changelog-release.ts +100 -0
  113. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  114. package/src/routes/api.auth.$.ts +3 -5
  115. package/src/server/__tests__/agent-crud.test.ts +4 -4
  116. package/src/server/__tests__/auth-registration.test.ts +60 -0
  117. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  118. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  119. package/src/server/__tests__/permissions.test.ts +1 -3
  120. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  121. package/src/server/__tests__/skill-crud.test.ts +3 -3
  122. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  123. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  124. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  125. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  126. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  127. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  128. package/src/server/ai-gateway/index.ts +5 -0
  129. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  130. package/src/server/ai-gateway/llm-fence.ts +82 -0
  131. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  132. package/src/server/ai-gateway/story-prompt.ts +4 -2
  133. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  134. package/src/server/ai-gateway/tools/server.ts +97 -54
  135. package/src/server/auth-registration.ts +40 -0
  136. package/src/server/billing-email.ts +39 -0
  137. package/src/server/builtin-project.ts +21 -0
  138. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  139. package/src/server/email/__tests__/index.test.ts +32 -0
  140. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  141. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  142. package/src/server/email/__tests__/registry.test.tsx +50 -0
  143. package/src/server/email/deliverability.ts +57 -0
  144. package/src/server/email/drivers/console.ts +11 -0
  145. package/src/server/email/drivers/resend.ts +31 -0
  146. package/src/server/email/drivers/smtp.ts +27 -0
  147. package/src/server/email/index.ts +56 -0
  148. package/src/server/email/outbox.ts +141 -0
  149. package/src/server/email/preferences.ts +20 -0
  150. package/src/server/email/registry.ts +38 -0
  151. package/src/server/email/sender.ts +36 -13
  152. package/src/server/email/templates/_base/button.tsx +12 -0
  153. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  154. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  155. package/src/server/email/templates/_base/theme.ts +22 -0
  156. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  157. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  158. package/src/server/email/templates/auth/welcome.tsx +49 -0
  159. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  160. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  161. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  162. package/src/server/email/templates/notification/mirror.tsx +50 -0
  163. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  164. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  165. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  166. package/src/server/email/templates/team/approved.tsx +53 -0
  167. package/src/server/email/templates/team/invite.tsx +57 -0
  168. package/src/server/fetch-guard.ts +170 -0
  169. package/src/server/middlewares/security-headers.ts +122 -0
  170. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  171. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  172. package/src/server/notifications/notification-service.ts +71 -1
  173. package/src/server/notifications/verify-reminder.ts +55 -0
  174. package/src/server/oauth-config.ts +59 -0
  175. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  176. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  177. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  178. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  179. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  180. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  181. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  182. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  183. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  184. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  185. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  186. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  187. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  188. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  189. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  190. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  191. package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
  192. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  193. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  194. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  195. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  196. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  197. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  198. package/src/server/serverFns/auth/email-verification.ts +132 -0
  199. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  200. package/src/server/serverFns/auth/providers.ts +4 -3
  201. package/src/server/serverFns/auth/register.ts +63 -19
  202. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  203. package/src/server/serverFns/auth/reset-password.ts +1 -1
  204. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  205. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  206. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  207. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  208. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  209. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  210. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  211. package/src/server/serverFns/kb-doc-search.ts +111 -0
  212. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  213. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  214. package/src/server/serverFns/notifications.ts +10 -0
  215. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  216. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  217. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  218. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  219. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  220. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  221. package/src/server/serverFns/story/kb-general.ts +30 -17
  222. package/src/server/serverFns/story/story-assoc.ts +13 -103
  223. package/src/server/serverFns/story/story-crud.ts +2 -5
  224. package/src/server/serverFns/story/story-groups.ts +4 -13
  225. package/src/server/serverFns/team.ts +50 -12
  226. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  227. package/src/server/serverFns/workspace-kb.ts +403 -0
  228. package/src/server/serverFns/workspace.ts +4 -1
  229. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  230. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  231. package/src/shared/ai-cli/adapter.ts +2 -2
  232. package/src/shared/ai-cli/cli-detector.ts +18 -9
  233. package/src/start.ts +2 -1
  234. package/src/styles/globals.css +114 -0
  235. package/src/types/jest-dom-vitest.d.ts +8 -7
  236. package/vite.shared.ts +105 -4
  237. package/public/file.svg +0 -1
  238. package/public/globe.svg +0 -1
  239. package/public/window.svg +0 -1
  240. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  241. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  242. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  243. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  244. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  245. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  246. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@tanstack/react-start', () => ({
4
+ createServerFn: () => {
5
+ const api: any = {
6
+ _validator: undefined,
7
+ validator(schema: any) {
8
+ this._validator = schema
9
+ return this
10
+ },
11
+ handler(fn: Function) {
12
+ return async (args: any) => {
13
+ let data = args?.data
14
+ if (this._validator && data !== undefined) {
15
+ data = this._validator.parse(data)
16
+ }
17
+ return fn({ data })
18
+ }
19
+ },
20
+ }
21
+ return api
22
+ },
23
+ }))
24
+
25
+ const mockUserFindUnique = vi.fn()
26
+ const mockUserUpdate = vi.fn().mockResolvedValue({})
27
+ const mockTokenFindUnique = vi.fn()
28
+ const mockTokenCreate = vi.fn().mockResolvedValue({})
29
+ const mockTokenUpdate = vi.fn().mockResolvedValue({})
30
+ const mockTxRun = vi.fn().mockResolvedValue([])
31
+ const mockSendTemplateEmail = vi.fn().mockResolvedValue({ sent: true })
32
+ const mockBcryptHash = vi.fn().mockResolvedValue('hashed')
33
+
34
+ vi.mock('@allbluecn/database', () => ({
35
+ prisma: {
36
+ user: {
37
+ findUnique: (...a: unknown[]) => mockUserFindUnique(...a),
38
+ update: (...a: unknown[]) => mockUserUpdate(...a),
39
+ },
40
+ passwordResetToken: {
41
+ findUnique: (...a: unknown[]) => mockTokenFindUnique(...a),
42
+ create: (...a: unknown[]) => mockTokenCreate(...a),
43
+ update: (...a: unknown[]) => mockTokenUpdate(...a),
44
+ },
45
+ $transaction: (...a: unknown[]) => mockTxRun(...a),
46
+ },
47
+ }))
48
+
49
+ vi.mock('@/server/email', () => ({
50
+ sendTemplateEmail: (...a: unknown[]) => mockSendTemplateEmail(...a),
51
+ }))
52
+
53
+ vi.mock('@/server/middlewares/rate-limit', () => ({
54
+ checkAuthRateLimit: vi.fn().mockResolvedValue(undefined),
55
+ }))
56
+
57
+ vi.mock('crypto', () => ({
58
+ randomBytes: () => ({ toString: () => 'tok' }),
59
+ default: { randomBytes: () => ({ toString: () => 'tok' }) },
60
+ }))
61
+
62
+ vi.mock('bcryptjs', () => ({
63
+ default: { hash: (...a: unknown[]) => mockBcryptHash(...a) },
64
+ }))
65
+
66
+ import { forgotPasswordFn } from '../auth/forgot-password'
67
+ import { resetPasswordFn } from '../auth/reset-password'
68
+
69
+ const FUTURE = new Date('2027-01-01T00:00:00.000Z')
70
+
71
+ describe('forgotPasswordFn 邮箱归一化', () => {
72
+ beforeEach(() => {
73
+ vi.clearAllMocks()
74
+ mockUserFindUnique.mockResolvedValue(null)
75
+ mockSendTemplateEmail.mockResolvedValue({ sent: true })
76
+ })
77
+
78
+ it('大写邮箱 + 首尾空格 → 查库用小写 trim 值', async () => {
79
+ await forgotPasswordFn({ data: { email: ' Alice@X.com ' } })
80
+ expect(mockUserFindUnique).toHaveBeenCalledWith({
81
+ where: { email: 'alice@x.com' },
82
+ select: { id: true, email: true, locale: true },
83
+ })
84
+ })
85
+
86
+ it('小写邮箱用户存在 → 创建 token 并发送重置邮件', async () => {
87
+ mockUserFindUnique.mockResolvedValue({
88
+ id: 'u1',
89
+ email: 'alice@x.com',
90
+ locale: null,
91
+ })
92
+ await forgotPasswordFn({ data: { email: 'ALICE@X.COM' } })
93
+ expect(mockTokenCreate).toHaveBeenCalledWith({
94
+ data: expect.objectContaining({ token: 'tok', userId: 'u1' }),
95
+ })
96
+ expect(mockSendTemplateEmail).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ key: 'auth.reset-password',
99
+ to: 'alice@x.com',
100
+ locale: 'zh',
101
+ userId: 'u1',
102
+ vars: expect.objectContaining({
103
+ url: expect.stringContaining('/reset-password?token='),
104
+ }),
105
+ }),
106
+ )
107
+ })
108
+
109
+ it('非法邮箱格式 → schema 拒绝', async () => {
110
+ await expect(forgotPasswordFn({ data: { email: 'not-an-email' } })).rejects.toThrow()
111
+ expect(mockUserFindUnique).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('数据库写入失败 → 不发送重置邮件', async () => {
115
+ mockUserFindUnique.mockResolvedValue({
116
+ id: 'u1',
117
+ email: 'alice@x.com',
118
+ locale: null,
119
+ })
120
+ mockTokenCreate.mockRejectedValueOnce(new Error('db down'))
121
+ await forgotPasswordFn({ data: { email: 'alice@x.com' } })
122
+ expect(mockSendTemplateEmail).not.toHaveBeenCalled()
123
+ })
124
+ })
125
+
126
+ describe('resetPasswordFn 密码强度', () => {
127
+ beforeEach(() => {
128
+ vi.clearAllMocks()
129
+ mockTokenFindUnique.mockResolvedValue(null)
130
+ mockTxRun.mockResolvedValue([])
131
+ mockBcryptHash.mockResolvedValue('hashed')
132
+ })
133
+
134
+ it('纯字母密码(缺数字)→ schema 拒绝', async () => {
135
+ await expect(resetPasswordFn({ data: { token: 't', password: 'abcdefgh' } })).rejects.toThrow(
136
+ /数字/,
137
+ )
138
+ expect(mockTxRun).not.toHaveBeenCalled()
139
+ })
140
+
141
+ it('纯数字密码(缺字母)→ schema 拒绝', async () => {
142
+ await expect(resetPasswordFn({ data: { token: 't', password: '12345678' } })).rejects.toThrow(
143
+ /字母/,
144
+ )
145
+ })
146
+
147
+ it('字母+数字密码 → 通过校验并重置', async () => {
148
+ mockTokenFindUnique.mockResolvedValue({
149
+ id: 'rt1',
150
+ userId: 'u1',
151
+ usedAt: null,
152
+ expiresAt: FUTURE,
153
+ })
154
+ await expect(
155
+ resetPasswordFn({ data: { token: 't', password: 'abc12345' } }),
156
+ ).resolves.toEqual({ message: 'Password reset successfully' })
157
+ expect(mockBcryptHash).toHaveBeenCalledWith('abc12345', 12)
158
+ expect(mockTxRun).toHaveBeenCalledTimes(1)
159
+ expect(mockUserUpdate).toHaveBeenCalledWith({
160
+ where: { id: 'u1' },
161
+ data: { password: 'hashed' },
162
+ })
163
+ expect(mockTokenUpdate).toHaveBeenCalledWith({
164
+ where: { id: 'rt1' },
165
+ data: { usedAt: expect.any(Date) },
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,263 @@
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
+ // billing 域覆盖补全:7 个 serverFn(plan/subscription/checkout/portal/invoices/currency)。
20
+ // 关键断言:OSS 无 Stripe 凭据时 createCheckoutSessionFn 走 VITE_APP_URL 回退;locked 用户与
21
+ // 未登录态拒绝;plan 查询按 tier 过滤且断言 Prisma where 形状。mock 骨架同域样板。
22
+ import { beforeEach, describe, expect, it, vi } from "vitest";
23
+
24
+ const M = vi.hoisted(() => ({
25
+ requireAuth: vi.fn(),
26
+ getProService: vi.fn(),
27
+ getEntitlements: vi.fn(),
28
+ getResourceSnapshots: vi.fn(),
29
+ userUpdate: vi.fn(),
30
+ }));
31
+
32
+ const billingHolder = vi.hoisted(() => ({
33
+ current: null as null | {
34
+ getSubscription: ReturnType<typeof vi.fn>;
35
+ listPublicPlans: ReturnType<typeof vi.fn>;
36
+ createCheckoutSession: ReturnType<typeof vi.fn>;
37
+ createCustomerPortalSession: ReturnType<typeof vi.fn>;
38
+ listInvoices: ReturnType<typeof vi.fn>;
39
+ },
40
+ }));
41
+
42
+ vi.mock("@tanstack/react-start", () => ({
43
+ createServerFn: () => {
44
+ const api = {
45
+ _validator: undefined as unknown,
46
+ validator(schema: unknown) {
47
+ this._validator = schema;
48
+ return this;
49
+ },
50
+ handler(fn: (p: { data: unknown }) => Promise<unknown>) {
51
+ const self = this;
52
+ return async (args: { data?: unknown }) => {
53
+ let data = args?.data;
54
+ if (self._validator && data !== undefined) {
55
+ const v = self._validator as { parse?: (d: unknown) => unknown };
56
+ data = v.parse ? v.parse(data) : data;
57
+ }
58
+ return fn({ data });
59
+ };
60
+ },
61
+ };
62
+ return api;
63
+ },
64
+ }));
65
+
66
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
67
+
68
+ vi.mock("@allbluecn/database", () => ({
69
+ prisma: { user: { update: M.userUpdate } },
70
+ Prisma: {},
71
+ }));
72
+
73
+ vi.mock("@/server/pro-services", () => ({
74
+ getProService: (key: string) => (key === "billing" ? billingHolder.current : null),
75
+ }));
76
+
77
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: M.requireAuth }));
78
+
79
+ vi.mock("@/server/entitlements", () => ({ getEntitlements: M.getEntitlements }));
80
+
81
+ vi.mock("@/server/resources", () => ({ getResourceSnapshots: M.getResourceSnapshots }));
82
+
83
+ import {
84
+ getEffectivePlanFn,
85
+ getSubscriptionFn,
86
+ listPublicPlansFn,
87
+ createCheckoutSessionFn,
88
+ createCustomerPortalSessionFn,
89
+ listInvoicesFn,
90
+ updatePreferredCurrencyFn,
91
+ } from "../billing";
92
+ import { BillingUnavailableError } from "@allbluecn/kernel";
93
+
94
+ const U = "u-1";
95
+
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ M.requireAuth.mockResolvedValue({ user: { id: U } });
99
+ M.userUpdate.mockResolvedValue({ id: U });
100
+ billingHolder.current = {
101
+ getSubscription: vi.fn().mockResolvedValue({ plan: "pro" }),
102
+ listPublicPlans: vi.fn().mockResolvedValue([{ id: "pro", name: "Pro" }]),
103
+ createCheckoutSession: vi.fn().mockResolvedValue({ url: "https://checkout/x" }),
104
+ createCustomerPortalSession: vi.fn().mockResolvedValue({ url: "https://portal/x" }),
105
+ listInvoices: vi.fn().mockResolvedValue([{ id: "in-1" }]),
106
+ };
107
+ });
108
+
109
+ describe("getEffectivePlanFn", () => {
110
+ it("未登录拒绝", async () => {
111
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
112
+ await expect(getEffectivePlanFn()).rejects.toThrow("UNAUTHORIZED");
113
+ expect(M.getEntitlements).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("聚合权益与资源快照", async () => {
117
+ M.getEntitlements.mockResolvedValue({
118
+ planName: "Pro",
119
+ enabled: { "billing.checkout": true },
120
+ unavailable: ["sso"],
121
+ source: "database",
122
+ });
123
+ M.getResourceSnapshots.mockResolvedValue({ seats: 10 });
124
+
125
+ await expect(getEffectivePlanFn()).resolves.toEqual({
126
+ planName: "Pro",
127
+ features: { "billing.checkout": true },
128
+ unavailable: ["sso"],
129
+ source: "database",
130
+ resources: { seats: 10 },
131
+ });
132
+ expect(M.getEntitlements).toHaveBeenCalledWith({});
133
+ expect(M.getResourceSnapshots).toHaveBeenCalledWith({});
134
+ });
135
+ });
136
+
137
+ describe("getSubscriptionFn", () => {
138
+ it("未登录拒绝", async () => {
139
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
140
+ await expect(getSubscriptionFn()).rejects.toThrow("UNAUTHORIZED");
141
+ });
142
+
143
+ it("计费模块不可用时抛出 BillingUnavailableError", async () => {
144
+ billingHolder.current = null;
145
+ await expect(getSubscriptionFn()).rejects.toBeInstanceOf(BillingUnavailableError);
146
+ });
147
+
148
+ it("正常返回当前订阅", async () => {
149
+ await expect(getSubscriptionFn()).resolves.toEqual({ plan: "pro" });
150
+ expect(billingHolder.current!.getSubscription).toHaveBeenCalledWith(U);
151
+ });
152
+ });
153
+
154
+ describe("listPublicPlansFn", () => {
155
+ it("币种枚举校验拒绝非法值", async () => {
156
+ const badCurrency = "eur" as unknown as "usd";
157
+ await expect(listPublicPlansFn({ data: { currency: badCurrency } })).rejects.toThrow();
158
+ });
159
+
160
+ it("正常按币种列出公开套餐", async () => {
161
+ await expect(listPublicPlansFn({ data: { currency: "cny" } })).resolves.toEqual([
162
+ { id: "pro", name: "Pro" },
163
+ ]);
164
+ expect(billingHolder.current!.listPublicPlans).toHaveBeenCalledWith("cny");
165
+ });
166
+ });
167
+
168
+ describe("createCheckoutSessionFn", () => {
169
+ it("未登录拒绝", async () => {
170
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
171
+ await expect(createCheckoutSessionFn({ data: { planId: "pro", currency: "usd" } })).rejects.toThrow(
172
+ "UNAUTHORIZED",
173
+ );
174
+ });
175
+
176
+ it("使用 VITE_APP_URL 拼装回调地址并带 14 天试用", async () => {
177
+ const prev = process.env.VITE_APP_URL;
178
+ process.env.VITE_APP_URL = "https://allblue.test";
179
+ try {
180
+ await createCheckoutSessionFn({ data: { planId: "pro", currency: "usd" } });
181
+ } finally {
182
+ if (prev === undefined) delete process.env.VITE_APP_URL;
183
+ else process.env.VITE_APP_URL = prev;
184
+ }
185
+
186
+ expect(billingHolder.current!.createCheckoutSession).toHaveBeenCalledWith({
187
+ userId: U,
188
+ planId: "pro",
189
+ currency: "usd",
190
+ successUrl: "https://allblue.test/settings/billing?checkout=success",
191
+ cancelUrl: "https://allblue.test/settings/billing?checkout=cancel",
192
+ trialDays: 14,
193
+ });
194
+ });
195
+
196
+ it("未配置 VITE_APP_URL 时回退本地地址", async () => {
197
+ const prev = process.env.VITE_APP_URL;
198
+ delete process.env.VITE_APP_URL;
199
+ try {
200
+ await expect(
201
+ createCheckoutSessionFn({ data: { planId: "pro", currency: "cny" } }),
202
+ ).resolves.toEqual({ url: "https://checkout/x" });
203
+ } finally {
204
+ if (prev === undefined) delete process.env.VITE_APP_URL;
205
+ else process.env.VITE_APP_URL = prev;
206
+ }
207
+
208
+ expect(billingHolder.current!.createCheckoutSession).toHaveBeenCalledWith(
209
+ expect.objectContaining({
210
+ successUrl: "http://localhost:3000/settings/billing?checkout=success",
211
+ }),
212
+ );
213
+ });
214
+ });
215
+
216
+ describe("createCustomerPortalSessionFn", () => {
217
+ it("未登录拒绝", async () => {
218
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
219
+ await expect(createCustomerPortalSessionFn({ data: {} })).rejects.toThrow("UNAUTHORIZED");
220
+ });
221
+
222
+ it("正常创建客户门户会话", async () => {
223
+ await expect(createCustomerPortalSessionFn({ data: {} })).resolves.toEqual({ url: "https://portal/x" });
224
+ expect(billingHolder.current!.createCustomerPortalSession).toHaveBeenCalledWith({
225
+ userId: U,
226
+ returnUrl: "http://localhost:3000/settings/billing",
227
+ });
228
+ });
229
+ });
230
+
231
+ describe("listInvoicesFn", () => {
232
+ it("未登录拒绝", async () => {
233
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
234
+ await expect(listInvoicesFn()).rejects.toThrow("UNAUTHORIZED");
235
+ });
236
+
237
+ it("按默认条数返回账单列表", async () => {
238
+ await expect(listInvoicesFn()).resolves.toEqual([{ id: "in-1" }]);
239
+ expect(billingHolder.current!.listInvoices).toHaveBeenCalledWith(U, 20);
240
+ });
241
+ });
242
+
243
+ describe("updatePreferredCurrencyFn", () => {
244
+ it("未登录拒绝", async () => {
245
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
246
+ await expect(updatePreferredCurrencyFn({ data: { currency: "usd" } })).rejects.toThrow("UNAUTHORIZED");
247
+ expect(M.userUpdate).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it("币种枚举校验拒绝非法值", async () => {
251
+ const badCurrency = "eur" as unknown as "usd";
252
+ await expect(updatePreferredCurrencyFn({ data: { currency: badCurrency } })).rejects.toThrow();
253
+ expect(M.userUpdate).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it("正常更新用户偏好币种", async () => {
257
+ await expect(updatePreferredCurrencyFn({ data: { currency: "cny" } })).resolves.toBeUndefined();
258
+ expect(M.userUpdate).toHaveBeenCalledWith({
259
+ where: { id: U },
260
+ data: { preferredCurrency: "cny" },
261
+ });
262
+ });
263
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@tanstack/react-start', () => ({
4
+ createServerFn: (config: any) => {
5
+ const api: any = {
6
+ _validator: undefined,
7
+ validator(schema: any) {
8
+ this._validator = schema
9
+ return this
10
+ },
11
+ handler(fn: Function) {
12
+ const wrapped = async (args: any) => {
13
+ let data = args?.data
14
+ if (this._validator && data !== undefined) {
15
+ data = this._validator.parse ? this._validator.parse(data) : data
16
+ }
17
+ return fn({ data })
18
+ }
19
+ return wrapped
20
+ },
21
+ }
22
+ return api
23
+ },
24
+ }))
25
+
26
+ vi.mock('@tanstack/react-start/server', () => ({
27
+ getRequest: vi.fn(),
28
+ }))
29
+
30
+ const mockFindUniqueToken = vi.fn()
31
+ const mockTxTokenUpdateMany = vi.fn()
32
+ const mockTxUserUpdate = vi.fn()
33
+ const mockUserFindUnique = vi.fn()
34
+
35
+ vi.mock('@allbluecn/database', () => ({
36
+ prisma: {
37
+ emailVerificationToken: {
38
+ findUnique: (...a: unknown[]) => mockFindUniqueToken(...a),
39
+ updateMany: (...a: unknown[]) => mockTxTokenUpdateMany(...a),
40
+ },
41
+ user: {
42
+ findUnique: (...a: unknown[]) => mockUserFindUnique(...a),
43
+ update: (...a: unknown[]) => mockTxUserUpdate(...a),
44
+ },
45
+ $transaction: (fn: (tx: unknown) => Promise<unknown>) =>
46
+ fn({
47
+ emailVerificationToken: {
48
+ updateMany: (...a: unknown[]) => mockTxTokenUpdateMany(...a),
49
+ },
50
+ user: { update: (...a: unknown[]) => mockTxUserUpdate(...a) },
51
+ }),
52
+ },
53
+ }))
54
+
55
+ vi.mock('@/server/email', () => ({
56
+ sendTemplateEmail: vi.fn().mockResolvedValue({ sent: true }),
57
+ generateEmailToken: vi.fn().mockReturnValue('tok-123'),
58
+ }))
59
+
60
+ vi.mock('@/server/middlewares/rate-limit', () => ({
61
+ checkAuthRateLimit: vi.fn().mockResolvedValue(undefined),
62
+ }))
63
+
64
+ vi.mock('@/lib/session', () => ({
65
+ getCurrentSession: vi.fn().mockResolvedValue(null),
66
+ }))
67
+
68
+ import { confirmEmailVerificationFn } from '../auth/email-verification'
69
+
70
+ const NOW = new Date('2026-09-12T00:00:00.000Z')
71
+ const FUTURE = new Date('2027-01-01T00:00:00.000Z')
72
+
73
+ describe('confirmEmailVerificationFn', () => {
74
+ beforeEach(() => {
75
+ vi.clearAllMocks()
76
+ mockFindUniqueToken.mockImplementation(async () => null)
77
+ mockTxTokenUpdateMany.mockResolvedValue({ count: 0 })
78
+ mockTxUserUpdate.mockResolvedValue({})
79
+ mockUserFindUnique.mockResolvedValue(null)
80
+ })
81
+
82
+ it('token 不存在 → ok:false', async () => {
83
+ mockFindUniqueToken.mockResolvedValue(null)
84
+ await expect(
85
+ confirmEmailVerificationFn({ data: { token: 'nope' } }),
86
+ ).resolves.toEqual({ ok: false })
87
+ expect(mockTxUserUpdate).not.toHaveBeenCalled()
88
+ })
89
+
90
+ it('token 已使用 → ok:false', async () => {
91
+ mockFindUniqueToken.mockResolvedValue({
92
+ id: 't1',
93
+ userId: 'u1',
94
+ usedAt: NOW,
95
+ expiresAt: FUTURE,
96
+ })
97
+ await expect(
98
+ confirmEmailVerificationFn({ data: { token: 'used' } }),
99
+ ).resolves.toEqual({ ok: false })
100
+ expect(mockTxTokenUpdateMany).not.toHaveBeenCalled()
101
+ })
102
+
103
+ it('token 已过期 → ok:false', async () => {
104
+ mockFindUniqueToken.mockResolvedValue({
105
+ id: 't1',
106
+ userId: 'u1',
107
+ usedAt: null,
108
+ expiresAt: new Date('2020-01-01'),
109
+ })
110
+ await expect(
111
+ confirmEmailVerificationFn({ data: { token: 'exp' } }),
112
+ ).resolves.toEqual({ ok: false })
113
+ })
114
+
115
+ it('首次验证:抢占成功 → 标记已用 + 用户已验证 → ok:true', async () => {
116
+ mockFindUniqueToken.mockResolvedValue({
117
+ id: 't1',
118
+ userId: 'u1',
119
+ usedAt: null,
120
+ expiresAt: FUTURE,
121
+ })
122
+ mockTxTokenUpdateMany.mockResolvedValue({ count: 1 })
123
+ await expect(
124
+ confirmEmailVerificationFn({ data: { token: 'good' } }),
125
+ ).resolves.toEqual({ ok: true })
126
+ expect(mockTxTokenUpdateMany).toHaveBeenCalledWith({
127
+ where: { id: 't1', usedAt: null },
128
+ data: expect.objectContaining({ usedAt: expect.any(Date) }),
129
+ })
130
+ expect(mockTxUserUpdate).toHaveBeenCalledWith({
131
+ where: { id: 'u1' },
132
+ data: expect.objectContaining({ emailVerified: expect.any(Date) }),
133
+ })
134
+ })
135
+
136
+ it('并发第二个请求:抢占失败(count=0)→ ok:false 且不更新用户', async () => {
137
+ mockFindUniqueToken.mockResolvedValue({
138
+ id: 't1',
139
+ userId: 'u1',
140
+ usedAt: null,
141
+ expiresAt: FUTURE,
142
+ })
143
+ mockTxTokenUpdateMany.mockResolvedValue({ count: 0 })
144
+ await expect(
145
+ confirmEmailVerificationFn({ data: { token: 'race' } }),
146
+ ).resolves.toEqual({ ok: false })
147
+ expect(mockTxUserUpdate).not.toHaveBeenCalled()
148
+ })
149
+ })