Files
0pt/tests/analytics/dashboard-release.test.ts
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

213 lines
8.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tests/analytics/dashboard-release.test.ts
* ==========================================
* BI-P061 — DASHBOARD VERSIONING. DoD: "rollback to previous dashboard
* definition".
*
* A rollback test that only asserts "the service returned version 4" proves
* nothing: the interesting failure is a rollback that publishes a DIFFERENT
* document while reporting success. So every rollback assertion here compares
* BI-P060 canonical hashes and re-plans the restored dashboard, and one test
* corrupts the stored document underneath a release to show that serving it
* is refused rather than silently rendered.
*/
import { describe, expect, it } from 'vitest';
import { SiaxError } from '@/src/platform/errors';
import {
dashboardDocumentHash,
parseDashboardDocument,
planDashboard,
} from '@/src/analytics/dashboards/dashboard-document';
import {
DashboardReleaseService,
InMemoryDashboardReleaseStore,
isLegalTransition,
DASHBOARD_RELEASE_STATES,
type DashboardReleaseState,
} from '@/src/analytics/dashboards/dashboard-release';
const SCOPE = { tenant_id: 't_alpha', project_id: 'p_main' };
function doc(title: string, metric = 'revenue') {
return {
id: 'dash_sales',
title,
version: 1,
layout: { grid_columns: 12, row_height_px: 40 },
parameters: [],
filters: [],
widgets: [
{
widget_id: 'w_1',
title,
source: { kind: 'metric', dataset: 'ds_sales', metric },
visualization: { type: 'big_number', encoding: { value: metric } },
layout: { x: 0, y: 0, w: 4, h: 2 },
},
],
refresh: { mode: 'manual', max_staleness_seconds: 3600 },
permissions: { visibility: 'tenant' },
};
}
function service() {
let n = 0;
let t = Date.parse('2026-09-07T10:00:00.000Z');
return new DashboardReleaseService(
new InMemoryDashboardReleaseStore(),
() => new Date((t += 1000)),
() => `id_${++n}`,
);
}
describe('BI-P061 release lifecycle', () => {
it('the transition table is exactly DRAFT -> PUBLISHED -> SUPERSEDED', () => {
const legal: Array<[DashboardReleaseState, DashboardReleaseState]> = [
['draft', 'published'],
['published', 'superseded'],
];
for (const from of DASHBOARD_RELEASE_STATES) {
for (const to of DASHBOARD_RELEASE_STATES) {
const expected = legal.some(([f, t]) => f === from && t === to);
expect(isLegalTransition(from, to), `${from} -> ${to}`).toBe(expected);
}
}
// Named explicitly because each is a real request someone will make.
expect(isLegalTransition('published', 'draft')).toBe(false);
expect(isLegalTransition('superseded', 'published')).toBe(false);
expect(isLegalTransition('draft', 'superseded')).toBe(false);
});
it('publishing supersedes the previous release, and only one is ever live', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
const v2 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v2') });
const result = await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
expect(result.published.version).toBe(2);
expect(result.superseded?.version).toBe(1);
expect(result.superseded?.superseded_by_release_id).toBe(v2.release.release_id);
const history = await svc.history(SCOPE, 'dash_sales');
expect(history.map((r) => `${r.version}:${r.state}`)).toEqual(['1:superseded', '2:published']);
expect((await svc.live(SCOPE, 'dash_sales'))?.version).toBe(2);
});
it('a second open draft is refused rather than racing the first', async () => {
const svc = service();
await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await expect(
svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1-again') }),
).rejects.toThrow(/already has an open draft/);
});
it('a superseded release cannot be re-published', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
const v2 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v2') });
await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
await expect(svc.publish({ scope: SCOPE, releaseId: v1.release.release_id })).rejects.toThrow(
/is superseded, not draft/,
);
});
it('another tenant cannot see, publish or roll back this tenant’s releases', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
const other = { tenant_id: 't_beta', project_id: 'p_main' };
await expect(svc.publish({ scope: other, releaseId: v1.release.release_id })).rejects.toThrow(
/does not exist in this tenant/,
);
expect(await svc.live(other, 'dash_sales')).toBeUndefined();
await expect(svc.rollback({ scope: other, dashboardId: 'dash_sales', toVersion: 1 })).rejects.toThrow(
/no version 1 in this tenant/,
);
});
});
describe('BI-P061 rollback — the DoD', () => {
it('restores the earlier definition byte-for-byte, as a NEW version', async () => {
const svc = service();
const original = doc('Sales v1', 'revenue');
const originalHash = dashboardDocumentHash(parseDashboardDocument(original));
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: original });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
const v2 = await svc.draft({
scope: SCOPE,
dashboardId: 'dash_sales',
document: doc('Sales v2 (broken)', 'revenue_net'),
});
await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
expect((await svc.live(SCOPE, 'dash_sales'))?.document_hash).not.toBe(originalHash);
const rolled = await svc.rollback({ scope: SCOPE, dashboardId: 'dash_sales', toVersion: 1 });
// Faithful: same canonical document.
expect(rolled.document_hash).toBe(originalHash);
// New version, not a resurrected row — the history stays linear.
expect(rolled.release.version).toBe(3);
expect(rolled.release.rollback_of_version).toBe(1);
const history = await svc.history(SCOPE, 'dash_sales');
expect(history.map((r) => `${r.version}:${r.state}`)).toEqual([
'1:superseded',
'2:superseded',
'3:published',
]);
// And the restored dashboard actually renders as the original did.
const live = await svc.live(SCOPE, 'dash_sales');
const restored = await svc.documentOf(SCOPE, live!);
expect(planDashboard(restored)).toEqual(planDashboard(parseDashboardDocument(original)));
});
it('rolling back to the live version is refused as a no-op', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
await expect(svc.rollback({ scope: SCOPE, dashboardId: 'dash_sales', toVersion: 1 })).rejects.toThrow(
/already live/,
);
});
it('a stored document that no longer matches its released hash is refused, not rendered', async () => {
const store = new InMemoryDashboardReleaseStore();
const svc = new DashboardReleaseService(
store,
() => new Date(),
() => crypto.randomUUID(),
);
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
const { published } = await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
// Tamper with storage the way a bad migration or a manual UPDATE would.
const stored = await store.getVersion(SCOPE, published.dashboard_version_id);
const tampered = JSON.parse(stored!.layout_json);
tampered.widgets[0].source.metric = 'revenue_including_vat';
await store.insertVersionAndRelease(
{ ...stored!, dashboard_version_id: 'tampered', version: 99, layout_json: JSON.stringify(tampered) },
{
...published,
release_id: 'r_tampered',
dashboard_version_id: 'tampered',
version: 99,
state: 'draft',
published_at: undefined,
},
);
// documentOf must compare hashes, not trust the row.
await expect(svc.documentOf(SCOPE, { ...published, dashboard_version_id: 'tampered' })).rejects.toThrow(
/refusing to serve an unapproved definition/,
);
await expect(svc.documentOf(SCOPE, { ...published, dashboard_version_id: 'tampered' })).rejects.toThrow(
SiaxError,
);
});
});