@goplusvn/core 0.1.66 → 0.1.69

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 (37) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/bin/goerp-guardrails.mjs +45 -0
  3. package/eslint/index.mjs +120 -0
  4. package/package.json +10 -3
  5. package/scripts/doctor.ts +99 -0
  6. package/src/auth/__tests__/permissions.test.ts +162 -0
  7. package/src/auth/index.ts +113 -34
  8. package/src/guardrails/__tests__/guardrails.test.ts +430 -0
  9. package/src/guardrails/index.ts +57 -0
  10. package/src/guardrails/preset.ts +75 -0
  11. package/src/guardrails/primitives.ts +307 -0
  12. package/src/guardrails/rules/auth.ts +178 -0
  13. package/src/guardrails/rules/debt.ts +71 -0
  14. package/src/guardrails/rules/design.ts +95 -0
  15. package/src/guardrails/rules/layering.ts +160 -0
  16. package/src/guardrails/rules/one-door.ts +115 -0
  17. package/src/guardrails/rules/rbac.ts +282 -0
  18. package/src/guardrails/rules/safety.ts +86 -0
  19. package/src/guardrails/rules/structure.ts +136 -0
  20. package/src/guardrails/run.ts +130 -0
  21. package/src/guardrails/scanner.ts +144 -0
  22. package/src/guardrails/types.ts +181 -0
  23. package/src/rbac/index.ts +6 -9
  24. package/src/types/index.ts +17 -0
  25. package/src/ui/data-display/shallow-pagination.tsx +189 -0
  26. package/src/ui/index.tsx +1 -0
  27. package/src/user/components/index.ts +1 -0
  28. package/src/user/components/user-toolbar.tsx +8 -2
  29. package/src/user/components/user-visuals.tsx +84 -0
  30. package/src/user/components/users-card-view.tsx +1 -26
  31. package/src/user/components/users-table.tsx +215 -0
  32. package/src/user/pages/users-client-page.tsx +84 -259
  33. package/templates/starter-app/AGENTS.md +39 -3
  34. package/templates/starter-app/eslint.config.mjs +85 -0
  35. package/templates/starter-app/package.json +19 -2
  36. package/templates/starter-app/prettier.config.mjs +54 -0
  37. package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
