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.
205 lines
7.7 KiB
JavaScript
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));
|
|
});
|
|
});
|
|
});
|