370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
/**
|
|
* BI-P002 DoD: "Database migrations + persistence + tenant isolation +
|
|
* restore tests."
|
|
*
|
|
* REAL Postgres, same honest-degrade convention as
|
|
* tests/p0/source-registry-postgres.realinfra.test.ts: gated on
|
|
* ANALYTICS_TEST_DATABASE_URL, SKIPS HONESTLY when unset, never fabricates.
|
|
*
|
|
* docker run -d --name opt-bi-pg -p 15455:5432 \
|
|
* -e POSTGRES_USER=opt -e POSTGRES_PASSWORD=opt_bi_throwaway \
|
|
* -e POSTGRES_DB=opt_bi_test postgres:16-alpine
|
|
* ANALYTICS_TEST_DATABASE_URL=postgres://opt:opt_bi_throwaway@localhost:15455/opt_bi_test \
|
|
* pnpm vitest run tests/analytics/analytics-domain-postgres.realinfra.test.ts
|
|
*
|
|
* The RESTORE half is the part that catches migration-chain rot: it replays
|
|
* sql/001..008 in order into a FRESH database, exactly as
|
|
* docs/runbook/backup-restore.md describes, and only then runs the
|
|
* persistence assertions. sql/003_datasets.sql exists because that same drill
|
|
* found a table no file ever created; this suite keeps that class of defect
|
|
* from coming back through sql/008.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import postgres from 'postgres';
|
|
import {
|
|
ANALYTICS_DOMAIN_ENTITIES,
|
|
ANALYTICS_ENTITY_TABLES,
|
|
IMMUTABLE_VERSION_ENTITIES,
|
|
} from '@/src/analytics/domain/entities';
|
|
import { AnalyticsDomainRepository } from '@/src/analytics/repositories/analytics-domain-repository';
|
|
import { contractHash, type DatasetContract } from '@/src/analytics/domain/contract';
|
|
|
|
const TEST_DATABASE_URL = process.env.ANALYTICS_TEST_DATABASE_URL;
|
|
const run = TEST_DATABASE_URL ? describe : describe.skip;
|
|
|
|
/** A schema of its own so the drill starts from genuinely nothing. */
|
|
const SCHEMA = `bi_p002_${Date.now().toString(36)}`;
|
|
|
|
let sql: ReturnType<typeof postgres> | undefined;
|
|
let reachable = false;
|
|
let unreachableReason = '';
|
|
/** Which sql/*.sql files the restore drill actually applied. */
|
|
const applied: string[] = [];
|
|
|
|
const ALPHA = { tenant_id: 't_alpha', project_id: 'p_main' };
|
|
const BETA = { tenant_id: 't_beta', project_id: 'p_main' };
|
|
|
|
function repo(): AnalyticsDomainRepository {
|
|
return new AnalyticsDomainRepository(sql!);
|
|
}
|
|
|
|
function contract(datasetId: string): DatasetContract {
|
|
return {
|
|
dataset_id: datasetId,
|
|
version: '1.0.0',
|
|
tier: 'CURATED',
|
|
fields: [{ name: 'id', type: 'string', nullable: false, role: 'identifier', primary_identifier: true }],
|
|
primary_identifiers: ['id'],
|
|
dimensions: [],
|
|
measures: [],
|
|
freshness_slo_seconds: 3600,
|
|
retention_days: 365,
|
|
pii_classification: 'none',
|
|
source_lineage: ['src_x'],
|
|
quality_rules: [],
|
|
allowed_purposes: ['analytics'],
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
if (!TEST_DATABASE_URL) {
|
|
console.warn(
|
|
'[realinfra] ANALYTICS_TEST_DATABASE_URL not set — skipping honestly, not fabricating success.',
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
sql = postgres(TEST_DATABASE_URL, { prepare: false, ssl: false, max: 3 });
|
|
await Promise.race([
|
|
sql`SELECT 1`,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('connect timeout')), 5000)),
|
|
]);
|
|
// RESTORE DRILL: a brand-new schema, then every migration in order.
|
|
await sql.unsafe(`CREATE SCHEMA ${SCHEMA}`);
|
|
await sql.unsafe(`SET search_path TO ${SCHEMA}, public`);
|
|
const dir = path.join(process.cwd(), 'sql');
|
|
for (const file of fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.endsWith('.sql'))
|
|
.sort()) {
|
|
const body = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
try {
|
|
await sql.unsafe(`SET search_path TO ${SCHEMA}, public; ${body}`);
|
|
applied.push(file);
|
|
} catch (err) {
|
|
// 001 needs the `vector` extension, which a stock postgres:16-alpine
|
|
// does not carry. Record the skip rather than hiding it — the files
|
|
// sql/008 actually depends on must still apply.
|
|
applied.push(`${file} (FAILED: ${String(err).slice(0, 120)})`);
|
|
}
|
|
}
|
|
reachable = true;
|
|
} catch (err) {
|
|
reachable = false;
|
|
unreachableReason = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (sql && reachable) {
|
|
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => undefined);
|
|
}
|
|
await sql?.end({ timeout: 5 });
|
|
});
|
|
|
|
run('BI-P002 analytics domain model against a LIVE Postgres', () => {
|
|
it('restore drill: sql/008 applies cleanly on top of the replayed chain', (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
expect(unreachableReason).toBe('');
|
|
const p008 = applied.find((f) => f.startsWith('008_'));
|
|
expect(p008, `sql/008 was not applied. Applied: ${applied.join(', ')}`).toBe(
|
|
'008_analytics_domain_model.sql',
|
|
);
|
|
});
|
|
|
|
it('all nineteen entity tables really exist after the restore', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const rows = await sql!<{ table_name: string }[]>`
|
|
SELECT table_name FROM information_schema.tables WHERE table_schema = ${SCHEMA}`;
|
|
const present = new Set(rows.map((r) => r.table_name));
|
|
const missing = ANALYTICS_DOMAIN_ENTITIES.filter((e) => !present.has(ANALYTICS_ENTITY_TABLES[e]));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it('replaying sql/008 a second time is a no-op, not an error', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const body = fs.readFileSync(path.join(process.cwd(), 'sql', '008_analytics_domain_model.sql'), 'utf8');
|
|
await expect(sql!.unsafe(`SET search_path TO ${SCHEMA}, public; ${body}`)).resolves.toBeDefined();
|
|
});
|
|
|
|
it('persists a dataset and reads it back through the repository', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
const created = await r.createDataset(ALPHA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
name: 'Alpha listings',
|
|
tier: 'CURATED',
|
|
schema_contract_id: 'dc_alpha',
|
|
schema_contract_version: '1.0.0',
|
|
owner_id: 'team_data',
|
|
source_ref: 'src_feed',
|
|
freshness_slo_seconds: 3600,
|
|
quality_rule_ids: [],
|
|
retention_days: 365,
|
|
classification: 'none',
|
|
status: 'active',
|
|
created_by: 'usr_1',
|
|
});
|
|
expect(created.tenant_id).toBe('t_alpha');
|
|
const read = await r.getDataset(ALPHA, 'ds_alpha_1');
|
|
expect(read?.name).toBe('Alpha listings');
|
|
expect(read?.freshness_slo_seconds).toBe(3600);
|
|
// Cross-check with direct SQL — proving the row is really in the table,
|
|
// not an artifact of the repository's own bookkeeping.
|
|
const direct = await sql!<{ c: string }[]>`
|
|
SELECT count(*)::text AS c FROM opt_analytics_dataset WHERE dataset_id = 'ds_alpha_1'`;
|
|
expect(Number(direct[0].c)).toBe(1);
|
|
});
|
|
|
|
it("TENANT ISOLATION: t_beta cannot read t_alpha's dataset, even with the exact id", async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
expect(await r.getDataset(BETA, 'ds_alpha_1')).toBeUndefined();
|
|
expect(await r.listDatasets(BETA)).toEqual([]);
|
|
// And the row IS there — the empty result is scoping, not absence.
|
|
const direct = await sql!<{ c: string }[]>`SELECT count(*)::text AS c FROM opt_analytics_dataset`;
|
|
expect(Number(direct[0].c)).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("TENANT ISOLATION: a version cannot be minted against another tenant's dataset", async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
await expect(
|
|
repo().createDatasetVersion(BETA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
contract_hash: 'deadbeef',
|
|
schema_contract_version: '1.0.0',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
});
|
|
|
|
it('dataset versions are monotonic and carry the contract hash', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
const c = contract('ds_alpha_1');
|
|
const v1 = await r.createDatasetVersion(ALPHA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
contract_hash: contractHash(c),
|
|
schema_contract_version: '1.0.0',
|
|
});
|
|
const v2 = await r.createDatasetVersion(ALPHA, {
|
|
dataset_id: 'ds_alpha_1',
|
|
contract_hash: contractHash({ ...c, version: '2.0.0' }),
|
|
schema_contract_version: '2.0.0',
|
|
});
|
|
expect(v1.version).toBe(1);
|
|
expect(v2.version).toBe(2);
|
|
expect(v2.contract_hash).not.toBe(v1.contract_hash);
|
|
expect((await r.listDatasetVersions(ALPHA, 'ds_alpha_1')).map((v) => v.version)).toEqual([1, 2]);
|
|
});
|
|
|
|
it('IMMUTABILITY is enforced by the DATABASE: UPDATE and DELETE on a version row are refused', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
// Direct SQL, bypassing every TypeScript `readonly` — the only test that
|
|
// proves the guarantee survives a psql session.
|
|
await expect(
|
|
sql!.unsafe(
|
|
`UPDATE ${SCHEMA}.opt_analytics_dataset_version SET contract_hash = 'tampered' WHERE dataset_id = 'ds_alpha_1'`,
|
|
),
|
|
).rejects.toThrow(/immutable/i);
|
|
await expect(
|
|
sql!.unsafe(`DELETE FROM ${SCHEMA}.opt_analytics_dataset_version WHERE dataset_id = 'ds_alpha_1'`),
|
|
).rejects.toThrow(/immutable/i);
|
|
// The rows are untouched.
|
|
const rows = await sql!<{ contract_hash: string }[]>`
|
|
SELECT contract_hash FROM opt_analytics_dataset_version WHERE dataset_id = 'ds_alpha_1'`;
|
|
expect(rows).toHaveLength(2);
|
|
for (const r of rows) expect(r.contract_hash).not.toBe('tampered');
|
|
});
|
|
|
|
it('all four immutable entities refuse an UPDATE, not just the one that was tested first', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await r.createDataset(ALPHA, {
|
|
dataset_id: 'ds_imm',
|
|
name: 'Immutability fixture',
|
|
tier: 'SERVING',
|
|
schema_contract_id: 'dc_i',
|
|
schema_contract_version: '1.0.0',
|
|
owner_id: 'team_data',
|
|
source_ref: 'src',
|
|
freshness_slo_seconds: 60,
|
|
quality_rule_ids: [],
|
|
retention_days: 30,
|
|
classification: 'none',
|
|
status: 'active',
|
|
});
|
|
const metric = await r.createMetric(ALPHA, {
|
|
metric_id: 'm_imm',
|
|
name: 'immutable metric',
|
|
dataset_id: 'ds_imm',
|
|
aggregation: 'sum',
|
|
measure_field: 'value',
|
|
allowed_dimension_ids: [],
|
|
owner_id: 'team_data',
|
|
});
|
|
await r.createMetricVersion(ALPHA, { metric_id: metric.metric_id, definition_json: '{"v":1}' });
|
|
await r.createDatasetVersion(ALPHA, {
|
|
dataset_id: 'ds_imm',
|
|
contract_hash: 'h1',
|
|
schema_contract_version: '1.0.0',
|
|
});
|
|
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_semantic_model
|
|
(semantic_model_id, tenant_id, project_id, name, version, model_json, model_hash, owner_id)
|
|
VALUES ('sm_1','t_alpha','p_main','model_a',1,'{}','h','team_data')`);
|
|
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dashboard_definition
|
|
(dashboard_id, tenant_id, project_id, name, owner_id) VALUES ('db_1','t_alpha','p_main','dash','team_data')`);
|
|
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dashboard_version
|
|
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
|
|
VALUES ('dv_1','db_1','t_alpha','p_main',1,'{}','h')`);
|
|
|
|
const updates: Record<string, string> = {
|
|
DatasetVersion: `UPDATE ${SCHEMA}.opt_analytics_dataset_version SET contract_hash='x' WHERE dataset_id='ds_imm'`,
|
|
MetricVersion: `UPDATE ${SCHEMA}.opt_analytics_metric_version SET definition_json='{}' WHERE metric_id='m_imm'`,
|
|
SemanticModel: `UPDATE ${SCHEMA}.opt_analytics_semantic_model SET model_json='{}' WHERE semantic_model_id='sm_1'`,
|
|
DashboardVersion: `UPDATE ${SCHEMA}.opt_analytics_dashboard_version SET layout_json='{}' WHERE dashboard_version_id='dv_1'`,
|
|
};
|
|
for (const entity of IMMUTABLE_VERSION_ENTITIES) {
|
|
await expect(sql!.unsafe(updates[entity]), `${entity} is NOT immutable`).rejects.toThrow(/immutable/i);
|
|
}
|
|
});
|
|
|
|
it('a metric version after the first must state WHY the number changed meaning', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await expect(
|
|
r.createMetricVersion(ALPHA, { metric_id: 'm_imm', definition_json: '{"v":2}' }),
|
|
).rejects.toThrow();
|
|
const v2 = await r.createMetricVersion(ALPHA, {
|
|
metric_id: 'm_imm',
|
|
definition_json: '{"v":2}',
|
|
change_reason: 'excluded internal test accounts from the numerator',
|
|
});
|
|
expect(v2.version).toBe(2);
|
|
});
|
|
|
|
it('a DatasetGrant without a purpose is refused by the DATABASE (purpose limitation)', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
// Regression pin: the first version of this constraint was written
|
|
// `array_length(allowed_purposes, 1) >= 1`, which is NULL for '{}' — and a
|
|
// CHECK evaluating to NULL PASSES in Postgres. It could never fail. This
|
|
// test found that; keep it.
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dataset_grant
|
|
(grant_id, dataset_id, tenant_id, project_id, subject_id, subject_type, level, allowed_purposes)
|
|
VALUES ('g_1','ds_imm','t_alpha','p_main','usr_1','user','read', '{}')`),
|
|
).rejects.toThrow(/purposes_nonempty/);
|
|
// A grant WITH a purpose is accepted, so the constraint is not simply
|
|
// rejecting everything.
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dataset_grant
|
|
(grant_id, dataset_id, tenant_id, project_id, subject_id, subject_type, level, allowed_purposes)
|
|
VALUES ('g_ok','ds_imm','t_alpha','p_main','usr_1','user','read', ARRAY['market_analytics'])`),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it('a BIApplicationBinding without a purpose is refused too, and carries no credential column', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_bi_application_binding
|
|
(binding_id, tenant_id, project_id, kind, name, principal_id, allowed_purposes)
|
|
VALUES ('b_1','t_alpha','p_main','superset','sup','svc_1','{}')`),
|
|
).rejects.toThrow(/purposes_nonempty/);
|
|
|
|
const cols = await sql!<{ column_name: string }[]>`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = ${SCHEMA} AND table_name = 'opt_analytics_bi_application_binding'`;
|
|
const names = cols.map((c) => c.column_name.toLowerCase());
|
|
for (const forbidden of ['password', 'secret', 'dsn', 'token', 'connection_string', 'clickhouse_url']) {
|
|
expect(names, `BI binding must never store ${forbidden}`).not.toContain(forbidden);
|
|
}
|
|
});
|
|
|
|
it('the dimension catalog is tenant-scoped and FAILS CLOSED for a tenant with none', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await r.createDimension(ALPHA, {
|
|
dimension_id: 'dim_1',
|
|
name: 'make',
|
|
dataset_id: 'ds_imm',
|
|
field_name: 'vehicle_make',
|
|
field_type: 'string',
|
|
classification: 'none',
|
|
});
|
|
expect(await r.dimensionCatalog(ALPHA)).toEqual({ make: 'vehicle_make' });
|
|
// t_beta declared nothing, so it may reference nothing.
|
|
expect(await r.dimensionCatalog(BETA)).toEqual({});
|
|
});
|
|
|
|
it('a dimension whose physical field is not identifier-safe is refused by the DATABASE', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
await expect(
|
|
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dimension_definition
|
|
(dimension_id, tenant_id, project_id, name, dataset_id, field_name, field_type)
|
|
VALUES ('dim_bad','t_alpha','p_main','evil','ds_imm', 'x''; DROP TABLE opt_analytics_dataset; --','string')`),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('the repository refuses to act without a verified tenant scope', async (ctx) => {
|
|
if (!reachable) ctx.skip();
|
|
const r = repo();
|
|
await expect(r.getDataset({ tenant_id: '', project_id: 'p' }, 'ds_imm')).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
await expect(r.listDatasets({ tenant_id: 't_alpha', project_id: ' ' })).rejects.toMatchObject({
|
|
code: 'UNAUTHENTICATED',
|
|
});
|
|
});
|
|
});
|