Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
206 lines
9.2 KiB
JavaScript
206 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// ADR-0003 §C-5 smoke — BDD release gate auto-publish / demote.
|
|
//
|
|
// Scenarios (all against PROD DB, inside space 11):
|
|
// S1 bootstrap — create a throwaway documents_registry table in the BDD
|
|
// project (marked `smoke_c5=true`) + seed a draft doc row there.
|
|
// S2 seed BDD — one bdd_specs + two Must bdd_criteria (status=pending).
|
|
// S3 gate red — maybeTransitionDocumentStatus returns {noop}.
|
|
// S4 verify one — still {noop}, ready=false.
|
|
// S5 verify both — {published: true}, registry.status flips to 'published',
|
|
// bdd_audit_log gets action='document_publish', pg_notify emitted.
|
|
// S6 idempotent — second call → {noop}.
|
|
// S7 regress one — {demoted: true}, registry.status → 'regressed-published',
|
|
// bdd_audit_log gets action='document_regress'.
|
|
// S8 cleanup — drop the throwaway table + rows + audit entries.
|
|
|
|
// ADR-0009 Phase 5: boot guard — aborts (exit 2) if POSTGRES_HOST is PROD.
|
|
import '../backend/test/setup.js';
|
|
import pg from 'pg';
|
|
import { maybeTransitionDocumentStatus } from '../backend/services/bdd/releaseGate.js';
|
|
|
|
const { Pool } = pg;
|
|
const pool = new Pool({
|
|
host: process.env.POSTGRES_HOST || 'localhost',
|
|
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
|
|
database: process.env.POSTGRES_DB || 'godcrm_prod',
|
|
user: process.env.POSTGRES_USER || 'godcrm',
|
|
password: process.env.POSTGRES_PASSWORD || 'godcrm_dev_2026',
|
|
});
|
|
|
|
async function bddTableId(client, name) {
|
|
const { rows } = await client.query(`
|
|
SELECT ut.id FROM universal_tables ut
|
|
JOIN projects p ON p.id = ut.project_id
|
|
WHERE p.space_id = 11 AND ut.name = $1 LIMIT 1
|
|
`, [name]);
|
|
return rows[0]?.id;
|
|
}
|
|
|
|
async function insert(client, tid, data, { createdBy = 1 } = {}) {
|
|
const baseId = `SMK-C5-${Date.now().toString(36).toUpperCase()}-${Math.floor(Math.random() * 1000)}`;
|
|
const { rows } = await client.query(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
|
|
VALUES ($1, $2, $3::jsonb, $4, NOW(), NOW()) RETURNING id`,
|
|
[tid, baseId, JSON.stringify(data), createdBy]
|
|
);
|
|
return rows[0].id;
|
|
}
|
|
|
|
async function patch(client, rowId, patchObj) {
|
|
await client.query(
|
|
`UPDATE table_rows SET data = data || $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
|
[JSON.stringify(patchObj), rowId]
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const client = await pool.connect();
|
|
const created = {
|
|
registryTableId: null,
|
|
registryProjectId: null,
|
|
docRowId: null,
|
|
specRowId: null,
|
|
crit1Id: null,
|
|
crit2Id: null,
|
|
auditRowIds: [],
|
|
};
|
|
try {
|
|
// --- S1: bootstrap throwaway documents_registry table ---------------
|
|
const { rows: projectRows } = await client.query(
|
|
`SELECT id FROM projects WHERE space_id = 11 AND name = 'BDD / ADR-156' LIMIT 1`
|
|
);
|
|
const projectId = projectRows[0]?.id;
|
|
if (!projectId) throw new Error('BDD project not found');
|
|
created.registryProjectId = projectId;
|
|
|
|
const throwawayName = `_smoke_c5_registry_${Date.now()}`;
|
|
const { rows: utRows } = await client.query(`
|
|
INSERT INTO universal_tables (project_id, name, description, icon, table_type, is_system, created_at, updated_at)
|
|
VALUES ($1, $2, 'smoke-c5 registry', '🧪', 'documents_registry', 0, NOW(), NOW())
|
|
RETURNING id
|
|
`, [projectId, throwawayName]);
|
|
created.registryTableId = utRows[0].id;
|
|
console.log(`S1 ✓ throwaway registry table id=${created.registryTableId} name=${throwawayName}`);
|
|
|
|
// Seed a draft doc row
|
|
created.docRowId = await insert(client, created.registryTableId, {
|
|
name: 'SMK-C5 draft doc',
|
|
slug: 'smk-c5-draft',
|
|
status: 'draft',
|
|
});
|
|
console.log(`S1 ✓ draft doc row id=${created.docRowId}`);
|
|
|
|
// --- S2: seed BDD spec + two must criteria --------------------------
|
|
const specTid = await bddTableId(client, 'bdd_specs');
|
|
const critTid = await bddTableId(client, 'bdd_criteria');
|
|
const auditTid = await bddTableId(client, 'bdd_audit_log');
|
|
if (!specTid || !critTid || !auditTid) throw new Error('BDD tables missing');
|
|
|
|
created.specRowId = await insert(client, specTid, {
|
|
title: 'SMK-C5 spec',
|
|
source_doc_id: created.docRowId,
|
|
status: 'active',
|
|
});
|
|
created.crit1Id = await insert(client, critTid, {
|
|
code: 'SMK-C5-AC1',
|
|
title: 'must AC 1',
|
|
spec_id: created.specRowId,
|
|
source_doc_id: created.docRowId,
|
|
priority: 'must',
|
|
status: 'pending',
|
|
});
|
|
created.crit2Id = await insert(client, critTid, {
|
|
code: 'SMK-C5-AC2',
|
|
title: 'must AC 2',
|
|
spec_id: created.specRowId,
|
|
source_doc_id: created.docRowId,
|
|
priority: 'must',
|
|
status: 'pending',
|
|
});
|
|
console.log(`S2 ✓ spec=${created.specRowId} crit1=${created.crit1Id} crit2=${created.crit2Id}`);
|
|
|
|
// --- S3: gate red → noop --------------------------------------------
|
|
let r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S3' });
|
|
if (!r.noop) throw new Error(`S3 ✗ expected noop, got ${JSON.stringify(r)}`);
|
|
console.log(`S3 ✓ gate red: ${JSON.stringify(r)}`);
|
|
|
|
// --- S4: verify one must → still red --------------------------------
|
|
await patch(client, created.crit1Id, { status: 'verified' });
|
|
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S4' });
|
|
if (!r.noop) throw new Error(`S4 ✗ expected noop, got ${JSON.stringify(r)}`);
|
|
console.log(`S4 ✓ 1/2 verified — still ${JSON.stringify(r)}`);
|
|
|
|
// --- S5: verify both → published ------------------------------------
|
|
await patch(client, created.crit2Id, { status: 'verified' });
|
|
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S5' });
|
|
if (!r.published) throw new Error(`S5 ✗ expected published, got ${JSON.stringify(r)}`);
|
|
console.log(`S5 ✓ gate green → published: ${JSON.stringify(r)}`);
|
|
|
|
const { rows: regRows } = await client.query(
|
|
`SELECT data FROM table_rows WHERE id = $1`, [created.docRowId]
|
|
);
|
|
const regData = regRows[0].data;
|
|
if (regData.status !== 'published') throw new Error(`S5 ✗ registry.status=${regData.status}`);
|
|
if (!regData.published_at) throw new Error('S5 ✗ registry.published_at not set');
|
|
console.log(`S5 ✓ registry.status=published at ${regData.published_at}`);
|
|
|
|
const { rows: pubAudit } = await client.query(
|
|
`SELECT id, data FROM table_rows WHERE table_id = $1 AND data->>'action' = 'document_publish' AND (data->>'doc_id')::int = $2 ORDER BY id DESC LIMIT 1`,
|
|
[auditTid, created.docRowId]
|
|
);
|
|
if (!pubAudit.length) throw new Error('S5 ✗ no document_publish audit row');
|
|
created.auditRowIds.push(pubAudit[0].id);
|
|
console.log(`S5 ✓ audit row id=${pubAudit[0].id} caused_by=${pubAudit[0].data.caused_by}`);
|
|
|
|
// --- S6: idempotent — second call → noop ----------------------------
|
|
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S6' });
|
|
if (!r.noop) throw new Error(`S6 ✗ expected noop on re-publish, got ${JSON.stringify(r)}`);
|
|
console.log(`S6 ✓ second call idempotent: ${JSON.stringify(r)}`);
|
|
|
|
// --- S7: regress one must → demoted to regressed-published ----------
|
|
await patch(client, created.crit1Id, { status: 'regressed' });
|
|
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S7' });
|
|
if (!r.demoted) throw new Error(`S7 ✗ expected demoted, got ${JSON.stringify(r)}`);
|
|
const { rows: regAfter } = await client.query(
|
|
`SELECT data FROM table_rows WHERE id = $1`, [created.docRowId]
|
|
);
|
|
if (regAfter[0].data.status !== 'regressed-published') {
|
|
throw new Error(`S7 ✗ status=${regAfter[0].data.status}`);
|
|
}
|
|
console.log(`S7 ✓ demoted to regressed-published: ${JSON.stringify(r)}`);
|
|
const { rows: regAudit } = await client.query(
|
|
`SELECT id FROM table_rows WHERE table_id = $1 AND data->>'action' = 'document_regress' AND (data->>'doc_id')::int = $2 ORDER BY id DESC LIMIT 1`,
|
|
[auditTid, created.docRowId]
|
|
);
|
|
if (!regAudit.length) throw new Error('S7 ✗ no document_regress audit row');
|
|
created.auditRowIds.push(regAudit[0].id);
|
|
console.log(`S7 ✓ audit row id=${regAudit[0].id}`);
|
|
|
|
console.log('\nC-5 release-gate smoke: ALL GREEN');
|
|
} finally {
|
|
// --- S8: cleanup ----------------------------------------------------
|
|
try {
|
|
for (const id of created.auditRowIds) {
|
|
await client.query(`DELETE FROM table_rows WHERE id = $1`, [id]);
|
|
}
|
|
for (const id of [created.crit1Id, created.crit2Id, created.specRowId, created.docRowId]) {
|
|
if (id) await client.query(`DELETE FROM table_rows WHERE id = $1`, [id]);
|
|
}
|
|
if (created.registryTableId) {
|
|
// drop columns first (there are none), then table
|
|
await client.query(`DELETE FROM universal_tables WHERE id = $1`, [created.registryTableId]);
|
|
}
|
|
console.log(`cleanup ✓ audit=[${created.auditRowIds.join(',')}] rows doc=${created.docRowId} spec=${created.specRowId} crit1=${created.crit1Id} crit2=${created.crit2Id} ut=${created.registryTableId}`);
|
|
} catch (e) {
|
|
console.error('cleanup error:', e.message);
|
|
}
|
|
client.release();
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('C-5 smoke FAILED:', e.message);
|
|
process.exit(1);
|
|
});
|