mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
The admin saveSettings socket had zero direct backend coverage and the e2e 'restart works' test only checked the page renders after restart — neither catches a deployment that resets settings.json on restart, nor the user-visible workflow that triggered #7819 (add a top-level plugin block via Raw, save, watch it disappear). Adds three backend specs for the saveSettings socket: - payload is written byte-for-byte to settings.settingsFilename - augmenting existing JSON with a new top-level block round-trips through the next load reply - /* */ comments survive the write path Adds one e2e spec mirroring the #7819 workflow: open Raw, prepend an ep_oauth-shaped top-level block, save, restartEtherpad(), re-login, confirm the block is still in Raw and surfaces as its own Form-view section ('Ep oauth' from humanize()). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
271eb6ab5c
commit
abff8124e8
2 changed files with 280 additions and 0 deletions
220
src/tests/backend/specs/admin/adminSettingsSave.ts
Normal file
220
src/tests/backend/specs/admin/adminSettingsSave.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
'use strict';
|
||||
|
||||
import {strict as assert} from 'assert';
|
||||
import setCookieParser from 'set-cookie-parser';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const io = require('socket.io-client');
|
||||
const common = require('../../common');
|
||||
const settings = require('../../../../node/utils/Settings');
|
||||
|
||||
// Mirrors the adminSocket helper in adminSettingsResolved.ts. Lifted here
|
||||
// because the suite owns its own settings.settingsFilename stub and we
|
||||
// don't want either suite's setup/teardown to step on the other.
|
||||
const adminSocket = async () => {
|
||||
settings.users = settings.users || {};
|
||||
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
|
||||
const savedRequireAuthentication = settings.requireAuthentication;
|
||||
settings.requireAuthentication = true;
|
||||
let res: any;
|
||||
try {
|
||||
res = await (common.agent as any)
|
||||
.get('/admin/')
|
||||
.auth('test-admin', 'test-admin-password');
|
||||
} finally {
|
||||
settings.requireAuthentication = savedRequireAuthentication;
|
||||
}
|
||||
const resCookies = setCookieParser.parse(res, {map: true});
|
||||
const reqCookieHdr = Object.entries(resCookies)
|
||||
.map(([name, cookie]: [string, any]) =>
|
||||
`${name}=${encodeURIComponent(cookie.value)}`)
|
||||
.join('; ');
|
||||
const socket = io(`${common.baseUrl}/settings`, {
|
||||
forceNew: true,
|
||||
query: {cookie: reqCookieHdr},
|
||||
});
|
||||
await new Promise<void>((res, rej) => {
|
||||
const onErr = (err: any) => { socket.off('connect', onConn); rej(err); };
|
||||
const onConn = () => { socket.off('connect_error', onErr); res(); };
|
||||
socket.once('connect', onConn);
|
||||
socket.once('connect_error', onErr);
|
||||
});
|
||||
return socket;
|
||||
};
|
||||
|
||||
const PROBE_BUDGET_MS = 15000;
|
||||
const adminSocketWithProbe = async (budgetMs: number): Promise<{
|
||||
ok: true; socket: any;
|
||||
} | {ok: false; reason: string;}> => {
|
||||
const deadline = Date.now() + budgetMs;
|
||||
let socket: any;
|
||||
try {
|
||||
socket = await Promise.race([
|
||||
adminSocket(),
|
||||
new Promise<never>((_, rej) =>
|
||||
setTimeout(() => rej(new Error('adminSocket connect timed out')),
|
||||
Math.max(0, deadline - Date.now()))),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
return {ok: false, reason: String(err && err.message || err)};
|
||||
}
|
||||
const remaining = Math.max(0, deadline - Date.now());
|
||||
const replied = new Promise<true>((res) => socket.once('settings', () => res(true)));
|
||||
socket.emit('load', null);
|
||||
const probed = await Promise.race([
|
||||
replied,
|
||||
new Promise<false>((res) => setTimeout(() => res(false), remaining)),
|
||||
]);
|
||||
if (!probed) {
|
||||
socket.disconnect();
|
||||
return {ok: false, reason: `no \`settings\` reply within ${budgetMs}ms`};
|
||||
}
|
||||
return {ok: true, socket};
|
||||
};
|
||||
|
||||
const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
|
||||
new Promise<any>((res) => {
|
||||
socket.once(replyEvt, res);
|
||||
socket.emit(evt, payload);
|
||||
});
|
||||
|
||||
const save = (socket: any, payload: string) =>
|
||||
new Promise<{status: string; detail?: any}>((res, rej) => {
|
||||
const timeout = setTimeout(
|
||||
() => rej(new Error('saveSettings: no saveprogress within 5s')), 5000);
|
||||
socket.once('saveprogress', (status: string, detail: any) => {
|
||||
clearTimeout(timeout);
|
||||
res({status, detail});
|
||||
});
|
||||
socket.emit('saveSettings', payload);
|
||||
});
|
||||
|
||||
// Regression coverage for issue #7819 / the broader observation that the
|
||||
// admin saveSettings socket has zero backend coverage. The goal here is
|
||||
// narrow: prove that whatever raw string the admin SPA emits ends up on
|
||||
// disk byte-for-byte at settings.settingsFilename, and the subsequent
|
||||
// `load` reply reflects the new file contents. We do NOT exercise
|
||||
// runtime reload — that's reloadSettings()' job and is covered elsewhere.
|
||||
describe(__filename, function () {
|
||||
let socket: any;
|
||||
let savedUsers: any;
|
||||
let savedRequireAuthentication: boolean;
|
||||
let savedSettingsFilename: any;
|
||||
let tmpSettingsPath: string | null = null;
|
||||
let baselineContents: string;
|
||||
let setupCompleted = false;
|
||||
|
||||
before(async function () {
|
||||
this.timeout(60000);
|
||||
await common.init();
|
||||
|
||||
savedSettingsFilename = settings.settingsFilename;
|
||||
tmpSettingsPath = path.join(os.tmpdir(),
|
||||
`etherpad-7819-settings-${process.pid}.json`);
|
||||
// Realistic baseline: keys you'd find in a stock settings.json.
|
||||
// Saved with two-space indent so we can later assert formatting is
|
||||
// preserved through the write path.
|
||||
baselineContents = JSON.stringify({
|
||||
title: 'Etherpad',
|
||||
ip: '0.0.0.0',
|
||||
port: 9001,
|
||||
users: {admin: {password: 'changeme1', is_admin: true}},
|
||||
}, null, 2) + '\n';
|
||||
fs.writeFileSync(tmpSettingsPath, baselineContents);
|
||||
settings.settingsFilename = tmpSettingsPath;
|
||||
|
||||
savedUsers = settings.users;
|
||||
savedRequireAuthentication = settings.requireAuthentication;
|
||||
setupCompleted = true;
|
||||
|
||||
const probe = await adminSocketWithProbe(PROBE_BUDGET_MS);
|
||||
if (!probe.ok) {
|
||||
console.warn(
|
||||
`[adminSettingsSave] admin socket probe failed (${probe.reason}); ` +
|
||||
'skipping suite — likely an authenticate-hook plugin rejecting test creds.');
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
socket = probe.socket;
|
||||
});
|
||||
|
||||
after(function () {
|
||||
if (socket) socket.disconnect();
|
||||
if (!setupCompleted) return;
|
||||
settings.settingsFilename = savedSettingsFilename;
|
||||
if (tmpSettingsPath) {
|
||||
try { fs.unlinkSync(tmpSettingsPath); } catch { /* best effort */ }
|
||||
}
|
||||
if (settings.users) delete settings.users['test-admin'];
|
||||
settings.users = savedUsers;
|
||||
settings.requireAuthentication = savedRequireAuthentication;
|
||||
});
|
||||
|
||||
// Reset to baseline between tests so each it() is independent — earlier
|
||||
// suites in the same mocha run can leave behind state via shared sockets.
|
||||
beforeEach(function () {
|
||||
if (!tmpSettingsPath) this.skip();
|
||||
fs.writeFileSync(tmpSettingsPath!, baselineContents);
|
||||
});
|
||||
|
||||
it('saveSettings writes the payload byte-for-byte to settings.settingsFilename',
|
||||
async function () {
|
||||
const payload = JSON.stringify({title: 'EtherpadWrittenViaSocket'}, null, 2);
|
||||
const ack = await save(socket, payload);
|
||||
assert.equal(ack.status, 'saved', 'saveprogress should be "saved"');
|
||||
const onDisk = fs.readFileSync(tmpSettingsPath!, 'utf8');
|
||||
assert.equal(onDisk, payload,
|
||||
'on-disk contents must equal the raw payload (no transform)');
|
||||
});
|
||||
|
||||
// The shape that triggered #7819: take an existing settings.json and add
|
||||
// one new top-level block (a plugin config). The block must persist on
|
||||
// disk verbatim and reappear in the next `load` reply.
|
||||
it('augmenting existing JSON with a new top-level plugin block round-trips',
|
||||
async function () {
|
||||
const augmented = JSON.stringify({
|
||||
title: 'Etherpad',
|
||||
ip: '0.0.0.0',
|
||||
port: 9001,
|
||||
ep_oauth: {
|
||||
clientID: 'Iv1.testclient',
|
||||
clientSecret: 'testsecret',
|
||||
callbackURL: 'https://etherpad.example.com/auth/callback',
|
||||
},
|
||||
users: {admin: {password: 'changeme1', is_admin: true}},
|
||||
}, null, 2);
|
||||
|
||||
const ack = await save(socket, augmented);
|
||||
assert.equal(ack.status, 'saved');
|
||||
|
||||
const onDisk = fs.readFileSync(tmpSettingsPath!, 'utf8');
|
||||
assert.equal(onDisk, augmented,
|
||||
'augmented JSON must be on disk verbatim');
|
||||
|
||||
// load() now reads the file we just wrote — `results` is the raw
|
||||
// string, so it must contain the plugin block we added.
|
||||
const reply: any = await ask(socket, 'load', null, 'settings');
|
||||
assert.equal(reply.results, augmented,
|
||||
'load.results must equal the file we just saved');
|
||||
assert.ok(reply.results.includes('"ep_oauth"'),
|
||||
'plugin block must be present in subsequent load');
|
||||
});
|
||||
|
||||
// /* */ comments are legal in the admin editor (jsonc-parser tolerates
|
||||
// them; the SPA's isJSONClean strips them before validation). The save
|
||||
// path must not normalize or strip them — the SPA test
|
||||
// 'preserves /* */ comments after save round-trip' covers the UI side;
|
||||
// this one covers the socket-level guarantee.
|
||||
it('preserves /* */ comments in the written file', async function () {
|
||||
const withComment =
|
||||
'/* persisted-marker-7819 */\n' +
|
||||
JSON.stringify({title: 'Etherpad'}, null, 2);
|
||||
const ack = await save(socket, withComment);
|
||||
assert.equal(ack.status, 'saved');
|
||||
const onDisk = fs.readFileSync(tmpSettingsPath!, 'utf8');
|
||||
assert.ok(onDisk.includes('persisted-marker-7819'),
|
||||
'comment must survive the write path');
|
||||
});
|
||||
});
|
||||
|
|
@ -124,6 +124,66 @@ test.describe('admin settings',()=> {
|
|||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
});
|
||||
|
||||
// Regression for https://github.com/ether/etherpad/issues/7819.
|
||||
// The pre-existing 'restart works' test only proves the server comes
|
||||
// back up; it never sets a custom value first, so a deployment that
|
||||
// resets settings.json on restart (Docker layer wipe, init-container
|
||||
// template render, snap reseed bug) would pass it. This test mirrors
|
||||
// the user's actual workflow: open Raw, add a new top-level plugin
|
||||
// block, save, restart, confirm the block is still there.
|
||||
test('#7819 custom top-level block survives server restart', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const raw = page.getByTestId('settings-raw-textarea');
|
||||
await expect(raw).toBeVisible({timeout: 10000});
|
||||
const original = await raw.inputValue();
|
||||
expect(original.length).toBeGreaterThan(0);
|
||||
|
||||
// Inject `"ep_oauth": {…},` right after the opening brace. This matches
|
||||
// what a user adding a plugin config block would type — we keep every
|
||||
// other key intact, no schema, just a new top-level object.
|
||||
const augmented = original.replace(
|
||||
/^(\s*\{)/,
|
||||
'$1"ep_oauth":{"clientID":"persist-marker-7819","clientSecret":"x",' +
|
||||
'"callbackURL":"http://x/cb"},',
|
||||
);
|
||||
expect(augmented).not.toEqual(original);
|
||||
expect(augmented).toContain('persist-marker-7819');
|
||||
|
||||
await raw.fill(augmented);
|
||||
await saveSettings(page);
|
||||
|
||||
await restartEtherpad(page);
|
||||
await loginToAdmin(page, 'admin', 'changeme1');
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const rawAfter = page.getByTestId('settings-raw-textarea');
|
||||
await expect(rawAfter).toBeVisible({timeout: 10000});
|
||||
await expect(rawAfter).not.toHaveValue('', {timeout: 30000});
|
||||
|
||||
const afterRestart = await rawAfter.inputValue();
|
||||
expect(afterRestart).toContain('"ep_oauth"');
|
||||
expect(afterRestart).toContain('persist-marker-7819');
|
||||
|
||||
// The Form view must also surface the new block as its own section,
|
||||
// since FormView renders every top-level object/array as a Section
|
||||
// (admin/src/components/settings/FormView.tsx). humanize('ep_oauth')
|
||||
// becomes 'Ep oauth'.
|
||||
await page.getByTestId('mode-toggle-form').click();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await expect(page.locator('[data-testid="settings-form-view"]'))
|
||||
.toContainText(/Ep oauth/i);
|
||||
|
||||
// Restore — DO NOT rely on the next test's cleanup; the file is
|
||||
// shared across the serial run and a custom ep_oauth block could
|
||||
// poison anything that asserts the exact length of settings.
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await rawAfter.fill(original);
|
||||
await saveSettings(page);
|
||||
});
|
||||
|
||||
test('form view derives label + help text from key comment', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue