mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
639 lines
24 KiB
JavaScript
639 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
const { describe, it, before, after, beforeEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { MailboxStore } = require('../src/proxy/mailbox/store');
|
|
const { ProxyHttpServer, DEFAULT_PORT } = require('../src/proxy/server/http');
|
|
const { buildRoutes } = require('../src/proxy/server/routes');
|
|
|
|
function tmpDataDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'proxy-test-'));
|
|
}
|
|
|
|
function request(url, method, body, token) {
|
|
return new Promise((resolve, reject) => {
|
|
const u = new URL(url);
|
|
const payload = body ? JSON.stringify(body) : '';
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(payload),
|
|
};
|
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
|
const req = http.request({
|
|
hostname: u.hostname,
|
|
port: u.port,
|
|
path: u.pathname + u.search,
|
|
method,
|
|
headers,
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString();
|
|
try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
|
|
catch { resolve({ status: res.statusCode, body: raw }); }
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
describe('ProxyHttpServer', () => {
|
|
let store, server, baseUrl, dataDir, serverToken;
|
|
let authedReq;
|
|
let savedSettingsDir;
|
|
let settingsDir;
|
|
|
|
before(async () => {
|
|
dataDir = tmpDataDir();
|
|
store = new MailboxStore(dataDir);
|
|
// Redirect proxy settings into a temp dir so server.start() does not
|
|
// write to the shared ~/.evolver/settings.json and race with the webui
|
|
// observer test running in a parallel worker.
|
|
settingsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxy-settings-'));
|
|
savedSettingsDir = process.env.EVOLVER_SETTINGS_DIR;
|
|
process.env.EVOLVER_SETTINGS_DIR = settingsDir;
|
|
|
|
const mockProxyHandlers = {
|
|
assetFetch: async (body) => ({ assets: [], query: body }),
|
|
assetSearch: async (body) => ({ results: [], query: body }),
|
|
assetValidate: async (body) => ({ valid: true, asset_id: body.asset_id || 'test' }),
|
|
assetPublish: async (body) => ({
|
|
published: (body.assets || []).length,
|
|
total: (body.assets || []).length,
|
|
results: (body.assets || []).map(() => ({ ok: true, gene_asset_id: 'sha256:test', capsule_asset_id: 'sha256:test' })),
|
|
}),
|
|
};
|
|
|
|
const routes = buildRoutes(store, mockProxyHandlers, null, {});
|
|
|
|
server = new ProxyHttpServer(routes, { port: 39820, logger: { log: () => {}, error: () => {}, warn: () => {} } });
|
|
const info = await server.start();
|
|
baseUrl = info.url;
|
|
serverToken = info.token;
|
|
authedReq = (url, method, body) => request(url, method, body, serverToken);
|
|
});
|
|
|
|
after(async () => {
|
|
await server.stop();
|
|
store.close();
|
|
try { fs.rmSync(dataDir, { recursive: true }); } catch {}
|
|
try { fs.rmSync(settingsDir, { recursive: true }); } catch {}
|
|
if (savedSettingsDir === undefined) delete process.env.EVOLVER_SETTINGS_DIR;
|
|
else process.env.EVOLVER_SETTINGS_DIR = savedSettingsDir;
|
|
});
|
|
|
|
describe('POST /mailbox/send', () => {
|
|
it('sends a message and returns message_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/send`, 'POST', {
|
|
type: 'asset_submit',
|
|
payload: { data: 'test' },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.message_id);
|
|
assert.equal(res.body.status, 'pending');
|
|
});
|
|
|
|
it('rejects missing type', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/send`, 'POST', {
|
|
payload: { data: 'test' },
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('rejects missing payload', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/send`, 'POST', {
|
|
type: 'test',
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
});
|
|
|
|
describe('POST /mailbox/poll', () => {
|
|
it('returns inbound messages', async () => {
|
|
store.writeInbound({ type: 'poll_test', payload: { x: 1 } });
|
|
const res = await authedReq(`${baseUrl}/mailbox/poll`, 'POST', {
|
|
type: 'poll_test',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.messages.length >= 1);
|
|
});
|
|
});
|
|
|
|
describe('POST /mailbox/ack', () => {
|
|
it('acknowledges messages', async () => {
|
|
const id = store.writeInbound({ type: 'ack_test', payload: {} });
|
|
const res = await authedReq(`${baseUrl}/mailbox/ack`, 'POST', {
|
|
message_ids: [id],
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.acknowledged, 1);
|
|
});
|
|
|
|
it('rejects missing message_ids', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/ack`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
});
|
|
|
|
describe('GET /mailbox/status/:id', () => {
|
|
it('returns message details', async () => {
|
|
const { message_id } = store.send({ type: 'status_test', payload: { test: true } });
|
|
const res = await authedReq(`${baseUrl}/mailbox/status/${message_id}`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.id, message_id);
|
|
assert.equal(res.body.type, 'status_test');
|
|
});
|
|
|
|
it('returns 404 for unknown id', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/status/nonexistent`, 'GET');
|
|
assert.equal(res.status, 404);
|
|
});
|
|
});
|
|
|
|
describe('GET /mailbox/list', () => {
|
|
it('lists messages by type', async () => {
|
|
store.send({ type: 'list_http_test', payload: {} });
|
|
const res = await authedReq(`${baseUrl}/mailbox/list?type=list_http_test`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.messages.length >= 1);
|
|
});
|
|
|
|
it('requires type query param', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/list`, 'GET');
|
|
assert.equal(res.status, 400);
|
|
});
|
|
});
|
|
|
|
describe('POST /asset/submit', () => {
|
|
it('publishes asset submission via the /a2a/publish bundle path', async () => {
|
|
const res = await authedReq(`${baseUrl}/asset/submit`, 'POST', {
|
|
assets: [{ type: 'Gene', content: 'test' }],
|
|
});
|
|
assert.equal(res.status, 200);
|
|
// No longer the gated mailbox path (which returned a queued message_id);
|
|
// routes through proxyHandlers.assetPublish → POST /a2a/publish.
|
|
assert.equal(res.body.published, 1);
|
|
assert.ok(Array.isArray(res.body.results));
|
|
});
|
|
|
|
it('rejects missing assets and asset_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/asset/submit`, 'POST', {
|
|
priority: 'high',
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
});
|
|
|
|
describe('POST /conversation/distill', () => {
|
|
const validConversation = {
|
|
summary: 'Reusable Evolver distill endpoint compatibility workflow for MCP plugin bridges.',
|
|
assistant_summary: 'Added a Proxy conversation distillation bridge so Codex, Claude Code, Cursor, WorkBuddy, and Antigravity plugins can publish Genes and Capsules without hitting a 404.',
|
|
strategy: [
|
|
'Verify each plugin bridge calls the same Proxy route before changing repository code.',
|
|
'Keep the Proxy route on the current signed asset publish path instead of the old mailbox submit path.',
|
|
'Add focused tests for draft distillation, publish forwarding, and low quality skipped inputs.',
|
|
],
|
|
artifacts: ['src/proxy/server/routes.js', 'src/gep/conversationDistiller.js'],
|
|
validation: ['node --test test/proxyServer.test.js'],
|
|
signals: ['distill_endpoint', 'proxy_compatibility', 'test_verified'],
|
|
};
|
|
|
|
it('distills a high-confidence conversation as a draft when publish and persist are disabled', async () => {
|
|
const res = await authedReq(`${baseUrl}/conversation/distill`, 'POST', {
|
|
...validConversation,
|
|
persist: false,
|
|
publish: false,
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.ok, true);
|
|
assert.equal(res.body.status, 'draft');
|
|
assert.equal(res.body.published, false);
|
|
assert.equal(res.body.publish_result, null);
|
|
assert.equal(res.body.gene.type, 'Gene');
|
|
assert.equal(res.body.capsule.type, 'Capsule');
|
|
assert.deepEqual(res.body.capsule.blast_radius, { files: 1, lines: 1 });
|
|
assert.equal(typeof res.body.capsule.content, 'string');
|
|
assert.equal(typeof res.body.capsule.diff, 'string');
|
|
assert.equal(typeof res.body.capsule.reused_asset_id, 'string');
|
|
assert.equal(typeof res.body.capsule.env_fingerprint, 'object');
|
|
});
|
|
|
|
it('publishes distilled Gene and Capsule through the current asset publish handler', async () => {
|
|
const res = await authedReq(`${baseUrl}/conversation/distill`, 'POST', {
|
|
...validConversation,
|
|
persist: false,
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.ok, true);
|
|
assert.equal(res.body.published, true);
|
|
assert.equal(res.body.publish_result.published, 2);
|
|
assert.equal(res.body.publish_result.total, 2);
|
|
});
|
|
|
|
it('skips low-signal conversations instead of publishing noise', async () => {
|
|
const res = await authedReq(`${baseUrl}/conversation/distill`, 'POST', {
|
|
summary: 'too short',
|
|
publish: false,
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.ok, false);
|
|
assert.equal(res.body.status, 'skipped');
|
|
});
|
|
});
|
|
|
|
describe('POST /asset/validate', () => {
|
|
it('validates asset via proxy', async () => {
|
|
const res = await authedReq(`${baseUrl}/asset/validate`, 'POST', {
|
|
asset_id: 'sha256:abc',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.valid, true);
|
|
});
|
|
|
|
it('rejects missing asset_id and assets', async () => {
|
|
const res = await authedReq(`${baseUrl}/asset/validate`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
});
|
|
|
|
describe('GET /asset/submissions', () => {
|
|
it('lists asset submissions with results', async () => {
|
|
store.send({ type: 'asset_submit', payload: { assets: [{ type: 'Gene' }] } });
|
|
const res = await authedReq(`${baseUrl}/asset/submissions`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.ok(Array.isArray(res.body.submissions));
|
|
assert.ok(res.body.count >= 1);
|
|
});
|
|
});
|
|
|
|
describe('POST /asset/fetch', () => {
|
|
it('proxies fetch request (mock)', async () => {
|
|
const res = await authedReq(`${baseUrl}/asset/fetch`, 'POST', {
|
|
asset_ids: ['sha256:abc'],
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.assets);
|
|
});
|
|
});
|
|
|
|
describe('POST /asset/search', () => {
|
|
it('proxies search request (mock)', async () => {
|
|
const res = await authedReq(`${baseUrl}/asset/search`, 'POST', {
|
|
signals: ['log_error'],
|
|
mode: 'semantic',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.results);
|
|
});
|
|
});
|
|
|
|
describe('Task routes', () => {
|
|
it('POST /task/subscribe returns message_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/task/subscribe`, 'POST', {});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.message_id);
|
|
});
|
|
|
|
it('POST /task/claim requires task_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/task/claim`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /task/claim accepts valid task_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/task/claim`, 'POST', { task_id: 'task_123' });
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.message_id);
|
|
});
|
|
|
|
it('POST /task/complete requires task_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/task/complete`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /task/complete accepts valid data', async () => {
|
|
const res = await authedReq(`${baseUrl}/task/complete`, 'POST', {
|
|
task_id: 'task_123',
|
|
asset_id: 'sha256:abc',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.message_id);
|
|
});
|
|
});
|
|
|
|
describe('GET /task/metrics', () => {
|
|
it('returns task metrics (null monitor)', async () => {
|
|
const res = await authedReq(`${baseUrl}/task/metrics`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.subscribed, false);
|
|
});
|
|
});
|
|
|
|
describe('DM routes', () => {
|
|
it('POST /dm/send sends a direct message', async () => {
|
|
const res = await authedReq(`${baseUrl}/dm/send`, 'POST', {
|
|
recipient_node_id: 'node_xyz',
|
|
content: 'Hello from test',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(res.body.message_id);
|
|
});
|
|
|
|
it('POST /dm/send rejects missing recipient', async () => {
|
|
const res = await authedReq(`${baseUrl}/dm/send`, 'POST', {
|
|
content: 'Hello',
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /dm/send rejects missing content', async () => {
|
|
const res = await authedReq(`${baseUrl}/dm/send`, 'POST', {
|
|
recipient_node_id: 'node_xyz',
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /dm/poll returns DM messages', async () => {
|
|
store.writeInbound({ type: 'dm', payload: { content: 'test dm' } });
|
|
const res = await authedReq(`${baseUrl}/dm/poll`, 'POST', {});
|
|
assert.equal(res.status, 200);
|
|
assert.ok(Array.isArray(res.body.messages));
|
|
});
|
|
|
|
it('GET /dm/list lists DM history', async () => {
|
|
const res = await authedReq(`${baseUrl}/dm/list`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.ok(Array.isArray(res.body.messages));
|
|
});
|
|
});
|
|
|
|
describe('GET /proxy/status', () => {
|
|
it('returns proxy status with version info', async () => {
|
|
const res = await authedReq(`${baseUrl}/proxy/status`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.status, 'running');
|
|
assert.ok('outbound_pending' in res.body);
|
|
assert.ok('inbound_pending' in res.body);
|
|
assert.ok(res.body.proxy_protocol_version, 'should include proxy_protocol_version');
|
|
assert.ok(res.body.schema_version, 'should include schema_version');
|
|
assert.match(res.body.proxy_protocol_version, /^\d+\.\d+\.\d+$/);
|
|
});
|
|
|
|
it('surfaces sync and auth diagnostics for local bridge clients', async () => {
|
|
store.setState('last_sync_error', 'hub_401');
|
|
store.setState('hub_auth_status', 'unauthorized');
|
|
store.setState('reauth_backoff_until', '2026-06-15T12:00:00.000Z');
|
|
store.setState('hello_rate_limit_until', '2026-06-15T12:01:00.000Z');
|
|
|
|
const res = await authedReq(`${baseUrl}/proxy/status`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.last_sync_error, 'hub_401');
|
|
assert.equal(res.body.hub_auth_status, 'unauthorized');
|
|
assert.equal(res.body.reauth_backoff_until, '2026-06-15T12:00:00.000Z');
|
|
assert.equal(res.body.hello_rate_limit_until, '2026-06-15T12:01:00.000Z');
|
|
});
|
|
});
|
|
|
|
describe('404 handling', () => {
|
|
it('returns 404 for unknown routes', async () => {
|
|
const res = await authedReq(`${baseUrl}/nonexistent`, 'GET');
|
|
assert.equal(res.status, 404);
|
|
});
|
|
});
|
|
|
|
describe('auth (C3)', () => {
|
|
it('returns 401 with no token', async () => {
|
|
const res = await request(`${baseUrl}/proxy/status`, 'GET');
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
it('returns 401 with wrong token', async () => {
|
|
const res = await request(`${baseUrl}/proxy/status`, 'GET', null, 'wrong-token');
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
it('returns 200 with correct token', async () => {
|
|
const res = await authedReq(`${baseUrl}/proxy/status`, 'GET');
|
|
assert.equal(res.status, 200);
|
|
});
|
|
|
|
it('server.token accessor returns the same token written to settings', () => {
|
|
assert.ok(server.token, 'server.token must be set after start()');
|
|
assert.equal(typeof server.token, 'string');
|
|
assert.equal(server.token.length, 64, 'token is 32 random bytes hex-encoded');
|
|
assert.equal(server.token, serverToken);
|
|
});
|
|
});
|
|
|
|
describe('body size cap (GHSA-7xp7-m392-h92c)', () => {
|
|
// The proxy binds to 127.0.0.1 but any local process (other users on a
|
|
// shared dev box, sibling containers sharing the netns, malicious
|
|
// postinstall scripts) can still hit it. Without a cap, /mailbox/send and
|
|
// /asset/submit would persist multi-GB payloads into messages.jsonl and
|
|
// exhaust disk / OOM the daemon on every restart.
|
|
it('rejects content-length headers above the default cap with 413', async () => {
|
|
// 2 MiB body, default cap is 1 MiB.
|
|
const big = 'x'.repeat(2 * 1024 * 1024);
|
|
const res = await authedReq(`${baseUrl}/mailbox/send`, 'POST', {
|
|
type: 'hub_event',
|
|
payload: { blob: big },
|
|
});
|
|
assert.equal(res.status, 413, 'oversized body must 413');
|
|
assert.ok(res.body && /too large/i.test(res.body.error || ''),
|
|
'response should explain body too large');
|
|
});
|
|
|
|
it('rejects streaming bodies that exceed the cap even when Content-Length lies', async () => {
|
|
// Chunked upload without declared Content-Length: the per-chunk counter
|
|
// must still fire.
|
|
const u = new URL(`${baseUrl}/mailbox/send`);
|
|
const payload = JSON.stringify({ type: 'hub_event', payload: { blob: 'x'.repeat(2 * 1024 * 1024) } });
|
|
const result = await new Promise((resolve, reject) => {
|
|
const req = http.request({
|
|
hostname: u.hostname,
|
|
port: u.port,
|
|
path: u.pathname,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Transfer-Encoding': 'chunked',
|
|
'Authorization': 'Bearer ' + serverToken,
|
|
},
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString();
|
|
try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
|
|
catch { resolve({ status: res.statusCode, body: raw }); }
|
|
});
|
|
});
|
|
req.on('error', (e) => {
|
|
// The server destroys the socket when the cap is hit. Map socket
|
|
// hang-up into the expected reject-by-413 shape for this assertion.
|
|
if (e.code === 'ECONNRESET' || e.code === 'EPIPE') return resolve({ status: 413, body: { error: 'aborted' } });
|
|
reject(e);
|
|
});
|
|
req.write(payload);
|
|
req.end();
|
|
});
|
|
assert.equal(result.status, 413, 'streaming oversized body must 413 or be aborted');
|
|
});
|
|
|
|
it('accepts bodies within the cap', async () => {
|
|
const res = await authedReq(`${baseUrl}/mailbox/send`, 'POST', {
|
|
type: 'hub_event',
|
|
payload: { ok: true },
|
|
});
|
|
assert.ok(res.status === 200 || res.status === 201,
|
|
'small bodies must still pass: got ' + res.status);
|
|
});
|
|
});
|
|
|
|
// Session routes exercised in fallback mode (buildRoutes called with
|
|
// `extensions: {}`, so the route's `if (sessionHandler)` branch is skipped
|
|
// and the new shared normalizers run instead). These tests verify that the
|
|
// fallback path applies the same input validation as the SessionHandler
|
|
// extension, closing the wire-contract inconsistency.
|
|
describe('Session routes (fallback path)', () => {
|
|
it('POST /session/create clamps max_participants to 20', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/create`, 'POST', {
|
|
title: 'Test',
|
|
max_participants: 100,
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const msg = store.getById(res.body.message_id);
|
|
assert.equal(msg.payload.max_participants, 20);
|
|
});
|
|
|
|
it('POST /session/create clamps max_participants to minimum of 2', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/create`, 'POST', {
|
|
title: 'Test',
|
|
max_participants: 0,
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const msg = store.getById(res.body.message_id);
|
|
assert.equal(msg.payload.max_participants, 2);
|
|
});
|
|
|
|
it('POST /session/create slices invite_node_ids to first 10', async () => {
|
|
const ids = Array.from({ length: 15 }, (_, i) => 'n' + i);
|
|
const res = await authedReq(`${baseUrl}/session/create`, 'POST', {
|
|
title: 'Test',
|
|
invite_node_ids: ids,
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const msg = store.getById(res.body.message_id);
|
|
assert.equal(msg.payload.invite_node_ids.length, 10);
|
|
});
|
|
|
|
it('POST /session/create rejects missing title with 400', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/create`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /session/join returns 400 on missing session_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/join`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /session/leave returns 400 on missing session_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/leave`, 'POST', {});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /session/message rejects oversized payload with 400', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/message`, 'POST', {
|
|
session_id: 's1',
|
|
payload: { data: 'x'.repeat(20000) },
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
it('POST /session/delegate normalizes invalid role to builder', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/delegate`, 'POST', {
|
|
session_id: 's1',
|
|
title: 'task',
|
|
role: 'invalid',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const msg = store.getById(res.body.message_id);
|
|
assert.equal(msg.payload.role, 'builder');
|
|
});
|
|
|
|
it('POST /session/submit truncates summary to 200 chars', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/submit`, 'POST', {
|
|
session_id: 's1',
|
|
task_id: 't1',
|
|
summary: 'x'.repeat(300),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const msg = store.getById(res.body.message_id);
|
|
assert.equal(msg.payload.summary.length, 200);
|
|
});
|
|
|
|
it('POST /session/submit returns 400 on missing task_id', async () => {
|
|
const res = await authedReq(`${baseUrl}/session/submit`, 'POST', {
|
|
session_id: 's1',
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('EvoMapProxy._buildBundleFromLooseAsset', () => {
|
|
const { EvoMapProxy } = require('../src/proxy');
|
|
const build = (raw) => EvoMapProxy.prototype._buildBundleFromLooseAsset.call({}, raw);
|
|
|
|
it('turns a loose MCP asset into a Hub-valid Gene+Capsule bundle', () => {
|
|
const { gene, capsule } = build({
|
|
type: 'Gene',
|
|
content: 'A reusable approach long enough to clear the fifty character substance minimum the Hub enforces on assets.',
|
|
signals: ['test_failure'],
|
|
});
|
|
assert.equal(gene.type, 'Gene');
|
|
assert.ok(gene.strategy.length >= 2, 'gene strategy needs >=2 steps');
|
|
assert.match(gene.validation[0], /^node |^npm |^npx /, 'validation must be node/npm/npx');
|
|
assert.doesNotMatch(gene.validation[0], /process\.exit\(0\)/, 'validation must be a real assertion');
|
|
assert.equal(capsule.gene, gene.id, 'capsule references the gene');
|
|
assert.ok(Array.isArray(capsule.trigger), 'capsule trigger is an array');
|
|
assert.ok(capsule.blast_radius.files >= 1 && capsule.blast_radius.lines >= 1);
|
|
assert.ok(capsule.env_fingerprint.platform && capsule.env_fingerprint.arch);
|
|
assert.ok(capsule.content.length >= 50, 'capsule needs >=50 chars of substance');
|
|
});
|
|
|
|
it('rejects an asset with no substantive content', () => {
|
|
assert.throws(() => build({ type: 'Gene', summary: 'too short' }), /content|strategy/);
|
|
});
|
|
|
|
it('preserves caller-supplied strategy and category', () => {
|
|
const { gene } = build({
|
|
type: 'Gene', category: 'optimize',
|
|
strategy: ['Step one that is long enough to count', 'Step two that is also long enough to count'],
|
|
signals: ['perf_bottleneck'],
|
|
});
|
|
assert.equal(gene.category, 'optimize');
|
|
assert.equal(gene.strategy.length, 2);
|
|
assert.deepEqual(gene.signals_match, ['perf_bottleneck']);
|
|
});
|
|
|
|
it('rejects caller strategy whose steps are under 15 chars (Hub gate)', () => {
|
|
assert.throws(() => build({ type: 'Gene', strategy: ['short', 'tiny'], signals: ['x'] }), /strategy.*15 chars/);
|
|
});
|
|
|
|
it('rejects when capsule content resolves below 50 chars', () => {
|
|
// strategy passes (2 steps >=15 chars) but summary + steps still < 50 chars.
|
|
assert.throws(
|
|
() => build({ type: 'Gene', summary: 'hi', strategy: ['aaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbb'] }),
|
|
/content resolves to <50|50 chars/,
|
|
);
|
|
});
|
|
});
|