7a88b74016
- 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>
108 lines
4.0 KiB
JavaScript
108 lines
4.0 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|