CI (SIAX Cloud) / security (push) Successful in 25s
Security Scan (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / integration (push) Successful in 39s
CI (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / quality (push) Successful in 57s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m38s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m59s
The eight-field AnalyticsQueryRequest BI-P016 names, verbatim, with the rule
that makes it safe enforced structurally rather than by convention: there is
no field that can carry SQL, and parseAnalyticsQueryRequest REJECTS unknown
keys instead of ignoring them. Ignoring an unsupported field is how it becomes
a supported one after the next refactor.
`tenant`, `tenant_id`, `project_id`, `database`, `catalog` and `table` are
rejected by name. INF0's Wave-2 cross-tenant hole was a tenant read out of a
request body; the fix that sticks is a request type in which the field does
not exist, and a parser that refuses it when a caller sends it anyway.
BI-P016's DoD ("same request compiles to ClickHouse/semantic engine without
consumer rewrite") is tested as an equality, not narrated: both targets are
projected onto a comparable shape and compared. The ClickHouse side of that
projection is obtained by PARSING the emitted SQL and its bound parameters in
the test — not by reading a field the compiler helpfully returned — so a
compiler that silently drops a filter fails the comparison instead of passing
it. The parser throws on any condition it does not recognise, which is what
would catch unbound caller text appearing in the statement.
BI-P011's forced tenant predicate is re-proven against this new path: five
request shapes are enumerated and each must have FORCED_TENANT_PREDICATE as
condition[0]; a blank tenant refuses to compile rather than emitting no
predicate; a hostile filter value appears only in params, never in sql.
45 tests, all failing-capable: every rejection assertion checks the SiaxError
code, and the DoD assertions compare reconstructed meaning rather than strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
555 lines
19 KiB
TypeScript
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',
|
|
},
|
|
],
|
|
];
|
|
}
|