Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
332 lines
10 KiB
JavaScript
332 lines
10 KiB
JavaScript
/**
|
|
* ADR-0002 §8 Phase 4 — Ticket seal (TOTP-act) tests.
|
|
*
|
|
* Endpoints:
|
|
* POST /api/v3/tickets/:id/seal body: { totp_code, notes? }
|
|
* POST /api/v3/tickets/:id/unseal body: { totp_code, reason }
|
|
*
|
|
* Scenarios covered (per A4.x acceptance):
|
|
* 1. Success: gate green + valid TOTP → 200 + sealed_at written
|
|
* 2. Bad TOTP → 401 TICKET_SEAL_TOTP_INVALID, no audit row touches Tickets
|
|
* 3. Gate fail → 409 MUST_CRITERIA_INCOMPLETE
|
|
* 4. Double-seal (already sealed) → 409 TICKET_ALREADY_SEALED
|
|
* 5. Unseal without reason → 400
|
|
* 6. Unseal with valid TOTP + reason → 200 + sealed_* cleared
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import express from 'express';
|
|
|
|
// ===== MOCKS =====
|
|
// Mock all DB/IO surfaces. Keeps the test hermetic — no DB / no speakeasy.
|
|
|
|
const mockDbGet = vi.fn();
|
|
const mockDbRun = vi.fn();
|
|
const mockDbAll = vi.fn();
|
|
const mockTrxRun = vi.fn();
|
|
const mockTrxGet = vi.fn();
|
|
const mockWithTransactionAsync = vi.fn(async (cb) =>
|
|
cb({ run: mockTrxRun, get: mockTrxGet }),
|
|
);
|
|
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: (...args) => mockDbGet(...args),
|
|
dbRun: (...args) => mockDbRun(...args),
|
|
dbAll: (...args) => mockDbAll(...args),
|
|
isPostgres: () => true,
|
|
safeJsonParse: (v, d) => {
|
|
if (v == null) return d;
|
|
if (typeof v === 'object') return v;
|
|
try {
|
|
return JSON.parse(v);
|
|
} catch {
|
|
return d;
|
|
}
|
|
},
|
|
withTransactionAsync: (cb) => mockWithTransactionAsync(cb),
|
|
}));
|
|
|
|
vi.mock('../../../utils/logger.js', () => ({
|
|
apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('../../../utils/baseId.js', () => ({
|
|
generateBaseId: () => 'test-base-id-001',
|
|
}));
|
|
|
|
const mockSpeakeasyVerify = vi.fn();
|
|
vi.mock('speakeasy', () => ({
|
|
default: {
|
|
totp: { verify: (...args) => mockSpeakeasyVerify(...args) },
|
|
},
|
|
totp: { verify: (...args) => mockSpeakeasyVerify(...args) },
|
|
}));
|
|
|
|
const mockCheckCompletionGate = vi.fn();
|
|
vi.mock('../../../services/bdd/completionGate.js', () => ({
|
|
checkCompletionGate: (...args) => mockCheckCompletionGate(...args),
|
|
formatGateError: (gate) => ({
|
|
code: 'MUST_CRITERIA_INCOMPLETE',
|
|
must_total: gate.must_total,
|
|
must_verified: gate.must_verified,
|
|
failed: gate.blockers,
|
|
}),
|
|
}));
|
|
|
|
// Bypass the per-IP rate limiter — its window persists across tests if not stubbed.
|
|
vi.mock('../bdd/shared.js', () => ({
|
|
totpLimiter: (req, res, next) => next(),
|
|
}));
|
|
|
|
// Import AFTER mocks
|
|
import registerSealRoutes from '../tickets/seal.js';
|
|
|
|
const TICKETS_TABLE_ID = 1708;
|
|
const SEAL_TABLE_ID = 9850;
|
|
|
|
// ===== TEST APP =====
|
|
|
|
function createApp(userOverride = {}) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, res, next) => {
|
|
req.user = { id: 42, role: 'admin', user_type: 'human', ...userOverride };
|
|
next();
|
|
});
|
|
const router = express.Router();
|
|
registerSealRoutes(router);
|
|
app.use('/api/v3', router);
|
|
return app;
|
|
}
|
|
|
|
/**
|
|
* Configure mockDbGet to mimic the seal flow:
|
|
* - lookup ticket_seal_verification table-id by name
|
|
* - load Tickets row
|
|
* - load users row for TOTP verify
|
|
*/
|
|
function primeDbGet({ ticket, user }) {
|
|
mockDbGet.mockImplementation(async (sql, params) => {
|
|
if (typeof sql !== 'string') return null;
|
|
if (sql.includes('FROM universal_tables')) {
|
|
return { id: SEAL_TABLE_ID };
|
|
}
|
|
if (sql.includes('FROM table_rows') && Array.isArray(params) && params[1] === TICKETS_TABLE_ID) {
|
|
return ticket ? { id: ticket.id, data: ticket.data } : null;
|
|
}
|
|
if (sql.includes('FROM users')) {
|
|
return user;
|
|
}
|
|
return null;
|
|
});
|
|
// Inside-txn re-read of Tickets row
|
|
mockTrxGet.mockImplementation(async () => (ticket ? { id: ticket.id, data: ticket.data } : null));
|
|
mockTrxRun.mockResolvedValue({ changes: 1 });
|
|
}
|
|
|
|
// ===== TESTS =====
|
|
|
|
describe('ADR-0002 §8 Phase 4: POST /tickets/:id/seal', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
app = createApp();
|
|
});
|
|
|
|
it('A4.3 success — green gate + valid TOTP → 200 + sealed_at', async () => {
|
|
primeDbGet({
|
|
ticket: { id: 999, data: { state: 24276, criteria_progress: '3/3' } },
|
|
user: { totp_secret: 'JBSWY3DPEHPK3PXP', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
mockCheckCompletionGate.mockResolvedValue({
|
|
ok: true,
|
|
must_total: 3,
|
|
must_verified: 3,
|
|
blockers: [],
|
|
});
|
|
mockSpeakeasyVerify.mockReturnValue(true);
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/seal')
|
|
.send({ totp_code: '123456', notes: 'sprint complete' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.ticket_id).toBe(999);
|
|
expect(res.body.data.sealed_by).toBe(42);
|
|
expect(typeof res.body.data.sealed_at).toBe('string');
|
|
|
|
// Audit row INSERT inside txn + Tickets UPDATE inside txn (2 calls).
|
|
expect(mockTrxRun).toHaveBeenCalledTimes(2);
|
|
const insertCall = mockTrxRun.mock.calls[0];
|
|
expect(insertCall[0]).toContain('INSERT INTO table_rows');
|
|
expect(insertCall[1][0]).toBe(SEAL_TABLE_ID);
|
|
const auditPayload = JSON.parse(insertCall[1][2]);
|
|
expect(auditPayload.action).toBe('sealed');
|
|
expect(auditPayload.ticket_id).toBe(999);
|
|
expect(auditPayload.user_id).toBe(42);
|
|
expect(auditPayload.totp_proof).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('A4.x bad TOTP → 401 TICKET_SEAL_TOTP_INVALID, no audit row inserted', async () => {
|
|
primeDbGet({
|
|
ticket: { id: 999, data: { state: 24276 } },
|
|
user: { totp_secret: 'JBSWY3DPEHPK3PXP', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
mockCheckCompletionGate.mockResolvedValue({
|
|
ok: true,
|
|
must_total: 0,
|
|
must_verified: 0,
|
|
blockers: [],
|
|
});
|
|
mockSpeakeasyVerify.mockReturnValue(false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/seal')
|
|
.send({ totp_code: '000000' });
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error.code).toBe('TICKET_SEAL_TOTP_INVALID');
|
|
expect(mockTrxRun).not.toHaveBeenCalled();
|
|
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('A4.3 gate fail → 409 MUST_CRITERIA_INCOMPLETE', async () => {
|
|
primeDbGet({
|
|
ticket: { id: 999, data: { state: 24276 } },
|
|
user: { totp_secret: 'JBSWY3DPEHPK3PXP', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
mockCheckCompletionGate.mockResolvedValue({
|
|
ok: false,
|
|
must_total: 3,
|
|
must_verified: 1,
|
|
blockers: [
|
|
{ id: 7001, code: 'A1', title: 'Login', status: 'pending' },
|
|
{ id: 7002, code: 'A2', title: 'Logout', status: 'pending' },
|
|
],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/seal')
|
|
.send({ totp_code: '123456' });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error.code).toBe('MUST_CRITERIA_INCOMPLETE');
|
|
expect(res.body.error.details.must_total).toBe(3);
|
|
expect(res.body.error.details.must_verified).toBe(1);
|
|
expect(res.body.error.details.failed).toHaveLength(2);
|
|
// TOTP must NOT be checked when gate is red — fail-fast.
|
|
expect(mockSpeakeasyVerify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('A4.x double-seal → 409 TICKET_ALREADY_SEALED', async () => {
|
|
primeDbGet({
|
|
ticket: {
|
|
id: 999,
|
|
data: {
|
|
state: 24276,
|
|
sealed_at: '2026-05-01T10:00:00.000Z',
|
|
sealed_by: '42',
|
|
},
|
|
},
|
|
user: { totp_secret: 'JBSWY3DPEHPK3PXP', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/seal')
|
|
.send({ totp_code: '123456' });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error.code).toBe('TICKET_ALREADY_SEALED');
|
|
expect(mockCheckCompletionGate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('A4.x ticket not found → 404', async () => {
|
|
primeDbGet({ ticket: null, user: null });
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/seal')
|
|
.send({ totp_code: '123456' });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('ADR-0002 §8 Phase 4: POST /tickets/:id/unseal', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
app = createApp();
|
|
});
|
|
|
|
it('A4.4 success — sealed ticket + TOTP + reason → 200 + sealed_* cleared', async () => {
|
|
primeDbGet({
|
|
ticket: {
|
|
id: 999,
|
|
data: {
|
|
state: 24278, // done
|
|
sealed_at: '2026-05-01T10:00:00.000Z',
|
|
sealed_by: '42',
|
|
seal_proof: 'abc',
|
|
},
|
|
},
|
|
user: { totp_secret: 'JBSWY3DPEHPK3PXP', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
mockSpeakeasyVerify.mockReturnValue(true);
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/unseal')
|
|
.send({ totp_code: '123456', reason: 'rework requested by QA' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(mockTrxRun).toHaveBeenCalledTimes(2);
|
|
const auditPayload = JSON.parse(mockTrxRun.mock.calls[0][1][2]);
|
|
expect(auditPayload.action).toBe('broken');
|
|
expect(auditPayload.reason).toBe('rework requested by QA');
|
|
});
|
|
|
|
it('A4.4 unseal without reason → 400', async () => {
|
|
primeDbGet({
|
|
ticket: {
|
|
id: 999,
|
|
data: { sealed_at: '2026-05-01T10:00:00.000Z', sealed_by: '42' },
|
|
},
|
|
user: { totp_secret: 'X', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/unseal')
|
|
.send({ totp_code: '123456' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('unseal a non-sealed ticket → 409 TICKET_NOT_SEALED', async () => {
|
|
primeDbGet({
|
|
ticket: { id: 999, data: { state: 24276 } },
|
|
user: { totp_secret: 'X', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/unseal')
|
|
.send({ totp_code: '123456', reason: 'no-op test' });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error.code).toBe('TICKET_NOT_SEALED');
|
|
});
|
|
|
|
it('A4.4 unseal with bad TOTP → 401, sealed_* untouched', async () => {
|
|
primeDbGet({
|
|
ticket: {
|
|
id: 999,
|
|
data: { sealed_at: '2026-05-01T10:00:00.000Z', sealed_by: '42' },
|
|
},
|
|
user: { totp_secret: 'X', totp_enabled: 1, user_type: 'human' },
|
|
});
|
|
mockSpeakeasyVerify.mockReturnValue(false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/tickets/999/unseal')
|
|
.send({ totp_code: 'wrong', reason: 'rework' });
|
|
expect(res.status).toBe(401);
|
|
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
|
|
});
|
|
});
|