/** * 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 = { '=': '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', }, ], ]; }