03ebea1d92
- 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.
307 lines
14 KiB
JavaScript
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", {})], []);
|
|
});
|
|
});
|