Files
0pt/tests/analytics/query-request-contract.test.ts
T

555 lines
19 KiB
TypeScript

/**
* tests/analytics/query-request-contract.test.ts
* ==============================================
* BI-P016 — QUERY REQUEST CONTRACT.
*
* Two claims are under test, and both are stated so they can FAIL:
*
* 1. "The default product/application API must NOT accept arbitrary SQL."
* Tested by feeding the parser every field name a caller would reach for
* to smuggle SQL and asserting a rejection — not by asserting the type
* lacks the field, which a compiler already guarantees and a JSON body
* ignores.
*
* 2. "Same request can compile to ClickHouse/semantic engine without
* consumer rewrite." Tested by projecting BOTH compiled artefacts onto a
* comparable shape and demanding equality. The ClickHouse side of that
* projection is obtained by PARSING the emitted SQL and its bound
* parameters here in the test — not by trusting a field the compiler
* helpfully returned. A compiler that silently drops a filter therefore
* fails this test instead of passing it.
*/
import { describe, expect, it } from 'vitest';
import { SiaxError } from '@/src/platform/errors';
import {
ANALYTICS_QUERY_REQUEST_KEYS,
FILTER_OPERATORS,
parseAnalyticsQueryRequest,
queryResolutionFrom,
resolveAnalyticsQuery,
type AnalyticsQueryRequest,
type FilterOperator,
type ResolvedDimension,
type ResolvedMetric,
} from '@/src/analytics/contract/query-request';
import {
compileRequestToClickHouse,
type CompiledAnalyticsQuery,
} from '@/src/analytics/contract/compile-clickhouse';
import {
compileRequestToSemantic,
shapeOfSemanticSpec,
type QueryShape,
} from '@/src/analytics/contract/compile-semantic';
import { FORCED_TENANT_PREDICATE } from '@/src/analytics/engine/query-compiler';
// ── the declared catalog under test ────────────────────────────────────────
const METRICS: ResolvedMetric[] = [
{
name: 'orders',
metric_id: 'm_orders',
event_metric_key: 'order_count',
aggregation: 'count',
allowed_dimensions: ['country', 'channel', 'segment'],
},
{
name: 'revenue',
metric_id: 'm_revenue',
event_metric_key: 'revenue_sek',
aggregation: 'sum',
allowed_dimensions: ['country', 'channel', 'segment'],
unit: 'SEK',
},
{
name: 'restricted',
metric_id: 'm_restricted',
event_metric_key: 'restricted_measure',
aggregation: 'sum',
// Deliberately NOT sliceable by `segment`.
allowed_dimensions: ['country'],
},
];
const DIMENSIONS: ResolvedDimension[] = [
{ name: 'country', dimension_id: 'd_country', field_name: 'country_code' },
{ name: 'channel', dimension_id: 'd_channel', field_name: 'sales_channel' },
{ name: 'segment', dimension_id: 'd_segment', field_name: 'customer_segment' },
];
const resolution = queryResolutionFrom({
dataset_id: 'ds_sales',
metrics: METRICS,
dimensions: DIMENSIONS,
semantic_model_version: 7,
defaultTimeDimension: 'event_time',
});
const FIELD_TO_NAME = new Map(DIMENSIONS.map((d) => [d.field_name, d.name]));
const KEY_TO_METRIC = new Map(METRICS.map((m) => [m.event_metric_key, m.name]));
function compile(req: AnalyticsQueryRequest): CompiledAnalyticsQuery {
return compileRequestToClickHouse(resolveAnalyticsQuery(req, resolution), {
tenant_id: 't_alpha',
project_id: 'p_main',
database: 'opt_analytics',
maxResultRows: 10_000,
});
}
// ── independent parser: SQL text + bound params -> comparable shape ────────
/** Split a WHERE clause on top-level " AND " (parenthesised lists survive). */
function splitConditions(where: string): string[] {
const out: string[] = [];
let depth = 0;
let current = '';
for (let i = 0; i < where.length; i += 1) {
const c = where[i];
if (c === '(') depth += 1;
if (c === ')') depth -= 1;
if (depth === 0 && where.startsWith(' AND ', i)) {
out.push(current);
current = '';
i += 4;
continue;
}
current += c;
}
if (current) out.push(current);
return out;
}
const OP_SYMBOL: Record<string, FilterOperator> = {
'=': 'eq',
'!=': 'ne',
'<': 'lt',
'<=': 'lte',
'>': 'gt',
'>=': 'gte',
};
/**
* Reconstruct the query's meaning from the artefact the engine would actually
* send. Anything the compiler failed to emit is invisible here, which is the
* point.
*/
function shapeOfCompiledSql(c: CompiledAnalyticsQuery): QueryShape {
const p = (key: string): string => {
const v = c.params[`param_${key}`];
if (v === undefined) throw new Error(`compiled SQL references unbound parameter ${key}`);
return v;
};
const whereMatch = /\sWHERE\s(.*?)(?:\sGROUP BY\s|\sORDER BY\s|\sLIMIT\s)/.exec(c.sql);
if (!whereMatch) throw new Error('compiled SQL has no WHERE clause');
const conditions = splitConditions(whereMatch[1]);
const filters: QueryShape['filters'] = [];
let timeFrom: string | undefined;
let timeTo: string | undefined;
for (const cond of conditions) {
if (cond === FORCED_TENANT_PREDICATE) continue;
if (/^project_id = \{project:String\}$/.test(cond)) continue;
if (/^dataset_id = \{dataset:String\}$/.test(cond)) continue;
if (/^metric IN \(/.test(cond)) continue;
let m = /^\w+ >= parseDateTime64BestEffort\(\{fromTime:String\}\)$/.exec(cond);
if (m) {
timeFrom = p('fromTime');
continue;
}
m = /^\w+ < parseDateTime64BestEffort\(\{toTime:String\}\)$/.exec(cond);
if (m) {
timeTo = p('toTime');
continue;
}
m = /^position\(dim_values\[indexOf\(dim_names, \{(fn\d+):String\}\)\], \{(fv\d+):String\}\) > 0$/.exec(
cond,
);
if (m) {
filters.push({ dimension: dimNameFor(p(m[1])), operator: 'contains', values: [p(m[2])] });
continue;
}
m = /^(NOT )?has\(dim_names, \{(fn\d+):String\}\)$/.exec(cond);
if (m) {
filters.push({
dimension: dimNameFor(p(m[2])),
operator: m[1] ? 'is_null' : 'is_not_null',
values: [],
});
continue;
}
m = /^dim_values\[indexOf\(dim_names, \{(fn\d+):String\}\)\] (NOT IN|IN) \((.+)\)$/.exec(cond);
if (m) {
const keys = m[3].split(', ').map((t) => /^\{(fv\d+_\d+):String\}$/.exec(t)?.[1]);
if (keys.some((k) => !k)) throw new Error(`IN list contains a non-parameter token: ${m[3]}`);
filters.push({
dimension: dimNameFor(p(m[1])),
operator: m[2] === 'NOT IN' ? 'not_in' : 'in',
values: keys.map((k) => p(k as string)),
});
continue;
}
m = /^dim_values\[indexOf\(dim_names, \{(fn\d+):String\}\)\] (=|!=|<=|>=|<|>) \{(fv\d+):String\}$/.exec(
cond,
);
if (m) {
filters.push({ dimension: dimNameFor(p(m[1])), operator: OP_SYMBOL[m[2]], values: [p(m[3])] });
continue;
}
throw new Error(`unrecognised compiled condition (possible unbound SQL text): ${cond}`);
}
const selectMatch = /^SELECT (.*?) FROM /.exec(c.sql);
if (!selectMatch) throw new Error('compiled SQL has no SELECT list');
const metricKeys = [...selectMatch[1].matchAll(/\{(m\d+):String\}/g)].map((mm) => p(mm[1]));
const dimKeys = [...selectMatch[1].matchAll(/\{(d\d+):String\}/g)].map((mm) => p(mm[1]));
const grainMatch = /toStartOf(\w+)\(/.exec(selectMatch[1]);
const orderMatch = /\sORDER BY\s(.*?)\sLIMIT\s/.exec(c.sql);
const order = orderMatch
? orderMatch[1].split(', ').map((tok) => {
const [alias, dir] = tok.split(' ');
const name = c.metricAliases[alias] ?? c.dimensionAliases[alias];
if (!name) throw new Error(`ORDER BY references unknown alias ${alias}`);
return `${name}:${dir.toLowerCase()}`;
})
: [];
const limitMatch = /\sLIMIT (\d+) FORMAT JSON$/.exec(c.sql);
if (!limitMatch) throw new Error('compiled SQL has no LIMIT');
return {
dataset_id: p('dataset'),
metrics: metricKeys.map((k) => KEY_TO_METRIC.get(k) ?? k).sort(),
dimensions: dimKeys.map((k) => FIELD_TO_NAME.get(k) ?? k).sort(),
filters: filters.sort((a, b) => (a.dimension + a.operator).localeCompare(b.dimension + b.operator)),
order,
limit: Number(limitMatch[1]),
timeFrom,
timeTo,
grain: grainMatch ? (grainMatch[1].toLowerCase() as QueryShape['grain']) : undefined,
};
}
function dimNameFor(field: string): string {
const name = FIELD_TO_NAME.get(field);
if (!name) throw new Error(`compiled SQL bound an unknown physical dimension "${field}"`);
return name;
}
// ── 1. the API does not accept SQL ─────────────────────────────────────────
describe('BI-P016 — the product API accepts no SQL', () => {
const base = { dataset: 'ds_sales', metrics: ['orders'] };
const smuggling = [
'sql',
'query',
'rawSql',
'raw_sql',
'raw',
'where',
'having',
'expression',
'expr',
'select',
'from',
'groupBy',
'group_by',
'unsafe',
];
it.each(smuggling)('rejects a request carrying a %s field', (key) => {
expect(() => parseAnalyticsQueryRequest({ ...base, [key]: 'SELECT 1' })).toThrowError(SiaxError);
try {
parseAnalyticsQueryRequest({ ...base, [key]: 'SELECT 1' });
} catch (e) {
expect((e as SiaxError).code).toBe('INVALID_REQUEST');
}
});
// The INF0 Wave-2 lesson: a tenant read out of a request body. The contract
// must not merely IGNORE such a field; ignoring it is what lets a later
// refactor start honouring it.
it.each(['tenant', 'tenant_id', 'tenantId', 'project_id', 'database', 'catalog', 'table'])(
'rejects caller-supplied %s (tenant/catalog selection is never a request field)',
(key) => {
expect(() => parseAnalyticsQueryRequest({ ...base, [key]: 't_beta' })).toThrowError(SiaxError);
},
);
it('rejects ANY unknown key, not just the ones on the smell list', () => {
expect(() => parseAnalyticsQueryRequest({ ...base, somethingNewNextQuarter: 1 })).toThrowError(SiaxError);
});
it('accepts exactly the eight BI-P016 fields and no more', () => {
expect([...ANALYTICS_QUERY_REQUEST_KEYS]).toEqual([
'dataset',
'metrics',
'dimensions',
'filters',
'timeRange',
'orderBy',
'limit',
'format',
]);
});
it('rejects SQL smuggled into a nested filter object', () => {
expect(() =>
parseAnalyticsQueryRequest({
...base,
filters: [{ dimension: 'country', operator: 'eq', values: ['SE'], sql: 'OR 1=1' }],
}),
).toThrowError(SiaxError);
});
it('keeps the filter operator set closed', () => {
expect([...FILTER_OPERATORS]).toEqual([
'eq',
'ne',
'in',
'not_in',
'lt',
'lte',
'gt',
'gte',
'contains',
'is_null',
'is_not_null',
]);
expect(() =>
parseAnalyticsQueryRequest({
...base,
filters: [{ dimension: 'country', operator: 'raw', values: ['x'] }],
}),
).toThrowError(SiaxError);
});
it('refuses an orderBy that is not selected (the obvious expression smuggling slot)', () => {
expect(() =>
parseAnalyticsQueryRequest({ ...base, orderBy: [{ ref: '(SELECT 1)', direction: 'asc' }] }),
).toThrowError(SiaxError);
});
it('refuses a metric the catalog does not declare', () => {
const req = parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['orders OR 1=1'] });
expect(() => resolveAnalyticsQuery(req, resolution)).toThrowError(SiaxError);
});
it('refuses slicing a metric by a dimension its definition disallows', () => {
const req = parseAnalyticsQueryRequest({
dataset: 'ds_sales',
metrics: ['restricted'],
dimensions: ['segment'],
});
try {
resolveAnalyticsQuery(req, resolution);
throw new Error('expected a rejection');
} catch (e) {
expect((e as SiaxError).code).toBe('AUTHORIZATION_DENIED');
}
});
it('refuses a dataset that is not the authorised one', () => {
const req = parseAnalyticsQueryRequest({ dataset: 'ds_other', metrics: ['orders'] });
try {
resolveAnalyticsQuery(req, resolution);
throw new Error('expected a rejection');
} catch (e) {
expect((e as SiaxError).code).toBe('AUTHORIZATION_DENIED');
}
});
it('rejects a reversed time range instead of silently returning zero rows', () => {
expect(() =>
parseAnalyticsQueryRequest({ ...base, timeRange: { from: '2026-02-01', to: '2026-01-01' } }),
).toThrowError(SiaxError);
});
});
// ── 2. one request, two targets, same meaning ──────────────────────────────
describe('BI-P016 DoD — one request compiles to ClickHouse and to the semantic engine', () => {
const requests: Array<[string, unknown]> = [
['metric only', { dataset: 'ds_sales', metrics: ['orders'] }],
[
'two metrics, two dimensions',
{ dataset: 'ds_sales', metrics: ['orders', 'revenue'], dimensions: ['country', 'channel'] },
],
[
'filters of every value operator',
{
dataset: 'ds_sales',
metrics: ['revenue'],
dimensions: ['country'],
filters: [
{ dimension: 'country', operator: 'eq', values: ['SE'] },
{ dimension: 'channel', operator: 'in', values: ['web', 'retail'] },
{ dimension: 'segment', operator: 'not_in', values: ['test'] },
{ dimension: 'channel', operator: 'contains', values: ['we'] },
],
},
],
[
'time range with grain and ordering',
{
dataset: 'ds_sales',
metrics: ['revenue'],
dimensions: ['country'],
timeRange: { from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z', grain: 'day' },
orderBy: [{ ref: 'revenue', direction: 'desc' }],
limit: 50,
},
],
[
'presence filters',
{
dataset: 'ds_sales',
metrics: ['orders'],
filters: [
{ dimension: 'segment', operator: 'is_null' },
{ dimension: 'channel', operator: 'is_not_null' },
],
},
],
];
it.each(requests)('%s — both targets agree', (_label, raw) => {
const req = parseAnalyticsQueryRequest(raw);
const resolved = resolveAnalyticsQuery(req, resolution);
const ch = compileRequestToClickHouse(resolved, {
tenant_id: 't_alpha',
project_id: 'p_main',
database: 'opt_analytics',
maxResultRows: 10_000,
});
const sem = compileRequestToSemantic(resolved);
const fromSql = shapeOfCompiledSql(ch);
const fromSemantic = shapeOfSemanticSpec(sem);
expect(fromSql.dataset_id).toBe(fromSemantic.dataset_id);
expect(fromSql.metrics).toEqual(fromSemantic.metrics);
expect(fromSql.dimensions).toEqual(fromSemantic.dimensions);
expect(fromSql.filters).toEqual(fromSemantic.filters);
expect(fromSql.order).toEqual(fromSemantic.order);
expect(fromSql.limit).toBe(fromSemantic.limit);
expect(fromSql.timeFrom).toBe(fromSemantic.timeFrom);
expect(fromSql.timeTo).toBe(fromSemantic.timeTo);
expect(fromSql.grain).toBe(fromSemantic.grain);
});
it('carries the canonical metric_id into the semantic spec so the engine is replaceable', () => {
const req = parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['revenue'] });
const spec = compileRequestToSemantic(resolveAnalyticsQuery(req, resolution));
expect(spec.metrics[0].metric_id).toBe('m_revenue');
expect(spec.semantic_model_version).toBe(7);
});
it('never puts a SQL predicate string in the semantic spec', () => {
const req = parseAnalyticsQueryRequest({
dataset: 'ds_sales',
metrics: ['orders'],
filters: [{ dimension: 'country', operator: 'eq', values: ["SE' OR '1'='1"] }],
});
const spec = compileRequestToSemantic(resolveAnalyticsQuery(req, resolution));
// The hostile value survives as DATA — structured, not concatenated.
expect(spec.where[0].values).toEqual(["SE' OR '1'='1"]);
expect(JSON.stringify(spec)).not.toMatch(/SELECT|WHERE\s/i);
});
});
// ── 3. tenant forcing survives every request shape ─────────────────────────
describe('BI-P011 forced tenant predicate survives the BI-P016 contract', () => {
it.each(requests())('compiled SQL forces the tenant predicate first: %s', (_label, raw) => {
const c = compile(parseAnalyticsQueryRequest(raw));
const where = /\sWHERE\s(.*?)(?:\sGROUP BY\s|\sORDER BY\s|\sLIMIT\s)/.exec(c.sql);
expect(where).not.toBeNull();
expect(splitConditions((where as RegExpExecArray)[1])[0]).toBe(FORCED_TENANT_PREDICATE);
expect(c.params.param_tenant).toBe('t_alpha');
});
it('refuses to compile with a blank tenant rather than emitting no predicate', () => {
const resolved = resolveAnalyticsQuery(
parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['orders'] }),
resolution,
);
for (const blank of ['', ' ']) {
expect(() =>
compileRequestToClickHouse(resolved, {
tenant_id: blank,
database: 'opt_analytics',
maxResultRows: 100,
}),
).toThrowError(SiaxError);
}
});
it('puts no caller-supplied value anywhere in the SQL text', () => {
const hostile = "'; DROP TABLE opt_analytics_event; --";
const c = compile(
parseAnalyticsQueryRequest({
dataset: 'ds_sales',
metrics: ['orders'],
filters: [{ dimension: 'country', operator: 'eq', values: [hostile] }],
}),
);
expect(c.sql).not.toContain(hostile);
expect(c.sql).not.toContain('DROP');
expect(Object.values(c.params)).toContain(hostile);
});
it('clamps the limit down to the governance ceiling and never up', () => {
const resolved = resolveAnalyticsQuery(
parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['orders'], limit: 1_000_000 }),
resolution,
);
const c = compileRequestToClickHouse(resolved, {
tenant_id: 't_alpha',
database: 'opt_analytics',
maxResultRows: 500,
});
expect(c.effectiveLimit).toBe(500);
expect(c.sql).toContain('LIMIT 500');
});
});
function requests(): Array<[string, unknown]> {
return [
['bare', { dataset: 'ds_sales', metrics: ['orders'] }],
['grouped', { dataset: 'ds_sales', metrics: ['orders'], dimensions: ['country'] }],
[
'filtered',
{
dataset: 'ds_sales',
metrics: ['orders'],
filters: [{ dimension: 'country', operator: 'in', values: ['SE', 'NO'] }],
},
],
[
'time-bucketed',
{ dataset: 'ds_sales', metrics: ['revenue'], timeRange: { from: '2026-01-01', grain: 'month' } },
],
[
'everything at once',
{
dataset: 'ds_sales',
metrics: ['orders', 'revenue'],
dimensions: ['country', 'channel'],
filters: [
{ dimension: 'segment', operator: 'ne', values: ['internal'] },
{ dimension: 'channel', operator: 'is_not_null' },
],
timeRange: { from: '2026-01-01', to: '2026-03-01', grain: 'week' },
orderBy: [{ ref: 'revenue', direction: 'desc' }],
limit: 25,
format: 'arrow_ipc',
},
],
];
}