Files
0pt/tests/analytics/dashboard-release-postgres.realinfra.test.ts
T
Claude Opus 5 1a12e8a882
CI (SIAX Cloud) / quality (push) Successful in 50s
Security Scan (SIAX Cloud) / security (push) Successful in 45s
CI (SIAX Cloud) / security (push) Successful in 1m0s
CI (SIAX Cloud) / integration (push) Successful in 1m12s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m6s
CI (SIAX Cloud) / security (pull_request) Successful in 33s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / integration (pull_request) Successful in 47s
feat(analytics): BI-P061 — dashboard releases, with the publish race measured both ways
DRAFT -> PUBLISHED -> SUPERSEDED as a ledger (sql/010) rather than a column on
the frozen version: sql/008 already makes DashboardVersion immutable at the
database, and a lifecycle column there would have meant weakening that.

Rollback mints a NEW version carrying the old document and publishes it, so
"we went back to v3" is a timestamped fact (v5 = rollback_of_version 3) rather
than a hole where v4 used to be. It is verified by BI-P060 canonical hash, and
documentOf() refuses to serve a stored document whose hash no longer matches
the release — a dashboard that renders an unapproved definition is worse than
one that will not open.

The claim worth attacking is "only one release can be live". The application
check is read-then-write, so it is NOT the guarantee, and the evidence says so
in both directions against Postgres 16.15:

  guard_absent   partial unique index dropped -> BOTH concurrent publishes
                 commit, dashboard ends with TWO live versions
  guard_present  index in place -> loser fails 23505 on
                 opt_analytics_dashboard_release_one_published, one live

evidence/bi-p061/dashboard-release-race.json, reproducible with
scripts/bi-p061-release-race.mjs.

Two findings while writing the tests, both fixed here rather than papered over:
the one-draft-per-dashboard index makes the two-draft race impossible to even
construct (so the race is run as two create-and-publish transactions, which is
what a "save and publish" button does), and an UPDATE matching zero rows
raises nothing — which reads as "refused" to a careless test, so the
immutability test asserts the row exists first.

23 tests: 8 unit + 15 against live Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 03:51:04 +02:00

347 lines
14 KiB
TypeScript

