From 4021724ca47d627e04b28366fa087bdd7e81e451 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 16:40:01 +0100 Subject: [PATCH 1/4] nextcloud: make the mesh's own account match its secret when Nextcloud refuses it, and back off a refusal that stays (issue 1253) The image creates NEXTCLOUD_ADMIN_USER only on a fresh install. novox's instance was installed before the mesh adopted it, so mesh-admin was never created there, and the shares poll asked Nextcloud every minute with a login that cannot pass: 401, then Nextcloud's brute-force throttle (429), which also held the backup before a delivery. On a refused login the client now adds the account through occ (admin group), or sets its password to the module's own secret, passing it by OC_PASS so it is on no argv, and asks once more. A refusal that stays is said once and the poll backs off from 5 minutes to an hour instead of nine lines of XML every minute. An empty setting no longer becomes an empty name. --- modules/nextcloud/client.ts | 163 ++++++++++++++-- modules/nextcloud/index.ts | 8 +- modules/nextcloud/test/admin-login.test.ts | 205 +++++++++++++++++++++ 3 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 modules/nextcloud/test/admin-login.test.ts diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index f0461db8..91a79c5a 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -61,10 +61,67 @@ const ONLYOFFICE_KEYS: ReadonlyArray([^<]*)<\/message>/.exec(body); + if (xml) return xml[1].trim(); + try { return String(JSON.parse(body)?.ocs?.meta?.message ?? "").trim(); } catch { return ""; } +} + +/** + * How long to leave a failing poll alone: the base interval after the first failure, doubling on + * each further one up to `maxMs`, and back to every tick once a poll passes. `due` says whether a + * tick should poll; `failed` records a failure and answers whether it is the first of a run (said + * loud) or the one that reached the ceiling (said once more), so a failure is never said every tick. + */ +export class Backoff { + private delayMs = 0; + private nextAt = 0; + private failures = 0; + + constructor(private readonly baseMs: number, private readonly maxMs: number) {} + + due(now: number): boolean { + return now >= this.nextAt; + } + + failed(now: number): { first: boolean; atCeiling: boolean; delayMs: number } { + this.failures += 1; + const before = this.delayMs; + this.delayMs = before === 0 ? this.baseMs : Math.min(before * 2, this.maxMs); + this.nextAt = now + this.delayMs; + return { first: this.failures === 1, atCeiling: this.delayMs === this.maxMs && before !== this.maxMs, delayMs: this.delayMs }; + } + + /** A poll passed; answers whether it ended a run of failures. */ + passed(): boolean { + const wasFailing = this.failures > 0; + this.failures = 0; + this.delayMs = 0; + this.nextAt = 0; + return wasFailing; + } +} + export class NextcloudClient { // Whether the last connection check failed (novox/hq issue 1832): while it did, every tick runs // the check again, even when nothing changed, until it passes. private lastCheckFailed = false; + // Whether the account was already made right since the last accepted login (novox/hq issue + // 1253): a refusal makes it right once, and a refusal after that is said, not repaired again. + private accountMadeRight = false; constructor( private readonly container: string, @@ -82,9 +139,10 @@ export class NextcloudClient { */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); - const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; - const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; - const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; + // An empty setting is the manifest's "empty is the default", never an empty name. + const container = cfg.container || env.MESH_NEXTCLOUD_CONTAINER || "nextcloud"; + const ocsUrl = cfg.url || env.MESH_NEXTCLOUD_URL || `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + const adminUser = cfg.user || env.MESH_NEXTCLOUD_ADMIN_USER || "admin"; const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE; ignoreSecretSetting(cfg, "password"); const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD @@ -100,16 +158,20 @@ export class NextcloudClient { * Run occ inside the container as the web user, returning its stdout, throwing its own message. * With `opts.input` given, occ reads it from stdin (`docker exec -i`) instead of taking it on the * command line — the only way a secret value reaches occ without sitting in the docker CLI's argv, - * the container's php argv and docker's exec record (novox/hq issue 1831). + * the container's php argv and docker's exec record (novox/hq issue 1831). With `opts.env` given, + * each variable is passed by name alone (`docker exec -e NAME`), its value taken from the docker + * CLI's own environment, so it is on no argv either (occ's `--password-from-env` reads OC_PASS). */ - occ(args: string[], opts: { input?: string } = {}): string { - const dockerArgs = opts.input !== undefined - ? ["exec", "-i", "-u", "www-data", this.container, "php", "occ", ...args] - : ["exec", "-u", "www-data", this.container, "php", "occ", ...args]; + occ(args: string[], opts: { input?: string; env?: Record } = {}): string { + const dockerArgs = ["exec"]; + if (opts.input !== undefined) dockerArgs.push("-i"); + for (const name of Object.keys(opts.env ?? {})) dockerArgs.push("-e", name); + dockerArgs.push("-u", "www-data", this.container, "php", "occ", ...args); try { return execFileSync("docker", dockerArgs, { encoding: "utf8", timeout: 60_000, ...(opts.input !== undefined ? { input: opts.input } : {}), + ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}), }).trim(); } catch (err: any) { const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim(); @@ -188,14 +250,62 @@ export class NextcloudClient { return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) }; } - /** List every share, over the OCS Sharing API with the admin credentials. */ - async listShares(): Promise { + /** + * Makes the mesh's own account in Nextcloud match the module's own secret (novox/hq issue 1253). + * The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance + * the mesh adopted never has it, and a secret made anew never reaches it. Through occ: the + * account is added in the admin group when missing; otherwise its password is set to the secret + * and it is put in the admin group. The password goes by OC_PASS (`--password-from-env`), never + * on an argv. Answers what it did; throws occ's own message when occ fails. + */ + ensureAdminAccount(): "created" | "password-set" { + const pass = { env: { OC_PASS: this.adminPassword } }; + let exists = true; + try { + this.occ(["user:info", this.adminUser, "--output=json"]); + } catch (err) { + if (!/not found|does not exist/i.test(String((err as Error)?.message ?? err))) throw err; + exists = false; + } + if (!exists) { + this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", "--group=admin", this.adminUser], pass); + return "created"; + } + this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); + try { + this.occ(["group:adduser", "admin", this.adminUser]); + } catch (err) { + if (!/already/i.test(String((err as Error)?.message ?? err))) throw err; + } + return "password-set"; + } + + private fetchShares(): Promise { const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64"); - const res = await fetch( + return fetch( `${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, { headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } }, ); - if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`); + } + + /** + * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) + * makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or a + * throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). + */ + async listShares(): Promise { + let res = await this.fetchShares(); + if (res.status === 401 && !this.accountMadeRight) { + await res.text(); + this.accountMadeRight = true; + this.ensureAdminAccount(); + res = await this.fetchShares(); + } + if (res.status === 401 || res.status === 429) { + throw new LoginRefusedError(res.status, this.adminUser, ocsMessage(await res.text())); + } + if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${ocsMessage(await res.text())}`.trim()); + this.accountMadeRight = false; const rows = ((await res.json())?.ocs?.data ?? []) as any[]; return rows.map((s) => ({ id: String(s.id), @@ -206,3 +316,32 @@ export class NextcloudClient { })); } } + +/** + * Runs one poll of the shares under a back-off (novox/hq issue 1253): skipped while backing off; + * a LoginRefusedError is said loud at the first failure and once more when the wait reaches its + * ceiling, never on every tick; one line says when it passes again. Other errors are thrown on, + * so the loop logs them as before. + */ +export async function pollSharesGuarded( + client: NextcloudClient, + backoff: Backoff, + poll: (c: NextcloudClient) => Promise, + now: () => number = Date.now, +): Promise { + if (!backoff.due(now())) return; + try { + await poll(client); + } catch (err) { + if (!(err instanceof LoginRefusedError)) throw err; + const step = backoff.failed(now()); + const minutes = Math.round(step.delayMs / 60_000); + if (step.first) { + console.error(`[nextcloud] shares: ${err.message}; not asking again for ${minutes} min, then less often, up to every hour`); + } else if (step.atCeiling) { + console.error(`[nextcloud] shares: ${err.message}, still; asking once an hour from now on`); + } + return; + } + if (backoff.passed()) console.log("[nextcloud] shares: the login is accepted again"); +} diff --git a/modules/nextcloud/index.ts b/modules/nextcloud/index.ts index 86e70d80..b02f2318 100644 --- a/modules/nextcloud/index.ts +++ b/modules/nextcloud/index.ts @@ -9,7 +9,7 @@ // existing user and share as freshly created. import { emit } from "@novox/mesh-sdk/events"; -import { NextcloudClient } from "./client.js"; +import { Backoff, NextcloudClient, pollSharesGuarded } from "./client.js"; // Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without // crashing the events host — it simply watches nothing. @@ -69,7 +69,11 @@ if (nextcloud) { run(); }; tick(pollUsers); - tick(pollShares); + // A refused login is said once and the shares are left alone for 5 minutes, doubling to an hour, + // instead of a refusal every minute that Nextcloud's brute-force protection then throttles + // (novox/hq issue 1253). + const sharesBackoff = new Backoff(5 * 60_000, 60 * 60_000); + tick((c) => pollSharesGuarded(c, sharesBackoff, pollShares)); tick(applyOnlyOffice); console.log("[nextcloud] watching users, shares and onlyoffice settings"); } diff --git a/modules/nextcloud/test/admin-login.test.ts b/modules/nextcloud/test/admin-login.test.ts new file mode 100644 index 00000000..08e2d1e7 --- /dev/null +++ b/modules/nextcloud/test/admin-login.test.ts @@ -0,0 +1,205 @@ +// 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(): any { + return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", 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) { + const calls: string[][] = []; + const envs: Array | undefined> = []; + const impl = (args: string[], opts: { env?: Record } = {}): string => { + calls.push(args); + envs.push(opts.env); + if (args[0] === "user:info") { + if (!accountExists) throw new Error("user not found"); + return "{}"; + } + if (["user:add", "user:resetpassword", "group:adduser"].includes(args[0])) return ""; + throw new Error("unexpected occ call: " + args.join(" ")); + }; + return { calls, envs, impl }; +} + +// 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the +// login, then the account is added in the admin group with the module's own secret, by +// OC_PASS and never on an argv, and the shares are asked again and listed. +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("--group=admin") && add.includes("mesh-admin")); + 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 and it is put in the admin group. +test("a refused login resets an existing account's password and puts it in the admin 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", "group:adduser"]); + 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 a refusal too, and is not met by a repair. +test("a throttled login (429) throws LoginRefusedError without touching the account", async () => { + const c = client(); + const occ = standInOcc(true); + mock.method(c, "occ", occ.impl); + const f = mock.method(globalThis, "fetch", standInFetch([429]).impl); + try { + await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429); + assert.equal(occ.calls.length, 0); + } 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"); +}); From 67b13c42d32a20ebdd0a4e95ea794e418a434158 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 16:40:54 +0100 Subject: [PATCH 2/4] nextcloud: repair the account on a throttle too, since a throttle follows refusals (issue 1253) Right after the delivery the first answer may be Nextcloud's 429, not a 401; without a repair then the account would never be made and the back-off would wait out a throttle that cannot end. --- modules/nextcloud/client.ts | 10 ++++++---- modules/nextcloud/test/admin-login.test.ts | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index 91a79c5a..f3d1f725 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -289,13 +289,15 @@ export class NextcloudClient { } /** - * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) - * makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or a - * throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). + * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) or + * a throttle (429) makes the account right once (ensureAdminAccount) and asks again; a refusal + * or a throttle after that throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). */ async listShares(): Promise { let res = await this.fetchShares(); - if (res.status === 401 && !this.accountMadeRight) { + // A throttle (429) follows refusals, so it gets the same one repair: the repair goes by occ, + // never by a login, and the throttle then wears off under the caller's back-off. + if ((res.status === 401 || res.status === 429) && !this.accountMadeRight) { await res.text(); this.accountMadeRight = true; this.ensureAdminAccount(); diff --git a/modules/nextcloud/test/admin-login.test.ts b/modules/nextcloud/test/admin-login.test.ts index 08e2d1e7..1e38a22b 100644 --- a/modules/nextcloud/test/admin-login.test.ts +++ b/modules/nextcloud/test/admin-login.test.ts @@ -113,15 +113,17 @@ test("a refusal after the repair throws LoginRefusedError once-repaired, short a } }); -// 1253/AC3b: a throttle (429) is a refusal too, and is not met by a repair. -test("a throttled login (429) throws LoginRefusedError without touching the account", async () => { +// 1253/AC3b: a throttle (429) follows refusals: it gets the same one repair, and a throttle +// that stays throws LoginRefusedError. +test("a throttled login (429) is repaired once and then throws LoginRefusedError", async () => { const c = client(); const occ = standInOcc(true); mock.method(c, "occ", occ.impl); const f = mock.method(globalThis, "fetch", standInFetch([429]).impl); try { await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429); - assert.equal(occ.calls.length, 0); + await assert.rejects(c.listShares(), LoginRefusedError); + assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").length, 1); } finally { f.mock.restore(); } From 962bc359a7aebaf3fac31c64a310a5ef2fd586d1 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 16:54:51 +0100 Subject: [PATCH 3/4] nextcloud: repair only the module's own account, read its existence from the user list, no admin group (issue 1253, review 1) occ's user:info says 'user not found' on stdout with an empty stderr, so the error read as 'occ produced no output' and mesh-admin was never made on novox: occ now says stderr and stdout both, and existence is read from user:list. The repair could reset whatever account the user setting names, the operator's included, and the 'admin' fallback is a hand-made install's human administrator: only mesh-admin is repaired now, and it is the default. The shares poll needs no admin rights, so the account gets no group. After a repair the throttle of the caller's address is reset, and a failing repair reaches the back-off's one loud line with occ's message. --- modules/nextcloud/client.ts | 72 +++++++++---- modules/nextcloud/module.json | 2 +- modules/nextcloud/test/admin-login.test.ts | 116 ++++++++++++++++++--- 3 files changed, 153 insertions(+), 37 deletions(-) diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index f3d1f725..95c94977 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -61,6 +61,13 @@ const ONLYOFFICE_KEYS: ReadonlyArray String(x ?? "").trim()).filter(Boolean).join("\n") + || String(err?.message ?? "").trim(); throw new Error(detail || `occ produced no output — is the ${this.container} container running?`); } } @@ -253,31 +263,51 @@ export class NextcloudClient { /** * Makes the mesh's own account in Nextcloud match the module's own secret (novox/hq issue 1253). * The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance - * the mesh adopted never has it, and a secret made anew never reaches it. Through occ: the - * account is added in the admin group when missing; otherwise its password is set to the secret - * and it is put in the admin group. The password goes by OC_PASS (`--password-from-env`), never - * on an argv. Answers what it did; throws occ's own message when occ fails. + * the mesh adopted never has it, and a secret made anew never reaches it. Only the module's own + * account (MESH_ACCOUNT) is touched: under any other name it writes nothing and throws + * LoginRefusedError. Through occ: whether the account exists is read from user:list's + * membership; a missing one is added, an existing one gets its password set to the secret. It + * gets no admin group: the shares poll needs no admin rights (least privilege). The password + * goes by OC_PASS (`--password-from-env`), never on an argv. Then the brute-force record of the + * addresses the module's calls arrive from is reset, so the first good login is not held behind + * the throttle. A failing occ write is thrown as LoginRefusedError carrying occ's own message. */ - ensureAdminAccount(): "created" | "password-set" { + ensureAdminAccount(): { account: "created" | "password-set"; throttleReset: boolean } { + if (this.adminUser !== MESH_ACCOUNT) { + throw new LoginRefusedError(401, this.adminUser, `not repaired: only ${MESH_ACCOUNT}, the module's own account, is`); + } const pass = { env: { OC_PASS: this.adminPassword } }; - let exists = true; + let account: "created" | "password-set"; try { - this.occ(["user:info", this.adminUser, "--output=json"]); + if (!this.listUsers().some((u) => u.uid === this.adminUser)) { + this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass); + account = "created"; + } else { + this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); + account = "password-set"; + } } catch (err) { - if (!/not found|does not exist/i.test(String((err as Error)?.message ?? err))) throw err; - exists = false; + throw new LoginRefusedError(401, this.adminUser, `repair failed: ${String((err as Error)?.message ?? err).split("\n")[0]}`); } - if (!exists) { - this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", "--group=admin", this.adminUser], pass); - return "created"; - } - this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); + let throttleReset = true; try { - this.occ(["group:adduser", "admin", this.adminUser]); - } catch (err) { - if (!/already/i.test(String((err as Error)?.message ?? err))) throw err; + for (const address of this.callerAddresses()) this.occ(["security:bruteforce:reset", address]); + } catch { + throttleReset = false; } - return "password-set"; + return { account, throttleReset }; + } + + /** + * The addresses the module's calls reach Nextcloud from, as Nextcloud sees them: the gateway of + * each network the container is on (a published port arrives from it; the access log shows + * 172.17.0.1 on the default bridge). + */ + callerAddresses(): string[] { + const out = execFileSync("docker", ["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.Gateway}} {{end}}", this.container], { + encoding: "utf8", timeout: 30_000, + }); + return out.split(/\s+/).filter(Boolean); } private fetchShares(): Promise { diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index dd28417f..5d9f2ddc 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -72,7 +72,7 @@ }, "user": { "type": "string", - "description": "The Nextcloud account the module's tools act as; empty is admin.", + "description": "The Nextcloud account the module's tools act as; empty is mesh-admin, the account the module makes and keeps for itself. Any other account is used as given and never repaired.", "trusted": true, "default": "" } diff --git a/modules/nextcloud/test/admin-login.test.ts b/modules/nextcloud/test/admin-login.test.ts index 1e38a22b..61496606 100644 --- a/modules/nextcloud/test/admin-login.test.ts +++ b/modules/nextcloud/test/admin-login.test.ts @@ -15,8 +15,11 @@ 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(): any { - return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD); +function client(user = "mesh-admin"): any { + const c = new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", user, PASSWORD); + // The address Nextcloud sees the module's calls arrive from (docker inspect in the real one). + mock.method(c, "callerAddresses", () => ["172.17.0.1"]); + return c; } /** A stand-in Nextcloud: answers `statuses` in turn (the last one repeating) and counts the asks. */ @@ -32,25 +35,31 @@ function standInFetch(statuses: number[]) { } /** A stand-in occ: knows whether the account exists, records every call and every env passed. */ -function standInOcc(accountExists: boolean) { +function standInOcc(accountExists: boolean, opts: { failWrite?: string } = {}) { const calls: string[][] = []; const envs: Array | undefined> = []; - const impl = (args: string[], opts: { env?: Record } = {}): string => { + const impl = (args: string[], occOpts: { env?: Record } = {}): string => { calls.push(args); - envs.push(opts.env); - if (args[0] === "user:info") { - if (!accountExists) throw new Error("user not found"); - return "{}"; + envs.push(occOpts.env); + if (args[0] === "user:list") { + return JSON.stringify(accountExists ? { jochens: "Jochen", "mesh-admin": "Mesh administration" } : { jochens: "Jochen" }); } - if (["user:add", "user:resetpassword", "group:adduser"].includes(args[0])) return ""; + if (["user:add", "user:resetpassword"].includes(args[0])) { + if (opts.failWrite) throw new Error(opts.failWrite); + return ""; + } + if (args[0] === "security:bruteforce:reset") return ""; throw new Error("unexpected occ call: " + args.join(" ")); }; return { calls, envs, impl }; } +/** The occ calls that write: anything but a read of the user list. */ +const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:list"); + // 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the -// login, then the account is added in the admin group with the module's own secret, by -// OC_PASS and never on an argv, and the shares are asked again and listed. +// login, then the account is added (no admin group) with the module's own secret, by OC_PASS and +// never on an argv, the throttle of the caller's address is reset, and the shares are listed. test("a refused login adds the missing account, password by environment, and lists the shares", async () => { const c = client(); const occ = standInOcc(false); @@ -63,7 +72,9 @@ test("a refused login adds the missing account, password by environment, and lis 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("--group=admin") && add.includes("mesh-admin")); + 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.at(-1), ["security:bruteforce:reset", "172.17.0.1"]); 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 { @@ -72,15 +83,15 @@ test("a refused login adds the missing account, password by environment, and lis }); // 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 and it is put in the admin group. -test("a refused login resets an existing account's password and puts it in the admin group", async () => { +// then its password is set to the module's own secret, in no group, and the throttle is reset. +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", "group:adduser"]); + assert.deepEqual(occ.calls.map((a) => a[0]), ["user:list", "user:resetpassword", "security:bruteforce:reset"]); assert.equal(occ.envs[1]?.OC_PASS, PASSWORD); assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD))); } finally { @@ -205,3 +216,78 @@ test("an empty user setting falls back to the manifest's account", () => { 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(users: Record): { 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 ;;", + ` *' occ user:list '*) printf '%s' '${JSON.stringify(users)}' ;;`, + " inspect*) echo '172.17.0.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({ jochens: "Jochen" }); + 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.deepEqual(did, { account: "created", throttleReset: true }); + }); + 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.endsWith("php occ security:bruteforce:reset 172.17.0.1")), lines.join("\n")); + assert.ok(!lines.join("\n").includes(PASSWORD)); +}); From 1cc54959afcf4d2fe658ed47531829e45c6aee89 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 16:57:46 +0100 Subject: [PATCH 4/4] nextcloud: repair the account only on a 401, never reset the throttle (issue 1253, review 2) Resetting Nextcloud's brute-force record for the docker gateway cleared it for every client behind that address: someone guessing passwords could drive the throttle to 429 and have the module's poll wipe it. A throttle is no evidence the password is wrong, so a 429 is now backed off only, and the back-off outlasts the throttle. The account's existence is read from user:info's exit status, since user:list pages. --- modules/nextcloud/client.ts | 67 ++++++++++------------ modules/nextcloud/test/admin-login.test.ts | 64 ++++++++++++--------- 2 files changed, 68 insertions(+), 63 deletions(-) diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index 95c94977..1dfaaaa6 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -185,7 +185,9 @@ export class NextcloudClient { // occ writes many refusals ("user not found") to stdout with an empty stderr: say both. const detail = [err?.stderr, err?.stdout].map((x) => String(x ?? "").trim()).filter(Boolean).join("\n") || String(err?.message ?? "").trim(); - throw new Error(detail || `occ produced no output — is the ${this.container} container running?`); + // The exit status rides along, so a caller can tell occ's own answer from a failure. + throw Object.assign(new Error(detail || `occ produced no output — is the ${this.container} container running?`), + { status: typeof err?.status === "number" ? err.status : undefined }); } } @@ -265,49 +267,40 @@ export class NextcloudClient { * The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance * the mesh adopted never has it, and a secret made anew never reaches it. Only the module's own * account (MESH_ACCOUNT) is touched: under any other name it writes nothing and throws - * LoginRefusedError. Through occ: whether the account exists is read from user:list's - * membership; a missing one is added, an existing one gets its password set to the secret. It - * gets no admin group: the shares poll needs no admin rights (least privilege). The password - * goes by OC_PASS (`--password-from-env`), never on an argv. Then the brute-force record of the - * addresses the module's calls arrive from is reset, so the first good login is not held behind - * the throttle. A failing occ write is thrown as LoginRefusedError carrying occ's own message. + * LoginRefusedError. Through occ: whether the account exists is read from `user:info`, whose + * exit status 1 with "not found" means missing (user:list pages, so it is not asked); a missing + * one is added, an existing one gets its password set to the secret. It gets no admin group: the + * shares poll needs no admin rights (least privilege). The password goes by OC_PASS + * (`--password-from-env`), never on an argv. Nextcloud's brute-force record is left alone: it is + * shared by every client behind the same address, and the caller's back-off outlasts it. A + * failing occ is thrown as LoginRefusedError carrying occ's own message. */ - ensureAdminAccount(): { account: "created" | "password-set"; throttleReset: boolean } { + ensureAdminAccount(): "created" | "password-set" { if (this.adminUser !== MESH_ACCOUNT) { throw new LoginRefusedError(401, this.adminUser, `not repaired: only ${MESH_ACCOUNT}, the module's own account, is`); } const pass = { env: { OC_PASS: this.adminPassword } }; - let account: "created" | "password-set"; try { - if (!this.listUsers().some((u) => u.uid === this.adminUser)) { + if (!this.accountExists()) { this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass); - account = "created"; - } else { - this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); - account = "password-set"; + return "created"; } + this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); + return "password-set"; } catch (err) { throw new LoginRefusedError(401, this.adminUser, `repair failed: ${String((err as Error)?.message ?? err).split("\n")[0]}`); } - let throttleReset = true; - try { - for (const address of this.callerAddresses()) this.occ(["security:bruteforce:reset", address]); - } catch { - throttleReset = false; - } - return { account, throttleReset }; } - /** - * The addresses the module's calls reach Nextcloud from, as Nextcloud sees them: the gateway of - * each network the container is on (a published port arrives from it; the access log shows - * 172.17.0.1 on the default bridge). - */ - callerAddresses(): string[] { - const out = execFileSync("docker", ["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.Gateway}} {{end}}", this.container], { - encoding: "utf8", timeout: 30_000, - }); - return out.split(/\s+/).filter(Boolean); + /** Whether the account exists: occ user:info answers it, exit 1 and "not found" when missing. */ + private accountExists(): boolean { + try { + this.occ(["user:info", this.adminUser, "--output=json"]); + return true; + } catch (err: any) { + if (err?.status === 1 && /not found/i.test(String(err?.message ?? ""))) return false; + throw err; + } } private fetchShares(): Promise { @@ -319,15 +312,15 @@ export class NextcloudClient { } /** - * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) or - * a throttle (429) makes the account right once (ensureAdminAccount) and asks again; a refusal - * or a throttle after that throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). + * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) + * makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or + * any throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). */ async listShares(): Promise { let res = await this.fetchShares(); - // A throttle (429) follows refusals, so it gets the same one repair: the repair goes by occ, - // never by a login, and the throttle then wears off under the caller's back-off. - if ((res.status === 401 || res.status === 429) && !this.accountMadeRight) { + // Only a refused login (401) is repaired. A throttle (429) is no evidence the password is + // wrong (anyone guessing passwords from the same address causes it): it is backed off only. + if (res.status === 401 && !this.accountMadeRight) { await res.text(); this.accountMadeRight = true; this.ensureAdminAccount(); diff --git a/modules/nextcloud/test/admin-login.test.ts b/modules/nextcloud/test/admin-login.test.ts index 61496606..218c9030 100644 --- a/modules/nextcloud/test/admin-login.test.ts +++ b/modules/nextcloud/test/admin-login.test.ts @@ -16,10 +16,7 @@ const REFUSED_XML = `\n\n \n failure ["172.17.0.1"]); - return c; + 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. */ @@ -41,25 +38,26 @@ function standInOcc(accountExists: boolean, opts: { failWrite?: string } = {}) { const impl = (args: string[], occOpts: { env?: Record } = {}): string => { calls.push(args); envs.push(occOpts.env); - if (args[0] === "user:list") { - return JSON.stringify(accountExists ? { jochens: "Jochen", "mesh-admin": "Mesh administration" } : { jochens: "Jochen" }); + 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 ""; } - if (args[0] === "security:bruteforce:reset") return ""; throw new Error("unexpected occ call: " + args.join(" ")); }; return { calls, envs, impl }; } -/** The occ calls that write: anything but a read of the user list. */ -const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:list"); +/** 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, the throttle of the caller's address is reset, and the shares are listed. +// 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); @@ -74,7 +72,7 @@ test("a refused login adds the missing account, password by environment, and lis 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.at(-1), ["security:bruteforce:reset", "172.17.0.1"]); + 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 { @@ -83,7 +81,7 @@ test("a refused login adds the missing account, password by environment, and lis }); // 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, and the throttle is reset. +// 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); @@ -91,7 +89,7 @@ test("a refused login resets an existing account's password, in no group", async const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl); try { await c.listShares(); - assert.deepEqual(occ.calls.map((a) => a[0]), ["user:list", "user:resetpassword", "security:bruteforce:reset"]); + 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 { @@ -124,17 +122,33 @@ test("a refusal after the repair throws LoginRefusedError once-repaired, short a } }); -// 1253/AC3b: a throttle (429) follows refusals: it gets the same one repair, and a throttle -// that stays throws LoginRefusedError. -test("a throttled login (429) is repaired once and then throws LoginRefusedError", async () => { +// 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]).impl); + const f = mock.method(globalThis, "fetch", standInFetch([429, 200, 429, 200]).impl); try { - await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429); - await assert.rejects(c.listShares(), LoginRefusedError); - assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").length, 1); + 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(); } @@ -252,7 +266,7 @@ test("a failing repair carries occ's message into LoginRefusedError", async () = }); /** A fake docker on PATH: answers occ like Nextcloud (user:info's refusal on stdout, exit 1). */ -function fakeDocker(users: Record): { dir: string; record: string } { +function fakeDocker(): { dir: string; record: string } { const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-docker-")); const record = join(dir, "record"); writeFileSync(join(dir, "docker"), [ @@ -260,8 +274,6 @@ function fakeDocker(users: Record): { dir: string; record: strin `printf '%s\\n' "$*" >> ${record}`, 'case "$*" in', " *' occ user:info '*) echo 'user not found'; exit 1 ;;", - ` *' occ user:list '*) printf '%s' '${JSON.stringify(users)}' ;;`, - " inspect*) echo '172.17.0.1 ' ;;", "esac", "exit 0", "", @@ -279,15 +291,15 @@ function withPath(dir: string, fn: () => T): T { // 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({ jochens: "Jochen" }); + 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.deepEqual(did, { account: "created", throttleReset: true }); + 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.endsWith("php occ security:bruteforce:reset 172.17.0.1")), 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)); });