// novox/hq issue 1253: the module's own Nextcloud account is made to match its own secret when // Nextcloud refuses the login, and a refusal that stays is said once and backed off, never asked // again every minute into Nextcloud's brute-force throttle. Run against the compiled module, the // way the runtime loads it. import { test, mock } from "node:test"; import assert from "node:assert/strict"; import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Backoff, LoginRefusedError, NextcloudClient, pollSharesGuarded } from "../dist/client.js"; const PASSWORD = "the-modules-own-secret"; const REFUSED_XML = `\n\n \n failure\n 997\n Unauthorised\n \n \n\n`; const SHARES = { ocs: { data: [{ id: 7, path: "/Photos", share_type: 3, share_with: "", uid_owner: "jochens" }] } }; function client(user = "mesh-admin"): any { return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", user, PASSWORD); } /** A stand-in Nextcloud: answers `statuses` in turn (the last one repeating) and counts the asks. */ function standInFetch(statuses: number[]) { let asked = 0; const impl = async (): Promise => { const status = statuses[Math.min(asked, statuses.length - 1)]; asked += 1; if (status === 200) return new Response(JSON.stringify(SHARES), { status }); return new Response(REFUSED_XML, { status }); }; return { impl, asked: () => asked }; } /** A stand-in occ: knows whether the account exists, records every call and every env passed. */ function standInOcc(accountExists: boolean, opts: { failWrite?: string } = {}) { const calls: string[][] = []; const envs: Array | undefined> = []; const impl = (args: string[], occOpts: { env?: Record } = {}): string => { calls.push(args); envs.push(occOpts.env); if (args[0] === "user:info") { // As the real occ() throws it: occ's stdout as the message, its exit status alongside. if (!accountExists) throw Object.assign(new Error("user not found"), { status: 1 }); return "{}"; } if (["user:add", "user:resetpassword"].includes(args[0])) { if (opts.failWrite) throw new Error(opts.failWrite); return ""; } throw new Error("unexpected occ call: " + args.join(" ")); }; return { calls, envs, impl }; } /** The occ calls that write: anything but a read of the account. */ const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:info"); // 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the // login, then the account is added (no admin group) with the module's own secret, by OC_PASS and // never on an argv, and the shares are listed; Nextcloud's brute-force record is left alone. test("a refused login adds the missing account, password by environment, and lists the shares", async () => { const c = client(); const occ = standInOcc(false); mock.method(c, "occ", occ.impl); const web = standInFetch([401, 200]); const f = mock.method(globalThis, "fetch", web.impl); try { const shares = await c.listShares(); assert.equal(web.asked(), 2); assert.deepEqual(shares.map((s: any) => s.id), ["7"]); const add = occ.calls.find((a) => a[0] === "user:add"); assert.ok(add, "user:add ran"); assert.ok(add.includes("--password-from-env") && add.includes("mesh-admin")); assert.ok(!add.some((a) => a.includes("group")), "no admin group: least privilege"); assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:add"]); assert.equal(occ.envs[occ.calls.indexOf(add)]?.OC_PASS, PASSWORD); assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)), "the password is on no argv"); } finally { f.mock.restore(); } }); // 1253/AC2: given the account exists with another password, when Nextcloud refuses the login, // then its password is set to the module's own secret, in no group. test("a refused login resets an existing account's password, in no group", async () => { const c = client(); const occ = standInOcc(true); mock.method(c, "occ", occ.impl); const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl); try { await c.listShares(); assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:resetpassword"]); assert.equal(occ.envs[1]?.OC_PASS, PASSWORD); assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD))); } finally { f.mock.restore(); } }); // 1253/AC3: given the login is still refused after the account was made right, then // listShares throws LoginRefusedError naming the status and the account, never the password or // the whole XML, and a later call does not repair the account again. test("a refusal after the repair throws LoginRefusedError once-repaired, short and without the password", async () => { const c = client(); const occ = standInOcc(true); mock.method(c, "occ", occ.impl); const web = standInFetch([401]); const f = mock.method(globalThis, "fetch", web.impl); try { await assert.rejects(c.listShares(), (err: any) => { assert.ok(err instanceof LoginRefusedError); assert.equal(err.status, 401); assert.equal(err.message, "Nextcloud refused the login of mesh-admin (401: Unauthorised)"); assert.ok(!err.message.includes(PASSWORD)); return true; }); await assert.rejects(c.listShares(), LoginRefusedError); assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").length, 1, "repaired once"); assert.equal(web.asked(), 3); } finally { f.mock.restore(); } }); // 1253/AC3b: a throttle (429) is no evidence the password is wrong: two cycles of 429 then 200 // run no occ write, and the 429s are LoginRefusedError for the back-off. test("a throttle (429) is backed off, never repaired", async () => { const c = client(); const occ = standInOcc(true); mock.method(c, "occ", occ.impl); const f = mock.method(globalThis, "fetch", standInFetch([429, 200, 429, 200]).impl); try { for (let cycle = 0; cycle < 2; cycle++) { await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429); assert.equal((await c.listShares()).length, 1); } assert.deepEqual(occ.calls, [], "no occ call at all, so no write"); } finally { f.mock.restore(); } }); // 1253/AC3c: a 401 runs exactly one repair. test("a 401 runs exactly one repair", async () => { const c = client(); const occ = standInOcc(true); mock.method(c, "occ", occ.impl); const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl); try { await c.listShares(); assert.equal(writes(occ.calls).length, 1); } finally { f.mock.restore(); } }); // 1253/AC4: given the login stays refused, when the loop ticks every minute, then the refusal // is said once, the shares are asked only after 5, 10, 20, 40 and then every 60 minutes, the // ceiling is said once more, and one line says when the login is accepted again. test("a refusal that stays is said once, backed off to an hour, and its end is said", async () => { const said: string[] = []; const err = mock.method(console, "error", (...a: unknown[]) => { said.push(a.join(" ")); }); const log = mock.method(console, "log", (...a: unknown[]) => { said.push(a.join(" ")); }); try { let refused = true; let polls = 0; const askedAt: number[] = []; let clock = 0; const poll = async (): Promise => { polls += 1; askedAt.push(clock / 60_000); if (refused) throw new LoginRefusedError(401, "mesh-admin", "Unauthorised"); }; const backoff = new Backoff(5 * 60_000, 60 * 60_000); for (let minute = 0; minute <= 200; minute++) { clock = minute * 60_000; await pollSharesGuarded({} as any, backoff, poll, () => clock); } assert.deepEqual(askedAt, [0, 5, 15, 35, 75, 135, 195]); assert.equal(said.length, 2, said.join("\n")); assert.match(said[0], /refused the login of mesh-admin \(401: Unauthorised\); not asking again for 5 min/); assert.match(said[1], /still; asking once an hour/); refused = false; clock = 260 * 60_000; await pollSharesGuarded({} as any, backoff, poll, () => clock); assert.equal(said[2], "[nextcloud] shares: the login is accepted again"); const before = polls; clock += 60_000; await pollSharesGuarded({} as any, backoff, poll, () => clock); assert.equal(polls, before + 1, "every tick again once it passes"); assert.equal(said.length, 3); } finally { err.mock.restore(); log.mock.restore(); } }); // 1253/AC5: occ passes an environment variable by name alone, its value through the docker // CLI's environment, so the password is on no argv. test("occ passes OC_PASS by name, its value only in docker's environment", () => { const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-")); const record = join(dir, "record"); writeFileSync(join(dir, "docker"), `#!/bin/sh\nprintf '%s\\n' "$*" > ${record}\nprintf '%s' "$OC_PASS" >> ${record}\n`); chmodSync(join(dir, "docker"), 0o755); const path = process.env.PATH; process.env.PATH = `${dir}:${path}`; try { client().occ(["user:resetpassword", "--password-from-env", "mesh-admin"], { env: { OC_PASS: PASSWORD } }); } finally { process.env.PATH = path; } const [argv, env] = readFileSync(record, "utf8").split("\n"); assert.equal(argv, "exec -e OC_PASS -u www-data nextcloud php occ user:resetpassword --password-from-env mesh-admin"); assert.equal(env, PASSWORD); }); // 1253/AC6: an empty setting is the manifest's default, never an empty account name. test("an empty user setting falls back to the manifest's account", () => { const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-")); writeFileSync(join(dir, "config.json"), JSON.stringify({ user: "", container: "", url: "" })); writeFileSync(join(dir, "admin.secret"), PASSWORD); const c: any = NextcloudClient.fromEnv({ MESH_NEXTCLOUD_CONFIG_FILE: join(dir, "config.json"), MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE: join(dir, "admin.secret"), MESH_NEXTCLOUD_ADMIN_USER: "mesh-admin", MESH_NEXTCLOUD_URL: "http://127.0.0.1:20003", }); assert.equal(c.adminUser, "mesh-admin"); assert.equal(c.container, "nextcloud"); assert.equal(c.ocsUrl, "http://127.0.0.1:20003"); }); // 1253/AC7: the repair touches only the module's own account: a refusal under a user setting // naming another account (the operator's) runs no occ write and throws LoginRefusedError. test("a refusal under another account's name writes nothing", async () => { const c = client("jochens"); const occ = standInOcc(true); mock.method(c, "occ", occ.impl); const web = standInFetch([401]); const f = mock.method(globalThis, "fetch", web.impl); try { await assert.rejects(c.listShares(), (err: any) => { assert.ok(err instanceof LoginRefusedError); assert.match(err.message, /refused the login of jochens .*not repaired: only mesh-admin/); return true; }); assert.deepEqual(writes(occ.calls), []); } finally { f.mock.restore(); } }); // 1253/AC8: a repair whose occ write fails is said as a LoginRefusedError carrying occ's own // message, so the back-off's one loud line says why. test("a failing repair carries occ's message into LoginRefusedError", async () => { const c = client(); mock.method(c, "occ", standInOcc(false, { failWrite: "The password policy refused the password" }).impl); const f = mock.method(globalThis, "fetch", standInFetch([401]).impl); try { await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && /repair failed: The password policy refused the password/.test(err.message)); } finally { f.mock.restore(); } }); /** A fake docker on PATH: answers occ like Nextcloud (user:info's refusal on stdout, exit 1). */ function fakeDocker(): { dir: string; record: string } { const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-docker-")); const record = join(dir, "record"); writeFileSync(join(dir, "docker"), [ "#!/bin/sh", `printf '%s\\n' "$*" >> ${record}`, 'case "$*" in', " *' occ user:info '*) echo 'user not found'; exit 1 ;;", "esac", "exit 0", "", ].join("\n")); chmodSync(join(dir, "docker"), 0o755); return { dir, record }; } function withPath(dir: string, fn: () => T): T { const path = process.env.PATH; process.env.PATH = `${dir}:${path}`; try { return fn(); } finally { process.env.PATH = path; } } // 1253/AC9: occ's own refusal on stdout with an empty stderr reaches the error, and the real // occ() through a docker answering like Nextcloud makes the missing account. test("occ says a refusal printed on stdout, and the real occ makes the missing account", () => { const docker = fakeDocker(); const c = new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD); withPath(docker.dir, () => { assert.throws(() => c.occ(["user:info", "mesh-admin", "--output=json"]), /user not found/); const did = c.ensureAdminAccount(); assert.equal(did, "created"); }); const lines = readFileSync(docker.record, "utf8").trim().split("\n"); assert.ok(lines.some((l) => l.endsWith("php occ user:add --password-from-env --display-name=Mesh administration mesh-admin")), lines.join("\n")); assert.ok(!lines.some((l) => l.includes("bruteforce")), "the brute-force record is left alone"); assert.ok(!lines.join("\n").includes(PASSWORD)); });