-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathexecution-context-bridge.test.ts
More file actions
335 lines (285 loc) · 13.5 KB
/
Copy pathexecution-context-bridge.test.ts
File metadata and controls
335 lines (285 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* ADR-0021 D-C — SECOND belt (#3602): the analytics→engine bridge carries the
* request's `ExecutionContext`.
*
* `objectql-read-scope.test.ts` covers the FIRST belt (#3601): the strategy ANDs
* `getReadScope`'s predicate into the filter it hands the engine. That belt only
* works if every strategy remembers to call it — #3597 is what happens when one
* does not, and a third strategy will eventually repeat it.
*
* The second belt is independent: hand the engine the caller's context and the
* engine's own middleware chain (`mergeReadContext` → RLS injection) scopes the
* read whether or not the analytics layer did. `BaseEngineOptions.context` was
* always `.optional()`, so nothing ever forced the bridge to pass it — and it
* did not, which is precisely why plugin-security's principal-less fall-open
* left BOTH belts off at once in #3597.
*
* These cases assert on what reaches the bridge, because that is the seam the
* engine's RLS hangs off of.
*/
import { describe, it, expect } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';
import { compileDataset } from '../dataset-compiler.js';
const dataset = DatasetSchema.parse({
name: 'sales',
label: 'Sales',
object: 'opportunity',
dimensions: [{ name: 'region', field: 'region', type: 'string' }],
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
});
type AggOpts = {
groupBy?: string[];
aggregations?: Array<{ field: string; method: string; alias: string }>;
filter?: Record<string, unknown>;
timezone?: string;
context?: ExecutionContext;
};
const ctxA = { tenantId: 'org_A', userId: 'u_a' } as ExecutionContext;
const ctxB = { tenantId: 'org_B', userId: 'u_b' } as ExecutionContext;
const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });
const readScope = (_o: string, context?: ExecutionContext): FilterCondition | undefined =>
context?.tenantId ? { organization_id: context.tenantId } : undefined;
function makeService(seen: AggOpts[], overrides: Record<string, unknown> = {}) {
const compiled = compileDataset(dataset);
return new AnalyticsService({
cubes: [compiled.cube],
queryCapabilities: objectqlOnly,
executeAggregate: async (_object: string, opts: AggOpts) => { seen.push(opts); return []; },
getReadScope: readScope,
...overrides,
});
}
const baseQuery = { cube: 'sales', dimensions: ['region'], measures: ['revenue'] };
describe('executeAggregate bridge carries the ExecutionContext (#3602)', () => {
it('forwards the caller context to the aggregate bridge', async () => {
const seen: AggOpts[] = [];
await makeService(seen).query(baseQuery, ctxA);
expect(seen[0].context).toBe(ctxA);
});
it('forwards the context even when NO read-scope provider is configured', async () => {
// The depth case. With the analytics-layer belt absent, the engine's own
// RLS is the ONLY thing standing between this query and every tenant's
// rows — so the context must reach it. Gating context on `getReadScope`
// being wired would starve exactly the deployment that needs it most.
const seen: AggOpts[] = [];
await makeService(seen, { getReadScope: undefined }).query(baseQuery, ctxA);
expect(seen[0].context).toBe(ctxA);
expect(seen[0].filter).toBeUndefined();
});
it('gives each caller its own context on a shared service instance', async () => {
const seen: AggOpts[] = [];
const service = makeService(seen);
await service.query(baseQuery, ctxA);
await service.query(baseQuery, ctxB);
expect(seen.map((s) => s.context)).toEqual([ctxA, ctxB]);
});
it('leaves context undefined when the caller supplied none', async () => {
// In-memory / dev / system-internal use. Unchanged behaviour: absent is not
// "unrestricted" — the engine applies its own policy to a context-less op.
const seen: AggOpts[] = [];
await makeService(seen).query(baseQuery);
expect(seen[0].context).toBeUndefined();
});
it('carries context on the date-bucketed path (where NativeSQL declines)', async () => {
const compiled = compileDataset(
DatasetSchema.parse({
name: 'sales_t',
label: 'Sales',
object: 'opportunity',
dimensions: [{ name: 'created', field: 'created_at', type: 'date' }],
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
}),
);
const seen: AggOpts[] = [];
const service = new AnalyticsService({
cubes: [compiled.cube],
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
executeRawSql: async () => { throw new Error('NativeSQL must decline on granularity'); },
executeAggregate: async (_o: string, opts: AggOpts) => { seen.push(opts); return []; },
getReadScope: readScope,
});
await service.query(
{ cube: 'sales_t', measures: ['revenue'], timeDimensions: [{ dimension: 'created', granularity: 'month' }] },
ctxA,
);
expect(seen[0].context).toBe(ctxA);
});
});
// ── The auto-bridge, which is what production actually runs ──────────────────
/** Minimal PluginContext: the four members `AnalyticsServicePlugin.init` uses. */
function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
return {
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn() {}, error() {}, debug() {} },
},
registered,
};
}
describe('plugin auto-bridge → engine.aggregate (#3602)', () => {
it('threads the context into the engine call', async () => {
const calls: Array<Record<string, unknown>> = [];
const engine = {
aggregate: async (_object: string, options: Record<string, unknown>) => {
calls.push(options);
return [];
},
getObject: () => undefined,
};
const { ctx, registered } = fakePluginContext({ data: engine });
const compiled = compileDataset(dataset);
await new AnalyticsServicePlugin({
cubes: [compiled.cube],
queryCapabilities: objectqlOnly,
getReadScope: readScope,
}).init(ctx as never);
await (registered.analytics as AnalyticsService).query(baseQuery, ctxA);
// `engine.aggregate` merges this into the operation context, which is what
// lets the middleware chain inject RLS into `opCtx.ast.where`.
expect(calls[0].context).toBe(ctxA);
// First belt still intact — the two are additive, not either/or.
expect(calls[0].where).toEqual({ organization_id: 'org_A' });
});
});
// ── Item 2: the record-label lookup rides the same bridge ────────────────────
const labelDataset = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'string' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});
/** What the ENGINE sees — the bridge translates `filter` → `where`. */
type EngineCall = { object: string; where?: Record<string, unknown>; context?: ExecutionContext };
/**
* Drive the label path through the real plugin wiring and return every call the
* engine saw. The dimension-label pass runs on `queryDataset`, after the
* aggregate returns rows carrying FK ids.
*/
async function runLabelLookup(opts: {
scope: (o: string, c?: ExecutionContext) => FilterCondition | undefined;
context?: ExecutionContext;
}) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, ...options } as EngineCall);
// The grouped aggregate returns an FK id; the label pass then resolves it.
return object === 'task' ? [{ account: 'acc1', cnt: 1 }] : [{ id: 'acc1', name: 'Acme Corp', _c: 1 }];
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered } = fakePluginContext({ data: engine });
await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: opts.scope,
}).init(ctx as never);
const result = await (registered.analytics as AnalyticsService).queryDataset(
labelDataset as never,
{ dimensions: ['account'], measures: ['cnt'] } as never,
opts.context,
);
return { seen, result };
}
/**
* #3639 gave this lookup the analytics-layer belt (the referenced object's own
* read scope) and covers that belt's own behaviour in `dimension-labels.test.ts`
* / `query-dataset.test.ts`. What is asserted here is the SECOND belt on the
* same hook, and — the part neither change covers alone — that the two arrive
* together on one engine call. Two PRs touching one hook is exactly how one of
* them quietly stops being wired.
*/
describe('fetchRecordLabels carries BOTH belts (#3602)', () => {
it('lands the read scope and the context on the same engine call', async () => {
const { seen } = await runLabelLookup({ scope: readScope, context: ctxA });
// [0] is the dataset aggregate; [1] is the id→label lookup — one row per
// record, real display names, so it is row-granular and needs both belts.
expect(seen[1].object).toBe('crm_account');
expect(seen[1].where).toEqual({
$and: [{ id: { $in: ['acc1'] } }, { organization_id: 'org_A' }],
});
expect(seen[1].context).toBe(ctxA);
});
it('carries the context even when the target object has no read scope', async () => {
// The depth case for this hook: scope resolves to nothing, so the engine's
// own RLS is all that is left. Dropping the context here would make an
// unscoped-at-the-analytics-layer label read also unscoped at the engine.
const { seen } = await runLabelLookup({ scope: () => undefined, context: ctxA });
expect(seen[1].where).toEqual({ id: { $in: ['acc1'] } });
expect(seen[1].context).toBe(ctxA);
});
it('resolves the label when the record is in scope', async () => {
const { result } = await runLabelLookup({ scope: readScope, context: ctxA });
expect(result.rows[0].account).toBe('Acme Corp');
});
});
// ── Item 3: the /analytics/sql preview must match what executes ──────────────
describe('ObjectQLStrategy.generateSql reflects the executed query (#3602 item 3)', () => {
it('renders the read scope in the WHERE clause', async () => {
const { sql, params } = await makeService([]).generateSql(baseQuery, ctxA);
expect(sql).toContain('WHERE ("opportunity"."organization_id" = $1)');
expect(params).toEqual(['org_A']);
});
it('renders the caller filters alongside the scope', async () => {
const { sql, params } = await makeService([]).generateSql(
{ ...baseQuery, where: { region: 'West' } },
ctxA,
);
expect(sql).toContain('WHERE region = $1 AND ("opportunity"."organization_id" = $2)');
expect(params).toEqual(['West', 'org_A']);
});
it('emits no WHERE when there is neither a filter nor a scope', async () => {
const { sql, params } = await makeService([], { getReadScope: undefined }).generateSql(baseQuery, ctxA);
expect(sql).not.toContain('WHERE');
expect(params).toEqual([]);
});
it('renders the operators the analytics layer emits', async () => {
const { sql, params } = await makeService([], { getReadScope: undefined }).generateSql(
{ ...baseQuery, where: { region: { $in: ['West', 'East'] }, amount: { $gte: 10 } } },
ctxA,
);
expect(sql).toContain('region IN ($1, $2)');
expect(sql).toContain('amount >= $3');
expect(params).toEqual(['West', 'East', 10]);
});
it('renders nothing for an OUT-OF-ENVELOPE cross-object query `execute()` would reject', async () => {
// The rendering must not describe a query that cannot run. #3654 now SERVES
// in-envelope cross-object DIMENSIONS by FK-expand (generateSql renders the
// equivalent LEFT JOIN — see objectql-crossobj-expand.test.ts). What execute()
// still rejects — a cross-object MEASURE — generateSql must reject too, so the
// preview and the executed path stay in step over the same set.
const compiled = compileDataset(
DatasetSchema.parse({
name: 'sales_by_account',
label: 'Sales by account',
object: 'opportunity',
include: ['account'],
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
measures: [{ name: 'acct_rev', aggregate: 'sum', field: 'account.annual_revenue' }],
}),
);
const service = new AnalyticsService({
cubes: [compiled.cube],
queryCapabilities: objectqlOnly,
executeAggregate: async () => [],
getReadScope: () => ({ organization_id: 'org_A' }),
getAllowedRelationships: () => compiled.allowedRelationships,
});
await expect(
service.generateSql({ cube: 'sales_by_account', dimensions: ['stage'], measures: ['acct_rev'] }, ctxA),
).rejects.toThrow(/cannot evaluate a cross-object measure/);
});
});