package/CHANGELOG.md CHANGED
@@ -1,5 +1,121 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.69 — Hàng rào kiến trúc thành thư viện: `@goerp/core/guardrails`
4
+
5
+ Mỗi app dựng từ bộ init trước đây tự chép một file `architecture.test.ts`. Chép
6
+ là đóng băng: bài học rút ra ở app này không bao giờ tới được app kia, và bản
7
+ chép mục dần cho tới lúc không ai dám sửa. Nay luật sống trong core, app chỉ
8
+ khai phần dữ liệu của mình.
9
+
10
+ **`runCoreGuardrails({...})`** — một lời gọi, sinh ra ~41 thước theo bảy nhóm
11
+ (`auth` `rbac` `layering` `one-door` `structure` `design` `safety` + trần
12
+ `debt`). App khai đúng ba thứ: `allowlists` (nợ cũ, CHỈ được rút bớt),
13
+ `ceilings` (trần theo thư mục, chỉ được hạ và không được dư), `skip` (tắt hẳn,
14
+ bắt buộc kèm lý do). Thêm `doors` / `domain` / `extraRules` cho từ vựng riêng.
15
+ Vinhhoa chuyển sang preset: 1.273 → 547 dòng, số thước chạy TĂNG 57 → 79.
16
+
17
+ **Thước tự bỏ qua khi không áp dụng** (`NOT_APPLICABLE`): cùng một preset chạy
18
+ được trên app starter trống lẫn app 1.400 file, không phải khai thừa thứ chưa
19
+ có.
20
+
21
+ **Mỗi thước mang theo `why` là sự cố THẬT** — thông báo lỗi in `[id] why → fix`,
22
+ nên người gặp đỏ đọc được vì sao luật tồn tại thay vì đi tìm cách tắt nó.
23
+
24
+ **Ratchet hai chiều**: `staleAllowlist` báo entry đã hết vi phạm (suất miễn trừ
25
+ quá hạn chính là chỗ nợ mới chui vào), `debt/*-no-slack` báo trần dư.
26
+
27
+ **Allowlist theo THƯ MỤC**: entry kết thúc bằng `/` miễn trừ cả cây con. Dùng
28
+ cho `_handlers/` của dispatcher — resource là BIẾN theo loại đơn nên không khai
29
+ được ở cổng, mà số file thì tăng mỗi lần thêm hành động; liệt kê từng file ở đó
30
+ chỉ đẻ ra một danh sách phải sửa cho xanh.
31
+
32
+ **`rbac/gate-*` đọc đúng chỗ khai quyền.** Bản đầu quét trần `resource: "..."`
33
+ trên cả file và báo nhầm hai thứ rất phổ biến: `orderBy: { _count: { resource:
34
+ "desc" } }` của Prisma, và payload nhật ký `logEntityAction({ resource:
35
+ "stock-movement" })` — ở đó `resource` là tên THỰC THỂ bị ghi vết, không phải mã
36
+ quyền. Nay chỉ đọc các đối số object literal ở TẦNG NGOÀI CÙNG của lời gọi cổng;
37
+ tên cổng khai qua `domain.gateCalls` (mặc định `apiHandler` /
38
+ `requirePermission` / `requireApiPermission`).
39
+
40
+ **`goerp-guardrails`** — CLI chẩn đoán: liệt kê mọi thước kèm trạng thái, và
41
+ `--write-baseline` sinh sẵn khối `allowlists`/`ceilings` đóng băng hiện trạng
42
+ cho app đang migrate.
43
+
44
+ **`@goerp/core/eslint`** — lớp chặn thứ hai, lúc gõ code thay vì lúc chạy test.
45
+ Mỗi thông báo kết bằng `[rule/id]` tương ứng để hai lớp nói cùng một ngôn ngữ.
46
+
47
+ Bộ init: `templates/starter-app` nay đi kèm `architecture.test.ts` mỏng (một lời
48
+ gọi + trần `any` của chính bộ khung), `eslint.config.mjs`, `prettier.config.mjs`
49
+ và mục hàng rào trong `AGENTS.md`.
50
+
51
+ `vitest` thành peerDependency (optional) — chỉ cần khi app dùng
52
+ `@goerp/core/guardrails`.
53
+
54
+ ## 0.1.68 — Trang Người dùng: bảng chuẩn + phân trang chung hai chế độ xem
55
+
56
+ Trang `UsersClientPage` lệch chuẩn ở cả hai chế độ: card view KHÔNG phân trang
57
+ (38 user là 38 thẻ đổ hết xuống), còn table view là `BasicUserTable` tự chế —
58
+ `useState` trang cục bộ, nút "Trang trước/sau" tự vẽ, số trang không vào URL và
59
+ không ăn nhập gì với card view.
60
+
61
+ **`UsersTable` mới** (`user/components/users-table.tsx`): column def tanstack
62
+ trên `DataTable` chuẩn của core — STT tự tính theo trang, sticky header, thanh
63
+ phân trang "Hiển thị x - y trong tổng số n mục" + ô nhập trang + chọn số dòng,
64
+ click dòng mở dialog hồ sơ. `BasicUserTable` xoá hẳn.
65
+
66
+ **Phân trang CHUNG qua URL (`?page&pageSize`)**: client page cắt trang một chỗ
67
+ cho cả hai chế độ (server vẫn trả toàn bộ danh sách đã lọc). Card view thêm
68
+ footer `ShallowPagination`; đổi trang ghi URL bằng `history.replaceState` —
69
+ không kéo lượt RSC chỉ để lấy dữ liệu client đang cầm. Chuyển qua lại
70
+ bảng ↔ thẻ giữ nguyên trang đang đứng. Đổi bộ lọc/pageSize thì về trang 1;
71
+ trang đang đứng vượt phạm vi (do lọc bớt) thì tự kẹp về trang cuối.
72
+
73
+ **Thêm `ShallowPagination` vào `@goerp/core/ui`** (port từ vinhhoa, giao diện
74
+ 1:1 với `DataTablePagination`/`UrlPagination`) — cho mọi trang mà client làm
75
+ chủ dữ liệu; trang SSR thuần vẫn dùng `UrlPagination`.
76
+
77
+ **Sửa bug toolbar nuốt query**: effect debounce ô tìm kiếm trong `UserToolbar`
78
+ phụ thuộc identity `onSearchChange`; URL đổi → client page tạo lại callback →
79
+ effect chạy lại → 300ms sau phát `""` → `router.push` XOÁ sạch query (mất
80
+ `?page` vừa bấm). Nay chỉ phát khi giá trị thật sự đổi so với lần phát trước,
81
+ và `applyFilter` phía client page đọc query tại thời điểm gọi
82
+ (`window.location.search`) thay vì phụ thuộc `searchParams`.
83
+
84
+ Kèm `user-visuals.tsx` dùng chung palette avatar + badge trạng thái/loại user
85
+ cho card view lẫn table (trước lặp 2 bản trong card view và bảng cũ).
86
+
87
+ ## 0.1.67 — Quyền trong session: dạng map thay cho mảng cặp
88
+
89
+ `session.user.permissions` là `Permission[]` — mảng các cặp
90
+ `{resourceCode, actionCode}`, lặp lại tên resource ở MỌI cặp. Đo trên một ERP
91
+ thật (user 9 vai trò): **2.428 quyền trải trên 73 resource = 139,7 KB JSON**,
92
+ đi kèm mọi lượt đọc session, kể cả chuyến ra client. Và mỗi lần
93
+ `checkPermission` là một lần quét tuyến tính cả 2.428 phần tử.
94
+
95
+ **Thêm `ExtendedUser.permissionMap?: PermissionMap`** (`Record<resource,
96
+ action[]>`) — cùng bộ quyền đó còn **35,5 KB**, và tra cứu thành "lấy đúng
97
+ resource rồi quét vài chục action" thay vì quét cả mảng.
98
+
99
+ **KHÔNG breaking.** `permissions` vẫn chạy nguyên vẹn: mọi hàm kiểm quyền đọc
100
+ `permissionMap` trước rồi mới lùi về `permissions`. App chưa chuyển không phải
101
+ sửa gì. (Bản chuyển đổi cho đường cũ được nhớ trong `WeakMap` theo tham chiếu
102
+ mảng, nếu không thì một trang gọi `checkPermission` 20 lần sẽ dựng map 20 lần —
103
+ tệ hơn hẳn phép quét có thoát sớm mà nó thay thế.) Có cả hai thì
104
+ `permissionMap` thắng.
105
+
106
+ **Thêm `getAccessibleResources(session, actionCode?)`.** Trả `Set` các resource
107
+ user chạm được — bỏ trống `actionCode` là "có bất kỳ action nào" (App Launcher),
108
+ truyền `"view"` là lọc menu. Admin trả `Set(["*"])` khớp quy ước wildcard của
109
+ cây điều hướng. Có ở core vì mọi app đều tự viết lại đúng vòng lặp này, mỗi nơi
110
+ một kiểu.
111
+
112
+ Kèm `toPermissionMap` / `fromPermissionMap` cho ranh giới với code chưa chuyển.
113
+
114
+ **Dọn hai bản sao của `Permission`.** `auth/index.ts` khai báo lại y hệt bản
115
+ trong `types/index.ts`, và `rbac/index.ts` còn một `ExtendedUser` thứ ba không
116
+ ai dùng. Nay `Permission`/`PermissionMap` có ĐÚNG một nguồn ở `types`, các nơi
117
+ khác chỉ re-export.
118
+
3
119
  ## 0.1.66 — Cache: hết TTL vô hạn, hết nuốt lỗi RBAC
