Merge pull request 'nextcloud: the module makes its own mesh-admin account right on a 401 and backs off on a refusal, so its shares poll stops being refused every minute (issue 1253)' (#478) from fix/nextcloud-admin-401 into main

Reviewed-on: #478
This commit was merged in pull request #478.
This commit is contained in:
2026-10-25 16:01:37 +00:00
4 changed files with 491 additions and 18 deletions
+179 -15
View File
@@ -61,10 +61,74 @@ const ONLYOFFICE_KEYS: ReadonlyArray<readonly [occKey: string, configKey: string
let saidOnlyOfficeNotGiven = false;
/**
* The one Nextcloud account the module makes and keeps for itself: the same name files/server.env
* gives NEXTCLOUD_ADMIN_USER (novox/hq issue 1253). Only this account is ever repaired; a `user`
* setting naming any other account is used as given and never written to.
*/
export const MESH_ACCOUNT = "mesh-admin";
/**
* Nextcloud refused the mesh's own login on its OCS surface (HTTP 401), or throttled it after
* refusals (HTTP 429, its brute-force protection). Said by its status and the account, never a
* password; the events loop backs off on it instead of asking again every tick.
*/
export class LoginRefusedError extends Error {
constructor(readonly status: number, readonly user: string, detail: string) {
super(`Nextcloud refused the login of ${user} (${status}${detail ? ": " + detail : ""})`);
this.name = "LoginRefusedError";
}
}
/** The OCS envelope's own message, the one line worth saying of a refusal; empty when none. */
function ocsMessage(body: string): string {
const xml = /<message>([^<]*)<\/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,
@@ -76,15 +140,17 @@ export class NextcloudClient {
/**
* Build from the module's resolved environment. occ needs only the container name (default
* "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default mesh-admin, URL the local
* container). The admin password is treated as the "configured for mesh administration" signal:
* throws without it, and the module then contributes nothing rather than failing.
*/
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"}`;
// No "admin" fallback: that is what a hand-made install calls its human administrator.
const adminUser = cfg.user || env.MESH_NEXTCLOUD_ADMIN_USER || MESH_ACCOUNT;
const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE;
ignoreSecretSetting(cfg, "password");
const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD
@@ -100,20 +166,28 @@ 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, string> } = {}): 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();
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
// 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();
// 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 });
}
}
@@ -188,14 +262,75 @@ 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<NextcloudShare[]> {
/**
* 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. 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: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(): "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 } };
try {
if (!this.accountExists()) {
this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass);
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]}`);
}
}
/** 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<Response> {
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
* any throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253).
*/
async listShares(): Promise<NextcloudShare[]> {
let res = await this.fetchShares();
// 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();
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 +341,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<void>,
now: () => number = Date.now,
): Promise<void> {
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");
}
+6 -2
View File
@@ -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");
}
+1 -1
View File
@@ -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": ""
}
+305
View File
@@ -0,0 +1,305 @@
// 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 = `<?xml version="1.0"?>\n<ocs>\n <meta>\n <status>failure</status>\n <statuscode>997</statuscode>\n <message>Unauthorised</message>\n </meta>\n <data/>\n</ocs>\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<Response> => {
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<Record<string, string> | undefined> = [];
const impl = (args: string[], occOpts: { env?: Record<string, string> } = {}): 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<void> => {
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<T>(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));
});