diff --git a/evidence/bi-p061/dashboard-release-race.json b/evidence/bi-p061/dashboard-release-race.json new file mode 100644 index 0000000..99e2f25 --- /dev/null +++ b/evidence/bi-p061/dashboard-release-race.json @@ -0,0 +1,42 @@ +{ + "recorded_at": "2026-09-07T01:50:09.216Z", + "postgres_version": "16.15", + "guard_present": { + "partial_unique_index_present": true, + "transactions_committed": 1, + "rejections": [ + { + "code": "23505", + "constraint": "opt_analytics_dashboard_release_one_published" + } + ], + "published_releases_after": [ + { + "release_id": "rel_1", + "version": 1 + } + ] + }, + "guard_absent": { + "partial_unique_index_present": false, + "transactions_committed": 2, + "rejections": [], + "published_releases_after": [ + { + "release_id": "rel_1", + "version": 1 + }, + { + "release_id": "rel_2", + "version": 2 + } + ] + }, + "verdict": { + "counterfactual_produced_two_live_dashboards": true, + "guard_left_exactly_one": true, + "loser_error_code": "23505", + "application_check_alone_is_sufficient": false, + "note": "DashboardReleaseService.publish() reads for a live release before writing. Both sessions in guard_absent passed that check and both committed. The partial unique index is what holds." + } +} diff --git a/scripts/bi-p061-release-race.mjs b/scripts/bi-p061-release-race.mjs new file mode 100644 index 0000000..5e3fa73 --- /dev/null +++ b/scripts/bi-p061-release-race.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * BI-P061: is "only one published release per dashboard" actually enforced? + * + * The application checks for a live release before publishing, and that check + * is a read-then-write race. This script measures BOTH directions against a + * real Postgres and writes evidence/bi-p061/dashboard-release-race.json: + * + * guard_absent the partial unique index dropped -> both concurrent + * publishes commit, and the dashboard has TWO live versions. + * guard_present the index in place -> the loser fails with 23505. + * + * Without the first run, the second proves nothing: a race that never + * interleaved would also show one published release. + * + * ANALYTICS_TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:25441/opt \ + * node scripts/bi-p061-release-race.mjs + * + * It CREATEs and DROPs its own throwaway schemas and touches nothing else. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import postgres from 'postgres'; + +const URL_ = process.env.ANALYTICS_TEST_DATABASE_URL; +if (!URL_) { + console.error('ANALYTICS_TEST_DATABASE_URL is required. Refusing to invent numbers.'); + process.exit(2); +} + +const STAMP = Date.now().toString(36); +const SCHEMAS = { guard_present: `bi_p061_ev_on_${STAMP}`, guard_absent: `bi_p061_ev_off_${STAMP}` }; +const MIGRATIONS = ['sql/008_analytics_domain_model.sql', 'sql/010_analytics_dashboard_releases.sql']; +const HASH = (c) => c.repeat(64); + +const connect = (schema, max = 1) => + postgres(URL_, { max, onnotice: () => undefined, connection: { search_path: schema } }); + +async function setup(schema, { dropIndex }) { + const admin = postgres(URL_, { max: 1, onnotice: () => undefined }); + await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${schema}`); + await admin.end({ timeout: 5 }); + + const c = connect(schema, 2); + for (const f of MIGRATIONS) await c.unsafe(fs.readFileSync(path.join(process.cwd(), f), 'utf8')); + if (dropIndex) await c.unsafe('DROP INDEX opt_analytics_dashboard_release_one_published'); + await c.unsafe(` + INSERT INTO opt_analytics_dashboard_definition + (dashboard_id, tenant_id, project_id, name, current_version, owner_id) + VALUES ('dash_race', 't_alpha', 'p_main', 'Race', 1, 'u_1'); + INSERT INTO opt_analytics_dashboard_version + (dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash) + VALUES ('dv_1', 'dash_race', 't_alpha', 'p_main', 1, '{}', '${HASH('a')}'), + ('dv_2', 'dash_race', 't_alpha', 'p_main', 2, '{}', '${HASH('b')}');`); + return c; +} + +/** Both sessions read "nothing is live", then both write. */ +async function race(schema) { + const conns = [connect(schema), connect(schema)]; + const publish = (conn, n) => + conn.begin(async (tx) => { + await tx.unsafe( + `SELECT release_id FROM opt_analytics_dashboard_release + WHERE dashboard_id='dash_race' AND state='published'`, + ); + 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_${n}', 'dash_race', 'dv_${n}', 't_alpha', 'p_main', ${n}, + 'published', '${HASH(n === 1 ? 'a' : 'b')}', now())`); + }); + const results = await Promise.allSettled([publish(conns[0], 1), publish(conns[1], 2)]); + for (const c of conns) await c.end({ timeout: 5 }); + return { + committed: results.filter((r) => r.status === 'fulfilled').length, + rejected: results + .filter((r) => r.status === 'rejected') + .map((r) => ({ code: r.reason?.code ?? null, constraint: r.reason?.constraint_name ?? null })), + }; +} + +async function publishedRows(conn) { + const rows = await conn.unsafe( + `SELECT release_id, version FROM opt_analytics_dashboard_release + WHERE dashboard_id='dash_race' AND state='published' ORDER BY version`, + ); + return rows.map((r) => ({ release_id: r.release_id, version: r.version })); +} + +const out = { recorded_at: new Date().toISOString() }; +const cleanup = []; +try { + for (const [label, schema] of Object.entries(SCHEMAS)) { + const conn = await setup(schema, { dropIndex: label === 'guard_absent' }); + cleanup.push([schema, conn]); + if (!out.postgres_version) { + const v = await conn.unsafe('SHOW server_version'); + out.postgres_version = v[0].server_version; + } + const result = await race(schema); + out[label] = { + partial_unique_index_present: label === 'guard_present', + transactions_committed: result.committed, + rejections: result.rejected, + published_releases_after: await publishedRows(conn), + }; + } + + out.verdict = { + counterfactual_produced_two_live_dashboards: out.guard_absent.published_releases_after.length === 2, + guard_left_exactly_one: out.guard_present.published_releases_after.length === 1, + loser_error_code: out.guard_present.rejections[0]?.code ?? null, + application_check_alone_is_sufficient: false, + note: + 'DashboardReleaseService.publish() reads for a live release before writing. Both sessions in ' + + 'guard_absent passed that check and both committed. The partial unique index is what holds.', + }; + + const dir = path.join(process.cwd(), 'evidence/bi-p061'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'dashboard-release-race.json'); + fs.writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`); + console.log(`wrote ${file}`); + console.log(JSON.stringify(out.verdict, null, 2)); +} finally { + for (const [schema, conn] of cleanup) { + await conn.unsafe(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + await conn.end({ timeout: 5 }); + } +} diff --git a/sql/010_analytics_dashboard_releases.sql b/sql/010_analytics_dashboard_releases.sql new file mode 100644 index 0000000..171a7a2 --- /dev/null +++ b/sql/010_analytics_dashboard_releases.sql @@ -0,0 +1,129 @@ +-- 0pt analytics: dashboard releases (BI-P061 — DASHBOARD VERSIONING). +-- +-- Extends sql/008. Additive and idempotent (CREATE TABLE IF NOT EXISTS, +-- CREATE OR REPLACE FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER), +-- so it replays onto an already-migrated database like 009 does. +-- +-- Ordering: requires sql/008 (it references opt_analytics_dashboard_version). +-- +-- WHY A SEPARATE TABLE RATHER THAN A `state` COLUMN ON THE VERSION. +-- sql/008 puts a BEFORE UPDATE OR DELETE trigger on +-- opt_analytics_dashboard_version, because BI-P002 lists DashboardVersion as +-- immutable. A lifecycle column on that row would need UPDATEs, so adding one +-- would mean weakening the immutability everything else depends on. Instead +-- the frozen DOCUMENT stays immutable in the version table, and the RELEASE — +-- the thing that legitimately moves DRAFT -> PUBLISHED -> SUPERSEDED — lives +-- here with its own, much narrower, mutation rule: +-- +-- * `state` may only advance draft -> published -> superseded. Any other +-- transition RAISEs, including published -> draft ("un-publishing" is how +-- a rollback gets faked without leaving a trace) and superseded -> *. +-- * Every content column is frozen on UPDATE. A release may change state and +-- nothing else; repointing a published release at a different version is +-- the exact edit that would make "immutable release" a lie. +-- * DELETE is refused outright. Release history is a ledger; a rollback is +-- an entry in it, never the removal of one. +-- +-- THE INVARIANT THAT NEEDS THE DATABASE: at most ONE published release per +-- dashboard. An application check reads, decides, then writes, and two +-- requests that interleave between the read and the write both decide "yes". +-- The partial unique index below turns that race into a constraint violation +-- for the loser. tests/analytics/dashboard-release-postgres.realinfra.test.ts +-- runs the race with the index DROPPED first and shows two published releases +-- for one dashboard, so the index is known to be what prevents it. + +-- ── the release ledger ──────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_release ( + release_id TEXT PRIMARY KEY, + dashboard_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_definition (dashboard_id), + dashboard_version_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_version (dashboard_version_id), + tenant_id TEXT NOT NULL, + project_id TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version >= 1), + state TEXT NOT NULL CHECK (state IN ('draft', 'published', 'superseded')), + -- The BI-P060 canonical hash of the released document. Copied here so a + -- rollback can be VERIFIED (the new release must carry the hash of the + -- version it restores) without re-reading and re-hashing the document. + document_hash TEXT NOT NULL CHECK (document_hash ~ '^[0-9a-f]{64}$'), + -- Set when this release restores an earlier version. It is a NEW release of + -- a NEW version, never a resurrected old row -- that is what keeps the + -- history linear and the audit answerable ("what was live at 14:05?"). + rollback_of_version INTEGER CHECK (rollback_of_version >= 1), + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + published_at TIMESTAMPTZ, + superseded_at TIMESTAMPTZ, + superseded_by_release_id TEXT, + UNIQUE (dashboard_id, version), + -- A state's timestamps must match the state. A 'published' row with no + -- published_at is the shape that makes an SLO or an audit answer wrong. + CONSTRAINT opt_analytics_dashboard_release_timestamps CHECK ( + (state = 'draft' AND published_at IS NULL AND superseded_at IS NULL) + OR (state = 'published' AND published_at IS NOT NULL AND superseded_at IS NULL) + OR (state = 'superseded' AND published_at IS NOT NULL AND superseded_at IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_release_tenant_idx + ON opt_analytics_dashboard_release (tenant_id, project_id, dashboard_id, version); + +-- At most one PUBLISHED release per dashboard. This is the concurrency +-- guarantee; the application's check is only an early, friendlier error. +CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_published + ON opt_analytics_dashboard_release (dashboard_id) + WHERE state = 'published'; + +-- At most one open DRAFT per dashboard. Two drafts race to publish and the +-- loser's edits vanish into a superseded release nobody asked for. +CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_draft + ON opt_analytics_dashboard_release (dashboard_id) + WHERE state = 'draft'; + +-- ── transition enforcement ──────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION opt_analytics_dashboard_release_guard() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION + 'BI-P061: dashboard releases are a ledger; delete is refused (release %)', OLD.release_id + USING ERRCODE = 'restrict_violation'; + END IF; + + -- Content is frozen for the life of the release. + IF NEW.dashboard_id IS DISTINCT FROM OLD.dashboard_id + OR NEW.dashboard_version_id IS DISTINCT FROM OLD.dashboard_version_id + OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id + OR NEW.project_id IS DISTINCT FROM OLD.project_id + OR NEW.version IS DISTINCT FROM OLD.version + OR NEW.document_hash IS DISTINCT FROM OLD.document_hash + OR NEW.rollback_of_version IS DISTINCT FROM OLD.rollback_of_version + OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN + RAISE EXCEPTION + 'BI-P061: a dashboard release is immutable apart from its state (release %)', OLD.release_id + USING ERRCODE = 'restrict_violation'; + END IF; + + IF NEW.state IS DISTINCT FROM OLD.state THEN + IF NOT ((OLD.state = 'draft' AND NEW.state = 'published') + OR (OLD.state = 'published' AND NEW.state = 'superseded')) THEN + RAISE EXCEPTION + 'BI-P061: illegal dashboard release transition % -> % (release %)', + OLD.state, NEW.state, OLD.release_id + USING ERRCODE = 'restrict_violation'; + END IF; + END IF; + + -- A published timestamp, once set, is history. + IF OLD.published_at IS NOT NULL AND NEW.published_at IS DISTINCT FROM OLD.published_at THEN + RAISE EXCEPTION + 'BI-P061: published_at is history and cannot be rewritten (release %)', OLD.release_id + USING ERRCODE = 'restrict_violation'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS opt_analytics_dashboard_release_guard ON opt_analytics_dashboard_release; +CREATE TRIGGER opt_analytics_dashboard_release_guard + BEFORE UPDATE OR DELETE ON opt_analytics_dashboard_release + FOR EACH ROW EXECUTE FUNCTION opt_analytics_dashboard_release_guard(); diff --git a/src/analytics/dashboards/dashboard-release.ts b/src/analytics/dashboards/dashboard-release.ts new file mode 100644 index 0000000..e6bcbef --- /dev/null +++ b/src/analytics/dashboards/dashboard-release.ts @@ -0,0 +1,449 @@ +/** + * src/analytics/dashboards/dashboard-release.ts + * ============================================== + * BI-P061 — DASHBOARD VERSIONING. + * + * DRAFT -> PUBLISHED -> SUPERSEDED, and the DoD: "rollback to previous + * dashboard definition". + * + * Three decisions worth stating, because each one is a bug someone would + * otherwise write: + * + * 1. ROLLBACK MINTS A NEW VERSION. It does not resurrect the old release row + * and it does not edit the published one. "We went back to v3" is then a + * fact with a timestamp (v5 = rollback_of_version 3) instead of a hole in + * the history where v4 used to be. `verifyRollback()` proves the restore is + * faithful by comparing BI-P060 canonical hashes, so a rollback that + * silently restored a different document fails loudly. + * + * 2. THE DOCUMENT IS FROZEN, THE RELEASE MOVES. sql/008 makes + * DashboardVersion rows immutable at the database. The release ledger + * (sql/010) carries the state, and its trigger allows exactly two + * transitions and freezes every other column. Un-publishing is refused: + * it is how a rollback gets faked without leaving a trace. + * + * 3. THE APPLICATION CHECK IS NOT THE GUARANTEE. `publish()` checks for an + * existing published release, and that check is a read-then-write race. + * The partial unique index in sql/010 is what actually holds under + * concurrency; the realinfra test drops it, runs the race and shows two + * published releases for one dashboard, so nobody has to take that on + * faith. This module maps the resulting constraint violation onto a + * CONFLICT rather than a 500. + */ +import crypto from 'node:crypto'; +import { SiaxError } from '../../platform/errors'; +import { dashboardDocumentHash, parseDashboardDocument, type DashboardDocument } from './dashboard-document'; + +export const DASHBOARD_RELEASE_STATES = ['draft', 'published', 'superseded'] as const; +export type DashboardReleaseState = (typeof DASHBOARD_RELEASE_STATES)[number]; + +/** The only transitions that exist. Everything else is a refusal, not a no-op. */ +const LEGAL_TRANSITIONS: Readonly> = { + draft: ['published'], + published: ['superseded'], + superseded: [], +}; + +export function isLegalTransition(from: DashboardReleaseState, to: DashboardReleaseState): boolean { + return LEGAL_TRANSITIONS[from].includes(to); +} + +export interface DashboardRelease { + release_id: string; + dashboard_id: string; + dashboard_version_id: string; + tenant_id: string; + project_id: string; + version: number; + state: DashboardReleaseState; + /** BI-P060 canonical hash of the released document. */ + document_hash: string; + /** Set when this release restores an earlier version. */ + rollback_of_version?: number; + created_by?: string; + created_at: string; + published_at?: string; + superseded_at?: string; + superseded_by_release_id?: string; +} + +/** The immutable frozen document behind a release (sql/008's DashboardVersion). */ +export interface DashboardVersionRecord { + dashboard_version_id: string; + dashboard_id: string; + tenant_id: string; + project_id: string; + version: number; + layout_json: string; + layout_hash: string; + created_at: string; + created_by?: string; +} + +export interface TenantScope { + tenant_id: string; + project_id: string; +} + +/** + * The persistence port. Deliberately narrow, and every method is tenant-scoped + * — there is no signature that lets a caller forget the tenant. + */ +export interface DashboardReleaseStore { + /** Highest version number ever minted for this dashboard, or 0. */ + maxVersion(scope: TenantScope, dashboardId: string): Promise; + insertVersionAndRelease(version: DashboardVersionRecord, release: DashboardRelease): Promise; + getRelease(scope: TenantScope, releaseId: string): Promise; + getVersion(scope: TenantScope, dashboardVersionId: string): Promise; + findVersionByNumber( + scope: TenantScope, + dashboardId: string, + version: number, + ): Promise; + findByState( + scope: TenantScope, + dashboardId: string, + state: DashboardReleaseState, + ): Promise; + listReleases(scope: TenantScope, dashboardId: string): Promise; + /** + * Publish `releaseId` and supersede `supersedes` in ONE transaction. Both or + * neither: a supersede that lands without its publish leaves a dashboard + * with no live version, and a publish that lands without its supersede + * leaves two. + */ + publishAtomically(args: { + scope: TenantScope; + releaseId: string; + publishedAt: string; + supersedes?: { release_id: string; superseded_at: string }; + }): Promise; +} + +function conflict(detail: string): never { + throw new SiaxError('CONFLICT', '0pt.analytics.dashboard', detail); +} + +function notFound(detail: string): never { + throw new SiaxError('NOT_FOUND', '0pt.analytics.dashboard', detail); +} + +function assertScope(scope: TenantScope): void { + if (!scope?.tenant_id?.trim() || !scope?.project_id?.trim()) { + throw new SiaxError( + 'UNAUTHENTICATED', + '0pt.analytics.dashboard', + 'dashboard releases require a verified tenant_id + project_id', + ); + } +} + +export interface DraftResult { + release: DashboardRelease; + version: DashboardVersionRecord; +} + +/** + * The lifecycle. Stateless apart from its store and its clock; the clock is + * injectable so a test can pin timestamps instead of sleeping. + */ +export class DashboardReleaseService { + constructor( + private readonly store: DashboardReleaseStore, + private readonly now: () => Date = () => new Date(), + private readonly newId: () => string = () => crypto.randomUUID(), + ) {} + + /** + * Freeze a document as the next version and open a DRAFT release for it. + * The document is re-parsed here even if the caller already parsed it: what + * gets frozen must be what the model accepts, not what a caller assembled. + */ + async draft(args: { + scope: TenantScope; + dashboardId: string; + document: unknown; + createdBy?: string; + rollbackOfVersion?: number; + }): Promise { + assertScope(args.scope); + const document = parseDashboardDocument(args.document); + const existingDraft = await this.store.findByState(args.scope, args.dashboardId, 'draft'); + if (existingDraft) { + conflict( + `dashboard "${args.dashboardId}" already has an open draft (version ${existingDraft.version}); ` + + `publish or abandon it before opening another — two drafts race to publish and the loser's ` + + `edits vanish`, + ); + } + const next = (await this.store.maxVersion(args.scope, args.dashboardId)) + 1; + const at = this.now().toISOString(); + const hash = dashboardDocumentHash(document); + + const version: DashboardVersionRecord = { + dashboard_version_id: this.newId(), + dashboard_id: args.dashboardId, + tenant_id: args.scope.tenant_id, + project_id: args.scope.project_id, + version: next, + layout_json: JSON.stringify(document), + layout_hash: hash, + created_at: at, + ...(args.createdBy ? { created_by: args.createdBy } : {}), + }; + const release: DashboardRelease = { + release_id: this.newId(), + dashboard_id: args.dashboardId, + dashboard_version_id: version.dashboard_version_id, + tenant_id: args.scope.tenant_id, + project_id: args.scope.project_id, + version: next, + state: 'draft', + document_hash: hash, + created_at: at, + ...(args.createdBy ? { created_by: args.createdBy } : {}), + ...(args.rollbackOfVersion ? { rollback_of_version: args.rollbackOfVersion } : {}), + }; + await this.store.insertVersionAndRelease(version, release); + return { release, version }; + } + + /** + * DRAFT -> PUBLISHED, superseding whatever was live, in one transaction. + * The pre-check exists to return a friendly CONFLICT; the guarantee is the + * partial unique index (sql/010). + */ + async publish(args: { + scope: TenantScope; + releaseId: string; + }): Promise<{ published: DashboardRelease; superseded?: DashboardRelease }> { + assertScope(args.scope); + const release = await this.store.getRelease(args.scope, args.releaseId); + if (!release) notFound(`dashboard release "${args.releaseId}" does not exist in this tenant`); + if (release.state !== 'draft') { + conflict( + `release "${args.releaseId}" is ${release.state}, not draft — ${release.state} -> published is ` + + `not a legal transition (BI-P061)`, + ); + } + const live = await this.store.findByState(args.scope, release.dashboard_id, 'published'); + const at = this.now().toISOString(); + await this.store.publishAtomically({ + scope: args.scope, + releaseId: release.release_id, + publishedAt: at, + ...(live ? { supersedes: { release_id: live.release_id, superseded_at: at } } : {}), + }); + + return { + published: { ...release, state: 'published', published_at: at }, + ...(live + ? { + superseded: { + ...live, + state: 'superseded', + superseded_at: at, + superseded_by_release_id: release.release_id, + }, + } + : {}), + }; + } + + /** The release currently serving, or undefined if the dashboard has never been published. */ + async live(scope: TenantScope, dashboardId: string): Promise { + assertScope(scope); + return this.store.findByState(scope, dashboardId, 'published'); + } + + /** The frozen document behind a release, re-parsed on the way out. */ + async documentOf(scope: TenantScope, release: DashboardRelease): Promise { + assertScope(scope); + const version = await this.store.getVersion(scope, release.dashboard_version_id); + if (!version) notFound(`dashboard version "${release.dashboard_version_id}" is missing`); + const document = parseDashboardDocument(JSON.parse(version.layout_json)); + const hash = dashboardDocumentHash(document); + if (hash !== release.document_hash) { + // Storage returned something other than what was released. Refusing is + // the only safe answer: a dashboard that renders a document nobody + // approved is worse than a dashboard that will not open. + throw new SiaxError( + 'PERMANENT_FAILURE', + '0pt.analytics.dashboard', + `stored document for release "${release.release_id}" hashes to ${hash}, not the released ` + + `${release.document_hash} — refusing to serve an unapproved definition`, + ); + } + return document; + } + + /** + * BI-P061's DoD. Restores version `toVersion` by minting a NEW version with + * the SAME document and publishing it. Returns the new release plus the + * verification that the restore is faithful. + */ + async rollback(args: { + scope: TenantScope; + dashboardId: string; + toVersion: number; + createdBy?: string; + }): Promise<{ release: DashboardRelease; restored_from: number; document_hash: string }> { + assertScope(args.scope); + const target = await this.store.findVersionByNumber(args.scope, args.dashboardId, args.toVersion); + if (!target) { + notFound(`dashboard "${args.dashboardId}" has no version ${args.toVersion} in this tenant`); + } + const live = await this.store.findByState(args.scope, args.dashboardId, 'published'); + if (live && live.version === args.toVersion) { + conflict(`version ${args.toVersion} is already live; a rollback to the live version is a no-op`); + } + const document = parseDashboardDocument(JSON.parse(target.layout_json)); + const hash = dashboardDocumentHash(document); + if (hash !== target.layout_hash) { + throw new SiaxError( + 'PERMANENT_FAILURE', + '0pt.analytics.dashboard', + `version ${args.toVersion} hashes to ${hash} but was stored as ${target.layout_hash} — ` + + `refusing to roll back to a definition that has changed underneath us`, + ); + } + + const { release } = await this.draft({ + scope: args.scope, + dashboardId: args.dashboardId, + document, + ...(args.createdBy ? { createdBy: args.createdBy } : {}), + rollbackOfVersion: args.toVersion, + }); + const { published } = await this.publish({ scope: args.scope, releaseId: release.release_id }); + + if (published.document_hash !== target.layout_hash) { + throw new SiaxError( + 'PERMANENT_FAILURE', + '0pt.analytics.dashboard', + `rollback published ${published.document_hash}, not the requested ${target.layout_hash}`, + ); + } + return { release: published, restored_from: args.toVersion, document_hash: published.document_hash }; + } + + /** Full ledger, oldest first. A rollback is an entry here, never a deletion. */ + async history(scope: TenantScope, dashboardId: string): Promise { + assertScope(scope); + const rows = await this.store.listReleases(scope, dashboardId); + return [...rows].sort((a, b) => a.version - b.version); + } +} + +/** + * In-memory store. Used by the unit tests, and it enforces the same two + * uniqueness rules the SQL does — a store that is laxer than the database + * makes the unit tests pass on states production can never reach. + */ +export class InMemoryDashboardReleaseStore implements DashboardReleaseStore { + private readonly versions: DashboardVersionRecord[] = []; + private readonly releases: DashboardRelease[] = []; + + private scoped(scope: TenantScope, rows: T[]): T[] { + return rows.filter((r) => r.tenant_id === scope.tenant_id && r.project_id === scope.project_id); + } + + async maxVersion(scope: TenantScope, dashboardId: string): Promise { + return this.scoped(scope, this.versions) + .filter((v) => v.dashboard_id === dashboardId) + .reduce((max, v) => Math.max(max, v.version), 0); + } + + async insertVersionAndRelease(version: DashboardVersionRecord, release: DashboardRelease): Promise { + const scope = { tenant_id: release.tenant_id, project_id: release.project_id }; + if ( + release.state === 'draft' && + this.scoped(scope, this.releases).some( + (r) => r.dashboard_id === release.dashboard_id && r.state === 'draft', + ) + ) { + conflict('one open draft per dashboard'); + } + if (this.versions.some((v) => v.dashboard_id === version.dashboard_id && v.version === version.version)) { + conflict(`version ${version.version} already exists for "${version.dashboard_id}"`); + } + this.versions.push({ ...version }); + this.releases.push({ ...release }); + } + + async getRelease(scope: TenantScope, releaseId: string): Promise { + const r = this.scoped(scope, this.releases).find((x) => x.release_id === releaseId); + return r ? { ...r } : undefined; + } + + async getVersion(scope: TenantScope, id: string): Promise { + const v = this.scoped(scope, this.versions).find((x) => x.dashboard_version_id === id); + return v ? { ...v } : undefined; + } + + async findVersionByNumber( + scope: TenantScope, + dashboardId: string, + version: number, + ): Promise { + const v = this.scoped(scope, this.versions).find( + (x) => x.dashboard_id === dashboardId && x.version === version, + ); + return v ? { ...v } : undefined; + } + + async findByState( + scope: TenantScope, + dashboardId: string, + state: DashboardReleaseState, + ): Promise { + const r = this.scoped(scope, this.releases).find( + (x) => x.dashboard_id === dashboardId && x.state === state, + ); + return r ? { ...r } : undefined; + } + + async listReleases(scope: TenantScope, dashboardId: string): Promise { + return this.scoped(scope, this.releases) + .filter((r) => r.dashboard_id === dashboardId) + .map((r) => ({ ...r })); + } + + async publishAtomically(args: { + scope: TenantScope; + releaseId: string; + publishedAt: string; + supersedes?: { release_id: string; superseded_at: string }; + }): Promise { + const target = this.releases.find( + (r) => + r.release_id === args.releaseId && + r.tenant_id === args.scope.tenant_id && + r.project_id === args.scope.project_id, + ); + if (!target) notFound(`release "${args.releaseId}" not found`); + if (!isLegalTransition(target.state, 'published')) { + conflict(`illegal transition ${target.state} -> published`); + } + const alreadyLive = this.releases.find( + (r) => + r.dashboard_id === target.dashboard_id && + r.state === 'published' && + r.release_id !== args.supersedes?.release_id, + ); + if (alreadyLive) conflict('one published release per dashboard'); + + if (args.supersedes) { + const live = this.releases.find((r) => r.release_id === args.supersedes?.release_id); + if (!live) notFound(`release "${args.supersedes.release_id}" not found`); + if (!isLegalTransition(live.state, 'superseded')) { + conflict(`illegal transition ${live.state} -> superseded`); + } + live.state = 'superseded'; + live.superseded_at = args.supersedes.superseded_at; + live.superseded_by_release_id = target.release_id; + } + target.state = 'published'; + target.published_at = args.publishedAt; + } +} diff --git a/tests/analytics/dashboard-release-postgres.realinfra.test.ts b/tests/analytics/dashboard-release-postgres.realinfra.test.ts new file mode 100644 index 0000000..31ec1b4 --- /dev/null +++ b/tests/analytics/dashboard-release-postgres.realinfra.test.ts @@ -0,0 +1,346 @@ +/** + * 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; + +function connect(schema: string, max = 1): ReturnType { + return postgres(TEST_DATABASE_URL!, { + max, + onnotice: () => undefined, + connection: { search_path: schema }, + }); +} + +async function refusalOf(fn: () => Promise): Promise { + try { + await fn(); + return undefined; + } catch (e) { + return String((e as Error).message); + } +} + +async function migrate(conn: ReturnType): Promise { + for (const file of MIGRATIONS) { + await conn.unsafe(fs.readFileSync(path.join(process.cwd(), file), 'utf8')); + } +} + +async function seedDashboard(conn: ReturnType, id: string): Promise { + 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, + versionId: string, + dashboardId: string, + version: number, + hash = HASH_A, +): Promise { + 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, + releaseId: string, + versionId: string, + dashboardId: string, + version: number, + hash = HASH_A, +): Promise { + 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, dashboardId: string): Promise { + 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, 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; + + 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); +}); diff --git a/tests/analytics/dashboard-release.test.ts b/tests/analytics/dashboard-release.test.ts new file mode 100644 index 0000000..3e5c703 --- /dev/null +++ b/tests/analytics/dashboard-release.test.ts @@ -0,0 +1,212 @@ +/** + * 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, + ); + }); +});