@goplusvn/core 0.1.74 → 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/ui/auth/auth-layout.tsx +106 -82
- 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
|
@@ -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
|
+
});
|