Files
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

138 lines
5.6 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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("<svg xmlns='http://www.w3.org/2000/svg'/>"));
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.")));
});