-
-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(admin): don't let one unreadable pad empty the Manage-pads list (#7935) #7938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+315
−12
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
54a8eb1
fix(admin): don't let one unreadable pad empty the Manage-pads list (…
JohnMcLear 295ad8b
fix(admin): sanitize padLoad error logs to avoid leaking pad content
JohnMcLear 4378466
fix(admin): make surfaced corrupt pads deletable from the admin UI
JohnMcLear 8d4701a
test(admin): assert corrupt-pad delete via listing, not raw db.get
JohnMcLear File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| 'use strict'; | ||
|
|
||
| // Regression test for issue #7935 ("Display issue of notes"): pads exist | ||
| // (visible on the welcome page, returned by the API `listAllPads`) but the | ||
| // admin "Manage pads" UI shows none. | ||
| // | ||
| // Root cause: the admin /settings `padLoad` handler hydrates every pad via | ||
| // `padManager.getPad()` to build the listing (the default `lastEdited` sort | ||
| // forces a full scan). `findKeys('pad:*', '*:*:*')` returns *every* key under | ||
| // the `pad:` prefix, including legacy / foreign / migration-corrupted records | ||
| // — e.g. a value stored as a JSON *string* rather than a pad object, which is | ||
| // exactly what a botched dirty.db → PostgreSQL migration produces. Loading | ||
| // such a record makes `Pad.init` throw `Cannot use 'in' operator to search | ||
| // for 'pool' in <string>`. Before the fix that single rejection took out the | ||
| // whole handler: no `results:padLoad` was ever emitted (the SPA showed an | ||
| // empty "No results" state forever) and the unhandled rejection could exit | ||
| // the server. The handler now skips unreadable pads (surfacing them with | ||
| // zeroed metadata so an admin can still delete them) and always emits a | ||
| // terminal reply. | ||
|
|
||
| import {strict as assert} from 'assert'; | ||
| import setCookieParser from 'set-cookie-parser'; | ||
|
|
||
| const io = require('socket.io-client'); | ||
| const common = require('../../common'); | ||
| const settings = require('../../../../node/utils/Settings'); | ||
| const padManager = require('../../../../node/db/PadManager'); | ||
| const db = require('../../../../node/db/DB'); | ||
|
|
||
| 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>((resolve, reject) => { | ||
| const onErr = (err: any) => { socket.off('connect', onConn); reject(err); }; | ||
| const onConn = () => { socket.off('connect_error', onErr); resolve(); }; | ||
| socket.once('connect', onConn); | ||
| socket.once('connect_error', onErr); | ||
| }); | ||
| return socket; | ||
| }; | ||
|
|
||
| const ask = (socket: any, evt: string, payload: any, replyEvt: string, timeoutMs = 10000) => | ||
| new Promise<any>((resolve, reject) => { | ||
| const timer = setTimeout( | ||
| () => reject(new Error(`no \`${replyEvt}\` reply within ${timeoutMs}ms`)), timeoutMs); | ||
| socket.once(replyEvt, (data: any) => { clearTimeout(timer); resolve(data); }); | ||
| socket.emit(evt, payload); | ||
| }); | ||
|
|
||
| describe(__filename, function () { | ||
| let socket: any; | ||
| let savedUsers: any; | ||
| let savedRequireAuthentication: boolean; | ||
| let setupCompleted = false; | ||
| const tag = `padLoadResilience-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; | ||
| const goodId = `${tag}-good`; | ||
| const corruptId = `${tag}-corrupt`; | ||
|
|
||
| before(async function () { | ||
| this.timeout(120000); | ||
| await common.init(); | ||
|
|
||
| savedUsers = settings.users; | ||
| savedRequireAuthentication = settings.requireAuthentication; | ||
| setupCompleted = true; | ||
|
|
||
| try { | ||
| socket = await adminSocket(); | ||
| } catch (err: any) { | ||
| console.warn( | ||
| `[padLoadResilience] admin socket connect failed (${err && err.message}); ` + | ||
| "skipping suite — likely an authenticate-hook plugin rejecting the test's " + | ||
| 'admin credentials.'); | ||
| this.skip(); | ||
| return; | ||
| } | ||
|
|
||
| // A normal, readable pad — this is what must still show up. | ||
| await padManager.getPad(goodId, 'good content\n'); | ||
|
|
||
| // A pad that enters the pad-name index normally, then has its stored | ||
| // value clobbered into a non-object (a JSON string) to mimic a | ||
| // migration-corrupted / foreign `pad:*` record. Evicting it from the | ||
| // in-memory cache forces the next getPad() to re-read the bad value. | ||
| await padManager.getPad(corruptId, 'temp\n'); | ||
| await db.set(`pad:${corruptId}`, 'corrupt-non-object-value'); | ||
| padManager.unloadPad(corruptId); | ||
|
|
||
| // Sanity-check that the setup actually reproduces the failing read; if | ||
| // this stops throwing the test is no longer exercising the bug. | ||
| await assert.rejects(padManager.getPad(corruptId), | ||
| 'expected the corrupted pad record to make getPad throw'); | ||
| }); | ||
|
|
||
| after(async function () { | ||
| if (socket) socket.disconnect(); | ||
| if (!setupCompleted) return; | ||
| if (settings.users) delete settings.users['test-admin']; | ||
| settings.users = savedUsers; | ||
| settings.requireAuthentication = savedRequireAuthentication; | ||
| for (const id of [goodId, corruptId]) { | ||
| try { await db.remove(`pad:${id}`); } catch { /* ignore */ } | ||
| try { await db.remove(`pad:${id}:revs:0`); } catch { /* ignore */ } | ||
| try { padManager.unloadPad(id); } catch { /* ignore */ } | ||
| } | ||
| }); | ||
|
|
||
| it('a single corrupt pad does not hide every other pad (issue #7935)', async function () { | ||
| this.timeout(30000); | ||
| // The default query the SPA sends on initial load: lastEdited sort forces | ||
| // the full-scan hydration path that touches every pad — including the | ||
| // corrupt one. | ||
| const res = await ask(socket, 'padLoad', { | ||
| pattern: tag, offset: 0, limit: 12, | ||
| sortBy: 'lastEdited', ascending: false, filter: 'all', | ||
| }, 'results:padLoad'); | ||
|
|
||
| const names = res.results.map((r: any) => r.padName); | ||
| assert.ok(names.includes(goodId), | ||
| `the readable pad must still be listed; got ${JSON.stringify(names)}`); | ||
| // The bad pad is surfaced (zeroed metadata) rather than silently dropped, | ||
| // so an admin can see and delete it. | ||
| assert.ok(names.includes(corruptId), | ||
| `the corrupt pad should surface for deletion; got ${JSON.stringify(names)}`); | ||
| assert.equal(res.total, 2, `expected total=2, got ${JSON.stringify(res)}`); | ||
| }); | ||
|
|
||
| it('still replies on the fast path (padName sort) with a corrupt pad present', async function () { | ||
| this.timeout(30000); | ||
| const res = await ask(socket, 'padLoad', { | ||
| pattern: tag, offset: 0, limit: 12, | ||
| sortBy: 'padName', ascending: true, filter: 'all', | ||
| }, 'results:padLoad'); | ||
| const names = res.results.map((r: any) => r.padName); | ||
| assert.ok(names.includes(goodId), `got ${JSON.stringify(names)}`); | ||
| assert.ok(names.includes(corruptId), `got ${JSON.stringify(names)}`); | ||
| }); | ||
|
|
||
| // Runs last: surfacing a corrupt pad is only useful if it can be removed. | ||
| // deletePad's normal path (doesPadExists + getPad + Pad.remove) can't touch | ||
| // an unreadable record, so it must fall back to a raw key purge. | ||
| it('a surfaced corrupt pad can be deleted from the admin UI', async function () { | ||
| this.timeout(30000); | ||
| const ack = await ask(socket, 'deletePad', corruptId, 'results:deletePad'); | ||
| assert.equal(ack, corruptId, `expected deletePad to ack "${corruptId}", got ${JSON.stringify(ack)}`); | ||
|
|
||
| // Assert the user-facing outcome — the corrupt pad is gone from the | ||
| // listing (its pad-list entry was dropped) while the good pad stays. | ||
| // (We deliberately don't probe `db.get('pad:<id>')` here: ueberdb2's | ||
| // per-backend read/write buffering can still return the just-removed | ||
| // value immediately after `remove()`, so that would be a flaky | ||
| // storage-internals assertion rather than a behavioural one.) | ||
| const res = await ask(socket, 'padLoad', { | ||
| pattern: tag, offset: 0, limit: 12, | ||
| sortBy: 'padName', ascending: true, filter: 'all', | ||
| }, 'results:padLoad'); | ||
| const names = res.results.map((r: any) => r.padName); | ||
| assert.ok(!names.includes(corruptId), `corrupt pad still listed: ${JSON.stringify(names)}`); | ||
| assert.ok(names.includes(goodId), `good pad missing after delete: ${JSON.stringify(names)}`); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import {expect, test} from "@playwright/test"; | ||
| import {loginToAdmin} from "../helper/adminhelper"; | ||
| import {goToPad, writeToPad} from "../helper/padHelper"; | ||
|
|
||
| // End-to-end coverage for issue #7935: a pad that exists must be visible | ||
| // both on the welcome page's "recent pads" list (driven by localStorage, | ||
| // i.e. gated on the browser having opened the pad) and in the admin | ||
| // "Manage pads" UI (driven by the /settings socket `padLoad` handler, | ||
| // which enumerates the DB). The admin side regressed when a single | ||
| // unreadable pad record made `padLoad` throw and silently return nothing | ||
| // — see specs/admin/padLoadResilience.ts for the server-side guard. | ||
|
|
||
| // /admin tests mutate global server state, so keep them serial. | ||
| test.describe.configure({mode: 'serial'}); | ||
|
|
||
| const ADMIN_URL = 'http://localhost:9001/admin'; | ||
|
|
||
| test.describe('a created pad shows up on the home page and in /admin', () => { | ||
| // Unique, URL-safe id so the recent-pads localStorage entry and the admin | ||
| // search both target exactly this pad and ignore leftovers from other suites. | ||
| const padId = `pw-pads-7935-${Date.now()}`; | ||
|
|
||
| test('opening a pad lists it in the welcome page recent-pads', async ({page}) => { | ||
| await goToPad(page, padId); | ||
| await writeToPad(page, 'hello from 7935'); | ||
|
|
||
| // Opening the pad writes it to `recentPads` localStorage (colibris | ||
| // pad.js). The welcome page renders that list — same browser context, | ||
| // so the entry carries over. | ||
| await page.goto('http://localhost:9001/'); | ||
| const recentPad = page.locator('.recent-pad', {hasText: padId}); | ||
| await expect(recentPad).toBeVisible({timeout: 10000}); | ||
| await expect(recentPad.locator('a')).toHaveText(padId); | ||
| }); | ||
|
|
||
| test('the same pad is listed in the admin Manage pads UI', async ({page}) => { | ||
| await loginToAdmin(page, 'admin', 'changeme1'); | ||
| await page.goto(`${ADMIN_URL}/pads`); | ||
|
|
||
| await expect(page.getByRole('heading', {name: 'Manage pads'})) | ||
| .toBeVisible({timeout: 30000}); | ||
|
|
||
| // Narrow the (full-scan) listing to our pad. The search is debounced | ||
| // server-side; allow the round-trip to settle. | ||
| const search = page.getByPlaceholder('Search for pads'); | ||
| await search.fill(padId); | ||
|
|
||
| await expect(page.locator('.pm-pad-title', {hasText: padId})) | ||
| .toBeVisible({timeout: 15000}); | ||
| // The "No results" empty state must NOT be showing — the exact #7935 | ||
| // symptom was an empty Manage-pads list for pads that demonstrably exist. | ||
| await expect(page.locator('.pm-empty')).toHaveCount(0); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.