@cosmicdrift/kumiko-framework 0.318.0 → 0.320.0
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 +8 -4
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +139 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +91 -0
- package/src/api/index.ts +15 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +128 -40
- package/src/changes.json +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// Tests for projectAppSchemaForRoles (fw#3314). Pins:
|
|
2
|
+
// 1. LEAK INVARIANT — a role-gated screen disappears from EVERY reference
|
|
3
|
+
// site the projection file header claims to cover for a denied role,
|
|
4
|
+
// and survives — with the projection returning the exact input
|
|
5
|
+
// reference — for a granted one: the screen itself, nav.screen, a
|
|
6
|
+
// standalone nav's actions/createAction (TreeAction.screen), a content
|
|
7
|
+
// collection's nav and its separate nav-copy actions/createAction, a
|
|
8
|
+
// workspace's navMember + definition.nav, rowAction navigate by both
|
|
9
|
+
// `screen` and `entity` (detailFor), a drawer rowAction/toolbarAction
|
|
10
|
+
// (same-feature analog — see the comment on hostFeature below), a
|
|
11
|
+
// toolbar navigate, a projectionDetail's header actions/metrics.navigate/
|
|
12
|
+
// listScreenId, an entityEdit redirect, an actionForm redirect/
|
|
13
|
+
// cancelTarget/listScreenId, a dashboard screen-panel, and every
|
|
14
|
+
// relatedList-section field (actions/rowActions/toolbarActions/
|
|
15
|
+
// rowClick.entity/emptyState.action). The denied-role assertion checks
|
|
16
|
+
// this both structurally, per site, and by proving the secret screen
|
|
17
|
+
// id is absent from the full serialized {screens, navs,
|
|
18
|
+
// contentCollections} of every feature plus `workspaces` — not just
|
|
19
|
+
// from the sites this file happened to think to check.
|
|
20
|
+
// 2. No mutation of the input schema.
|
|
21
|
+
// 3. Settings-Hub-generated configEdit screens/navs are gated by their
|
|
22
|
+
// derived write-role access, same as author-registered ones.
|
|
23
|
+
// 4. Empty-section drop: a parent nav whose every child got dropped is
|
|
24
|
+
// itself dropped.
|
|
25
|
+
// 5. Default-workspace drop: a workspace with no surviving nav members
|
|
26
|
+
// is removed from the switcher list.
|
|
27
|
+
import { describe, expect, test } from "bun:test";
|
|
28
|
+
import { buildAppSchema } from "../build-app-schema";
|
|
29
|
+
import { access, createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
30
|
+
import { defineFeature } from "../define-feature";
|
|
31
|
+
import { projectAppSchemaForRoles } from "../project-app-schema-for-roles";
|
|
32
|
+
import { createRegistry } from "../registry";
|
|
33
|
+
|
|
34
|
+
const SECRET_SCREEN_ID = "zz-secret-audit";
|
|
35
|
+
const ADMIN_ONLY = { roles: ["Admin"] } as const;
|
|
36
|
+
|
|
37
|
+
function findFeature(app: ReturnType<typeof buildAppSchema>, featureName: string) {
|
|
38
|
+
const feature = app.features.find((f) => f.featureName === featureName);
|
|
39
|
+
if (feature === undefined) throw new Error(`feature "${featureName}" missing from schema`);
|
|
40
|
+
return feature;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("projectAppSchemaForRoles — leak invariant", () => {
|
|
44
|
+
const SECRET_QN = `audit:screen:${SECRET_SCREEN_ID}`;
|
|
45
|
+
|
|
46
|
+
const auditFeature = defineFeature("audit", (r) => {
|
|
47
|
+
r.entity("record", { fields: { label: { type: "text" } } });
|
|
48
|
+
r.screen({
|
|
49
|
+
id: SECRET_SCREEN_ID,
|
|
50
|
+
type: "entityList",
|
|
51
|
+
entity: "record",
|
|
52
|
+
columns: ["label"],
|
|
53
|
+
detailFor: "record",
|
|
54
|
+
access: ADMIN_ONLY,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// "host-secret-form" is the drawer-action target below: RowActionDrawer/
|
|
59
|
+
// ToolbarAction "drawer" resolve same-feature-only (validateDrawerTargetAction),
|
|
60
|
+
// so SECRET_SCREEN_ID itself (a different feature) can never be a drawer
|
|
61
|
+
// target — this is the one site the header lists that a real app could
|
|
62
|
+
// never point cross-feature. Gating this screen the same way (ADMIN_ONLY)
|
|
63
|
+
// proves the drawer-stripping code path with the closest valid analog.
|
|
64
|
+
const hostFeature = defineFeature("host", (r) => {
|
|
65
|
+
r.entity("hostRecord", { fields: { label: { type: "text" } } });
|
|
66
|
+
|
|
67
|
+
r.nav({ id: "secret-nav", label: "Secret", screen: SECRET_QN });
|
|
68
|
+
// Standalone grouping nav with no `.screen` of its own — isolates the
|
|
69
|
+
// TreeAction-array stripping (actions/createAction) from the
|
|
70
|
+
// whole-nav-drop path "secret-nav" above already exercises.
|
|
71
|
+
r.nav({
|
|
72
|
+
id: "browse",
|
|
73
|
+
label: "Browse",
|
|
74
|
+
actions: [{ icon: "eye", label: "Open", screen: SECRET_QN }],
|
|
75
|
+
createAction: { icon: "plus", label: "New", screen: SECRET_QN },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
r.contentCollection({
|
|
79
|
+
id: "docs",
|
|
80
|
+
kind: "text-block",
|
|
81
|
+
nav: {
|
|
82
|
+
label: "Docs",
|
|
83
|
+
actions: [{ icon: "eye", label: "Open", screen: SECRET_QN }],
|
|
84
|
+
createAction: { icon: "plus", label: "New", screen: SECRET_QN },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
r.workspace({ id: "audit-ws", label: "Audit WS", nav: ["host:nav:secret-nav"] });
|
|
89
|
+
|
|
90
|
+
r.screen({
|
|
91
|
+
id: "host-secret-form",
|
|
92
|
+
type: "actionForm",
|
|
93
|
+
handler: "host:write:noop",
|
|
94
|
+
fields: { note: { type: "text" } },
|
|
95
|
+
layout: { sections: [{ fields: ["note"] }] },
|
|
96
|
+
access: ADMIN_ONLY,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
r.screen({
|
|
100
|
+
id: "list",
|
|
101
|
+
type: "entityList",
|
|
102
|
+
entity: "hostRecord",
|
|
103
|
+
columns: ["label"],
|
|
104
|
+
rowActions: [
|
|
105
|
+
{ kind: "navigate", id: "open-secret", label: "Open", screen: SECRET_SCREEN_ID },
|
|
106
|
+
{ kind: "navigate", id: "open-secret-by-entity", label: "Open2", entity: "record" },
|
|
107
|
+
{ kind: "drawer", id: "open-secret-drawer", label: "Drawer", screen: "host-secret-form" },
|
|
108
|
+
],
|
|
109
|
+
toolbarActions: [
|
|
110
|
+
{ kind: "navigate", id: "open-secret-tb", label: "Open", screen: SECRET_SCREEN_ID },
|
|
111
|
+
{
|
|
112
|
+
kind: "drawer",
|
|
113
|
+
id: "open-secret-drawer-tb",
|
|
114
|
+
label: "DrawerTB",
|
|
115
|
+
screen: "host-secret-form",
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
r.screen({
|
|
120
|
+
id: "edit",
|
|
121
|
+
type: "entityEdit",
|
|
122
|
+
entity: "hostRecord",
|
|
123
|
+
layout: { sections: [{ fields: ["label"] }] },
|
|
124
|
+
redirect: SECRET_QN,
|
|
125
|
+
});
|
|
126
|
+
r.screen({
|
|
127
|
+
id: "board",
|
|
128
|
+
type: "dashboard",
|
|
129
|
+
panels: [{ kind: "screen", id: "secret-panel", screen: SECRET_QN }],
|
|
130
|
+
});
|
|
131
|
+
r.screen({
|
|
132
|
+
id: "host-action",
|
|
133
|
+
type: "actionForm",
|
|
134
|
+
handler: "host:write:noop",
|
|
135
|
+
fields: { note: { type: "text" } },
|
|
136
|
+
layout: { sections: [{ fields: ["note"] }] },
|
|
137
|
+
redirect: SECRET_QN,
|
|
138
|
+
cancelTarget: SECRET_QN,
|
|
139
|
+
listScreenId: SECRET_SCREEN_ID,
|
|
140
|
+
});
|
|
141
|
+
r.screen({
|
|
142
|
+
id: "detail",
|
|
143
|
+
type: "projectionDetail",
|
|
144
|
+
query: "host:query:record:detail",
|
|
145
|
+
layout: {
|
|
146
|
+
sections: [
|
|
147
|
+
{ fields: ["label"] },
|
|
148
|
+
// relatedList is only valid on projectionDetail (fw#2166) — the
|
|
149
|
+
// boot-validator rejects it on entityEdit/actionForm/configEdit,
|
|
150
|
+
// so this is the closest valid host for its own site set.
|
|
151
|
+
{
|
|
152
|
+
kind: "relatedList",
|
|
153
|
+
title: "Related",
|
|
154
|
+
query: "host:query:record:related",
|
|
155
|
+
columns: ["label"],
|
|
156
|
+
rowClick: { entity: "record" },
|
|
157
|
+
actions: [
|
|
158
|
+
{ kind: "navigate", id: "rl-actions", label: "RL", screen: SECRET_SCREEN_ID },
|
|
159
|
+
],
|
|
160
|
+
rowActions: [
|
|
161
|
+
{ kind: "navigate", id: "rl-row", label: "RLRow", screen: SECRET_SCREEN_ID },
|
|
162
|
+
],
|
|
163
|
+
toolbarActions: [
|
|
164
|
+
{ kind: "navigate", id: "rl-tb", label: "RLTb", screen: SECRET_SCREEN_ID },
|
|
165
|
+
],
|
|
166
|
+
emptyState: {
|
|
167
|
+
title: "none",
|
|
168
|
+
action: {
|
|
169
|
+
kind: "navigate",
|
|
170
|
+
id: "rl-empty",
|
|
171
|
+
label: "Empty",
|
|
172
|
+
screen: SECRET_SCREEN_ID,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
actions: [
|
|
179
|
+
{ kind: "navigate", id: "detail-header-action", label: "Header", screen: SECRET_SCREEN_ID },
|
|
180
|
+
],
|
|
181
|
+
metrics: [{ field: "label", navigate: { screen: SECRET_SCREEN_ID } }],
|
|
182
|
+
listScreenId: SECRET_SCREEN_ID,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const registry = createRegistry([auditFeature, hostFeature]);
|
|
187
|
+
const app = buildAppSchema(registry);
|
|
188
|
+
|
|
189
|
+
// Sanity: the absence check below isn't vacuous — the unprojected schema
|
|
190
|
+
// really does repeat the id this many times across the wired sites.
|
|
191
|
+
test("sanity: the unprojected schema contains the secret screen id many times", () => {
|
|
192
|
+
const occurrences = JSON.stringify(app).split(SECRET_SCREEN_ID).length - 1;
|
|
193
|
+
expect(occurrences).toBeGreaterThanOrEqual(15);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("granted role returns the identical schema — every site survives untouched", () => {
|
|
197
|
+
const projected = projectAppSchemaForRoles(app, ["Admin"]);
|
|
198
|
+
expect(projected).toBe(app);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("granted role: per-site structural spot checks", () => {
|
|
202
|
+
const projected = projectAppSchemaForRoles(app, ["Admin"]);
|
|
203
|
+
const audit = findFeature(projected, "audit");
|
|
204
|
+
expect(audit.screens.map((s) => s.id)).toContain(SECRET_SCREEN_ID);
|
|
205
|
+
|
|
206
|
+
const host = findFeature(projected, "host");
|
|
207
|
+
expect(host.navs?.find((n) => n.id === "secret-nav")).toBeDefined();
|
|
208
|
+
const browse = host.navs?.find((n) => n.id === "browse");
|
|
209
|
+
expect(browse?.actions).toHaveLength(1);
|
|
210
|
+
expect(browse?.createAction).toBeDefined();
|
|
211
|
+
|
|
212
|
+
const docs = host.contentCollections?.find((c) => c.id === "docs");
|
|
213
|
+
expect(docs?.nav.actions).toHaveLength(1);
|
|
214
|
+
expect(docs?.nav.createAction).toBeDefined();
|
|
215
|
+
|
|
216
|
+
expect(projected.workspaces?.some((w) => w.definition.id === "audit-ws")).toBe(true);
|
|
217
|
+
|
|
218
|
+
const list = host.screens.find((s) => s.id === "list");
|
|
219
|
+
expect(list?.type).toBe("entityList");
|
|
220
|
+
if (list?.type !== "entityList") throw new Error("unreachable");
|
|
221
|
+
expect(list.rowActions).toHaveLength(3);
|
|
222
|
+
expect(list.toolbarActions).toHaveLength(2);
|
|
223
|
+
|
|
224
|
+
const edit = host.screens.find((s) => s.id === "edit");
|
|
225
|
+
expect(edit?.type).toBe("entityEdit");
|
|
226
|
+
if (edit?.type !== "entityEdit") throw new Error("unreachable");
|
|
227
|
+
expect(edit.redirect).toBe(SECRET_QN);
|
|
228
|
+
|
|
229
|
+
const board = host.screens.find((s) => s.id === "board");
|
|
230
|
+
expect(board?.type).toBe("dashboard");
|
|
231
|
+
if (board?.type !== "dashboard") throw new Error("unreachable");
|
|
232
|
+
expect(board.panels).toHaveLength(1);
|
|
233
|
+
|
|
234
|
+
const hostAction = host.screens.find((s) => s.id === "host-action");
|
|
235
|
+
expect(hostAction?.type).toBe("actionForm");
|
|
236
|
+
if (hostAction?.type !== "actionForm") throw new Error("unreachable");
|
|
237
|
+
expect(hostAction.redirect).toBe(SECRET_QN);
|
|
238
|
+
expect(hostAction.cancelTarget).toBe(SECRET_QN);
|
|
239
|
+
expect(hostAction.listScreenId).toBe(SECRET_SCREEN_ID);
|
|
240
|
+
|
|
241
|
+
const detail = host.screens.find((s) => s.id === "detail");
|
|
242
|
+
expect(detail?.type).toBe("projectionDetail");
|
|
243
|
+
if (detail?.type !== "projectionDetail") throw new Error("unreachable");
|
|
244
|
+
expect(detail.metrics?.[0]).toEqual({ field: "label", navigate: { screen: SECRET_SCREEN_ID } });
|
|
245
|
+
expect(detail.actions).toHaveLength(1);
|
|
246
|
+
expect(detail.listScreenId).toBe(SECRET_SCREEN_ID);
|
|
247
|
+
const relatedList = detail.layout.sections.find((s) => s.kind === "relatedList");
|
|
248
|
+
expect(relatedList?.kind).toBe("relatedList");
|
|
249
|
+
if (relatedList?.kind !== "relatedList") throw new Error("unreachable");
|
|
250
|
+
expect(relatedList.actions).toHaveLength(1);
|
|
251
|
+
expect(relatedList.rowActions).toHaveLength(1);
|
|
252
|
+
expect(relatedList.toolbarActions).toHaveLength(1);
|
|
253
|
+
expect(relatedList.rowClick).toEqual({ entity: "record" });
|
|
254
|
+
expect(relatedList.emptyState?.action).toBeDefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("denied role: the secret screen id disappears from every projected feature and from workspaces", () => {
|
|
258
|
+
const projected = projectAppSchemaForRoles(app, ["User"]);
|
|
259
|
+
for (const feature of projected.features) {
|
|
260
|
+
const featureJson = JSON.stringify({
|
|
261
|
+
screens: feature.screens,
|
|
262
|
+
navs: feature.navs,
|
|
263
|
+
contentCollections: feature.contentCollections,
|
|
264
|
+
});
|
|
265
|
+
expect(featureJson).not.toContain(SECRET_SCREEN_ID);
|
|
266
|
+
}
|
|
267
|
+
expect(JSON.stringify(projected.workspaces ?? null)).not.toContain(SECRET_SCREEN_ID);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("denied role: per-site structural spot checks", () => {
|
|
271
|
+
const projected = projectAppSchemaForRoles(app, ["User"]);
|
|
272
|
+
const audit = findFeature(projected, "audit");
|
|
273
|
+
expect(audit.screens.map((s) => s.id)).not.toContain(SECRET_SCREEN_ID);
|
|
274
|
+
|
|
275
|
+
const host = findFeature(projected, "host");
|
|
276
|
+
expect(host.navs?.find((n) => n.id === "secret-nav")).toBeUndefined();
|
|
277
|
+
const browse = host.navs?.find((n) => n.id === "browse");
|
|
278
|
+
expect(browse?.actions).toBeUndefined();
|
|
279
|
+
expect(browse?.createAction).toBeUndefined();
|
|
280
|
+
|
|
281
|
+
const docs = host.contentCollections?.find((c) => c.id === "docs");
|
|
282
|
+
expect(docs?.nav.actions).toBeUndefined();
|
|
283
|
+
expect(docs?.nav.createAction).toBeUndefined();
|
|
284
|
+
|
|
285
|
+
// "audit-ws" was the only workspace, so losing its only member drops the
|
|
286
|
+
// workspace itself — omitted entirely (undefined), not an empty array.
|
|
287
|
+
expect(projected.workspaces?.some((w) => w.definition.id === "audit-ws") ?? false).toBe(false);
|
|
288
|
+
|
|
289
|
+
const list = host.screens.find((s) => s.id === "list");
|
|
290
|
+
expect(list?.type).toBe("entityList");
|
|
291
|
+
if (list?.type !== "entityList") throw new Error("unreachable");
|
|
292
|
+
// "open-secret-drawer"/"-tb" target "host-secret-form", itself
|
|
293
|
+
// ADMIN_ONLY — stripped for User too (same-feature drawer analog, see
|
|
294
|
+
// the comment on hostFeature above).
|
|
295
|
+
expect(list.rowActions).toBeUndefined();
|
|
296
|
+
expect(list.toolbarActions).toBeUndefined();
|
|
297
|
+
|
|
298
|
+
const edit = host.screens.find((s) => s.id === "edit");
|
|
299
|
+
expect(edit?.type).toBe("entityEdit");
|
|
300
|
+
if (edit?.type !== "entityEdit") throw new Error("unreachable");
|
|
301
|
+
expect(edit.redirect).toBeUndefined();
|
|
302
|
+
|
|
303
|
+
const board = host.screens.find((s) => s.id === "board");
|
|
304
|
+
expect(board?.type).toBe("dashboard");
|
|
305
|
+
if (board?.type !== "dashboard") throw new Error("unreachable");
|
|
306
|
+
expect(board.panels).toHaveLength(0);
|
|
307
|
+
|
|
308
|
+
const hostAction = host.screens.find((s) => s.id === "host-action");
|
|
309
|
+
expect(hostAction?.type).toBe("actionForm");
|
|
310
|
+
if (hostAction?.type !== "actionForm") throw new Error("unreachable");
|
|
311
|
+
expect(hostAction.redirect).toBeUndefined();
|
|
312
|
+
expect(hostAction.cancelTarget).toBeUndefined();
|
|
313
|
+
expect(hostAction.listScreenId).toBeUndefined();
|
|
314
|
+
|
|
315
|
+
const detail = host.screens.find((s) => s.id === "detail");
|
|
316
|
+
expect(detail?.type).toBe("projectionDetail");
|
|
317
|
+
if (detail?.type !== "projectionDetail") throw new Error("unreachable");
|
|
318
|
+
expect(detail.metrics?.[0]).toEqual({ field: "label" });
|
|
319
|
+
expect(detail.actions).toBeUndefined();
|
|
320
|
+
expect(detail.listScreenId).toBeUndefined();
|
|
321
|
+
const relatedList = detail.layout.sections.find((s) => s.kind === "relatedList");
|
|
322
|
+
expect(relatedList?.kind).toBe("relatedList");
|
|
323
|
+
if (relatedList?.kind !== "relatedList") throw new Error("unreachable");
|
|
324
|
+
expect(relatedList.actions).toBeUndefined();
|
|
325
|
+
expect(relatedList.rowActions).toBeUndefined();
|
|
326
|
+
expect(relatedList.toolbarActions).toBeUndefined();
|
|
327
|
+
expect(relatedList.rowClick).toBeUndefined();
|
|
328
|
+
expect(relatedList.emptyState?.action).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("projectAppSchemaForRoles — no mutation", () => {
|
|
333
|
+
test("never mutates the input schema", () => {
|
|
334
|
+
const feature = defineFeature("widgets", (r) => {
|
|
335
|
+
r.entity("widget", { fields: { name: { type: "text" } } });
|
|
336
|
+
r.screen({
|
|
337
|
+
id: "secret",
|
|
338
|
+
type: "entityList",
|
|
339
|
+
entity: "widget",
|
|
340
|
+
columns: ["name"],
|
|
341
|
+
access: ADMIN_ONLY,
|
|
342
|
+
});
|
|
343
|
+
r.screen({ id: "list", type: "entityList", entity: "widget", columns: ["name"] });
|
|
344
|
+
r.nav({ id: "secret", label: "Secret", screen: "secret" });
|
|
345
|
+
r.nav({ id: "list", label: "List", screen: "list" });
|
|
346
|
+
});
|
|
347
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
348
|
+
const before = JSON.stringify(app);
|
|
349
|
+
|
|
350
|
+
projectAppSchemaForRoles(app, ["User"]);
|
|
351
|
+
projectAppSchemaForRoles(app, ["Admin"]);
|
|
352
|
+
|
|
353
|
+
expect(JSON.stringify(app)).toBe(before);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("returns the identical reference when a fully-open role changes nothing", () => {
|
|
357
|
+
const feature = defineFeature("open-widgets", (r) => {
|
|
358
|
+
r.entity("widget", { fields: { name: { type: "text" } } });
|
|
359
|
+
r.screen({ id: "list", type: "entityList", entity: "widget", columns: ["name"] });
|
|
360
|
+
r.nav({ id: "list", label: "List", screen: "list" });
|
|
361
|
+
});
|
|
362
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
363
|
+
const projected = projectAppSchemaForRoles(app, ["Whatever"]);
|
|
364
|
+
expect(projected).toBe(app);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("projectAppSchemaForRoles — Settings-Hub visibility", () => {
|
|
369
|
+
const billing = defineFeature("billing", (r) => {
|
|
370
|
+
r.config({
|
|
371
|
+
keys: {
|
|
372
|
+
stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }),
|
|
373
|
+
platformFee: createSystemConfig("number", {
|
|
374
|
+
write: access.systemAdmin,
|
|
375
|
+
mask: { title: "billing.platform-fee" },
|
|
376
|
+
}),
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
const app = buildAppSchema(createRegistry([billing]));
|
|
381
|
+
|
|
382
|
+
test("Admin sees the tenant-scoped hub screen but not the SystemAdmin-only one", () => {
|
|
383
|
+
const projected = projectAppSchemaForRoles(app, ["Admin"]);
|
|
384
|
+
const config = projected.features.find((f) => f.featureName === "config");
|
|
385
|
+
expect(config?.screens.map((s) => s.id)).toContain("billing-tenant");
|
|
386
|
+
expect(config?.screens.map((s) => s.id)).not.toContain("billing-system");
|
|
387
|
+
expect(config?.navs?.map((n) => n.id)).toContain("billing-tenant");
|
|
388
|
+
expect(config?.navs?.map((n) => n.id)).not.toContain("billing-system");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("plain User sees neither hub screen", () => {
|
|
392
|
+
const projected = projectAppSchemaForRoles(app, ["User"]);
|
|
393
|
+
const config = projected.features.find((f) => f.featureName === "config");
|
|
394
|
+
expect(config?.screens.map((s) => s.id)).not.toContain("billing-tenant");
|
|
395
|
+
expect(config?.screens.map((s) => s.id)).not.toContain("billing-system");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("projectAppSchemaForRoles — nav pruning", () => {
|
|
400
|
+
test("a parent nav whose only child gets dropped is itself dropped (empty section)", () => {
|
|
401
|
+
const feature = defineFeature("menu", (r) => {
|
|
402
|
+
r.entity("widget", { fields: { name: { type: "text" } } });
|
|
403
|
+
r.nav({ id: "section", label: "Section" }); // pure grouping node, no screen
|
|
404
|
+
r.nav({
|
|
405
|
+
id: "child",
|
|
406
|
+
label: "Child",
|
|
407
|
+
parent: "menu:nav:section",
|
|
408
|
+
screen: "child-screen",
|
|
409
|
+
access: ADMIN_ONLY,
|
|
410
|
+
});
|
|
411
|
+
r.screen({
|
|
412
|
+
id: "child-screen",
|
|
413
|
+
type: "entityList",
|
|
414
|
+
entity: "widget",
|
|
415
|
+
columns: ["name"],
|
|
416
|
+
access: ADMIN_ONLY,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
420
|
+
|
|
421
|
+
const forAdmin = projectAppSchemaForRoles(app, ["Admin"]);
|
|
422
|
+
const adminMenu = findFeature(forAdmin, "menu");
|
|
423
|
+
expect(adminMenu.navs?.map((n) => n.id).sort()).toEqual(["child", "section"]);
|
|
424
|
+
|
|
425
|
+
const forUser = projectAppSchemaForRoles(app, ["User"]);
|
|
426
|
+
const userMenu = findFeature(forUser, "menu");
|
|
427
|
+
expect(userMenu.navs).toBeUndefined();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a descendant three levels deep is dropped when its own access denies", () => {
|
|
431
|
+
const feature = defineFeature("tree", (r) => {
|
|
432
|
+
r.entity("widget", { fields: { name: { type: "text" } } });
|
|
433
|
+
r.nav({ id: "root", label: "Root" });
|
|
434
|
+
r.nav({ id: "mid", label: "Mid", parent: "tree:nav:root" });
|
|
435
|
+
r.nav({
|
|
436
|
+
id: "leaf",
|
|
437
|
+
label: "Leaf",
|
|
438
|
+
parent: "tree:nav:mid",
|
|
439
|
+
screen: "leaf-screen",
|
|
440
|
+
access: ADMIN_ONLY,
|
|
441
|
+
});
|
|
442
|
+
r.screen({
|
|
443
|
+
id: "leaf-screen",
|
|
444
|
+
type: "entityList",
|
|
445
|
+
entity: "widget",
|
|
446
|
+
columns: ["name"],
|
|
447
|
+
access: ADMIN_ONLY,
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
451
|
+
|
|
452
|
+
const forUser = projectAppSchemaForRoles(app, ["User"]);
|
|
453
|
+
const userTree = findFeature(forUser, "tree");
|
|
454
|
+
expect(userTree.navs).toBeUndefined(); // root+mid become empty sections too
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe("projectAppSchemaForRoles — workspace pruning", () => {
|
|
459
|
+
test("drops the default workspace when its role loses every member, and keeps the other one", () => {
|
|
460
|
+
const feature = defineFeature("shell", (r) => {
|
|
461
|
+
r.entity("widget", { fields: { name: { type: "text" } } });
|
|
462
|
+
r.screen({
|
|
463
|
+
id: "admin-home",
|
|
464
|
+
type: "entityList",
|
|
465
|
+
entity: "widget",
|
|
466
|
+
columns: ["name"],
|
|
467
|
+
access: ADMIN_ONLY,
|
|
468
|
+
});
|
|
469
|
+
r.screen({ id: "user-home", type: "entityList", entity: "widget", columns: ["name"] });
|
|
470
|
+
r.nav({ id: "admin-home", label: "Admin Home", screen: "admin-home" });
|
|
471
|
+
r.nav({ id: "user-home", label: "User Home", screen: "user-home" });
|
|
472
|
+
r.workspace({ id: "admin", label: "Admin", default: true, nav: ["shell:nav:admin-home"] });
|
|
473
|
+
r.workspace({ id: "user", label: "User", nav: ["shell:nav:user-home"] });
|
|
474
|
+
});
|
|
475
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
476
|
+
|
|
477
|
+
const forUser = projectAppSchemaForRoles(app, ["User"]);
|
|
478
|
+
const ids = forUser.workspaces?.map((w) => w.definition.id);
|
|
479
|
+
expect(ids).toEqual(["user"]);
|
|
480
|
+
|
|
481
|
+
const forAdmin = projectAppSchemaForRoles(app, ["Admin"]);
|
|
482
|
+
expect(forAdmin.workspaces?.map((w) => w.definition.id).sort()).toEqual(["admin", "user"]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("keeps a workspace that was declared without members instead of treating it as emptied", () => {
|
|
486
|
+
const feature = defineFeature("shell", (r) => {
|
|
487
|
+
r.entity("widget", { fields: { name: { type: "text" } } });
|
|
488
|
+
r.screen({ id: "user-home", type: "entityList", entity: "widget", columns: ["name"] });
|
|
489
|
+
r.nav({ id: "user-home", label: "User Home", screen: "user-home" });
|
|
490
|
+
r.workspace({ id: "work", label: "Work", nav: ["shell:nav:user-home"] });
|
|
491
|
+
r.workspace({ id: "empty", label: "Empty" });
|
|
492
|
+
});
|
|
493
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
494
|
+
expect(app.workspaces?.find((w) => w.definition.id === "empty")?.navMembers).toEqual([]);
|
|
495
|
+
|
|
496
|
+
const forUser = projectAppSchemaForRoles(app, ["User"]);
|
|
497
|
+
|
|
498
|
+
expect(forUser.workspaces?.map((w) => w.definition.id).sort()).toEqual(["empty", "work"]);
|
|
499
|
+
});
|
|
500
|
+
});
|