@goplusvn/core 0.1.77 → 0.1.79

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 CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.79 — Vai trò cũng có phạm vi: quản trị viên đơn vị chỉ thấy & sửa vai trò của mình
4
+
5
+ Lỗ hổng đối xứng của đợt 0.1.76: người dùng đã bị kẹp theo workspace, còn VAI
6
+ TRÒ thì không. Chặn *gán* vai trò mạnh (D4) mà để ngỏ đường *tạo* vai trò mạnh
7
+ chỉ là hàng rào có cửa sau — admin đơn vị tạo "Trợ lý" kèm `role:update` rồi tự
8
+ gán cho tài khoản mình vừa lập. Toàn bộ đợt này **ADDITIVE**: app không truyền
9
+ `getDelegationActor` / `scope` chạy y như trước.
10
+
11
+ - `workspace/delegation.ts` — thêm `assertCanManageRole`, `assertCanDeleteRole`,
12
+ `canManageRole`, `defaultRoleWorkspaceIdFor`, type `RoleDraft`. Vai trò mới bị
13
+ đóng dấu `workspaceId` của người tạo, ép `rank > actor.rank`, chặn quyền nguy
14
+ hiểm (D4) và quyền vượt trần của chính người tạo (D2).
15
+ - `rbac/role-service.ts` — `getRolesData(db, params, schema, scope?)` nhận
16
+ `RoleQueryScope`: lọc theo `adminWorkspaceIds` (+ vai trò dùng chung, chỉ
17
+ đọc), kẹp cả mảng `users[]` nhúng trong mỗi vai trò bằng `userWhere` — trước
18
+ đó trang vai trò là cửa hậu xem toàn bộ danh bạ. Trả thêm `workspaceId` /
19
+ `isSystem` / `rank` / `canManage`.
20
+ - `rbac/route-handlers.ts` — dep tuỳ chọn `getDelegationActor`. GET lọc theo
21
+ phạm vi; GET một vai trò ngoài phạm vi trả **404** (403 là xác nhận nó tồn
22
+ tại); POST/PUT/DELETE đi qua cổng uỷ quyền, từ chối trả 403 kèm mã D1/D2/D4.
23
+ - `user/user-service.ts` — `getAssignableRoles(db, actor)`: ô chọn vai trò trong
24
+ hộp thoại người dùng chỉ hiện vai trò GÁN ĐƯỢC (D2∧D4), không còn kiểu chọn
25
+ xong bấm lưu mới ăn 403.
26
+ - `rbac/pages/role-form-page.tsx` — prop `grantablePermissions`: ma trận quyền
27
+ cắt theo trần quyền người tạo ngay tại `resourceInfo.allowed` (mọi thứ dựng
28
+ trên nó tự tuân theo), mẫu vai trò cũng bị kẹp. Quyền đã cấp nằm ngoài trần
29
+ thì KHÔNG hiện và KHÔNG bị xoá — API từ chối lưu, thà chặn còn hơn âm thầm
30
+ tước quyền.
31
+ - `rbac` UI — `Role.canManage` tắt nút Sửa/Xoá và ô nhiệt trong danh sách, kèm
32
+ huy hiệu "Dùng chung" / "Hệ thống" để người dùng hiểu vì sao nút biến mất.
33
+
34
+ ### Cô lập DỮ LIỆU theo vai trò (`Role.dataScope` → nấc phạm vi)
35
+
36
+ Nửa còn lại: 0.1.76 kẹp *người dùng*, phần trên kẹp *vai trò*, nhưng **dữ liệu
37
+ nghiệp vụ** vẫn mở. Thang 5 nấc `none | own | workspace | subtree | all` (Δ1,
38
+ kiểu Dynamics 365) trước đây là code chết — nay được đấu vào phạm vi thật.
39
+
40
+ Quyết định thiết kế đáng nhớ: bốn nấc `none/workspace/subtree/all` được **gấp
41
+ thẳng vào `canViewAll` + `allowedIds` lúc dựng phạm vi**, nên mọi `scopedWhere`
42
+ / `memberScopeWhere` / guard lớp 2 đang có tự tuân theo — **không call-site nào
43
+ phải sửa**. Chỉ nấc `own` cần biết cột chủ sở hữu nên phải khai ở call-site.
44
+
45
+ - `workspace/types.ts` — `WorkspaceScope` thêm `level?` + `userId?`;
46
+ `WorkspaceScopeConfig` thêm `getScopeLevel?(session)` và `ownerField?`.
47
+ - `workspace/scope.ts` — `getWorkspaceScope` áp nấc: `all` → bỏ cô lập kể cả khi
48
+ không có quyền view-all; `none` → sentinel; `workspace` → **không** bung con
49
+ cháu; `subtree`/bỏ trống → y hệt hôm nay. `scopedWhere(scope, { ownerField })`
50
+ và `memberScopeWhere` hiểu nấc `own`; **thiếu `ownerField` là fail-closed**
51
+ (khớp sentinel) chứ không âm thầm nới lên `subtree`.
52
+ - `rbac/route-handlers.ts` — đọc/ghi `Role.dataScope` (dò cột bằng
53
+ `role.fields`, app chưa có cột thì bỏ qua). **Chỉ vai vận hành**
54
+ (`actor.canManageAll`) đặt được nấc: admin đơn vị gửi `dataScope` lên thì
55
+ field bị bỏ im lặng, nếu không họ tự nới tầm nhìn của chính mình.
56
+ - `rbac/pages/role-form-page.tsx` — ô chọn "Cô lập dữ liệu" sau prop
57
+ `showDataScope` (app truyền `canViewAllWorkspaces(session)`).
58
+
3
59
  ## 0.1.76 — Multi-tenant + workspace: một cây tổ chức/chi nhánh/phòng ban, uỷ quyền cấp tài khoản trong phạm vi
