deploy / deploy (push) Canceled after 0s
- /security.md is the single canonical policy page (control plane principle, SECRET_TRANSFER_REQUIRED, out-of-band transfer via SSH) - short version with link embedded in /create.md, room .md, llms.txt, landing, MCP create tool response; docs updated - redactSecrets() applied on input to messages, questions, resolutions, contracts and room brief/goal/participant instructions (best-effort: PEM keys, JWTs, common token prefixes, password/token/secret assignments)
192 lines
9.6 KiB
TypeScript
192 lines
9.6 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createApp } from '../src/index.js';
|
|
|
|
async function startServer() {
|
|
const port = 30000 + Math.floor(Math.random() * 10000);
|
|
const app = createApp({ port, baseUrl: `http://127.0.0.1:${port}`, dbPath: ':memory:', cleanupIntervalMs: 3_600_000 });
|
|
await new Promise<void>((r) => app.server.listen(port, r));
|
|
return { app, baseUrl: `http://127.0.0.1:${port}` };
|
|
}
|
|
|
|
const ROOM_PAYLOAD = {
|
|
title: '1C <-> app integration contract',
|
|
goal: 'Agree endpoints, schedule, auth',
|
|
participants: [
|
|
{ role: 'windows-1c', knows: ['IIS', '1C', 'Windows logs'], needs_to_determine: ['frequency'] },
|
|
{ role: 'application', knows: ['app code'], needs_to_determine: ['contract'] },
|
|
],
|
|
};
|
|
|
|
test('HTTP integration: create -> markdown invite -> negotiate -> finalize -> final.md', async () => {
|
|
const { app, baseUrl } = await startServer();
|
|
try {
|
|
// 1. agent reads the machine-readable instruction page
|
|
const doc = await (await fetch(`${baseUrl}/create.md`)).text();
|
|
assert.match(doc, /POST .*\/api\/rooms/);
|
|
|
|
// 2. creates the room
|
|
const created = (await (await fetch(`${baseUrl}/api/rooms`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(ROOM_PAYLOAD),
|
|
})).json()) as any;
|
|
assert.equal(created.invite_urls.length, 2);
|
|
const [aUrl, bUrl] = created.invite_urls;
|
|
const tokenA = aUrl.split('/').pop();
|
|
const tokenB = bUrl.split('/').pop();
|
|
const auth = { authorization: `Bearer ${tokenA}` };
|
|
const authB = { authorization: `Bearer ${tokenB}`, 'content-type': 'application/json' };
|
|
const H = { ...auth, 'content-type': 'application/json' };
|
|
|
|
// 3. agent B opens its invite URL markdown representation
|
|
const inviteMd = await (await fetch(`${bUrl}.md`)).text();
|
|
assert.match(inviteMd, /AI Rendezvous/);
|
|
assert.match(inviteMd, /application/);
|
|
|
|
// 4. A states facts
|
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { method: 'POST', headers: H, body: JSON.stringify({ content: 'IIS exposes /exchange/hs with basic auth.' }) });
|
|
|
|
// 5. B asks a blocking question
|
|
const q = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, {
|
|
method: 'POST', headers: authB,
|
|
body: JSON.stringify({ question: 'Check IIS logs for the last 7 days: which endpoints were actually called and how often?', blocking: true, addressed_to_participant_id: created.participants[0].id }),
|
|
})).json()) as any;
|
|
assert.equal(q.status, 'open');
|
|
|
|
// 6. room view tells A what to do next
|
|
const viewA = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: auth })).json()) as any;
|
|
assert.equal(viewA.your_role, 'windows-1c');
|
|
assert.match(viewA.what_you_should_do_next, /blocking question/i);
|
|
assert.ok(Array.isArray(viewA.available_actions) && viewA.available_actions.length > 0);
|
|
|
|
// 7. A verifies and resolves
|
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { method: 'POST', headers: H, body: JSON.stringify({ content: 'IIS logs: /exchange/hs called every 15 min by svc_exchange.' }) });
|
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions/${q.id}/resolve`, { method: 'POST', headers: H, body: JSON.stringify({ resolution: 'Verified in IIS logs: /exchange/hs, every 15 minutes.' }) });
|
|
|
|
// 8. B proposes contract, both agree
|
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/contract`, {
|
|
method: 'PUT', headers: authB,
|
|
body: JSON.stringify({ markdown: '## Facts\n- /exchange/hs every 15 min\n## Interface\nPOST /exchange/hs\n## Authentication\nBasic auth\n## Error handling\nHTTP 5xx -> retry 3x\n## Unresolved\nnone' }),
|
|
});
|
|
const agree1 = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/agree`, { method: 'POST', headers: authB, body: '{}' })).json()) as any;
|
|
assert.equal(agree1.room_status, 'open');
|
|
const agree2 = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/agree`, { method: 'POST', headers: H, body: '{}' })).json()) as any;
|
|
assert.equal(agree2.room_status, 'agreed');
|
|
|
|
// 9. final markdown artifact
|
|
const final = await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/final.md`, { headers: auth })).text();
|
|
assert.match(final, /Agreed Contract/);
|
|
assert.match(final, /exchange\/hs/);
|
|
|
|
// 10. token isolation: tokenB cannot use room of another room; forged token rejected
|
|
const forged = await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: { authorization: 'Bearer nope' } });
|
|
assert.equal(forged.status, 403);
|
|
// room id without token -> forbidden
|
|
const noToken = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
|
assert.equal(noToken.status, 403);
|
|
} finally {
|
|
app.close();
|
|
}
|
|
});
|
|
|
|
test('HTTP: cannot read room by id without token; unknown routes 404; human pages render', async () => {
|
|
const { app, baseUrl } = await startServer();
|
|
try {
|
|
const created = (await (await fetch(`${baseUrl}/api/rooms`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
|
|
})).json()) as any;
|
|
|
|
// observer URL: read-only view with turn indicator
|
|
assert.ok(created.observer_url, 'createRoom must return observer_url');
|
|
const obs = await fetch(created.observer_url);
|
|
assert.equal(obs.status, 200);
|
|
const obsHtml = await obs.text();
|
|
assert.match(obsHtml, /observer/i);
|
|
assert.match(obsHtml, /Whose turn/);
|
|
const obsMd = await (await fetch(`${created.observer_url}.md`)).text();
|
|
assert.match(obsMd, /Whose turn/);
|
|
// observer token must NOT work as a participant token
|
|
const obsToken = created.observer_url.split('/').pop();
|
|
const write = await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, {
|
|
method: 'POST', headers: { authorization: `Bearer ${obsToken}`, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ content: 'hi' }),
|
|
});
|
|
assert.equal(write.status, 403);
|
|
// wrong observer token -> forbidden
|
|
assert.equal((await fetch(`${baseUrl}/o/${created.room_id}/nope`)).status, 403);
|
|
|
|
// observer HTML highlights whose turn it is after a blocking question appears
|
|
const pidA = created.participants[0].id;
|
|
const tokB = created.invite_urls[1].split('/').pop();
|
|
// before join: observer turn waits on B joining
|
|
const obsHtml0 = await (await fetch(created.observer_url)).text();
|
|
assert.match(obsHtml0, /invite not confirmed/);
|
|
// B reports taking the room into work
|
|
const j = await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/join`, {
|
|
method: 'POST', headers: { authorization: `Bearer ${tokB}` },
|
|
})).json();
|
|
assert.equal((j as any).joined, true);
|
|
await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, {
|
|
method: 'POST', headers: { authorization: `Bearer ${tokB}`, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ question: 'verify X on your side', blocking: true, addressed_to_participant_id: pidA }),
|
|
});
|
|
const obsHtml2 = await (await fetch(created.observer_url)).text();
|
|
assert.match(obsHtml2, /turn-role/);
|
|
assert.match(obsHtml2, /windows-1c/);
|
|
|
|
const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
|
assert.equal(r.status, 403);
|
|
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);
|
|
assert.equal(r2.status, 200);
|
|
const r3 = await fetch(`${baseUrl}/api/nosuch`);
|
|
assert.equal(r3.status, 404);
|
|
|
|
const html = await (await fetch(`${baseUrl}/create`)).text();
|
|
assert.match(html, /AI Rendezvous/);
|
|
const landing = await (await fetch(baseUrl)).text();
|
|
assert.match(landing, /Meet\. Verify\. Agree\. Disappear\./);
|
|
assert.match(landing, /How it works/);
|
|
const llms = await (await fetch(`${baseUrl}/llms.txt`)).text();
|
|
assert.match(llms, /^# http/ms);
|
|
assert.match(llms, /\/api\/rooms/);
|
|
assert.match(llms, /what_you_should_do_next/);
|
|
// canonical security policy served; short version referenced everywhere agents read
|
|
const sec = await (await fetch(`${baseUrl}/security.md`)).text();
|
|
assert.match(sec, /control plane/);
|
|
assert.match(sec, /SECRET_TRANSFER_REQUIRED/);
|
|
assert.match(llms, /security\.md/);
|
|
const createMd = await (await fetch(`${baseUrl}/create.md`)).text();
|
|
assert.match(createMd, /security\.md/);
|
|
assert.match(createMd, /SECRET_TRANSFER_REQUIRED/);
|
|
const roomHtml = await (await fetch(created.invite_urls[0])).text();
|
|
assert.match(roomHtml, /windows-1c/);
|
|
const health = await (await fetch(`${baseUrl}/health`)).json();
|
|
assert.ok(health.ok);
|
|
|
|
// any member can destroy (checked last — it deletes the room)
|
|
const del = await fetch(`${baseUrl}/api/rooms/${created.room_id}`, {
|
|
method: 'DELETE', headers: { authorization: `Bearer ${obsToken}` },
|
|
});
|
|
assert.equal(del.status, 200);
|
|
assert.equal(
|
|
(await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: { authorization: `Bearer ${created.participants[0].token}` } })).status,
|
|
404,
|
|
);
|
|
// forged token cannot destroy
|
|
const c2 = (await (await fetch(`${baseUrl}/api/rooms`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
|
|
})).json()) as any;
|
|
assert.equal(
|
|
(await fetch(`${baseUrl}/api/rooms/${c2.room_id}`, { method: 'DELETE', headers: { authorization: 'Bearer nope' } })).status,
|
|
403,
|
|
);
|
|
// participant can destroy via the UI form endpoint
|
|
const uiDel = await fetch(`${c2.invite_urls[0]}/destroy`, { method: 'POST' });
|
|
assert.equal(uiDel.status, 200);
|
|
assert.match(await uiDel.text(), /destroyed/i);
|
|
} finally {
|
|
app.close();
|
|
}
|
|
});
|