etherpad-lite/src/tests/backend/specs/openapi-admin.ts
John McLear 29dac6bfcc
fix(pad): redesign outdated-version notice (#7799) (#7804)
* docs: design spec for #7799 outdated-notice redesign

Per-pad first-author gating, dismissable gritter, minor-or-more rule, drop vulnerable UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: implementation plan for #7799 outdated-notice redesign

12 bite-sized tasks, TDD-first where applicable; closes the spec end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): add isMinorOrMoreBehind, drop major/vulnerable helpers

Adds isMinorOrMoreBehind(current, latest) which returns true only when
the latest release is at least one minor version ahead (patch-only deltas
return false). Removes isMajorBehind, parseVulnerableBelow, and
isVulnerable from versionCompare.ts — callers in updateStatus.ts,
VersionChecker.ts, and index.ts will be updated in subsequent tasks.

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

* refactor(updater): drop vulnerable-below directive and state field

Remove VulnerableBelowDirective type, UpdateState.vulnerableBelow field, and
all related scraping/checking logic (parseVulnerableBelow, isVulnerable imports).
Clean up Notifier, OpenAPI schema, and all test fixtures to match.

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

* refactor(updater): drop residual EmailSendLog vulnerable fields

Remove `vulnerableAt` and `vulnerableNewReleaseTag` from the
`EmailSendLog` interface, `EMPTY_STATE`, and the `isValidEmail`
validator — these backed the removed `vulnerable`/`vulnerable-new-release`
email kinds and are now dead code.

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

* feat(updater): add firstAuthorOf helper

Export firstAuthorOf() from updateStatus.ts — finds the lowest-numbered
author attrib in a pad's pool, skipping empty-string placeholders.
Covered by 6 vitest cases in tests/backend-new/specs/hooks/express/.

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

* feat(updater): add resolveRequestAuthor helper for HTTP GET

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

* feat(updater): pad-aware /api/version-status with first-author gating

Replace global badge cache with a per-(padId, authorId) LRU cache. The
new response shape is {outdated: 'minor' | null, isFirstAuthor: boolean};
the old 'severe'/'vulnerable' enum is dropped entirely. computeOutdated
now resolves the pad's first author and compares it against the session
author before returning outdated:'minor', so the notice is only shown to
the person who created the pad.

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

* fix(updater): switch isSevere signal from major-only to minor-or-more behind

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

* test(updater): end-to-end coverage for /api/version-status

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(openapi): /api/version-status pad-aware shape and gating

Add the /api/version-status GET operation to the admin OpenAPI spec with
the new pad-aware response shape: outdated enum reduced to [minor]|null,
isFirstAuthor boolean, and an optional padId query param.

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

* chore(pad): remove unused #version-badge template and CSS

* feat(pad): replace persistent badge with first-author outdated gritter

Renames pad_version_badge.ts → pad_outdated_notice.ts and rewrites it
as a fire-and-forget gritter notice that only shows when the API reports
outdated=minor AND the current user is the pad's first author.  Wires
the new maybeShowOutdatedNotice() call into pad.ts immediately after
showPrivacyBannerIfEnabled().

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

* test(pad): playwright coverage for outdated notice gritter

Six Playwright specs exercise maybeShowOutdatedNotice: null response,
isFirstAuthor:false guard, positive appearance + text, X-dismiss,
500 server error tolerance, and 8 s auto-fade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(pad): outdated-notice redesign + drop vulnerable-below docs

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

* chore(test): remove stale specs for deleted #version-badge surface

Delete the GET /api/version-status describe block from the legacy mocha
spec (asserted outdated:null and outdated:'severe' — both no longer match
the new response shape). The new vitest spec at
tests/backend-new/specs/hooks/express/updateStatus.test.ts covers this
surface comprehensively.

Delete src/tests/frontend-new/specs/pad-version-badge.spec.ts entirely:
all three tests reference the #version-badge DOM element removed in Task 8
and stub 'severe'/'vulnerable' enum values that no longer exist.

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

* chore: clean stale references to vulnerable/severe in types, emails, docs

- Remove OutdatedLevel type (null|'severe') from types.ts — no consumers
  remain after the badge redesign removed the severe tier.