4
60
 
5
61
  Nguồn: `docs/WORKSPACE-MULTITENANT-PLAN.md` + `docs/WORKSPACE-RESEARCH-IAM.md`
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.77",
4
+ "version": "0.1.79",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -54,6 +54,7 @@
54
54
  "./import": "./src/import/index.ts",
55
55
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
56
56
  "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
57
+ "./rbac/landing-path": "./src/rbac/landing-path.ts",
57
58
  "./workspace/route-handlers": "./src/workspace/route-handlers.ts",
58
59
  "./rbac/permissions-version": "./src/rbac/permissions-version.ts",
59
60
  "./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
@@ -343,6 +343,88 @@ describe("rule bắt được vi phạm thật", () => {
343
343
  ]);
344
344
  });
345
345
 
346
+ /**
347
+ * Ca lỗi thật: tanloc-spartronics khởi tạo mà không chép
348
+ * `services/navigation-service.ts`, nên layout truyền thẳng cây menu xuống —
349
+ * vai `staff` thấy đủ mục, bấm vào mới bị cổng gác đá về (2026-08-15).
350
+ */
351
+ const NAV_RULE = "rbac/nav-filtered-by-permission";
352
+
353
+ it(`${NAV_RULE} — layout cấp menu thô cho vỏ ứng dụng thì đỏ`, () => {
354
+ const results = run(
355
+ fixture({
356
+ "app/[lang]/(main)/layout.tsx": [
357
+ 'import { navigations } from "@/data/navigations"',
358
+ "export default function L() {",
359
+ " return <MainLayout navigation={navigations} />",
360
+ "}",
361
+ ].join("\n"),
362
+ }),
363
+ );
364
+ expect(resultOf(results, NAV_RULE).violations).toEqual([
365
+ "app/[lang]/(main)/layout.tsx — cấp menu cho vỏ ứng dụng mà không lọc",
366
+ ]);
367
+ });
368
+
369
+ it(`${NAV_RULE} — lọc bằng mốc rộng hơn cổng gác trang thì đỏ`, () => {
370
+ const results = run(
371
+ fixture({
372
+ "app/[lang]/(main)/layout.tsx": [
373
+ 'import { getAccessibleResources } from "@goerp/core/auth"',
374
+ 'import { filterNavigationTree } from "@goerp/core/navigation"',
375
+ 'import { navigations } from "@/data/navigations"',
376
+ "export default async function L() {",
377
+ // Thiếu `"view"` = "có BẤT KỲ action nào" → menu rộng hơn trang mở được.
378
+ " const nav = filterNavigationTree(navigations, getAccessibleResources(session))",
379
+ " return <MainLayout navigation={nav} />",
380
+ "}",
381
+ ].join("\n"),
382
+ }),
383
+ );
384
+ expect(resultOf(results, NAV_RULE).violations).toEqual([
385
+ 'app/[lang]/(main)/layout.tsx — lọc menu không theo action "view"',
386
+ ]);
387
+ });
388
+
389
+ it(`${NAV_RULE} — truy sang service mà layout gọi, không dừng ở layout`, () => {
390
+ const results = run(
391
+ fixture({
392
+ "app/[lang]/(main)/layout.tsx": [
393
+ 'import { getNavigationForUser } from "@/services/navigation-service"',
394
+ "export default async function L() {",
395
+ " const nav = await getNavigationForUser()",
396
+ " return <MainLayout navigation={nav} />",
397
+ "}",
398
+ ].join("\n"),
399
+ "services/navigation-service.ts": [
400
+ 'import { navigationsData } from "@/data/navigations"',
401
+ "export async function getNavigationForUser() { return navigationsData }",
402
+ ].join("\n"),
403
+ }),
404
+ );
405
+ expect(resultOf(results, NAV_RULE).violations).toEqual([
406
+ "services/navigation-service.ts (qua app/[lang]/(main)/layout.tsx) — cấp menu cho vỏ ứng dụng mà không lọc",
407
+ ]);
408
+ });
409
+
410
+ it(`${NAV_RULE} — file đọc menu cho việc KHÁC không bị bắt oan`, () => {
411
+ // Ba ca có thật trong vinhhoa/thingtodo/spartronics: sinh registry TỪ menu,
412
+ // seed RBAC từ menu, và provider tra tiêu đề tab. Không chỗ nào vẽ menu.
413
+ const results = run(
414
+ fixture({
415
+ "configs/permissions/menu-tree.ts":
416
+ 'import { navigations } from "@/data/navigations"\nexport const MENU_TREE = navigations',
417
+ "app/api/resources/sync/route.ts":
418
+ 'import { navigationsData } from "@/data/navigations"\nexport const POST = () => sync(navigationsData)',
419
+ "providers/index.tsx":
420
+ 'import { navigations } from "@/data/navigations"\nexport const P = () => <TabNavigationProvider navigations={navigations} />',
421
+ "app/[lang]/layout.tsx":
422
+ 'import { P } from "@/providers"\nexport default function Root() { return <P /> }',
423
+ }),
424
+ );
425
+ expect(resultOf(results, NAV_RULE).violations).toEqual([]);
426
+ });
427
+
346
428
  it("rbac/* tự bỏ qua khi app không truyền registry", () => {
347
429
  const results = run(fixture({ "lib/a.ts": "export const a = 1" }));
348
430
  expect(resultOf(results, "rbac/known-actions").status).toBe(
@@ -18,6 +18,41 @@ const declaredResources = (ctx: GuardrailContext) =>
18
18
  (registryOf(ctx) ?? []).flatMap((f) => f.resources.map((r) => r.code)),
19
19
  );
20
20
 
21
+ /** Cây menu thô. Mọi app đều để ở `data/navigations` — cũng là `options.navigations`. */
22
+ const READS_RAW_NAV = /from\s+["'][^"']*data\/navigations["']/;
23
+
24
+ /** Hai lối ra hợp lệ cho việc lọc menu. */
25
+ const FILTERS_NAV = /(buildNavigationForSession|filterNavigationTree)\s*\(/;
26
+
27
+ /**
28
+ * Các file nguồn (rel `srcDir`) mà `code` import từ TRONG app — cả `@/x` lẫn
29
+ * `./x`. Trả về mọi ứng viên phần mở rộng; chỗ gọi dùng `readCodeRel` nên file
30
+ * không tồn tại chỉ ra chuỗi rỗng, không cần kiểm tra trước.
31
+ */
32
+ function localImportsOf(code: string, fromDir: string): string[] {
33
+ const out: string[] = [];
34
+ for (const m of code.matchAll(/from\s+["'](@\/|\.\.?\/)([^"']+)["']/g)) {
35
+ const base =
36
+ m[1] === "@/"
37
+ ? m[2]
38
+ : // `./a/b` và `../a/b` — gộp về đường dẫn tương đối srcDir rồi rút gọn.
39
+ `${fromDir}/${m[1]}${m[2]}`
40
+ .split("/")
41
+ .reduce<string[]>((acc, seg) => {
42
+ if (seg === "." || seg === "") return acc;
43
+ if (seg === "..") {
44
+ acc.pop();
45
+ return acc;
46
+ }
47
+ acc.push(seg);
48
+ return acc;
49
+ }, [])
50
+ .join("/");
51
+ out.push(`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`);
52
+ }
53
+ return out;
54
+ }
55
+
21
56
  const isApiFile = (rel: string) =>
22
57
  (rel.startsWith("app/api/") && rel.endsWith("/route.ts")) ||
23
58
  /^modules\/[a-z-]+\/api\//.test(rel);
@@ -202,6 +237,91 @@ export const rbacRules: GuardrailRule[] = [
202
237
  },
203
238
  },
204
239
 
240
+ {
241
+ id: "rbac/nav-filtered-by-permission",
242
+ title: "file nào đọc cây menu thô cũng phải lọc theo quyền",
243
+ why:
244
+ "Cây menu là bản đồ TOÀN BỘ hệ thống. Truyền thẳng nó xuống layout thì " +
245
+ "mọi người đăng nhập đều thấy đủ mục — vai `staff` không có một quyền " +
246
+ "nào trên `role`/`user` vẫn thấy Vai trò, Người dùng; bấm vào thì cổng " +
247
+ "gác đá về trang chủ, menu hứa một đằng trang trả lời một nẻo, và người " +
248
+ "dùng chỉ đọc được là 'hệ thống lỗi'. Đúng lỗi này sống trong " +
249
+ "tanloc-spartronics tới 2026-08-15 vì app đó khởi tạo mà không chép " +
250
+ "`services/navigation-service.ts` — `filterNavigationTree` có trong core " +
251
+ "nhưng KHÔNG một chỗ nào gọi. Ẩn ở client không cứu được: layout render " +
252
+ "ra HTML nên mục ẩn bằng CSS vẫn nằm nguyên trong payload gửi xuống.",
253
+ fix:
254
+ "Dùng `buildNavigationForSession(navigations, session)` của " +
255
+ "`@goerp/core/navigation` (fail-closed, mốc `view` sẵn), hoặc tự gọi " +
256
+ "`filterNavigationTree`. File chỉ đọc menu cho việc khác (breadcrumb, " +
257
+ "sitemap) thì khai vào `allowlists[\"rbac/nav-filtered-by-permission\"]`.",
258
+ run(ctx) {
259
+ const allowed = new Set(
260
+ ctx.options.allowlists["rbac/nav-filtered-by-permission"] ?? [],
261
+ );
262
+
263
+ const problems: string[] = [];
264
+ const seen = new Set<string>();
265
+
266
+ const check = (rel: string, code: string, via?: string) => {
267
+ if (!code || seen.has(rel) || allowed.has(rel)) return;
268
+ seen.add(rel);
269
+ const label = via ? `${rel} (qua ${via})` : rel;
270
+ if (!FILTERS_NAV.test(code)) {
271
+ problems.push(`${label} — cấp menu cho vỏ ứng dụng mà không lọc`);
272
+ return;
273
+ }
274
+ // Mốc lọc phải TRÙNG mốc cổng gác trang. `getAccessibleResources` bỏ
275
+ // trống action = "có BẤT KỲ action nào" → menu rộng hơn trang mở được.
276
+ // `buildNavigationForSession` đã chốt `"view"` bên trong nên không soi.
277
+ if (
278
+ /getAccessibleResources\s*\(/.test(code) &&
279
+ !/getAccessibleResources\s*\([^)]*["']view["']/.test(code)
280
+ ) {
281
+ problems.push(`${label} — lọc menu không theo action "view"`);
282
+ }
283
+ };
284
+
285
+ for (const file of ctx.files) {
286
+ const rel = ctx.rel(file);
287
+ const code = ctx.readCode(file);
288
+ // Chỉ soi chỗ menu đi VÀO GIAO DIỆN: prop `navigation` của vỏ ứng dụng
289
+ // (`MainLayout` của core), hoặc file layout. Quét mọi file đọc cây menu
290
+ // là báo oan hàng loạt — `configs/permissions/menu-tree.ts` sinh registry
291
+ // TỪ menu, `api/resources/sync` seed RBAC từ menu, trang audit tra nhãn
292
+ // resource: không chỗ nào trong số đó vẽ menu ra cả.
293
+ const feedsShell = /\bnavigation=\{/.test(code);
294
+ const isLayout = /(^|\/)layout\.tsx$/.test(rel);
295
+ if (!feedsShell && !isLayout) continue;
296
+
297
+ if (READS_RAW_NAV.test(code)) {
298
+ check(rel, code);
299
+ continue;
300
+ }
301
+ // Layout thường không tự đọc cây menu mà gọi một service
302
+ // (`getNavigationForUser`). Truy sang file đó: chính nó mới là chỗ
303
+ // quên lọc được, và không ai soi nó cả.
304
+ //
305
+ // CHỈ truy từ layout đang thật sự cấp `navigation=` cho vỏ. Truy từ mọi
306
+ // layout thì layout GỐC kéo theo `providers/index.tsx` — file này đọc
307
+ // cây menu để `TabNavigationProvider` tra tiêu đề tab, không vẽ menu, và
308
+ // bắt nó ở đây là báo oan ở cả ba app đang chạy.
309
+ if (!feedsShell) continue;
310
+ const dir = rel.split("/").slice(0, -1).join("/");
311
+ for (const dep of localImportsOf(code, dir)) {
312
+ const depCode = ctx.readCodeRel(dep);
313
+ if (depCode && READS_RAW_NAV.test(depCode)) check(dep, depCode, rel);
314
+ }
315
+ }
316
+ return problems;
317
+ },
318
+ staleAllowlist(ctx) {
319
+ return (
320
+ ctx.options.allowlists["rbac/nav-filtered-by-permission"] ?? []
321
+ ).filter((rel) => !ctx.existsInSrc(rel));
322
+ },
323
+ },
324
+
205
325
  {
206
326
  id: "rbac/gate-resource-declared",
207
327
  title: "resource mà cổng gác đều đã khai trong registry",
@@ -2,10 +2,13 @@
2
2
  // Core navigation utilities for GoERP platform
3
3
  // Consolidated from packages/shared/navigation
4
4
 
5
+ import { getAccessibleResources } from "../auth";
6
+
5
7
  import type {
6
8
  NavigationType,
7
9
  NavigationRootItem,
8
10
  NavigationNestedItem,
11
+ Session,
9
12
  } from "../types";
10
13
 
11
14
  /**
@@ -89,3 +92,49 @@ export function filterNavigationTree(
89
92
 
90
93
  return filteredNavigation;
91
94
  }
95
+
96
+ /**
97
+ * Menu của MỘT session — bản ráp sẵn, đúng-mặc-định.
98
+ *
99
+ * `filterNavigationTree` ở trên là nguyên liệu, và trong hai năm nó là thứ DUY
100
+ * NHẤT core cung cấp: mỗi app tự viết lấy phần ráp trong
101
+ * `src/services/navigation-service.ts`. Bốn app, bốn bản chép tay, và mỗi bản
102
+ * lệch một kiểu — tanloc-spartronics thì không có file đó luôn, nên menu không
103
+ * hề được lọc: mọi người đăng nhập đều thấy đủ mục, bấm vào mới bị
104
+ * `requirePageAccess` đá về trang chủ (2026-08-15). Hàm này tồn tại để câu trả
105
+ * lời đúng có đúng MỘT chỗ.
106
+ *
107
+ * Ba mặc định ở đây đều là bài học đã trả giá, đừng đảo:
108
+ *
109
+ * 1. **Chưa đăng nhập ⇒ menu RỖNG.** Fail-closed. Cây menu là bản đồ toàn bộ
110
+ * hệ thống; trả đầy đủ rồi tính sau là lộ cấu trúc màn hình cho người lạ.
111
+ * 2. **Mốc lọc mặc định là `"view"`**, TRÙNG mặc định của cổng gác trang
112
+ * (`requirePageAccess(lang, resource)`). `getAccessibleResources` bỏ trống
113
+ * action nghĩa là "có bất kỳ action nào" — lấy mốc đó thì menu rộng hơn
114
+ * trang, tức là lại hứa một đằng trả lời một nẻo, chỉ lệch ít hơn.
115
+ * 3. **KHÔNG bắt lỗi.** Hai bản chép tay cũ `catch → return []`, biến sự cố DB
116
+ * thành sidebar trắng trơn không lời giải thích — chế độ hỏng tệ nhất của
117
+ * app RBAC: người dùng tưởng bị thu hồi quyền và đi báo lỗi sai chỗ. Cứ để
118
+ * lỗi nổi lên error boundary.
119
+ *
120
+ * Core KHÔNG tự đọc session: app dùng better-auth, app dùng next-auth, và cách
121
+ * lấy session là thứ duy nhất khác nhau thật giữa chúng. App truyền session vào
122
+ * và tự bọc `cache()` của React nếu muốn gọi nhiều lần trong một request.
123
+ *
124
+ * ```ts
125
+ * // app/[lang]/(main)/layout.tsx
126
+ * const navigation = buildNavigationForSession(navigations, await getSession())
127
+ * ```
128
+ */
129
+ export function buildNavigationForSession(
130
+ navigation: NavigationType[],
131
+ session: Session | null,
132
+ options: { action?: string } = {},
133
+ ): NavigationType[] {
134
+ if (!session?.user) return [];
135
+
136
+ return filterNavigationTree(
137
+ navigation,
138
+ getAccessibleResources(session, options.action ?? "view"),
139
+ );
140
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createLandingPathResolver } from "../landing-path";
4
+ import type { NavigationType } from "../../types";
5
+
6
+ /**
7
+ * Ba thứ dễ hỏng ở đây, và hỏng thì người dùng KHÔNG vào được hệ thống:
8
+ * - `landingPath` trỏ tới trang vai trò không còn quyền → phải bỏ qua, nếu
9
+ * không sẽ là vòng chuyển hướng vô hạn.
10
+ * - App chưa migrate cột → phải im lặng bỏ qua bước 1, không được ném.
11
+ * - Người kiêm nhiều vai → theo vai MẠNH nhất (rank nhỏ nhất), tức theo đúng
12
+ * thứ tự DB trả về.
13
+ */
14
+
15
+ const NAV: NavigationType[] = [
16
+ {
17
+ title: "Tổng quan",
18
+ items: [
19
+ { title: "Trang chủ", href: "/", iconName: "House", resource: "dashboard" },
20
+ { title: "Hồ sơ", href: "/profile", iconName: "User" },
21
+ ],
22
+ },
23
+ {
24
+ title: "Bếp",
25
+ iconName: "Utensils",
26
+ items: [
27
+ {
28
+ title: "Đặt món",
29
+ href: "/ordering",
30
+ iconName: "Utensils",
31
+ resource: "ordering",
32
+ },
33
+ {
34
+ title: "Nhận món",
35
+ href: "/pickup",
36
+ iconName: "Hand",
37
+ resource: "pickup",
38
+ },
39
+ ],
40
+ },
41
+ ];
42
+
43
+ /** Phiên có đúng bộ quyền được liệt kê, không phải admin. */
44
+ function session(roles: string[], perms: Record<string, string[]>) {
45
+ return {
46
+ user: {
47
+ id: "u1",
48
+ roles,
49
+ permissionMap: perms,
50
+ },
51
+ } as any;
52
+ }
53
+
54
+ function fakePrisma(
55
+ rows: Array<{ landingPath: string | null }>,
56
+ opts?: { withColumn?: boolean; withRank?: boolean },
57
+ ) {
58
+ const findMany = vi.fn().mockResolvedValue(rows);
59
+ const role: any = { findMany };
60
+ if (opts?.withColumn !== false) {
61
+ role.fields = { landingPath: {} };
62
+ if (opts?.withRank !== false) role.fields.rank = {};
63
+ }
64
+ return { prisma: { role } as any, findMany };
65
+ }
66
+
67
+ describe("createLandingPathResolver", () => {
68
+ it("dùng landingPath của vai trò khi quyền còn hợp lệ", async () => {
69
+ const { prisma } = fakePrisma([{ landingPath: "/pickup" }]);
70
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
71
+
72
+ await expect(
73
+ resolve(session(["kitchen"], { pickup: ["view"] }), "vi"),
74
+ ).resolves.toBe("/vi/pickup");
75
+ });
76
+
77
+ it("bỏ qua landingPath đã mất quyền, rơi về mục menu đầu tiên mở được", async () => {
78
+ const { prisma } = fakePrisma([{ landingPath: "/pickup" }]);
79
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
80
+
81
+ await expect(
82
+ resolve(session(["staff"], { ordering: ["view"] }), "vi"),
83
+ ).resolves.toBe("/vi/profile");
84
+ });
85
+
86
+ it("bỏ qua landingPath không có trên menu (trang đã bị gỡ)", async () => {
87
+ const { prisma } = fakePrisma([{ landingPath: "/da-xoa" }]);
88
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
89
+
90
+ await expect(
91
+ resolve(session(["staff"], { ordering: ["view"] }), "vi"),
92
+ ).resolves.toBe("/vi/profile");
93
+ });
94
+
95
+ it("người kiêm nhiều vai đi theo dòng đầu tiên DB trả về (rank asc)", async () => {
96
+ const { prisma, findMany } = fakePrisma([
97
+ { landingPath: "/" },
98
+ { landingPath: "/pickup" },
99
+ ]);
100
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
101
+
102
+ await expect(
103
+ resolve(
104
+ session(["admin", "kitchen"], { dashboard: ["view"], pickup: ["view"] }),
105
+ "vi",
106
+ ),
107
+ ).resolves.toBe("/vi");
108
+ expect(findMany.mock.calls[0][0].orderBy).toEqual({ rank: "asc" });
109
+ });
110
+
111
+ it("app không có cột `rank` (vinhhoa) thì không sắp xếp, vẫn chạy", async () => {
112
+ const { prisma, findMany } = fakePrisma([{ landingPath: "/pickup" }], {
113
+ withRank: false,
114
+ });
115
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
116
+
117
+ await expect(
118
+ resolve(session(["kitchen"], { pickup: ["view"] }), "vi"),
119
+ ).resolves.toBe("/vi/pickup");
120
+ expect(findMany.mock.calls[0][0].orderBy).toBeUndefined();
121
+ });
122
+
123
+ it("app chưa migrate cột thì không hỏi DB, vẫn trả mục menu đầu tiên", async () => {
124
+ const { prisma, findMany } = fakePrisma([], { withColumn: false });
125
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
126
+
127
+ await expect(
128
+ resolve(session(["kitchen"], { pickup: ["view"] }), "vi"),
129
+ ).resolves.toBe("/vi/profile");
130
+ expect(findMany).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("mục không khai resource là trang ai đăng nhập cũng mở được", async () => {
134
+ const { prisma } = fakePrisma([{ landingPath: null }]);
135
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
136
+
137
+ await expect(resolve(session(["nobody"], {}), "vi")).resolves.toBe(
138
+ "/vi/profile",
139
+ );
140
+ });
141
+
142
+ it("chưa đăng nhập thì về trang đăng nhập", async () => {
143
+ const { prisma } = fakePrisma([]);
144
+ const resolve = createLandingPathResolver({ prisma, navigations: NAV });
145
+
146
+ await expect(resolve(null, "vi")).resolves.toBe("/vi/sign-in");
147
+ });
148
+ });