Files
0pt/tests/analytics/tenant-predicate-regression.test.ts
T

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}');
});
});