Files
mesh-catalog/modules/nextcloud/test/onlyoffice.test.ts
T
jochen edb6d3b7d3 nextcloud: keep checking onlyoffice's connection while the last check failed (issue 1832)
applyOnlyOffice only ran the connection check when a setting changed, so
once it failed while settings matched, nothing checked again and the
failure was logged once and forgotten. The client now remembers whether
the last check failed and re-runs it on every tick until it passes;
index.ts logs every failing tick and one line when it recovers.
2026-10-22 16:28:35 +02:00

216 lines
9.3 KiB
TypeScript

// 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, string>): 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<string, string>, opts: { checkFails?: boolean } = {}) {
const calls: string[][] = [];
const inputs: Array<string | undefined> = [];
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<string, string> = {
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<string, string> = {
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<string, string> = {}; // 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<string, string> = {
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<string, string> = {}; // 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();
}
});