OSVauco/opax-web/backend/socketHandlers.test.js
Chris Christiansen dc9f7b6626 feat(opax-web): add read-only Git BFF and hardened UI
Replace the generic mcp-call socket path with two authenticated, read-only Git actions. Add response normalization and error masking, a purpose-built frontend, container build configuration, and socket handler unit tests.
2026-09-09 17:26:07 +00:00

205 lines
7.7 KiB
JavaScript

import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { registerBrowserSocketHandlers } from './socketHandlers.js';
// --- Test-hjelpere ---
class FakeSocket {
constructor(user = null) {
this.request = { session: { user } };
this.handlers = new Map();
this.emitted = [];
}
on(eventName, handler) {
this.handlers.set(eventName, handler);
}
emit(eventName, payload) {
this.emitted.push({ eventName, payload });
}
async trigger(eventName, payload) {
const handler = this.handlers.get(eventName);
if (handler) {
await handler(payload);
}
}
findEmitted(eventName) {
return this.emitted.find(e => e.eventName === eventName);
}
}
function createFakeCallAgent() {
const fakeCallAgent = async (tool, args) => {
fakeCallAgent.calls.push({ tool, args });
if (fakeCallAgent.nextError) {
throw fakeCallAgent.nextError;
}
return fakeCallAgent.nextResult;
};
fakeCallAgent.calls = [];
fakeCallAgent.nextResult = null;
fakeCallAgent.nextError = null;
fakeCallAgent.setNextResult = (result) => {
fakeCallAgent.nextResult = result;
fakeCallAgent.nextError = null;
};
fakeCallAgent.setNextError = (error) => {
fakeCallAgent.nextError = error;
fakeCallAgent.nextResult = null;
};
return fakeCallAgent;
}
// --- Tester ---
describe('registerBrowserSocketHandlers', () => {
let fakeSocket;
let fakeCallAgent;
beforeEach(() => {
fakeCallAgent = createFakeCallAgent();
});
describe('for unauthenticated user', () => {
beforeEach(() => {
fakeSocket = new FakeSocket(null); // Ingen bruker i session
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('git:getStatus is rejected and does not call agent', async () => {
await fakeSocket.trigger('git:getStatus');
const errorEvent = fakeSocket.findEmitted('git:status:error');
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, 1);
});
it('git:getBranch is rejected and does not call agent', async () => {
await fakeSocket.trigger('git:getBranch');
const errorEvent = fakeSocket.findEmitted('git:branch:error');
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, 1);
});
});
describe('for authenticated user', () => {
beforeEach(() => {
fakeSocket = new FakeSocket({ email: 'test@example.com' });
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('git:getStatus calls agent correctly and returns normalized data', async () => {
const mcpResult = { status: 'dirty', details: 'M file.txt', secret: 'should-be-stripped' };
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getStatus');
assert.strictEqual(fakeCallAgent.calls.length, 1);
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_status', args: {} });
const resultEvent = fakeSocket.findEmitted('git:status:result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload.data, { status: 'dirty', details: 'M file.txt' });
});
it('git:getBranch calls agent correctly and returns normalized data', async () => {
const mcpResult = { current_branch: 'main', url: 'https://internal.example.invalid' };
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getBranch');
assert.strictEqual(fakeCallAgent.calls.length, 1);
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_branch', args: {} });
const resultEvent = fakeSocket.findEmitted('git:branch:result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload.data, { current_branch: 'main' });
});
it('emits static error if git:getStatus agent response is malformed', async () => {
const malformedResults = [null, {}, [], 'a string', { status: 123, details: 'a' }];
for (const mcpResult of malformedResults) {
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getStatus');
}
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:status:error'));
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente Git-status.'));
});
it('emits static error if git:getBranch agent response is malformed', async () => {
const malformedResults = [null, {}, [], 'a string', { current_branch: 123 }];
for (const mcpResult of malformedResults) {
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getBranch');
}
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:branch:error'));
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente aktiv branch.'));
});
it('masks agent error for git:getStatus and returns static message', async () => {
const internalError = new Error('Contains test-secret-must-not-leak');
fakeCallAgent.setNextError(internalError);
await fakeSocket.trigger('git:getStatus');
const errorEvent = fakeSocket.findEmitted('git:status:error');
assert.ok(errorEvent, 'Expected an error event');
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente Git-status.' });
assert.strictEqual(fakeSocket.findEmitted('git:status:result'), undefined);
});
it('masks agent error for git:getBranch and returns static message', async () => {
const internalError = new Error('Another secret for https://internal.example.invalid');
fakeCallAgent.setNextError(internalError);
await fakeSocket.trigger('git:getBranch');
const errorEvent = fakeSocket.findEmitted('git:branch:error');
assert.ok(errorEvent, 'Expected an error event');
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente aktiv branch.' });
assert.strictEqual(fakeSocket.findEmitted('git:branch:result'), undefined);
});
});
describe('legacy mcp-call handler', () => {
beforeEach(() => {
fakeSocket = new FakeSocket({ email: 'test@example.com' });
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('rejects calls and does not call agent', async () => {
const payload = { id: 'test-id', tool: 'commit_and_push_files', args: { message: 'evil' } };
await fakeSocket.trigger('mcp-call', payload);
assert.strictEqual(fakeCallAgent.calls.length, 0);
const resultEvent = fakeSocket.findEmitted('mcp-result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload, { id: 'test-id', error: 'Denne funksjonen er ikke tilgjengelig.' });
});
it('handles malformed payloads gracefully and uses null id', async () => {
const malformed = [null, 'a string', { id: 'a'.repeat(129) }];
for (const payload of malformed) {
await fakeSocket.trigger('mcp-call', payload);
}
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, malformed.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'mcp-result'));
assert.ok(fakeSocket.emitted.every(e => e.payload.id === null));
});
});
});