/**
* tests/analytics/dashboard-release-postgres.realinfra.test.ts
* =============================================================
* sql/010 (BI-P061) against a REAL Postgres.
*
* The interesting claim in sql/010 is not "there is a state machine" — it is
* "TWO PEOPLE CANNOT PUBLISH AT ONCE". That claim lives in a partial unique
* index, and an index nobody raced is an index nobody has tested. So the
* concurrency test runs BOTH directions, in two separate schemas so neither
* can contaminate the other:
*
* 1. WITHOUT the index (a schema where it is dropped): two sessions publish
* a release of the same dashboard concurrently, each having first read
* "nothing is live" — the exact interleaving an application-level check
* cannot see. The database ends up with TWO published releases. This is
* the counterfactual: it proves the read-then-write check in
* DashboardReleaseService.publish() is NOT the guarantee, and that this
* test can detect the failure it claims to prevent.
* 2. WITH the index: the same race, and the loser fails with
* unique_violation (23505). Exactly one published release remains.
*
* The transition trigger is attacked the same way: every illegal transition is
* attempted against the database, not against the TypeScript.
*
* Gated on ANALYTICS_TEST_DATABASE_URL, matching the convention in
* analytics-governance-postgres.realinfra.test.ts. With it set and Postgres
* down this FAILS rather than skipping.
*
* docker run -d --name pg -p 25441:5432 -e POSTGRES_PASSWORD=test \
* -e POSTGRES_DB=opt postgres:16-alpine
* ANALYTICS_TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:25441/opt \
* pnpm vitest run tests/analytics/dashboard-release-postgres.realinfra.test.ts
*/
import fs from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import postgres from 'postgres';
const TEST_DATABASE_URL = process.env.ANALYTICS_TEST_DATABASE_URL;
const run = TEST_DATABASE_URL ? describe : describe.skip;
const STAMP = Date.now().toString(36);
const SCHEMA = `bi_p061_${STAMP}`;
/** The counterfactual lives in its own schema: no cleanup, no contamination. */
const SCHEMA_NOGUARD = `bi_p061_noguard_${STAMP}`;
const HASH_A = 'a'.repeat(64);
const HASH_B = 'b'.repeat(64);
const MIGRATIONS = ['sql/008_analytics_domain_model.sql', 'sql/010_analytics_dashboard_releases.sql'];
let sql: ReturnType<typeof postgres>;
function connect(schema: string, max = 1): ReturnType<typeof postgres> {
return postgres(TEST_DATABASE_URL!, {
max,
onnotice: () => undefined,
connection: { search_path: schema },
});
}
async function refusalOf(fn: () => Promise<unknown>): Promise<string | undefined> {
try {
await fn();
return undefined;
} catch (e) {
return String((e as Error).message);
}
}
async function migrate(conn: ReturnType<typeof postgres>): Promise<void> {
for (const file of MIGRATIONS) {
await conn.unsafe(fs.readFileSync(path.join(process.cwd(), file), 'utf8'));
}
}
async function seedDashboard(conn: ReturnType<typeof postgres>, id: string): Promise<void> {
await conn.unsafe(`
INSERT INTO opt_analytics_dashboard_definition
(dashboard_id, tenant_id, project_id, name, current_version, owner_id)
VALUES ('${id}', 't_alpha', 'p_main', '${id}', 1, 'u_1')
ON CONFLICT DO NOTHING`);
}
async function seedVersion(
conn: ReturnType<typeof postgres>,
versionId: string,
dashboardId: string,
version: number,
hash = HASH_A,
): Promise<void> {
await conn.unsafe(`
INSERT INTO opt_analytics_dashboard_version
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
VALUES ('${versionId}', '${dashboardId}', 't_alpha', 'p_main', ${version}, '{}', '${hash}')`);
}
async function seedDraftRelease(
conn: ReturnType<typeof postgres>,
releaseId: string,
versionId: string,
dashboardId: string,
version: number,
hash = HASH_A,
): Promise<void> {
await conn.unsafe(`
INSERT INTO opt_analytics_dashboard_release
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state, document_hash)
VALUES ('${releaseId}', '${dashboardId}', '${versionId}', 't_alpha', 'p_main', ${version}, 'draft', '${hash}')`);
}
async function publishedCount(conn: ReturnType<typeof postgres>, dashboardId: string): Promise<number> {
const rows = (await conn.unsafe(
`SELECT count(*)::int AS n FROM opt_analytics_dashboard_release
WHERE dashboard_id = '${dashboardId}' AND state = 'published'`,
)) as unknown as Array<{ n: number }>;
return rows[0].n;
}
/**
* Two sessions publish a release of the SAME dashboard at the same time, each
* having first read "nothing is live". This is the create-and-publish path:
* a new version is minted and released in one transaction, which is what a
* "save and publish" button does.
*/
async function racePublish(schema: string, dashboardId: string): Promise<{ ok: number; codes: string[] }> {
const a = connect(schema);
const b = connect(schema);
const publish = (conn: ReturnType<typeof postgres>, n: number) =>
conn.begin(async (tx) => {
await tx.unsafe(
`SELECT release_id FROM opt_analytics_dashboard_release
WHERE dashboard_id = '${dashboardId}' AND state = 'published'`,
);
// A tiny pause so both transactions have certainly done their read
// before either writes. Without it the race may not interleave and the
// counterfactual could pass for the wrong reason.
await new Promise((r) => setTimeout(r, 60));
await tx.unsafe(`
INSERT INTO opt_analytics_dashboard_release
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state,
document_hash, published_at)
VALUES ('rel_race_${n}', '${dashboardId}', 'dv_race_${n}', 't_alpha', 'p_main', ${400 + n},
'published', '${n === 1 ? HASH_A : HASH_B}', now())`);
});
const results = await Promise.allSettled([publish(a, 1), publish(b, 2)]);
await a.end({ timeout: 5 });
await b.end({ timeout: 5 });
return {
ok: results.filter((r) => r.status === 'fulfilled').length,
codes: results.flatMap((r) =>
r.status === 'rejected' ? [String((r.reason as { code?: string })?.code ?? r.reason)] : [],
),
};
}
beforeAll(async () => {
if (!TEST_DATABASE_URL) return;
const admin = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => undefined });
await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA}`);
await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NOGUARD}`);
await admin.end({ timeout: 5 });
sql = connect(SCHEMA, 3);
await migrate(sql);
for (const d of ['dash_txn', 'dash_race', 'dash_draft']) await seedDashboard(sql, d);
}, 120_000);
afterAll(async () => {
if (!TEST_DATABASE_URL || !sql) return;
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA_NOGUARD} CASCADE`);
await sql.end({ timeout: 5 });
});
run('sql/010 applies and replays', () => {
it('is idempotent — a replay onto an already-migrated schema succeeds', async () => {
const text = fs.readFileSync(
path.join(process.cwd(), 'sql/010_analytics_dashboard_releases.sql'),
'utf8',
);
await expect(sql.unsafe(text)).resolves.toBeDefined();
});
it('has no column that could hold a document body — the document lives in the version row', async () => {
const rows = (await sql.unsafe(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = '${SCHEMA}' AND table_name = 'opt_analytics_dashboard_release'`,
)) as unknown as Array<{ column_name: string }>;
const names = new Set(rows.map((r) => r.column_name));
for (const forbidden of ['layout_json', 'document', 'definition', 'payload', 'sql']) {
expect(names.has(forbidden), forbidden).toBe(false);
}
expect(names.has('document_hash')).toBe(true);
});
});
run('BI-P061 transitions are enforced by the database', () => {
beforeAll(async () => {
await seedVersion(sql, 'dv_t1', 'dash_txn', 101);
await seedVersion(sql, 'dv_t3', 'dash_txn', 103, HASH_B);
await seedDraftRelease(sql, 'rel_t1', 'dv_t1', 'dash_txn', 101);
});
it('draft -> published is allowed', async () => {
await sql.unsafe(
`UPDATE opt_analytics_dashboard_release SET state='published', published_at=now() WHERE release_id='rel_t1'`,
);
const rows = (await sql.unsafe(
`SELECT state FROM opt_analytics_dashboard_release WHERE release_id='rel_t1'`,
)) as unknown as Array<{ state: string }>;
expect(rows[0].state).toBe('published');
});
it('published -> draft (un-publishing) is REFUSED', async () => {
const msg = await refusalOf(() =>
sql.unsafe(
`UPDATE opt_analytics_dashboard_release SET state='draft', published_at=NULL WHERE release_id='rel_t1'`,
),
);
expect(msg).toMatch(/illegal dashboard release transition published -> draft/);
});
it('repointing a release at a different version is REFUSED', async () => {
const msg = await refusalOf(() =>
sql.unsafe(
`UPDATE opt_analytics_dashboard_release SET dashboard_version_id='dv_t3' WHERE release_id='rel_t1'`,
),
);
expect(msg).toMatch(/immutable apart from its state/);
});
it('rewriting document_hash on a release is REFUSED', async () => {
const msg = await refusalOf(() =>
sql.unsafe(
`UPDATE opt_analytics_dashboard_release SET document_hash='${HASH_B}' WHERE release_id='rel_t1'`,
),
);
expect(msg).toMatch(/immutable apart from its state/);
});
it('rewriting published_at is REFUSED — a published timestamp is history', async () => {
const msg = await refusalOf(() =>
sql.unsafe(
`UPDATE opt_analytics_dashboard_release SET published_at = now() - interval '10 days'
WHERE release_id='rel_t1'`,
),
);
expect(msg).toMatch(/published_at is history/);
});
it('published -> superseded is allowed, and superseded -> published is REFUSED', async () => {
await sql.unsafe(
`UPDATE opt_analytics_dashboard_release SET state='superseded', superseded_at=now() WHERE release_id='rel_t1'`,
);
const msg = await refusalOf(() =>
sql.unsafe(`UPDATE opt_analytics_dashboard_release SET state='published' WHERE release_id='rel_t1'`),
);
expect(msg).toMatch(/illegal dashboard release transition superseded -> published/);
});
it('deleting a release is REFUSED — the ledger has no eraser', async () => {
const msg = await refusalOf(() =>
sql.unsafe(`DELETE FROM opt_analytics_dashboard_release WHERE release_id='rel_t1'`),
);
expect(msg).toMatch(/ledger; delete is refused/);
});
it('a published release with no published_at is REFUSED by the CHECK', async () => {
const msg = await refusalOf(() =>
sql.unsafe(`
INSERT INTO opt_analytics_dashboard_release
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state, document_hash)
VALUES ('rel_bad', 'dash_txn', 'dv_t3', 't_alpha', 'p_main', 900, 'published', '${HASH_A}')`),
);
expect(msg).toMatch(/opt_analytics_dashboard_release_timestamps/);
});
it('a document_hash that is not a sha256 digest is REFUSED', async () => {
const msg = await refusalOf(() =>
sql.unsafe(`
INSERT INTO opt_analytics_dashboard_release
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state, document_hash)
VALUES ('rel_bad2', 'dash_txn', 'dv_t3', 't_alpha', 'p_main', 901, 'draft', 'not-a-hash')`),
);
expect(msg).toMatch(/document_hash/);
});
it('the sql/008 immutability trigger still forbids editing the frozen document', async () => {
// Assert the row exists FIRST: an UPDATE that matches nothing raises
// nothing, which would read as "refused" to a careless test.
const before = (await sql.unsafe(
`SELECT count(*)::int AS n FROM opt_analytics_dashboard_version WHERE dashboard_version_id='dv_t3'`,
)) as unknown as Array<{ n: number }>;
expect(before[0].n).toBe(1);
const msg = await refusalOf(() =>
sql.unsafe(
`UPDATE opt_analytics_dashboard_version SET layout_json='{"tampered":true}'
WHERE dashboard_version_id='dv_t3'`,
),
);
expect(msg).toMatch(/rows are immutable/);
});
it('a second open draft for one dashboard is REFUSED', async () => {
await seedVersion(sql, 'dv_d1', 'dash_draft', 301);
await seedVersion(sql, 'dv_d2', 'dash_draft', 302);
await seedDraftRelease(sql, 'rel_d1', 'dv_d1', 'dash_draft', 301);
const msg = await refusalOf(() => seedDraftRelease(sql, 'rel_d2', 'dv_d2', 'dash_draft', 302));
expect(msg).toMatch(/one_draft/);
});
});
run('BI-P061 concurrency: the partial unique index is the guarantee', () => {
let noguard: ReturnType<typeof postgres>;
beforeAll(async () => {
noguard = connect(SCHEMA_NOGUARD, 3);
await migrate(noguard);
await noguard.unsafe(`DROP INDEX opt_analytics_dashboard_release_one_published`);
await seedDashboard(noguard, 'dash_race');
await seedVersion(noguard, 'dv_race_1', 'dash_race', 401);
await seedVersion(noguard, 'dv_race_2', 'dash_race', 402, HASH_B);
await seedVersion(sql, 'dv_race_1', 'dash_race', 401);
await seedVersion(sql, 'dv_race_2', 'dash_race', 402, HASH_B);
}, 60_000);
afterAll(async () => {
if (noguard) await noguard.end({ timeout: 5 });
});
it('WITHOUT the index, two concurrent publishes BOTH succeed — two live dashboards', async () => {
const race = await racePublish(SCHEMA_NOGUARD, 'dash_race');
expect(race.ok).toBe(2);
expect(await publishedCount(noguard, 'dash_race')).toBe(2);
}, 30_000);
it('WITH the index, the same race leaves exactly one published release', async () => {
expect(await publishedCount(sql, 'dash_race')).toBe(0);
const race = await racePublish(SCHEMA, 'dash_race');
expect(race.ok).toBe(1);
expect(race.codes.join(' ')).toContain('23505');
expect(await publishedCount(sql, 'dash_race')).toBe(1);
}, 30_000);
});