@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  17. package/src/user/user-service.ts +64 -10
  18. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  19. package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
  20. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  21. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  22. package/src/workspace/components/scope-level-select.tsx +91 -0
  23. package/src/workspace/components/workspace-switcher.tsx +139 -0
  24. package/src/workspace/components/workspace-tree-view.tsx +260 -0
  25. package/src/workspace/context.ts +78 -0
  26. package/src/workspace/delegation.ts +400 -0
  27. package/src/workspace/guard.ts +138 -0
  28. package/src/workspace/index.ts +157 -0
  29. package/src/workspace/pages/workspace-list-page.tsx +430 -0
  30. package/src/workspace/route-handlers.ts +274 -0
  31. package/src/workspace/scope.ts +396 -0
  32. package/src/workspace/service.ts +301 -0
  33. package/src/workspace/tree.ts +193 -0
  34. package/src/workspace/types.ts +182 -0
@@ -0,0 +1,414 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Kiểm tra tầng CƯỠNG CHẾ, không phải tầng hiển thị.
3
+ //
4
+ // Trang cây đã ẩn nút ở nhánh ngoài phạm vi, nhưng ẩn nút không phải hàng rào.
5
+ // Mọi ca ở đây gọi thẳng handler như một client tự chế: không đi qua UI, không
6
+ // tin gì từ client. Sai ở đây là rò dữ liệu chéo khách hàng một cách IM LẶNG —
7
+ // không ném lỗi, không log, chỉ là ai đó thấy thứ không được thấy.
8
+
9
+ import { beforeEach, describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ createWorkspaceCollectionHandlers,
13
+ createWorkspaceItemHandlers,
14
+ createWorkspaceMoveHandler,
15
+ } from "../route-handlers";
16
+ import { configureWorkspaces, resetWorkspaceConfig } from "../scope";
17
+ import { subtreePrefix } from "../tree";
18
+
19
+ // Singleton cấu hình — reset trước mọi ca để một test không thừa hưởng cây của
20
+ // test trước (hook cấp gốc chạy trước hook trong `describe`).
21
+ beforeEach(() => {
22
+ resetWorkspaceConfig();
23
+ });
24
+
25
+ /**
26
+ * Cây mẫu theo đúng bài toán spartronics:
27
+ *
28
+ * grp (tập đoàn — chỉ toàn quyền đụng tới)
29
+ * ├── tanloc (nhà ăn Tấn Lộc — vận hành)
30
+ * └── spa (Spartronics — khách hàng)
31
+ * └── spa-qc (phòng QC của khách hàng)
32
+ */
33
+ function seedRows() {
34
+ return [
35
+ {
36
+ id: "grp",
37
+ code: "GRP",
38
+ name: "Tập đoàn",
39
+ kind: "org",
40
+ parentId: null,
41
+ path: "/grp/",
42
+ depth: 1,
43
+ isActive: true,
44
+ },
45
+ {
46
+ id: "tanloc",
47
+ code: "TL",
48
+ name: "Nhà ăn Tấn Lộc",
49
+ kind: "unit",
50
+ parentId: "grp",
51
+ path: "/grp/tanloc/",
52
+ depth: 2,
53
+ isActive: true,
54
+ },
55
+ {
56
+ id: "spa",
57
+ code: "SPA",
58
+ name: "Spartronics",
59
+ kind: "unit",
60
+ parentId: "grp",
61
+ path: "/grp/spa/",
62
+ depth: 2,
63
+ isActive: true,
64
+ },
65
+ {
66
+ id: "spa-qc",
67
+ code: "SPA-QC",
68
+ name: "Spartronics QC",
69
+ kind: "department",
70
+ parentId: "spa",
71
+ path: "/grp/spa/spa-qc/",
72
+ depth: 3,
73
+ isActive: true,
74
+ },
75
+ ];
76
+ }
77
+
78
+ interface Call {
79
+ fn: string;
80
+ args: any;
81
+ }
82
+
83
+ /**
84
+ * Prisma giả — ghi lại mọi lời gọi để test soi được `where`/`data` thật sự gửi
85
+ * xuống DB, chứ không chỉ soi thứ handler trả về.
86
+ */
87
+ function fakeDb() {
88
+ const rows = seedRows();
89
+ const calls: Call[] = [];
90
+
91
+ return {
92
+ calls,
93
+ rows,
94
+ workspace: {
95
+ async findMany(args: any = {}) {
96
+ calls.push({ fn: "findMany", args });
97
+ const startsWith = args?.where?.path?.startsWith;
98
+ const list = startsWith
99
+ ? rows.filter((r) => r.path.startsWith(startsWith))
100
+ : rows;
101
+ return list.map((r) => ({ ...r, _count: { members: 2 } }));
102
+ },
103
+ async findUnique(args: any) {
104
+ calls.push({ fn: "findUnique", args });
105
+ return rows.find((r) => r.id === args?.where?.id) ?? null;
106
+ },
107
+ async create(args: any) {
108
+ calls.push({ fn: "create", args });
109
+ const row = { isActive: true, ...args.data };
110
+ rows.push(row);
111
+ return row;
112
+ },
113
+ async update(args: any) {
114
+ calls.push({ fn: "update", args });
115
+ const row = rows.find((r) => r.id === args?.where?.id);
116
+ if (row) Object.assign(row, args.data);
117
+ return row;
118
+ },
119
+ async count(args: any = {}) {
120
+ calls.push({ fn: "count", args });
121
+ return rows.filter((r) => r.parentId === args?.where?.parentId).length;
122
+ },
123
+ },
124
+ };
125
+ }
126
+
127
+ type Session = { userId: string; memberships: string[]; viewAll?: boolean };
128
+
129
+ /** Quản trị viên phía khách hàng: chỉ nhánh `spa`. */
130
+ const customerAdmin: Session = { userId: "u-spa", memberships: ["spa"] };
131
+ /** Vận hành Tấn Lộc — toàn quyền. */
132
+ const opsAdmin: Session = { userId: "u-ops", memberships: [], viewAll: true };
133
+ /** Người dùng thường của khách hàng: thuộc `spa` nhưng KHÔNG quản trị. */
134
+ const customerUser: Session = { userId: "u-plain", memberships: [] };
135
+
136
+ function configure(db: ReturnType<typeof fakeDb>) {
137
+ configureWorkspaces<Session>({
138
+ canViewAll: (session) => Boolean(session?.viewAll),
139
+ getUserId: (session) => session?.userId ?? null,
140
+ getMemberships: (session) =>
141
+ (session?.memberships ?? []).map((workspaceId) => ({
142
+ workspaceId,
143
+ isAdmin: true,
144
+ })),
145
+ expandDescendants: async (rootIds) => {
146
+ const prefixes = db.rows
147
+ .filter((r) => rootIds.includes(r.id))
148
+ .map((r) => subtreePrefix(r.path));
149
+ return db.rows
150
+ .filter((r) => prefixes.some((p) => r.path.startsWith(p)))
151
+ .map((r) => r.id);
152
+ },
153
+ });
154
+ }
155
+
156
+ function deps(db: ReturnType<typeof fakeDb>, session: Session | null) {
157
+ return {
158
+ prisma: db as any,
159
+ getSession: () => session,
160
+ canManageAll: (s: Session) => Boolean(s?.viewAll),
161
+ };
162
+ }
163
+
164
+ const post = (body: unknown) =>
165
+ new Request("http://test/api/workspaces", {
166
+ method: "POST",
167
+ body: JSON.stringify(body),
168
+ });
169
+
170
+ const patch = (body: unknown) =>
171
+ new Request("http://test/api/workspaces/x", {
172
+ method: "PATCH",
173
+ body: JSON.stringify(body),
174
+ });
175
+
176
+ describe("workspace route handlers — GET", () => {
177
+ let db: ReturnType<typeof fakeDb>;
178
+ beforeEach(() => {
179
+ db = fakeDb();
180
+ configure(db);
181
+ });
182
+
183
+ it("lọc ở SERVER: admin khách hàng không nhận được nhánh của tenant khác", async () => {
184
+ const { GET } = createWorkspaceCollectionHandlers(
185
+ deps(db, customerAdmin) as any,
186
+ );
187
+ const res = await GET(new Request("http://test/api/workspaces"));
188
+ const tree = await res.json();
189
+
190
+ // Chỉ còn `spa` (thành nút gốc vì cha `grp` đã bị lọc) và con của nó.
191
+ expect(tree).toHaveLength(1);
192
+ expect(tree[0].id).toBe("spa");
193
+ expect(tree[0].children.map((c: any) => c.id)).toEqual(["spa-qc"]);
194
+
195
+ // Không có mẩu nào của Tấn Lộc / tập đoàn lọt vào payload.
196
+ const raw = JSON.stringify(tree);
197
+ expect(raw).not.toContain("tanloc");
198
+ expect(raw).not.toContain("Tấn Lộc");
199
+ expect(raw).not.toContain("grp");
200
+ });
201
+
202
+ it("toàn quyền thì thấy cả cây", async () => {
203
+ const { GET } = createWorkspaceCollectionHandlers(
204
+ deps(db, opsAdmin) as any,
205
+ );
206
+ const tree = await (
207
+ await GET(new Request("http://test/api/workspaces"))
208
+ ).json();
209
+ expect(tree).toHaveLength(1);
210
+ expect(tree[0].id).toBe("grp");
211
+ expect(tree[0].children.map((c: any) => c.id).sort()).toEqual([
212
+ "spa",
213
+ "tanloc",
214
+ ]);
215
+ });
216
+
217
+ it("chưa đăng nhập → 401, không chạm DB", async () => {
218
+ const { GET } = createWorkspaceCollectionHandlers(deps(db, null) as any);
219
+ const res = await GET(new Request("http://test/api/workspaces"));
220
+ expect(res.status).toBe(401);
221
+ expect(db.calls).toHaveLength(0);
222
+ });
223
+ });
224
+
225
+ describe("workspace route handlers — POST", () => {
226
+ let db: ReturnType<typeof fakeDb>;
227
+ beforeEach(() => {
228
+ db = fakeDb();
229
+ configure(db);
230
+ });
231
+
232
+ it("nút GỐC chỉ dành cho toàn quyền — admin khách hàng bị chặn", async () => {
233
+ const { POST } = createWorkspaceCollectionHandlers(
234
+ deps(db, customerAdmin) as any,
235
+ );
236
+ const res = await POST(
237
+ post({ code: "X", name: "Tổ chức song song", parentId: null }),
238
+ );
239
+ expect(res.status).toBe(403);
240
+ // Chặn TRƯỚC khi ghi: không có dòng nào được tạo.
241
+ expect(db.calls.some((c) => c.fn === "create")).toBe(false);
242
+ });
243
+
244
+ it("toàn quyền tạo được nút gốc", async () => {
245
+ const { POST } = createWorkspaceCollectionHandlers(
246
+ deps(db, opsAdmin) as any,
247
+ );
248
+ const res = await POST(post({ code: "X", name: "Tổ chức mới" }));
249
+ expect(res.status).toBe(201);
250
+ });
251
+
252
+ it("tạo con dưới nhánh NGOÀI phạm vi quản trị → 403", async () => {
253
+ const { POST } = createWorkspaceCollectionHandlers(
254
+ deps(db, customerAdmin) as any,
255
+ );
256
+ const res = await POST(
257
+ post({ code: "TL-2", name: "Bếp 2", parentId: "tanloc" }),
258
+ );
259
+ expect(res.status).toBe(403);
260
+ expect(db.calls.some((c) => c.fn === "create")).toBe(false);
261
+ });
262
+
263
+ it("tạo con trong nhánh mình quản trị → 201, path tính từ cha", async () => {
264
+ const { POST } = createWorkspaceCollectionHandlers(
265
+ deps(db, customerAdmin) as any,
266
+ );
267
+ const res = await POST(
268
+ post({ code: "SPA-KHO", name: "Kho", parentId: "spa" }),
269
+ );
270
+ expect(res.status).toBe(201);
271
+ const created = await res.json();
272
+ expect(created.path.startsWith("/grp/spa/")).toBe(true);
273
+ expect(created.depth).toBe(3);
274
+ });
275
+
276
+ it("thành viên thường (không được uỷ quyền) không tạo được gì", async () => {
277
+ const { POST } = createWorkspaceCollectionHandlers(
278
+ deps(db, customerUser) as any,
279
+ );
280
+ const res = await POST(post({ code: "A", name: "A", parentId: "spa" }));
281
+ expect(res.status).toBe(403);
282
+ });
283
+ });
284
+
285
+ describe("workspace route handlers — PATCH / DELETE", () => {
286
+ let db: ReturnType<typeof fakeDb>;
287
+ beforeEach(() => {
288
+ db = fakeDb();
289
+ configure(db);
290
+ });
291
+
292
+ it("PATCH ngoài phạm vi → 403", async () => {
293
+ const { PATCH } = createWorkspaceItemHandlers(
294
+ deps(db, customerAdmin) as any,
295
+ );
296
+ const res = await PATCH(patch({ name: "Đổi tên" }), {
297
+ params: { id: "tanloc" },
298
+ });
299
+ expect(res.status).toBe(403);
300
+ expect(db.calls.some((c) => c.fn === "update")).toBe(false);
301
+ });
302
+
303
+ it("PATCH KHÔNG nhận path/depth/parentId từ body — sửa tay là làm hỏng cây", async () => {
304
+ const { PATCH } = createWorkspaceItemHandlers(
305
+ deps(db, customerAdmin) as any,
306
+ );
307
+ const res = await PATCH(
308
+ patch({
309
+ name: "Spartronics VN",
310
+ path: "/grp/",
311
+ depth: 1,
312
+ parentId: "grp",
313
+ }),
314
+ { params: { id: "spa" } },
315
+ );
316
+ expect(res.status).toBe(200);
317
+
318
+ const update = db.calls.find((c) => c.fn === "update");
319
+ expect(update?.args.data).toEqual({ name: "Spartronics VN" });
320
+ // Cây trong DB không hề suy chuyển.
321
+ expect(db.rows.find((r) => r.id === "spa")?.path).toBe("/grp/spa/");
322
+ });
323
+
324
+ it("DELETE là ngừng hoạt động CẢ NHÁNH, không xoá cứng", async () => {
325
+ const { DELETE } = createWorkspaceItemHandlers(
326
+ deps(db, customerAdmin) as any,
327
+ );
328
+ const before = db.rows.length;
329
+ const res = await DELETE(
330
+ new Request("http://test/api/workspaces/spa", { method: "DELETE" }),
331
+ { params: { id: "spa" } },
332
+ );
333
+ expect(res.status).toBe(200);
334
+ expect(await res.json()).toEqual({ deactivated: 2 });
335
+ expect(db.rows).toHaveLength(before);
336
+ expect(db.rows.find((r) => r.id === "spa")?.isActive).toBe(false);
337
+ expect(db.rows.find((r) => r.id === "spa-qc")?.isActive).toBe(false);
338
+ expect(db.rows.find((r) => r.id === "tanloc")?.isActive).toBe(true);
339
+ });
340
+
341
+ it("DELETE ngoài phạm vi → 403", async () => {
342
+ const { DELETE } = createWorkspaceItemHandlers(
343
+ deps(db, customerAdmin) as any,
344
+ );
345
+ const res = await DELETE(
346
+ new Request("http://test/api/workspaces/tanloc", { method: "DELETE" }),
347
+ { params: { id: "tanloc" } },
348
+ );
349
+ expect(res.status).toBe(403);
350
+ expect(db.rows.find((r) => r.id === "tanloc")?.isActive).toBe(true);
351
+ });
352
+ });
353
+
354
+ describe("workspace route handlers — move", () => {
355
+ let db: ReturnType<typeof fakeDb>;
356
+ beforeEach(() => {
357
+ db = fakeDb();
358
+ configure(db);
359
+ });
360
+
361
+ it("nguồn trong phạm vi nhưng ĐÍCH ngoài phạm vi → 403", async () => {
362
+ const { POST } = createWorkspaceMoveHandler(deps(db, customerAdmin) as any);
363
+ const res = await POST(post({ newParentId: "tanloc" }), {
364
+ params: { id: "spa-qc" },
365
+ });
366
+ expect(res.status).toBe(403);
367
+ // Không có dòng nào bị repath.
368
+ expect(db.calls.some((c) => c.fn === "update")).toBe(false);
369
+ expect(db.rows.find((r) => r.id === "spa-qc")?.parentId).toBe("spa");
370
+ });
371
+
372
+ it("chuyển lên GỐC chỉ dành cho toàn quyền — nếu không, admin nhánh tự tách thành tenant riêng", async () => {
373
+ const { POST } = createWorkspaceMoveHandler(deps(db, customerAdmin) as any);
374
+ const res = await POST(post({ newParentId: null }), {
375
+ params: { id: "spa" },
376
+ });
377
+ expect(res.status).toBe(403);
378
+ expect(db.rows.find((r) => r.id === "spa")?.parentId).toBe("grp");
379
+ });
380
+
381
+ it("cả hai đầu trong phạm vi → chuyển được, con cháu repath theo", async () => {
382
+ db.rows.push({
383
+ id: "spa-kho",
384
+ code: "SPA-KHO",
385
+ name: "Kho",
386
+ kind: "department",
387
+ parentId: "spa",
388
+ path: "/grp/spa/spa-kho/",
389
+ depth: 3,
390
+ isActive: true,
391
+ });
392
+ const { POST } = createWorkspaceMoveHandler(deps(db, customerAdmin) as any);
393
+ const res = await POST(post({ newParentId: "spa-qc" }), {
394
+ params: { id: "spa-kho" },
395
+ });
396
+ expect(res.status).toBe(200);
397
+
398
+ const moved = db.rows.find((r) => r.id === "spa-kho");
399
+ expect(moved?.path).toBe("/grp/spa/spa-qc/spa-kho/");
400
+ expect(moved?.depth).toBe(4);
401
+ expect(moved?.parentId).toBe("spa-qc");
402
+ });
403
+
404
+ it("chu trình (chuyển vào chính con cháu của mình) → 400 kèm câu tiếng Việt", async () => {
405
+ const { POST } = createWorkspaceMoveHandler(deps(db, customerAdmin) as any);
406
+ const res = await POST(post({ newParentId: "spa-qc" }), {
407
+ params: { id: "spa" },
408
+ });
409
+ // `spa` ngoài adminIds? Không — admin của `spa` gồm cả con cháu, nên qua
410
+ // được cửa quyền và dừng ở luật cây.
411
+ expect(res.status).toBe(400);
412
+ expect(String((await res.json()).error)).toMatch(/chu trình|chính nó|con/i);
413
+ });
414
+ });