4
120
 
5
121
  Hai chế độ hỏng âm thầm, cùng một họ: hệ thống *tin* là nó có dữ liệu trong khi
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // CLI `goerp-guardrails` — chạy hàng rào kiến trúc của @goerp/core lên app hiện
3
+ // tại và báo cáo (không làm đỏ CI).
4
+ //
5
+ // pnpm guardrails # bảng trạng thái từng thước
6
+ // pnpm guardrails --verbose # in đủ mọi chỗ chưa đạt
7
+ // pnpm guardrails --write-baseline # in khối allowlist/ceilings/skip để dán
8
+ //
9
+ // Engine viết bằng TypeScript và import không kèm đuôi file, nên bộ strip-types
10
+ // sẵn có của Node không nạp được — chạy qua `tsx` (đã nằm trong devDependencies
11
+ // của bộ khung; app cũ chưa có thì `pnpm add -D tsx`).
12
+
13
+ import { spawnSync } from "node:child_process";
14
+ import { createRequire } from "node:module";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const doctor = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "doctor.ts");
19
+ const appRoot = process.cwd();
20
+
21
+ /** tsx của app trước, rồi tới tsx cài kèm core. */
22
+ function findTsx() {
23
+ for (const from of [join(appRoot, "package.json"), import.meta.url]) {
24
+ try {
25
+ return createRequire(from).resolve("tsx/cli");
26
+ } catch {
27
+ /* thử chỗ kế tiếp */
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ const tsx = findTsx();
34
+ if (!tsx) {
35
+ console.error("Không tìm thấy `tsx`. Cài bằng: pnpm add -D tsx");
36
+ process.exit(1);
37
+ }
38
+
39
+ // Không truyền appRoot làm tham số vị trí: doctor mặc định lấy cwd, và người
40
+ // dùng vẫn có thể chỉ định thư mục khác.
41
+ const { status } = spawnSync(process.execPath, [tsx, doctor, ...process.argv.slice(2)], {
42
+ stdio: "inherit",
43
+ cwd: appRoot,
44
+ });
45
+ process.exit(status ?? 1);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tầng lint của hàng rào GoERP — `@goerp/core/eslint`.
3
+ *
4
+ * Cùng bài học với `@goerp/core/guardrails`, khác thời điểm bắt:
5
+ *
6
+ * - guardrails chạy lúc `pnpm test`, quét được quan hệ giữa NHIỀU file
7
+ * (ranh giới module, đếm nợ theo thư mục, route nào chưa gác);
8
+ * - lint chạy ngay trong editor, chỉ thấy MỘT file — nhưng thấy lúc người ta
9
+ * còn đang gõ, tức là trước khi cái sai kịp lan ra.
10
+ *
11
+ * Nên ở đây chỉ có những luật quyết định được trong phạm vi một file, và mỗi
12
+ * thông báo đều dẫn về id của thước tương ứng để hai tầng không trôi khỏi nhau.
13
+ *
14
+ * Cách dùng trong eslint.config.mjs của app:
15
+ *
16
+ * import goerp from "@goerp/core/eslint"
17
+ * export default [ ...(cấu hình sẵn có), ...goerp() ]
18
+ *
19
+ * Cố ý KHÔNG phụ thuộc plugin nào: chỉ dùng luật lõi của ESLint, nên khối này
20
+ * ghép được vào mọi cấu hình mà không kéo thêm dependency hay đụng phiên bản.
21
+ */
22
+
23
+ /** @type {import("eslint").Linter.RuleEntry} */
24
+ const NO_RESTRICTED_IMPORTS = [
25
+ "error",
26
+ {
27
+ paths: [
28
+ {
29
+ name: "next-auth",
30
+ message:
31
+ "next-auth đã gỡ — dùng Better Auth (@/lib/auth). [auth/no-next-auth]",
32
+ },
33
+ {
34
+ name: "@goerp/core/storage",
35
+ message:
36
+ "Kho tập tin đi qua đúng một cửa: import @/lib/storage. Cửa đó là nơi " +
37
+ "gọi configureStorage — import thẳng core thì chạy vào engine chưa cấu " +
38
+ "hình. [one-door/storage]",
39
+ },
40
+ {
41
+ name: "@goerp/core/branch-scope",
42
+ message:
43
+ "Phạm vi chi nhánh đi qua cửa hạ tầng của app (@/lib/rbac/branch-scope " +
44
+ "hoặc composition root), đừng import thẳng core. [one-door/branch-scope]",
45
+ },
46
+ ],
47
+ patterns: [
48
+ {
49
+ group: ["@/modules/*/services/*", "@/modules/*/repositories/*", "@/modules/*/lib/*"],
50
+ message:
51
+ "Deep-import xuyên ranh giới module. Dùng public API `@/modules/<domain>`. " +
52
+ "(Ngoại lệ đã được thước kiểm tra tự nhận: stub app/api và file server " +
53
+ "action.) [layering/module-public-api]",
54
+ },
55
+ ],
56
+ },
57
+ ];
58
+
59
+ /** @type {import("eslint").Linter.RuleEntry} */
60
+ const NO_RESTRICTED_SYNTAX = [
61
+ "error",
62
+ {
63
+ selector: "NewExpression[callee.name='PrismaClient']",
64
+ message:
65
+ "Chỉ composition root (src/lib/prisma.ts) được tạo PrismaClient. Client thứ " +
66
+ "hai mở thêm một pool và không có extension audit/branch-guard. " +
67
+ "[one-door/single-prisma-client]",
68
+ },
69
+ {
70
+ selector:
71
+ "CallExpression[callee.object.name='window'][callee.property.name='open'] > " +
72
+ "Literal.arguments[value=/\\/export/]",
73
+ message:
74
+ "Tải file qua hook useExport (giữ header xác thực + báo lỗi). window.open " +
75
+ "tới endpoint export bỏ qua cả hai. [one-door/export-no-window-open]",
76
+ },
77
+ {
78
+ selector: "TSAsExpression > TSAnyKeyword",
79
+ message:
80
+ "`as any` tắt kiểm tra kiểu ngay chỗ dễ sai nhất. Nếu buộc phải dùng thì " +
81
+ "eslint-disable kèm lý do — số lượng còn bị đếm bởi thước [debt/any].",
82
+ },
83
+ ];
84
+
85
+ /**
86
+ * @param {{ srcDir?: string }} [options]
87
+ * @returns {import("eslint").Linter.Config[]}
88
+ */
89
+ export default function goerpEslintConfig(options = {}) {
90
+ const src = options.srcDir ?? "src";
91
+ return [
92
+ {
93
+ files: [`${src}/**/*.{ts,tsx}`],
94
+ rules: {
95
+ "no-restricted-imports": NO_RESTRICTED_IMPORTS,
96
+ "no-restricted-syntax": NO_RESTRICTED_SYNTAX,
97
+ },
98
+ },
99
+ {
100
+ // Composition root là nơi DUY NHẤT được làm những việc trên — nó chính là
101
+ // cái cửa mà các luật kia bắt mọi người đi qua.
102
+ files: [
103
+ `${src}/lib/prisma.ts`,
104
+ `${src}/lib/storage.ts`,
105
+ `${src}/lib/rbac/branch-scope.ts`,
106
+ `${src}/lib/branch-scope.ts`,
107
+ `${src}/server/bootstrap.ts`,
108
+ ],
109
+ rules: {
110
+ "no-restricted-imports": "off",
111
+ "no-restricted-syntax": "off",
112
+ },
113
+ },
114
+ {
115
+ // Script vận hành chạy độc lập ngoài Next: nó tự mở client của mình.
116
+ files: [`${src}/../scripts/**/*.{ts,mts}`, "scripts/**/*.{ts,mts}", "prisma/**/*.ts"],
117
+ rules: { "no-restricted-syntax": "off" },
118
+ },
119
+ ];
120
+ }
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.66",
4
+ "version": "0.1.69",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -13,11 +13,13 @@
13
13
  "sideEffects": false,
14
14
  "bin": {
15
15
  "goerp-features": "./bin/goerp-features.mjs",
16
+ "goerp-guardrails": "./bin/goerp-guardrails.mjs",
16
17
  "goerp-init": "./bin/goerp-init.mjs"
17
18
  },
18
19
  "files": [
19
20
  "src",
20
21
  "bin",
22
+ "eslint",
21
23
  "scripts",
22
24
  "features",
23
25
  "templates",
@@ -95,7 +97,8 @@
95
97
  "./package.json": "./package.json",
96
98
  "./providers/brand-theme": "./src/providers/brand-theme.ts",
97
99
  "./storage": "./src/storage/index.ts",
98
- "./storage/s3": "./src/storage/s3/index.ts"
100
+ "./storage/s3": "./src/storage/s3/index.ts",
101
+ "./eslint": "./eslint/index.mjs"
99
102
  },
100
103
  "peerDependencies": {
101
104
  "@aws-sdk/client-s3": "^3.0.0",
@@ -103,7 +106,8 @@
103
106
  "@smithy/node-http-handler": "^4.0.0",
104
107
  "next": ">=14.0.0",
105
108
  "react": "^18.0.0 || ^19.0.0",
106
- "react-dom": "^18.0.0 || ^19.0.0"
109
+ "react-dom": "^18.0.0 || ^19.0.0",
110
+ "vitest": ">=2"
107
111
  },
108
112
  "devDependencies": {
109
113
  "@aws-sdk/client-s3": "^3.1101.0",
@@ -215,6 +219,9 @@
215
219
  },
216
220
  "@smithy/node-http-handler": {
217
221
  "optional": true
222
+ },
223
+ "vitest": {
224
+ "optional": true
218
225
  }
219
226
  },
220
227
  "scripts": {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `goerp doctor` — chạy toàn bộ hàng rào kiến trúc lên app hiện tại và BÁO CÁO,
3
+ * không làm đỏ CI.
4
+ *
5
+ * Nó tồn tại vì một app đã chạy nhiều tháng (thingtodo, tanloc, wu) không thể
6
+ * bật hết luật trong một lần: bật lên là vài trăm chỗ đỏ cùng lúc, và phản ứng
7
+ * tự nhiên khi thấy vậy là tắt luôn cả bộ luật. Nên có hai bước:
8
+ *
9
+ * pnpm guardrails # xem mình đang ở đâu
10
+ * pnpm guardrails --write-baseline # in ra khối allowlist/ceilings/skip
11
+ *
12
+ * Dán khối đó vào `runCoreGuardrails({...})` là hiện trạng được ĐÓNG BĂNG: nợ
13
+ * cũ không chặn ai, nhưng từ đó trở đi không ai thêm nợ mới được nữa. Trả nợ
14
+ * tới đâu thì xoá entry / hạ trần tới đó — thước sẽ tự báo entry nào đã thừa.
15
+ */
16
+ import { evaluateGuardrails } from "../src/guardrails/preset";
17
+ import type { GuardrailResult } from "../src/guardrails/types";
18
+
19
+ const args = process.argv.slice(2);
20
+ const writeBaseline = args.includes("--write-baseline");
21
+ const verbose = args.includes("--verbose") || args.includes("-v");
22
+ const root = args.find((a) => !a.startsWith("-")) ?? process.cwd();
23
+
24
+ const results = evaluateGuardrails({ root });
25
+
26
+ const failed = results.filter((r) => r.status === "fail");
27
+ const passed = results.filter((r) => r.status === "pass");
28
+ const skipped = results.filter((r) => r.status === "skipped");
29
+ const na = results.filter((r) => r.status === "not-applicable");
30
+
31
+ const ICON: Record<GuardrailResult["status"], string> = {
32
+ pass: "✔",
33
+ fail: "✘",
34
+ skipped: "○",
35
+ "not-applicable": "–",
36
+ };
37
+
38
+ console.log(`\nHàng rào kiến trúc @goerp/core — ${root}\n`);
39
+ for (const r of results) {
40
+ const count = r.violations.length ? ` (${r.violations.length})` : "";
41
+ console.log(` ${ICON[r.status]} ${r.rule.id}${count}`);
42
+ if (r.status === "fail") {
43
+ const show = verbose ? r.violations : r.violations.slice(0, 3);
44
+ for (const v of show) console.log(` - ${v}`);
45
+ if (!verbose && r.violations.length > 3) {
46
+ console.log(` … còn ${r.violations.length - 3} chỗ (thêm --verbose)`);
47
+ }
48
+ }
49
+ }
50
+
51
+ console.log(
52
+ `\n${passed.length} đạt · ${failed.length} chưa đạt · ` +
53
+ `${skipped.length} đang tắt · ${na.length} không áp dụng\n`,
54
+ );
55
+
56
+ if (failed.length > 0 && !writeBaseline) {
57
+ console.log("Chạy lại với --write-baseline để lấy khối đóng băng hiện trạng.\n");
58
+ }
59
+
60
+ if (!writeBaseline) process.exit(0);
61
+
62
+ /** Vi phạm của thước đếm-theo-thư-mục có dạng `app: 671 > trần 0`. */
63
+ const CEILING_LINE = /^(\S+):\s+(\d+)\s+>/;
64
+
65
+ const allowlists: Record<string, string[]> = {};
66
+ const ceilings: Record<string, Record<string, number>> = {};
67
+ const skip: Record<string, string> = {};
68
+
69
+ for (const r of failed) {
70
+ const ceilingEntries = r.violations
71
+ .map((v) => CEILING_LINE.exec(v))
72
+ .filter((m): m is RegExpExecArray => m !== null);
73
+
74
+ if (ceilingEntries.length === r.violations.length && ceilingEntries.length > 0) {
75
+ ceilings[r.rule.id] = Object.fromEntries(
76
+ ceilingEntries.map((m) => [m[1], Number(m[2])]),
77
+ );
78
+ continue;
79
+ }
80
+
81
+ if (r.rule.staleAllowlist) {
82
+ // Vi phạm có thể kèm giải thích sau ` — `; allowlist chỉ nhận đường dẫn.
83
+ allowlists[r.rule.id] = r.violations.map((v) => v.split(" — ")[0].trim()).sort();
84
+ continue;
85
+ }
86
+
87
+ // Không có allowlist thì chỉ còn cách tắt hẳn — và phải ghi lý do.
88
+ skip[r.rule.id] = `TODO: ${r.violations.length} chỗ chưa đạt, chưa hẹn ngày trả`;
89
+ }
90
+
91
+ const block = { allowlists, ceilings, skip };
92
+ console.log("// Dán vào runCoreGuardrails({ ... }) — sửa mọi dòng TODO thành lý do thật:");
93
+ console.log(
94
+ JSON.stringify(block, null, 2)
95
+ .split("\n")
96
+ .filter((line) => !/^\s*"(allowlists|ceilings|skip)": \{\},?$/.test(line))
97
+ .join("\n"),
98
+ );
99
+ console.log();
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ checkPermission,
5
+ fromPermissionMap,
6
+ getAccessibleResources,
7
+ getCrudPermissionsFromSession,
8
+ getUserPermissions,
9
+ toPermissionMap,
10
+ } from "../index";
11
+
12
+ /** Session dạng CŨ — app chưa chuyển vẫn gán `permissions`. */
13
+ const legacy = {
14
+ user: {
15
+ id: "u1",
16
+ roles: ["ACCOUNTANT"],
17
+ permissions: [
18
+ { resourceCode: "receipt", actionCode: "view" },
19
+ { resourceCode: "receipt", actionCode: "create" },
20
+ { resourceCode: "expense", actionCode: "view" },
21
+ ],
22
+ },
23
+ };
24
+
25
+ /** Session dạng GỌN — cùng đúng bộ quyền trên. */
26
+ const compact = {
27
+ user: {
28
+ id: "u1",
29
+ roles: ["ACCOUNTANT"],
30
+ permissionMap: { receipt: ["view", "create"], expense: ["view"] },
31
+ },
32
+ };
33
+
34
+ const both = [
35
+ ["dạng cũ (permissions)", legacy],
36
+ ["dạng gọn (permissionMap)", compact],
37
+ ] as const;
38
+
39
+ describe("checkPermission — hai dạng phải cho CÙNG kết quả", () => {
40
+ for (const [label, session] of both) {
41
+ it(`${label}: đúng resource + đúng action → true`, () => {
42
+ expect(checkPermission(session, "receipt", "create")).toBe(true);
43
+ });
44
+
45
+ it(`${label}: đúng resource nhưng SAI action → false`, () => {
46
+ expect(checkPermission(session, "receipt", "delete")).toBe(false);
47
+ });
48
+
49
+ it(`${label}: action có ở resource KHÁC không được lây sang`, () => {
50
+ // Bẫy kinh điển của việc gộp khoá: "expense" có "view", "receipt" có
51
+ // "create" — không được suy ra "expense:create".
52
+ expect(checkPermission(session, "expense", "create")).toBe(false);
53
+ });
54
+
55
+ it(`${label}: resource lạ → false`, () => {
56
+ expect(checkPermission(session, "ghost", "view")).toBe(false);
57
+ });
58
+ }
59
+
60
+ it("SUPER_ADMIN qua cổng kể cả khi không có dòng quyền nào", () => {
61
+ const admin = { user: { id: "u2", roles: ["SUPER_ADMIN"] } };
62
+ expect(checkPermission(admin, "bất-kỳ", "bất-kỳ")).toBe(true);
63
+ });
64
+
65
+ it("không có session → false", () => {
66
+ expect(checkPermission(null, "receipt", "view")).toBe(false);
67
+ });
68
+
69
+ it("permissionMap ĐÈ permissions khi có cả hai", () => {
70
+ const mixed = {
71
+ user: {
72
+ id: "u1",
73
+ roles: [],
74
+ permissions: [{ resourceCode: "receipt", actionCode: "delete" }],
75
+ permissionMap: { receipt: ["view"] },
76
+ },
77
+ };
78
+ expect(checkPermission(mixed, "receipt", "view")).toBe(true);
79
+ expect(checkPermission(mixed, "receipt", "delete")).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("getCrudPermissionsFromSession", () => {
84
+ for (const [label, session] of both) {
85
+ it(`${label}: chỉ bật đúng action đang có`, () => {
86
+ const p = getCrudPermissionsFromSession(session, "receipt");
87
+ expect(p).toEqual({
88
+ view: true,
89
+ create: true,
90
+ update: false,
91
+ delete: false,
92
+ export: false,
93
+ import: false,
94
+ approve: false,
95
+ reject: false,
96
+ });
97
+ });
98
+ }
99
+
100
+ it("entity không có quyền nào → tất cả false", () => {
101
+ const p = getCrudPermissionsFromSession(compact, "ghost");
102
+ expect(Object.values(p).every((v) => v === false)).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe("getAccessibleResources", () => {
107
+ for (const [label, session] of both) {
108
+ it(`${label}: bỏ trống action = có BẤT KỲ action nào`, () => {
109
+ expect(getAccessibleResources(session)).toEqual(
110
+ new Set(["receipt", "expense"]),
111
+ );
112
+ });
113
+
114
+ it(`${label}: lọc theo action cụ thể`, () => {
115
+ expect(getAccessibleResources(session, "create")).toEqual(
116
+ new Set(["receipt"]),
117
+ );
118
+ });
119
+ }
120
+
121
+ it("admin → wildcard, khớp quy ước cây điều hướng", () => {
122
+ const admin = { user: { id: "u2", roles: ["admin"] } };
123
+ expect(getAccessibleResources(admin)).toEqual(new Set(["*"]));
124
+ });
125
+
126
+ it("không có session → rỗng, KHÔNG phải wildcard", () => {
127
+ expect(getAccessibleResources(null)).toEqual(new Set());
128
+ });
129
+ });
130
+
131
+ describe("chuyển đổi hai chiều", () => {
132
+ it("toPermissionMap gộp trùng", () => {
133
+ expect(
134
+ toPermissionMap([
135
+ { resourceCode: "receipt", actionCode: "view" },
136
+ { resourceCode: "receipt", actionCode: "view" },
137
+ { resourceCode: "receipt", actionCode: "create" },
138
+ ]),
139
+ ).toEqual({ receipt: ["view", "create"] });
140
+ });
141
+
142
+ it("đi rồi về giữ nguyên tập quyền", () => {
143
+ const back = fromPermissionMap(toPermissionMap(legacy.user.permissions));
144
+ expect(new Set(back.map((p) => `${p.resourceCode}:${p.actionCode}`))).toEqual(
145
+ new Set(
146
+ legacy.user.permissions.map((p) => `${p.resourceCode}:${p.actionCode}`),
147
+ ),
148
+ );
149
+ });
150
+
151
+ it("getUserPermissions bung map ra dạng cũ", () => {
152
+ expect(new Set(getUserPermissions(compact).map((p) => p.resourceCode))).toEqual(
153
+ new Set(["receipt", "expense"]),
154
+ );
155
+ expect(getUserPermissions(compact)).toHaveLength(3);
156
+ });
157
+
158
+ it("map rỗng ↔ mảng rỗng", () => {
159
+ expect(toPermissionMap([])).toEqual({});
160
+ expect(fromPermissionMap({})).toEqual([]);
161
+ });
162
+ });