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>
This commit is contained in:
John McLear 2026-05-18 12:23:40 +01:00 committed by GitHub
parent acc7a5dd4f
commit 29dac6bfcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2269 additions and 432 deletions

View file

@ -4,6 +4,9 @@
### Notable enhancements
- **pad: Outdated-version notice redesigned (#7799).** The persistent "severely outdated" banner is replaced by a dismissable gritter notification (auto-fades after 8 seconds), shown only to a pad's first author and only when the server is at least one minor version behind the latest released version. Patch-only deltas no longer fire the notice. The `vulnerable-below` directive scraping, the `severe` and `vulnerable` enum values, and the `vulnerableBelow` state field have been removed.
- **API: `GET /api/version-status` updated (#7799).** Now accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}`. The `severe` and `vulnerable` enum values are gone. Results are cached per `(padId, authorId)` for 60 seconds.
- **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until <iso>" subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753).
- **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine.
- **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show <tag>:package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`.

View file

@ -45,7 +45,6 @@ export interface UpdateStatusPayload {
installMethod: string;
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
// Tier 2 additions:
execution: Execution;
lastResult: LastResult;

View file

@ -2,7 +2,7 @@
Etherpad ships with a built-in update subsystem.
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No execution.
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a dismissable gritter notification if the running version is at least one minor version behind the latest release. No execution.
- **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure.
- **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag.
- **Tier 4 (autonomous in maintenance window)** — opt-in. Tier 3 + `updates.maintenanceWindow` is required; the scheduler only fires while the wall clock is inside the configured window. Updates detected outside the window queue for the next opening.
@ -52,30 +52,27 @@ In `settings.json`:
## What "outdated" means
- **`severe`** — running at least one major version behind the latest release.
- **`vulnerable`** — the running version is below a `vulnerable-below` threshold announced in a recent release. Releases declare these via a `<!-- updater: vulnerable-below X.Y.Z -->` HTML comment in their body. The newest such directive wins.
- **`minor`** — the running server is at least one minor version behind the latest published release. Patch-only deltas (same major and minor, higher patch) do not fire the notice.
## Email cadence (when `adminEmail` is set)
| Trigger | First send | Repeat |
| --- | --- | --- |
| Vulnerable status detected | Immediate | Weekly while still vulnerable |
| New release announced while still vulnerable | Immediate | n/a (one event per tag change) |
| Severely outdated detected | Immediate | Monthly while still severely outdated |
| Outdated (minor or more behind) detected | Immediate | Monthly while still outdated |
| Up to date | No email | — |
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it.
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it.
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
## Pad-side badge
## Pad-side notice
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
Pad users see no version information by default. A dismissable gritter notification appears only when:
- The instance is `severe` (one or more major versions behind), or
- The instance is `vulnerable` (running below an announced threshold).
- The running server is at least one minor version behind the latest published release (patch-only deltas do not fire), **and**
- The requesting user is the first author of the pad.
The public endpoint `/api/version-status` returns only `{outdated: null|"severe"|"vulnerable"}` — it never leaks the running version, so attackers do not gain a fingerprint vector.
The notice auto-fades after 8 seconds and can be dismissed immediately. The public endpoint `/api/version-status` accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}` — it never leaks the running version, so attackers do not gain a fingerprint vector. Results are cached per `(padId, authorId)` for 60 seconds.
## Disabling everything

View file

@ -712,3 +712,31 @@ get stats of the etherpad instance
_Example returns_:
* `{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}`
===== `GET /api/version-status`
Returns an outdated-version signal intended for the pad-side gritter.
*Query parameters:*
[cols="1,1,1,3"]
|===
| name | type | required | description
| `padId`
| string
| no
| Pad whose first-author membership is being checked.
|===
*Response 200 (`application/json`):*
[source,json]
----
{
"outdated": "minor",
"isFirstAuthor": true
}
----
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.

View file

@ -764,3 +764,24 @@ get stats of the etherpad instance
{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}
```
#### `GET /api/version-status`
Returns an outdated-version signal intended for the pad-side gritter.
**Query parameters:**
| name | type | required | description |
| ------- | ------ | -------- | --------------------------------------------------------------------------- |
| `padId` | string | no | Pad whose first-author membership is being checked. |
**Response 200 (`application/json`):**
```json
{
"outdated": "minor",
"isFirstAuthor": true
}
```
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,278 @@
# Outdated-version notice redesign
**Issue:** [ether/etherpad#7799](https://github.com/ether/etherpad/issues/7799)
**Date:** 2026-05-18
**Status:** Design
## Problem
The pad-side "Etherpad on this server is severely outdated. Tell your admin." banner is shown to every visitor of every pad, persistently, whenever the running server is at least one major version behind the latest published release. The reporter (a server admin) says:
> "it's inappropriate to inform users of a site about maintenance tasks that they don't understand or have context to resolve. It wastes users' time by having them try and contact me, and it wastes my time by having to respond."
In addition to the social problem, the current implementation triggers on develop checkouts and on minor-only deltas in some upstream-version states, and has been observed intercepting chat-icon clicks (z-index 9999, bottom-right) in plugin test matrices pinned to older cores.
## Goals
1. The notice is shown **only to the pad's first author** (the author whose ID occupies position 0 in the pad's attribute pool — i.e. whoever made the first edit).
2. The notice is **non-persistent**: a dismissable `$.gritter` toast, auto-fading after 8s, rather than an always-visible badge.
3. The notice fires **only on minor-or-more behind** (e.g. 3.1.0 → 3.2.0, 2.7.3 → 3.0.0). Patch-only deltas (3.0.1 → 3.0.2) never fire.
4. The notice never fires when `current >= latest` (covers the develop-after-bump case).
5. The `vulnerable-below` UI is **dropped entirely**, along with the directive parser and state field. The vulnerable enum is gone from the API.
## Non-goals
- No new settings flag. `updates.tier = 'off'` remains the kill-switch.
- No translations of new strings in this PR. A `TODO(i18n)` placeholder is carried forward — strings are hard-coded English, mirroring the current state of the badge code. A follow-up adds `pad.outdatedNotice.*` keys once the html10n key set is set up to be shared with the pad-side bundle.
## Architecture
```
Browser (pad load, after CLIENT_VARS) Server
───────────────────────────────────── ──────
pad.ts → maybeShowOutdatedNotice()
├─ GET /api/version-status?padId=<id> (cookies: express_sid)
│ loadState(stateFilePath())
│ │
│ ├─ no latest → {outdated:null,isFirstAuthor:false}
│ ├─ current >= latest → {outdated:null,isFirstAuthor:false}
│ ├─ same major + minor differs → next step
│ ├─ major differs → next step
│ └─ patch-only behind → {outdated:null,isFirstAuthor:false}
│ next step:
│ resolve req-author via express_sid
│ load pad → firstAuthor = pool position 0
│ if req-author === firstAuthor
│ return {outdated:'minor',isFirstAuthor:true}
│ else
│ return {outdated:null,isFirstAuthor:false}
├─ outdated:'minor' && isFirstAuthor
│ → $.gritter.add({class_name:'outdated-notice', position:'bottom',
│ sticky:false, time:8000, title, text})
└─ else
→ no-op
```
The endpoint shape collapses to a single enum (`'minor' | null`) plus a per-request `isFirstAuthor` boolean. The server never returns a positive `outdated` value to a non-first-author requester — there is no client-side "the answer is minor, but show it conditionally" path. Operational signal does not leak to ordinary pad visitors.
## Server changes
### `src/node/updater/versionCompare.ts`
- **Add** `isMinorOrMoreBehind(current: string, latest: string): boolean``true` iff `parseSemver(current).major < parseSemver(latest).major`, or majors equal and `current.minor < latest.minor`. Patch-only delta returns `false`. Returns `false` on parse failure of either side.
- **Delete** `isMajorBehind`, `isVulnerable`, `parseVulnerableBelow`, the `VULN_RE` regex, and the `VulnerableBelowDirective` import.
### `src/node/updater/types.ts`
- **Delete** `VulnerableBelowDirective`.
- **Delete** `UpdaterState.vulnerableBelow` field.
### `src/node/updater/state.ts`
- Stop reading and stop writing `vulnerableBelow`. Existing state files with the field still parse — the loader ignores unknown keys. No migration needed; the field naturally drops on next write.
### `src/node/updater/VersionChecker.ts`
- Remove the release-notes scraping that called `parseVulnerableBelow`. The rest of the check (current vs latest tag) is unchanged.
### `src/node/hooks/express/updateStatus.ts` (load-bearing change)
```ts
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
const cache = new LRU<string, {value: OutdatedResponse; at: number}>(1000);
const inFlight = new Map<string, Promise<OutdatedResponse>>();
const TTL_MS = 60 * 1000;
const firstAuthorOf = (pad: Pad): string | null => {
const num2attrib = pad.pool.numToAttrib;
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
for (const k of keys) {
const a = num2attrib[k];
if (a && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') return a[1];
}
return null;
};
const computeOutdated = async (padId: string | null, authorId: string | null): Promise<OutdatedResponse> => {
const state = await loadState(stateFilePath());
if (!state.latest) return EMPTY;
const current = getEpVersion();
if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY;
if (!padId || !authorId) return EMPTY;
if (!(await padManager.doesPadExist(padId))) return EMPTY;
const pad = await padManager.getPad(padId, null);
if (firstAuthorOf(pad) !== authorId) return EMPTY;
return {outdated: 'minor', isFirstAuthor: true};
};
app.get('/api/version-status', wrapAsync(async (req, res) => {
const padId = typeof req.query.padId === 'string' ? req.query.padId : null;
const authorId = await resolveRequestAuthor(req); // express_sid → session → author, null on miss
const key = `${padId ?? ''}|${authorId ?? ''}`;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at <= TTL_MS) {
res.json(hit.value);
return;
}
let flight = inFlight.get(key);
if (!flight) {
flight = computeOutdated(padId, authorId).finally(() => inFlight.delete(key));
inFlight.set(key, flight);
}
const value = await flight;
cache.set(key, {value, at: now});
res.json(value);
}));
```
- `resolveRequestAuthor(req)` is a small helper that reads `req.cookies.express_sid`, calls `sessionStore.get(sid)` (the same store used by the express-session middleware), and returns `session?.user?.author ?? null`. On any failure path it returns `null` — the request is then treated as anonymous and gets `EMPTY`.
- `padId` is validated through `padutils.validateRequest({padID: padId})` before being passed to `padManager`. Validation failures map to `EMPTY`, not 400 — keeping the endpoint quiet about whether the pad exists.
- LRU cap of 1000 entries bounds memory on busy servers; entries expire by TTL anyway.
- Single-flight per cache key collapses bursts at expiry into one disk read.
- `_resetBadgeCacheForTests()` clears both `cache` and `inFlight`.
### `src/node/hooks/express/openapi-admin.ts`
- Update the OpenAPI doc for `/api/version-status`:
- Add `padId` query parameter (string, optional, must match Etherpad's pad-id format).
- Update response schema: `{outdated: 'minor' | null, isFirstAuthor: boolean}`.
- Drop the `severe` and `vulnerable` enum values.
## Client changes
### `src/templates/pad.html`
- Delete line 648 (`<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>`).
### `src/static/css/pad.css`
- Delete the `#version-badge { … }` rule block (lines ~119131). Gritter's stock styling carries the notice; no new CSS is added — matches `.privacy-notice` precedent.
### `src/static/js/pad_version_badge.ts` → renamed to `pad_outdated_notice.ts`
```ts
'use strict';
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const apiBasePath = (): string => {
if (typeof window === 'undefined') return '/';
return new URL('..', window.location.href).pathname;
};
const currentPadId = (): string | null => {
const id = (window as any).clientVars?.padId;
return typeof id === 'string' && id.length > 0 ? id : null;
};
export const maybeShowOutdatedNotice = async (): Promise<void> => {
const padId = currentPadId();
if (!padId) return;
const $ = (window as any).$;
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
try {
const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`;
const res = await fetch(url, {credentials: 'same-origin'});
if (!res.ok) return;
const data = (await res.json()) as OutdatedResponse;
if (data.outdated !== 'minor' || !data.isFirstAuthor) return;
// TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land.
$.gritter.add({
title: 'Etherpad update available',
text: 'A newer version of Etherpad has been released. Consider updating this server.',
sticky: false,
position: 'bottom',
class_name: 'outdated-notice',
time: 8000,
});
} catch {
/* never block pad load */
}
};
```
- Module no longer self-bootstraps on `DOMContentLoaded`; it needs `clientVars.padId`, which is only present after `CLIENT_VARS` arrives.
- Invocation site: `src/static/js/pad.ts`, in the same post-`handleClientVars` block where `showPrivacyBannerIfEnabled` is called.
- No `localStorage` write — dismissal is per-session (gritter X-click clears DOM; reload re-fetches and re-shows if still outdated).
## Tests
### Backend — `src/tests/backend/specs/api/updateStatus.spec.ts` (rewrite affected blocks)
- Drop `describe('vulnerable …')` cases entirely.
- Replace `describe('severe / isMajorBehind …')` with `describe('isMinorOrMoreBehind …')` covering:
- patch-only delta returns `false` (2.7.3 vs 2.7.4)
- minor delta returns `true` (2.7.3 vs 2.8.0)
- major delta returns `true` (2.7.3 vs 3.0.0)
- equal versions return `false`
- current newer than latest returns `false` (develop-on-bumped-package.json case)
- unparseable input on either side returns `false`
- New `describe('GET /api/version-status')` cases:
- no `state.latest``{outdated:null,isFirstAuthor:false}`
- current ≥ latest, with valid padId+author → `EMPTY`
- padId omitted → `EMPTY` (no leak)
- authorId resolves but isn't pool position 0 → `EMPTY`
- current is minor-behind AND requester is pool position 0 → `{outdated:'minor',isFirstAuthor:true}`
- current is patch-behind, requester IS pool position 0 → `EMPTY`
- cache hit within 60s for same `padId|authorId` does NOT re-call `loadState` (spy assertion)
- two different `padId|authorId` pairs are cached independently
- with the LRU cap forced low (test-only setter), the oldest entry is evicted first
Each case calls `_resetBadgeCacheForTests()` in `beforeEach`.
### Backend — `firstAuthorOf` unit test (new file next to the helper)
- empty pad → `null`
- single-author pad → that author
- A edited first then B → A
- pool with non-author attribs interleaved at low numeric keys → still returns the lowest `['author', X]`
- pool with `['author', '']` placeholder → skipped; returns the next real author
### Frontend — `src/tests/frontend-new/specs/outdated_notice.spec.ts` (new, mirrors `privacy_banner.spec.ts`)
- stub `/api/version-status` to `{outdated:null,…}` → no `.gritter-item.outdated-notice` after pad load
- stub to `{outdated:'minor', isFirstAuthor:false}` → no gritter (client belt-and-braces guard)
- stub to `{outdated:'minor', isFirstAuthor:true}``.gritter-item.outdated-notice` appears, body text matches, dismisses on X-click
- stub returning 500 → no DOM injection, no user-visible console error
- after ~9s with positive stub → gritter auto-faded (asserts `sticky:false` + `time:8000` wiring)
### Files removed entirely
- Any standalone `versionBadge.spec.ts` fixture file (merged into `updateStatus.spec.ts`).
- Any fixture referencing `vulnerableBelow`.
### Verification gates (mandatory before claiming done)
- `pnpm --filter ep_etherpad-lite test:vitest` clean (backend).
- `pnpm exec playwright test outdated_notice` clean under `xvfb-run` (frontend).
- Manual: load a pad on the dev server (`http://localhost.lan:9003/p/test`) with `var/update.state.json` pinned to a higher `latest.version` — gritter appears once for first-author in incognito-A, absent in incognito-B (second visitor).
## Docs / settings / build
- `doc/api/http_api.md` (and `.adoc` if present) — update `/api/version-status` entry: new shape, new `padId` query param, note that positive results are scoped to first-author.
- `doc/api/updater.md` (or the relevant `updates.tier` section in `doc/settings.md`) — drop the paragraph(s) on the vulnerable-below directive and the persistent banner UI.
- `CHANGELOG.md` (Unreleased) — one entry: "Outdated-version notice redesigned per #7799 — transient gritter, first-author only, minor-or-major behind only. The persistent banner, `severe` enum, and `vulnerable-below` directive scraping are removed."
- No settings-schema changes. `updates.tier = 'off'` remains the full kill-switch.
- `vite.config.ts` (and any other bundle config) — rename `pad_version_badge` entries to `pad_outdated_notice`. Grep to confirm no admin-bundle reference exists (shouldn't; pad-only).
## Risk / open questions
- **Develop-on-stale-package.json.** Today develop's `package.json` reads `2.7.3` while the latest npm release is newer. Under this design, the notice still triggers on develop because `current < latest`. The expected operational practice is for the post-release bump of develop's `package.json` to a higher pre-release identifier to short-circuit this naturally. Documented in the CHANGELOG entry. If maintainers want belt-and-braces, a follow-up can add a `.git`-presence short-circuit, but that is explicitly out-of-scope here per the design decision.
- **First-author churn on imported pads.** If a pad was created via `setText`/API by an admin script using a service-account author, the first-author signal points at that service account. Operationally fine — the notice just won't fire for anyone. Acceptable.
- **Anonymous browsers without express_sid.** First load of a pad with no prior session has no `express_sid` cookie until `socket.io` connects. The version-status request fires after `CLIENT_VARS`, which is after the socket handshake, so by then the cookie exists. If for any reason it doesn't, `resolveRequestAuthor` returns `null` and the response is `EMPTY` — fail-quiet.

View file

@ -45,13 +45,51 @@ export const generateAdminDefinition = (): any => ({
},
},
},
'/api/version-status': {
get: {
operationId: 'getVersionStatus',
summary: 'Outdated-version notice signal for the pad UI',
description:
'Returns a non-null `outdated` value only to the first author of the supplied pad, ' +
'and only when the running server is at least one minor version behind the latest ' +
'published release. Result is cached per (padId, authorId) for 60 s.',
parameters: [
{
name: 'padId',
in: 'query',
required: false,
schema: {type: 'string'},
description:
'Pad whose first-author membership is being checked. ' +
'Omitted padId always yields a null result.',
},
],
responses: {
'200': {
description: 'Outdated-notice signal.',
content: {
'application/json': {
schema: {
type: 'object',
required: ['outdated', 'isFirstAuthor'],
properties: {
outdated: {type: 'string', enum: ['minor'], nullable: true},
isFirstAuthor: {type: 'boolean'},
},
},
},
},
},
},
},
},
'/admin/update/status': {
get: {
operationId: 'getUpdateStatus',
summary: 'Fetch updater status for the admin UI banner and update page',
description:
'Returns the cached update state (current version, latest known release, ' +
'install method, tier, policy verdict, and vulnerability directives). ' +
'install method, tier, policy verdict, execution state, lastResult, and lockHeld). ' +
'Open by default; gated to authenticated admin sessions when ' +
'updates.requireAdminForStatus=true in settings.',
security: [
@ -102,17 +140,9 @@ export const generateAdminDefinition = (): any => ({
reason: {type: 'string'},
},
},
VulnerableBelowDirective: {
type: 'object',
required: ['announcedBy', 'threshold'],
properties: {
announcedBy: {type: 'string'},
threshold: {type: 'string'},
},
},
UpdateStatus: {
type: 'object',
required: ['currentVersion', 'installMethod', 'tier', 'vulnerableBelow'],
required: ['currentVersion', 'installMethod', 'tier'],
properties: {
currentVersion: {type: 'string'},
latest: {
@ -132,10 +162,6 @@ export const generateAdminDefinition = (): any => ({
allOf: [{$ref: '#/components/schemas/PolicyResult'}],
nullable: true,
},
vulnerableBelow: {
type: 'array',
items: {$ref: '#/components/schemas/VulnerableBelowDirective'},
},
},
},
},

View file

@ -1,36 +1,102 @@
'use strict';
import path from 'node:path';
import {LRUCache} from 'lru-cache';
import {ArgsExpressType} from '../../types/ArgsExpressType';
import settings, {getEpVersion} from '../../utils/Settings';
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
import {evaluatePolicy} from '../../updater/UpdatePolicy';
import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare';
import {isMinorOrMoreBehind} from '../../updater/versionCompare';
import {loadState} from '../../updater/state';
import {isHeld} from '../../updater/lock';
import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow';
let badgeCache: {value: 'severe' | 'vulnerable' | null; at: number} = {value: null, at: 0};
// Coalesce concurrent computeOutdated() calls during a cache-miss so a burst of
// requests at expiry doesn't fan out into N redundant disk reads.
let badgeInFlight: Promise<'severe' | 'vulnerable' | null> | null = null;
const BADGE_CACHE_MS = 60 * 1000;
const computeOutdated = async (): Promise<'severe' | 'vulnerable' | null> => {
const state = await loadState(stateFilePath());
if (!state.latest) return null;
const current = getEpVersion();
if (compareSemver(current, state.latest.version) >= 0) return null;
if (isVulnerable(current, state.vulnerableBelow)) return 'vulnerable';
if (isMajorBehind(current, state.latest.version)) return 'severe';
/**
* Returns the authorID of whoever first contributed to the pad i.e. the
* `['author', X]` entry at the lowest numeric key in the pool, with empty-X
* placeholders skipped. Returns null for a pad with no real author attribs yet.
*/
export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record<number | string, unknown>}}): string | null => {
const num2attrib = pad?.pool?.numToAttrib;
if (!num2attrib) return null;
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
for (const k of keys) {
const a = num2attrib[k];
if (Array.isArray(a) && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') {
return a[1];
}
}
return null;
};
/**
* 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.
*/
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()));
});
} catch {
return null;
}
return readAuthor();
};
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
const TTL_MS = 60 * 1000;
let cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max: 1000});
const inFlight = new Map<string, Promise<OutdatedResponse>>();
/** Test-only setter: rebuild the LRU with a smaller cap so eviction can be asserted. */
export const _setBadgeCacheCapForTests = (max: number): void => {
cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max});
};
/** Test-only: clear the in-memory badge cache so integration tests see fresh state. */
export const _resetBadgeCacheForTests = (): void => {
badgeCache = {value: null, at: 0};
badgeInFlight = null;
cache.clear();
inFlight.clear();
};
const computeOutdated = async (
padId: string | null,
authorId: string | null,
): Promise<OutdatedResponse> => {
const state = await loadState(stateFilePath());
if (!state.latest) return EMPTY;
const current = getEpVersion();
if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY;
if (!padId || !authorId) return EMPTY;
// padManager is loaded via dynamic import to avoid circular-init w/ updater.
const padManagerMod: any = await import('../../db/PadManager');
const padManager = padManagerMod.default ?? padManagerMod;
if (typeof padManager.isValidPadId !== 'function' || !padManager.isValidPadId(padId)) return EMPTY;
if (!(await padManager.doesPadExist(padId))) return EMPTY;
const pad = await padManager.getPad(padId);
if (firstAuthorOf(pad) !== authorId) return EMPTY;
return {outdated: 'minor', isFirstAuthor: true};
};
// Wrap an async Express handler so a rejected promise becomes next(err) rather than
@ -65,22 +131,27 @@ export const expressCreateServer = (
// Tier "off" disables the entire updater feature, including its HTTP surface.
if (settings.updates.tier === 'off') return cb();
// Public endpoint. Cached for 60s. Returns only an enum — no version string.
app.get('/api/version-status', wrapAsync(async (_req, res) => {
// Public endpoint. Cached for 60s per (padId, authorId) key.
app.get('/api/version-status', wrapAsync(async (req, res) => {
const padId = typeof req.query.padId === 'string' ? req.query.padId : null;
const authorId = await resolveRequestAuthor(req);
const key = `${padId ?? ''}|${authorId ?? ''}`;
const now = Date.now();
if (now - badgeCache.at > BADGE_CACHE_MS) {
// Single-flight: if another request is already computing, await its
// promise instead of starting a second one. The first to land seeds
// the cache; the rest read it.
if (!badgeInFlight) {
badgeInFlight = computeOutdated().finally(() => { badgeInFlight = null; });
}
const value = await badgeInFlight;
// Only the request that observed the original miss writes the cache;
// followers may race on the assignment but write the same value.
badgeCache = {value, at: now};
const hit = cache.get(key);
if (hit && now - hit.at <= TTL_MS) {
res.json(hit.value);
return;
}
res.json({outdated: badgeCache.value});
let flight = inFlight.get(key);
if (!flight) {
flight = computeOutdated(padId, authorId).finally(() => { inFlight.delete(key); });
inFlight.set(key, flight);
}
const value = await flight;
cache.set(key, {value, at: now});
res.json(value);
}));
// Admin UI status endpoint. By default this is open: the running version is already
@ -138,7 +209,6 @@ export const expressCreateServer = (
installMethod,
tier: settings.updates.tier,
policy,
vulnerableBelow: state.vulnerableBelow,
// PR 2 additions:
execution,
lastResult,

View file

@ -1,13 +1,10 @@
import {EmailSendLog} from './types';
// TODO(future): surface the threshold version in email bodies so admins know which version
// clears the vulnerability. Requires extending NotifierInput with the relevant directive(s).
export interface NotifierInput {
adminEmail: string | null;
current: string;
latest: string;
latestTag: string;
isVulnerable: boolean;
isSevere: boolean;
state: EmailSendLog;
now: Date;
@ -15,8 +12,6 @@ export interface NotifierInput {
export type EmailKind =
| 'severe'
| 'vulnerable'
| 'vulnerable-new-release'
| 'grace-start'
| 'update-preflight-failed'
| 'update-rolled-back'
@ -35,7 +30,6 @@ export interface NotifierResult {
const DAY = 24 * 60 * 60 * 1000;
const SEVERE_INTERVAL = 30 * DAY;
const VULNERABLE_INTERVAL = 7 * DAY;
const sinceMs = (iso: string | null, now: Date): number =>
iso ? now.getTime() - new Date(iso).getTime() : Infinity;
@ -44,48 +38,23 @@ const sinceMs = (iso: string | null, now: Date): number =>
* Decide which emails to send and what the new dedupe-log state should be.
* Pure function: returns plans + new state, does not actually send.
*
* Cadence: vulnerable beats severe; vulnerable repeats every 7 days; severe every 30.
* If vulnerable AND the release tag changed since last send, fire `vulnerable-new-release`
* even within the 7-day window so admins learn of the fixed release.
* Cadence: severe repeats every 30 days.
*/
export const decideEmails = (input: NotifierInput): NotifierResult => {
const {adminEmail, current, latest, latestTag, isVulnerable, isSevere, state, now} = input;
const {adminEmail, current, latest, isSevere, state, now} = input;
if (!adminEmail) return {toSend: [], newState: state};
const toSend: PlannedEmail[] = [];
const newState: EmailSendLog = {...state};
if (isVulnerable) {
const sinceVuln = sinceMs(state.vulnerableAt, now);
const tagChanged = state.vulnerableNewReleaseTag !== null && state.vulnerableNewReleaseTag !== latestTag;
if (tagChanged) {
// A new release shipped while the instance is still vulnerable. Fire regardless
// of the 7-day cadence: the admin needs to know a fix exists.
toSend.push({
kind: 'vulnerable-new-release',
subject: `[Etherpad] New release available — ${latest} (your version is vulnerable)`,
body: `A new Etherpad release (${latestTag}) is available. Your version (${current}) is flagged as vulnerable. Please update.`,
});
newState.vulnerableNewReleaseTag = latestTag;
// Also reset the periodic clock so we don't immediately re-nag on next tick.
newState.vulnerableAt = now.toISOString();
} else if (sinceVuln >= VULNERABLE_INTERVAL) {
toSend.push({
kind: 'vulnerable',
subject: `[Etherpad] Your instance is running a vulnerable version (${current})`,
body: `Your Etherpad version (${current}) is below the security threshold. Latest is ${latest}.`,
});
newState.vulnerableAt = now.toISOString();
newState.vulnerableNewReleaseTag = latestTag;
}
} else if (isSevere) {
if (isSevere) {
const sinceSevere = sinceMs(state.severeAt, now);
if (sinceSevere >= SEVERE_INTERVAL) {
toSend.push({
kind: 'severe',
subject: `[Etherpad] Your instance is severely outdated (${current})`,
body: `Your Etherpad version (${current}) is more than one major release behind ${latest}.`,
subject: `[Etherpad] Your instance is outdated (${current})`,
body: `Your Etherpad version (${current}) is at least one minor release behind the latest published version (${latest}). Consider scheduling an upgrade.`,
});
newState.severeAt = now.toISOString();
}

View file

@ -1,5 +1,4 @@
import {ReleaseInfo, VulnerableBelowDirective} from './types';
import {parseVulnerableBelow} from './versionCompare';
import {ReleaseInfo} from './types';
import {isValidTag} from './refSafety';
export interface FetchResult {
@ -14,7 +13,7 @@ export type Fetcher = (url: string, etag: string | null) => Promise<FetchResult>
/** Discriminated union of every outcome the checker can return. */
export type CheckResult =
| {kind: 'updated'; release: ReleaseInfo; etag: string | null; vulnerableBelow: VulnerableBelowDirective[]}
| {kind: 'updated'; release: ReleaseInfo; etag: string | null}
| {kind: 'notmodified'}
| {kind: 'ratelimited'}
| {kind: 'skipped-prerelease'; etag: string | null}
@ -72,12 +71,7 @@ export const checkLatestRelease = async (
htmlUrl: j.html_url,
};
const directiveThreshold = parseVulnerableBelow(body);
const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold
? [{announcedBy: tag, threshold: directiveThreshold}]
: [];
return {kind: 'updated', release, etag: res.etag, vulnerableBelow};
return {kind: 'updated', release, etag: res.etag};
};
/** Production fetcher built on Node 18+ native fetch. Honors If-None-Match for cheap polling. */

View file

@ -6,7 +6,7 @@ import settings, {getEpVersion} from '../utils/Settings';
import {detectInstallMethod} from './InstallMethodDetector';
import {checkLatestRelease, realFetcher} from './VersionChecker';
import {loadState, saveState} from './state';
import {isMajorBehind, isVulnerable} from './versionCompare';
import {isMinorOrMoreBehind} from './versionCompare';
import {evaluatePolicy} from './UpdatePolicy';
import {decideEmails, decideOutcomeEmail, FailureOutcome} from './Notifier';
import {checkPendingVerification, CheckResult, RollbackDeps, performRollback} from './RollbackHandler';
@ -132,11 +132,6 @@ const performCheck = async (): Promise<void> => {
if (result.kind === 'updated') {
state.latest = result.release;
state.lastEtag = result.etag;
// Union new directives with existing — same announcedBy is a no-op.
const existingTags = new Set(state.vulnerableBelow.map((v) => v.announcedBy));
for (const v of result.vulnerableBelow) {
if (!existingTags.has(v.announcedBy)) state.vulnerableBelow.push(v);
}
} else if (result.kind === 'skipped-prerelease') {
// Preserve ETag so we don't re-fetch an unchanged prerelease body next tick.
state.lastEtag = result.etag;
@ -164,8 +159,7 @@ const performCheck = async (): Promise<void> => {
current,
latest: state.latest.version,
latestTag: state.latest.tag,
isVulnerable: isVulnerable(current, state.vulnerableBelow),
isSevere: isMajorBehind(current, state.latest.version),
isSevere: isMinorOrMoreBehind(current, state.latest.version),
state: state.email,
now,
});

View file

@ -78,14 +78,6 @@ const isValidLatest = (v: unknown): boolean => {
&& typeof v.prerelease === 'boolean';
};
const isValidVulnerableBelow = (v: unknown): boolean => {
if (!Array.isArray(v)) return false;
return v.every((entry) =>
isPlainObject(entry)
&& typeof entry.announcedBy === 'string'
&& typeof entry.threshold === 'string');
};
const isValidEmail = (v: unknown): boolean => {
if (!isPlainObject(v)) return false;
// graceStartTag (Tier 3) and lastFailureKey (Tier 4) are both optional for
@ -94,8 +86,6 @@ const isValidEmail = (v: unknown): boolean => {
const graceOk = v.graceStartTag === undefined || isStringOrNull(v.graceStartTag);
const failOk = v.lastFailureKey === undefined || isStringOrNull(v.lastFailureKey);
return isStringOrNull(v.severeAt)
&& isStringOrNull(v.vulnerableAt)
&& isStringOrNull(v.vulnerableNewReleaseTag)
&& graceOk
&& failOk;
};
@ -114,7 +104,6 @@ const isValid = (raw: unknown): raw is Partial<UpdateState> & object => {
if (!isStringOrNull(raw.lastCheckAt)) return false;
if (!isStringOrNull(raw.lastEtag)) return false;
if (!isValidLatest(raw.latest)) return false;
if (!isValidVulnerableBelow(raw.vulnerableBelow)) return false;
if (!isValidEmail(raw.email)) return false;
if (raw.execution !== undefined && !isValidExecution(raw.execution)) return false;
if (raw.bootCount !== undefined && typeof raw.bootCount !== 'number') return false;

View file

@ -13,9 +13,6 @@ export interface MaintenanceWindow {
tz: 'local' | 'utc';
}
/** null = up-to-date (or not yet checked); 'severe' = at least one major version behind; 'vulnerable' = matched a vulnerable-below directive. */
export type OutdatedLevel = null | 'severe' | 'vulnerable';
export interface ReleaseInfo {
/** semver string without leading 'v', e.g. "2.7.2". */
version: string;
@ -31,13 +28,6 @@ export interface ReleaseInfo {
htmlUrl: string;
}
export interface VulnerableBelowDirective {
/** The release that *announced* the vulnerability (latest release wins on conflict). */
announcedBy: string;
/** Versions strictly below this string are considered vulnerable. */
threshold: string;
}
export interface PolicyResult {
canNotify: boolean;
canManual: boolean;
@ -50,10 +40,6 @@ export interface PolicyResult {
export interface EmailSendLog {
/** Last time we emailed about being severely-outdated, ISO-8601. */
severeAt: string | null;
/** Last time we emailed about being vulnerable, ISO-8601. */
vulnerableAt: string | null;
/** Tag of the release the last "new release while vulnerable" email referenced. */
vulnerableNewReleaseTag: string | null;
/** Tag of the most recent release for which we sent a Tier 3 `grace-start` email. */
graceStartTag: string | null;
/**
@ -114,8 +100,6 @@ export interface UpdateState {
lastEtag: string | null;
/** Cached release info, or null if we've never successfully fetched. */
latest: ReleaseInfo | null;
/** Vulnerable-below directives parsed from the most recent N releases. */
vulnerableBelow: VulnerableBelowDirective[];
/** Email send dedupe state. */
email: EmailSendLog;
/** Current in-flight execution state. Persisted so a restart mid-update reaches RollbackHandler. */
@ -135,11 +119,8 @@ export const EMPTY_STATE: UpdateState = {
lastCheckAt: null,
lastEtag: null,
latest: null,
vulnerableBelow: [],
email: {
severeAt: null,
vulnerableAt: null,
vulnerableNewReleaseTag: null,
graceStartTag: null,
lastFailureKey: null,
},

View file

@ -1,5 +1,3 @@
import type {VulnerableBelowDirective} from './types';
export interface ParsedSemver {
major: number;
minor: number;
@ -26,28 +24,14 @@ export const compareSemver = (a: string, b: string): -1 | 0 | 1 => {
return 0;
};
export const isMajorBehind = (current: string, latest: string): boolean => {
// True iff `current` is at least one minor version behind `latest`.
// Equivalent to: latest.major > current.major, OR same major and
// latest.minor > current.minor. Patch-only deltas return false, equal
// versions return false, current newer than latest returns false.
export const isMinorOrMoreBehind = (current: string, latest: string): boolean => {
const c = parseSemver(current);
const l = parseSemver(latest);
if (!c || !l) return false;
return l.major - c.major >= 1;
};
const VULN_RE = /<!--\s*updater\s*:\s*vulnerable-below\s+([^\s-][^\s]*)\s*-->/i;
export const parseVulnerableBelow = (body: string): string | null => {
const m = VULN_RE.exec(body);
if (!m) return null;
if (!parseSemver(m[1])) return null;
return m[1];
};
export const isVulnerable = (
current: string,
directives: readonly VulnerableBelowDirective[],
): boolean => {
for (const d of directives) {
if (compareSemver(current, d.threshold) < 0) return true;
}
return false;
if (l.major !== c.major) return l.major > c.major;
return l.minor > c.minor;
};

View file

@ -115,20 +115,6 @@ input {
margin-right:auto;
}
/* Auto-update version badge — only visible when /api/version-status reports severe or vulnerable. */
#version-badge {
position: fixed;
bottom: 8px;
right: 8px;
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
z-index: 9999;
pointer-events: auto;
max-width: 320px;
}
#version-badge[data-level="severe"] { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }
#version-badge[data-level="vulnerable"] { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }
/* ----------------------------------------------------------------------- */
/* History mode (issue #7659): timeslider rendered in-place inside the */

View file

@ -55,8 +55,7 @@ const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks');
import {showPrivacyBannerIfEnabled} from './privacy_banner';
import './pad_version_badge';
import {maybeShowOutdatedNotice} from './pad_outdated_notice';
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
@ -749,6 +748,7 @@ const pad = {
showDeletionTokenModalIfPresent();
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
void maybeShowOutdatedNotice();
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
};

View file

@ -0,0 +1,43 @@
'use strict';
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const apiBasePath = (): string => {
if (typeof window === 'undefined') return '/';
return new URL('..', window.location.href).pathname;
};
const currentPadId = (): string | null => {
const id = (window as any).clientVars?.padId;
return typeof id === 'string' && id.length > 0 ? id : null;
};
export const maybeShowOutdatedNotice = async (): Promise<void> => {
const padId = currentPadId();
if (!padId) return;
const $ = (window as any).$;
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
try {
const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`;
const res = await fetch(url, {credentials: 'same-origin'});
if (!res.ok) return;
const data = (await res.json()) as OutdatedResponse;
if (data.outdated !== 'minor' || !data.isFirstAuthor) return;
// TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land.
$.gritter.add({
title: 'Etherpad update available',
text: 'A newer version of Etherpad has been released. Consider updating this server.',
sticky: false,
position: 'bottom',
class_name: 'outdated-notice',
time: 8000,
});
} catch {
/* never block pad load */
}
};

View file

@ -1,46 +0,0 @@
'use strict';
interface BadgeResponse { outdated: 'severe' | 'vulnerable' | null }
// TODO(i18n): switch to html10n once a `pad.update.badge.*` key set is added there.
// (Strings are deliberately not pulled from /locales/en.json yet — that file is
// consumed by the admin UI's i18next, not the pad's html10n. Cross-wiring is
// a separate piece of work.)
const TEXT_BY_LEVEL: Record<'severe' | 'vulnerable', string> = {
severe: 'Etherpad on this server is severely outdated. Tell your admin.',
vulnerable: 'Etherpad on this server is running a version with known security issues. Tell your admin.',
};
// padBootstrap.js derives basePath from window.location ('..' relative to the
// pad URL) so deployments hosted under a subpath route requests through the
// same prefix. We replicate that here rather than importing pad.ts (which
// would reintroduce the badge↔pad circular initialisation).
const apiBasePath = (): string => {
if (typeof window === 'undefined') return '/';
return new URL('..', window.location.href).pathname;
};
export const renderVersionBadge = async (): Promise<void> => {
const el = document.getElementById('version-badge');
if (!el) return;
try {
const res = await fetch(`${apiBasePath()}api/version-status`, {credentials: 'same-origin'});
if (!res.ok) return;
const data = (await res.json()) as BadgeResponse;
if (!data.outdated) { el.style.display = 'none'; return; }
el.textContent = TEXT_BY_LEVEL[data.outdated];
el.dataset.level = data.outdated;
el.style.display = '';
} catch {
// Quiet failure — never block the pad load.
}
};
// Auto-render once DOM is ready.
if (typeof window !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { void renderVersionBadge(); });
} else {
void renderVersionBadge();
}
}

View file

@ -645,7 +645,6 @@
<% e.end_block(); %>
<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>
</div> <!-- End of #editorcontainerbox -->
<% e.end_block(); %>

View file

@ -0,0 +1,42 @@
import {describe, expect, it} from 'vitest';
import {firstAuthorOf} from '../../../../../node/hooks/express/updateStatus';
const makePad = (entries: Record<number, [string, string]>): any => ({
pool: {numToAttrib: entries},
});
describe('firstAuthorOf', () => {
it('returns null for a pad with no attribs', () => {
expect(firstAuthorOf(makePad({}))).toBeNull();
});
it('returns null when no author attribs exist', () => {
expect(firstAuthorOf(makePad({0: ['bold', 'true'], 1: ['italic', 'true']}))).toBeNull();
});
it('returns the only author when there is one', () => {
expect(firstAuthorOf(makePad({0: ['author', 'a.alice']}))).toBe('a.alice');
});
it('returns the lowest-numbered author when there are several', () => {
expect(firstAuthorOf(makePad({
0: ['bold', 'true'],
1: ['author', 'a.alice'],
2: ['author', 'a.bob'],
}))).toBe('a.alice');
});
it('skips empty-string author placeholders', () => {
expect(firstAuthorOf(makePad({
0: ['author', ''],
1: ['author', 'a.alice'],
}))).toBe('a.alice');
});
it('walks keys in numeric order, not string order', () => {
expect(firstAuthorOf(makePad({
10: ['author', 'a.bob'],
2: ['author', 'a.alice'],
}))).toBe('a.alice');
});
});

View file

@ -0,0 +1,315 @@
/**
* End-to-end vitest coverage for the /api/version-status route.
*
* Harness: minimal Express app built by calling `expressCreateServer` directly
* (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.
*
* 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.
*/
import {describe, it, expect, vi, beforeAll, beforeEach, afterEach} from 'vitest';
import express from 'express';
import supertest from 'supertest';
import type {Express} from 'express';
import type {UpdateState} from '../../../../../node/updater/types';
import {EMPTY_STATE} from '../../../../../node/updater/types';
// ---------------------------------------------------------------------------
// Module mocks — must appear before any import that transitively imports them.
// vi.mock() is hoisted by vitest ahead of all imports, so these factories run
// before updateStatus.ts is loaded and its own `import {loadState}` runs.
// ---------------------------------------------------------------------------
vi.mock('../../../../../node/updater/state', () => ({
loadState: vi.fn(),
saveState: vi.fn(),
}));
// The updater index is imported by updateStatus.ts for stateFilePath() and
// getDetectedInstallMethod(). Provide stubs so we don't boot the full updater.
vi.mock('../../../../../node/updater', () => ({
stateFilePath: () => '/tmp/test-update-state.json',
getDetectedInstallMethod: () => 'git',
}));
// 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', () => {
const pads = new Map<string, any>();
return {
default: {
isValidPadId: (id: string) => /^[^$]{1,50}$/.test(id),
doesPadExist: async (id: string) => pads.has(id),
getPad: async (id: string) => pads.get(id),
},
// Also expose the map for test setup via the named export __pads__.
__pads__: pads,
};
});
// ---------------------------------------------------------------------------
// Import the SUT *after* vi.mock declarations so the mocks take effect.
// ---------------------------------------------------------------------------
import * as stateModule from '../../../../../node/updater/state';
import {
expressCreateServer,
_resetBadgeCacheForTests,
_setBadgeCacheCapForTests,
} from '../../../../../node/hooks/express/updateStatus';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build the mocked state with optional `latest` field. */
const makeState = (latest: UpdateState['latest']): UpdateState => ({
...EMPTY_STATE,
latest,
});
/** A fake ReleaseInfo for a version that is a minor-version ahead of 3.1.0. */
const MINOR_AHEAD: UpdateState['latest'] = {
version: '3.2.0',
tag: 'v3.2.0',
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.2.0',
};
/** A fake ReleaseInfo that is only a patch ahead of 3.1.0 (no minor/major delta). */
const PATCH_AHEAD: UpdateState['latest'] = {
version: '3.1.1',
tag: 'v3.1.1',
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.1.1',
};
/** A fake ReleaseInfo that is behind or equal to 3.1.0 (current >= latest). */
const SAME_OR_BEHIND: UpdateState['latest'] = {
version: '3.0.0',
tag: 'v3.0.0',
body: '',
publishedAt: '2026-01-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.0.0',
};
// ---------------------------------------------------------------------------
// Test app setup
// ---------------------------------------------------------------------------
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).
*/
let sessionAuthor: string | null = null;
beforeAll(() => {
app = express();
// Fake session middleware: sets req.session.user.author from our test
// variable, so resolveRequestAuthor() in the route sees the right identity.
app.use((req: any, _res, next) => {
if (sessionAuthor !== null) {
req.session = {user: {author: sessionAuthor}};
}
next();
});
// Register the route under test. The hook signature is (hookName, {app, ...}, cb).
expressCreateServer('expressCreateServer', {app, io: null, server: null, settings: null as any}, () => {});
request = supertest(app);
});
beforeEach(() => {
// Reset LRU cache and in-flight map so every test sees a cold cache.
_resetBadgeCacheForTests();
// Reset the session author to "anonymous" by default.
sessionAuthor = null;
// Reset the loadState spy so each test controls its own return value.
vi.mocked(stateModule.loadState).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Helper to get the pad map from the mocked PadManager.
// ---------------------------------------------------------------------------
const getPadMap = async (): Promise<Map<string, any>> => {
// Dynamic import returns the mock factory's return value.
const mod: any = await import('../../../../../node/db/PadManager');
return mod.__pads__ as Map<string, any>;
};
/** Create a minimal fake pad object with the given author at pool position 0. */
const makePad = (firstAuthorId: string, secondAuthorId?: string) => {
const numToAttrib: Record<number, [string, string]> = {
0: ['author', firstAuthorId],
};
if (secondAuthorId) {
numToAttrib[1] = ['author', secondAuthorId];
}
return {pool: {numToAttrib}};
};
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
describe('/api/version-status', () => {
// Case 1: loadState returns no `latest` → EMPTY response regardless of author/padId.
it('case 1: returns {outdated:null, isFirstAuthor:false} when state has no latest', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(null));
const res = await request.get('/api/version-status').query({padId: 'testpad1'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 2: current >= latest → no banner.
it('case 2: returns {outdated:null, isFirstAuthor:false} when current >= latest', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(SAME_OR_BEHIND));
const res = await request.get('/api/version-status').query({padId: 'testpad2'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 3: delta is patch-only → isMinorOrMoreBehind returns false → no banner.
it('case 3: returns {outdated:null, isFirstAuthor:false} for patch-only delta', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(PATCH_AHEAD));
const res = await request.get('/api/version-status').query({padId: 'testpad3'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 4: padId omitted → even if behind, route returns EMPTY because padId is null.
it('case 4: returns {outdated:null, isFirstAuthor:false} when padId is omitted', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const res = await request.get('/api/version-status');
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 5: pad exists, request author is NOT pool position 0 → EMPTY.
it('case 5: returns {outdated:null, isFirstAuthor:false} when requester is not first author', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad5', makePad('a.alice', 'a.bob'));
// Request is made by a.bob (position 1), not a.alice (position 0).
sessionAuthor = 'a.bob';
const res = await request.get('/api/version-status').query({padId: 'mypad5'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
padMap.delete('mypad5');
});
// Case 6: request author IS pool position 0 AND latest is minor-behind → full badge.
it('case 6: returns {outdated:"minor", isFirstAuthor:true} when requester is first author and minor behind', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad6', makePad('a.alice'));
sessionAuthor = 'a.alice';
const res = await request.get('/api/version-status').query({padId: 'mypad6'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: 'minor', isFirstAuthor: true});
padMap.delete('mypad6');
});
// Case 7: two requests to the same (padId, authorId) → loadState called exactly once (cache hit).
it('case 7: cache hit — loadState called exactly once across two identical requests', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad7', makePad('a.alice'));
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad7'});
await request.get('/api/version-status').query({padId: 'mypad7'});
expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(1);
padMap.delete('mypad7');
});
// Case 8: different (padId, authorId) pairs → cache entries are independent.
it('case 8: cache isolation — different keys result in separate loadState calls', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad8a', makePad('a.alice'));
padMap.set('mypad8b', makePad('a.bob'));
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad8a'});
sessionAuthor = 'a.bob';
await request.get('/api/version-status').query({padId: 'mypad8b'});
// Two distinct cache keys → two separate computeOutdated() calls → two loadState calls.
expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(2);
padMap.delete('mypad8a');
padMap.delete('mypad8b');
});
// Case 9: LRU eviction — cap at 2, insert 3 entries, then re-hit key 1 → 4 total loadState calls.
it('case 9: LRU eviction causes re-computation after capacity exceeded', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
_setBadgeCacheCapForTests(2);
const padMap = await getPadMap();
padMap.set('mypad9a', makePad('a.alice'));
padMap.set('mypad9b', makePad('a.bob'));
padMap.set('mypad9c', makePad('a.carol'));
// First three distinct keys: key1, key2, key3.
// With cap=2, after inserting key3 the LRU evicts the least-recently-used
// (key1, since key2 was accessed after key1).
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad9a'}); // key1, miss → call 1
sessionAuthor = 'a.bob';
await request.get('/api/version-status').query({padId: 'mypad9b'}); // key2, miss → call 2
sessionAuthor = 'a.carol';
await request.get('/api/version-status').query({padId: 'mypad9c'}); // key3, miss → call 3, evicts key1
// Re-hit key1 → it was evicted, so another miss → call 4.
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad9a'}); // key1, miss → call 4
expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(4);
padMap.delete('mypad9a');
padMap.delete('mypad9b');
padMap.delete('mypad9c');
});
});

View file

@ -7,7 +7,6 @@ const base: NotifierInput = {
current: '2.0.0',
latest: '2.7.2',
latestTag: 'v2.7.2',
isVulnerable: false,
isSevere: false,
state: EMPTY_STATE.email,
now: new Date('2026-04-25T12:00:00Z'),
@ -43,55 +42,10 @@ describe('decideEmails', () => {
expect(r.toSend.map(e => e.kind)).toEqual(['severe']);
});
it('emits vulnerable email on first detection', () => {
const r = decideEmails({...base, isVulnerable: true});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
});
it('does not re-emit vulnerable within 7 days', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-22T12:00:00.000Z'},
});
it('emits no email when neither severe nor vulnerable', () => {
const r = decideEmails({...base});
expect(r.toSend).toEqual([]);
});
it('re-emits vulnerable after 7 days', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-15T12:00:00.000Z'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
});
it('emits new-release-while-vulnerable when latest tag changes', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-25T11:59:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
});
it('vulnerable wins over severe in the same tick', () => {
const r = decideEmails({...base, isSevere: true, isVulnerable: true});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
});
it('emits new-release-while-vulnerable even after the 7-day window has passed', () => {
// Regression: tagChanged should fire regardless of cadence; admin must learn of the fix.
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-01T12:00:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
expect(r.newState.vulnerableNewReleaseTag).toBe('v2.7.2');
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
});
});
describe('decideOutcomeEmail', () => {

View file

@ -4,7 +4,7 @@ import {ReleaseInfo} from '../../../../node/updater/types';
const ghBody = (overrides: Partial<{tag_name: string; body: string; prerelease: boolean; html_url: string; published_at: string}> = {}) => ({
tag_name: 'v2.7.2',
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
body: 'Some changes.',
prerelease: false,
html_url: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
published_at: '2026-04-25T00:00:00Z',
@ -24,14 +24,13 @@ describe('checkLatestRelease', () => {
const expected: ReleaseInfo = {
version: '2.7.2',
tag: 'v2.7.2',
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
body: 'Some changes.',
publishedAt: '2026-04-25T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
};
expect(r.release).toEqual(expected);
expect(r.etag).toBe('abc');
expect(r.vulnerableBelow).toEqual([{announcedBy: 'v2.7.2', threshold: '2.6.4'}]);
});
it('returns notmodified on 304', async () => {

View file

@ -62,22 +62,8 @@ describe('loadState', () => {
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when vulnerableBelow entries miss threshold', async () => {
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1.0.0'}]};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when vulnerableBelow.threshold is non-string', async () => {
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1', threshold: 123}]};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when email subfield is wrong type', async () => {
const broken = {...EMPTY_STATE, email: {severeAt: 0, vulnerableAt: null, vulnerableNewReleaseTag: null}};
const broken = {...EMPTY_STATE, email: {severeAt: 0}};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);

View file

@ -1,13 +1,13 @@
import {describe, it, expect} from 'vitest';
import {
parseSemver,
compareSemver,
isMajorBehind,
parseVulnerableBelow,
isVulnerable,
} from '../../../../node/updater/versionCompare';
import {describe, expect, it} from 'vitest';
import {compareSemver, isMinorOrMoreBehind, parseSemver} from '../../../../node/updater/versionCompare';
describe('parseSemver', () => {
it('parses standard semver', () => {
expect(parseSemver('1.2.3')).toEqual({major: 1, minor: 2, patch: 3});
});
it('accepts v-prefix and pre-release', () => {
expect(parseSemver('v2.7.3-rc.1')).toEqual({major: 2, minor: 7, patch: 3});
});
it('parses a plain version', () => {
expect(parseSemver('2.7.1')).toEqual({major: 2, minor: 7, patch: 1});
});
@ -19,6 +19,11 @@ describe('parseSemver', () => {
expect(parseSemver('')).toBeNull();
expect(parseSemver('2.7')).toBeNull();
});
it('rejects garbage', () => {
expect(parseSemver('not-a-version')).toBeNull();
expect(parseSemver('1.2')).toBeNull();
expect(parseSemver('2.7.1.4')).toBeNull();
});
it('strips prerelease suffix', () => {
expect(parseSemver('2.7.1-rc.1')).toEqual({major: 2, minor: 7, patch: 1});
expect(parseSemver('v2.7.1-beta')).toEqual({major: 2, minor: 7, patch: 1});
@ -33,6 +38,11 @@ describe('parseSemver', () => {
});
describe('compareSemver', () => {
it('returns -1, 0, 1', () => {
expect(compareSemver('1.2.3', '1.2.4')).toBe(-1);
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
expect(compareSemver('1.2.4', '1.2.3')).toBe(1);
});
it('orders correctly', () => {
expect(compareSemver('2.7.1', '2.7.2')).toBe(-1);
expect(compareSemver('2.7.2', '2.7.1')).toBe(1);
@ -44,49 +54,26 @@ describe('compareSemver', () => {
});
});
describe('isMajorBehind', () => {
it('true when at least one major behind', () => {
expect(isMajorBehind('2.7.1', '3.0.0')).toBe(true);
expect(isMajorBehind('2.7.1', '4.0.0')).toBe(true);
describe('isMinorOrMoreBehind', () => {
it('returns false for equal versions', () => {
expect(isMinorOrMoreBehind('3.0.0', '3.0.0')).toBe(false);
});
it('false otherwise', () => {
expect(isMajorBehind('2.7.1', '2.99.99')).toBe(false);
expect(isMajorBehind('3.0.0', '3.0.0')).toBe(false);
expect(isMajorBehind('3.0.0', '2.7.1')).toBe(false);
});
});
describe('parseVulnerableBelow', () => {
it('extracts directive from release body', () => {
const body = 'Fixes a few things.\n<!-- updater: vulnerable-below 2.6.4 -->\nMore notes.';
expect(parseVulnerableBelow(body)).toBe('2.6.4');
});
it('tolerates whitespace and casing', () => {
expect(parseVulnerableBelow('<!--updater:vulnerable-below 1.0.0-->')).toBe('1.0.0');
expect(parseVulnerableBelow('<!-- UPDATER: VULNERABLE-BELOW 1.0.0 -->')).toBe('1.0.0');
});
it('returns null when absent or malformed', () => {
expect(parseVulnerableBelow('no directive here')).toBeNull();
expect(parseVulnerableBelow('<!-- updater: vulnerable-below garbage -->')).toBeNull();
});
});
describe('isVulnerable', () => {
it('true if current strictly below any directive threshold', () => {
expect(isVulnerable('2.6.3', [
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
])).toBe(true);
});
it('false at or above all thresholds', () => {
expect(isVulnerable('2.6.4', [
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
])).toBe(false);
expect(isVulnerable('2.7.0', [])).toBe(false);
});
it('handles multiple directives', () => {
expect(isVulnerable('1.5.0', [
{announcedBy: 'v2.0.0', threshold: '2.0.0'},
{announcedBy: 'v3.0.0', threshold: '1.9.0'},
])).toBe(true);
it('returns false for current ahead of latest', () => {
expect(isMinorOrMoreBehind('3.1.0', '3.0.5')).toBe(false);
});
it('returns false for patch-only delta', () => {
expect(isMinorOrMoreBehind('2.7.3', '2.7.4')).toBe(false);
expect(isMinorOrMoreBehind('3.0.1', '3.0.9')).toBe(false);
});
it('returns true for minor delta', () => {
expect(isMinorOrMoreBehind('3.1.0', '3.2.0')).toBe(true);
expect(isMinorOrMoreBehind('3.1.5', '3.2.0')).toBe(true);
});
it('returns true for major delta', () => {
expect(isMinorOrMoreBehind('2.7.3', '3.0.0')).toBe(true);
});
it('returns false on unparseable input on either side', () => {
expect(isMinorOrMoreBehind('garbage', '3.0.0')).toBe(false);
expect(isMinorOrMoreBehind('3.0.0', 'garbage')).toBe(false);
});
});

View file

@ -90,7 +90,6 @@ describe('admin OpenAPI document', function () {
'latest',
'policy',
'tier',
'vulnerableBelow',
]);
});
@ -104,10 +103,9 @@ describe('admin OpenAPI document', function () {
assert.deepEqual(enums.slice().sort(), ['auto', 'autonomous', 'manual', 'notify', 'off']);
});
it('declares ReleaseInfo, PolicyResult, VulnerableBelowDirective sub-schemas', function () {
it('declares ReleaseInfo and PolicyResult sub-schemas', function () {
assert.ok(doc.components.schemas.ReleaseInfo);
assert.ok(doc.components.schemas.PolicyResult);
assert.ok(doc.components.schemas.VulnerableBelowDirective);
});
it('ReleaseInfo properties mirror updater/types.ts', function () {
@ -124,10 +122,6 @@ describe('admin OpenAPI document', function () {
]);
});
it('VulnerableBelowDirective properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.VulnerableBelowDirective.properties).sort();
assert.deepEqual(props, ['announcedBy', 'threshold']);
});
});
describe('cross-collision with public spec', function () {

View file

@ -44,35 +44,6 @@ describe(__filename, function () {
Object.assign(settings, backups.settings);
});
describe('GET /api/version-status', function () {
it('returns null when no state', async function () {
await saveState(statePath(), {...EMPTY_STATE});
const res = await agent.get('/api/version-status').expect(200);
assert.deepEqual(res.body, {outdated: null});
});
it('does not leak the running version', async function () {
const res = await agent.get('/api/version-status').expect(200);
assert.ok(!('version' in res.body), 'response leaks version field');
assert.ok(!('latest' in res.body), 'response leaks latest field');
assert.ok(!('currentVersion' in res.body), 'response leaks currentVersion field');
});
it('returns severe when running > 1 major behind', async function () {
// Force "latest" to be 99.0.0 so our running version is severely outdated.
await saveState(statePath(), {
...EMPTY_STATE,
latest: {
version: '99.0.0', tag: 'v99.0.0', body: '',
publishedAt: '2099-01-01T00:00:00Z', prerelease: false,
htmlUrl: 'https://example/',
},
});
const res = await agent.get('/api/version-status').expect(200);
assert.equal(res.body.outdated, 'severe');
});
});
describe('GET /admin/update/status', function () {
// Auth on this endpoint is intentionally loose: the running version is already
// exposed publicly via /health (releaseId), and latest/changelog come from a
@ -85,7 +56,6 @@ describe(__filename, function () {
assert.ok(typeof res.body.currentVersion === 'string');
assert.equal(res.body.latest, null);
assert.equal(res.body.tier, settings.updates.tier);
assert.ok(Array.isArray(res.body.vulnerableBelow));
});
it('redacts execution.reason / lastResult.reason for unauth callers', async function () {

View file

@ -26,7 +26,7 @@ test.describe('admin update page', () => {
installMethod: 'git',
tier: 'notify',
policy: null,
vulnerableBelow: [],
}),
});
});
@ -59,7 +59,7 @@ test.describe('admin update page', () => {
installMethod: 'git',
tier: 'notify',
policy: {canNotify: true, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'},
vulnerableBelow: [],
}),
});
});

View file

@ -15,7 +15,6 @@ const baseStatus = {
installMethod: 'git',
tier: 'manual',
policy: {canNotify: true, canManual: true, canAuto: false, canAutonomous: false, reason: 'ok'},
vulnerableBelow: [],
execution: {status: 'idle'},
lastResult: null,
lockHeld: false,

View file

@ -15,7 +15,6 @@ const scheduledStatus = (msFromNow: number) => ({
installMethod: 'git',
tier: 'auto',
policy: {canNotify: true, canManual: true, canAuto: true, canAutonomous: false, reason: 'ok'},
vulnerableBelow: [],
execution: {
status: 'scheduled',
targetTag: 'v2.7.2',

View file

@ -0,0 +1,124 @@
import {expect, test, Page} from '@playwright/test';
import {randomUUID} from 'node:crypto';
// Gritter items for outdated-notice are rendered into #gritter-container.bottom
// and tagged with class_name:'outdated-notice' so tests can target them
// independently of any other gritter surfaced during the pad session.
const NOTICE = '#gritter-container.bottom .gritter-item.outdated-notice';
const freshPad = async (page: Page) => {
// Suppress the pad-deletion-token modal (same technique as goToNewPad in
// padHelper.ts) so it can't race with postAceInit or steal DOM focus.
await page.addInitScript(() => {
let stored: unknown;
Object.defineProperty(window, 'clientVars', {
configurable: true,
get() { return stored; },
set(v) {
if (v != null && typeof v === 'object') {
(v as {padDeletionToken?: string | null}).padDeletionToken = null;
}
stored = v;
},
});
});
const padId = `FRONTEND_TESTS${randomUUID()}`;
await page.goto(`http://localhost:9001/p/${padId}`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');
// Wait for the inner editor to be content-editable so postAceInit has fully
// resolved and the async maybeShowOutdatedNotice fetch has been dispatched.
await page.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe[name="ace_inner"]')
.locator('#innerdocbody[contenteditable="true"]')
.waitFor({state: 'attached'});
return padId;
};
test.describe('outdated notice (gritter-based)', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('outdated:null — no outdated-notice gritter is shown', async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: null, isFirstAuthor: true}),
}));
await freshPad(page);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('outdated:minor, isFirstAuthor:false — no gritter shown (client guard)',
async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: false}),
}));
await freshPad(page);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('outdated:minor, isFirstAuthor:true — gritter appears with correct text',
async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}),
}));
await freshPad(page);
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await expect(item.locator('.gritter-title')).toHaveText('Etherpad update available');
await expect(item).toContainText(
'A newer version of Etherpad has been released');
});
test('user dismisses by clicking X — gritter disappears', async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}),
}));
await freshPad(page);
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await item.locator('.gritter-close').click();
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('server returns 500 — no gritter and no user-visible error', async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({status: 500, body: 'Internal Server Error'}));
await freshPad(page);
// Allow the async fetch to settle before asserting nothing appeared.
await page.waitForTimeout(500);
await expect(page.locator(NOTICE)).toHaveCount(0);
// No generic JS error dialog should have appeared.
await expect(page.locator('#errorpopup')).toHaveCount(0);
});
test('auto-fade after 8s — gritter gone after 9s', async ({page}) => {
// This test deliberately waits ~9 s for the gritter's time:8000 to elapse.
// Mark as slow so Playwright allocates a larger timeout budget.
test.slow();
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}),
}));
await freshPad(page);
// Confirm it appeared first.
await expect(page.locator(NOTICE)).toBeVisible();
// Wait for auto-fade (time:8000 ms in the gritter.add call).
await page.waitForTimeout(9000);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
});

View file

@ -1,47 +0,0 @@
import {expect, test} from '@playwright/test';
const padUrl = (id = `test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`) =>
`http://localhost:9001/p/${id}`;
test.describe('pad version badge', () => {
test('hidden when /api/version-status returns outdated:null', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: null}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
// The badge is rendered hidden (display:none) and stays hidden.
await expect(badge).toBeHidden({timeout: 30000});
});
test('shows severe text when outdated=severe', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'severe'}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
await expect(badge).toBeVisible({timeout: 30000});
await expect(badge).toContainText(/severely outdated/i);
await expect(badge).toHaveAttribute('data-level', 'severe');
});
test('shows vulnerable text when outdated=vulnerable', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'vulnerable'}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
await expect(badge).toBeVisible({timeout: 30000});
await expect(badge).toContainText(/security issues/i);
await expect(badge).toHaveAttribute('data-level', 'vulnerable');
});
});