bytelyst-devops-tools/dashboard/backend/src/modules/deployments/orchestrator.test.ts
Hermes VM 18180aab78 test(dashboard): Phase 5 P1 — auth/csrf/health/orchestrator tests + coverage gate
Closes the Phase 5 P1 testing checkbox. Adds 35 new unit tests across the
modules called out in the roadmap and wires a v8 coverage gate into CI.

Coverage of newly-tested files (lines / branches):
  lib/auth.ts                          94.4% / 100%
  lib/csrf.ts                          95.1% /  90%
  modules/health/repository.ts          100% /  92%
  modules/deployments/orchestrator.ts  95.2% /  74%
  modules/services/repository.ts        100% / 100%
  modules/hermes-ops/repository.ts     95.2% /  68%

Threshold (lines/funcs/stmts ≥85%, branches ≥65%) is scoped to those six
files via `coverage.include` so untested legacy modules (vm, system,
audit, route handlers) report but don't gate. Add files there as they
gain real tests — ratchet up, never relax.

Test approach mirrors the existing services/hermes-ops suites: hoisted
mocks for I/O (fetch, child_process, fs/promises, cosmos-init), real
JOSE-signed JWTs for the auth path, fake timers for cache TTL and CSRF
expiry assertions.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 06:56:16 +00:00

144 lines
5.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Service } from '../services/types.js';
// --- I/O mocks. Hoisted so vi.mock factories below can see them. ---------------
const execMock = vi.hoisted(() => vi.fn());
vi.mock('child_process', () => ({ exec: execMock }));
const createDeploymentMock = vi.hoisted(() => vi.fn());
const updateDeploymentMock = vi.hoisted(() => vi.fn());
vi.mock('./repository.js', () => ({
createDeployment: createDeploymentMock,
updateDeployment: updateDeploymentMock,
}));
const getServiceByIdMock = vi.hoisted(() => vi.fn());
const updateServiceMock = vi.hoisted(() => vi.fn());
vi.mock('../services/repository.js', () => ({
getServiceById: getServiceByIdMock,
updateService: updateServiceMock,
}));
vi.mock('../../lib/config.js', () => ({
config: {},
productId: 'devops-internal',
}));
const { triggerDeployment } = await import('./orchestrator.js');
function makeService(overrides?: Partial<Service>): Service {
return {
id: 'svc-1',
name: 'Test Service',
scriptPath: 'deploy.sh',
healthUrl: 'https://example.com/health',
repoPath: '../repo',
status: 'up',
version: '1.0.0',
productId: 'devops-internal',
...overrides,
};
}
// promisify(exec) calls exec(cmd, options, cb(err, { stdout, stderr })). Drive
// the callback synchronously off the mock so the deferred script work resolves
// before our awaited assertion.
function setExec(handler: () => { error?: Error & { stdout?: string; stderr?: string }; stdout?: string; stderr?: string }) {
execMock.mockImplementation(
(
_cmd: string,
_opts: unknown,
cb: (err: (Error & { stdout?: string; stderr?: string }) | null, result?: { stdout: string; stderr: string }) => void,
) => {
const res = handler();
if (res.error) cb(res.error);
else cb(null, { stdout: res.stdout ?? '', stderr: res.stderr ?? '' });
},
);
}
describe('triggerDeployment', () => {
beforeEach(() => {
vi.clearAllMocks();
createDeploymentMock.mockImplementation(async (data) => ({ id: 'dep-1', ...data }));
updateDeploymentMock.mockResolvedValue({});
getServiceByIdMock.mockImplementation(async (id) => makeService({ id, version: '0.9.0' }));
updateServiceMock.mockResolvedValue({});
});
it('creates a pending deployment record and returns its id immediately', async () => {
setExec(() => ({ stdout: 'deployed v1.2.3', stderr: '' }));
const id = await triggerDeployment(makeService(), 'tester@bytelyst');
expect(id).toBe('dep-1');
expect(createDeploymentMock).toHaveBeenCalledWith({
serviceId: 'svc-1',
version: 'pending',
triggeredBy: 'tester@bytelyst',
productId: 'devops-internal',
});
});
// Wait for the post-trigger async work to flush. We can't await the inner
// promise directly (orchestrator deliberately fire-and-forgets), so we yield
// ticks until updateDeployment is observed.
async function flushBackground(): Promise<void> {
for (let i = 0; i < 50; i++) {
if (updateDeploymentMock.mock.calls.length > 0) return;
await Promise.resolve();
}
}
it('marks the deployment success and updates the service version on a clean run', async () => {
setExec(() => ({ stdout: 'release version: 2.5.1\n', stderr: '' }));
await triggerDeployment(makeService(), 'tester');
await flushBackground();
const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
expect(finalCall.status).toBe('success');
expect(finalCall.version).toBe('2.5.1');
expect(typeof finalCall.completedAt).toBe('string');
// Service is moved to 'up' with the extracted version.
expect(updateServiceMock).toHaveBeenCalledWith(
'svc-1',
expect.objectContaining({ status: 'up', version: '2.5.1' }),
);
});
it('falls back to version "unknown" when the script logs no recognizable version', async () => {
setExec(() => ({ stdout: 'all good, no numbers here', stderr: '' }));
await triggerDeployment(makeService(), 'tester');
await flushBackground();
const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
expect(finalCall.status).toBe('success');
expect(finalCall.version).toBe('unknown');
});
it('marks the deployment failed and the service down when the script throws', async () => {
const err = Object.assign(new Error('exit 1'), { stdout: 'partial', stderr: 'boom' });
setExec(() => ({ error: err }));
await triggerDeployment(makeService(), 'tester');
await flushBackground();
const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
expect(finalCall.status).toBe('failed');
expect(finalCall.logs).toContain('ERROR: exit 1');
expect(finalCall.logs).toContain('STDERR:\nboom');
expect(finalCall).not.toHaveProperty('version');
expect(updateServiceMock).toHaveBeenCalledWith('svc-1', { status: 'down' });
});
it('does not crash when getServiceById returns null in the success path', async () => {
getServiceByIdMock.mockResolvedValue(null);
setExec(() => ({ stdout: 'version: 1.0.0', stderr: '' }));
await triggerDeployment(makeService(), 'tester');
await flushBackground();
expect(updateServiceMock).not.toHaveBeenCalled();
const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
expect(finalCall.status).toBe('success');
});
});