// novox/hq issue 1830: nextcloud reaches only-office's document server by its mesh-delivered // config, not by hand-set settings, and the loop that applies it never leaks the shared secret. // Run against the compiled module, the way the runtime loads it. import { test, mock } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { NextcloudClient } from "../dist/client.js"; const SECRET = "the-shared-jwt-secret"; const FULL_CONFIG = { onlyofficeUrl: "https://office.example/", onlyofficeInternalUrl: "http://10.10.0.1:9070/", onlyofficeStorageUrl: "https://drive.example/", onlyofficeSecret: SECRET, }; function configFile(values: Record): string { const dir = mkdtempSync(join(tmpdir(), "nextcloud-1830-")); const path = join(dir, "config.json"); writeFileSync(path, JSON.stringify(values)); return path; } function client(): any { return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1", "admin", "irrelevant"); } /** A stand-in occ: config:app:get answers `current`, or throws like the real occ when unset. */ function standInOcc(current: Record, opts: { checkFails?: boolean } = {}) { const calls: string[][] = []; const inputs: Array = []; const impl = (args: string[], occOpts: { input?: string } = {}): string => { calls.push(args); inputs.push(occOpts.input); if (args[0] === "config:app:get") { const key = args[2]; if (!(key in current)) throw new Error("occ produced no output"); return current[key]; } if (args[0] === "config:app:set") { const key = args[2]; const value = args[3].slice("--value=".length); current[key] = value; return "System config value onlyoffice => " + key + " set to string " + value; } if (args[0] === "config:import") { const parsed = JSON.parse(occOpts.input ?? "{}"); Object.assign(current, parsed?.apps?.onlyoffice ?? {}); return ""; } if (args[0] === "onlyoffice:documentserver") { if (opts.checkFails) throw new Error("occ produced no output"); return ""; } throw new Error("unexpected occ call: " + args.join(" ")); }; return { calls, inputs, impl }; } // 1830/AC3a: when the delivered values differ from Nextcloud's settings, it sets exactly the // differing ones and runs OnlyOffice's connection check. test("sets exactly the keys that differ, and runs the connection check", () => { const c = client(); const current: Record = { DocumentServerUrl: "https://old-office.example/", StorageUrl: FULL_CONFIG.onlyofficeStorageUrl, // already correct jwt_secret: "a-stale-secret", // DocumentServerInternalUrl absent: occ's config:app:get throws, as for a never-set key. }; const stand = standInOcc(current); mock.method(c, "occ", stand.impl); const result = c.applyOnlyOffice(configFile(FULL_CONFIG)); assert.deepEqual(result.changed, ["DocumentServerUrl", "DocumentServerInternalUrl", "jwt_secret"]); assert.equal(result.checkOk, true); assert.ok(stand.calls.some((a) => a[0] === "onlyoffice:documentserver" && a[1] === "--check"), "the connection check ran"); assert.equal(current.StorageUrl, FULL_CONFIG.onlyofficeStorageUrl, "the matching key was left alone"); }); // 1831/AC1: the jwt_secret never reaches a docker/occ argv — it is applied only as stdin of a // config:import call, never as a config:app:set --value on the command line (novox/hq issue 1831). test("the jwt_secret is set by stdin of config:import, never in an argv", () => { const c = client(); const current: Record = { DocumentServerUrl: "https://old-office.example/", DocumentServerInternalUrl: FULL_CONFIG.onlyofficeInternalUrl, StorageUrl: FULL_CONFIG.onlyofficeStorageUrl, jwt_secret: "a-stale-secret", }; const stand = standInOcc(current); mock.method(c, "occ", stand.impl); const result = c.applyOnlyOffice(configFile(FULL_CONFIG)); assert.ok(result.changed.includes("jwt_secret"), "jwt_secret was changed"); assert.ok(!stand.calls.some((a) => a.join(" ").includes(SECRET)), "the secret is in no argv"); const importCall = stand.calls.findIndex((a) => a[0] === "config:import"); assert.notEqual(importCall, -1, "a config:import call was made"); assert.ok(!stand.calls.some((a) => a[0] === "config:app:set" && a[2] === "jwt_secret"), "jwt_secret is never set via config:app:set"); assert.equal(stand.inputs[importCall], JSON.stringify({ apps: { onlyoffice: { jwt_secret: SECRET } } }), "the secret is passed only as stdin of config:import"); assert.equal(current.jwt_secret, SECRET, "the secret was actually applied"); }); // 1832/AC1: a failed check keeps running on every later tick, even with nothing changed, reporting // checkOk false each time, until it passes — then nothing runs again while nothing changes // (novox/hq issue 1832). test("a failed check is re-run every tick until it passes, then nothing runs while unchanged", () => { const c = client(); const current: Record = {}; // every key unset, so tick 1 changes all four const checkOpts: { checkFails?: boolean } = { checkFails: true }; const stand = standInOcc(current, checkOpts); mock.method(c, "occ", stand.impl); const checkCalls = () => stand.calls.filter((a) => a[0] === "onlyoffice:documentserver").length; const file = configFile(FULL_CONFIG); // Tick 1: everything changes, the check runs and fails (1832/AC1c: reported on a failing tick). const tick1 = c.applyOnlyOffice(file); assert.deepEqual(tick1.changed.sort(), ["DocumentServerInternalUrl", "DocumentServerUrl", "StorageUrl", "jwt_secret"].sort()); assert.equal(tick1.checkOk, false); assert.equal(checkCalls(), 1); // Tick 2: nothing changed, but the last check failed — it is run again (1832/AC1a), and fails // again, reported again (1832/AC1c). const tick2 = c.applyOnlyOffice(file); assert.deepEqual(tick2.changed, []); assert.equal(tick2.checkOk, false); assert.equal(checkCalls(), 2, "the check ran again though nothing changed"); // Tick 3: the document server now answers; still nothing changed, but the last check had // failed, so it is run again — and this time it passes. checkOpts.checkFails = false; const tick3 = c.applyOnlyOffice(file); assert.deepEqual(tick3.changed, []); assert.equal(tick3.checkOk, true); assert.equal(checkCalls(), 3); // Tick 4: nothing changed, and the last check passed — no check runs (1832/AC1b). const tick4 = c.applyOnlyOffice(file); assert.deepEqual(tick4.changed, []); assert.equal(tick4.checkOk, undefined); assert.equal(checkCalls(), 3, "no further check ran once the last one passed"); }); // 1830/AC3b: when they match, it changes nothing and runs no check. test("changes nothing and runs no check when every value already matches", () => { const c = client(); const current: Record = { DocumentServerUrl: FULL_CONFIG.onlyofficeUrl, DocumentServerInternalUrl: FULL_CONFIG.onlyofficeInternalUrl, StorageUrl: FULL_CONFIG.onlyofficeStorageUrl, jwt_secret: FULL_CONFIG.onlyofficeSecret, }; const stand = standInOcc(current); mock.method(c, "occ", stand.impl); const result = c.applyOnlyOffice(configFile(FULL_CONFIG)); assert.deepEqual(result.changed, []); assert.equal(result.checkOk, undefined); assert.ok(!stand.calls.some((a) => a[0] === "config:app:set"), "nothing was set"); assert.ok(!stand.calls.some((a) => a[0] === "onlyoffice:documentserver"), "no check ran"); }); // 1830/AC3c: when a value is empty (not bound or not given yet), it changes nothing, and says so // once rather than on every call. test("changes nothing when a value is not given yet, and says so only once", () => { const c = client(); const stand = standInOcc({}); mock.method(c, "occ", stand.impl); const err = mock.method(console, "error", () => {}); try { const partial = { ...FULL_CONFIG, onlyofficeSecret: "" }; const file = configFile(partial); const first = c.applyOnlyOffice(file); const second = c.applyOnlyOffice(file); assert.deepEqual(first.changed, []); assert.deepEqual(second.changed, []); assert.equal(stand.calls.length, 0, "occ was never called"); assert.equal(err.mock.callCount(), 1, "said so once, not on every call"); } finally { err.mock.restore(); } }); // 1830/AC3d: the secret appears in no log line and no return value. test("the secret's value never appears in what is logged or returned", () => { const c = client(); const current: Record = {}; // every key unset, so jwt_secret is among those changed const stand = standInOcc(current); mock.method(c, "occ", stand.impl); const log = mock.method(console, "log", () => {}); const err = mock.method(console, "error", () => {}); try { const result = c.applyOnlyOffice(configFile(FULL_CONFIG)); assert.ok(result.changed.includes("jwt_secret"), "the key name is returned"); assert.ok(!result.changed.some((k: string) => k.includes(SECRET)), "never the value, in changed"); const said = [...log.mock.calls, ...err.mock.calls].map((c) => c.arguments.join(" ")).join("\n"); assert.ok(!said.includes(SECRET), "never the value, in a log line"); } finally { log.mock.restore(); err.mock.restore(); } });