import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; import test from 'node:test'; 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 }); } }); 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 }); } });