@allbluecn/admin-app 0.2.3 → 0.2.4
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 +13 -8
- package/src/__tests__/components/provider-icon.test.tsx +59 -0
- package/src/__tests__/setup.ts +35 -0
- package/src/server/__tests__/admin-billing.test.ts +182 -0
- package/src/server/__tests__/compliance.test.ts +136 -0
- package/src/server/__tests__/permissions.test.ts +301 -0
- package/src/server/middlewares/security-headers.ts +113 -0
- package/src/server/serverFns/components/import.ts +3 -3
- package/src/start.ts +2 -1
- package/src/types/jest-dom-vitest.d.ts +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/admin-app",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"class-variance-authority": "^0.7.1",
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"date-fns": "^4.4.0",
|
|
33
|
-
"lucide-react": "^1.
|
|
33
|
+
"lucide-react": "^1.43.0",
|
|
34
34
|
"motion": "^12.42.2",
|
|
35
35
|
"next-themes": "^0.4.6",
|
|
36
36
|
"radix-ui": "^1.4.3",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"start-authjs": "^1.0.2",
|
|
43
43
|
"tailwind-merge": "^3.5.0",
|
|
44
44
|
"zod": "^4.4.3",
|
|
45
|
-
"@allbluecn/database": "0.
|
|
46
|
-
"@allbluecn/kernel": "0.6.
|
|
47
|
-
"@allbluecn/web-app": "0.
|
|
45
|
+
"@allbluecn/database": "0.6.0",
|
|
46
|
+
"@allbluecn/kernel": "0.6.1",
|
|
47
|
+
"@allbluecn/web-app": "0.11.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@cloudflare/vite-plugin": "^1.40.1",
|
|
@@ -54,19 +54,22 @@
|
|
|
54
54
|
"@tailwindcss/vite": "^4.3.2",
|
|
55
55
|
"@tanstack/react-query-devtools": "5.102.8",
|
|
56
56
|
"@tanstack/react-router-devtools": "1.167.1",
|
|
57
|
+
"@testing-library/jest-dom": "7.0.1",
|
|
58
|
+
"@testing-library/react": "^16.3.2",
|
|
57
59
|
"@types/bcryptjs": "^2.4.6",
|
|
58
60
|
"@types/node": "^24.13.3",
|
|
59
61
|
"@types/react": "^19",
|
|
60
62
|
"@types/react-dom": "^19",
|
|
61
63
|
"@vitejs/plugin-react": "^6.0.1",
|
|
62
|
-
"@vitest/coverage-v8": "
|
|
64
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
63
65
|
"eslint": "^9",
|
|
66
|
+
"jsdom": "^29.1.1",
|
|
64
67
|
"nitro": "3.0.260903-beta",
|
|
65
68
|
"shadcn": "^4.15.0",
|
|
66
69
|
"tailwindcss": "^4",
|
|
67
70
|
"typescript": "^7",
|
|
68
71
|
"vite": "^8.1.5",
|
|
69
|
-
"vitest": "
|
|
72
|
+
"vitest": "5.0.0",
|
|
70
73
|
"wrangler": "^4.99.0"
|
|
71
74
|
},
|
|
72
75
|
"files": [
|
|
@@ -81,6 +84,8 @@
|
|
|
81
84
|
"build": "node scripts/i18n/compile.mjs && vite build && if [ -d .output/server ]; then cp instrument.server.mjs .output/server; fi",
|
|
82
85
|
"start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs",
|
|
83
86
|
"lint": "eslint",
|
|
84
|
-
"typecheck": "node scripts/i18n/compile.mjs && tsc --noEmit"
|
|
87
|
+
"typecheck": "node scripts/i18n/compile.mjs && tsc --noEmit",
|
|
88
|
+
"test": "vitest",
|
|
89
|
+
"test:run": "vitest run"
|
|
85
90
|
}
|
|
86
91
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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 { describe, it, expect, vi } from 'vitest'
|
|
19
|
+
import { render } from '@testing-library/react'
|
|
20
|
+
|
|
21
|
+
// @lobehub/icons 是数百个品牌图标的聚合包,dynamic import 会拖慢 vitest 且
|
|
22
|
+
// 测试目标是 fallback 渲染路径;mock 为空对象使 mod[lobeName] 为 undefined,
|
|
23
|
+
// ProviderLogo 走 InitialFallback 分支(与"平台下架的 provider"真实行为一致)。
|
|
24
|
+
vi.mock('@lobehub/icons', () => ({}))
|
|
25
|
+
|
|
26
|
+
import { ProviderIcon } from '../../components/ai-provider/provider-icon'
|
|
27
|
+
|
|
28
|
+
describe('ProviderIcon', () => {
|
|
29
|
+
it('按 size 设置容器内联宽高', () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<ProviderIcon
|
|
32
|
+
provider={{ providerId: 'deprecated-vendor', brandName: 'Deprecated Vendor' }}
|
|
33
|
+
size={56}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
const box = container.querySelector('div')
|
|
37
|
+
expect(box).not.toBeNull()
|
|
38
|
+
expect(box).toHaveStyle({ width: '56px', height: '56px' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('未知 providerId 时回落到品牌名首字母', () => {
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<ProviderIcon
|
|
44
|
+
provider={{ providerId: 'deprecated-vendor', brandName: 'Deprecated Vendor' }}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
expect(container.textContent).toContain('D')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('透传 className 到容器', () => {
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<ProviderIcon
|
|
53
|
+
provider={{ providerId: 'deprecated-vendor', brandName: 'Deprecated Vendor' }}
|
|
54
|
+
className="mt-4"
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
expect(container.querySelector('div')).toHaveClass('mt-4')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
/// <reference types="vitest/globals" />
|
|
19
|
+
import '@testing-library/jest-dom/vitest'
|
|
20
|
+
|
|
21
|
+
import { overwriteGetLocale } from '@/paraglide/runtime.js'
|
|
22
|
+
|
|
23
|
+
// jsdom 无 ResizeObserver(@tanstack/charts、虚拟滚动等库模块加载时引用)
|
|
24
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
25
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
26
|
+
observe() {}
|
|
27
|
+
unobserve() {}
|
|
28
|
+
disconnect() {}
|
|
29
|
+
} as unknown as typeof globalThis.ResizeObserver
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
// jsdom 的 navigator.language 为 en-US,会导致中文文案断言失败
|
|
34
|
+
overwriteGetLocale(() => 'zh')
|
|
35
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
19
|
+
|
|
20
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
21
|
+
createServerFn: (_config: unknown) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema
|
|
26
|
+
return this
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data
|
|
33
|
+
}
|
|
34
|
+
return fn({ data })
|
|
35
|
+
}
|
|
36
|
+
return wrapped
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
return api
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
vi.mock('@allbluecn/database', () => ({
|
|
48
|
+
prisma: {
|
|
49
|
+
subscription: {
|
|
50
|
+
findMany: vi.fn(),
|
|
51
|
+
count: vi.fn(),
|
|
52
|
+
findUnique: vi.fn(),
|
|
53
|
+
},
|
|
54
|
+
invoice: {
|
|
55
|
+
findMany: vi.fn(),
|
|
56
|
+
count: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
62
|
+
requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
import { prisma } from '@allbluecn/database'
|
|
66
|
+
import { requireAdmin } from '@/server/auth-helpers'
|
|
67
|
+
import {
|
|
68
|
+
listAllSubscriptionsFn,
|
|
69
|
+
getSubscriptionDetailFn,
|
|
70
|
+
listAllInvoicesFn,
|
|
71
|
+
} from '../serverFns/admin-billing'
|
|
72
|
+
|
|
73
|
+
const subscription = prisma.subscription as unknown as {
|
|
74
|
+
findMany: ReturnType<typeof vi.fn>
|
|
75
|
+
count: ReturnType<typeof vi.fn>
|
|
76
|
+
findUnique: ReturnType<typeof vi.fn>
|
|
77
|
+
}
|
|
78
|
+
const invoice = prisma.invoice as unknown as {
|
|
79
|
+
findMany: ReturnType<typeof vi.fn>
|
|
80
|
+
count: ReturnType<typeof vi.fn>
|
|
81
|
+
}
|
|
82
|
+
const requireAdminMock = requireAdmin as unknown as ReturnType<typeof vi.fn>
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.clearAllMocks()
|
|
86
|
+
requireAdminMock.mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } })
|
|
87
|
+
subscription.findMany.mockResolvedValue([])
|
|
88
|
+
subscription.count.mockResolvedValue(0)
|
|
89
|
+
invoice.findMany.mockResolvedValue([])
|
|
90
|
+
invoice.count.mockResolvedValue(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('listAllSubscriptionsFn', () => {
|
|
94
|
+
it('非管理员拒绝:requireAdmin 抛错时零 prisma 调用', async () => {
|
|
95
|
+
requireAdminMock.mockRejectedValue(new Error('not admin'))
|
|
96
|
+
|
|
97
|
+
await expect(listAllSubscriptionsFn({ data: {} })).rejects.toThrow('not admin')
|
|
98
|
+
expect(subscription.findMany).not.toHaveBeenCalled()
|
|
99
|
+
expect(subscription.count).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('默认分页:page=1 pageSize=20 时 skip=0 take=20', async () => {
|
|
103
|
+
await listAllSubscriptionsFn({ data: {} })
|
|
104
|
+
|
|
105
|
+
expect(subscription.findMany).toHaveBeenCalledWith({
|
|
106
|
+
where: {},
|
|
107
|
+
include: { plan: true, workspace: true },
|
|
108
|
+
orderBy: { createdAt: 'desc' },
|
|
109
|
+
skip: 0,
|
|
110
|
+
take: 20,
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('search 按工作空间名模糊匹配(管理员全局检索入口)', async () => {
|
|
115
|
+
await listAllSubscriptionsFn({ data: { search: 'acme' } })
|
|
116
|
+
|
|
117
|
+
const arg = subscription.findMany.mock.calls[0][0]
|
|
118
|
+
expect(arg.where.OR).toEqual([{ workspace: { name: { contains: 'acme' } } }])
|
|
119
|
+
expect(subscription.count).toHaveBeenCalledWith({
|
|
120
|
+
where: { OR: [{ workspace: { name: { contains: 'acme' } } }] },
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('plan 与 status 组合过滤同时生效', async () => {
|
|
125
|
+
await listAllSubscriptionsFn({ data: { plan: 'pro', status: 'active' } })
|
|
126
|
+
|
|
127
|
+
expect(subscription.findMany.mock.calls[0][0].where).toEqual({
|
|
128
|
+
planId: 'pro',
|
|
129
|
+
status: 'active',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('分页偏移正确计算(page=3 pageSize=10 → skip=20)', async () => {
|
|
134
|
+
await listAllSubscriptionsFn({ data: { page: 3, pageSize: 10 } })
|
|
135
|
+
|
|
136
|
+
const arg = subscription.findMany.mock.calls[0][0]
|
|
137
|
+
expect(arg.skip).toBe(20)
|
|
138
|
+
expect(arg.take).toBe(10)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('getSubscriptionDetailFn', () => {
|
|
143
|
+
it('按 subscriptionId 查详情并附带发票列表', async () => {
|
|
144
|
+
subscription.findUnique.mockResolvedValue({ id: 'sub-1' })
|
|
145
|
+
|
|
146
|
+
const res = await getSubscriptionDetailFn({ data: { subscriptionId: 'sub-1' } })
|
|
147
|
+
|
|
148
|
+
expect(subscription.findUnique).toHaveBeenCalledWith({
|
|
149
|
+
where: { id: 'sub-1' },
|
|
150
|
+
include: {
|
|
151
|
+
plan: true,
|
|
152
|
+
workspace: true,
|
|
153
|
+
invoices: { orderBy: { createdAt: 'desc' } },
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
expect(res).toEqual({ id: 'sub-1' })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('非管理员拒绝:不触达 subscription 查询', async () => {
|
|
160
|
+
requireAdminMock.mockRejectedValue(new Error('not admin'))
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
getSubscriptionDetailFn({ data: { subscriptionId: 'sub-1' } }),
|
|
164
|
+
).rejects.toThrow('not admin')
|
|
165
|
+
expect(subscription.findUnique).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('listAllInvoicesFn', () => {
|
|
170
|
+
it('按 createdAt 降序分页并附带订阅归属信息', async () => {
|
|
171
|
+
await listAllInvoicesFn({ data: { page: 2, pageSize: 5 } })
|
|
172
|
+
|
|
173
|
+
expect(invoice.findMany).toHaveBeenCalledWith({
|
|
174
|
+
orderBy: { createdAt: 'desc' },
|
|
175
|
+
include: { subscription: { include: { workspace: true, plan: true } } },
|
|
176
|
+
skip: 5,
|
|
177
|
+
take: 5,
|
|
178
|
+
})
|
|
179
|
+
// 发票总数不受分页影响
|
|
180
|
+
expect(invoice.count).toHaveBeenCalledWith()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
19
|
+
|
|
20
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
21
|
+
createServerFn: (_config: unknown) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema
|
|
26
|
+
return this
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data
|
|
33
|
+
}
|
|
34
|
+
return fn({ data })
|
|
35
|
+
}
|
|
36
|
+
return wrapped
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
return api
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
vi.mock('@allbluecn/database', () => ({
|
|
48
|
+
prisma: {
|
|
49
|
+
complianceExport: {
|
|
50
|
+
findMany: vi.fn(),
|
|
51
|
+
count: vi.fn(),
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
57
|
+
requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
vi.mock('@/server/entitlements', () => ({
|
|
61
|
+
requireFeature: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
const services = vi.hoisted(() => new Map<string, unknown>())
|
|
65
|
+
|
|
66
|
+
vi.mock('@/server/pro-services', () => ({
|
|
67
|
+
getProService: (key: string) => services.get(key) ?? null,
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
import { prisma } from '@allbluecn/database'
|
|
71
|
+
import { requireAdmin } from '@/server/auth-helpers'
|
|
72
|
+
import { requireFeature } from '@/server/entitlements'
|
|
73
|
+
import { listComplianceExportsFn } from '../serverFns/compliance'
|
|
74
|
+
|
|
75
|
+
const complianceExport = prisma.complianceExport as unknown as {
|
|
76
|
+
findMany: ReturnType<typeof vi.fn>
|
|
77
|
+
count: ReturnType<typeof vi.fn>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('listComplianceExportsFn(admin 合规导出任务列表)', () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.clearAllMocks()
|
|
83
|
+
services.clear()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('Pro 服务注册时委派给 Pro 实现', async () => {
|
|
87
|
+
const listJobs = vi.fn().mockResolvedValue({ items: ['pro-job'], total: 1 })
|
|
88
|
+
services.set('compliance', { listJobs })
|
|
89
|
+
|
|
90
|
+
const result = await listComplianceExportsFn({ data: { page: 1, pageSize: 20 } })
|
|
91
|
+
|
|
92
|
+
expect(listJobs).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
|
|
93
|
+
expect(result).toEqual({ items: ['pro-job'], total: 1 })
|
|
94
|
+
// Pro 路径不应触达 prisma
|
|
95
|
+
expect(complianceExport.findMany).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('Pro 服务缺省时走 prisma 分页查询,skip/take 按页计算', async () => {
|
|
99
|
+
const items = [{ id: 'e-1' }]
|
|
100
|
+
complianceExport.findMany.mockResolvedValue(items)
|
|
101
|
+
complianceExport.count.mockResolvedValue(42)
|
|
102
|
+
|
|
103
|
+
const result = await listComplianceExportsFn({ data: { page: 3, pageSize: 10 } })
|
|
104
|
+
|
|
105
|
+
expect(complianceExport.findMany).toHaveBeenCalledWith({
|
|
106
|
+
orderBy: { createdAt: 'desc' },
|
|
107
|
+
skip: 20,
|
|
108
|
+
take: 10,
|
|
109
|
+
})
|
|
110
|
+
expect(complianceExport.count).toHaveBeenCalledWith()
|
|
111
|
+
expect(result).toEqual({ items, total: 42 })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('未传分页参数时使用默认值(page=1, pageSize=20)', async () => {
|
|
115
|
+
complianceExport.findMany.mockResolvedValue([])
|
|
116
|
+
complianceExport.count.mockResolvedValue(0)
|
|
117
|
+
|
|
118
|
+
await listComplianceExportsFn({ data: {} })
|
|
119
|
+
|
|
120
|
+
expect(complianceExport.findMany).toHaveBeenCalledWith({
|
|
121
|
+
orderBy: { createdAt: 'desc' },
|
|
122
|
+
skip: 0,
|
|
123
|
+
take: 20,
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('查询前强制管理员身份与合规功能授权', async () => {
|
|
128
|
+
complianceExport.findMany.mockResolvedValue([])
|
|
129
|
+
complianceExport.count.mockResolvedValue(0)
|
|
130
|
+
|
|
131
|
+
await listComplianceExportsFn({ data: { page: 1, pageSize: 20 } })
|
|
132
|
+
|
|
133
|
+
expect(requireAdmin).toHaveBeenCalledTimes(1)
|
|
134
|
+
expect(requireFeature).toHaveBeenCalledWith({}, 'compliance')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
19
|
+
|
|
20
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
21
|
+
createServerFn: (_config: unknown) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema
|
|
26
|
+
return this
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data
|
|
33
|
+
}
|
|
34
|
+
return fn({ data })
|
|
35
|
+
}
|
|
36
|
+
return wrapped
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
return api
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
// prisma mock 必须覆盖被测模块触达的全部 model 键——缺一个会在用例内
|
|
48
|
+
// 报 "Cannot read properties of undefined"(见 memrec vitest-mock 陷阱)
|
|
49
|
+
vi.mock('@allbluecn/database', () => ({
|
|
50
|
+
prisma: {
|
|
51
|
+
customRole: {
|
|
52
|
+
count: vi.fn(),
|
|
53
|
+
create: vi.fn(),
|
|
54
|
+
findMany: vi.fn(),
|
|
55
|
+
findFirst: vi.fn(),
|
|
56
|
+
delete: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
user: {
|
|
59
|
+
findMany: vi.fn(),
|
|
60
|
+
},
|
|
61
|
+
roleAssignment: {
|
|
62
|
+
count: vi.fn(),
|
|
63
|
+
create: vi.fn(),
|
|
64
|
+
createMany: vi.fn(),
|
|
65
|
+
delete: vi.fn(),
|
|
66
|
+
findMany: vi.fn(),
|
|
67
|
+
},
|
|
68
|
+
workspace: {
|
|
69
|
+
findMany: vi.fn(),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
75
|
+
requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
vi.mock('@/server/entitlements', () => ({
|
|
79
|
+
requireFeature: vi.fn().mockResolvedValue(undefined),
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
const services = vi.hoisted(() => new Map<string, unknown>())
|
|
83
|
+
|
|
84
|
+
vi.mock('@/server/pro-services', () => ({
|
|
85
|
+
getProService: (key: string) => services.get(key) ?? null,
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
import { prisma } from '@allbluecn/database'
|
|
89
|
+
import { requireAdmin } from '@/server/auth-helpers'
|
|
90
|
+
import { requireFeature } from '@/server/entitlements'
|
|
91
|
+
import {
|
|
92
|
+
listRolesFn,
|
|
93
|
+
createRoleFn,
|
|
94
|
+
deleteRoleFn,
|
|
95
|
+
assignRoleFn,
|
|
96
|
+
unassignRoleFn,
|
|
97
|
+
listRoleAssignmentsFn,
|
|
98
|
+
listWorkspacesFn,
|
|
99
|
+
} from '../serverFns/permissions'
|
|
100
|
+
|
|
101
|
+
const customRole = prisma.customRole as unknown as {
|
|
102
|
+
count: ReturnType<typeof vi.fn>
|
|
103
|
+
create: ReturnType<typeof vi.fn>
|
|
104
|
+
findMany: ReturnType<typeof vi.fn>
|
|
105
|
+
findFirst: ReturnType<typeof vi.fn>
|
|
106
|
+
delete: ReturnType<typeof vi.fn>
|
|
107
|
+
}
|
|
108
|
+
const userMock = prisma.user as unknown as { findMany: ReturnType<typeof vi.fn> }
|
|
109
|
+
const roleAssignment = prisma.roleAssignment as unknown as {
|
|
110
|
+
count: ReturnType<typeof vi.fn>
|
|
111
|
+
create:ReturnType<typeof vi.fn>
|
|
112
|
+
createMany: ReturnType<typeof vi.fn>
|
|
113
|
+
delete: ReturnType<typeof vi.fn>
|
|
114
|
+
findMany: ReturnType<typeof vi.fn>
|
|
115
|
+
}
|
|
116
|
+
const workspaceMock = prisma.workspace as unknown as { findMany: ReturnType<typeof vi.fn> }
|
|
117
|
+
const requireAdminMock = requireAdmin as unknown as ReturnType<typeof vi.fn>
|
|
118
|
+
const requireFeatureMock = requireFeature as unknown as ReturnType<typeof vi.fn>
|
|
119
|
+
|
|
120
|
+
const WS = 'ws-1'
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
vi.clearAllMocks()
|
|
124
|
+
services.clear()
|
|
125
|
+
requireAdminMock.mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } })
|
|
126
|
+
requireFeatureMock.mockResolvedValue(undefined)
|
|
127
|
+
customRole.count.mockResolvedValue(0)
|
|
128
|
+
customRole.findMany.mockResolvedValue([])
|
|
129
|
+
customRole.findFirst.mockResolvedValue(null)
|
|
130
|
+
userMock.findMany.mockResolvedValue([])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('listRolesFn', () => {
|
|
134
|
+
it('非管理员拒绝:requireAdmin 抛错时零 prisma 调用', async () => {
|
|
135
|
+
requireAdminMock.mockRejectedValue(new Error('not admin'))
|
|
136
|
+
|
|
137
|
+
await expect(listRolesFn({ data: { workspaceId: WS } })).rejects.toThrow('not admin')
|
|
138
|
+
expect(customRole.count).not.toHaveBeenCalled()
|
|
139
|
+
expect(customRole.findMany).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('feature 未开通拒绝:requireFeature 抛错时零 prisma 调用', async () => {
|
|
143
|
+
requireFeatureMock.mockRejectedValue(new Error('locked'))
|
|
144
|
+
|
|
145
|
+
await expect(listRolesFn({ data: { workspaceId: WS } })).rejects.toThrow('locked')
|
|
146
|
+
expect(customRole.count).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('只查指定 workspaceId(跨租户隔离,不泄露其他 workspace 角色)', async () => {
|
|
150
|
+
customRole.count.mockResolvedValue(3)
|
|
151
|
+
customRole.findMany.mockResolvedValue([{ id: 'role-1', name: 'owner' }])
|
|
152
|
+
|
|
153
|
+
const res = await listRolesFn({ data: { workspaceId: WS } })
|
|
154
|
+
|
|
155
|
+
expect(customRole.findMany).toHaveBeenCalledWith({
|
|
156
|
+
where: { workspaceId: WS },
|
|
157
|
+
include: { _count: { select: { assignments: true } } },
|
|
158
|
+
orderBy: { createdAt: 'desc' },
|
|
159
|
+
})
|
|
160
|
+
expect(customRole.count).toHaveBeenCalledWith({ where: { workspaceId: WS } })
|
|
161
|
+
expect(res).toEqual({ items: [{ id: 'role-1', name: 'owner' }], total: 3, seeded: 'done' })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('已有系统角色时跳过种子化(不重复创建预设)', async () => {
|
|
165
|
+
customRole.count.mockResolvedValue(5)
|
|
166
|
+
|
|
167
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
168
|
+
|
|
169
|
+
expect(customRole.create).not.toHaveBeenCalled()
|
|
170
|
+
expect(roleAssignment.createMany).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('首次种子化创建 5 个系统角色预设', async () => {
|
|
174
|
+
customRole.count.mockResolvedValue(0)
|
|
175
|
+
services.set('permissions', { kind: 'pro' })
|
|
176
|
+
|
|
177
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
178
|
+
|
|
179
|
+
const names = customRole.create.mock.calls.map((c: any[]) => c[0].data.name)
|
|
180
|
+
expect(names).toEqual(['owner', 'admin', 'member', 'editor', 'viewer'])
|
|
181
|
+
// 系统角色必须标记 isSystem,用户不可伪造/删除
|
|
182
|
+
expect(customRole.create.mock.calls.every((c: any[]) => c[0].data.isSystem === true)).toBe(true)
|
|
183
|
+
expect(customRole.create.mock.calls.every((c: any[]) => c[0].data.workspaceId === WS)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('种子化时给 ADMIN 用户赋 owner 角色', async () => {
|
|
187
|
+
customRole.count.mockResolvedValue(0)
|
|
188
|
+
services.set('permissions', { kind: 'pro' })
|
|
189
|
+
userMock.findMany.mockResolvedValue([{ id: 'u-1' }, { id: 'u-2' }])
|
|
190
|
+
customRole.findFirst.mockResolvedValue({ id: 'role-owner', name: 'owner' })
|
|
191
|
+
|
|
192
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
193
|
+
|
|
194
|
+
expect(userMock.findMany).toHaveBeenCalledWith({
|
|
195
|
+
where: { role: 'ADMIN' },
|
|
196
|
+
select: { id: true },
|
|
197
|
+
})
|
|
198
|
+
expect(roleAssignment.createMany).toHaveBeenCalledWith({
|
|
199
|
+
data: [{ userId: 'u-1', roleId: 'role-owner' }, { userId: 'u-2', roleId: 'role-owner' }],
|
|
200
|
+
skipDuplicates: true,
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('pro service 缺失时不种子化(OSS 部署无高级权限)', async () => {
|
|
205
|
+
customRole.count.mockResolvedValue(0)
|
|
206
|
+
|
|
207
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
208
|
+
|
|
209
|
+
expect(customRole.create).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('createRoleFn', () => {
|
|
214
|
+
it('强制 isSystem=false(不可伪造系统角色)', async () => {
|
|
215
|
+
customRole.create.mockResolvedValue({ id: 'role-new' })
|
|
216
|
+
|
|
217
|
+
await createRoleFn({ data: { workspaceId: WS, name: 'custom-1', permissions: { 'story:read': 'read' } } })
|
|
218
|
+
|
|
219
|
+
expect(customRole.create).toHaveBeenCalledWith({
|
|
220
|
+
data: {
|
|
221
|
+
workspaceId: WS,
|
|
222
|
+
name: 'custom-1',
|
|
223
|
+
permissions: { 'story:read': 'read' },
|
|
224
|
+
isSystem: false,
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('permissions 缺省为空对象', async () => {
|
|
230
|
+
customRole.create.mockResolvedValue({ id: 'role-new' })
|
|
231
|
+
|
|
232
|
+
await createRoleFn({ data: { workspaceId: WS, name: 'custom-2' } })
|
|
233
|
+
|
|
234
|
+
expect(customRole.create.mock.calls[0][0].data.permissions).toEqual({})
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('assignRoleFn', () => {
|
|
239
|
+
it('写入 userId 与 roleId 并返回 ok', async () => {
|
|
240
|
+
roleAssignment.create.mockResolvedValue({ id: 'assign-1' })
|
|
241
|
+
|
|
242
|
+
const res = await assignRoleFn({ data: { userId: 'u-1', roleId: 'role-1' } })
|
|
243
|
+
|
|
244
|
+
expect(roleAssignment.create).toHaveBeenCalledWith({ data: { userId: 'u-1', roleId: 'role-1' } })
|
|
245
|
+
expect(res).toEqual({ ok: true })
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('unassignRoleFn', () => {
|
|
250
|
+
it('按 assignmentId 删除', async () => {
|
|
251
|
+
roleAssignment.delete.mockResolvedValue({ id: 'assign-1' })
|
|
252
|
+
|
|
253
|
+
const res = await unassignRoleFn({ data: { assignmentId: 'assign-1' } })
|
|
254
|
+
|
|
255
|
+
expect(roleAssignment.delete).toHaveBeenCalledWith({ where: { id: 'assign-1' } })
|
|
256
|
+
expect(res).toEqual({ ok: true })
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('deleteRoleFn', () => {
|
|
261
|
+
it('按 roleId 删除并返回 success', async () => {
|
|
262
|
+
customRole.delete.mockResolvedValue({ id: 'role-1' })
|
|
263
|
+
|
|
264
|
+
const res = await deleteRoleFn({ data: { roleId: 'role-1' } })
|
|
265
|
+
|
|
266
|
+
expect(customRole.delete).toHaveBeenCalledWith({ where: { id: 'role-1' } })
|
|
267
|
+
expect(res).toEqual({ success: true })
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('listRoleAssignmentsFn', () => {
|
|
272
|
+
it('按 roleId 过滤并附带用户信息选择集', async () => {
|
|
273
|
+
roleAssignment.findMany.mockResolvedValue([{ id: 'assign-1', user: { id: 'u-1', username: 'alice' } }])
|
|
274
|
+
|
|
275
|
+
const res = await listRoleAssignmentsFn({ data: { roleId: 'role-1' } })
|
|
276
|
+
|
|
277
|
+
expect(roleAssignment.findMany).toHaveBeenCalledWith({
|
|
278
|
+
where: { roleId: 'role-1' },
|
|
279
|
+
include: { user: { select: { id: true, username: true, email: true } } },
|
|
280
|
+
orderBy: { createdAt: 'desc' },
|
|
281
|
+
})
|
|
282
|
+
expect(res).toHaveLength(1)
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('listWorkspacesFn', () => {
|
|
287
|
+
it('按 name 升序返回工作空间', async () => {
|
|
288
|
+
workspaceMock.findMany.mockResolvedValue([
|
|
289
|
+
{ id: 'ws-a', name: 'alpha' },
|
|
290
|
+
{ id: 'ws-b', name: 'beta' },
|
|
291
|
+
])
|
|
292
|
+
|
|
293
|
+
const res = await listWorkspacesFn({ data: {} })
|
|
294
|
+
|
|
295
|
+
expect(workspaceMock.findMany).toHaveBeenCalledWith({
|
|
296
|
+
select: { id: true, name: true },
|
|
297
|
+
orderBy: { name: 'asc' },
|
|
298
|
+
})
|
|
299
|
+
expect(res).toHaveLength(2)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { createServerFn } from '@tanstack/react-start'
|
|
19
19
|
import { z } from 'zod'
|
|
20
20
|
import { prisma } from '@allbluecn/database'
|
|
21
|
-
import {
|
|
21
|
+
import { execFileSync } from 'node:child_process'
|
|
22
22
|
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'
|
|
23
23
|
import { resolve, join } from 'node:path'
|
|
24
24
|
|
|
@@ -89,7 +89,7 @@ export const importComponentFn = createServerFn({ method: 'POST' })
|
|
|
89
89
|
if (!packageName) throw new Error('npm 包名不能为空')
|
|
90
90
|
const prototypeDir = join(monorepoRoot, 'packages/prototype')
|
|
91
91
|
try {
|
|
92
|
-
|
|
92
|
+
execFileSync('pnpm', ['add', packageName], {
|
|
93
93
|
cwd: prototypeDir,
|
|
94
94
|
stdio: 'pipe',
|
|
95
95
|
timeout: 60000,
|
|
@@ -114,7 +114,7 @@ export const importComponentFn = createServerFn({ method: 'POST' })
|
|
|
114
114
|
} else {
|
|
115
115
|
const compName = importPath.split('/').pop() ?? importPath
|
|
116
116
|
try {
|
|
117
|
-
|
|
117
|
+
execFileSync('pnpm', ['dlx', 'shadcn@latest', 'add', importPath, '--yes'], {
|
|
118
118
|
cwd: webDir,
|
|
119
119
|
stdio: 'pipe',
|
|
120
120
|
timeout: 120000,
|
package/src/start.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
sentryGlobalRequestMiddleware,
|
|
21
21
|
} from '@sentry/tanstackstart-react'
|
|
22
22
|
import { createStart, createMiddleware, createCsrfMiddleware } from '@tanstack/react-start'
|
|
23
|
+
import { securityHeadersMiddleware } from '@/server/middlewares/security-headers'
|
|
23
24
|
import { requestContextMiddleware } from '@/server/middlewares/request'
|
|
24
25
|
import {
|
|
25
26
|
observabilityMiddleware,
|
|
@@ -80,6 +81,6 @@ if (typeof process !== 'undefined' && process.versions?.node && process.env.NODE
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export const startInstance = createStart(() => ({
|
|
83
|
-
requestMiddleware: [sentryGlobalRequestMiddleware, requestContextMiddleware, observabilityMiddleware, debugMiddleware, apiCorsMiddleware, csrfMiddleware],
|
|
84
|
+
requestMiddleware: [securityHeadersMiddleware, sentryGlobalRequestMiddleware, requestContextMiddleware, observabilityMiddleware, debugMiddleware, apiCorsMiddleware, csrfMiddleware],
|
|
84
85
|
functionMiddleware: [sentryGlobalFunctionMiddleware],
|
|
85
86
|
}))
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
// Vitest 5 类型兼容层(与 apps/web 同名文件保持一致):Vitest 5 的 `Assertion`
|
|
19
|
+
// 接口使用双类型参数 `Assertion<R, T>`,而 @testing-library/jest-dom 官方
|
|
20
|
+
// types/vitest.d.ts 声明单参数 `Assertion<T = any>`,泛型参数个数不一致导致
|
|
21
|
+
// 声明合并报 TS2428。这里以正确的双参数签名重写增强,使 `toBeInTheDocument`
|
|
22
|
+
// 等 jest-dom matcher 类型可用。运行时 matcher 注册在 src/__tests__/setup.ts
|
|
23
|
+
// 的 `import '@testing-library/jest-dom/vitest'`。
|
|
24
|
+
import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
|
|
25
|
+
|
|
26
|
+
declare module 'vitest' {
|
|
27
|
+
interface Assertion<R extends void | Promise<void> = void, T = unknown>
|
|
28
|
+
extends TestingLibraryMatchers<R, T> {}
|
|
29
|
+
interface AsymmetricMatchersContaining
|
|
30
|
+
extends TestingLibraryMatchers<any, any> {}
|
|
31
|
+
}
|