require('ts-node/register/transpile-only'); const assert = require('node:assert/strict'); const fs = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); const { Sequelize } = require('sequelize'); const { defineSchemaMigrationModel, } = require('../../back/data/schemaMigration'); const { projectPolicyMigration, } = require('../../back/migrations/0017-project-policy'); const { APPROVAL_REQUEST_CONSUMPTION_ID_INDEX, APPROVAL_REQUEST_DECISION_ID_INDEX, APPROVAL_REQUEST_PENDING_INDEX, APPROVAL_REQUEST_REQUESTER_INDEX, APPROVAL_REQUEST_TABLE, APPROVED_ACTION_DISPATCH_PENDING_INDEX, APPROVED_ACTION_DISPATCH_REQUEST_INDEX, APPROVED_ACTION_DISPATCH_TABLE, approvalRequestMigration, } = require('../../back/migrations/0020-approval-requests'); const { approvedActionDispatchExecutionMigration, } = require('../../back/migrations/0021-approved-action-dispatch-executions'); const { runMigrations } = require('../../back/migrations/runner'); const { LegacySequelizeApprovalRequestRepository, } = require('../../back/runtime/adapters/legacy-sequelize/approvalRequestRepository'); const { LegacySequelizeProjectPolicyRepository, } = require('../../back/runtime/adapters/legacy-sequelize/projectPolicyRepository'); const { ApprovalRequestService, } = require('../../back/runtime/application/approvalRequestService'); const { ProjectPolicyEngine, } = require('../../back/runtime/application/projectPolicyEngine'); const { ApprovalHumanDecisionRequiredError, ApprovalMutationConflictError, ApprovalPolicyDeniedError, ApprovalPolicyFenceConflictError, ApprovalRequestExpiredError, ApprovalRequestStateConflictError, ApprovalRequestVersionConflictError, } = require('../../back/runtime/domain/approvalRequest'); const PROJECT_ID = 'default'; const AGENT = Object.freeze({ type: 'agent', id: 'agent-1' }); const OWNER = Object.freeze({ type: 'user', id: 'owner-1' }); const VIEWER = Object.freeze({ type: 'user', id: 'viewer-1' }); const SYSTEM = Object.freeze({ type: 'system', id: 'approval-dispatcher' }); const NOW = 100_000; const EXPIRES_AT = 200_000; function action(overrides = {}) { return { permission: 'tool.call:filesystem.write', actionType: 'tool_call', actionRef: 'planned-action-1', actionDigest: 'a'.repeat(64), previewDigest: 'b'.repeat(64), ...overrides, }; } function createInput(overrides = {}) { return { id: 'approval-1', projectId: PROJECT_ID, action: action(), risk: 'high', requestedBy: AGENT, requestedAtMs: NOW, expiresAtMs: EXPIRES_AT, ...overrides, }; } function decisionInput(overrides = {}) { return { requestId: 'approval-1', expectedVersion: 1, decisionId: 'decision-1', decision: 'approved', reasonCode: 'reviewed_action', decidedBy: OWNER, decidedAtMs: NOW + 10, ...overrides, }; } function consumptionInput(overrides = {}) { return { requestId: 'approval-1', expectedVersion: 2, consumptionId: 'consumption-1', dispatchId: 'dispatch-1', action: action(), requestedBy: AGENT, consumedBy: SYSTEM, consumedAtMs: NOW + 20, ...overrides, }; } async function migrate(database) { await runMigrations({ database, migrationModel: defineSchemaMigrationModel(database), migrations: [ projectPolicyMigration, approvalRequestMigration, approvedActionDispatchExecutionMigration, ], logger: { info() {} }, }); } async function bind(policyRepository, subject, role, mutationId) { await policyRepository.append({ expectedCurrentVersion: 0, binding: { projectId: PROJECT_ID, subject, version: 1, state: 'active', role, mutationId, changedBy: OWNER, createdAtMs: NOW - 100, }, }); } async function setup(t, storage = ':memory:') { const database = new Sequelize({ dialect: 'sqlite', storage, logging: false, }); t.after(() => database.close()); await migrate(database); const policyRepository = new LegacySequelizeProjectPolicyRepository(database); await bind(policyRepository, OWNER, 'owner', 'bind-owner'); await bind(policyRepository, AGENT, 'operator', 'bind-agent'); await bind(policyRepository, VIEWER, 'viewer', 'bind-viewer'); const repository = new LegacySequelizeApprovalRequestRepository(database); const policy = new ProjectPolicyEngine(policyRepository); return { database, policyRepository, repository, policy, service: new ApprovalRequestService(repository, policy), }; } test('migration owns bounded approval and durable dispatch indexes', async (t) => { const { database, service } = await setup(t); await service.create(createInput()); const rows = await database .getQueryInterface() .select(null, APPROVAL_REQUEST_TABLE); assert.equal(rows.length, 1); assert.equal(rows[0].action_digest, 'a'.repeat(64)); assert.equal(rows[0].preview_digest, 'b'.repeat(64)); assert.equal(JSON.stringify(rows).includes('filesystem.write'), true); assert.equal('preview' in rows[0], false); assert.equal('arguments' in rows[0], false); assert.equal('secret' in rows[0], false); const requestIndexes = new Set( (await database.getQueryInterface().showIndex(APPROVAL_REQUEST_TABLE)).map( (index) => index.name, ), ); for (const name of [ APPROVAL_REQUEST_DECISION_ID_INDEX, APPROVAL_REQUEST_CONSUMPTION_ID_INDEX, APPROVAL_REQUEST_PENDING_INDEX, APPROVAL_REQUEST_REQUESTER_INDEX, ]) { assert.ok(requestIndexes.has(name)); } const dispatchIndexes = new Set( ( await database .getQueryInterface() .showIndex(APPROVED_ACTION_DISPATCH_TABLE) ).map((index) => index.name), ); assert.ok(dispatchIndexes.has(APPROVED_ACTION_DISPATCH_REQUEST_INDEX)); assert.ok(dispatchIndexes.has(APPROVED_ACTION_DISPATCH_PENDING_INDEX)); }); test('creates, approves and atomically emits a one-time durable dispatch', async (t) => { const { database, service } = await setup(t); const pending = await service.create(createInput()); assert.equal(pending.state, 'pending'); assert.equal(pending.version, 1); const approved = await service.decide(decisionInput()); assert.equal(approved.state, 'approved'); assert.equal(approved.version, 2); assert.deepEqual(approved.decidedBy, OWNER); const consumed = await service.consume(consumptionInput()); assert.equal(consumed.request.state, 'consumed'); assert.equal(consumed.request.version, 3); assert.equal(consumed.dispatch.state, 'pending'); assert.equal(consumed.dispatch.approvalRequestId, pending.id); assert.equal( consumed.dispatch.action.actionDigest, pending.action.actionDigest, ); assert.equal( ( await database .getQueryInterface() .select(null, APPROVED_ACTION_DISPATCH_TABLE) ).length, 1, ); }); test('only require-approval actions can create requests', async (t) => { const { service } = await setup(t); await assert.rejects( service.create(createInput({ requestedBy: OWNER })), ApprovalPolicyDeniedError, ); await assert.rejects( service.create( createInput({ requestedBy: { type: 'agent', id: 'unbound-agent' } }), ), ApprovalPolicyDeniedError, ); }); test('decisions require a bound human user with approval.decide', async (t) => { const { service } = await setup(t); await service.create(createInput()); await assert.rejects( service.decide(decisionInput({ decidedBy: AGENT })), ApprovalHumanDecisionRequiredError, ); await assert.rejects( service.decide(decisionInput({ decidedBy: VIEWER })), ApprovalPolicyDeniedError, ); const rejected = await service.decide( decisionInput({ decision: 'rejected', reasonCode: 'unsafe_action' }), ); assert.equal(rejected.state, 'rejected'); await assert.rejects( service.consume(consumptionInput()), ApprovalRequestStateConflictError, ); }); test('expiry is exact and does not require background timers', async (t) => { const { service } = await setup(t); await service.create(createInput()); assert.equal( (await service.get('approval-1', EXPIRES_AT - 1)).effectiveStatus, 'pending', ); assert.equal( (await service.get('approval-1', EXPIRES_AT)).effectiveStatus, 'expired', ); await assert.rejects( service.decide(decisionInput({ decidedAtMs: EXPIRES_AT })), ApprovalRequestExpiredError, ); await service.create(createInput({ id: 'approval-2' })); await service.decide( decisionInput({ requestId: 'approval-2', decisionId: 'decision-2', decidedAtMs: EXPIRES_AT - 2, }), ); await assert.rejects( service.consume( consumptionInput({ requestId: 'approval-2', consumptionId: 'consumption-2', dispatchId: 'dispatch-2', consumedAtMs: EXPIRES_AT, }), ), ApprovalRequestExpiredError, ); }); test('exact mutation replays are idempotent and drift conflicts', async (t) => { const { service } = await setup(t); const pending = await service.create(createInput()); assert.deepEqual(await service.create(createInput()), pending); await assert.rejects( service.create(createInput({ risk: 'critical' })), ApprovalMutationConflictError, ); const approved = await service.decide(decisionInput()); assert.deepEqual(await service.decide(decisionInput()), approved); await assert.rejects( service.decide(decisionInput({ reasonCode: 'different_review' })), ApprovalMutationConflictError, ); const consumed = await service.consume(consumptionInput()); assert.deepEqual(await service.consume(consumptionInput()), consumed); assert.deepEqual(await service.create(createInput()), consumed.request); await assert.rejects( service.consume( consumptionInput({ action: action({ actionDigest: 'c'.repeat(64) }) }), ), ApprovalMutationConflictError, ); }); test('policy versions fence every mutation against revocation races', async (t) => { const { policyRepository, policy, repository } = await setup(t); const authorization = await policy.decideWithFence({ projectId: PROJECT_ID, subject: AGENT, permission: action().permission, }); assert.equal(authorization.decision.effect, 'require_approval'); await policyRepository.append({ expectedCurrentVersion: 1, binding: { projectId: PROJECT_ID, subject: AGENT, version: 2, state: 'revoked', mutationId: 'revoke-agent', changedBy: OWNER, createdAtMs: NOW, }, }); const request = { ...createInput(), version: 1, state: 'pending', decisionId: null, decision: null, decisionReasonCode: null, decidedBy: null, decidedAtMs: null, consumptionId: null, dispatchId: null, consumedBy: null, consumedAtMs: null, }; await assert.rejects( repository.create({ request, authorizationFence: authorization.fence, }), ApprovalPolicyFenceConflictError, ); }); test('two SQLite connections allow only one decision and one consumption', async (t) => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-approval-')); t.after(() => fs.rm(directory, { recursive: true, force: true })); const storage = path.join(directory, 'database.sqlite'); const first = await setup(t, storage); const secondDatabase = new Sequelize({ dialect: 'sqlite', storage, logging: false, }); t.after(() => secondDatabase.close()); const secondPolicyRepository = new LegacySequelizeProjectPolicyRepository( secondDatabase, ); const secondRepository = new LegacySequelizeApprovalRequestRepository( secondDatabase, ); const secondService = new ApprovalRequestService( secondRepository, new ProjectPolicyEngine(secondPolicyRepository), ); await first.service.create(createInput()); const decisions = await Promise.allSettled([ first.service.decide(decisionInput()), secondService.decide( decisionInput({ decisionId: 'decision-racer', reasonCode: 'racer' }), ), ]); assert.equal( decisions.filter((entry) => entry.status === 'fulfilled').length, 1, ); assert.equal( decisions.filter((entry) => entry.status === 'rejected').length, 1, ); assert.ok( decisions .filter((entry) => entry.status === 'rejected') .every( (entry) => entry.reason instanceof ApprovalRequestVersionConflictError || entry.reason instanceof ApprovalRequestStateConflictError, ), ); const approved = await first.repository.findById('approval-1'); const winnerDecisionId = approved.decisionId; const winnerDecision = winnerDecisionId === 'decision-1' ? decisionInput() : decisionInput({ decisionId: 'decision-racer', reasonCode: 'racer' }); assert.deepEqual(await first.service.decide(winnerDecision), approved); const consumptions = await Promise.allSettled([ first.service.consume(consumptionInput()), secondService.consume( consumptionInput({ consumptionId: 'consumption-racer', dispatchId: 'dispatch-racer', }), ), ]); assert.equal( consumptions.filter((entry) => entry.status === 'fulfilled').length, 1, ); assert.equal( consumptions.filter((entry) => entry.status === 'rejected').length, 1, ); assert.equal( ( await first.database .getQueryInterface() .select(null, APPROVED_ACTION_DISPATCH_TABLE) ).length, 1, ); }); test('dispatch collisions roll back consumption and leave approval reusable', async (t) => { const { database, service, repository } = await setup(t); await service.create(createInput()); await service.decide(decisionInput()); await service.create( createInput({ id: 'approval-2', action: action({ actionRef: 'planned-action-2' }), }), ); await service.decide( decisionInput({ requestId: 'approval-2', decisionId: 'decision-2' }), ); await service.consume(consumptionInput()); await assert.rejects( service.consume( consumptionInput({ requestId: 'approval-2', consumptionId: 'consumption-2', dispatchId: 'dispatch-1', action: action({ actionRef: 'planned-action-2' }), }), ), ApprovalMutationConflictError, ); assert.equal((await repository.findById('approval-2')).state, 'approved'); assert.equal( ( await database .getQueryInterface() .select(null, APPROVED_ACTION_DISPATCH_TABLE) ).length, 1, ); });