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.
This commit is contained in:
2026-10-25 16:54:51 +01:00
parent 67b13c42d3
commit 962bc359a7
3 changed files with 153 additions and 37 deletions
+101 -15
View File
@@ -15,8 +15,11 @@ 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(): 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<Record<string, string> | undefined> = [];
const impl = (args: string[], opts: { env?: Record<string, string> } = {}): string => {
const impl = (args: string[], occOpts: { env?: Record<string, string> } = {}): 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<string, string>): { 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<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({ 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));
});