create-fullstack-scaffold 0.5.7 → 0.6.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.
- package/dist/cli/index.js +65 -2
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/CLAUDE.md +3 -3
- package/template/PROJECT_SUMMARY.md +1 -1
- package/template/QUICKSTART.md +3 -3
- package/template/README.md +2 -2
- package/template/drizzle/0000_init_full_schema.sql +390 -0
- package/template/drizzle/meta/0000_snapshot.json +2487 -0
- package/template/drizzle/meta/_journal.json +13 -0
- package/template/package.json +10 -10
- package/template/src/cli/modules/tenant/index.ts +89 -0
- package/template/src/server/db/init.ts +0 -52
- package/template/src/server/db/schema/index.ts +3 -0
- package/template/src/server/db/schema/tenant-invitations.ts +33 -0
- package/template/src/server/db/schema/tenant-members.ts +38 -0
- package/template/src/server/db/schema/tenant-roles.ts +32 -0
- package/template/src/server/db/schema/todos.ts +4 -0
- package/template/src/server/db/test-setup.ts +128 -0
- package/template/src/server/entries/node.ts +13 -0
- package/template/src/server/index.ts +12 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
- package/template/src/server/middleware/auth.ts +17 -19
- package/template/src/server/middleware/tenant-isolation.ts +4 -2
- package/template/src/server/module-auth/module.ts +2 -1
- package/template/src/server/module-auth/services/auth-service.ts +39 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
- package/template/src/server/module-tenant/module.ts +1 -1
- package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
- package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
- package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
- package/template/src/server/module-todos/services/todo-service.ts +12 -2
- package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
- package/template/src/server/utils/id-helpers.ts +13 -0
- package/template/src/shared/modules/index.ts +29 -6
- package/template/src/shared/modules/tenant/index.ts +30 -0
- package/template/src/shared/modules/tenant/permissions.ts +54 -0
- package/template/src/shared/modules/tenant/role-templates.ts +66 -0
- package/template/src/shared/modules/tenant/schemas.ts +113 -1
- package/template/src/shared/modules/todos/schemas.ts +1 -0
- package/template/src/shared/schemas/index.ts +28 -0
- package/template/src/tenant/App.tsx +30 -23
- package/template/src/tenant/components/TenantGuard.tsx +12 -2
- package/template/src/tenant/layouts/Header.tsx +6 -2
- package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
- package/template/src/tenant/pages/LoginPage.tsx +82 -0
- package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
- package/template/src/tenant/pages/UsersPage.tsx +134 -88
- package/template/src/tenant/services/tenantApi.ts +61 -0
- package/template/src/tenant/stores/tenantStore.ts +172 -49
- package/template/src/test/setup-db-path.ts +20 -0
- package/template/vitest.config.ts +3 -1
|
@@ -58,23 +58,37 @@ describe('tenantIsolationMiddleware', () => {
|
|
|
58
58
|
return app
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// P2 起中间件为"可选上下文"语义:无租户标识 → 放行且不设上下文
|
|
62
|
+
//(全局模式:dev token / 平台级 API / 无 tenant 模块 preset 不受影响)
|
|
63
|
+
it('should pass through without context when no tenant header or subdomain', async () => {
|
|
62
64
|
const app = createApp()
|
|
63
65
|
const res = await app.request('/api/test')
|
|
64
|
-
|
|
66
|
+
// /api/test 无路由 → 404 来自路由缺失而非中间件;断言响应不是
|
|
67
|
+
// tenant-not-found 错误体即可证明中间件放行
|
|
68
|
+
const body = (await res.json()) as { error?: string }
|
|
69
|
+
expect(String(body.error)).not.toContain('Tenant')
|
|
65
70
|
})
|
|
66
71
|
|
|
67
|
-
it('should
|
|
72
|
+
it('should pass through without context when tenant header is empty string', async () => {
|
|
68
73
|
const app = createApp()
|
|
69
74
|
const res = await app.request('/api/test', {
|
|
70
75
|
headers: { 'X-Tenant-Slug': '' },
|
|
71
76
|
})
|
|
72
|
-
|
|
77
|
+
const body = (await res.json()) as { error?: string }
|
|
78
|
+
expect(String(body.error)).not.toContain('Tenant')
|
|
73
79
|
})
|
|
74
80
|
|
|
75
81
|
it('should pass with valid tenant from header', async () => {
|
|
76
82
|
mockDbResult([
|
|
77
|
-
{
|
|
83
|
+
{
|
|
84
|
+
id: 1,
|
|
85
|
+
slug: 'test-tenant',
|
|
86
|
+
name: 'Test',
|
|
87
|
+
status: 'active',
|
|
88
|
+
plan: 'free',
|
|
89
|
+
maxUsers: 10,
|
|
90
|
+
settings: null,
|
|
91
|
+
},
|
|
78
92
|
])
|
|
79
93
|
const app = createApp()
|
|
80
94
|
const res = await app.request('/api/test', {
|
|
@@ -98,7 +112,15 @@ describe('tenantIsolationMiddleware', () => {
|
|
|
98
112
|
|
|
99
113
|
it('should extract tenant from subdomain when no header', async () => {
|
|
100
114
|
mockDbResult([
|
|
101
|
-
{
|
|
115
|
+
{
|
|
116
|
+
id: 2,
|
|
117
|
+
slug: 'acme',
|
|
118
|
+
name: 'Acme',
|
|
119
|
+
status: 'active',
|
|
120
|
+
plan: 'pro',
|
|
121
|
+
maxUsers: 100,
|
|
122
|
+
settings: null,
|
|
123
|
+
},
|
|
102
124
|
])
|
|
103
125
|
const app = createApp()
|
|
104
126
|
const res = await app.request('/api/test', {
|
|
@@ -111,7 +133,15 @@ describe('tenantIsolationMiddleware', () => {
|
|
|
111
133
|
|
|
112
134
|
it('should prefer X-Tenant-Slug header over subdomain', async () => {
|
|
113
135
|
mockDbResult([
|
|
114
|
-
{
|
|
136
|
+
{
|
|
137
|
+
id: 3,
|
|
138
|
+
slug: 'header-tenant',
|
|
139
|
+
name: 'Header',
|
|
140
|
+
status: 'active',
|
|
141
|
+
plan: 'free',
|
|
142
|
+
maxUsers: 5,
|
|
143
|
+
settings: null,
|
|
144
|
+
},
|
|
115
145
|
])
|
|
116
146
|
const app = createApp()
|
|
117
147
|
const res = await app.request('/api/test', {
|
|
@@ -124,7 +154,15 @@ describe('tenantIsolationMiddleware', () => {
|
|
|
124
154
|
|
|
125
155
|
it('should parse tenant settings JSON', async () => {
|
|
126
156
|
mockDbResult([
|
|
127
|
-
{
|
|
157
|
+
{
|
|
158
|
+
id: 4,
|
|
159
|
+
slug: 'with-settings',
|
|
160
|
+
name: 'Settings',
|
|
161
|
+
status: 'active',
|
|
162
|
+
plan: 'pro',
|
|
163
|
+
maxUsers: 50,
|
|
164
|
+
settings: '{"theme":"dark"}',
|
|
165
|
+
},
|
|
128
166
|
])
|
|
129
167
|
const app = createApp()
|
|
130
168
|
const res = await app.request('/api/test', {
|
|
@@ -137,7 +175,15 @@ describe('tenantIsolationMiddleware', () => {
|
|
|
137
175
|
|
|
138
176
|
it('should handle null settings gracefully', async () => {
|
|
139
177
|
mockDbResult([
|
|
140
|
-
{
|
|
178
|
+
{
|
|
179
|
+
id: 5,
|
|
180
|
+
slug: 'no-settings',
|
|
181
|
+
name: 'NoSettings',
|
|
182
|
+
status: 'active',
|
|
183
|
+
plan: 'free',
|
|
184
|
+
maxUsers: 5,
|
|
185
|
+
settings: null,
|
|
186
|
+
},
|
|
141
187
|
])
|
|
142
188
|
const app = createApp()
|
|
143
189
|
const res = await app.request('/api/test', {
|
|
@@ -131,27 +131,25 @@ function verifyToken(token: string, key: string): AuthUser | null {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
} catch {
|
|
150
|
-
return null
|
|
134
|
+
// 真实 JWT 验证对默认/自定义 key 均启用——此前仅非默认 key 才走此分支,
|
|
135
|
+
// 导致 dev 未设 AUTH_SECRET_KEY 时所有注册账号的登录 token 恒 401
|
|
136
|
+
try {
|
|
137
|
+
const decoded = jwt.verify(token, key) as {
|
|
138
|
+
userId: string
|
|
139
|
+
role: string
|
|
140
|
+
username: string
|
|
141
|
+
email: string
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
id: decoded.userId,
|
|
145
|
+
username: decoded.username,
|
|
146
|
+
email: decoded.email,
|
|
147
|
+
role: decoded.role as UserRole,
|
|
148
|
+
permissions: getPermissionsByRole(decoded.role as UserRole),
|
|
151
149
|
}
|
|
150
|
+
} catch {
|
|
151
|
+
return null
|
|
152
152
|
}
|
|
153
|
-
|
|
154
|
-
return null
|
|
155
153
|
}
|
|
156
154
|
|
|
157
155
|
export function authMiddleware(options: AuthMiddlewareOptions = {}): MiddlewareHandler {
|
|
@@ -41,9 +41,11 @@ export function tenantIsolationMiddleware(): MiddlewareHandler {
|
|
|
41
41
|
const hostname = c.req.header('host') || ''
|
|
42
42
|
const tenantSlug = extractTenantSlug(hostname, c.req.header('X-Tenant-Slug'))
|
|
43
43
|
|
|
44
|
+
// 无租户标识 → 不设上下文继续(全局模式:dev token、平台级 API、
|
|
45
|
+
// 无 tenant 模块 preset 的既有行为都不受影响)
|
|
44
46
|
if (!tenantSlug) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
await next()
|
|
48
|
+
return
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
const db = await getDb()
|
|
@@ -33,7 +33,8 @@ const authManifest: ModuleManifest = {
|
|
|
33
33
|
|
|
34
34
|
dbSchemas: {
|
|
35
35
|
files: ['developers'],
|
|
36
|
-
hasSeed:
|
|
36
|
+
hasSeed: true,
|
|
37
|
+
seed: { serviceFile: 'auth-service', functionName: 'seedDevelopersIfEmpty' },
|
|
37
38
|
},
|
|
38
39
|
|
|
39
40
|
cliModule: { dir: 'auth', registerFunction: 'registerAuthCommands' },
|
|
@@ -6,6 +6,7 @@ import { developers, type DeveloperTable } from '@server/db/schema'
|
|
|
6
6
|
import { ConflictError, AuthenticationError } from '@server/utils/app-error'
|
|
7
7
|
import { generateUUID } from '@server/utils/uuid'
|
|
8
8
|
import { toISOString } from '@server/utils/date'
|
|
9
|
+
import { createModuleLoggerSync } from '@server/utils/logger'
|
|
9
10
|
|
|
10
11
|
// eslint-disable-next-line local-rules/no-util-functions-in-service -- module-specific row-to-profile mapping
|
|
11
12
|
function toProfile(row: DeveloperTable): DeveloperProfile {
|
|
@@ -98,3 +99,41 @@ export async function getDeveloperById(id: string): Promise<DeveloperProfile | n
|
|
|
98
99
|
|
|
99
100
|
return toProfile(rows[0])
|
|
100
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 认证种子(首启空库):demo 开发者 + 平台超管。
|
|
105
|
+
* superadmin 必须是 super_admin 角色——requireSuperAdminMiddleware 只认这个值。
|
|
106
|
+
* 凭据 superadmin/admin123,首启日志打印一次,上线前必须改密。
|
|
107
|
+
*/
|
|
108
|
+
export async function seedDevelopersIfEmpty(): Promise<void> {
|
|
109
|
+
const db = await getDb()
|
|
110
|
+
const existing = await db.select().from(developers)
|
|
111
|
+
if (existing.length > 0) return
|
|
112
|
+
|
|
113
|
+
const log = createModuleLoggerSync('auth-service')
|
|
114
|
+
log.info({}, 'Seeding developers...')
|
|
115
|
+
const demoPasswordHash = hashSync('demo123', 10)
|
|
116
|
+
const adminPasswordHash = hashSync('admin123', 10)
|
|
117
|
+
await db.insert(developers).values([
|
|
118
|
+
{
|
|
119
|
+
id: generateUUID(),
|
|
120
|
+
username: 'demo',
|
|
121
|
+
email: 'demo@biomimic.app',
|
|
122
|
+
passwordHash: demoPasswordHash,
|
|
123
|
+
role: 'developer' as const,
|
|
124
|
+
apiKey: generateUUID(),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: generateUUID(),
|
|
128
|
+
username: 'superadmin',
|
|
129
|
+
email: 'admin@biomimic.app',
|
|
130
|
+
passwordHash: adminPasswordHash,
|
|
131
|
+
role: 'super_admin' as const,
|
|
132
|
+
apiKey: generateUUID(),
|
|
133
|
+
},
|
|
134
|
+
])
|
|
135
|
+
log.warn(
|
|
136
|
+
{ account: 'superadmin', password: 'admin123', note: 'CHANGE IMMEDIATELY' },
|
|
137
|
+
'Platform super admin seeded (first boot only) — change the password before any real deployment'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -140,3 +140,100 @@ describe('Tenant Routes', () => {
|
|
|
140
140
|
})
|
|
141
141
|
})
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
describe('membership & invitation routes (P1)', () => {
|
|
145
|
+
const authHeaders = { Authorization: 'Bearer test-super-admin-1' }
|
|
146
|
+
|
|
147
|
+
beforeAll(async () => {
|
|
148
|
+
await setupTestDatabase()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
afterAll(async () => {
|
|
152
|
+
await cleanupTestDatabase()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('GET /api/tenants/mine 返回当前用户租户', async () => {
|
|
156
|
+
const client = createTestClient(undefined, { headers: authHeaders })
|
|
157
|
+
const res = await client.api.tenants.mine.$get()
|
|
158
|
+
expect(res.status).toBe(200)
|
|
159
|
+
const data = await res.json()
|
|
160
|
+
if (data.success) {
|
|
161
|
+
expect(Array.isArray(data.data)).toBe(true)
|
|
162
|
+
expect(data.data.length).toBeGreaterThanOrEqual(1) // demo 种子 owner
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('非成员访问租户成员列表 403', async () => {
|
|
167
|
+
const client = createTestClient(undefined, {
|
|
168
|
+
headers: { Authorization: 'Bearer test-user-2' },
|
|
169
|
+
})
|
|
170
|
+
const res = await client.api.tenants[':tenantId'].members.$get({ param: { tenantId: '1' } })
|
|
171
|
+
expect(res.status).toBe(403)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('owner 邀请→公开查详情→接受→列表可见→移除', async () => {
|
|
175
|
+
const owner = createTestClient(undefined, { headers: authHeaders })
|
|
176
|
+
|
|
177
|
+
// demo 租户(id=1,seed owner=test-super-admin-1)
|
|
178
|
+
const rolesRes = await owner.api.tenants[':tenantId'].roles.$get({ param: { tenantId: '1' } })
|
|
179
|
+
expect(rolesRes.status).toBe(200)
|
|
180
|
+
const rolesData = await rolesRes.json()
|
|
181
|
+
if (!rolesData.success) throw new Error('roles failed')
|
|
182
|
+
const guestRole = rolesData.data.find(r => r.code === 'tenant_guest')!
|
|
183
|
+
|
|
184
|
+
const inviteRes = await owner.api.tenants[':tenantId'].members.invite.$post({
|
|
185
|
+
param: { tenantId: '1' },
|
|
186
|
+
json: { email: 'route-newbie@example.com', roleId: guestRole.id },
|
|
187
|
+
})
|
|
188
|
+
expect(inviteRes.status).toBe(201)
|
|
189
|
+
const inviteData = await inviteRes.json()
|
|
190
|
+
if (!inviteData.success) throw new Error('invite failed')
|
|
191
|
+
const token = inviteData.data.token
|
|
192
|
+
|
|
193
|
+
// 公开接口(无认证)可查脱敏详情
|
|
194
|
+
const pub = createTestClient(undefined)
|
|
195
|
+
const pubRes = await pub.api.tenants.invitations[':token'].$get({ param: { token } })
|
|
196
|
+
expect(pubRes.status).toBe(200)
|
|
197
|
+
const pubData = await pubRes.json()
|
|
198
|
+
if (pubData.success) {
|
|
199
|
+
expect(pubData.data.tenantSlug).toBe('demo')
|
|
200
|
+
expect(pubData.data.roleLabel).toBe('访客')
|
|
201
|
+
expect(pubData.data).not.toHaveProperty('inviterId')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 被邀人接受
|
|
205
|
+
const newbie = createTestClient(undefined, {
|
|
206
|
+
headers: { Authorization: 'Bearer test-user-2' },
|
|
207
|
+
})
|
|
208
|
+
const acceptRes = await newbie.api.tenants.invitations[':token'].accept.$post({
|
|
209
|
+
param: { token },
|
|
210
|
+
})
|
|
211
|
+
expect(acceptRes.status).toBe(200)
|
|
212
|
+
|
|
213
|
+
// owner 视角成员列表可见新人
|
|
214
|
+
const membersRes = await owner.api.tenants[':tenantId'].members.$get({
|
|
215
|
+
param: { tenantId: '1' },
|
|
216
|
+
})
|
|
217
|
+
const membersData = await membersRes.json()
|
|
218
|
+
if (membersData.success) {
|
|
219
|
+
const newbieRow = membersData.data.find((m: { userId: string }) => m.userId === 'test-user-2')
|
|
220
|
+
expect(newbieRow).toBeDefined()
|
|
221
|
+
expect(newbieRow!.role?.code).toBe('tenant_guest')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 移除后新人 403
|
|
225
|
+
if (membersData.success) {
|
|
226
|
+
const newbieRow = membersData.data.find(
|
|
227
|
+
(m: { userId: string }) => m.userId === 'test-user-2'
|
|
228
|
+
)!
|
|
229
|
+
const delRes = await owner.api.tenants[':tenantId'].members[':memberId'].$delete({
|
|
230
|
+
param: { tenantId: '1', memberId: newbieRow.id },
|
|
231
|
+
})
|
|
232
|
+
expect(delRes.status).toBe(200)
|
|
233
|
+
const denied = await newbie.api.tenants[':tenantId'].members.$get({
|
|
234
|
+
param: { tenantId: '1' },
|
|
235
|
+
})
|
|
236
|
+
expect(denied.status).toBe(403)
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -7,7 +7,19 @@ import {
|
|
|
7
7
|
createTenant,
|
|
8
8
|
updateTenant,
|
|
9
9
|
deleteTenant,
|
|
10
|
+
getTenantRoles,
|
|
11
|
+
createTenantRole,
|
|
12
|
+
getTenantMembers,
|
|
13
|
+
getUserTenants,
|
|
14
|
+
getUserTenantPermissions,
|
|
15
|
+
hasTenantPermission,
|
|
16
|
+
requireTenantMembership,
|
|
17
|
+
inviteMember,
|
|
18
|
+
getInvitationByToken,
|
|
19
|
+
acceptInvitation,
|
|
20
|
+
removeMember,
|
|
10
21
|
} from '../services/tenant-service'
|
|
22
|
+
import { TenantPermission } from '@shared/schemas'
|
|
11
23
|
import type { CreateTenantInput } from '@shared/schemas'
|
|
12
24
|
|
|
13
25
|
describe('Tenant Service', () => {
|
|
@@ -84,7 +96,7 @@ describe('Tenant Service', () => {
|
|
|
84
96
|
maxUsers: 5,
|
|
85
97
|
settings: null,
|
|
86
98
|
}
|
|
87
|
-
const result = await createTenant(input)
|
|
99
|
+
const result = await createTenant(input, 'test-super-admin-1')
|
|
88
100
|
expect(result.name).toBe('New Corp')
|
|
89
101
|
expect(result.slug).toBe('new-corp')
|
|
90
102
|
expect(result.status).toBe('trial')
|
|
@@ -93,13 +105,16 @@ describe('Tenant Service', () => {
|
|
|
93
105
|
|
|
94
106
|
it('should reject duplicate slug', async () => {
|
|
95
107
|
await expect(
|
|
96
|
-
createTenant(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
createTenant(
|
|
109
|
+
{
|
|
110
|
+
name: 'Duplicate',
|
|
111
|
+
slug: 'demo',
|
|
112
|
+
plan: 'free',
|
|
113
|
+
maxUsers: 5,
|
|
114
|
+
settings: null,
|
|
115
|
+
},
|
|
116
|
+
'test-super-admin-1'
|
|
117
|
+
)
|
|
103
118
|
).rejects.toThrow(/already exists/)
|
|
104
119
|
})
|
|
105
120
|
})
|
|
@@ -112,13 +127,16 @@ describe('Tenant Service', () => {
|
|
|
112
127
|
|
|
113
128
|
it('should update tenant fields', async () => {
|
|
114
129
|
// Create a tenant to update
|
|
115
|
-
const created = await createTenant(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
const created = await createTenant(
|
|
131
|
+
{
|
|
132
|
+
name: 'Update Test',
|
|
133
|
+
slug: 'update-test',
|
|
134
|
+
plan: 'free',
|
|
135
|
+
maxUsers: 5,
|
|
136
|
+
settings: null,
|
|
137
|
+
},
|
|
138
|
+
'test-super-admin-1'
|
|
139
|
+
)
|
|
122
140
|
|
|
123
141
|
const result = await updateTenant(created.id, { name: 'Updated Name' })
|
|
124
142
|
expect(result).not.toBeNull()
|
|
@@ -133,13 +151,16 @@ describe('Tenant Service', () => {
|
|
|
133
151
|
})
|
|
134
152
|
|
|
135
153
|
it('should delete and return true', async () => {
|
|
136
|
-
const created = await createTenant(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
const created = await createTenant(
|
|
155
|
+
{
|
|
156
|
+
name: 'Delete Test',
|
|
157
|
+
slug: 'delete-test',
|
|
158
|
+
plan: 'free',
|
|
159
|
+
maxUsers: 5,
|
|
160
|
+
settings: null,
|
|
161
|
+
},
|
|
162
|
+
'test-super-admin-1'
|
|
163
|
+
)
|
|
143
164
|
|
|
144
165
|
const result = await deleteTenant(created.id)
|
|
145
166
|
expect(result).toBe(true)
|
|
@@ -150,3 +171,161 @@ describe('Tenant Service', () => {
|
|
|
150
171
|
})
|
|
151
172
|
})
|
|
152
173
|
})
|
|
174
|
+
|
|
175
|
+
describe('provisioning & membership (P1)', () => {
|
|
176
|
+
beforeAll(async () => {
|
|
177
|
+
await setupTestDatabase()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
afterAll(async () => {
|
|
181
|
+
await cleanupTestDatabase()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('createTenant 事务播种 3 个系统角色 + owner 为租户管理员', async () => {
|
|
185
|
+
const tenant = await createTenant(
|
|
186
|
+
{ name: '开通验证', slug: 'prov-check', plan: 'pro', maxUsers: 20, settings: {} },
|
|
187
|
+
'test-super-admin-1'
|
|
188
|
+
)
|
|
189
|
+
const roles = await getTenantRoles(tenant.id)
|
|
190
|
+
expect(roles.length).toBe(3)
|
|
191
|
+
expect(roles.map(r => r.code).sort()).toEqual(['tenant_admin', 'tenant_guest', 'tenant_member'])
|
|
192
|
+
expect(roles.every(r => r.isSystem)).toBe(true)
|
|
193
|
+
|
|
194
|
+
const members = await getTenantMembers(tenant.id)
|
|
195
|
+
expect(members.length).toBe(1)
|
|
196
|
+
expect(members[0].userId).toBe('test-super-admin-1')
|
|
197
|
+
expect(members[0].role?.code).toBe('tenant_admin')
|
|
198
|
+
expect(members[0].status).toBe('active')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('owner 拥有全部租户权限,陌生人无任何权限', async () => {
|
|
202
|
+
const tenant = await createTenant(
|
|
203
|
+
{ name: '权限验证', slug: 'perm-check', plan: 'free', maxUsers: 5, settings: {} },
|
|
204
|
+
'test-super-admin-1'
|
|
205
|
+
)
|
|
206
|
+
const ownerPerms = await getUserTenantPermissions('test-super-admin-1', tenant.id)
|
|
207
|
+
expect(ownerPerms.length).toBeGreaterThan(10)
|
|
208
|
+
expect(
|
|
209
|
+
await hasTenantPermission('test-super-admin-1', tenant.id, TenantPermission.MEMBER_INVITE)
|
|
210
|
+
).toBe(true)
|
|
211
|
+
expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
|
|
212
|
+
false
|
|
213
|
+
)
|
|
214
|
+
await expect(requireTenantMembership('test-user-2', tenant.id)).rejects.toThrow()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('邀请→接受→成员可见→移除后失权 全链路', async () => {
|
|
218
|
+
const tenant = await createTenant(
|
|
219
|
+
{ name: '邀请验证', slug: 'invite-check', plan: 'free', maxUsers: 5, settings: {} },
|
|
220
|
+
'test-super-admin-1'
|
|
221
|
+
)
|
|
222
|
+
const guestRole = (await getTenantRoles(tenant.id)).find(r => r.code === 'tenant_guest')!
|
|
223
|
+
|
|
224
|
+
const invitation = await inviteMember(
|
|
225
|
+
tenant.id,
|
|
226
|
+
'newbie@example.com',
|
|
227
|
+
guestRole.id,
|
|
228
|
+
'test-super-admin-1'
|
|
229
|
+
)
|
|
230
|
+
expect(invitation.status).toBe('pending')
|
|
231
|
+
expect(invitation.token.length).toBeGreaterThanOrEqual(32)
|
|
232
|
+
|
|
233
|
+
// 公开详情可查(带租户名/角色名)
|
|
234
|
+
const detail = await getInvitationByToken(invitation.token)
|
|
235
|
+
expect(detail?.tenantName).toBe('邀请验证')
|
|
236
|
+
expect(detail?.roleLabel).toBe('访客')
|
|
237
|
+
|
|
238
|
+
// 陌生用户接受邀请后成为 guest
|
|
239
|
+
const member = await acceptInvitation(invitation.token, 'test-user-2')
|
|
240
|
+
expect(member?.status).toBe('active')
|
|
241
|
+
expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
|
|
242
|
+
true
|
|
243
|
+
)
|
|
244
|
+
expect(
|
|
245
|
+
await hasTenantPermission('test-user-2', tenant.id, TenantPermission.MEMBER_INVITE)
|
|
246
|
+
).toBe(false)
|
|
247
|
+
|
|
248
|
+
// 二次接受同一 token 失效
|
|
249
|
+
const again = await acceptInvitation(invitation.token, 'test-user-3')
|
|
250
|
+
expect(again).toBeNull()
|
|
251
|
+
|
|
252
|
+
// 移除后立即失权
|
|
253
|
+
await removeMember(tenant.id, member!.id)
|
|
254
|
+
expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
|
|
255
|
+
false
|
|
256
|
+
)
|
|
257
|
+
await expect(requireTenantMembership('test-user-2', tenant.id)).rejects.toThrow()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('套餐角色数配额:free 上限 3,创建第 4 个被拦截', async () => {
|
|
261
|
+
const tenant = await createTenant(
|
|
262
|
+
{ name: '配额验证', slug: 'quota-check', plan: 'free', maxUsers: 5, settings: {} },
|
|
263
|
+
'test-super-admin-1'
|
|
264
|
+
)
|
|
265
|
+
// 3 个系统角色已占满 free 上限
|
|
266
|
+
await expect(
|
|
267
|
+
createTenantRole(tenant.id, {
|
|
268
|
+
code: 'custom-a',
|
|
269
|
+
name: 'custom-a',
|
|
270
|
+
label: '自定义A',
|
|
271
|
+
description: null,
|
|
272
|
+
permissions: [TenantPermission.DATA_VIEW],
|
|
273
|
+
})
|
|
274
|
+
).rejects.toThrow(/上限/)
|
|
275
|
+
|
|
276
|
+
// 升级 pro 后可继续创建
|
|
277
|
+
await updateTenant(tenant.id, { plan: 'pro' })
|
|
278
|
+
const role = await createTenantRole(tenant.id, {
|
|
279
|
+
code: 'custom-a',
|
|
280
|
+
name: 'custom-a',
|
|
281
|
+
label: '自定义A',
|
|
282
|
+
description: null,
|
|
283
|
+
permissions: [TenantPermission.DATA_VIEW],
|
|
284
|
+
})
|
|
285
|
+
expect(role.isSystem).toBe(false)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('P4:套餐成员配额——满员后邀请被拦截,升级后放行', async () => {
|
|
289
|
+
// maxUsers=1 的租户:owner 已占 1 席
|
|
290
|
+
const tiny = await createTenant(
|
|
291
|
+
{ name: '配额租户', slug: 'quota-member', plan: 'free', maxUsers: 1, settings: {} },
|
|
292
|
+
'test-super-admin-1'
|
|
293
|
+
)
|
|
294
|
+
const guestRole = (await getTenantRoles(tiny.id)).find(r => r.code === 'tenant_guest')!
|
|
295
|
+
|
|
296
|
+
await expect(
|
|
297
|
+
inviteMember(tiny.id, 'full@quota.io', guestRole.id, 'test-super-admin-1')
|
|
298
|
+
).rejects.toThrow(/上限/)
|
|
299
|
+
|
|
300
|
+
// 升级 maxUsers=2 后放行;接受时也过配额
|
|
301
|
+
await updateTenant(tiny.id, { maxUsers: 2 })
|
|
302
|
+
const inv = await inviteMember(tiny.id, 'full@quota.io', guestRole.id, 'test-super-admin-1')
|
|
303
|
+
const member = await acceptInvitation(inv.token, 'test-user-4')
|
|
304
|
+
expect(member?.status).toBe('active')
|
|
305
|
+
|
|
306
|
+
// 满员(2/2)后再邀新邮箱 → 拦截
|
|
307
|
+
await expect(
|
|
308
|
+
inviteMember(tiny.id, 'overflow@quota.io', guestRole.id, 'test-super-admin-1')
|
|
309
|
+
).rejects.toThrow(/上限/)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('P4:接受时二次校验——邀请有效期内满员则接受被拦截', async () => {
|
|
313
|
+
const t = await createTenant(
|
|
314
|
+
{ name: '二次校验', slug: 'quota-accept', plan: 'free', maxUsers: 2, settings: {} },
|
|
315
|
+
'test-super-admin-1'
|
|
316
|
+
)
|
|
317
|
+
const guestRole = (await getTenantRoles(t.id)).find(r => r.code === 'tenant_guest')!
|
|
318
|
+
const inv = await inviteMember(t.id, 'late@quota.io', guestRole.id, 'test-super-admin-1')
|
|
319
|
+
|
|
320
|
+
// 邀请发出后把 maxUsers 降到 1(owner 已满员)
|
|
321
|
+
await updateTenant(t.id, { maxUsers: 1 })
|
|
322
|
+
await expect(acceptInvitation(inv.token, 'test-user-5')).rejects.toThrow(/上限/)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('getUserTenants 只返回本人 active 成员的租户', async () => {
|
|
326
|
+
const mine = await getUserTenants('test-super-admin-1')
|
|
327
|
+
expect(mine.length).toBeGreaterThanOrEqual(2) // demo 种子 + 本组新建
|
|
328
|
+
expect(mine.every(t => t.slug)).toBe(true)
|
|
329
|
+
expect((await getUserTenants('test-user-2')).length).toBe(0)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -22,7 +22,7 @@ const tenantManifest: ModuleManifest = {
|
|
|
22
22
|
clientPages: [],
|
|
23
23
|
|
|
24
24
|
dbSchemas: {
|
|
25
|
-
files: ['tenants'],
|
|
25
|
+
files: ['tenants', 'tenant-roles', 'tenant-members', 'tenant-invitations'],
|
|
26
26
|
hasSeed: true,
|
|
27
27
|
seed: { serviceFile: 'tenant-service', functionName: 'seedTenantsIfEmpty' },
|
|
28
28
|
},
|