@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.
- package/package.json +26 -11
- package/public/favicon.ico +0 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-secondary.tsx +57 -47
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/mdx.tsx +9 -0
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/fetch-json.ts +23 -0
- package/src/lib/fumadocs-i18n.ts +7 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/layout.shared.tsx +40 -0
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +9 -1
- package/src/lib/source.ts +15 -0
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/$lang/docs/$/route.lazy.tsx +62 -0
- package/src/routes/$lang/docs/$/route.tsx +52 -0
- package/src/routes/-tests/docs-redirect.test.ts +45 -0
- package/src/routes/__root.tsx +13 -7
- package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
- package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +21 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/search.ts +13 -0
- package/src/routes/docs.tsx +9 -0
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/sender.ts +8 -2
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +113 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +1 -1
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +116 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +18 -1
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
19
|
+
|
|
20
|
+
const PROD_CSP = [
|
|
21
|
+
"default-src 'self'",
|
|
22
|
+
"script-src 'self'",
|
|
23
|
+
"style-src 'self' 'unsafe-inline'",
|
|
24
|
+
"img-src 'self' data: blob: https:",
|
|
25
|
+
"font-src 'self' data:",
|
|
26
|
+
"connect-src 'self' https: wss:",
|
|
27
|
+
"media-src 'self' blob: https:",
|
|
28
|
+
"object-src 'none'",
|
|
29
|
+
"base-uri 'self'",
|
|
30
|
+
"form-action 'self'",
|
|
31
|
+
"child-src 'none'",
|
|
32
|
+
"frame-src 'none'",
|
|
33
|
+
"worker-src 'self' blob:",
|
|
34
|
+
"frame-ancestors 'none'",
|
|
35
|
+
"upgrade-insecure-requests",
|
|
36
|
+
].join('; ')
|
|
37
|
+
|
|
38
|
+
const DEV_CSP = [
|
|
39
|
+
"default-src 'self' 'unsafe-inline' data: blob:",
|
|
40
|
+
"script-src 'self' 'unsafe-inline' data: blob:",
|
|
41
|
+
"style-src 'self' 'unsafe-inline'",
|
|
42
|
+
"img-src 'self' data: blob: https: http:",
|
|
43
|
+
"font-src 'self' data:",
|
|
44
|
+
"connect-src 'self' ws: wss: http: https:",
|
|
45
|
+
"media-src 'self' blob: https: http:",
|
|
46
|
+
"object-src 'none'",
|
|
47
|
+
"base-uri 'self'",
|
|
48
|
+
"form-action 'self'",
|
|
49
|
+
"child-src 'none'",
|
|
50
|
+
"frame-src 'none'",
|
|
51
|
+
"worker-src 'self' blob:",
|
|
52
|
+
"frame-ancestors 'none'",
|
|
53
|
+
].join('; ')
|
|
54
|
+
|
|
55
|
+
export function buildSecurityHeaders(prod: boolean): Record<string, string> {
|
|
56
|
+
const headers: Record<string, string> = {
|
|
57
|
+
'X-Content-Type-Options': 'nosniff',
|
|
58
|
+
'X-Frame-Options': 'DENY',
|
|
59
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
60
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
61
|
+
'Permissions-Policy':
|
|
62
|
+
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)',
|
|
63
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
64
|
+
'Content-Security-Policy': prod ? PROD_CSP : DEV_CSP,
|
|
65
|
+
}
|
|
66
|
+
if (prod) {
|
|
67
|
+
headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload'
|
|
68
|
+
}
|
|
69
|
+
return headers
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mergeSecurityHeaders(source: Headers, headers: Record<string, string>): Headers {
|
|
73
|
+
const entries = Object.entries(headers)
|
|
74
|
+
try {
|
|
75
|
+
for (const [name, value] of entries) source.set(name, value)
|
|
76
|
+
return source
|
|
77
|
+
} catch {
|
|
78
|
+
// @auth/core 响应(登录回调成功/失败路径)的 headers 为 immutable guard,
|
|
79
|
+
// 原地 set 抛 TypeError: immutable → 未捕获异常 → 登录 500。
|
|
80
|
+
const merged = new Headers(source)
|
|
81
|
+
for (const [name, value] of entries) merged.set(name, value)
|
|
82
|
+
return merged
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function withSecurityHeaders(result: Response, headers: Record<string, string>): Response {
|
|
87
|
+
const merged = mergeSecurityHeaders(result.headers, headers)
|
|
88
|
+
if (merged === result.headers) return result
|
|
89
|
+
return new Response(result.body, {
|
|
90
|
+
status: result.status,
|
|
91
|
+
statusText: result.statusText,
|
|
92
|
+
headers: merged,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const securityHeadersMiddleware = createMiddleware().server(async ({ next }: any) => {
|
|
97
|
+
const headers = buildSecurityHeaders(process.env.NODE_ENV === 'production')
|
|
98
|
+
const result = await next() as any
|
|
99
|
+
|
|
100
|
+
if (result instanceof Response) return withSecurityHeaders(result, headers)
|
|
101
|
+
|
|
102
|
+
const carrier: any = result?.response || result
|
|
103
|
+
const target: Headers | undefined = carrier?.headers
|
|
104
|
+
if (!target) return result
|
|
105
|
+
|
|
106
|
+
const merged = mergeSecurityHeaders(target, headers)
|
|
107
|
+
if (merged === target) return result
|
|
108
|
+
return new Response(result?.body ?? null, {
|
|
109
|
+
status: result?.status ?? 200,
|
|
110
|
+
statusText: result?.statusText,
|
|
111
|
+
headers: merged,
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -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,239 @@
|
|
|
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
|
+
// link-preview 域覆盖补全:checkLinkEmbeddableFn 外链嵌入校验。
|
|
20
|
+
// 关键断言:非法/空 URL 与 SSRF 主机拒绝时零 HTTP 请求(fetch 未触达);
|
|
21
|
+
// 白名单域名放行并解析 title/description。fetch 用 vi.fn 替代真实 HTTP。
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
+
|
|
24
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
25
|
+
createServerFn: (_config: unknown) => {
|
|
26
|
+
const api: any = {
|
|
27
|
+
_validator: undefined,
|
|
28
|
+
validator(schema: any) {
|
|
29
|
+
this._validator = schema;
|
|
30
|
+
return this;
|
|
31
|
+
},
|
|
32
|
+
handler(fn: Function) {
|
|
33
|
+
return async (args: any) => {
|
|
34
|
+
let data = args?.data;
|
|
35
|
+
if (this._validator && data !== undefined) {
|
|
36
|
+
data = this._validator.parse(data);
|
|
37
|
+
}
|
|
38
|
+
return fn({ data });
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return api;
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
|
|
46
|
+
|
|
47
|
+
const M = vi.hoisted(() => ({ requireAuth: vi.fn() }));
|
|
48
|
+
|
|
49
|
+
vi.mock("@/server/auth-helpers", () => ({ requireAuth: M.requireAuth }));
|
|
50
|
+
|
|
51
|
+
import { checkLinkEmbeddableFn } from "../story/link-preview";
|
|
52
|
+
|
|
53
|
+
const APP_ORIGIN = "https://app.allblue.cn";
|
|
54
|
+
const URL_OK = "https://docs.example.com/page";
|
|
55
|
+
|
|
56
|
+
type FetchMock = ReturnType<typeof vi.fn>;
|
|
57
|
+
let fetchMock: FetchMock;
|
|
58
|
+
|
|
59
|
+
/** 构造 fetch 响应 mock(含可取消 body) */
|
|
60
|
+
const jsonResponse = (headers: Record<string, string> = {}) => {
|
|
61
|
+
const body = { cancel: vi.fn().mockResolvedValue(undefined) };
|
|
62
|
+
return {
|
|
63
|
+
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
|
|
64
|
+
body,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
M.requireAuth.mockResolvedValue({ user: { id: "u1" } });
|
|
71
|
+
fetchMock = vi.fn().mockResolvedValue(jsonResponse());
|
|
72
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
73
|
+
// jsdom 环境缺 AbortSignal.timeout(Node 17.3+ 才有),补一个等价实现
|
|
74
|
+
if (!AbortSignal.timeout) {
|
|
75
|
+
vi.stubGlobal("AbortSignal", { timeout: (_ms: number) => new AbortController().signal });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.unstubAllGlobals();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("checkLinkEmbeddableFn", () => {
|
|
84
|
+
it("未登录直接拒绝,且不发起任何网络请求", async () => {
|
|
85
|
+
M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
|
|
86
|
+
await expect(
|
|
87
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK } }),
|
|
88
|
+
).rejects.toThrow("UNAUTHORIZED");
|
|
89
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
["非 http/https 协议", "ftp://docs.example.com/page"],
|
|
94
|
+
["localhost", "http://localhost:8000/admin"],
|
|
95
|
+
["IPv6 回环", "http://[::1]/admin"],
|
|
96
|
+
["A 类内网 10.x", "http://10.0.0.8/x"],
|
|
97
|
+
["回环 127.x", "http://127.0.0.1/x"],
|
|
98
|
+
["链路本地 169.254.x", "http://169.254.10.10/x"],
|
|
99
|
+
["C 类内网 192.168.x", "http://192.168.1.1/x"],
|
|
100
|
+
["B 类内网 172.16.x", "http://172.20.0.1/x"],
|
|
101
|
+
["零网段", "http://0.0.0.0/x"],
|
|
102
|
+
["组播 224+", "http://239.1.1.1/x"],
|
|
103
|
+
["本地域 .local", "http://host.local/x"],
|
|
104
|
+
["内部域 .internal", "http://api.internal/x"],
|
|
105
|
+
] as Array<[string, string]>)(
|
|
106
|
+
"%s 被 SSRF 防护拒绝(不发网络请求)",
|
|
107
|
+
async (_name, url) => {
|
|
108
|
+
const res = await checkLinkEmbeddableFn({ data: { url } });
|
|
109
|
+
expect(res).toEqual({ embeddable: false, reason: "invalid" });
|
|
110
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
it("合法公网 URL 且响应允许嵌入时返回 embeddable", async () => {
|
|
115
|
+
const response = jsonResponse({ "content-type": "text/html" });
|
|
116
|
+
fetchMock.mockResolvedValue(response);
|
|
117
|
+
|
|
118
|
+
const res = await checkLinkEmbeddableFn({ data: { url: URL_OK } });
|
|
119
|
+
|
|
120
|
+
expect(res).toEqual({ embeddable: true });
|
|
121
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
122
|
+
const [calledUrl, init] = fetchMock.mock.calls[0];
|
|
123
|
+
expect(calledUrl).toBe(URL_OK);
|
|
124
|
+
expect(init).toMatchObject({ method: "GET", redirect: "follow" });
|
|
125
|
+
expect(init.headers["user-agent"]).toContain("AllBlueEmbedCheck");
|
|
126
|
+
// 预检无需读取正文,读完响应头即释放 body
|
|
127
|
+
expect(response.body.cancel).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("PDF 内容类型也允许嵌入", async () => {
|
|
131
|
+
fetchMock.mockResolvedValue(jsonResponse({ "content-type": "application/pdf" }));
|
|
132
|
+
await expect(
|
|
133
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK } }),
|
|
134
|
+
).resolves.toEqual({ embeddable: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("非 HTML/PDF 内容类型返回 not-html", async () => {
|
|
138
|
+
fetchMock.mockResolvedValue(jsonResponse({ "content-type": "application/json" }));
|
|
139
|
+
await expect(
|
|
140
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK } }),
|
|
141
|
+
).resolves.toEqual({ embeddable: false, reason: "not-html" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it.each([
|
|
145
|
+
["X-Frame-Options: DENY", { "x-frame-options": "DENY" }],
|
|
146
|
+
["X-Frame-Options: SAMEORIGIN", { "x-frame-options": "SAMEORIGIN" }],
|
|
147
|
+
])("%s 被拒", async (_name, headers) => {
|
|
148
|
+
fetchMock.mockResolvedValue(jsonResponse({ "content-type": "text/html", ...headers }));
|
|
149
|
+
await expect(
|
|
150
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK } }),
|
|
151
|
+
).resolves.toEqual({ embeddable: false, reason: "blocked" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("CSP frame-ancestors 'none' 被拒", async () => {
|
|
155
|
+
fetchMock.mockResolvedValue(
|
|
156
|
+
jsonResponse({
|
|
157
|
+
"content-type": "text/html",
|
|
158
|
+
"content-security-policy": "default-src 'self'; frame-ancestors 'none'",
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
await expect(
|
|
162
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK, origin: APP_ORIGIN } }),
|
|
163
|
+
).resolves.toEqual({ embeddable: false, reason: "blocked" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("CSP frame-ancestors 为 * 时放行", async () => {
|
|
167
|
+
fetchMock.mockResolvedValue(
|
|
168
|
+
jsonResponse({
|
|
169
|
+
"content-type": "text/html",
|
|
170
|
+
"content-security-policy": "frame-ancestors *",
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
await expect(
|
|
174
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK, origin: APP_ORIGIN } }),
|
|
175
|
+
).resolves.toEqual({ embeddable: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("CSP frame-ancestors 显式命中应用域名时放行", async () => {
|
|
179
|
+
fetchMock.mockResolvedValue(
|
|
180
|
+
jsonResponse({
|
|
181
|
+
"content-type": "text/html",
|
|
182
|
+
"content-security-policy": "frame-ancestors https://app.allblue.cn https://other.example.com",
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
await expect(
|
|
186
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK, origin: APP_ORIGIN } }),
|
|
187
|
+
).resolves.toEqual({ embeddable: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("CSP frame-ancestors 未命中应用域名时拒绝", async () => {
|
|
191
|
+
fetchMock.mockResolvedValue(
|
|
192
|
+
jsonResponse({
|
|
193
|
+
"content-type": "text/html",
|
|
194
|
+
"content-security-policy": "frame-ancestors https://other.example.com",
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
await expect(
|
|
198
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK, origin: APP_ORIGIN } }),
|
|
199
|
+
).resolves.toEqual({ embeddable: false, reason: "blocked" });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("CSP 无 frame-ancestors 指令时视为放行", async () => {
|
|
203
|
+
fetchMock.mockResolvedValue(
|
|
204
|
+
jsonResponse({
|
|
205
|
+
"content-type": "text/html",
|
|
206
|
+
"content-security-policy": "default-src 'self'",
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
await expect(
|
|
210
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK, origin: APP_ORIGIN } }),
|
|
211
|
+
).resolves.toEqual({ embeddable: true });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("目标与应用同源时 'self' 视为允许", async () => {
|
|
215
|
+
fetchMock.mockResolvedValue(
|
|
216
|
+
jsonResponse({
|
|
217
|
+
"content-type": "text/html",
|
|
218
|
+
"content-security-policy": "frame-ancestors 'self'",
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
await expect(
|
|
222
|
+
checkLinkEmbeddableFn({
|
|
223
|
+
data: { url: "https://app.allblue.cn/embed/page", origin: APP_ORIGIN },
|
|
224
|
+
}),
|
|
225
|
+
).resolves.toEqual({ embeddable: true });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("预检超时/网络异常时降级为可嵌入(交由 iframe 实际尝试)", async () => {
|
|
229
|
+
fetchMock.mockRejectedValue(new Error("timeout"));
|
|
230
|
+
await expect(
|
|
231
|
+
checkLinkEmbeddableFn({ data: { url: URL_OK } }),
|
|
232
|
+
).resolves.toEqual({ embeddable: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("validator 拒绝非法 URL(不是合法 URL 格式)", async () => {
|
|
236
|
+
await expect(checkLinkEmbeddableFn({ data: { url: "not-a-url" } })).rejects.toThrow();
|
|
237
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -84,7 +84,7 @@ describe("setProjectKbsFn 校验", () => {
|
|
|
84
84
|
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]); // 请求 [kb1(team), kb2(general)] 但只查回 kb1
|
|
85
85
|
await expect(
|
|
86
86
|
setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } }),
|
|
87
|
-
).rejects.
|
|
87
|
+
).rejects.toThrow("仅允许团队可见");
|
|
88
88
|
expect(mocks.transaction).not.toHaveBeenCalled();
|
|
89
89
|
});
|
|
90
90
|
|
|
@@ -117,14 +117,14 @@ describe("setKbProjectsFn 校验", () => {
|
|
|
117
117
|
mocks.kbFindFirst.mockResolvedValue({ id: "kbG", visibility: "general" });
|
|
118
118
|
await expect(
|
|
119
119
|
setKbProjectsFn({ data: { kbId: "kbG", projectIds: ["p1"] } }),
|
|
120
|
-
).rejects.
|
|
120
|
+
).rejects.toThrow("通用知识库");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
it("private KB 拒绝关联", async () => {
|
|
124
124
|
mocks.kbFindFirst.mockResolvedValue({ id: "kbP", visibility: "private" });
|
|
125
125
|
await expect(
|
|
126
126
|
setKbProjectsFn({ data: { kbId: "kbP", projectIds: ["p1"] } }),
|
|
127
|
-
).rejects.
|
|
127
|
+
).rejects.toThrow("私有知识库");
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
130
|
|
|
@@ -65,7 +65,7 @@ describe('addProjectMemberFromTeamFn', () => {
|
|
|
65
65
|
vi.mocked(prisma.project.findUnique).mockResolvedValue({ id: 'p1', ownerId: 'owner1', workspaceId: 'ws1' } as never)
|
|
66
66
|
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
67
67
|
await expect(addProjectMemberFromTeamFn({ data: { projectId: 'p1', userId: 'u9' } }))
|
|
68
|
-
.rejects.
|
|
68
|
+
.rejects.toThrow('团队')
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
it('guest 强制 viewer 角色', async () => {
|