125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
/**
|
|
* BI-P009 DoD: "Provider contract tests added."
|
|
*
|
|
* This is the contract every `SiaxAnalyticsEngine` provider must satisfy
|
|
* regardless of engine — ClickHouse today, DuckDB (BI-P014) and Trino
|
|
* (BI-P015) later. It is written against the INTERFACE, so a future provider
|
|
* imports `analyticsEngineContract` and gets the same bar.
|
|
*
|
|
* The identity rules here are the ones that matter most: this estate has
|
|
* already shipped a live cross-tenant hole by reading the tenant out of a
|
|
* REQUEST BODY (INF0 Wave 2). So the contract asserts that a request field
|
|
* named `tenant_id` cannot influence the engine at all.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { ClickHouseAnalyticsEngine } from '@/src/analytics/engine/clickhouse-engine';
|
|
import {
|
|
SIAX_ANALYTICS_ENGINE_CAPABILITIES,
|
|
type AnalyticsIdentity,
|
|
type SiaxAnalyticsEngine,
|
|
} from '@/src/analytics/engine/siax-analytics-engine';
|
|
|
|
const IDENTITY: AnalyticsIdentity = {
|
|
tenant_id: 't_alpha',
|
|
project_id: 'p_default',
|
|
principal_id: 'usr_1',
|
|
};
|
|
|
|
/** Reusable contract suite — future providers call this with their factory. */
|
|
export function analyticsEngineContract(name: string, make: () => SiaxAnalyticsEngine): void {
|
|
describe(`SiaxAnalyticsEngine contract: ${name}`, () => {
|
|
it('implements all eight BI-P009 capabilities as callable methods', () => {
|
|
const engine = make() as unknown as Record<string, unknown>;
|
|
for (const cap of SIAX_ANALYTICS_ENGINE_CAPABILITIES) {
|
|
expect(typeof engine[cap], `${cap} is not a method`).toBe('function');
|
|
}
|
|
expect(SIAX_ANALYTICS_ENGINE_CAPABILITIES).toEqual([
|
|
'health',
|
|
'executeMetric',
|
|
'executeQuery',
|
|
'estimateQuery',
|
|
'cancelQuery',
|
|
'explain',
|
|
'ingestProjection',
|
|
'refreshProjection',
|
|
]);
|
|
});
|
|
|
|
it('names its provider', () => {
|
|
expect(make().provider.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('refuses every tenant-scoped capability without a verified identity', async () => {
|
|
const engine = make();
|
|
const blank = { tenant_id: '', project_id: 'p', principal_id: 'usr_1' };
|
|
const noPrincipal = { tenant_id: 't_alpha', project_id: 'p', principal_id: '' };
|
|
for (const bad of [blank, noPrincipal]) {
|
|
await expect(engine.executeMetric(bad, { metric: 'm', aggregate: 'sum' })).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
await expect(engine.estimateQuery(bad, { metric: 'm', aggregate: 'sum' })).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
await expect(engine.ingestProjection(bad, { dataset_id: 'ds', rows: [] })).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
await expect(engine.refreshProjection(bad, 'ds')).rejects.toMatchObject({ code: 'UNAUTHENTICATED' });
|
|
await expect(engine.cancelQuery(bad, 'q')).rejects.toMatchObject({ code: 'UNAUTHENTICATED' });
|
|
}
|
|
});
|
|
|
|
it('a tenant_id smuggled into the REQUEST cannot influence the engine (INF0 Wave-2 bug class)', async () => {
|
|
const engine = make();
|
|
const smuggled = { metric: 'm', aggregate: 'sum', tenant_id: 't_victim' } as never;
|
|
// The request type has no tenant field; if a provider ever started
|
|
// reading one, this call would target t_victim. The engine must still
|
|
// fail on the IDENTITY, proving the request field is inert.
|
|
await expect(
|
|
engine.executeMetric({ tenant_id: '', project_id: 'p', principal_id: 'u' }, smuggled),
|
|
).rejects.toMatchObject({ code: 'UNAUTHENTICATED' });
|
|
});
|
|
|
|
it('cancelQuery refuses a query id belonging to another tenant', async () => {
|
|
const engine = make();
|
|
await expect(engine.cancelQuery(IDENTITY, 'opt_deadbeefdead_someoneelses')).rejects.toMatchObject({
|
|
code: 'AUTHORIZATION_DENIED',
|
|
});
|
|
});
|
|
|
|
it('health() reports unhealthy rather than throwing when the engine is unreachable', async () => {
|
|
const report = await make().health();
|
|
expect(report.healthy).toBe(false);
|
|
expect(report.provider.length).toBeGreaterThan(0);
|
|
// Never leak an engine body or a DSN into the operator-facing detail.
|
|
expect(report.detail ?? '').not.toMatch(/http|password|@/i);
|
|
});
|
|
});
|
|
}
|
|
|
|
analyticsEngineContract(
|
|
'clickhouse (unreachable host — contract-level assertions only)',
|
|
() =>
|
|
new ClickHouseAnalyticsEngine({
|
|
baseUrl: 'http://clickhouse.invalid:8123',
|
|
database: 'opt_contract_test',
|
|
timeoutMs: 500,
|
|
}),
|
|
);
|
|
|
|
describe('BI-P009 refusals that are deliberate, not missing', () => {
|
|
it('executeQuery (analyst SQL) is declared and REFUSED until BI-P017/BI-P033', async () => {
|
|
const engine = new ClickHouseAnalyticsEngine({ baseUrl: 'http://clickhouse.invalid:8123' });
|
|
await expect(engine.executeQuery(IDENTITY, { sql: 'SELECT 1', dataset_ids: [] })).rejects.toMatchObject({
|
|
code: 'CAPABILITY_UNAVAILABLE',
|
|
});
|
|
});
|
|
|
|
it('the PRESERVED ClickHouseAnalytics is reachable through the engine, not replaced by it', () => {
|
|
const engine = new ClickHouseAnalyticsEngine({ baseUrl: 'http://clickhouse.invalid:8123' });
|
|
expect(engine.legacyAnalytics.constructor.name).toBe('ClickHouseAnalytics');
|
|
expect(typeof engine.legacyAnalytics.queryMetric).toBe('function');
|
|
expect(typeof engine.legacyAnalytics.ensureSchema).toBe('function');
|
|
expect(typeof engine.legacyAnalytics.ingest).toBe('function');
|
|
});
|
|
});
|