@goplusvn/core 0.1.76 → 0.1.77

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.76",
4
+ "version": "0.1.77",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -52,6 +52,25 @@ interface UnifiedProfileDialogProps {
52
52
  onSaveProfile?: (data: any, id?: string) => Promise<void>;
53
53
  onSaveRoles?: (userId: string, roleCodes: string[]) => Promise<void>;
54
54
  onSavePassword?: (userId: string, password: string) => Promise<void>;
55
+ /**
56
+ * Bật ô chọn KHÔNG GIAN LÀM VIỆC bằng cách trỏ tới API cây không gian
57
+ * (thường `/api/workspaces`). Bỏ trống ⇒ không fetch, không render, hộp thoại
58
+ * y hệt trước — app chưa bật tính năng workspace (vinhhoa) không đổi một pixel.
59
+ */
60
+ workspaceApiUrl?: string;
61
+ /** Nhãn theo nghiệp vụ của app: "Đơn vị", "Tổ chức", "Chi nhánh"… */
62
+ workspaceLabel?: string;
63
+ }
64
+
65
+ /** Cây → danh sách phẳng, giữ thứ tự duyệt để dòng con nằm ngay dưới dòng cha. */
66
+ function flattenWorkspaceTree(
67
+ nodes: any[],
68
+ depth = 0,
69
+ ): { id: string; name: string; depth: number }[] {
70
+ return (nodes ?? []).flatMap((node) => [
71
+ { id: node.id, name: node.name, depth },
72
+ ...flattenWorkspaceTree(node.children ?? [], depth + 1),
73
+ ]);
55
74
  }
56
75
 
