0016b6a427
Rail: a room — open, mark as read, mute / unmute notifications (per browser, the unread count still shows), copy name, group settings, leave; the pet and + — chat with the pet, new chat, mark all chats as read. A message — copy text, reply (quotes it into the box), message the sender privately (group), delete your own (asks first). A file — open in a new tab, download, copy file name. A group member — message privately, copy name, remove (the creator). DELETE /api/chat/rooms/:id/messages/:messageId deletes the caller's OWN text message and its files (403 for anyone else, an admin included) and tells the members with a chat_deleted frame (exactAudience).
190 lines
7.9 KiB
JavaScript
190 lines
7.9 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, 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"]);
|
|
});
|
|
|
|
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("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", {})], []);
|
|
});
|
|
});
|