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>
213 lines
8.6 KiB
TypeScript
213 lines
8.6 KiB
TypeScript
/**
|
||
* 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,
|
||
);
|
||
});
|
||
});
|