import { test } from 'node:test'; import assert from 'node:assert/strict'; import { RendezvousService, Store, RendezvousError, redactSecrets } 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'); });