RTL/test/backend/rtlconf.test.mjs

603 lines
21 KiB
JavaScript
Raw Permalink Normal View History

import assert from 'node:assert/strict';
Release 0.15.10 (#1665) * Update version 0.15.10 * Update project dependencies to resolve Dependabot security alerts Applies the fixes from the open Dependabot PRs (#1648, #1649, #1650) in a single pass on the release branch, regenerating the lockfile from scratch. axios 1.16.0 -> 1.18.1 was the only production exposure (10 advisories). Transitive deps moved to their fixed in-range versions (fast-uri 3.1.4, form-data, qs, tough-cookie, tar, del, globby); dev toolchain took safe bumps (nodemon 3.1.14, eslint 9.39.5, @typescript-eslint 8.65.0). Drops the unused protractor devDependency: no e2e directory, no config and no e2e target in angular.json, but 100 packages and the deprecated request stack behind it. That clears both critical advisories. npm audit: 50 (2 critical) -> 29 (0 critical); production deps 1 -> 0. Remaining findings are dev-only tooling needing an Angular 21 migration rather than a version bump. Verified: lint, 204 frontend specs, backend + frontend production builds, and 19 API checks against the docker regtest fixture covering LND, Core Lightning and Eclair (getinfo, channels, peers, invoices, payments and forwarding history). * Fill in PR number in release note (#1653) * Harden login request validation (#1654) Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly. Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture. * Reduce exposure of authentication secrets in logs and config responses (#1659) * Reduce exposure of authentication secrets in logs and config responses * Fill in PR number in release note (#1659) * Harden redaction helpers and secret restore paths * Pin deployment auth switches server-side and harden settings persistence * Contain backup file reads and harden config persistence * Pin backup containment root and preserve config file mode on save * Update Angular framework packages to 20.3.27 (#1661) * Update Angular framework packages to 20.3.27 Batches the three Dependabot PRs open against master for the Angular framework (@angular/core #1658, @angular/compiler #1657, @angular/common #1655) into one update on the release branch. The framework packages are pinned to exact versions and their peer ranges require them to move together, so all nine 20.3.26 packages go to 20.3.27: animations, common, compiler, compiler-cli, core, forms, platform-browser, platform-browser-dynamic and router. Patch-level upstream fixes only, no advisories. The update stays inside Angular 20 - @angular/build and @angular/cli (20.3.32) and @angular/cdk/@angular/material (20.2.14) are already at the top of their v20 lines - so it does not pull in the Angular 21 migration tracked by #1650. Rebuilt frontend/ for the new framework code. backend/ is unchanged, as no server/ source moved. * Fill in PR number in release note (#1661) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 (#1651) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 * Address review feedback: fix options race, error handling, release notes * Improve release notes entry to cover full PR scope * Address review feedback: per-task options copy, exclude qs from alias requests * Stop logging the eclair auth header at DEBUG level (#1664) * Stop logging the eclair auth header at DEBUG level getChannels in the eclair channels controller logged its whole request options object. Eclair authenticates with HTTP basic auth, so those options carry the configured lnApiPassword in an authorization header - raising an eclair node's logLevel to DEBUG wrote "authorization":"Basic <base64>" into the node log file, which is a recoverable form of the credential and is routinely shared when debugging. The log now carries only the request url and form, matching every other DEBUG log in the controllers. This was the only site in server/ passing a whole options object to the logger; the rest log options.form, .url, .body or .qs, none of which hold credentials. Present since 0.12.0 and only reachable by opting in to DEBUG (the default log level is ERROR), but it contradicted the logging guarantee stated for #1659. Found by scanning node logs at DEBUG while verifying the 0.15.10 branch against the regtest fixture. Regression test added in test/backend/eclair-channels.test.mjs; it fails on the previous code with "auth header key must not reach the node log". * Fill in PR number in release note (#1664) --------- Co-authored-by: Osuji <weezdomosuji@gmail.com>
2026-08-03 22:49:14 -07:00
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
Release 0.15.10 (#1665) * Update version 0.15.10 * Update project dependencies to resolve Dependabot security alerts Applies the fixes from the open Dependabot PRs (#1648, #1649, #1650) in a single pass on the release branch, regenerating the lockfile from scratch. axios 1.16.0 -> 1.18.1 was the only production exposure (10 advisories). Transitive deps moved to their fixed in-range versions (fast-uri 3.1.4, form-data, qs, tough-cookie, tar, del, globby); dev toolchain took safe bumps (nodemon 3.1.14, eslint 9.39.5, @typescript-eslint 8.65.0). Drops the unused protractor devDependency: no e2e directory, no config and no e2e target in angular.json, but 100 packages and the deprecated request stack behind it. That clears both critical advisories. npm audit: 50 (2 critical) -> 29 (0 critical); production deps 1 -> 0. Remaining findings are dev-only tooling needing an Angular 21 migration rather than a version bump. Verified: lint, 204 frontend specs, backend + frontend production builds, and 19 API checks against the docker regtest fixture covering LND, Core Lightning and Eclair (getinfo, channels, peers, invoices, payments and forwarding history). * Fill in PR number in release note (#1653) * Harden login request validation (#1654) Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly. Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture. * Reduce exposure of authentication secrets in logs and config responses (#1659) * Reduce exposure of authentication secrets in logs and config responses * Fill in PR number in release note (#1659) * Harden redaction helpers and secret restore paths * Pin deployment auth switches server-side and harden settings persistence * Contain backup file reads and harden config persistence * Pin backup containment root and preserve config file mode on save * Update Angular framework packages to 20.3.27 (#1661) * Update Angular framework packages to 20.3.27 Batches the three Dependabot PRs open against master for the Angular framework (@angular/core #1658, @angular/compiler #1657, @angular/common #1655) into one update on the release branch. The framework packages are pinned to exact versions and their peer ranges require them to move together, so all nine 20.3.26 packages go to 20.3.27: animations, common, compiler, compiler-cli, core, forms, platform-browser, platform-browser-dynamic and router. Patch-level upstream fixes only, no advisories. The update stays inside Angular 20 - @angular/build and @angular/cli (20.3.32) and @angular/cdk/@angular/material (20.2.14) are already at the top of their v20 lines - so it does not pull in the Angular 21 migration tracked by #1650. Rebuilt frontend/ for the new framework code. backend/ is unchanged, as no server/ source moved. * Fill in PR number in release note (#1661) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 (#1651) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 * Address review feedback: fix options race, error handling, release notes * Improve release notes entry to cover full PR scope * Address review feedback: per-task options copy, exclude qs from alias requests * Stop logging the eclair auth header at DEBUG level (#1664) * Stop logging the eclair auth header at DEBUG level getChannels in the eclair channels controller logged its whole request options object. Eclair authenticates with HTTP basic auth, so those options carry the configured lnApiPassword in an authorization header - raising an eclair node's logLevel to DEBUG wrote "authorization":"Basic <base64>" into the node log file, which is a recoverable form of the credential and is routinely shared when debugging. The log now carries only the request url and form, matching every other DEBUG log in the controllers. This was the only site in server/ passing a whole options object to the logger; the rest log options.form, .url, .body or .qs, none of which hold credentials. Present since 0.12.0 and only reachable by opting in to DEBUG (the default log level is ERROR), but it contradicted the logging guarantee stated for #1659. Found by scanning node logs at DEBUG while verifying the 0.15.10 branch against the regtest fixture. Regression test added in test/backend/eclair-channels.test.mjs; it fails on the previous code with "auth header key must not reach the node log". * Fill in PR number in release note (#1664) --------- Co-authored-by: Osuji <weezdomosuji@gmail.com>
2026-08-03 22:49:14 -07:00
import { join, sep } from 'node:path';
import test from 'node:test';
Release 0.15.10 (#1665) * Update version 0.15.10 * Update project dependencies to resolve Dependabot security alerts Applies the fixes from the open Dependabot PRs (#1648, #1649, #1650) in a single pass on the release branch, regenerating the lockfile from scratch. axios 1.16.0 -> 1.18.1 was the only production exposure (10 advisories). Transitive deps moved to their fixed in-range versions (fast-uri 3.1.4, form-data, qs, tough-cookie, tar, del, globby); dev toolchain took safe bumps (nodemon 3.1.14, eslint 9.39.5, @typescript-eslint 8.65.0). Drops the unused protractor devDependency: no e2e directory, no config and no e2e target in angular.json, but 100 packages and the deprecated request stack behind it. That clears both critical advisories. npm audit: 50 (2 critical) -> 29 (0 critical); production deps 1 -> 0. Remaining findings are dev-only tooling needing an Angular 21 migration rather than a version bump. Verified: lint, 204 frontend specs, backend + frontend production builds, and 19 API checks against the docker regtest fixture covering LND, Core Lightning and Eclair (getinfo, channels, peers, invoices, payments and forwarding history). * Fill in PR number in release note (#1653) * Harden login request validation (#1654) Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly. Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture. * Reduce exposure of authentication secrets in logs and config responses (#1659) * Reduce exposure of authentication secrets in logs and config responses * Fill in PR number in release note (#1659) * Harden redaction helpers and secret restore paths * Pin deployment auth switches server-side and harden settings persistence * Contain backup file reads and harden config persistence * Pin backup containment root and preserve config file mode on save * Update Angular framework packages to 20.3.27 (#1661) * Update Angular framework packages to 20.3.27 Batches the three Dependabot PRs open against master for the Angular framework (@angular/core #1658, @angular/compiler #1657, @angular/common #1655) into one update on the release branch. The framework packages are pinned to exact versions and their peer ranges require them to move together, so all nine 20.3.26 packages go to 20.3.27: animations, common, compiler, compiler-cli, core, forms, platform-browser, platform-browser-dynamic and router. Patch-level upstream fixes only, no advisories. The update stays inside Angular 20 - @angular/build and @angular/cli (20.3.32) and @angular/cdk/@angular/material (20.2.14) are already at the top of their v20 lines - so it does not pull in the Angular 21 migration tracked by #1650. Rebuilt frontend/ for the new framework code. backend/ is unchanged, as no server/ source moved. * Fill in PR number in release note (#1661) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 (#1651) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 * Address review feedback: fix options race, error handling, release notes * Improve release notes entry to cover full PR scope * Address review feedback: per-task options copy, exclude qs from alias requests * Stop logging the eclair auth header at DEBUG level (#1664) * Stop logging the eclair auth header at DEBUG level getChannels in the eclair channels controller logged its whole request options object. Eclair authenticates with HTTP basic auth, so those options carry the configured lnApiPassword in an authorization header - raising an eclair node's logLevel to DEBUG wrote "authorization":"Basic <base64>" into the node log file, which is a recoverable form of the credential and is routinely shared when debugging. The log now carries only the request url and form, matching every other DEBUG log in the controllers. This was the only site in server/ passing a whole options object to the logger; the rest log options.form, .url, .body or .qs, none of which hold credentials. Present since 0.12.0 and only reachable by opting in to DEBUG (the default log level is ERROR), but it contradicted the logging guarantee stated for #1659. Found by scanning node logs at DEBUG while verifying the 0.15.10 branch against the regtest fixture. Regression test added in test/backend/eclair-channels.test.mjs; it fails on the previous code with "auth header key must not reach the node log". * Fill in PR number in release note (#1664) --------- Co-authored-by: Osuji <weezdomosuji@gmail.com>
2026-08-03 22:49:14 -07:00
import { updateApplicationSettings, updateNodeSettings, getFile } from '../../backend/controllers/shared/RTLConf.js';
import { Common } from '../../backend/utils/common.js';
import { WSServer } from '../../backend/utils/webSocketServer.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
test('updateApplicationSettings preserves indexed node auth and sanitizes only persisted config', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie', logoutRedirectLink: '', cookieValue: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
},
{
index: 2,
lnNode: 'cln-secondary',
lnImplementation: 'CLN',
authentication: { runePath: '/cln/rune' },
settings: { userPersona: 'MERCHANT', themeMode: 'NIGHT', blockExplorerUrl: 'https://old.example' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 2,
enable2FA: true,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
multiPassHashed: 'multi-pass-hash',
nodes: [
{
...oldConfig.nodes[0],
authentication: {
...oldConfig.nodes[0].authentication,
options: { headers: { 'Grpc-Metadata-macaroon': 'runtime-lnd-macaroon' } }
}
},
{
...oldConfig.nodes[1],
authentication: {
...oldConfig.nodes[1].authentication,
runeValue: 'runtime-rune',
options: { headers: { rune: 'runtime-rune' } }
}
}
]
});
const requestBody = {
defaultNodeIndex: 0,
selectedNodeIndex: 2,
enable2FA: false,
allowPasswordUpdate: false,
dbDirectoryPath: '/db-updated',
secret2FA: '',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' },
nodes: [
{
index: 2,
lnNode: 'cln-secondary',
lnImplementation: 'CLN',
authentication: { swapMacaroonPath: '/loop/cln' },
settings: { themeMode: 'DAY' }
},
{
index: 5,
lnNode: 'new-lnd',
lnImplementation: 'LND',
authentication: { macaroonPath: '/new-lnd/admin' },
settings: { userPersona: 'OPERATOR' }
}
]
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[1];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
let responseBody;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return {
json: (body) => {
responseBody = body;
}
};
}
},
null
);
assert.equal(responseStatus, 201);
assert.deepEqual(Common.appConfig.nodes.map((node) => node.index), [0, 2, 5]);
const runtimeClnNode = Common.appConfig.nodes[1];
assert.equal(runtimeClnNode.authentication.runePath, '/cln/rune');
assert.equal(runtimeClnNode.authentication.macaroonPath, undefined);
assert.equal(runtimeClnNode.authentication.runeValue, 'runtime-rune');
assert.deepEqual(runtimeClnNode.authentication.options, { headers: { rune: 'runtime-rune' } });
assert.equal(runtimeClnNode.authentication.swapMacaroonPath, '/loop/cln');
assert.equal(runtimeClnNode.settings.themeMode, 'DAY');
assert.equal(runtimeClnNode.settings.blockExplorerUrl, 'https://old.example');
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.deepEqual(fileConfig.nodes.map((node) => node.index), [0, 2, 5]);
assert.equal(fileConfig.rtlPass, undefined);
assert.equal(fileConfig.rtlConfFilePath, undefined);
assert.equal(fileConfig.selectedNodeIndex, undefined);
assert.equal(fileConfig.enable2FA, undefined);
assert.equal(fileConfig.allowPasswordUpdate, undefined);
assert.equal(fileConfig.nodes[1].authentication.runePath, '/cln/rune');
assert.equal(fileConfig.nodes[1].authentication.macaroonPath, undefined);
assert.equal(fileConfig.nodes[1].authentication.runeValue, undefined);
assert.equal(fileConfig.nodes[1].authentication.options, undefined);
assert.equal(responseBody.nodes[1].authentication.runePath, undefined);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
Release 0.15.10 (#1665) * Update version 0.15.10 * Update project dependencies to resolve Dependabot security alerts Applies the fixes from the open Dependabot PRs (#1648, #1649, #1650) in a single pass on the release branch, regenerating the lockfile from scratch. axios 1.16.0 -> 1.18.1 was the only production exposure (10 advisories). Transitive deps moved to their fixed in-range versions (fast-uri 3.1.4, form-data, qs, tough-cookie, tar, del, globby); dev toolchain took safe bumps (nodemon 3.1.14, eslint 9.39.5, @typescript-eslint 8.65.0). Drops the unused protractor devDependency: no e2e directory, no config and no e2e target in angular.json, but 100 packages and the deprecated request stack behind it. That clears both critical advisories. npm audit: 50 (2 critical) -> 29 (0 critical); production deps 1 -> 0. Remaining findings are dev-only tooling needing an Angular 21 migration rather than a version bump. Verified: lint, 204 frontend specs, backend + frontend production builds, and 19 API checks against the docker regtest fixture covering LND, Core Lightning and Eclair (getinfo, channels, peers, invoices, payments and forwarding history). * Fill in PR number in release note (#1653) * Harden login request validation (#1654) Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly. Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture. * Reduce exposure of authentication secrets in logs and config responses (#1659) * Reduce exposure of authentication secrets in logs and config responses * Fill in PR number in release note (#1659) * Harden redaction helpers and secret restore paths * Pin deployment auth switches server-side and harden settings persistence * Contain backup file reads and harden config persistence * Pin backup containment root and preserve config file mode on save * Update Angular framework packages to 20.3.27 (#1661) * Update Angular framework packages to 20.3.27 Batches the three Dependabot PRs open against master for the Angular framework (@angular/core #1658, @angular/compiler #1657, @angular/common #1655) into one update on the release branch. The framework packages are pinned to exact versions and their peer ranges require them to move together, so all nine 20.3.26 packages go to 20.3.27: animations, common, compiler, compiler-cli, core, forms, platform-browser, platform-browser-dynamic and router. Patch-level upstream fixes only, no advisories. The update stays inside Angular 20 - @angular/build and @angular/cli (20.3.32) and @angular/cdk/@angular/material (20.2.14) are already at the top of their v20 lines - so it does not pull in the Angular 21 migration tracked by #1650. Rebuilt frontend/ for the new framework code. backend/ is unchanged, as no server/ source moved. * Fill in PR number in release note (#1661) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 (#1651) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 * Address review feedback: fix options race, error handling, release notes * Improve release notes entry to cover full PR scope * Address review feedback: per-task options copy, exclude qs from alias requests * Stop logging the eclair auth header at DEBUG level (#1664) * Stop logging the eclair auth header at DEBUG level getChannels in the eclair channels controller logged its whole request options object. Eclair authenticates with HTTP basic auth, so those options carry the configured lnApiPassword in an authorization header - raising an eclair node's logLevel to DEBUG wrote "authorization":"Basic <base64>" into the node log file, which is a recoverable form of the credential and is routinely shared when debugging. The log now carries only the request url and form, matching every other DEBUG log in the controllers. This was the only site in server/ passing a whole options object to the logger; the rest log options.form, .url, .body or .qs, none of which hold credentials. Present since 0.12.0 and only reachable by opting in to DEBUG (the default log level is ERROR), but it contradicted the logging guarantee stated for #1659. Found by scanning node logs at DEBUG while verifying the 0.15.10 branch against the regtest fixture. Regression test added in test/backend/eclair-channels.test.mjs; it fails on the previous code with "auth header key must not reach the node log". * Fill in PR number in release note (#1664) --------- Co-authored-by: Osuji <weezdomosuji@gmail.com>
2026-08-03 22:49:14 -07:00
test('updateApplicationSettings keeps the SSO cookie server-side without exposing or persisting it', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-sso-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
enable2FA: false,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
});
// The request carries only what the sanitized client can have seen: no cookieValue.
// The server must re-attach it — a settings save must never wipe the live cookie —
// while keeping it out of both the response and the persisted file.
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
enable2FA: false,
allowPasswordUpdate: true,
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' }
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
let responseBody;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return {
json: (body) => {
responseBody = body;
}
};
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.SSO.rtlCookiePath, '/cookie-path');
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.equal(fileConfig.SSO.cookieValue, undefined);
assert.equal(responseBody.SSO.cookieValue, undefined);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings restores omitted secret2FA and merges a trimmed SSO object', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-secrets-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
enable2FA: true,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
secret2FA: 'live-totp-seed',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example', cookieValue: 'live-sso-cookie' }
});
// Sanitized responses carry neither secret2FA nor cookieValue, so an echoing client
// omits both; a trimmed SSO object also lacks logoutRedirectLink. All three must
// survive the save server-side.
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
enable2FA: true,
allowPasswordUpdate: true,
SSO: { rtlSSO: 0 }
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
assert.equal(Common.appConfig.enable2FA, true);
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.SSO.logoutRedirectLink, 'https://logout.example');
assert.equal(Common.appConfig.SSO.rtlSSO, 0);
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.equal(fileConfig.SSO.cookieValue, undefined);
assert.equal(fileConfig.secret2FA, 'live-totp-seed');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings tolerates a request body without an SSO object', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nosso-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const requestBody = clone(oldConfig);
delete requestBody.SSO;
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
updateApplicationSettings(
{ body: requestBody, session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(typeof Common.appConfig.SSO, 'object');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings leaves the runtime config untouched when the file write fails', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-writefail-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
secret2FA: 'live-totp-seed',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
});
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
SSO: { rtlSSO: 0 },
nodes: [{ ...clone(oldConfig.nodes[0]), settings: { themeMode: 'NIGHT' } }]
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
// Both write paths must fail: a read-only dir defeats the temp-file write, and a
// read-only file defeats the in-place fallback.
chmodSync(confPath, 0o444);
chmodSync(tempDir, 0o555);
let responseStatus = null;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 500);
// The failed write must not have committed the prospective config in memory either.
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.nodes[0].settings.themeMode, 'DAY');
// And the on-disk file still parses as the pre-call config.
const onDisk = JSON.parse(readFileSync(confPath, 'utf-8'));
assert.equal(onDisk.nodes.length, 1);
} finally {
clearInterval(WSServer.pingInterval);
chmodSync(confPath, 0o644);
chmodSync(tempDir, 0o755);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings preserves the config file mode across the atomic write', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-mode-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
chmodSync(confPath, 0o600); // operator-hardened; must not be silently downgraded
let responseStatus = null;
updateApplicationSettings(
{ body: clone(oldConfig), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(statSync(confPath).mode & 0o777, 0o600);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings falls back to an in-place write when the rename fails', () => {
// Single-file bind mounts and symlinks cannot be renamed over; the save must still work.
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-fallback-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
chmodSync(confPath, 0o600);
mkdirSync(confPath + '.tmp'); // forces the temp write to fail, exercising the fallback
let responseStatus = null;
updateApplicationSettings(
{ body: clone(oldConfig), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(statSync(confPath).mode & 0o777, 0o600); // in-place write keeps the inode
assert.deepEqual(JSON.parse(readFileSync(confPath, 'utf-8')).nodes.length, 1);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateNodeSettings pins channelBackupPath to the server-held value', () => {
// channelBackupPath anchors getFile's containment root; accepting it from the request
// would let the caller being contained choose the containment base.
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nodesettings-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY', channelBackupPath: '/server/backups' }
}
]
};
try {
Common.appConfig = clone({ ...oldConfig, rtlConfFilePath: tempDir });
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus = null;
updateNodeSettings(
{
body: { settings: { themeMode: 'NIGHT', channelBackupPath: tempDir } },
session: { selectedNode: Common.nodes[0] }
},
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
const fileNode = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8')).nodes[0];
assert.equal(fileNode.settings.channelBackupPath, '/server/backups');
assert.equal(fileNode.settings.themeMode, 'NIGHT'); // other settings still merge
assert.equal(Common.nodes[0].settings.channelBackupPath, '/server/backups');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('getFile contains caller paths to the channel backup directory', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-getfile-'));
const backupDir = join(tempDir, 'backups');
mkdirSync(backupDir);
writeFileSync(join(tempDir, 'secret.bak'), 'top-secret', 'utf-8');
writeFileSync(join(backupDir, 'channel-1x2x3.bak'), 'backup-data', 'utf-8');
const session = { selectedNode: { lnImplementation: 'LND', settings: { channelBackupPath: backupDir } } };
const mockRes = () => {
const res = { statusCode: null, body: null };
res.status = (code) => {
res.statusCode = code;
return { json: (body) => { res.body = body; } };
};
return res;
};
try {
// An escaping path is rejected before any read.
const rejected = mockRes();
getFile({ query: { path: join(tempDir, 'secret.bak') }, session }, rejected, null);
assert.equal(rejected.statusCode, 403);
// A contained path is served.
const served = mockRes();
await new Promise((resolve) => {
const res = { status: (code) => { served.statusCode = code; return { json: (body) => { served.body = body; resolve(); } }; } };
getFile({ query: { path: join(backupDir, 'channel-1x2x3.bak') }, session }, res, null);
});
assert.equal(served.statusCode, 200);
assert.equal(served.body, 'backup-data');
// A contained but missing file returns a path-free error (the ENOENT branch).
const missing = mockRes();
await new Promise((resolve) => {
const res = { status: (code) => { missing.statusCode = code; return { json: (body) => { missing.body = body; resolve(); } }; } };
getFile({ query: { path: join(backupDir, 'channel-missing.bak') }, session }, res, null);
});
assert.equal(missing.statusCode, 500);
assert.equal(JSON.stringify(missing.body).includes(backupDir), false);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});