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
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>
347 lines
14 KiB
TypeScript
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);
|
|
});
|