mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
Merge branch 'develop'
This commit is contained in:
commit
92e23bf7d4
28 changed files with 1951 additions and 120 deletions
26
.github/workflows/docker.yml
vendored
26
.github/workflows/docker.yml
vendored
|
|
@ -151,6 +151,32 @@ jobs:
|
|||
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
|
||||
[ "$ok" = "1" ] || exit 1
|
||||
|
||||
-
|
||||
# Regression test: Etherpad container must boot without external
|
||||
# network access (e.g., air-gapped host or restrictive runtime policy).
|
||||
name: Regression — boot with disabled network (--network none)
|
||||
working-directory: etherpad
|
||||
run: |
|
||||
docker run --rm -d \
|
||||
--network none \
|
||||
--name test-offline ${{ env.TEST_TAG }}
|
||||
docker logs -f test-offline &
|
||||
ok=0
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
|
||||
echo "container exited prematurely while offline"
|
||||
docker logs test-offline || true
|
||||
break
|
||||
}
|
||||
case ${status} in
|
||||
healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
|
||||
starting) sleep 2;;
|
||||
*) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
|
||||
esac
|
||||
done
|
||||
docker rm -f test-offline >/dev/null 2>&1 || true
|
||||
[ "$ok" = "1" ] || exit 1
|
||||
|
||||
build-test-local-plugin:
|
||||
# Regression coverage for #7687: the Docker image's
|
||||
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
|
||||
|
|
|
|||
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
name: Downstream smoke
|
||||
|
||||
# Boots a real Etherpad from the PR and verifies the separate downstream clients
|
||||
# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it.
|
||||
# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the
|
||||
# per-client matrix activates as each client flips `enabled:true` in clients.json.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "doc/**"
|
||||
- "docs/**"
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # nightly against the default branch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Boot + downstream clients
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
PNPM_HOME: ~/.pnpm-store
|
||||
APIKEY: downstream-smoke-key
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Boot Etherpad on :9003 (apikey auth)
|
||||
run: |
|
||||
# The template ships a literal "port": 9001 and sso auth; rewrite both.
|
||||
# (PORT env is ignored once the settings file specifies a port.)
|
||||
sed -e 's#"port": 9001,#"port": 9003,#' \
|
||||
-e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \
|
||||
settings.json.template > settings.json
|
||||
# Fail fast if the template format drifted and sed silently no-op'd.
|
||||
grep -q '"port": 9003,' settings.json \
|
||||
|| { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; }
|
||||
grep -q '"authenticationMethod": "apikey"' settings.json \
|
||||
|| { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; }
|
||||
printf '%s' "$APIKEY" > APIKEY.txt
|
||||
pnpm run prod > /tmp/ep.log 2>&1 &
|
||||
echo $! > /tmp/ep.pid
|
||||
echo "booted pid $(cat /tmp/ep.pid)"
|
||||
|
||||
- name: Wait for healthcheck
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then
|
||||
echo "server up after ${i} tries"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1
|
||||
|
||||
- name: Self-check — authenticated create + read roundtrip
|
||||
run: |
|
||||
K="$APIKEY"
|
||||
curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json
|
||||
grep -q '"code":0' /tmp/create.json
|
||||
curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json
|
||||
grep -q '"text":"hi' /tmp/get.json
|
||||
|
||||
- name: Generate canonical wire-vectors
|
||||
run: cd src && pnpm run vectors:gen
|
||||
|
||||
# Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds
|
||||
# use the node+pnpm already set up above.
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Run enabled downstream clients
|
||||
env:
|
||||
SMOKE_URL: http://localhost:9003
|
||||
SMOKE_APIKEY: ${{ env.APIKEY }}
|
||||
run: bash src/tests/downstream/run-clients.sh
|
||||
|
||||
- name: Teardown (by PID, never pkill)
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ep.pid ]; then
|
||||
kill "$(cat /tmp/ep.pid)" 2>/dev/null || true
|
||||
echo "killed $(cat /tmp/ep.pid)"
|
||||
fi
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,3 +1,28 @@
|
|||
# 3.3.1
|
||||
|
||||
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
|
||||
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
|
||||
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
|
||||
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
|
||||
|
||||
### Internal / contributor-facing
|
||||
|
||||
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
|
||||
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
|
||||
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
|
||||
|
||||
# 3.3.0
|
||||
|
||||
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:api && vite",
|
||||
|
|
@ -27,8 +27,8 @@
|
|||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"eslint": "^10.4.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bin",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"description": "",
|
||||
"main": "checkAllPads.js",
|
||||
"directories": {
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
"dependencies": {
|
||||
"ep_etherpad-lite": "workspace:../src",
|
||||
"log4js": "^6.9.1",
|
||||
"semver": "^7.8.2",
|
||||
"semver": "^7.8.3",
|
||||
"tsx": "^4.22.4",
|
||||
"ueberdb2": "^6.1.8"
|
||||
"ueberdb2": "^6.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,633 @@
|
|||
# Downstream Client Compatibility Tests — Phase 1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
|
||||
|
||||
**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
|
||||
|
||||
**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md`
|
||||
|
||||
**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
|
||||
- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
|
||||
- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
|
||||
- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes.
|
||||
- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes.
|
||||
- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands).
|
||||
- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold.
|
||||
- Modify `src/package.json` — add `vectors:gen` script.
|
||||
|
||||
All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Golden-vector generator module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/generate-vectors.ts`
|
||||
- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run)
|
||||
|
||||
- [ ] **Step 1: Write the generator module**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/generate-vectors.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for the downstream wire-compatibility fixtures.
|
||||
*
|
||||
* Each vector is a self-contained changeset application: given `initialAText`
|
||||
* and `pool`, applying `changeset` yields `resultAText`. Downstream clients
|
||||
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
|
||||
* exact same JSON and must reproduce `resultAText`. See the Phase 1 plan.
|
||||
*
|
||||
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
|
||||
* pnpm run vectors:gen
|
||||
*/
|
||||
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
|
||||
export type WireVector = {
|
||||
name: string;
|
||||
initialText: string;
|
||||
changeset: string;
|
||||
pool: ReturnType<AttributePool['toJsonable']>;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
const vector = (
|
||||
name: string,
|
||||
initialText: string,
|
||||
build: (pool: AttributePool) => string,
|
||||
): WireVector => {
|
||||
const pool = new AttributePool();
|
||||
const changeset = build(pool);
|
||||
Changeset.checkRep(changeset);
|
||||
return {
|
||||
name,
|
||||
initialText,
|
||||
changeset,
|
||||
pool: pool.toJsonable(),
|
||||
resultText: Changeset.applyToText(changeset, initialText),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateVectors = (): WireVector[] => [
|
||||
vector('plain-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
|
||||
|
||||
vector('plain-delete', 'abcdef\n', () =>
|
||||
Changeset.makeSplice('abcdef\n', 1, 3, '')),
|
||||
|
||||
vector('formatted-insert', 'abc\n', (pool) =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
|
||||
|
||||
vector('multiline-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
|
||||
|
||||
vector('attrib-reuse', 'abc\n', (pool) => {
|
||||
// Two formatted inserts sharing one pool entry exercises pool index reuse.
|
||||
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
|
||||
const mid = Changeset.applyToText(cs1, 'abc\n');
|
||||
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
|
||||
return Changeset.compose(cs1, cs2, pool);
|
||||
}),
|
||||
];
|
||||
|
||||
// CLI entry: write the canonical fixture to disk.
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`wrote ${out}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `vectors:gen` script**
|
||||
|
||||
In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry):
|
||||
|
||||
```json
|
||||
"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen
|
||||
```
|
||||
Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json
|
||||
git commit -m "test(downstream): add golden wire-vector generator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Commit the generated fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/fixtures/wire-vectors.json`
|
||||
|
||||
- [ ] **Step 1: Generate the fixture**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen
|
||||
```
|
||||
Expected: `src/tests/fixtures/wire-vectors.json` written.
|
||||
|
||||
- [ ] **Step 2: Eyeball the fixture**
|
||||
|
||||
Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/fixtures/wire-vectors.json
|
||||
git commit -m "test(downstream): add committed golden wire-vectors fixture"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fixture stability + self-consistency spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-vectors.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-vectors.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Guards the downstream wire-format contract:
|
||||
* - the committed fixture exactly matches a fresh regeneration (any drift is a
|
||||
* deliberate wire change and must be re-generated + reviewed in the same PR), and
|
||||
* - every vector is internally consistent under core's own Changeset engine.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
import {generateVectors} from './generate-vectors';
|
||||
|
||||
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
|
||||
describe(__filename, function () {
|
||||
it('committed fixture matches a fresh regeneration', function () {
|
||||
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const fresh = generateVectors();
|
||||
assert.deepEqual(committed, fresh,
|
||||
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
|
||||
});
|
||||
|
||||
it('every vector applies to its result under core Changeset', function () {
|
||||
for (const v of generateVectors()) {
|
||||
Changeset.checkRep(v.changeset);
|
||||
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
|
||||
`vector ${v.name} result mismatch`);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes (fixture already committed)**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts
|
||||
```
|
||||
Expected: 2 passing.
|
||||
|
||||
- [ ] **Step 3: Prove the guard bites (temporary edit)**
|
||||
|
||||
Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above.
|
||||
Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-vectors.ts
|
||||
git commit -m "test(downstream): assert wire-vectors fixture stability + consistency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Socket message-sequence spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts`
|
||||
|
||||
Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pins the socket.io message sequence + shapes that every realtime client
|
||||
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
|
||||
* A change here is a wire-protocol change that will break downstream clients.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let socket: any;
|
||||
let padId: string;
|
||||
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
beforeEach(async function () {
|
||||
padId = common.randomString();
|
||||
const pad = await padManager.getPad(padId, 'init\n');
|
||||
await pad.setText('init\n');
|
||||
const res = await agent.get(`/p/${padId}`).expect(200);
|
||||
socket = await common.connect(res);
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (socket != null) socket.close();
|
||||
socket = null;
|
||||
});
|
||||
|
||||
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
|
||||
const {type, data} = await common.handshake(socket, padId);
|
||||
assert.equal(type, 'CLIENT_VARS');
|
||||
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
|
||||
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
|
||||
assert.equal(typeof data.collab_client_vars.rev, 'number');
|
||||
assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing');
|
||||
});
|
||||
|
||||
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
|
||||
const {data: clientVars} = await common.handshake(socket, padId);
|
||||
const rev = clientVars.collab_client_vars.rev;
|
||||
const pool = new AttributePool();
|
||||
const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool);
|
||||
|
||||
const accepted = common.waitForSocketEvent(socket, 'message');
|
||||
socket.emit('message', {
|
||||
type: 'COLLABROOM',
|
||||
component: 'pad',
|
||||
data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()},
|
||||
});
|
||||
const msg: any = await accepted;
|
||||
assert.equal(msg.type, 'COLLABROOM');
|
||||
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
|
||||
assert.equal(msg.data.newRev, rev + 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||
```
|
||||
Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||
git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: HTTP API shape spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-http-api.ts`
|
||||
|
||||
Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-http-api.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
|
||||
* endpoints downstream clients call to create pads and round-trip text.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let apiVersion = 1;
|
||||
const apiKey = common.apiKey;
|
||||
const padId = common.randomString();
|
||||
const ep = (point: string, qs: string) =>
|
||||
`/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`;
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
const res = await agent.get('/api/').expect(200);
|
||||
apiVersion = res.body.currentVersion;
|
||||
});
|
||||
|
||||
it('createPad returns the standard {code,message,data} envelope', async function () {
|
||||
const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200);
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
|
||||
assert.equal(res.body.code, 0);
|
||||
});
|
||||
|
||||
it('setText + getText round-trips text through the documented shape', async function () {
|
||||
await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200);
|
||||
const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.text, 'string');
|
||||
assert.equal(res.body.data.text, 'world\n');
|
||||
});
|
||||
|
||||
it('getRevisionsCount exposes a numeric revisions field', async function () {
|
||||
const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.revisions, 'number');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the apiKey helper name**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
|
||||
```
|
||||
Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it passes**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts
|
||||
```
|
||||
Expected: 3 passing.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-http-api.ts
|
||||
git commit -m "test(downstream): snapshot client-facing HTTP API shapes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Client manifest
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/downstream/clients.json`
|
||||
|
||||
- [ ] **Step 1: Write the manifest**
|
||||
|
||||
Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "etherpad-pad",
|
||||
"repo": "https://github.com/ether/pad.git",
|
||||
"ref": "31176d64ce746d45349e58ee6c0bb043052c6e66",
|
||||
"kind": "rust",
|
||||
"enabled": false,
|
||||
"vectorTest": "cargo test --test vectors",
|
||||
"smokeCmd": "cargo test --test smoke -- --ignored"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-cli-client",
|
||||
"repo": "https://github.com/ether/etherpad-cli-client.git",
|
||||
"ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff",
|
||||
"kind": "node",
|
||||
"enabled": false,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-desktop",
|
||||
"repo": "https://github.com/ether/etherpad-desktop.git",
|
||||
"ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f",
|
||||
"kind": "desktop",
|
||||
"enabled": false,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate it is well-formed JSON**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))"
|
||||
```
|
||||
Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/downstream/clients.json
|
||||
git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Downstream-smoke workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/downstream-smoke.yml`
|
||||
|
||||
Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
|
||||
|
||||
- [ ] **Step 1: Write the workflow**
|
||||
|
||||
Create `.github/workflows/downstream-smoke.yml`:
|
||||
|
||||
```yaml
|
||||
name: Downstream smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # nightly against develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Boot Etherpad on :9003
|
||||
env:
|
||||
APIKEY: downstream-smoke-key
|
||||
run: |
|
||||
mkdir -p var
|
||||
echo -n "$APIKEY" > APIKEY.txt
|
||||
PORT=9003 pnpm run prod &
|
||||
echo $! > /tmp/ep.pid
|
||||
|
||||
- name: Wait for healthcheck
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:9003/api/ >/dev/null; then
|
||||
echo "up"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "server did not come up"; exit 1
|
||||
|
||||
- name: Self-check (boot + API roundtrip proves the harness)
|
||||
run: |
|
||||
K=downstream-smoke-key
|
||||
curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A"
|
||||
curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi'
|
||||
|
||||
- name: Generate canonical wire-vectors
|
||||
run: cd src && pnpm run vectors:gen
|
||||
|
||||
- name: Run enabled downstream clients
|
||||
run: |
|
||||
node -e '
|
||||
const fs=require("fs");
|
||||
const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled);
|
||||
if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);}
|
||||
fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients));
|
||||
'
|
||||
# Phase 2 wires per-kind clone + toolchain + vector injection + smoke here,
|
||||
# iterating /tmp/clients.json. Until a client is enabled this is a no-op.
|
||||
|
||||
- name: Teardown (by PID, never pkill)
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the boot command + port env**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
|
||||
grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
|
||||
```
|
||||
Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
|
||||
|
||||
- [ ] **Step 3: Lint the workflow YAML**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')"
|
||||
```
|
||||
Expected: `valid yaml`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/downstream-smoke.yml
|
||||
git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full backend-suite run + push
|
||||
|
||||
**Files:** none (verification + integration)
|
||||
|
||||
- [ ] **Step 1: Run the whole downstream spec group**
|
||||
|
||||
Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`:
|
||||
```bash
|
||||
cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream
|
||||
```
|
||||
Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files).
|
||||
|
||||
- [ ] **Step 2: Confirm no regression in the fixture guard**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json
|
||||
```
|
||||
Expected: exit 0 (regeneration is byte-identical to the committed fixture).
|
||||
|
||||
- [ ] **Step 3: Push the branch and open the PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/downstream-client-compat-tests
|
||||
gh pr create --base develop \
|
||||
--title "test: downstream client compatibility gate (Phase 1)" \
|
||||
--body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Watch CI**
|
||||
|
||||
Per the "check CI after PRs" rule, wait ~20s then:
|
||||
```bash
|
||||
gh pr checks --watch
|
||||
```
|
||||
Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Layer A golden vectors → Tasks 1–3. ✅
|
||||
- Layer A socket sequence → Task 4. ✅
|
||||
- Layer A HTTP API shapes → Task 5. ✅
|
||||
- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅
|
||||
- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅
|
||||
- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
|
||||
- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
|
||||
|
||||
**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
|
||||
|
||||
**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# Downstream Client Compatibility Tests — Design
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (brainstorming complete)
|
||||
**Target repo:** `ether/etherpad` (core), branch `develop`
|
||||
**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile)
|
||||
|
||||
## Problem
|
||||
|
||||
The three downstream clients live in **separate repos** and consume core's wire
|
||||
protocols rather than importing core as a library:
|
||||
|
||||
- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts`
|
||||
and talks the socket.io `message` protocol. No test script today.
|
||||
- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset
|
||||
decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature.
|
||||
- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI.
|
||||
|
||||
A core PR can change the **HTTP API**, the **socket.io handshake / `message`
|
||||
sequence**, or the **changeset / attribpool wire format** and break these clients
|
||||
**silently**, because their CI never runs against the new core. The goal: a PR
|
||||
against core `develop` (and friends) must detect downstream breakage before merge.
|
||||
|
||||
## Decisions (locked during brainstorming)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) |
|
||||
| Smoke cadence | **Every PR** (with strong flakiness mitigations) |
|
||||
| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core |
|
||||
| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder |
|
||||
| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time |
|
||||
|
||||
## Architecture
|
||||
|
||||
Two-layer compatibility gate on every core PR:
|
||||
|
||||
```
|
||||
core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients)
|
||||
│ • golden-vector assertions (changeset/attribpool roundtrip)
|
||||
│ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS,
|
||||
│ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES)
|
||||
│ • HTTP API shape snapshots
|
||||
│
|
||||
└─► Layer B: Downstream smoke (boots a real server, runs real clients)
|
||||
• build + boot Etherpad from the PR on :9003 with a known API key
|
||||
• healthcheck-poll until ready
|
||||
• matrix over manifest: clone client @ pinned ref, set up toolchain,
|
||||
inject core's freshly-generated vectors, run client `test:vectors`,
|
||||
run client smoke: connect → create/open pad → write text →
|
||||
read back via HTTP API → assert equality
|
||||
• tear down server by PID
|
||||
```
|
||||
|
||||
## Layer A — Contract tests (Phase 1, core)
|
||||
|
||||
### Golden vectors
|
||||
- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package
|
||||
script, e.g. `pnpm run vectors:gen`).
|
||||
- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record:
|
||||
`{ name, initialAText, changeset, pool, resultAText }` covering the operation
|
||||
classes clients must decode: plain insert, delete, format/attrib op,
|
||||
multi-line insert (char_bank ending in `\n`), attribpool reuse across ops.
|
||||
- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and
|
||||
assert it matches the committed fixture exactly (drift requires a deliberate
|
||||
commit, which is the signal a wire change happened).
|
||||
|
||||
### Socket message-sequence test
|
||||
- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client
|
||||
against the in-process server, assert the handshake message sequence and the
|
||||
shape of `CLIENT_VARS`, `USER_CHANGES` → `ACCEPT_COMMIT`, and broadcast
|
||||
`NEW_CHANGES`. Reuses the existing backend socket test helpers.
|
||||
|
||||
### HTTP API shape snapshots
|
||||
- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes*
|
||||
(keys / types, not volatile values) of the API endpoints clients call
|
||||
(`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed).
|
||||
|
||||
These join the existing `backend-tests.yml` run — no new per-PR job, no new infra.
|
||||
|
||||
## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client)
|
||||
|
||||
### Manifest
|
||||
`src/tests/downstream/clients.json`:
|
||||
```json
|
||||
[
|
||||
{ "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "kind": "desktop", "smokeCmd": "..." }
|
||||
]
|
||||
```
|
||||
Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes
|
||||
cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at
|
||||
authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`.
|
||||
Pinned refs mean a client's *own* breakage never randomly reddens core; picking up
|
||||
a client fix is a deliberate ref-bump PR. Clients are added to the manifest as
|
||||
their Phase-2 smoke lands — the workflow only runs what's registered.
|
||||
|
||||
### Workflow
|
||||
`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly
|
||||
`schedule` against `develop`):
|
||||
1. Build core from the PR, install deps.
|
||||
2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID.
|
||||
3. Healthcheck-poll the server (bounded timeout) before proceeding.
|
||||
4. Matrix over manifest entries: clone @ pinned ref → set up toolchain
|
||||
(node+pnpm / rust / electron+xvfb) → copy core's freshly-generated
|
||||
`wire-vectors.json` into the client → run `test:vectors` → run smoke.
|
||||
5. Tear down: kill the recorded **PID** (never `pkill -f`).
|
||||
|
||||
### Per-client smoke (Phase 2)
|
||||
Minimal roundtrip exercising the real protocol end-to-end:
|
||||
- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using
|
||||
the real tungstenite socket — connect, open pad, send a changeset, read back
|
||||
via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the
|
||||
injected fixture.
|
||||
- **etherpad-cli-client** (`node`): add a minimal test runner (none today —
|
||||
`node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke
|
||||
using the client lib: connect → write → verify via HTTP `getText`.
|
||||
- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points
|
||||
the shell/webview at the booted URL and roundtrips. The full Electron e2e stays
|
||||
in desktop's own CI — it is **not** in the core gate. If Electron is
|
||||
unavoidable here, run under `xvfb-run`.
|
||||
|
||||
## Flakiness mitigations (because smoke runs on every PR)
|
||||
|
||||
- Healthcheck-poll-with-timeout before any client runs.
|
||||
- Bounded timeout + 1 retry per client smoke.
|
||||
- Desktop kept headless-light; heavy Electron e2e excluded from the gate.
|
||||
- PID-based teardown, never `pkill -f` (would kill the developer's other servers).
|
||||
- Pinned manifest refs isolate core CI from clients' own breakage.
|
||||
- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use).
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Phase 1 (core only, lands first, immediately useful):** vector generator +
|
||||
`wire-vectors.json` + three contract specs + `downstream-smoke.yml` +
|
||||
`clients.json` manifest. Harness proven with **one** reference client wired in
|
||||
(the Rust `etherpad-pad`, which already has test infra).
|
||||
- **Phase 2 (one client repo at a time):** order `etherpad-pad` →
|
||||
`etherpad-cli-client` → `etherpad-desktop`. Each PR adds that client's
|
||||
`test:vectors` + smoke and registers it in the core manifest.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing clients' existing full e2e suites (they stay in their own repos).
|
||||
- ep_kaput (excluded from all sweeps per standing instruction).
|
||||
- Changing the wire protocol itself — this work only *observes* it.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A core PR that alters changeset serialization, the socket message sequence, or a
|
||||
client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge.
|
||||
- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix.
|
||||
- Bumping a client's pinned ref is the only way a client's own changes affect core CI.
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
"url": "https://github.com/ether/etherpad.git"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"license": "Apache-2.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
178
pnpm-lock.yaml
generated
178
pnpm-lock.yaml
generated
|
|
@ -78,11 +78,11 @@ importers:
|
|||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.60.1
|
||||
version: 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
|
||||
specifier: ^8.61.0
|
||||
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.60.1
|
||||
version: 8.60.1(eslint@10.4.1)(typescript@6.0.3)
|
||||
specifier: ^8.61.0
|
||||
version: 8.61.0(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(babel-plugin-react-compiler@19.1.0-rc.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(tsx@4.22.4))
|
||||
|
|
@ -153,14 +153,14 @@ importers:
|
|||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
semver:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
specifier: ^7.8.3
|
||||
version: 7.8.3
|
||||
tsx:
|
||||
specifier: ^4.22.4
|
||||
version: 4.22.4
|
||||
ueberdb2:
|
||||
specifier: ^6.1.8
|
||||
version: 6.1.8(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
|
||||
specifier: ^6.1.9
|
||||
version: 6.1.9(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.9.2
|
||||
|
|
@ -314,8 +314,8 @@ importers:
|
|||
specifier: ^2.0.7
|
||||
version: 2.0.7
|
||||
rate-limiter-flexible:
|
||||
specifier: ^11.1.1
|
||||
version: 11.1.1
|
||||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
redis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(@opentelemetry/api@1.9.1)
|
||||
|
|
@ -338,8 +338,8 @@ importers:
|
|||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
semver:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
specifier: ^7.8.3
|
||||
version: 7.8.3
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
|
|
@ -359,8 +359,8 @@ importers:
|
|||
specifier: 4.22.4
|
||||
version: 4.22.4
|
||||
ueberdb2:
|
||||
specifier: ^6.1.8
|
||||
version: 6.1.8(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
|
||||
specifier: ^6.1.9
|
||||
version: 6.1.9(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
|
||||
underscore:
|
||||
specifier: 1.13.8
|
||||
version: 1.13.8
|
||||
|
|
@ -1941,11 +1941,11 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.60.1':
|
||||
resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==}
|
||||
'@typescript-eslint/eslint-plugin@8.61.0':
|
||||
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.60.1
|
||||
'@typescript-eslint/parser': ^8.61.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
|
|
@ -1959,15 +1959,15 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/parser@8.60.1':
|
||||
resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==}
|
||||
'@typescript-eslint/parser@8.61.0':
|
||||
resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.60.1':
|
||||
resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==}
|
||||
'@typescript-eslint/project-service@8.61.0':
|
||||
resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
|
@ -1976,12 +1976,12 @@ packages:
|
|||
resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.60.1':
|
||||
resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==}
|
||||
'@typescript-eslint/scope-manager@8.61.0':
|
||||
resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.60.1':
|
||||
resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==}
|
||||
'@typescript-eslint/tsconfig-utils@8.61.0':
|
||||
resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
|
@ -1996,8 +1996,8 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/type-utils@8.60.1':
|
||||
resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==}
|
||||
'@typescript-eslint/type-utils@8.61.0':
|
||||
resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
|
@ -2007,8 +2007,8 @@ packages:
|
|||
resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
|
||||
'@typescript-eslint/types@8.60.1':
|
||||
resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==}
|
||||
'@typescript-eslint/types@8.61.0':
|
||||
resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@7.18.0':
|
||||
|
|
@ -2020,8 +2020,8 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.60.1':
|
||||
resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==}
|
||||
'@typescript-eslint/typescript-estree@8.61.0':
|
||||
resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
|
@ -2032,8 +2032,8 @@ packages:
|
|||
peerDependencies:
|
||||
eslint: ^8.56.0
|
||||
|
||||
'@typescript-eslint/utils@8.60.1':
|
||||
resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==}
|
||||
'@typescript-eslint/utils@8.61.0':
|
||||
resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
|
@ -2043,8 +2043,8 @@ packages:
|
|||
resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.60.1':
|
||||
resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==}
|
||||
'@typescript-eslint/visitor-keys@8.61.0':
|
||||
resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.5':
|
||||
|
|
@ -4661,8 +4661,8 @@ packages:
|
|||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
rate-limiter-flexible@11.1.1:
|
||||
resolution: {integrity: sha512-ZT/GnTlqjX+vEnI39tubPX8bQY7xIvp0e0q+DgxiA2RTfwj7t/jy6GTgdkJNb22qPCqjXJLoPLxff7RolmGc8Q==}
|
||||
rate-limiter-flexible@11.2.0:
|
||||
resolution: {integrity: sha512-L0eIK+BmFMi6NcvmtEg7RSswOFKi9MMD5RhBIypFfOve+G6Jl1Xbb8qEHgK3uRzNtkOFYF0L9f7P4rSf2PnUVw==}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
|
|
@ -4962,13 +4962,13 @@ packages:
|
|||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
semver@7.8.2:
|
||||
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.2:
|
||||
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
||||
semver@7.8.3:
|
||||
resolution: {integrity: sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -5367,8 +5367,8 @@ packages:
|
|||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ueberdb2@6.1.8:
|
||||
resolution: {integrity: sha512-X8XnjzjSyI3J5cAwf0tSrSi1qs5ciB0qPJSG/G7txuh3H0R/dm+GU8hno2ec+0m/WpHrlWV1bl6DzUoxNU7hBg==}
|
||||
ueberdb2@6.1.9:
|
||||
resolution: {integrity: sha512-AOifNHJT0A9c52eEQqcm77d3/RUZVIvGFN1KFbXEPpfI89fa5c9lS8z7piu6m+CwBgWD+Fq5wAmP7W3LVZ4MwA==}
|
||||
engines: {node: '>=24.0.0'}
|
||||
peerDependencies:
|
||||
'@elastic/elasticsearch': ^9.0.0
|
||||
|
|
@ -7207,14 +7207,14 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/scope-manager': 8.60.1
|
||||
'@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
eslint: 10.4.1
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
|
|
@ -7236,22 +7236,22 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
|
||||
'@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.60.1
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 10.4.1
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.60.1(typescript@6.0.3)':
|
||||
'@typescript-eslint/project-service@8.61.0(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -7262,12 +7262,12 @@ snapshots:
|
|||
'@typescript-eslint/types': 7.18.0
|
||||
'@typescript-eslint/visitor-keys': 7.18.0
|
||||
|
||||
'@typescript-eslint/scope-manager@8.60.1':
|
||||
'@typescript-eslint/scope-manager@8.61.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3)':
|
||||
'@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
|
|
@ -7283,11 +7283,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
|
||||
'@typescript-eslint/type-utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 10.4.1
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
|
|
@ -7297,7 +7297,7 @@ snapshots:
|
|||
|
||||
'@typescript-eslint/types@7.18.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.60.1': {}
|
||||
'@typescript-eslint/types@8.61.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@7.18.0(typescript@6.0.3)':
|
||||
dependencies:
|
||||
|
|
@ -7307,22 +7307,22 @@ snapshots:
|
|||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
ts-api-utils: 1.4.3(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3)':
|
||||
'@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.60.1(typescript@6.0.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
'@typescript-eslint/project-service': 8.61.0(typescript@6.0.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
|
|
@ -7340,12 +7340,12 @@ snapshots:
|
|||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@typescript-eslint/utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
|
||||
'@typescript-eslint/utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1)
|
||||
'@typescript-eslint/scope-manager': 8.60.1
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
|
||||
eslint: 10.4.1
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -7356,9 +7356,9 @@ snapshots:
|
|||
'@typescript-eslint/types': 7.18.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.60.1':
|
||||
'@typescript-eslint/visitor-keys@8.61.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.5':
|
||||
|
|
@ -8339,7 +8339,7 @@ snapshots:
|
|||
eslint-compat-utils@0.5.1(eslint@10.4.1):
|
||||
dependencies:
|
||||
eslint: 10.4.1
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
|
||||
eslint-config-etherpad@4.0.5(eslint@10.4.1)(typescript@6.0.3):
|
||||
dependencies:
|
||||
|
|
@ -8460,7 +8460,7 @@ snapshots:
|
|||
globals: 15.15.0
|
||||
globrex: 0.1.2
|
||||
ignore: 5.3.2
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
ts-declaration-location: 1.0.7(typescript@6.0.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
|
@ -9165,7 +9165,7 @@ snapshots:
|
|||
|
||||
is-bun-module@1.3.0:
|
||||
dependencies:
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
|
||||
is-callable@1.2.7: {}
|
||||
|
||||
|
|
@ -9377,7 +9377,7 @@ snapshots:
|
|||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
|
|
@ -9513,7 +9513,7 @@ snapshots:
|
|||
lockfile: 1.0.4
|
||||
node-fetch-commonjs: 3.3.2
|
||||
proxy-agent: 6.5.0
|
||||
semver: 7.8.1
|
||||
semver: 7.8.2
|
||||
tar: 7.5.13
|
||||
url-join: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -10171,7 +10171,7 @@ snapshots:
|
|||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
|
|
@ -10209,7 +10209,7 @@ snapshots:
|
|||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
rate-limiter-flexible@11.1.1: {}
|
||||
rate-limiter-flexible@11.2.0: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
|
|
@ -10543,10 +10543,10 @@ snapshots:
|
|||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
semver@7.8.2: {}
|
||||
|
||||
semver@7.8.3: {}
|
||||
|
||||
send@1.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
|
|
@ -11027,7 +11027,7 @@ snapshots:
|
|||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
ueberdb2@6.1.8(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3)):
|
||||
ueberdb2@6.1.9(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3)):
|
||||
optionalDependencies:
|
||||
'@elastic/elasticsearch': 9.4.2
|
||||
async: 3.2.6
|
||||
|
|
|
|||
|
|
@ -1287,9 +1287,15 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
|
|||
// once. Readonly sessions never see it.
|
||||
const isCreator =
|
||||
!sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0);
|
||||
// Skip token issuance when requireAuthentication is on: every creator has a
|
||||
// stable identity so the cookie/identity path is sufficient.
|
||||
const padDeletionToken = isCreator && !settings.requireAuthentication
|
||||
// Skip token issuance — and so the client never shows the "Save your pad
|
||||
// deletion token" modal (issue #7926) — when the token cannot help:
|
||||
// - requireAuthentication: every creator already has a stable identity, so
|
||||
// the cookie/identity path is sufficient.
|
||||
// - allowPadDeletionByAllUsers: anyone can delete the pad with no token at
|
||||
// all (see handlePadDelete's flagOk branch), so a recovery token is noise
|
||||
// and the modal only overwhelms users who will never need it.
|
||||
const padDeletionToken =
|
||||
isCreator && !settings.requireAuthentication && !settings.allowPadDeletionByAllUsers
|
||||
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/
|
|||
import {getLatestVersion} from '../../utils/UpdateCheck';
|
||||
import {redactSettings} from '../../utils/AdminSettingsRedact';
|
||||
const padManager = require('../../db/PadManager');
|
||||
const db = require('../../db/DB');
|
||||
const api = require('../../db/API');
|
||||
import {deleteRevisions} from '../../utils/Cleanup';
|
||||
|
||||
|
|
@ -26,6 +27,24 @@ const queryPadLimit = 12;
|
|||
const PAD_HYDRATE_CONCURRENCY = 16;
|
||||
const logger = log4js.getLogger('adminSettings');
|
||||
|
||||
// Errors thrown while reading a pad record can embed the raw stored value
|
||||
// in their message — e.g. Pad.init's `'pool' in value` TypeError stringifies
|
||||
// the offending value ("Cannot use 'in' operator to search for 'pool' in
|
||||
// <value>"). For a corrupt record that value may be actual pad text, so
|
||||
// logging it verbatim would leak content, bloat the log, and let embedded
|
||||
// newlines forge log lines. Reduce any error to its name plus a single-line,
|
||||
// length-capped message before logging.
|
||||
const safeErr = (err: unknown): string => {
|
||||
const e = err as {name?: unknown, message?: unknown} | null;
|
||||
const name = (e && typeof e.name === 'string' && e.name) || 'Error';
|
||||
const msg = String((e && e.message) ?? err ?? '')
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return `${name}: ${msg}`;
|
||||
};
|
||||
|
||||
// Concurrency-limited Promise.all replacement. Preserves the input
|
||||
// order in the returned array (caller slices later). Used by padLoad
|
||||
// to bound DB reads during hydration.
|
||||
|
|
@ -147,6 +166,7 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
|
||||
|
||||
socket.on('padLoad', async (query: PadSearchQuery) => {
|
||||
try {
|
||||
const {padIDs} = await padManager.listAllPads();
|
||||
|
||||
// ── 1. Pattern filter (cheap, by name only) ─────────────────────
|
||||
|
|
@ -172,13 +192,27 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
const needsFullScan = filter !== 'all' || query.sortBy !== 'padName';
|
||||
|
||||
const loadMeta = async (padName: string): Promise<PadQueryResult> => {
|
||||
const pad = await padManager.getPad(padName);
|
||||
return {
|
||||
padName,
|
||||
lastEdited: await pad.getLastEdit(),
|
||||
userCount: api.padUsersCount(padName).padUsersCount,
|
||||
revisionNumber: pad.getHeadRevisionNumber(),
|
||||
};
|
||||
// A single unreadable record must not take out the whole listing.
|
||||
// `findKeys('pad:*', '*:*:*')` returns every key under the `pad:`
|
||||
// prefix, including legacy/foreign or migration-corrupted records
|
||||
// (e.g. a value stored as a JSON *string* rather than a pad object,
|
||||
// which makes Pad.init throw `'pool' in value`). Before this guard
|
||||
// one such key rejected the whole `padLoad` handler — the admin
|
||||
// "Manage pads" page then showed *no* pads at all (issue #7935) and
|
||||
// the unhandled rejection could exit the server. Surfacing the bad
|
||||
// pad with zeroed metadata lets an admin see and delete it instead.
|
||||
try {
|
||||
const pad = await padManager.getPad(padName);
|
||||
return {
|
||||
padName,
|
||||
lastEdited: await pad.getLastEdit(),
|
||||
userCount: api.padUsersCount(padName).padUsersCount,
|
||||
revisionNumber: pad.getHeadRevisionNumber(),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(`padLoad: skipping unreadable pad "${padName}": ${safeErr(err)}`);
|
||||
return {padName, lastEdited: 0 as any, userCount: 0, revisionNumber: 0};
|
||||
}
|
||||
};
|
||||
|
||||
// Lazily lifted so we don't load every pad twice on the fast path.
|
||||
|
|
@ -256,16 +290,51 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
|
||||
const data: {total: number, results?: PadQueryResult[]} = {total, results};
|
||||
socket.emit('results:padLoad', data);
|
||||
} catch (err) {
|
||||
// Never leave the SPA hanging on a missing reply (it would show an
|
||||
// empty "No results" state forever) and never let this bubble up to
|
||||
// the process-level unhandledRejection handler, which would exit the
|
||||
// whole server. Always emit a terminal reply for the request.
|
||||
logger.error(`padLoad failed: ${safeErr(err)}`);
|
||||
socket.emit('results:padLoad',
|
||||
{total: 0, results: [], error: safeErr(err)});
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
socket.on('deletePad', async (padId: string) => {
|
||||
const padExists = await padManager.doesPadExists(padId);
|
||||
if (padExists) {
|
||||
logger.info(`Deleting pad: ${padId}`);
|
||||
const pad = await padManager.getPad(padId);
|
||||
await pad.remove();
|
||||
try {
|
||||
if (await padManager.doesPadExists(padId)) {
|
||||
// Healthy pad — full relational cleanup (revs, chat, readonly,
|
||||
// authors, deletion token, hooks).
|
||||
logger.info(`Deleting pad: ${padId}`);
|
||||
const pad = await padManager.getPad(padId);
|
||||
await pad.remove();
|
||||
socket.emit('results:deletePad', padId);
|
||||
return;
|
||||
}
|
||||
|
||||
// doesPadExists() is false either because nothing is stored under
|
||||
// this id, or because the record is unreadable (a non-object value
|
||||
// leaves `value.atext` undefined). The latter is exactly what
|
||||
// padLoad now surfaces with zeroed metadata — getPad()/Pad.remove()
|
||||
// would throw on it, so fall back to a raw key purge. Without this
|
||||
// the surfaced corrupt pad is undeletable from the admin UI.
|
||||
const raw = await db.get(`pad:${padId}`);
|
||||
if (raw != null) {
|
||||
logger.info(`Deleting unreadable pad record via raw key purge: ${padId}`);
|
||||
// Best-effort sweep of sub-keys (revs/chat/deletionToken/…) and
|
||||
// the readonly mapping, then the main key + pad-list/cache entry.
|
||||
const subKeys: string[] = (await db.findKeys(`pad:${padId}:*`, null)) || [];
|
||||
await Promise.all(subKeys.map((k) => db.remove(k)));
|
||||
await db.remove(`pad2readonly:${padId}`);
|
||||
await padManager.removePad(padId);
|
||||
}
|
||||
// Always emit a terminal reply (even for an already-absent id) so the
|
||||
// UI clears the row instead of silently doing nothing.
|
||||
socket.emit('results:deletePad', padId);
|
||||
} catch (err) {
|
||||
logger.error(`deletePad failed for "${padId}": ${safeErr(err)}`);
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@
|
|||
"pg": "^8.21.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"rate-limiter-flexible": "^11.1.1",
|
||||
"rate-limiter-flexible": "^11.2.0",
|
||||
"redis": "^6.0.0",
|
||||
"rehype": "^13.0.2",
|
||||
"rehype-minify-whitespace": "^6.0.2",
|
||||
|
|
@ -80,14 +80,14 @@
|
|||
"rethinkdb": "^2.4.2",
|
||||
"rusty-store-kv": "^1.3.1",
|
||||
"security": "1.0.0",
|
||||
"semver": "^7.8.2",
|
||||
"semver": "^7.8.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"superagent": "10.3.0",
|
||||
"surrealdb": "^2.0.3",
|
||||
"tinycon": "0.6.8",
|
||||
"tsx": "4.22.4",
|
||||
"ueberdb2": "^6.1.8",
|
||||
"ueberdb2": "^6.1.9",
|
||||
"underscore": "1.13.8",
|
||||
"undici": "^8.4.1",
|
||||
"unorm": "1.6.0",
|
||||
|
|
@ -154,6 +154,7 @@
|
|||
"test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api",
|
||||
"dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
|
||||
"prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts",
|
||||
"vectors:gen": "node --require tsx/cjs tests/backend/specs/downstream/generate-vectors.ts",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"ts-check:watch": "tsc --noEmit --watch",
|
||||
"test-ui": "cross-env NODE_ENV=production npx playwright test",
|
||||
|
|
@ -163,6 +164,6 @@
|
|||
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
|
||||
"test:vitest": "vitest"
|
||||
},
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.1",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,12 +102,21 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
|
|||
postHtml = `</li></ul>${postHtml}`;
|
||||
} else {
|
||||
if (start) { // is it a start of a list with more than one item in?
|
||||
if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
|
||||
// The `start` value comes verbatim from the attribute pool (which
|
||||
// an attacker can populate via a crafted `.etherpad` import), so it
|
||||
// must never be interpolated raw into the markup. An <ol> start is
|
||||
// only ever an integer: coerce it and HTML-escape it defensively so
|
||||
// a value such as `1><svg/onload=...>` cannot break out of the tag.
|
||||
const startNum = Number.parseInt(start[1]);
|
||||
if (startNum === 1) { // if its the first one at this level?
|
||||
// Add start class to DIV node
|
||||
lineClass = `${lineClass} ` + `list-start-${listType}`;
|
||||
}
|
||||
const startAttr = Number.isNaN(startNum)
|
||||
? ''
|
||||
: ` start="${Security.escapeHTMLAttribute(String(startNum))}"`;
|
||||
preHtml +=
|
||||
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
|
||||
`<ol${startAttr} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
|
||||
} else {
|
||||
// Handles pasted contents into existing lists
|
||||
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
|
||||
|
|
|
|||
|
|
@ -581,7 +581,10 @@ const pad = {
|
|||
$('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false);
|
||||
$('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue);
|
||||
$('#padsettings-viewfontmenu').val(view.padFontFamily || '');
|
||||
$('#padsettings-languagemenu').val(padOptions.lang || 'en');
|
||||
// When no pad-wide lang is set, reflect the language html10n actually
|
||||
// detected and rendered (e.g. from the browser) instead of defaulting the
|
||||
// dropdown to English while the UI is in another language. See #7925.
|
||||
$('#padsettings-languagemenu').val(padOptions.lang || html10n.getLanguage() || 'en');
|
||||
$('#padsettings-enforcecheck').prop('checked', !!padOptions.enforceSettings);
|
||||
$('#padsettings-options-stickychat, #padsettings-options-chatandusers')
|
||||
.prop('disabled', padOptions.showChat === false);
|
||||
|
|
@ -599,7 +602,10 @@ const pad = {
|
|||
$('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false);
|
||||
$('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue);
|
||||
$('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || '');
|
||||
$('#languagemenu').val(effectiveOptions.lang || 'en');
|
||||
// Fall back to the detected language rather than hardcoded English when the
|
||||
// user has not explicitly chosen one, so the dropdown matches the rendered
|
||||
// UI language. See #7925.
|
||||
$('#languagemenu').val(effectiveOptions.lang || html10n.getLanguage() || 'en');
|
||||
$('#settings input[id^="options-"]').prop('disabled', disabled);
|
||||
$('#viewfontmenu, #languagemenu').prop('disabled', disabled);
|
||||
$('#options-stickychat, #options-chatandusers')
|
||||
|
|
|
|||
|
|
@ -133,6 +133,22 @@
|
|||
box-shadow: 0 0 14px 0px var(--super-dark-color);
|
||||
}
|
||||
|
||||
/* == Root canvas / iOS status-bar safe area (issue #7606) == */
|
||||
/* The variant rules above only paint inner containers, leaving the <html> root
|
||||
transparent. iOS Safari fills the status-bar safe area above the page from
|
||||
the ROOT canvas background (not `theme-color`), so a dark-mode pad showed a
|
||||
white strip above the dark toolbar. Paint the root with the TOOLBAR colour
|
||||
(matching `theme-color` and the OP's request that the bar match the toolbar)
|
||||
so the safe area, toolbar, and address bar are one seamless colour. The
|
||||
colours mirror toolbarColorForTokens / skin_toolbar_colors.ts. `color-scheme`
|
||||
keeps UA-painted chrome (overscroll, scrollbars, form controls) in step. */
|
||||
html.super-light-toolbar, html.light-toolbar { color-scheme: light; }
|
||||
html.super-dark-toolbar, html.dark-toolbar { color-scheme: dark; }
|
||||
html.super-light-toolbar { background-color: var(--super-light-color); }
|
||||
html.light-toolbar { background-color: var(--light-color); }
|
||||
html.super-dark-toolbar { background-color: var(--super-dark-color); }
|
||||
html.dark-toolbar { background-color: var(--dark-color); }
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
180
src/tests/backend/specs/admin/padLoadResilience.ts
Normal file
180
src/tests/backend/specs/admin/padLoadResilience.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
'use strict';
|
||||
|
||||
// Regression test for issue #7935 ("Display issue of notes"): pads exist
|
||||
// (visible on the welcome page, returned by the API `listAllPads`) but the
|
||||
// admin "Manage pads" UI shows none.
|
||||
//
|
||||
// Root cause: the admin /settings `padLoad` handler hydrates every pad via
|
||||
// `padManager.getPad()` to build the listing (the default `lastEdited` sort
|
||||
// forces a full scan). `findKeys('pad:*', '*:*:*')` returns *every* key under
|
||||
// the `pad:` prefix, including legacy / foreign / migration-corrupted records
|
||||
// — e.g. a value stored as a JSON *string* rather than a pad object, which is
|
||||
// exactly what a botched dirty.db → PostgreSQL migration produces. Loading
|
||||
// such a record makes `Pad.init` throw `Cannot use 'in' operator to search
|
||||
// for 'pool' in <string>`. Before the fix that single rejection took out the
|
||||
// whole handler: no `results:padLoad` was ever emitted (the SPA showed an
|
||||
// empty "No results" state forever) and the unhandled rejection could exit
|
||||
// the server. The handler now skips unreadable pads (surfacing them with
|
||||
// zeroed metadata so an admin can still delete them) and always emits a
|
||||
// terminal reply.
|
||||
|
||||
import {strict as assert} from 'assert';
|
||||
import setCookieParser from 'set-cookie-parser';
|
||||
|
||||
const io = require('socket.io-client');
|
||||
const common = require('../../common');
|
||||
const settings = require('../../../../node/utils/Settings');
|
||||
const padManager = require('../../../../node/db/PadManager');
|
||||
const db = require('../../../../node/db/DB');
|
||||
|
||||
const adminSocket = async () => {
|
||||
settings.users = settings.users || {};
|
||||
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
|
||||
const savedRequireAuthentication = settings.requireAuthentication;
|
||||
settings.requireAuthentication = true;
|
||||
let res: any;
|
||||
try {
|
||||
res = await (common.agent as any)
|
||||
.get('/admin/')
|
||||
.auth('test-admin', 'test-admin-password');
|
||||
} finally {
|
||||
settings.requireAuthentication = savedRequireAuthentication;
|
||||
}
|
||||
const resCookies = setCookieParser.parse(res, {map: true});
|
||||
const reqCookieHdr = Object.entries(resCookies)
|
||||
.map(([name, cookie]: [string, any]) =>
|
||||
`${name}=${encodeURIComponent(cookie.value)}`)
|
||||
.join('; ');
|
||||
const socket = io(`${common.baseUrl}/settings`, {
|
||||
forceNew: true,
|
||||
query: {cookie: reqCookieHdr},
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onErr = (err: any) => { socket.off('connect', onConn); reject(err); };
|
||||
const onConn = () => { socket.off('connect_error', onErr); resolve(); };
|
||||
socket.once('connect', onConn);
|
||||
socket.once('connect_error', onErr);
|
||||
});
|
||||
return socket;
|
||||
};
|
||||
|
||||
const ask = (socket: any, evt: string, payload: any, replyEvt: string, timeoutMs = 10000) =>
|
||||
new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`no \`${replyEvt}\` reply within ${timeoutMs}ms`)), timeoutMs);
|
||||
socket.once(replyEvt, (data: any) => { clearTimeout(timer); resolve(data); });
|
||||
socket.emit(evt, payload);
|
||||
});
|
||||
|
||||
describe(__filename, function () {
|
||||
let socket: any;
|
||||
let savedUsers: any;
|
||||
let savedRequireAuthentication: boolean;
|
||||
let setupCompleted = false;
|
||||
const tag = `padLoadResilience-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
const goodId = `${tag}-good`;
|
||||
const corruptId = `${tag}-corrupt`;
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000);
|
||||
await common.init();
|
||||
|
||||
savedUsers = settings.users;
|
||||
savedRequireAuthentication = settings.requireAuthentication;
|
||||
setupCompleted = true;
|
||||
|
||||
try {
|
||||
socket = await adminSocket();
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`[padLoadResilience] admin socket connect failed (${err && err.message}); ` +
|
||||
"skipping suite — likely an authenticate-hook plugin rejecting the test's " +
|
||||
'admin credentials.');
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// A normal, readable pad — this is what must still show up.
|
||||
await padManager.getPad(goodId, 'good content\n');
|
||||
|
||||
// A pad that enters the pad-name index normally, then has its stored
|
||||
// value clobbered into a non-object (a JSON string) to mimic a
|
||||
// migration-corrupted / foreign `pad:*` record. Evicting it from the
|
||||
// in-memory cache forces the next getPad() to re-read the bad value.
|
||||
await padManager.getPad(corruptId, 'temp\n');
|
||||
await db.set(`pad:${corruptId}`, 'corrupt-non-object-value');
|
||||
padManager.unloadPad(corruptId);
|
||||
|
||||
// Sanity-check that the setup actually reproduces the failing read; if
|
||||
// this stops throwing the test is no longer exercising the bug.
|
||||
await assert.rejects(padManager.getPad(corruptId),
|
||||
'expected the corrupted pad record to make getPad throw');
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
if (socket) socket.disconnect();
|
||||
if (!setupCompleted) return;
|
||||
if (settings.users) delete settings.users['test-admin'];
|
||||
settings.users = savedUsers;
|
||||
settings.requireAuthentication = savedRequireAuthentication;
|
||||
for (const id of [goodId, corruptId]) {
|
||||
try { await db.remove(`pad:${id}`); } catch { /* ignore */ }
|
||||
try { await db.remove(`pad:${id}:revs:0`); } catch { /* ignore */ }
|
||||
try { padManager.unloadPad(id); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('a single corrupt pad does not hide every other pad (issue #7935)', async function () {
|
||||
this.timeout(30000);
|
||||
// The default query the SPA sends on initial load: lastEdited sort forces
|
||||
// the full-scan hydration path that touches every pad — including the
|
||||
// corrupt one.
|
||||
const res = await ask(socket, 'padLoad', {
|
||||
pattern: tag, offset: 0, limit: 12,
|
||||
sortBy: 'lastEdited', ascending: false, filter: 'all',
|
||||
}, 'results:padLoad');
|
||||
|
||||
const names = res.results.map((r: any) => r.padName);
|
||||
assert.ok(names.includes(goodId),
|
||||
`the readable pad must still be listed; got ${JSON.stringify(names)}`);
|
||||
// The bad pad is surfaced (zeroed metadata) rather than silently dropped,
|
||||
// so an admin can see and delete it.
|
||||
assert.ok(names.includes(corruptId),
|
||||
`the corrupt pad should surface for deletion; got ${JSON.stringify(names)}`);
|
||||
assert.equal(res.total, 2, `expected total=2, got ${JSON.stringify(res)}`);
|
||||
});
|
||||
|
||||
it('still replies on the fast path (padName sort) with a corrupt pad present', async function () {
|
||||
this.timeout(30000);
|
||||
const res = await ask(socket, 'padLoad', {
|
||||
pattern: tag, offset: 0, limit: 12,
|
||||
sortBy: 'padName', ascending: true, filter: 'all',
|
||||
}, 'results:padLoad');
|
||||
const names = res.results.map((r: any) => r.padName);
|
||||
assert.ok(names.includes(goodId), `got ${JSON.stringify(names)}`);
|
||||
assert.ok(names.includes(corruptId), `got ${JSON.stringify(names)}`);
|
||||
});
|
||||
|
||||
// Runs last: surfacing a corrupt pad is only useful if it can be removed.
|
||||
// deletePad's normal path (doesPadExists + getPad + Pad.remove) can't touch
|
||||
// an unreadable record, so it must fall back to a raw key purge.
|
||||
it('a surfaced corrupt pad can be deleted from the admin UI', async function () {
|
||||
this.timeout(30000);
|
||||
const ack = await ask(socket, 'deletePad', corruptId, 'results:deletePad');
|
||||
assert.equal(ack, corruptId, `expected deletePad to ack "${corruptId}", got ${JSON.stringify(ack)}`);
|
||||
|
||||
// Assert the user-facing outcome — the corrupt pad is gone from the
|
||||
// listing (its pad-list entry was dropped) while the good pad stays.
|
||||
// (We deliberately don't probe `db.get('pad:<id>')` here: ueberdb2's
|
||||
// per-backend read/write buffering can still return the just-removed
|
||||
// value immediately after `remove()`, so that would be a flaky
|
||||
// storage-internals assertion rather than a behavioural one.)
|
||||
const res = await ask(socket, 'padLoad', {
|
||||
pattern: tag, offset: 0, limit: 12,
|
||||
sortBy: 'padName', ascending: true, filter: 'all',
|
||||
}, 'results:padLoad');
|
||||
const names = res.results.map((r: any) => r.padName);
|
||||
assert.ok(!names.includes(corruptId), `corrupt pad still listed: ${JSON.stringify(names)}`);
|
||||
assert.ok(names.includes(goodId), `good pad missing after delete: ${JSON.stringify(names)}`);
|
||||
});
|
||||
});
|
||||
62
src/tests/backend/specs/domline_list_start.ts
Normal file
62
src/tests/backend/specs/domline_list_start.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Regression test for GHSA-f7h5-v9hm-548j.
|
||||
*
|
||||
* The numbered-list branch of `domline.appendSpan` used to interpolate the
|
||||
* line's `start` attribute value into an `<ol start=...>` tag unquoted and
|
||||
* unescaped, then assign the result to `node.innerHTML`. The value comes
|
||||
* verbatim from the attribute pool, which an attacker can populate via a
|
||||
* crafted `.etherpad` import, so a value such as `1><svg/onload=alert(1)>`
|
||||
* broke out of the tag and produced a live element -> stored XSS for every
|
||||
* viewer of the pad/timeslider.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const domline = require('../../../static/js/domline').domline;
|
||||
const {lineAttributeMarker} = require('../../../static/js/linestylefilter');
|
||||
import jsdom from 'jsdom';
|
||||
|
||||
// Build the per-span class string exactly as linestylefilter would for a
|
||||
// numbered-list line marker carrying the given `start` value.
|
||||
const listCls = (start: string) =>
|
||||
`${lineAttributeMarker} list:number1 start:${start}`;
|
||||
|
||||
// Render a single line-marker span through the real domline sink and return
|
||||
// the resulting DOM node.
|
||||
const renderLine = (cls: string) => {
|
||||
const {window} = new jsdom.JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const node = domline.createDomLine(true, false, window, window.document);
|
||||
node.clearSpans();
|
||||
node.appendSpan('*', cls);
|
||||
node.finishUpdate();
|
||||
return node.node as HTMLElement;
|
||||
};
|
||||
|
||||
describe(__filename, function () {
|
||||
it('does not create a live element from a malicious start value', async function () {
|
||||
// Space-free payload: satisfies the `\S+` capture the sink matches on.
|
||||
const node = renderLine(listCls('1><svg/onload=alert(document.domain)>'));
|
||||
assert.equal(node.querySelector('svg'), null,
|
||||
'malicious start value must not be parsed into a live <svg> element');
|
||||
assert.ok(!node.innerHTML.includes('<svg'),
|
||||
`rendered markup must not contain a raw <svg> tag: ${node.innerHTML}`);
|
||||
// The numbered list itself still renders; only the bogus value is dropped.
|
||||
assert.ok(node.querySelector('ol'), 'a numbered list should still render');
|
||||
});
|
||||
|
||||
it('renders a legitimate integer start value safely', async function () {
|
||||
const node = renderLine(listCls('2'));
|
||||
const ol = node.querySelector('ol');
|
||||
assert.ok(ol, 'a numbered list should render');
|
||||
assert.equal(ol!.getAttribute('start'), '2');
|
||||
});
|
||||
|
||||
it('coerces a non-integer start value away instead of emitting it', async function () {
|
||||
const node = renderLine(listCls('notanumber'));
|
||||
const ol = node.querySelector('ol');
|
||||
assert.ok(ol, 'a numbered list should still render');
|
||||
assert.equal(ol!.getAttribute('start'), null,
|
||||
'a non-integer start value must not reach the <ol> start attribute');
|
||||
});
|
||||
});
|
||||
77
src/tests/backend/specs/downstream/generate-vectors.ts
Normal file
77
src/tests/backend/specs/downstream/generate-vectors.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for the downstream wire-compatibility fixtures.
|
||||
*
|
||||
* Each vector is a self-contained changeset application: given `initialText`
|
||||
* and `pool`, applying `changeset` yields `resultText`. Downstream clients
|
||||
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
|
||||
* exact same JSON and must reproduce `resultText`. See the Phase 1 plan
|
||||
* at docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md.
|
||||
*
|
||||
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
|
||||
* pnpm run vectors:gen
|
||||
*/
|
||||
|
||||
import * as Changeset from '../../../../static/js/Changeset';
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
|
||||
export type WireVector = {
|
||||
name: string;
|
||||
initialText: string;
|
||||
changeset: string;
|
||||
pool: ReturnType<AttributePool['toJsonable']>;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
const vector = (
|
||||
name: string,
|
||||
initialText: string,
|
||||
build: (pool: AttributePool) => string,
|
||||
): WireVector => {
|
||||
const pool = new AttributePool();
|
||||
const changeset = build(pool);
|
||||
Changeset.checkRep(changeset);
|
||||
return {
|
||||
name,
|
||||
initialText,
|
||||
changeset,
|
||||
pool: pool.toJsonable(),
|
||||
resultText: Changeset.applyToText(changeset, initialText),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateVectors = (): WireVector[] => [
|
||||
vector('plain-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
|
||||
|
||||
vector('plain-delete', 'abcdef\n', () =>
|
||||
Changeset.makeSplice('abcdef\n', 1, 3, '')),
|
||||
|
||||
vector('formatted-insert', 'abc\n', (pool) =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
|
||||
|
||||
vector('multiline-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
|
||||
|
||||
vector('attrib-reuse', 'abc\n', (pool) => {
|
||||
// Two formatted inserts sharing one pool entry exercises pool index reuse.
|
||||
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
|
||||
const mid = Changeset.applyToText(cs1, 'abc\n');
|
||||
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
|
||||
return Changeset.compose(cs1, cs2, pool);
|
||||
}),
|
||||
];
|
||||
|
||||
// CLI entry: write the canonical fixture to disk.
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
fs.mkdirSync(path.dirname(out), {recursive: true});
|
||||
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`wrote ${out}`);
|
||||
}
|
||||
54
src/tests/backend/specs/downstream/wire-http-api.ts
Normal file
54
src/tests/backend/specs/downstream/wire-http-api.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
|
||||
* endpoints downstream clients call to create pads and round-trip text.
|
||||
* Auth in the test harness is via JWT (common.generateJWTToken), matching the
|
||||
* rest of the api specs — see api/createDiffHTML.ts.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let apiVersion = 1;
|
||||
const padId = `wireHttp_${common.randomString()}`;
|
||||
const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
const res = await agent.get('/api/').expect(200).expect('Content-Type', /json/);
|
||||
apiVersion = res.body.currentVersion;
|
||||
assert(apiVersion);
|
||||
});
|
||||
|
||||
it('createPad returns the standard {code,data,message} envelope', async function () {
|
||||
const res = await agent.get(`${endPoint('createPad')}?padID=${padId}&text=hello%0A`)
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.expect(200);
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
|
||||
assert.equal(res.body.code, 0);
|
||||
});
|
||||
|
||||
it('setText + getText round-trips text through the documented shape', async function () {
|
||||
await agent.post(endPoint('setText'))
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.send({padID: padId, text: 'world\n'})
|
||||
.expect(200);
|
||||
const res = await agent.get(`${endPoint('getText')}?padID=${padId}`)
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.text, 'string');
|
||||
assert.equal(res.body.data.text, 'world\n');
|
||||
});
|
||||
|
||||
it('getRevisionsCount exposes a numeric revisions field', async function () {
|
||||
const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${padId}`)
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.revisions, 'number');
|
||||
});
|
||||
});
|
||||
60
src/tests/backend/specs/downstream/wire-socket-sequence.ts
Normal file
60
src/tests/backend/specs/downstream/wire-socket-sequence.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Pins the socket.io message sequence + shapes that every realtime client
|
||||
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
|
||||
* A change here is a wire-protocol change that will break downstream clients
|
||||
* (the Rust terminal editor and the Node CLI both speak this sequence by hand).
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
const padManager = require('../../../../node/db/PadManager');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let socket: any;
|
||||
let pad: any;
|
||||
let padId: string;
|
||||
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
beforeEach(async function () {
|
||||
padId = common.randomString();
|
||||
pad = await padManager.getPad(padId, 'dummy\n');
|
||||
await pad.setText('\n'); // ensure the pad exists at a known empty state
|
||||
const res = await agent.get(`/p/${padId}`).expect(200);
|
||||
socket = await common.connect(res);
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (socket != null) socket.close();
|
||||
socket = null;
|
||||
if (pad != null) await pad.remove();
|
||||
pad = null;
|
||||
});
|
||||
|
||||
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
|
||||
const {type, data} = await common.handshake(socket, padId);
|
||||
assert.equal(type, 'CLIENT_VARS');
|
||||
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
|
||||
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
|
||||
assert.equal(typeof data.collab_client_vars.rev, 'number');
|
||||
assert.ok(data.collab_client_vars.initialAttributedText,
|
||||
'collab_client_vars.initialAttributedText missing');
|
||||
});
|
||||
|
||||
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
|
||||
const {data: clientVars} = await common.handshake(socket, padId);
|
||||
const rev = clientVars.collab_client_vars.rev;
|
||||
const authorId = clientVars.userId;
|
||||
// Insert ops must carry the session author attribute (`*0+N` + matching
|
||||
// apool entry) or the server rejects with {disconnect:'badChangeset'}.
|
||||
const apool = {numToAttrib: {0: ['author', authorId]}, nextNum: 1};
|
||||
await Promise.all([
|
||||
common.waitForAcceptCommit(socket, rev + 1),
|
||||
common.sendUserChanges(socket, {baseRev: rev, changeset: 'Z:1>5*0+5$hello', apool}),
|
||||
]);
|
||||
assert.equal(pad.text(), 'hello\n');
|
||||
});
|
||||
});
|
||||
33
src/tests/backend/specs/downstream/wire-vectors.ts
Normal file
33
src/tests/backend/specs/downstream/wire-vectors.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Guards the downstream wire-format contract:
|
||||
* - the committed fixture exactly matches a fresh regeneration (any drift is a
|
||||
* deliberate wire change and must be re-generated + reviewed in the same PR), and
|
||||
* - every vector is internally consistent under core's own Changeset engine.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as Changeset from '../../../../static/js/Changeset';
|
||||
import {generateVectors} from './generate-vectors';
|
||||
|
||||
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
|
||||
describe(__filename, function () {
|
||||
it('committed fixture matches a fresh regeneration', function () {
|
||||
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const fresh = generateVectors();
|
||||
assert.deepEqual(committed, fresh,
|
||||
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
|
||||
});
|
||||
|
||||
it('every vector applies to its result under core Changeset', function () {
|
||||
for (const v of generateVectors()) {
|
||||
Changeset.checkRep(v.changeset);
|
||||
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
|
||||
`vector ${v.name} result mismatch`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@ describe(__filename, function () {
|
|||
plugins.hooks[hookName] = [];
|
||||
}
|
||||
backups.settings = {};
|
||||
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings']) {
|
||||
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings', 'allowPadDeletionByAllUsers']) {
|
||||
// @ts-ignore
|
||||
backups.settings[setting] = settings[setting];
|
||||
}
|
||||
|
|
@ -492,6 +492,49 @@ describe(__filename, function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Pad deletion token issuance (#7926)', function () {
|
||||
const removeIfExists = async (padId: string) => {
|
||||
if (await padManager.doesPadExist(padId)) {
|
||||
const p = await padManager.getPad(padId);
|
||||
await p.remove();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async function () {
|
||||
// @ts-ignore - public setting toggled per test
|
||||
settings.allowPadDeletionByAllUsers = false;
|
||||
await removeIfExists('pad');
|
||||
});
|
||||
afterEach(async function () {
|
||||
if (socket) socket.close();
|
||||
socket = null;
|
||||
await removeIfExists('pad');
|
||||
});
|
||||
|
||||
it('anonymous creator receives a deletion token by default', async function () {
|
||||
const res = await agent.get('/p/pad').expect(200);
|
||||
socket = await common.connect(res);
|
||||
const cv: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cv.type, 'CLIENT_VARS');
|
||||
assert.equal(typeof cv.data.padDeletionToken, 'string',
|
||||
'creator should get a token so the client can show the save-token modal');
|
||||
assert.ok(cv.data.padDeletionToken.length >= 32);
|
||||
});
|
||||
|
||||
it('no token (and so no modal) when allowPadDeletionByAllUsers is true', async function () {
|
||||
// @ts-ignore - public setting
|
||||
settings.allowPadDeletionByAllUsers = true;
|
||||
const res = await agent.get('/p/pad').expect(200);
|
||||
socket = await common.connect(res);
|
||||
const cv: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cv.type, 'CLIENT_VARS');
|
||||
// A null token means showDeletionTokenModalIfPresent() returns early on the
|
||||
// client, so the "Save your pad deletion token" modal never appears. Anyone
|
||||
// can already delete the pad without a token in this configuration.
|
||||
assert.equal(cv.data.padDeletionToken, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocketIORouter.js', function () {
|
||||
const Module = class {
|
||||
setSocketIO(io:any) {}
|
||||
|
|
|
|||
29
src/tests/downstream/clients.json
Normal file
29
src/tests/downstream/clients.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[
|
||||
{
|
||||
"name": "etherpad-pad",
|
||||
"repo": "https://github.com/ether/pad.git",
|
||||
"ref": "91620c67c49536bb77e90b39f298ea70ae93c4a0",
|
||||
"kind": "rust",
|
||||
"enabled": true,
|
||||
"vectorTest": "cargo test --test vectors",
|
||||
"smokeCmd": "cargo test --test smoke -- --ignored"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-cli-client",
|
||||
"repo": "https://github.com/ether/etherpad-cli-client.git",
|
||||
"ref": "ebc516ef1a4e7a0c97ccd7a3f2db65e99f8e177c",
|
||||
"kind": "node",
|
||||
"enabled": true,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-desktop",
|
||||
"repo": "https://github.com/ether/etherpad-desktop.git",
|
||||
"ref": "ab83da645b8683afbbc203e4a3fa6f3622a55709",
|
||||
"kind": "desktop",
|
||||
"enabled": true,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
}
|
||||
]
|
||||
86
src/tests/downstream/run-clients.sh
Executable file
86
src/tests/downstream/run-clients.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Runs each enabled downstream client from clients.json against an already-booted
|
||||
# Etherpad: clone @ pinned ref, set up its toolchain, point it at core's freshly
|
||||
# generated wire-vectors fixture, then run the client's vectorTest + smokeCmd.
|
||||
#
|
||||
# The fixture is injected via $ETHERPAD_WIRE_VECTORS (absolute) so clients test
|
||||
# against CURRENT core's serialization, not their vendored snapshot. The smoke
|
||||
# reaches the server via $ETHERPAD_SMOKE_URL + $ETHERPAD_SMOKE_APIKEY.
|
||||
#
|
||||
# Env (all optional except APIKEY):
|
||||
# SMOKE_URL default http://localhost:9003
|
||||
# SMOKE_APIKEY required for the live smoke (clients skip cleanly without it)
|
||||
# MANIFEST default src/tests/downstream/clients.json (relative to repo root)
|
||||
# WIRE_VECTORS default <repo>/src/tests/fixtures/wire-vectors.json
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
MANIFEST="${MANIFEST:-$REPO_ROOT/src/tests/downstream/clients.json}"
|
||||
WIRE_VECTORS="${WIRE_VECTORS:-$REPO_ROOT/src/tests/fixtures/wire-vectors.json}"
|
||||
SMOKE_URL="${SMOKE_URL:-http://localhost:9003}"
|
||||
SMOKE_APIKEY="${SMOKE_APIKEY:-}"
|
||||
|
||||
[ -f "$WIRE_VECTORS" ] || { echo "::error::fixture not found: $WIRE_VECTORS"; exit 1; }
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
MANIFEST="$MANIFEST" node -e '
|
||||
const c = require(process.env.MANIFEST).filter((x) => x.enabled);
|
||||
for (const x of c) {
|
||||
process.stdout.write([x.name, x.repo, x.ref, x.kind, x.vectorTest, x.smokeCmd].join("\t") + "\n");
|
||||
}
|
||||
' > "$WORK/clients.tsv"
|
||||
|
||||
if [ ! -s "$WORK/clients.tsv" ]; then
|
||||
echo "No downstream clients enabled. Nothing to run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export ETHERPAD_WIRE_VECTORS="$WIRE_VECTORS"
|
||||
export ETHERPAD_SMOKE_URL="$SMOKE_URL"
|
||||
export ETHERPAD_SMOKE_APIKEY="$SMOKE_APIKEY"
|
||||
|
||||
fail=0
|
||||
while IFS=$'\t' read -r name repo ref kind vectorTest smokeCmd; do
|
||||
echo "::group::$name ($kind) @ ${ref:0:12}"
|
||||
dir="$WORK/$name"
|
||||
# Everything — clone, checkout, AND the tests — runs inside one guarded
|
||||
# subshell so a single client's failure becomes a per-client failure (fail=1)
|
||||
# and the loop continues to the rest. NOTE: `set -e` is suspended inside a
|
||||
# subshell used as an `||` operand, so every step is guarded with an explicit
|
||||
# `|| exit 1` rather than relying on `set -e`. The manifest commands are a
|
||||
# trusted in-repo allowlist; running them via `bash -euo pipefail -c` (not
|
||||
# `eval`) keeps them out of this script's own shell and applies strict mode
|
||||
# (pipeline-stage failures surface) inside the child.
|
||||
(
|
||||
git clone --quiet "$repo" "$dir" || exit 1
|
||||
# A default clone has all branch heads; fetch the pinned commit only if it
|
||||
# is not already reachable (e.g. a non-branch-tip SHA). Fetch errors are
|
||||
# NOT suppressed so the real cause surfaces instead of a vague checkout fail.
|
||||
if ! git -C "$dir" cat-file -e "${ref}^{commit}" 2>/dev/null; then
|
||||
git -C "$dir" fetch --quiet origin "$ref" || exit 1
|
||||
fi
|
||||
git -C "$dir" checkout --quiet "$ref" || exit 1
|
||||
|
||||
cd "$dir" || exit 1
|
||||
case "$kind" in
|
||||
rust)
|
||||
bash -euo pipefail -c "$vectorTest" || exit 1
|
||||
bash -euo pipefail -c "$smokeCmd" || exit 1
|
||||
;;
|
||||
node|desktop)
|
||||
pnpm install || exit 1
|
||||
bash -euo pipefail -c "$vectorTest" || exit 1
|
||||
bash -euo pipefail -c "$smokeCmd" || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown client kind: $kind"; exit 1
|
||||
;;
|
||||
esac
|
||||
) || { echo "::error::downstream client '$name' failed (clone/checkout/test)"; fail=1; }
|
||||
echo "::endgroup::"
|
||||
done < "$WORK/clients.tsv"
|
||||
|
||||
exit "$fail"
|
||||
62
src/tests/fixtures/wire-vectors.json
vendored
Normal file
62
src/tests/fixtures/wire-vectors.json
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
[
|
||||
{
|
||||
"name": "plain-insert",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>3=3+3$XYZ",
|
||||
"pool": {
|
||||
"numToAttrib": {},
|
||||
"nextNum": 0
|
||||
},
|
||||
"resultText": "abcXYZ\n"
|
||||
},
|
||||
{
|
||||
"name": "plain-delete",
|
||||
"initialText": "abcdef\n",
|
||||
"changeset": "Z:7<3=1-3$",
|
||||
"pool": {
|
||||
"numToAttrib": {},
|
||||
"nextNum": 0
|
||||
},
|
||||
"resultText": "aef\n"
|
||||
},
|
||||
{
|
||||
"name": "formatted-insert",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>4=3*0+4$bold",
|
||||
"pool": {
|
||||
"numToAttrib": {
|
||||
"0": [
|
||||
"bold",
|
||||
"true"
|
||||
]
|
||||
},
|
||||
"nextNum": 1
|
||||
},
|
||||
"resultText": "abcbold\n"
|
||||
},
|
||||
{
|
||||
"name": "multiline-insert",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>8=3|2+8$one\ntwo\n",
|
||||
"pool": {
|
||||
"numToAttrib": {},
|
||||
"nextNum": 0
|
||||
},
|
||||
"resultText": "abcone\ntwo\n\n"
|
||||
},
|
||||
{
|
||||
"name": "attrib-reuse",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>2*0+1=3*0+1$AB",
|
||||
"pool": {
|
||||
"numToAttrib": {
|
||||
"0": [
|
||||
"bold",
|
||||
"true"
|
||||
]
|
||||
},
|
||||
"nextNum": 1
|
||||
},
|
||||
"resultText": "AabcB\n"
|
||||
}
|
||||
]
|
||||
54
src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts
Normal file
54
src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {expect, test} from "@playwright/test";
|
||||
import {loginToAdmin} from "../helper/adminhelper";
|
||||
import {goToPad, writeToPad} from "../helper/padHelper";
|
||||
|
||||
// End-to-end coverage for issue #7935: a pad that exists must be visible
|
||||
// both on the welcome page's "recent pads" list (driven by localStorage,
|
||||
// i.e. gated on the browser having opened the pad) and in the admin
|
||||
// "Manage pads" UI (driven by the /settings socket `padLoad` handler,
|
||||
// which enumerates the DB). The admin side regressed when a single
|
||||
// unreadable pad record made `padLoad` throw and silently return nothing
|
||||
// — see specs/admin/padLoadResilience.ts for the server-side guard.
|
||||
|
||||
// /admin tests mutate global server state, so keep them serial.
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
const ADMIN_URL = 'http://localhost:9001/admin';
|
||||
|
||||
test.describe('a created pad shows up on the home page and in /admin', () => {
|
||||
// Unique, URL-safe id so the recent-pads localStorage entry and the admin
|
||||
// search both target exactly this pad and ignore leftovers from other suites.
|
||||
const padId = `pw-pads-7935-${Date.now()}`;
|
||||
|
||||
test('opening a pad lists it in the welcome page recent-pads', async ({page}) => {
|
||||
await goToPad(page, padId);
|
||||
await writeToPad(page, 'hello from 7935');
|
||||
|
||||
// Opening the pad writes it to `recentPads` localStorage (colibris
|
||||
// pad.js). The welcome page renders that list — same browser context,
|
||||
// so the entry carries over.
|
||||
await page.goto('http://localhost:9001/');
|
||||
const recentPad = page.locator('.recent-pad', {hasText: padId});
|
||||
await expect(recentPad).toBeVisible({timeout: 10000});
|
||||
await expect(recentPad.locator('a')).toHaveText(padId);
|
||||
});
|
||||
|
||||
test('the same pad is listed in the admin Manage pads UI', async ({page}) => {
|
||||
await loginToAdmin(page, 'admin', 'changeme1');
|
||||
await page.goto(`${ADMIN_URL}/pads`);
|
||||
|
||||
await expect(page.getByRole('heading', {name: 'Manage pads'}))
|
||||
.toBeVisible({timeout: 30000});
|
||||
|
||||
// Narrow the (full-scan) listing to our pad. The search is debounced
|
||||
// server-side; allow the round-trip to settle.
|
||||
const search = page.getByPlaceholder('Search for pads');
|
||||
await search.fill(padId);
|
||||
|
||||
await expect(page.locator('.pm-pad-title', {hasText: padId}))
|
||||
.toBeVisible({timeout: 15000});
|
||||
// The "No results" empty state must NOT be showing — the exact #7935
|
||||
// symptom was an empty Manage-pads list for pads that demonstrably exist.
|
||||
await expect(page.locator('.pm-empty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -90,4 +90,28 @@ test.describe('Language select and change', function () {
|
|||
await page.waitForSelector('html[dir="ltr"]')
|
||||
|
||||
});
|
||||
|
||||
// Regression test for #7925: when the UI language is auto-detected from the
|
||||
// browser (no language cookie, no pad-wide lang set), the language dropdown
|
||||
// must reflect the detected language instead of defaulting to English.
|
||||
test('dropdown reflects the browser-detected language', async function ({browser}) {
|
||||
const context = await browser.newContext({locale: 'de-DE'})
|
||||
await context.clearCookies()
|
||||
const page = await context.newPage()
|
||||
try {
|
||||
await goToNewPad(page)
|
||||
|
||||
// The toolbar should have rendered in German (detection works) — the
|
||||
// bold button's parent <li> carries the localized German tooltip.
|
||||
await expect(page.locator('.buttonicon-bold').locator('..'))
|
||||
.toHaveAttribute('title', 'Fett (Strg-B)')
|
||||
|
||||
// ... and the language dropdown must show the detected language, not
|
||||
// English, even though it was never explicitly selected.
|
||||
await showSettings(page)
|
||||
await expect(langDropdown(page).locator('.current')).toHaveText('Deutsch')
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,4 +59,18 @@ test.describe('dark color scheme', () => {
|
|||
// before pad.css so it takes effect at first paint.
|
||||
await expect(page.locator('html')).toHaveClass(/super-dark-editor/);
|
||||
});
|
||||
|
||||
test('root canvas matches the toolbar so the iOS status-bar area is not white',
|
||||
async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
// The <html> root must carry the toolbar colour as its background — iOS
|
||||
// Safari paints the status-bar safe area from the root canvas, not from
|
||||
// theme-color, so leaving it transparent produced a white strip above
|
||||
// the dark pad (issue #7606). #485365 == --super-dark-color, the dark
|
||||
// toolbar colour.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).backgroundColor))
|
||||
.toBe('rgb(72, 83, 101)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue