Bifrost/tests/persona-matrix-mouth.test.mjs

347 lines
18 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { resolve, join } from "node:path";
import { createMouth, createBrakeReader, createRing, redact } from "../tools/persona-matrix-mouth.mjs";
const TOKEN = "syt_SECRET_TOKEN_9f3";
const HS_NAME = "gamecult.test";
const ROOMS = { [`#swarm:${HS_NAME}`]: "!swarm:gamecult.test", [`#operator:${HS_NAME}`]: "!operator:gamecult.test" };
const SELF = `@eureka:${HS_NAME}`;
const message = (id, sender, body, ts) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", body } });
const tick = () => new Promise(r => setImmediate(r));
// A fake Matrix client-server API: just the endpoints the mouth uses.
async function fakeHomeserver({ sync, failWith } = {}) {
const seen = [];
const sent = new Map(); // txnId -> event_id
const server = createServer((req, res) => {
let raw = "";
req.on("data", c => (raw += c));
req.on("end", () => {
const url = new URL(req.url, "http://x");
const record = { method: req.method, path: decodeURIComponent(url.pathname), query: url.searchParams, auth: req.headers.authorization, body: raw ? JSON.parse(raw) : null };
seen.push(record);
const reply = (status, body) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
if (failWith) return reply(failWith, { errcode: "M_UNKNOWN_TOKEN", error: `bad token ${record.auth}` });
const p = record.path.replace("/_matrix/client/v3", "");
if (p === "/account/whoami") return reply(200, { user_id: SELF });
if (p.startsWith("/directory/room/")) return reply(200, { room_id: ROOMS[p.slice("/directory/room/".length)] });
if (p.startsWith("/join/")) return reply(200, { room_id: p.slice(6) });
if (p === "/sync") return reply(200, sync(record.query.get("since")));
const m = p.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/);
if (m && req.method === "PUT") {
if (!sent.has(m[2])) sent.set(m[2], `$event${sent.size + 1}`);
return reply(200, { event_id: sent.get(m[2]) });
}
reply(404, { errcode: "M_NOT_FOUND" });
});
});
await new Promise(ok => server.listen(0, "127.0.0.1", ok));
return {
url: `http://127.0.0.1:${server.address().port}`,
seen,
puts: () => seen.filter(r => r.method === "PUT"),
close: () => new Promise(ok => server.close(ok)),
};
}
function memoryState(initial = null) {
const box = { value: initial };
return { box, load: async () => box.value, save: async v => { box.value = v; } };
}
const joined = events => ({ rooms: { join: events } });
function mouthFor(hs, overrides = {}) {
return createMouth({
persona: "eureka", homeserver: hs.url, rooms: Object.keys(ROOMS), token: TOKEN, state: memoryState(),
brake: async () => true, ring: async () => {}, log: () => {}, syncTimeoutMs: 1, ...overrides,
});
}
// The scripted timeline: the sync with no position is the baseline; later positions advance it.
const timeline = {
null: { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$old", `@alice:${HS_NAME}`, "history before the ear", 1)] } } }) },
b0: {
next_batch: "b1",
...joined({
"!swarm:gamecult.test": {
state: { events: [{ type: "m.room.member", state_key: `@alice:${HS_NAME}`, content: { displayname: "Alice" } }] },
timeline: { events: [
message("$m1", `@alice:${HS_NAME}`, "hello eureka", 1000),
message("$m2", SELF, "my own words", 1001),
{ type: "m.room.topic", event_id: "$t", sender: `@alice:${HS_NAME}`, content: { topic: "x" } },
] },
},
"!elsewhere:gamecult.test": { timeline: { events: [message("$x", `@bob:${HS_NAME}`, "not my room", 1002)] } },
}),
},
b1: { next_batch: "b2", ...joined({ "!operator:gamecult.test": { timeline: { events: [message("$m3", `@bob:${HS_NAME}`, "second batch", 2000)] } } }) },
};
const script = since => timeline[since] ?? { next_batch: since };
test("sync_message_becomes_delivery: another sender's message in a joined room is one delivery with context; own and foreign-room messages are none", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs);
assert.deepEqual(await mouth.syncOnce(), [], "history at first sync is context, not mail");
const made = await mouth.syncOnce();
assert.equal(made.length, 1);
const [d] = made;
assert.deepEqual({ roomAlias: d.roomAlias, roomId: d.roomId, eventId: d.eventId, sender: d.sender, senderDisplay: d.senderDisplay, text: d.text, atUnixMs: d.atUnixMs }, {
roomAlias: `#swarm:${HS_NAME}`, roomId: "!swarm:gamecult.test", eventId: "$m1", sender: `@alice:${HS_NAME}`, senderDisplay: "Alice", text: "hello eureka", atUnixMs: 1000,
});
assert.deepEqual(d.context, [{ sender: `@alice:${HS_NAME}`, text: "history before the ear", atUnixMs: 1 }]);
assert.equal(d.seq, 1);
assert.deepEqual(mouth.pendingAfter(0).map(x => x.eventId), ["$m1"]);
assert.deepEqual(mouth.pendingAfter(1), []);
} finally { await hs.close(); }
});
test("context holds the last 20 messages before the delivery, oldest first", async () => {
const many = Array.from({ length: 25 }, (_, i) => message(`$h${i}`, `@alice:${HS_NAME}`, `m${i}`, 10 + i));
const hs = await fakeHomeserver({
sync: since => since === null
? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: many } } }) }
: { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$n", `@bob:${HS_NAME}`, "now", 99)] } } }) },
});
try {
const mouth = mouthFor(hs);
await mouth.syncOnce();
const [d] = await mouth.syncOnce();
assert.equal(d.context.length, 20);
assert.equal(d.context[0].text, "m5");
assert.equal(d.context[19].text, "m24");
} finally { await hs.close(); }
});
test("next_batch_persisted_only_after_ack: restart before ack re-delivers; after ack does not", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const state = memoryState();
const first = mouthFor(hs, { state });
await first.syncOnce(); // baseline b0, nothing pending: persisted at once
assert.equal(state.box.value.nextBatch, "b0");
const [d] = await first.syncOnce(); // delivery in the batch ending b1
assert.equal(state.box.value.nextBatch, "b0", "b1 must not be persisted before an ack");
const restarted = mouthFor(hs, { state }); // crash before the ack
const [again] = await restarted.syncOnce();
assert.equal(again.eventId, "$m1", "the unacked message arrives again");
assert.ok(again.seq > d.seq, "sequence numbers never repeat across a restart");
await restarted.ack(again.seq);
assert.equal(state.box.value.nextBatch, "b1");
const afterAck = mouthFor(hs, { state });
assert.deepEqual((await afterAck.syncOnce()).map(x => x.eventId), ["$m3"], "an acked batch is not delivered again: only the newer message arrives");
assert.equal(hs.seen.filter(r => r.path.endsWith("/sync")).at(-1).query.get("since"), "b1");
} finally { await hs.close(); }
});
test("an ack covers only the batches whose deliveries it reaches", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const state = memoryState();
const mouth = mouthFor(hs, { state });
await mouth.syncOnce();
const [a] = await mouth.syncOnce(); // batch ending b1 holds seq 1
const [b] = await mouth.syncOnce(); // batch ending b2 holds seq 2
assert.deepEqual([a.seq, b.seq], [1, 2]);
await mouth.ack(1);
assert.equal(state.box.value.nextBatch, "b1", "acking seq 1 persists the batch that ends at seq 1, not the later one");
await mouth.ack(2);
assert.equal(state.box.value.nextBatch, "b2");
assert.equal(mouth.pendingCount, 0);
} finally { await hs.close(); }
});
test("send_uses_request_id_txn: the PUT carries txnId = request_id and a repeat sends the same txnId", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs);
const request = { requestId: "req 7f", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hello world" };
const one = await mouth.send(request);
const two = await mouth.send(request);
assert.equal(one.refusal, null);
assert.equal(one.eventId, two.eventId, "the homeserver deduplicates by txnId, so a retry is the same event");
const puts = hs.puts();
assert.equal(puts.length, 2);
for (const put of puts) {
assert.equal(put.path, "/_matrix/client/v3/rooms/!swarm:gamecult.test/send/m.room.message/req 7f");
assert.deepEqual(put.body, { msgtype: "m.text", body: "hello world" });
}
assert.notEqual((await mouth.send({ ...request, requestId: "req-8" })).eventId, one.eventId, "a different request is a different txn");
} finally { await hs.close(); }
});
test("send refuses another Persona's request, an unknown room and empty text, without a PUT", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs);
assert.equal((await mouth.send({ requestId: "a", persona: "other", roomAlias: `#swarm:${HS_NAME}`, text: "x" })).refusal, "PersonaMismatch");
assert.equal((await mouth.send({ requestId: "b", persona: "eureka", roomAlias: "#nowhere:gamecult.test", text: "x" })).refusal, "UnknownRoom");
assert.equal((await mouth.send({ requestId: "c", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: " " })).refusal, "EmptyText");
assert.equal(hs.puts().length, 0);
} finally { await hs.close(); }
});
const payload = value => Buffer.from(JSON.stringify(value));
const store = (...envelopes) => ({ absent: false, envelopes });
const brakeEnvelope = value => ({ type: "eureka.control.brake.v1", key: "brake", payload: payload(value) });
const jsonDecode = b => JSON.parse(Buffer.from(b).toString());
const readerFor = read => createBrakeReader({ controlPath: "x", readStore: () => read, decode: jsonDecode });
test("held_brake_refuses_send: held, throwing, missing and non-boolean brakes refuse with Brake and nothing reaches the homeserver", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const request = { requestId: "r1", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "speak" };
const brakes = [
async () => false,
async () => { throw new Error("control store unreadable"); },
readerFor({ absent: true, envelopes: [] }),
readerFor(store(brakeEnvelope([false, "t", "op"]))),
async () => "yes",
];
for (const brake of brakes) {
const receipt = await mouthFor(hs, { brake }).send(request);
assert.deepEqual({ eventId: receipt.eventId, refusal: receipt.refusal }, { eventId: null, refusal: "Brake" });
}
assert.equal(hs.puts().length, 0);
assert.equal(hs.seen.length, 0, "a refused send does not even touch the homeserver");
const released = await mouthFor(hs, { brake: readerFor(store(brakeEnvelope([true, "t", "op"]))) }).send(request);
assert.equal(released.refusal, null, "the same request goes through once the brake is released");
} finally { await hs.close(); }
});
test("brake reader: only a decoded released brake counts as released", async () => {
assert.equal(await readerFor(store(brakeEnvelope([true, "t", "op"])))(), true);
assert.equal(await readerFor(store(brakeEnvelope([false, "t", "op"])))(), false);
assert.equal(await readerFor(store(brakeEnvelope({ 0: true })))(), true, "integer-keyed map form");
assert.equal(await readerFor(store(brakeEnvelope({ 0: false })))(), false);
assert.equal(await readerFor(store(brakeEnvelope(["true"])))(), false, "a non-boolean is not a release");
assert.equal(await readerFor(store())(), false, "no brake document");
assert.equal(await readerFor(store({ ...brakeEnvelope([true]), key: "dial" }))(), false, "wrong key");
assert.equal(await readerFor(store({ ...brakeEnvelope([true]), type: "other.v1" }))(), false, "wrong type");
assert.equal(await createBrakeReader({ controlPath: "x", readStore: () => { throw new Error("corrupt"); }, decode: jsonDecode })(), false);
assert.equal(await readerFor(store({ type: "eureka.control.brake.v1", key: "brake", payload: Buffer.from("not json") }))(), false, "undecodable bytes");
});
test("ring_starts_persona_unit_once: two deliveries in one idle window cause one start; a drained mailbox rings again", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
let rings = 0;
const mouth = mouthFor(hs, { ring: async () => { rings++; } });
await mouth.syncOnce(); // baseline
await mouth.syncOnce(); // delivery 1 rings
await mouth.syncOnce(); // delivery 2, same window, still unacked
await tick();
assert.equal(rings, 1);
} finally { await hs.close(); }
const every = await fakeHomeserver({ sync: since => ({ next_batch: `n${since}`, ...joined({ "!swarm:gamecult.test": { timeline: { events: [message(`$e${since}`, `@alice:${HS_NAME}`, "again", 5)] } } }) }) });
try {
let clock = 0, rings = 0;
const mouth = mouthFor(every, { ring: async () => { rings++; }, now: () => clock });
await mouth.syncOnce(); // baseline
await mouth.syncOnce(); await tick();
assert.equal(rings, 1);
await mouth.syncOnce(); await tick();
assert.equal(rings, 1, "inside the window and unacked: no second start");
await mouth.ack(2);
await mouth.syncOnce(); await tick();
assert.equal(rings, 2, "after a drain a new delivery rings again");
clock = 6 * 60 * 1000;
await mouth.syncOnce(); await tick();
assert.equal(rings, 3, "a delivery left unanswered past the window rings again");
} finally { await every.close(); }
});
test("a failed ring is logged once and never retried", async () => {
const logs = [];
let calls = 0;
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs, { ring: async () => { calls++; throw new Error("systemctl refused"); }, log: l => logs.push(l) });
await mouth.syncOnce(); await mouth.syncOnce(); await tick(); await tick();
assert.equal(calls, 1);
assert.equal(logs.filter(l => l.includes("ring failed")).length, 1);
} finally { await hs.close(); }
});
test("ring runs exactly systemctl start --no-block on the instance's unit, with no token in the child's environment", async () => {
const calls = [];
await createRing({ instance: "eureka", run: (cmd, args, options, cb) => { calls.push({ cmd, args, options }); cb(null); } })();
assert.equal(calls.length, 1);
assert.deepEqual([calls[0].cmd, calls[0].args], ["systemctl", ["start", "--no-block", "mind-persona@eureka.service"]]);
assert.deepEqual(Object.keys(calls[0].options.env), ["PATH"]);
const failed = [];
await createRing({ instance: "eureka", run: (c, a, o, cb) => cb(new Error("denied")), log: l => failed.push(l) })();
assert.deepEqual(failed, ["ring failed: denied"]);
});
test("the Matrix token goes only in the Authorization header: never in logs, errors, deliveries or receipts", async () => {
const logs = [];
const bad = await fakeHomeserver({ sync: script, failWith: 401 });
try {
const mouth = mouthFor(bad, { log: l => logs.push(l) });
await assert.rejects(mouth.syncOnce(), err => { assert.ok(!err.message.includes(TOKEN)); return true; });
const receipt = await mouth.send({ requestId: "r", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "x" }).catch(e => ({ thrown: e.message }));
assert.ok(!JSON.stringify(receipt).includes(TOKEN));
const controller = new AbortController();
const running = mouthFor(bad, { log: l => logs.push(l) }).run(controller.signal);
await new Promise(r => setTimeout(r, 30));
controller.abort();
void running; // the run loop is left to its backoff; only its log lines matter here
} finally { await bad.close(); }
const unreachable = mouthFor({ url: "http://127.0.0.1:1" }, {
log: l => logs.push(l),
fetch: async (url, init) => { throw new Error(`connect failed with ${init.headers.authorization} to ${url}`); },
});
await assert.rejects(unreachable.syncOnce(), err => { assert.ok(!err.message.includes(TOKEN), "transport errors are redacted"); return true; });
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs, { log: l => logs.push(l) });
await mouth.syncOnce();
const made = await mouth.syncOnce();
const receipt = await mouth.send({ requestId: "ok", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hi" });
assert.ok(!JSON.stringify([made, receipt, mouth.pendingAfter(0)]).includes(TOKEN));
assert.ok(hs.seen.every(r => r.auth === `Bearer ${TOKEN}`), "the token is presented to the homeserver on every call");
} finally { await hs.close(); }
assert.ok(logs.length > 0, "failures were logged");
assert.ok(logs.every(l => !l.includes(TOKEN)), `a log line carried the token: ${logs.find(l => l.includes(TOKEN))}`);
assert.equal(redact(`x ${TOKEN} y`, TOKEN), "x [redacted] y");
});
test("a homeserver error is reported with its status and none of its body", async () => {
const hs = await fakeHomeserver({ sync: script, failWith: 500 });
try {
await assert.rejects(mouthFor(hs).syncOnce(), err => /answered 500/.test(err.message) && !/bad token/.test(err.message));
} finally { await hs.close(); }
});
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
if (name === ".git" || name === "node_modules" || name === "bin" || name === "obj") continue;
const full = join(dir, name);
statSync(full).isDirectory() ? walk(full, out) : out.push(full);
}
return out;
}
test("the Discord posting actuator is gone and the Matrix path names no Discord", () => {
const root = resolve(import.meta.dirname, "..");
for (const file of ["tools/persona-matrix-mouth.mjs", "tools/persona-chat-documents.mjs"]) {
assert.ok(!/discord/i.test(readFileSync(resolve(root, file), "utf8")), `${file} mentions Discord`);
}
const survivors = walk(root).filter(f => /\.(mjs|cs|json|ya?ml|ps1|sh)$/.test(f) && /persona-discord-delivery/.test(readFileSync(f, "utf8")) && !/persona-matrix-mouth\.test\.mjs$/.test(f));
assert.deepEqual(survivors, []);
for (const gone of ["persona-discord-delivery.mjs", "persona-discord-rudp.mjs", "persona-discord-delivery-documents.mjs", "persona-discord-permit.mjs"]) {
assert.throws(() => statSync(resolve(root, "tools", gone)));
}
});