@goplusvn/core 0.1.85 → 0.1.86

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.85",
4
+ "version": "0.1.86",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { buildNavigationForSession, filterNavigationTree } from "../index";
4
+
5
+ import type { NavigationType, PermissionMap, Session } from "../../types";
6
+
7
+ /**
8
+ * Menu mẫu: một mục xem thường và một mục CHỈ dành cho người có quyền nhập.
9
+ * Đúng hình dạng đã làm lộ lỗ hổng ở tanloc-spartronics (2026-08-20).
10
+ */
11
+ const NAVIGATION: NavigationType[] = [
12
+ {
13
+ title: "Căn-tin",
14
+ items: [
15
+ {
16
+ title: "Đặt suất ăn",
17
+ iconName: "UtensilsCrossed",
18
+ href: "/ordering",
19
+ resource: "ordering",
20
+ },
21
+ {
22
+ title: "Nhập đăng ký",
23
+ iconName: "FileSpreadsheet",
24
+ href: "/ordering/import",
25
+ resource: "ordering",
26
+ action: "import",
27
+ },
28
+ ],
29
+ },
30
+ ];
31
+
32
+ function sessionWith(permissionMap: PermissionMap): Session {
33
+ return { user: { id: "u1", roles: ["staff"], permissionMap } } as Session;
34
+ }
35
+
36
+ const titles = (nav: NavigationType[]) =>
37
+ nav.flatMap((group) => group.items.map((item) => item.title));
38
+
39
+ describe("buildNavigationForSession", () => {
40
+ it("giấu mục khai action mà người dùng không có quyền đó", () => {
41
+ const nav = buildNavigationForSession(
42
+ NAVIGATION,
43
+ sessionWith({ ordering: ["view", "create"] }),
44
+ );
45
+
46
+ expect(titles(nav)).toEqual(["Đặt suất ăn"]);
47
+ });
48
+
49
+ it("hiện mục khai action khi người dùng có ĐÚNG action đó", () => {
50
+ const nav = buildNavigationForSession(
51
+ NAVIGATION,
52
+ sessionWith({ ordering: ["view", "import"] }),
53
+ );
54
+
55
+ expect(titles(nav)).toEqual(["Đặt suất ăn", "Nhập đăng ký"]);
56
+ });
57
+
58
+ it("có action nhưng không xem được resource thì rụng cả nhóm", () => {
59
+ const nav = buildNavigationForSession(
60
+ NAVIGATION,
61
+ sessionWith({ employees: ["view"] }),
62
+ );
63
+
64
+ expect(nav).toEqual([]);
65
+ });
66
+
67
+ it("admin thấy nguyên cây, không đụng tới action", () => {
68
+ const session = { user: { id: "u1", roles: ["admin"] } } as Session;
69
+
70
+ expect(buildNavigationForSession(NAVIGATION, session)).toEqual(NAVIGATION);
71
+ });
72
+
73
+ it("chưa đăng nhập ⇒ menu rỗng", () => {
74
+ expect(buildNavigationForSession(NAVIGATION, null)).toEqual([]);
75
+ });
76
+ });
77
+
78
+ describe("filterNavigationTree", () => {
79
+ it("không có `can` thì mục khai action bị ẩn kèm cảnh báo (fail-closed)", () => {
80
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
81
+
82
+ const nav = filterNavigationTree(NAVIGATION, new Set(["ordering"]));
83
+
84
+ expect(titles(nav)).toEqual(["Đặt suất ăn"]);
85
+ expect(warn).toHaveBeenCalledOnce();
86
+ warn.mockRestore();
87
+ });
88
+
89
+ it("nhận `can` thì hỏi đúng cặp (resource, action)", () => {
90
+ const can = vi.fn(
91
+ (resource: string, action: string) =>
92
+ resource === "ordering" && action === "import",
93
+ );
94
+
95
+ const nav = filterNavigationTree(NAVIGATION, new Set(["ordering"]), {
96
+ can,
97
+ });
98
+
99
+ expect(titles(nav)).toEqual(["Đặt suất ăn", "Nhập đăng ký"]);
100
+ expect(can).toHaveBeenCalledExactlyOnceWith("ordering", "import");
101
+ });
102
+
103
+ it("mục lồng nhau cũng được kiểm theo action đã khai", () => {
104
+ const nested: NavigationType[] = [
105
+ {
106
+ title: "Căn-tin",
107
+ items: [
108
+ {
109
+ title: "Quản trị",
110
+ iconName: "Settings",
111
+ items: [
112
+ { title: "Thực đơn", href: "/menus", resource: "ordering" },
113
+ {
114
+ title: "Nhập đăng ký",
115
+ href: "/ordering/import",
116
+ resource: "ordering",
117
+ action: "import",
118
+ },
119
+ ],
120
+ },
121
+ ],
122
+ },
123
+ ];
124
+
125
+ const nav = filterNavigationTree(nested, new Set(["ordering"]), {
126
+ can: () => false,
127
+ });
128
+
129
+ expect(nav[0].items[0].items?.map((item) => item.title)).toEqual([
130
+ "Thực đơn",
131
+ ]);
132
+ });
133
+ });
@@ -2,7 +2,7 @@
2
2
  // Core navigation utilities for GoERP platform
