@goplusvn/core 0.1.78 → 0.1.81

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.
@@ -13,12 +13,22 @@
13
13
  // prisma, getSession, getCrudPermissions, schema: { userNameField: "fullName", ... },
14
14
  // });
15
15
 
16
- import { getRolesData, type RoleServiceSchema } from "./role-service";
16
+ import { getRolesData } from "./role-service";
17
17
  import { normalizeLandingPath } from "./landing-path";
18
18
  import {
19
19
  bumpPermissionsVersion,
20
20
  getPermissionsVersion,
21
21
  } from "./permissions-version";
22
+ import {
23
+ DelegationError,
24
+ assertCanDeleteRole,
25
+ assertCanManageRole,
26
+ canManageRole,
27
+ } from "../workspace/delegation";
28
+ import { memberScopeWhere } from "../workspace/scope";
29
+
30
+ import type { RoleQueryScope, RoleServiceSchema } from "./role-service";
31
+ import type { DelegationActor } from "../workspace/delegation";
22
32
 
23
33
  type MaybePromise<T> = T | Promise<T>;
24
34
 
@@ -29,12 +39,81 @@ export interface RbacHandlerDeps {
29
39
  getCrudPermissions: (session: any, resource: string) => Promise<{ read?: boolean; create?: boolean; update?: boolean; delete?: boolean }>;
30
40
  /** Schema field-map when User/Role diverge from defaults. */
31
41
  schema?: RoleServiceSchema;
42
+ /**
43
+ * UỶ QUYỀN (app multi-tenant). Bỏ trống ⇒ handler chạy y như trước: quyền
44
+ * `role:create/update/delete` là toàn quyền trên MỌI vai trò.
45
+ *
46
+ * Truyền vào thì cùng bộ luật D1/D2/D4 của người dùng được áp cho vai trò:
47
+ * quản trị viên đơn vị chỉ thấy vai trò của đơn vị mình (+ vai trò dùng chung,
48
+ * chỉ đọc), tạo vai trò thì bị đóng dấu đơn vị và không nhét được quyền mà
49
+ * chính họ không có. Thiếu cửa này thì chặn *gán* vai trò mạnh là vô nghĩa —
50
+ * họ tự tạo một vai trò mạnh rồi gán.
51
+ */
52
+ getDelegationActor?: (session: any) => MaybePromise<DelegationActor | null>;
32
53
  onError?: (error: unknown, req: Request) => Response | Promise<Response>;
33
54
  }
34
55
 
35
56
  const json = (data: unknown, status = 200) =>
36
57
  new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
37
58
 
