diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1e7ac79ed..e7fcee26f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,4 +17,11 @@ updates: open-pull-requests-limit: 30 groups: dev-dependencies: - dependency-type: "development" \ No newline at end of file + dependency-type: "development" + cooldown: + default-days: 1 # fallback for anything not covered below + semver-major-days: 7 + semver-minor-days: 3 + semver-patch-days: 1 + include: + - "*" diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index c076a3542..64ba0e90c 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -32,8 +32,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -51,7 +51,7 @@ jobs: cache: pnpm - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libreoffice libreoffice-pdfimport version: 1.0 @@ -88,8 +88,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -107,7 +107,7 @@ jobs: cache: pnpm - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libreoffice libreoffice-pdfimport version: 1.0 @@ -168,8 +168,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -234,8 +234,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/build-and-deploy-docs.yml b/.github/workflows/build-and-deploy-docs.yml index 26d6a5e14..f49cf2974 100644 --- a/.github/workflows/build-and-deploy-docs.yml +++ b/.github/workflows/build-and-deploy-docs.yml @@ -36,15 +36,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache vitepress build with: path: doc/.vitepress/cache diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c6c10937b..37d2d28b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 603089c26..51bad3b93 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -15,6 +15,24 @@ on: - 'src/node/utils/run_cmd.ts' - 'src/static/js/pluginfw/**' - 'settings.json.template' + # Also build + smoke-test the package on PRs that touch the production + # footprint. The smoke step boots the packaged server and waits for /health, + # which is the only check that catches "server starts then exits before + # binding the port" startup regressions (e.g. a dependency bump whose change + # lets the event loop drain mid-boot). Previously this workflow ran only on + # push to develop, so such a regression was caught *after* merge — by which + # point develop was already red. Running it pre-merge blocks the PR instead. + # The release/apt-publish jobs are tag-guarded, so PRs run the build job only. + pull_request: + paths: + - 'packaging/**' + - '.github/workflows/deb-package.yml' + - 'src/package.json' + - 'pnpm-lock.yaml' + - 'src/node/server.ts' + - 'src/node/utils/run_cmd.ts' + - 'src/static/js/pluginfw/**' + - 'settings.json.template' workflow_dispatch: inputs: ref: @@ -44,7 +62,7 @@ jobs: runner: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.ref }} @@ -328,7 +346,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout etherpad source (for packaging/apt/key.asc) - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 1fd74e4a5..da2ac6321 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Dependency Review' uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0d1c51a7c..cca0feb45 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -42,7 +42,7 @@ jobs: tags: ${{ env.TEST_TAG }} cache-from: type=gha cache-to: type=gha,mode=max - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -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 @@ -164,7 +190,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -235,7 +261,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -332,7 +358,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -390,7 +416,7 @@ jobs: enable-url-completion: true - name: Check out ether-charts if: github.ref == 'refs/heads/develop' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: ether-charts repository: ether/ether-charts diff --git a/.github/workflows/downstream-smoke.yml b/.github/workflows/downstream-smoke.yml new file mode 100644 index 000000000..fc42cfe3e --- /dev/null +++ b/.github/workflows/downstream-smoke.yml @@ -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@v7 + + - uses: actions/cache@v6 + 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 diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml index 8d23a23f2..5ff9dea29 100644 --- a/.github/workflows/frontend-admin-tests.yml +++ b/.github/workflows/frontend-admin-tests.yml @@ -27,8 +27,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -48,7 +48,7 @@ jobs: name: Install all dependencies and symlink for ep_etherpad-lite run: pnpm i - name: Cache Playwright browsers - uses: actions/cache@v5 + uses: actions/cache@v6 id: playwright-cache with: path: ~/.cache/ms-playwright diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 19fa6da3f..0def07b51 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 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: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright @@ -92,15 +92,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 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: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright @@ -168,15 +168,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 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: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright @@ -269,15 +269,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 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: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright diff --git a/.github/workflows/handleRelease.yml b/.github/workflows/handleRelease.yml index 21189a1e6..03ca88667 100644 --- a/.github/workflows/handleRelease.yml +++ b/.github/workflows/handleRelease.yml @@ -27,8 +27,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/installer-test.yml b/.github/workflows/installer-test.yml index 36634a37a..a40cd9db2 100644 --- a/.github/workflows/installer-test.yml +++ b/.github/workflows/installer-test.yml @@ -23,7 +23,7 @@ jobs: name: shellcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run shellcheck on installer.sh run: | sudo apt-get update @@ -38,7 +38,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -70,13 +70,30 @@ jobs: - name: Smoke test - start Etherpad and curl /api shell: bash + # Hard backstop: if teardown ever fails to reap the server the step + # fails in minutes instead of burning to GitHub's 6h job ceiling. + timeout-minutes: 8 env: ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test run: | set -eu + # Enable job control so the backgrounded launcher gets its own + # process group, letting us reap the whole pnpm -> node tree below. + set -m cd "$ETHERPAD_DIR" pnpm run prod >/tmp/etherpad.log 2>&1 & PID=$! + # `pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node), + # so killing $PID alone orphans the node server, which keeps the step's + # output pipe open and hangs CI. Kill the entire process group, with a + # SIGKILL fallback in case SIGTERM is swallowed (e.g. a live flush timer + # keeping the event loop alive), so the step always exits cleanly. + reap() { + kill -TERM "-$PID" 2>/dev/null || true + sleep 5 + kill -KILL "-$PID" 2>/dev/null || true + } + trap reap EXIT # Wait up to 60s for the API to come up. ok=0 for i in $(seq 1 60); do @@ -90,17 +107,14 @@ jobs: if [ "$ok" != "1" ]; then echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2 tail -200 /tmp/etherpad.log >&2 || true - kill "$PID" 2>/dev/null || true exit 1 fi - kill "$PID" 2>/dev/null || true - wait "$PID" 2>/dev/null || true installer-windows: name: end-to-end install (windows-latest) runs-on: windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -131,6 +145,7 @@ jobs: - name: Smoke test - start Etherpad and curl /api shell: pwsh + timeout-minutes: 8 env: ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test run: | diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 8b1b3f3f8..9b498416c 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -24,8 +24,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -62,8 +62,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -125,8 +125,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/perform-type-check.yml b/.github/workflows/perform-type-check.yml index b3aeb697c..fbed6df2c 100644 --- a/.github/workflows/perform-type-check.yml +++ b/.github/workflows/perform-type-check.yml @@ -24,8 +24,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/rate-limit.yml b/.github/workflows/rate-limit.yml index c08e6f224..3cd791164 100644 --- a/.github/workflows/rate-limit.yml +++ b/.github/workflows/rate-limit.yml @@ -27,8 +27,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4d2b5907..b0b697845 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ether/etherpad-lite path: etherpad @@ -42,12 +42,12 @@ jobs: git checkout develop git reset --hard origin/develop - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ether/ether.github.com path: ether.github.com token: '${{ secrets.ETHER_RELEASE_TOKEN }}' - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -82,7 +82,7 @@ jobs: with: ruby-version: 2.7 - - uses: reitzig/actions-asciidoctor@v2.0.4 + - uses: reitzig/actions-asciidoctor@v2.0.5 with: version: 2.0.18 - name: Prepare release diff --git a/.github/workflows/releaseEtherpad.yml b/.github/workflows/releaseEtherpad.yml index a30c0c474..dd53cf9ae 100644 --- a/.github/workflows/releaseEtherpad.yml +++ b/.github/workflows/releaseEtherpad.yml @@ -1,9 +1,33 @@ +# PARKED — npm publish of the core package is not part of the standard release. +# +# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes +# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose: +# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it; +# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install, +# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core; +# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`. +# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC +# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0 +# while 3.x shipped fine without it. +# +# It is therefore gated behind an explicit `confirm: true` dispatch input so a +# stray run fails fast with a clear message instead of a confusing 404. To +# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first +# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings -> +# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`. +# Decision pending: finish that config, or remove this workflow. See AGENTS.MD. name: releaseEtherpad.yaml permissions: contents: read id-token: write # for npm OIDC trusted publishing on: workflow_dispatch: + inputs: + confirm: + description: 'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.' + required: true + default: false + type: boolean env: PNPM_HOME: ~/.pnpm-store @@ -12,8 +36,16 @@ jobs: release: runs-on: ubuntu-latest steps: + - name: Guard — refuse unless explicitly confirmed + if: ${{ inputs.confirm != true }} + run: | + echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional" + echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)." + echo "::error::Re-run with confirm=true only after the owner configures a trusted" + echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section." + exit 1 - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: # OIDC trusted publishing needs npm >= 11.5.1, which requires @@ -22,7 +54,7 @@ jobs: registry-url: https://registry.npmjs.org/ - name: Upgrade npm to >=11.5.1 (required for trusted publishing) run: npm install -g npm@latest - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/snap-build.yml b/.github/workflows/snap-build.yml index dcbe923ef..9255db173 100644 --- a/.github/workflows/snap-build.yml +++ b/.github/workflows/snap-build.yml @@ -34,7 +34,7 @@ jobs: name: Wrapper unit tests runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run snap/tests/run-all.sh run: bash snap/tests/run-all.sh @@ -43,7 +43,7 @@ jobs: needs: wrapper-tests runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install snapcraft run: sudo snap install --classic snapcraft diff --git a/.github/workflows/snap-publish.yml b/.github/workflows/snap-publish.yml index ba9b8a5bf..6f692d27f 100644 --- a/.github/workflows/snap-publish.yml +++ b/.github/workflows/snap-publish.yml @@ -35,7 +35,7 @@ jobs: snap-file: ${{ steps.build.outputs.snap }} steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Build snap id: build diff --git a/.github/workflows/update-plugins.yml b/.github/workflows/update-plugins.yml index 22c1976b6..2f3ca4783 100644 --- a/.github/workflows/update-plugins.yml +++ b/.github/workflows/update-plugins.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out etherpad-lite - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 name: Install pnpm diff --git a/.github/workflows/upgrade-from-latest-release.yml b/.github/workflows/upgrade-from-latest-release.yml index c3ef545f5..02c527e70 100644 --- a/.github/workflows/upgrade-from-latest-release.yml +++ b/.github/workflows/upgrade-from-latest-release.yml @@ -32,13 +32,13 @@ jobs: steps: - name: Check out latest release - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Check out latest release tag run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)" - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -55,7 +55,7 @@ jobs: node-version: ${{ matrix.node }} cache: pnpm - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libreoffice libreoffice-pdfimport version: 1.0 diff --git a/AGENTS.MD b/AGENTS.MD index c3cb1115e..80abdce91 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -231,7 +231,7 @@ Releases are driven almost entirely by GitHub Actions. A maintainer dispatches * - `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`); - `docker.yml` → builds & pushes the Docker images; - `snap-publish.yml` → publishes the snap. -5. **npm publish is a separate manual step:** dispatch **"releaseEtherpad.yaml"** (`workflow_dispatch`), which runs `npm publish --provenance --access public` via npm **OIDC trusted publishing**. It is *not* fired by the tag. +5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending. ### Documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4622048..e7721883b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,64 @@ +# 3.3.2 + +3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot. + +### Security + +- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch` → `@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump. +- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6. +- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode. + +### Notable enhancements + +- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path. +- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change. + +### Notable fixes + +- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings. +- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths. +- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site. +- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed. + +### Internal / contributor-facing + +- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean. +- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps. +- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded. +- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`. +- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted. + +### Dependencies + +- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret. +- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974). +- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977). + +# 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 `
    ` 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 ` + + + + + + + - <% } %>

    About

    Powered by Etherpad diff --git a/src/tests/backend/specs/PadManager.ts b/src/tests/backend/specs/PadManager.ts new file mode 100644 index 000000000..c4ddf79a0 --- /dev/null +++ b/src/tests/backend/specs/PadManager.ts @@ -0,0 +1,48 @@ +'use strict'; + +// Unit coverage for PadManager.isValidPadId. +// +// isValidPadId is a pure function (a regex test), so this spec just requires +// PadManager and exercises it directly — no running database is needed. +// PadManager's import-time `require`s (DB, Pad, customError) only *define* +// things; the database connection happens lazily in DB.init(), so loading the +// module here has no side effects. This runs under the mocha backend suite +// (`--import=tsx`), where `require()` resolves the `.ts` sources natively. + +import {strict as assert} from 'assert'; + +const padManager = require('../../../node/db/PadManager'); + +describe(__filename, function () { + describe('isValidPadId', function () { + it('accepts ordinary pad ids', async function () { + for (const id of [ + 'foo', + 'TF-EVC', + 'TF-LEC_IP03-EMS-CSM', + 'a.b', + '.foo', + 'foo.', + "a'b", + 'g.s8oes9dhwrvt0zif$bar', // group pad + ]) { + assert.equal(padManager.isValidPadId(id), true, `expected "${id}" to be valid`); + } + }); + + // Regression test for "Cannot GET /p/": a pad id that is a URL dot-segment + // ('.' or '..') is normalised away by the browser per the WHATWG URL + // standard ('/p/.' -> '/p/', '/p/..' -> '/'), so the pad can never be + // opened or exported. Such ids must be rejected. Before the fix + // isValidPadId returned true for both, so this test would fail. + it('rejects URL dot-segment pad ids that would be unreachable', async function () { + assert.equal(padManager.isValidPadId('.'), false); + assert.equal(padManager.isValidPadId('..'), false); + }); + + it('still rejects empty ids and ids containing "$"', async function () { + assert.equal(padManager.isValidPadId(''), false); + assert.equal(padManager.isValidPadId('a$b'), false); + }); + }); +}); diff --git a/src/tests/backend/specs/admin/padLoadResilience.ts b/src/tests/backend/specs/admin/padLoadResilience.ts new file mode 100644 index 000000000..b69517d74 --- /dev/null +++ b/src/tests/backend/specs/admin/padLoadResilience.ts @@ -0,0 +1,203 @@ +'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 `. 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((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((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:')` 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)}`); + }); + + // Regression for the isValidPadId tightening that rejects '.' and '..': a + // legacy pad with such an id predates the validation, so it still exists in + // the DB (doesPadExists is true → deletePad takes the "healthy" branch) but + // getPad() now throws on it. deletePad must fall back to the raw key purge + // instead of failing silently, otherwise the orphan is undeletable from the + // admin UI. Before the fallback this `ask()` never gets `results:deletePad` + // and times out. + it("a legacy '.' pad (now an invalid id) can still be deleted", async function () { + this.timeout(30000); + const dotId = '.'; + // getPad('.') would now throw, so seed the record directly with a truthy + // `atext` so doesPadExists() returns true and the handler enters the branch + // where getPad() throws. + await db.set(`pad:${dotId}`, {atext: {text: '\n', attribs: ''}, pool: {}, head: -1, savedRevisions: []}); + try { + const ack = await ask(socket, 'deletePad', dotId, 'results:deletePad'); + assert.equal(ack, dotId, `expected deletePad to ack "${dotId}", got ${JSON.stringify(ack)}`); + } finally { + try { await db.remove(`pad:${dotId}`); } catch { /* ignore */ } + try { padManager.unloadPad(dotId); } catch { /* ignore */ } + } + }); +}); diff --git a/src/tests/backend/specs/api/deletePad.ts b/src/tests/backend/specs/api/deletePad.ts index fe118aa4e..1268f4e9a 100644 --- a/src/tests/backend/specs/api/deletePad.ts +++ b/src/tests/backend/specs/api/deletePad.ts @@ -80,6 +80,17 @@ describe(__filename, function () { await callApi('deletePad', {padID: padId}); }); + it('createPad returns null deletionToken when allowPadDeletionByAllUsers is on', async function () { + // Anyone can delete the pad with no token at all, so the recovery token is + // pointless — matches the socket/UI path (issue #7926). + settings.allowPadDeletionByAllUsers = true; + const padId = makeId(); + const res = await callApi('createPad', {padID: padId}); + assert.equal(res.body.code, 0, JSON.stringify(res.body)); + assert.equal(res.body.data.deletionToken, null); + await callApi('deletePad', {padID: padId}); + }); + it('JWT admin call (no deletionToken) still works — admins stay trusted', async function () { const padId = makeId(); await callApi('createPad', {padID: padId}); diff --git a/src/tests/backend/specs/domline_list_start.ts b/src/tests/backend/specs/domline_list_start.ts new file mode 100644 index 000000000..17700483e --- /dev/null +++ b/src/tests/backend/specs/domline_list_start.ts @@ -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 `
      ` 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>` + * 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(''); + 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>')); + assert.equal(node.querySelector('svg'), null, + 'malicious start value must not be parsed into a live element'); + assert.ok(!node.innerHTML.includes(' 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
        start attribute'); + }); +}); diff --git a/src/tests/backend/specs/downstream/generate-vectors.ts b/src/tests/backend/specs/downstream/generate-vectors.ts new file mode 100644 index 000000000..5d821a3b1 --- /dev/null +++ b/src/tests/backend/specs/downstream/generate-vectors.ts @@ -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; + 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}`); +} diff --git a/src/tests/backend/specs/downstream/wire-http-api.ts b/src/tests/backend/specs/downstream/wire-http-api.ts new file mode 100644 index 000000000..ce688151b --- /dev/null +++ b/src/tests/backend/specs/downstream/wire-http-api.ts @@ -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'); + }); +}); diff --git a/src/tests/backend/specs/downstream/wire-socket-sequence.ts b/src/tests/backend/specs/downstream/wire-socket-sequence.ts new file mode 100644 index 000000000..3aa829362 --- /dev/null +++ b/src/tests/backend/specs/downstream/wire-socket-sequence.ts @@ -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'); + }); +}); diff --git a/src/tests/backend/specs/downstream/wire-vectors.ts b/src/tests/backend/specs/downstream/wire-vectors.ts new file mode 100644 index 000000000..3fc5c092d --- /dev/null +++ b/src/tests/backend/specs/downstream/wire-vectors.ts @@ -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`); + } + }); +}); diff --git a/src/tests/backend/specs/padDeletionUiPlacement.ts b/src/tests/backend/specs/padDeletionUiPlacement.ts new file mode 100644 index 000000000..e23596b80 --- /dev/null +++ b/src/tests/backend/specs/padDeletionUiPlacement.ts @@ -0,0 +1,44 @@ +'use strict'; + +import {MapArrayType} from '../../../node/types/MapType'; +import settings from '../../../node/utils/Settings'; + +const assert = require('assert').strict; +const common = require('../common'); + +// Regression coverage for issue #7959. The token-less "Delete pad" button +// (#delete-pad) used to be nested inside the `enablePadWideSettings`-gated +// pad-settings section, so disabling pad-wide settings removed the only way to +// delete a pad without a recovery token. Pad deletion is unrelated to pad-wide +// settings, so the button must be rendered regardless of that flag (its +// visibility is then driven at runtime by clientVars.canDeletePad). +describe(__filename, function () { + this.timeout(30000); + let agent: any; + const backup: MapArrayType = {}; + + before(async function () { agent = await common.init(); }); + + beforeEach(async function () { + backup.enablePadWideSettings = settings.enablePadWideSettings; + }); + + afterEach(async function () { + settings.enablePadWideSettings = backup.enablePadWideSettings; + }); + + const hasDeletePadButton = (html: string): boolean => + /id="delete-pad"/.test(html); + + it('renders the Delete pad button with pad-wide settings enabled', async function () { + settings.enablePadWideSettings = true; + const res = await agent.get('/p/deleteUiPlacementOn').expect(200); + assert.equal(hasDeletePadButton(res.text), true); + }); + + it('renders the Delete pad button with pad-wide settings disabled (#7959)', async function () { + settings.enablePadWideSettings = false; + const res = await agent.get('/p/deleteUiPlacementOff').expect(200); + assert.equal(hasDeletePadButton(res.text), true); + }); +}); diff --git a/src/tests/backend/specs/security.ts b/src/tests/backend/specs/security.ts new file mode 100644 index 000000000..47663e223 --- /dev/null +++ b/src/tests/backend/specs/security.ts @@ -0,0 +1,78 @@ +'use strict'; + +const assert = require('assert').strict; +// The escaping helpers are a client module, but they are pure (no browser +// globals) so they can be exercised directly from a backend spec. This locks +// the byte-for-byte output of the helpers vendored from the (now removed) +// `security` npm package, so the vendoring can never silently drift. +const Security = require('../../../static/js/security'); + +describe(__filename, function () { + describe('public API', function () { + it('exposes the full set of helpers plugins may rely on', function () { + for (const fn of [ + 'escapeHTML', 'escapeHTMLAttribute', + 'encodeJavaScriptIdentifier', 'encodeJavaScriptString', 'encodeJavaScriptData', + 'encodeCSSIdentifier', 'encodeCSSString', + ]) { + assert.equal(typeof Security[fn], 'function', `Security.${fn} must be a function`); + } + }); + }); + + describe('escapeHTML', function () { + it('escapes &, <, >, ", \' and / per OWASP', function () { + assert.equal(Security.escapeHTML('/&\''), + '<a href="x">/&''); + }); + it('neutralises a script tag', function () { + assert.equal(Security.escapeHTML(''), + '<script>alert(1)</script>'); + }); + it('leaves plain alphanumerics untouched', function () { + assert.equal(Security.escapeHTML('Hello World 123'), 'Hello World 123'); + }); + it('passes falsy input straight through', function () { + assert.equal(Security.escapeHTML(''), ''); + }); + }); + + describe('escapeHTMLAttribute', function () { + it('hex-encodes non-alphanumeric ASCII not covered by named entities', function () { + assert.equal(Security.escapeHTMLAttribute('a b'), 'a b'); + // hex is lowercased, matching the original `security` package output. + assert.equal(Security.escapeHTMLAttribute('javascript:alert(1)'), + 'javascript:alert(1)'); + }); + it('prefers named entities for &, <, >, ", \', /', function () { + assert.equal(Security.escapeHTMLAttribute('<>&"\'/'), + '<>&"'/'); + }); + it('leaves alphanumerics untouched', function () { + assert.equal(Security.escapeHTMLAttribute('abcXYZ0189'), 'abcXYZ0189'); + }); + }); + + describe('javascript / css encoders', function () { + it('encodeJavaScriptString quotes and backslash-u-escapes specials', function () { + assert.equal(Security.encodeJavaScriptString('a', c: 'x"y', d: 'a\\b'}), + '{"a":"\\u003cb\\u003e","c":"x\\u0022y","d":"a\\u005cb"}'); + }); + it('encodeJavaScriptData regex is linear (ReDoS guard)', function () { + // The JSON-string-literal regex used to be /"(?:\\.|[^"])*"/, which + // backtracks exponentially on an unterminated string of `\!` repeats. + // Run the regex directly on adversarial input and assert it returns fast. + const evil = `"${'\\!'.repeat(50000)}`; // no closing quote + const start = Date.now(); + /"(?:[^"\\]|\\.)*"/gm.test(evil); + assert.ok(Date.now() - start < 1000, 'regex must not backtrack exponentially'); + }); + }); +}); diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index 23290156f..4409d0910 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -197,4 +197,143 @@ describe(__filename, function () { assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true); }); }); + + // Regression test for ether/etherpad#7911. + // Air-gapped / firewalled deployments must be able to disable Etherpad's + // outbound calls (version check + plugin catalogue + self-updater) purely + // via environment variables, without editing settings.json inside the image. + // These assertions parse the *shipped* settings.json.docker so a future edit + // that drops the ${ENV} placeholders fails loudly here. + describe('offline / air-gapped env overrides (issue #7911)', function () { + const dockerSettings = path.join(__dirname, '../../../../settings.json.docker'); + const templateSettings = path.join(__dirname, '../../../../settings.json.template'); + const envVars = [ + 'PRIVACY_UPDATE_CHECK', 'PRIVACY_PLUGIN_CATALOG', 'UPDATES_TIER', + 'UPDATES_SOURCE', 'UPDATES_CHANNEL', 'UPDATES_CHECK_INTERVAL_HOURS', + 'UPDATES_GITHUB_REPO', 'UPDATES_REQUIRE_ADMIN_FOR_STATUS', 'UPDATE_SERVER', + ]; + const saved: {[k: string]: string | undefined} = {}; + + before(function () { for (const v of envVars) saved[v] = process.env[v]; }); + afterEach(function () { + for (const v of envVars) { + if (saved[v] == null) delete process.env[v]; + else process.env[v] = saved[v]; + } + }); + + it('keeps shipped defaults when no env vars are set', function () { + for (const v of envVars) delete process.env[v]; + const s = exportedForTestingOnly.parseSettings(dockerSettings, true); + assert.strictEqual(s!.privacy.updateCheck, true); + assert.strictEqual(s!.privacy.pluginCatalog, true); + assert.strictEqual(s!.updates.tier, 'notify'); + assert.strictEqual(s!.updates.checkIntervalHours, 6); + assert.strictEqual(s!.updateServer, 'https://etherpad.org/ep_infos'); + }); + + it('disables all outbound calls when the offline env vars are set', function () { + process.env.PRIVACY_UPDATE_CHECK = 'false'; + process.env.PRIVACY_PLUGIN_CATALOG = 'false'; + process.env.UPDATES_TIER = 'off'; + const s = exportedForTestingOnly.parseSettings(dockerSettings, true); + // Coerced to real booleans, not the strings "false". + assert.strictEqual(s!.privacy.updateCheck, false); + assert.strictEqual(s!.privacy.pluginCatalog, false); + assert.strictEqual(s!.updates.tier, 'off'); + }); + + it('honours the remaining updates.* and updateServer overrides', function () { + process.env.UPDATES_SOURCE = 'gitlab'; + process.env.UPDATES_CHANNEL = 'beta'; + process.env.UPDATES_CHECK_INTERVAL_HOURS = '24'; + process.env.UPDATES_GITHUB_REPO = 'acme/etherpad-fork'; + process.env.UPDATES_REQUIRE_ADMIN_FOR_STATUS = 'true'; + process.env.UPDATE_SERVER = 'https://mirror.internal/ep_infos'; + const s = exportedForTestingOnly.parseSettings(dockerSettings, true); + assert.strictEqual(s!.updates.source, 'gitlab'); + assert.strictEqual(s!.updates.channel, 'beta'); + assert.strictEqual(s!.updates.checkIntervalHours, 24); // numeric coercion + assert.strictEqual(s!.updates.githubRepo, 'acme/etherpad-fork'); + assert.strictEqual(s!.updates.requireAdminForStatus, true); // boolean coercion + assert.strictEqual(s!.updateServer, 'https://mirror.internal/ep_infos'); + }); + + // The source-install template carries the same placeholders so non-Docker + // deployments get the offline knobs too. + it('settings.json.template exposes the same offline overrides', function () { + for (const v of envVars) delete process.env[v]; + const dflt = exportedForTestingOnly.parseSettings(templateSettings, true); + assert.strictEqual(dflt!.privacy.updateCheck, true); + assert.strictEqual(dflt!.privacy.pluginCatalog, true); + assert.strictEqual(dflt!.updates.tier, 'notify'); + assert.strictEqual(dflt!.updateServer, 'https://etherpad.org/ep_infos'); + + process.env.UPDATES_TIER = 'off'; + process.env.PRIVACY_UPDATE_CHECK = 'false'; + process.env.PRIVACY_PLUGIN_CATALOG = 'false'; + const over = exportedForTestingOnly.parseSettings(templateSettings, true); + assert.strictEqual(over!.updates.tier, 'off'); + assert.strictEqual(over!.privacy.updateCheck, false); + assert.strictEqual(over!.privacy.pluginCatalog, false); + }); + }); + + // Regression test for the jsonminify -> jsonc-parser migration. + // The old parser was `jsonminify(str).replace(',]', ']').replace(',}', '}')`, + // which had two correctness bugs that jsonc-parser (allowTrailingComma) fixes: + // 1. String#replace with a string needle only swaps the FIRST match, so a + // file with more than one trailing comma of the same kind stayed invalid. + // 2. The blind ',]' / ',}' replace also corrupted those byte sequences when + // they appeared *inside* a string value (e.g. a URL or literal text). + describe('JSONC settings parsing (jsonminify -> jsonc-parser)', function () { + const fs = require('fs'); + const os = require('os'); + let tmpFile: string; + + const writeTmp = (contents: string) => { + tmpFile = path.join(os.tmpdir(), `ep-settings-jsonc-${process.pid}.json`); + fs.writeFileSync(tmpFile, contents); + return tmpFile; + }; + + afterEach(function () { + if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ } } + }); + + it('strips comments and tolerates multiple trailing commas', function () { + const file = writeTmp(`// leading line comment +/* block comment */ +{ + "list": [ + "a", + "b", + ], + "nested": [ + [1, 2,], + [3, 4,], + ], + "obj": { + "x": 1, + "y": 2, + }, +}`); + const s: any = exportedForTestingOnly.parseSettings(file, true); + assert.deepEqual(s.list, ['a', 'b']); + assert.deepEqual(s.nested, [[1, 2], [3, 4]]); + assert.deepEqual(s.obj, {x: 1, y: 2}); + }); + + it('does not corrupt ",]" / ",}" sequences inside string values', function () { + const file = writeTmp(`{ + "url": "http://example.com/a,]b,}c", + "text": "trailing-comma-like ,] and ,} must survive" +}`); + const s: any = exportedForTestingOnly.parseSettings(file, true); + // The old replace() would have stripped the comma and produced + // "http://example.com/a]b}c" here. + assert.strictEqual(s.url, 'http://example.com/a,]b,}c'); + assert.strictEqual(s.text, 'trailing-comma-like ,] and ,} must survive'); + }); + }); }); diff --git a/src/tests/backend/specs/socketio.ts b/src/tests/backend/specs/socketio.ts index 43da20b15..209b7610f 100644 --- a/src/tests/backend/specs/socketio.ts +++ b/src/tests/backend/specs/socketio.ts @@ -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,170 @@ describe(__filename, function () { }); }); + describe('Pad deletion token issuance (#7926)', function () { + let getAuthorIdBackup: any; + + const removeIfExists = async (padId: string) => { + if (await padManager.doesPadExist(padId)) { + const p = await padManager.getPad(padId); + await p.remove(); + } + }; + + // A getAuthorId hook that pins authorID to the authenticated username — the + // documented way (doc/api/hooks_server-side) to give a user a stable + // identity across cookie clears and devices. Its mere presence is what makes + // an authenticated session "durable" for token-suppression purposes. + const installStableIdentityHook = () => { + plugins.hooks.getAuthorId = [{hook_fn: async (hookName: string, context: any) => { + const username = context.user && context.user.username; + if (!username) return; + context.dbKey = `username=${username}`; + return ''; + }}]; + }; + + beforeEach(async function () { + // @ts-ignore - public setting toggled per test + settings.allowPadDeletionByAllUsers = false; + // The outer harness only backs up preAuthorize/authenticate/authorize, so + // manage getAuthorId ourselves to avoid leaking it into later specs. + getAuthorIdBackup = plugins.hooks.getAuthorId; + plugins.hooks.getAuthorId = []; + await removeIfExists('pad'); + }); + afterEach(async function () { + if (socket) socket.close(); + socket = null; + plugins.hooks.getAuthorId = getAuthorIdBackup; + 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); + assert.equal(cv.data.canDeleteWithoutToken, false); + // The creator can always delete without a token on this device, so the + // plain "Delete pad" button is offered (issue #7959). + assert.equal(cv.data.canDeletePad, true); + }); + + 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); + assert.equal(cv.data.canDeleteWithoutToken, true); + assert.equal(cv.data.canDeletePad, true); + }); + + it('non-creator gets canDeletePad=false by default, true under allowPadDeletionByAllUsers (#7959)', + async function () { + const supertest = require('supertest'); + // The creator (default cookie jar) establishes the pad's rev-0 author. + const resCreator = await agent.get('/p/pad').expect(200); + socket = await common.connect(resCreator); + const cvCreator: any = await common.handshake(socket, 'pad'); + assert.equal(cvCreator.data.canDeletePad, true, 'creator can always delete'); + + // A different browser (separate cookie jar) is NOT the creator, so with + // allowPadDeletionByAllUsers off it must not be offered the token-less + // Delete pad button. + const otherBrowser = supertest(common.baseUrl); + const resOther = await otherBrowser.get('/p/pad').expect(200); + const otherSocket = await common.connect(resOther); + try { + const cvOther: any = await common.handshake(otherSocket, 'pad'); + assert.equal(cvOther.data.canDeletePad, false, + 'non-creator must not see Delete pad by default'); + } finally { + otherSocket.close(); + } + + // With everyone opted in, the same non-creator CAN delete, so the + // button must be offered — independent of enablePadWideSettings (#7959). + // @ts-ignore - public setting toggled per test + settings.allowPadDeletionByAllUsers = true; + const otherBrowser2 = supertest(common.baseUrl); + const resOther2 = await otherBrowser2.get('/p/pad').expect(200); + const otherSocket2 = await common.connect(resOther2); + try { + const cvOther2: any = await common.handshake(otherSocket2, 'pad'); + assert.equal(cvOther2.data.canDeletePad, true, + 'allowPadDeletionByAllUsers must offer Delete pad to everyone'); + } finally { + otherSocket2.close(); + } + }); + + it('readonly viewer is denied canDeletePad and token-less deletion under allowPadDeletionByAllUsers (#7959)', + async function () { + // @ts-ignore - public setting toggled per test + settings.allowPadDeletionByAllUsers = true; + // Creator establishes the pad (rev-0 author) and yields its read-only id. + const resCreator = await agent.get('/p/pad').expect(200); + const creatorSocket = await common.connect(resCreator); + const cvCreator: any = await common.handshake(creatorSocket, 'pad'); + const readOnlyId = cvCreator.data.readOnlyId; + assert.ok(readOnlyManager.isReadOnlyId(readOnlyId)); + creatorSocket.close(); + + // A read-only viewer must NOT be offered the token-less delete button, + // even with deletion opened to all users — readonly viewers cannot edit, + // let alone delete (issue #7959). + const resRo = await agent.get(`/p/${readOnlyId}`).expect(200); + socket = await common.connect(resRo); + const cvRo: any = await common.handshake(socket, readOnlyId); + assert.equal(cvRo.data.readonly, true); + assert.equal(cvRo.data.canDeletePad, false, + 'readonly viewers must not get the token-less Delete pad button'); + + // ...and the server must refuse a token-less PAD_DELETE from a readonly + // session, or allowPadDeletionByAllUsers becomes a data-loss hole. + await common.sendPadDelete(socket, {padId: 'pad'}).catch(() => {}); + assert.ok(await padManager.doesPadExist('pad'), + 'readonly session must not be able to delete the pad without a token'); + }); + + it('authenticated creator WITHOUT a getAuthorId hook still gets a token', async function () { + // requireAuthentication alone is NOT durable: the authorID still comes from + // the per-browser token cookie, so this user would be stranded on a second + // device if the token were withheld. They must keep getting one. + settings.requireAuthentication = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').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'); + assert.equal(cv.data.canDeleteWithoutToken, false); + assert.equal(cv.data.canDeletePad, true); + }); + + it('authenticated creator WITH a getAuthorId hook gets no token (durable identity)', + async function () { + settings.requireAuthentication = true; + installStableIdentityHook(); + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const cv: any = await common.handshake(socket, 'pad'); + assert.equal(cv.type, 'CLIENT_VARS'); + assert.equal(cv.data.padDeletionToken, null); + assert.equal(cv.data.canDeleteWithoutToken, true); + assert.equal(cv.data.canDeletePad, true); + }); + }); + describe('SocketIORouter.js', function () { const Module = class { setSocketIO(io:any) {} diff --git a/src/tests/container/loadSettings.js b/src/tests/container/loadSettings.js index b59ff0165..7b3174503 100644 --- a/src/tests/container/loadSettings.js +++ b/src/tests/container/loadSettings.js @@ -13,15 +13,16 @@ */ const fs = require('fs'); -const jsonminify = require('jsonminify'); +const {parse: parseJsonc} = require('jsonc-parser'); function loadSettings() { let settingsStr = fs.readFileSync(`${__dirname}/../../../settings.json.docker`).toString(); // try to parse the settings try { if (settingsStr) { - settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); - const settings = JSON.parse(settingsStr); + // jsonc-parser tolerates the comments and trailing commas in the docker + // settings file, matching node/utils/Settings.ts. + const settings = parseJsonc(settingsStr, [], {allowTrailingComma: true}); // custom settings for running in a container settings.ip = 'localhost'; diff --git a/src/tests/downstream/clients.json b/src/tests/downstream/clients.json new file mode 100644 index 000000000..b4013a72f --- /dev/null +++ b/src/tests/downstream/clients.json @@ -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" + } +] diff --git a/src/tests/downstream/run-clients.sh b/src/tests/downstream/run-clients.sh new file mode 100755 index 000000000..e2f386820 --- /dev/null +++ b/src/tests/downstream/run-clients.sh @@ -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 /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" diff --git a/src/tests/fixtures/wire-vectors.json b/src/tests/fixtures/wire-vectors.json new file mode 100644 index 000000000..86870ae6e --- /dev/null +++ b/src/tests/fixtures/wire-vectors.json @@ -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" + } +] diff --git a/src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts b/src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts new file mode 100644 index 000000000..11b0e60cc --- /dev/null +++ b/src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts @@ -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); + }); +}); diff --git a/src/tests/frontend-new/specs/language.spec.ts b/src/tests/frontend-new/specs/language.spec.ts index dc80785dd..b32e628e1 100644 --- a/src/tests/frontend-new/specs/language.spec.ts +++ b/src/tests/frontend-new/specs/language.spec.ts @@ -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
      1. 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() + } + }); }); diff --git a/src/tests/frontend-new/specs/pad_settings.spec.ts b/src/tests/frontend-new/specs/pad_settings.spec.ts index 1fbd74f86..070491cef 100644 --- a/src/tests/frontend-new/specs/pad_settings.spec.ts +++ b/src/tests/frontend-new/specs/pad_settings.spec.ts @@ -3,7 +3,7 @@ import {goToNewPad, goToPad, sendChatMessage, showChat} from "../helper/padHelpe import {showSettings} from "../helper/settingsHelper"; test.describe('creator-owned pad settings', () => { - test('shows pad settings only to the creator and keeps delete pad there', async ({page, browser}) => { + test('shows pad settings only to the creator; delete pad is creator-gated but separate', async ({page, browser}) => { const padId = await goToNewPad(page); const context2 = await browser.newContext(); @@ -19,6 +19,9 @@ test.describe('creator-owned pad settings', () => { await expect(page.locator('#pad-settings-section')).toBeVisible(); await expect(page.locator('#delete-pad')).toBeVisible(); await expect(page.locator('#padsettings-enforcecheck')).toBeVisible(); + // The delete-pad button is no longer nested inside the pad-wide settings + // section: deletion is independent of enablePadWideSettings (issue #7959). + await expect(page.locator('#pad-settings-section #delete-pad')).toHaveCount(0); await expect(page2.locator('#user-settings-section > h2')).toHaveText('User Settings'); await expect(page2.locator('#theme-toggle-row')).toBeVisible(); diff --git a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts index 056d232e9..bd0206f6a 100644 --- a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts +++ b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts @@ -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 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)'); + }); }); diff --git a/src/tests/frontend-new/specs/timeslider_deeplink.spec.ts b/src/tests/frontend-new/specs/timeslider_deeplink.spec.ts new file mode 100644 index 000000000..d4441ef9d --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_deeplink.spec.ts @@ -0,0 +1,53 @@ +import {expect, Page, test} from "@playwright/test"; +import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper"; + +// Ported from the "jumps to a revision given in the url" case of the legacy +// timeslider_revisions.js (which no CI workflow ran). Re-targeted at the in-pad +// history model (#7659): a #rev/N hash on the pad URL boots straight into +// history mode at that revision (pad_mode.bootstrapFromHash), and the legacy +// #N shortlink form is still accepted for old bookmarks. +test.describe('timeslider deep link', function () { + test.describe.configure({mode: 'serial'}); + + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + const expectHistoryAtRev0 = async (page: Page) => { + await expect(page.locator('body.history-mode')).toBeVisible({timeout: 15000}); + await expect(page.locator('#history-controls')).toBeVisible(); + // bootstrapFromHash canonicalizes any accepted hash form to #rev/0. + await expect.poll(() => new URL(page.url()).hash, {timeout: 15000}).toBe('#rev/0'); + // The slider lands on revision 0 once pad_mode syncs from the embedded + // BroadcastSlider — the signal that we're actually viewing that revision. + await expect.poll( + async () => await page.locator('#history-slider-input').evaluate( + (el) => Number((el as HTMLInputElement).value)), + {timeout: 15000}).toBe(0); + }; + + test('#rev/N hash boots into history mode at that revision', async function ({page}) { + const padId = await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'One '); + await page.waitForTimeout(400); + await writeToPad(page, 'Two '); + await page.waitForTimeout(800); + + // Deep-link to revision 0 of the same pad. + await page.goto(`http://localhost:9001/p/${padId}#rev/0`); + await expectHistoryAtRev0(page); + }); + + test('legacy #N shortlink hash still enters history mode', async function ({page}) { + const padId = await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'One '); + await page.waitForTimeout(400); + await writeToPad(page, 'Two '); + await page.waitForTimeout(800); + + await page.goto(`http://localhost:9001/p/${padId}#0`); + await expectHistoryAtRev0(page); + }); +}); diff --git a/src/tests/frontend-new/specs/timeslider_export_links.spec.ts b/src/tests/frontend-new/specs/timeslider_export_links.spec.ts new file mode 100644 index 000000000..e9ef7adba --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_export_links.spec.ts @@ -0,0 +1,88 @@ +import {expect, Page, test} from "@playwright/test"; +import {clearPadContent, goToPad, writeToPad} from "../helper/padHelper"; + +// Ported from the legacy mocha suite (timeslider_numeric_padID.js and the +// "checks the export url" case in timeslider_revisions.js), neither of which +// ran in CI. Re-targeted at the in-pad history UI (#7659): the export links +// live in the outer #exportColumn and pad_mode.ts rewrites their hrefs to +// /p///export/ for the revision currently being viewed. +test.describe('timeslider export links', function () { + test.describe.configure({mode: 'serial'}); + + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + // Suppress the one-time pad-deletion-token modal (same trick goToNewPad uses) + // so it can't steal focus mid-test on a creator session. + const suppressDeletionTokenModal = async (page: Page) => { + await page.addInitScript(() => { + let stored: unknown; + Object.defineProperty(window, 'clientVars', { + configurable: true, + get() { return stored; }, + set(v) { + if (v != null && typeof v === 'object') { + (v as {padDeletionToken?: string | null}).padDeletionToken = null; + } + stored = v; + }, + }); + }); + }; + + const enterHistoryMode = async (page: Page) => { + await page.click('.buttonicon-history'); + await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'}); + await page.waitForSelector('#history-frame'); + }; + + const goToRevision = async (page: Page, rev: number) => { + await page.locator('#history-slider-input').evaluate((el, value) => { + (el as HTMLInputElement).value = String(value); + el.dispatchEvent(new Event('input', {bubbles: true})); + }, rev); + await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${rev}`, {timeout: 15000}); + }; + + const exportHref = (page: Page, id: string) => + page.locator(`#${id}`).getAttribute('href'); + + test('export hrefs target the viewed revision, including a numeric pad id', async function ({page}) { + // A numeric pad id is the specific case the legacy test guarded — the + // href rewriter must not confuse it with the revision segment. Use a + // high-entropy numeric id (timestamp + random) so reruns against a + // persistent DB can't collide on the same pad. + const padId = `${Date.now()}${Math.floor(Math.random() * 1000)}`; + await suppressDeletionTokenModal(page); + await goToPad(page, padId); // navigates and waits for the editor to be ready + await clearPadContent(page); + + await writeToPad(page, 'One '); + await page.waitForTimeout(400); + await writeToPad(page, 'Two '); + await page.waitForTimeout(800); + + await enterHistoryMode(page); + + // Wait for pad_mode to sync the slider max from the embedded BroadcastSlider. + await expect.poll( + async () => await page.locator('#history-slider-input').evaluate( + (el) => Number((el as HTMLInputElement).max)), + {timeout: 15000}).toBeGreaterThan(0); + const maxRev = await page.locator('#history-slider-input').evaluate( + (el) => Number((el as HTMLInputElement).max)); + expect(maxRev).toBeGreaterThan(0); + + // On entry the slider is at the latest revision; hrefs point there. + await expect.poll(() => exportHref(page, 'exporthtmla'), {timeout: 15000}) + .toContain(`/${padId}/${maxRev}/export/html`); + expect(await exportHref(page, 'exportplaina')).toContain(`/${padId}/${maxRev}/export/txt`); + + // Scrub to revision 0 — the export targets must follow. + await goToRevision(page, 0); + await expect.poll(() => exportHref(page, 'exporthtmla'), {timeout: 15000}) + .toContain(`/${padId}/0/export/html`); + expect(await exportHref(page, 'exportplaina')).toContain(`/${padId}/0/export/txt`); + }); +}); diff --git a/src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts b/src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts new file mode 100644 index 000000000..4cfcb70a4 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts @@ -0,0 +1,81 @@ +import {expect, Page, test} from "@playwright/test"; +import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper"; + +// Ported from the legacy mocha suite (src/tests/frontend/specs/timeslider_labels.js), +// which no CI workflow ran. Re-targeted at the in-pad history UI (#7659): the +// revision label and date are shown in the outer #history-banner, populated by +// pad_mode.ts mirroring the embedded timeslider's #revision_label / #revision_date. +// This guards the banner bridge against silently breaking again (cf. #7946). +test.describe('timeslider revision labels', function () { + test.describe.configure({mode: 'serial'}); + // The "Version N" label and "Saved , " date are localized + // (timeslider.version / timeslider.saved). Pin the locale so the assertions + // are deterministic and the date string stays Date-parseable. + test.use({locale: 'en-US'}); + + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + const enterHistoryMode = async (page: Page) => { + await page.click('.buttonicon-history'); + await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'}); + await page.waitForSelector('#history-frame'); + }; + + // Drive the outer slider (a remote control for the embedded BroadcastSlider) + // to a specific revision and wait for the banner to reflect it. + const goToRevision = async (page: Page, rev: number) => { + await page.locator('#history-slider-input').evaluate((el, value) => { + (el as HTMLInputElement).value = String(value); + el.dispatchEvent(new Event('input', {bubbles: true})); + }, rev); + await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${rev}`, {timeout: 15000}); + }; + + // "Saved June 12, 2026" -> a parseable Date (the banner mirrors the + // timeslider.saved l10n string "Saved {{month}} {{day}}, {{year}}"). + const parsedBannerDate = async (page: Page) => await page.locator('#history-banner-date').evaluate( + (el) => new Date((el.textContent || '').replace(/^Saved\s+/i, '')).getTime()); + + test('shows Version label and a valid date that update while scrubbing', async function ({page}) { + await goToNewPad(page); + await clearPadContent(page); + + // Produce a few revisions. + await writeToPad(page, 'Alpha '); + await page.waitForTimeout(400); + await writeToPad(page, 'Beta '); + await page.waitForTimeout(400); + await writeToPad(page, 'Gamma '); + await page.waitForTimeout(800); + + await enterHistoryMode(page); + + // On entry the slider sits at the latest revision; the banner must show a + // non-empty "Version N" label and a non-NaN date. Wait for pad_mode to sync + // the slider max from the embedded BroadcastSlider before reading it. + await expect.poll( + async () => await page.locator('#history-slider-input').evaluate( + (el) => Number((el as HTMLInputElement).max)), + {timeout: 15000}).toBeGreaterThan(0); + const maxRev = await page.locator('#history-slider-input').evaluate( + (el) => Number((el as HTMLInputElement).max)); + expect(maxRev).toBeGreaterThan(0); + + await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${maxRev}`); + const dateLast = await parsedBannerDate(page); + expect(Number.isNaN(dateLast)).toBe(false); + // The mirrored timer must also be a real, non-NaN datetime. + const timerLast = await page.locator('#history-timer').textContent(); + expect(Number.isNaN(new Date(timerLast || '').getTime())).toBe(false); + + // Scrub back to revision 0 — label and date must update. + await goToRevision(page, 0); + await expect(page.locator('#history-banner-rev')).toHaveText('Version 0'); + const dateFirst = await parsedBannerDate(page); + expect(Number.isNaN(dateFirst)).toBe(false); + // The latest revision is never older than revision 0. + expect(dateLast).toBeGreaterThanOrEqual(dateFirst); + }); +}); diff --git a/src/tests/frontend-new/specs/timeslider_saved_revisions.spec.ts b/src/tests/frontend-new/specs/timeslider_saved_revisions.spec.ts new file mode 100644 index 000000000..f99684c82 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_saved_revisions.spec.ts @@ -0,0 +1,97 @@ +import {expect, Page, test} from "@playwright/test"; +import {clearPadContent, goToNewPad, goToPad, writeToPad} from "../helper/padHelper"; + +// Regression coverage for #7946: after #7659 moved the timeslider into the pad +// as an embedded iframe, the user-facing control became the outer +// #history-slider-input range input. Saved revisions ("Save Revision" button) +// are still drawn as stars inside the now-hidden iframe slider, so they +// stopped appearing for users. pad_mode.ts must bridge the saved revisions out +// onto the outer slider as markers. +test.describe('timeslider saved-revision markers', function () { + test.describe.configure({mode: 'serial'}); + + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + const enterHistoryMode = async (page: Page) => { + await page.click('.buttonicon-history'); + await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'}); + await page.waitForSelector('#history-frame'); + }; + + test('a saved revision shows a marker on the outer history slider', async function ({page}) { + await goToNewPad(page); + await clearPadContent(page); + + // Build a few revisions, save one partway through, then add more so the + // saved marker lands in the middle of the slider (not under the thumb). + await writeToPad(page, 'One '); + await page.waitForTimeout(400); + await writeToPad(page, 'Two '); + await page.waitForTimeout(600); + + // Save a revision at the current head. + await page.click('.buttonicon-savedRevision'); + // The save confirmation gritter carries class `saved-revision`. + await page.waitForSelector('.saved-revision', {state: 'visible'}); + + // Add more edits so head advances past the saved revision. + await writeToPad(page, 'Three '); + await page.waitForTimeout(400); + await writeToPad(page, 'Four '); + await page.waitForTimeout(800); + + await enterHistoryMode(page); + + // The outer slider must show at least one visible saved-revision marker. + const marker = page.locator('.history-star'); + await expect(marker.first()).toBeVisible({timeout: 15000}); + expect(await marker.count()).toBeGreaterThanOrEqual(1); + + // The marker must be positioned within the slider track, not collapsed to + // the origin. Assert on the inline left percentage directly (markers are + // always positioned via style.left = "N%"); a fallback to a layout-derived + // coordinate would let a degenerate left:0% render still pass. + const leftPct = await marker.first().evaluate( + (el) => parseFloat((el as HTMLElement).style.left)); + expect(leftPct).toBeGreaterThan(0); + expect(leftPct).toBeLessThan(100); + }); + + test('a revision saved live appears on an already-open history slider', async function ({browser}) { + // Reviewer (history viewer). + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const padId = await goToNewPad(pageA); + await clearPadContent(pageA); + await writeToPad(pageA, 'Alpha Beta '); + await pageA.waitForTimeout(600); + await pageA.click('.buttonicon-savedRevision'); + await pageA.waitForSelector('.saved-revision', {state: 'visible'}); + await writeToPad(pageA, 'Gamma Delta '); + await pageA.waitForTimeout(800); + await enterHistoryMode(pageA); + await expect(pageA.locator('.history-star').first()).toBeVisible({timeout: 15000}); + const before = await pageA.locator('.history-star').count(); + + // A second collaborator keeps editing the live pad and saves a revision + // while pageA is sitting in history mode. The server must broadcast + // NEW_SAVEDREV so pageA's open timeslider gains a marker without reloading. + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await goToPad(pageB, padId); + await writeToPad(pageB, 'Epsilon Zeta Eta '); + await pageB.waitForTimeout(800); + await pageB.click('.buttonicon-savedRevision'); + await pageB.waitForSelector('.saved-revision', {state: 'visible'}); + + await expect.poll( + async () => await pageA.locator('.history-star').count(), + {timeout: 20000}) + .toBeGreaterThan(before); + + await ctxA.close(); + await ctxB.close(); + }); +}); diff --git a/src/tests/frontend/specs/timeslider_labels.js b/src/tests/frontend/specs/timeslider_labels.js deleted file mode 100644 index c43552c6b..000000000 --- a/src/tests/frontend/specs/timeslider_labels.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -describe('timeslider', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - /** - * @todo test authorsList - */ - it("Shows a date/time in the timeslider and make sure it doesn't include NaN", async function () { - this.timeout(12000); - // make some changes to produce 3 revisions - const revs = 3; - - for (let i = 0; i < revs; i++) { - await helper.edit('a\n'); - } - - await helper.gotoTimeslider(revs); - await helper.waitForPromise(() => helper.contentWindow().location.hash === `#${revs}`); - - // the datetime of last edit - const timerTimeLast = new Date(helper.timesliderTimerTime()).getTime(); - - // the day of this revision, e.g. August 12, 2020 (stripped the string "Saved") - const dateLast = new Date(helper.revisionDateElem().substr(6)).getTime(); - - // the label/revision, ie Version 3 - const labelLast = helper.revisionLabelElem().text(); - - // the datetime should be a date - expect(Number.isNaN(timerTimeLast)).to.eql(false); - - // the Date object of the day should not be NaN - expect(Number.isNaN(dateLast)).to.eql(false); - - // the label should be Version `Number` - expect(labelLast).to.be(`Version ${revs}`); - - // Click somewhere left on the timeslider to go to revision 0 - helper.sliderClick(1); - - // the datetime of last edit - const timerTime = new Date(helper.timesliderTimerTime()).getTime(); - - // the day of this revision, e.g. August 12, 2020 - const date = new Date(helper.revisionDateElem().substr(6)).getTime(); - - // the label/revision, e.g. Version 0 - const label = helper.revisionLabelElem().text(); - - // the datetime should be a date - expect(Number.isNaN(timerTime)).to.eql(false); - // the last revision should be newer or have the same time - expect(timerTimeLast).to.not.be.lessThan(timerTime); - - // the Date object of the day should not be NaN - expect(Number.isNaN(date)).to.eql(false); - - // the label should be Version 0 - expect(label).to.be('Version 0'); - }); -}); diff --git a/src/tests/frontend/specs/timeslider_numeric_padID.js b/src/tests/frontend/specs/timeslider_numeric_padID.js deleted file mode 100644 index a1ba6728f..000000000 --- a/src/tests/frontend/specs/timeslider_numeric_padID.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -describe('timeslider', function () { - const padId = 735773577357 + (Math.round(Math.random() * 1000)); - - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad({id: padId}); - }); - - it('Makes sure the export URIs are as expected when the padID is numeric', async function () { - await helper.edit('a\n'); - - await helper.gotoTimeslider(1); - - // ensure we are on revision 1 - await helper.waitForPromise(() => helper.contentWindow().location.hash === '#1'); - - // expect URI to be similar to - // http://192.168.1.48:9001/p/2/1/export/html - // http://192.168.1.48:9001/p/735773577399/1/export/html - const rev1ExportLink = helper.contentWindow().$('#exporthtmla').attr('href'); - expect(rev1ExportLink).to.contain('/1/export/html'); - - // Click somewhere left on the timeslider to go to revision 0 - helper.sliderClick(30); - - const rev0ExportLink = helper.contentWindow().$('#exporthtmla').attr('href'); - expect(rev0ExportLink).to.contain('/0/export/html'); - }); -}); diff --git a/src/tests/frontend/specs/timeslider_revisions.js b/src/tests/frontend/specs/timeslider_revisions.js deleted file mode 100644 index c2de15c42..000000000 --- a/src/tests/frontend/specs/timeslider_revisions.js +++ /dev/null @@ -1,150 +0,0 @@ -'use strict'; - -describe('timeslider', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('loads adds a hundred revisions', async function () { - this.timeout(100000); - const chrome$ = helper.padChrome$; - - // Create a bunch of revisions. - for (let i = 0; i < 99; i++) await helper.edit('a'); - chrome$('.buttonicon-savedRevision').trigger('click'); - await helper.waitForPromise(() => helper.padChrome$('.saved-revision').length > 0); - // Give some time to send the SAVE_REVISION message to the server before navigating away. - await new Promise((resolve) => setTimeout(resolve, 100)); - - // go to timeslider - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`); - - await new Promise((resolve) => setTimeout(resolve, 6000)); - - const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - const $sliderBar = timeslider$('#ui-slider-bar'); - - const latestContents = timeslider$('#innerdocbody').text(); - - // Click somewhere on the timeslider - let e = new jQuery.Event('mousedown'); - // sets y co-ordinate of the pad slider modal. - const base = (timeslider$('#ui-slider-bar').offset().top - 24); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = base + 5; - $sliderBar.trigger(e); - - e = new jQuery.Event('mousedown'); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = base; - $sliderBar.trigger(e); - - e = new jQuery.Event('mousedown'); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = base - 5; - $sliderBar.trigger(e); - - $sliderBar.trigger('mouseup'); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // make sure the text has changed - expect(timeslider$('#innerdocbody').text()).not.to.eql(latestContents); - const starIsVisible = timeslider$('.star').is(':visible'); - expect(starIsVisible).to.eql(true); - }); - - - // Disabled as jquery trigger no longer works properly - xit('changes the url when clicking on the timeslider', async function () { - // Create some revisions. - for (let i = 0; i < 20; i++) await helper.edit('a'); - - // go to timeslider - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`); - - await new Promise((resolve) => setTimeout(resolve, 6000)); - - const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - const $sliderBar = timeslider$('#ui-slider-bar'); - - const oldUrl = $('#iframe-container iframe')[0].contentWindow.location.hash; - - // Click somewhere on the timeslider - const e = new jQuery.Event('mousedown'); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = 60; - $sliderBar.trigger(e); - - await helper.waitForPromise( - () => $('#iframe-container iframe')[0].contentWindow.location.hash !== oldUrl, 6000); - }); - - it('jumps to a revision given in the url', async function () { - const inner$ = helper.padInner$; - this.timeout(40000); - - // wait for the text to be loaded - await helper.waitForPromise(() => inner$('body').text().length !== 0, 10000); - - const newLines = inner$('body div').length; - const oldLength = inner$('body').text().length + newLines / 2; - expect(oldLength).to.not.eql(0); - inner$('div').first().sendkeys('a'); - let timeslider$; - - // wait for our additional revision to be added - await helper.waitForPromise(() => { - // newLines takes the new lines into account which are strippen when using - // inner$('body').text(), one
        is used for one line in ACE. - const lenOkay = inner$('body').text().length + newLines / 2 !== oldLength; - // this waits for the color to be added to our , which means that the revision - // was accepted by the server. - const colorOkay = inner$('span').first().attr('class').indexOf('author-') === 0; - return lenOkay && colorOkay; - }, 10000); - - // go to timeslider with a specific revision set - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`); - - // wait for the timeslider to be loaded - await helper.waitForPromise(() => { - try { - timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - } catch (e) { - // Empty catch block <3 - } - return timeslider$ && timeslider$('#innerdocbody').text().length === oldLength; - }, 10000); - }); - - it('checks the export url', async function () { - const inner$ = helper.padInner$; - this.timeout(11000); - inner$('div').first().sendkeys('a'); - - await new Promise((resolve) => setTimeout(resolve, 2500)); - - // go to timeslider - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`); - let timeslider$; - let exportLink; - - await helper.waitForPromise(() => { - try { - timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - } catch (e) { - // Empty catch block <3 - } - if (!timeslider$) return false; - exportLink = timeslider$('#exportplaina').attr('href'); - if (!exportLink) return false; - return exportLink.substr(exportLink.length - 12) === '0/export/txt'; - }, 6000); - }); -}); diff --git a/ui/package.json b/ui/package.json index 100a3b19b..578590117 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,6 @@ "devDependencies": { "ep_etherpad-lite": "workspace:../src", "typescript": "^6.0.3", - "vite": "^8.0.16" + "vite": "^8.1.4" } }