/** * @file me-avatar.test.js * @description Settings → Account's avatar (`/api/me/avatar`, * server/lib/avatars.js): an account sets, reads and removes its own picture; * only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB, * served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`; * another account never sees it through `/api/me`; staff read it at * `/api/users/:id/avatar` (a developer is refused) and a workspace carries * `owner_avatar_at` for the sidebar's owner groups. * @author Nguyễn Ngọc Trí Vĩ */ const { 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(), "me-avatar-")); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DATA_DIR = TMP; process.env.DASHBOARD_LIVENESS_PROBE = "0"; const { createApp, startServer } = require("../index"); const auth = require("../lib/auth"); let server; let BASE; const PNG = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32, 1), ]); const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 2)]); async function login(username, password) { const res = await fetch(BASE + "/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); return res.headers.get("set-cookie").split(";")[0]; } const put = (cookie, body) => fetch(BASE + "/api/me/avatar", { method: "PUT", headers: { cookie, "Content-Type": "application/octet-stream" }, body, }); before(async () => { process.env.AGENTHUB_MODE = "server"; server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; await auth.createUser("ava", "ava-pass-1", "developer"); await auth.createUser("bob", "bob-pass-1", "developer"); await auth.createUser("ann", "ann-pass-1", "analyst"); }); after(() => { delete process.env.AGENTHUB_MODE; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); it("sets, serves, replaces and removes the account's own avatar", async () => { const ava = await login("ava", "ava-pass-1"); const bob = await login("bob", "bob-pass-1"); assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } })).status, 404); const set = await put(ava, PNG); assert.equal(set.status, 200); const at = (await set.json()).user.avatarAt; assert.ok(at); const me = await (await fetch(BASE + "/api/auth/me", { headers: { cookie: ava } })).json(); assert.equal(me.user.avatarAt, at); const got = await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } }); assert.equal(got.status, 200); assert.equal(got.headers.get("content-type"), "image/png"); assert.equal(got.headers.get("x-content-type-options"), "nosniff"); assert.match(got.headers.get("content-security-policy"), /sandbox/); assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG); // Another account sees only its own (none). assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: bob } })).status, 404); // A JPEG replaces the PNG — one file per account. assert.equal((await put(ava, JPG)).status, 200); const files = fs.readdirSync(path.join(TMP, "avatars")); assert.deepEqual(files, [files.find((f) => f.endsWith(".jpg"))]); const del = await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } }); assert.equal(del.status, 200); assert.equal((await del.json()).user.avatarAt, null); assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0); }); it("refuses an SVG, an empty body, a too-large image and no login", async () => { const ava = await login("ava", "ava-pass-1"); const svg = await put(ava, Buffer.from("")); assert.equal(svg.status, 400); assert.equal((await svg.json()).error.code, "EBADIMAGE"); assert.equal((await put(ava, Buffer.alloc(0))).status, 400); const big = await put(ava, Buffer.concat([PNG, Buffer.alloc(1024 * 1024)])); assert.equal(big.status, 413); assert.equal((await big.json()).error.code, "ETOOBIG"); assert.equal((await put("", PNG)).status, 401); }); it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => { const ava = await login("ava", "ava-pass-1"); const ann = await login("ann", "ann-pass-1"); const bob = await login("bob", "bob-pass-1"); const id = auth.findUser("ava").id; const { db } = require("../db"); db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/ava', 'claude', ?)").run(id); assert.equal((await put(ava, PNG)).status, 200); const got = await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: ann } }); assert.equal(got.status, 200); assert.equal(got.headers.get("x-content-type-options"), "nosniff"); assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG); assert.equal( (await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status, 403 ); const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json()) .lanes; assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at); }); it("deleting the account deletes its avatar", async () => { const bob = await login("bob", "bob-pass-1"); assert.equal((await put(bob, PNG)).status, 200); auth.deleteUser("bob"); assert.ok(!fs.readdirSync(path.join(TMP, "avatars")).some((f) => f.startsWith("u2."))); });