Files
AgentHub/server/__tests__/chat.test.js
T
nntrivi2001 03ebea1d92 fix(chat): the review's gaps — owner hand-over, live group lines, names, scroll, one tab's sound, uploads on disk, a chat button without the pet
- A group whose creator left or was deleted passes to its longest-standing
  member (ensureOwner), so it can still be managed.
- Group changes (added / removed / renamed / left) are sent live as
  chat_message and never count as unread, here or on the server.
- A disabled account is still named in old chats (people carries
  `disabled`) and never offered for a new one; a deleted one reads
  'Deleted user' instead of '?'.
- Load older keeps the message being read in place.
- With several tabs open only the first to see a message plays its sound /
  system notification (agenthub.chat.notified).
- The people list is read again on New chat and on every room change.
- Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are
  renamed into place — never held in memory; a refused one is removed.
- With the pet hidden, a chat button in the bottom-right corner (ChatDock,
  server mode) opens the same window, unread count and new-message card
  included.
2026-11-03 09:59:13 +07:00

307 lines
14 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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"]);
assert.ok(r.body.people.every((p) => p.disabled === false));
});
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(["<script>x</script>"], { 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("hands a group to its longest-standing member when the creator leaves", async () => {
const g = (await call(2, "POST", "/rooms", { kind: "group", name: "Kế", userIds: [3, 4] })).body
.room.id;
await call(2, "POST", `/rooms/${g}/leave`);
const room = (await call(3, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(room.createdBy, 3);
assert.equal((await call(3, "PATCH", `/rooms/${g}`, { name: "Kế 2" })).status, 200);
// The "left" and "renamed" lines never count as unread.
const four = (await call(4, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(four.unread, 0);
});
it("still names a disabled user, and never offers them for a new chat", async () => {
const { db } = require("../db");
db.prepare("UPDATE users SET disabled = 1 WHERE id = 4").run();
try {
const chi = (await call(2, "GET", "/people")).body.people.find((p) => p.id === 4);
assert.equal(chi.disabled, true);
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 4 })).status, 400);
} finally {
db.prepare("UPDATE users SET disabled = 0 WHERE id = 4").run();
}
});
it("leaves nothing behind in the upload folder", async () => {
const fd = new FormData();
fd.append("files", new Blob(["z"], { type: "text/plain" }), "d.txt");
assert.equal((await call(2, "POST", `/rooms/${group}/messages`, fd)).status, 201);
const fd2 = new FormData();
fd2.append("files", new Blob(["z"], { type: "text/plain" }), "e.txt");
assert.equal((await call(1, "POST", `/rooms/${group}/messages`, fd2)).status, 404);
assert.deepEqual(fs.readdirSync(path.join(TMP, "chat", ".incoming")), []);
});
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", {})], []);
});
});