152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
/**
|
|
* BI-P009's non-negotiable line: "Do not regress the existing forced tenant
|
|
* predicate."
|
|
*
|
|
* This suite is written to FAIL IF THE FORCING IS REMOVED, which is a
|
|
* different thing from testing that it currently works. Concretely, each test
|
|
* below would go red under a specific, plausible regression:
|
|
*
|
|
* * someone makes `tenant_id` optional in the metric DSL -> "refuses a
|
|
* blank tenant" fails;
|
|
* * someone "simplifies" the WHERE builder and drops the tenant condition
|
|
* -> "every compiled statement contains the predicate" fails on a missing
|
|
* substring;
|
|
* * someone starts splicing the tenant value into SQL text instead of
|
|
* binding it -> "the tenant VALUE never appears in the SQL text" fails;
|
|
* * someone lets a caller pass a tenant through the request body rather
|
|
* than the verified identity -> "identity wins over any request field"
|
|
* fails.
|
|
*
|
|
* It covers BOTH compilers: the PRESERVED `ClickHouseAnalytics.queryMetric`
|
|
* (src/analytics/clickhouse-analytics.ts, untouched by BI-P009) and the new
|
|
* `compileMetricQuery` — because "preserved" means the old guarantee has to
|
|
* keep holding, not merely that the file still exists.
|
|
*
|
|
* The ClickHouseAnalytics half asserts against the URL the adapter actually
|
|
* builds, captured with a stub `fetch`. That is deliberate: a mock ENGINE
|
|
* could be made to return anything, but the SQL text on the wire is the thing
|
|
* the real server would receive, so asserting on it cannot be faked by the
|
|
* test double.
|
|
*/
|
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import { ClickHouseAnalytics } from '@/src/analytics/clickhouse-analytics';
|
|
import {
|
|
compileMetricQuery,
|
|
compileProjectionQuery,
|
|
dimensionCatalogFrom,
|
|
FORCED_TENANT_PREDICATE,
|
|
} from '@/src/analytics/engine/query-compiler';
|
|
|
|
const catalog = dimensionCatalogFrom({ severity: 'severity', make: 'vehicle_make' });
|
|
|
|
/** Captures every URL the adapter fetches, returning an empty JSON result. */
|
|
function captureFetch(): { urls: string[]; restore: () => void } {
|
|
const urls: string[] = [];
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
urls.push(String(input));
|
|
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
}) as typeof fetch;
|
|
return { urls, restore: () => (globalThis.fetch = original) };
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('BI-P009 regression guard: the forced tenant predicate', () => {
|
|
it('PRESERVED ClickHouseAnalytics still emits tenant_id = {tenant:String} on the wire', async () => {
|
|
const cap = captureFetch();
|
|
try {
|
|
const analytics = new ClickHouseAnalytics({ baseUrl: 'http://ch.invalid:8123', database: 'opt_test' });
|
|
await analytics.queryMetric({ tenant_id: 't_alpha', metric: 'rows', aggregate: 'sum' });
|
|
expect(cap.urls).toHaveLength(1);
|
|
// URLSearchParams decodes `+` as a space, which raw decodeURIComponent
|
|
// does not — read the statement the way the server will.
|
|
const query = new URL(cap.urls[0]).searchParams.get('query') ?? '';
|
|
expect(query).toContain('tenant_id = {tenant:String}');
|
|
expect(query).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
|
// The VALUE travels as a bound parameter, never inside the statement.
|
|
expect(query).not.toContain('t_alpha');
|
|
expect(new URL(cap.urls[0]).searchParams.get('param_tenant')).toBe('t_alpha');
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
|
|
it('PRESERVED ClickHouseAnalytics still refuses a blank tenant_id', async () => {
|
|
const analytics = new ClickHouseAnalytics({ baseUrl: 'http://ch.invalid:8123' });
|
|
await expect(
|
|
analytics.queryMetric({ tenant_id: ' ', metric: 'x', aggregate: 'sum' }),
|
|
).rejects.toMatchObject({
|
|
code: 'INVALID_REQUEST',
|
|
});
|
|
});
|
|
|
|
it('PRESERVED ClickHouseAnalytics still refuses a hostile group_by identifier', async () => {
|
|
const analytics = new ClickHouseAnalytics({ baseUrl: 'http://ch.invalid:8123' });
|
|
await expect(
|
|
analytics.queryMetric({
|
|
tenant_id: 't_alpha',
|
|
metric: 'x',
|
|
aggregate: 'sum',
|
|
group_by: "sev'; DROP TABLE opt_metric_events; --",
|
|
}),
|
|
).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
});
|
|
|
|
it('every statement the NEW compiler produces contains the forced predicate', () => {
|
|
const shapes = [
|
|
{ metric: 'm', aggregate: 'sum' as const },
|
|
{ metric: 'm', aggregate: 'count' as const, group_by: ['severity'] },
|
|
{ metric: 'm', aggregate: 'avg' as const, filters: { make: 'volvo' }, dataset_id: 'ds_1' },
|
|
{ metric: 'm', aggregate: 'max' as const, from_time: '2026-01-01', to_time: '2026-02-01' },
|
|
{ metric: 'm', aggregate: 'count_distinct' as const, group_by: ['severity', 'make'], limit: 5 },
|
|
];
|
|
for (const shape of shapes) {
|
|
const compiled = compileMetricQuery({ tenant_id: 't_alpha', ...shape }, 'opt_analytics', catalog, 1000);
|
|
expect(compiled.sql).toContain(FORCED_TENANT_PREDICATE);
|
|
// The predicate must be in the WHERE, not in a comment or an alias.
|
|
expect(compiled.sql).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
|
expect(compiled.params.param_tenant).toBe('t_alpha');
|
|
expect(compiled.sql).not.toContain('t_alpha');
|
|
}
|
|
const projection = compileProjectionQuery(
|
|
{ tenant_id: 't_alpha', dataset_id: 'ds_1', entity_keys: ['a', 'b'] },
|
|
'opt_analytics',
|
|
1000,
|
|
);
|
|
expect(projection.sql).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
|
expect(projection.sql).not.toContain('t_alpha');
|
|
});
|
|
|
|
it('the NEW compiler fails closed on a blank/absent tenant instead of emitting an unfiltered query', () => {
|
|
for (const tenant of ['', ' ']) {
|
|
expect(() =>
|
|
compileMetricQuery(
|
|
{ tenant_id: tenant, metric: 'm', aggregate: 'sum' },
|
|
'opt_analytics',
|
|
catalog,
|
|
1000,
|
|
),
|
|
).toThrowError(/tenant_id is required/);
|
|
}
|
|
// The undefined case, which TypeScript would normally forbid, is what a
|
|
// JS caller or a loosely-typed HTTP boundary can actually produce.
|
|
expect(() =>
|
|
compileMetricQuery(
|
|
{ tenant_id: undefined as unknown as string, metric: 'm', aggregate: 'sum' },
|
|
'opt_analytics',
|
|
catalog,
|
|
1000,
|
|
),
|
|
).toThrowError(/tenant_id is required/);
|
|
});
|
|
|
|
it('the constant itself is the predicate, so deleting the forcing cannot pass silently', () => {
|
|
// If someone redefines FORCED_TENANT_PREDICATE to something harmless
|
|
// (e.g. "1 = 1") to make the substring assertions above pass, this fails.
|
|
expect(FORCED_TENANT_PREDICATE).toBe('tenant_id = {tenant:String}');
|
|
});
|
|
});
|