57
76
  const fetcher = async (url: string) => {
@@ -87,6 +106,8 @@ export function UnifiedProfileDialog({
87
106
  onSaveProfile,
88
107
  onSaveRoles,
89
108
  onSavePassword,
109
+ workspaceApiUrl,
110
+ workspaceLabel = "Không gian làm việc",
90
111
  }: UnifiedProfileDialogProps) {
91
112
  // -- Data Fetching --
92
113
  const { data: departments } = useSWR<{ id: string; name: string }[]>(
@@ -109,6 +130,16 @@ export function UnifiedProfileDialog({
109
130
  "/api/branches",
110
131
  fetcher,
111
132
  );
133
+ // Key `null` ⇒ SWR không gọi gì cả. App chưa bật workspace không phát sinh
134
+ // thêm một request nào.
135
+ const { data: workspaceTree } = useSWR<any[]>(
136
+ workspaceApiUrl ?? null,
137
+ fetcher,
138
+ );
139
+ const workspaces = useMemo(
140
+ () => flattenWorkspaceTree(workspaceTree ?? []),
141
+ [workspaceTree],
142
+ );
112
143
 
113
144
  // -- State --
114
145
  const [activeSection, setActiveSection] = useState("general");
@@ -121,6 +152,10 @@ export function UnifiedProfileDialog({
121
152
  const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
122
153
  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
123
154
  const [defaultBranchId, setDefaultBranchId] = useState<string | null>(null);
155
+ const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>([]);
156
+ const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(
157
+ null,
158
+ );
124
159
  const [passwordData, setPasswordData] = useState({
125
160
  password: "",
126
161
  confirm: "",
@@ -193,6 +228,8 @@ export function UnifiedProfileDialog({
193
228
  setSelectedRoles([]);
194
229
  setSelectedBranches([]);
195
230
  setDefaultBranchId(null);
231
+ setSelectedWorkspaces([]);
232
+ setDefaultWorkspaceId(null);
196
233
  setPasswordData({ password: "", confirm: "" });
197
234
  setEnableAccount(false);
198
235
  } else {
@@ -226,6 +263,10 @@ export function UnifiedProfileDialog({
226
263
  setDefaultBranchId(
227
264
  d.defaultBranchId || (d.branchIds && d.branchIds[0]) || null,
228
265
  );
266
+ setSelectedWorkspaces(d.workspaceIds || []);
267
+ setDefaultWorkspaceId(
268
+ d.defaultWorkspaceId || (d.workspaceIds && d.workspaceIds[0]) || null,
269
+ );
229
270
  setPasswordData({ password: "", confirm: "" });
230
271
  // Enable account if user has roles or if it's not a customer (employees always have accounts?)
231
272
  // For now, if roles exist, we assume account is enabled.
@@ -245,6 +286,38 @@ export function UnifiedProfileDialog({
245
286
  }
246
287
  }, [open, branches, mode, data]);
247
288
 
289
+ // Chỉ có đúng một không gian để chọn thì chọn sẵn — cùng cách cư xử với ô chi
290
+ // nhánh ngay bên cạnh, đỡ một cú bấm bắt buộc mà không có lựa chọn nào khác.
291
+ useEffect(() => {
292
+ if (!open || !workspaceApiUrl || workspaces.length !== 1) return;
293
+ if (mode === "create" && selectedWorkspaces.length === 0) {
294
+ setSelectedWorkspaces([workspaces[0].id]);
295
+ setDefaultWorkspaceId(workspaces[0].id);
296
+ }
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
298
+ }, [open, workspaces, mode, workspaceApiUrl]);
299
+
300
+ // -- Workspace selection helpers — giữ đúng khuôn của cặp hàm chi nhánh dưới đây.
301
+ const toggleWorkspace = (workspaceId: string) => {
302
+ if (selectedWorkspaces.includes(workspaceId)) {
303
+ const next = selectedWorkspaces.filter((id) => id !== workspaceId);
304
+ setSelectedWorkspaces(next);
305
+ if (defaultWorkspaceId === workspaceId) {
306
+ setDefaultWorkspaceId(next[0] ?? null);
307
+ }
308
+ } else {
309
+ setSelectedWorkspaces([...selectedWorkspaces, workspaceId]);
310
+ if (!defaultWorkspaceId) setDefaultWorkspaceId(workspaceId);
311
+ }
312
+ };
313
+
314
+ const markDefaultWorkspace = (workspaceId: string) => {
315
+ if (!selectedWorkspaces.includes(workspaceId)) {
316
+ setSelectedWorkspaces((prev) => [...prev, workspaceId]);
317
+ }
318
+ setDefaultWorkspaceId(workspaceId);
319
+ };
320
+
248
321
  // -- Branch selection helpers --
249
322
  // Toggle a branch's membership while keeping a valid default branch.
250
323
  const toggleBranch = (branchId: string) => {
@@ -311,6 +384,16 @@ export function UnifiedProfileDialog({
311
384
  : (selectedBranches[0] ?? null),
312
385
  };
313
386
 
387
+ // Chỉ gửi khi app bật tính năng — gửi mảng rỗng lên app chưa bật là mời
388
+ // route xoá sạch membership của người đang sửa.
389
+ if (workspaceApiUrl) {
390
+ payload.workspaceIds = selectedWorkspaces;
391
+ payload.defaultWorkspaceId =
392
+ defaultWorkspaceId && selectedWorkspaces.includes(defaultWorkspaceId)
393
+ ? defaultWorkspaceId
394
+ : (selectedWorkspaces[0] ?? null);
395
+ }
396
+
314
397
  // Handle Password for Customer
315
398
  if (isCustomer) {
316
399
  if (enableAccount) {
@@ -943,6 +1026,83 @@ export function UnifiedProfileDialog({
943
1026
  })}
944
1027
  </div>
945
1028
  </div>
1029
+
1030
+ {workspaceApiUrl ? (
1031
+ <div className="col-span-2 space-y-3 pt-2">
1032
+ <div className="flex items-center justify-between gap-3 flex-wrap">
1033
+ <Label className="text-[13px] font-medium text-foreground">
1034
+ {workspaceLabel}
1035
+ </Label>
1036
+ <span className="inline-flex items-center gap-1 text-[12px] text-muted-foreground">
1037
+ <Star className="h-3 w-3" />
1038
+ Đánh dấu một mục mặc định
1039
+ </span>
1040
+ </div>
1041
+ {workspaces.length === 0 ? (
1042
+ // Rỗng ở đây gần như luôn là "ngoài phạm vi của bạn",
1043
+ // không phải "hệ thống chưa có" — nói rõ để khỏi
1044
+ // tưởng mất dữ liệu.
1045
+ <p className="text-[13px] text-muted-foreground">
1046
+ Không có mục nào trong phạm vi của bạn.
1047
+ </p>
1048
+ ) : (
1049
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1050
+ {workspaces.map((ws) => {
1051
+ const isSelected = selectedWorkspaces.includes(
1052
+ ws.id,
1053
+ );
1054
+ const isDefault =
1055
+ isSelected && defaultWorkspaceId === ws.id;
1056
+ return (
1057
+ <div
1058
+ key={ws.id}
1059
+ className={cn(
1060
+ "flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-accent/50 transition-colors",
1061
+ isSelected
1062
+ ? "border-primary bg-primary/5"
1063
+ : "bg-background border-border",
1064
+ )}
1065
+ onClick={() => toggleWorkspace(ws.id)}
1066
+ >
1067
+ <Checkbox
1068
+ checked={isSelected}
1069
+ className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
1070
+ />
1071
+ <span
1072
+ className="flex-1 text-sm font-medium text-foreground truncate"
1073
+ // Thụt theo cấp để dòng con đọc ra là con
1074
+ // của dòng ngay trên nó.
1075
+ style={{ paddingLeft: ws.depth * 12 }}
1076
+ >
1077
+ {ws.name}
1078
+ </span>
1079
+ {isSelected &&
1080
+ (isDefault ? (
1081
+ <span className="inline-flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground shrink-0">
1082
+ <Star className="h-3 w-3 fill-current" />
1083
+ Mặc định
1084
+ </span>
1085
+ ) : (
1086
+ <button
1087
+ type="button"
1088
+ onClick={(e) => {
1089
+ e.stopPropagation();
1090
+ markDefaultWorkspace(ws.id);
1091
+ }}
1092
+ title="Đặt làm mặc định"
1093
+ className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground hover:border-primary/50 hover:text-primary transition-colors shrink-0"
1094
+ >
1095
+ <Star className="h-3 w-3" />
1096
+ Đặt mặc định
1097
+ </button>
1098
+ ))}
1099
+ </div>
1100
+ );
1101
+ })}
1102
+ </div>
1103
+ )}
1104
+ </div>
1105
+ ) : null}
946
1106
  </div>
947
1107
  </div>
948
1108
  )}
@@ -37,6 +37,12 @@ interface UsersClientPageProps {
37
37
  /** Cây menu + meta action cho dialog "Quyền hiệu lực" (additive). */
38
38
  menuTree?: EffectiveMenuTreeSection[];
39
39
  actionMeta?: Record<string, { label?: string; flow?: string; description?: string }>;
40
+ /**
41
+ * Bật ô chọn không gian làm việc trong hộp thoại người dùng (additive). Bỏ
42
+ * trống ⇒ hộp thoại giữ nguyên như cũ, không fetch gì thêm.
43
+ */
44
+ workspaceApiUrl?: string;
45
+ workspaceLabel?: string;
40
46
  }
41
47
 
42
48
  const DEFAULT_PAGE_SIZE = 20;
@@ -52,6 +58,8 @@ export function UsersClientPage({
52
58
  onResetPassword,
53
59
  menuTree,
54
60
  actionMeta,
61
+ workspaceApiUrl,
62
+ workspaceLabel,
55
63
  }: UsersClientPageProps) {
56
64
  const [permUser, setPermUser] = useState<any | null>(null);
57
65
  const router = useRouter();
@@ -282,6 +290,8 @@ export function UsersClientPage({
282
290
  mode="create"
283
291
  viewMode="admin"
284
292
  roles={roles}
293
+ workspaceApiUrl={workspaceApiUrl}
294
+ workspaceLabel={workspaceLabel}
285
295
  onSaveProfile={(data) => handleUserSubmit(data)}
286
296
  />
287
297
 
@@ -294,6 +304,8 @@ export function UsersClientPage({
294
304
  mode="edit"
295
305
  viewMode="admin"
296
306
  roles={roles}
307
+ workspaceApiUrl={workspaceApiUrl}
308
+ workspaceLabel={workspaceLabel}
297
309
  onSaveProfile={handleUserSubmit}
298
310
  onSaveRoles={handleAssignRoles}
299
311
  onSavePassword={handleResetPassword}
@@ -352,7 +352,7 @@ describe("cổng tổng hợp", () => {
352
352
  assertCanCreateUser(actor, ["spa-qc"], [role()]),
353
353
  ).not.toThrow();
354
354
  expect(() => assertCanCreateUser(actor, [])).toThrow(
355
- /ít nhất một không gian/,
355
+ /ít nhất một workspace/,
356
356
  );
357
357
  expect(() =>
358
358
  assertCanCreateUser(actor, ["spa-qc"], [role({ rank: 10 })]),
@@ -0,0 +1,407 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Đường GHI membership — nơi một admin khách hàng có thể tự bành trướng nếu
3
+ // hàng rào hở.
4
+ //
5
+ // Mọi ca gọi thẳng handler như một client tự chế (curl), không đi qua giao diện:
6
+ // trang có ẩn nút hay không là chuyện khác, hàng rào phải nằm ở đây. Ba đường
7
+ // leo thang được soi riêng: hút người của tenant khác về nhánh mình (D5), tự
8
+ // nâng chính mình (D3), và đẩy nạn nhân ra khỏi nhánh khác để chiếm (D7).
9
+
10
+ import { beforeEach, describe, expect, it } from "vitest";
11
+
12
+ import { createWorkspaceMemberHandlers } from "../route-handlers";
13
+ import { configureWorkspaces, resetWorkspaceConfig } from "../scope";
14
+ import { subtreePrefix } from "../tree";
15
+
16
+ beforeEach(() => {
17
+ resetWorkspaceConfig();
18
+ });
19
+
20
+ /**
21
+ * grp (tập đoàn)
22
+ * ├── tanloc (Nhà ăn Tấn Lộc — vận hành)
23
+ * └── spa (Spartronics — khách hàng)
24
+ * └── spa-qc (phòng QC của khách hàng)
25
+ */
26
+ const WORKSPACES = [
27
+ { id: "grp", path: "/grp/" },
28
+ { id: "tanloc", path: "/grp/tanloc/" },
29
+ { id: "spa", path: "/grp/spa/" },
30
+ { id: "spa-qc", path: "/grp/spa/spa-qc/" },
31
+ ];
32
+
33
+ interface FakeUser {
34
+ id: string;
35
+ name: string;
36
+ email: string;
37
+ isActive: boolean;
38
+ isProtected?: boolean;
39
+ permissionCeilingRoleId?: string | null;
40
+ workspaceIds: string[];
41
+ }
42
+
43
+ function fakeDb(users: FakeUser[]) {
44
+ const calls: { fn: string; args: any }[] = [];
45
+ const members = users.flatMap((u) =>
46
+ u.workspaceIds.map((workspaceId) => ({
47
+ userId: u.id,
48
+ workspaceId,
49
+ isAdmin: false,
50
+ isDefault: false,
51
+ })),
52
+ );
53
+
54
+ const findUser = (id: string) => users.find((u) => u.id === id) ?? null;
55
+
56
+ return {
57
+ calls,
58
+ members,
59
+ user: {
60
+ async findUnique(args: any) {
61
+ const u = findUser(args?.where?.id);
62
+ if (!u) return null;
63
+ return {
64
+ id: u.id,
65
+ isProtected: u.isProtected ?? false,
66
+ permissionCeilingRoleId: u.permissionCeilingRoleId ?? null,
67
+ userWorkspaces: members
68
+ .filter((m) => m.userId === u.id)
69
+ .map((m) => ({ workspaceId: m.workspaceId })),
70
+ };
71
+ },
72
+ async findMany(args: any) {
73
+ calls.push({ fn: "user.findMany", args });
74
+ // Không mô phỏng lại engine Prisma — ca kiểm tra `where` gửi xuống, còn
75
+ // ở đây chỉ cần trả một tập ổn định để handler chạy hết đường.
76
+ return users
77
+ .filter(
78
+ (u) =>
79
+ !members.some(
80
+ (m) => m.userId === u.id && m.workspaceId === "spa-qc",
81
+ ),
82
+ )
83
+ .map((u) => ({
84
+ id: u.id,
85
+ name: u.name,
86
+ email: u.email,
87
+ isActive: u.isActive,
88
+ }));
89
+ },
90
+ },
91
+ userWorkspace: {
92
+ async findMany(args: any) {
93
+ calls.push({ fn: "userWorkspace.findMany", args });
94
+ return members
95
+ .filter((m) => m.workspaceId === args?.where?.workspaceId)
96
+ .map((m) => ({
97
+ isAdmin: m.isAdmin,
98
+ isDefault: m.isDefault,
99
+ user: findUser(m.userId),
100
+ }));
101
+ },
102
+ async upsert(args: any) {
103
+ calls.push({ fn: "userWorkspace.upsert", args });
104
+ const { userId, workspaceId } = args.where.userId_workspaceId;
105
+ const found = members.find(
106
+ (m) => m.userId === userId && m.workspaceId === workspaceId,
107
+ );
108
+ if (found) Object.assign(found, args.update);
109
+ else members.push({ userId, workspaceId, ...args.create });
110
+ return { userId, workspaceId };
111
+ },
112
+ async updateMany(args: any) {
113
+ calls.push({ fn: "userWorkspace.updateMany", args });
114
+ return { count: 0 };
115
+ },
116
+ async deleteMany(args: any) {
117
+ calls.push({ fn: "userWorkspace.deleteMany", args });
118
+ const before = members.length;
119
+ for (let i = members.length - 1; i >= 0; i -= 1) {
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
+ }
127
+ }
128
+ return { count: before - members.length };
129
+ },
130
+ },
131
+ async $transaction(fn: any) {
132
+ return fn(this);
133
+ },
134
+ };
135
+ }
136
+
137
+ type Session = { userId: string; adminOf: string[]; viewAll?: boolean };
138
+
139
+ /** Quản trị viên phía khách hàng: quản trị nhánh `spa` (và con cháu). */
140
+ const customerAdmin: Session = { userId: "u-spa-admin", adminOf: ["spa"] };
141
+ /** Vận hành Tấn Lộc — toàn quyền, miễn D1–D7. */
142
+ const opsAdmin: Session = { userId: "u-ops", adminOf: [], viewAll: true };
143
+
144
+ function configure(db: ReturnType<typeof fakeDb>) {
145
+ void db;
146
+ configureWorkspaces<Session>({
147
+ canViewAll: (session) => Boolean(session?.viewAll),
148
+ getUserId: (session) => session?.userId ?? null,
149
+ getMemberships: (session) =>
150
+ (session?.adminOf ?? []).map((workspaceId) => ({
151
+ workspaceId,
152
+ isAdmin: true,
153
+ })),
154
+ expandDescendants: async (rootIds) => {
155
+ const prefixes = WORKSPACES.filter((w) => rootIds.includes(w.id)).map(
156
+ (w) => subtreePrefix(w.path),
157
+ );
158
+ return WORKSPACES.filter((w) =>
159
+ prefixes.some((p) => w.path.startsWith(p)),
160
+ ).map((w) => w.id);
161
+ },
162
+ });
163
+ }
164
+
165
+ const deps = (db: any, session: Session | null) => ({
166
+ prisma: db,
167
+ getSession: () => session,
168
+ canManageAll: (s: Session) => Boolean(s?.viewAll),
169
+ });
170
+
171
+ const ctx = (id: string) => ({ params: { id } });
172
+
173
+ const postReq = (body: unknown) =>
174
+ new Request("http://test/api/workspaces/x/members", {
175
+ method: "POST",
176
+ body: JSON.stringify(body),
177
+ });
178
+
179
+ const deleteReq = (userId: string) =>
180
+ new Request(`http://test/api/workspaces/x/members?userId=${userId}`, {
181
+ method: "DELETE",
182
+ });
183
+
184
+ const baseUsers = (): FakeUser[] => [
185
+ {
186
+ id: "u-qc",
187
+ name: "Nhân viên QC",
188
+ email: "qc@spa.vn",
189
+ isActive: true,
190
+ workspaceIds: ["spa"],
191
+ },
192
+ {
193
+ id: "u-tanloc",
194
+ name: "Nhân viên Tấn Lộc",
195
+ email: "nv@tanloc.vn",
196
+ isActive: true,
197
+ workspaceIds: ["tanloc"],
198
+ },
199
+ {
200
+ id: "u-orphan",
201
+ name: "Chưa gán",
202
+ email: "orphan@x.vn",
203
+ isActive: true,
204
+ workspaceIds: [],
205
+ },
206
+ {
207
+ id: "u-spa-admin",
208
+ name: "Admin Spartronics",
209
+ email: "admin@spa.vn",
210
+ isActive: true,
211
+ workspaceIds: ["spa"],
212
+ },
213
+ ];
214
+
215
+ describe("gán người vào không gian — đường hợp lệ", () => {
216
+ let db: ReturnType<typeof fakeDb>;
217
+ beforeEach(() => {
218
+ db = fakeDb(baseUsers());
219
+ configure(db);
220
+ });
221
+
222
+ it("admin nhánh gán được người của chính nhánh mình xuống phòng ban con", async () => {
223
+ const { POST } = createWorkspaceMemberHandlers(
224
+ deps(db, customerAdmin) as any,
225
+ );
226
+ const res = await POST(postReq({ userId: "u-qc" }), ctx("spa-qc"));
227
+
228
+ expect(res.status).toBe(201);
229
+ expect(
230
+ db.members.some((m) => m.userId === "u-qc" && m.workspaceId === "spa-qc"),
231
+ ).toBe(true);
232
+ });
233
+
234
+ it("đặt cờ mặc định thì HẠ cờ mặc định cũ — mỗi người chỉ một không gian mặc định", async () => {
235
+ const { POST } = createWorkspaceMemberHandlers(
236
+ deps(db, customerAdmin) as any,
237
+ );
238
+ await POST(postReq({ userId: "u-qc", isDefault: true }), ctx("spa-qc"));
239
+
240
+ const cleared = db.calls.find((c) => c.fn === "userWorkspace.updateMany");
241
+ expect(cleared).toBeDefined();
242
+ expect(cleared!.args.where).toMatchObject({
243
+ userId: "u-qc",
244
+ isDefault: true,
245
+ NOT: { workspaceId: "spa-qc" },
246
+ });
247
+ });
248
+
249
+ it("gán lại người đã ở trong = đổi cờ, không đẻ bản ghi trùng", async () => {
250
+ const { POST } = createWorkspaceMemberHandlers(
251
+ deps(db, customerAdmin) as any,
252
+ );
253
+ const res = await POST(
254
+ postReq({ userId: "u-qc", isAdmin: true }),
255
+ ctx("spa"),
256
+ );
257
+
258
+ expect(res.status).toBe(200);
259
+ expect(
260
+ db.members.filter((m) => m.userId === "u-qc" && m.workspaceId === "spa"),
261
+ ).toHaveLength(1);
262
+ expect(
263
+ db.members.find((m) => m.userId === "u-qc" && m.workspaceId === "spa")
264
+ ?.isAdmin,
265
+ ).toBe(true);
266
+ });
267
+ });
268
+
269
+ describe("gán người vào không gian — ba đường leo thang", () => {
270
+ let db: ReturnType<typeof fakeDb>;
271
+ beforeEach(() => {
272
+ db = fakeDb(baseUsers());
273
+ configure(db);
274
+ });
275
+
276
+ it("D1 — không gán được vào nhánh ngoài phạm vi quản trị", async () => {
277
+ const { POST } = createWorkspaceMemberHandlers(
278
+ deps(db, customerAdmin) as any,
279
+ );
280
+ const res = await POST(postReq({ userId: "u-qc" }), ctx("tanloc"));
281
+
282
+ expect(res.status).toBe(403);
283
+ expect(
284
+ db.members.some((m) => m.workspaceId === "tanloc" && m.userId === "u-qc"),
285
+ ).toBe(false);
286
+ });
287
+
288
+ it("D5 — không hút được người của tenant khác về nhánh mình", async () => {
289
+ const { POST } = createWorkspaceMemberHandlers(
290
+ deps(db, customerAdmin) as any,
291
+ );
292
+ const res = await POST(postReq({ userId: "u-tanloc" }), ctx("spa"));
293
+
294
+ expect(res.status).toBe(403);
295
+ expect(await res.json()).toMatchObject({ code: "D5_NOT_FULLY_OWNED" });
296
+ });
297
+
298
+ it("D5 — người chưa thuộc không gian nào thì admin nhánh KHÔNG tự nhận về", async () => {
299
+ const { POST } = createWorkspaceMemberHandlers(
300
+ deps(db, customerAdmin) as any,
301
+ );
302
+ const res = await POST(postReq({ userId: "u-orphan" }), ctx("spa"));
303
+
304
+ expect(res.status).toBe(403);
305
+ expect(await res.json()).toMatchObject({ code: "D5_NOT_FULLY_OWNED" });
306
+ });
307
+
308
+ it("nhưng vận hành toàn quyền thì gán được người chưa thuộc đâu cả", async () => {
309
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
310
+ const res = await POST(postReq({ userId: "u-orphan" }), ctx("spa"));
311
+
312
+ expect(res.status).toBe(201);
313
+ });
314
+
315
+ it("D3 — không tự sửa membership của chính mình", async () => {
316
+ const { POST } = createWorkspaceMemberHandlers(
317
+ deps(db, customerAdmin) as any,
318
+ );
319
+ const res = await POST(
320
+ postReq({ userId: "u-spa-admin", isAdmin: true }),
321
+ ctx("spa-qc"),
322
+ );
323
+
324
+ expect(res.status).toBe(403);
325
+ expect(await res.json()).toMatchObject({ code: "D3_SELF_ESCALATION" });
326
+ });
327
+ });
328
+
329
+ describe("gỡ người khỏi không gian", () => {
330
+ let db: ReturnType<typeof fakeDb>;
331
+ beforeEach(() => {
332
+ db = fakeDb(baseUsers());
333
+ configure(db);
334
+ });
335
+
336
+ it("chặn gỡ không gian CUỐI CÙNG — gỡ xong thì chính mình cũng hết đụng được", async () => {
337
+ const { DELETE } = createWorkspaceMemberHandlers(
338
+ deps(db, customerAdmin) as any,
339
+ );
340
+ const res = await DELETE(deleteReq("u-qc"), ctx("spa"));
341
+
342
+ expect(res.status).toBe(400);
343
+ expect(db.members.some((m) => m.userId === "u-qc")).toBe(true);
344
+ });
345
+
346
+ it("gỡ được khi người đó còn không gian khác trong phạm vi", async () => {
347
+ const { POST, DELETE } = createWorkspaceMemberHandlers(
348
+ deps(db, customerAdmin) as any,
349
+ );
350
+ await POST(postReq({ userId: "u-qc" }), ctx("spa-qc"));
351
+ const res = await DELETE(deleteReq("u-qc"), ctx("spa"));
352
+
353
+ expect(res.status).toBe(200);
354
+ expect(
355
+ db.members.some((m) => m.userId === "u-qc" && m.workspaceId === "spa"),
356
+ ).toBe(false);
357
+ });
358
+
359
+ it("không gỡ được người thuộc nhánh ngoài phạm vi", async () => {
360
+ const { DELETE } = createWorkspaceMemberHandlers(
361
+ deps(db, customerAdmin) as any,
362
+ );
363
+ const res = await DELETE(deleteReq("u-tanloc"), ctx("tanloc"));
364
+
365
+ expect(res.status).toBe(403);
366
+ expect(db.members.some((m) => m.userId === "u-tanloc")).toBe(true);
367
+ });
368
+ });
369
+
370
+ describe("tìm ứng viên để thêm", () => {
371
+ let db: ReturnType<typeof fakeDb>;
372
+ beforeEach(() => {
373
+ db = fakeDb(baseUsers());
374
+ configure(db);
375
+ });
376
+
377
+ it("chỉ tìm trong nhánh QUẢN TRỊ được, và loại người đã ở trong", async () => {
378
+ const { GET } = createWorkspaceMemberHandlers(
379
+ deps(db, customerAdmin) as any,
380
+ );
381
+ const res = await GET(
382
+ new Request("http://test/api/workspaces/x/members?candidates=1&q=nh"),
383
+ ctx("spa-qc"),
384
+ );
385
+ expect(res.status).toBe(200);
386
+
387
+ const call = db.calls.find((c) => c.fn === "user.findMany");
388
+ const and = call!.args.where.AND;
389
+ // Ràng buộc phạm vi phải bám `adminIds` (spa + spa-qc), KHÔNG phải allowedIds.
390
+ expect(JSON.stringify(and[0])).toContain("spa-qc");
391
+ expect(JSON.stringify(and[0])).not.toContain("tanloc");
392
+ expect(and[1]).toMatchObject({
393
+ userWorkspaces: { none: { workspaceId: "spa-qc" } },
394
+ });
395
+ });
396
+
397
+ it("người thường (không quản trị nhánh nào) không tìm được ứng viên", async () => {
398
+ const plain: Session = { userId: "u-plain", adminOf: [] };
399
+ const { GET } = createWorkspaceMemberHandlers(deps(db, plain) as any);
400
+ const res = await GET(
401
+ new Request("http://test/api/workspaces/x/members?candidates=1"),
402
+ ctx("spa"),
403
+ );
404
+
405
+ expect(res.status).toBe(403);
406
+ });
407
+ });