mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
* 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>
210 lines
8.5 KiB
TypeScript
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'], '*');
|
|
});
|
|
});
|
|
});
|