Skip to content

Commit f31ca81

Browse files
committed
test(service-analytics): pin the object a hop with no declared join reads, red
A relationship-path hop the cube declares no join for must read the object its lookup field declares as its reference, on both strategies and at every reader: the door's admission, read scope and field gate, the native join and the scope applied to it, and the engine-aggregate strategy's FK-expand. Committed red against the base tree: unit 24 failed / 4 passed (the controls), route 8 failed / 2 passed (the controls). Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f78495 commit f31ca81

2 files changed

Lines changed: 522 additions & 0 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20986] On an inferred cube, a dotted path through a lookup whose NAME
5+
* differs from its target object reads the TARGET object — the object the
6+
* lookup field declares as its `reference` — on both strategies and on both
7+
* faces, the cube read (`AnalyticsService.query`, what
8+
* `POST /api/v1/analytics/query` relays) and the SQL echo
9+
* (`AnalyticsService.generateSql`, what `POST /api/v1/analytics/sql` relays).
10+
*
11+
* An inferred cube declares no join, so each hop's object used to fall back to
12+
* the ALIAS, the lookup's own name. For a lookup named after its target that
13+
* is the target; for `owner` → a person object it is no object at all. The
14+
* door admitted the name as an object, so a member who may read both objects
15+
* was refused `403` "reading "owner" is not permitted", and a caller the
16+
* object check passes reached a statement over a table named `owner`.
17+
*
18+
* The reference for every answer is the same question asked through a
19+
* DECLARED join — an authored cube whose `joins` keys the lookup and names the
20+
* target — by the same caller, on the same strategy, in the same test: a
21+
* declared join was always resolved to its target. Each reference is also
22+
* checked absolutely, so an equality between two wrong answers cannot pass.
23+
* On the native strategy the dotted filter also answers what the engine's
24+
* nested form `{ owner: { region: … } }` answers, the form the dotted
25+
* spelling maps onto. A lookup named after its target is the control.
26+
*
27+
* The composition is the shipped one, with the REAL security layer:
28+
* `SecurityPlugin` over a real `ObjectQL` on a real `SqlDriver` (SQLite), and
29+
* `AnalyticsServicePlugin` over the same engine as its `'data'` service, whose
30+
* `relationshipResolver` reads each lookup's declared `reference` off the
31+
* engine's object schema. Two compositions, one per strategy: `native` (the
32+
* plugin's own capabilities) and `objectql` (narrowed to the engine-aggregate
33+
* path).
34+
*/
35+
36+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
37+
import { PermissionSetSchema } from '@objectstack/spec/security';
38+
import { ObjectQL } from '@objectstack/objectql';
39+
import { SqlDriver } from '@objectstack/driver-sql';
40+
import { SecurityPlugin } from '@objectstack/plugin-security';
41+
import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics';
42+
43+
const LEDGER = 'rest_an_hop_ledger';
44+
/** The target of lookup `owner`: readable by the member. */
45+
const PERSON = 'rest_an_hop_person';
46+
/** The target of lookup `keeper`: the member holds no read grant on it. */
47+
const VAULT = 'rest_an_hop_vault';
48+
/** A lookup named after its target, readable — the control. */
49+
const OPEN = 'rest_an_hop_open';
50+
/** A lookup named after its target, without a read grant — the control's refusal. */
51+
const SHUT = 'rest_an_hop_shut';
52+
53+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
54+
55+
const MEMBER_SET = PermissionSetSchema.parse({
56+
name: 'member_default',
57+
label: 'Member',
58+
objects: {
59+
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
60+
[VAULT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false },
61+
[SHUT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false },
62+
},
63+
});
64+
65+
const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' };
66+
67+
const count = { type: 'count', sql: '*', label: 'Count' };
68+
69+
/** The declared-join references: the lookups keyed in `joins`, each naming its target. */
70+
const VIA_PERSON = {
71+
name: 'rest_an_hop_via_person',
72+
title: 'Via person',
73+
sql: LEDGER,
74+
measures: { count },
75+
dimensions: { owner_region: { type: 'string', sql: 'owner.region', label: 'Region' } },
76+
joins: { owner: { name: PERSON } },
77+
};
78+
const VIA_VAULT = {
79+
name: 'rest_an_hop_via_vault',
80+
title: 'Via vault',
81+
sql: LEDGER,
82+
measures: { count },
83+
dimensions: { keeper_code: { type: 'string', sql: 'keeper.code', label: 'Code' } },
84+
joins: { keeper: { name: VAULT } },
85+
};
86+
87+
const PERSONS = [{ id: 'p1', region: 'NA' }, { id: 'p2', region: 'EU' }];
88+
const VAULTS = [{ id: 'v1', code: 'c1' }];
89+
const OPENS = [{ id: 'o1', region: 'NA' }, { id: 'o2', region: 'EU' }];
90+
const SHUTS = [{ id: 's1', code: 'c1' }];
91+
const LEDGER_ROWS = [
92+
{ id: 'd1', title: 't1', owner: 'p1', keeper: 'v1', [OPEN]: 'o1', [SHUT]: 's1' },
93+
{ id: 'd2', title: 't2', owner: 'p2', keeper: 'v1', [OPEN]: 'o2', [SHUT]: 's1' },
94+
{ id: 'd3', title: 't3', owner: 'p1', [OPEN]: 'o1' },
95+
];
96+
97+
const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
98+
99+
interface Harness {
100+
engine: ObjectQL;
101+
service: AnalyticsService;
102+
/** Every object the security service was asked to admit, since the last `clear()`. */
103+
admitted: { objects: () => string[]; clear: () => void };
104+
}
105+
106+
async function boot(strategy: 'native' | 'objectql'): Promise<Harness> {
107+
const engine = new ObjectQL({ logger: quiet } as any);
108+
engine.registerDriver(
109+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any),
110+
true,
111+
);
112+
await engine.init();
113+
engine.registerApp({
114+
id: 'com.objectstack.qa.analytics-hop-object-reference-20986',
115+
name: 'Analytics hop object reference',
116+
version: '1.0.0',
117+
type: 'plugin',
118+
scope: 'system',
119+
objects: [
120+
{ name: PERSON, label: 'Person', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } },
121+
{ name: VAULT, label: 'Vault', sharingModel: 'public_read_write', fields: { code: { name: 'code', type: 'text' } } },
122+
{ name: OPEN, label: 'Open', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } },
123+
{ name: SHUT, label: 'Shut', sharingModel: 'public_read_write', fields: { code: { name: 'code', type: 'text' } } },
124+
{
125+
name: LEDGER,
126+
label: 'Ledger',
127+
sharingModel: 'public_read_write',
128+
fields: {
129+
title: { name: 'title', type: 'text' },
130+
// Named differently from their targets.
131+
owner: { name: 'owner', type: 'lookup', reference: PERSON },
132+
keeper: { name: 'keeper', type: 'lookup', reference: VAULT },
133+
// Named after their targets: the control.
134+
[OPEN]: { name: OPEN, type: 'lookup', reference: OPEN },
135+
[SHUT]: { name: SHUT, type: 'lookup', reference: SHUT },
136+
},
137+
},
138+
],
139+
} as never);
140+
await engine.syncSchemas();
141+
142+
const services: Record<string, unknown> = {
143+
manifest: { register: vi.fn() },
144+
objectql: engine,
145+
data: engine,
146+
metadata: {
147+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
148+
list: async () => [MEMBER_SET],
149+
},
150+
};
151+
const ctx: any = {
152+
logger: quiet,
153+
hook: () => {},
154+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
155+
replaceService: (name: string, svc: unknown) => { services[name] = svc; },
156+
getService: (name: string) => {
157+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
158+
return services[name];
159+
},
160+
};
161+
const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
162+
await security.init(ctx);
163+
await security.start(ctx);
164+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
165+
166+
await engine.insert(PERSON, PERSONS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
167+
await engine.insert(VAULT, VAULTS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
168+
await engine.insert(OPEN, OPENS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
169+
await engine.insert(SHUT, SHUTS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
170+
await engine.insert(LEDGER, LEDGER_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
171+
172+
await new AnalyticsServicePlugin({
173+
cubes: [VIA_PERSON, VIA_VAULT] as never,
174+
...(strategy === 'objectql'
175+
? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) }
176+
: {}),
177+
}).init(ctx);
178+
179+
const spy = vi.spyOn(services.security as { canReadObject: (object: string, context?: unknown) => Promise<boolean> }, 'canReadObject');
180+
return {
181+
engine,
182+
service: services.analytics as AnalyticsService,
183+
admitted: { objects: () => spy.mock.calls.map((call) => call[0] as string), clear: () => spy.mockClear() },
184+
};
185+
}
186+
187+
type Thrown = { code?: string; status?: number; statusCode?: number; object?: string } | null;
188+
189+
/**
190+
* What a face answered — its rows with the column names dropped (a path member
191+
* and the declared member that reads the same column are named differently),
192+
* or its refusal's envelope and the object it names.
193+
*/
194+
const answerOf = (run: () => Promise<{ rows?: ReadonlyArray<Record<string, unknown>>; sql?: unknown }>) =>
195+
run().then(
196+
(r) => ({ answered: r.rows ? [...r.rows].map((row) => JSON.stringify(Object.values(row))).sort() : typeof r.sql }),
197+
(e: Thrown) => ({ refused: { code: e?.code, status: e?.status ?? e?.statusCode, object: e?.object } }),
198+
);
199+
200+
for (const strategy of ['native', 'objectql'] as const) {
201+
describe(`[#20986] a dotted path through a lookup named differently from its target reads the target — ${strategy} composition`, () => {
202+
let h: Harness;
203+
204+
beforeAll(async () => {
205+
h = await boot(strategy);
206+
}, 60_000);
207+
208+
afterAll(async () => {
209+
try { await h?.engine.destroy(); } catch { /* noop */ }
210+
});
211+
212+
it('readable target, a dimension: the rows a declared join answers, and only the target is admitted', async () => {
213+
const reference = await answerOf(() => h.service.query({ cube: VIA_PERSON.name, measures: ['count'], dimensions: ['owner_region'] } as never, MEMBER_CTX as never));
214+
expect(reference).toEqual({ answered: [JSON.stringify(['EU', 1]), JSON.stringify(['NA', 2])] });
215+
h.admitted.clear();
216+
const answer = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: ['owner.region'] } as never, MEMBER_CTX as never));
217+
expect(answer).toEqual(reference);
218+
expect([...new Set(h.admitted.objects())].sort()).toEqual([LEDGER, PERSON].sort());
219+
expect(await answerOf(() => h.service.generateSql({ cube: LEDGER, measures: ['count'], dimensions: ['owner.region'] } as never, MEMBER_CTX as never))).toEqual({ answered: 'string' });
220+
});
221+
222+
it('readable target, a filter member: what a declared join answers in the same position', async () => {
223+
const reference = await answerOf(() => h.service.query({ cube: VIA_PERSON.name, measures: ['count'], where: { owner_region: 'NA' } } as never, MEMBER_CTX as never));
224+
const answer = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { 'owner.region': 'NA' } } as never, MEMBER_CTX as never));
225+
expect(answer).toEqual(reference);
226+
if (strategy === 'native') {
227+
// Served: the two rows whose owner is in region NA — what the engine's
228+
// nested form answers for the same condition.
229+
expect(answer).toEqual({ answered: [JSON.stringify([2])] });
230+
const nested = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { owner: { region: 'NA' } } } as never, MEMBER_CTX as never));
231+
expect(answer).toEqual(nested);
232+
} else {
233+
// The engine-aggregate strategy filters on no related value, through a
234+
// declared join or not: its own capability refusal, never the door's.
235+
expect(answer).toEqual({ refused: { code: 'INVALID_FIELD', status: 400, object: undefined } });
236+
}
237+
});
238+
239+
it.each([
240+
['a dimension', { cube: LEDGER, measures: ['count'], dimensions: ['keeper.code'] }, { cube: VIA_VAULT.name, measures: ['count'], dimensions: ['keeper_code'] }],
241+
['a filter member', { cube: LEDGER, measures: ['count'], where: { 'keeper.code': 'c1' } }, { cube: VIA_VAULT.name, measures: ['count'], where: { keeper_code: 'c1' } }],
242+
])('unreadable target, %s: refused 403 naming the target, as through a declared join, on both faces', async (_label, path, declared) => {
243+
const reference = await answerOf(() => h.service.query(declared as never, MEMBER_CTX as never));
244+
expect(reference).toEqual({ refused: { code: 'PERMISSION_DENIED', status: 403, object: VAULT } });
245+
expect(await answerOf(() => h.service.query(path as never, MEMBER_CTX as never)), 'the cube read').toEqual(reference);
246+
expect(await answerOf(() => h.service.generateSql(path as never, MEMBER_CTX as never)), 'the SQL echo').toEqual(reference);
247+
});
248+
249+
it('the control: a lookup named after its target is answered when readable and refused naming it when not', async () => {
250+
expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${OPEN}.region`] } as never, MEMBER_CTX as never))).toEqual({
251+
answered: [JSON.stringify(['EU', 1]), JSON.stringify(['NA', 2])],
252+
});
253+
expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${SHUT}.code`] } as never, MEMBER_CTX as never))).toEqual({
254+
refused: { code: 'PERMISSION_DENIED', status: 403, object: SHUT },
255+
});
256+
});
257+
});
258+
}

0 commit comments

Comments
 (0)