3
3
  // Consolidated from packages/shared/navigation
4
4
 
5
- import { getAccessibleResources } from "../auth";
5
+ import { checkPermission, getAccessibleResources } from "../auth";
6
6
 
7
7
  import type {
8
8
  NavigationType,
@@ -11,20 +11,72 @@ import type {
11
11
  Session,
12
12
  } from "../types";
13
13
 
14
+ /**
15
+ * Cách kiểm MỘT cặp (resource, action) cho mục menu tự khai `action`.
16
+ *
17
+ * `accessibleCodes` chỉ là tập resource của ĐÚNG MỘT action (thường là "view"),
18
+ * nên tự nó không trả lời được câu "người này có `ordering:import` không".
19
+ * `buildNavigationForSession` tự dựng hàm này từ session; app gọi thẳng
20
+ * `filterNavigationTree` thì truyền vào.
21
+ */
22
+ export interface NavigationFilterOptions {
23
+ can?: (resourceCode: string, actionCode: string) => boolean;
24
+ }
25
+
26
+ /**
27
+ * Một mục menu có được hiện không.
28
+ *
29
+ * Mục KHÔNG khai `resource` ⇒ công khai. Mục khai `resource` mà không khai
30
+ * `action` ⇒ so với `accessibleCodes` như trước nay.
31
+ *
32
+ * Mục khai `action` (vd `resource: "ordering", action: "import"`) phải kiểm
33
+ * ĐÚNG action đó: `accessibleCodes` dựng theo "view" nên mọi người xem được
34
+ * `ordering` sẽ thấy mục "Nhập đăng ký" rồi bấm vào bị cổng gác trang đá về
35
+ * trang chủ — đúng cái bẫy "menu hứa một đằng, trang trả lời một nẻo" mà
36
+ * `buildNavigationForSession` sinh ra để dẹp (tanloc-spartronics, 2026-08-20).
37
+ *
38
+ * Không có `can` thì FAIL-CLOSED kèm cảnh báo: ẩn một mục còn đỡ hơn mời người
39
+ * dùng bấm vào chỗ họ không có quyền, và cảnh báo giữ cho nó không im lặng —
40
+ * sidebar trắng trơn không lời giải thích là chế độ hỏng tệ nhất của app RBAC.
41
+ */
42
+ function isItemVisible(
43
+ item: NavigationRootItem | NavigationNestedItem,
44
+ accessibleCodes: Set<string>,
45
+ options?: NavigationFilterOptions,
46
+ ): boolean {
47
+ if (!item.resource) return true;
48
+ if (!item.action) return accessibleCodes.has(item.resource);
49
+
50
+ if (!options?.can) {
51
+ console.warn(
52
+ `[goerp/navigation] Mục "${item.title}" khai action "${item.action}" ` +
53
+ `nhưng filterNavigationTree không nhận được \`options.can\` nên không ` +
54
+ `kiểm được — đã ẩn. Dùng buildNavigationForSession(navigation, session) ` +
55
+ `hoặc truyền { can } vào.`,
56
+ );
57
+ return false;
58
+ }
59
+
60
+ return options.can(item.resource, item.action);
61
+ }
62
+
14
63
  /**
15
64
  * Filter navigation items based on permissions recursively
16
65
  */
17
66
  export function filterNavigationItems(
18
67
  items: NavigationRootItem[],
19
68
  accessibleCodes: Set<string>,
69
+ options?: NavigationFilterOptions,
20
70
  ): NavigationRootItem[];
21
71
  export function filterNavigationItems(
22
72
  items: NavigationNestedItem[],
23
73
  accessibleCodes: Set<string>,
74
+ options?: NavigationFilterOptions,
24
75
  ): NavigationNestedItem[];
25
76
  export function filterNavigationItems(
26
77
  items: NavigationRootItem[] | NavigationNestedItem[],
27
78
  accessibleCodes: Set<string>,
79
+ options?: NavigationFilterOptions,
28
80
  ): (NavigationRootItem | NavigationNestedItem)[] {
29
81
  return items.reduce<(NavigationRootItem | NavigationNestedItem)[]>(
30
82
  (acc, item) => {
@@ -34,9 +86,9 @@ export function filterNavigationItems(
34
86
  return acc;
35
87
  }
36
88
 
37
- // Default Deny: If resource is defined AND resource is not accessible
38
- // If resource is missing, we consider it public (as per comment below)
39
- if (item.resource && !accessibleCodes.has(item.resource)) {
89
+ // Default Deny: mục khai resource không đạt quyền thì rụng. Mục khai
90
+ // thêm `action` được kiểm theo đúng action đó (xem isItemVisible).
91
+ if (!isItemVisible(item, accessibleCodes, options)) {
40
92
  return acc;
41
93
  }
42
94
 
@@ -45,6 +97,7 @@ export function filterNavigationItems(
45
97
  const filteredChildren = filterNavigationItems(
46
98
  item.items as (NavigationRootItem | NavigationNestedItem)[],
47
99
  accessibleCodes,
100
+ options,
48
101
  );
49
102
 
50
103
  // Only add parent if it has visible children
@@ -72,6 +125,7 @@ export function filterNavigationItems(
72
125
  export function filterNavigationTree(
73
126
  navigation: NavigationType[],
74
127
  accessibleCodes: Set<string>,
128
+ options?: NavigationFilterOptions,
75
129
  ): NavigationType[] {
76
130
  if (accessibleCodes.has("*")) {
77
131
  return navigation;
@@ -80,7 +134,11 @@ export function filterNavigationTree(
80
134
  const filteredNavigation: NavigationType[] = [];
81
135
 
82
136
  for (const group of navigation) {
83
- const filteredItems = filterNavigationItems(group.items, accessibleCodes);
137
+ const filteredItems = filterNavigationItems(
138
+ group.items,
139
+ accessibleCodes,
140
+ options,
141
+ );
84
142
 
85
143
  if (filteredItems.length > 0) {
86
144
  filteredNavigation.push({
@@ -116,6 +174,10 @@ export function filterNavigationTree(
116
174
  * thành sidebar trắng trơn không lời giải thích — chế độ hỏng tệ nhất của
117
175
  * app RBAC: người dùng tưởng bị thu hồi quyền và đi báo lỗi sai chỗ. Cứ để
118
176
  * lỗi nổi lên error boundary.
177
+ * 4. **Mục tự khai `action` được kiểm theo ĐÚNG action đó**, không theo mốc
178
+ * chung ở (2). Ví dụ "Nhập đăng ký" (`resource: "ordering"`,
179
+ * `action: "import"`) chỉ hiện với ai có `ordering:import`, dù cả phòng đều
180
+ * có `ordering:view`.
119
181
  *
120
182
  * Core KHÔNG tự đọc session: app dùng better-auth, app dùng next-auth, và cách
121
183
  * lấy session là thứ duy nhất khác nhau thật giữa chúng. App truyền session vào
@@ -136,5 +198,8 @@ export function buildNavigationForSession(
136
198
  return filterNavigationTree(
137
199
  navigation,
138
200
  getAccessibleResources(session, options.action ?? "view"),
201
+ // Mục tự khai `action` được kiểm theo ĐÚNG action đó, không theo mốc chung
202
+ // ở trên: `accessibleCodes` chỉ là tập resource của một action duy nhất.
203
+ { can: (resource, action) => checkPermission(session, resource, action) },
139
204
  );
140
205
  }