278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { RendezvousService, Store, RendezvousError, redactSecrets, buildTimeline } from '../src/index.js';
|
|
|
|
function makeService(): { svc: RendezvousService; dbPath: string } {
|
|
const dbPath = `:memory:`;
|
|
const store = new Store(dbPath);
|
|
return { svc: new RendezvousService(store), dbPath };
|
|
}
|
|
|
|
function createTwoPartyRoom(svc: RendezvousService) {
|
|
return svc.createRoom(
|
|
{
|
|
title: '1C <-> app integration',
|
|
goal: 'Agree on exchange contract',
|
|
participants: [
|
|
{ role: 'windows-1c', knows: ['IIS', '1C'], needs_to_determine: ['frequency'] },
|
|
{ role: 'application', knows: ['app code'], needs_to_determine: ['contract'] },
|
|
],
|
|
},
|
|
'http://test.local',
|
|
);
|
|
}
|
|
|
|
test('createRoom returns invite urls and tokens for each participant', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
assert.equal(created.status, 'open');
|
|
assert.equal(created.invite_urls.length, 2);
|
|
assert.match(created.invite_urls[0], /http:\/\/test\.local\/r\/.+\/.+/);
|
|
assert.notEqual(created.participants[0].token, created.participants[1].token);
|
|
assert.ok(created.participants[0].token.length >= 32, 'token must be long and random');
|
|
});
|
|
|
|
test('token of participant A cannot act as participant B context and vice versa works', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const roomId = created.room_id;
|
|
const tokenA = created.participants[0].token;
|
|
const tokenB = created.participants[1].token;
|
|
|
|
const viewA = svc.getRoomView(roomId, tokenA);
|
|
assert.equal(viewA.your_role, 'windows-1c');
|
|
const viewB = svc.getRoomView(roomId, tokenB);
|
|
assert.equal(viewB.your_role, 'application');
|
|
|
|
// wrong room id with a valid token -> forbidden/not found
|
|
assert.throws(() => svc.getRoomView('nosuchroom', tokenA), RendezvousError);
|
|
});
|
|
|
|
test('participant tokens are isolated: a token from another room is rejected', () => {
|
|
const { svc } = makeService();
|
|
const room1 = createTwoPartyRoom(svc);
|
|
const room2 = svc.createRoom(
|
|
{ title: 'other', participants: [{ role: 'x' }, { role: 'y' }] },
|
|
'http://test.local',
|
|
);
|
|
assert.throws(() => svc.getRoomView(room2.room_id, room1.participants[0].token), RendezvousError);
|
|
});
|
|
|
|
test('invalid token rejected, room id alone is not enough', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
assert.throws(() => svc.authenticate(created.room_id, 'forged-token-123'), RendezvousError);
|
|
});
|
|
|
|
test('full negotiation scenario: messages -> questions -> contract -> agree -> final.md', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const roomId = created.room_id;
|
|
const [, B] = created.participants.map((p) => p.token);
|
|
|
|
// A states facts
|
|
svc.postMessage(roomId, created.participants[0].token, 'IIS has /exchange/hs endpoint, auth is basic.');
|
|
|
|
// B asks a blocking question addressed to A
|
|
const q = svc.openQuestion(roomId, B, 'What is the real exchange frequency? Check the 1C job config.', true, created.participants[0].id);
|
|
assert.equal(q.status, 'open');
|
|
assert.equal(q.blocking, true);
|
|
|
|
// A cannot finalize while blocking question open
|
|
svc.proposeContract(roomId, created.participants[0].token, '## Facts\ntodo');
|
|
const agreeWhileOpen = svc.agree(roomId, B);
|
|
assert.equal(agreeWhileOpen.room_status, 'open');
|
|
|
|
// A verifies and resolves
|
|
svc.postMessage(roomId, created.participants[0].token, 'Checked regagent job: every 15 minutes.');
|
|
svc.resolveQuestion(roomId, created.participants[0].token, q.id, 'Verified in 1C job config: every 15 min.');
|
|
|
|
// B proposes final contract, both agree -> agreed
|
|
svc.proposeContract(roomId, B, '## Facts\n- every 15 min\n## Interface\n- POST /exchange/hs');
|
|
svc.agree(roomId, B);
|
|
const final = svc.agree(roomId, created.participants[0].token);
|
|
assert.equal(final.room_status, 'agreed');
|
|
|
|
const view = svc.getRoomView(roomId, B);
|
|
assert.equal(view.room_status, 'agreed');
|
|
assert.ok(view.what_you_should_do_next.includes('final'));
|
|
|
|
// room is closed for writes
|
|
assert.throws(() => svc.postMessage(roomId, B, 'late message'), (e: RendezvousError) => e.code === 'conflict');
|
|
});
|
|
|
|
test('TTL cleanup deletes rooms, messages, tokens and artifacts', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const roomId = created.room_id;
|
|
const [, B] = created.participants.map((p) => p.token);
|
|
svc.postMessage(roomId, B, 'hello');
|
|
const q = svc.openQuestion(roomId, B, 'q?', true, created.participants[0].id);
|
|
svc.proposeContract(roomId, B, '## Facts\n- x');
|
|
|
|
// force-expire the room directly in storage
|
|
svc.store.db
|
|
.prepare('UPDATE rooms SET expires_at = ? WHERE id = ?')
|
|
.run(new Date(Date.now() - 1000).toISOString(), roomId);
|
|
|
|
const removed = svc.cleanupExpired();
|
|
assert.equal(removed, 1);
|
|
|
|
const count = (table: string) =>
|
|
(svc.store.db.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE room_id = ?`).get(roomId) as { c: number }).c;
|
|
assert.equal((svc.store.db.prepare('SELECT COUNT(*) AS c FROM rooms').get() as { c: number }).c, 0);
|
|
assert.equal(count('participants'), 0);
|
|
assert.equal(count('messages'), 0);
|
|
assert.equal(count('questions'), 0);
|
|
assert.equal(count('contract_revisions'), 0);
|
|
|
|
// everything is gone: token no longer authenticates
|
|
assert.throws(() => svc.getRoomView(roomId, B), RendezvousError);
|
|
assert.equal(svc.cleanupExpired(), 0, 'second run is a no-op');
|
|
});
|
|
|
|
test('limits: ttl > 24h rejected, oversize message rejected, 1 participant rejected', () => {
|
|
const { svc } = makeService();
|
|
assert.throws(
|
|
() => svc.createRoom({ title: 'x', ttl_hours: 25, participants: [{ role: 'a' }, { role: 'b' }] }, 'http://x'),
|
|
RendezvousError,
|
|
);
|
|
assert.throws(() => svc.createRoom({ title: 'x', participants: [{ role: 'a' }] }, 'http://x'), RendezvousError);
|
|
const created = createTwoPartyRoom(svc);
|
|
assert.throws(
|
|
() => svc.postMessage(created.room_id, created.participants[0].token, 'a'.repeat(33 * 1024)),
|
|
RendezvousError,
|
|
);
|
|
});
|
|
|
|
test('destroyRoom: allowed for participant and observer, rejected for strangers', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const roomId = created.room_id;
|
|
const tokenA = created.participants[0].token;
|
|
const observerToken = created.observer_url.split('/').pop()!;
|
|
// stranger
|
|
assert.throws(() => svc.destroyRoom(roomId, 'forged-token'), RendezvousError);
|
|
// observer may read and destroy
|
|
const view = svc.getObserverView(roomId, observerToken);
|
|
assert.ok(view.turn.length > 0);
|
|
svc.destroyRoom(roomId, observerToken);
|
|
assert.throws(() => svc.getRoomView(roomId, tokenA), RendezvousError, 'room is gone');
|
|
|
|
// participant may also destroy
|
|
const c2 = createTwoPartyRoom(svc);
|
|
svc.destroyRoom(c2.room_id, c2.participants[1].token);
|
|
assert.throws(() => svc.getRoomView(c2.room_id, c2.participants[0].token), RendezvousError);
|
|
});
|
|
|
|
test('join: creator auto-joined, invitee reports once and idempotently', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const [, tokenB] = created.participants.map((p) => p.token);
|
|
|
|
const viewA = svc.getRoomView(created.room_id, created.participants[0].token);
|
|
assert.ok(viewA.participants[0].joined_at, 'creator is joined from the start');
|
|
assert.equal(viewA.participants[1].joined_at, null);
|
|
|
|
// B's advice tells it to join first
|
|
const viewB = svc.getRoomView(created.room_id, tokenB);
|
|
assert.match(viewB.what_you_should_do_next, /join/);
|
|
|
|
const first = svc.join(created.room_id, tokenB).participants.find((p) => p.role === 'application')!;
|
|
const ts = first.joined_at!;
|
|
assert.ok(ts);
|
|
// idempotent: timestamp does not change
|
|
const again = svc.join(created.room_id, tokenB).participants.find((p) => p.role === 'application')!;
|
|
assert.equal(again.joined_at, ts);
|
|
});
|
|
|
|
test('redactSecrets strips obvious secret values (best-effort)', () => {
|
|
const input = [
|
|
'db password: hunter2secret123',
|
|
'token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N65IWDpmNfXQ',
|
|
'key sk-abcdefabcdefabcdefabcdef',
|
|
'-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAsecret\n-----END RSA PRIVATE KEY-----',
|
|
'ok: the db password lives in vault prod/db, deliver via scp',
|
|
].join('\n');
|
|
const out = redactSecrets(input);
|
|
assert.ok(!out.includes('hunter2secret123'), 'password value must be redacted');
|
|
assert.ok(!out.includes('eyJhbGciOi'), 'JWT must be redacted');
|
|
assert.ok(!out.includes('sk-abcdefabcdef'), 'prefixed token must be redacted');
|
|
assert.ok(!out.includes('MIIEpAIBAAKCAsecret'), 'private key must be redacted');
|
|
assert.ok(out.includes('[REDACTED'), 'redaction marker present');
|
|
// safe discussion is untouched
|
|
assert.ok(out.includes('lives in vault prod/db'), 'name/source discussion kept');
|
|
// and redaction is applied on the way into a room
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const msg = svc.postMessage(created.room_id, created.participants[0].token, 'api_key: supersecretvalue99');
|
|
assert.ok(!msg.content.includes('supersecretvalue99'));
|
|
assert.ok(msg.content.includes('[REDACTED'));
|
|
});
|
|
|
|
test('only addressee or author can resolve a question', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const [A, B] = created.participants.map((p) => p.token);
|
|
const q = svc.openQuestion(created.room_id, B, 'check logs', true, created.participants[0].id);
|
|
// a random third participant would be needed to test "other"; with 2
|
|
// participants the author/addressee rule reduces to both being allowed.
|
|
const resolved = svc.resolveQuestion(created.room_id, A, q.id, 'checked: ok');
|
|
assert.equal(resolved.status, 'resolved');
|
|
});
|
|
|
|
test('activity timeline includes every action of every participant', () => {
|
|
const { svc } = makeService();
|
|
const created = createTwoPartyRoom(svc);
|
|
const [A, B] = created.participants.map((p) => p.token);
|
|
svc.join(created.room_id, A);
|
|
svc.join(created.room_id, B);
|
|
svc.postMessage(created.room_id, A, 'fact from A');
|
|
const q = svc.openQuestion(created.room_id, B, 'check IIS logs', true, created.participants[0].id);
|
|
svc.resolveQuestion(created.room_id, A, q.id, 'checked: every 15 min');
|
|
svc.proposeContract(created.room_id, B, '## Facts\n- ok');
|
|
svc.proposeContract(created.room_id, A, '## Facts\n- ok\n## Decisions\n- v2');
|
|
svc.agree(created.room_id, A);
|
|
svc.agree(created.room_id, B);
|
|
|
|
const view = svc.getRoomView(created.room_id, A);
|
|
const timeline = buildTimeline({
|
|
participants: view.participants,
|
|
conversation: view.conversation,
|
|
openQuestions: view.open_questions,
|
|
resolvedQuestions: view.resolved_questions,
|
|
contract: view.current_contract,
|
|
});
|
|
const kinds: string[] = timeline.map((e) => e.kind);
|
|
for (const expected of ['joined', 'joined', 'message', 'asked', 'resolved', 'contract', 'agreed', 'agreed'])
|
|
assert.ok(kinds.includes(expected), `timeline must contain "${expected}", got: ${kinds.join(',')}`);
|
|
// chronological order
|
|
for (let i = 1; i < timeline.length; i++)
|
|
assert.ok(timeline[i - 1].at <= timeline[i].at, 'timeline must be sorted by time');
|
|
// every event is attributed to a role
|
|
for (const e of timeline) assert.ok(e.role.length > 0 && e.action.length > 0);
|
|
// resolution is attributed to the RESOLVER (A), not the question author (B)
|
|
const roleA = created.participants.find((p) => p.token === A)!.role;
|
|
const roleB = created.participants.find((p) => p.token === B)!.role;
|
|
const resolved = timeline.find((e) => e.kind === 'resolved')!;
|
|
assert.equal(resolved.role, roleA);
|
|
assert.ok(resolved.action.includes(`'s question`), 'resolved action should name the author');
|
|
// contract proposal is attributed to the proposer (B)
|
|
const contractEv = timeline.find((e) => e.kind === 'contract')!;
|
|
assert.equal(contractEv.role, roleB);
|
|
// a second revision is its own timeline event with its own author
|
|
const view2 = svc.getRoomView(created.room_id, A);
|
|
const timeline2 = buildTimeline({
|
|
participants: view2.participants,
|
|
conversation: view2.conversation,
|
|
openQuestions: view2.open_questions,
|
|
resolvedQuestions: view2.resolved_questions,
|
|
contract: view2.current_contract,
|
|
});
|
|
const contractEvents = timeline2.filter((e) => e.kind === 'contract');
|
|
assert.equal(contractEvents.length, 2, 'each contract revision is a timeline event');
|
|
assert.equal(contractEvents[0].action, 'proposed contract v1');
|
|
assert.equal(contractEvents[1].action, 'proposed contract v2');
|
|
assert.notEqual(contractEvents[0].role, contractEvents[1].role, 'authors differ per revision');
|
|
});
|