/** * @file chat.test.js * @description Chat between users (server/lib/chat.js, server/routes/chat.js): * a direct chat is one per pair, a group's creator alone changes it, only a * room's members read it — an admin included —, unread counts and read marks, * files served only to members under a sandbox CSP, a message deleted only by * its author, "delete chat" for the caller alone, a group disbanded only by its * creator, local mode refused, and * every chat frame reaches exactly the users in its `to` (scope.exactAudience). * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "chat-test-")); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DATA_DIR = TMP; const prevMode = process.env.AGENTHUB_MODE; process.env.AGENTHUB_MODE = "server"; const { db } = require("../db"); const scope = require("../lib/scope"); const USERS = { 1: { id: 1, username: "boss", role: "admin" }, 2: { id: 2, username: "an", role: "developer" }, 3: { id: 3, username: "binh", role: "developer" }, 4: { id: 4, username: "chi", role: "analyst" }, }; let srv; let base; before(async () => { const ins = db.prepare( "INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')" ); for (const u of Object.values(USERS)) ins.run(u.id, u.username, u.role); const express = require("express"); const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.user = USERS[req.headers["x-user"]]; next(); }); app.use("/api/chat", require("../routes/chat")); await new Promise((r) => (srv = app.listen(0, r))); base = `http://127.0.0.1:${srv.address().port}/api/chat`; }); after(() => { srv?.close(); if (prevMode === undefined) delete process.env.AGENTHUB_MODE; else process.env.AGENTHUB_MODE = prevMode; fs.rmSync(TMP, { recursive: true, force: true }); }); async function call(user, method, p, body) { const init = { method, headers: { "x-user": String(user) } }; if (body instanceof FormData) init.body = body; else if (body !== undefined) { init.body = JSON.stringify(body); init.headers["content-type"] = "application/json"; } const r = await fetch(base + p, init); const type = r.headers.get("content-type") || ""; return { status: r.status, headers: r.headers, body: type.includes("json") ? await r.json() : await r.text(), }; } describe("chat", () => { let dm; let group; it("lists the other enabled users to chat with", async () => { const r = await call(2, "GET", "/people"); assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]); }); it("opens one direct chat per pair, whoever starts it", async () => { const a = await call(2, "POST", "/rooms", { kind: "dm", userId: 3 }); assert.equal(a.status, 201); const b = await call(3, "POST", "/rooms", { kind: "dm", userId: 2 }); assert.equal(b.status, 200); assert.equal(b.body.room.id, a.body.room.id); dm = a.body.room.id; assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 2 })).status, 400); }); it("keeps a chat to its members — an admin gets 404 like a missing room", async () => { const m = await call(2, "POST", `/rooms/${dm}/messages`, { body: "chào Bình" }); assert.equal(m.status, 201); assert.equal((await call(1, "GET", `/rooms/${dm}/messages`)).status, 404); assert.equal((await call(1, "POST", `/rooms/${dm}/messages`, { body: "x" })).status, 404); assert.deepEqual((await call(1, "GET", "/rooms")).body.rooms, []); const seen = await call(3, "GET", `/rooms/${dm}/messages`); assert.deepEqual( seen.body.messages.map((x) => x.body), ["chào Bình"] ); }); it("counts unread messages until they are marked read", async () => { let rooms = (await call(3, "GET", "/rooms")).body.rooms; assert.equal(rooms[0].unread, 1); assert.equal((await call(2, "GET", "/rooms")).body.rooms[0].unread, 0); // your own await call(3, "POST", `/rooms/${dm}/read`, { upTo: rooms[0].lastMessage.id }); rooms = (await call(3, "GET", "/rooms")).body.rooms; assert.equal(rooms[0].unread, 0); }); it("lets a group's creator alone change it, and any member leave", async () => { const g = await call(2, "POST", "/rooms", { kind: "group", name: "Team", userIds: [3] }); assert.equal(g.status, 201); group = g.body.room.id; assert.deepEqual(g.body.room.members, [2, 3]); assert.equal((await call(3, "PATCH", `/rooms/${group}`, { add: [4] })).status, 403); const up = await call(2, "PATCH", `/rooms/${group}`, { add: [4], name: "Team A" }); assert.deepEqual(up.body.room.members, [2, 3, 4]); assert.equal(up.body.room.name, "Team A"); assert.equal((await call(4, "POST", `/rooms/${group}/leave`)).status, 200); assert.equal((await call(4, "GET", `/rooms/${group}/messages`)).status, 404); assert.equal((await call(2, "PATCH", `/rooms/${group}`, { add: [99] })).status, 400); }); it("stores files and serves them only to members, sandboxed", async () => { const fd = new FormData(); fd.append("body", "ảnh nè"); fd.append("files", new Blob([Buffer.from("PNGDATA")], { type: "image/png" }), "màn hình.png"); fd.append("files", new Blob([""], { type: "text/html" }), "a.html"); const m = await call(3, "POST", `/rooms/${group}/messages`, fd); assert.equal(m.status, 201); const [img, page] = m.body.message.files; assert.equal(img.name, "màn hình.png"); const got = await call(2, "GET", `/rooms/${group}/files/${img.id}`); assert.equal(got.status, 200); assert.equal(got.body, "PNGDATA"); assert.match(got.headers.get("content-security-policy"), /sandbox/); assert.equal(got.headers.get("x-content-type-options"), "nosniff"); assert.match(got.headers.get("content-disposition"), /^inline/); const html = await call(2, "GET", `/rooms/${group}/files/${page.id}`); assert.match(html.headers.get("content-disposition"), /^attachment/); assert.equal((await call(1, "GET", `/rooms/${group}/files/${img.id}`)).status, 404); assert.equal( (await call(2, "GET", `/rooms/${group}/files/..%2f..%2fdashboard.db`)).status, 404 ); }); it("lets only the author delete a message, its files too", async () => { const fd = new FormData(); fd.append("body", "xoá tôi"); fd.append("files", new Blob(["x"], { type: "text/plain" }), "a.txt"); const m = (await call(2, "POST", `/rooms/${group}/messages`, fd)).body.message; assert.equal((await call(3, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 403); assert.equal((await call(1, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 404); assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 200); const left = (await call(2, "GET", `/rooms/${group}/messages`)).body.messages; assert.ok(!left.some((x) => x.id === m.id)); assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404); // A system line is nobody's to delete. const sys = left.find((x) => x.kind === "system"); assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403); }); it("deletes a direct chat for the caller only, until a new message", async () => { await call(3, "POST", `/rooms/${dm}/messages`, { body: "cũ" }); const c = await call(2, "POST", `/rooms/${dm}/clear`); assert.deepEqual(c.body, { hidden: true, purged: false }); assert.ok(!(await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === dm)); assert.deepEqual((await call(2, "GET", `/rooms/${dm}/messages`)).body.messages, []); // The other person keeps everything. assert.ok((await call(3, "GET", `/rooms/${dm}/messages`)).body.messages.length >= 2); await call(3, "POST", `/rooms/${dm}/messages`, { body: "mới" }); const back = (await call(2, "GET", "/rooms")).body.rooms.find((r) => r.id === dm); assert.equal(back.unread, 1); assert.deepEqual( (await call(2, "GET", `/rooms/${dm}/messages`)).body.messages.map((m) => m.body), ["mới"] ); }); it("hides a cleared message's files from the one who cleared it", async () => { const fd = new FormData(); fd.append("files", new Blob(["x"], { type: "text/plain" }), "b.txt"); const m = (await call(3, "POST", `/rooms/${group}/messages`, fd)).body.message; await call(2, "POST", `/rooms/${group}/clear`); assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404); assert.equal((await call(3, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 200); // A group stays in the list after its history is cleared. assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === group)); }); it("lets only a group's creator disband it, for everyone", async () => { const g = (await call(3, "POST", "/rooms", { kind: "group", name: "Tạm", userIds: [2] })).body .room.id; assert.equal((await call(2, "DELETE", `/rooms/${g}`)).status, 403); assert.equal((await call(3, "DELETE", `/rooms/${g}`)).status, 200); assert.equal((await call(2, "GET", `/rooms/${g}/messages`)).status, 404); assert.equal((await call(3, "DELETE", `/rooms/${dm}`)).status, 400); }); it("deletes for real what everyone deleted, and a direct chat both deleted whole", async () => { const { db } = require("../db"); const fd = new FormData(); fd.append("body", "bí mật"); fd.append("files", new Blob(["y"], { type: "text/plain" }), "c.txt"); const room = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room.id; const m = (await call(4, "POST", `/rooms/${room}/messages`, fd)).body.message; const onDisk = path.join(TMP, "chat", String(room), m.files[0].id); assert.ok(fs.existsSync(onDisk)); await call(4, "POST", `/rooms/${room}/clear`); // One side only: still stored for the other. assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id)); assert.ok(fs.existsSync(onDisk)); const both = await call(3, "POST", `/rooms/${room}/clear`); assert.equal(both.body.purged, true); assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined); assert.equal(db.prepare("SELECT 1 FROM chat_rooms WHERE id = ?").get(room), undefined); assert.ok(!fs.existsSync(onDisk)); // Opening it again starts afresh. const again = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room; assert.equal(again.lastMessage, null); }); it("purges a group's history once every member cleared it", async () => { const { db } = require("../db"); const g = (await call(2, "POST", "/rooms", { kind: "group", userIds: [3] })).body.room.id; const m = (await call(2, "POST", `/rooms/${g}/messages`, { body: "x" })).body.message; await call(2, "POST", `/rooms/${g}/clear`); assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id)); await call(3, "POST", `/rooms/${g}/clear`); assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined); // The group itself stays. assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === g)); }); it("tells every member's read mark (seen), never past the newest message", async () => { const r = await call(3, "POST", `/rooms/${group}/read`, { upTo: 1e9 }); const room = (await call(2, "GET", "/rooms")).body.rooms.find((x) => x.id === group); assert.equal(room.reads[3], r.body.lastReadId); assert.equal(room.reads[3], room.lastMessage ? room.lastMessage.id : room.reads[3]); assert.ok(r.body.lastReadId < 1e9); assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]); }); it("refuses an empty message", async () => { assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400); }); it("is refused in local mode", async () => { delete process.env.AGENTHUB_MODE; try { assert.equal((await call(2, "GET", "/rooms")).body.error.code, "ELOCALMODE"); } finally { process.env.AGENTHUB_MODE = "server"; } }); it("sends a chat frame to exactly its `to`, an admin not included", () => { const only = scope.exactAudience("chat_message", { roomId: 1, to: [2, 3] }); assert.deepEqual([...only], [2, 3]); assert.deepEqual([...scope.exactAudience("chat_room", {})], []); }); });