Skip to content

Commit bbe04d9

Browse files
committed
test(metadata-protocol,service-analytics,dogfood): pin the second-object exposure decision on $expand and the label passes (#22661)
The label face asks `get` (a by-id read), the operation the data door's expansion asks of the same target. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
1 parent 424a01e commit bbe04d9

5 files changed

Lines changed: 532 additions & 8 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22661] The data door's `$expand` asks every level's TARGET object the
5+
* spec's one exposure decision (`canServeApiOperation(enable, 'get')`,
6+
* ADR-0049), and withholds an entry it does not serve, so the field answers as
7+
* an unexpanded lookup — its stored id.
8+
*
9+
* Asserted on what the door hands the engine: a withheld entry never reaches
10+
* `engine.find` / `engine.findOne`, so nothing of the target is read. Each
11+
* withheld shape is paired with a CONTROL the decision serves, which must reach
12+
* the engine unchanged — a door that dropped every expansion would pass every
13+
* "withheld" case and serve nothing. The decision takes no caller, so a system
14+
* context is withheld from too. The rows themselves (the stored id where the
15+
* record would have been, for an administrator and a member alike) are pinned
16+
* on a real stack in `@objectstack/dogfood`.
17+
*/
18+
19+
import { describe, expect, it, vi } from 'vitest';
20+
import type { ServiceObject } from '@objectstack/spec/data';
21+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
22+
import { ObjectStackProtocolImplementation } from './protocol.js';
23+
24+
const lookup = (name: string, reference: string) => ({ name, label: name, type: 'lookup', reference });
25+
26+
const target = (name: string, enable?: Record<string, unknown>): ServiceObject => ({
27+
name,
28+
label: name,
29+
...(enable ? { enable } : {}),
30+
fields: {
31+
id: { name: 'id', label: 'ID', type: 'text' },
32+
name: { name: 'name', label: 'Name', type: 'text' },
33+
},
34+
} as unknown as ServiceObject);
35+
36+
/** Every exposure shape the decision distinguishes for `get`, one lookup each. */
37+
const SOURCE: ServiceObject = {
38+
name: 'xt_source',
39+
label: 'Source',
40+
fields: {
41+
id: { name: 'id', label: 'ID', type: 'text' },
42+
name: { name: 'name', label: 'Name', type: 'text' },
43+
// `apiEnabled: false` is judged FIRST: a whitelist granting `get` beside it changes nothing.
44+
hidden: lookup('hidden', 'xt_hidden'),
45+
closed: lookup('closed', 'xt_closed'),
46+
denyall: lookup('denyall', 'xt_denyall'),
47+
listonly: lookup('listonly', 'xt_listonly'),
48+
getonly: lookup('getonly', 'xt_getonly'),
49+
open: lookup('open', 'xt_open'),
50+
},
51+
} as unknown as ServiceObject;
52+
53+
/** Exposed, with a lookup into the off-switched object one hop further on. */
54+
const OPEN: ServiceObject = {
55+
name: 'xt_open',
56+
label: 'Open',
57+
fields: {
58+
id: { name: 'id', label: 'ID', type: 'text' },
59+
name: { name: 'name', label: 'Name', type: 'text' },
60+
inner: lookup('inner', 'xt_hidden'),
61+
peer: lookup('peer', 'xt_getonly'),
62+
},
63+
} as unknown as ServiceObject;
64+
65+
const SCHEMAS: Record<string, unknown> = {
66+
xt_source: SOURCE,
67+
xt_open: OPEN,
68+
xt_hidden: target('xt_hidden', { apiEnabled: false, apiMethods: ['get'] }),
69+
xt_closed: target('xt_closed', { apiMethods: ['create'] }),
70+
xt_denyall: target('xt_denyall', { apiMethods: [] }),
71+
xt_listonly: target('xt_listonly', { apiMethods: ['list'] }),
72+
xt_getonly: target('xt_getonly', { apiMethods: ['get'] }),
73+
};
74+
75+
function makeProtocol() {
76+
const find = vi.fn(async (_object: string, _o: any) => [{ id: 's1', name: 'source' }]);
77+
const findOne = vi.fn(async (object: string, o: any) => {
78+
assertEngineFindOnePredicate(object, o);
79+
return { id: 's1', name: 'source' };
80+
});
81+
const count = vi.fn(async () => 1);
82+
const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, findOne, count };
83+
return { p: new ObjectStackProtocolImplementation(engine), find, findOne };
84+
}
85+
86+
/** The `expand` the door handed the engine's list read. */
87+
async function expandHandedToFind(query: Record<string, unknown>, context?: unknown): Promise<unknown> {
88+
const { p, find } = makeProtocol();
89+
await p.findData({ object: 'xt_source', query, ...(context ? { context } : {}) });
90+
expect(find).toHaveBeenCalledTimes(1);
91+
return (find.mock.calls[0]![1] as { expand?: unknown }).expand;
92+
}
93+
94+
const WITHHELD = ['hidden', 'closed', 'denyall', 'listonly'] as const;
95+
const SERVED = ['getonly', 'open'] as const;
96+
97+
describe('[#22661] the data door withholds an $expand entry whose target the API does not serve', () => {
98+
for (const rel of WITHHELD) {
99+
it(`${rel}: withheld on the comma list, the engine reads nothing of the target`, async () => {
100+
expect(await expandHandedToFind({ $expand: rel })).toBeUndefined();
101+
});
102+
it(`${rel}: withheld from the POST relation map, beside a served entry that is kept`, async () => {
103+
const expand = await expandHandedToFind({ expand: { [rel]: { object: rel, fields: ['name'] }, open: { object: 'open' } } });
104+
expect(expand).toEqual({ open: { object: 'open' } });
105+
});
106+
}
107+
108+
for (const rel of SERVED) {
109+
it(`CONTROL ${rel}: served, handed to the engine unchanged`, async () => {
110+
const map = { [rel]: { object: rel, fields: ['name'] } };
111+
expect(await expandHandedToFind({ expand: map })).toEqual(map);
112+
expect(await expandHandedToFind({ $expand: rel })).toEqual({ [rel]: { object: rel } });
113+
});
114+
}
115+
116+
it('every level is judged against its own target: a second-level entry into an unexposed object is withheld', async () => {
117+
const expand = await expandHandedToFind({
118+
expand: {
119+
open: {
120+
object: 'open',
121+
fields: ['name'],
122+
expand: { inner: { object: 'inner' }, peer: { object: 'peer' } },
123+
},
124+
},
125+
});
126+
expect(expand).toEqual({ open: { object: 'open', fields: ['name'], expand: { peer: { object: 'peer' } } } });
127+
});
128+
129+
it('a level left with nothing to expand carries no expand of its own', async () => {
130+
const expand = await expandHandedToFind({ expand: { open: { object: 'open', expand: { inner: { object: 'inner' } } } } });
131+
expect(expand).toEqual({ open: { object: 'open' } });
132+
});
133+
134+
it('the caller\'s relation map is never mutated', async () => {
135+
const map = { hidden: { object: 'hidden' }, open: { object: 'open', expand: { inner: { object: 'inner' } } } };
136+
const before = JSON.stringify(map);
137+
await expandHandedToFind({ expand: map });
138+
expect(JSON.stringify(map)).toBe(before);
139+
});
140+
141+
it('the decision takes no caller: a system context is withheld from too', async () => {
142+
expect(await expandHandedToFind({ $expand: 'hidden,open' }, { isSystem: true })).toEqual({ open: { object: 'open' } });
143+
});
144+
145+
it('the single-record read withholds the same entries, and keeps the served ones', async () => {
146+
const { p, findOne } = makeProtocol();
147+
await p.getData({ object: 'xt_source', id: 's1', expand: 'hidden,listonly,getonly,open' });
148+
expect((findOne.mock.calls[0]![1] as { expand?: unknown }).expand).toEqual({
149+
getonly: { object: 'getonly' },
150+
open: { object: 'open' },
151+
});
152+
await p.getData({ object: 'xt_source', id: 's1', expand: ['closed', 'denyall'] });
153+
expect((findOne.mock.calls[1]![1] as { expand?: unknown }).expand).toBeUndefined();
154+
});
155+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4455,7 +4455,10 @@ const INTERNAL_FIELD_WALK_DEPTH = 8;
44554455
* is judged `get`. It never enumerates the target: the engine's sub-read is
44564456
* bounded to `id $in` the source rows' own values, so a nested `where` can only
44574457
* narrow records the caller could fetch by id. `list` would refuse an object
4458-
* that serves exactly those records by id; it is not the read this is.
4458+
* that serves exactly those records by id; it is not the read this is. The
4459+
* dataset door's dimension-label pass asks the same operation of the same
4460+
* target (`@objectstack/service-analytics`), so a lookup renders its name, or
4461+
* its stored id, alike on both doors.
44594462
*/
44604463
const EXPANSION_TARGET_OPERATION = 'get';
44614464

@@ -12658,7 +12661,8 @@ export class ObjectStackProtocolImplementation implements
1265812661
const nested = this.servedExpand(target, entry.expand, depth + 1);
1265912662
if (nested !== entry.expand) {
1266012663
served ??= { ...expand };
12661-
served[rel] = { ...entry, expand: nested };
12664+
const { expand: _withheld, ...rest } = entry;
12665+
served[rel] = Object.keys(nested).length > 0 ? { ...rest, expand: nested } : rest;
1266212666
}
1266312667
}
1266412668
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22661] A read that reaches a SECOND object — the record a lookup points at
4+
// — asks that object's own declared exposure (`enable.apiEnabled` /
5+
// `enable.apiMethods`, ADR-0049), through the spec's one decision, on a real
6+
// boot: the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST, auth and
7+
// analytics layers.
8+
//
9+
// ## The reads, and what each must answer
10+
//
11+
// - The data door's `$expand` — the list route, the single-record route, the
12+
// query route's relation map, a second-level entry, and the export door
13+
// (which expands every lookup to name it). An entry whose target the
14+
// decision does not serve for `get` answers as an UNEXPANDED lookup: the
15+
// stored id, the answer the door already gives for a related record the
16+
// caller may not read (the precedent leg below).
17+
// - The dataset door's two dimension-label passes. A target the decision does
18+
// not serve for `get` is not read: the stored id renders, and an `order` on
19+
// the dimension sorts by it.
20+
//
21+
// ## Armed before anything is believed
22+
//
23+
// The decision's own answer for each target is read off the data door first:
24+
// `GET /data/{target}/{id}` answers 404 for the off switch, 405 for a
25+
// whitelist that does not grant `get`, and 200 for the targets that serve it —
26+
// and the expansion and label of each target must agree with that answer, for
27+
// an administrator and a member alike (the decision takes no caller). The
28+
// served targets are the controls: a door that withheld everything would fail
29+
// them.
30+
//
31+
// Fixtures are synthetic. ⚠️ No test title states a value.
32+
// `@objectstack/metadata-protocol` and `@objectstack/service-analytics`
33+
// resolve through their BUILT output here, so a verdict on a change to either
34+
// is a verdict on its last build.
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { bootStack, type VerifyStack } from '@objectstack/verify';
38+
import { secondObjectExposureStack, secondObjectExposureSecurity } from './fixtures/second-object-exposure-fixture.js';
39+
40+
const MEMBER_EMAIL = 'sox-member@verify.test';
41+
const SYS = { context: { isSystem: true } } as const;
42+
43+
/** Each source lookup, the object it points at, and the data door's `get` answer for that object. */
44+
const TARGETS = {
45+
hidden: { object: 'sox_hidden', getStatus: 404 },
46+
closed: { object: 'sox_closed', getStatus: 405 },
47+
listonly: { object: 'sox_listonly', getStatus: 405 },
48+
getonly: { object: 'sox_getonly', getStatus: 200 },
49+
open: { object: 'sox_open', getStatus: 200 },
50+
} as const;
51+
type Rel = keyof typeof TARGETS;
52+
const WITHHELD: Rel[] = ['hidden', 'closed', 'listonly'];
53+
const SERVED: Rel[] = ['getonly', 'open'];
54+
55+
/** Synthetic display names; each is unique, so finding one in a body means it was served. */
56+
const MARK: Record<Rel | 'private', string> = {
57+
hidden: 'SOXHIDDEN71',
58+
closed: 'SOXCLOSED72',
59+
listonly: 'SOXLISTONLY73',
60+
getonly: 'SOXGETONLY74',
61+
open: 'SOXOPEN75',
62+
private: 'SOXPRIVATE76',
63+
};
64+
65+
type Row = Record<string, any>;
66+
const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? (Array.isArray(body) ? body : []);
67+
68+
describe('[#22661] a second-object read serves only a target whose declared exposure serves it', () => {
69+
let stack: VerifyStack;
70+
const token: Record<'admin' | 'member', string> = { admin: '', member: '' };
71+
/** The stored id each source lookup holds (the same on both source rows, except `hidden`). */
72+
const ids: Record<string, string> = {};
73+
let sourceId = '';
74+
75+
beforeAll(async () => {
76+
stack = await bootStack(secondObjectExposureStack as never, { security: secondObjectExposureSecurity() });
77+
token.admin = await stack.signIn();
78+
token.member = await stack.signUp(MEMBER_EMAIL);
79+
const ql = (await stack.kernel.getServiceAsync('objectql')) as any;
80+
// Two hidden rows whose name order is the reverse of their id order, so a
81+
// sort by the withheld name and a sort by the stored id disagree.
82+
await ql.insert('sox_hidden', { id: 'soxh_2', name: `A-${MARK.hidden}` }, SYS);
83+
await ql.insert('sox_hidden', { id: 'soxh_1', name: `Z-${MARK.hidden}` }, SYS);
84+
ids.hidden = 'soxh_2';
85+
for (const rel of ['closed', 'listonly', 'getonly', 'private'] as const) {
86+
const row = await ql.insert(`sox_${rel}`, { name: MARK[rel] }, SYS);
87+
ids[rel] = String(row.id);
88+
}
89+
const open = await ql.insert('sox_open', { name: MARK.open, inner: 'soxh_1' }, SYS);
90+
ids.open = String(open.id);
91+
const a = await ql.insert('sox_source', { name: 'src-a', amount: 1, ...ids }, SYS);
92+
sourceId = String(a.id);
93+
await ql.insert('sox_source', { name: 'src-b', amount: 2, ...ids, hidden: 'soxh_1' }, SYS);
94+
}, 180_000);
95+
96+
afterAll(async () => {
97+
await stack?.stop?.();
98+
});
99+
100+
for (const persona of ['admin', 'member'] as const) {
101+
describe(persona, () => {
102+
it('ARMED — the data door answers each target\'s own get as its declaration says', async () => {
103+
for (const rel of [...WITHHELD, ...SERVED]) {
104+
const res = await stack.apiAs(token[persona], 'GET', `/data/${TARGETS[rel].object}/${ids[rel]}`);
105+
expect(res.status, `${rel}: ${await res.clone().text()}`).toBe(TARGETS[rel].getStatus);
106+
}
107+
});
108+
109+
const spellings: Array<[string, (rel: Rel) => Promise<Row>]> = [
110+
['the list route', async (rel) => {
111+
const res = await stack.apiAs(token[persona], 'GET', `/data/sox_source?$expand=${rel}&$filter=${encodeURIComponent(JSON.stringify({ name: 'src-a' }))}`);
112+
expect(res.status, await res.clone().text()).toBe(200);
113+
return rowsOf(await res.json())[0]!;
114+
}],
115+
['the single-record route', async (rel) => {
116+
const res = await stack.apiAs(token[persona], 'GET', `/data/sox_source/${sourceId}?expand=${rel}`);
117+
expect(res.status, await res.clone().text()).toBe(200);
118+
const body: any = await res.json();
119+
return body.record ?? body;
120+
}],
121+
['the query route relation map', async (rel) => {
122+
const res = await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', {
123+
where: { name: 'src-a' },
124+
expand: { [rel]: { object: rel, fields: ['name'] } },
125+
});
126+
expect(res.status, await res.clone().text()).toBe(200);
127+
return rowsOf(await res.json())[0]!;
128+
}],
129+
];
130+
131+
for (const [spelling, read] of spellings) {
132+
it(`$expand on ${spelling}: a withheld target answers as an unexpanded lookup`, async () => {
133+
for (const rel of WITHHELD) {
134+
const row = await read(rel);
135+
expect(row[rel], rel).toBe(ids[rel]);
136+
}
137+
});
138+
it(`CONTROL $expand on ${spelling}: a served target is expanded`, async () => {
139+
for (const rel of SERVED) {
140+
const row = await read(rel);
141+
expect(row[rel]?.name, rel).toBe(MARK[rel]);
142+
}
143+
});
144+
}
145+
146+
it('$expand second level: the inner entry into the unexposed object is withheld, the outer one served', async () => {
147+
const res = await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', {
148+
where: { name: 'src-a' },
149+
expand: { open: { object: 'open', expand: { inner: { object: 'inner' } } } },
150+
});
151+
expect(res.status, await res.clone().text()).toBe(200);
152+
const row = rowsOf(await res.json())[0]!;
153+
expect(row.open?.name).toBe(MARK.open);
154+
expect(row.open?.inner).toBe('soxh_1');
155+
});
156+
157+
it('the export door names a served target and not a withheld one', async () => {
158+
const res = await stack.apiAs(token[persona], 'GET', '/data/sox_source/export?format=json');
159+
expect(res.status, await res.clone().text()).toBe(200);
160+
const text = await res.text();
161+
for (const rel of SERVED) expect(text, rel).toContain(MARK[rel]);
162+
for (const rel of WITHHELD) expect(text, rel).not.toContain(MARK[rel]);
163+
});
164+
165+
const dataset = (rel: Rel) => ({
166+
name: `sox_by_${rel}`,
167+
label: `By ${rel}`,
168+
object: 'sox_source',
169+
dimensions: [{ name: rel, label: rel, field: rel, type: 'lookup' }],
170+
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
171+
});
172+
const datasetRows = async (rel: Rel, order?: Record<string, 'asc' | 'desc'>) => {
173+
const res = await stack.apiAs(token[persona], 'POST', '/analytics/dataset/query', {
174+
dataset: dataset(rel),
175+
selection: { dimensions: [rel], measures: ['cnt'], ...(order ? { order } : {}) },
176+
});
177+
expect(res.status, await res.clone().text()).toBe(200);
178+
return ((await res.json()) as { rows?: Row[] }).rows ?? [];
179+
};
180+
181+
it('dataset label pass: a withheld target renders its stored id', async () => {
182+
for (const rel of ['closed', 'listonly'] as const) {
183+
expect((await datasetRows(rel)).map((r) => r[rel]), rel).toEqual([ids[rel]]);
184+
}
185+
expect((await datasetRows('hidden')).map((r) => r.hidden).sort()).toEqual(['soxh_1', 'soxh_2']);
186+
});
187+
188+
it('CONTROL dataset label pass: a served target renders its name', async () => {
189+
for (const rel of SERVED) {
190+
expect((await datasetRows(rel)).map((r) => r[rel]), rel).toEqual([MARK[rel]]);
191+
}
192+
});
193+
194+
it('dataset sort-key pass: an order on a withheld dimension sorts by the stored id', async () => {
195+
// By the withheld names this would read soxh_2 (A-…) first.
196+
expect((await datasetRows('hidden', { hidden: 'asc' })).map((r) => r.hidden)).toEqual(['soxh_1', 'soxh_2']);
197+
});
198+
});
199+
}
200+
201+
it('PRECEDENT — a related record the member may not read answers as an unexpanded lookup, unchanged', async () => {
202+
const res = await stack.apiAs(token.member, 'GET', `/data/sox_source/${sourceId}?expand=private`);
203+
expect(res.status, await res.clone().text()).toBe(200);
204+
const body: any = await res.json();
205+
expect((body.record ?? body).private).toBe(ids.private);
206+
});
207+
});

0 commit comments

Comments
 (0)