Skip to content

Commit cb800e2

Browse files
committed
feat(rest): app-nav prunes doc entries by the docs audience; pins
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
1 parent 3b67a44 commit cb800e2

2 files changed

Lines changed: 407 additions & 13 deletions

File tree

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19790] `GET /meta/app` prunes a `type: 'doc'` nav entry by the docs
5+
* audience (ADR-0046 §6.7) — the rule `DocNavItemSchema` declares: a `doc`
6+
* entry the member may not read is not rendered, and a `book` entry is not
7+
* rendered for a member with no readable page in it.
8+
*
9+
* Before this arm the server pruned such entries on `requiredPermissions`,
10+
* `requiresService` and object servability only, so every member of the app
11+
* received the entry — its label and the gated book / doc name — however the
12+
* book was gated, and only a renderer could hide it.
13+
*
14+
* Driven through the real list and by-name routes, over one fixture whose
15+
* audiences come from the spec's own resolver:
16+
*
17+
* admin_guide { permissionSet: crm_admin } claims crm_admin_runbook (crm),
18+
* ops_keys + ops_rotation (ops)
19+
* help_center 'org' claims crm_intro
20+
* implicit `crm` book → crm_intro (org) + crm_admin_runbook (gated): ONE readable page
21+
* implicit `ops` book → ops_keys + ops_rotation, both gated: NO readable page
22+
*/
23+
24+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
25+
// Explicit `.js` extension: NodeNext resolution (see the sibling nav-gate tests).
26+
import { RestServer } from './rest-server.js';
27+
28+
// This file spies on `console.warn` (the fail-closed diagnostic), so it
29+
// declares the level it observes under — the SHIPPED default.
30+
beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); });
31+
afterAll(() => { vi.unstubAllEnvs(); });
32+
33+
let warn: ReturnType<typeof vi.spyOn>;
34+
beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
35+
afterEach(() => { warn.mockRestore(); });
36+
37+
const ADMIN_GUIDE = {
38+
name: 'admin_guide',
39+
label: 'Admin Guide',
40+
audience: { permissionSet: 'crm_admin' },
41+
_packageId: 'crm',
42+
groups: [
43+
{ key: 'admin', label: 'Admin', include: 'crm_admin_*' },
44+
{ key: 'ops', label: 'Operations', include: 'ops_*', package: 'ops' },
45+
],
46+
};
47+
const HELP_CENTER = {
48+
name: 'help_center',
49+
label: 'Help Centre',
50+
audience: 'org',
51+
_packageId: 'crm',
52+
groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }],
53+
};
54+
const DOCS = [
55+
{ name: 'crm_intro', label: 'Getting started', _packageId: 'crm' },
56+
{ name: 'crm_admin_runbook', label: 'Admin runbook', _packageId: 'crm' },
57+
{ name: 'ops_keys', label: 'Key handling', _packageId: 'ops' },
58+
{ name: 'ops_rotation', label: 'Key rotation', _packageId: 'ops' },
59+
];
60+
61+
const CRM_APP = {
62+
name: 'crm',
63+
label: 'CRM',
64+
navigation: [
65+
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
66+
// Control: a `requiredPermissions`-only entry the caller satisfies.
67+
{ id: 'nav_reports', type: 'page', label: 'Reports', pageName: 'crm_reports', requiredPermissions: ['crm.reports'] },
68+
{ id: 'nav_admin_guide', type: 'doc', label: 'Admin Guide', book: 'admin_guide' },
69+
{ id: 'nav_admin_runbook', type: 'doc', label: 'Admin Runbook', doc: 'crm_admin_runbook' },
70+
{ id: 'nav_ops_book', type: 'doc', label: 'Ops Handbook', book: 'ops' },
71+
{ id: 'nav_crm_book', type: 'doc', label: 'CRM Handbook', book: 'crm' },
72+
{ id: 'nav_intro', type: 'doc', label: 'Getting started', doc: 'crm_intro' },
73+
// A readable page opened in a book the caller may not open.
74+
{ id: 'nav_intro_in_admin_guide', type: 'doc', label: 'Intro, admin edition', book: 'admin_guide', doc: 'crm_intro' },
75+
// Control: a readable doc that `requiredPermissions` still narrows away.
76+
{ id: 'nav_intro_locked', type: 'doc', label: 'Intro, locked', doc: 'crm_intro', requiredPermissions: ['crm.docs_admin'] },
77+
{
78+
id: 'grp_admin_docs', type: 'group', label: 'Admin docs',
79+
children: [{ id: 'nav_admin_runbook_nested', type: 'doc', doc: 'crm_admin_runbook' }],
80+
},
81+
],
82+
areas: [{ id: 'area_ops', label: 'Operations', navigation: [{ id: 'nav_ops_book_area', type: 'doc', book: 'ops' }] }],
83+
};
84+
85+
/** What a member who does NOT hold `crm_admin` is served. */
86+
const NON_HOLDER_NAV = ['nav_leads', 'nav_reports', 'nav_crm_book', 'nav_intro'];
87+
/** What a `crm_admin` holder is served — every doc entry but the permission-locked one. */
88+
const HOLDER_NAV = [
89+
'nav_leads', 'nav_reports', 'nav_admin_guide', 'nav_admin_runbook', 'nav_ops_book',
90+
'nav_crm_book', 'nav_intro', 'nav_intro_in_admin_guide', 'grp_admin_docs',
91+
];
92+
93+
function createMockServer() {
94+
return {
95+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
96+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
97+
};
98+
}
99+
100+
function makeRes() {
101+
const res: any = { statusCode: 200, body: undefined };
102+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
103+
res.json = vi.fn((b: any) => { res.body = b; return res; });
104+
res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn();
105+
return res;
106+
}
107+
108+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
109+
110+
interface SetupOpts {
111+
/** Permission sets the caller holds; `'unresolvable'` makes the security service throw. */
112+
holdings?: string[] | 'unresolvable';
113+
books?: any[];
114+
docs?: any[];
115+
apps?: any[];
116+
/** Metadata types whose LIST read throws. */
117+
failing?: string[];
118+
/** No resolved session at all. */
119+
anonymous?: boolean;
120+
}
121+
122+
function setup(opts: SetupOpts = {}) {
123+
const { holdings = [], books = [ADMIN_GUIDE, HELP_CENTER], docs = DOCS, apps = [CRM_APP], failing = [] } = opts;
124+
const byType: Record<string, any[]> = { app: apps, book: books, doc: docs };
125+
const protocol: any = {
126+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
127+
getMetaTypes: vi.fn().mockResolvedValue([]),
128+
getMetaItems: vi.fn(async ({ type }: any) => {
129+
const t = String(type ?? '').replace(/s$/, '');
130+
if (failing.includes(t)) throw new Error(`${t} store unavailable`);
131+
return clone(byType[t] ?? []);
132+
}),
133+
getMetaItem: vi.fn(async ({ type, name }: any) => {
134+
const t = String(type ?? '').replace(/s$/, '');
135+
const found = (byType[t] ?? []).find((i: any) => i.name === name);
136+
return { type: t, name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false };
137+
}),
138+
findData: vi.fn().mockResolvedValue([]),
139+
};
140+
const rest: any = new RestServer(createMockServer() as any, protocol, {} as any);
141+
const resolvePermissionSetNames = vi.fn(async () => {
142+
if (holdings === 'unresolvable') throw new Error('security service unavailable');
143+
return holdings;
144+
});
145+
if (!opts.anonymous) {
146+
rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['crm.reports'] });
147+
rest.securityServiceProvider = async () => ({ resolvePermissionSetNames });
148+
}
149+
rest.registerRoutes();
150+
return { rest, protocol, resolvePermissionSetNames };
151+
}
152+
153+
async function call(rest: any, path: string, params: Record<string, string>) {
154+
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `/api/v1/meta${path}`);
155+
if (!route) throw new Error(`GET /meta${path} not registered`);
156+
const res = makeRes();
157+
await route.handler({ method: 'GET', params, query: {}, body: {}, headers: {} }, res);
158+
return res;
159+
}
160+
const listApps = (rest: any) => call(rest, '/:type', { type: 'app' });
161+
const getApp = (rest: any, name = 'crm') => call(rest, '/:type/:name', { type: 'app', name });
162+
const bookTree = (rest: any, name: string) => call(rest, '/book/:name/tree', { name });
163+
164+
const appsOf = (body: any): any[] => (Array.isArray(body) ? body : (body?.items ?? []));
165+
const navIds = (app: any): string[] => (app?.navigation ?? []).map((e: any) => e.id);
166+
const areaIds = (app: any): string[] => (app?.areas ?? []).map((a: any) => a.id);
167+
const listedApp = async (rest: any, name = 'crm') => appsOf((await listApps(rest)).body).find((a: any) => a?.name === name);
168+
const namedApp = async (rest: any, name = 'crm') => (await getApp(rest, name)).body?.item;
169+
const reads = (protocol: any, type: string): number =>
170+
protocol.getMetaItems.mock.calls.filter((c: any[]) => String(c[0]?.type ?? '').replace(/s$/, '') === type).length;
171+
const treeDocs = (tree: any): string[] =>
172+
(tree?.groups ?? []).flatMap((g: any) => g.entries.map((e: any) => e.doc)).filter(Boolean).sort();
173+
174+
describe('[#19790] a member who may NOT read — the entries leave the server', () => {
175+
it('LIST: no `doc` entry for a gated doc, a gated book, or a book whose every page is gated', async () => {
176+
const { rest } = setup({ holdings: [] });
177+
const app = await listedApp(rest);
178+
179+
expect(navIds(app)).toEqual(NON_HOLDER_NAV);
180+
// The gate reaches the area tree too, and an area it empties is dropped.
181+
expect(areaIds(app)).toEqual([]);
182+
});
183+
184+
it('LIST: the wire carries none of the gated names or labels', async () => {
185+
const { rest } = setup({ holdings: [] });
186+
const wire = JSON.stringify((await listApps(rest)).body);
187+
188+
for (const leaked of ['admin_guide', 'crm_admin_runbook', 'Admin Guide', 'Ops Handbook', '"book":"ops"']) {
189+
expect(wire).not.toContain(leaked);
190+
}
191+
});
192+
193+
it('a `book` + `doc` entry is dropped when the BOOK is gated, although the doc alone is readable', async () => {
194+
// `crm_intro` is readable (help_center claims it, `org`) — `nav_intro`
195+
// survives. The same page opened in `admin_guide`'s context names a
196+
// book this member cannot open (its tree read answers 403), so it goes.
197+
const { rest } = setup({ holdings: [] });
198+
const ids = navIds(await listedApp(rest));
199+
200+
expect(ids).toContain('nav_intro');
201+
expect(ids).not.toContain('nav_intro_in_admin_guide');
202+
});
203+
204+
it('a group left empty by the arm collapses, like any other gate (#7380)', async () => {
205+
const { rest } = setup({ holdings: [] });
206+
expect(navIds(await listedApp(rest))).not.toContain('grp_admin_docs');
207+
});
208+
});
209+
210+
describe('[#19790] a member who MAY read — the same entries are served', () => {
211+
it('LIST: every doc entry a `crm_admin` holder can read is present, area included', async () => {
212+
const { rest } = setup({ holdings: ['crm_admin'] });
213+
const app = await listedApp(rest);
214+
215+
expect(navIds(app)).toEqual(HOLDER_NAV);
216+
expect(areaIds(app)).toEqual(['area_ops']);
217+
expect(app.navigation.find((e: any) => e.id === 'grp_admin_docs').children.map((c: any) => c.id))
218+
.toEqual(['nav_admin_runbook_nested']);
219+
});
220+
});
221+
222+
describe('[#19790] a `book` entry is judged on its readable PAGES', () => {
223+
it('⭐ a book with exactly one readable page stays — and the tree read agrees on that page', async () => {
224+
const { rest } = setup({ holdings: [] });
225+
226+
expect(navIds(await listedApp(rest))).toContain('nav_crm_book');
227+
// The implicit `crm` book holds crm_intro (readable) and
228+
// crm_admin_runbook (gated): the tree this member is served has one page.
229+
expect(treeDocs((await bookTree(rest, 'crm')).body)).toEqual(['crm_intro']);
230+
});
231+
232+
it('a book whose OWN audience admits the member but whose every page is gated is dropped', async () => {
233+
const { rest } = setup({ holdings: [] });
234+
235+
expect(navIds(await listedApp(rest))).not.toContain('nav_ops_book');
236+
// The implicit `ops` book is `org` — the tree read opens (200)…
237+
const tree = await bookTree(rest, 'ops');
238+
expect(tree.statusCode).toBe(200);
239+
// …and serves `crm_intro` in the synthetic Uncategorized group only. The
240+
// spec calls those orphans "not an authored membership claim", so they
241+
// are not the book's pages, and the entry is still dropped.
242+
expect(tree.body.groups.map((g: any) => g.key)).toEqual(['uncategorized']);
243+
});
244+
});
245+
246+
describe('[#19790] the by-name route prunes exactly what the list route prunes', () => {
247+
for (const [who, holdings, expected] of [
248+
['non-holder', [], NON_HOLDER_NAV],
249+
['holder', ['crm_admin'], HOLDER_NAV],
250+
] as const) {
251+
it(`${who}: GET /meta/app/crm serves the list route's navigation and areas`, async () => {
252+
const { rest } = setup({ holdings: [...holdings] });
253+
const listed = await listedApp(rest);
254+
const named = await namedApp(rest);
255+
256+
expect(navIds(named)).toEqual(expected);
257+
expect(navIds(named)).toEqual(navIds(listed));
258+
expect(areaIds(named)).toEqual(areaIds(listed));
259+
});
260+
}
261+
});
262+
263+
describe('[#19790] controls — the arm narrows `doc` entries and nothing else', () => {
264+
it('a `requiredPermissions` entry is judged as before, whatever the caller holds', async () => {
265+
for (const holdings of [[], ['crm_admin']]) {
266+
const ids = navIds(await listedApp(setup({ holdings }).rest));
267+
expect(ids).toContain('nav_reports');
268+
// Readable doc, missing system permission: `requiredPermissions`
269+
// still narrows further, exactly as the schema says.
270+
expect(ids).not.toContain('nav_intro_locked');
271+
expect(ids).toContain('nav_leads');
272+
}
273+
});
274+
});
275+
276+
describe('[#19790] fails CLOSED', () => {
277+
const DOC_IDS = ['nav_admin_guide', 'nav_admin_runbook', 'nav_ops_book', 'nav_crm_book', 'nav_intro', 'nav_intro_in_admin_guide'];
278+
279+
it('the books read throws: every `doc` entry is dropped, the rest is served, and the fault is logged', async () => {
280+
const { rest } = setup({ holdings: ['crm_admin'], failing: ['book'] });
281+
282+
for (const app of [await listedApp(rest), await namedApp(rest)]) {
283+
expect(navIds(app)).toEqual(['nav_leads', 'nav_reports']);
284+
}
285+
const lines = (warn.mock.calls as unknown[][]).map((c) => String(c[0]));
286+
expect(lines.some((l) => l.includes('the book read failed') && l.includes('failing CLOSED'))).toBe(true);
287+
});
288+
289+
it('the doc corpus read throws: every `doc` entry is dropped — never read as "unclaimed, so org"', async () => {
290+
const { rest } = setup({ holdings: ['crm_admin'], failing: ['doc'] });
291+
const ids = navIds(await listedApp(rest));
292+
293+
for (const id of DOC_IDS) expect(ids).not.toContain(id);
294+
expect(ids).toEqual(['nav_leads', 'nav_reports']);
295+
});
296+
297+
it('unresolvable permission-set holdings deny the set-gated entries and keep the `org` ones', async () => {
298+
const { rest } = setup({ holdings: 'unresolvable' });
299+
expect(navIds(await listedApp(rest))).toEqual(NON_HOLDER_NAV);
300+
});
301+
302+
it('no gate handed to the filter at all: `doc` entries are dropped, not served', () => {
303+
const rest: any = new RestServer(createMockServer() as any, {} as any, {} as any);
304+
const out = rest.filterAppForUser(clone(CRM_APP), new Set(['crm.reports']));
305+
expect(navIds(out)).toEqual(['nav_leads', 'nav_reports']);
306+
});
307+
});
308+
309+
describe('[#19790] the fast path and the existence question', () => {
310+
const FAST_APP = {
311+
name: 'crm',
312+
navigation: [
313+
{ id: 'nav_intro', type: 'doc', doc: 'crm_intro' },
314+
{ id: 'nav_help', type: 'doc', book: 'help_center' },
315+
// Existence is `docs/nav-target`'s question. The resolver's own
316+
// default for a doc it has no entry for is `org`: served.
317+
{ id: 'nav_unwritten_doc', type: 'doc', doc: 'not_written_yet' },
318+
// A name no book or package carries: its implicit book has no page.
319+
{ id: 'nav_no_such_book', type: 'doc', book: 'no_such_book' },
320+
],
321+
};
322+
323+
it('no set-gated book anywhere: readable entries stay, a book with no page goes, no holdings resolved', async () => {
324+
const { rest, protocol, resolvePermissionSetNames } = setup({ books: [HELP_CENTER], apps: [FAST_APP] });
325+
326+
expect(navIds(await listedApp(rest))).toEqual(['nav_intro', 'nav_help', 'nav_unwritten_doc']);
327+
expect(resolvePermissionSetNames).not.toHaveBeenCalled();
328+
// The corpus is read once, and only because a `book` entry needs its pages.
329+
expect(reads(protocol, 'doc')).toBe(1);
330+
});
331+
332+
it('no set-gated book and no `book` entry: no doc corpus read at all', async () => {
333+
const app = { name: 'crm', navigation: [{ id: 'nav_intro', type: 'doc', doc: 'crm_intro' }] };
334+
const { rest, protocol } = setup({ books: [HELP_CENTER], apps: [app] });
335+
336+
expect(navIds(await listedApp(rest))).toEqual(['nav_intro']);
337+
expect(reads(protocol, 'book')).toBe(1);
338+
expect(reads(protocol, 'doc')).toBe(0);
339+
});
340+
});
341+
342+
describe('[#19790] cost — resolved once per request, and not at all without a `doc` entry', () => {
343+
it('an app list with no `doc` entry reads no book, no doc and no holdings', async () => {
344+
const plain = { name: 'sales', navigation: [{ id: 'nav_leads', type: 'object', objectName: 'lead' }] };
345+
const { rest, protocol, resolvePermissionSetNames } = setup({ apps: [plain] });
346+
347+
expect(navIds(await listedApp(rest, 'sales'))).toEqual(['nav_leads']);
348+
expect(reads(protocol, 'book')).toBe(0);
349+
expect(reads(protocol, 'doc')).toBe(0);
350+
expect(resolvePermissionSetNames).not.toHaveBeenCalled();
351+
});
352+
353+
it('two apps with `doc` entries: one book read, one doc read, one holdings resolution for the whole list', async () => {
354+
const second = { ...clone(CRM_APP), name: 'support' };
355+
const { rest, protocol, resolvePermissionSetNames } = setup({ holdings: [], apps: [CRM_APP, second] });
356+
357+
const apps = appsOf((await listApps(rest)).body);
358+
expect(apps.map((a: any) => navIds(a))).toEqual([NON_HOLDER_NAV, NON_HOLDER_NAV]);
359+
expect(reads(protocol, 'book')).toBe(1);
360+
expect(reads(protocol, 'doc')).toBe(1);
361+
expect(resolvePermissionSetNames).toHaveBeenCalledTimes(1);
362+
});
363+
});
364+
365+
describe('[#19790] the anonymous path', () => {
366+
it('GET /meta/app is 401 before any read — there is no anonymous app nav for the arm to cover', async () => {
367+
const { rest, protocol } = setup({ anonymous: true });
368+
const res = await listApps(rest);
369+
370+
expect(res.statusCode).toBe(401);
371+
expect(protocol.getMetaItems).not.toHaveBeenCalled();
372+
});
373+
});

0 commit comments

Comments
 (0)