a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
/**
|
||
* @file pets.test.js
|
||
* @description `/api/pets` and `server/lib/pets.js`: importing a
|
||
* `.codex-pet.zip` (root or one folder deep; refused when a file is missing,
|
||
* the sheet is not a 1536×1872 WebP, or an entry is too large BEFORE it is
|
||
* inflated), uploading an image (raster only — SVG refused), installing a
|
||
* featured pet only from codex-pets.net by its fixed id, deleting, serving the
|
||
* sprite with nosniff, ids made by the server, and every write same-origin.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
"use strict";
|
||
|
||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs");
|
||
const os = require("node:os");
|
||
const path = require("node:path");
|
||
const http = require("node:http");
|
||
const AdmZip = require("adm-zip");
|
||
|
||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pets-")));
|
||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||
|
||
const { createApp } = require("../index");
|
||
const pets = require("../lib/pets");
|
||
|
||
let server;
|
||
let base;
|
||
|
||
/** A WebP header (VP8X) claiming `w`×`h` — the server never decodes pixels. */
|
||
function webp(w = 1536, h = 1872, pad = 64) {
|
||
const b = Buffer.alloc(30 + pad);
|
||
b.write("RIFF", 0);
|
||
b.writeUInt32LE(b.length - 8, 4);
|
||
b.write("WEBP", 8);
|
||
b.write("VP8X", 12);
|
||
b.writeUInt32LE(10, 16);
|
||
b.writeUIntLE(w - 1, 24, 3);
|
||
b.writeUIntLE(h - 1, 27, 3);
|
||
return b;
|
||
}
|
||
const PNG = Buffer.concat([
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
Buffer.alloc(32),
|
||
]);
|
||
|
||
function bundle(files) {
|
||
const zip = new AdmZip();
|
||
for (const [name, data] of Object.entries(files))
|
||
zip.addFile(name, Buffer.isBuffer(data) ? data : Buffer.from(JSON.stringify(data)));
|
||
return zip.toBuffer();
|
||
}
|
||
|
||
function request(method, url, { body, headers = {} } = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const req = http.request(
|
||
`${base}${url}`,
|
||
{
|
||
method,
|
||
headers: {
|
||
...(body ? { "content-type": "application/octet-stream" } : {}),
|
||
...headers,
|
||
},
|
||
},
|
||
(res) => {
|
||
const chunks = [];
|
||
res.on("data", (c) => chunks.push(c));
|
||
res.on("end", () => {
|
||
const buf = Buffer.concat(chunks);
|
||
let json = null;
|
||
try {
|
||
json = JSON.parse(buf.toString("utf8"));
|
||
} catch {
|
||
/* a sprite */
|
||
}
|
||
resolve({ status: res.statusCode, headers: res.headers, json, buf });
|
||
});
|
||
}
|
||
);
|
||
req.on("error", reject);
|
||
if (body) req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
before(async () => {
|
||
const app = createApp();
|
||
server = http.createServer(app);
|
||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||
base = `http://127.0.0.1:${server.address().port}`;
|
||
});
|
||
|
||
after(() => {
|
||
server.close();
|
||
pets.__setFetch(null);
|
||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||
});
|
||
|
||
beforeEach(() => {
|
||
fs.rmSync(path.join(ROOT, "data", "pets"), { recursive: true, force: true });
|
||
pets.__setFetch(() => {
|
||
throw new Error("no network in tests");
|
||
});
|
||
});
|
||
|
||
describe("importing a .codex-pet bundle", () => {
|
||
it("stores pet.json + spritesheet.webp under an id the server makes, and serves the sheet", async () => {
|
||
const r = await request("POST", "/api/pets/import", {
|
||
body: bundle({
|
||
"pet.json": { id: "../../evil", displayName: " Corgi\nByte ", description: "A corgi." },
|
||
"spritesheet.webp": webp(),
|
||
}),
|
||
});
|
||
assert.equal(r.status, 201);
|
||
assert.match(r.json.pet.id, /^p-[0-9a-f]{12}$/);
|
||
assert.equal(r.json.pet.displayName, "Corgi Byte");
|
||
assert.equal(r.json.pet.kind, "codex");
|
||
const list = await request("GET", "/api/pets");
|
||
assert.deepEqual(
|
||
list.json.pets.map((p) => p.id),
|
||
[r.json.pet.id]
|
||
);
|
||
assert.deepEqual(list.json.sheet, {
|
||
width: 1536,
|
||
height: 1872,
|
||
cellWidth: 192,
|
||
cellHeight: 208,
|
||
});
|
||
const sprite = await request("GET", `/api/pets/${r.json.pet.id}/sprite`);
|
||
assert.equal(sprite.status, 200);
|
||
assert.equal(sprite.headers["content-type"], "image/webp");
|
||
assert.equal(sprite.headers["x-content-type-options"], "nosniff");
|
||
});
|
||
|
||
it("accepts the two files one folder deep", async () => {
|
||
const r = await request("POST", "/api/pets/import", {
|
||
body: bundle({
|
||
"corgi/pet.json": { displayName: "Corgi" },
|
||
"corgi/spritesheet.webp": webp(),
|
||
}),
|
||
});
|
||
assert.equal(r.status, 201);
|
||
});
|
||
|
||
it("accepts a v2 sheet (1536×2288, the 16 gaze poses in rows 9–10)", async () => {
|
||
const r = await request("POST", "/api/pets/import", {
|
||
body: bundle({
|
||
"pet.json": { spriteVersionNumber: 2 },
|
||
"spritesheet.webp": webp(1536, 2288),
|
||
}),
|
||
});
|
||
assert.equal(r.status, 201);
|
||
});
|
||
|
||
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
|
||
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
|
||
assert.equal(missing.status, 400);
|
||
assert.equal(missing.json.error.code, "EBADPET");
|
||
const wrong = await request("POST", "/api/pets/import", {
|
||
body: bundle({ "pet.json": {}, "spritesheet.webp": webp(512, 512) }),
|
||
});
|
||
assert.equal(wrong.status, 400);
|
||
assert.match(wrong.json.error.message, /512×512/);
|
||
const notZip = await request("POST", "/api/pets/import", { body: Buffer.from("hello") });
|
||
assert.equal(notZip.json.error.code, "EBADPET");
|
||
assert.deepEqual((await request("GET", "/api/pets")).json.pets, []);
|
||
});
|
||
|
||
it("refuses an oversized entry from its header, before inflating it", async () => {
|
||
const r = await request("POST", "/api/pets/import", {
|
||
body: bundle({
|
||
"pet.json": {},
|
||
"spritesheet.webp": Buffer.concat([webp(), Buffer.alloc(7 * 1024 * 1024)]),
|
||
}),
|
||
});
|
||
assert.equal(r.status, 413);
|
||
assert.equal(r.json.error.code, "ETOOBIG");
|
||
});
|
||
});
|
||
|
||
describe("uploading an image", () => {
|
||
it("keeps a raster image as a still pet, named after its file", async () => {
|
||
const r = await request("POST", "/api/pets/upload?name=My%20cat.png", { body: PNG });
|
||
assert.equal(r.status, 201);
|
||
assert.equal(r.json.pet.kind, "image");
|
||
assert.equal(r.json.pet.displayName, "My cat");
|
||
const sprite = await request("GET", `/api/pets/${r.json.pet.id}/sprite`);
|
||
assert.equal(sprite.headers["content-type"], "image/png");
|
||
});
|
||
|
||
it("refuses SVG and anything that is not a known raster", async () => {
|
||
const svg = await request("POST", "/api/pets/upload?name=x.svg", {
|
||
body: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
|
||
});
|
||
assert.equal(svg.status, 400);
|
||
assert.equal(svg.json.error.code, "EBADPET");
|
||
});
|
||
});
|
||
|
||
describe("featured pets", () => {
|
||
it("installs one only from codex-pets.net by its fixed id, once per id", async () => {
|
||
const urls = [];
|
||
pets.__setFetch(async (url) => {
|
||
urls.push(url);
|
||
const body = url.endsWith("/spritesheet")
|
||
? webp()
|
||
: Buffer.from(JSON.stringify({ pet: { description: "A martial artist." } }));
|
||
return { ok: true, status: 200, headers: { get: () => null }, arrayBuffer: async () => body };
|
||
});
|
||
const r = await request("POST", "/api/pets/featured/goku");
|
||
assert.equal(r.status, 201);
|
||
assert.equal(r.json.pet.id, "cp-goku");
|
||
assert.equal(r.json.pet.source, "featured");
|
||
assert.equal(r.json.pet.description, "A martial artist.");
|
||
assert.deepEqual(urls, [
|
||
"https://codex-pets.net/api/pets/goku",
|
||
"https://codex-pets.net/api/pets/goku/spritesheet",
|
||
]);
|
||
await request("POST", "/api/pets/featured/goku");
|
||
const list = await request("GET", "/api/pets");
|
||
assert.equal(list.json.pets.length, 1);
|
||
assert.equal(list.json.featured.find((f) => f.id === "goku").installed, true);
|
||
});
|
||
|
||
it("refuses an id that is not featured without any request", async () => {
|
||
let called = false;
|
||
pets.__setFetch(async () => {
|
||
called = true;
|
||
});
|
||
const r = await request("POST", "/api/pets/featured/some-other-pet");
|
||
assert.equal(r.status, 404);
|
||
assert.equal(called, false);
|
||
});
|
||
|
||
it("reports a failed download as EFETCH and a bad sheet as EBADPET", async () => {
|
||
pets.__setFetch(async () => ({ ok: false, status: 503, headers: { get: () => null } }));
|
||
const down = await request("POST", "/api/pets/featured/goku");
|
||
assert.equal(down.status, 502);
|
||
assert.equal(down.json.error.code, "EFETCH");
|
||
pets.__setFetch(async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
headers: { get: () => null },
|
||
arrayBuffer: async () => webp(100, 100),
|
||
}));
|
||
const bad = await request("POST", "/api/pets/featured/goku");
|
||
assert.equal(bad.json.error.code, "EBADPET");
|
||
});
|
||
});
|
||
|
||
describe("deleting and serving", () => {
|
||
it("deletes a pet, and answers 404 for an unknown or path-shaped id", async () => {
|
||
const r = await request("POST", "/api/pets/upload?name=a.png", { body: PNG });
|
||
const del = await request("DELETE", `/api/pets/${r.json.pet.id}`);
|
||
assert.equal(del.status, 200);
|
||
assert.deepEqual((await request("GET", "/api/pets")).json.pets, []);
|
||
assert.equal((await request("DELETE", `/api/pets/${r.json.pet.id}`)).status, 404);
|
||
assert.equal((await request("GET", "/api/pets/..%2F..%2Ft.db/sprite")).status, 404);
|
||
assert.equal((await request("DELETE", "/api/pets/p-nothere00000")).status, 404);
|
||
});
|
||
});
|
||
|
||
describe("same-origin", () => {
|
||
it("refuses every write from another origin", async () => {
|
||
const headers = { origin: "https://evil.example" };
|
||
assert.equal(
|
||
(await request("POST", "/api/pets/upload?name=a.png", { body: PNG, headers })).status,
|
||
403
|
||
);
|
||
assert.equal((await request("POST", "/api/pets/import", { body: PNG, headers })).status, 403);
|
||
assert.equal((await request("POST", "/api/pets/featured/goku", { headers })).status, 403);
|
||
assert.equal((await request("DELETE", "/api/pets/p-000000000000", { headers })).status, 403);
|
||
});
|
||
});
|