fix(pad): outdated notice — resolve author from token cookie (Qodo #7804) (#7805)

* fix(updater): resolve pad author from token cookie, not session.user

Etherpad does not populate an authorID into the express-session user
object for pad visitors, so resolveRequestAuthor() always returned null
in production, causing computeOutdated() to return EMPTY and the
pad-side gritter to never fire.

Replace the session-based lookup with a cookie-based path that mirrors
how the socket.io handshake resolves pad-visitor identity: read the
HttpOnly `token` (or `<prefix>token`) cookie and call
authorManager.getAuthorId(token, user) via dynamic import (same
circular-init guard pattern as the PadManager import).

Update the test harness to mock AuthorManager instead of injecting a
fake req.session.user.author, and to set req.cookies.token directly.
All 9 cases continue to pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(openapi): clarify admin spec scope includes pad-side endpoints

/api/version-status is a public pad-side endpoint but lives in the
admin OpenAPI document because it shares the same internal route
registration. Add a note to info.description so downstream tooling
consumers are not misled into treating it as an admin-only route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-18 12:45:03 +01:00 committed by GitHub
parent 29dac6bfcc
commit f6ab8561ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 33 deletions

View file

@ -20,7 +20,10 @@ export const generateAdminDefinition = (): any => ({
title: 'Etherpad Admin API',
description:
'Authenticated administrative endpoints consumed by the Etherpad admin UI. ' +
'Distinct from the public /api/{version}/* surface served by /api/openapi.json.',
'Distinct from the public /api/{version}/* surface served by /api/openapi.json. ' +
'For completeness this document also includes non-admin endpoints that are ' +
'consumed by the pad UI itself (e.g. /api/version-status) since they share the ' +
'same internal route registration.',
version: getEpVersion(),
},
paths: {

View file

@ -31,31 +31,32 @@ export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record<number | string
};
/**
* Resolve the express-session author for a plain HTTP GET. The pad-side fetch
* is `credentials: 'same-origin'`, so the `express_sid` cookie is sent
* automatically. The global express-session middleware should have populated
* `req.session` already but if not (e.g. test harness without middleware),
* we re-invoke it ourselves. On any failure path we return null and the
* caller treats the request as anonymous.
* Resolve the pad-visitor's authorID from the HttpOnly token cookie. This
* mirrors how Etherpad's own socket.io handshake resolves pad-visitor identity:
* the browser sends the `token` cookie (or `<prefix>token`) with every
* same-origin request, and `authorManager.getAuthorId(token, user)` maps the
* token to a stable authorID via the `token2author` DB key.
*
* We do NOT use `req.session.user.author` because Etherpad does not populate
* an authorID into the express-session `user` object for pad visitors, so that
* field is always undefined in production.
*
* On any failure path we return null and the caller treats the request as
* anonymous, resulting in an EMPTY (no-badge) response.
*/
export const resolveRequestAuthor = async (req: any): Promise<string | null> => {
const readAuthor = (): string | null => {
const a = req?.session?.user?.author;
return typeof a === 'string' && a !== '' ? a : null;
};
const fromSession = readAuthor();
if (fromSession !== null) return fromSession;
try {
const expressModule = await import('../express');
const mw = (expressModule as any).sessionMiddleware;
if (typeof mw !== 'function') return null;
await new Promise<void>((resolve, reject) => {
mw(req, {} as any, (err?: unknown) => (err ? reject(err) : resolve()));
});
const cookiePrefix = (settings as any).cookie?.prefix ?? '';
const token = req?.cookies?.[`${cookiePrefix}token`];
if (typeof token !== 'string' || token === '') return null;
const authorManagerMod: any = await import('../../db/AuthorManager');
const authorManager = authorManagerMod.default ?? authorManagerMod;
if (typeof authorManager.getAuthorId !== 'function') return null;
const authorId = await authorManager.getAuthorId(token, req?.session?.user);
return typeof authorId === 'string' && authorId !== '' ? authorId : null;
} catch {
return null;
}
return readAuthor();
};
interface OutdatedResponse {

View file

@ -5,12 +5,12 @@
* (same as production), then exercised via supertest. `loadState` is mocked so
* tests control the "latest" version without touching the filesystem.
* `PadManager` is mocked so pad-creation doesn't require a running database.
* `AuthorManager` is mocked so tokenauthorID resolution doesn't require a DB.
*
* Session injection: `resolveRequestAuthor` reads `req.session.user.author`
* directly. A simple before-route middleware sets that property on each
* request, keyed by the `X-Test-Author` request header, so individual tests
* can choose which author the request "belongs to" without needing real
* session middleware.
* Author injection: `resolveRequestAuthor` reads the `token` cookie and calls
* `authorManager.getAuthorId(token, user)`. Tests set `sessionAuthor` to
* control which authorID the mock returns for the test token `t.alice`.
* `null` means "anonymous" (getAuthorId returns null / empty for the token).
*/
import {describe, it, expect, vi, beforeAll, beforeEach, afterEach} from 'vitest';
@ -38,6 +38,14 @@ vi.mock('../../../../../node/updater', () => ({
getDetectedInstallMethod: () => 'git',
}));
// AuthorManager is dynamically imported inside resolveRequestAuthor(). Stubbing
// it here lets tests control token→authorID resolution without a DB.
vi.mock('../../../../../node/db/AuthorManager', () => ({
default: {
getAuthorId: vi.fn(),
},
}));
// PadManager is dynamically imported inside computeOutdated(). Stubbing it
// here lets us control pad existence and author-pool contents without a DB.
vi.mock('../../../../../node/db/PadManager', () => {
@ -58,6 +66,7 @@ vi.mock('../../../../../node/db/PadManager', () => {
// ---------------------------------------------------------------------------
import * as stateModule from '../../../../../node/updater/state';
import * as authorManagerModule from '../../../../../node/db/AuthorManager';
import {
expressCreateServer,
_resetBadgeCacheForTests,
@ -112,21 +121,22 @@ let app: Express;
let request: ReturnType<typeof supertest>;
/**
* The author that the fake session middleware will inject into req.session.
* Tests that need a specific author set this before making a request.
* `null` means "anonymous" (no session author).
* The author that `authorManager.getAuthorId` will return for the test token
* `t.alice`. Tests set this before making a request. `null` means "anonymous"
* (getAuthorId returns null/empty, so resolveRequestAuthor returns null).
*/
let sessionAuthor: string | null = null;
/** Fixed test token used in every request. The cookie name has no prefix in tests. */
const TEST_TOKEN = 't.alice';
beforeAll(() => {
app = express();
// Fake session middleware: sets req.session.user.author from our test
// variable, so resolveRequestAuthor() in the route sees the right identity.
// Inject the test token cookie so resolveRequestAuthor() sees it.
// The real cookie-parser middleware is not needed: we set req.cookies directly.
app.use((req: any, _res, next) => {
if (sessionAuthor !== null) {
req.session = {user: {author: sessionAuthor}};
}
req.cookies = {token: TEST_TOKEN};
next();
});
@ -143,6 +153,13 @@ beforeEach(() => {
sessionAuthor = null;
// Reset the loadState spy so each test controls its own return value.
vi.mocked(stateModule.loadState).mockReset();
// Wire up the AuthorManager mock: return sessionAuthor (or null) for our test token.
vi.mocked((authorManagerModule as any).default.getAuthorId).mockImplementation(
async (token: string) => {
if (token === TEST_TOKEN && sessionAuthor !== null) return sessionAuthor;
return null;
},
);
});
afterEach(() => {