etherpad-lite/snap/tests/test-cli.sh
John McLear 02e37e0112
feat(packaging): publish Etherpad as a Snap (#7558)
* feat(packaging): publish Etherpad as a Snap

Adds first-class Snap packaging so Ubuntu / snapd users can install via
`sudo snap install etherpad-lite`.

- snap/snapcraft.yaml — core24, strict confinement, builds with pnpm
  against a pinned Node.js 22 runtime. Version is auto-derived from
  src/package.json so `snap info` tracks upstream release numbering.
- snap/local/bin/etherpad-service — launch wrapper that seeds
  $SNAP_COMMON/etc/settings.json on first run (rewriting the default
  dirty-DB path to a writable $SNAP_COMMON location) and execs Etherpad
  via `node --import tsx/esm`.
- snap/local/bin/etherpad-healthcheck-wrapper — HTTP probe for external
  supervisors, falling back to Node if curl isn't staged.
- snap/local/bin/etherpad-cli — thin passthrough to Etherpad's bin/
  scripts (importSqlFile, checkPad, etc.).
- snap/hooks/configure — exposes `snap set etherpad-lite port=<n>` and
  `ip=<addr>` with validation, restarts the service when running.
- snap/README.md — build / install / configure / publish instructions.
- .github/workflows/snap-publish.yml — builds on every v* tag, uploads
  a short-lived artifact, publishes to `edge`, and then promotes to
  `stable` through a manually-approved GitHub Environment. Requires a
  one-time `snapcraft register etherpad-lite` plus provisioning of the
  `SNAPCRAFT_STORE_CREDENTIALS` repo secret (instructions inline).

Pad data (dirty DB, logs) lives in /var/snap/etherpad-lite/common/ and
survives snap refreshes. The read-only $SNAP squashfs is never written
to at runtime.

Refs #7529

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

* fix(snap): pass --settings flag, env-subst ip/port, 2-space indent

Addresses Qodo review feedback on #7558:

1. Settings file ignored: Etherpad's Settings loader reads `argv.settings`,
   not the `EP_SETTINGS` env var. Without `--settings`, the launcher's
   seeded $SNAP_COMMON/etc/settings.json is never loaded; Etherpad falls
   back to <install-root>/settings.json, which lives on the read-only
   squashfs — so the default dirty-DB path ends up unwritable and the
   daemon fails to persist pads. Fix: pass `--settings "${SETTINGS}"` to
   node; drop the EP_SETTINGS export.

2. `snap set` overrides were no-ops: the seeded settings.json carries the
   template's literal `"ip": "0.0.0.0"` / `"port": 9001` values, which
   override the env-based defaults Etherpad exposes via ${…}
   substitution. Users following the README saw the listener stay put
   after `snap set etherpad-lite port=…`. Fix: after copying the
   template on first run, rewrite the top-level `ip` and `port` lines
   to `"${IP:0.0.0.0}"` / `"${PORT:9001}"`. Use `0,/…/` anchors so the
   `dbSettings.port` entry further down stays literal.

3. Indentation: reflow the new shell scripts from 4-space to 2-space to
   match the repo style rule.

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

* fix(snap): default seeded settings to sqlite, not dirty

settings.json.template's own comment says dirty is for testing only.
A Snap install is the "not testing" case — shipping it by default
means every `sudo snap install etherpad-lite` starts on a DB the
project explicitly recommends against.

Rewrite the postinstall sed to switch dbType: "dirty" → "sqlite" and
point filename at $SNAP_COMMON/var/etherpad.db. sqlite is already
shipped in-tree via ueberdb2 → rusty-store-kv (prebuilt napi-rs
binary, no build deps), so this works under strict confinement with
zero snap.yaml changes.

Only affects first-run seeding; existing $SNAP_COMMON/etc/settings.json
is never touched on refresh.

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

* fix(snap): rename to "etherpad", glob tag filter, harden cli

- Snap is registered as `etherpad` (the project's only name) — drops the
  legacy `etherpad-lite` from the name, app, paths, install dir, configure
  hook, README and workflow artifact. The daemon app shares the snap name,
  so `snap install etherpad` exposes a bare `etherpad` command; the bin/
  passthrough is now `etherpad.cli`.
- snap-publish.yml: GitHub Actions tag filters use globs, not regex. The
  prior `v?[0-9]+.[0-9]+.[0-9]+` pattern would never match a real release
  tag (Qodo review). Replace with two glob entries covering `vX.Y.Z` and
  `X.Y.Z`.
- etherpad-cli: reject path-traversal in the `<bin-script>` arg (anything
  containing `/`, `..`, or empty) and add a default `*)` case so files
  with unsupported extensions fail loud instead of silently exiting 0
  (Qodo review).

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

* fix(snap): unbreak build — refresh corepack, drop pnpm prune

Two issues hit on the first real `snapcraft pack` of this recipe:

- `corepack prepare pnpm@10.33.0 --activate` failed with
  `Cannot find matching keyid` because Node 22.12's bundled corepack
  ships a stale signing-key list and rejects newer pnpm releases
  (nodejs/corepack#612). Refresh corepack itself via npm before
  preparing pnpm.
- `pnpm prune --prod` is interactive on workspace projects: it asks
  "The modules directories will be removed and reinstalled from
  scratch. Proceed? (Y/n)" and deadlocks on stdin under sudo + tee.
  Replace it with the explicit "wipe node_modules + prod reinstall"
  pattern, which is non-interactive, faster (pnpm resolves the prod
  graph from its CAS cache), and byte-identical in result.

Verified locally: `snapcraft pack --destructive-mode` produces
`etherpad_2.6.1_amd64.snap` end-to-end in ~3 min.

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

* fix(snap): unbreak runtime — tsx resolution, var/ writability, env

Three runtime crashes surfaced when actually installing the built snap
under strict confinement. Fixed each, plus a smoke-test script.

- `tsx` is in the `src` workspace's node_modules under pnpm hoisting,
  not at the snap install root. The wrapper now `cd "${APP_DIR}/src"`
  and uses bare `--import tsx` (matching `bin/cleanRun.sh`); the prior
  `--import tsx/esm` triggered ERR_REQUIRE_CYCLE on Etherpad's mixed
  CJS/ESM source tree.
- Etherpad's plugin installer writes `var/installed_plugins.json` via
  __dirname-relative paths, which resolve to absolute paths inside the
  read-only snap squashfs (EROFS). snap layouts can't intercept paths
  inside `$SNAP`, so replace the shipped `var/` dir with a symlink to
  `/var/snap/etherpad/common/etherpad-app-var/` (auto-created by the
  wrapper on first run). Persistent state survives `snap refresh`.
- Drop the unused `EP_SETTINGS` and `EP_DATA_DIR` env vars from the
  app's `environment:` block. Etherpad's settings loader doesn't read
  them — it reads `argv.settings`, which the wrapper already passes via
  `--settings`. They were producing `[WARN] settings - Unknown Setting`
  noise on every start.

Add `snap/tests/smoke.sh`: rebuild + install + configure test port 9003
+ assert listener + curl /health + tail logs. Local verified output:
  HTTP 200, body {"status":"pass","releaseId":"2.6.1"}, server logs
  `Etherpad is running` on `http://0.0.0.0:9003/`.

.gitignore now excludes destructive-mode build outputs (parts/, stage/,
prime/, .craft/, *.snap).

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

* test(snap): wrapper unit tests, PR CI build, expanded docs

Coverage in snap/tests/ (47 assertions, ~5s, no snapd/sudo/network):
- test-snapcraft-yaml.sh: required keys, name validity, daemon-app
  matches snap name, no etherpad-lite regression, env-var whitelist.
- test-cli.sh: path-traversal rejection, .ts/.sh dispatch, default-case
  rejection, no-args usage.
- test-configure.sh: port (1-65535) and ip (v4/v6) validation via
  mocked snapctl.
- test-service-bootstrap.sh: first-run seeding from
  settings.json.template, sed rewrite of dbType/filename/ip/port,
  writable-dir creation, snapctl override propagation to node env,
  idempotency on second run, default fallbacks.
- run-all.sh: bash -n syntax check on every wrapper + hook, then
  sources each test file and reports totals. All assertions use port
  9003 (project test convention).

CI in .github/workflows/snap-build.yml:
- Triggers on PR / push-to-develop touching snap/, settings.json.template,
  or the workflow itself.
- Job 1 wrapper-tests: runs run-all.sh.
- Job 2 snap-pack: snapcraft pack --destructive-mode, uploads .snap as
  PR artifact for sideload.
- Stays separate from snap-publish.yml (tag-triggered, store-bound).

snap/README.md fully rewritten:
- User-facing usage, install, configure
- Architecture: file layout, var/-symlink rationale, settings.json
  rewrite rationale, double-pnpm-install rationale, daemon-name-shares-
  snap-name rationale
- Three test layers with exactly when/why to run each
- Dev workflow loop
- Publishing maintainer setup
- Troubleshooting for every failure mode hit during this PR (EROFS,
  tsx not found, ERR_REQUIRE_CYCLE, snap-store-down, pnpm prune hang)

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

* docs(snap): replace dead snapcraft.io/docs/releasing-to-the-snap-store link

That URL now 404s. Point at the canonical documentation.ubuntu.com
locations instead, broken out into the specific pages a maintainer
actually needs:

- Register a snap (to claim the name)
- snapcraft export-login (to generate the SNAPCRAFT_STORE_CREDENTIALS
  secret)
- Publishing how-to index (root index for everything else)

Same fix in the snap-publish.yml header comment.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:19:10 +01:00

77 lines
2.7 KiB
Bash
Executable file

#!/bin/bash
# Unit tests for snap/local/bin/etherpad-cli.
# Exercises path-traversal rejection, extension dispatch, default-case
# rejection, no-args usage, and missing-script rejection — all with a
# mocked $SNAP root so no real install is needed.
set -uo pipefail
TEST_NAME="etherpad-cli"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
TMP=$(mktemp -d)
trap 'rm -rf "${TMP}"' EXIT
# Layout: $SNAP/opt/etherpad/bin/{checkPad.sh,importSqlFile.ts,orphan.txt}
# $SNAP/opt/node/bin/node (mock that just echoes its argv)
mkdir -p "${TMP}/opt/etherpad/bin" "${TMP}/opt/node/bin"
cat > "${TMP}/opt/node/bin/node" <<'EOF'
#!/bin/bash
echo "node-mock argv: $*"
EOF
chmod +x "${TMP}/opt/node/bin/node"
cat > "${TMP}/opt/etherpad/bin/checkPad.sh" <<'EOF'
#!/bin/bash
echo "checkPad.sh args: $*"
EOF
chmod +x "${TMP}/opt/etherpad/bin/checkPad.sh"
touch "${TMP}/opt/etherpad/bin/importSqlFile.ts"
touch "${TMP}/opt/etherpad/bin/orphan.txt"
CLI="${WRAPPERS_DIR}/etherpad-cli"
export SNAP="${TMP}"
section "path-traversal rejection"
# We assert exit code == 2 and that the stderr message matches "invalid"
run_cli() { "${CLI}" "$@"; }
assert_exit 2 "rejects ../../etc/passwd" run_cli "../../etc/passwd"
assert_exit 2 "rejects subdir/script.ts" run_cli "subdir/script.ts"
assert_exit 2 "rejects ..hidden" run_cli "..hidden"
assert_exit 2 "rejects empty argument" run_cli ""
assert_grep "invalid script name" "traversal error message mentions 'invalid'" \
run_cli "../etc/passwd"
section "missing / unsupported scripts"
assert_exit 2 "rejects nonexistent.ts" run_cli "nonexistent.ts"
assert_grep "no such script" "missing-script message" \
run_cli "nonexistent.ts"
assert_exit 2 "rejects orphan.txt (no .ts/.sh)" run_cli "orphan.txt"
assert_grep "unsupported script type" "unsupported-extension message" \
run_cli "orphan.txt"
section "valid dispatch"
# .sh runs the script directly
out=$("${CLI}" "checkPad.sh" hello world 2>&1) || true
assert_eq "$out" "checkPad.sh args: hello world" "checkPad.sh forwards args"
# .ts runs node --import tsx with the script path appended
out=$("${CLI}" "importSqlFile.ts" --some-arg 2>&1) || true
case "$out" in
*"--import tsx/esm"*"importSqlFile.ts"*"--some-arg"*) pass "importSqlFile.ts dispatched via node tsx" ;;
*) fail "importSqlFile.ts dispatched via node tsx" "got: $out" ;;
esac
section "no-args usage"
out=$("${CLI}" 2>&1) || true
case "$out" in
*"Usage: etherpad.cli"*"checkPad.sh"*"importSqlFile.ts"*) pass "no-args prints usage and lists scripts" ;;
*) fail "no-args prints usage and lists scripts" "got: $out" ;;
esac
# Summary handed back to the runner via env counters.
return 0 2>/dev/null || exit 0