59
+ /** DelegationError → 403 kèm mã bất biến; lỗi khác trả về cho `fail` xử lý. */
60
+ const delegationDenied = (e: unknown) =>
61
+ e instanceof DelegationError ? json({ error: e.message, code: e.code }, 403) : null;
62
+
63
+ /**
64
+ * Phạm vi đọc vai trò của một người thao tác. Export vì TRANG danh sách vai trò
65
+ * (server component) phải nạp đúng bộ dữ liệu mà API sẽ trả — lệch nhau thì
66
+ * lần render đầu hiện vai trò của đơn vị khác rồi mới biến mất sau khi SWR chạy.
67
+ */
68
+ export function roleScopeOf(actor: DelegationActor | null): RoleQueryScope | undefined {
69
+ if (!actor) return undefined;
70
+ return {
71
+ canManageAll: actor.canManageAll,
72
+ adminWorkspaceIds: actor.scope.adminIds,
73
+ userWhere: actor.canManageAll ? undefined : memberScopeWhere(actor.scope),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Quyền trong vai trò đi trên dây dưới dạng `"action:resource"` (khuôn của
79
+ * `RoleListPage`), còn tầng uỷ quyền nói `"resource:action"`. Đổi chiều ở đúng
80
+ * một chỗ — trộn hai khuôn thì mọi phép so quyền lặng lẽ trả về "không khớp",
81
+ * tức hàng rào D2 vẫn xanh nhưng không chặn gì.
82
+ */
83
+ function toResourceAction(permissions: readonly string[]): string[] {
84
+ return permissions
85
+ .map((p) => {
86
+ const [actionCode, resourceCode] = p.split(":");
87
+ return actionCode && resourceCode ? `${resourceCode}:${actionCode}` : null;
88
+ })
89
+ .filter((p): p is string => Boolean(p));
90
+ }
91
+
92
+ /** Cột uỷ quyền chỉ tồn tại ở app đã migrate — ghi mù là ném `Unknown argument`. */
93
+ const hasRoleField = (prisma: any, field: string) => Boolean(prisma?.role?.fields?.[field]);
94
+
95
+ /** Đọc vai trò trong DB về khuôn `DelegationRole` cho cổng D1/D2/D4. */
96
+ async function loadRoleForDelegation(prisma: any, id: string) {
97
+ const role = await prisma.role.findUnique({
98
+ where: { id },
99
+ include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
100
+ });
101
+ if (!role) return null;
102
+ return {
103
+ role,
104
+ delegation: {
105
+ id: role.id,
106
+ code: role.code,
107
+ rank: typeof role.rank === "number" ? role.rank : 100,
108
+ isSystem: Boolean(role.isSystem),
109
+ workspaceId: role.workspaceId ?? null,
110
+ permissions: (role.rolePermissions ?? []).map(
111
+ (p: any) => `${p.resourceCode}:${p.actionCode}`,
112
+ ),
113
+ },
114
+ };
115
+ }
116
+
38
117
  // permissions "action:resource" → RolePermission rows {roleCode,resourceCode,actionCode}.
39
118
  async function writePermissions(tx: any, roleCode: string, permissions: string[]) {
40
119
  await tx.rolePermission.deleteMany({ where: { roleCode } });
@@ -59,9 +138,26 @@ function landingPathData(prisma: any, body: any) {
59
138
  return { landingPath: normalizeLandingPath(body.landingPath) };
60
139
  }
61
140
 
141
+ // ── Nấc cô lập dữ liệu theo vai trò (`Role.dataScope`) ─────────────────────
142
+ const SCOPE_LEVELS = ["none", "own", "workspace", "subtree", "all"] as const;
143
+
144
+ /**
145
+ * CHỈ VAI VẬN HÀNH đổi được nấc này. Nếu để quản trị viên đơn vị tự đặt, họ chỉ
146
+ * cần tạo một vai trò `all` rồi tự gán — cô lập dữ liệu biến thành tuỳ chọn.
147
+ * `actor === null` (app chưa bật uỷ quyền) thì giữ hành vi cũ: ai sửa vai trò
148
+ * được thì sửa được cột này.
149
+ */
150
+ function dataScopeData(prisma: any, body: any, actor: DelegationActor | null) {
151
+ if (!hasRoleField(prisma, "dataScope") || !("dataScope" in (body ?? {}))) return {};
152
+ if (actor && !actor.canManageAll) return {};
153
+ const raw = body.dataScope;
154
+ if (raw === null || raw === "") return { dataScope: null };
155
+ return SCOPE_LEVELS.includes(raw) ? { dataScope: raw } : {};
156
+ }
157
+
62
158
  // GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
63
159
  export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
64
- const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
160
+ const { prisma, getSession, getCrudPermissions, schema, getDelegationActor, onError } = deps;
65
161
  const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
66
162
 
67
163
  async function GET(req: Request) {
@@ -71,6 +167,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
71
167
  const perms = await getCrudPermissions(session, "role");
72
168
  if (!perms.read) return json({ error: "Forbidden" }, 403);
73
169
  const sp = new URL(req.url).searchParams;
170
+ const actor = getDelegationActor ? await getDelegationActor(session) : null;
74
171
  const result = await getRolesData(
75
172
  prisma,
76
173
  {
@@ -80,6 +177,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
80
177
  status: sp.get("status")?.trim() || undefined,
81
178
  },
82
179
  schema,
180
+ roleScopeOf(actor),
83
181
  );
84
182
  return json(result);
85
183
  } catch (e) {
@@ -98,6 +196,29 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
98
196
  const name = (body.name ?? "").trim();
99
197
  if (!code || !name) return json({ error: "Thiếu mã hoặc tên vai trò" }, 400);
100
198
  const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
199
+
200
+ // Cổng uỷ quyền: đóng dấu đơn vị + ép cấp thấp hơn người tạo + chặn quyền
201
+ // vượt trần. Chỉ chạy khi app khai `getDelegationActor`.
202
+ const stamp: Record<string, unknown> = {};
203
+ const actor = getDelegationActor ? await getDelegationActor(session) : null;
204
+ if (actor) {
205
+ try {
206
+ const gate = assertCanManageRole(actor, null, {
207
+ code,
208
+ workspaceId: body.workspaceId ?? undefined,
209
+ rank: typeof body.rank === "number" ? body.rank : undefined,
210
+ isSystem: Boolean(body.isSystem),
211
+ permissions: toResourceAction(permissions),
212
+ });
213
+ if (hasRoleField(prisma, "workspaceId")) stamp.workspaceId = gate.workspaceId;
214
+ if (hasRoleField(prisma, "rank")) stamp.rank = gate.rank;
215
+ } catch (e) {
216
+ const denied = delegationDenied(e);
217
+ if (denied) return denied;
218
+ throw e;
219
+ }
220
+ }
221
+
101
222
  const role = await prisma.$transaction(async (tx: any) => {
102
223
  const created = await tx.role.create({
103
224
  data: {
@@ -105,7 +226,9 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
105
226
  name,
106
227
  description: body.description ?? null,
107
228
  status: body.status ?? "active",
229
+ ...stamp,
108
230
  ...landingPathData(prisma, body),
231
+ ...dataScopeData(prisma, body, actor),
109
232
  },
110
233
  });
111
234
  await writePermissions(tx, created.code, permissions);
@@ -123,7 +246,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
123
246
 
124
247
  // GET + PUT (update + replace permissions) + DELETE (guarded by user count) for /api/roles/[id].
125
248
  export function createRoleItemHandlers(deps: RbacHandlerDeps) {
126
- const { prisma, getSession, getCrudPermissions, onError } = deps;
249
+ const { prisma, getSession, getCrudPermissions, getDelegationActor, onError } = deps;
127
250
  const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
128
251
  type Ctx = { params: Promise<{ id: string }> };
129
252
 
@@ -134,11 +257,17 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
134
257
  if (!session) return json({ error: "Unauthorized" }, 401);
135
258
  const perms = await getCrudPermissions(session, "role");
136
259
  if (!perms.read) return json({ error: "Forbidden" }, 403);
137
- const role = await prisma.role.findUnique({
138
- where: { id },
139
- include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
140
- });
141
- if (!role) return json({ error: "Not found" }, 404);
260
+ const loaded = await loadRoleForDelegation(prisma, id);
261
+ if (!loaded) return json({ error: "Not found" }, 404);
262
+ const { role } = loaded;
263
+ // Vai trò ngoài phạm vi thì coi như KHÔNG TỒN TẠI: trả 403 ở đây là xác
264
+ // nhận "có vai trò id này", đủ để dò danh sách vai trò của đơn vị khác.
265
+ const viewer = getDelegationActor ? await getDelegationActor(session) : null;
266
+ if (viewer && !viewer.canManageAll) {
267
+ const ws = loaded.delegation.workspaceId;
268
+ const visible = !ws || viewer.scope.adminIds.includes(ws);
269
+ if (!visible) return json({ error: "Not found" }, 404);
270
+ }
142
271
  return json({
143
272
  id: role.id,
144
273
  code: role.code,
@@ -146,6 +275,10 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
146
275
  description: role.description ?? "",
147
276
  status: role.status,
148
277
  landingPath: role.landingPath ?? null,
278
+ dataScope: role.dataScope ?? null,
279
+ workspaceId: role.workspaceId ?? null,
280
+ isSystem: Boolean(role.isSystem),
281
+ canManage: viewer ? canManageRole(viewer, loaded.delegation) : undefined,
149
282
  permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
150
283
  });
151
284
  } catch (e) {
@@ -161,9 +294,44 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
161
294
  const perms = await getCrudPermissions(session, "role");
162
295
  if (!perms.update) return json({ error: "Forbidden" }, 403);
163
296
  const body = await req.json();
164
- const existing = await prisma.role.findUnique({ where: { id } });
165
- if (!existing) return json({ error: "Not found" }, 404);
297
+ const loaded = await loadRoleForDelegation(prisma, id);
298
+ if (!loaded) return json({ error: "Not found" }, 404);
299
+ const existing = loaded.role;
166
300
  const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
301
+
302
+ // Vai trò hệ thống / vai trò dùng chung / vai trò đơn vị khác: chặn ở đây.
303
+ // Không có cổng này thì quản trị viên đơn vị chỉ cần MỞ vai trò "Quản trị
304
+ // hệ thống" rồi bấm lưu là tự nâng cấp mình.
305
+ const stamp: Record<string, unknown> = {};
306
+ const actor = getDelegationActor ? await getDelegationActor(session) : null;
307
+ if (actor) {
308
+ try {
309
+ const gate = assertCanManageRole(actor, loaded.delegation, {
310
+ code: existing.code,
311
+ // Client KHÔNG gửi trường ⇒ giữ nguyên chủ sở hữu. Gửi ⇒ cổng uỷ
312
+ // quyền quyết: vai vận hành đổi được tuỳ ý, quản trị đơn vị chỉ
313
+ // chuyển được trong nhánh mình (D1 ném nếu ra ngoài).
314
+ workspaceId:
315
+ body.workspaceId !== undefined
316
+ ? body.workspaceId
317
+ : loaded.delegation.workspaceId,
318
+ rank: typeof body.rank === "number" ? body.rank : loaded.delegation.rank,
319
+ permissions: toResourceAction(permissions),
320
+ });
321
+ if (hasRoleField(prisma, "rank")) stamp.rank = gate.rank;
322
+ // Chỉ ghi khi client CÓ ý đổi: `gate.workspaceId` luôn có giá trị, ghi
323
+ // vô điều kiện là mọi lần sửa vai trò đều đóng dấu lại — vai trò dùng
324
+ // chung do vai vận hành sửa sẽ lặng lẽ rơi vào một đơn vị.
325
+ if (body.workspaceId !== undefined && hasRoleField(prisma, "workspaceId")) {
326
+ stamp.workspaceId = gate.workspaceId;
327
+ }
328
+ } catch (e) {
329
+ const denied = delegationDenied(e);
330
+ if (denied) return denied;
331
+ throw e;
332
+ }
333
+ }
334
+
167
335
  const role = await prisma.$transaction(async (tx: any) => {
168
336
  const updated = await tx.role.update({
169
337
  where: { id },
@@ -171,7 +339,9 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
171
339
  name: (body.name ?? existing.name).trim(),
172
340
  description: body.description ?? existing.description,
173
341
  status: body.status ?? existing.status,
342
+ ...stamp,
174
343
  ...landingPathData(prisma, body),
344
+ ...dataScopeData(prisma, body, actor),
175
345
  },
176
346
  });
177
347
  await writePermissions(tx, updated.code, permissions);
@@ -192,6 +362,18 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
192
362
  if (!session) return json({ error: "Unauthorized" }, 401);
193
363
  const perms = await getCrudPermissions(session, "role");
194
364
  if (!perms.delete) return json({ error: "Forbidden" }, 403);
365
+ const loaded = await loadRoleForDelegation(prisma, id);
366
+ if (!loaded) return json({ error: "Not found" }, 404);
367
+ const actor = getDelegationActor ? await getDelegationActor(session) : null;
368
+ if (actor) {
369
+ try {
370
+ assertCanDeleteRole(actor, loaded.delegation);
371
+ } catch (e) {
372
+ const denied = delegationDenied(e);
373
+ if (denied) return denied;
374
+ throw e;
375
+ }
376
+ }
195
377
  const role = await prisma.role.findUnique({ where: { id }, include: { _count: { select: { userRoles: true } } } });
196
378
  if (!role) return json({ error: "Not found" }, 404);
197
379
  if (role._count.userRoles > 0)
package/src/rbac/types.ts CHANGED
@@ -20,6 +20,21 @@ export type Role = {
20
20
  updatedAt: string;
21
21
  createdBy?: string | null | undefined;
22
22
  updatedBy?: string | null | undefined;
23
+ /** App multi-tenant: đơn vị sở hữu vai trò; `null` = vai trò dùng chung. */
24
+ workspaceId?: string | null;
25
+ /**
26
+ * Tên đơn vị sở hữu — server giải sẵn để thẻ vai trò khỏi phải tra cây. `null`
27
+ * khi vai trò dùng chung; trường VẮNG MẶT khi app không bật multi-tenant (khác
28
+ * hẳn `null`, đừng gộp hai trường hợp).
29
+ */
30
+ workspaceName?: string | null;
31
+ isSystem?: boolean;
32
+ rank?: number;
33
+ /**
34
+ * Người đang xem có SỬA/XOÁ được vai trò này không — server tính, client chỉ
35
+ * vẽ theo. `undefined` = app không xét uỷ quyền (hành vi cũ, coi như được).
36
+ */
37
+ canManage?: boolean;
23
38
  };
24
39
 
25
40
  export type Permission = {
@@ -1,5 +1,7 @@
1
+ import { assignableRoles } from "../workspace/delegation";
1
2
  import { memberScopeWhere } from "../workspace/scope";
2
3
 
4
+ import type { DelegationActor, DelegationRole } from "../workspace/delegation";
3
5
  import type { WorkspaceScope } from "../workspace/types";
4
6
  import type { CrudResponse } from "../types";
5
7
 
@@ -441,6 +443,61 @@ export async function getActiveRoles(db: UserPrismaClient) {
441
443
  return roles;
442
444
  }
443
445
 
446
+ /**
447
+ * Vai trò mà NGƯỜI ĐANG THAO TÁC được phép gán — dùng cho ô chọn vai trò trong
448
+ * hộp thoại người dùng.
449
+ *
450
+ * `getActiveRoles` trả về mọi vai trò đang hoạt động, kể cả "Quản trị hệ thống".
451
+ * Bản thân việc gán đã bị `assertCanCreateUser` chặn ở tầng API, nên đây không
452
+ * phải lỗ hổng — nhưng nó là cái bẫy giao diện: quản trị viên đơn vị chọn vai
453
+ * trò, bấm lưu, rồi mới ăn 403. Danh sách này lọc trước bằng CHÍNH luật D4
454
+ * (`assignableRoles`), nên thứ hiện ra là thứ lưu được.
455
+ *
456
+ * App chưa có cột `rank` (một tổ chức, không uỷ quyền) rơi về `getActiveRoles`.
457
+ */
458
+ export async function getAssignableRoles(
459
+ db: UserPrismaClient,
460
+ actor: DelegationActor | null,
461
+ ) {
462
+ if (!actor || actor.canManageAll || !db.role.fields?.rank) {
463
+ return getActiveRoles(db);
464
+ }
465
+
466
+ const rows = await db.role.findMany({
467
+ where: { status: "active" },
468
+ select: {
469
+ id: true,
470
+ code: true,
471
+ name: true,
472
+ status: true,
473
+ rank: true,
474
+ isSystem: db.role.fields?.isSystem ? true : undefined,
475
+ workspaceId: db.role.fields?.workspaceId ? true : undefined,
476
+ rolePermissions: { select: { resourceCode: true, actionCode: true } },
477
+ },
478
+ orderBy: { name: "asc" },
479
+ });
480
+
481
+ const candidates: (DelegationRole & { name: string; status: string })[] = rows.map((r: any) => ({
482
+ id: r.id,
483
+ code: r.code,
484
+ name: r.name,
485
+ status: r.status,
486
+ rank: r.rank,
487
+ isSystem: Boolean(r.isSystem),
488
+ workspaceId: r.workspaceId ?? null,
489
+ permissions: (r.rolePermissions ?? []).map(
490
+ (p: any) => `${p.resourceCode}:${p.actionCode}`,
491
+ ),
492
+ }));
493
+
494
+ return assignableRoles(actor, candidates).map((r) => ({
495
+ code: r.code,
496
+ name: r.name,
497
+ status: r.status,
498
+ }));
499
+ }
500
+
444
501
  /**
445
502
  * Get all active departments for filter dropdown
446
503
  */
@@ -10,8 +10,12 @@ import {
10
10
  assertNotSelf,
11
11
  assertRoleAssignable,
12
12
  assertWorkspacesInScope,
13
+ assertCanDeleteRole,
14
+ assertCanManageRole,
13
15
  assignableRoles,
14
16
  canDelegateUsers,
17
+ canManageRole,
18
+ defaultRoleWorkspaceIdFor,
15
19
  DelegationError,
16
20
  isDangerousPermission,
17
21
  } from "../delegation";
@@ -24,6 +28,7 @@ import type {
24
28
  DelegationActor,
25
29
  DelegationRole,
26
30
  DelegationTarget,
31
+ RoleDraft,
27
32
  } from "../delegation";
28
33
 
29
34
  /**
@@ -361,3 +366,100 @@ describe("cổng tổng hợp", () => {
361
366
  expect(() => assertCanCreateUser(opsAdmin(), [])).not.toThrow();
362
367
  });
363
368
  });
369
+
370
+ describe("Quản trị VAI TRÒ — cổng tạo/sửa/xoá", () => {
371
+ const draft = (over: Partial<RoleDraft> = {}): RoleDraft => ({
372
+ code: "TO_TRUONG",
373
+ permissions: ["meal-order:view"],
374
+ ...over,
375
+ });
376
+
377
+ it("tạo vai trò trong nhánh mình: được đóng dấu workspace + giữ rank", () => {
378
+ const gate = assertCanManageRole(customerAdmin(), null, draft({ rank: 80 }));
379
+ expect(gate.workspaceId).toBe("spa");
380
+ expect(gate.rank).toBe(80);
381
+ });
382
+
383
+ it("không tự tạo được vai trò DÙNG CHUNG (workspaceId null)", () => {
384
+ // Cửa hậu kinh điển: vai trò không mang dấu đơn vị thì đơn vị khác cũng gán
385
+ // được — admin nhánh vừa ghi vào không gian của cả hệ thống.
386
+ const actor = customerAdmin({
387
+ scope: createWorkspaceScope({
388
+ canViewAll: false,
389
+ rootIds: ["spa"],
390
+ allowedIds: ["spa"],
391
+ adminIds: [],
392
+ }),
393
+ });
394
+ expect(() => assertCanManageRole(actor, null, draft())).toThrow(
395
+ DelegationError,
396
+ );
397
+ });
398
+
399
+ it("không tạo được vai trò ngang hoặc mạnh hơn chính mình", () => {
400
+ try {
401
+ assertCanManageRole(customerAdmin(), null, draft({ rank: 50 }));
402
+ throw new Error("đáng lẽ phải ném");
403
+ } catch (error) {
404
+ expect((error as DelegationError).code).toBe("D2_EXCEEDS_CEILING");
405
+ }
406
+ });
407
+
408
+ it("không nhét được quyền quản trị hệ thống vào vai trò mới (D4)", () => {
409
+ try {
410
+ assertCanManageRole(
411
+ customerAdmin(),
412
+ null,
413
+ draft({ rank: 80, permissions: ["role:create"] }),
414
+ );
415
+ throw new Error("đáng lẽ phải ném");
416
+ } catch (error) {
417
+ expect((error as DelegationError).code).toBe("D4_DANGEROUS_ROLE");
418
+ }
419
+ });
420
+
421
+ it("không cấp được quyền mà chính mình không có (D2)", () => {
422
+ try {
423
+ assertCanManageRole(
424
+ customerAdmin(),
425
+ null,
426
+ draft({ rank: 80, permissions: ["payroll:view"] }),
427
+ );
428
+ throw new Error("đáng lẽ phải ném");
429
+ } catch (error) {
430
+ expect((error as DelegationError).code).toBe("D2_EXCEEDS_CEILING");
431
+ expect((error as DelegationError).message).toContain("payroll:view");
432
+ }
433
+ });
434
+
435
+ it("vai trò dùng chung: THẤY nhưng không sửa, không xoá", () => {
436
+ const shared = role({ rank: 100, workspaceId: null });
437
+ expect(canManageRole(customerAdmin(), shared)).toBe(false);
438
+ expect(() =>
439
+ assertCanManageRole(customerAdmin(), shared, draft({ rank: 100 })),
440
+ ).toThrow(DelegationError);
441
+ expect(() => assertCanDeleteRole(customerAdmin(), shared)).toThrow(
442
+ DelegationError,
443
+ );
444
+ });
445
+
446
+ it("vai trò của đơn vị khác / vai trò hệ thống đều không sửa được", () => {
447
+ expect(
448
+ canManageRole(customerAdmin(), role({ workspaceId: "tanloc" })),
449
+ ).toBe(false);
450
+ expect(
451
+ canManageRole(customerAdmin(), role({ workspaceId: "spa", isSystem: true })),
452
+ ).toBe(false);
453
+ expect(canManageRole(customerAdmin(), role({ workspaceId: "spa-qc" }))).toBe(
454
+ true,
455
+ );
456
+ });
457
+
458
+ it("nhóm vận hành miễn mọi luật", () => {
459
+ expect(canManageRole(opsAdmin(), role({ isSystem: true }))).toBe(true);
460
+ expect(() =>
461
+ assertCanManageRole(opsAdmin(), null, draft({ permissions: ["role:create"] })),
462
+ ).not.toThrow();
463
+ expect(defaultRoleWorkspaceIdFor(opsAdmin())).toBeNull();
464
+ });
465
+ });
@@ -118,12 +118,16 @@ function fakeDb(users: FakeUser[]) {
118
118
  const before = members.length;
119
119
  for (let i = members.length - 1; i >= 0; i -= 1) {
120
120
  const m = members[i];
121
- if (
122
- m.userId === args.where.userId &&
123
- m.workspaceId === args.where.workspaceId
124
- ) {
125
- members.splice(i, 1);
126
- }
121
+ if (m.userId !== args.where.userId) continue;
122
+ // Hai dạng `where` mà handler thực sự gửi xuống: gỡ ĐÚNG một nút
123
+ // (đường xoá một người) và gỡ MỌI nút khác (đường chuyển hàng loạt).
124
+ const matches =
125
+ args.where.workspaceId !== undefined
126
+ ? m.workspaceId === args.where.workspaceId
127
+ : args.where.NOT?.workspaceId !== undefined
128
+ ? m.workspaceId !== args.where.NOT.workspaceId
129
+ : true;
130
+ if (matches) members.splice(i, 1);
127
131
  }
128
132
  return { count: before - members.length };
129
133
  },
@@ -405,3 +409,66 @@ describe("tìm ứng viên để thêm", () => {
405
409
  expect(res.status).toBe(403);
406
410
  });
407
411
  });
412
+
413
+ describe("gán HÀNG LOẠT", () => {
414
+ let db: ReturnType<typeof fakeDb>;
415
+ beforeEach(() => {
416
+ db = fakeDb(baseUsers());
417
+ configure(db);
418
+ });
419
+
420
+ const wsOf = (userId: string) =>
421
+ db.members.filter((m) => m.userId === userId).map((m) => m.workspaceId);
422
+
423
+ it('mode "move" GỠ mọi không gian cũ — nếu không thì cô lập là giả', async () => {
424
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
425
+ const res = await POST(
426
+ postReq({ userIds: ["u-qc", "u-tanloc"], mode: "move" }),
427
+ ctx("spa-qc"),
428
+ );
429
+
430
+ expect(res.status).toBe(200);
431
+ await expect(res.json()).resolves.toMatchObject({ changed: 2, failed: [] });
432
+ // Còn giữ membership cũ là người đó vẫn thấy dữ liệu nhánh cũ (phạm vi là
433
+ // HỢP các không gian), tức bấm gán xong mà không đổi gì.
434
+ expect(wsOf("u-qc")).toEqual(["spa-qc"]);
435
+ expect(wsOf("u-tanloc")).toEqual(["spa-qc"]);
436
+ });
437
+
438
+ it('mode "add" giữ nguyên nơi cũ', async () => {
439
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
440
+ const res = await POST(
441
+ postReq({ userIds: ["u-qc"], mode: "add" }),
442
+ ctx("spa-qc"),
443
+ );
444
+
445
+ expect(res.status).toBe(200);
446
+ expect(wsOf("u-qc").sort()).toEqual(["spa", "spa-qc"]);
447
+ });
448
+
449
+ it("người ngoài phạm vi bị loại RIÊNG, không kéo đổ cả mẻ", async () => {
450
+ const { POST } = createWorkspaceMemberHandlers(
451
+ deps(db, customerAdmin) as any,
452
+ );
453
+ const res = await POST(
454
+ // `u-tanloc` thuộc nhánh Tấn Lộc — admin khách hàng không sở hữu (D7).
455
+ postReq({ userIds: ["u-qc", "u-tanloc"] }),
456
+ ctx("spa-qc"),
457
+ );
458
+
459
+ expect(res.status).toBe(200);
460
+ const payload = await res.json();
461
+ expect(payload.changed).toBe(1);
462
+ expect(payload.failed).toHaveLength(1);
463
+ expect(payload.failed[0].userId).toBe("u-tanloc");
464
+ // Nạn nhân KHÔNG bị đẩy khỏi nhánh cũ — đó chính là đường chiếm người mà D7 chặn.
465
+ expect(wsOf("u-tanloc")).toEqual(["tanloc"]);
466
+ expect(wsOf("u-qc")).toEqual(["spa-qc"]);
467
+ });
468
+
469
+ it("danh sách rỗng → 400 chứ không im lặng báo thành công", async () => {
470
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
471
+ const res = await POST(postReq({ userIds: [] }), ctx("spa-qc"));
472
+ expect(res.status).toBe(400);
473
+ });
474
+ });