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>
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||
* symlink/secret refusal, install into the caller's Claude home with
|
||||
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||
* argv), sharing refused in local mode, visibility for a server-mode
|
||||
* argv; a marketplace link read into its plugin list, the same plugin added
|
||||
* twice kept as one row, key/market edited), skills uploaded (one Markdown
|
||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||
* developer, the admin's installs switch, and same-origin writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -219,6 +223,182 @@ describe("plugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function upload(p, files, fields = {}) {
|
||||
const form = new FormData();
|
||||
const paths = [];
|
||||
for (const [name, content] of files) {
|
||||
form.append("files", new Blob([content]), name.split("/").pop());
|
||||
paths.push(name);
|
||||
}
|
||||
form.append("paths", JSON.stringify(paths));
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, v);
|
||||
return fetch(`${base}${p}`, { method: "POST", body: form }).then(async (r) => ({
|
||||
status: r.status,
|
||||
body: await r.json(),
|
||||
}));
|
||||
}
|
||||
|
||||
const snapText = (item, file = "SKILL.md") =>
|
||||
fs.readFileSync(
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("plugins from a marketplace link", () => {
|
||||
const MARKET = JSON.stringify({
|
||||
name: "sg",
|
||||
description: "Smartgift skills",
|
||||
plugins: [
|
||||
{ name: "sg-dev", source: "./plugins/dev", description: "Dev team" },
|
||||
{ name: "sg-common", source: "./plugins/common" },
|
||||
{ name: "-bad", source: "./x" },
|
||||
],
|
||||
});
|
||||
|
||||
it("normalizes what a user pastes into a marketplace source", () => {
|
||||
const n = artifacts.normalizeMarket;
|
||||
assert.equal(n("https://github.com/o/r"), "o/r");
|
||||
assert.equal(n("https://github.com/o/r.git/"), "o/r");
|
||||
assert.equal(
|
||||
n("https://git.example.vn/Org/skills/src/branch/main/"),
|
||||
"https://git.example.vn/Org/skills"
|
||||
);
|
||||
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
|
||||
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
|
||||
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
|
||||
});
|
||||
|
||||
it("lists a marketplace's plugins and adds a picked one once", async () => {
|
||||
let asked = null;
|
||||
artifacts.__setInspectImpl(async (m) => {
|
||||
asked = m;
|
||||
return MARKET;
|
||||
});
|
||||
const r = await request("POST", "/api/artifacts/plugin/inspect", {
|
||||
market: "https://git.example.vn/Org/skills.git",
|
||||
});
|
||||
artifacts.__setInspectImpl(null);
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(asked, "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(r.body.name, "sg");
|
||||
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
|
||||
|
||||
const add = (title) =>
|
||||
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
|
||||
const a = await add("Dev");
|
||||
const b = await add("Dev team");
|
||||
assert.equal(a.status, 201, a.text);
|
||||
assert.equal(b.body.item.id, a.body.item.id);
|
||||
assert.equal(b.body.item.title, "Dev team");
|
||||
|
||||
const edit = await request("PATCH", `/api/artifacts/${a.body.item.id}`, {
|
||||
source: "sg-common@sg",
|
||||
market: "https://github.com/o/r",
|
||||
});
|
||||
assert.equal(edit.status, 200, edit.text);
|
||||
assert.equal(edit.body.item.source, "sg-common@sg");
|
||||
assert.equal(edit.body.item.market, "o/r");
|
||||
const bad = await request("PATCH", `/api/artifacts/${a.body.item.id}`, { source: "nope" });
|
||||
assert.equal(bad.body.error.code, "EBADPLUGIN");
|
||||
});
|
||||
|
||||
it("refuses a marketplace.json with no usable name", async () => {
|
||||
artifacts.__setInspectImpl(async () => "{}");
|
||||
const r = await request("POST", "/api/artifacts/plugin/inspect", { market: "o/r" });
|
||||
artifacts.__setInspectImpl(null);
|
||||
assert.equal(r.body.error.code, "EBADMARKET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills from files or written", () => {
|
||||
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
|
||||
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const [item] = r.body.items;
|
||||
assert.equal(item.source, "Huong-dan-deploy");
|
||||
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
|
||||
const text = snapText(item);
|
||||
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
|
||||
assert.match(text, /# Do this/);
|
||||
});
|
||||
|
||||
it("refuses a skill with no description anywhere", async () => {
|
||||
const r = await upload("/api/artifacts/skill/upload", [["x.md", "body"]], { name: "x" });
|
||||
assert.equal(r.body.error.code, "ENODESCRIPTION");
|
||||
});
|
||||
|
||||
it("takes a picked folder: the top folder is stripped, junk skipped, the name from the frontmatter", async () => {
|
||||
const r = await upload("/api/artifacts/skill/upload", [
|
||||
["my-skill/SKILL.md", "---\nname: release-notes\ndescription: Write notes\n---\nGo."],
|
||||
["my-skill/scripts/run.sh", "echo"],
|
||||
["my-skill/.DS_Store", "junk"],
|
||||
]);
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const [item] = r.body.items;
|
||||
assert.equal(item.source, "release-notes");
|
||||
assert.equal(item.description, "Write notes");
|
||||
assert.deepEqual(item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
|
||||
});
|
||||
|
||||
it("unpacks a zip holding several skills into one artifact each", async () => {
|
||||
const AdmZip = require("adm-zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("pack/alpha/SKILL.md", Buffer.from("---\nname: alpha\ndescription: A\n---\n"));
|
||||
zip.addFile("pack/alpha/ref/more.md", Buffer.from("x"));
|
||||
zip.addFile("pack/beta/skill.md", Buffer.from("---\ndescription: B\n---\nb"));
|
||||
zip.addFile("__MACOSX/pack/._alpha", Buffer.from("junk"));
|
||||
const r = await upload("/api/artifacts/skill/upload", [["pack.zip", zip.toBuffer()]]);
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body.items.map((i) => i.source).sort(), ["alpha", "beta"]);
|
||||
const beta = r.body.items.find((i) => i.source === "beta");
|
||||
assert.match(snapText(beta), /^---\nname: beta\ndescription: B\n---/);
|
||||
});
|
||||
|
||||
it("stores nothing when one skill of an upload is bad, and refuses a secret or a path out", async () => {
|
||||
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n;
|
||||
const r = await upload("/api/artifacts/skill/upload", [
|
||||
["a/SKILL.md", "---\ndescription: A\n---\n"],
|
||||
["b/SKILL.md", "no frontmatter"],
|
||||
]);
|
||||
assert.equal(r.body.error.code, "ENODESCRIPTION");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
|
||||
const secret = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
|
||||
);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
|
||||
assert.equal(out.body.error.code, "EBADPATH");
|
||||
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
|
||||
assert.equal(none.body.error.code, "ENOSKILLMD");
|
||||
});
|
||||
|
||||
it("writes a skill from the dialog", async () => {
|
||||
const r = await request("POST", "/api/artifacts/skill/new", {
|
||||
name: "standup",
|
||||
description: "Use for the daily standup: summarise yesterday",
|
||||
content: "Summarise.",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
|
||||
assert.equal(bad.body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.equal(r.body.item.kind, "file");
|
||||
assert.equal(r.body.item.title, "Report");
|
||||
const raw = await request("GET", `/api/artifacts/${r.body.item.id}/raw`);
|
||||
assert.equal(raw.text, "<h1>x</h1>");
|
||||
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharing and visibility", () => {
|
||||
it("refuses to share in local mode", async () => {
|
||||
const item = (await request("GET", "/api/artifacts")).body.items[0];
|
||||
|
||||
@@ -120,6 +120,28 @@ describe("a pool login", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is a host pane's account when it is the host's own login, and nothing is written", () => {
|
||||
const hostCreds = path.join(TMP, ".claude", ".credentials.json");
|
||||
fs.mkdirSync(path.dirname(hostCreds), { recursive: true });
|
||||
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("h") }));
|
||||
try {
|
||||
pool.addLoginAccount("mine", { oauth: oauth("h"), account: ACCOUNT, hostHolds: true });
|
||||
const spawn = pool.forSpawn(null);
|
||||
assert.equal(spawn.name, "mine", "the host pane's usage is booked to it");
|
||||
assert.deepEqual(spawn.env, {});
|
||||
spawn.place();
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(hostCreds, "utf8")).claudeAiOauth.refreshToken,
|
||||
oauth("h").refreshToken
|
||||
);
|
||||
// The host logged in as someone else: no longer the pool's.
|
||||
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("other") }));
|
||||
assert.equal(pool.forSpawn(null), null);
|
||||
} finally {
|
||||
fs.rmSync(hostCreds, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is never written over a member's own login", () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||
fs.mkdirSync(path.dirname(CREDS), { recursive: true });
|
||||
|
||||
@@ -27,21 +27,33 @@ const pty = require("../lib/pty-run");
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
const realFetchUsage = ccUsage.fetchUsage;
|
||||
|
||||
function run(id, account, { status = "completed", session = null, started } = {}) {
|
||||
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
|
||||
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
|
||||
).run(id, session, status, started || new Date().toISOString(), account);
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, ended_at, account)
|
||||
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?, ?)`
|
||||
).run(id, session, status, started || new Date().toISOString(), ended, account);
|
||||
}
|
||||
|
||||
function spend(session, tokens) {
|
||||
const MODEL = "claude-sonnet-4-5-20250929";
|
||||
const hoursAgo = (h) => new Date(Date.now() - h * 3600e3).toISOString();
|
||||
|
||||
/**
|
||||
* `tokens` output tokens of `session` spent `atHoursAgo` hours ago — the
|
||||
* hourly row plus the session total, as the hook writer keeps them both.
|
||||
* `{hourly: false}` = a total with no hourly row (a sub-agent's bucket).
|
||||
*/
|
||||
function spend(session, tokens, { atHoursAgo = 0, hourly = true, model = MODEL } = {}) {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
|
||||
).run(session, session);
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, started_at) VALUES (?, ?, 'completed', '/tmp', ?)"
|
||||
).run(session, session, hoursAgo(atHoursAgo));
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(session, new Date().toISOString().slice(0, 13), tokens);
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
|
||||
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
||||
).run(session, model, tokens);
|
||||
if (!hourly) return;
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)`
|
||||
).run(session, hoursAgo(atHoursAgo).slice(0, 13), model, tokens);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
@@ -62,6 +74,8 @@ describe("claude pool", () => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
pool.__quotaCache.clear();
|
||||
});
|
||||
|
||||
@@ -109,6 +123,26 @@ describe("claude pool", () => {
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
});
|
||||
|
||||
it("books a session to an account only for the hours its runs held it, sub-agents included", () => {
|
||||
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
|
||||
// s1 ran on a 30 h ago, then was resumed on b 2 h ago.
|
||||
run("r-a", "a", { session: "s1", started: hoursAgo(30), ended: hoursAgo(29) });
|
||||
run("r-b", "b", { session: "s1", started: hoursAgo(2), status: "running" });
|
||||
spend("s1", 1000, { atHoursAgo: 30 });
|
||||
spend("s1", 3000, { atHoursAgo: 2 });
|
||||
// s2 (on a, now): its main agent and a Haiku sub-agent with no hourly row.
|
||||
run("r-a2", "a", { session: "s2" });
|
||||
spend("s2", 500);
|
||||
spend("s2", 200, { hourly: false, model: "claude-haiku-4-5-20251001" });
|
||||
const status = Object.fromEntries(pool.poolStatus().map((x) => [x.name, x]));
|
||||
assert.equal(status.a.tokens5h, 700, "s2, sub-agent included; s1 is b's now");
|
||||
assert.equal(status.a.tokens7d, 1700);
|
||||
assert.equal(status.b.tokens5h, 3000, "not the hours a held s1");
|
||||
assert.equal(status.b.tokens7d, 3000);
|
||||
assert.ok(status.a.spend7d > status.a.spend5h && status.a.spend5h > 0);
|
||||
assert.ok(status.b.spend5h > 0);
|
||||
});
|
||||
|
||||
it("puts an account whose quota is nearly used up last", async () => {
|
||||
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
|
||||
ccUsage.fetchUsage = async (token) => ({
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @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.")));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file user-storage.test.js
|
||||
* @description The Users page's Storage column (server/lib/user-storage.js,
|
||||
* `GET /api/users/storage`): the walk counts allocated blocks, a hard link
|
||||
* once and never follows a symlink; a server-mode user's folder under
|
||||
* AGENTHUB_USERS_ROOT is measured in the background and reported to staff,
|
||||
* a member is refused, and a user with no folder of their own reads null.
|
||||
* @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(), "user-storage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const USERS_ROOT = path.join(TMP, "users");
|
||||
fs.mkdirSync(USERS_ROOT);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const { walk } = require("../lib/user-storage");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
|
||||
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 }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
async function storage(cookie, q = "") {
|
||||
const res = await fetch(`${BASE}/api/users/storage${q}`, { headers: { cookie } });
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS_ROOT;
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
const home = path.join(USERS_ROOT, "mia");
|
||||
fs.mkdirSync(path.join(home, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, "src", "a.bin"), Buffer.alloc(64 * 1024, 1));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("walk", () => {
|
||||
it("counts blocks, a hard link once, and never follows a symlink", () => {
|
||||
const dir = fs.mkdtempSync(path.join(TMP, "walk-"));
|
||||
fs.writeFileSync(path.join(dir, "big"), Buffer.alloc(128 * 1024, 1));
|
||||
fs.linkSync(path.join(dir, "big"), path.join(dir, "big-link"));
|
||||
fs.symlinkSync("/usr", path.join(dir, "usr"));
|
||||
const r = walk(dir, Date.now() + 10_000);
|
||||
assert.equal(r.partial, false);
|
||||
assert.equal(r.files, 2); // big + the symlink itself; the hard link is skipped
|
||||
assert.ok(r.bytes >= 128 * 1024 && r.bytes < 1024 * 1024, `bytes ${r.bytes}`);
|
||||
});
|
||||
|
||||
it("stops at the deadline as a lower bound", () => {
|
||||
const r = walk(TMP, Date.now() - 1);
|
||||
assert.equal(r.partial, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/users/storage", () => {
|
||||
it("measures a user's folder in the background for staff", async () => {
|
||||
const admin = await login("boss", "admin-pass-1");
|
||||
let r = await storage(admin);
|
||||
assert.equal(r.status, 200);
|
||||
for (let i = 0; i < 50 && (r.body.measuring || !r.body.storage[ids.mia]); i++) {
|
||||
await new Promise((ok) => setTimeout(ok, 100));
|
||||
r = await storage(admin);
|
||||
}
|
||||
const mia = r.body.storage[ids.mia];
|
||||
assert.ok(mia, "mia's folder was measured");
|
||||
assert.ok(mia.bytes >= 64 * 1024);
|
||||
assert.equal(mia.files, 1);
|
||||
assert.equal(mia.partial, false);
|
||||
assert.ok(mia.measuredAt);
|
||||
// The admin has no folder under the users root: nothing to measure.
|
||||
assert.equal(r.body.storage[ids.boss], null);
|
||||
});
|
||||
|
||||
it("refuses a developer", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await storage(mia);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user