- Fix Notifier severe-email body: was "more than one major release behind"
  but isSevere now fires on minor-or-more, so update to "at least one
  minor release behind the latest published version".
- Drop "vulnerability directives" from the /admin/update/status OpenAPI
  description; replace with the actual response fields.
- Remove stale vulnerableBelow field from UpdateStatusPayload in
  admin/src/store/store.ts — server no longer sends it.
- Fix docs/admin/updates.md: "pad-side badge" → "pad-side notice".

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:23:40 +01:00

210 lines
8.5 KiB
TypeScript

'use strict';
import {strict as assert} from 'assert';
const validateOpenAPI = require('openapi-schema-validation').validate;
const openapiAdmin = require('../../../node/hooks/express/openapi-admin');
describe('admin OpenAPI document', function () {
let doc: any;
before(function () {
doc = openapiAdmin.generateAdminDefinition();
});
it('returns a valid OpenAPI 3.0 document', function () {
const {valid, errors} = validateOpenAPI(doc, 3);
if (!valid) {
throw new Error(
`admin OpenAPI doc is invalid: ${JSON.stringify(errors, null, 2)}`,
);
}
});
it('declares info.title as "Etherpad Admin API"', function () {
assert.equal(doc.info.title, 'Etherpad Admin API');
});
it('exposes basicAuth and sessionCookie security schemes', function () {
assert.ok(doc.components.securitySchemes.basicAuth);
assert.equal(doc.components.securitySchemes.basicAuth.type, 'http');
assert.equal(doc.components.securitySchemes.basicAuth.scheme, 'basic');
assert.ok(doc.components.securitySchemes.sessionCookie);
assert.equal(doc.components.securitySchemes.sessionCookie.type, 'apiKey');
assert.equal(doc.components.securitySchemes.sessionCookie.in, 'cookie');
});
describe('/admin-auth/', function () {
it('declares POST with operationId verifyAdminAccess', function () {
const op = doc.paths['/admin-auth/']?.post;
assert.ok(op, 'POST /admin-auth/ is missing');
assert.equal(op.operationId, 'verifyAdminAccess');
});
it('documents responses 200, 401, 403', function () {
const responses = doc.paths['/admin-auth/'].post.responses;
assert.ok(responses['200'], 'missing 200 response');
assert.ok(responses['401'], 'missing 401 response');
assert.ok(responses['403'], 'missing 403 response');
});
it('declares security: basicAuth, sessionCookie, anonymous', function () {
const security = doc.paths['/admin-auth/'].post.security;
assert.ok(Array.isArray(security));
const keys = security.map((s: any) => Object.keys(s)[0] ?? '__anon__');
assert.deepEqual(keys.sort(), ['__anon__', 'basicAuth', 'sessionCookie'].sort());
});
});
describe('/admin/update/status', function () {
it('declares GET with operationId getUpdateStatus', function () {
const op = doc.paths['/admin/update/status']?.get;
assert.ok(op, 'GET /admin/update/status is missing');
assert.equal(op.operationId, 'getUpdateStatus');
});
it('200 response references components.schemas.UpdateStatus', function () {
const ok = doc.paths['/admin/update/status'].get.responses['200'];
assert.equal(
ok.content['application/json'].schema.$ref,
'#/components/schemas/UpdateStatus',
);
});
it('declares security: sessionCookie OR anonymous', function () {
const security = doc.paths['/admin/update/status'].get.security;
const keys = security.map((s: any) => Object.keys(s)[0] ?? '__anon__');
assert.deepEqual(keys.sort(), ['__anon__', 'sessionCookie'].sort());
});
});
describe('UpdateStatus schema', function () {
it('declares all properties emitted by the handler', function () {
const schema = doc.components.schemas.UpdateStatus;
assert.equal(schema.type, 'object');
const props = Object.keys(schema.properties).sort();
assert.deepEqual(props, [
'currentVersion',
'installMethod',
'lastCheckAt',
'latest',
'policy',
'tier',
]);
});
it('installMethod enum matches updater/types.ts InstallMethod', function () {
const enums = doc.components.schemas.UpdateStatus.properties.installMethod.enum;
assert.deepEqual(enums.slice().sort(), ['auto', 'docker', 'git', 'managed', 'npm']);
});
it('tier enum matches updater/types.ts Tier', function () {
const enums = doc.components.schemas.UpdateStatus.properties.tier.enum;
assert.deepEqual(enums.slice().sort(), ['auto', 'autonomous', 'manual', 'notify', 'off']);
});
it('declares ReleaseInfo and PolicyResult sub-schemas', function () {
assert.ok(doc.components.schemas.ReleaseInfo);
assert.ok(doc.components.schemas.PolicyResult);
});
it('ReleaseInfo properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.ReleaseInfo.properties).sort();
assert.deepEqual(props, [
'body', 'htmlUrl', 'prerelease', 'publishedAt', 'tag', 'version',
]);
});
it('PolicyResult properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.PolicyResult.properties).sort();
assert.deepEqual(props, [
'canAuto', 'canAutonomous', 'canManual', 'canNotify', 'reason',
]);
});
});
describe('cross-collision with public spec', function () {
let publicDoc: any;
before(function () {
const apiHandler = require('../../../node/handler/APIHandler');
const openapi = require('../../../node/hooks/express/openapi');
publicDoc = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);
});
it('admin paths and operationIds do not collide with the latest public spec', function () {
const adminPaths = Object.keys(doc.paths);
const publicPaths = Object.keys(publicDoc.paths);
const pathCollisions = adminPaths.filter((p) => publicPaths.includes(p));
assert.deepEqual(pathCollisions, [], `path collisions: ${pathCollisions.join(', ')}`);
const collectOpIds = (d: any): string[] => {
const ids: string[] = [];
for (const item of Object.values(d.paths) as any[]) {
for (const op of Object.values(item) as any[]) {
if (op && typeof op.operationId === 'string') ids.push(op.operationId);
}
}
return ids;
};
const adminIds = collectOpIds(doc);
const publicIds = collectOpIds(publicDoc);
const idCollisions = adminIds.filter((id) => publicIds.includes(id));
assert.deepEqual(idCollisions, [], `operationId collisions: ${idCollisions.join(', ')}`);
});
it('schema names do not collide with the latest public spec', function () {
const adminSchemas = Object.keys(doc.components.schemas);
const publicSchemas = Object.keys(publicDoc.components.schemas || {});
const collisions = adminSchemas.filter((n) => publicSchemas.includes(n));
assert.deepEqual(collisions, [], `schema name collisions: ${collisions.join(', ')}`);
});
});
describe('GET /admin/openapi.json (feature flag)', function () {
// The route is registered unconditionally; the handler reads
// settings.adminOpenAPI.enabled per-request. This lets a single Express
// agent (shared across the whole suite via common.init()) exercise both
// states by toggling the flag in-process — no server restart needed.
let agent: any;
let settingsModule: any;
before(async function () {
const common = require('../common');
agent = await common.init();
settingsModule = require('../../../node/utils/Settings').default;
});
after(function () {
// Restore default-off so subsequent specs don't see leaked state.
if (settingsModule?.adminOpenAPI) settingsModule.adminOpenAPI.enabled = false;
});
it('returns 404 JSON when settings.adminOpenAPI.enabled is false (default)', async function () {
settingsModule.adminOpenAPI = settingsModule.adminOpenAPI || {enabled: false};
settingsModule.adminOpenAPI.enabled = false;
const res = await agent.get('/admin/openapi.json').expect(404);
assert.match(res.headers['content-type'] || '', /application\/json/);
assert.deepEqual(res.body, {error: 'Not Found'});
});
it('serves the admin OpenAPI document as JSON when the flag is on', async function () {
settingsModule.adminOpenAPI.enabled = true;
const res = await agent.get('/admin/openapi.json').expect(200);
assert.match(res.headers['content-type'] || '', /application\/json/);
assert.equal(res.body.openapi, '3.0.2');
assert.equal(res.body.info.title, 'Etherpad Admin API');
assert.ok(res.body.paths['/admin-auth/']);
assert.ok(res.body.paths['/admin/update/status']);
});
it('sets a permissive CORS header when enabled (matches /api/openapi.json)', async function () {
settingsModule.adminOpenAPI.enabled = true;
const res = await agent.get('/admin/openapi.json').expect(200);
assert.equal(res.headers['access-control-allow-origin'], '*');
});
});
});