@goplusvn/core 0.1.75 → 0.1.76
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/CHANGELOG.md +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +260 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +157 -0
- package/src/workspace/pages/workspace-list-page.tsx +430 -0
- package/src/workspace/route-handlers.ts +274 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -70,6 +70,29 @@ export const oneDoorRules: GuardrailRule[] = [
|
|
|
70
70
|
ctx.files.some((f) => /\b(getBranchScope|scopedBranchWhere)\s*\(/.test(ctx.readCode(f))),
|
|
71
71
|
}),
|
|
72
72
|
|
|
73
|
+
singleDoorImport({
|
|
74
|
+
id: "one-door/workspace",
|
|
75
|
+
title: "chỉ file hạ tầng được import @goerp/core/workspace",
|
|
76
|
+
why:
|
|
77
|
+
"Cùng một engine, cùng một AsyncLocalStorage với branch-scope — chỉ tổng " +
|
|
78
|
+
"quát hơn (chi nhánh / đơn vị / phòng ban là một cây). Import thẳng lấy " +
|
|
79
|
+
"được hàm nhưng bỏ lỡ `configureWorkspaces()`, và ở module này 'bỏ lỡ' " +
|
|
80
|
+
"nghĩa là rò dữ liệu chéo không gian — im lặng, không exception.",
|
|
81
|
+
fix: "Import qua cửa của app (`@/lib/workspace` hoặc `@/lib/branch-scope`); composition root phải gọi `configureWorkspaces`.",
|
|
82
|
+
pattern: /from\s+["']@goerp\/core\/workspace["']/,
|
|
83
|
+
doors: (ctx) => ctx.options.doors.workspace,
|
|
84
|
+
mustConfigure: /configureWorkspaces\s*[<(]/,
|
|
85
|
+
mustConfigureName: "configureWorkspaces",
|
|
86
|
+
// Lớp 2 (`createScopeGuardExtension`) và các hàm cây thuần không cần
|
|
87
|
+
// singleton; chỉ lớp 1 mới cần, và thiếu thì nó NÉM.
|
|
88
|
+
mustConfigureWhen: (ctx) =>
|
|
89
|
+
ctx.files.some((f) =>
|
|
90
|
+
/\b(getWorkspaceScope|scopedWhere|scopeLevelWhere|workspaceScopeField)\s*\(/.test(
|
|
91
|
+
ctx.readCode(f),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
}),
|
|
95
|
+
|
|
73
96
|
forbidPattern({
|
|
74
97
|
id: "one-door/export-no-window-open",
|
|
75
98
|
title: "không window.open(...) tới endpoint /export",
|
|
@@ -79,6 +79,15 @@ export function resolveOptions(
|
|
|
79
79
|
"lib/rbac/branch-scope.ts",
|
|
80
80
|
"lib/prisma.ts",
|
|
81
81
|
],
|
|
82
|
+
// `lib/workspace.ts` đứng trước để app nào tách file riêng thì phép kiểm
|
|
83
|
+
// "cửa duy nhất phải gọi configureWorkspaces" nhìn đúng file đó.
|
|
84
|
+
workspace: options.doors?.workspace ??
|
|
85
|
+
options.doors?.branchScope ?? [
|
|
86
|
+
"lib/workspace.ts",
|
|
87
|
+
"lib/branch-scope.ts",
|
|
88
|
+
"lib/rbac/branch-scope.ts",
|
|
89
|
+
"lib/prisma.ts",
|
|
90
|
+
],
|
|
82
91
|
},
|
|
83
92
|
};
|
|
84
93
|
}
|
package/src/guardrails/types.ts
CHANGED
|
@@ -137,6 +137,12 @@ export interface GuardrailOptions {
|
|
|
137
137
|
storage?: string[];
|
|
138
138
|
/** File hạ tầng được import `@goerp/core/branch-scope`. */
|
|
139
139
|
branchScope?: string[];
|
|
140
|
+
/**
|
|
141
|
+
* File hạ tầng được import `@goerp/core/workspace`. Mặc định gồm cả các cửa
|
|
142
|
+
* của `branchScope` — hai module chạy chung MỘT AsyncLocalStorage, nên app
|
|
143
|
+
* cắm chúng ở cùng một composition root là chuyện thường.
|
|
144
|
+
*/
|
|
145
|
+
workspace?: string[];
|
|
140
146
|
};
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -149,6 +155,7 @@ export type ResolvedGuardrailOptions = GuardrailOptions &
|
|
|
149
155
|
prisma: string;
|
|
150
156
|
storage: string[];
|
|
151
157
|
branchScope: string[];
|
|
158
|
+
workspace: string[];
|
|
152
159
|
};
|
|
153
160
|
};
|
|
154
161
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
configureWorkspaces,
|
|
5
|
+
createWorkspaceScope,
|
|
6
|
+
resetWorkspaceConfig,
|
|
7
|
+
} from "../../workspace/scope";
|
|
8
|
+
import {
|
|
9
|
+
getUsersData,
|
|
10
|
+
getUserStats,
|
|
11
|
+
getUsersWithDetails,
|
|
12
|
+
} from "../user-service";
|
|
13
|
+
import type { UserPrismaClient } from "../user-service";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lỗ hổng đang được vá ở đây: danh sách người dùng của core dựng `where` từ
|
|
17
|
+
* search/status/roleCode và KHÔNG có điều kiện phạm vi nào — admin nhánh của
|
|
18
|
+
* khách hàng A nhìn thấy toàn bộ người dùng của khách hàng B.
|
|
19
|
+
*
|
|
20
|
+
* Test soi thẳng `where` gửi xuống Prisma chứ không soi kết quả: kết quả là do
|
|
21
|
+
* DB giả trả, còn `where` mới là thứ chạy trên DB thật.
|
|
22
|
+
*/
|
|
23
|
+
function spyDb() {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
const calls: { fn: string; where: any }[] = [];
|
|
26
|
+
const db = {
|
|
27
|
+
user: {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
findMany: async (args: any) => {
|
|
30
|
+
calls.push({ fn: "user.findMany", where: args.where });
|
|
31
|
+
return [];
|
|
32
|
+
},
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
count: async (args?: any) => {
|
|
35
|
+
calls.push({ fn: "user.count", where: args?.where });
|
|
36
|
+
return 0;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
role: { count: async () => 0, findMany: async () => [] },
|
|
40
|
+
department: { findMany: async () => [] },
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
$transaction: async (args: any) => args,
|
|
43
|
+
} as unknown as UserPrismaClient;
|
|
44
|
+
return { db, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SCOPE_WHERE = {
|
|
48
|
+
userBranches: { some: { branchId: { in: ["spa", "spa-qc"] } } },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function scopeOfSpa() {
|
|
52
|
+
return createWorkspaceScope({
|
|
53
|
+
canViewAll: false,
|
|
54
|
+
rootIds: ["spa"],
|
|
55
|
+
allowedIds: ["spa", "spa-qc"],
|
|
56
|
+
adminIds: ["spa"],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
resetWorkspaceConfig();
|
|
62
|
+
configureWorkspaces({
|
|
63
|
+
getUserId: () => undefined,
|
|
64
|
+
canViewAll: () => false,
|
|
65
|
+
membershipRelation: "userBranches",
|
|
66
|
+
membershipField: "branchId",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("getUsersData", () => {
|
|
71
|
+
it("không truyền scope ⇒ where y hệt hôm nay (L2: additive)", async () => {
|
|
72
|
+
const { db, calls } = spyDb();
|
|
73
|
+
await getUsersData(db, { search: "an" });
|
|
74
|
+
expect(JSON.stringify(calls[0].where)).not.toContain("userBranches");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("truyền scope ⇒ điều kiện phạm vi vào CẢ findMany lẫn count", async () => {
|
|
78
|
+
const { db, calls } = spyDb();
|
|
79
|
+
await getUsersData(db, { scope: scopeOfSpa() });
|
|
80
|
+
const find = calls.find((c) => c.fn === "user.findMany")!;
|
|
81
|
+
const count = calls.find((c) => c.fn === "user.count")!;
|
|
82
|
+
expect(find.where.AND).toContainEqual(SCOPE_WHERE);
|
|
83
|
+
expect(count.where.AND).toContainEqual(SCOPE_WHERE);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("phạm vi cộng THÊM vào bộ lọc, không thay thế", async () => {
|
|
87
|
+
const { db, calls } = spyDb();
|
|
88
|
+
await getUsersData(db, {
|
|
89
|
+
search: "an",
|
|
90
|
+
status: "active",
|
|
91
|
+
scope: scopeOfSpa(),
|
|
92
|
+
});
|
|
93
|
+
expect(calls[0].where.AND).toHaveLength(3);
|
|
94
|
+
expect(calls[0].where.AND).toContainEqual(SCOPE_WHERE);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("xem được tất ⇒ không thêm điều kiện", async () => {
|
|
98
|
+
const { db, calls } = spyDb();
|
|
99
|
+
await getUsersData(db, {
|
|
100
|
+
scope: createWorkspaceScope({ canViewAll: true }),
|
|
101
|
+
});
|
|
102
|
+
expect(JSON.stringify(calls[0].where)).not.toContain("userBranches");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("adminOnly thu hẹp về nhánh được uỷ quyền", async () => {
|
|
106
|
+
const { db, calls } = spyDb();
|
|
107
|
+
await getUsersData(db, { scope: scopeOfSpa(), adminOnly: true });
|
|
108
|
+
expect(calls[0].where.AND).toContainEqual({
|
|
109
|
+
userBranches: { some: { branchId: { in: ["spa"] } } },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("getUsersWithDetails — kể cả các ô thống kê", () => {
|
|
115
|
+
it("MỌI truy vấn đếm đều mang phạm vi, không sót ô nào", async () => {
|
|
116
|
+
const { db, calls } = spyDb();
|
|
117
|
+
await getUsersWithDetails(db, { scope: scopeOfSpa() });
|
|
118
|
+
const userQueries = calls.filter((c) => c.fn.startsWith("user."));
|
|
119
|
+
expect(userQueries.length).toBeGreaterThan(3);
|
|
120
|
+
for (const call of userQueries) {
|
|
121
|
+
expect(JSON.stringify(call.where)).toContain("userBranches");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("không có scope thì không truy vấn nào bị thêm điều kiện", async () => {
|
|
126
|
+
const { db, calls } = spyDb();
|
|
127
|
+
await getUsersWithDetails(db, {});
|
|
128
|
+
for (const call of calls) {
|
|
129
|
+
expect(JSON.stringify(call.where ?? {})).not.toContain("userBranches");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("getUserStats", () => {
|
|
135
|
+
it("đếm trong phạm vi khi được truyền scope", async () => {
|
|
136
|
+
const { db, calls } = spyDb();
|
|
137
|
+
await getUserStats(db, scopeOfSpa());
|
|
138
|
+
for (const call of calls.filter((c) => c.fn === "user.count")) {
|
|
139
|
+
expect(JSON.stringify(call.where)).toContain("userBranches");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("không scope ⇒ đếm toàn hệ thống như cũ", async () => {
|
|
144
|
+
const { db, calls } = spyDb();
|
|
145
|
+
await getUserStats(db);
|
|
146
|
+
expect(calls.find((c) => c.fn === "user.count")!.where).toEqual({});
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/user/user-service.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { memberScopeWhere } from "../workspace/scope";
|
|
2
|
+
|
|
3
|
+
import type { WorkspaceScope } from "../workspace/types";
|
|
1
4
|
import type { CrudResponse } from "../types";
|
|
2
5
|
|
|
3
6
|
// Define the shape of the Prisma Client required by this service
|
|
@@ -16,6 +19,24 @@ export interface GetUsersParams {
|
|
|
16
19
|
status?: string;
|
|
17
20
|
roleCode?: string;
|
|
18
21
|
departmentId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Phạm vi không gian của người ĐANG XEM. Bỏ trống = không lọc, giữ nguyên hành
|
|
24
|
+
* vi cũ cho app một-tổ-chức (luật L2: additive).
|
|
25
|
+
*
|
|
26
|
+
* App nhiều tổ chức PHẢI truyền: thiếu nó thì admin nhánh khách hàng A nhìn
|
|
27
|
+
* thấy toàn bộ người dùng của khách hàng B. Lọc ở tầng service chứ không ở
|
|
28
|
+
* tầng page — Entra từng thừa nhận đúng lỗi này (UI lọc, API thì không).
|
|
29
|
+
*/
|
|
30
|
+
scope?: WorkspaceScope;
|
|
31
|
+
/** Chỉ hiện người dùng trong nhánh mình QUẢN TRỊ, không phải mọi nhánh nhìn được. */
|
|
32
|
+
adminOnly?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Điều kiện phạm vi cho danh sách người dùng; `null` khi không phải lọc. */
|
|
36
|
+
function scopeCondition(params: GetUsersParams): Record<string, unknown> | null {
|
|
37
|
+
if (!params.scope) return null;
|
|
38
|
+
const where = memberScopeWhere(params.scope, { adminOnly: params.adminOnly });
|
|
39
|
+
return Object.keys(where).length > 0 ? where : null;
|
|
19
40
|
}
|
|
20
41
|
|
|
21
42
|
export interface UserWithDetails {
|
|
@@ -82,6 +103,9 @@ export async function getUsersData(
|
|
|
82
103
|
});
|
|
83
104
|
}
|
|
84
105
|
|
|
106
|
+
const scoped = scopeCondition(params);
|
|
107
|
+
if (scoped) whereConditions.push(scoped);
|
|
108
|
+
|
|
85
109
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
86
110
|
|
|
87
111
|
// ⚡ Bolt: Execute independent read queries concurrently to reduce latency
|
|
@@ -252,8 +276,15 @@ export async function getUsersWithDetails(
|
|
|
252
276
|
});
|
|
253
277
|
}
|
|
254
278
|
|
|
279
|
+
const scoped = scopeCondition(params);
|
|
280
|
+
if (scoped) whereConditions.push(scoped);
|
|
281
|
+
|
|
255
282
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
256
283
|
|
|
284
|
+
/** Đếm KHÔNG theo bộ lọc nhưng VẪN theo phạm vi. */
|
|
285
|
+
const scopedCount = (extra: Record<string, unknown>): any =>
|
|
286
|
+
scoped ? { AND: [extra, scoped] } : extra;
|
|
287
|
+
|
|
257
288
|
// ⚡ Bolt: Execute independent read queries concurrently to reduce latency
|
|
258
289
|
const [users, total, activeCount, rolesCount, customers, suppliers] =
|
|
259
290
|
await Promise.all([
|
|
@@ -327,13 +358,22 @@ export async function getUsersWithDetails(
|
|
|
327
358
|
db.user.count({ where }),
|
|
328
359
|
db.user.count({ where: { ...where, isActive: true } }),
|
|
329
360
|
db.role.count({ where: { status: "active" } }),
|
|
330
|
-
|
|
331
|
-
|
|
361
|
+
// Hai ô thống kê này cố ý BỎ QUA bộ lọc tìm kiếm, nên phải tự cộng lại
|
|
362
|
+
// điều kiện phạm vi — không thì con số vẫn đếm cả người dùng của tổ chức
|
|
363
|
+
// khác. Rò một con số vẫn là rò.
|
|
364
|
+
db.user
|
|
365
|
+
.count({ where: scopedCount({ userType: "customer" }) })
|
|
366
|
+
.catch(() => 0),
|
|
367
|
+
db.user
|
|
368
|
+
.count({ where: scopedCount({ userType: "supplier" }) })
|
|
369
|
+
.catch(() => 0),
|
|
332
370
|
]);
|
|
333
371
|
|
|
334
|
-
//
|
|
335
|
-
const totalUsersCount = await db.user.count();
|
|
336
|
-
const totalActiveCount = await db.user.count({
|
|
372
|
+
// Tổng số người dùng — bỏ bộ lọc nhưng KHÔNG bỏ phạm vi.
|
|
373
|
+
const totalUsersCount = await db.user.count({ where: scoped ?? {} });
|
|
374
|
+
const totalActiveCount = await db.user.count({
|
|
375
|
+
where: scopedCount({ isActive: true }),
|
|
376
|
+
});
|
|
337
377
|
const employees = Math.max(0, totalUsersCount - customers - suppliers);
|
|
338
378
|
|
|
339
379
|
// Transform data
|
|
@@ -420,13 +460,27 @@ export async function getActiveDepartments(db: UserPrismaClient) {
|
|
|
420
460
|
/**
|
|
421
461
|
* Get user statistics
|
|
422
462
|
*/
|
|
423
|
-
export async function getUserStats(
|
|
463
|
+
export async function getUserStats(
|
|
464
|
+
db: UserPrismaClient,
|
|
465
|
+
/** Bỏ trống = đếm toàn hệ thống (hành vi cũ). Truyền vào để đếm trong phạm vi. */
|
|
466
|
+
scope?: WorkspaceScope,
|
|
467
|
+
) {
|
|
468
|
+
const scoped = scope ? scopeCondition({ scope }) : null;
|
|
469
|
+
const withScope = (extra?: Record<string, unknown>): any => {
|
|
470
|
+
if (!scoped) return extra ?? {};
|
|
471
|
+
return extra ? { AND: [extra, scoped] } : scoped;
|
|
472
|
+
};
|
|
473
|
+
|
|
424
474
|
const [totalUsers, activeUsers, customers, suppliers, totalRoles] =
|
|
425
475
|
await Promise.all([
|
|
426
|
-
db.user.count(),
|
|
427
|
-
db.user.count({ where: { isActive: true } }),
|
|
428
|
-
db.user
|
|
429
|
-
|
|
476
|
+
db.user.count({ where: withScope() }),
|
|
477
|
+
db.user.count({ where: withScope({ isActive: true }) }),
|
|
478
|
+
db.user
|
|
479
|
+
.count({ where: withScope({ userType: "customer" }) })
|
|
480
|
+
.catch(() => 0),
|
|
481
|
+
db.user
|
|
482
|
+
.count({ where: withScope({ userType: "supplier" }) })
|
|
483
|
+
.catch(() => 0),
|
|
430
484
|
db.role.count({ where: { status: "active" } }),
|
|
431
485
|
]);
|
|
432
486
|
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyPermissionCeiling,
|
|
5
|
+
assertBoundaryIntact,
|
|
6
|
+
assertCanCreateUser,
|
|
7
|
+
assertCanUpdateUser,
|
|
8
|
+
assertFullyOwned,
|
|
9
|
+
assertNotProtected,
|
|
10
|
+
assertNotSelf,
|
|
11
|
+
assertRoleAssignable,
|
|
12
|
+
assertWorkspacesInScope,
|
|
13
|
+
assignableRoles,
|
|
14
|
+
canDelegateUsers,
|
|
15
|
+
DelegationError,
|
|
16
|
+
isDangerousPermission,
|
|
17
|
+
} from "../delegation";
|
|
18
|
+
import {
|
|
19
|
+
configureWorkspaces,
|
|
20
|
+
createWorkspaceScope,
|
|
21
|
+
resetWorkspaceConfig,
|
|
22
|
+
} from "../scope";
|
|
23
|
+
import type {
|
|
24
|
+
DelegationActor,
|
|
25
|
+
DelegationRole,
|
|
26
|
+
DelegationTarget,
|
|
27
|
+
} from "../delegation";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bối cảnh: Tấn Lộc vận hành (toàn quyền), Spartronics là khách hàng chỉ quản
|
|
31
|
+
* trị nhánh `/spa/` của mình — gồm hai phòng ban con.
|
|
32
|
+
*/
|
|
33
|
+
function customerAdmin(over: Partial<DelegationActor> = {}): DelegationActor {
|
|
34
|
+
return {
|
|
35
|
+
userId: "u-spa-admin",
|
|
36
|
+
scope: createWorkspaceScope({
|
|
37
|
+
canViewAll: false,
|
|
38
|
+
rootIds: ["spa"],
|
|
39
|
+
allowedIds: ["spa", "spa-qc", "spa-kho"],
|
|
40
|
+
adminIds: ["spa", "spa-qc", "spa-kho"],
|
|
41
|
+
}),
|
|
42
|
+
canManageAll: false,
|
|
43
|
+
permissions: new Set([
|
|
44
|
+
"user:view",
|
|
45
|
+
"user:create",
|
|
46
|
+
"user:update",
|
|
47
|
+
"meal-order:view",
|
|
48
|
+
"meal-order:create",
|
|
49
|
+
]),
|
|
50
|
+
rank: 50,
|
|
51
|
+
...over,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function opsAdmin(): DelegationActor {
|
|
56
|
+
return {
|
|
57
|
+
userId: "u-ops",
|
|
58
|
+
scope: createWorkspaceScope({ canViewAll: true }),
|
|
59
|
+
canManageAll: true,
|
|
60
|
+
permissions: new Set(),
|
|
61
|
+
rank: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function target(over: Partial<DelegationTarget> = {}): DelegationTarget {
|
|
66
|
+
return { id: "u-nv", workspaceIds: ["spa-qc"], ...over };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function role(over: Partial<DelegationRole> = {}): DelegationRole {
|
|
70
|
+
return {
|
|
71
|
+
id: "r-nv",
|
|
72
|
+
code: "NHAN_VIEN",
|
|
73
|
+
rank: 100,
|
|
74
|
+
permissions: ["meal-order:view", "meal-order:create"],
|
|
75
|
+
...over,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
resetWorkspaceConfig();
|
|
81
|
+
configureWorkspaces({ getUserId: () => undefined, canViewAll: () => false });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("D1 — chứa trong nhánh", () => {
|
|
85
|
+
it("gán trong nhánh mình thì qua", () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
assertWorkspacesInScope(customerAdmin(), ["spa-qc", "spa-kho"]),
|
|
88
|
+
).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("gán workspace ngoài nhánh thì NÉM, không lặng lẽ cắt bớt", () => {
|
|
92
|
+
try {
|
|
93
|
+
assertWorkspacesInScope(customerAdmin(), ["spa-qc", "tanloc"]);
|
|
94
|
+
throw new Error("đáng lẽ phải ném");
|
|
95
|
+
} catch (error) {
|
|
96
|
+
expect(error).toBeInstanceOf(DelegationError);
|
|
97
|
+
expect((error as DelegationError).code).toBe("D1_OUT_OF_SCOPE");
|
|
98
|
+
expect((error as DelegationError).message).toContain("tanloc");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("người không được uỷ quyền ở đâu cả thì chặn ngay", () => {
|
|
103
|
+
const nobody = customerAdmin({
|
|
104
|
+
scope: createWorkspaceScope({
|
|
105
|
+
canViewAll: false,
|
|
106
|
+
rootIds: ["spa"],
|
|
107
|
+
allowedIds: ["spa"],
|
|
108
|
+
adminIds: [],
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
expect(() => assertWorkspacesInScope(nobody, ["spa"])).toThrow(
|
|
112
|
+
/không được uỷ quyền/,
|
|
113
|
+
);
|
|
114
|
+
expect(canDelegateUsers(nobody)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("toàn quyền đi qua mọi thứ", () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
assertWorkspacesInScope(opsAdmin(), ["bat-ky", "cai-gi-do"]),
|
|
120
|
+
).not.toThrow();
|
|
121
|
+
expect(canDelegateUsers(opsAdmin())).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("D3 — không tự nâng", () => {
|
|
126
|
+
it("sửa chính mình thì chặn", () => {
|
|
127
|
+
expect(() => assertNotSelf(customerAdmin(), "u-spa-admin")).toThrow(
|
|
128
|
+
DelegationError,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("sửa người khác thì qua", () => {
|
|
133
|
+
expect(() => assertNotSelf(customerAdmin(), "u-nv")).not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("toàn quyền tự sửa mình được", () => {
|
|
137
|
+
expect(() => assertNotSelf(opsAdmin(), "u-ops")).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("D5 — sửa được thì phải sở hữu TRỌN", () => {
|
|
142
|
+
it("mọi membership nằm trong nhánh thì qua", () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
assertFullyOwned(customerAdmin(), target({ workspaceIds: ["spa-qc"] })),
|
|
145
|
+
).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("người VẮT ra ngoài nhánh: đọc được, sửa KHÔNG được", () => {
|
|
149
|
+
try {
|
|
150
|
+
assertFullyOwned(
|
|
151
|
+
customerAdmin(),
|
|
152
|
+
target({ workspaceIds: ["spa-qc", "tanloc"] }),
|
|
153
|
+
);
|
|
154
|
+
throw new Error("đáng lẽ phải ném");
|
|
155
|
+
} catch (error) {
|
|
156
|
+
expect((error as DelegationError).code).toBe("D5_NOT_FULLY_OWNED");
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("người chưa thuộc không gian nào thì không ai ngoài toàn quyền đụng được", () => {
|
|
161
|
+
expect(() =>
|
|
162
|
+
assertFullyOwned(customerAdmin(), target({ workspaceIds: [] })),
|
|
163
|
+
).toThrow(DelegationError);
|
|
164
|
+
expect(() =>
|
|
165
|
+
assertFullyOwned(opsAdmin(), target({ workspaceIds: [] })),
|
|
166
|
+
).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("D6 — tài khoản được bảo vệ", () => {
|
|
171
|
+
it("chặn dù CÙNG workspace và D5 đã thoả", () => {
|
|
172
|
+
const protectedOps = target({
|
|
173
|
+
id: "u-ops-support",
|
|
174
|
+
workspaceIds: ["spa-qc"],
|
|
175
|
+
isProtected: true,
|
|
176
|
+
});
|
|
177
|
+
// D5 qua…
|
|
178
|
+
expect(() => assertFullyOwned(customerAdmin(), protectedOps)).not.toThrow();
|
|
179
|
+
// …nhưng D6 chặn. Đây chính là lỗ mà D5 một mình không bịt được.
|
|
180
|
+
expect(() => assertNotProtected(customerAdmin(), protectedOps)).toThrow(
|
|
181
|
+
DelegationError,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("toàn quyền vẫn thao tác được", () => {
|
|
186
|
+
expect(() =>
|
|
187
|
+
assertNotProtected(opsAdmin(), target({ isProtected: true })),
|
|
188
|
+
).not.toThrow();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("D2 + D4 — vai trò gán được", () => {
|
|
193
|
+
it("vai trò nằm trọn trong quyền của mình và rank thấp hơn thì qua", () => {
|
|
194
|
+
expect(() => assertRoleAssignable(customerAdmin(), role())).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("vai trò chứa quyền mình KHÔNG có thì chặn (trần quyền)", () => {
|
|
198
|
+
expect(() =>
|
|
199
|
+
assertRoleAssignable(
|
|
200
|
+
customerAdmin(),
|
|
201
|
+
role({ permissions: ["meal-order:view", "payment:approve"] }),
|
|
202
|
+
),
|
|
203
|
+
).toThrow(/payment:approve/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("vai trò chứa action nguy hiểm thì chặn KỂ CẢ khi trần quyền thoả", () => {
|
|
207
|
+
const actor = customerAdmin({
|
|
208
|
+
permissions: new Set(["role:update", "user:view"]),
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
assertRoleAssignable(actor, role({ permissions: ["role:update"] }));
|
|
212
|
+
throw new Error("đáng lẽ phải ném");
|
|
213
|
+
} catch (error) {
|
|
214
|
+
expect((error as DelegationError).code).toBe("D4_DANGEROUS_ROLE");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("hậu tố view-all-* là nguy hiểm bất kể resource nào mang nó", () => {
|
|
219
|
+
expect(isDangerousPermission("user:view-all-workspaces")).toBe(true);
|
|
220
|
+
expect(isDangerousPermission("sales-order:view-all-branches")).toBe(true);
|
|
221
|
+
expect(isDangerousPermission("meal-order:view")).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("vai trò hệ thống thì chặn", () => {
|
|
225
|
+
expect(() =>
|
|
226
|
+
assertRoleAssignable(customerAdmin(), role({ isSystem: true })),
|
|
227
|
+
).toThrow(/vai trò hệ thống/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("vai trò ngang hoặc cao hơn cấp mình thì chặn", () => {
|
|
231
|
+
expect(() =>
|
|
232
|
+
assertRoleAssignable(customerAdmin(), role({ rank: 50 })),
|
|
233
|
+
).toThrow(/ngang hoặc cao hơn/);
|
|
234
|
+
expect(() =>
|
|
235
|
+
assertRoleAssignable(customerAdmin(), role({ rank: 10 })),
|
|
236
|
+
).toThrow(/ngang hoặc cao hơn/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("vai trò thuộc nhánh khác thì chặn", () => {
|
|
240
|
+
expect(() =>
|
|
241
|
+
assertRoleAssignable(customerAdmin(), role({ workspaceId: "tanloc" })),
|
|
242
|
+
).toThrow(/ngoài phạm vi/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("assignableRoles lọc đúng cái picker được phép hiện", () => {
|
|
246
|
+
const roles = [
|
|
247
|
+
role({ id: "ok", code: "NV" }),
|
|
248
|
+
role({ id: "cao", code: "QL", rank: 10 }),
|
|
249
|
+
role({ id: "nguy", code: "ADMIN", permissions: ["role:update"] }),
|
|
250
|
+
role({ id: "vuot", code: "KT", permissions: ["payment:approve"] }),
|
|
251
|
+
];
|
|
252
|
+
expect(assignableRoles(customerAdmin(), roles).map((r) => r.id)).toEqual([
|
|
253
|
+
"ok",
|
|
254
|
+
]);
|
|
255
|
+
expect(assignableRoles(opsAdmin(), roles)).toHaveLength(4);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("D2 bản CHẠY — trần quyền là phép GIAO", () => {
|
|
260
|
+
it("không có trần thì giữ nguyên quyền vai trò", () => {
|
|
261
|
+
expect(applyPermissionCeiling(["a:x", "b:y"], null)).toEqual([
|
|
262
|
+
"a:x",
|
|
263
|
+
"b:y",
|
|
264
|
+
]);
|
|
265
|
+
expect(applyPermissionCeiling(["a:x"], undefined)).toEqual(["a:x"]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("có trần thì cắt phần vượt", () => {
|
|
269
|
+
expect(applyPermissionCeiling(["a:x", "b:y"], ["a:x", "c:z"])).toEqual([
|
|
270
|
+
"a:x",
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("trần rỗng khác trần null: rỗng = không còn quyền nào", () => {
|
|
275
|
+
expect(applyPermissionCeiling(["a:x"], [])).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("D7 — không tháo được rào", () => {
|
|
280
|
+
const base = target({
|
|
281
|
+
workspaceIds: ["spa-qc"],
|
|
282
|
+
permissionCeilingRoleId: "r-tran",
|
|
283
|
+
isProtected: false,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("sửa trần quyền của người khác thì chặn", () => {
|
|
287
|
+
expect(() =>
|
|
288
|
+
assertBoundaryIntact(customerAdmin(), base, {
|
|
289
|
+
permissionCeilingRoleId: null,
|
|
290
|
+
}),
|
|
291
|
+
).toThrow(/trần quyền/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("gửi lại đúng giá trị cũ thì không tính là sửa", () => {
|
|
295
|
+
expect(() =>
|
|
296
|
+
assertBoundaryIntact(customerAdmin(), base, {
|
|
297
|
+
permissionCeilingRoleId: "r-tran",
|
|
298
|
+
}),
|
|
299
|
+
).not.toThrow();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("tự bật cờ bảo vệ thì chặn", () => {
|
|
303
|
+
expect(() =>
|
|
304
|
+
assertBoundaryIntact(customerAdmin(), base, { isProtected: true }),
|
|
305
|
+
).toThrow(/trạng thái bảo vệ/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("gỡ membership NGOÀI nhánh để đẩy nạn nhân ra rồi thao tác tiếp → chặn", () => {
|
|
309
|
+
const crossed = target({ workspaceIds: ["spa-qc", "tanloc"] });
|
|
310
|
+
try {
|
|
311
|
+
assertBoundaryIntact(customerAdmin(), crossed, {
|
|
312
|
+
workspaceIds: ["spa-qc"],
|
|
313
|
+
});
|
|
314
|
+
throw new Error("đáng lẽ phải ném");
|
|
315
|
+
} catch (error) {
|
|
316
|
+
expect((error as DelegationError).code).toBe("D7_BOUNDARY_TAMPER");
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("gỡ membership TRONG nhánh mình thì được", () => {
|
|
321
|
+
const both = target({ workspaceIds: ["spa-qc", "spa-kho"] });
|
|
322
|
+
expect(() =>
|
|
323
|
+
assertBoundaryIntact(customerAdmin(), both, { workspaceIds: ["spa-qc"] }),
|
|
324
|
+
).not.toThrow();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("cổng tổng hợp", () => {
|
|
329
|
+
it("assertCanUpdateUser chạy đủ D3/D5/D6/D7/D1", () => {
|
|
330
|
+
const actor = customerAdmin();
|
|
331
|
+
expect(() =>
|
|
332
|
+
assertCanUpdateUser(actor, target(), { workspaceIds: ["spa-kho"] }),
|
|
333
|
+
).not.toThrow();
|
|
334
|
+
|
|
335
|
+
// D3
|
|
336
|
+
expect(() =>
|
|
337
|
+
assertCanUpdateUser(actor, target({ id: actor.userId })),
|
|
338
|
+
).toThrow(/chính mình/);
|
|
339
|
+
// D6 chạy TRƯỚC D5 — thông báo phải nói về tài khoản bảo vệ, không phải phạm vi
|
|
340
|
+
expect(() =>
|
|
341
|
+
assertCanUpdateUser(actor, target({ isProtected: true })),
|
|
342
|
+
).toThrow(/được bảo vệ/);
|
|
343
|
+
// D1 trên danh sách mới
|
|
344
|
+
expect(() =>
|
|
345
|
+
assertCanUpdateUser(actor, target(), { workspaceIds: ["tanloc"] }),
|
|
346
|
+
).toThrow(DelegationError);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("assertCanCreateUser bắt buộc chọn ít nhất một không gian trong nhánh", () => {
|
|
350
|
+
const actor = customerAdmin();
|
|
351
|
+
expect(() =>
|
|
352
|
+
assertCanCreateUser(actor, ["spa-qc"], [role()]),
|
|
353
|
+
).not.toThrow();
|
|
354
|
+
expect(() => assertCanCreateUser(actor, [])).toThrow(
|
|
355
|
+
/ít nhất một không gian/,
|
|
356
|
+
);
|
|
357
|
+
expect(() =>
|
|
358
|
+
assertCanCreateUser(actor, ["spa-qc"], [role({ rank: 10 })]),
|
|
359
|
+
).toThrow(DelegationError);
|
|
360
|
+
// Toàn quyền được phép tạo user chưa gắn không gian nào.
|
|
361
|
+
expect(() => assertCanCreateUser(opsAdmin(), [])).not.toThrow();
|
|
362
|
+
});
|
|
363
|
+
});
|