/** * @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. A picture from a link * (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never * downloaded, and the avatar routes redirect to it; https only, never a * loopback/private IP literal. * @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("a picture from a link is stored and redirected to, never downloaded", async () => { const ava = await login("ava", "ava-pass-1"); const ann = await login("ann", "ann-pass-1"); const link = "https://chat.runsystem.vn/api/v4/users/7bya4wy58bdk3qmb5ks1rgoqkh/image?_=1776312011651"; assert.equal((await put(ava, PNG)).status, 200); const set = await fetch(BASE + "/api/me/avatar/url", { method: "POST", headers: { cookie: ava, "Content-Type": "application/json" }, body: JSON.stringify({ url: ` ${link} ` }), }); assert.equal(set.status, 200); assert.ok((await set.json()).user.avatarAt); // The uploaded file is gone: a link replaces it. assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0); const got = await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava }, redirect: "manual", }); assert.equal(got.status, 302); assert.equal(got.headers.get("location"), link); assert.equal(got.headers.get("referrer-policy"), "no-referrer"); const id = auth.findUser("ava").id; const staff = await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: ann }, redirect: "manual", }); assert.equal(staff.status, 302); assert.equal(staff.headers.get("location"), link); // An upload replaces the link. assert.equal((await put(ava, PNG)).status, 200); assert.equal( (await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava }, redirect: "manual" })).status, 200 ); await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } }); }); it("a picture from a link: https only, never a private or local address", async () => { const ava = await login("ava", "ava-pass-1"); const post = (url) => fetch(BASE + "/api/me/avatar/url", { method: "POST", headers: { cookie: ava, "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); for (const [url, code] of [ ["not a url", "EBADURL"], ["http://example.com/a.png", "EBADURL"], ["https://user:pw@example.com/a.png", "EBADURL"], ["https://127.0.0.1/a.png", "EPRIVATEURL"], ["https://[::1]/a.png", "EPRIVATEURL"], ["https://localhost/a.png", "EPRIVATEURL"], ["https://169.254.169.254/latest", "EPRIVATEURL"], ]) { const res = await post(url); assert.equal(res.status, 400, url); assert.equal((await res.json()).error.code, code, url); } assert.equal((await post("")).status, 400); const { isPublicAddress } = require("../lib/avatar-url"); for (const a of [ "10.1.2.3", "172.20.0.1", "192.168.1.1", "100.64.0.1", "0.0.0.0", "::", "fe80::1", "fd00::1", "::ffff:127.0.0.1", "::ffff:7f00:1", "2002:7f00::1", "ff02::1", ]) { assert.equal(isPublicAddress(a), false, a); } for (const a of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) { assert.equal(isPublicAddress(a), true, a); } }); 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."))); });