8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
470 lines
19 KiB
JavaScript
470 lines
19 KiB
JavaScript
/**
|
|
* @file server-s3-cc-config.test.js
|
|
* @description Settings for members (S3 plan, P6): a non-admin manages THEIR
|
|
* OWN Claude Code configuration through `/api/cc-config`. Covers the router's
|
|
* own allowlist gate (`cc-target.js` — no `/profiles*`, no
|
|
* `/plugins/update-all`, nothing unlisted), the one target resolver (a
|
|
* member's `~/.claude`, an admin's host home unchanged, 403 ENOACCOUNT for a
|
|
* developer with no account — never the host's), every file write running AS
|
|
* the member through `write-as` with the content on stdin (skills, settings,
|
|
* keybindings, backups), `claude plugin|mcp …` run as the member's sudo argv
|
|
* (no `-y`, user scope only, no env/headers across sudo), project writes only
|
|
* in the member's own workspaces, an admin's project-memory create in a
|
|
* member's folder written as that member, and a member's change broadcast to
|
|
* that member alone. A fake passwd (every member = this test's uid),
|
|
* server.conf, fake sudo (run-as) and a fake `claude` spawn make it run
|
|
* without root.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after, beforeEach } = 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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-cc-config-")));
|
|
const USERS = path.join(TMP, "users");
|
|
const MIA = path.join(USERS, "mia");
|
|
const BOB = path.join(USERS, "bob");
|
|
const ANA = path.join(USERS, "ana");
|
|
const HOST = path.join(TMP, "host-claude");
|
|
const TOOLS = path.join(TMP, "tools");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "data", "dashboard.db");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
|
process.env.CLAUDE_HOME = HOST;
|
|
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
|
process.env.AGENTHUB_TOOLS_ROOT = TOOLS;
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
const linux = require("../lib/linux-accounts");
|
|
const runAs = require("../lib/run-as");
|
|
const tmux = require("../lib/tmux");
|
|
const ccCli = require("../lib/cc-plugin-cli");
|
|
const scope = require("../lib/scope");
|
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
|
|
|
const WRITE_AS = "/opt/agenthub/libexec/write-as";
|
|
const CLAUDE = path.join(TOOLS, "bin", "claude");
|
|
const UID = process.getuid();
|
|
|
|
let server;
|
|
let BASE;
|
|
const cookies = {};
|
|
let sudo;
|
|
let cliCalls = [];
|
|
|
|
const call = (who, method, p, body) =>
|
|
fetch(BASE + p, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
|
|
|
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
|
const addLane = (cwd, owner) =>
|
|
db
|
|
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
|
|
.run(cwd, idOf(owner));
|
|
|
|
/** Every sudo argv recorded since the last reset, as one string each. */
|
|
const sudoLines = () => sudo.calls.map((c) => c.join(" "));
|
|
|
|
before(async () => {
|
|
for (const d of [path.join(MIA, "proj"), path.join(MIA, "proj2"), path.join(BOB, "proj"), ANA])
|
|
fs.mkdirSync(d, { recursive: true });
|
|
fs.mkdirSync(HOST, { recursive: true });
|
|
fs.mkdirSync(path.dirname(CLAUDE), { recursive: true });
|
|
fs.writeFileSync(CLAUDE, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_PASSWD,
|
|
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
|
|
);
|
|
runAs.__resetCache();
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
linux.__setRunImpl(() => "ok");
|
|
sudo = fakeSudo();
|
|
runAs.__setExecImpl(sudo.exec);
|
|
tmux.__setExecImpl((args) => {
|
|
if (args[0] === "-V") return "tmux 3.4";
|
|
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
|
|
return "";
|
|
});
|
|
// The `claude` CLI never really runs: every spawn is recorded and answers ok.
|
|
ccCli.__setExecFileImpl((cmd, argv, opts, cb) => {
|
|
cliCalls.push({ cmd, argv, opts });
|
|
const listing = argv.includes("list")
|
|
? JSON.stringify({ installed: [], available: [{ pluginId: "p@m" }] })
|
|
: "done\n";
|
|
setImmediate(() => cb(null, listing, ""));
|
|
return { stdin: { end() {} } };
|
|
});
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
process.env.AGENTHUB_MODE = "server";
|
|
for (const [n, role] of [
|
|
["mia", "developer"],
|
|
["bob", "developer"],
|
|
["eve", "developer"], // no Linux account
|
|
["ana", "analyst"],
|
|
["boss", "admin"],
|
|
]) {
|
|
await auth.createUser(n, `${n}-pass-123`, role);
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
|
});
|
|
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
|
}
|
|
addLane(path.join(MIA, "proj"), "mia");
|
|
addLane(path.join(BOB, "proj"), "bob");
|
|
});
|
|
|
|
beforeEach(() => {
|
|
sudo.calls.length = 0;
|
|
cliCalls = [];
|
|
});
|
|
|
|
after(() => {
|
|
ccCli.__setExecFileImpl(null);
|
|
tmux.__reset();
|
|
delete process.env.AGENTHUB_MODE;
|
|
delete process.env.AGENTHUB_USERS_ROOT;
|
|
delete process.env.AGENTHUB_TOOLS_ROOT;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("the gate: what a non-admin may reach under /api/cc-config", () => {
|
|
it("keeps the host's login profiles and Update all an admin's", async () => {
|
|
for (const [method, p] of [
|
|
["GET", "/api/cc-config/profiles"],
|
|
["POST", "/api/cc-config/profiles"],
|
|
["POST", "/api/cc-config/profiles/activate"],
|
|
["POST", "/api/cc-config/profiles/usage"],
|
|
["DELETE", "/api/cc-config/profiles"],
|
|
["GET", "/api/cc-config/Profiles"],
|
|
["GET", "/api/cc-config/%70rofiles"],
|
|
["GET", "/api/cc-config/plugins/update-all"],
|
|
["POST", "/api/cc-config/plugins/update-all"],
|
|
["GET", "/api/cc-config/no-such-route"],
|
|
["POST", "/api/cc-config/no-such-route"],
|
|
]) {
|
|
const r = await call("mia", method, p, method === "GET" ? undefined : {});
|
|
assert.equal(r.status, 403, `${method} ${p}`);
|
|
assert.equal(r.body.error.code, "EADMIN", `${method} ${p}`);
|
|
}
|
|
assert.equal(cliCalls.length, 0);
|
|
});
|
|
|
|
it("lets an analyst still read the host-wide views, never change them", async () => {
|
|
assert.equal((await call("ana", "GET", "/api/cc-config/profiles")).status, 200);
|
|
const w = await call("ana", "POST", "/api/cc-config/profiles", { name: "x" });
|
|
assert.equal(w.status, 403);
|
|
assert.equal(w.body.error.code, "EADMIN");
|
|
});
|
|
|
|
it("leaves an admin every endpoint, the host's as before", async () => {
|
|
assert.equal((await call("boss", "GET", "/api/cc-config/profiles")).status, 200);
|
|
const t = await call("boss", "GET", "/api/cc-config/target");
|
|
assert.deepEqual(t.body, { kind: "host", readOnly: false, claudeHome: HOST });
|
|
});
|
|
});
|
|
|
|
describe("a developer with no Linux account", () => {
|
|
it("is refused — never served or written the host's config", async () => {
|
|
const t = await call("eve", "GET", "/api/cc-config/target");
|
|
assert.equal(t.body.kind, "none");
|
|
for (const [method, p, body] of [
|
|
["GET", "/api/cc-config/overview"],
|
|
["GET", "/api/cc-config/settings"],
|
|
["PUT", "/api/cc-config/file", { scope: "user", type: "skills", name: "x", content: "y" }],
|
|
["PATCH", "/api/cc-config/settings", { scope: "user", set: { theme: "dark" } }],
|
|
["POST", "/api/cc-config/plugins/install", { spec: "p@m" }],
|
|
]) {
|
|
const r = await call("eve", method, p, body);
|
|
assert.equal(r.status, 403, `${method} ${p}`);
|
|
assert.equal(r.body.error.code, "ENOACCOUNT", `${method} ${p}`);
|
|
}
|
|
assert.ok(!fs.existsSync(path.join(HOST, "skills", "x")));
|
|
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
|
|
assert.equal(cliCalls.length, 0);
|
|
assert.equal(sudo.calls.length, 0);
|
|
// The run form's own lists still answer (empty), as before.
|
|
const skills = await call("eve", "GET", "/api/cc-config/skills");
|
|
assert.equal(skills.status, 200);
|
|
assert.deepEqual(skills.body.items, []);
|
|
});
|
|
});
|
|
|
|
describe("a member's own config", () => {
|
|
const own = path.join(MIA, ".claude");
|
|
|
|
it("is the one the page shows", async () => {
|
|
const t = await call("mia", "GET", "/api/cc-config/target");
|
|
assert.deepEqual(t.body, { kind: "own", readOnly: false, claudeHome: own });
|
|
const o = await call("mia", "GET", "/api/cc-config/overview");
|
|
assert.equal(o.status, 200);
|
|
assert.equal(o.body.roots.claudeHome, own);
|
|
assert.equal(o.body.roots.claudeJson, path.join(MIA, ".claude.json"));
|
|
// No workspace named: no project layer — never the dashboard's own folder.
|
|
assert.equal(o.body.roots.projectRoot, null);
|
|
});
|
|
|
|
it("writes a skill AS the member through write-as, content on stdin, with a backup", async () => {
|
|
const body = {
|
|
scope: "user",
|
|
type: "skills",
|
|
name: "mine",
|
|
content: "---\nname: mine\n---\nv1",
|
|
};
|
|
const r1 = await call("mia", "PUT", "/api/cc-config/file", body);
|
|
assert.equal(r1.status, 200, JSON.stringify(r1.body));
|
|
const file = path.join(own, "skills", "mine", "SKILL.md");
|
|
assert.equal(fs.readFileSync(file, "utf8"), body.content);
|
|
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
|
|
assert.ok(!sudoLines().some((l) => l.includes("v1")), "content crossed sudo's argv");
|
|
assert.ok(!fs.existsSync(path.join(HOST, "skills", "mine")));
|
|
|
|
sudo.calls.length = 0;
|
|
const r2 = await call("mia", "PUT", "/api/cc-config/file", { ...body, content: "v2" });
|
|
assert.equal(r2.status, 200);
|
|
assert.ok(r2.body.backupPath.startsWith(path.join(own, "cc-config-backups", "skills")));
|
|
assert.equal(fs.readFileSync(path.join(r2.body.backupPath, "SKILL.md"), "utf8"), body.content);
|
|
assert.ok(sudoLines().some((l) => l.includes(` copy ${r2.body.backupPath}`)));
|
|
|
|
const list = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
|
assert.deepEqual(
|
|
list.body.items.map((i) => i.name),
|
|
["mine"]
|
|
);
|
|
|
|
sudo.calls.length = 0;
|
|
const del = await call("mia", "DELETE", "/api/cc-config/file", {
|
|
scope: "user",
|
|
type: "skills",
|
|
name: "mine",
|
|
});
|
|
assert.equal(del.status, 200);
|
|
assert.ok(!fs.existsSync(path.join(own, "skills", "mine")));
|
|
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "skills", "mine")} remove`)));
|
|
});
|
|
|
|
it("merges settings.json and writes keybindings.json as the member", async () => {
|
|
const r = await call("mia", "PATCH", "/api/cc-config/settings", {
|
|
scope: "user",
|
|
set: { "env.SECRETISH": "s3cr3t-value", theme: "dark" },
|
|
});
|
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
const settings = JSON.parse(fs.readFileSync(path.join(own, "settings.json"), "utf8"));
|
|
assert.equal(settings.theme, "dark");
|
|
assert.ok(!sudoLines().some((l) => l.includes("s3cr3t-value")));
|
|
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "settings.json")} replace`)));
|
|
|
|
const again = await call("mia", "PATCH", "/api/cc-config/settings", {
|
|
scope: "user",
|
|
set: { theme: "light" },
|
|
});
|
|
assert.ok(again.body.backupPath.startsWith(path.join(own, ".cc-config-backups")));
|
|
|
|
const kb = await call("mia", "PUT", "/api/cc-config/keybindings", {
|
|
groups: [{ context: "Chat", bindings: [{ key: "ctrl+t", action: "toggleTodos" }] }],
|
|
});
|
|
assert.equal(kb.status, 200, JSON.stringify(kb.body));
|
|
assert.equal(kb.body.file, path.join(own, "keybindings.json"));
|
|
const read = await call("mia", "GET", "/api/cc-config/keybindings");
|
|
assert.equal(read.body.groups[0].bindings[0].action, "toggleTodos");
|
|
// Project layers need a workspace named.
|
|
const proj = await call("mia", "PATCH", "/api/cc-config/settings", {
|
|
scope: "project",
|
|
set: { theme: "dark" },
|
|
});
|
|
assert.equal(proj.status, 400);
|
|
assert.equal(proj.body.error.code, "EBADSCOPE");
|
|
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
|
|
});
|
|
|
|
it("writes into a project only in a workspace of the member's own", async () => {
|
|
const q = (cwd) => `/api/cc-config/file?cwd=${encodeURIComponent(cwd)}`;
|
|
const body = { scope: "project", type: "agents", name: "helper", content: "agent" };
|
|
const mine = await call("mia", "PUT", q(path.join(MIA, "proj")), body);
|
|
assert.equal(mine.status, 200, JSON.stringify(mine.body));
|
|
const file = path.join(MIA, "proj", ".claude", "agents", "helper.md");
|
|
assert.equal(fs.readFileSync(file, "utf8"), "agent");
|
|
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
|
|
|
|
for (const cwd of [path.join(BOB, "proj"), path.join(MIA, "proj2"), MIA]) {
|
|
const r = await call("mia", "PUT", q(cwd), body);
|
|
assert.equal(r.status, 403, cwd);
|
|
assert.equal(r.body.error.code, "EFOLDER", cwd);
|
|
}
|
|
assert.ok(!fs.existsSync(path.join(BOB, "proj", ".claude")));
|
|
const pm = await call("mia", "POST", "/api/cc-config/project-memory", {
|
|
dir: path.join(BOB, "proj"),
|
|
content: "x",
|
|
});
|
|
assert.equal(pm.status, 403);
|
|
assert.ok(!fs.existsSync(path.join(BOB, "proj", "CLAUDE.md")));
|
|
});
|
|
|
|
it("runs `claude plugin` and `claude mcp` as the member — no -y, user scope only", async () => {
|
|
const inst = await call("mia", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
|
|
assert.equal(inst.status, 200, JSON.stringify(inst.body));
|
|
const add = await call("mia", "POST", "/api/cc-config/marketplaces/add", {
|
|
source: "owner/repo",
|
|
});
|
|
assert.equal(add.status, 200);
|
|
const mcp = await call("mia", "POST", "/api/cc-config/mcp/add", {
|
|
name: "srv",
|
|
scope: "user",
|
|
command: "npx",
|
|
args: ["srv"],
|
|
});
|
|
assert.equal(mcp.status, 200, JSON.stringify(mcp.body));
|
|
const avail = await call("mia", "GET", "/api/cc-config/plugins/available");
|
|
assert.equal(avail.status, 200);
|
|
const prefix = ["-n", "-u", "ah-mia", `HOME=${MIA}`, "DISABLE_AUTOUPDATER=1", CLAUDE];
|
|
assert.deepEqual(
|
|
cliCalls.map((c) => [c.cmd, ...c.argv]),
|
|
[
|
|
["sudo", ...prefix, "plugin", "install", "p@m", "--scope", "user"],
|
|
["sudo", ...prefix, "plugin", "marketplace", "add", "owner/repo", "--scope", "user"],
|
|
["sudo", ...prefix, "mcp", "add", "--scope", "user", "srv", "--", "npx", "srv"],
|
|
["sudo", ...prefix, "plugin", "list", "--available", "--json"],
|
|
]
|
|
);
|
|
assert.ok(cliCalls.every((c) => c.opts.cwd === MIA));
|
|
assert.ok(!cliCalls.some((c) => c.argv.some((a) => a === "-y" || a.startsWith("--danger"))));
|
|
|
|
cliCalls = [];
|
|
for (const [p, body] of [
|
|
["/api/cc-config/plugins/install", { spec: "p@m", scope: "project" }],
|
|
["/api/cc-config/marketplaces/add", { source: "owner/repo", scope: "local" }],
|
|
["/api/cc-config/mcp/add", { name: "s", scope: "local", command: "x" }],
|
|
["/api/cc-config/mcp/add", { name: "s", scope: "user", command: "x", env: { K: "v" } }],
|
|
[
|
|
"/api/cc-config/mcp/add",
|
|
{ name: "s", scope: "user", url: "https://x", headers: { a: "b" } },
|
|
],
|
|
]) {
|
|
const r = await call("mia", "POST", p, body);
|
|
assert.equal(r.status, 400, `${p} ${JSON.stringify(body)}`);
|
|
}
|
|
assert.equal(cliCalls.length, 0, "a refused call never spawned");
|
|
});
|
|
|
|
it("answers ENOTSHARED while claude is not in the shared tools", async () => {
|
|
fs.renameSync(CLAUDE, `${CLAUDE}.off`);
|
|
try {
|
|
const r = await call("mia", "POST", "/api/cc-config/plugins/action", {
|
|
key: "p@m",
|
|
action: "enable",
|
|
});
|
|
assert.equal(r.status, 503);
|
|
assert.equal(r.body.error.code, "ENOTSHARED");
|
|
assert.equal(cliCalls.length, 0);
|
|
} finally {
|
|
fs.renameSync(`${CLAUDE}.off`, CLAUDE);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("an admin", () => {
|
|
it("still writes the host's own config, as the host", async () => {
|
|
const r = await call("boss", "PUT", "/api/cc-config/file", {
|
|
scope: "user",
|
|
type: "commands",
|
|
name: "hosty",
|
|
content: "host cmd",
|
|
});
|
|
assert.equal(r.status, 200);
|
|
assert.equal(fs.readFileSync(path.join(HOST, "commands", "hosty.md"), "utf8"), "host cmd");
|
|
assert.equal(sudo.calls.length, 0);
|
|
const inst = await call("boss", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
|
|
assert.equal(inst.status, 200);
|
|
assert.deepEqual(
|
|
cliCalls.map((c) => [c.cmd, ...c.argv]),
|
|
[["claude", "plugin", "install", "p@m", "--scope", "user", "-y"]]
|
|
);
|
|
});
|
|
|
|
it("creates CLAUDE.md in a member's folder as that member, never as the host", async () => {
|
|
const dir = path.join(MIA, "proj2");
|
|
const r = await call("boss", "POST", "/api/cc-config/project-memory", {
|
|
dir,
|
|
content: "# memo",
|
|
});
|
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"), "# memo");
|
|
assert.ok(
|
|
sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${path.join(dir, "CLAUDE.md")}`))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("a member's config change on the WebSocket", () => {
|
|
it("names the owner, so it reaches that member alone", () => {
|
|
assert.deepEqual([...scope.exactAudience("cc_config_changed", { ownerId: 7, name: "x" })], [7]);
|
|
assert.equal(scope.exactAudience("cc_config_changed", { name: "x" }), null);
|
|
assert.equal(scope.exactAudience("lane_update", { ownerId: 7 }), null);
|
|
});
|
|
|
|
it("is delivered to the member and not to an admin; the host's reaches both", async () => {
|
|
const WebSocket = require("ws");
|
|
const open = (who) => {
|
|
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", {
|
|
headers: { cookie: cookies[who] },
|
|
});
|
|
const seen = [];
|
|
ws.on("message", (m) => {
|
|
const msg = JSON.parse(String(m));
|
|
if (msg.type === "cc_config_changed") seen.push(msg.data);
|
|
});
|
|
return new Promise((resolve, reject) =>
|
|
ws.on("open", () => resolve({ ws, seen })).on("error", reject)
|
|
);
|
|
};
|
|
const mia = await open("mia");
|
|
const boss = await open("boss");
|
|
try {
|
|
await call("mia", "PUT", "/api/cc-config/file", {
|
|
scope: "user",
|
|
type: "commands",
|
|
name: "secret-plan",
|
|
content: "x",
|
|
});
|
|
await call("boss", "PUT", "/api/cc-config/file", {
|
|
scope: "user",
|
|
type: "commands",
|
|
name: "host-cmd",
|
|
content: "x",
|
|
});
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
assert.ok(
|
|
mia.seen.some((d) => d.name === "secret-plan"),
|
|
JSON.stringify(mia.seen)
|
|
);
|
|
assert.ok(!JSON.stringify(boss.seen).includes("secret-plan"), JSON.stringify(boss.seen));
|
|
assert.ok(boss.seen.some((d) => d.name === "host-cmd"));
|
|
// The host's change reaches the member as the bare type (as before).
|
|
assert.ok(!JSON.stringify(mia.seen).includes("host-cmd"));
|
|
assert.ok(mia.seen.some((d) => d && Object.keys(d).length === 0));
|
|
} finally {
|
|
mia.ws.close();
|
|
boss.ws.close();
|
|
}
|
|
});
|
|
});
|