mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
259 lines
8 KiB
TypeScript
259 lines
8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { WorkflowService } from '../services/workflow-service.js';
|
|
import { ValidationError, type WorkflowDefinition } from '../types/workflow.js';
|
|
|
|
function workflow(overrides: Record<string, any> = {}) {
|
|
return {
|
|
id: 'demo-workflow',
|
|
name: 'Demo Workflow',
|
|
version: 1,
|
|
description: 'A workflow for tests',
|
|
variables: { project: 'demo' },
|
|
agents: [{ id: 'agent-1', name: 'Agent 1', model: 'x' }],
|
|
steps: [{ id: 'step-1', type: 'agent', agent: 'agent-1', prompt: 'Do thing' }],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('WorkflowService', () => {
|
|
let tmpDir: string;
|
|
let service: WorkflowService;
|
|
let originalCodexEnv: {
|
|
VERITAS_CODEX_EXECUTABLE?: string;
|
|
CODEX_PATH?: string;
|
|
VERITAS_ALLOW_UNSAFE_CODEX_COMMAND_OVERRIDES?: string;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workflow-service-'));
|
|
service = new WorkflowService(tmpDir);
|
|
originalCodexEnv = {
|
|
VERITAS_CODEX_EXECUTABLE: process.env.VERITAS_CODEX_EXECUTABLE,
|
|
CODEX_PATH: process.env.CODEX_PATH,
|
|
VERITAS_ALLOW_UNSAFE_CODEX_COMMAND_OVERRIDES:
|
|
process.env.VERITAS_ALLOW_UNSAFE_CODEX_COMMAND_OVERRIDES,
|
|
};
|
|
delete process.env.VERITAS_CODEX_EXECUTABLE;
|
|
delete process.env.CODEX_PATH;
|
|
delete process.env.VERITAS_ALLOW_UNSAFE_CODEX_COMMAND_OVERRIDES;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const [key, value] of Object.entries(originalCodexEnv)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('saves, loads, caches, and clears cached workflows', async () => {
|
|
const wf = workflow();
|
|
await service.saveWorkflow(wf as any);
|
|
|
|
const loaded = await service.loadWorkflow('demo-workflow');
|
|
expect(loaded).toMatchObject({ id: 'demo-workflow', name: 'Demo Workflow' });
|
|
|
|
const filePath = path.join(tmpDir, 'demo-workflow.yml');
|
|
const rewritten = (await fs.readFile(filePath, 'utf8')).replace(
|
|
'Demo Workflow',
|
|
'Changed Name'
|
|
);
|
|
await fs.writeFile(filePath, rewritten, 'utf8');
|
|
|
|
expect((await service.loadWorkflow('demo-workflow'))?.name).toBe('Demo Workflow');
|
|
service.clearCache();
|
|
expect((await service.loadWorkflow('demo-workflow'))?.name).toBe('Changed Name');
|
|
});
|
|
|
|
it('lists workflow definitions and metadata from valid workflow files', async () => {
|
|
await service.saveWorkflow(workflow() as any);
|
|
await service.saveWorkflow(
|
|
workflow({
|
|
id: 'second',
|
|
name: 'Second',
|
|
steps: [{ id: 'step-1', type: 'agent', agent: 'agent-1', prompt: 'x' }],
|
|
}) as any
|
|
);
|
|
|
|
expect((await service.listWorkflows()).map((w) => w.id).sort()).toEqual([
|
|
'demo-workflow',
|
|
'second',
|
|
]);
|
|
expect((await service.listWorkflowsMetadata()).map((w) => w.id).sort()).toEqual([
|
|
'demo-workflow',
|
|
'second',
|
|
]);
|
|
});
|
|
|
|
it('deletes workflows and returns null for missing files', async () => {
|
|
await service.saveWorkflow(workflow() as any);
|
|
await service.deleteWorkflow('demo-workflow');
|
|
await expect(service.loadWorkflow('demo-workflow')).resolves.toBeNull();
|
|
});
|
|
|
|
it('validates workflow ids and schema limits', async () => {
|
|
await expect(service.loadWorkflow('../bad')).rejects.toThrow(/illegal path characters/);
|
|
await expect(service.saveWorkflow(workflow({ id: ' bad ' }) as any)).rejects.toThrow(
|
|
ValidationError
|
|
);
|
|
await expect(service.saveWorkflow(workflow({ agents: [] }) as any)).rejects.toThrow(
|
|
/at least one agent/
|
|
);
|
|
await expect(service.saveWorkflow(workflow({ steps: [] }) as any)).rejects.toThrow(
|
|
/at least one step/
|
|
);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
agents: [
|
|
{ id: 'dup', name: 'A' },
|
|
{ id: 'dup', name: 'B' },
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/Duplicate agent IDs/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
steps: [
|
|
{ id: 'dup', type: 'agent', agent: 'agent-1', prompt: 'a' },
|
|
{ id: 'dup', type: 'agent', agent: 'agent-1', prompt: 'b' },
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/Duplicate step IDs/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({ steps: [{ id: 'step-1', type: 'agent', agent: 'missing', prompt: 'x' }] }) as any
|
|
)
|
|
).rejects.toThrow(/unknown agent/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
steps: [
|
|
{
|
|
id: 'step-1',
|
|
type: 'agent',
|
|
agent: 'agent-1',
|
|
prompt: 'x',
|
|
on_fail: { retry_step: 'missing' },
|
|
},
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/retry_step references unknown step/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
steps: [
|
|
{
|
|
id: 'step-1',
|
|
type: 'loop',
|
|
agent: 'agent-1',
|
|
prompt: 'x',
|
|
loop: { verify_step: 'missing' },
|
|
},
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/verify_step references unknown step/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
steps: [
|
|
{
|
|
id: 'step-1',
|
|
type: 'agent',
|
|
agent: 'agent-1',
|
|
prompt: 'x',
|
|
on_fail: { retry_delay_ms: -1 },
|
|
},
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/cannot be negative/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
steps: [
|
|
{
|
|
id: 'step-1',
|
|
type: 'agent',
|
|
agent: 'agent-1',
|
|
prompt: 'x',
|
|
on_fail: { retry_delay_ms: 300001 },
|
|
},
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/exceeds maximum/);
|
|
await expect(service.saveWorkflow(workflow({ name: 'x'.repeat(201) }) as any)).rejects.toThrow(
|
|
/name exceeds maximum/
|
|
);
|
|
await expect(
|
|
service.saveWorkflow(workflow({ description: 'x'.repeat(2001) }) as any)
|
|
).rejects.toThrow(/description exceeds maximum/);
|
|
await expect(
|
|
service.saveWorkflow(
|
|
workflow({
|
|
agents: [
|
|
{ id: 'agent-1', name: 'A', tools: Array.from({ length: 51 }, (_, i) => `t${i}`) },
|
|
],
|
|
}) as any
|
|
)
|
|
).rejects.toThrow(/exceeds maximum of 50 tools/);
|
|
});
|
|
|
|
it('rejects arbitrary Codex command overrides unless explicitly allowlisted', async () => {
|
|
const codexWorkflow = workflow({
|
|
agents: [
|
|
{
|
|
id: 'codex',
|
|
name: 'Codex',
|
|
role: 'developer',
|
|
provider: 'codex-sdk',
|
|
command: '/tmp/not-the-codex-binary',
|
|
description: 'Runs Codex.',
|
|
},
|
|
],
|
|
steps: [{ id: 'step-1', type: 'agent', agent: 'codex', prompt: 'x' }],
|
|
}) as WorkflowDefinition;
|
|
|
|
await expect(service.saveWorkflow(codexWorkflow)).rejects.toThrow(
|
|
/unsafe Codex command override/
|
|
);
|
|
|
|
process.env.VERITAS_CODEX_EXECUTABLE = '/tmp/not-the-codex-binary';
|
|
await expect(service.saveWorkflow(codexWorkflow)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('saves ACLs and audit entries', async () => {
|
|
await service.saveACL({
|
|
workflowId: 'wf-1',
|
|
allowedUsers: ['u1'],
|
|
allowedRoles: ['admin'],
|
|
} as any);
|
|
expect(await service.loadACL('wf-1')).toEqual({
|
|
workflowId: 'wf-1',
|
|
allowedUsers: ['u1'],
|
|
allowedRoles: ['admin'],
|
|
});
|
|
expect(await service.loadACL('missing')).toBeNull();
|
|
|
|
await service.auditChange({
|
|
workflowId: 'wf-1',
|
|
action: 'updated',
|
|
userId: 'u1',
|
|
timestamp: '2026-03-01T00:00:00.000Z',
|
|
} as any);
|
|
const audit = await fs.readFile(path.join(tmpDir, '.audit.jsonl'), 'utf8');
|
|
expect(audit).toContain('"workflowId":"wf-1"');
|
|
});
|
|
});
|