// 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) 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);
await assert.rejects(c.listShares(), LoginRefusedError);
assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").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");
});