diff --git a/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts b/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts index 486a2dad0..a0de73495 100644 --- a/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts +++ b/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts @@ -61,25 +61,95 @@ const ask = (socket: any, evt: string, payload: any, replyEvt: string) => socket.emit(evt, payload); }); +// adminSocket() depends on Etherpad's default plain-text password check for +// settings.users[name].password. Plugins like ep_hash_auth replace the +// authenticate hook to expect hashed credentials, so the basic-auth probe +// returns no admin session, /settings's connection handler returns without +// registering listeners (see src/node/hooks/express/adminsettings.ts:25), +// and every socket.emit() afterwards waits forever for a reply that +// nothing will ever send. The socket itself still connects when admin +// session is missing, so the probe has to run at the application layer: +// emit a known `/settings` event (`load`) and wait for the matching reply +// (`settings`). If it doesn't arrive within the budget, skip — much +// cheaper than letting mocha's 120s per-test timeout absorb 7 stalled +// tests. Tracked in #7795. +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((_, 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()); + // authorLoad is gated on the admin session being present (see + // adminsettings.ts:25 — non-admin connections never register it) but + // doesn't depend on any disk-resident settings file the way `load` + // does, so it's a stable application-level liveness probe. + const replied = new Promise((res) => socket.once('results:authorLoad', () => res(true))); + socket.emit('authorLoad', { + pattern: '__anonymizeAuthorSocket-probe__', offset: 0, limit: 1, + sortBy: 'name', ascending: true, includeErased: false, + }); + const probed = await Promise.race([ + replied, + new Promise((res) => setTimeout(() => res(false), remaining)), + ]); + if (!probed) { + socket.disconnect(); + return {ok: false, reason: `no \`results:authorLoad\` reply within ${budgetMs}ms (no admin handlers registered)`}; + } + return {ok: true, socket}; +}; + describe(__filename, function () { let socket: any; let originalFlag: boolean; let savedUsers: any; let savedRequireAuthentication: boolean; + let setupCompleted = false; before(async function () { this.timeout(60000); await common.init(); + + // Capture backups BEFORE any mutation so after() can restore cleanly + // even if the probe times out (adminSocket mutates settings.users + // and settings.requireAuthentication on its way in). settings.gdprAuthorErasure = settings.gdprAuthorErasure || {enabled: false}; originalFlag = settings.gdprAuthorErasure.enabled; - settings.gdprAuthorErasure.enabled = true; savedUsers = settings.users; savedRequireAuthentication = settings.requireAuthentication; - socket = await adminSocket(); + settings.gdprAuthorErasure.enabled = true; + setupCompleted = true; + + const probe = await adminSocketWithProbe(PROBE_BUDGET_MS); + if (!probe.ok) { + console.warn( + `[anonymizeAuthorSocket] admin socket probe failed (${probe.reason}); ` + + 'skipping suite — likely an authenticate-hook plugin (e.g. ep_hash_auth) ' + + 'rejecting the test\'s plain-text admin credentials. Tracked in #7795.'); + this.skip(); + return; + } + socket = probe.socket; }); after(function () { if (socket) socket.disconnect(); + // before() may have called this.skip() before capturing backups (e.g. + // a common.init() failure), so guard against writing undefined into + // settings. Once setupCompleted flips true the backup variables are + // safe to read. + if (!setupCompleted) return; settings.gdprAuthorErasure.enabled = originalFlag; // savedUsers and settings.users point at the same object — restoring // the reference is a no-op against the in-place mutation. Delete the