deploy / deploy (push) Canceled after 0s
- DELETE /api/rooms/:id + destroy button in the header of human pages (participant and observer views), with confirmation - turn indicator now names and highlights the expected participant(s)
178 lines
7.4 KiB
TypeScript
178 lines
7.4 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { RendezvousService, Store, RendezvousError } 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('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');
|
|
});
|