diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51d7745c..1a671e14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Keep local AI tooling out of Git + - name: Keep local tooling out of Git run: python3 tools/check_local_tooling.py - name: Install Rust toolchain diff --git a/.gitignore b/.gitignore index 7d04a0d8..1e0be8f0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,6 @@ tests/* !tests/fixtures/ tests/fixtures/* !tests/fixtures/media/ -!tests/e2e/ -tests/e2e/* -!tests/e2e/non-video-media.spec.ts -!tests/e2e/settings-restart.spec.ts # Build output, dependency caches, coverage, and profiling output. target/ @@ -67,10 +63,15 @@ __pycache__/ /core.[0-9]* /rustchan-cli*.dmp -# Browser infrastructure (tests/e2e, config, npm lockfile, CI) is source. -# Small intentional fixtures belong in tests/e2e/fixtures, never output/. -# Disposable audit notes and generated browser evidence remain local. -/tests/e2e/AUDIT-*.md +# Local Playwright tooling: keep the complete browser harness on this machine. +/tests/e2e/ +/playwright*.config.* +/package.json +/package-lock.json +/.github/workflows/e2e.yml +# End local Playwright tooling. + +# Generated browser evidence remains local too. /output/ playwright-report/ test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff604de7..c2e21291 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,14 +78,16 @@ cargo test --locked --workspace --all-features RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --all-features --no-deps ``` -Use the Playwright harness under `tests/e2e/` for public UI, admin UI, media, -backup and restore, moderation, Tor/proxy, and no-JavaScript changes. The -maintained harness source is tracked: `playwright.config.ts`, `package.json`, -`package-lock.json`, and `tests/e2e/**`. Browser validation runs locally. -Generated evidence is not: keep reports, traces, screenshots, videos, storage state, +When the local Playwright harness is available, use it under `tests/e2e/` for +public UI, admin UI, media, backup and restore, moderation, Tor/proxy, and +no-JavaScript changes. The complete harness, `playwright*.config.*`, `package.json`, +and `package-lock.json` are intentionally ignored and must remain untracked. +Do not remove these ignore rules or add exceptions to publish browser tests. +Browser validation runs locally; no hosted browser CI is required. +Keep reports, traces, screenshots, videos, storage state, runtime databases, preserved fixture roots, audit output, and other browser artifacts under the matching `.gitignore` rules and out of commits. See -`tests/e2e/README.md` for the normal regression and deep audit commands. +the local `tests/e2e/README.md` for the normal regression and deep audit commands. Run focused scenarios first and the available browser matrix for broad changes. Some media tests require `ffmpeg` and specific codecs; do not replace deterministic fixtures with private uploads from a live site. Permanent Rust diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a7517d2e..00000000 --- a/package-lock.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "rustchan-e2e", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "rustchan-e2e", - "version": "0.0.0", - "devDependencies": { - "@playwright/test": "^1.63.0" - } - }, - "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.63.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.63.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index c5495523..00000000 --- a/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "rustchan-e2e", - "private": true, - "version": "0.0.0", - "scripts": { - "test:e2e": "playwright test", - "test:e2e:ci": "playwright test --project=chromium --workers=2 --max-failures=5", - "test:e2e:chromium": "playwright test --project=chromium", - "test:e2e:firefox": "playwright test --project=firefox", - "test:e2e:firefox-nojs": "playwright test --project=firefox-nojs", - "test:e2e:webkit": "playwright test --project=webkit", - "test:e2e:headed": "playwright test --headed", - "test:e2e:console": "playwright test tests/e2e/console-headless.spec.ts tests/e2e/helpers-lifecycle.spec.ts --project=chromium", - "test:e2e:deep": "playwright test", - "test:e2e:harness": "playwright test tests/e2e/audit-harness-contract.spec.ts --project=chromium", - "test:e2e:audit": "playwright test tests/e2e/audit-journeys.spec.ts tests/e2e/audit-permissions.spec.ts tests/e2e/audit-nojs-parity.spec.ts tests/e2e/audit-ui-a11y.spec.ts tests/e2e/audit-performance.spec.ts tests/e2e/audit-admin-ui.spec.ts tests/e2e/audit-harness-contract.spec.ts --project=chromium", - "test:e2e:audit:nojs": "playwright test tests/e2e/audit-nojs-parity.spec.ts --project=chromium-nojs", - "test:e2e:perf": "playwright test tests/e2e/audit-performance.spec.ts --project=chromium --workers=1", - "test:e2e:media": "node tests/e2e/media-toolchain-check.mjs --self-test && node tests/e2e/media-toolchain-check.mjs && RUSTCHAN_E2E_MEDIA_TOOLCHAIN=1 playwright test tests/e2e/media-toolchain.spec.ts --project=chromium", - "test:e2e:checks": "node --check static/admin.js && node --check tests/e2e/media-toolchain-check.mjs && node tests/e2e/media-toolchain-check.mjs --self-test && playwright test --list" - }, - "devDependencies": { - "@playwright/test": "^1.63.0" - } -} diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 66d35a26..00000000 --- a/playwright.config.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import { rebrandedFirefoxExecutable } from './tests/e2e/firefox-rebrand'; - -// Automatic identity isolation for the macOS 27 / stock Gecko TCC defect. -const firefoxExecutable = rebrandedFirefoxExecutable(); -const firefoxLaunchOptions = firefoxExecutable - ? { launchOptions: { executablePath: firefoxExecutable } } - : {}; - -export default defineConfig({ - testDir: './tests/e2e', - globalSetup: './tests/e2e/global-setup.ts', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: 0, - workers: 2, - timeout: 90_000, - expect: { - timeout: 10_000, - }, - outputDir: process.env.RUSTCHAN_AUDIT_OUTPUT ? `${process.env.RUSTCHAN_AUDIT_OUTPUT}/artifacts` : 'test-results/e2e/artifacts', - reporter: [ - ['list'], - ['html', { outputFolder: process.env.RUSTCHAN_AUDIT_OUTPUT ? `${process.env.RUSTCHAN_AUDIT_OUTPUT}/html` : 'playwright-report', open: 'never' }], - ['json', { outputFile: process.env.RUSTCHAN_AUDIT_OUTPUT ? `${process.env.RUSTCHAN_AUDIT_OUTPUT}/results.json` : 'test-results/e2e/results.json' }], - ], - use: { - actionTimeout: 15_000, - navigationTimeout: 20_000, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'], ...firefoxLaunchOptions }, - }, - { - name: 'mobile-firefox', - use: { - browserName: 'firefox', - viewport: { width: 390, height: 844 }, - deviceScaleFactor: 2, - hasTouch: true, - userAgent: 'Mozilla/5.0 (Android 14; Mobile; rv:120.0) Gecko/120.0 Firefox/120.0', - ...firefoxLaunchOptions, - }, - }, - { - name: 'mobile-webkit', - use: { ...devices['iPhone 13'] }, - }, - { - name: 'firefox-nojs', - use: { - ...devices['Desktop Firefox'], - javaScriptEnabled: false, - ...firefoxLaunchOptions, - }, - }, - { - name: 'chromium-nojs', - use: { - ...devices['Desktop Chrome'], - javaScriptEnabled: false, - }, - }, - ], -}); diff --git a/playwright.restart.config.ts b/playwright.restart.config.ts deleted file mode 100644 index 63744042..00000000 --- a/playwright.restart.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -// Restart tests own disposable processes and have no dependency on the media audit harness. -export default defineConfig({ - testDir: './tests/e2e', - testMatch: 'settings-restart.spec.ts', - workers: 1, - timeout: 120_000, - expect: { timeout: 15_000 }, - outputDir: 'test-results/restart', - use: { ...devices['Desktop Chrome'], trace: 'retain-on-failure' }, - projects: [ - { name: 'chromium' }, - { name: 'chromium-nojs', use: { javaScriptEnabled: false } }, - ], -}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md deleted file mode 100644 index aaa7669e..00000000 --- a/tests/e2e/README.md +++ /dev/null @@ -1,561 +0,0 @@ -# RustChan Playwright E2E Suite - -The maintained local Playwright harness belongs in version control: `playwright.config.ts`, -`package.json` / `package-lock.json`, and `tests/e2e/**`. -Browser tests do not run in GitHub Actions. Generated run evidence stays ignored: browser -reports, `test-results/`, the `output/playwright/` runtime tree (including -preserved debug roots and audit output), traces, videos, screenshots, storage -state, and the rebranded-Firefox workaround bundle. Never force-add generated -artifacts. - -Each test receives an isolated runtime under `output/playwright/runtimes/`, -which is removed on teardown. - -The suite covers RustChan only: public pages, admin, posting, media, backups, -authentication, Tor-disabled UI, and no-JavaScript fallbacks. - -## Install - -Run from the repository root: - -```sh -npm ci -npx playwright install -``` - -The global setup builds `target/debug/rustchan-cli` unless -`RUSTCHAN_E2E_SKIP_BUILD=1` is set. External target overrides are rejected. Each test copies the binary into a unique temporary -runtime, reserves its own loopback port, and writes settings, SQLite data, -uploads, logs, backups, downloads, fixtures, screenshots, traces, and videos -under the ignored output/playwright/runtimes directory. - -Runtime disposal first closes audited pages on that runtime’s origin while its -listener is alive, including manually owned standalone runtimes. Pages belonging -to other runtimes remain open. This prevents background polling from racing a -server shutdown; diagnostics are neither disabled nor allowlisted. - -Temporary RustChan runtime roots are removed during fixture teardown, including -after failed tests once logs and other failure artifacts are attached. Set -`RUSTCHAN_E2E_PRESERVE_ROOTS=1` to retain them for debugging; the harness prints -each preserved path. - -## Test modes - -Two supported modes share one configuration; retries stay at **zero** in both. - -### Normal regression mode - -Run the maintained local suite per engine with a failure cap: - -```sh -RUSTCHAN_E2E_SKIP_BUILD=1 npx playwright test --project=chromium --workers=2 --max-failures=5 -RUSTCHAN_E2E_SKIP_BUILD=1 npx playwright test --project=webkit --workers=2 --max-failures=5 -RUSTCHAN_E2E_SKIP_BUILD=1 npx playwright test --project=firefox --workers=2 --max-failures=5 -RUSTCHAN_E2E_SKIP_BUILD=1 npx playwright test --project=chromium-nojs --workers=2 --max-failures=5 -``` - -`npm run test:e2e:ci` is the bounded Chromium shorthand (`--max-failures=5`). -These commands run locally; pull requests, pushes, and merge groups do not -trigger browser checks. - -### Deep audit mode - -The full project matrix with bounded deep diagnostics (traces where enabled, -step screenshots for `deep-journey` tests, `RUST_LOG` request lines, timing -files, and the artifact budget), all under the existing `RUSTCHAN_AUDIT_OUTPUT` -convention: - -```sh -RUSTCHAN_E2E_DEEP_DIAGNOSTICS=1 \ -RUSTCHAN_AUDIT_OUTPUT=output/playwright/deep-audit/ \ -RUSTCHAN_E2E_SKIP_BUILD=1 \ -npm run test:e2e:deep -``` - -`npm run test:e2e:deep` is the all-projects matrix (`chromium`, -`chromium-nojs`, `webkit`, `mobile-webkit`, `firefox`, `mobile-firefox`, -`firefox-nojs`). Deep diagnostics are expensive and run only when requested -locally. A single project can be run deeply with an explicit `--project`. - -## Firefox on macOS 27 (Playwright issue #42768) - -The stock Playwright 1.63 Firefox bundle shares `Name=Firefox` / `Vendor=Mozilla` -with the user's Firefox. On macOS 27, TCC denies this host access to -`~/Library/Application Support/Firefox`. Gecko accesses that app-data root even -with a valid temporary `-profile`; changing HOME, TMPDIR, profile location or -launch timing does not isolate it. A minimal stock `firefox.launch()` reproduces -`Could not find profile folder.` without RustChan. This is an upstream OS/bundle -identity defect, not ARM64 emulation or a server readiness failure: -[Playwright #42768](https://github.com/microsoft/playwright/issues/42768). - -Normal commands work without shell setup: - -```sh -npm run test:e2e:firefox -npm run test:e2e:firefox-nojs -``` - -`firefox-rebrand.ts` automatically isolates the app identity only on Darwin 27+ -when the installed bundle still has the original Firefox/Mozilla identity. It -copies the exact installed browser revision to ignored output, changes only the -application descriptor's Name/Vendor, and generates a safely quoted launcher -that puts `-app` before Playwright's arguments (required by Gecko). Linux, older -macOS and an upstream-isolated identity use the stock binary. No launch retries, -sleeps, external browser selection, HOME changes or permission grants are used. -The content/revision keyed cache is published atomically and checked before reuse. -The main config and the alternate button-sizing config use the same helper. - -The upstream issue is still open with a v1.64 label as of 2026-09-30. An original -identity on Playwright 1.64+ fails configuration with an explicit re-evaluation -message; an upstream-isolated identity bypasses the helper automatically. -`firefox-launch.spec.ts` checks OS/identity scope and actual browser launch plus -JavaScript/no-JavaScript behavior on every configured project. Remove the helper -and config wiring once the supported bundled browser fixes #42768 and the stock -Firefox/mobile/no-JS launch probes pass. - -## Maintained source, fixtures and disposable evidence - -Track the TypeScript specs/helpers/configs, helper `.mjs` scripts, npm manifest -and lockfile. Required small sanitized regression fixtures -belong in `tests/e2e/fixtures/`; most current fixtures are generated deterministically -inside each isolated runtime by `helpers.ts`. Never copy runtime databases, -authentication state or downloaded browser bundles into source fixtures. - -`output/`, `test-results/`, browser reports, caches, profiles, logs, videos, -traces and screenshots are disposable ignored evidence. The historical -`tests/e2e/AUDIT-*.md` notes also stay local because they refer to disposable runs. -The admin-polish scripts under `output/playwright/admin-polish/` use a fixed -local audit server/auth state and remain disposable review tools. Reusable -regressions are maintained as specs, including `admin-polling-lifecycle.spec.ts` -and `admin-table-scroll.spec.ts`; no preserved local evidence is deleted. - -Wide admin tables intentionally retain dense native table columns and scroll -within named focusable regions at 320px. Keyboard arrows pan those regions; -child action keys retain native behavior. Native touch panning remains enabled, -focus outlines stay outside the container and there are no sticky cells. The -regression covers all 21 sections, nine themes on Chromium and the forest theme -on every browser/no-JS project. This behavior is intentional, not a layout defect. - -The site-health poller now uses completion-based scheduling, at most one request -in flight, and beforeunload/pagehide cancellation. WebKit can throw synchronously -when an interval starts fetch during a provisional form navigation; pagehide -alone occurs too late. The lifecycle regression runs the next health deadline -at beforeunload, after production cancellation, without sleeps or retries. -BFCache pageshow resumes polling for restored documents. - - -## Diagnostics - -`tests/e2e/diagnostics.ts` installs a strict, always-on browser diagnostics layer. -Every observation is recorded as a structured, redacted event carrying the acting -actor, project, test title, worker, timestamp, HTTP status, content type, -`Location`, and the server's `x-request-id`, and these rules apply: - -| Observation | Default outcome | -| --- | --- | -| Uncaught page error (`weberror`) | **Test fails** | -| `console.error` | **Test fails** | -| `console.warning` | Recorded only | -| HTTP 4xx from page traffic | **Test fails** | -| HTTP 5xx from page traffic | **Test fails** | -| HTTP 3xx | Recorded (redirect observability) | -| Transport failure (DNS, refused, TLS, timeout) | **Test fails** | -| `net::ERR_ABORTED` | Recorded and counted; not a failure (navigation and teardown cancel requests by design) | -| Wrong content type for `/static/**.css`, `/static/**.js`, `/theme-css/**` | **Test fails** | -| New `[ERROR]` line in the fixture server log | **Test fails** | - -`page.request.*` traffic is out of scope by construction: Playwright does not emit -context response events for `APIRequestContext`, so API-driven prerequisites and -negative tests need no allowlists. - -A browser console message that refers to an already-declared HTTP error response is -treated as explained by that declaration. Chromium logs -`Failed to load resource: the server responded with a status of 404` for 404 -*document* navigations as well as subresources, so this avoids requiring two -declarations for one response. An undeclared 4xx still fails, and its console -message with it. - -### Declaring expected failures - -A test that intentionally triggers one of the above declares it *before* the -action, naming the kind plus method/path/status or a message pattern: - -```ts -expectHttpError({ method: 'GET', path: '/locked/catalog', status: 403, reason: 'the gate is the subject' }); -expectConsoleError({ pattern: /Failed to load resource/, reason: 'simulated stylesheet failure' }); -expectTransportFailure({ url: /api\/post/, reason: 'offline recovery path under test' }); -expectPageError({ pattern: /deliberate/, reason: 'error boundary under test' }); -expectServerLogError({ pattern: /repair failed/, reason: 'fault-injected repair path' }); -noteEvent('evidence-key', 'free-form redacted evidence attached to the report'); -``` - -`expectServerLogError` lives in `helpers.ts` (the app fixture owns the server -log); the rest live in `diagnostics.ts`. `noteEvent(kind, detail)` takes two -arguments and never fails a test — it attaches evidence only. - -### Rendered-DOM facts that repeatedly bite test authors - -- `` matches ARIA role `button`. An unnamed - `getByRole('button')` can therefore click the file picker instead of the - submit control; use `button[type="submit"]` for unnamed submits. -- The admin panel renders a deliberate theme-preview pair of `.admin-flash` - elements ("Saved theme preview" / "Validation message preview") inside - `.theme-preview-flashes`, so a bare `.admin-flash` locator matches two - elements. Scope flash assertions by text. -- Admin controls live inside `
` disclosures (`admin-dropdown`, - `backup-manual-details`, per-board cards). Open them through their real - `` before interacting, and only click `:visible` summaries so the - responsive mobile menu is never targeted. -- `waitForURL(/section/)` resolves immediately when the current URL already - contains the same section hash. Wait for the mutation response or assert the - record outcome instead. -- On loopback the actor identity is the `rustchan_visitor_id` cookie: a fresh - context is a different actor, and bans/cooldowns are per-cookie. Bans are - enforced on the posting path: a plain form POST by a banned identity returns - HTTP 403 whose body is the ban page, while the XHR path returns - `X-Rustchan-Redirect: /banned?reason=…`. -- Favicon uploads must be exactly 512×512 px (`src/favicon.rs`); banners must be - at least 468×60 with the exact 468:60 aspect ratio (`src/banner.rs`). -- Dispose a standalone instance only after navigating the browser away from any - admin page that polls (`/admin/log/live`, `/admin/site-health/jobs`), otherwise - teardown produces `ERR_CONNECTION_REFUSED` browser errors. -- `ban_appeals.status` defaults to `'open'`, not `'pending'`. - -Declarations are consumed by observed events, once each, in observation order. An -unused declaration fails the test, so expectations cannot silently rot into a -blanket allowlist. Mark a genuinely conditional declaration `optional: true` and -say why. - -### Harness self-verification - -`audit-harness-contract.spec.ts` proves the layer fails closed. Its negative cases -are marked `test.fail(true, ...)`: if the diagnostics ever stop failing on an -undeclared 4xx, console error, or uncaught page error, those tests unexpectedly -*pass* and the suite goes red. It also verifies the declaration matcher directly, -that manual contexts inherit the project JavaScript mode, and that the fixture -server belongs to the current run. - -### Deep diagnostics (opt-in, bounded) - -```sh -RUSTCHAN_E2E_DEEP_DIAGNOSTICS=1 RUSTCHAN_E2E_DEEP_DIAGNOSTICS_MAX=400 \ - npx playwright test tests/e2e/audit-journeys.spec.ts --project=chromium --workers=1 -RUSTCHAN_E2E_TIMING=1 npx playwright test tests/e2e/audit-performance.spec.ts --project=chromium --workers=2 -``` - -Deep mode keeps Playwright tracing enabled for the tests it runs, attaches bounded -screenshots for steps in tests annotated `deep-journey`, injects -`RUST_LOG=info,tower_http::trace=trace` so request status/latency lines land in the -runtime dependency log, attaches the dependency-log tail on failure, and writes -`timings.json` per test. The default screenshot budget is 400 per run. Timing runs -must stay separate from trace/video capture; `audit-performance.spec.ts` disables -both. `RUST_LOG` is only injected in deep mode: an empty value would change the -default filter and hide startup lines other tests assert. - -### Artifact sensitivity - -Failure artifacts contain synthetic credentials (`admin` / `AdminPass123!`), -session cookies, CSRF tokens, and request IDs. `redact()` strips credentials, -session cookie values, CSRF tokens, ownership grants, and `deletion_token` values -before anything is attached or written under `RUSTCHAN_AUDIT_OUTPUT`. All fixture -runtime roots, uploads, and databases are synthetic and local; do not add real -secrets, real user data, or external targets. - -### Emulated configurations - -`mobile-firefox` (custom viewport/touch/user agent) and `mobile-webkit` (iPhone 13 -preset) are **emulated** configurations. They are not tests on physical Android or -iPhone hardware, and results must not be reported as such. - -### No-JavaScript projects - -`firefox-nojs` is the canonical no-JavaScript project. `chromium-nojs` was added by -the 2026-09-29 deep audit because the Playwright Firefox build could not launch on -that host (upstream #42768; see the Firefox section above) and keeps the -JavaScript-disabled contract covered on every host. Both projects are listed in -`NO_JS_PROJECTS` in `diagnostics.ts`, so manual contexts created through -`newAuditedContext`/`newAuditedPage` inherit the no-JavaScript mode there. A spec -that disables JavaScript locally with `test.use({ javaScriptEnabled: false })` -must still pass `javaScriptEnabled` explicitly when it creates a context, because -Playwright does not expose describe-level `use` overrides to fixtures. - -## Deep Audit (2026-09-29) - -`tests/e2e/AUDIT-deep-audit-2026-09-29.md` is the historical audit report: what -the suite proved at revision `146423b3`, the evidence, and the confirmed -findings. It is provenance, not a description of current behaviour. The three -confirmed application findings it recorded are fixed in the maintained suite: - -- banned identities can no longer file reports or poll votes; ban enforcement - is centralized in `src/handlers/board.rs::ensure_actor_not_banned` and used by - the posting, report, and vote paths; -- the standalone ban notice wraps long unbroken reasons at narrow widths; -- the NSFW consent dialog moves focus into the dialog on open and restores it to - the trigger on dismiss, like the other modals. - -The strict-diagnostics declarations the audit added remain part of the contract. - -Quick confidence passes: - -```sh -npm run test:e2e:harness # strict diagnostics self-verification -npm run test:e2e:audit # maintained audit regressions on Chromium -npm run test:e2e:audit:nojs # no-JavaScript parity on Chromium with JS disabled -npm run test:e2e:perf # bounded performance diagnostics, serial -``` - -## Terminal Console Compatibility - -Browser fixtures deliberately start RustChan **headlessly**, with stdin disabled -and stdout/stderr piped into `server.log`. The Ratatui console and interactive -first-run setup require both stdin and stdout to be terminals, so they must not -activate in Playwright workers—even when `TERM`, `COLUMNS`, or `LINES` are -inherited from an interactive shell. Fixtures use CLI or web setup to create -administrators and boards, HTTP `/readyz` for readiness, and SIGTERM for shutdown. -Do not inherit stdio, allocate a PTY, send console shortcuts, or strip terminal -escape sequences from captured logs to make browser tests pass. - -Run the focused console/headless compatibility pass: - -```sh -npm run test:e2e:console -npx playwright test tests/e2e/auth-admin.spec.ts --project=chromium -``` - -It checks fresh web setup without terminal prompts, plain captured logs, -CLI-seeded admin/board access across restarts, and graceful signal-driven -shutdown. Startup failures include a bounded output tail to expose terminal -initialization errors directly. - -Playwright does not render or drive the native terminal UI. Its navigation, -forms, masking, confirmations, log following, selection, and responsive layout -coverage lives in the Rust console tests: - -```sh -cargo test --all-features server::console -- --test-threads=1 -``` - -For interactive release QA, run a disposable instance in a real terminal and -check the 1–4 navigation tabs, C/A/D forms, Tab/Shift-Tab focus, F2 submission, -Escape cancellation/back navigation, Q confirmation, Ctrl-C, log scrolling and -follow mode, and terminal restoration. Exercise 40×10 (resize guidance), 44×14, -60×18, 80×24, and 120×40 dimensions, including resizing with a dialog open. -Never use a live instance for destructive workflow checks. - -## Quick Smoke - -This is the recommended fast confidence pass across desktop Chromium, desktop -WebKit, desktop Firefox, mobile WebKit, and Firefox with JavaScript disabled: - -```sh -npx playwright test \ - tests/e2e/audit-surfaces.spec.ts \ - tests/e2e/phase3-media-runtime.spec.ts \ - tests/e2e/phase3-mobile-webkit-runtime.spec.ts \ - tests/e2e/firefox-nojs-public.spec.ts \ - --project=chromium \ - --project=webkit \ - --project=firefox \ - --project=mobile-webkit \ - --project=firefox-nojs -``` - -For a narrower preflight while iterating on harness code: - -```sh -npx playwright test --list -npx playwright test tests/e2e/audit-surfaces.spec.ts --project=chromium -npx playwright test tests/e2e/firefox-nojs-public.spec.ts --project=firefox-nojs -``` - -The retained local npm shorthand runs a bounded Chromium pass with two workers and a five-failure cap: - -```sh -npm run test:e2e:ci -``` - -The browser suite runs locally; there is no GitHub Actions browser workflow or weekly -schedule. Failure diagnostics retain traces, screenshots, videos, server logs, -and browser errors; retries are disabled. - -## Full Local Matrix - -Run a single browser when investigating a failure: - -```sh -npm run test:e2e:chromium -npm run test:e2e:webkit -npm run test:e2e:firefox-nojs -npx playwright test --project=firefox -npx playwright test --project=mobile-webkit -``` - -Run the whole local matrix: - -```sh -npm run test:e2e -``` - -Run focused suites by domain: - -```sh -npx playwright test tests/e2e/auth-admin.spec.ts tests/e2e/csrf-navigation.spec.ts --project=chromium -npx playwright test tests/e2e/posting-thread.spec.ts tests/e2e/password-boards.spec.ts --project=chromium -npx playwright test tests/e2e/backup-restore.spec.ts tests/e2e/maintenance-backup-phase2.spec.ts --project=chromium -npx playwright test tests/e2e/phase4-accessibility-progressive.spec.ts --project=chromium --project=mobile-webkit --project=firefox-nojs -``` - -## Opt-In Heavy Passes - -Real media toolchain validation is disabled by default. The default harness sets -`CHAN_REQUIRE_FFMPEG=0` and points FFmpeg to a sentinel binary name so -local codec installs do not affect deterministic browser tests. - -```sh -npm run test:e2e:media -``` - -Override media tool paths when needed: - -```sh -RUSTCHAN_E2E_FFMPEG_PATH=/path/to/ffmpeg \ -npm run test:e2e:media -``` - -The video pass requires FFmpeg with PNG frame extraction, the `libvpx-vp9` and -`libopus` encoders, and the WebM muxer. WebP encoding (including video previews) -uses Rust and does not require an FFmpeg WebP encoder. PDF previews use the built-in Rust renderer or SVG fallback. -Common audio waveforms, image conversion and metadata inspection also run in -the default harness with FFmpeg unavailable. - -The upload matrix runs only against an isolated local runtime: - -```sh -npx playwright test tests/e2e/upload-validation.spec.ts --project=chromium --project=firefox-nojs --project=mobile-webkit -``` - -The button sizing harness has its own config and artifact directory: - -```sh -npx playwright test -c tests/e2e/button-sizing.config.ts -``` - -## Manual Or Skipped Areas - -Some release checks intentionally remain manual because they depend on local -services, trusted certificates, or fault injection that the normal binary does -not expose: - -- Active Tor/Arti onion bootstrap, onion copy behavior, `Onion-Location`, and - already-onion suppression. Start a deterministic Tor/Arti release environment, - enable Tor in RustChan, and use `phase3-static-proxy-tor.spec.ts` as the - checklist for the browser assertions. -- Public certificate authority/ACME trust and renewal. `release-tls.spec.ts` now covers local HTTPS, secure sessions, restart/restoration, logout invalidation, and redirect-host rejection using a self-signed certificate trusted only by its isolated browser context. -- Fault-injected database repair failure and pre-repair backup failure. These - require a binary or harness exposing deterministic failure hooks. -- Extreme file-size and codec performance limits. The upload and media suites - cover policy and safe fallback behavior; final performance limits should be - checked on representative release hardware. -- Long-duration soak and load runs. The complete bounded local matrix runs with zero retries. - -## Suite Map - -- `helpers.ts`: isolated RustChan runtime fixture, CLI setup, seeded boards, - fixture media, CSRF/admin/post helpers, DB fixture helpers, server-log evidence - (`logSize`/`logsSince`/`dependencyLogs`), and `expectServerLogError`. -- `diagnostics.ts`: strict browser diagnostics layer (see § Diagnostics). -- `firefox-rebrand.ts`: opt-in macOS-only workaround for Playwright issue #42768 - (see § Firefox on macOS 27). -- `audit-harness-contract.spec.ts`: meta-tests proving the diagnostics layer fails - closed, that manual contexts inherit the project JavaScript mode, and that the - fixture server belongs to this run. Chromium only. -- `audit-journeys.spec.ts`: complete multi-actor journeys and state transitions. -- `audit-permissions.spec.ts`: actor/resource permission matrix with crafted - requests, CSRF matrix, stale sessions, tampered identifiers, hostile content. -- `audit-nojs-parity.spec.ts`: server-rendered plain-form contract and - JavaScript/no-JavaScript business-outcome parity. -- `audit-ui-a11y.spec.ts`: bounded viewport matrix, keyboard/focus behaviour, - modal focus management, accessible names and error associations. -- `audit-performance.spec.ts`: bounded repeated-sample timings, request counts, - duplicate-request checks, transfer sizes, observable server CPU/RSS samples. -- `audit-admin-ui.spec.ts`: admin surfaces previously covered only through - `page.request`, driven through the rendered forms. -- `console-headless.spec.ts`: fresh/headless startup, terminal-output isolation, - web setup availability, and seeded administration across process restarts. -- `helpers-lifecycle.spec.ts`: graceful headless shutdown, process/port cleanup, - and temporary-root disposal or explicit preservation. -- `activity-helpers.ts`: shared activity badge setup/assertions used by the - broad activity audit and Phase 3 BFCache/restart checks. -- `phase4-helpers.ts`: safe-body/header assertions, overflow/focus/target - checks, contrast checks, client-error watcher, and Phase 4 project gates. -- `auth-admin.spec.ts`: first-run startup, admin login/logout, stale pages, - cookie attributes, context isolation, board creation/edit/delete, and invalid - board names. -- `csrf-navigation.spec.ts`: CSRF scope, missing/invalid tokens, Origin/Referer - policy, loopback/null-origin behavior, POST-only routes, and stale navigation. -- `posting-thread.spec.ts`: public browsing, catalog/thread navigation, posting, - replies, escaping, duplicate/back-forward/refresh behavior, and own-post - controls. -- `password-boards.spec.ts`: view-password, post-password, unlock cookie - isolation, mobile WebKit secure-cookie behavior, and password changes. -- `media.spec.ts`, `phase3-media-runtime.spec.ts`, `media-toolchain.spec.ts`, - and `upload-validation.spec.ts`: upload acceptance/rejection, media viewer, - no-JS media fallbacks, media/download headers, Range requests, protected - media denial, real FFmpeg/PDF toolchain behavior, and isolated upload - validation. -- `activity-notifications.spec.ts` and `phase3-activity-cache.spec.ts`: activity - badge visibility, clearing, settings toggles, restart persistence, BFCache, - protected-board non-leakage, and Firefox no-JS clearing. -- `admin-settings-phase2.spec.ts`, `board-settings-phase2.spec.ts`, - `settings.spec.ts`, and `assets-themes-phase2.spec.ts`: admin/site/board - settings, media/banner/theme settings, cache-busting, search/catalog - hardening, archive behavior, maintenance CSRF, and settings persistence. -- `moderation-phase2.spec.ts`, `admin-ban-delete.spec.ts`, and - `admin-polish.spec.ts`: report flow, duplicate reports, ban/appeal lifecycle, - moderation queues, styled ban+delete modal, no-JS fallback, and public/admin - moderation controls. -- `backup-restore.spec.ts`, `maintenance-backup-phase2.spec.ts`, and - `phase4-stress-release-smoke.spec.ts`: full/board backups, split backup - options, downloads, restore into fresh runtimes, invalid restore rejection, - scheduled backups, maintenance progress, and release-smoke restore. -- `phase3-static-proxy-tor.spec.ts`: static/runtime security headers, - cache-policy checks, trusted proxy and secure-cookie behavior, Tor-disabled UI - safety, plus manual Tor/TLS notes. -- `audit-surfaces.spec.ts`, `mobile-nojs.spec.ts`, `firefox-nojs-public.spec.ts`, - `captcha.spec.ts`, `button-sizing.spec.ts`, - `phase4-accessibility-progressive.spec.ts`, - `phase4-responsive-long-content.spec.ts`, - `phase4-theme-contrast.spec.ts`, and - `phase4-empty-error-permission.spec.ts`: cross-browser reachable-surface - audit, mobile/no-JS flows, CAPTCHA, control sizing, keyboard/focus/accessibility - basics, responsive/long-content layout, built-in theme readability, and safe - empty/error/permission states. - -## Coverage Map - -| System | Status | Main Coverage | -| --- | --- | --- | -| Setup/config | Strong | First-run runtime layout, bad settings fail-closed, settings persistence, isolated tests, external targets rejected | -| Terminal/browser boundary | Automated boundary + Rust/manual TUI | Headless startup/restart, plain logs, no interactive prompts, SIGTERM shutdown; native rendering and keyboard behavior use Rust console tests and real-terminal QA | -| Admin auth/CSRF | Strong | Login/logout, stale pages, cookie attributes, session CSRF, Origin/Referer/null-origin policy, POST-only mutations | -| Board access | Strong | Public boards, view-password/post-password boards, unlock cookie isolation, secure cookie mode, protected non-leakage | -| Posting/thread lifecycle | Strong | Thread/reply create, validation, duplicate prevention, navigation refresh/back-forward, lock/archive/delete/ban denials | -| Media/upload/viewer | Strong | Upload matrix, disabled policy, spoofed/oversized/traversal inputs, viewer, headers, downloads, Range, protected denial, opt-in real tools | -| Own-post controls | Strong | Edit/delete modal, no-JS edit/delete pages, ownership cookie isolation, expired/replied/locked/archived/deleted denials | -| Activity/cache | Strong | Home/board/catalog/thread badges, settings toggles, restart persistence, BFCache, protected non-leakage, Firefox no-JS clearing | -| Admin/settings | Strong | Site, board, media, backup, banner, theme, validation, TOML/writeback, admin-only and CSRF protection | -| Moderation | Strong | Reports, duplicate reports, resolution, delete, bans, appeals, mod log, ban+delete modal, no-JS fallbacks | -| Backup/restore | Strong | Directory/split full backups, board backups, saved restore, fresh-runtime restore, invalid restore, Tor key include/exclude | -| Maintenance | Adequate | DB check/repair status/progress/stale jobs/concurrent backup blocking; deterministic repair failure remains manual | -| Themes/assets | Strong | Built-in themes, site defaults, board overrides, favicons, banners, cache-busting, readability/contrast sanity | -| Tor/TLS/proxy | Partial | Trusted proxy, local HTTPS, secure sessions, restoration, and redirect-host checks are automated; active Tor and public certificate trust/renewal remain manual | -| Accessibility/responsive/error states | Adequate | Named controls, focus restore, target sizing, no-overlap checks, long content, mobile WebKit, safe 4xx/invalid states | - -## Release audit additions - -- `release-audit.spec.ts`: empty/nonempty statistics, malformed catalog storage, stale editing and locked-thread races, repeated Enter during an in-flight upload, signed CSRF behavior after cookie loss, update failure/recovery, and natural multi-board navigation with page lifecycle evidence. -- `release-nojs-catalog.spec.ts`: actual pin/unpin, report, and hide/unhide actions with JavaScript disabled; button containment at 1280, 390, and 320 pixels. -- `release-tls.spec.ts`: isolated HTTPS login, secure cookie attributes, server restart and session restoration, cross-context logout invalidation, and safe redirects. -- `auto-compress.spec.ts`: visible failure feedback and real silent/audio video compression, upload, browser playback, and audio-track preservation. WebKit asserts its unsupported-capture feedback. - -Set `RUSTCHAN_AUDIT_OUTPUT=output/playwright/release-audit/` for isolated JSON/HTML/artifact output. Browser console errors, request failures, and HTTP errors are attached for review; uncaught page errors fail the test. Negative-response tests intentionally generate some 4xx and aborted requests. diff --git a/tests/e2e/activity-helpers.ts b/tests/e2e/activity-helpers.ts deleted file mode 100644 index 91ce0783..00000000 --- a/tests/e2e/activity-helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { newAuditedContext } from './diagnostics'; -import type { Browser, BrowserContext, Page, TestInfo } from '@playwright/test'; -import { - boardId, - expect, - setSiteFixtureSettings, - sqliteQuery, - type RustChanServer, -} from './helpers'; - -export type BadgeSettings = { - homepageNewThreadBadgesEnabled: boolean; - homepageNewReplyBadgesEnabled: boolean; - threadNewReplyBadgesEnabled: boolean; -}; - -export async function restartWithActivitySettings( - app: RustChanServer, - settings: BadgeSettings, -): Promise { - setSiteFixtureSettings(app, settings); -} - -export async function seedReaderBaselines( - page: Page, - app: RustChanServer, - entries: Array<[string, number]>, -): Promise { - const now = Math.floor(Date.now() / 1000); - const boardMarkers: string[] = []; - const threadMarkers: string[] = []; - for (const [board, threadId] of entries) { - const id = boardId(app, board); - const createdAt = Number(sqliteQuery(app, `SELECT created_at FROM threads WHERE id = ${threadId};`)); - const replyCount = Number(sqliteQuery(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)); - if (!Number.isFinite(createdAt) || !Number.isFinite(replyCount)) { - throw new Error(`thread ${threadId} baseline not found for /${board}/`); - } - boardMarkers.push(`${id}.${createdAt}.${threadId}.${now}`); - threadMarkers.push(`${threadId}.${replyCount}.${now}`); - } - await page.context().addCookies([ - { - name: 'rustchan_board_activity', - value: `v1|${boardMarkers.join('|')}`, - url: app.baseURL, - httpOnly: true, - sameSite: 'Lax', - }, - { - name: 'rustchan_thread_activity', - value: `v1|${threadMarkers.join('|')}`, - url: app.baseURL, - httpOnly: true, - sameSite: 'Lax', - }, - ]); -} - -export async function writerPage( - browser: Browser, - testInfo: TestInfo, -): Promise<{ context: BrowserContext; page: Page }> { - const context = await newAuditedContext(browser, { - javaScriptEnabled: testInfo.project.name !== 'firefox-nojs', - }); - return { context, page: await context.newPage() }; -} - -export function boardCard(page: Page, board: string) { - return page.locator('.board-card').filter({ - has: page.locator(`a[href="/${board}/catalog"], a[href="/${board}"], a[href="/${board}/unlock"]`), - }).first(); -} - -export async function expectBoardBadge( - page: Page, - board: string, - badgeSelector: string, - text: RegExp, -): Promise { - await expect(boardCard(page, board).locator(badgeSelector)).toContainText(text); -} - -export async function expectBoardBadgeCleared( - page: Page, - board: string, - badgeSelector: string, -): Promise { - await expect(boardCard(page, board).locator(badgeSelector)).toHaveCount(0); -} - -export async function expectThreadSummaryBadge( - page: Page, - threadId: number, - text: RegExp, -): Promise { - await expect(page.locator(`#t${threadId} .thread-summary-activity-badge`)).toContainText(text); -} - -export async function expectThreadSummaryBadgeCleared(page: Page, threadId: number): Promise { - await expect(page.locator(`#t${threadId} .thread-summary-activity-badge`)).toHaveCount(0); -} - -export async function expectCatalogBadge(page: Page, threadId: number, text: RegExp): Promise { - await expect(page.locator(`.catalog-item:has(a[href$="/thread/${threadId}"]) .catalog-activity-badge`)).toContainText(text); -} - -export async function expectCatalogBadgeCleared(page: Page, threadId: number): Promise { - await expect(page.locator(`.catalog-item:has(a[href$="/thread/${threadId}"]) .catalog-activity-badge`)).toHaveCount(0); -} diff --git a/tests/e2e/activity-notifications.spec.ts b/tests/e2e/activity-notifications.spec.ts deleted file mode 100644 index c44617b7..00000000 --- a/tests/e2e/activity-notifications.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { newAuditedContext } from './diagnostics'; -import { expect, test, type Browser, type BrowserContext, type Page, type TestInfo } from './diagnostics'; -import { - createReplyViaRequest, - createThreadViaRequest, - expectSafePage, - gotoAppPage, - RustChanServer, - setBoardFixtureSettings, - setSiteFixtureSettings, - uniqueShort, - isNoJsProject, -} from './helpers'; -import { - type BadgeSettings, - expectBoardBadge, - expectBoardBadgeCleared, - expectCatalogBadge, - expectCatalogBadgeCleared, - expectThreadSummaryBadge, - expectThreadSummaryBadgeCleared, - seedReaderBaselines, - writerPage, -} from './activity-helpers'; - -test.describe('new activity notification clearing audit', () => { - test.describe.configure({ timeout: 240_000 }); - - test.describe('scripted projects', () => { - test.skip( - ({ browserName, isMobile }) => !(browserName === 'chromium' || (browserName === 'webkit' && isMobile)), - 'focused Chromium and mobile WebKit activity audit', - ); - - test('homepage, board, catalog, and thread badges clear after viewed in Chromium and mobile WebKit', async ({ - browser, - }, testInfo) => { - test.setTimeout(240_000); - - const settings = { - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: true, - threadNewReplyBadgesEnabled: true, - }; - annotateActivityScenario(testInfo, 'all', settings); - - const boardA = uniqueShort('aa', testInfo); - const boardB = uniqueShort('bb', testInfo); - const app = await createActivityApp(settings, boardA, boardB); - const reader = await projectPage(browser, testInfo); - const page = reader.page; - - try { - const threadA = await createThreadViaRequest(page, app, boardA, { - subject: `baseline ${boardA}`, - body: 'reader-visible baseline on the first audit board', - }); - const threadB = await createThreadViaRequest(page, app, boardB, { - subject: `baseline ${boardB}`, - body: 'reader-visible baseline on the second audit board', - }); - await seedReaderBaselines(page, app, [ - [boardA, threadA], - [boardB, threadB], - ]); - - const writer = await writerPage(browser, testInfo); - try { - await createReplyViaRequest(writer.page, app, boardA, threadA, `reply for ${boardA}`); - await createThreadViaRequest(writer.page, app, boardA, { - subject: `new thread ${boardA}`, - body: 'new board thread from another browser context', - }); - await createReplyViaRequest(writer.page, app, boardB, threadB, `reply for ${boardB}`); - await createThreadViaRequest(writer.page, app, boardB, { - subject: `new thread ${boardB}`, - body: 'unrelated unread board activity', - }); - } finally { - await writer.context.close(); - } - - await gotoAppPage(page, app.baseURL); - await expectBoardBadge(page, boardA, '.board-card-new-thread-badge', /1 New Threads/i); - await expectBoardBadge(page, boardA, '.board-card-new-reply-badge', /1 New Replies/i); - await expectBoardBadge(page, boardB, '.board-card-new-thread-badge', /1 New Threads/i); - - await gotoAppPage(page, `${app.baseURL}/${boardA}`); - await expectThreadSummaryBadge(page, threadA, /1 New/i); - await gotoAppPage(page, app.baseURL); - await expectBoardBadgeCleared(page, boardA, '.board-card-new-thread-badge'); - await expectBoardBadgeCleared(page, boardA, '.board-card-new-reply-badge'); - await expectBoardBadge(page, boardB, '.board-card-new-thread-badge', /1 New Threads/i); - await expectBoardBadge(page, boardB, '.board-card-new-reply-badge', /1 New Replies/i); - - await gotoAppPage(page, `${app.baseURL}/${boardB}/catalog`); - await expectCatalogBadge(page, threadB, /1 New/i); - await page.locator(`.catalog-item:has(a[href="/${boardB}/thread/${threadB}"]) a[href="/${boardB}/thread/${threadB}"]`).first().click(); - await expectSafePage(page); - await expect(page.locator('#thread-posts')).toHaveAttribute('data-thread-id', String(threadB)); - - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expectCatalogBadgeCleared(page, threadB); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expectCatalogBadgeCleared(page, threadB); - - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expectSafePage(page); - await gotoAppPage(page, app.baseURL); - await expectBoardBadgeCleared(page, boardB, '.board-card-new-reply-badge'); - await gotoAppPage(page, `${app.baseURL}/${boardB}/catalog`); - await expectCatalogBadgeCleared(page, threadB); - await gotoAppPage(page, `${app.baseURL}/${boardB}`); - await expectThreadSummaryBadgeCleared(page, threadB); - } finally { - await reader.context.close(); - await stopActivityApp(testInfo, app); - } - }); - - test('admin toggles keep homepage and thread badges independent', async ({ browser }, testInfo) => { - test.setTimeout(240_000); - - const boards = ['home', 'thread', 'off'].map((label) => uniqueShort(label, testInfo)); - const app = await createActivityApp({ - homepageNewThreadBadgesEnabled: false, - homepageNewReplyBadgesEnabled: false, - threadNewReplyBadgesEnabled: false, - }, ...boards); - const reader = await projectPage(browser, testInfo); - const page = reader.page; - - try { - await runToggleScenario(page, browser, app, testInfo, 'home', boards[0], { - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: false, - threadNewReplyBadgesEnabled: false, - }, { - expectHomepageThreadBadge: true, - expectHomepageReplyBadge: false, - expectThreadBadge: false, - }); - - await runToggleScenario(page, browser, app, testInfo, 'thread', boards[1], { - homepageNewThreadBadgesEnabled: false, - homepageNewReplyBadgesEnabled: false, - threadNewReplyBadgesEnabled: true, - }, { - expectHomepageThreadBadge: false, - expectHomepageReplyBadge: false, - expectThreadBadge: true, - }); - - await runToggleScenario(page, browser, app, testInfo, 'off', boards[2], { - homepageNewThreadBadgesEnabled: false, - homepageNewReplyBadgesEnabled: false, - threadNewReplyBadgesEnabled: false, - }, { - expectHomepageThreadBadge: false, - expectHomepageReplyBadge: false, - expectThreadBadge: false, - }); - } finally { - await reader.context.close(); - await stopActivityApp(testInfo, app); - } - }); - }); - - test.describe('Firefox no-JS project', () => { - test.skip( - ({ browserName, javaScriptEnabled }) => browserName !== 'firefox' || javaScriptEnabled, - 'Firefox no-JS activity smoke', - ); - - test('server-rendered read state clears without JavaScript', async ({ browser }, testInfo) => { - test.setTimeout(150_000); - - const settings = { - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: true, - threadNewReplyBadgesEnabled: true, - }; - annotateActivityScenario(testInfo, 'nojs', settings); - const board = uniqueShort('nj', testInfo); - const app = await createActivityApp(settings, board); - const reader = await projectPage(browser, testInfo); - const page = reader.page; - - try { - const threadId = await createThreadViaRequest(page, app, board, { - subject: 'no js baseline', - body: 'baseline visible before unread activity', - }); - await seedReaderBaselines(page, app, [[board, threadId]]); - - const writer = await newAuditedContext(browser, { javaScriptEnabled: false }); - try { - const writerTab = await writer.newPage(); - await createReplyViaRequest(writerTab, app, board, threadId, 'no-js unread reply'); - await createThreadViaRequest(writerTab, app, board, { - subject: 'no-js unread thread', - body: 'server-rendered unread thread', - }); - } finally { - await writer.close(); - } - - await gotoAppPage(page, app.baseURL); - await expectBoardBadge(page, board, '.board-card-new-thread-badge', /1 New Threads/i); - await expectBoardBadge(page, board, '.board-card-new-reply-badge', /1 New Replies/i); - - await gotoAppPage(page, `${app.baseURL}/${board}`); - await expectThreadSummaryBadge(page, threadId, /1 New/i); - await page.locator(`#t${threadId} a[href="/${board}/thread/${threadId}"]`).first().click(); - await expectSafePage(page); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expectThreadSummaryBadgeCleared(page, threadId); - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expectSafePage(page); - await gotoAppPage(page, app.baseURL); - await expectBoardBadgeCleared(page, board, '.board-card-new-thread-badge'); - await expectBoardBadgeCleared(page, board, '.board-card-new-reply-badge'); - await gotoAppPage(page, `${app.baseURL}/${board}`); - await expectThreadSummaryBadgeCleared(page, threadId); - } finally { - await reader.context.close(); - await stopActivityApp(testInfo, app); - } - }); - }); -}); - -function annotateActivityScenario( - testInfo: TestInfo, - label: string, - settings: BadgeSettings, -): void { - testInfo.annotations.push({ - type: 'activity-settings', - description: `${label}: homepage_new_thread=${settings.homepageNewThreadBadgesEnabled}, homepage_new_reply=${settings.homepageNewReplyBadgesEnabled}, thread_new_reply=${settings.threadNewReplyBadgesEnabled}`, - }); -} - -async function createActivityApp(settings: BadgeSettings, ...boards: string[]): Promise { - const app = await RustChanServer.create(); - try { - await app.initializeDefaultData(); - setSiteFixtureSettings(app, settings); - createAuditBoards(app, ...boards); - await app.start(); - return app; - } catch (error) { - await app.dispose(); - throw error; - } -} - -function createAuditBoards(app: RustChanServer, ...boards: string[]): void { - for (const board of boards) { - app.createBoardCli({ - short: board, - name: `Activity ${board}`, - description: `Activity audit board ${board}`, - }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - } -} - -async function projectPage( - browser: Browser, - testInfo: TestInfo, -): Promise<{ context: BrowserContext; page: Page }> { - const context = await newAuditedContext(browser, { - javaScriptEnabled: !isNoJsProject(testInfo), - }); - return { context, page: await context.newPage() }; -} - -async function stopActivityApp(testInfo: TestInfo, app: RustChanServer): Promise { - if (testInfo.status !== testInfo.expectedStatus) { - await testInfo.attach('rustchan-server.log', { - body: await app.logs(), - contentType: 'text/plain', - }); - } - await app.dispose(); -} - -async function runToggleScenario( - page: Page, - browser: Browser, - app: RustChanServer, - testInfo: TestInfo, - label: string, - board: string, - settings: BadgeSettings, - expected: { - expectHomepageThreadBadge: boolean; - expectHomepageReplyBadge: boolean; - expectThreadBadge: boolean; - }, -): Promise { - annotateActivityScenario(testInfo, label, settings); - setSiteFixtureSettings(app, settings); - const threadId = await createThreadViaRequest(page, app, board, { - subject: `${label} baseline`, - body: 'baseline before toggle assertion', - }); - await seedReaderBaselines(page, app, [[board, threadId]]); - - const writer = await writerPage(browser, testInfo); - try { - await createReplyViaRequest(writer.page, app, board, threadId, `${label} unread reply`); - await createThreadViaRequest(writer.page, app, board, { - subject: `${label} unread thread`, - body: 'toggle-specific unread thread', - }); - } finally { - await writer.context.close(); - } - - await gotoAppPage(page, app.baseURL); - if (expected.expectHomepageThreadBadge) { - await expectBoardBadge(page, board, '.board-card-new-thread-badge', /1 New Threads/i); - } else { - await expectBoardBadgeCleared(page, board, '.board-card-new-thread-badge'); - } - if (expected.expectHomepageReplyBadge) { - await expectBoardBadge(page, board, '.board-card-new-reply-badge', /1 New Replies/i); - } else { - await expectBoardBadgeCleared(page, board, '.board-card-new-reply-badge'); - } - - await gotoAppPage(page, `${app.baseURL}/${board}/catalog`); - if (expected.expectThreadBadge) { - await expectCatalogBadge(page, threadId, /1 New/i); - } else { - await expectCatalogBadgeCleared(page, threadId); - } -} diff --git a/tests/e2e/admin-ban-delete.spec.ts b/tests/e2e/admin-ban-delete.spec.ts deleted file mode 100644 index c9f14dd1..00000000 --- a/tests/e2e/admin-ban-delete.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Locator, Page, TestInfo } from '@playwright/test'; -import { - adminLogin, - createReply, - createThread, - expect, - expectSafePage, - sqliteQuery, - test, - uniqueShort, - isNoJsProject, -} from './helpers'; - -const DESKTOP_MIN_TARGET = 30; -const MOBILE_MIN_TARGET = 38; - -test.describe('admin ban+delete confirmation flow', () => { - test('styled modal handles cancel, validation, focus, and safe reply submission', async ({ page, app }, testInfo) => { - test.skip(isNoJsProject(testInfo), 'modal flow requires JavaScript'); - - const board = uniqueShort('bnd', testInfo); - app.createBoardCli({ short: board, name: 'Ban Delete Modal', description: 'ban delete fixture' }); - const dialogs: string[] = []; - page.on('dialog', async (dialog) => { - dialogs.push(dialog.message()); - await dialog.dismiss(); - }); - - const threadId = await createThread(page, app, board, { - subject: 'ban delete modal', - body: 'OP stays in place while a reply is deleted', - }); - await createReply(page, app, board, threadId, 'reply selected for ban and delete'); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expectSafePage(page, { allowAdminInternals: true }); - - const reply = page.locator('.post.reply').first(); - const banForm = reply.locator('form[data-ban-delete-pid]').first(); - const banButton = banForm.getByRole('button', { name: /ban\+del/i }); - const postId = Number(await banForm.getAttribute('data-ban-delete-pid')); - const ipHash = await banForm.locator('input[name="ip_hash"]').inputValue(); - expect(Number.isInteger(postId), 'reply post id should be available').toBe(true); - expect(ipHash, 'reply IP hash should be available').toMatch(/^[0-9a-f]{64}$/i); - - await banButton.focus(); - await banButton.press('Enter'); - const modal = page.locator('#ban-delete-modal'); - await expect(modal).toBeVisible(); - await expect(page.getByRole('dialog', { name: /ban ip \+ delete post/i })).toBeVisible(); - expect(dialogs, 'ban+delete should not open browser prompt dialogs').toEqual([]); - - const reason = page.getByLabel('Ban reason'); - const duration = page.getByLabel('Duration in hours'); - const cancel = page.locator('#ban-delete-cancel'); - const submit = page.locator('#ban-delete-submit'); - await expect(reason).toBeFocused(); - await expect(page.locator('#ban-delete-post-label')).toHaveText(`No.${postId}`); - await expectUsableTarget(reason, 'ban reason input', testInfo); - await expectUsableTarget(duration, 'ban duration input', testInfo); - await expectUsableTarget(cancel, 'ban delete cancel', testInfo); - await expectUsableTarget(submit, 'ban delete destructive submit', testInfo); - await expect(submit, 'submit should carry destructive styling').toHaveClass(/btn-danger/); - await expectNoCoveredCenters(page.locator('#ban-delete-modal input, #ban-delete-modal button'), 'ban delete modal controls'); - await expectNoHorizontalOverflow(page, 'ban delete modal'); - - await cancel.click(); - await expect(modal).toBeHidden(); - await expect(reply).toBeVisible(); - await expect(banButton).toBeFocused(); - - await banButton.click(); - await expect(modal).toBeVisible(); - await duration.fill('-1'); - await submit.click(); - await expect(page.locator('#ban-delete-error')).toContainText(/duration must be 0 or a positive number/i); - await expect(duration).toBeFocused(); - - await reason.fill('modal ban delete reason'); - await duration.fill('2'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}#p${postId}`)), - submit.click(), - ]); - await expect(page.locator(`#p${postId}`)).toHaveCount(0); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${postId};`)).toBe('0'); - expect(sqliteQuery(app, `SELECT reason FROM bans WHERE ip_hash = '${ipHash}' ORDER BY id DESC LIMIT 1;`)).toBe('modal ban delete reason'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${ipHash}' AND expires_at IS NOT NULL;`)).toBe('1'); - }); - - test('no-JS fallback still submits the original form with default moderation fields', async ({ page, app }, testInfo) => { - test.skip(!isNoJsProject(testInfo), 'no-JS fallback is covered by the no-JS projects'); - - const board = uniqueShort('bnj', testInfo); - app.createBoardCli({ short: board, name: 'Ban Delete No JS', description: 'no js ban delete fixture' }); - const threadId = await createThreadNoJs(page, app.baseURL, board, { - subject: 'ban delete no js', - body: 'OP remains after reply deletion', - }); - await createReplyNoJs(page, app.baseURL, board, threadId, 'reply selected for no-js ban delete'); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - - const reply = page.locator('.post.reply').first(); - const banForm = reply.locator('form[data-ban-delete-pid]').first(); - const postId = Number(await banForm.getAttribute('data-ban-delete-pid')); - const ipHash = await banForm.locator('input[name="ip_hash"]').inputValue(); - - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}#p${postId}`)), - banForm.getByRole('button', { name: /ban\+del/i }).click(), - ]); - await expectSafePage(page, { allowAdminInternals: true }); - await expect(page.locator(`#p${postId}`)).toHaveCount(0); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${postId};`)).toBe('0'); - expect(sqliteQuery(app, `SELECT reason FROM bans WHERE ip_hash = '${ipHash}' ORDER BY id DESC LIMIT 1;`)).toBe('Rule violation'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${ipHash}' AND expires_at IS NULL;`)).toBe('1'); - }); -}); - -async function createThreadNoJs( - page: Page, - baseURL: string, - board: string, - options: { subject: string; body: string }, -): Promise { - await page.goto(`${baseURL}/${board}`); - const form = page.locator(`form[action="/${board}"]`).first(); - await form.locator('input[name="subject"]').fill(options.subject); - await form.locator('textarea[name="body"]').fill(options.body); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/\\d+`)), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - await expectSafePage(page); - const threadId = Number(page.url().match(/\/thread\/(\d+)/)?.[1]); - expect(Number.isInteger(threadId), 'thread id should be present in URL').toBe(true); - return threadId; -} - -async function createReplyNoJs( - page: Page, - baseURL: string, - board: string, - threadId: number, - body: string, -): Promise { - await page.goto(`${baseURL}/${board}/thread/${threadId}`); - const form = page.locator(`form[action="/${board}/thread/${threadId}"]`).first(); - await form.locator('textarea[name="body"]').fill(body); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - form.getByRole('button', { name: /post reply/i }).click(), - ]); - await expectSafePage(page); -} - -async function expectUsableTarget(locator: Locator, name: string, testInfo: TestInfo): Promise { - await expect(locator, `${name} should be visible`).toBeVisible(); - const box = await locator.boundingBox(); - expect(box, `${name} should have layout bounds`).not.toBeNull(); - expect(box!.width, `${name} should not collapse horizontally`).toBeGreaterThan(0); - expect(box!.height, `${name} should be tall enough to use`).toBeGreaterThanOrEqual( - testInfo.project.name.includes('mobile') ? MOBILE_MIN_TARGET : DESKTOP_MIN_TARGET, - ); - const awkward = await locator.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - horizontalOverflow: element.scrollWidth > element.clientWidth + 1, - verticalOverflow: element.scrollHeight > element.clientHeight + 3 && style.whiteSpace === 'nowrap', - }; - }); - expect(awkward.horizontalOverflow, `${name} text/content should not be clipped horizontally`).toBeFalsy(); - expect(awkward.verticalOverflow, `${name} text/content should not be clipped vertically`).toBeFalsy(); -} - -async function expectNoHorizontalOverflow(page: Page, name: string): Promise { - const overflow = await page.evaluate(() => { - const doc = document.documentElement; - const body = document.body; - return Math.max(doc.scrollWidth - doc.clientWidth, body.scrollWidth - body.clientWidth); - }); - expect(overflow, `${name} should not create horizontal page overflow`).toBeLessThanOrEqual(2); -} - -async function expectNoCoveredCenters(locator: Locator, name: string): Promise { - const covered = await locator.evaluateAll((elements) => elements - .filter((element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; - }) - .map((element, index) => { - const rect = element.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { - return null; - } - const top = document.elementFromPoint(x, y); - return top && (element === top || element.contains(top) || top.contains(element)) ? null : index + 1; - }) - .filter((index): index is number => index !== null)); - expect(covered, `${name} should not be covered or overlapped at control centers`).toEqual([]); -} diff --git a/tests/e2e/admin-dashboard.spec.ts b/tests/e2e/admin-dashboard.spec.ts deleted file mode 100644 index 9e3a3808..00000000 --- a/tests/e2e/admin-dashboard.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { expectHttpError, newAuditedContext } from './diagnostics'; -import type { Locator, Page, TestInfo } from '@playwright/test'; -import { - adminLogin, - expect, - expectSafePage, - RustChanServer, - sqliteExec, - sqliteQuery, - test, - isNoJsProject, -} from './helpers'; -import { BUILTIN_THEMES, expectReadableContrast } from './phase4-helpers'; - -test.describe('admin dashboard control center', () => { - test('desktop dashboard prioritizes current issues and task-oriented workflows', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'dashboard desktop coverage runs on Chromium first'); - - let liveLogRequests = 0; - page.on('request', (request) => { - if (new URL(request.url()).pathname === '/admin/log/live') liveLogRequests += 1; - }); - const reason = seedOpenReport(app, testInfo); - await adminLogin(page, app); - await expect(page.locator('#admin-live-log-status')).toContainText('Open this section'); - expect(liveLogRequests, 'a closed live-log disclosure should not poll').toBe(0); - await page.goto(`${app.baseURL}/admin/panel?open=control-center#control-center`); - await expectTargetBelowStickyHeader(page, '#control-center', 'direct Control Center navigation'); - - const controlCenter = page.locator('#control-center'); - const disclosure = controlCenter.locator('details.admin-dropdown').first(); - await expect(disclosure).toHaveAttribute('open', ''); - await expect(page.getByRole('heading', { level: 2, name: /control center/i })).toBeVisible(); - - for (const heading of [ - 'site overview and health', - 'moderation and recent activity', - 'backups and recovery', - 'maintenance and background jobs', - 'network and Tor', - 'configuration shortcuts', - ]) { - await expect(controlCenter.getByRole('heading', { level: 3, name: new RegExp(heading, 'i') })).toBeVisible(); - } - await expect(controlCenter.locator('.admin-control-group')).toHaveCount(6); - - await expect(dashboardStatus(page, 'jobs')).toHaveAttribute('data-dashboard-state', 'ok'); - await expect(dashboardStatus(page, 'jobs')).toContainText(/idle.*ready/i); - await expect(dashboardStatus(page, 'tor')).toHaveAttribute('data-dashboard-state', 'disabled'); - await expect(dashboardStatus(page, 'media-tools')).toHaveAttribute('data-dashboard-state', 'informational'); - await expect(dashboardStatus(page, 'reports')).toHaveAttribute('data-dashboard-state', 'action-needed'); - await expect(controlCenter.locator('[data-dashboard-alert="reports"]')).toBeVisible(); - await expect(controlCenter.locator('[data-dashboard-alert="backups"]')).toBeVisible(); - await expect(controlCenter.locator('a[href="/admin/panel?open=full-backup-restore#full-backup-restore"]')).toHaveCount(1); - await expect(page.locator('#reports')).toContainText(reason); - - await expect(controlCenter.locator('form[action="/admin/setup/reopen"]')).toHaveCount(0); - await expect(page.locator('#database-maintenance form[action="/admin/setup/reopen"] input[name="_csrf"]')).toHaveCount(1); - await expect(controlCenter.locator('a[href="/admin/panel?open=boards#boards"]').first()).toBeVisible(); - await expect(controlCenter.locator('a[href="/admin/panel?open=site-health#site-health"]').first()).toBeVisible(); - await expect(controlCenter.locator('a[href="/admin/mod-log"]').first()).toBeVisible(); - - const systemDetails = controlCenter.locator('.admin-control-system-details'); - await systemDetails.locator(':scope > summary').focus(); - await page.keyboard.press('Enter'); - await expect(systemDetails).toHaveAttribute('open', ''); - await expect(systemDetails).toContainText(/database|media totals|diagnostics/i); - - await controlCenter.locator('a[href="/admin/panel?open=full-backup-restore#full-backup-restore"]').first().click(); - await expect(page.locator('#full-backup-restore > details')).toHaveAttribute('open', ''); - await expect(page).toHaveURL(/open=full-backup-restore#full-backup-restore$/); - await expectTargetBelowStickyHeader(page, '#full-backup-restore', 'enhanced backup navigation'); - - await page.locator('.admin-section-index a[href="#control-center"]').click(); - await controlCenter.locator('a[href="/admin/panel?open=live-log#live-log"]').first().click(); - await expect(page.locator('#live-log > details')).toHaveAttribute('open', ''); - await expect.poll(() => liveLogRequests).toBeGreaterThan(0); - await page.locator('#live-log > details > summary').click(); - await expect(page.locator('#admin-live-log-status')).toContainText('Open this section'); - const requestsAfterClose = liveLogRequests; - await page.clock.install(); - await page.clock.runFor(4000); - expect(liveLogRequests, 'closing live-log should stop polling').toBe(requestsAfterClose); - await expectNoHorizontalOverflow(page, 'desktop dashboard'); - await expectSafePage(page, { allowAdminInternals: true }); - }); - - test('mobile dashboard is visible, tappable, and contained at 390px and 320px', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'mobile-firefox', 'mobile dashboard coverage runs on mobile Firefox'); - - await adminLogin(page, app); - await expect(page.locator('#control-center > details')).toHaveAttribute('open', ''); - await expect(page.locator('#control-center .admin-control-group')).toHaveCount(6); - await expect(page.locator('#control-center .admin-control-group').first()).toBeVisible(); - - for (const width of [390, 320]) { - await page.setViewportSize({ width, height: 844 }); - await expectNoHorizontalOverflow(page, `${width}px dashboard`); - const escaped = await page.locator('#control-center .admin-control-group').evaluateAll((groups) => { - const viewportWidth = document.documentElement.clientWidth; - return groups - .map((group) => group.getBoundingClientRect()) - .filter((rect) => rect.left < -1 || rect.right > viewportWidth + 1) - .map((rect) => ({ left: rect.left, right: rect.right, viewportWidth })); - }); - expect(escaped, `${width}px task groups should stay within the viewport`).toEqual([]); - } - - const actionHeights = await page.locator('#control-center .admin-control-action:visible').evaluateAll((actions) => ( - actions.map((action) => action.getBoundingClientRect().height) - )); - expect(actionHeights.length).toBeGreaterThan(0); - expect(Math.min(...actionHeights), 'mobile Control Center actions should be at least 42px tall').toBeGreaterThanOrEqual(42); - }); - - test('no-JS dashboard opens by default and its links reveal protected destinations', async ({ page, app }, testInfo) => { - test.skip(!isNoJsProject(testInfo), 'no-JS dashboard coverage runs on the no-JS projects'); - - await adminLogin(page, app); - await expect(page.locator('#control-center > details')).toHaveAttribute('open', ''); - - const systemDetails = page.locator('#control-center .admin-control-system-details'); - await systemDetails.locator(':scope > summary').click(); - await expect(systemDetails).toHaveAttribute('open', ''); - - await page.locator('#control-center a[href="/admin/panel?open=full-backup-restore#full-backup-restore"]').first().click(); - await expect(page).toHaveURL(/open=full-backup-restore#full-backup-restore$/); - await expect(page.locator('#full-backup-restore > details')).toHaveAttribute('open', ''); - await expectTargetBelowStickyHeader(page, '#full-backup-restore', 'no-JS backup navigation'); - - await page.goto(`${app.baseURL}/admin/panel`); - await page.locator('#control-center a[href="/admin/panel?open=database-maintenance#database-maintenance"]').last().click(); - await expect(page.locator('#database-maintenance > details')).toHaveAttribute('open', ''); - await expect(page.locator('#database-maintenance form[action="/admin/setup/reopen"] input[name="_csrf"]')).toHaveCount(1); - - await page.goto(`${app.baseURL}/admin/panel`); - await page.locator('#control-center a[href="/admin/panel?open=theme-catalog#theme-catalog"]').click(); - await expect(page.locator('#theme-catalog > details')).toHaveAttribute('open', ''); - - await page.goto(`${app.baseURL}/admin/panel?open=live-log#live-log`); - await expect(page.locator('#live-log > details')).toHaveAttribute('open', ''); - await expect(page.locator('#live-log a[href="/admin/log/live?bytes=65536"]')).toBeVisible(); - await expect(page.locator('[data-admin-live-log-controls]')).toBeHidden(); - await expect(page.locator('#admin-live-log-output')).toContainText(/JavaScript is available/i); - - await page.goto(`${app.baseURL}/admin/panel?open=site-health#site-health`); - await page.locator('[data-admin-diagnostics] > summary').click(); - await expect(page.locator('[data-admin-diagnostics-text]')).toContainText(/RustChan version/i); - await expect(page.locator('[data-admin-diagnostics-text]')).not.toContainText(/\/Users\//); - await expectNoHorizontalOverflow(page, 'no-JS dashboard'); - }); - - test('state pills retain readable text contrast across built-in themes', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'theme contrast coverage runs on Chromium first'); - - await adminLogin(page, app); - for (const [slug, label] of BUILTIN_THEMES) { - await page.goto(`${app.baseURL}/theme/${slug}?return_to=/admin/panel`); - await page.waitForURL(`${app.baseURL}/admin/panel`); - await expect(page.locator('#control-center > details')).toHaveAttribute('open', ''); - for (const state of ['ok', 'informational', 'action-needed', 'disabled', 'unknown']) { - const selector = `#control-center .admin-state-pill-${state}:visible`; - if (await page.locator(selector).count()) { - await expectReadableContrast(page, selector, `${label} ${state} status`, 4.5); - } - } - } - }); - - test('unauthenticated admin panel requests do not render the dashboard', async ({ browser, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'unauthenticated dashboard coverage runs on Chromium first'); - - const context = await newAuditedContext(browser); - const page = await context.newPage(); - try { - // The 403 document response is the subject of this test, so it is declared - // rather than left as an undeclared error surface. - expectHttpError({ - method: 'GET', - path: '/admin/panel', - status: 403, - reason: 'an unauthenticated admin panel request is denied with the forbidden page', - }); - await page.goto(`${app.baseURL}/admin/panel`); - await expect(page.locator('#control-center')).toHaveCount(0); - await expect(page.locator('body')).toContainText(/not logged in|forbidden|admin login/i); - } finally { - await context.close(); - } - }); -}); - -function dashboardStatus(page: Page, key: string): Locator { - return page.locator(`#control-center [data-dashboard-status="${key}"]`).first(); -} - -function seedOpenReport(app: RustChanServer, testInfo: TestInfo): string { - const boardId = Number(sqliteQuery(app, "SELECT id FROM boards WHERE short_name = 'pub' LIMIT 1;")); - const stamp = `${Date.now()}-${testInfo.workerIndex}-${testInfo.repeatEachIndex}`; - const reason = `dashboard report ${stamp}`; - const now = Math.floor(Date.now() / 1000); - sqliteExec(app, [ - `INSERT INTO threads (board_id, subject, created_at, bumped_at) VALUES (${boardId}, 'dashboard report', ${now}, ${now});`, - `INSERT INTO posts (thread_id, board_id, body, body_html, deletion_token, is_op, created_at) VALUES (last_insert_rowid(), ${boardId}, 'reported dashboard post', 'reported dashboard post', 'dash-${stamp}', 1, ${now});`, - `INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash, created_at) VALUES (last_insert_rowid(), (SELECT thread_id FROM posts WHERE id = last_insert_rowid()), ${boardId}, ${sqlString(reason)}, 'dashboard-reporter', ${now});`, - ].join('\n')); - return reason; -} - -function sqlString(value: string): string { - return `'${value.replace(/'/g, "''")}'`; -} - -async function expectNoHorizontalOverflow(page: Page, label: string): Promise { - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); - expect(overflow, `${label} should not overflow horizontally`).toBeLessThanOrEqual(1); -} - -async function expectTargetBelowStickyHeader(page: Page, selector: string, label: string): Promise { - const positions = await page.locator(selector).evaluate((target) => ({ - headerBottom: document.querySelector('.site-header')?.getBoundingClientRect().bottom ?? 0, - targetTop: target.getBoundingClientRect().top, - })); - expect(positions.targetTop, `${label} should clear the sticky header`).toBeGreaterThanOrEqual( - positions.headerBottom - 1, - ); -} diff --git a/tests/e2e/admin-management-settings.spec.ts b/tests/e2e/admin-management-settings.spec.ts deleted file mode 100644 index 7693be86..00000000 --- a/tests/e2e/admin-management-settings.spec.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { test, expect, adminLogin, adminCsrf, createStandaloneApp, sqliteExec, sqliteQuery, isNoJsProject } from './helpers'; -import { expectHttpError, newAuditedContext } from './diagnostics'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import net from 'node:net'; - -async function freePort(): Promise { - const server = net.createServer(); - await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Missing disposable listener address'); - await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); - return address.port; -} - -// Every instance, SQL trigger and destructive action is confined to disposable loopback fixtures. -test('task navigation exposes one workspace at a time on desktop and mobile without JavaScript', async ({ page, browser }, info) => { - const app = await createStandaloneApp({ admin: true, boards:[{short:"pub",name:"Public Board"}] }); - try { - await adminLogin(page, app); - const storageState = await page.context().storageState(); - for (const [layout, viewport] of [['desktop', {width:1280,height:900}], ['mobile', {width:390,height:844}]] as const) { - const context = await newAuditedContext(browser, { viewport, javaScriptEnabled: !isNoJsProject(info), storageState }); - try { - const ui = await context.newPage(); - await ui.goto(`${app.baseURL}/admin/panel`); - await expect(ui.locator('.admin-task:visible')).toHaveCount(1); - const links = await ui.locator('.admin-section-index a').evaluateAll(items => items.map(item => item.getAttribute('href')!)); - expect(links.length).toBeGreaterThanOrEqual(17); - for (const href of links) { - await ui.locator(`.admin-section-index a[href="${href}"]`).click(); - await expect(ui.locator('.admin-task:visible')).toHaveCount(1); - await expect(ui.locator(href)).toBeVisible(); - expect(await ui.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - } - if (layout === 'desktop') { - for (const [open, target] of [['media-settings','#media-settings'],['database-maintenance','#database-maintenance'],['full-backup-restore','#full-backup-restore'],['board-pub','#board-pub'],['board-appearance-pub','#board-appearance-pub'],['reports','#reports']]) { - await ui.goto(`${app.baseURL}/admin/panel?open=${open}`); - await expect(ui.locator('.admin-task:visible')).toHaveCount(1); - await expect(ui.locator(target)).toBeVisible(); - } - } - await ui.locator('.admin-section-index a[href="#accounts"]').click(); - await ui.locator('#accounts').scrollIntoViewIfNeeded(); - await ui.screenshot({path:info.outputPath(`accounts-${layout}.png`)}); - await ui.locator('.admin-section-index a[href="#control-center"]').click(); - await ui.locator('#control-center').scrollIntoViewIfNeeded(); - await ui.screenshot({path:info.outputPath(`overview-${layout}.png`)}); - } finally { await context.close(); } - } - } finally { await app.dispose(); } -}); - -test('HTTPS controls stage real TLS configuration and the restarted native listener serves HTTPS', async ({page}) => { - const app = await createStandaloneApp({admin:true}); - try { - await adminLogin(page,app); - await page.goto(`${app.baseURL}/admin/panel#https`); - await expect(page.locator('#https .admin-setting')).toHaveCount(12); - await expect(page.locator('#https')).toContainText('Build support: ACME'); - await expect(page.locator('#https')).toContainText('self-signed development certificates available'); - const port = await freePort(); - await page.locator('[name="tls.port"]').fill(String(port)); - await page.locator('[name="tls.enabled"]').selectOption('true'); - await page.locator('#admin-https-settings button[type=submit]').click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('Restart required'); - await expect(page.locator('[id="state-tls.enabled"]')).toContainText('restart required'); - const file = path.join(app.dataDir,'settings.toml'); - const before = await fsp.readFile(file,'utf8'); - await page.locator('[name="tls.manual_cert.cert_path"]').fill('missing.pem'); - expectHttpError({method:'POST',path:'/admin/config/https',status:422,reason:'Incomplete or missing manual certificate source must fail before saving.'}); - await page.locator('#admin-https-settings button[type=submit]').click(); - await expect(page.getByRole('alert')).toContainText('No changes saved'); - expect(await fsp.readFile(file,'utf8')).toBe(before); - await app.restart(); - const secure = await page.request.get(`https://127.0.0.1:${port}/healthz`, {ignoreHTTPSErrors:true}); - expect(secure.status()).toBe(200); - await page.goto(`${app.baseURL}/admin/panel#https`); - await expect(page.locator('[id="state-tls.enabled"]')).toContainText('Saved configuration matches'); - } finally { await app.dispose(); } -}); - -test('account creation and password resets require reauthentication and revoke target sessions', async ({page,browser},info) => { - const app = await createStandaloneApp({admin:true}); - let targetContext; - try { - await adminLogin(page,app); - await page.goto(`${app.baseURL}/admin/panel#accounts`); - const create = page.locator('#admin-account-create'); - await create.locator('[name=username]').fill('operator'); - await create.locator('[name=current_password]').fill('incorrect'); - await create.locator('[name=new_password]').fill('OperatorPass123!'); - await create.locator('[name=confirm_password]').fill('OperatorPass123!'); - await create.getByRole('button').click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('No account changes saved'); - expect(sqliteQuery(app,"SELECT COUNT(*) FROM admin_users WHERE username='operator';")).toBe('0'); - await create.locator('[name=current_password]').fill('AdminPass123!'); - await create.locator('[name=username]').fill('operator'); - await create.locator('[name=new_password]').fill('OperatorPass123!'); - await create.locator('[name=confirm_password]').fill('OperatorPass123!'); - await create.getByRole('button').click(); - await expect(page.locator('#accounts table')).toContainText('operator'); - targetContext = await newAuditedContext(browser,{javaScriptEnabled:!isNoJsProject(info)}); - const target = await targetContext.newPage(); - await target.goto(`${app.baseURL}/admin`); - await target.getByLabel('Username').fill('operator'); - await target.getByLabel('Password').fill('OperatorPass123!'); - await target.getByRole('button',{name:'authenticate'}).click(); - await expect(target.locator('.admin-panel')).toBeVisible(); - // Keep the authenticated session, but stop unrelated health/update polling - // before deliberately revoking it. The explicit protected-page request - // below still proves that the old session fails closed. - await target.goto(app.baseURL, {waitUntil:'domcontentloaded'}); - expect((await targetContext.cookies(app.baseURL)).some(cookie => cookie.name === 'chan_admin_session')).toBe(true); - expect(sqliteQuery(app,"SELECT COUNT(*) FROM admin_sessions WHERE admin_id=(SELECT id FROM admin_users WHERE username='operator');")).toBe('1'); - const reset=page.locator('#admin-account-password'); - await reset.locator('[name=username]').selectOption('operator'); - await reset.locator('[name=current_password]').fill('AdminPass123!'); - await reset.locator('[name=new_password]').fill('ReplacementPass123!'); - await reset.locator('[name=confirm_password]').fill('ReplacementPass123!'); - await reset.getByRole('button').click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('Account saved'); - expect(sqliteQuery(app,"SELECT COUNT(*) FROM admin_sessions WHERE admin_id=(SELECT id FROM admin_users WHERE username='operator');")).toBe('0'); - expectHttpError({method:'GET',path:'/admin/panel',status:403,reason:'Reset target session is revoked and the protected panel fails closed.'}); - const revoked = await target.goto(`${app.baseURL}/admin/panel`); - expect(revoked?.status()).toBe(403); - await expect(target.locator('main')).toContainText('Session expired or invalid'); - await target.goto(`${app.baseURL}/admin`); - await expect(target.getByRole('button',{name:'authenticate'})).toBeVisible(); - await target.getByLabel('Username').fill('operator'); - await target.getByLabel('Password').fill('ReplacementPass123!'); - await target.getByRole('button',{name:'authenticate'}).click(); - await expect(target.locator('.admin-panel')).toBeVisible(); - const html=await page.content(); - for(const secret of ['AdminPass123!','OperatorPass123!','ReplacementPass123!','$argon2']) expect(html).not.toContain(secret); - } finally { if(targetContext) await targetContext.close(); await app.dispose(); } -}); - -test('secret rotation is staged, redacted, and invalidates administrator sessions only after restart', async ({page}) => { - const app=await createStandaloneApp({admin:true}); - try { - await adminLogin(page,app); - await page.goto(`${app.baseURL}/admin/panel#storage`); - await expect(page.locator('#storage')).toContainText('--data-dir /absolute/new/data serve'); - await expect(page.locator('#storage')).toContainText('CHAN_UPLOADS'); - const settings=path.join(app.dataDir,'settings.toml'); - const before=await fsp.readFile(settings,'utf8'); - const rotate=page.locator('#admin-secret-rotation'); - await rotate.locator('[name=current_password]').fill('AdminPass123!'); - await rotate.locator('[name=confirmation]').fill('ROTATE'); - await rotate.getByRole('button').click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('Fresh secret staged'); - await expect(page.locator('#storage')).toContainText('different file secret is staged'); - const after=await fsp.readFile(settings,'utf8'); - const secret=(after.match(/^cookie_secret\s*=\s*"([a-f0-9]{64})"/m)??[])[1]; - expect(Boolean(secret)).toBe(true); - // Compare only non-secret content, avoiding secret dumps on test failures. - expect(after.replace(/^cookie_secret.*$/m,'cookie_secret = [redacted]')).toBe(before.replace(/^cookie_secret.*$/m,'cookie_secret = [redacted]')); - expect((await page.content()).includes(secret)).toBe(false); - expect(sqliteQuery(app,'SELECT COUNT(*) FROM admin_sessions;')).not.toBe('0'); - await app.restart(); - expect(sqliteQuery(app,'SELECT COUNT(*) FROM admin_sessions;')).toBe('0'); - expectHttpError({method:'GET',path:'/admin/panel',status:403,reason:'Secret rotation revokes the existing session at restart.'}); - const revoked = await page.goto(`${app.baseURL}/admin/panel`); - expect(revoked?.status()).toBe(403); - await expect(page.locator('main')).toContainText('Session expired or invalid'); - await page.goto(`${app.baseURL}/admin`); - await expect(page.getByRole('button',{name:'authenticate'})).toBeVisible(); - await adminLogin(page,app); - await page.goto(`${app.baseURL}/admin/panel#storage`); - await expect(page.locator('#storage')).toContainText('No staged file rotation detected'); - } finally {await app.dispose();} -}); - -test('database failures roll back complete Appearance and Media saves without changing live state or file seeds', async ({page}) => { - const app=await createStandaloneApp({admin:true}); - try { - await adminLogin(page,app); - const settings=path.join(app.dataDir,'settings.toml'); - const bytes=await fsp.readFile(settings,'utf8'); - const dbBefore=sqliteQuery(app,"SELECT key||'='||value FROM site_settings ORDER BY key;"); - sqliteExec(app,"CREATE TRIGGER reject_theme BEFORE INSERT ON site_settings WHEN NEW.key='default_theme' BEGIN SELECT RAISE(ABORT,'fixture rejected theme save'); END;"); - const site=await page.request.post(`${app.baseURL}/admin/site/settings`,{form:{_csrf:await adminCsrf(page,app),site_name:'MUST NOT APPLY',site_subtitle:'MUST NOT APPLY',default_theme:'terminal'},maxRedirects:0}); - expect(site.status()).toBe(500); - expect(sqliteQuery(app,"SELECT key||'='||value FROM site_settings ORDER BY key;")).toBe(dbBefore); - expect(await fsp.readFile(settings,'utf8')).toBe(bytes); - await page.goto(app.baseURL); - await expect(page.locator('body')).not.toContainText('MUST NOT APPLY'); - sqliteExec(app,"DROP TRIGGER reject_theme; CREATE TRIGGER reject_timeout BEFORE INSERT ON site_settings WHEN NEW.key='ffmpeg_timeout_secs' BEGIN SELECT RAISE(ABORT,'fixture rejected media save'); END;"); - const media=await page.request.post(`${app.baseURL}/admin/media/settings`,{form:{_csrf:await adminCsrf(page,app),ffmpeg_timeout_secs:'1800',media_auto_prune_enabled:'1',media_max_active_content_size:'1',media_max_active_content_size_unit:'gib'},maxRedirects:0}); - expect(media.status()).toBe(500); - expect(sqliteQuery(app,"SELECT key||'='||value FROM site_settings ORDER BY key;")).toBe(dbBefore); - expect(await fsp.readFile(settings,'utf8')).toBe(bytes); - } finally {await app.dispose();} -}); - -test('logging and bounded operator policies save actual supported values and apply after restart', async ({page}) => { - const app=await createStandaloneApp({admin:true,env:{RUST_LOG:undefined}}); - try { - await adminLogin(page,app); - for(const [section,key,value] of [['access','admin_login_fail_limit','7'],['display','index_threads_per_page','12'],['timeouts','write_timeout_secs','420'],['logging','log_filter','warn,admin=info']]) { - await page.goto(`${app.baseURL}/admin/panel#${section}`); - await page.locator(`[name=${key}]`).fill(value); - await page.locator(`#admin-${section}-settings button[type=submit]`).click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('Restart required'); - await expect(page.locator(`#state-${key}`)).toContainText('restart required'); - } - const file=path.join(app.dataDir,'settings.toml'); - const before=await fsp.readFile(file,'utf8'); - await page.locator('[name=log_filter]').fill('not a valid directive['); - expectHttpError({method:'POST',path:'/admin/config/logging',status:422,reason:'Malformed tracing filters must fail before persistence.'}); - await page.locator('#admin-logging-settings button[type=submit]').click(); - await expect(page.getByRole('alert')).toContainText('No changes saved'); - expect(await fsp.readFile(file,'utf8')).toBe(before); - await app.restart(); - await page.goto(`${app.baseURL}/admin/panel#access`); - await expect(page.locator('#setting-admin_login_fail_limit')).toHaveValue('7'); - await expect(page.locator('#state-admin_login_fail_limit')).toContainText('Saved configuration matches'); - } finally {await app.dispose();} -}); - -test('regular Appearance control applies the site NSFW default live while explicit visitor cookies win',async({page})=>{ - const app=await createStandaloneApp({admin:true}); - try{ - await adminLogin(page,app); - await page.goto(`${app.baseURL}/admin/panel#site-settings`); - await page.locator('#setting-default_hide_nsfw_boards').selectOption('true'); - await page.getByRole('button',{name:'Save visitor default'}).click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('applied live'); - expect(sqliteQuery(app,"SELECT value FROM site_settings WHERE key='default_hide_nsfw_boards';")).toBe('1'); - await page.goto(app.baseURL); - await expect(page.locator('input[name=hide_nsfw_boards]').first()).toBeChecked(); - await page.context().addCookies([{name:'rustchan_hide_nsfw',value:'0',url:app.baseURL}]); - await page.reload(); - await expect(page.locator('input[name=hide_nsfw_boards]').first()).not.toBeChecked(); - await app.restart(); - await page.goto(app.baseURL); - await expect(page.locator('input[name=hide_nsfw_boards]').first()).not.toBeChecked(); - }finally{await app.dispose();} -}); - - -test('every new configuration and credential route rejects anonymous, forged and cross-origin saves', async ({page}) => { - const app = await createStandaloneApp({admin:true}); - try { - const routes = ['https','tor','media','maintenance','system','access','display','logging','timeouts'].map(section=>`/admin/config/${section}`); - routes.push('/admin/accounts/create','/admin/accounts/password','/admin/secrets/rotate','/admin/appearance/defaults'); - const fields = {username:'owner',current_password:'AdminPass123!',new_password:'ChangedPass123!',confirm_password:'ChangedPass123!',confirmation:'ROTATE',default_hide_nsfw_boards:'true'}; - const settings = path.join(app.dataDir,'settings.toml'); - const fileBefore = await fsp.readFile(settings,'utf8'); - const dbBefore = sqliteQuery(app,"SELECT key||'='||value FROM site_settings ORDER BY key;"); - for(const route of routes) { - const response = await page.request.post(`${app.baseURL}${route}`,{form:fields,maxRedirects:0}); - expect(response.status(),route).toBe(403); - } - await adminLogin(page,app); - const csrf = await adminCsrf(page,app); - for(const route of routes) { - const forged = await page.request.post(`${app.baseURL}${route}`,{form:{...fields,_csrf:'forged'},maxRedirects:0}); - expect(forged.status(),route).toBe(403); - const cross = await page.request.post(`${app.baseURL}${route}`,{headers:{Origin:'https://attacker.example'},form:{...fields,_csrf:csrf},maxRedirects:0}); - expect(cross.status(),route).toBe(403); - } - expect(await fsp.readFile(settings,'utf8')).toBe(fileBefore); - expect(sqliteQuery(app,"SELECT key||'='||value FROM site_settings ORDER BY key;")).toBe(dbBefore); - expect(sqliteQuery(app,'SELECT COUNT(*) FROM admin_users;')).toBe('1'); - } finally {await app.dispose();} -}); - -test('backup save failure preserves the live scheduler and invalid environment fallback is reported accurately', async ({page}) => { - const app = await createStandaloneApp({admin:true,env:{CHAN_AUTO_FULL_BACKUP_COPIES:'malformed'}}); - const file = path.join(app.dataDir,'settings.toml'); - const preserved = path.join(app.dataDir,'preserved-settings.toml'); - let replaced = false; - try { - await adminLogin(page,app); - await page.goto(`${app.baseURL}/admin/panel?open=full-backup-restore#full-backup-restore`); - const form = page.locator('form[action="/admin/backup/settings"]:has([name=auto_full_backup_copies_to_keep])'); - await form.locator('[name=auto_full_backup_copies_to_keep]').fill('7'); - const before = await fsp.readFile(file,'utf8'); - await fsp.rename(file,preserved); - await fsp.mkdir(file); - replaced = true; - expectHttpError({method:'POST',path:'/admin/backup/settings',status:400,reason:'Actual settings path I/O failure must not update the live scheduler.'}); - await form.getByRole('button',{name:/save/i}).click(); - await expect(page.locator('main')).toContainText('No settings saved'); - expect(await fsp.readFile(preserved,'utf8')).toBe(before); - await fsp.rmdir(file); await fsp.rename(preserved,file); replaced = false; - await page.goto(`${app.baseURL}/admin/panel#configuration-state`); - const row = page.locator('#application-auto_full_backup_copies_to_keep'); - await expect(row).toContainText('present but invalid'); - await expect(row.locator('td').nth(0)).toHaveText('1'); - await page.goto(`${app.baseURL}/admin/panel?open=full-backup-restore#full-backup-restore`); - await form.locator('[name=auto_full_backup_copies_to_keep]').fill('7'); - await form.getByRole('button',{name:/save/i}).click(); - await page.goto(`${app.baseURL}/admin/panel#configuration-state`); - await expect(row.locator('td').nth(0)).toHaveText('7'); - await expect(row.locator('td').nth(1)).toHaveText('7'); - await expect(row).toContainText('present but invalid'); - await app.restart(); - await page.goto(`${app.baseURL}/admin/panel#configuration-state`); - await expect(row.locator('td').nth(0)).toHaveText('7'); - } finally { - if(replaced) {await fsp.rmdir(file);await fsp.rename(preserved,file);} - await app.dispose(); - } -}); diff --git a/tests/e2e/admin-network-settings.spec.ts b/tests/e2e/admin-network-settings.spec.ts deleted file mode 100644 index 59b15d03..00000000 --- a/tests/e2e/admin-network-settings.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { test, expect, adminLogin, createStandaloneApp, RustChanServer, isNoJsProject, ADMIN_USERNAME, ADMIN_PASSWORD } from './helpers'; -import { expectHttpError, newAuditedContext } from './diagnostics'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; - -// All runtimes are disposable local harness instances. Nothing reaches a public server. -test('network controls preserve comments, report overrides, and apply only after restart', async ({ page, browser }, testInfo) => { - const app = await createStandaloneApp({ admin: true }); - try { - const settings = path.join(app.dataDir, 'settings.toml'); - await fsp.appendFile(settings, '\n# operator note: retain this comment\n'); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel#network-security`); - const section = page.locator('#network-security'); - await expect(section).toBeVisible(); - await expect(section.locator('.admin-setting')).toHaveCount(12); - await expect(section.locator('#state-rate_limit_gets')).toContainText('CHAN_RATE_GETS'); - await expect(section.locator('#state-bind_addr')).toContainText('CHAN_BIND'); - await section.locator('[name=rate_limit_gets]').fill('180'); - await section.locator('[name=rate_limit_policy]').selectOption('reads'); - await section.locator('[name=trusted_proxy_cidrs]').fill('127.0.0.1/32\n::1/128'); - await section.locator('[name=public_hosts]').fill('example.test'); - await section.getByRole('button', { name: 'Save for next restart' }).click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('Restart required'); - await expect(page.locator('#state-rate_limit_gets')).toContainText('180'); - await expect(page.locator('#state-rate_limit_gets')).toContainText('1000'); - await expect(page.locator('#state-rate_limit_gets')).toContainText('Override prevents'); - await expect(page.locator('#state-rate_limit_policy')).toContainText('restart required'); - const bytes = await fsp.readFile(settings, 'utf8'); - expect(bytes).toContain('# operator note: retain this comment'); - expect(bytes).toContain('rate_limit_gets = 180'); - expect(bytes).toContain('rate_limit_policy = "reads"'); - await app.restart(); - await page.goto(`${app.baseURL}/admin/panel#network-security`); - await expect(page.locator('#state-rate_limit_policy')).toContainText('Saved configuration matches'); - await expect(page.locator('#setting-rate_limit_policy')).toHaveValue('reads'); - const storageState = await page.context().storageState(); - for (const [layout, viewport] of [['desktop', { width: 1280, height: 900 }], ['mobile', { width: 390, height: 844 }]] as const) { - const context = await newAuditedContext(browser, { viewport, javaScriptEnabled: !isNoJsProject(testInfo), storageState }); - try { - const rendered = await context.newPage(); - await rendered.goto(`${app.baseURL}/admin/panel#network-security`); - await rendered.locator('#network-security-title').scrollIntoViewIfNeeded(); - await expect(rendered.locator('#setting-behind_proxy')).toBeVisible(); - expect(await rendered.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await rendered.screenshot({ path: testInfo.outputPath(`network-${layout}.png`) }); - await rendered.locator('#setting-behind_proxy').focus(); - await rendered.keyboard.press('Tab'); - await expect(rendered.locator('#setting-trusted_proxy_cidrs')).toBeFocused(); - } finally { await context.close(); } - } - } finally { await app.dispose(); } -}); - -test('invalid network form retains entered values and makes no partial change', async ({ page }) => { - const app = await createStandaloneApp({ admin: true }); - try { - await adminLogin(page, app); - await page.goto(`${app.baseURL}/theme/blue-sky?return_to=/admin/panel%23network-security`); - const settings = path.join(app.dataDir, 'settings.toml'); - const before = await fsp.readFile(settings, 'utf8'); - await page.locator('#setting-rate_limit_gets').fill('180'); - await page.locator('#setting-trusted_proxy_cidrs').fill('bad proxy'); - expectHttpError({ method: 'POST', path: '/admin/network/settings', status: 422, reason: 'Invalid CIDR must be rejected without mutation.' }); - await page.locator('#admin-network-settings button[type=submit]').click(); - await expect(page.getByRole('alert')).toContainText('No changes saved'); - await expect(page.locator('html')).toHaveAttribute('data-theme', 'blue-sky'); - await expect(page.locator('#setting-rate_limit_gets')).toHaveValue('180'); - await expect(page.locator('#setting-trusted_proxy_cidrs')).toHaveValue('bad proxy'); - expect(await fsp.readFile(settings, 'utf8')).toBe(before); - } finally { await app.dispose(); } -}); - -test('network saves reject absent authorization, forged CSRF and cross-origin requests', async ({ page }) => { - const app = await createStandaloneApp({ admin: true }); - try { - const settings = path.join(app.dataDir, 'settings.toml'); - const before = await fsp.readFile(settings, 'utf8'); - const anonymous = await page.request.post(`${app.baseURL}/admin/network/settings`, { form: { rate_limit_gets: '100' } }); - expect(anonymous.status()).toBe(403); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel#network-security`); - const csrf = await page.locator('#admin-network-settings [name=_csrf]').inputValue(); - const forged = await page.request.post(`${app.baseURL}/admin/network/settings`, { form: { _csrf: 'forged', rate_limit_gets: '100' } }); - expect(forged.status()).toBe(403); - const crossOrigin = await page.request.post(`${app.baseURL}/admin/network/settings`, { headers: { Origin: 'https://attacker.example' }, form: { _csrf: csrf, rate_limit_gets: '100' } }); - expect(crossOrigin.status()).toBe(403); - expect(await fsp.readFile(settings, 'utf8')).toBe(before); - } finally { await app.dispose(); } -}); - -test('failed persistence is reported honestly and preserves the original file', async ({ page }) => { - const app = await createStandaloneApp({ admin: true }); - const settings = path.join(app.dataDir, 'settings.toml'); - const preserved = path.join(app.dataDir, 'preserved-settings.toml'); - let replaced = false; - try { - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel#network-security`); - const before = await fsp.readFile(settings, 'utf8'); - await fsp.rename(settings, preserved); - await fsp.mkdir(settings); - replaced = true; - await page.locator('#setting-rate_limit_gets').fill('180'); - expectHttpError({ method: 'POST', path: '/admin/network/settings', status: 422, reason: 'Disposable fixture replaces settings path with a directory to force a real I/O failure.' }); - await page.locator('#admin-network-settings button[type=submit]').click(); - await expect(page.getByRole('alert').first()).toContainText('No changes saved'); - expect(await fsp.readFile(preserved, 'utf8')).toBe(before); - } finally { - if (replaced) { await fsp.rmdir(settings); await fsp.rename(preserved, settings); } - await app.dispose(); - } -}); - -test('local trusted proxy simulation gives visitors independent browsing counters', async ({ request }) => { - const app = await RustChanServer.create(undefined, { env: { CHAN_BEHIND_PROXY: '1', CHAN_TRUSTED_PROXY_CIDRS: '127.0.0.1/32', CHAN_RATE_GETS: '3', CHAN_RATE_WINDOW: '600' } }); - try { - app.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - await app.start(); - const visitor = (ip: string) => request.get(`${app.baseURL}/healthz`, { headers: { 'X-Real-IP': ip } }); - for (let count = 0; count < 3; count += 1) expect((await visitor('198.51.100.20')).status()).toBe(200); - expect((await visitor('198.51.100.20')).status()).toBe(429); - expect((await visitor('198.51.100.21')).status()).toBe(200); - } finally { await app.dispose(); } -}); - -test('local untrusted peer cannot split its counter with spoofed forwarding headers', async ({ request }) => { - const app = await RustChanServer.create(undefined, { env: { CHAN_BEHIND_PROXY: '1', CHAN_TRUSTED_PROXY_CIDRS: '192.0.2.0/24', CHAN_RATE_GETS: '6', CHAN_RATE_WINDOW: '600' } }); - try { - app.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - await app.start(); - const statuses: number[] = []; - for (let count = 0; count < 7; count += 1) { - const response = await request.get(`${app.baseURL}/healthz`, { headers: { 'X-Real-IP': `198.51.100.${count + 1}`, 'X-Forwarded-For': `203.0.113.${count + 1}` } }); - statuses.push(response.status()); - } - expect(statuses).toContain(200); - expect(statuses[statuses.length - 1]).toBe(429); - } finally { await app.dispose(); } -}); diff --git a/tests/e2e/admin-polish.spec.ts b/tests/e2e/admin-polish.spec.ts deleted file mode 100644 index cabdd160..00000000 --- a/tests/e2e/admin-polish.spec.ts +++ /dev/null @@ -1,479 +0,0 @@ -import type { Locator, Page, TestInfo } from '@playwright/test'; -import { - RustChanServer, - adminCsrf, - adminLogin, - createReply, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - sqliteQuery, - test, - uniqueShort, - isNoJsProject, -} from './helpers'; - -const DESKTOP_MIN_TARGET = 30; -const MOBILE_MIN_TARGET = 38; - -test.describe('admin panel and moderation UI polish', () => { - test('admin login, panel sections, settings forms, and moderation queues stay usable', async ({ page, app }, testInfo) => { - await page.goto(`${app.baseURL}/admin`, { waitUntil: 'domcontentloaded' }); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'admin login'); - await expectUsableTarget(page.getByLabel('Username'), 'login username', testInfo); - await expectUsableTarget(page.getByLabel('Password'), 'login password', testInfo); - await expectUsableTarget(page.getByRole('button', { name: 'authenticate' }), 'login submit', testInfo); - await expectFocusAffordance(page.getByLabel('Username'), 'login username'); - - await page.getByLabel('Username').fill('admin'); - await page.getByLabel('Password').fill('wrong-password'); - await page.getByRole('button', { name: 'authenticate' }).click(); - await expect(page.getByRole('alert')).toContainText(/invalid username or password/i); - - const reported = await seedReportedPost(page, app, testInfo); - await adminLogin(page, app); - await expectNoHorizontalOverflow(page, 'admin panel shell'); - await openAdminSection(page, 'site health'); - await openAdminSection(page, 'boards'); - await openAdminSection(page, 'moderation'); - await openAdminSection(page, 'board banners'); - await openAdminSection(page, 'themes'); - await openAdminSection(page, 'full site backup'); - await openAdminSection(page, 'media settings'); - await openAdminSection(page, 'database maintenance'); - - await expectAdminControlsUsable(page, testInfo); - await expectFocusAffordance(page.locator('.admin-section-index a[href="#boards"]'), 'section jump link'); - await page.locator('.admin-section-index a[href="#site-settings"]').click(); - await expectFocusAffordance(page.locator('input[name="site_name"]').first(), 'site name input'); - - await page.locator('.admin-section-index a[href="#boards"]').click(); - const board = uniqueShort('pol', testInfo); - await page.locator('form.admin-board-create-form input[name="short_name"]').fill(board); - await page.locator('form.admin-board-create-form input[name="name"]').fill('Polish Board'); - await page.locator('form.admin-board-create-form input[name="description"]').fill('Board created by the admin polish pass'); - await Promise.all([ - page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/admin/board/create'), - page.locator('form.admin-board-create-form').getByRole('button', { name: 'create' }).click(), - ]); - await openAdminSection(page, 'boards'); - await expect(page.locator(`#board-${board}`)).toBeVisible(); - await page.locator(`#board-${board} > summary`).click(); - await expectUsableTarget(page.locator(`#board-${board} input[name="description"]`), 'board description input', testInfo); - await expectUsableTarget(page.locator(`#board-${board} button`, { hasText: 'save settings' }), 'board save settings', testInfo); - await expectUsableTarget(page.locator(`#board-${board} button`, { hasText: 'delete board' }), 'board delete action', testInfo); - await expectNoHorizontalOverflow(page, 'expanded board settings'); - - await openAdminSection(page, 'moderation'); - const reportRow = page.locator('#reports table').filter({ hasText: reported.reason }).locator('tbody tr').first(); - await expect(reportRow).toBeVisible(); - await expectUsableTarget(reportRow.getByRole('button', { name: /resolve/i }), 'resolve report', testInfo); - await expectFocusAffordance(reportRow.getByRole('button', { name: /resolve/i }), 'resolve report'); - - await page.locator('#active-bans input[name="ip_hash"]').fill(reported.ipHash); - await page.locator('#active-bans input[name="reason"]').fill('layout verification'); - await expectUsableTarget(page.locator('#active-bans').getByRole('button', { name: 'ban' }), 'add ban', testInfo); - await page.locator('#word-filters input[name="pattern"]').fill('awkward phrase'); - await page.locator('#word-filters input[name="replacement"]').fill('clear phrase'); - await expectUsableTarget(page.locator('#word-filters').getByRole('button', { name: 'add' }), 'add filter', testInfo); - - await openAdminSection(page, 'database maintenance'); - await page.getByRole('button', { name: /check database health/i }).click(); - await expect(page.locator('body')).toContainText(/database health|integrity/i); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'database health result'); - }); - - test('admin controls on public board, catalog, thread, and post surfaces stay grouped and tappable', async ({ page, app }, testInfo) => { - const fixture = await seedAdminControlFixture(page, app, testInfo); - await adminLogin(page, app); - - await page.goto(`${app.baseURL}/`, { waitUntil: 'domcontentloaded' }); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'homepage with admin controls'); - const reorder = page.locator(`.board-card:has(a[href="/${fixture.board}"]) .board-reorder-toggle`).first(); - if (await reorder.count()) { - await expectUsableTarget(reorder, 'homepage board reorder toggle', testInfo); - await reorder.click(); - await expectUsableTarget(page.locator(`.board-card:has(a[href="/${fixture.board}"]) .board-reorder-controls button`).first(), 'homepage reorder button', testInfo); - } - - await page.goto(`${app.baseURL}/${fixture.board}/catalog`, { waitUntil: 'domcontentloaded' }); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'catalog with admin session'); - await expectAdminToolbarUsable(page, testInfo); - const catalogMenu = page.locator('.catalog-thread-menu-toggle').first(); - if (isNoJsProject(testInfo)) { - await expect(catalogMenu).toBeHidden(); - await expectCatalogFallbackActionsUsable(page, testInfo); - } else { - await page.locator('.catalog-item').first().hover(); - await expectUsableTarget(catalogMenu, 'catalog admin thread menu toggle', testInfo); - await catalogMenu.click(); - await expectUsableTarget(page.locator('.catalog-thread-menu-item').first(), 'catalog admin thread menu action', testInfo); - } - - await page.goto(`${app.baseURL}/${fixture.board}/thread/${fixture.threadId}`, { waitUntil: 'domcontentloaded' }); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'thread with admin controls'); - await expectAdminToolbarUsable(page, testInfo); - await expectPostAdminControlsUsable(page, testInfo); - if (isNoJsProject(testInfo)) { - await expectNoJsThreadFallbackControls(page, testInfo); - return; - } - - await page.getByRole('button', { name: /sticky/i }).click(); - await expect(page).toHaveURL(new RegExp(`/${fixture.board}/thread/${fixture.threadId}`)); - await expect(page.locator('.thread-state-badge-pin').first()).toBeVisible(); - await expectAdminToolbarUsable(page, testInfo); - - await page.getByRole('button', { name: /lock/i }).click(); - await expect(page.locator('.locked-notice')).toBeVisible(); - await expectNoHorizontalOverflow(page, 'locked thread admin state'); - - await page.getByRole('button', { name: /unlock/i }).click(); - await expect(page.locator('.locked-notice')).toHaveCount(0); - - const deleteThread = page.getByRole('button', { name: /delete thread/i }); - await deleteThread.click(); - await expect(page.locator('#confirm-modal')).toBeVisible(); - await expectConfirmationModalUsable(page, testInfo); - await page.locator('#confirm-modal-cancel').click(); - await expect(page.locator('#confirm-modal')).toBeHidden(); - - const prompts: string[] = []; - page.on('dialog', async (dialog) => { - prompts.push(dialog.message()); - await dialog.dismiss(); - }); - await page.locator('.admin-post-controls form[data-ban-delete-pid]').first().getByRole('button', { name: /ban\+del/i }).click(); - expect(prompts, 'ban+delete should use the styled modal instead of browser prompts').toHaveLength(0); - await expect(page.locator('#ban-delete-modal')).toBeVisible(); - await expectUsableTarget(page.locator('#ban-delete-reason'), 'ban delete reason input', testInfo); - await expectUsableTarget(page.locator('#ban-delete-duration'), 'ban delete duration input', testInfo); - await expectUsableTarget(page.locator('#ban-delete-cancel'), 'ban delete cancel', testInfo); - await expectUsableTarget(page.locator('#ban-delete-submit'), 'ban delete submit', testInfo); - await page.locator('#ban-delete-cancel').click(); - await expect(page.locator('#ban-delete-modal')).toBeHidden(); - await expect(page).toHaveURL(new RegExp(`/${fixture.board}/thread/${fixture.threadId}`)); - - await page.locator('.post-controls .report-btn').first().click(); - await expect(page.locator('#report-modal')).toBeVisible(); - await expectUsableTarget(page.locator('#report-reason'), 'report reason input', testInfo); - await expectUsableTarget(page.locator('#report-submit-btn'), 'report submit', testInfo); - await expectUsableTarget(page.locator('#report-modal [data-action="close-report"]'), 'report cancel', testInfo); - await page.locator('#report-modal [data-action="close-report"]').click(); - await expect(page.locator('#report-modal')).toBeHidden(); - }); -}); - -async function seedReportedPost( - page: Page, - app: RustChanServer, - testInfo: TestInfo, -): Promise<{ board: string; threadId: number; postId: number; reason: string; ipHash: string }> { - const board = uniqueShort('rpt', testInfo); - app.createBoardCli({ short: board, name: 'Reported Board', description: 'Moderation queue fixture' }); - const threadId = await createThread(page, app, board, { - subject: 'reported thread', - body: 'post that should appear in the admin report queue', - }); - const postId = await firstPostId(page); - const reason = `admin polish report ${Date.now()}`; - await submitReport(page, board, threadId, postId, reason); - const ipHash = sqliteQuery(app, `SELECT ip_hash FROM posts WHERE id = ${postId};`); - return { board, threadId, postId, reason, ipHash }; -} - -async function seedAdminControlFixture( - page: Page, - app: RustChanServer, - testInfo: TestInfo, -): Promise<{ board: string; threadId: number }> { - const board = uniqueShort('admctl', testInfo); - app.createBoardCli({ short: board, name: 'Admin Controls', description: 'Public admin controls fixture' }); - setBoardFixtureSettings(app, board, { - allowArchive: true, - allowEditing: true, - allowSelfDelete: true, - allowImages: true, - }); - const threadId = await createThread(page, app, board, { - subject: 'admin control surface', - body: 'OP with media so post controls share space with attachments', - filePath: app.fixtures().tinyPng, - }); - await createReply(page, app, board, threadId, 'reply that exposes admin delete and ban controls'); - return { board, threadId }; -} - -async function firstPostId(page: Page): Promise { - const id = await page.locator('.post').first().getAttribute('id'); - const postId = Number(id?.replace(/^p/, '')); - expect(Number.isInteger(postId)).toBeTruthy(); - return postId; -} - -async function openAdminSection(page: Page, label: string): Promise { - const anchors: Record = { 'site health': 'site-health', boards: 'boards', moderation: 'moderation', 'board banners': 'appearance', themes: 'appearance', 'full site backup': 'backups', 'media settings': 'maintenance', 'database maintenance': 'maintenance' }; - await page.locator(`.admin-section-index a[href="#${anchors[label]}"]`).click(); - const summary = page.locator('.admin-dropdown > summary').filter({ hasText: label }).first(); - await expect(summary, `admin section ${label} should exist`).toBeVisible(); - const open = await summary.evaluate((element) => element.parentElement instanceof HTMLDetailsElement && element.parentElement.open); - if (!open) { - await summary.click(); - } - await expect.poll( - () => summary.evaluate((element) => element.parentElement instanceof HTMLDetailsElement && element.parentElement.open), - { message: `admin section ${label} should be open` }, - ).toBe(true); -} - -async function submitReport(page: Page, board: string, threadId: number, postId: number, reason: string): Promise { - const post = page.locator(`#p${postId}`); - const reportButton = post.locator('.report-btn').first(); - if (await reportButton.isVisible()) { - await reportButton.click(); - await page.locator('#report-reason').fill(reason); - await Promise.all([ - page.waitForURL(url => url.pathname === `/${board}/thread/${threadId}` && url.searchParams.get('reported') === '1', { waitUntil: 'domcontentloaded' }), - page.locator('#report-submit-btn').click(), - ]); - return; - } - - const fallback = post.locator('.report-fallback-form').first(); - await expect(fallback).toBeVisible(); - await openDetailsIfClosed(fallback.locator('.report-fallback-details').first()); - await fallback.locator('.report-fallback-reason').fill(reason); - await Promise.all([ - page.waitForURL(url => url.pathname === `/${board}/thread/${threadId}` && url.searchParams.get('reported') === '1', { waitUntil: 'domcontentloaded' }), - fallback.locator('.report-fallback-submit').click(), - ]); -} - -async function expectCatalogFallbackActionsUsable(page: Page, testInfo: TestInfo): Promise { - const actions = page.locator('.catalog-thread-fallback-actions').first(); - await expect(actions).toBeVisible(); - await expectUsableTarget(actions.locator('.catalog-thread-fallback-summary'), 'catalog fallback actions summary', testInfo); - await expectUsableTarget(actions.getByRole('button', { name: /pin thread|unpin thread/i }).first(), 'catalog fallback pin action', testInfo); - await expectUsableTarget(actions.getByRole('button', { name: /hide thread|unhide thread/i }).first(), 'catalog fallback hide action', testInfo); - await expectReportFallbackUsable(actions, testInfo); -} - -async function expectNoJsThreadFallbackControls(page: Page, testInfo: TestInfo): Promise { - await expect(page.locator('.post-controls .report-btn').first()).toBeHidden(); - await expectUsableTarget(page.getByRole('button', { name: /sticky/i }).first(), 'thread sticky action', testInfo); - await expectUsableTarget(page.getByRole('button', { name: /lock/i }).first(), 'thread lock action', testInfo); - await expectUsableTarget(page.getByRole('button', { name: /delete thread/i }).first(), 'thread delete action', testInfo); - await expectUsableTarget( - page.locator('.admin-post-controls form[data-ban-delete-pid]').first().getByRole('button', { name: /ban\+del/i }), - 'post ban delete action', - testInfo, - ); - await expectReportFallbackUsable(page.locator('.post-controls').first(), testInfo); -} - -async function expectReportFallbackUsable(root: Locator, testInfo: TestInfo): Promise { - const fallback = root.locator('.report-fallback-form').first(); - await expect(fallback).toBeVisible(); - await expectUsableTarget(fallback.locator('.report-fallback-summary'), 'report fallback summary', testInfo); - await openDetailsIfClosed(fallback.locator('.report-fallback-details').first()); - await expectUsableTarget(fallback.locator('.report-fallback-reason'), 'report fallback reason input', testInfo); - await expectUsableTarget(fallback.locator('.report-fallback-submit'), 'report fallback submit', testInfo); -} - -async function openDetailsIfClosed(details: Locator): Promise { - const open = await details.evaluate((element) => element instanceof HTMLDetailsElement && element.open); - if (!open) { - await details.locator('summary').click(); - } -} - -async function expectAdminControlsUsable(page: Page, testInfo: TestInfo): Promise { - await expectNoHorizontalOverflow(page, 'admin controls'); - const controls = page.locator([ - '.admin-panel button:visible', - '.admin-panel a.admin-link-button:visible', - '.admin-panel input[type="text"]:visible', - '.admin-panel input[type="password"]:visible', - '.admin-panel input[type="number"]:visible', - '.admin-panel select:visible', - '.admin-panel textarea:visible', - ].join(',')); - await expectVisibleControlsRendered(controls, 'admin panel controls'); - await expectNoCoveredCenters(controls, 'admin panel controls'); -} - -async function expectAdminToolbarUsable(page: Page, testInfo: TestInfo): Promise { - const toolbar = page.locator('.admin-toolbar').first(); - await expect(toolbar).toBeVisible(); - await expectVisibleControlsUsable(toolbar.locator('button, a'), 'thread admin toolbar', testInfo); - await expectNoCoveredCenters(toolbar.locator('button, a'), 'thread admin toolbar'); -} - -async function expectPostAdminControlsUsable(page: Page, testInfo: TestInfo): Promise { - const controls = page.locator('.post-controls.admin-post-controls').first(); - await expect(controls).toBeVisible(); - await expectVisibleControlsUsable(controls.locator('button, a'), 'post admin controls', testInfo); - await expectNoCoveredCenters(controls.locator('button, a'), 'post admin controls'); - await expectSameVisualGroup(controls.locator('button, a'), 'post admin controls'); -} - -async function expectConfirmationModalUsable(page: Page, testInfo: TestInfo): Promise { - await expectUsableTarget(page.locator('#confirm-modal-cancel'), 'confirm cancel', testInfo); - await expectUsableTarget(page.locator('#confirm-modal-continue'), 'confirm continue', testInfo); - await expectNoCoveredCenters(page.locator('#confirm-modal button'), 'confirmation modal buttons'); -} - -async function expectVisibleControlsUsable(locator: Locator, name: string, testInfo: TestInfo): Promise { - const count = await locator.count(); - expect(count, `${name} should expose visible controls`).toBeGreaterThan(0); - for (let index = 0; index < count; index += 1) { - const item = locator.nth(index); - if (!(await item.isVisible())) { - continue; - } - await expectUsableTarget(item, `${name} #${index + 1}`, testInfo); - } -} - -async function expectVisibleControlsRendered(locator: Locator, name: string): Promise { - const count = await locator.count(); - expect(count, `${name} should expose visible controls`).toBeGreaterThan(0); - for (let index = 0; index < count; index += 1) { - const item = locator.nth(index); - if (!(await item.isVisible())) { - continue; - } - const box = await item.boundingBox(); - expect(box, `${name} #${index + 1} should have layout bounds`).not.toBeNull(); - expect(box!.width, `${name} #${index + 1} should not collapse horizontally`).toBeGreaterThan(0); - expect(box!.height, `${name} #${index + 1} should not collapse vertically`).toBeGreaterThan(0); - } -} - -async function expectUsableTarget(locator: Locator, name: string, testInfo: TestInfo): Promise { - await expect(locator, `${name} should be visible`).toBeVisible(); - const box = await locator.boundingBox(); - expect(box, `${name} should have layout bounds`).not.toBeNull(); - expect(box!.width, `${name} should not collapse horizontally`).toBeGreaterThan(0); - expect(box!.height, `${name} should be tall enough to use`).toBeGreaterThanOrEqual( - isMobileProject(testInfo) ? MOBILE_MIN_TARGET : DESKTOP_MIN_TARGET, - ); - const awkward = await locator.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - horizontalOverflow: element.scrollWidth > element.clientWidth + 1, - verticalOverflow: element.scrollHeight > element.clientHeight + 3 && style.whiteSpace === 'nowrap', - }; - }); - const skipsHorizontalClipCheck = await locator.evaluate((element) => ( - element instanceof HTMLInputElement - || element instanceof HTMLSelectElement - || element instanceof HTMLTextAreaElement - )); - if (!skipsHorizontalClipCheck) { - expect(awkward.horizontalOverflow, `${name} text/content should not be clipped horizontally`).toBeFalsy(); - } - expect(awkward.verticalOverflow, `${name} text/content should not be clipped vertically`).toBeFalsy(); -} - -async function expectFocusAffordance(locator: Locator, name: string): Promise { - // Take an unfocused baseline first. The previous disjunction included - // `borderColor !== ''`, which is always true because computed borderColor is - // never the empty string, so it proved nothing. - await locator.evaluate((element) => { - if (document.activeElement === element) (element as HTMLElement).blur(); - }); - // Several focus affordances in this app are written as `:focus-visible` - // (e.g. `.admin-section-index a:focus-visible` in static/admin.css). Chromium - // only matches that pseudo-class after a keyboard interaction, so a - // programmatic focus() following a pointer click reports the *unfocused* - // style and would produce a false failure. Press Tab first to put the browser - // in keyboard modality, then focus the target. Verified with a raw-engine - // probe: click -> focus() gives `:focus-visible: false`; click -> Tab -> - // focus() gives `:focus-visible: true`. - await locator.page().keyboard.press('Tab'); - const before = await locator.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - boxShadow: style.boxShadow, - borderColor: style.borderColor, - outlineStyle: style.outlineStyle, - outlineWidth: Number.parseFloat(style.outlineWidth || '0'), - }; - }); - await locator.focus(); - await expect(locator).toBeFocused(); - const focusStyle = await locator.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - outlineStyle: style.outlineStyle, - outlineWidth: Number.parseFloat(style.outlineWidth), - boxShadow: style.boxShadow, - borderColor: style.borderColor, - }; - }); - expect( - focusStyle.outlineStyle !== 'none' && focusStyle.outlineWidth > 0 - || focusStyle.boxShadow !== before.boxShadow - || focusStyle.borderColor !== before.borderColor, - `${name} should show a focus affordance: ${JSON.stringify({ before, focus: focusStyle })}`, - ).toBeTruthy(); -} - -async function expectNoHorizontalOverflow(page: Page, name: string): Promise { - const overflow = await page.evaluate(() => { - const doc = document.documentElement; - const body = document.body; - return Math.max(doc.scrollWidth - doc.clientWidth, body.scrollWidth - body.clientWidth); - }); - expect(overflow, `${name} should not create horizontal page overflow`).toBeLessThanOrEqual(2); -} - -async function expectNoCoveredCenters(locator: Locator, name: string): Promise { - const covered = await locator.evaluateAll((elements) => elements - .filter((element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; - }) - .map((element, index) => { - const rect = element.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { - return null; - } - const top = document.elementFromPoint(x, y); - return top && (element === top || element.contains(top) || top.contains(element)) ? null : index + 1; - }) - .filter((index): index is number => index !== null)); - expect(covered, `${name} should not be covered or overlapped at control centers`).toEqual([]); -} - -async function expectSameVisualGroup(locator: Locator, name: string): Promise { - const boxes = await locator.evaluateAll((elements) => elements - .filter((element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; - }) - .map((element) => { - const rect = element.getBoundingClientRect(); - return { top: rect.top, bottom: rect.bottom }; - })); - expect(boxes.length, `${name} should have grouped controls`).toBeGreaterThan(1); - const firstTop = boxes[0].top; - for (const box of boxes) { - expect(Math.abs(box.top - firstTop), `${name} controls should stay visually grouped`).toBeLessThanOrEqual(8); - } -} - -function isMobileProject(testInfo: TestInfo): boolean { - return testInfo.project.name.includes('mobile'); -} diff --git a/tests/e2e/admin-polling-lifecycle.spec.ts b/tests/e2e/admin-polling-lifecycle.spec.ts deleted file mode 100644 index b5913229..00000000 --- a/tests/e2e/admin-polling-lifecycle.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { test, expect, adminLogin, isNoJsProject, uniqueShort } from './helpers'; - -test('site-health polling stops at navigation start, before the next timer callback', async ({ page, app }, testInfo) => { - test.skip(isNoJsProject(testInfo), 'polling requires JavaScript'); - await page.addInitScript(() => { - // Retain real timers, but allow the test to run the next health deadline - // exactly during beforeunload. Track clears as well so cancelled work does - // not get resurrected. This works even while Chromium blocks protocol - // evaluation behind a provisional navigation. - const deadlines = new Map void>(); - const timeout = window.setTimeout.bind(window); - const interval = window.setInterval.bind(window); - const clearTimeout = window.clearTimeout.bind(window); - const clearInterval = window.clearInterval.bind(window); - window.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => { - let id: number; - id = timeout(() => { - deadlines.delete(id); - if (typeof callback === 'function') callback(...args); - }, delay); - if (delay === 5000 && typeof callback === 'function') deadlines.set(id, () => callback(...args)); - return id; - }) as typeof window.setTimeout; - window.setInterval = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => { - const id = interval(callback, delay, ...args); - if (delay === 5000 && typeof callback === 'function') deadlines.set(id, () => callback(...args)); - return id; - }) as typeof window.setInterval; - window.clearTimeout = id => { deadlines.delete(id); clearTimeout(id); }; - window.clearInterval = id => { deadlines.delete(id); clearInterval(id); }; - (window as any).runHealthDeadline = () => { - for (const callback of [...deadlines.values()]) callback(); - }; - }); - await adminLogin(page, app); - await expect(page.locator('[data-admin-health-poll-status]')).toContainText('up to date'); - await page.evaluate(() => { - window.name = '0'; - const originalFetch = window.fetch.bind(window); - let navigating = false; - window.fetch = (input, init) => { - if (navigating && String(input).includes('/admin/site-health/jobs')) { - window.name = String(Number(window.name) + 1); - } - return originalFetch(input, init); - }; - // Registered after production lifecycle listeners: cancelled deadlines - // must already have been removed before the old document can fetch again. - window.addEventListener('beforeunload', () => { - navigating = true; - (window as any).runHealthDeadline(); - }); - }); - await page.locator('.admin-section-index a[href="#boards"]').click(); - const form = page.locator('form.admin-board-create-form'); - await form.locator('[name="short_name"]').fill(uniqueShort('nav', testInfo)); - await form.locator('[name="name"]').fill('Navigation lifecycle fixture'); - await Promise.all([ - page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/admin/board/create'), - form.getByRole('button', { name: 'create' }).click(), - ]); - await expect(page.locator('[data-admin-health-poll-status]')).toContainText('up to date'); - expect(await page.evaluate(() => window.name), 'no polling from the unloading document').toBe('0'); -}); diff --git a/tests/e2e/admin-runtime-settings.spec.ts b/tests/e2e/admin-runtime-settings.spec.ts deleted file mode 100644 index 2cbe39f0..00000000 --- a/tests/e2e/admin-runtime-settings.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { expectHttpError, newAuditedContext } from './diagnostics'; -import { test, expect, adminLogin, createStandaloneApp, sqliteQuery, isNoJsProject } from './helpers'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; - -// Shares the repository's disposable local runtime contract and no-JS projects. -test('Tor, media, schedules and system controls save supported settings with pending restart state', async ({ page }) => { - const app = await createStandaloneApp({ admin: true }); - try { - await adminLogin(page, app); - const cases = [ - { section: 'tor', field: 'tor_bootstrap_timeout_secs', value: '300', entries: 5 }, - { section: 'media', field: 'thumb_size', value: '512', entries: 16 }, - { section: 'schedules', field: 'db_warn_threshold_mb', value: '3000', entries: 4 }, - { section: 'system', field: 'db_pool_size', value: '12', entries: 2 }, - ]; - for (const item of cases) { - await page.goto(`${app.baseURL}/admin/panel#${item.section}`); - const section = page.locator(`#${item.section}`); - await expect(section.locator('.admin-setting')).toHaveCount(item.entries); - await section.locator(`[name=${item.field}]`).fill(item.value); - await section.getByRole('button', { name: 'Save for next restart' }).click(); - await expect(page.locator('.admin-panel > .admin-flash')).toContainText('Restart required'); - await expect(page.locator(`#state-${item.field}`)).toContainText('restart required'); - } - const bytes = await fsp.readFile(path.join(app.dataDir, 'settings.toml'), 'utf8'); - for (const item of cases) expect(bytes).toContain(`${item.field} = ${item.value}`); - expect(bytes).toContain('blocking_threads = 0'); - await app.restart(); - await page.goto(`${app.baseURL}/admin/panel#media`); - await expect(page.locator('#state-thumb_size')).toContainText('Saved configuration matches'); - await expect(page.locator('#setting-thumb_size')).toHaveValue('512'); - } finally { await app.dispose(); } -}); - -test('settings search finds operator synonyms through real GET forms', async ({ page }) => { - const app = await createStandaloneApp({ admin: true }); - try { - await adminLogin(page, app); - for (const [query, target] of [['Serveo', 'setting-behind_proxy'], ['proxy', 'setting-trusted_proxy_cidrs'], ['slow down', 'setting-rate_limit_gets'], ['upload limit', 'setting-max_image_size_mb']]) { - await page.locator('#admin-settings-query').fill(query); - await page.getByRole('button', { name: 'Search settings' }).click(); - const link = page.locator(`.admin-settings-search-results a[href$="#${target}"]`); - await expect(link).toBeVisible(); - await link.click(); - await expect(page.locator(`#${target}`)).toBeVisible(); - } - } finally { await app.dispose(); } -}); - -test('new-board upload defaults require restart and do not rewrite existing board caps', async ({ page }) => { - const app = await createStandaloneApp({ admin: true, boards: [{ short: 'existing', name: 'Existing' }] }); - try { - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel#media`); - await page.locator('#setting-max_image_size_mb').fill('12'); - await page.locator('#admin-media-settings button[type=submit]').click(); - await expect(page.locator('#state-max_image_size_mb')).toContainText('restart required'); - const create = async (short: string) => { - await page.goto(`${app.baseURL}/admin/panel?open=boards#boards`); - const form = page.locator('form.admin-board-create-form'); - await form.locator('[name=short_name]').fill(short); - await form.locator('[name=name]').fill(short); - await form.getByRole('button', { name: 'create', exact: true }).click(); - await expect(page.locator('.admin-panel')).toBeVisible(); - }; - await create('before'); - expect(sqliteQuery(app, "SELECT max_image_size FROM boards WHERE short_name='before';")).toBe(String(8 * 1024 * 1024)); - await app.restart(); - await create('after'); - expect(sqliteQuery(app, "SELECT max_image_size FROM boards WHERE short_name='after';")).toBe(String(12 * 1024 * 1024)); - expect(sqliteQuery(app, "SELECT max_image_size FROM boards WHERE short_name='existing';")).toBe(String(8 * 1024 * 1024)); - } finally { await app.dispose(); } -}); - -test('new network controls remain usable in every built-in theme on desktop and mobile', async ({ page, browser }, testInfo) => { - const app = await createStandaloneApp({ admin: true }); - try { - await adminLogin(page, app); - const storageState = await page.context().storageState(); - // Fixed-size contexts exercise actual desktop/mobile and no-JS rendering - // without Playwright's disabled-script document timing after viewport resize. - for (const [layout, viewport] of [['desktop', { width: 1280, height: 900 }], ['mobile', { width: 390, height: 844 }]] as const) { - const context = await newAuditedContext(browser, { viewport, javaScriptEnabled: !isNoJsProject(testInfo), storageState }); - try { - const themed = await context.newPage(); - for (const theme of ['forest', 'blue-sky', 'deep-orbit', 'terminal', 'dorfic', 'chanclassic', 'aero', 'neoncubicle', 'fluorogrid']) { - await themed.goto(`${app.baseURL}/theme/${theme}?return_to=${encodeURIComponent('/admin/panel#network-security')}`); - await expect(themed.locator('html')).toHaveAttribute('data-active-theme', theme); - await themed.locator('#network-security-title').scrollIntoViewIfNeeded(); - await expect(themed.locator('#setting-behind_proxy')).toBeVisible(); - expect(await themed.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await themed.screenshot({ path: testInfo.outputPath(`network-${theme}-${layout}.png`) }); - } - } finally { await context.close(); } - } - } finally { await app.dispose(); } -}); - -test('rejected runtime forms preserve the selected theme, entered fields and saved settings', async ({ page }) => { - const app = await createStandaloneApp({ admin: true }); - try { - await adminLogin(page, app); - const settings = path.join(app.dataDir, 'settings.toml'); - const before = await fsp.readFile(settings, 'utf8'); - for (const theme of ['blue-sky', 'deep-orbit']) { - await page.goto(`${app.baseURL}/theme/${theme}?return_to=/admin/panel%23tor`); - await page.locator('#setting-tor_service_nickname').fill('invalid nickname'); - expectHttpError({ method: 'POST', path: '/admin/config/tor', status: 422, reason: 'Invalid onion-service nickname must be rejected without mutation.' }); - await page.locator('#admin-tor-settings button[type=submit]').click(); - await expect(page.getByRole('alert')).toContainText('No changes saved'); - await expect(page.locator('html')).toHaveAttribute('data-theme', theme); - await expect(page.locator('#setting-tor_service_nickname')).toHaveValue('invalid nickname'); - expect(await fsp.readFile(settings, 'utf8')).toBe(before); - } - } finally { await app.dispose(); } -}); diff --git a/tests/e2e/admin-settings-phase2.spec.ts b/tests/e2e/admin-settings-phase2.spec.ts deleted file mode 100644 index a8533cc9..00000000 --- a/tests/e2e/admin-settings-phase2.spec.ts +++ /dev/null @@ -1,174 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { - adminCsrf, - adminLogin, - expect, - expectSafePage, - expectSafeResponse, - sqliteQuery, - test, -} from './helpers'; - -test.describe('phase 2 admin settings save flows', () => { - test('site, media, backup, banner toggles and authoritative persistence stay deterministic', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'browser-generic admin settings coverage runs on Chromium first'); - - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const initialSeeds = fs.readFileSync(path.join(app.dataDir, 'settings.toml'), 'utf8'); - - const longName = `Phase 2 ${'N'.repeat(90)}`; - const longSubtitle = `Subtitle ${'S'.repeat(160)}`; - const longSave = await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: csrf, - site_name: longName, - site_subtitle: longSubtitle, - default_theme: '../not-a-theme', - }, - maxRedirects: 0, - }); - expect(longSave.status()).toBe(303); - expect(settingValue(app, 'site_name')).toBe(longName.slice(0, 64)); - expect(settingValue(app, 'site_subtitle')).toBe(longSubtitle.slice(0, 128)); - expect(settingValue(app, 'default_theme')).toBe('forest'); - expect(settingValue(app, 'homepage_new_thread_badges_enabled')).toBe('0'); - expect(settingValue(app, 'homepage_new_reply_badges_enabled')).toBe('0'); - expect(settingValue(app, 'thread_new_reply_badges_enabled')).toBe('0'); - let settingsToml = fs.readFileSync(path.join(app.dataDir, 'settings.toml'), 'utf8'); - expect(settingsToml).toBe(initialSeeds); - - await page.goto(`${app.baseURL}/admin/panel#site-settings`); - const siteForm = page.locator('form[action="/admin/site/settings"]').first(); - await siteForm.locator('input[name="site_name"]').fill('Phase 2 Settings'); - await siteForm.locator('input[name="site_subtitle"]').fill('settings persisted through restart'); - await siteForm.locator('select[name="default_theme"]').selectOption('terminal'); - await siteForm.locator('input[name="homepage_new_thread_badges_enabled"]').uncheck(); - await siteForm.locator('input[name="homepage_new_reply_badges_enabled"]').uncheck(); - await siteForm.locator('input[name="thread_new_reply_badges_enabled"]').uncheck(); - await Promise.all([ - page.waitForURL(/\/admin\/panel/), - siteForm.getByRole('button', { name: /^save settings$/i }).click(), - ]); - expect(settingValue(app, 'site_name')).toBe('Phase 2 Settings'); - expect(settingValue(app, 'default_theme')).toBe('terminal'); - expect(settingValue(app, 'homepage_new_thread_badges_enabled')).toBe('0'); - - await page.goto(`${app.baseURL}/admin/panel?open=media-settings#media-settings`); - const mediaForm = page.locator('form[action="/admin/media/settings"]'); - await mediaForm.locator('input[name="ffmpeg_timeout_secs"]').fill('1800'); - await mediaForm.locator('input[name="media_max_active_content_size"]').fill('2'); - await mediaForm.locator('select[name="media_max_active_content_size_unit"]').selectOption('mib'); - await mediaForm.locator('input[name="media_auto_prune_enabled"]').check(); - await Promise.all([ - page.waitForURL(/\/admin\/panel/), - mediaForm.getByRole('button', { name: /save media settings/i }).click(), - ]); - settingsToml = fs.readFileSync(path.join(app.dataDir, 'settings.toml'), 'utf8'); - expect(settingsToml).toBe(initialSeeds); - expect(settingValue(app, 'media_auto_prune_enabled')).toBe('true'); - expect(settingValue(app, 'ffmpeg_timeout_secs')).toBe('1800'); - expect(settingValue(app, 'media_max_active_content_size_bytes')).toBe('2097152'); - - const invalidMedia = await page.request.post(`${app.baseURL}/admin/media/settings`, { - form: { - _csrf: await adminCsrf(page, app), - ffmpeg_timeout_secs: '29', - media_auto_prune_enabled: '1', - media_max_active_content_size: '0', - media_max_active_content_size_unit: 'mib', - }, - maxRedirects: 0, - }); - expect(invalidMedia.status()).toBe(303); - expect(decodeURIComponent(invalidMedia.headers().location ?? '')).toContain('ffmpeg_timeout_secs'); - expect(settingValue(app, 'media_auto_prune_enabled')).toBe('true'); - - const backupSave = await page.request.post(`${app.baseURL}/admin/backup/settings`, { - form: { - _csrf: await adminCsrf(page, app), - auto_full_backup_interval_hours: '0', - auto_full_backup_copies_to_keep: '7', - auto_full_backup_storage_mode: 'split_zip', - auto_full_backup_split_zip_part_size_gib: '2', - }, - maxRedirects: 0, - }); - expect(backupSave.status()).toBe(303); - settingsToml = fs.readFileSync(path.join(app.dataDir, 'settings.toml'), 'utf8'); - expect(settingsToml).toContain('auto_full_backup_interval_hours = 0'); - expect(settingsToml).toContain('auto_full_backup_copies_to_keep = 7'); - expect(settingsToml).toContain('auto_full_backup_include_tor_hidden_service_keys = false'); - expect(settingsToml).toContain('auto_full_backup_storage_mode = "split_zip"'); - expect(settingsToml).toContain('auto_full_backup_split_zip_part_size_gib = 2'); - - const invalidBackup = await page.request.post(`${app.baseURL}/admin/backup/settings`, { - form: { - _csrf: await adminCsrf(page, app), - auto_full_backup_interval_hours: '5', - auto_full_backup_copies_to_keep: '5', - auto_full_backup_storage_mode: 'tar', - auto_full_backup_split_zip_part_size_gib: '2', - }, - maxRedirects: 0, - }); - expect(invalidBackup.status()).toBe(400); - await expectSafeResponse(invalidBackup); - - const bannerOnly = await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: await adminCsrf(page, app), - banner_rotation_interval_minutes: '15', - }, - maxRedirects: 0, - }); - expect(bannerOnly.status()).toBe(303); - expect(settingValue(app, 'banner_rotation_interval_minutes')).toBe('15'); - expect(settingValue(app, 'banner_external_links_enabled')).toBe('0'); - expect(settingValue(app, 'homepage_new_thread_badges_enabled')).toBe('0'); - - await app.restart(); - await page.goto(app.baseURL); - await expect(page.locator('body')).toContainText('Phase 2 Settings'); - await expect(page.locator('body')).toContainText('settings persisted through restart'); - await expectSafePage(page); - expect(settingValue(app, 'site_name')).toBe('Phase 2 Settings'); - expect(settingValue(app, 'site_subtitle')).toBe('settings persisted through restart'); - expect(settingValue(app, 'ffmpeg_timeout_secs')).toBe('1800'); - settingsToml = fs.readFileSync(path.join(app.dataDir, 'settings.toml'), 'utf8'); - }); - - test('admin settings mutations are admin-only and CSRF-protected', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin security route coverage runs on Chromium first'); - - const loggedOut = await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: 'missing-session', - site_name: 'should not save', - site_subtitle: 'should not save', - default_theme: 'forest', - }, - maxRedirects: 0, - }); - expect(loggedOut.status()).toBe(403); - await expectSafeResponse(loggedOut); - - await adminLogin(page, app); - for (const route of ['/admin/site/settings', '/admin/media/settings', '/admin/backup/settings']) { - const response = await page.request.post(`${app.baseURL}${route}`, { - form: { _csrf: 'invalid.csrf' }, - maxRedirects: 0, - }); - expect(response.status(), route).toBe(403); - await expectSafeResponse(response); - } - }); -}); - -function settingValue(app: { dbPath(): string }, key: string): string { - return sqliteQuery( - app, - `SELECT value FROM site_settings WHERE key = '${key.replaceAll("'", "''")}' LIMIT 1;`, - ); -} diff --git a/tests/e2e/admin-table-scroll.spec.ts b/tests/e2e/admin-table-scroll.spec.ts deleted file mode 100644 index 202cde03..00000000 --- a/tests/e2e/admin-table-scroll.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { test, expect, adminLogin, sqliteExec } from './helpers'; -import { BUILTIN_THEMES } from './phase4-helpers'; - -const sections = ['control-center', 'site-settings', 'site-health', 'network-security', 'https', 'tor', 'access', 'timeouts', 'display', 'media', 'schedules', 'accounts', 'storage', 'logging', 'system', 'configuration-state', 'boards', 'moderation', 'appearance', 'backups', 'software-updates', 'maintenance']; - -for (const [theme] of BUILTIN_THEMES) { -test(`${theme}: 320px admin sections preserve native tables and keyboard-accessible local scrolling`, async ({ page, app }, testInfo) => { - test.skip(theme !== 'forest' && testInfo.project.name !== 'chromium', 'all-theme coverage runs on Chromium'); - const now = Math.floor(Date.now() / 1000); - sqliteExec(app, ` - INSERT INTO threads (board_id, subject, created_at, bumped_at) VALUES ((SELECT id FROM boards WHERE short_name='pub'), 'Wide table fixture', ${now}, ${now}); - INSERT INTO posts (thread_id, board_id, body, body_html, deletion_token, is_op, created_at) VALUES (last_insert_rowid(), (SELECT id FROM boards WHERE short_name='pub'), 'Long preview for the moderation table', 'Long preview for the moderation table', 'table-fixture', 1, ${now}); - INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash, created_at) VALUES (last_insert_rowid(), (SELECT thread_id FROM posts WHERE id=last_insert_rowid()), (SELECT id FROM boards WHERE short_name='pub'), 'A sufficiently long report reason to exercise action columns', 'table-reporter', ${now}); - `); - await page.setViewportSize({ width: 320, height: 844 }); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/theme/${theme}?return_to=/admin/panel`); - for (const section of sections) { - await page.goto(`${app.baseURL}/admin/panel?open=${section}#${section}`); - const target = page.locator(`#${section}`); - await expect(target).toBeVisible(); - expect(await page.evaluate(() => Math.max( - document.documentElement.scrollWidth - document.documentElement.clientWidth, - document.body.scrollWidth - document.body.clientWidth, - )), `${theme}/${section}: page-local overflow`).toBeLessThanOrEqual(2); - const wrappers = target.locator('.admin-table-wrap:visible'); - for (let i = 0; i < await wrappers.count(); i += 1) { - const wrapper = wrappers.nth(i); - const scrolls = await wrapper.evaluate(element => element.scrollWidth > element.clientWidth + 2); - if (!scrolls) continue; - await expect(wrapper).toHaveAttribute('tabindex', '0'); - await expect(wrapper).toHaveAttribute('role', 'region'); - await expect(wrapper).toHaveAttribute('aria-label', /\S/); - const semantics = await wrapper.locator('table').evaluate(table => ({ - display: getComputedStyle(table).display, - headers: table.querySelectorAll('th').length, - sticky: [...table.querySelectorAll('th,td')].some(cell => getComputedStyle(cell).position === 'sticky'), - touch: getComputedStyle(table.parentElement!).touchAction, - })); - expect(semantics.display).toBe('table'); - expect(semantics.headers).toBeGreaterThan(0); - expect(semantics.sticky, 'no sticky cells overlap content').toBe(false); - expect(semantics.touch, 'native touch panning is enabled').not.toBe('none'); - await wrapper.focus(); - await expect(wrapper).toBeFocused(); - const outline = await wrapper.evaluate(element => getComputedStyle(element).outlineWidth); - expect(parseFloat(outline), 'scroll region has visible focus').toBeGreaterThanOrEqual(2); - await page.keyboard.press('ArrowRight'); - await expect.poll(() => wrapper.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); - // Reach the far columns without moving the whole document. - await wrapper.evaluate(element => { element.scrollLeft = element.scrollWidth; }); - const actions = wrapper.locator('button, a').filter({ visible: true }); - for (let j = 0; j < await actions.count(); j += 1) { - await actions.nth(j).focus(); - await expect(actions.nth(j)).toBeFocused(); - const inside = await actions.nth(j).evaluate(element => { - const box = element.getBoundingClientRect(); - const parent = element.closest('.admin-table-wrap')!.getBoundingClientRect(); - return box.left >= parent.left - 1 && box.right <= parent.right + 1; - }); - expect(inside, 'focusing table actions scrolls them into reach').toBe(true); - } - } - } - await page.goto(`${app.baseURL}/admin/mod-log`); - await expect(page.locator('.admin-table-wrap')).toHaveAttribute('aria-label', 'Moderation log'); -}); -} diff --git a/tests/e2e/assets-themes-phase2.spec.ts b/tests/e2e/assets-themes-phase2.spec.ts deleted file mode 100644 index 10d4e954..00000000 --- a/tests/e2e/assets-themes-phase2.spec.ts +++ /dev/null @@ -1,351 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import * as zlib from 'node:zlib'; -import { - adminCsrf, - adminLogin, - boardId, - createBoard, - expect, - expectSafePage, - sqliteQuery, - test, - uniqueShort, - unlockBoard, - updateBoardSettings, -} from './helpers'; - -test.describe('phase 2 assets, favicons, banners, and themes', () => { - test('global and board favicons upload, replace, cache, protect, and clear without stale files', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'asset workflow coverage runs on Chromium first'); - - const faviconOne = path.join(app.fixtureDir, 'phase2-favicon-one.png'); - const faviconTwo = path.join(app.fixtureDir, 'phase2-favicon-two.png'); - fs.writeFileSync(faviconOne, pngRgba(512, 512, (index) => [12, 72, 180, 255][index % 4])); - fs.writeFileSync(faviconTwo, pngRgba(512, 512, (index) => [180, 42, 30, 255][index % 4])); - - await adminLogin(page, app); - const invalid = await page.request.post(`${app.baseURL}/admin/site/favicon`, { - multipart: { - _csrf: await adminCsrf(page, app), - favicon: { - name: 'tiny.png', - mimeType: 'image/png', - buffer: fs.readFileSync(app.fixtures().tinyPng), - }, - }, - headers: sameOriginHeaders(app), - maxRedirects: 0, - }); - expect(invalid.status()).toBe(303); - expect(fs.existsSync(path.join(app.dataDir, 'runtime', 'favicon', 'version.txt'))).toBe(false); - - const uploadOne = await uploadFavicon(page, app, '/admin/site/favicon', faviconOne); - expect(uploadOne.status()).toBe(303); - const globalDir = path.join(app.dataDir, 'runtime', 'favicon'); - const firstVersion = fs.readFileSync(path.join(globalDir, 'version.txt'), 'utf8').trim(); - expect(firstVersion).toMatch(/[0-9a-f-]{36}/i); - const versioned = await page.request.get(`${app.baseURL}/favicon-32x32.png?v=${firstVersion}`); - expect(versioned.status()).toBe(200); - expect(versioned.headers()['cache-control']).toContain('immutable'); - const unversioned = await page.request.get(`${app.baseURL}/favicon-32x32.png`); - expect(unversioned.status()).toBe(200); - expect(unversioned.headers()['cache-control']).not.toContain('immutable'); - - fs.writeFileSync(path.join(globalDir, 'stale-sibling.tmp'), 'stale'); - const uploadTwo = await uploadFavicon(page, app, '/admin/site/favicon', faviconTwo); - expect(uploadTwo.status()).toBe(303); - const secondVersion = fs.readFileSync(path.join(globalDir, 'version.txt'), 'utf8').trim(); - expect(secondVersion).not.toBe(firstVersion); - expect(fs.existsSync(path.join(globalDir, 'stale-sibling.tmp'))).toBe(false); - - const board = uniqueShort('fav', testInfo); - await createBoard(page, app, { short: board, name: 'Favicon Protected' }); - await updateBoardSettings(page, app, board, { - accessMode: 'view_password', - accessPassword: 'favicon-pass', - }); - const boardUpload = await uploadFavicon(page, app, '/admin/board/favicon', faviconOne, { - board_id: String(boardId(app, board)), - }); - expect(boardUpload.status()).toBe(303); - const boardFaviconDir = path.join(app.dataDir, 'boards', board, '_favicon'); - const boardVersion = fs.readFileSync(path.join(boardFaviconDir, 'version.txt'), 'utf8').trim(); - - await page.context().clearCookies(); - const protectedFavicon = await page.request.get(`${app.baseURL}/boards/${board}/_favicon/favicon-32x32.png?v=${boardVersion}`); - expect(protectedFavicon.status()).toBe(403); - await unlockBoard(page, app, board, 'favicon-pass'); - const accessibleFavicon = await page.request.get(`${app.baseURL}/boards/${board}/_favicon/favicon-32x32.png?v=${boardVersion}`); - expect(accessibleFavicon.status()).toBe(200); - expect(accessibleFavicon.headers()['cache-control']).toContain('private'); - - await adminLogin(page, app); - const clear = await page.request.post(`${app.baseURL}/admin/board/favicon/clear`, { - form: { - _csrf: await adminCsrf(page, app), - board_id: String(boardId(app, board)), - }, - maxRedirects: 0, - }); - expect(clear.status()).toBe(303); - expect(fs.existsSync(boardFaviconDir)).toBe(false); - }); - - test('banners upload, reject invalid assets, cache-bust, protect board assets, delete, and restore inherit mode', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'banner workflow coverage runs on Chromium first'); - - const bannerPng = path.join(app.fixtureDir, 'phase2-banner.png'); - fs.writeFileSync(bannerPng, pngRgba(468, 60, (index) => [20, 120, 80, 255][index % 4])); - await adminLogin(page, app); - const beforeGlobalCount = Number(sqliteQuery(app, "SELECT COUNT(*) FROM banner_assets WHERE scope_type = 'global';")); - const invalid = await uploadBanner(page, app, '/admin/site/banner', app.fixtures().tinyPng); - expect(invalid.status()).toBe(303); - expect(Number(sqliteQuery(app, "SELECT COUNT(*) FROM banner_assets WHERE scope_type = 'global';"))).toBe(beforeGlobalCount); - - const globalUpload = await uploadBanner(page, app, '/admin/site/banner', bannerPng, { - target_type: 'internal_board', - target_board_value: 'pub', - }); - expect(globalUpload.status()).toBe(303); - const global = bannerRow(app, "scope_type = 'global'"); - const globalPath = path.join(app.dataDir, 'runtime', 'banner', 'global', `${global.storageKey}.webp`); - expect(fs.existsSync(globalPath)).toBe(true); - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator(`img[data-banner-id="${global.id}"]`)).toBeVisible(); - - const globalUnversioned = await page.request.get(`${app.baseURL}/banner/assets/${global.id}`); - expect(globalUnversioned.status()).toBe(200); - expect(globalUnversioned.headers()['cache-control']).not.toContain('immutable'); - const globalVersioned = await page.request.get(`${app.baseURL}/banner/assets/${global.id}?v=${global.createdAt}`); - expect(globalVersioned.status()).toBe(200); - expect(globalVersioned.headers()['cache-control']).toContain('immutable'); - - const board = uniqueShort('banr', testInfo); - await createBoard(page, app, { short: board, name: 'Protected Banner' }); - await updateBoardSettings(page, app, board, { - accessMode: 'view_password', - accessPassword: 'banner-pass', - }); - const boardUpload = await uploadBanner(page, app, '/admin/board/banner', bannerPng, { - board_id: String(boardId(app, board)), - }); - expect(boardUpload.status()).toBe(303); - expect(sqliteQuery(app, `SELECT banner_mode FROM boards WHERE short_name = '${board}';`)).toBe('override'); - const boardBanner = bannerRow(app, `scope_type = 'board' AND board_id = ${boardId(app, board)}`); - const boardPath = path.join(app.dataDir, 'boards', board, '_banner', `${boardBanner.storageKey}.webp`); - expect(fs.existsSync(boardPath)).toBe(true); - - await page.context().clearCookies(); - const protectedBanner = await page.request.get(`${app.baseURL}/banner/assets/${boardBanner.id}?v=${boardBanner.createdAt}`); - expect(protectedBanner.status()).toBe(403); - await unlockBoard(page, app, board, 'banner-pass'); - const accessibleBanner = await page.request.get(`${app.baseURL}/banner/assets/${boardBanner.id}?v=${boardBanner.createdAt}`); - expect(accessibleBanner.status()).toBe(200); - expect(accessibleBanner.headers()['cache-control']).toContain('private'); - - await adminLogin(page, app); - const clearBoardBanner = await page.request.post(`${app.baseURL}/admin/board/banner/clear`, { - form: { - _csrf: await adminCsrf(page, app), - board_id: String(boardId(app, board)), - }, - maxRedirects: 0, - }); - expect(clearBoardBanner.status()).toBe(303); - expect(sqliteQuery(app, `SELECT banner_mode FROM boards WHERE short_name = '${board}';`)).toBe('inherit'); - expect(fs.existsSync(boardPath)).toBe(false); - - const deleteGlobal = await page.request.post(`${app.baseURL}/admin/banner/delete`, { - form: { - _csrf: await adminCsrf(page, app), - banner_id: String(global.id), - }, - maxRedirects: 0, - }); - expect(deleteGlobal.status()).toBe(303); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM banner_assets WHERE id = ${global.id};`)).toBe('0'); - expect(fs.existsSync(globalPath)).toBe(false); - }); - - test('custom themes create, edit, delete, and clear default references safely', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'theme management coverage runs on Chromium first'); - - await adminLogin(page, app); - const slug = uniqueShort('thm', testInfo); - const create = await page.request.post(`${app.baseURL}/admin/theme/create`, { - form: { - _csrf: await adminCsrf(page, app), - slug, - display_name: 'Phase 2 Theme', - description: 'created in e2e', - swatch_hex: '#336699', - theme_mode: 'legacy', - custom_css: `html[data-theme="${slug}"] body { --phase2-theme-test: 1; }`, - enabled: '1', - }, - maxRedirects: 0, - }); - expect(create.status()).toBe(303); - expect(sqliteQuery(app, `SELECT display_name FROM themes WHERE slug = '${slug}';`)).toBe('Phase 2 Theme'); - const css = await page.request.get(`${app.baseURL}/theme-css/${slug}`); - expect(css.status()).toBe(200); - expect(await css.text()).toContain('--phase2-theme-test'); - - const update = await page.request.post(`${app.baseURL}/admin/theme/update`, { - form: { - _csrf: await adminCsrf(page, app), - existing_slug: slug, - slug, - display_name: 'Phase 2 Theme Edited', - description: 'edited in e2e', - swatch_hex: '#663399', - theme_mode: 'legacy', - custom_css: `html[data-theme="${slug}"] body { --phase2-theme-edited: 1; }`, - enabled: '1', - }, - maxRedirects: 0, - }); - expect(update.status()).toBe(303); - expect(sqliteQuery(app, `SELECT display_name FROM themes WHERE slug = '${slug}';`)).toBe('Phase 2 Theme Edited'); - await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: await adminCsrf(page, app), - site_name: 'RustChan', - site_subtitle: 'theme fallback', - default_theme: slug, - homepage_new_thread_badges_enabled: '1', - homepage_new_reply_badges_enabled: '1', - thread_new_reply_badges_enabled: '1', - }, - maxRedirects: 0, - }); - expect(sqliteQuery(app, "SELECT value FROM site_settings WHERE key = 'default_theme';")).toBe(slug); - - const deleteTheme = await page.request.post(`${app.baseURL}/admin/theme/delete`, { - form: { - _csrf: await adminCsrf(page, app), - slug, - }, - maxRedirects: 0, - }); - expect(deleteTheme.status()).toBe(303); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM themes WHERE slug = '${slug}';`)).toBe('0'); - expect(sqliteQuery(app, "SELECT value FROM site_settings WHERE key = 'default_theme';")).toBe('forest'); - await page.goto(app.baseURL); - await expectSafePage(page); - }); -}); - -async function uploadFavicon( - page: Parameters[0], - app: Parameters[1], - route: string, - filePath: string, - fields: Record = {}, -) { - return page.request.post(`${app.baseURL}${route}`, { - multipart: { - _csrf: await adminCsrf(page, app), - ...fields, - favicon: { - name: path.basename(filePath), - mimeType: 'image/png', - buffer: fs.readFileSync(filePath), - }, - }, - headers: sameOriginHeaders(app), - maxRedirects: 0, - }); -} - -async function uploadBanner( - page: Parameters[0], - app: Parameters[1], - route: string, - filePath: string, - fields: Record = {}, -) { - return page.request.post(`${app.baseURL}${route}`, { - multipart: { - _csrf: await adminCsrf(page, app), - target_type: 'none', - show_on_index: '1', - show_on_catalog: '1', - enabled: '1', - ...fields, - banner: { - name: path.basename(filePath), - mimeType: 'image/png', - buffer: fs.readFileSync(filePath), - }, - }, - headers: sameOriginHeaders(app), - maxRedirects: 0, - }); -} - -function sameOriginHeaders(app: Parameters[1]): Record { - return { - Origin: app.baseURL, - Referer: `${app.baseURL}/admin/panel`, - }; -} - -function bannerRow(app: Parameters[0], whereClause: string): { id: number; storageKey: string; createdAt: string } { - const row = sqliteQuery( - app, - `SELECT id || '|' || storage_key || '|' || created_at FROM banner_assets WHERE ${whereClause} ORDER BY id DESC LIMIT 1;`, - ); - const [id, storageKey, createdAt] = row.split('|'); - return { id: Number(id), storageKey, createdAt }; -} - -function pngRgba(width: number, height: number, pixelByte: (index: number) => number): Buffer { - const raw = Buffer.alloc((width * 4 + 1) * height); - let sourceIndex = 0; - for (let y = 0; y < height; y += 1) { - const rowStart = y * (width * 4 + 1); - raw[rowStart] = 0; - for (let x = 0; x < width * 4; x += 1) { - raw[rowStart + 1 + x] = pixelByte(sourceIndex); - sourceIndex += 1; - } - } - const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - return Buffer.concat([ - signature, - pngChunk('IHDR', ihdr), - pngChunk('IDAT', zlib.deflateSync(raw)), - pngChunk('IEND', Buffer.alloc(0)), - ]); -} - -function pngChunk(type: string, data: Buffer): Buffer { - const typeBytes = Buffer.from(type, 'ascii'); - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - typeBytes.copy(out, 4); - data.copy(out, 8); - out.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length); - return out; -} - -function crc32(data: Buffer): number { - let crc = 0xffffffff; - for (const byte of data) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - return (crc ^ 0xffffffff) >>> 0; -} diff --git a/tests/e2e/audit-admin-ui.spec.ts b/tests/e2e/audit-admin-ui.spec.ts deleted file mode 100644 index 921d054f..00000000 --- a/tests/e2e/audit-admin-ui.spec.ts +++ /dev/null @@ -1,689 +0,0 @@ -import { actor, expectHttpError, journeyStep, newAuditedContext, noteEvent } from './diagnostics'; -import { - adminLogin, - createReply, - createStandaloneApp, - createThread, - expect, - expectAdminPanel, - expectSafePage, - sqliteQuery, - test, - uniqueShort, - type RustChanServer, -} from './helpers'; -import { deflateSync } from 'node:zlib'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import type { Locator, Page, Response } from '@playwright/test'; - -/** - * Admin-only surfaces that previously had API-only or zero browser coverage. - * - * Applicability and exclusions: this file runs on Chromium only - * (`test.skip(testInfo.project.name !== 'chromium', ...)` per test). Every - * scenario drives admin form controls, file inputs, download events and - * destructive maintenance; the application contract is engine independent, so - * repeating it across WebKit/Firefox/mobile would add runtime without adding - * information. Admin no-JavaScript behaviour is covered elsewhere - * (`firefox-nojs-public.spec.ts`, `nojs-enhancements.spec.ts`, - * `audit-nojs-parity.spec.ts`), so this file always runs with JavaScript on. - * - * Browser interaction is the subject; SQLite reads only corroborate. Restore and - * extract scenarios run against dedicated standalone instances because they are - * destructive by contract and must never touch a shared runtime. - * - * Interaction notes learned from the rendered DOM: - * - `` maps to ARIA role `button`, so submit controls are - * selected with `button[type="submit"]` rather than `getByRole('button')`. - * - Admin panel controls live inside collapsed `
` - * elements; the dropdowns are opened through their real `` before use. - * - Most admin saves are plain form POSTs, but backup create and restore-upload - * are XHR driven by `admin.js` and expose their own progress/done affordances. - */ - -/** - * Open every collapsed `
` inside `scope` through its real ``, - * which is what an operator does before reaching the control inside it. The - * admin panel nests controls in several different disclosure widgets - * (`admin-dropdown`, `backup-manual-details`, per-board cards), so scoping to a - * class list would silently miss one. Only visible summaries are clicked, so the - * responsive mobile board menu (hidden at desktop widths) is never targeted. - */ -async function openDetailsIn(page: Page, scope: string): Promise { - for (let attempt = 0; attempt < 60; attempt += 1) { - const closed = page.locator(`${scope} details:not([open]) > summary:visible`).first(); - if (await closed.count() === 0) return; - await closed.click(); - } -} - -async function panelSection(page: Page, app: RustChanServer, section: string): Promise { - await page.goto(`${app.baseURL}/admin/panel?open=${section}#${section}`); - await expect(page.locator(`#${section}`)).toBeAttached(); - await openDetailsIn(page, '.admin-panel'); - await expect(page.locator(`#${section}`)).toBeVisible(); - await expectSafePage(page, { allowAdminInternals: true }); -} - -/** Click a form's real submit button and wait for the mutation request it produces. */ -async function submitAndSettle(page: Page, form: Locator, actionPath: string, timeout = 90_000): Promise { - const submit = form.locator('button[type="submit"]').first(); - await expect(submit, `submit control for POST ${actionPath}`).toBeVisible(); - const [response] = await Promise.all([ - page.waitForResponse( - candidate => candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === actionPath, - { timeout }, - ), - submit.click(), - ]); - expect([200, 303], `POST ${actionPath} status`).toContain(response.status()); - return response; -} - -/** - * Submit a form whose submit button carries `data-confirm`. main.js shows the - * styled confirmation modal and only then runs the submission, which admin.js - * may further intercept as an XHR (restore upload). Both steps are driven. - */ -async function submitWithConfirm(page: Page, form: Locator, actionPath: string, timeout = 120_000): Promise { - const submit = form.locator('button[type="submit"]').first(); - await expect(submit, `submit control for POST ${actionPath}`).toBeVisible(); - const [response] = await Promise.all([ - page.waitForResponse( - candidate => candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === actionPath, - { timeout }, - ), - (async () => { - await submit.click(); - const modal = page.locator('#confirm-modal'); - await expect(modal, `confirmation modal for POST ${actionPath}`).toBeVisible(); - await modal.locator('#confirm-modal-continue').click(); - })(), - ]); - return response; -} - -/** Confirm through the styled confirmation modal the application renders for data-confirm. */ -async function confirmModal(page: Page, trigger: () => Promise, timeout = 90_000): Promise { - await trigger(); - const modal = page.locator('#confirm-modal'); - await expect(modal).toBeVisible(); - await Promise.all([ - page.waitForResponse(response => response.request().method() === 'POST', { timeout }), - modal.locator('#confirm-modal-continue').click(), - ]); -} - -/** - * Banners must be at least 468x60 with the exact 468:60 aspect ratio - * (src/banner.rs DISPLAY_WIDTH/DISPLAY_HEIGHT), so the 1x1 fixture PNG is not - * usable here. Encode a real 468x60 PNG instead of mocking the upload. - */ -async function encodedPng(width: number, height: number, dir: string, name: string): Promise { - const raw = Buffer.alloc((width * 4 + 1) * height); - for (let y = 0; y < height; y += 1) { - const rowStart = y * (width * 4 + 1); - raw[rowStart] = 0; - for (let x = 0; x < width; x += 1) { - const offset = rowStart + 1 + x * 4; - raw[offset] = (x * 3) % 256; - raw[offset + 1] = (y * 5) % 256; - raw[offset + 2] = 128; - raw[offset + 3] = 255; - } - } - const chunk = (type: string, data: Buffer): Buffer => { - const typeBytes = Buffer.from(type, 'ascii'); - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - typeBytes.copy(out, 4); - data.copy(out, 8); - out.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length); - return out; - }; - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - const png = Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]); - const target = path.join(dir, name); - await fsp.mkdir(dir, { recursive: true }); - await fsp.writeFile(target, png); - return target; -} - -/** Banners require the exact 468:60 aspect ratio (src/banner.rs). */ -function bannerPng(dir: string, name = 'deep-audit-banner.png'): Promise { - return encodedPng(468, 60, dir, name); -} - -/** Favicons must be exactly 512x512 pixels (src/favicon.rs decode_uploaded_favicon). */ -function faviconPng(dir: string, name = 'deep-audit-favicon.png'): Promise { - return encodedPng(512, 512, dir, name); -} - -function crc32(data: Buffer): number { - let crc = 0xffffffff; - for (const byte of data) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - return (crc ^ 0xffffffff) >>> 0; -} - -/** - * Reach a board's new-thread form the way a poster does. The form starts - * collapsed behind the post toggle on narrow layouts, so the toggle is clicked - * when it is present and the subject field is used as the readiness signal. - */ -async function openBoardPostForm(page: Page, board: string): Promise { - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) await toggle.click(); - const form = page.locator(`form[action="/${board}"]`).first(); - await expect(form.locator('input[name="subject"]')).toBeVisible(); - return form; -} - -async function uploadHomeBanner(page: Page, app: RustChanServer, expectedRows: number): Promise { - const form = page.locator('form[action="/admin/home/banner"]').first(); - await expect(form).toBeVisible(); - await form.locator('input[type="file"][name="banner"]') - .setInputFiles(await bannerPng(app.fixtureDir, `deep-audit-home-banner-${expectedRows}.png`)); - await submitAndSettle(page, form, '/admin/home/banner'); - await expect(page.locator('#home-banners .admin-banner-row'), 'uploaded banner rows').toHaveCount(expectedRows); -} - -test.describe('admin surface journeys', () => { - test('global favicon uploaded through the real file input is served with image headers', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'site-settings'); - - expect((await page.request.get(`${app.baseURL}/favicon-32x32.png`)).status()).toBe(404); - - const form = page.locator('form[action="/admin/site/favicon"]').first(); - await expect(form.locator('input[type="file"][name="favicon"]')).toBeAttached(); - // Favicons must be exactly 512x512; a 1x1 fixture is rejected by design. - await form.locator('input[type="file"][name="favicon"]').setInputFiles(await faviconPng(app.fixtureDir)); - await submitAndSettle(page, form, '/admin/site/favicon'); - await expectAdminPanel(page); - await expect(page.locator('.admin-flash').filter({ hasText: /Global favicon updated/i }).first(), 'favicon success feedback').toBeVisible(); - - for (const name of ['favicon-16x16.png', 'favicon-32x32.png', 'apple-touch-icon.png', 'favicon.ico']) { - const response = await page.request.get(`${app.baseURL}/${name}`); - expect(response.status(), `${name} after upload`).toBe(200); - expect(response.headers()['content-type'], `${name} content type`).toMatch(/image\//); - expect((await response.body()).length, `${name} body`).toBeGreaterThan(0); - } - - actor('visitor'); - await page.goto(`${app.baseURL}/`); - const iconLinks = await page.locator('link[rel*="icon"], link[rel="apple-touch-icon"]') - .evaluateAll(links => links.map(link => link.getAttribute('href') ?? '')); - expect(iconLinks.length, 'uploaded favourites should be advertised in the document head').toBeGreaterThan(0); - expect(iconLinks.every(href => href.includes('v=')), `versioned icon links, received ${JSON.stringify(iconLinks)}`).toBe(true); - - await test.step('board favicon override then clear through the real forms', async () => { - actor('admin'); - await panelSection(page, app, 'appearance'); - const boardUpload = page.locator('form[action="/admin/board/favicon"]').first(); - await expect(boardUpload).toBeVisible(); - await boardUpload.locator('input[type="file"][name="favicon"]').setInputFiles(await faviconPng(app.fixtureDir, 'deep-audit-board-favicon.png')); - await submitAndSettle(page, boardUpload, '/admin/board/favicon'); - expect((await page.request.get(`${app.baseURL}/boards/pub/_favicon/favicon-32x32.png`)).status()).toBe(200); - - actor('visitor'); - await page.goto(`${app.baseURL}/pub`); - const boardIcons = await page.locator('link[rel*="icon"]') - .evaluateAll(links => links.map(link => link.getAttribute('href') ?? '')); - expect(boardIcons.length, 'board favicon set should be advertised').toBeGreaterThan(0); - expect(boardIcons.every(href => href.startsWith('/boards/pub/_favicon/')), `board-scoped icon links, received ${JSON.stringify(boardIcons)}`).toBe(true); - - actor('admin'); - await panelSection(page, app, 'appearance'); - const clear = page.locator('form[action="/admin/board/favicon/clear"]').first(); - await expect(clear).toBeVisible(); - await submitAndSettle(page, clear, '/admin/board/favicon/clear'); - expect((await page.request.get(`${app.baseURL}/boards/pub/_favicon/favicon-32x32.png`)).status()).toBe(404); - - actor('visitor'); - await page.goto(`${app.baseURL}/pub`); - const fallback = await page.locator('link[rel*="icon"]') - .evaluateAll(links => links.map(link => link.getAttribute('href') ?? '')); - expect(fallback.length, 'global favicon should be advertised again').toBeGreaterThan(0); - expect(fallback.every(href => !href.startsWith('/boards/pub/_favicon/')), `global-scoped icon links, received ${JSON.stringify(fallback)}`).toBe(true); - }); - await journeyStep(testInfo, page, 'favicon-upload'); - }); - - test('vacuum runs from the admin panel and the site keeps serving its data', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('poster'); - await createThread(page, app, 'pub', { subject: 'vacuum survivor', body: 'vacuum survivor body' }); - const postsBefore = sqliteQuery(app, 'SELECT COUNT(*) FROM posts;'); - expect(Number(postsBefore)).toBeGreaterThan(0); - - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'maintenance'); - - const button = page.locator('#maintenance button[type="submit"]', { hasText: /run VACUUM/i }).first(); - await expect(button).toBeVisible(); - await confirmModal(page, async () => { - await button.click(); - }); - // The documented contract is a rendered VACUUM result page at /admin/vacuum - // (before/after sizes), not a redirect back to the panel. - await page.waitForURL(/\/admin\/vacuum/, { timeout: 90_000 }); - await expect(page.locator('body')).toContainText(/VACUUM complete/i); - await expect(page.locator('body')).toContainText(/Before/i); - await expect(page.locator('body')).toContainText(/After/i); - - expect(sqliteQuery(app, 'SELECT COUNT(*) FROM posts;'), 'vacuum must not drop rows').toBe(postsBefore); - expect(sqliteQuery(app, 'PRAGMA quick_check;')).toBe('ok'); - actor('visitor'); - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator('body')).toContainText('vacuum survivor'); - }); - - test('home banner uploaded through the file input renders on the homepage and deletes through the panel', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'home-banners'); - await uploadHomeBanner(page, app, 1); - - actor('visitor'); - await page.goto(`${app.baseURL}/`); - const rendered = page.locator('img[src*="/banner/assets/"]').first(); - await expect(rendered).toBeVisible(); - const src = await rendered.getAttribute('src'); - expect(src).toBeTruthy(); - expect(await rendered.evaluate(image => (image as HTMLImageElement).naturalWidth), 'banner must decode').toBeGreaterThan(0); - - actor('admin'); - await panelSection(page, app, 'home-banners'); - const row = page.locator('.admin-banner-row').first(); - await expect(row).toBeVisible(); - await submitAndSettle(page, row.locator('form[action="/admin/banner/delete"]'), '/admin/banner/delete'); - await expect(page.locator('.admin-banner-row')).toHaveCount(0); - actor('visitor'); - await page.goto(`${app.baseURL}/`); - await expect(page.locator(`img[src="${src}"]`)).toHaveCount(0); - await journeyStep(testInfo, page, 'home-banner-upload-delete'); - }); - - test('banner ordering and the enabled toggle change which banners the public page shows', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'home-banners'); - await uploadHomeBanner(page, app, 1); - await uploadHomeBanner(page, app, 2); - - const rowSrcs = () => page.locator('.admin-banner-row img') - .evaluateAll(images => images.map(image => (image as HTMLImageElement).getAttribute('src'))); - const before = await rowSrcs(); - expect(before.length).toBe(2); - - await test.step('move the second banner up and verify the panel order changed', async () => { - const last = page.locator('.admin-banner-row').nth(1); - const moveUp = last.locator('form[action="/admin/banner/move"]') - .filter({ has: page.getByRole('button', { name: 'up', exact: true }) }).first(); - await submitAndSettle(page, moveUp, '/admin/banner/move'); - expect(await rowSrcs()).toEqual([before[1], before[0]]); - }); - - await test.step('disable the first banner through its real meta form', async () => { - const first = page.locator('.admin-banner-row').first(); - // Read the src of the banner actually being disabled, so the public-page - // assertion below targets that banner rather than a stale ordering. - const disabledSrc = await first.locator('img').getAttribute('src'); - expect(disabledSrc).toBeTruthy(); - const enabled = first.locator('input[name="enabled"]'); - await expect(enabled).toBeChecked(); - await enabled.uncheck(); - await submitAndSettle(page, first.locator('form[action="/admin/banner/update"]'), '/admin/banner/update'); - await expect(page.locator('.admin-banner-row').first().locator('input[name="enabled"]')).not.toBeChecked(); - - actor('visitor'); - await page.goto(`${app.baseURL}/`); - await expect(page.locator(`img[src="${disabledSrc}"]`)).toHaveCount(0); - await expect(page.locator('img[src*="/banner/assets/"]')).toHaveCount(1); - }); - }); - - test('external banner links show the interstitial and Continue reaches the declared target', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('admin'); - await adminLogin(page, app); - - await test.step('enable external banner links through the real banner-settings form', async () => { - await panelSection(page, app, 'board-banners'); - const toggle = page.locator('input[name="banner_external_links_enabled"]').first(); - await expect(toggle).toBeVisible(); - if (!(await toggle.isChecked())) await toggle.check(); - const form = page.locator('form[action="/admin/site/settings"]') - .filter({ has: page.locator('input[name="banner_external_links_enabled"]') }).first(); - await submitAndSettle(page, form, '/admin/site/settings'); - await expectAdminPanel(page); - }); - - await test.step('point a banner at an absolute URL that resolves inside this fixture', async () => { - await panelSection(page, app, 'home-banners'); - await uploadHomeBanner(page, app, 1); - const row = page.locator('.admin-banner-row').first(); - await row.locator('select[name="target_type"]').selectOption({ label: 'Open another website' }); - await row.locator('input[name="target_external_url"]').fill(`${app.baseURL}/healthz`); - await submitAndSettle(page, row.locator('form[action="/admin/banner/update"]'), '/admin/banner/update'); - await expect(page.locator('.admin-banner-row').first().locator('input[name="target_external_url"]')) - .toHaveValue(`${app.baseURL}/healthz`); - }); - - await test.step('visitor gets the warning page before the redirect', async () => { - actor('visitor'); - await page.goto(`${app.baseURL}/`); - const link = page.locator('a[href*="/banner/external/"]').first(); - await expect(link).toBeVisible(); - await link.click(); - await expect(page).toHaveURL(/\/banner\/external\/\d+/); - await expect(page.locator('.external-banner-warning')).toContainText(/external website/i); - await Promise.all([ - page.waitForURL(`${app.baseURL}/healthz`), - page.getByRole('link', { name: /continue/i }).click(), - ]); - await expect(page.locator('body')).toContainText('ok'); - }); - }); - - test('word filter added and removed through the admin UI rewrites live posts', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - const board = uniqueShort('wfil', testInfo); - app.createBoardCli({ short: board, name: 'Word Filter' }); - - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'word-filters'); - const addForm = page.locator('form[action="/admin/filter/add"]').first(); - await addForm.locator('input[name="pattern"]').fill('rutabaga'); - await addForm.locator('input[name="replacement"]').fill('turnip'); - await submitAndSettle(page, addForm, '/admin/filter/add'); - expect(sqliteQuery(app, "SELECT COUNT(*) FROM word_filters WHERE pattern = 'rutabaga';")).toBe('1'); - await expect(page.locator('#word-filters tbody tr').filter({ hasText: 'rutabaga' })).toHaveCount(1); - - actor('poster'); - await createThread(page, app, board, { subject: 'filtered subject', body: 'the rutabaga is here' }); - const op = page.locator('.post.op'); - await expect(op).toContainText('turnip'); - expect(await op.innerText(), 'the raw filtered word must not survive').not.toContain('rutabaga'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body LIKE '%rutabaga%';`), 'stored body keeps the original text').toBe('1'); - - actor('admin'); - await panelSection(page, app, 'word-filters'); - const row = page.locator('#word-filters tbody tr').filter({ hasText: 'rutabaga' }).first(); - await expect(row).toBeVisible(); - await submitAndSettle(page, row.locator('form[action="/admin/filter/remove"]'), '/admin/filter/remove'); - expect(sqliteQuery(app, "SELECT COUNT(*) FROM word_filters WHERE pattern = 'rutabaga';")).toBe('0'); - - actor('poster'); - const threadId = Number(sqliteQuery(app, `SELECT id FROM threads WHERE subject = 'filtered subject' LIMIT 1;`)); - await createReply(page, app, board, threadId, `a second rutabaga arrives ${Date.now()}`); - await expect(page.locator('.post').last()).toContainText('rutabaga'); - await expect(page.locator('.post').last()).not.toContainText('turnip'); - }); - - test('admin ban add, appeal dismiss, and ban remove work through the rendered forms', async ({ page, browser, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('poster'); - await createThread(page, app, 'pub', { subject: 'ban target', body: 'ban target body' }); - const ipHash = sqliteQuery(app, "SELECT ip_hash FROM posts WHERE body = 'ban target body' LIMIT 1;"); - expect(ipHash).toMatch(/^[0-9a-f]{64}$/); - // On loopback the application derives identity from the rustchan_visitor_id - // cookie (src/handlers/board.rs identity_key), so the banned actor is the - // poster's cookie identity, not "every loopback client". Reuse that identity - // in a separate context instead of expecting a brand new context to be banned. - const posterState = await page.context().storageState(); - - await test.step('admin adds the ban through the real moderation form', async () => { - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'moderation'); - const addForm = page.locator('form[action="/admin/ban/add"]').first(); - await addForm.locator('input[name="ip_hash"]').fill(ipHash); - await addForm.locator('input[name="reason"]').fill('deep audit ban'); - await addForm.locator('input[name="duration_hours"]').fill('1'); - await submitAndSettle(page, addForm, '/admin/ban/add'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${ipHash}' AND reason = 'deep audit ban';`)).toBe('1'); - await expect(page.locator('#moderation tbody tr').filter({ hasText: 'deep audit ban' })).toHaveCount(1); - }); - - await test.step('the banned identity is refused at posting time and can appeal', async () => { - actor('banned'); - const bannedContext = await newAuditedContext(browser, { storageState: posterState }); - const bannedPage = await bannedContext.newPage(); - // The documented ban contract is enforcement on the posting path - // (src/handlers/posting.rs checks is_banned and redirects to /banned); - // reading boards stays allowed. - await bannedPage.goto(`${app.baseURL}/pub`); - await expect(bannedPage.locator('form[action="/pub"]')).toHaveCount(1); - const form = await openBoardPostForm(bannedPage, 'pub'); - await form.locator('input[name="subject"]').fill('banned attempt subject'); - await form.locator('textarea[name="body"]').fill('banned attempt body'); - // The plain-form ban response is HTTP 403 whose body is the ban page - // (src/error.rs maps AppError::BannedUser to templates::ban_page). The XHR - // path instead returns X-Rustchan-Redirect: /banned?reason=... - expectHttpError({ - method: 'POST', - path: '/pub', - status: 403, - reason: 'banned identity is refused at posting time with the ban page', - }); - await Promise.all([ - bannedPage.waitForResponse(response => response.request().method() === 'POST' - && new URL(response.url()).pathname === '/pub' - && response.status() === 403, { timeout: 30_000 }), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - await expect(bannedPage.locator('body')).toContainText(/you are banned/i); - await expect(bannedPage.locator('body')).toContainText(/deep audit ban/i); - expect( - sqliteQuery(app, "SELECT COUNT(*) FROM posts WHERE body = 'banned attempt body';"), - 'a banned identity must not create a post', - ).toBe('0'); - - const appeal = bannedPage.locator('form[action="/appeal"]').first(); - await expect(appeal).toBeVisible(); - await appeal.locator('textarea[name="reason"]').fill('deep audit appeal from a banned visitor'); - await Promise.all([ - bannedPage.waitForLoadState('domcontentloaded'), - appeal.getByRole('button', { name: /submit appeal/i }).click(), - ]); - await expect.poll( - () => Number(sqliteQuery(app, "SELECT COUNT(*) FROM ban_appeals WHERE status = 'open';")), - { timeout: 30_000, message: 'exactly one pending appeal row' }, - ).toBe(1); - await bannedContext.close(); - }); - - await test.step('admin dismisses the appeal and the ban remains', async () => { - actor('admin'); - await panelSection(page, app, 'moderation'); - const appealRow = page.locator('#moderation tr').filter({ hasText: 'deep audit appeal' }).first(); - await expect(appealRow).toBeVisible(); - await submitAndSettle(page, appealRow.locator('form[action="/admin/appeal/dismiss"]'), '/admin/appeal/dismiss'); - expect(sqliteQuery(app, "SELECT COUNT(*) FROM ban_appeals WHERE status = 'open';")).toBe('0'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${ipHash}';`)).toBe('1'); - }); - - await test.step('admin lifts the ban through the real form and posting is restored', async () => { - await panelSection(page, app, 'moderation'); - const banRow = page.locator('#moderation tr').filter({ hasText: 'deep audit ban' }).first(); - await expect(banRow).toBeVisible(); - await submitAndSettle(page, banRow.locator('form[action="/admin/ban/remove"]'), '/admin/ban/remove'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${ipHash}';`)).toBe('0'); - actor('poster'); - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator('body')).not.toContainText(/you are banned/i); - await expect(page.locator('form[action="/pub"]')).toHaveCount(1); - const form = await openBoardPostForm(page, 'pub'); - await form.locator('input[name="subject"]').fill('post-ban subject'); - await form.locator('textarea[name="body"]').fill('post-ban body'); - await Promise.all([ - page.waitForURL(/thread\/\d+/), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - expect( - sqliteQuery(app, "SELECT COUNT(*) FROM posts WHERE body = 'post-ban body';"), - 'lifting the ban restores posting for that identity', - ).toBe('1'); - }); - }); - - test('admin thread delete and post delete submit through the rendered controls', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('admin'); - const board = uniqueShort('adel', testInfo); - app.createBoardCli({ short: board, name: 'Admin Delete' }); - const doomed = await createThread(page, app, board, { subject: 'doomed thread', body: 'doomed thread body' }); - const survivor = await createThread(page, app, board, { subject: 'survivor thread', body: 'survivor thread body' }); - await createReply(page, app, board, survivor, 'survivor reply'); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/${board}/thread/${doomed}`); - const deleteForm = page.locator('form[action="/admin/thread/delete"]').first(); - await openDetailsIn(page, 'body'); - await expect(deleteForm).toBeVisible(); - await confirmModal(page, async () => { - await deleteForm.locator('button[type="submit"]').first().click(); - }); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${doomed};`)).toBe('0'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${survivor};`)).toBe('1'); - - await page.goto(`${app.baseURL}/${board}/thread/${survivor}`); - const replyId = Number((await page.locator('.post').last().getAttribute('id'))?.replace(/^p/, '')); - await openDetailsIn(page, 'body'); - const deletePostForm = page.locator('form[action="/admin/post/delete"]') - .filter({ has: page.locator(`input[name="post_id"][value="${replyId}"]`) }).first(); - await expect(deletePostForm).toBeVisible(); - await confirmModal(page, async () => { - await deletePostForm.locator('button[type="submit"]').first().click(); - }); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${replyId};`)).toBe('0'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${survivor};`)).toBe('1'); - actor('visitor'); - await page.goto(`${app.baseURL}/${board}/catalog`); - await expect(page.locator('body')).toContainText('survivor thread'); - await expect(page.locator('body')).not.toContainText('doomed thread'); - }); - - test('extract-board downloads one board and board restore imports it into a different instance', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - actor('admin-source'); - const source = await createStandaloneApp({ - admin: true, - boards: [{ short: 'ext', name: 'Extract Source' }, { short: 'oth', name: 'Other Board' }], - }); - const target = await createStandaloneApp({ admin: true, boards: [{ short: 'oth', name: 'Other Board' }] }); - try { - await createThread(page, source, 'ext', { subject: 'extract me', body: 'extract me body' }); - await adminLogin(page, source); - await panelSection(page, source, 'backups'); - - await test.step('create a full backup through the real form and its progress modal', async () => { - const createForm = page.locator('form[action="/admin/backup/create"]').first(); - await expect(createForm).toBeVisible(); - await createForm.locator('#full-backup-btn').click(); - // admin.js intercepts this form, shows #backup-modal and polls - // /admin/backup/progress; the app's own done signal is showDoneButton(). - await expect(page.locator('#backup-done-actions')).toBeVisible({ timeout: 120_000 }); - await expect(page.locator('#backup-progress-text')).toContainText(/Backup saved to server/i); - await page.locator('[data-action="close-backup-modal"]').click(); - await page.waitForLoadState('domcontentloaded'); - }); - await expectSafePage(page, { allowAdminInternals: true }); - // Closing the backup modal reloads the panel, so the saved-backup rows are - // collapsed again; re-open the disclosures before reaching the extract form. - await openDetailsIn(page, '.admin-panel'); - await expect(page.locator('#backups')).toBeVisible(); - - const extractForm = page.locator('form[action="/admin/backup/extract-board"]').first(); - await expect(extractForm).toBeVisible(); - await extractForm.locator('select[name="board_short"]').selectOption('ext'); - const [download] = await Promise.all([ - page.waitForEvent('download'), - extractForm.locator('button[name="action"][value="download"]').click(), - ]); - const archivePath = await download.path(); - expect(archivePath, 'downloaded board archive should be materialized').toBeTruthy(); - noteEvent('extract-board-download', `suggested filename ${download.suggestedFilename()}`); - - actor('admin-target'); - await adminLogin(page, target); - await panelSection(page, target, 'backups'); - const restoreForm = page.locator('form[action="/admin/board/restore"]').first(); - await restoreForm.locator('input[type="file"][name="backup_file"]').setInputFiles(archivePath!); - // admin.js intercepts this upload form; the upload itself is the subject, - // so the POST response and any operator-facing error text are captured as - // evidence rather than racing a URL that already matches /admin/panel. - const restoreResponse = await submitWithConfirm(page, restoreForm, '/admin/board/restore'); - // admin.js treats 204 (no content) as success and then navigates to the - // flash redirect; reading the body after that redirect is not possible. - noteEvent('board-restore-response', `status=${restoreResponse.status()} location=${restoreResponse.headers().location ?? ''}`); - expect([200, 204, 303], `board restore status ${restoreResponse.status()}`).toContain(restoreResponse.status()); - await expect.poll( - () => Number(sqliteQuery(target, "SELECT COUNT(*) FROM boards WHERE short_name = 'ext';")), - { timeout: 120_000, message: 'board restored onto the target instance' }, - ).toBe(1); - expect(sqliteQuery(target, "SELECT COUNT(*) FROM boards WHERE short_name = 'oth';"), 'unrelated board untouched').toBe('1'); - // A restore briefly takes the instance through its maintenance gate, so the - // first request after it can fail; poll the real condition instead of sleeping. - await expect.poll(async () => { - const response = await page.goto(`${target.baseURL}/ext`).catch(() => null); - return response?.status() ?? 0; - }, { timeout: 90_000, message: 'restored board page serves 200' }).toBe(200); - await expect(page.locator('body')).toContainText('extract me'); - await journeyStep(testInfo, page, 'extract-and-restore-board'); - } finally { - // Leave any polling admin page before disposing the standalone instances: - // the panel's live-log and site-health XHRs would otherwise be refused - // during teardown and be reported as browser errors. - await page.goto('about:blank').catch(() => undefined); - await source.dispose(); - await target.dispose(); - } - }); - - test('site-health jobs payload is admin-only and the dismiss control carries a CSRF token', async ({ page, browser, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'admin journey suite runs on Chromium'); - const anonymous = await newAuditedContext(browser); - const anonymousResponse = await anonymous.request.get(`${app.baseURL}/admin/site-health/jobs`, { maxRedirects: 0 }); - expect([302, 303, 401, 403]).toContain(anonymousResponse.status()); - await anonymous.close(); - - actor('admin'); - await adminLogin(page, app); - await panelSection(page, app, 'site-health'); - const jobs = await page.request.get(`${app.baseURL}/admin/site-health/jobs`); - expect(jobs.status()).toBe(200); - const payload = await jobs.json() as Record; - expect(Object.keys(payload).length).toBeGreaterThan(0); - noteEvent('site-health-payload', `keys ${JSON.stringify(Object.keys(payload))}`); - - const dismiss = page.locator('form[action="/admin/site-health/jobs/dismiss"]').first(); - await expect(dismiss).toBeVisible(); - await expect(dismiss.locator('input[name="_csrf"]')).toHaveValue(/.+/); - // Producing a failed job requires fault injection the binary does not expose, - // so submitting dismiss with nothing to dismiss is deliberately not asserted - // here; the audit report records it as an unverified gap. - }); -}); diff --git a/tests/e2e/audit-harness-contract.spec.ts b/tests/e2e/audit-harness-contract.spec.ts deleted file mode 100644 index c8963249..00000000 --- a/tests/e2e/audit-harness-contract.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - declarationStatus, - expectConsoleError, - expectHttpError, - newAuditedContext, - type DiagnosticRecord, - type ErrorExpectation, -} from './diagnostics'; -import { expect, expectServerLogError, test } from './helpers'; -import { existsSync } from 'node:fs'; - -/** - * Harness contract tests. - * - * These verify the *diagnostics layer*, not the application. They are built so a - * regression in strictness makes them red: - * - Negative cases use `test.fail(true, ...)`: if the layer stops failing - * closed, the test unexpectedly passes and Playwright reports - * "expected to fail but passed". - * - The declaration matcher is verified directly as a pure function, so its - * semantics do not depend on fixture teardown ordering. - * - * Applicability: Chromium only. These are harness assertions; repeating them per - * engine would add runtime without adding information. - */ - -test.describe('deep audit harness contract', () => { - test('undeclared HTTP 4xx from a page navigation fails the test', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - test.fail(true, 'proves the diagnostics layer fails closed on an undeclared 4xx'); - await page.goto(`${app.baseURL}/this-route-does-not-exist-deep-audit`); - }); - - test('declared HTTP 4xx is allowed, consumed, and the page contract is still asserted', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - expectHttpError({ - method: 'GET', - path: '/this-route-does-not-exist-deep-audit', - status: 404, - reason: 'the 404 page itself is the subject of this test', - }); - const response = await page.goto(`${app.baseURL}/this-route-does-not-exist-deep-audit`); - expect(response?.status()).toBe(404); - await expect(page.locator('body')).toContainText(/not found|404/i); - }); - - test('undeclared console errors fail the test', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - test.fail(true, 'proves console errors are not silently ignored'); - await page.goto(`${app.baseURL}/`); - await page.evaluate(() => { - console.error('deep-audit deliberate console error'); - }); - }); - - test('declared console errors are consumed and the rest of the test still passes', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - expectConsoleError({ pattern: /deep-audit deliberate console error/, reason: 'the client failure path under test' }); - await page.goto(`${app.baseURL}/`); - await page.evaluate(() => { - console.error('deep-audit deliberate console error'); - }); - await expect(page.locator('body')).toBeVisible(); - }); - - test('undeclared uncaught page errors fail the test', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - test.fail(true, 'proves uncaught page errors are not silently ignored'); - await page.goto(`${app.baseURL}/`); - await page.evaluate(() => { - setTimeout(() => { - throw new Error('deep-audit deliberate uncaught error'); - }, 0); - }); - await page.waitForTimeout(300); - }); - - test('diagnostics matcher consumes declarations once and reports unused ones', async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - const record = (partial: Partial): DiagnosticRecord => ({ - ts: '1970-01-01T00:00:00.000Z', - kind: 'http-error', - actor: 'anonymous', - project: 'chromium', - test: 'matcher', - worker: 0, - method: 'POST', - path: '/admin/ban/add', - status: 403, - ...partial, - }); - const expectation = (partial: Partial): ErrorExpectation => ({ - kind: 'http-error', - reason: 'matcher unit test', - ...partial, - }); - - // Two identical events and one declaration: the declaration matches once and - // the second event stays unexpected. - const duplicate = declarationStatus( - [record({}), record({})], - [expectation({ method: 'POST', path: '/admin/ban/add', status: 403 })], - ); - expect(duplicate.consumed).toEqual([0]); - expect(duplicate.matchedRecords).toEqual([0]); - expect(duplicate.unmatched).toEqual([]); - - // A declaration that cannot match is reported so it cannot rot. - const unused = declarationStatus( - [record({})], - [expectation({ method: 'POST', path: '/never-requested', status: 500 })], - ); - expect(unused.unmatched).toEqual([0]); - expect(unused.matchedRecords).toEqual([]); - - // Method and status must both match; a 500 is not a declared 403. - const wrongStatus = declarationStatus( - [record({ status: 500 })], - [expectation({ method: 'POST', path: '/admin/ban/add', status: 403 })], - ); - expect(wrongStatus.matchedRecords).toEqual([]); - - // Console declarations without a URL constraint retain pattern matching. - const consoleStatus = declarationStatus( - [record({ kind: 'console-error', detail: 'Failed to load resource: 404', method: undefined, path: undefined, status: undefined })], - [expectation({ kind: 'console-error', pattern: /Failed to load resource/ })], - ); - expect(consoleStatus.consumed).toEqual([0]); - - // A native-control declaration must not consume an application error with - // the same words, an unknown source, a different icon, or an extra message. - // Its finite capacity also leaves a repeated excess event unexpected. - const nativeMessage = 'Button failed to load, iconName = invalid-placard, layoutTraits = [MacOSLayoutTraits Inline], src = blob:http://127.0.0.1:1234/00000000-0000-4000-8000-000000000000'; - const nativeStatus = declarationStatus([ - record({ kind: 'console-error', url: 'http://127.0.0.1:1234/static/main.js', detail: nativeMessage }), - record({ kind: 'console-error', url: undefined, detail: nativeMessage }), - record({ kind: 'console-error', url: '', detail: `${nativeMessage} unexpected application failure` }), - record({ kind: 'console-error', url: '', detail: nativeMessage.replace('invalid-placard', 'custom-control') }), - record({ kind: 'console-error', url: '', detail: nativeMessage }), - record({ kind: 'console-error', url: '', detail: nativeMessage }), - ], [expectation({ - kind: 'console-error', - pattern: /^Button failed to load, iconName = invalid-placard, layoutTraits = \[MacOSLayoutTraits Inline\], src = blob:http:\/\/127\.0\.0\.1:1234\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - url: /^$/, - times: 1, - })]); - expect(nativeStatus.consumed).toEqual([0]); - expect(nativeStatus.matchedRecords).toEqual([4]); - expect(nativeStatus.unmatched).toEqual([]); - }); - - test('manual contexts inherit the project JavaScript mode', async ({ browser }, testInfo) => { - test.skip( - !['firefox-nojs', 'chromium-nojs'].includes(testInfo.project.name), - 'asserts the no-JS project guard on a project that can actually launch on this host', - ); - const context = await newAuditedContext(browser); - const page = await context.newPage(); - await page.goto('data:text/html,

probe

'); - // With JavaScript disabled the inline script must not run. - await expect(page.locator('html')).not.toHaveAttribute('data-js', 'on'); - await expect(page.locator('body')).toContainText('probe'); - await context.close(); - }); - - test('fixture server belongs to this run and its log is readable', async ({ app, page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'harness contract runs once'); - const ready = await fetch(`${app.baseURL}/readyz`); - expect(ready.status).toBe(200); - // /healthz reports "ok"; /readyz reports the readiness label "ready" - // (src/server/server/observability.rs). - expect((await ready.json() as { status?: string }).status).toBe('ready'); - expect(existsSync(app.logPath), 'fixture log exists inside the fixture root').toBe(true); - const before = app.logSize(); - await page.goto(`${app.baseURL}/healthz`); - expect((await page.request.get(`${app.baseURL}/healthz`)).status()).toBe(200); - const delta = await app.logsSince(before); - // Readiness/health traffic must not add new [ERROR] lines. - expect(delta.split('\n').filter(line => line.includes('[ERROR]'))).toEqual([]); - expectServerLogError({ - pattern: /never-matches-deep-audit-sentinel/, - reason: 'declared as optional because a healthy instance logs no errors', - optional: true, - }); - }); -}); diff --git a/tests/e2e/audit-journeys.spec.ts b/tests/e2e/audit-journeys.spec.ts deleted file mode 100644 index 4b7e13e7..00000000 --- a/tests/e2e/audit-journeys.spec.ts +++ /dev/null @@ -1,1346 +0,0 @@ -// Functional journey audit: complete user journeys and state transitions. -// -// Project applicability -// --------------------- -// Every journey in this file runs on the `chromium` desktop project because it -// asserts product behaviour and record state, not engine behaviour. Layout, -// progressive-enhancement, and engine differences are already covered by the -// phase/audit suites (`audit-surfaces`, `phase4-*`, `firefox-nojs-*`, -// `mobile-*`). The single exception is the JavaScript-disabled poll creation -// journey, which runs in the `chromium-nojs` and `firefox-nojs` projects because -// the `noscript` option rows are the point of that test. Each test states its own -// gate with `test.skip(...)` and a rationale. -// -// Harness rules honoured here -// --------------------------- -// * Locators favour roles, labels, and the app's documented stable hooks. -// * No request mocking, no `waitForTimeout`, no retries, no forced clicks. -// * Every intentional 4xx is declared with `expectHttpError` before the action. -// * Browser-visible outcomes are primary; SQLite reads corroborate them. - -import { - adminLogin, - boardId, - createReply, - createThread, - createThreadViaRequest, - expect, - expectNoDialog, - expectSafeResponse, - publicCsrf, - setBoardFixtureSettings, - setThreadFixtureState, - sqliteQuery, - test, - threadIdFromUrl, - uniqueShort, - type RustChanServer, -} from './helpers'; -import { - actor, - expectHttpError, - measureStep, - newAuditedContext, - type Page, - type TestInfo, -} from './diagnostics'; - -// --------------------------------------------------------------------------- -// Local helpers -// --------------------------------------------------------------------------- - -/** - * Collection-time annotation marking a bounded-screenshot deep journey so the - * audit runner's deep mode can treat it as a success-journey artifact even - * before the test body starts. - */ -const JOURNEY_DETAILS = { - annotation: { - type: 'deep-journey', - description: 'multi-actor product journey with browser and database evidence', - }, -} as const; - -/** Per-test board short name; `variant` yields a second distinct board. */ -function boardShort(testInfo: TestInfo, variant = ''): string { - return uniqueShort(`jrny${variant}`, testInfo); -} - -/** - * Navigates and asserts the exact document status. - * - * Chromium reports no response when the target URL is the one the document - * already shows (same-document no-op). That case is not a silent skip: the - * live location must match the requested URL exactly, and the earlier load of - * that URL asserted its own status. - */ -async function gotoOk(page: Page, url: string, expectedStatus = 200): Promise { - const response = await page.goto(url, { waitUntil: 'domcontentloaded' }); - if (response === null) { - expect(page.url(), `${url} should already be the current document`).toBe(new URL(url).href); - return; - } - expect(response.status(), `${url} should answer ${expectedStatus}`).toBe(expectedStatus); -} - -/** Opens the collapsed thread/reply composer and returns its form. */ -async function openComposer(page: Page, formSelector: string) { - const toggle = page.locator('[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - const form = page.locator(formSelector).first(); - await expect(form).toBeVisible(); - return form; -} - -/** Numeric scalar query; also asserts the value is a finite number. */ -function scalar(app: RustChanServer, sql: string): number { - const raw = sqliteQuery(app, sql); - const value = Number(raw); - expect(Number.isFinite(value), `expected a numeric query result, got ${JSON.stringify(raw)}`).toBe(true); - return value; -} - -/** Description of one board row used by several visibility assertions. */ -function boardRow(app: RustChanServer, short: string): string { - return sqliteQuery( - app, - `SELECT name || '|' || description || '|' || nsfw || '|' || allow_images FROM boards WHERE short_name = '${short}';`, - ); -} - -// --------------------------------------------------------------------------- -// 1. Multi-actor thread lifecycle with media on a fresh image board -// --------------------------------------------------------------------------- - -test('an owner uploads an image, an unrelated reader sees it and replies, and owner edit and delete round-trip for both actors', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium; engine/layout coverage lives in the phase suites.'); - const short = boardShort(testInfo); - const subject = `image thread ${short}`; - const initialBody = `initial op body ${short}`; - const editedBody = `edited op body ${short} café漢字`; - const readerBody = `reader reply ${short}`; - const ownerReplyBody = `owner reply ${short}`; - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}` }); - - let threadId = 0; - let opId = 0; - let filePath = ''; - let thumbPath = ''; - - await measureStep('owner creates the image thread', async () => { - actor('owner'); - threadId = await createThread(page, app, short, { - subject, - body: initialBody, - filePath: app.fixtures().tinyPng, - }); - expect(threadId).toBeGreaterThan(0); - opId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`); - filePath = sqliteQuery(app, `SELECT file_path FROM posts WHERE id = ${opId};`); - thumbPath = sqliteQuery(app, `SELECT thumb_path FROM posts WHERE id = ${opId};`); - expect(opId).toBeGreaterThan(0); - // The record, not just the rendered page, carries the upload and the body. - expect(sqliteQuery(app, `SELECT body FROM posts WHERE id = ${opId};`)).toBe(initialBody); - expect(filePath).toMatch(new RegExp(`^${short}/.+\\.(png|webp|jpg|jpeg)$`)); - expect(thumbPath).toMatch(new RegExp(`^${short}/.+`)); - expect( - scalar(app, `SELECT COUNT(*) FROM file_hashes WHERE file_path = '${filePath}' AND thumb_path = '${thumbPath}';`), - 'the media row should be registered for the uploaded file', - ).toBe(1); - - const op = page.locator(`#p${opId}`); - await expect(op.locator('.subject')).toContainText(subject); - await expect(op.locator('.post-body')).toContainText(initialBody); - await expect(op.locator('.file-info a').first()).toHaveAttribute('href', `/boards/${filePath}`); - const thumb = op.locator('img.thumb').first(); - await expect(thumb).toHaveAttribute('src', `/boards/${thumbPath}`); - await expect.poll( - () => thumb.evaluate((img: HTMLImageElement) => img.naturalWidth), - { message: 'the OP thumbnail bytes should load in the browser' }, - ).toBeGreaterThan(0); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('0'); - }); - - const readerContext = await newAuditedContext(browser); - const readerPage = await readerContext.newPage(); - - await measureStep('an unrelated reader sees the thread, image, and catalog card', async () => { - actor('unrelated'); - await gotoOk(readerPage, `${app.baseURL}/${short}`); - const boardOp = readerPage.locator(`#p${opId}`); - await expect(boardOp.locator('.subject')).toContainText(subject); - const boardThumb = boardOp.locator('img.thumb').first(); - await expect(boardThumb).toHaveAttribute('src', `/boards/${thumbPath}`); - await boardThumb.scrollIntoViewIfNeeded(); - await expect.poll( - () => boardThumb.evaluate((img: HTMLImageElement) => img.naturalWidth), - { message: 'the board-index thumbnail bytes should load' }, - ).toBeGreaterThan(0); - - await gotoOk(readerPage, `${app.baseURL}/${short}/catalog`); - const catalogItem = readerPage.locator('.catalog-item').filter({ hasText: subject }); - await expect(catalogItem).toHaveCount(1); - await expect(catalogItem.locator('img.catalog-thumb').first()).toBeVisible(); - - await gotoOk(readerPage, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(readerPage.locator(`#p${opId} .post-body`)).toContainText(initialBody); - // Owner-only controls never render for a visitor without an ownership grant. - await expect(readerPage.locator('.self-action-controls')).toHaveCount(0); - }); - - await measureStep('the unrelated reader replies and both views advance by one', async () => { - actor('unrelated'); - await createReply(readerPage, app, short, threadId, readerBody); - actor('owner'); - await gotoOk(page, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(page.locator('.post').filter({ hasText: readerBody })).toHaveCount(1); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - expect(scalar(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)).toBe(1); - }); - - await measureStep('the owner edits the opening post through the real modal', async () => { - actor('owner'); - const op = page.locator(`#p${opId}`); - const editLink = op.locator('.self-action-controls a.edit-btn'); - await expect(editLink).toBeVisible(); - await editLink.click(); - await expect(page.getByRole('dialog', { name: /edit your post/i })).toBeVisible(); - const modal = page.locator('#edit-modal'); - await expect(modal).toHaveClass(/is-open/); - const editor = modal.getByLabel('edit post body'); - await expect(editor).toHaveValue(initialBody); - await editor.fill(editedBody); - const [editResponse] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === `/${short}/post/${opId}/edit`), - modal.getByRole('button', { name: 'save edit' }).click(), - ]); - expect(editResponse.status()).toBe(204); - expect(editResponse.headers()['x-rustchan-redirect']).toBe(`/${short}/thread/${threadId}#p${opId}`); - // The modal handler only navigates on success, so a visible edited body on - // this document proves the post-edit navigation replaced the page. - await expect(page.locator(`#p${opId} .post-body`)).toHaveText(editedBody); - expect(sqliteQuery(app, `SELECT body FROM posts WHERE id = ${opId};`)).toBe(editedBody); - expect(scalar(app, `SELECT COUNT(*) FROM posts WHERE id = ${opId} AND edited_at IS NOT NULL;`)).toBe(1); - }); - - await measureStep('the edit is visible after reload and to the other actor', async () => { - actor('owner'); - await page.reload(); - await expect(page.locator(`#p${opId} .post-body`)).toHaveText(editedBody); - actor('unrelated'); - await gotoOk(readerPage, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(readerPage.locator(`#p${opId} .post-body`)).toHaveText(editedBody); - await expect(readerPage.locator('.post').filter({ hasText: editedBody })).toHaveCount(1); - }); - - await measureStep('the owner deletes their own reply and the media survives', async () => { - actor('owner'); - await createReply(page, app, short, threadId, ownerReplyBody); - const ownerReplyId = scalar( - app, - `SELECT id FROM posts WHERE thread_id = ${threadId} AND body = '${ownerReplyBody}';`, - ); - expect(ownerReplyId).toBeGreaterThan(0); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('2'); - expect(scalar(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)).toBe(2); - - const deleteLink = page.locator(`#p${ownerReplyId} .self-action-controls a.del-btn`); - await expect(deleteLink).toBeVisible(); - await expectNoDialog(page, async () => { - await deleteLink.click(); - await expect(page.locator('#confirm-modal-message')).toHaveText(`Delete your post No.${ownerReplyId}?`); - await page.locator('#confirm-modal-continue').click(); - }); - - actor('owner'); - await expect(page.locator(`#p${ownerReplyId}`)).toHaveCount(0); - await expect(page.locator('.post').filter({ hasText: readerBody })).toHaveCount(1); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - expect(scalar(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)).toBe(1); - expect(scalar(app, `SELECT COUNT(*) FROM posts WHERE id = ${ownerReplyId};`)).toBe(0); - - actor('unrelated'); - await gotoOk(readerPage, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(readerPage.locator(`#p${ownerReplyId}`)).toHaveCount(0); - await expect(readerPage.locator('.post').filter({ hasText: readerBody })).toHaveCount(1); - - // The OP media is untouched by the reply deletion. - expect(sqliteQuery(app, `SELECT file_path FROM posts WHERE id = ${opId};`)).toBe(filePath); - const mediaResponse = await page.request.get(`${app.baseURL}/boards/${filePath}`); - expect(mediaResponse.status()).toBe(200); - expect( - scalar(app, `SELECT COUNT(*) FROM file_hashes WHERE file_path = '${filePath}';`), - 'the OP media row should still exist after the reply was deleted', - ).toBe(1); - }); - - await readerContext.close(); -}); - -// --------------------------------------------------------------------------- -// 2. Own-post edit/delete denied when the board disables them -// --------------------------------------------------------------------------- - -test('a board with editing and self-delete disabled hides the controls and denies direct edit and delete requests', JOURNEY_DETAILS, async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - const subject = `locked controls ${short}`; - const body = `locked controls body ${short}`; - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}` }); - setBoardFixtureSettings(app, short, { allowEditing: false, allowSelfDelete: false }); - - let threadId = 0; - let opId = 0; - - await measureStep('the owner posts while controls are disabled', async () => { - actor('owner'); - threadId = await createThread(page, app, short, { subject, body }); - opId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`); - await expect(page.locator(`#p${opId} .subject`)).toContainText(subject); - // No owner controls at all: neither edit nor delete is offered. - await expect(page.locator('.self-action-controls')).toHaveCount(0); - await expect(page.locator(`#p${opId} .del-btn`)).toHaveCount(0); - }); - - await measureStep('a direct edit page request is forbidden', async () => { - actor('owner'); - const editPath = `/${short}/post/${opId}/edit`; - expectHttpError({ - method: 'GET', - path: editPath, - status: 403, - reason: 'the board disables user editing, so the edit page must be refused', - }); - const denied = await page.goto(`${app.baseURL}${editPath}`, { waitUntil: 'domcontentloaded' }); - expect(denied, 'the denied edit request should still produce a document').not.toBeNull(); - expect(denied!.status()).toBe(403); - await expect(page.locator('.error-page')).toContainText('cannot edit their own posts'); - }); - - await measureStep('a direct delete POST is forbidden and the record is unchanged', async () => { - actor('owner'); - const csrf = await publicCsrf(page, app, `/${short}/thread/${threadId}`); - const denied = await page.request.post(`${app.baseURL}/${short}/post/${opId}/delete`, { - form: { _csrf: csrf }, - maxRedirects: 0, - }); - // page.request responses are not part of the browser diagnostics stream, so - // the exact status must be asserted here. - expect(denied.status()).toBe(403); - const deniedBody = await expectSafeResponse(denied); - expect(deniedBody).toContain('cannot delete their own posts'); - - expect(sqliteQuery(app, `SELECT body FROM posts WHERE id = ${opId};`)).toBe(body); - expect(scalar(app, `SELECT COUNT(*) FROM posts WHERE id = ${opId};`)).toBe(1); - expect(scalar(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)).toBe(0); - expect(scalar(app, `SELECT COUNT(*) FROM threads WHERE id = ${threadId} AND locked = 0;`)).toBe(1); - - await gotoOk(page, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(page.locator(`#p${opId} .post-body`)).toHaveText(body); - await expect(page.locator('.post')).toHaveCount(1); - }); -}); - -// --------------------------------------------------------------------------- -// 3. Preferences and theme persistence (cookie-scoped contract) -// --------------------------------------------------------------------------- - -test('theme and preference changes persist across navigation, reload, and restart, but do not leak into a fresh visitor', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - - const otherContext = await newAuditedContext(browser); - const otherPage = await otherContext.newPage(); - - let initialTheme = ''; - let targetTheme = 'blue-sky'; - - await measureStep('baseline: a fresh visitor uses the site default with NSFW boards visible', async () => { - actor('unrelated'); - await gotoOk(otherPage, app.baseURL); - initialTheme = (await otherPage.locator('html').getAttribute('data-active-theme')) ?? ''; - expect(initialTheme, 'the site default theme should be exposed on the html element').not.toBe(''); - targetTheme = initialTheme === 'blue-sky' ? 'deep-orbit' : 'blue-sky'; - await expect(otherPage.locator('.index-section[data-board-nsfw="1"]')).toHaveCount(1); - await expect(otherPage.locator('.index-section[data-board-nsfw="1"] .board-card')).toHaveCount(1); - }); - - await measureStep('the visitor changes the theme and hides NSFW boards through the real preferences form', async () => { - actor('visitor'); - await gotoOk(page, app.baseURL); - await page.locator('#theme-picker-btn').click(); - const form = page.locator('.user-preferences-form').first(); - await expect(form).toBeVisible(); - const select = form.locator('select[name="theme"]'); - await expect(select.locator(`option[value="${targetTheme}"]`)).toHaveCount(1); - const [themeSave] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/preferences'), - select.selectOption(targetTheme), - ]); - expect(themeSave.status()).toBe(204); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', targetTheme); - await expect(page.locator('.user-preferences-status')).toHaveText('Saved.'); - const [hideSave] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/preferences'), - form.locator('input[name="hide_nsfw_boards"]').check(), - ]); - expect(hideSave.status()).toBe(204); - await expect(page.locator('.user-preferences-status')).toHaveText('Saved.'); - // Hiding NSFW is applied immediately (CSS) and again server-side on reload. - await expect(page.locator('.index-section[data-board-nsfw="1"]')).toBeHidden(); - - const cookies = await page.context().cookies(); - expect(cookies.find((cookie) => cookie.name === 'rustchan_theme')?.value).toBe(targetTheme); - expect(cookies.find((cookie) => cookie.name === 'rustchan_hide_nsfw')?.value).toBe('1'); - }); - - await measureStep('navigation, reload, and a server restart keep the visitor preferences', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/pub`); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', targetTheme); - await page.reload(); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', targetTheme); - - await app.restart(); - await gotoOk(page, app.baseURL); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', targetTheme); - await expect(page.locator('.index-section[data-board-nsfw="1"]')).toHaveCount(0); - const cookies = await page.context().cookies(); - expect(cookies.find((cookie) => cookie.name === 'rustchan_theme')?.value).toBe(targetTheme); - }); - - await measureStep('an unrelated visitor is unaffected by the other visitor preferences', async () => { - actor('unrelated'); - await gotoOk(otherPage, app.baseURL); - await expect(otherPage.locator('html')).toHaveAttribute('data-active-theme', initialTheme); - await expect(otherPage.locator('.index-section[data-board-nsfw="1"]')).toHaveCount(1); - await expect(otherPage.locator('.index-section[data-board-nsfw="1"] .board-card')).toHaveCount(1); - const cookies = await otherContext.cookies(); - expect(cookies.find((cookie) => cookie.name === 'rustchan_theme')).toBeUndefined(); - expect(cookies.find((cookie) => cookie.name === 'rustchan_hide_nsfw')).toBeUndefined(); - }); - - await otherContext.close(); -}); - -// --------------------------------------------------------------------------- -// 4. Board settings visibility through the admin panel UI -// --------------------------------------------------------------------------- - -test('admin board edits show up on the board, catalog, homepage, and a fresh visitor, and the upload toggle gates the form', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - const control = boardShort(testInfo, 'c'); - const initialName = `Before ${short}`; - const updatedName = `After ${short}`; - const initialDescription = `before description ${short}`; - const updatedDescription = `after description ${short}`; - app.createBoardCli({ short, name: initialName, description: initialDescription, noVideos: true }); - app.createBoardCli({ short: control, name: `Control ${control}`, description: `control description ${control}`, noVideos: true }); - - const adminContext = await newAuditedContext(browser); - const adminPage = await adminContext.newPage(); - - await measureStep('baseline: the board accepts image uploads and shows the initial identity', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - await expect(page.locator('.board-index-header h1')).toContainText(initialName); - await expect(page.locator('.board-desc')).toHaveText(initialDescription); - await expect(page.locator('#post-form-wrap input[type="file"]')).toHaveCount(1); - expect(boardRow(app, short)).toBe(`${initialName}|${initialDescription}|0|1`); - }); - - await measureStep('admin edits name, description, NSFW state, and the image toggle in the panel form', async () => { - actor('admin'); - await adminLogin(adminPage, app); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=board-${short}#board-${short}`); - const card = adminPage.locator(`#board-${short}`); - await expect(card).toBeVisible(); - await expect(card).toHaveAttribute('open', ''); - await card.locator('input[name="name"]').fill(updatedName); - await card.locator('input[name="description"]').fill(updatedDescription); - await card.locator('input[name="nsfw"]').check(); - await card.locator('input[name="allow_images"]').uncheck(); - const [saveResponse] = await Promise.all([ - adminPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' - && new URL(candidate.url()).pathname === '/admin/board/settings'), - card.locator('form.board-settings-form button[type="submit"]').click(), - ]); - expect(saveResponse.status()).toBe(303); - // The panel also renders a deliberate theme-preview flash pair, so the save - // feedback is pinned to its own message. - const flash = adminPage.locator('.admin-flash.flash-ok').filter({ hasText: 'Board settings saved.' }); - await expect(flash).toHaveCount(1); - await expect(flash).toContainText('Board settings saved.'); - expect(boardRow(app, short)).toBe(`${updatedName}|${updatedDescription}|1|0`); - }); - - await measureStep('the public board, catalog, and homepage reflect the new identity', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - await expect(page.locator('.board-index-header h1')).toContainText(updatedName); - await expect(page.locator('.board-desc')).toHaveText(updatedDescription); - // The upload toggle removes the user-visible file input, not just DB text. - await expect(page.locator('#post-form-wrap input[type="file"]')).toHaveCount(0); - await expect(page.locator('#post-form-wrap')).toContainText('uploads are disabled on this board'); - - await gotoOk(page, `${app.baseURL}/${short}/catalog`); - await expect(page.locator('.board-catalog-header h1')).toContainText(updatedName); - await expect(page.locator('.board-desc')).toHaveText(updatedDescription); - - await gotoOk(page, app.baseURL); - const slug = page.locator('.board-card-slug', { hasText: `/${short}/` }); - const card = page.locator('.board-card').filter({ has: slug }); - await expect(card).toHaveCount(1); - await expect(card.locator('.board-card-name')).toHaveText(updatedName); - await expect(card.locator('.board-card-desc')).toHaveText(updatedDescription); - await expect(card.locator('.nsfw-badge')).toBeVisible(); - await expect(page.locator('[data-board-nsfw="1"]').locator('.board-card').filter({ has: slug })).toHaveCount(1); - - // The unrelated board keeps its own identity and upload policy. - expect(boardRow(app, control)).toBe(`Control ${control}|control description ${control}|0|1`); - await gotoOk(page, `${app.baseURL}/${control}`); - await expect(page.locator('.board-index-header h1')).toContainText(`Control ${control}`); - await expect(page.locator('#post-form-wrap input[type="file"]')).toHaveCount(1); - }); - - const anonContext = await newAuditedContext(browser); - const anonPage = await anonContext.newPage(); - - await measureStep('a fresh visitor sees the updated board and the upload toggle', async () => { - actor('unrelated'); - await gotoOk(anonPage, `${app.baseURL}/${short}`); - await expect(anonPage.locator('.board-index-header h1')).toContainText(updatedName); - await expect(anonPage.locator('.board-desc')).toHaveText(updatedDescription); - await expect(anonPage.locator('#post-form-wrap input[type="file"]')).toHaveCount(0); - await gotoOk(anonPage, app.baseURL); - const anonSlug = anonPage.locator('.board-card-slug', { hasText: `/${short}/` }); - const anonCard = anonPage.locator('.board-card').filter({ has: anonSlug }); - await expect(anonCard.locator('.board-card-name')).toHaveText(updatedName); - await expect(anonCard.locator('.nsfw-badge')).toBeVisible(); - }); - - await measureStep('re-enabling images through the panel restores the public upload control', async () => { - actor('admin'); - await adminLogin(adminPage, app); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=board-${short}#board-${short}`); - const card = adminPage.locator(`#board-${short}`); - await card.locator('input[name="allow_images"]').check(); - const [saveResponse] = await Promise.all([ - adminPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' - && new URL(candidate.url()).pathname === '/admin/board/settings'), - card.locator('form.board-settings-form button[type="submit"]').click(), - ]); - expect(saveResponse.status()).toBe(303); - expect(boardRow(app, short)).toBe(`${updatedName}|${updatedDescription}|1|1`); - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - await expect(page.locator('#post-form-wrap input[type="file"]')).toHaveCount(1); - }); - - await anonContext.close(); - await adminContext.close(); -}); - -// --------------------------------------------------------------------------- -// 5. Search through the real search form -// --------------------------------------------------------------------------- - -test('search finds a unique token and a unicode marker and reports an empty result set for a blank query', JOURNEY_DETAILS, async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - const token = `findme${short}`; - const unicodeToken = 'café漢字'; - const matchingThread = await createThreadViaRequest(page, app, short, { - subject: `subject ${token}`, - body: `body carries ${token} once`, - }); - const otherThread = await createThreadViaRequest(page, app, short, { - subject: `unrelated subject ${short}`, - body: 'nothing to match here', - }); - const unicodeThread = await createThreadViaRequest(page, app, short, { - subject: `unicode ${short}`, - body: `marker ${unicodeToken} end`, - }); - // Search results render posts, so resolve the opening-post ids as well. - const matchingOpId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${matchingThread} AND is_op = 1;`); - const otherOpId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${otherThread} AND is_op = 1;`); - const unicodeOpId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${unicodeThread} AND is_op = 1;`); - - await measureStep('the search form finds the unique token and hides the non-match', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}/search`); - await page.getByLabel('Query:').fill(token); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'GET' - && new URL(candidate.url()).pathname === `/${short}/search`), - page.getByRole('button', { name: 'search' }).click(), - ]); - expect(response.status()).toBe(200); - await expect(page.locator(`#p${matchingOpId} .post-body`)).toContainText(token); - await expect(page.locator(`#p${otherOpId}`)).toHaveCount(0); - await expect(page.locator('.board-search-summary-results')).toHaveText('1 result'); - }); - - await measureStep('a unicode query matches its post', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}/search`); - await page.getByLabel('Query:').fill(unicodeToken); - await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'GET' - && new URL(candidate.url()).pathname === `/${short}/search`), - page.getByRole('button', { name: 'search' }).click(), - ]); - await expect(page.locator(`#p${unicodeOpId} .post-body`)).toContainText(unicodeToken); - await expect(page.locator(`#p${matchingOpId}`)).toHaveCount(0); - }); - - await measureStep('a whitespace-only query degrades to the empty result state', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}/search`); - await page.getByLabel('Query:').fill(' '); - await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'GET' - && new URL(candidate.url()).pathname === `/${short}/search`), - page.getByRole('button', { name: 'search' }).click(), - ]); - await expect(page.locator('.board-search-empty')).toContainText('no results found'); - await expect(page.locator('.post')).toHaveCount(0); - await expect(page.locator('.board-search-form input[name="q"]')).toHaveValue(''); - }); -}); - -// --------------------------------------------------------------------------- -// 6. Pagination boundaries on the board index -// --------------------------------------------------------------------------- - -test('board pagination splits threads across pages without duplicates and an out-of-range page stays safe', JOURNEY_DETAILS, async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - const created: number[] = []; - for (let index = 0; index < 11; index += 1) { - created.push(await createThreadViaRequest(page, app, short, { - subject: `page thread ${index} ${short}`, - body: `page thread body ${index} ${short}`, - })); - } - - await measureStep('page one and page two together contain every thread exactly once', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}?page=1`); - const pageOneIds = await page.locator('.thread').evaluateAll((elements) => - elements.map((element) => Number(element.id.slice(1)))); - await expect(page.locator('.pagination')).toHaveText(/page 1 \/ 2/); - await expect(page.locator('.pagination a', { hasText: '[next]' })).toHaveCount(1); - expect(pageOneIds).toHaveLength(10); - - await gotoOk(page, `${app.baseURL}/${short}?page=2`); - const pageTwoIds = await page.locator('.thread').evaluateAll((elements) => - elements.map((element) => Number(element.id.slice(1)))); - await expect(page.locator('.pagination')).toHaveText(/page 2 \/ 2/); - await expect(page.locator('.pagination a', { hasText: '[prev]' })).toHaveCount(1); - expect(pageTwoIds).toHaveLength(1); - - const union = [...pageOneIds, ...pageTwoIds].sort((left, right) => left - right); - expect(union).toEqual([...created].sort((left, right) => left - right)); - expect(pageOneIds.filter((id) => pageTwoIds.includes(id))).toEqual([]); - }); - - await measureStep('an out-of-range page renders an empty safe page instead of failing', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}?page=99`); - await expect(page.locator('.thread')).toHaveCount(0); - await expect(page.locator('.pagination')).toHaveText(/page 99 \/ 2/); - await expect(page.locator('body')).toContainText(`/${short}/`); - }); -}); - -// --------------------------------------------------------------------------- -// 7. NSFW consent gate (homepage gate contract, cookie scoped) -// --------------------------------------------------------------------------- - -test('the NSFW disclaimer gate blocks board content until consent, then persists for that browser only', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const subject = `nsfw gated thread ${uniqueShort('jrnyN', testInfo)}`; - await createThreadViaRequest(page, app, 'nsfw', { subject, body: `gated body ${subject}` }); - - const visitorContext = await newAuditedContext(browser); - const visitorPage = await visitorContext.newPage(); - - await measureStep('the homepage gates the NSFW card and hides board content', async () => { - actor('visitor'); - await gotoOk(visitorPage, app.baseURL); - const cardLink = visitorPage.locator('.board-card-link[data-action="open-nsfw-disclaimer"]').first(); - await expect(cardLink).toBeVisible(); - await expect(cardLink).toHaveAttribute('data-board-label', '/nsfw/'); - // Board content is not rendered before consent. - await expect(visitorPage.locator('.thread').filter({ hasText: subject })).toHaveCount(0); - await expect(visitorPage.getByText(subject)).toHaveCount(0); - }); - - await measureStep('accepting the disclaimer through the real control reveals the board', async () => { - actor('visitor'); - await visitorPage.locator('.board-card-link[data-action="open-nsfw-disclaimer"]').first().click(); - const disclaimer = visitorPage.locator('#nsfw-disclaimer-overlay'); - await expect(disclaimer).toBeVisible(); - await expect(disclaimer.getByRole('button', { name: 'I Agree' })).toBeVisible(); - await expect(visitorPage.getByText(subject)).toHaveCount(0); - const [acceptResponse] = await Promise.all([ - visitorPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/nsfw/accept'), - disclaimer.getByRole('button', { name: 'I Agree' }).click(), - ]); - expect(acceptResponse.status()).toBe(303); - await visitorPage.waitForURL(/\/nsfw\/catalog/); - await expect(visitorPage.locator('.catalog-subject').filter({ hasText: subject })).toHaveCount(1); - }); - - await measureStep('consent survives reload and navigation inside the same browser', async () => { - actor('visitor'); - await visitorPage.reload(); - await expect(visitorPage.locator('.catalog-subject').filter({ hasText: subject })).toHaveCount(1); - await gotoOk(visitorPage, `${app.baseURL}/nsfw`); - await expect(visitorPage.locator('.thread').filter({ hasText: subject })).toHaveCount(1); - // Unrelated boards keep rendering normally. - await gotoOk(visitorPage, `${app.baseURL}/pub`); - await expect(visitorPage.locator('.board-index-header h1')).toContainText('/pub/'); - await expect(visitorPage.locator('#nsfw-disclaimer-overlay')).toHaveCount(0); - const consentCookie = (await visitorContext.cookies()).find((cookie) => cookie.name === 'rustchan_nsfw_ok'); - expect(consentCookie?.value).toBe('1'); - }); - - await measureStep('a second browser still sees the gate', async () => { - actor('unrelated'); - const otherContext = await newAuditedContext(browser); - const otherPage = await otherContext.newPage(); - await gotoOk(otherPage, app.baseURL); - await expect(otherPage.locator('.board-card-link[data-action="open-nsfw-disclaimer"]').first()).toBeVisible(); - await expect(otherPage.getByText(subject)).toHaveCount(0); - const otherCookies = await otherContext.cookies(); - expect(otherCookies.find((cookie) => cookie.name === 'rustchan_nsfw_ok')).toBeUndefined(); - await otherContext.close(); - }); - - await visitorContext.close(); -}); - -// --------------------------------------------------------------------------- -// 8. Poll lifecycle with a three-option poll -// --------------------------------------------------------------------------- - -test('a three-option poll accepts one vote per identity, rejects a duplicate, and counts a second actor', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - const question = `journey poll ${short}?`; - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - - let threadId = 0; - let pollId = 0; - let alphaId = 0; - let betaId = 0; - - await measureStep('the poll is created through the new-thread form', async () => { - actor('owner'); - await gotoOk(page, `${app.baseURL}/${short}`); - const form = await openComposer(page, `form[action="/${short}"]`); - await form.locator('input[name="subject"]').fill(`poll thread ${short}`); - await form.locator('textarea[name="body"]').fill(`poll body ${short}`); - await form.locator('details.poll-creator > summary').click(); - await form.locator('input[name="poll_question"]').fill(question); - const optionInputs = form.locator('input[name="poll_option"]'); - await optionInputs.nth(0).fill('Option Alpha'); - await optionInputs.nth(1).fill('Option Beta'); - await form.locator('[data-action="add-poll-option"]').click(); - await expect(optionInputs).toHaveCount(3); - await optionInputs.nth(2).fill('Option Gamma'); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === `/${short}`), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - expect(response.status()).toBe(303); - await page.waitForURL(new RegExp(`/${short}/thread/\\d+`)); - threadId = threadIdFromUrl(page.url()); - pollId = scalar(app, `SELECT id FROM polls WHERE thread_id = ${threadId};`); - alphaId = scalar(app, `SELECT id FROM poll_options WHERE poll_id = ${pollId} AND text = 'Option Alpha';`); - betaId = scalar(app, `SELECT id FROM poll_options WHERE poll_id = ${pollId} AND text = 'Option Beta';`); - expect(pollId).toBeGreaterThan(0); - expect( - scalar(app, `SELECT COUNT(*) FROM poll_options WHERE poll_id = ${pollId};`), - 'three options should be stored for the poll', - ).toBe(3); - await expect(page.locator('.poll-container .poll-question')).toHaveText(question); - await expect(page.locator('.poll-vote-option')).toHaveCount(3); - await expect(page.locator('.poll-total')).toHaveCount(0); - }); - - const secondActorContext = await newAuditedContext(browser); - const secondActorPage = await secondActorContext.newPage(); - // Same identity, second document: its pre-vote form is the duplicate vector. - const stalePage = await page.context().newPage(); - - await measureStep('a non-voter sees the ballot while the owner votes', async () => { - actor('unrelated'); - await gotoOk(secondActorPage, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(secondActorPage.locator('.poll-vote-option')).toHaveCount(3); - await expect(secondActorPage.locator('.poll-results')).toHaveCount(0); - await gotoOk(stalePage, `${app.baseURL}/${short}/thread/${threadId}`); - await expect(stalePage.locator('.poll-vote-option')).toHaveCount(3); - - actor('owner'); - await gotoOk(page, `${app.baseURL}/${short}/thread/${threadId}`); - await page.locator('.poll-vote-option').filter({ hasText: 'Option Alpha' }).locator('input[name="option_id"]').check(); - const [voteResponse] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/vote'), - page.getByRole('button', { name: '[ Cast Vote ]' }).click(), - ]); - expect(voteResponse.status()).toBe(303); - await page.waitForURL(new RegExp(`/${short}/thread/${threadId}`)); - const alphaResult = page.locator('.poll-option-result').filter({ hasText: 'Option Alpha' }); - await expect(alphaResult.locator('.poll-opt-count')).toHaveText('1 (100%)'); - await expect(alphaResult).toHaveClass(/user-voted/); - await expect(page.locator('.poll-total')).toHaveText('1 total vote'); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE poll_id = ${pollId};`)).toBe(1); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE option_id = ${alphaId};`)).toBe(1); - }); - - await measureStep('a duplicate vote from the same identity is rejected and changes nothing', async () => { - actor('owner'); - expectHttpError({ - method: 'POST', - path: '/vote', - status: 400, - reason: 'the duplicate vote is rejected with the documented inline error page', - }); - await stalePage.locator('.poll-vote-option').filter({ hasText: 'Option Beta' }).locator('input[name="option_id"]').check(); - const [duplicate] = await Promise.all([ - stalePage.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/vote'), - stalePage.getByRole('button', { name: '[ Cast Vote ]' }).click(), - ]); - expect(duplicate.status()).toBe(400); - await expect(stalePage.locator('.error-page')).toContainText('already voted'); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE poll_id = ${pollId};`)).toBe(1); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE option_id = ${betaId};`)).toBe(0); - }); - - await measureStep('a different identity voting increments the chosen option only', async () => { - actor('unrelated'); - await secondActorPage.locator('.poll-vote-option').filter({ hasText: 'Option Beta' }).locator('input[name="option_id"]').check(); - const [voteResponse] = await Promise.all([ - secondActorPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/vote'), - secondActorPage.getByRole('button', { name: '[ Cast Vote ]' }).click(), - ]); - expect(voteResponse.status()).toBe(303); - await secondActorPage.waitForURL(new RegExp(`/${short}/thread/${threadId}`)); - const betaResult = secondActorPage.locator('.poll-option-result').filter({ hasText: 'Option Beta' }); - await expect(betaResult.locator('.poll-opt-count')).toHaveText('1 (50%)'); - await expect(betaResult).toHaveClass(/user-voted/); - await expect(secondActorPage.locator('.poll-option-result').filter({ hasText: 'Option Alpha' })).not.toHaveClass(/user-voted/); - await expect(secondActorPage.locator('.poll-total')).toHaveText('2 total votes'); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE poll_id = ${pollId};`)).toBe(2); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE option_id = ${alphaId};`)).toBe(1); - expect(scalar(app, `SELECT COUNT(*) FROM poll_votes WHERE option_id = ${betaId};`)).toBe(1); - - actor('owner'); - await page.reload(); - await expect(page.locator('.poll-total')).toHaveText('2 total votes'); - await expect(page.locator('.poll-option-result').filter({ hasText: 'Option Alpha' })).toHaveClass(/user-voted/); - await expect(page.locator('.poll-option-result').filter({ hasText: 'Option Beta' })).not.toHaveClass(/user-voted/); - }); - - await secondActorContext.close(); -}); - -// --------------------------------------------------------------------------- -// 9. Poll creation with JavaScript disabled (noscript option rows) -// --------------------------------------------------------------------------- - -test('a poll with three options is creatable without JavaScript through the noscript option rows', JOURNEY_DETAILS, async ({ page, app }, testInfo) => { - test.skip( - testInfo.project.name !== 'chromium-nojs' && testInfo.project.name !== 'firefox-nojs', - 'This journey asserts the noscript poll option rows, which only exist when scripting is disabled; JavaScript projects cover the add-option button instead.', - ); - const short = boardShort(testInfo); - const question = `no-js poll ${short}?`; - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - - await measureStep('the no-JS form exposes the extra option rows and creates the poll', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - const form = page.locator(`form[action="/${short}"]`).first(); - await expect(form).toBeVisible(); - await form.locator('input[name="subject"]').fill(`no-js poll thread ${short}`); - await form.locator('textarea[name="body"]').fill(`no-js poll body ${short}`); - await form.locator('details.poll-creator > summary').click(); - await form.locator('input[name="poll_question"]').fill(question); - const optionInputs = form.locator('input[name="poll_option"]'); - await optionInputs.nth(0).fill('NoJS One'); - await optionInputs.nth(1).fill('NoJS Two'); - // The extra rows only exist inside the noscript fallback. - const extraDetails = form.locator('details.poll-creator noscript details'); - await expect(extraDetails).toHaveCount(1); - await extraDetails.locator('summary').click(); - await optionInputs.nth(2).fill('NoJS Three'); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === `/${short}`), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - expect(response.status()).toBe(303); - await page.waitForURL(new RegExp(`/${short}/thread/\\d+`)); - const threadId = threadIdFromUrl(page.url()); - const pollId = scalar(app, `SELECT id FROM polls WHERE thread_id = ${threadId};`); - expect(scalar(app, `SELECT COUNT(*) FROM poll_options WHERE poll_id = ${pollId};`)).toBe(3); - await expect(page.locator('.poll-container .poll-question')).toHaveText(question); - await expect(page.locator('.poll-vote-option')).toHaveCount(3); - await expect(page.locator('.poll-opt-text').filter({ hasText: 'NoJS Three' })).toHaveCount(1); - }); -}); - -// --------------------------------------------------------------------------- -// 10. Sage and bump semantics -// --------------------------------------------------------------------------- - -test('a sage reply raises reply_count without bumping while a normal reply moves bumped_at forward', JOURNEY_DETAILS, async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - const threadId = await createThread(page, app, short, { - subject: `sage thread ${short}`, - body: `sage thread body ${short}`, - }); - // Freeze the bump clock so "did not advance" is observable even in a fast test. - const frozenBumpedAt = Math.floor(Date.now() / 1000) - 300; - setThreadFixtureState(app, threadId, { bumpedAt: frozenBumpedAt }); - - await measureStep('the sage reply increments the reply count but not the bump clock', async () => { - actor('owner'); - await gotoOk(page, `${app.baseURL}/${short}/thread/${threadId}`); - const form = await openComposer(page, `form[action="/${short}/thread/${threadId}"]`); - await form.locator('textarea[name="body"]').fill(`sage reply ${short}`); - await form.locator('input[name="sage"]').check(); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' - && new URL(candidate.url()).pathname === `/${short}/thread/${threadId}`), - form.getByRole('button', { name: /post reply/i }).click(), - ]); - expect(response.status()).toBe(303); - await expect(page.locator('.post').filter({ hasText: `sage reply ${short}` })).toHaveCount(1); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - expect(scalar(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)).toBe(1); - expect( - scalar(app, `SELECT bumped_at FROM threads WHERE id = ${threadId};`), - 'a sage reply must not bump the thread', - ).toBe(frozenBumpedAt); - }); - - await measureStep('a normal reply moves the bump clock forward', async () => { - actor('owner'); - const form = await openComposer(page, `form[action="/${short}/thread/${threadId}"]`); - await form.locator('textarea[name="body"]').fill(`bumping reply ${short}`); - await form.locator('input[name="sage"]').uncheck(); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' - && new URL(candidate.url()).pathname === `/${short}/thread/${threadId}`), - form.getByRole('button', { name: /post reply/i }).click(), - ]); - expect(response.status()).toBe(303); - await expect(page.locator('.post').filter({ hasText: `bumping reply ${short}` })).toHaveCount(1); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('2'); - expect(scalar(app, `SELECT reply_count FROM threads WHERE id = ${threadId};`)).toBe(2); - const bumpedAt = scalar(app, `SELECT bumped_at FROM threads WHERE id = ${threadId};`); - expect(bumpedAt, 'a normal reply should move bumped_at forward').toBeGreaterThan(frozenBumpedAt); - expect(Math.abs(Math.floor(Date.now() / 1000) - bumpedAt)).toBeLessThan(30); - }); -}); - -// --------------------------------------------------------------------------- -// 11. Boundary states: empty board, validation limits, unicode round-trip, -// missing thread -// --------------------------------------------------------------------------- - -test('an empty board, the one-thread state, validation limits, unicode round-trips, and a missing thread all behave', JOURNEY_DETAILS, async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noImages: true, noVideos: true }); - - await measureStep('a freshly created board renders both empty states', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - await expect(page.locator('.thread')).toHaveCount(0); - await expect(page.locator('.pagination')).toHaveCount(0); - await gotoOk(page, `${app.baseURL}/${short}/catalog`); - await expect(page.locator('.catalog-empty-state')).toContainText('No threads yet.'); - }); - - const singleThread = await createThreadViaRequest(page, app, short, { - subject: `single thread ${short}`, - body: `single thread body ${short}`, - }); - - await measureStep('exactly one thread appears in the index and catalog', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - await expect(page.locator('.thread')).toHaveCount(1); - await expect(page.locator(`#t${singleThread} .subject`)).toContainText(`single thread ${short}`); - await gotoOk(page, `${app.baseURL}/${short}/catalog`); - await expect(page.locator('.catalog-item')).toHaveCount(1); - }); - - await measureStep('an over-limit body is rejected inline and creates no post row', async () => { - actor('visitor'); - const postsBefore = scalar(app, `SELECT COUNT(*) FROM posts WHERE board_id = ${boardId(app, short)};`); - await gotoOk(page, `${app.baseURL}/${short}`); - const form = await openComposer(page, `form[action="/${short}"]`); - const oversizedBody = 'x'.repeat(4097); - await form.locator('input[name="subject"]').fill(`too long ${short}`); - // `fill` inserts text through the browser, which truncates at the - // textarea's maxlength="4096"; the contract under test here is the - // server-side limit, so the over-limit value is assigned directly and then - // submitted through the real form. - await form.locator('textarea[name="body"]').evaluate((element, value) => { - (element as HTMLTextAreaElement).value = value; - }, oversizedBody); - await expect(form.locator('textarea[name="body"]')).toHaveValue(oversizedBody); - expectHttpError({ - method: 'POST', - path: `/${short}`, - status: 422, - reason: 'an over-limit body is re-rendered inline with the documented validation banner', - }); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === `/${short}`), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - expect(response.status()).toBe(422); - await expect(page.locator('.post-error-banner')).toContainText('Post body exceeds 4096 characters.'); - await expect(page.locator('.post-error-banner')).not.toContainText('thread panicked'); - // The draft survives the failed submission. - await expect(page.locator(`form[action="/${short}"]`).first().locator('textarea[name="body"]')).toHaveValue(oversizedBody); - expect(scalar(app, `SELECT COUNT(*) FROM posts WHERE board_id = ${boardId(app, short)};`)).toBe(postsBefore); - }); - - await measureStep('a whitespace-only body is rejected and creates no post row', async () => { - actor('visitor'); - const postsBefore = scalar(app, `SELECT COUNT(*) FROM posts WHERE board_id = ${boardId(app, short)};`); - const csrf = await publicCsrf(page, app, `/${short}`); - const response = await page.request.post(`${app.baseURL}/${short}`, { - multipart: { - _csrf: csrf, - submission_token: `journey-${short}-${Date.now()}`, - subject: `blank ${short}`, - body: ' \n\t ', - }, - maxRedirects: 0, - }); - expect(response.status()).toBe(422); - const text = await expectSafeResponse(response); - expect(text).toContain('Post body cannot be empty.'); - expect(scalar(app, `SELECT COUNT(*) FROM posts WHERE board_id = ${boardId(app, short)};`)).toBe(postsBefore); - }); - - await measureStep('unicode, emoji, and a long unbroken run round-trip intact', async () => { - actor('visitor'); - const unicodeBody = `roundtrip café漢字 🚀🙂 ${'A'.repeat(400)}Z`; - const unicodeThread = await createThreadViaRequest(page, app, short, { - subject: `unicode thread ${short}`, - body: unicodeBody, - }); - const unicodeOpId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${unicodeThread} AND is_op = 1;`); - await gotoOk(page, `${app.baseURL}/${short}/thread/${unicodeThread}`); - const rendered = await page.locator(`#p${unicodeOpId} .post-body`).innerText(); - expect(rendered).toBe(unicodeBody); - expect(sqliteQuery(app, `SELECT body FROM posts WHERE thread_id = ${unicodeThread} AND is_op = 1;`)).toBe(unicodeBody); - }); - - await measureStep('a missing thread id renders the documented 404 instead of failing', async () => { - actor('visitor'); - const missingPath = `/${short}/thread/99999999`; - expectHttpError({ - method: 'GET', - path: missingPath, - status: 404, - reason: 'a missing thread id must render the 404 page', - }); - const response = await page.goto(`${app.baseURL}${missingPath}`, { waitUntil: 'domcontentloaded' }); - expect(response, 'the 404 navigation should still produce a document').not.toBeNull(); - expect(response!.status()).toBe(404); - await expect(page.locator('.error-page h1')).toHaveText('error 404'); - await expect(page.locator('.error-page')).toContainText(/not found/i); - await expect(page.locator('body')).not.toContainText('thread panicked'); - }); -}); - -// --------------------------------------------------------------------------- -// 12. Admin moderation: report queue and thread deletion -// --------------------------------------------------------------------------- - -test('admin deletes a reported thread through the modal, drains the report queue, and records the act in the mod log', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - const subject = `reported thread ${short}`; - const reason = `journey report ${short}`; - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - const threadId = await createThreadViaRequest(page, app, short, { subject, body: `reported body ${short}` }); - const opId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`); - - const reporterContext = await newAuditedContext(browser); - const reporterPage = await reporterContext.newPage(); - const adminContext = await newAuditedContext(browser); - const adminPage = await adminContext.newPage(); - - await measureStep('a visitor reports the opening post through the report modal', async () => { - actor('unrelated'); - await gotoOk(reporterPage, `${app.baseURL}/${short}/thread/${threadId}`); - await reporterPage.locator(`#p${opId} button[data-action="open-report"]`).click(); - const modal = reporterPage.locator('#report-modal'); - await expect(modal).toBeVisible(); - await modal.locator('#report-reason').fill(reason); - const [response] = await Promise.all([ - reporterPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/report'), - modal.locator('#report-submit-btn').click(), - ]); - expect(response.status()).toBe(303); - await reporterPage.waitForURL(new RegExp(`/${short}/thread/${threadId}\\?reported=1`)); - await expect(reporterPage.locator('.post-success-banner')).toContainText('Report submitted'); - expect(scalar(app, `SELECT COUNT(*) FROM reports WHERE board_id = ${boardId(app, short)} AND status = 'open';`)).toBe(1); - }); - - await measureStep('the admin panel shows the queue entry before the thread is deleted', async () => { - actor('admin'); - await adminLogin(adminPage, app); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=reports#reports`); - await expect(adminPage.locator('#reports details.admin-dropdown').first()).toHaveAttribute('open', ''); - await expect(adminPage.locator('#reports .admin-dropdown-counter-label')).toContainText('Report inbox: [1]'); - await expect(adminPage.locator('#reports')).toContainText(reason); - }); - - await measureStep('the admin deletes the whole thread through the modal confirmation', async () => { - actor('admin'); - await gotoOk(adminPage, `${app.baseURL}/${short}/thread/${threadId}`); - const deleteButton = adminPage.locator('.admin-toolbar button', { hasText: 'delete thread' }); - await expect(deleteButton).toBeVisible(); - await expectNoDialog(adminPage, async () => { - await deleteButton.click(); - await expect(adminPage.locator('#confirm-modal-message')).toHaveText('Delete this entire thread and all its posts?'); - await adminPage.locator('#confirm-modal-continue').click(); - }); - await adminPage.waitForURL(new RegExp(`/${short}$`)); - await expect(adminPage.locator('.thread').filter({ hasText: subject })).toHaveCount(0); - expect(scalar(app, `SELECT COUNT(*) FROM threads WHERE id = ${threadId};`)).toBe(0); - expect(scalar(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`)).toBe(0); - expect(scalar(app, `SELECT COUNT(*) FROM reports WHERE thread_id = ${threadId};`)).toBe(0); - }); - - await measureStep('board, catalog, and moderation log reflect the deletion', async () => { - actor('visitor'); - await gotoOk(page, `${app.baseURL}/${short}`); - await expect(page.locator('.thread')).toHaveCount(0); - await gotoOk(page, `${app.baseURL}/${short}/catalog`); - await expect(page.locator('.catalog-empty-state')).toContainText('No threads yet.'); - - actor('admin'); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=reports#reports`); - await expect(adminPage.locator('#reports .admin-dropdown-counter-label')).toContainText('Report inbox: [0]'); - expect(scalar(app, `SELECT COUNT(*) FROM mod_log WHERE action = 'delete_thread' AND target_id = ${threadId};`)).toBe(1); - await gotoOk(adminPage, `${app.baseURL}/admin/mod-log`); - const logRow = adminPage.locator('table.admin-table tbody tr').filter({ hasText: `thread #${threadId}` }); - await expect(logRow).toHaveCount(1); - await expect(logRow).toContainText('delete_thread'); - }); - - await reporterContext.close(); - await adminContext.close(); -}); - -// --------------------------------------------------------------------------- -// 13. Admin moderation: ban add/remove lifecycle -// --------------------------------------------------------------------------- - -test('admin bans a real poster hash, the actor lands on the ban notice, and lifting the ban restores posting', JOURNEY_DETAILS, async ({ page, app, browser }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'Product journey coverage runs on desktop Chromium.'); - const short = boardShort(testInfo); - const banReason = `journey ban ${short}`; - app.createBoardCli({ short, name: `Journey ${short}`, description: `journey board ${short}`, noVideos: true }); - const threadId = await createThreadViaRequest(page, app, short, { - subject: `ban host thread ${short}`, - body: `ban host body ${short}`, - }); - const replyBody = `ban target reply ${short}`; - await createReply(page, app, short, threadId, replyBody); - const replyId = scalar(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND body = '${replyBody}';`); - const ipHash = sqliteQuery(app, `SELECT ip_hash FROM posts WHERE id = ${replyId};`); - expect(ipHash).toMatch(/^[0-9a-f]{64}$/); - - const adminContext = await newAuditedContext(browser); - const adminPage = await adminContext.newPage(); - - await measureStep('the admin bans the poster hash through the panel form', async () => { - actor('admin'); - await adminLogin(adminPage, app); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=reports#reports`); - const banForm = adminPage.locator('form[action="/admin/ban/add"]'); - await expect(banForm).toBeVisible(); - await banForm.locator('input[name="ip_hash"]').fill(ipHash); - await banForm.locator('input[name="reason"]').fill(banReason); - await banForm.locator('input[name="duration_hours"]').fill('5'); - const [response] = await Promise.all([ - adminPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/admin/ban/add'), - banForm.getByRole('button', { name: 'ban' }).click(), - ]); - expect(response.status()).toBe(303); - const banFlash = adminPage.locator('.admin-flash.flash-ok').filter({ hasText: 'Ban added.' }); - await expect(banFlash).toHaveCount(1); - await expect(banFlash).toContainText('Ban added.'); - - const storedReason = sqliteQuery(app, `SELECT reason FROM bans WHERE ip_hash = '${ipHash}';`); - expect(storedReason).toBe(banReason); - const banWindowSecs = scalar(app, `SELECT expires_at - created_at FROM bans WHERE ip_hash = '${ipHash}';`); - expect(banWindowSecs).toBeGreaterThanOrEqual(17_990); - expect(banWindowSecs).toBeLessThanOrEqual(18_010); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=reports#reports`); - const banRow = adminPage.locator('#active-bans tbody tr').filter({ hasText: banReason }); - await expect(banRow).toHaveCount(1); - }); - - await measureStep('the banned actor is sent to the ban notice when posting', async () => { - actor('owner'); - await gotoOk(page, `${app.baseURL}/${short}`); - const form = await openComposer(page, `form[action="/${short}"]`); - await form.locator('input[name="subject"]').fill(`banned attempt ${short}`); - await form.locator('textarea[name="body"]').fill(`banned attempt body ${short}`); - await form.locator('input[type="file"]').first().setInputFiles(app.fixtures().tinyPng); - await form.getByRole('button', { name: /post thread/i }).click(); - await page.waitForURL(/\/banned/); - await expect(page.getByRole('heading', { name: /you are banned/i })).toBeVisible(); - await expect(page.locator('.error-page')).toContainText(banReason); - expect( - scalar(app, `SELECT COUNT(*) FROM posts WHERE body = 'banned attempt body ${short}';`), - 'the banned attempt must not create a post row', - ).toBe(0); - }); - - await measureStep('the admin lifts the ban and the actor can post again', async () => { - actor('admin'); - await gotoOk(adminPage, `${app.baseURL}/admin/panel?open=reports#reports`); - const banRow = adminPage.locator('#active-bans tbody tr').filter({ hasText: banReason }); - await expect(banRow).toHaveCount(1); - const [response] = await Promise.all([ - adminPage.waitForResponse((candidate) => - candidate.request().method() === 'POST' && new URL(candidate.url()).pathname === '/admin/ban/remove'), - banRow.getByRole('button', { name: 'lift' }).click(), - ]); - expect(response.status()).toBe(303); - const liftFlash = adminPage.locator('.admin-flash.flash-ok').filter({ hasText: 'Ban lifted.' }); - await expect(liftFlash).toHaveCount(1); - await expect(liftFlash).toContainText('Ban lifted.'); - expect(scalar(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${ipHash}';`)).toBe(0); - - actor('owner'); - const restoredThread = await createThread(page, app, short, { - subject: `restored thread ${short}`, - body: `restored body ${short}`, - filePath: app.fixtures().tinyPng, - }); - expect(restoredThread).toBeGreaterThan(0); - await expect(page.locator('.post.op .subject')).toContainText(`restored thread ${short}`); - expect(scalar(app, `SELECT COUNT(*) FROM threads WHERE board_id = ${boardId(app, short)};`)).toBe(2); - }); - - await adminContext.close(); -}); - -// --------------------------------------------------------------------------- -// Contract ledger -// --------------------------------------------------------------------------- -// -// Asserted by this file (browser-visible unless noted): -// * Anonymous identity is cookie-scoped: a fresh context is a different actor, -// and a second document in the same context shares the identity (duplicate -// poll vote rejection proves it). -// * Media upload lifecycle: an image OP stores body/file_path/thumb_path -// and a file_hashes row, renders the download link plus a loadable thumbnail, -// and appears in board index, catalog, and thread views to another actor. -// * Replies advance threads.reply_count and the rendered -// [data-role="thread-reply-count"] by exactly one. -// * The owner edit modal POSTs to /{board}/post/{id}/edit, answers with the -// X-Rustchan-Redirect header, sets posts.edited_at, and the edited body is -// visible after the modal flow, after reload, and to another actor. -// * Owner self-delete via the confirmation modal removes the post for both -// actors, decrements reply_count, and leaves the OP media row and file intact. -// Note: the brief's "owner deletes the unrelated actor's reply" is impossible -// by design (self-delete authority is the per-post rustchan_owned_posts grant -// of the author's browser), so the owner deletes their own reply while the -// unrelated reply acts as the unchanged control. -// * allow_editing=false / allow_self_delete=false hide .self-action-controls; -// GET /{board}/post/{id}/edit answers 403 and POST .../delete answers 403 with -// the documented message; the post row is unchanged. -// * Theme and preference cookies persist across navigation, reload, and server -// restart for the choosing browser, and never leak into a fresh context. -// * Board settings saved in the admin panel update the board page, catalog, -// homepage card and a fresh visitor; the unrelated board is unchanged; the -// allow_images toggle adds/removes the user-visible upload input. -// * Search (GET /{board}/search) matches a unique token and a unicode token, -// hides non-matches, and degrades a whitespace query to the empty state. -// * Board pagination (?page=2) partitions threads without duplicates or loss; -// ?page=99 stays a safe empty page (HTTP 200). -// * The NSFW gate is a homepage consent gate: before consent the card opens the -// disclaimer overlay and no board content is rendered; POST /nsfw/accept -// redirects to the board, persists across reload/navigation for that browser, -// and leaves other browsers and unrelated boards unaffected. -// * Poll creation through the form stores three poll_options; a vote through -// the /vote form records one poll_votes row and marks the result; a duplicate -// vote from the same identity answers 400 and adds no row; a different -// identity's vote increments only its option. -// * Without JavaScript the noscript "More poll options" rows can create a -// three-option poll (chromium-nojs and firefox-nojs projects). -// * A sage reply increments reply_count without moving threads.bumped_at; a -// normal reply moves it forward. -// * Board boundaries: empty index, empty catalog state, one-thread state, -// over-limit body -> 422 + banner + no row + draft preserved, whitespace body -// -> 422 + "Post body cannot be empty." + no row, unicode/emoji/long-run body -// round-trips byte-for-byte, missing thread -> 404 error page. -// * Admin moderation: the report queue counter and row are visible, the delete -// modal removes the thread from board/catalog/DB (reports cascade), the -// moderation counter returns to zero, and mod_log records delete_thread. -// * Admin bans a real posts.ip_hash from the panel; the stored reason and -// duration window match; the banned actor is redirected to /banned with the -// reason and no post row is created; lifting the ban restores posting. -// -// Left unverified here (covered elsewhere or by design out of scope): -// * View/post-password boards, CAPTCHA, archive/hidden-thread flows, pin/hide -// thread preferences, catalog sorting, media viewers beyond thumbnails, -// backups/maintenance, Tor/TLS, and all layout/contrast/responsive checks. -// * The `/theme/{theme}` GET link is a supported theme entry point that this -// file does not exercise; only the preferences form selector is driven. -// * Multi-board scale limits (max_threads, bump_limit overflow) are not pushed -// to their caps; only the default configuration is exercised. -// * The NSFW gate is asserted where the app implements it (the homepage card -// and the /?nsfw= entry point). Direct navigation to /nsfw or /nsfw/catalog -// is not gated by the server, so the journey documents the real contract -// rather than an assumed board-level gate. -// * The strict diagnostics publication of console/HTTP declarations for 4xx -// navigations declares only expectHttpError: diagnostics.ts derives the -// Chromium "Failed to load resource" console message from the declared HTTP -// error response, so a separate expectConsoleError is neither needed nor -// counted. diff --git a/tests/e2e/audit-nojs-parity.spec.ts b/tests/e2e/audit-nojs-parity.spec.ts deleted file mode 100644 index 62e83a48..00000000 --- a/tests/e2e/audit-nojs-parity.spec.ts +++ /dev/null @@ -1,715 +0,0 @@ -/** - * No-JavaScript parity audit of the server-rendered HTML/plain-form contract. - * - * Project applicability (documented here and gated with test.skip): - * - chromium with `test.use({ javaScriptEnabled: false })` below. - * - chromium-nojs, the project-level JavaScript-disabled configuration. - * - firefox-nojs remains in the gate set for configuration parity with the - * recorded project contract, but the Playwright Firefox build cannot create - * a profile on this host, so no assertion depends on it running. - * - webkit / firefox / mobile-firefox / mobile-webkit are excluded: those - * projects run with JavaScript enabled, all engines receive byte-identical - * server HTML, and their no-JS behaviour is already covered by - * mobile-nojs.spec.ts and firefox-nojs-public.spec.ts. Adding them here - * would only duplicate engine-agnostic markup coverage and triple runtime. - * - * Every interaction below uses only real links, form controls, native Enter - * submission, file inputs, redirects, validation, and browser history. The file - * never hides scripts, never calls app JavaScript, uses no route mocking and no - * timer synchronisation. The JS-path parity steps explicitly create an audited - * context with `javaScriptEnabled: true` so the enhancement is the thing under - * test rather than the project default. - */ -import { - expectHttpError, - newAuditedContext, - type Page, -} from './diagnostics'; -import { - ADMIN_PASSWORD, - adminLogin, - adminPasswordHash, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - sqliteQuery, - test, - threadIdFromUrl, - uniqueShort, -} from './helpers'; - -test.use({ javaScriptEnabled: false }); - -const NOJS_PROJECTS = ['chromium', 'chromium-nojs', 'firefox-nojs']; - -test.beforeEach(async ({}, testInfo) => { - test.skip( - !NOJS_PROJECTS.includes(testInfo.project.name), - 'no-JS parity runs on chromium and chromium-nojs with JavaScript disabled at context creation (firefox-nojs cannot launch on this host)', - ); -}); - -function marker(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; -} - -/** - * Thread and reply POSTs redirect to `/{board}/thread/{id}#p{postId}`. Wait for - * that redirect and fail if the fragment (the post anchor contract) is absent. - */ -async function waitForThreadRedirect(page: Page, board: string): Promise { - await page.waitForURL(new RegExp(`/${board}/thread/\\d+(?:#p\\d+)?$`)); - const url = page.url(); - expect(url, `thread redirect for /${board}/`).toMatch(new RegExp(`/${board}/thread/\\d+#p\\d+$`)); - return threadIdFromUrl(url); -} - -async function waitForReplyRedirect(page: Page, board: string, threadId: number): Promise { - await page.waitForURL(new RegExp(`/${board}/thread/${threadId}(?:#p\\d+)?$`)); - expect(page.url(), `reply redirect for /${board}/thread/${threadId}`).toMatch( - new RegExp(`/${board}/thread/${threadId}#p\\d+$`), - ); -} - -type MutationWatch = { requests: string[]; statuses: number[] }; - -function watchMutations(page: Page, pathname: string): MutationWatch { - const watch: MutationWatch = { requests: [], statuses: [] }; - page.on('request', (request) => { - if (request.method() === 'POST' && new URL(request.url()).pathname === pathname) { - watch.requests.push(`${request.method()} ${pathname}`); - } - }); - page.on('response', (response) => { - if (response.request().method() === 'POST' && new URL(response.url()).pathname === pathname) { - watch.statuses.push(response.status()); - } - }); - return watch; -} - -async function firstPostId(page: Page, selector = '.post.op'): Promise { - const id = await page.locator(selector).first().getAttribute('id'); - const numeric = Number(id?.replace(/^p/, '')); - expect(Number.isInteger(numeric) && numeric > 0, `post id for ${selector}`).toBe(true); - return numeric; -} - -async function submitThreadNoJs( - page: Page, - board: string, - options: { subject?: string; body?: string; filePath?: string } = {}, -): Promise { - const form = page.locator(`form[action="/${board}"]`).first(); - await expect(form.locator('textarea[name="body"]')).toBeVisible(); - if (options.subject !== undefined) { - await form.locator('input[name="subject"]').fill(options.subject); - } - await form.locator('textarea[name="body"]').fill(options.body ?? `thread body ${Date.now()}`); - if (options.filePath) { - await form.locator('input[type="file"]').first().setInputFiles(options.filePath); - } - await form.getByRole('button', { name: /post thread/i }).click(); -} - -test.describe('no-JavaScript server-rendered parity', () => { - test('an anonymous visitor creates a thread and a reply through plain forms with real redirects', async ({ page, app }, testInfo) => { - // Preconditions: public /pub board, no cooldown. - const subject = marker('nojs-thread'); - const body = marker('nojs-body'); - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator('html')).toHaveClass(/no-js/); - await expectSafePage(page); - await expect(page.locator(`form[action="/pub"]`)).toHaveCount(1); - - const threadWatch = watchMutations(page, '/pub'); - await submitThreadNoJs(page, 'pub', { subject, body }); - const threadId = await waitForThreadRedirect(page, 'pub'); - expect(threadWatch.requests).toHaveLength(1); - expect(threadWatch.statuses).toEqual([303]); - - await expectSafePage(page); - await expect(page.locator('.post.op')).toHaveCount(1); - expect(sqliteQuery(app, `SELECT subject FROM threads WHERE id = ${threadId};`)).toBe(subject); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`)).toBe('1'); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('body')).toContainText(body); - - const replyBody = marker('nojs-reply'); - const replyForm = page.locator(`form[action="/pub/thread/${threadId}"]`).first(); - await expect(replyForm.locator('textarea[name="body"]')).toBeVisible(); - await replyForm.locator('textarea[name="body"]').fill(replyBody); - const replyWatch = watchMutations(page, `/pub/thread/${threadId}`); - await replyForm.getByRole('button', { name: /post reply/i }).click(); - await waitForReplyRedirect(page, 'pub', threadId); - expect(replyWatch.requests).toHaveLength(1); - expect(replyWatch.statuses).toEqual([303]); - await expect(page.locator('body')).toContainText(replyBody); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('body')).toContainText(replyBody); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId} AND is_op = 0;`)).toBe('1'); - await expectSafePage(page); - }); - - test('the plain edit and delete pages complete the own-post lifecycle', async ({ page, app }, testInfo) => { - // Preconditions: board allows editing and self-delete; OP has no replies. - const board = uniqueShort('njsown', testInfo); - app.createBoardCli({ short: board, name: 'NoJS Own Post' }); - setBoardFixtureSettings(app, board, { allowEditing: true, allowSelfDelete: true, postCooldownSecs: 0 }); - - const subject = marker('nojs-edit'); - await page.goto(`${app.baseURL}/${board}`); - await submitThreadNoJs(page, board, { subject, body: 'no-js original body' }); - const threadId = await waitForThreadRedirect(page, board); - const postId = await firstPostId(page); - - const editHref = await page.locator('.self-action-controls .edit-btn').first().getAttribute('href'); - expect(editHref).toBe(`/${board}/post/${postId}/edit`); - await page.goto(`${app.baseURL}${editHref}`); - const editForm = page.locator(`form[action$="${editHref}"]`); - await expect(editForm.locator('textarea[name="body"]')).toHaveValue('no-js original body'); - await editForm.locator('textarea[name="body"]').fill('no-js edited body'); - const editWatch = watchMutations(page, editHref!); - await editForm.getByRole('button', { name: /save edit/i }).click(); - await page.waitForURL(new RegExp(`/${board}/thread/${threadId}#p${postId}$`)); - expect(editWatch.requests).toHaveLength(1); - expect(editWatch.statuses).toEqual([303]); - expect(sqliteQuery(app, `SELECT body FROM posts WHERE id = ${postId};`)).toBe('no-js edited body'); - - const deleteHref = await page.locator(`#p${postId} .self-action-controls .del-btn`).getAttribute('href'); - expect(deleteHref).toBe(`/${board}/post/${postId}/delete`); - await page.goto(`${app.baseURL}${deleteHref}`); - const deleteForm = page.locator(`form[action$="${deleteHref}"]`); - await expect(deleteForm).toBeVisible(); - const deleteWatch = watchMutations(page, deleteHref!); - await deleteForm.getByRole('button', { name: /delete post/i }).click(); - await page.waitForURL(new RegExp(`/${board}/catalog$`)); - expect(deleteWatch.requests).toHaveLength(1); - expect(deleteWatch.statuses).toEqual([303]); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${postId};`)).toBe('0'); - await expectSafePage(page); - }); - - test('catalog report, pin, hide, and unhide work through the server-rendered fallback forms', async ({ page, app }, testInfo) => { - // Preconditions: public /pub board; one thread on the catalog. - const subject = marker('nojs-catalog'); - await page.goto(`${app.baseURL}/pub`); - await submitThreadNoJs(page, 'pub', { subject, body: marker('nojs-catalog-body') }); - const threadId = await waitForThreadRedirect(page, 'pub'); - - await page.goto(`${app.baseURL}/pub/catalog`); - const card = page.locator('.catalog-item').filter({ hasText: subject }); - await expect(card).toBeVisible(); - - await test.step('report through the fallback details form with Enter submission', async () => { - const report = card.locator('.report-fallback-form'); - await report.locator('summary').click(); - await expect(report.getByLabel('reason')).toBeVisible(); - await report.getByLabel('reason').fill('no-js catalog report'); - const reportWatch = watchMutations(page, '/report'); - await report.getByLabel('reason').press('Enter'); - await page.waitForURL(new RegExp(`/pub/thread/${threadId}\\?reported=1`)); - expect(reportWatch.requests).toHaveLength(1); - expect(reportWatch.statuses).toEqual([303]); - await expect(page.locator('.post-success-banner')).toContainText(/report submitted/i); - expect(sqliteQuery(app, 'SELECT COUNT(*) FROM reports;')).toBe('1'); - }); - - await test.step('pin and unpin through the fallback form', async () => { - await page.goto(`${app.baseURL}/pub/catalog`); - const fallback = card.locator('details.catalog-thread-fallback-actions'); - await fallback.getByRole('button', { name: 'Pin thread', exact: true }).click(); - await expect(card).toHaveAttribute('data-pinned', '1'); - expect(sqliteQuery(app, `SELECT pinned FROM user_thread_preferences WHERE thread_id = ${threadId};`)).toBe('1'); - await fallback.getByRole('button', { name: 'Unpin thread', exact: true }).click(); - await expect(card).toHaveAttribute('data-pinned', '0'); - expect(Number(sqliteQuery(app, `SELECT COUNT(*) FROM user_thread_preferences WHERE thread_id = ${threadId};`))).toBe(0); - }); - - await test.step('hide and unhide through the fallback form and the hidden page', async () => { - await page.goto(`${app.baseURL}/pub/catalog`); - const fallback = card.locator('details.catalog-thread-fallback-actions'); - await fallback.getByRole('button', { name: 'Hide thread', exact: true }).click(); - await expect(page).toHaveURL(/\/pub\/catalog$/); - expect(sqliteQuery(app, `SELECT hidden FROM user_thread_preferences WHERE thread_id = ${threadId};`)).toBe('1'); - await expect(card).toHaveCount(0); - - await page.goto(`${app.baseURL}/pub/hidden`); - const unhide = page.getByRole('button', { name: 'Unhide thread', exact: true }).first(); - await expect(unhide).toBeVisible(); - await unhide.click(); - await expect(page).toHaveURL(/\/pub\/catalog$/); - await expect(card).toBeVisible(); - expect(Number(sqliteQuery(app, `SELECT COUNT(*) FROM user_thread_preferences WHERE thread_id = ${threadId};`))).toBe(0); - await expectSafePage(page); - }); - }); - - test('poll creation and noscript voting render one voting form and one persisted vote', async ({ page, app }) => { - // Preconditions: public /pub board. - const optionThree = marker('nojs-poll-option-3'); - await page.goto(`${app.baseURL}/pub`); - await page.locator('.poll-creator > summary').click(); - await page.getByText('More poll options', { exact: true }).click(); - await page.locator('textarea[name="body"]').fill(marker('nojs-poll-body')); - await page.locator('input[name="poll_question"]').fill('No-JS poll question'); - await page.getByRole('textbox', { name: 'poll option 1', exact: true }).fill('No-JS option one'); - await page.getByRole('textbox', { name: 'poll option 2', exact: true }).fill('No-JS option two'); - await page.getByRole('textbox', { name: 'poll option 3', exact: true }).fill(optionThree); - const createWatch = watchMutations(page, '/pub'); - await page.getByRole('button', { name: /post thread/i }).click(); - await waitForThreadRedirect(page, 'pub'); - expect(createWatch.requests).toHaveLength(1); - expect(createWatch.statuses).toEqual([303]); - - await expect(page.locator('.poll-container')).toContainText(optionThree); - await expect(page.locator('.poll-container .poll-vote-form')).toHaveCount(1); - await expect(page.locator('.poll-vote-form button[type="submit"]')).toHaveCount(1); - await expect(page.locator('.poll-vote-option input[name="option_id"]')).toHaveCount(3); - await expect(page.locator('.poll-add-btn')).toBeHidden(); - - const pollId = Number(sqliteQuery(app, 'SELECT id FROM polls ORDER BY id DESC LIMIT 1;')); - const option = Number(sqliteQuery(app, `SELECT id FROM poll_options WHERE poll_id = ${pollId} ORDER BY id ASC LIMIT 1;`)); - const voteForm = page.locator('.poll-vote-form'); - await voteForm.locator(`input[name="option_id"][value="${option}"]`).check(); - const voteWatch = watchMutations(page, '/vote'); - await voteForm.getByRole('button', { name: /cast vote/i }).click(); - await page.waitForURL(/\/pub\/thread\/\d+#poll$/); - expect(voteWatch.requests).toHaveLength(1); - expect(voteWatch.statuses).toEqual([303]); - await expect(page.locator('.poll-container')).toContainText('1 total vote'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM poll_votes WHERE poll_id = ${pollId};`)).toBe('1'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('.poll-container')).toContainText('1 total vote'); - await expectSafePage(page); - }); - - test('preferences forms and theme links apply immediately and persist by cookie', async ({ page, app }) => { - // Preconditions: public /pub board. - await page.goto(`${app.baseURL}/pub`); - await page.locator('.user-preferences-summary').click(); - const preferences = page.locator('.user-preferences-noscript'); - - const themeWatch = watchMutations(page, '/preferences'); - await preferences.locator('button[name="theme"][value="forest"]').click(); - await page.waitForURL(/\/pub$/); - expect(themeWatch.requests).toHaveLength(1); - expect(themeWatch.statuses).toEqual([303]); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'forest'); - let cookies = await page.context().cookies(app.baseURL); - expect(cookies.find((cookie) => cookie.name === 'rustchan_theme')?.value).toBe('forest'); - - await page.locator('.user-preferences-summary').click(); - const badgesWatch = watchMutations(page, '/preferences'); - await preferences.locator('button[name="show_activity_badges"][value="0"]').click(); - await page.waitForURL(/\/pub$/); - expect(badgesWatch.requests).toHaveLength(1); - expect(badgesWatch.statuses).toEqual([303]); - cookies = await page.context().cookies(app.baseURL); - expect(cookies.find((cookie) => cookie.name === 'rustchan_activity_badges')?.value).toBe('0'); - - await page.goto(`${app.baseURL}/theme/blue-sky?return_to=/pub`); - await expect(page).toHaveURL(/\/pub$/); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'blue-sky'); - await expectSafePage(page); - }); - - test('admin login, a site-setting save, a board-setting save, and a lock all work through plain forms', async ({ page, app }, testInfo) => { - // Preconditions: seeded admin; a thread exists for the moderation action. - const board = 'pub'; - const siteName = marker('nojs-site'); - const boardName = marker('nojs-board-name'); - await page.goto(`${app.baseURL}/${board}`); - await submitThreadNoJs(page, board, { subject: marker('nojs-mod-thread'), body: marker('nojs-mod-body') }); - const threadId = await waitForThreadRedirect(page, board); - - await adminLogin(page, app); - - await test.step('site settings save through the plain form', async () => { - await page.goto(`${app.baseURL}/admin/panel#site-settings`); - const form = page.locator('form.admin-site-settings-form'); - await expect(form.locator('input[name="site_name"]')).toBeVisible(); - await form.locator('input[name="site_name"]').fill(siteName); - const watch = watchMutations(page, '/admin/site/settings'); - await form.getByRole('button', { name: /save settings/i }).click(); - await page.waitForURL(/\/admin\/panel/); - expect(watch.requests).toHaveLength(1); - expect(watch.statuses).toEqual([303]); - expect(sqliteQuery(app, `SELECT value FROM site_settings WHERE key = 'site_name';`)).toBe(siteName); - }); - - await test.step('board settings save through the plain form', async () => { - await page.goto(`${app.baseURL}/admin/panel#board-${board}`); - const details = page.locator(`details#board-${board}`); - if ((await details.getAttribute('open')) === null) { - await details.locator('summary').first().click(); - } - await expect(details).toHaveAttribute('open', ''); - const form = details.locator('form.board-settings-form'); - await form.locator('input[name="name"]').fill(boardName); - const watch = watchMutations(page, '/admin/board/settings'); - await form.getByRole('button', { name: /save settings/i }).click(); - await page.waitForURL(/\/admin\/panel/); - expect(watch.requests).toHaveLength(1); - expect(watch.statuses).toEqual([303]); - expect(sqliteQuery(app, `SELECT name FROM boards WHERE short_name = '${board}';`)).toBe(boardName); - }); - - await test.step('a moderation lock posts through the plain admin toolbar form', async () => { - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - const lockForm = page.locator('form[action="/admin/thread/action"]:has(input[name="action"][value="lock"])').first(); - await expect(lockForm).toBeVisible(); - const watch = watchMutations(page, '/admin/thread/action'); - const [lockResponse] = await Promise.all([ - page.waitForResponse((response) => ( - new URL(response.url()).pathname === '/admin/thread/action' && response.request().method() === 'POST' - )), - lockForm.getByRole('button').click(), - ]); - expect(lockResponse.status()).toBe(303); - expect(watch.requests).toHaveLength(1); - expect(watch.statuses).toEqual([303]); - expect(sqliteQuery(app, `SELECT locked FROM threads WHERE id = ${threadId};`)).toBe('1'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('body')).toContainText(/locked/i); - }); - await expectSafePage(page, { allowAdminInternals: true }); - }); - - test('an invalid form re-renders with an understandable error and preserves the other fields', async ({ page, app }, testInfo) => { - // Preconditions: public /pub board; body is required when no file is attached. - const subject = marker('nojs-preserve-subject'); - await page.goto(`${app.baseURL}/pub`); - const form = page.locator('form[action="/pub"]').first(); - await form.locator('input[name="subject"]').fill(subject); - await form.locator('textarea[name="body"]').fill(''); - - const watch = watchMutations(page, '/pub'); - expectHttpError({ - method: 'POST', - path: '/pub', - status: 422, - reason: 'an empty body re-renders the form with a validation error', - }); - await form.getByRole('button', { name: /post thread/i }).click(); - - await expect(page.locator('.post-error-banner').first()).toContainText('Post must include either text or an attached file.'); - await expect(page.locator(`form[action="/pub"] input[name="subject"]`)).toHaveValue(subject); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE subject = '${subject}';`)).toBe('0'); - - const corrected = page.locator('form[action="/pub"]').first(); - await corrected.locator('textarea[name="body"]').fill('corrected body after validation'); - await corrected.getByRole('button', { name: /post thread/i }).click(); - const threadId = await waitForThreadRedirect(page, 'pub'); - expect(watch.statuses).toEqual([422, 303]); - expect(sqliteQuery(app, `SELECT subject FROM threads WHERE id = ${threadId};`)).toBe(subject); - expect(sqliteQuery(app, `SELECT body FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`)).toBe('corrected body after validation'); - await expectSafePage(page); - }); - - test('refresh, Back, and Forward after a successful POST never duplicate rows', async ({ page, app }) => { - // Preconditions: public /pub board; a thread with exactly one reply. - const subject = marker('nojs-history'); - await page.goto(`${app.baseURL}/pub`); - await submitThreadNoJs(page, 'pub', { subject, body: marker('nojs-history-body') }); - const threadId = await waitForThreadRedirect(page, 'pub'); - const postsAfterCreate = Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`)); - - const replyForm = page.locator(`form[action="/pub/thread/${threadId}"]`).first(); - await replyForm.locator('textarea[name="body"]').fill(marker('nojs-history-reply')); - await replyForm.getByRole('button', { name: /post reply/i }).click(); - await waitForReplyRedirect(page, 'pub', threadId); - const postsAfterReply = Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`)); - expect(postsAfterReply).toBe(postsAfterCreate + 1); - - await page.reload({ waitUntil: 'domcontentloaded' }); - expect(Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`))).toBe(postsAfterReply); - - await page.getByRole('link', { name: '[ Return ]', exact: true }).first().click(); - await expect(page).toHaveURL(/\/pub$/); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(new RegExp(`/pub/thread/${threadId}`)); - await expect(page.locator('.post')).toHaveCount(postsAfterReply); - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(/\/pub$/); - expect(Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`))).toBe(postsAfterReply); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE subject = '${subject}';`)).toBe('1'); - await expectSafePage(page); - }); - - test('thread-creation parity: JS and no-JS paths persist the same row shape with exactly one request each', async ({ page, browser, app }, testInfo) => { - // Preconditions: public /pub board, independent subjects for each path. The - // JS path must explicitly enable JavaScript because the project default is off. - const noJsSubject = marker('parity-thread-nojs'); - const jsSubject = marker('parity-thread-js'); - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator('form[action="/pub"]')).toHaveCount(1); - const noJsWatch = watchMutations(page, '/pub'); - await submitThreadNoJs(page, 'pub', { subject: noJsSubject, body: 'parity thread no-js body' }); - const noJsThreadId = await waitForThreadRedirect(page, 'pub'); - expect(noJsWatch.requests).toHaveLength(1); - expect(noJsWatch.statuses).toEqual([303]); - - const jsContext = await newAuditedContext(browser, { javaScriptEnabled: true }); - const jsPage = await jsContext.newPage(); - let jsThreadId = 0; - try { - await jsPage.goto(`${app.baseURL}/pub`); - await expect(jsPage.locator('html')).not.toHaveClass(/no-js/); - await expect(jsPage.locator('form[action="/pub"]')).toHaveCount(1); - const jsWatch = watchMutations(jsPage, '/pub'); - await createThread(jsPage, app, 'pub', { subject: jsSubject, body: 'parity thread js body' }); - jsThreadId = threadIdFromUrl(jsPage.url()); - await expect.poll(() => jsWatch.statuses.length).toBe(1); - expect(jsWatch.requests).toHaveLength(1); - expect(jsWatch.statuses).toEqual([303]); - expect(jsThreadId).not.toBe(noJsThreadId); - } finally { - await jsContext.close(); - } - - await test.step('both paths produce identical persisted rows and redirect contracts', async () => { - for (const [subject, body, threadId] of [ - [noJsSubject, 'parity thread no-js body', noJsThreadId], - [jsSubject, 'parity thread js body', jsThreadId], - ] as const) { - expect(sqliteQuery(app, `SELECT subject FROM threads WHERE subject = '${subject}';`)).toBe(subject); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body = '${body}' AND is_op = 1;`)).toBe('1'); - expect(sqliteQuery(app, `SELECT board_id FROM threads WHERE id = ${threadId};`)).toBe( - sqliteQuery(app, `SELECT id FROM boards WHERE short_name = 'pub';`), - ); - } - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE subject IN ('${noJsSubject}', '${jsSubject}');`)).toBe('2'); - }); - }); - - test('reply parity: JS and no-JS replies create the same reply rows with one request each', async ({ page, browser, app }, testInfo) => { - // Preconditions: two independent threads on /pub. - const noJsReply = marker('parity-reply-nojs'); - const jsReply = marker('parity-reply-js'); - await page.goto(`${app.baseURL}/pub`); - await submitThreadNoJs(page, 'pub', { subject: marker('parity-reply-thread-nojs'), body: marker('parity-op-nojs') }); - const noJsThreadId = await waitForThreadRedirect(page, 'pub'); - const noJsForm = page.locator(`form[action="/pub/thread/${noJsThreadId}"]`).first(); - await noJsForm.locator('textarea[name="body"]').fill(noJsReply); - const noJsWatch = watchMutations(page, `/pub/thread/${noJsThreadId}`); - await noJsForm.getByRole('button', { name: /post reply/i }).click(); - await waitForReplyRedirect(page, 'pub', noJsThreadId); - expect(noJsWatch.requests).toHaveLength(1); - expect(noJsWatch.statuses).toEqual([303]); - - const jsContext = await newAuditedContext(browser, { javaScriptEnabled: true }); - const jsPage = await jsContext.newPage(); - let jsThreadId = 0; - try { - jsThreadId = await createThread(jsPage, app, 'pub', { subject: marker('parity-reply-thread-js'), body: marker('parity-op-js') }); - const replyToggle = jsPage.locator('[data-action="toggle-post-form"]').first(); - if (await replyToggle.isVisible()) { - await replyToggle.click(); - } - const jsForm = jsPage.locator(`form[action="/pub/thread/${jsThreadId}"]`).first(); - await expect(jsForm.locator('textarea[name="body"]')).toBeVisible(); - await jsForm.locator('textarea[name="body"]').fill(jsReply); - const jsWatch = watchMutations(jsPage, `/pub/thread/${jsThreadId}`); - await Promise.all([ - jsPage.waitForURL(new RegExp(`/pub/thread/${jsThreadId}`)), - jsForm.getByRole('button', { name: /post reply/i }).click(), - ]); - await expect.poll(() => jsWatch.statuses.length).toBe(1); - expect(jsWatch.requests).toHaveLength(1); - expect(jsWatch.statuses).toEqual([303]); - } finally { - await jsContext.close(); - } - - await test.step('both replies exist with the same shape and distinct ids', async () => { - const noJsReplyId = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${noJsThreadId} AND is_op = 0;`)); - const jsReplyId = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${jsThreadId} AND is_op = 0;`)); - expect(noJsReplyId).toBeGreaterThan(0); - expect(jsReplyId).toBeGreaterThan(0); - expect(noJsReplyId).not.toBe(jsReplyId); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${noJsThreadId};`)).toBe('2'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${jsThreadId};`)).toBe('2'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body = '${noJsReply}';`)).toBe('1'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body = '${jsReply}';`)).toBe('1'); - expect(sqliteQuery(app, `SELECT reply_count FROM threads WHERE id = ${noJsThreadId};`)).toBe('1'); - expect(sqliteQuery(app, `SELECT reply_count FROM threads WHERE id = ${jsThreadId};`)).toBe('1'); - }); - }); - - test('catalog pin parity: the no-JS fallback and the JS menu write the same preference with one request each', async ({ page, browser, app }, testInfo) => { - // Preconditions: two independent threads on /pub/catalog. - const noJsSubject = marker('parity-pin-nojs'); - const jsSubject = marker('parity-pin-js'); - await page.goto(`${app.baseURL}/pub`); - await submitThreadNoJs(page, 'pub', { subject: noJsSubject, body: marker('parity-pin-body-nojs') }); - const noJsThreadId = await waitForThreadRedirect(page, 'pub'); - - await page.goto(`${app.baseURL}/pub/catalog`); - const noJsCard = page.locator('.catalog-item').filter({ hasText: noJsSubject }); - const noJsWatch = watchMutations(page, '/pub/thread-preference'); - await noJsCard.locator('details.catalog-thread-fallback-actions').getByRole('button', { name: 'Pin thread', exact: true }).click(); - await expect(noJsCard).toHaveAttribute('data-pinned', '1'); - expect(noJsWatch.requests).toHaveLength(1); - expect(noJsWatch.statuses).toEqual([303]); - - const jsContext = await newAuditedContext(browser, { javaScriptEnabled: true }); - const jsPage = await jsContext.newPage(); - let jsThreadId = 0; - try { - jsThreadId = await createThread(jsPage, app, 'pub', { subject: jsSubject, body: marker('parity-pin-body-js') }); - await jsPage.goto(`${app.baseURL}/pub/catalog`); - const jsCard = jsPage.locator('.catalog-item').filter({ hasText: jsSubject }); - await jsCard.locator('.catalog-thread-menu-toggle').click(); - const jsWatch = watchMutations(jsPage, '/pub/thread-preference'); - await jsCard.locator('.catalog-thread-menu:not([hidden])').getByRole('button', { name: 'Pin thread', exact: true }).click(); - await expect(jsCard).toHaveAttribute('data-pinned', '1'); - await expect.poll(() => jsWatch.statuses.length).toBe(1); - expect(jsWatch.requests).toHaveLength(1); - expect(jsWatch.statuses).toEqual([303]); - } finally { - await jsContext.close(); - } - - expect(sqliteQuery(app, `SELECT pinned FROM user_thread_preferences WHERE thread_id = ${noJsThreadId};`)).toBe('1'); - expect(sqliteQuery(app, `SELECT pinned FROM user_thread_preferences WHERE thread_id = ${jsThreadId};`)).toBe('1'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM user_thread_preferences WHERE thread_id IN (${noJsThreadId}, ${jsThreadId});`)).toBe('2'); - }); - - test('the no-JS unlock flow rejects a wrong password and then grants access', async ({ page, app }, testInfo) => { - // Preconditions: view_password board whose password hash matches ADMIN_PASSWORD. - const board = uniqueShort('njsunlock', testInfo); - const bodyMarker = marker('nojs-unlock-body'); - app.createBoardCli({ short: board, name: 'NoJS Unlock' }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - await page.goto(`${app.baseURL}/${board}`); - await submitThreadNoJs(page, board, { subject: marker('nojs-unlock-subject'), body: bodyMarker }); - const threadId = await waitForThreadRedirect(page, board); - setBoardFixtureSettings(app, board, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(app), - }); - - await page.context().clearCookies(); - expectHttpError({ - method: 'GET', - path: `/${board}`, - status: 403, - reason: 'a view-password board renders only the gate without the unlock cookie', - }); - await page.goto(`${app.baseURL}/${board}`); - await expect(page.locator('body')).toContainText('password protected board'); - await expect(page.locator('body')).not.toContainText(bodyMarker); - const unlockForm = page.locator(`form[action="/${board}/unlock"]`).first(); - await unlockForm.locator('input[name="password"]').fill('definitely-wrong'); - expectHttpError({ - method: 'POST', - path: `/${board}/unlock`, - status: 403, - reason: 'a wrong board password re-renders the gate with a 403', - }); - await unlockForm.getByRole('button', { name: /unlock board/i }).click(); - await expect(page.locator('body')).toContainText('Incorrect board password.'); - await expect(page.locator('body')).not.toContainText(bodyMarker); - - const retry = page.locator(`form[action="/${board}/unlock"]`).first(); - await retry.locator('input[name="password"]').fill(ADMIN_PASSWORD); - await retry.getByRole('button', { name: /unlock board/i }).click(); - await page.waitForURL(new RegExp(`/${board}$`)); - await expect(page.locator('body')).toContainText(bodyMarker); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expect(page.locator('body')).toContainText(bodyMarker); - await expectSafePage(page); - }); - - test('the no-JS captcha path renders a challenge, refreshes it, and reports the server-rendered failure', async ({ page, app }, testInfo) => { - // Preconditions: captcha-enabled board. - const board = uniqueShort('njscap', testInfo); - app.createBoardCli({ short: board, name: 'NoJS Captcha' }); - setBoardFixtureSettings(app, board, { allowCaptcha: true, postCooldownSecs: 0 }); - - await page.goto(`${app.baseURL}/${board}`); - const form = page.locator(`form[action="/${board}"]`).first(); - const firstCaptchaId = await form.locator('input[name="captcha_id"]').getAttribute('value'); - expect(firstCaptchaId).toMatch(/^[a-f0-9]{32}$/); - await expect(form.locator('.captcha-image')).toBeVisible(); - const imageSrc = await form.locator('.captcha-image').getAttribute('src'); - expect(imageSrc).toMatch(new RegExp(`^/captcha/[a-f0-9]{32}\\?board=${board}`)); - const image = await page.request.get(`${app.baseURL}${imageSrc}`); - expect(image.status()).toBe(200); - expect(image.headers()['content-type']).toContain('image/png'); - - await form.locator('.captcha-refresh-link').click(); - await page.waitForURL(new RegExp(`/${board}\\?captcha_refresh=[a-f0-9]{32}$`)); - const refreshedForm = page.locator(`form[action="/${board}"]`).first(); - const refreshedCaptchaId = await refreshedForm.locator('input[name="captcha_id"]').getAttribute('value'); - expect(refreshedCaptchaId).toMatch(/^[a-f0-9]{32}$/); - expect(refreshedCaptchaId).not.toBe(firstCaptchaId); - - await refreshedForm.locator('input[name="subject"]').fill(marker('nojs-captcha')); - await refreshedForm.locator('textarea[name="body"]').fill('no-js captcha body'); - await refreshedForm.locator('input[name="captcha_answer"]').fill('WRONG'); - expectHttpError({ - method: 'POST', - path: `/${board}`, - status: 422, - reason: 'a wrong captcha answer re-renders the form with an inline error', - }); - await refreshedForm.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/CAPTCHA verification failed/i); - const errorCaptchaId = await page.locator(`form[action="/${board}"] input[name="captcha_id"]`).first().getAttribute('value'); - expect(errorCaptchaId).toMatch(/^[a-f0-9]{32}$/); - expect(errorCaptchaId).not.toBe(refreshedCaptchaId); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE subject = 'nojs-captcha';`)).toBe('0'); - await expectSafePage(page); - }); - - test('the no-JS file-upload path stores and serves a file, and a disallowed type shows a readable error', async ({ page, app }, testInfo) => { - // Preconditions: img board accepts images only; PDFs are disabled there. - const subject = marker('nojs-upload'); - await page.goto(`${app.baseURL}/img`); - const form = page.locator('form[action="/img"]').first(); - await form.locator('input[name="subject"]').fill(subject); - await form.locator('textarea[name="body"]').fill(marker('nojs-upload-body')); - await form.locator('input[type="file"]').first().setInputFiles(app.fixtures().tinyPng); - await form.getByRole('button', { name: /post thread/i }).click(); - const threadId = await waitForThreadRedirect(page, 'img'); - await expect(page.locator('.post.op .file-info')).toContainText('tiny.png'); - const mediaHref = await page.locator('.post.op .file-info a').first().getAttribute('href'); - expect(mediaHref).toMatch(/^\/boards\/img\//); - const media = await page.request.get(`${app.baseURL}${mediaHref}`); - expect(media.status()).toBe(200); - // Production may retain PNG or publish its smaller lossless WebP output. - // Check the persisted format and actual bytes, rather than the upload name. - const storedMime = sqliteQuery(app, `SELECT mime_type FROM posts WHERE thread_id = ${threadId} AND is_op = 1;`); - expect(media.headers()['content-type']).toBe(storedMime); - const payload = await media.body(); - if (storedMime === 'image/webp') { - expect(payload.subarray(0, 4).toString('ascii')).toBe('RIFF'); - expect(payload.subarray(8, 12).toString('ascii')).toBe('WEBP'); - } else { - expect(storedMime).toBe('image/png'); - expect(payload.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); - } - expect(media.headers()['x-content-type-options']).toBe('nosniff'); - - await page.goto(`${app.baseURL}/img`); - const rejectBody = marker('nojs-upload-reject-body'); - const rejectForm = page.locator('form[action="/img"]').first(); - await rejectForm.locator('input[name="subject"]').fill(marker('nojs-upload-reject')); - await rejectForm.locator('textarea[name="body"]').fill(rejectBody); - await rejectForm.locator('input[type="file"]').first().setInputFiles(app.fixtures().tinyPdf); - expectHttpError({ - method: 'POST', - path: '/img', - status: 422, - reason: 'a PDF on an image-only board re-renders with an inline error', - }); - await rejectForm.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/pdf uploads are disabled|only accepts|not allowed/i); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body = '${rejectBody}';`)).toBe('0'); - await expectSafePage(page); - }); -}); diff --git a/tests/e2e/audit-performance.spec.ts b/tests/e2e/audit-performance.spec.ts deleted file mode 100644 index f1aad73f..00000000 --- a/tests/e2e/audit-performance.spec.ts +++ /dev/null @@ -1,783 +0,0 @@ -/** - * RustChan bounded performance diagnostics — NOT a load test. - * - * ## Measurement contract - * These tests record evidence; they do not assert latency thresholds. Every - * scenario takes >= 5 samples and labels sample 1 `cold` and samples 2..N - * `warm`. In this file "cold" means "the first navigation in a browser context - * whose page cache has not been used for that page": scenario setup that must - * not warm the cache is done through the request API (no page navigation). - * Where setup does touch the browser (the upload flow that precedes the media - * samples, or an admin login performed through the UI) the attachment records - * that caveat explicitly. Server-side caches are always warm from test setup, - * so no sample is a cold-server measurement. - * - * ## Environment per run - * - Project/browser: recorded from `testInfo.project.name` and `browser.version()`. - * The bounded samples run on the desktop Chromium project only; the - * capability probe runs on every JS-enabled project so other engines are - * described by observation rather than assumption. - * - Runtime: isolated local RustChan fixture on a loopback ephemeral port - * (`./helpers`), one instance per test, removed at teardown. - * - Instrumentation overhead: `test.use({ trace: 'off', video: 'off' })` - * because trace/video capture distorts navigation timing. Screenshots remain - * `only-on-failure` from the shared config. Playwright network-event - * observation and in-page performance entry buffers add overhead to every - * sample; absolute numbers include that overhead and are not release - * certification numbers. - * - * ## Engine support (detected, not assumed) - * - Navigation Timing and Resource Timing are read only when - * `performance.getEntriesByType` exposes the entry type; otherwise the metric - * is recorded as `null` with a reason and never asserted. - * - `PerformanceObserver.supportedEntryTypes` is probed per engine; entry types - * such as `largest-contentful-paint`, `layout-shift`, `longtask`, and `paint` - * are engine-dependent and are recorded, never asserted. - * - `request.sizes()` is a Playwright API; engines that report -1 sizes are - * recorded as `null`, never as zero. - * - The no-JS project can expose no JS performance API and is excluded from the - * probe with an explicit reason. - * - * ## What IS asserted - * - Real workflow outcomes (pages rendered, thread count increased, image - * decoded: `naturalWidth > 0`), so timings describe a genuine workflow. - * - No duplicate requests for the same resource within one navigation - * (intentional repeating endpoints are excluded and labelled). - * - The RustChan process is alive before/after server observation samples. - * - * ## Server CPU/RSS limitation - * `ps -o %cpu,rss` samples describe one bounded window. RSS can rise without a - * leak and fall without a fix; a single increase is NOT evidence of a memory - * leak and no leak assertion is made. `ps` `%cpu` is an interval average, not - * an instantaneous value. - */ -import { spawnSync } from 'node:child_process'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - createThread, - createThreadViaRequest, - expect, - extractCsrf, - setBoardFixtureSettings, - sqliteQuery, - test, - threadIdFromUrl, - uniqueShort, -} from './helpers'; -import { phase4SkipUnless } from './phase4-helpers'; -import type { Page, Request, Response, TestInfo } from './diagnostics'; -import * as diagnosticsApi from './diagnostics'; - -// Trace and video capture distort navigation timing; measurement runs without -// them. Failure screenshots remain enabled through the shared config. -test.use({ trace: 'off', video: 'off' }); - -type StrictDiagnosticsApi = { - measureStep?: (name: string, fn: () => Promise) => Promise; - actor?: (role: 'visitor' | 'admin') => void; -}; -const strictApi = diagnosticsApi as unknown as StrictDiagnosticsApi; - -function actor(role: 'visitor' | 'admin'): void { - strictApi.actor?.(role); -} - -async function measured(name: string, fn: () => Promise): Promise { - if (strictApi.measureStep) { - return strictApi.measureStep(name, fn); - } - return fn(); -} - -const SAMPLES = 5; - -/** - * Endpoints expected to repeat while a page is open (polling, user-triggered - * updates). They are excluded from the duplicate-request check because - * repetition is the documented behaviour, not an accidental fetch. - */ -const REPEATING_ENDPOINT_PATTERNS = [ - /\/updates\?/, - /\/admin\/log\/live/, - /\/admin\/backup\/progress/, - /\/admin\/db\/repair\/progress/, -]; - -/** Logs in without navigating a page, so measured samples can start cold. */ -async function loginViaRequest(page: Page, baseURL: string): Promise { - const loginPage = await page.request.get(`${baseURL}/admin`); - const csrf = extractCsrf(await loginPage.text()); - const response = await page.request.post(`${baseURL}/admin/login`, { - form: { - _csrf: csrf, - username: ADMIN_USERNAME, - password: ADMIN_PASSWORD, - }, - maxRedirects: 0, - }); - expect([302, 303], 'request-API admin login should succeed').toContain(response.status()); -} - -type RequestRecord = { method: string; url: string }; - -type NavigationTimingSnapshot = { - type: string; - durationMs: number; - responseStartMs: number; - responseEndMs: number; - domContentLoadedMs: number; - loadEventEndMs: number; - transferSize: number; - encodedBodySize: number; - decodedBodySize: number; - nextHopProtocol: string; -}; - -type NavigationSample = { - label: string; - url: string; - durationMs: number; - navigationTiming: NavigationTimingSnapshot | null; - navigationTimingNote: string | null; - requestCount: number; - duplicateRequests: string[]; -}; - -type Summary = { - sampleCount: number; - minMs: number; - p50Ms: number; - p90Ms: number; - maxMs: number; -}; - -function summarize(values: number[]): Summary { - if (values.length === 0) { - return { sampleCount: 0, minMs: 0, p50Ms: 0, p90Ms: 0, maxMs: 0 }; - } - const sorted = [...values].sort((left, right) => left - right); - const pick = (fraction: number): number => sorted[ - Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1)) - ]; - return { - sampleCount: sorted.length, - minMs: Math.round(sorted[0] * 10) / 10, - p50Ms: Math.round(pick(0.5) * 10) / 10, - p90Ms: Math.round(pick(0.9) * 10) / 10, - maxMs: Math.round(sorted[sorted.length - 1] * 10) / 10, - }; -} - -function beginRequestRecording(page: Page): () => { requests: RequestRecord[]; duplicates: string[] } { - const requests: RequestRecord[] = []; - const listener = (request: Request) => { - requests.push({ method: request.method(), url: request.url() }); - }; - page.on('request', listener); - return () => { - page.off('request', listener); - const counts = new Map(); - for (const request of requests) { - const key = `${request.method} ${request.url}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - const duplicates = [...counts.entries()] - .filter(([key, count]) => count > 1 && !REPEATING_ENDPOINT_PATTERNS.some((pattern) => pattern.test(key))) - .map(([key, count]) => `${count}x ${key}`); - return { requests, duplicates }; - }; -} - -async function readNavigationTiming(page: Page): Promise<{ timing: NavigationTimingSnapshot | null; note: string | null }> { - return page.evaluate(() => { - if (typeof performance.getEntriesByType !== 'function') { - return { timing: null, note: 'performance.getEntriesByType is not exposed' }; - } - const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]; - const entry = entries[0]; - if (!entry) { - return { timing: null, note: 'Navigation Timing entry is not exposed by this engine' }; - } - return { - timing: { - type: entry.type, - durationMs: entry.duration, - responseStartMs: entry.responseStart, - responseEndMs: entry.responseEnd, - domContentLoadedMs: entry.domContentLoadedEventEnd, - loadEventEndMs: entry.loadEventEnd, - transferSize: entry.transferSize, - encodedBodySize: entry.encodedBodySize, - decodedBodySize: entry.decodedBodySize, - nextHopProtocol: entry.nextHopProtocol, - }, - note: null, - }; - }); -} - -/** Navigates once, recording wall-clock time, per-navigation requests, and Navigation Timing. */ -async function measureNavigation(page: Page, url: string, label: string): Promise { - const finishRequests = beginRequestRecording(page); - const started = performance.now(); - await page.goto(url, { waitUntil: 'load' }); - const durationMs = performance.now() - started; - const { requests, duplicates } = finishRequests(); - const { timing, note } = await readNavigationTiming(page); - return { - label, - url, - durationMs: Math.round(durationMs * 10) / 10, - navigationTiming: timing, - navigationTimingNote: note, - requestCount: requests.length, - duplicateRequests: duplicates, - }; -} - -async function attachEvidence(testInfo: TestInfo, name: string, payload: unknown): Promise { - await testInfo.attach(name, { - body: JSON.stringify(payload, null, 2), - contentType: 'application/json', - }); -} - -// --------------------------------------------------------------------------- -// Engine capability probe (all JS-enabled projects; no latency assertions) -// --------------------------------------------------------------------------- -test.describe('performance instrumentation capability probe', () => { - test.beforeEach(({}, testInfo) => { - test.skip( - testInfo.project.name === 'firefox-nojs', - 'the no-JS project exposes no JavaScript performance APIs to probe', - ); - }); - - test('the engine exposes the performance APIs this audit can read', async ({ page, app, browser }) => { - await page.goto(app.baseURL, { waitUntil: 'load' }); - const capabilities = await page.evaluate(() => { - const observer = typeof PerformanceObserver !== 'undefined' ? PerformanceObserver : null; - const supported = observer && Array.isArray(observer.supportedEntryTypes) - ? [...observer.supportedEntryTypes] - : []; - return { - navigationTiming: typeof performance.getEntriesByType === 'function' - && performance.getEntriesByType('navigation').length > 0, - resourceTiming: typeof performance.getEntriesByType === 'function' - && Array.isArray(performance.getEntriesByType('resource')), - supportedEntryTypes: supported, - largestContentfulPaint: supported.includes('largest-contentful-paint'), - layoutShift: supported.includes('layout-shift'), - longtask: supported.includes('longtask'), - paint: supported.includes('paint'), - userAgent: navigator.userAgent, - }; - }); - const payload = { - project: test.info().project.name, - browserVersion: browser.version(), - note: 'Metric support is recorded per engine; absent entry types are skipped, never asserted.', - capabilities, - }; - console.log(`[audit-performance] capability probe: ${JSON.stringify(payload)}`); - await attachEvidence(test.info(), 'audit-performance-capabilities.json', payload); - expect( - capabilities.navigationTiming, - 'Navigation Timing is required for every measured sample in this file', - ).toBe(true); - }); -}); - -test.describe('bounded performance diagnostics — desktop chromium', () => { - test.beforeEach(({}, testInfo) => { - phase4SkipUnless( - testInfo, - ['chromium'], - 'bounded performance samples run on desktop Chromium; the capability probe records other engines', - ); - }); - - test('home → board → thread navigation stays within the request budget', async ({ page, app, browser }, testInfo) => { - actor('visitor'); - const board = uniqueShort('perf', testInfo); - app.createBoardCli({ short: board, name: 'Performance Board', description: 'performance audit' }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - // Request-API setup keeps the browser context cold for sample 1. - const threadId = await createThreadViaRequest(page, app, board, { - subject: 'performance navigation thread', - body: 'performance navigation body', - }); - - const samples: NavigationSample[] = []; - for (let sample = 1; sample <= SAMPLES; sample += 1) { - const state = sample === 1 ? 'cold' : 'warm'; - await measured(`home → board → thread sample ${sample} (${state})`, async () => { - samples.push(await measureNavigation(page, app.baseURL, `home sample ${sample} (${state})`)); - samples.push(await measureNavigation(page, `${app.baseURL}/${board}`, `board sample ${sample} (${state})`)); - samples.push(await measureNavigation(page, `${app.baseURL}/${board}/thread/${threadId}`, `thread sample ${sample} (${state})`)); - }); - } - - // Real workflow outcomes, asserted alongside the timings. - await expect(page.locator('#thread-posts').first()).toBeVisible(); - await expect(page.locator('.post').first()).toBeVisible(); - for (const sample of samples) { - expect(sample.duplicateRequests, `${sample.label} should not fetch the same resource twice`).toEqual([]); - expect(sample.requestCount, `${sample.label} should issue at least one request`).toBeGreaterThan(0); - } - const byLabel = new Map(); - for (const sample of samples) { - const key = sample.label.replace(/ sample \d+ \(.*\)$/, ''); - byLabel.set(key, [...(byLabel.get(key) ?? []), sample.durationMs]); - } - const summaries = Object.fromEntries([...byLabel.entries()].map(([key, values]) => [key, summarize(values)])); - await attachEvidence(testInfo, 'audit-performance-navigation.json', { - scenario: 'home → board → thread', - project: testInfo.project.name, - browserVersion: browser.version(), - samples, - summaries, - requestBudget: 'no duplicate resource requests within one navigation (repeating endpoints excluded)', - coldWarm: 'sample 1 cold browser cache (request-API setup, no prior page navigation); samples 2-5 warm browser cache; server caches warm from setup', - note: 'percentiles are indicative; no latency threshold is asserted', - }); - }); - - test('thread creation submit reaches a visible post', async ({ page, app, browser }, testInfo) => { - actor('visitor'); - const board = uniqueShort('perf', testInfo); - app.createBoardCli({ short: board, name: 'Performance Posting', description: 'posting performance audit' }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - - type CreationSample = { - sample: number; - state: string; - boardNavigation: NavigationSample; - submitToUrlMs: number; - totalMs: number; - threadId: number; - }; - const samples: CreationSample[] = []; - for (let sample = 1; sample <= SAMPLES; sample += 1) { - const state = sample === 1 ? 'cold' : 'warm'; - const record = await measured(`thread creation sample ${sample} (${state})`, async () => { - const boardNavigation = await measureNavigation(page, `${app.baseURL}/${board}`, `board before submit ${sample}`); - const threadsBefore = Number(sqliteQuery(app, 'SELECT COUNT(*) FROM threads;')); - const toggle = page.locator('[data-action="toggle-post-form"]').first(); - await toggle.click(); - const form = page.locator(`form[action="/${board}"]`).first(); - await expect(form).toBeVisible(); - const subject = `perf thread ${sample} ${Date.now()}`; - await form.locator('input[name="subject"]').fill(subject); - await form.locator('textarea[name="body"]').fill(`perf body ${sample}`); - const submit = form.getByRole('button', { name: /post thread/i }); - await submit.scrollIntoViewIfNeeded(); - const submitStarted = performance.now(); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/\\d+`), { waitUntil: 'domcontentloaded' }), - submit.click(), - ]); - const submitToUrlMs = performance.now() - submitStarted; - // Outcome assertions inside the measurement: only a real post counts. - await expect(page.locator('.post').first()).toBeVisible(); - await expect(page.locator('body')).toContainText(subject); - const threadsAfter = Number(sqliteQuery(app, 'SELECT COUNT(*) FROM threads;')); - expect(threadsAfter, 'thread count should increase after submit').toBe(threadsBefore + 1); - const threadId = threadIdFromUrl(page.url()); - return { sample, state, boardNavigation, submitToUrlMs, totalMs: 0, threadId }; - }); - samples.push({ - ...record, - submitToUrlMs: Math.round(record.submitToUrlMs * 10) / 10, - totalMs: Math.round((record.boardNavigation.durationMs + record.submitToUrlMs) * 10) / 10, - }); - } - for (const sample of samples) { - expect(sample.threadId).toBeGreaterThan(0); - expect(sample.boardNavigation.duplicateRequests).toEqual([]); - } - await attachEvidence(testInfo, 'audit-performance-thread-creation.json', { - scenario: 'board load → fill → submit → visible post', - project: testInfo.project.name, - browserVersion: browser.version(), - samples, - summaries: { - submitToVisiblePostMs: summarize(samples.map((sample) => sample.submitToUrlMs)), - boardLoadMs: summarize(samples.map((sample) => sample.boardNavigation.durationMs)), - }, - note: 'submit includes redirect + server render + post content committed; assertions cover the outcome, not the duration', - }); - }); - - test('a thread page with a real uploaded image decodes the image', async ({ page, app, browser }, testInfo) => { - actor('visitor'); - const board = uniqueShort('perf', testInfo); - app.createBoardCli({ short: board, name: 'Performance Media', description: 'media performance audit' }); - setBoardFixtureSettings(app, board, { allowImages: true, postCooldownSecs: 0 }); - // The upload happens through the real UI; it warms the context for the - // thread page before the measured loop, which the attachment records. - const threadId = await createThread(page, app, board, { - subject: 'performance media thread', - body: 'performance media body', - filePath: app.fixtures().tinyPng, - }); - const threadUrl = `${app.baseURL}/${board}/thread/${threadId}`; - - type BoardAssetRecord = { url: string; contentLength: number | null; responseBodyBytes: number | null }; - type MediaSample = { - sample: number; - state: string; - navigation: NavigationSample; - naturalWidth: number; - naturalHeight: number; - imageComplete: boolean; - imageResourceTiming: { name: string; durationMs: number; transferSize: number } | null; - imageResourceTimingNote: string | null; - boardAssets: BoardAssetRecord[]; - }; - const samples: MediaSample[] = []; - for (let sample = 1; sample <= SAMPLES; sample += 1) { - const state = sample === 1 ? 'cold' : 'warm'; - const record = await measured(`media thread page sample ${sample} (${state})`, async () => { - const boardAssets: BoardAssetRecord[] = []; - const pending: Array> = []; - const listener = (response: Response) => { - if (!response.url().includes('/boards/')) return; - const contentLengthRaw = response.headers()['content-length']; - const asset: BoardAssetRecord = { - url: response.url(), - contentLength: contentLengthRaw === undefined ? null : Number(contentLengthRaw), - responseBodyBytes: null, - }; - boardAssets.push(asset); - pending.push((async () => { - try { - const sizes = await response.request().sizes(); - if (sizes.responseBodySize >= 0) asset.responseBodyBytes = sizes.responseBodySize; - } catch (error) { - // Engine may not expose sizes for this response; recorded as null. - } - })()); - }; - page.on('response', listener); - const navigation = await measureNavigation(page, threadUrl, `media thread sample ${sample} (${state})`); - await expect(page.locator('.file-container').first()).toBeVisible(); - const thumb = page.locator('.file-container img.thumb').first(); - await thumb.scrollIntoViewIfNeeded(); - await expect(thumb).toBeVisible(); - await expect - .poll( - () => thumb.evaluate((element) => (element as HTMLImageElement).naturalWidth), - { message: 'uploaded image should decode (naturalWidth > 0)' }, - ) - .toBeGreaterThan(0); - const decoded = await thumb.evaluate((element) => { - const image = element as HTMLImageElement; - return { - naturalWidth: image.naturalWidth, - naturalHeight: image.naturalHeight, - complete: image.complete, - }; - }); - const resourceTiming = await page.evaluate((needle) => { - if (typeof performance.getEntriesByType !== 'function') { - return { entry: null, note: 'Resource Timing is not exposed by this engine' }; - } - const entries = performance.getEntriesByType('resource').filter((entry) => entry.name.includes(needle)); - const entry = entries[entries.length - 1] as PerformanceResourceTiming | undefined; - if (!entry) { - return { entry: null, note: 'no resource timing entry recorded for the image' }; - } - return { - entry: { name: entry.name, durationMs: entry.duration, transferSize: entry.transferSize }, - note: null, - }; - }, '/boards/'); - page.off('response', listener); - await Promise.allSettled(pending); - return { - sample, - state, - navigation, - naturalWidth: decoded.naturalWidth, - naturalHeight: decoded.naturalHeight, - imageComplete: decoded.complete, - imageResourceTiming: resourceTiming.entry, - imageResourceTimingNote: resourceTiming.note, - boardAssets, - }; - }); - samples.push(record); - } - for (const sample of samples) { - expect(sample.naturalWidth, 'uploaded image should decode in every sample').toBeGreaterThan(0); - expect(sample.navigation.duplicateRequests).toEqual([]); - } - await attachEvidence(testInfo, 'audit-performance-media.json', { - scenario: 'thread page with a real uploaded PNG (thumbnail decode)', - project: testInfo.project.name, - browserVersion: browser.version(), - samples, - summaries: { - navigationMs: summarize(samples.map((sample) => sample.navigation.durationMs)), - }, - coldWarm: 'the upload flow already visited this context before sample 1, so "cold" here labels the first measured sample; sample 2+ are warm', - note: 'image decode is asserted via naturalWidth > 0; resource timing is recorded when the engine exposes it', - }); - }); - - test('admin panel pages load for a logged-in operator', async ({ page, app, browser }, testInfo) => { - actor('admin'); - await loginViaRequest(page, app.baseURL); - const samples: NavigationSample[] = []; - for (let sample = 1; sample <= SAMPLES; sample += 1) { - const state = sample === 1 ? 'cold' : 'warm'; - samples.push(await measured(`admin panel sample ${sample} (${state})`, async () => measureNavigation( - page, - `${app.baseURL}/admin/panel`, - `admin panel sample ${sample} (${state})`, - ))); - await expect(page.getByRole('heading', { name: /admin panel/i })).toBeVisible(); - } - for (const sample of samples) { - expect(sample.duplicateRequests, `${sample.label} should not fetch the same resource twice`).toEqual([]); - } - - const modLogSamples: NavigationSample[] = []; - for (let sample = 1; sample <= SAMPLES; sample += 1) { - const state = sample === 1 ? 'cold' : 'warm'; - modLogSamples.push(await measured(`mod log sample ${sample} (${state})`, async () => measureNavigation( - page, - `${app.baseURL}/admin/mod-log`, - `mod log sample ${sample} (${state})`, - ))); - await expect(page.getByRole('heading', { name: /moderation log/i })).toBeVisible(); - } - for (const sample of modLogSamples) { - expect(sample.duplicateRequests, `${sample.label} should not fetch the same resource twice`).toEqual([]); - } - - await attachEvidence(testInfo, 'audit-performance-admin.json', { - scenario: 'admin panel and moderation log navigation (request-API login, logged-in admin)', - project: testInfo.project.name, - browserVersion: browser.version(), - samples, - modLogSamples, - summaries: { - adminPanelMs: summarize(samples.map((sample) => sample.durationMs)), - modLogMs: summarize(modLogSamples.map((sample) => sample.durationMs)), - }, - coldWarm: 'login used the request API (no page navigation), so sample 1 starts with an untouched page cache; samples 2-5 are warm', - }); - }); - - test('one navigation does not fetch the same resource twice', async ({ page, app }, testInfo) => { - actor('admin'); - const board = uniqueShort('perf', testInfo); - app.createBoardCli({ short: board, name: 'Performance Duplicates', description: 'duplicate request audit' }); - setBoardFixtureSettings(app, board, { allowImages: true, postCooldownSecs: 0 }); - const threadId = await createThreadViaRequest(page, app, board, { - subject: 'duplicate request thread', - body: 'duplicate request body', - }); - await loginViaRequest(page, app.baseURL); - - const targets = [ - { name: 'home', url: app.baseURL }, - { name: 'board index', url: `${app.baseURL}/${board}` }, - { name: 'catalog', url: `${app.baseURL}/${board}/catalog` }, - { name: 'thread', url: `${app.baseURL}/${board}/thread/${threadId}` }, - { name: 'admin panel', url: `${app.baseURL}/admin/panel` }, - { name: 'mod log', url: `${app.baseURL}/admin/mod-log` }, - ]; - const results: Array<{ target: string; repeat: number; durationMs: number; requestCount: number }> = []; - for (const target of targets) { - for (let repeat = 1; repeat <= 2; repeat += 1) { - const sample = await measured(`duplicate check ${target.name} repeat ${repeat}`, async () => measureNavigation( - page, - target.url, - `${target.name} repeat ${repeat}`, - )); - expect( - sample.duplicateRequests, - `${target.name} repeat ${repeat} should not fetch the same resource twice`, - ).toEqual([]); - results.push({ - target: target.name, - repeat, - durationMs: sample.durationMs, - requestCount: sample.requestCount, - }); - } - } - await attachEvidence(testInfo, 'audit-performance-duplicates.json', { - scenario: 'per-navigation duplicate resource check', - project: testInfo.project.name, - excludedRepeatEndpoints: REPEATING_ENDPOINT_PATTERNS.map((pattern) => String(pattern)), - results, - note: 'duplicates are compared by method + absolute URL within a single navigation; polling endpoints are excluded and labelled', - }); - }); - - test('large and repeatedly downloaded assets are reported', async ({ page, app }, testInfo) => { - actor('admin'); - const board = uniqueShort('perf', testInfo); - app.createBoardCli({ short: board, name: 'Performance Sizes', description: 'asset size audit' }); - setBoardFixtureSettings(app, board, { allowImages: true, postCooldownSecs: 0 }); - const threadId = await createThread(page, app, board, { - subject: 'asset size thread', - body: 'asset size body', - filePath: app.fixtures().tinyPng, - }); - await loginViaRequest(page, app.baseURL); - - type AssetSizeRecord = { - url: string; - method: string; - status: number; - responseBodyBytes: number | null; - responseHeaderBytes: number | null; - contentLengthHeaderBytes: number | null; - resourceType: string; - }; - - async function recordPageSizes(url: string, label: string): Promise<{ label: string; assets: AssetSizeRecord[] }> { - const pending: Array> = []; - const assets: AssetSizeRecord[] = []; - const listener = (response: Response) => { - pending.push((async () => { - const request = response.request(); - let responseBodyBytes: number | null = null; - let responseHeaderBytes: number | null = null; - try { - const sizes = await request.sizes(); - responseBodyBytes = sizes.responseBodySize >= 0 ? sizes.responseBodySize : null; - responseHeaderBytes = sizes.responseHeadersSize >= 0 ? sizes.responseHeadersSize : null; - } catch (error) { - // Engine-dependent; null means "not reported", never zero. - } - const contentLengthRaw = response.headers()['content-length']; - const contentLength = contentLengthRaw === undefined ? Number.NaN : Number(contentLengthRaw); - assets.push({ - url: response.url(), - method: request.method(), - status: response.status(), - responseBodyBytes, - responseHeaderBytes, - contentLengthHeaderBytes: Number.isFinite(contentLength) ? contentLength : null, - resourceType: request.resourceType(), - }); - })()); - }; - page.on('response', listener); - await page.goto(url, { waitUntil: 'load' }); - page.off('response', listener); - await Promise.allSettled(pending); - return { label, assets }; - } - - const pages = [ - await recordPageSizes(app.baseURL, 'home'), - await recordPageSizes(`${app.baseURL}/${board}`, 'board index'), - await recordPageSizes(`${app.baseURL}/${board}/thread/${threadId}`, 'thread with image'), - await recordPageSizes(`${app.baseURL}/admin/panel`, 'admin panel'), - ]; - const report = pages.map((entry) => ({ - label: entry.label, - assetCount: entry.assets.length, - topByResponseBody: [...entry.assets] - .sort((left, right) => (right.responseBodyBytes ?? -1) - (left.responseBodyBytes ?? -1)) - .slice(0, 10), - })); - await attachEvidence(testInfo, 'audit-performance-asset-sizes.json', { - scenario: 'transfer size inventory (no size threshold asserted)', - project: testInfo.project.name, - pages: report, - note: 'sizes come from request.sizes(); -1 (engine-dependent, e.g. some WebKit responses) is recorded as null, never as zero. ' - + 'No failure is raised on size because the application does not document a byte budget for these pages.', - }); - const totalBytes = report.reduce( - (sum, entry) => sum + entry.topByResponseBody - .reduce((inner, asset) => inner + Math.max(0, asset.responseBodyBytes ?? 0), 0), - 0, - ); - expect(totalBytes, 'asset size evidence should be collected').toBeGreaterThan(0); - }); - - test('server CPU and RSS samples are observed around a navigation workflow', async ({ page, app }, testInfo) => { - actor('visitor'); - const board = uniqueShort('perf', testInfo); - app.createBoardCli({ short: board, name: 'Performance Server', description: 'server observation audit' }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - const threadId = await createThreadViaRequest(page, app, board, { - subject: 'server observation thread', - body: 'server observation body', - }); - const pid = app.process?.pid ?? 0; - expect(pid, 'RustChan fixture process should expose a pid').toBeGreaterThan(0); - - type ProcessSample = { at: string; sample: number; cpuPercent: number; rssKb: number }; - type ProcessSampleResult = ProcessSample | { at: string; sample: number; error: string }; - - function sampleProcess(index: number): ProcessSampleResult { - const result = spawnSync('ps', ['-o', 'pid=,%cpu=,rss=', '-p', String(pid)], { encoding: 'utf8' }); - const at = new Date().toISOString(); - if (result.status !== 0) { - return { at, sample: index, error: result.stderr.trim() || `ps exited with ${result.status}` }; - } - const parts = result.stdout.trim().split(/\s+/); - const cpuPercent = Number(parts[1]); - const rssKb = Number(parts[2]); - if (!Number.isFinite(cpuPercent) || !Number.isFinite(rssKb)) { - return { at, sample: index, error: `unparseable ps output: ${result.stdout.trim()}` }; - } - return { at, sample: index, cpuPercent, rssKb }; - } - - function isProcessAlive(): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return false; - } - } - - expect(isProcessAlive(), 'RustChan fixture process should be alive before observation').toBe(true); - const samples: ProcessSampleResult[] = [sampleProcess(0)]; - for (let sample = 1; sample <= SAMPLES; sample += 1) { - await measured(`server observation navigation ${sample}`, async () => { - await page.goto(app.baseURL, { waitUntil: 'load' }); - await page.goto(`${app.baseURL}/${board}`, { waitUntil: 'load' }); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`, { waitUntil: 'load' }); - }); - samples.push(sampleProcess(sample)); - } - expect(isProcessAlive(), 'RustChan fixture process should still be alive after observation').toBe(true); - const usable = samples.filter((entry): entry is ProcessSample => !('error' in entry)); - expect(usable.length, 'ps should yield usable CPU/RSS samples').toBeGreaterThanOrEqual(SAMPLES); - for (const entry of usable) { - expect(entry.rssKb, 'RSS samples should be positive').toBeGreaterThan(0); - } - await attachEvidence(testInfo, 'audit-performance-server.json', { - scenario: 'server CPU/RSS sampled around 5 home → board → thread iterations', - project: testInfo.project.name, - pid, - sampleCount: samples.length, - samples, - deltas: usable.length >= 2 - ? { - rssKb: usable[usable.length - 1].rssKb - usable[0].rssKb, - cpuPercentRange: [ - Math.min(...usable.map((entry) => entry.cpuPercent)), - Math.max(...usable.map((entry) => entry.cpuPercent)), - ], - } - : null, - limitation: 'A single RSS increase is not evidence of a memory leak, and a decrease is not evidence of a fix: ' - + '5 iterations cannot characterize memory behaviour. ps %cpu is an interval average, not instantaneous CPU. ' - + 'These samples are bounded observations only and nothing here asserts a leak or a CPU budget.', - }); - }); -}); diff --git a/tests/e2e/audit-permissions.spec.ts b/tests/e2e/audit-permissions.spec.ts deleted file mode 100644 index 42ac290b..00000000 --- a/tests/e2e/audit-permissions.spec.ts +++ /dev/null @@ -1,1780 +0,0 @@ -/** - * Negative-testing / permissions audit for RustChan. - * - * Scope: - * - owner-vs-unrelated-actor authority for self-service post edit/delete - * - CSRF (public double-submit/signed tokens, admin session-scoped tokens) - * - Origin/Referer policy for admin mutations - * - anonymous access to every admin mutation route - * - stale/banked admin sessions - * - tampered identifiers and forged ownership grants - * - view-password / post-password board isolation - * - bans and appeals - * - escaped user content and hostile upload filenames - * - sensitive-data exposure and Set-Cookie attributes - * - * Transport note for the strict diagnostics layer: direct crafted requests go - * through Playwright APIRequestContext (`page.request` / `request.newContext`). - * Those responses are deliberately not observed as page traffic, so they do not - * need `expectHttpError` declarations. Declarations are used only where a real - * browser navigation receives the 4xx response, and each declared path is - * triggered exactly once. Every denial asserts both the response contract and - * the absence of the persisted state change (SQLite read). - * - * Preconditions are listed per test. Fresh isolated RustChan per test via the - * `app` fixture (admin + boards pub,img,vid,aud,nsfw,txt) unless a dedicated - * standalone instance is created for ban tests. - */ -import { randomBytes } from 'node:crypto'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import { - actor, - expectHttpError, - newAuditedContext, - noteEvent, - request, - type APIRequestContext, - type APIResponse, - type Page, -} from './diagnostics'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - adminCsrf, - adminLogin, - adminLogout, - adminPasswordHash, - createReply, - createStandaloneApp, - createThread, - expect, - expectNoDialog, - expectSafePage, - expectSafeResponse, - expectServerLogError, - extractCsrf, - publicCsrf, - setBoardFixtureSettings, - sqliteQuery, - test, - uniqueShort, - updateBoardSettings, - type RustChanServer, -} from './helpers'; - -const EDIT_DENIED = 'Edit permission for this post is no longer available in this browser.'; -const DELETE_DENIED = 'Delete permission for this post is no longer available in this browser.'; -const CSRF_DENIED = 'CSRF token mismatch.'; -const ADMIN_LOGIN_DENIED = 'Not logged in.'; -const SESSION_EXPIRED = 'Session expired or invalid.'; -const ORIGIN_MISMATCH = 'Origin/Referer origin mismatch.'; - -type OwnedGrant = { - post_id: number; - thread_id: number; - board_short: string; - deletion_token: string; - expires_at: number; -}; - -function marker(prefix: string): string { - return `${prefix}_${randomBytes(6).toString('hex')}`; -} - -async function postIdFrom(page: Page, selector = '.post.op'): Promise { - const id = await page.locator(selector).first().getAttribute('id'); - const numeric = Number(id?.replace(/^p/, '')); - expect(Number.isInteger(numeric) && numeric > 0, `post id for ${selector}`).toBe(true); - return numeric; -} - -async function postIdOf(page: Page, selector: string): Promise { - return postIdFrom(page, selector); -} - -function tableCount(app: RustChanServer, table: string, where = ''): number { - return Number(sqliteQuery(app, `SELECT COUNT(*) FROM ${table}${where ? ` WHERE ${where}` : ''};`)); -} - -function bodyOf(app: RustChanServer, postId: number): string { - return sqliteQuery(app, `SELECT body FROM posts WHERE id = ${postId};`); -} - -function decodeOwnedGrants(cookieValue: string): OwnedGrant[] { - const payloadHex = cookieValue.split('.')[0] ?? ''; - const parsed = JSON.parse(Buffer.from(payloadHex, 'hex').toString('utf8')) as { grants: OwnedGrant[] }; - return parsed.grants; -} - -function encodeOwnedPayload(grants: unknown[]): string { - return Buffer.from(JSON.stringify({ grants }), 'utf8').toString('hex'); -} - -async function signedCsrf(api: APIRequestContext, pathname: string): Promise { - const response = await api.get(pathname); - expect(response.status(), `GET ${pathname} for a signed CSRF token`).toBe(200); - return extractCsrf(await response.text()); -} - -/** Open the collapsed new-thread form on a board index before using its fields. */ -async function openPostForm(page: Page, board: string): Promise { - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - await expect(page.locator(`form[action="/${board}"] textarea[name="body"]`)).toBeVisible(); -} - -function setCookieValue(response: APIResponse, name: string): string | undefined { - return response - .headersArray() - .filter((header) => header.name.toLowerCase() === 'set-cookie') - .map((header) => header.value) - .find((value) => value.startsWith(`${name}=`)); -} - -async function submitThread( - api: APIRequestContext, - board: string, - csrf: string | undefined, - submissionToken: string, - body: string, -): Promise { - const multipart: Record = { - submission_token: submissionToken, - body, - }; - if (csrf !== undefined) { - multipart._csrf = csrf; - } - return api.post(`/${board}`, { multipart, maxRedirects: 0 }); -} - -const ADMIN_MUTATION_ROUTES: Array<{ path: string; multipartField?: string }> = [ - { path: '/admin/board/create' }, - { path: '/admin/board/delete' }, - { path: '/admin/board/settings' }, - { path: '/admin/board/reorder' }, - { path: '/admin/site/settings' }, - { path: '/admin/media/settings' }, - { path: '/admin/theme/create' }, - { path: '/admin/theme/update' }, - { path: '/admin/theme/delete' }, - { path: '/admin/backup/create' }, - { path: '/admin/backup/settings' }, - { path: '/admin/backup/delete' }, - { path: '/admin/board/backup/create' }, - { path: '/admin/backup/restore-saved' }, - { path: '/admin/board/backup/restore-saved' }, - { path: '/admin/backup/extract-board' }, - { path: '/admin/db/check' }, - { path: '/admin/db/repair' }, - { path: '/admin/vacuum' }, - { path: '/admin/ban/add' }, - { path: '/admin/ban/remove' }, - { path: '/admin/thread/action' }, - { path: '/admin/thread/delete' }, - { path: '/admin/post/delete' }, - { path: '/admin/post/ban-delete' }, - { path: '/admin/report/resolve' }, - { path: '/admin/filter/add' }, - { path: '/admin/filter/remove' }, - { path: '/admin/appeal/accept' }, - { path: '/admin/appeal/dismiss' }, - { path: '/admin/setup/reopen' }, - { path: '/admin/setup/close' }, - { path: '/admin/site-health/jobs/dismiss' }, - { path: '/admin/board/favicon/clear' }, - { path: '/admin/board/banner/clear' }, - { path: '/admin/banner/update' }, - { path: '/admin/banner/delete' }, - { path: '/admin/banner/move' }, - { path: '/admin/site/favicon', multipartField: 'favicon' }, - { path: '/admin/board/favicon', multipartField: 'favicon' }, - { path: '/admin/site/banner', multipartField: 'banner' }, - { path: '/admin/home/banner', multipartField: 'banner' }, - { path: '/admin/board/banner', multipartField: 'banner' }, - // /admin/restore and /admin/board/restore are intentionally NOT in this list: - // their anonymous denial is a 200 "Redirecting" interstitial plus a server - // [ERROR] line, asserted separately in the matrix test. -]; - -function adminDenialText(body: string): boolean { - return body.includes(CSRF_DENIED) - || body.includes('Missing Origin/Referer header.') - || body.includes(ADMIN_LOGIN_DENIED); -} - -function adminStateSnapshot(app: RustChanServer): Record { - const snapshot: Record = {}; - for (const table of [ - 'boards', - 'threads', - 'posts', - 'bans', - 'ban_appeals', - 'reports', - 'word_filters', - 'themes', - 'admin_sessions', - 'post_submissions', - 'site_settings', - 'mod_log', - 'polls', - 'poll_options', - 'poll_votes', - 'banner_assets', - ]) { - snapshot[table] = sqliteQuery(app, `SELECT COUNT(*) FROM ${table};`); - } - snapshot.site_name = sqliteQuery(app, `SELECT value FROM site_settings WHERE key = 'site_name';`); - snapshot.pub_name = sqliteQuery(app, `SELECT name FROM boards WHERE short_name = 'pub';`); - return snapshot; -} - -async function backupFiles(app: RustChanServer): Promise { - const dir = path.join(app.dataDir, 'backups'); - try { - return (await fsp.readdir(dir, { recursive: true })).sort(); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - throw error; - } -} - -test.describe('permissions, CSRF, and ownership', () => { - test('an unrelated actor cannot edit my post even with a valid CSRF token', async ({ page, browser, app }, testInfo) => { - // Preconditions: board allows editing and self-delete; anonymous actor. - actor('owner'); - const board = uniqueShort('ownedit', testInfo); - app.createBoardCli({ short: board, name: 'Ownership Edit' }); - setBoardFixtureSettings(app, board, { allowEditing: true, allowSelfDelete: true, postCooldownSecs: 0 }); - const threadId = await createThread(page, app, board, { subject: marker('owner-subject'), body: 'original owner body' }); - const postId = await postIdFrom(page); - const editHref = `/${board}/post/${postId}/edit`; - - await test.step('the owner edits through the real page', async () => { - await page.goto(`${app.baseURL}${editHref}`); - const form = page.locator(`form[action$="${editHref}"]`); - await expect(form.locator('textarea[name="body"]')).toHaveValue('original owner body'); - await form.locator('textarea[name="body"]').fill('owner edited body'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}#p${postId}$`)), - form.getByRole('button', { name: /save edit/i }).click(), - ]); - expect(bodyOf(app, postId)).toBe('owner edited body'); - }); - - actor('other'); - const otherContext = await newAuditedContext(browser); - const other = await otherContext.newPage(); - try { - await test.step('the unrelated browser is denied the edit page', async () => { - expectHttpError({ - method: 'GET', - path: editHref, - status: 403, - reason: 'without the ownership cookie the edit page must fail closed', - }); - await other.goto(`${app.baseURL}${editHref}`); - const body = await other.locator('body').innerText(); - expect(body).toContain(EDIT_DENIED); - expect(body).not.toContain('original owner body'); - expect(body).not.toContain('owner edited body'); - expect(await other.locator('textarea[name="body"]').count()).toBe(0); - expect(other.url()).toContain(editHref); - await expectSafePage(other); - }); - - await test.step('the crafted POST with the other actor valid CSRF token is denied and persists nothing', async () => { - const csrf = await publicCsrf(other, app, `/${board}/thread/${threadId}`); - const response = await other.request.post(`${app.baseURL}${editHref}`, { - form: { _csrf: csrf, body: 'attacker rewrite' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(EDIT_DENIED); - expect(bodyOf(app, postId)).toBe('owner edited body'); - }); - } finally { - await otherContext.close(); - } - }); - - test('an unrelated actor cannot delete my post and the owner delete still works', async ({ page, browser, app }, testInfo) => { - // Preconditions: board allows editing and self-delete; anonymous owner; OP has no replies. - actor('owner'); - const board = uniqueShort('owndel', testInfo); - app.createBoardCli({ short: board, name: 'Ownership Delete' }); - setBoardFixtureSettings(app, board, { allowEditing: true, allowSelfDelete: true, postCooldownSecs: 0 }); - const threadId = await createThread(page, app, board, { subject: marker('delete-subject'), body: 'delete target body' }); - const postId = await postIdFrom(page); - const deleteHref = `/${board}/post/${postId}/delete`; - - actor('other'); - const otherContext = await newAuditedContext(browser); - const other = await otherContext.newPage(); - try { - await test.step('the unrelated browser is denied the delete page', async () => { - expectHttpError({ - method: 'GET', - path: deleteHref, - status: 403, - reason: 'without the delete grant the confirmation page must fail closed', - }); - await other.goto(`${app.baseURL}${deleteHref}`); - const body = await other.locator('body').innerText(); - expect(body).toContain(DELETE_DENIED); - expect(body).not.toContain('delete target body'); - expect(await other.locator('form[action$="/delete"]').count()).toBe(0); - await expectSafePage(other); - }); - - await test.step('the crafted POST with a valid CSRF token is denied and keeps the row', async () => { - const csrf = await publicCsrf(other, app, `/${board}/thread/${threadId}`); - const response = await other.request.post(`${app.baseURL}${deleteHref}`, { - form: { _csrf: csrf }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(DELETE_DENIED); - expect(tableCount(app, 'posts', `id = ${postId}`)).toBe(1); - }); - } finally { - await otherContext.close(); - } - - await test.step('the owner deletes the post through the plain confirmation page', async () => { - await page.goto(`${app.baseURL}${deleteHref}`); - const form = page.locator(`form[action$="${deleteHref}"]`); - await expect(form).toBeVisible(); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}(/catalog)?$`)), - form.getByRole('button', { name: /delete post/i }).click(), - ]); - expect(tableCount(app, 'posts', `id = ${postId}`)).toBe(0); - expect(tableCount(app, 'threads', `id = ${threadId}`)).toBe(0); - }); - }); - - test('forged ownership grants, swapped identifiers, and extreme ids fail closed without cross-record writes', async ({ page, app }, testInfo) => { - // Preconditions: two editable public boards; owner owns one post; no replies on it. - actor('owner'); - const board = uniqueShort('tamp', testInfo); - const swappedBoard = uniqueShort('swap', testInfo); - app.createBoardCli({ short: board, name: 'Tamper Board' }); - app.createBoardCli({ short: swappedBoard, name: 'Swap Board' }); - setBoardFixtureSettings(app, board, { allowEditing: true, allowSelfDelete: true, postCooldownSecs: 0 }); - setBoardFixtureSettings(app, swappedBoard, { allowEditing: true, allowSelfDelete: true, postCooldownSecs: 0 }); - const threadId = await createThread(page, app, board, { subject: marker('tamper-subject'), body: 'tamper target body' }); - const postId = await postIdFrom(page); - const editHref = `/${board}/post/${postId}/edit`; - const signed = await publicCsrf(page, app, `/${board}/thread/${threadId}`); - const ownedCookie = (await page.context().cookies(app.baseURL)).find((cookie) => cookie.name === 'rustchan_owned_posts'); - expect(ownedCookie).toBeTruthy(); - const grant = decodeOwnedGrants(ownedCookie!.value).find((candidate) => candidate.post_id === postId); - expect(grant, 'ownership grant for the created post').toBeTruthy(); - const signature = ownedCookie!.value.split('.')[1]; - - const api = await request.newContext({ baseURL: app.baseURL }); - try { - await test.step('the genuine signed grant is the positive control', async () => { - const response = await api.post(editHref, { - form: { _csrf: signed, body: 'owner verified body' }, - headers: { Cookie: `rustchan_owned_posts=${ownedCookie!.value}` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(bodyOf(app, postId)).toBe('owner verified body'); - }); - - await test.step('a forged deletion_token under the original signature is rejected', async () => { - const forged = `${encodeOwnedPayload([{ ...grant!, deletion_token: 'forged-token' }])}.${signature}`; - const response = await api.post(editHref, { - form: { _csrf: signed, body: 'forged rewrite' }, - headers: { Cookie: `rustchan_owned_posts=${forged}` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(EDIT_DENIED); - expect(bodyOf(app, postId)).toBe('owner verified body'); - }); - - await test.step('an empty deletion_token with a stale signature is rejected', async () => { - const emptied = `${encodeOwnedPayload([{ ...grant!, deletion_token: '' }])}.${signature}`; - const response = await api.post(editHref, { - form: { _csrf: signed, body: 'empty-token rewrite' }, - headers: { Cookie: `rustchan_owned_posts=${emptied}` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(EDIT_DENIED); - expect(bodyOf(app, postId)).toBe('owner verified body'); - }); - - await test.step('a grant for one post cannot be reused on another post', async () => { - const secondThread = await createThread(page, app, board, { subject: marker('tamper-second'), body: 'second target body' }); - const secondPostId = await postIdFrom(page); - const secondEditHref = `/${board}/post/${secondPostId}/edit`; - expect(secondPostId).not.toBe(postId); - const response = await api.post(secondEditHref, { - form: { _csrf: signed, body: 'swapped grant rewrite' }, - headers: { Cookie: `rustchan_owned_posts=${ownedCookie!.value}` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(EDIT_DENIED); - expect(bodyOf(app, secondPostId)).toBe('second target body'); - expect(tableCount(app, 'threads', `id = ${secondThread}`)).toBe(1); - }); - - await test.step('a post id addressed through another board is a 404, not a cross-board edit', async () => { - const response = await api.get(`/${swappedBoard}/post/${postId}/edit`, { maxRedirects: 0 }); - expect(response.status()).toBe(404); - expect(await expectSafeResponse(response)).toContain('Post not found in this board.'); - expect(bodyOf(app, postId)).toBe('owner verified body'); - }); - - await test.step('negative and extreme ids are rejected without touching records', async () => { - const negative = await api.get(`/${board}/post/-1/edit`, { maxRedirects: 0 }); - expect(negative.status()).toBe(404); - expect(await expectSafeResponse(negative)).toContain('Post not found.'); - - const hugePost = await api.get(`/${board}/post/9223372036854775807/delete`, { maxRedirects: 0 }); - expect(hugePost.status()).toBe(404); - expect(await expectSafeResponse(hugePost)).toContain('Post not found.'); - - const csrf = await signedCsrf(api, `/${board}/thread/${threadId}`); - const hugeThread = await api.post(`/${board}/thread/9223372036854775807`, { - multipart: { _csrf: csrf, submission_token: marker('huge'), body: 'reply to nowhere' }, - maxRedirects: 0, - }); - expect(hugeThread.status()).toBe(404); - expect(await expectSafeResponse(hugeThread)).toContain('Thread not found.'); - expect(tableCount(app, 'posts', `body = 'reply to nowhere'`)).toBe(0); - }); - - await test.step('a swapped board field in thread-preference is a 400 with no preference row', async () => { - const response = await api.post(`/${board}/thread-preference`, { - form: { _csrf: signed, thread_id: String(threadId), board: swappedBoard, action: 'pin', return_to: `/${board}` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(400); - expect(await expectSafeResponse(response)).toContain('Board mismatch.'); - expect(tableCount(app, 'user_thread_preferences', `thread_id = ${threadId}`)).toBe(0); - }); - } finally { - await api.dispose(); - } - }); - - test('public CSRF: missing, empty, unsigned foreign, and signed cross-context tokens follow the documented contract', async ({ page, browser, app }) => { - // Preconditions: public /pub board, no cooldown. Requests use APIRequestContext - // so they are not page traffic for the diagnostics layer. - const api = await request.newContext({ baseURL: app.baseURL }); - const otherContext = await newAuditedContext(browser); - const other = await otherContext.newPage(); - try { - const baseline = tableCount(app, 'threads'); - await other.goto(`${app.baseURL}/pub`); - const otherRawToken = (await otherContext.cookies(app.baseURL)) - .find((cookie) => cookie.name === 'csrf_token')!.value; - - await test.step('missing, empty, and wrong tokens are denied with no row', async () => { - for (const [label, csrf] of [ - ['missing', undefined], - ['empty', ''], - ['wrong', 'bogus.csrf-token'], - ] as const) { - const response = await submitThread(api, 'pub', csrf, marker(`csrf-${label}`), `csrf ${label} body`); - expect(response.status(), `public CSRF ${label}`).toBe(403); - expect(await expectSafeResponse(response)).toContain(CSRF_DENIED); - } - expect(tableCount(app, 'threads')).toBe(baseline); - }); - - await test.step('an unsigned raw token copied from another actor is not accepted', async () => { - const response = await submitThread(api, 'pub', otherRawToken, marker('raw-foreign'), 'raw foreign body'); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(CSRF_DENIED); - expect(tableCount(app, 'threads')).toBe(baseline); - }); - - await test.step('a correctly signed public token is portable by design and creates exactly one thread', async () => { - noteEvent('csrf-contract', 'validate_csrf accepts any correctly signed token without binding the cookie; this is the documented bearer-token contract.'); - const signedFromOther = await publicCsrf(other, app, '/pub'); - const response = await submitThread(api, 'pub', signedFromOther, marker('signed-foreign'), 'signed foreign body'); - expect(response.status()).toBe(303); - expect(response.headers()['location']).toMatch(/^\/pub\/thread\/\d+/); - expect(tableCount(app, 'posts', `body = 'signed foreign body' AND is_op = 1`)).toBe(1); - expect(tableCount(app, 'threads')).toBe(baseline + 1); - }); - - await test.step('the cookie-equality path accepts a raw token only with the matching cookie', async () => { - const equalityApi = await request.newContext({ baseURL: app.baseURL }); - try { - const pageResponse = await equalityApi.get('/pub'); - expect(pageResponse.status()).toBe(200); - const rawToken = (await equalityApi.storageState()).cookies - .find((cookie) => cookie.name === 'csrf_token')!.value; - const response = await equalityApi.post('/pub', { - multipart: { _csrf: rawToken, submission_token: marker('raw-own'), body: 'raw own body' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(tableCount(app, 'posts', `body = 'raw own body' AND is_op = 1`)).toBe(1); - } finally { - await equalityApi.dispose(); - } - }); - } finally { - await otherContext.close(); - await api.dispose(); - } - }); - - test('admin CSRF scope: login/public tokens cannot drive admin mutations and admin tokens cannot drive public mutations', async ({ page, browser, app }, testInfo) => { - // Preconditions: fresh admin session in the default page. - actor('admin'); - const loginCsrf = await (async () => { - const response = await page.request.get(`${app.baseURL}/admin`); - expect(response.status()).toBe(200); - return extractCsrf(await response.text()); - })(); - const publicToken = await publicCsrf(page, app, '/pub'); - await adminLogin(page, app); - const panelCsrf = await adminCsrf(page, app); - - const boardsBefore = tableCount(app, 'boards'); - const threadsBefore = tableCount(app, 'threads'); - - await test.step('a login-scoped token is rejected for a session mutation', async () => { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { _csrf: loginCsrf, short_name: uniqueShort('lgs', testInfo), name: 'Login Scope', description: '' }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(CSRF_DENIED); - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - - await test.step('a signed public token is rejected for a session mutation', async () => { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { _csrf: publicToken, short_name: uniqueShort('pbs', testInfo), name: 'Public Scope', description: '' }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(CSRF_DENIED); - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - - await test.step('a token from a different admin session is rejected', async () => { - const siblingContext = await newAuditedContext(browser); - const sibling = await siblingContext.newPage(); - try { - await adminLogin(sibling, app); - const siblingCsrf = await adminCsrf(sibling, app); - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { _csrf: siblingCsrf, short_name: uniqueShort('csx', testInfo), name: 'Cross Session', description: '' }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(CSRF_DENIED); - } finally { - await siblingContext.close(); - } - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - - await test.step('the admin session-scoped token cannot drive a public thread even with the raw cookie', async () => { - const rawAdminCsrf = (await page.context().cookies(app.baseURL)) - .find((cookie) => cookie.name === 'csrf_token')!.value; - const api = await request.newContext({ baseURL: app.baseURL }); - try { - const response = await api.post('/pub', { - multipart: { _csrf: panelCsrf, submission_token: marker('admin-scope'), body: 'admin scoped public body' }, - headers: { Cookie: `csrf_token=${rawAdminCsrf}` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(CSRF_DENIED); - expect(tableCount(app, 'posts', `body = 'admin scoped public body'`)).toBe(0); - } finally { - await api.dispose(); - } - }); - expect(tableCount(app, 'threads')).toBe(threadsBefore); - }); - - test('admin Origin policy: foreign origin and referer fail closed, null and loopback origins agree with the Host header', async ({ page, app }, testInfo) => { - actor('admin'); - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const boardsBefore = tableCount(app, 'boards'); - const foreignBody = (short: string) => ({ - _csrf: csrf, - short_name: short, - name: 'Origin Audit', - description: '', - }); - - await test.step('a foreign Origin is rejected even with a valid admin CSRF token', async () => { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: foreignBody(uniqueShort('for', testInfo)), - headers: { Origin: 'http://evil.example' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(ORIGIN_MISMATCH); - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - - await test.step('a foreign Referer is rejected with no Origin header', async () => { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: foreignBody(uniqueShort('ref', testInfo)), - headers: { Referer: 'http://evil.example/path' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(ORIGIN_MISMATCH); - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - - await test.step('a null Origin is accepted on a loopback host and the board is created', async () => { - // A non-loopback Host cannot be simulated through APIRequestContext: - // Playwright does not deliver an overridden Host header to the server. - // The loopback-null acceptance below is therefore the reachable contract; - // the non-loopback rejection branch is covered by Rust unit tests for - // require_same_origin_request. - const short = uniqueShort('nul', testInfo); - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: foreignBody(short), - headers: { Origin: 'null' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(tableCount(app, 'boards', `short_name = '${short}'`)).toBe(1); - }); - - await test.step('a loopback alias origin matching the request host is accepted', async () => { - const short = uniqueShort('lop', testInfo); - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: foreignBody(short), - headers: { - Host: `localhost:${app.port}`, - Origin: `http://127.0.0.1:${app.port}`, - }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(tableCount(app, 'boards', `short_name = '${short}'`)).toBe(1); - }); - - await test.step('GET on POST-only mutation routes is method-not-allowed, never a success page', async () => { - const adminGet = await page.request.get(`${app.baseURL}/admin/board/create`, { maxRedirects: 0 }); - expect(adminGet.status()).toBe(405); - const adminBody = await expectSafeResponse(adminGet); - expect(adminBody).not.toContain('board management'); - - const publicGet = await page.request.get(`${app.baseURL}/vote`, { maxRedirects: 0 }); - expect(publicGet.status()).toBe(405); - }); - }); - - test('an anonymous attacker cannot reach any admin mutation route or persist anything', async ({ app, browser }, testInfo) => { - actor('anonymous'); - const fixtureThread = await (async () => { - const setupContext = await newAuditedContext(browser); - const setupPage = await setupContext.newPage(); - try { - return await createThread(setupPage, app, 'pub', { subject: marker('anon-matrix'), body: 'matrix fixture body' }); - } finally { - await setupContext.close(); - } - })(); - const fixturePost = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${fixtureThread} AND is_op = 1 LIMIT 1;`)); - const pubBoardId = Number(sqliteQuery(app, `SELECT id FROM boards WHERE short_name = 'pub' LIMIT 1;`)); - - const before = adminStateSnapshot(app); - const backupsBefore = await backupFiles(app); - const tinyPng = await fsp.readFile(app.fixtures().tinyPng); - const api = await request.newContext({ baseURL: app.baseURL }); - try { - const form: Record = { - _csrf: marker('anonymous'), - board_id: String(pubBoardId), - thread_id: String(fixtureThread), - post_id: String(fixturePost), - banner_id: '1', - report_id: '1', - ban_id: '1', - filter_id: '1', - appeal_id: '1', - ip_hash: 'a'.repeat(64), - reason: 'anonymous audit attempt', - duration_hours: '0', - board: 'pub', - board_short: 'pub', - action: 'sticky', - direction: 'up', - pattern: 'anonymous-audit-pattern', - replacement: 'anonymous-audit-replacement', - slug: 'anonymous-audit-theme', - existing_slug: 'anonymous-audit-theme', - display_name: 'Anonymous Audit Theme', - description: '', - name: 'Anonymous Audit Board', - short_name: uniqueShort('anmx', testInfo), - site_name: 'Anonymous Audit Site', - site_subtitle: '', - filename: 'missing-backup.zip', - kind: 'full', - target_type: 'none', - storage_mode: 'directory', - }; - - await test.step('every admin mutation route answers 403 with an exact denial body', async () => { - for (const route of ADMIN_MUTATION_ROUTES) { - const response = route.multipartField - ? await api.post(route.path, { - multipart: { - ...form, - [route.multipartField]: { name: 'audit.png', mimeType: 'image/png', buffer: tinyPng }, - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }) - : await api.post(route.path, { - form, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status(), `${route.path} must deny anonymous mutations`).toBe(403); - const body = await expectSafeResponse(response); - expect(adminDenialText(body), `${route.path} denial body: ${body.slice(0, 160)}`).toBe(true); - } - }); - - await test.step('restore upload routes reject anonymous uploads with the documented interstitial', async () => { - // Restore is a full-page multipart upload; the handler renders a 200 - // "Redirecting" interstitial whose Continue link points at the - // login-gated panel and logs one [ERROR] line. Assert that exact - // contract (not a generic 403) and declare the two expected log lines. - expectServerLogError({ - pattern: /Full restore failed - route: \/admin\/restore/, - reason: 'anonymous full restore is rejected and logged before any upload is applied', - }); - expectServerLogError({ - pattern: /Board restore failed - route: \/admin\/board\/restore/, - reason: 'anonymous board restore is rejected and logged before any upload is applied', - }); - for (const [path, message] of [ - ['/admin/restore', 'Restore failed.'], - ['/admin/board/restore', 'Board restore failed.'], - ] as const) { - const response = await api.post(path, { - multipart: { - ...form, - file: { name: 'audit.png', mimeType: 'image/png', buffer: tinyPng }, - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status(), `${path} reports the denial on an interstitial page`).toBe(200); - const body = await expectSafeResponse(response); - expect(body).toContain(message); - expect(body).toContain('Continue'); - expect(body).toContain(`/admin/panel?restore_error=`); - expect(body).not.toContain('site-settings-panel'); - expect(body).not.toContain('admin-panel-logout'); - } - }); - - await test.step('no database table, setting, or backup file changed', async () => { - const after = adminStateSnapshot(app); - expect(after).toEqual(before); - expect(await backupFiles(app)).toEqual(backupsBefore); - }); - } finally { - await api.dispose(); - } - }); - - test('an anonymous visitor gets a 403 admin panel, never a dashboard or login redirect', async ({ page, app }) => { - actor('anonymous'); - expectHttpError({ - method: 'GET', - path: '/admin/panel', - status: 403, - reason: 'the unauthenticated admin panel must fail closed', - }); - await page.goto(`${app.baseURL}/admin/panel`); - expect(new URL(page.url()).pathname).toBe('/admin/panel'); - const body = await page.locator('body').innerText(); - expect(body).toContain(ADMIN_LOGIN_DENIED); - expect(body).not.toContain('site settings'); - expect(body).not.toContain('logout'); - expect(await page.locator('#site-settings-panel, form[action="/admin/board/create"]').count()).toBe(0); - await expectSafePage(page); - }); - - test('logging out invalidates restored and sibling admin sessions for POST mutations', async ({ page, browser, app }, testInfo) => { - // Preconditions: one admin session; a sibling context is opened before logout. - await adminLogin(page, app); - const capturedCsrf = await adminCsrf(page, app); - const storage = await page.context().storageState(); - const sessionCookie = (await page.context().cookies(app.baseURL)).find((cookie) => cookie.name === 'chan_admin_session'); - expect(sessionCookie).toBeTruthy(); - - const siblingContext = await newAuditedContext(browser, { storageState: storage }); - const sibling = await siblingContext.newPage(); - const restoredContext = await newAuditedContext(browser, { storageState: storage }); - const restored = await restoredContext.newPage(); - try { - await sibling.goto(`${app.baseURL}/admin/panel`); - expect(new URL(sibling.url()).pathname).toBe('/admin/panel'); - - await adminLogout(page); - - const boardsBefore = tableCount(app, 'boards'); - await test.step('the restored storageState context is denied and persists nothing', async () => { - const response = await restored.request.post(`${app.baseURL}/admin/board/create`, { - form: { _csrf: capturedCsrf, short_name: uniqueShort('stale', testInfo), name: 'Stale Session', description: '' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(SESSION_EXPIRED); - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - - await test.step('a sibling context sharing the pre-logout session is denied too', async () => { - const response = await sibling.request.post(`${app.baseURL}/admin/board/create`, { - form: { _csrf: capturedCsrf, short_name: uniqueShort('sibl', testInfo), name: 'Sibling Session', description: '' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - expect(await expectSafeResponse(response)).toContain(SESSION_EXPIRED); - expect(tableCount(app, 'boards')).toBe(boardsBefore); - }); - } finally { - await restoredContext.close(); - await siblingContext.close(); - } - }); - - test('a view-password board leaks no content to an unrelated browser before unlock', async ({ page, browser, app }, testInfo) => { - // Preconditions: board is public while content is seeded, then switched to view_password. - actor('owner'); - const board = uniqueShort('vleak', testInfo); - const bodyMarker = marker('protected-body'); - const subjectMarker = marker('protected-subject'); - app.createBoardCli({ short: board, name: 'View Leak' }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - const threadId = await createThread(page, app, board, { - subject: subjectMarker, - body: bodyMarker, - filePath: app.fixtures().tinyPng, - }); - const postId = await postIdFrom(page); - const mediaHref = await page.locator('.post.op .file-info a').first().getAttribute('href'); - expect(mediaHref).toBeTruthy(); - setBoardFixtureSettings(app, board, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(app), - }); - - actor('other'); - const otherContext = await newAuditedContext(browser); - const other = await otherContext.newPage(); - try { - for (const pathname of [`/${board}`, `/${board}/catalog`, `/${board}/thread/${threadId}`]) { - await test.step(`the unrelated browser is gated at ${pathname}`, async () => { - expectHttpError({ - method: 'GET', - path: pathname, - status: 403, - reason: 'view-password boards must render only the unlock gate without the cookie', - }); - await other.goto(`${app.baseURL}${pathname}`); - const body = await other.locator('body').innerText(); - expect(body).toContain('password protected board'); - expect(body).not.toContain(bodyMarker); - expect(body).not.toContain(subjectMarker); - expect(body).not.toContain('tiny.png'); - await expect(other.locator('input[name="password"]')).toBeVisible(); - await expectSafePage(other); - }); - } - } finally { - await otherContext.close(); - } - - const api = await request.newContext({ baseURL: app.baseURL }); - try { - await test.step('direct media, post redirects, and updates endpoints deny without the cookie', async () => { - const media = await api.get(mediaHref!); - expect(media.status()).toBe(403); - expect(await media.text()).not.toContain(bodyMarker); - - const postRedirect = await api.get(`/${board}/post/${postId}`, { maxRedirects: 0 }); - expect(postRedirect.status()).toBe(303); - expect(postRedirect.headers()['location']).toContain(`/${board}/unlock?return_to=`); - expect(await postRedirect.text()).not.toContain(bodyMarker); - - const updates = await api.get(`/${board}/thread/${threadId}/updates?since=0`); - expect(updates.status()).toBe(403); - expect(await updates.text()).not.toContain(bodyMarker); - }); - } finally { - await api.dispose(); - } - }); - - test('unlock cookies are board-scoped and a password change retires the old password and old cookie', async ({ page, app }, testInfo) => { - // Preconditions: two view_password boards sharing a known password hash; the - // password of board A is rotated mid-test through the real admin UI. - const boardA = uniqueShort('unlA', testInfo); - const boardB = uniqueShort('unlB', testInfo); - const boardABody = marker('unlock-a-body'); - app.createBoardCli({ short: boardA, name: 'Unlock A' }); - app.createBoardCli({ short: boardB, name: 'Unlock B' }); - setBoardFixtureSettings(app, boardA, { postCooldownSecs: 0 }); - await createThread(page, app, boardA, { subject: marker('unlock-a-subject'), body: boardABody }); - const initialHash = adminPasswordHash(app); - setBoardFixtureSettings(app, boardA, { accessMode: 'view_password', accessPasswordHash: initialHash }); - setBoardFixtureSettings(app, boardB, { accessMode: 'view_password', accessPasswordHash: initialHash }); - - const api = await request.newContext({ baseURL: app.baseURL }); - try { - const unlockCsrf = await signedCsrf(api, `/${boardA}/unlock`); - const unlocked = await api.post(`/${boardA}/unlock`, { - form: { _csrf: unlockCsrf, password: ADMIN_PASSWORD, return_to: `/${boardA}` }, - maxRedirects: 0, - }); - expect(unlocked.status()).toBe(303); - const oldCookie = setCookieValue(unlocked, `rustchan_board_access_${boardA}`); - expect(oldCookie).toBeTruthy(); - - await test.step('the board A cookie opens A but not B', async () => { - const viewA = await api.get(`/${boardA}`); - expect(viewA.status()).toBe(200); - expect(await viewA.text()).toContain(boardABody); - const viewB = await api.get(`/${boardB}`); - expect(viewB.status()).toBe(403); - const viewBBody = await viewB.text(); - expect(viewBBody).toContain('password protected board'); - expect(viewBBody).not.toContain(boardABody); - }); - - await test.step('rotating the password retires the old cookie and old password', async () => { - await updateBoardSettings(page, app, boardA, { - accessMode: 'view_password', - accessPassword: 'rotated-secret-1', - }); - const newHash = sqliteQuery(app, `SELECT access_password_hash FROM boards WHERE short_name = '${boardA}';`); - expect(newHash).not.toBe(initialHash); - - const staleView = await api.get(`/${boardA}`); - expect(staleView.status()).toBe(403); - expect(await staleView.text()).toContain('password protected board'); - - const staleUnlockCsrf = await signedCsrf(api, `/${boardA}/unlock`); - const oldPassword = await api.post(`/${boardA}/unlock`, { - form: { _csrf: staleUnlockCsrf, password: ADMIN_PASSWORD, return_to: `/${boardA}` }, - maxRedirects: 0, - }); - expect(oldPassword.status()).toBe(403); - expect(await oldPassword.text()).toContain('Incorrect board password.'); - expect(setCookieValue(oldPassword, `rustchan_board_access_${boardA}`)).toBeUndefined(); - - const newPassword = await api.post(`/${boardA}/unlock`, { - form: { _csrf: staleUnlockCsrf, password: 'rotated-secret-1', return_to: `/${boardA}` }, - maxRedirects: 0, - }); - expect(newPassword.status()).toBe(303); - const freshCookie = setCookieValue(newPassword, `rustchan_board_access_${boardA}`); - expect(freshCookie).toBeTruthy(); - expect(freshCookie).not.toBe(oldCookie); - - const freshView = await api.get(`/${boardA}`); - expect(freshView.status()).toBe(200); - expect(await freshView.text()).toContain(boardABody); - }); - } finally { - await api.dispose(); - } - }); - - test('a post-password board allows viewing but denies thread and reply creation until unlock', async ({ page, app }, testInfo) => { - // Preconditions: content is seeded while public, then the board is switched to post_password. - const board = uniqueShort('postpw', testInfo); - const bodyMarker = marker('postpw-body'); - app.createBoardCli({ short: board, name: 'Post Password Audit' }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - const threadId = await createThread(page, app, board, { subject: marker('postpw-subject'), body: bodyMarker }); - const postId = await postIdFrom(page); - setBoardFixtureSettings(app, board, { - accessMode: 'post_password', - accessPasswordHash: adminPasswordHash(app), - }); - - const api = await request.newContext({ baseURL: app.baseURL }); - try { - await test.step('viewing stays public and shows the posting gate', async () => { - const view = await api.get(`/${board}`); - expect(view.status()).toBe(200); - const html = await view.text(); - expect(html).toContain(bodyMarker); - expect(html).toContain('id="board-access-gate"'); - expect(html).toContain('Viewing is public, but creating threads and replies on this board requires the board password.'); - expect(html).toContain(`action="/${board}/unlock"`); - }); - - await test.step('crafted thread creation redirects to unlock and persists nothing', async () => { - const csrf = await signedCsrf(api, `/${board}`); - const postsBefore = tableCount(app, 'posts'); - const response = await api.post(`/${board}`, { - multipart: { _csrf: csrf, submission_token: marker('pp-thread'), body: 'post password blocked thread' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(response.headers()['location']).toContain(`/${board}/unlock?return_to=`); - expect(tableCount(app, 'posts')).toBe(postsBefore); - expect(tableCount(app, 'threads', `id = ${threadId}`)).toBe(1); - }); - - await test.step('crafted reply redirects to unlock and persists nothing', async () => { - const csrf = await signedCsrf(api, `/${board}/thread/${threadId}`); - const postsBefore = tableCount(app, 'posts'); - const response = await api.post(`/${board}/thread/${threadId}`, { - multipart: { _csrf: csrf, submission_token: marker('pp-reply'), body: 'post password blocked reply' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(response.headers()['location']).toContain(`/${board}/unlock?return_to=`); - expect(tableCount(app, 'posts')).toBe(postsBefore); - }); - - await test.step('the self-service edit page fails closed without leaking the body', async () => { - const response = await api.get(`/${board}/post/${postId}/edit`, { maxRedirects: 0 }); - expect(response.status()).toBe(403); - const body = await expectSafeResponse(response); - expect(body).toContain('posting is password protected'); - expect(body).not.toContain(bodyMarker); - }); - - await test.step('after unlocking, the crafted reply is accepted exactly once', async () => { - const unlockCsrf = await signedCsrf(api, `/${board}/unlock`); - const unlocked = await api.post(`/${board}/unlock`, { - form: { _csrf: unlockCsrf, password: ADMIN_PASSWORD, return_to: `/${board}` }, - maxRedirects: 0, - }); - expect(unlocked.status()).toBe(303); - const postsBefore = tableCount(app, 'posts'); - const csrf = await signedCsrf(api, `/${board}/thread/${threadId}`); - const response = await api.post(`/${board}/thread/${threadId}`, { - multipart: { _csrf: csrf, submission_token: marker('pp-ok'), body: marker('postpw-reply') }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(tableCount(app, 'posts')).toBe(postsBefore + 1); - }); - } finally { - await api.dispose(); - } - }); - - test('swapped report/thread identifiers and cross-record report resolution stay scoped', async ({ page, app }) => { - // Preconditions: public board, two actors, two reports; admin session for resolution. - const subject = marker('report-target'); - const threadId = await createThread(page, app, 'pub', { subject, body: marker('report-body') }); - const postId = await postIdOf(page, '.post.op'); - const csrf = await publicCsrf(page, app, `/pub/thread/${threadId}`); - - await test.step('a report with a mismatched thread id is rejected and writes no report row', async () => { - const reportsBefore = tableCount(app, 'reports'); - const response = await page.request.post(`${app.baseURL}/report`, { - form: { _csrf: csrf, post_id: String(postId), thread_id: String(threadId + 9999), board: 'pub', reason: 'swapped thread' }, - maxRedirects: 0, - }); - expect(response.status()).toBe(400); - expect(await expectSafeResponse(response)).toContain('Reported thread does not match the selected post.'); - expect(tableCount(app, 'reports')).toBe(reportsBefore); - }); - - await test.step('resolving one report leaves the other report open', async () => { - const secondThread = await createThread(page, app, 'pub', { subject: marker('report-target-2'), body: marker('report-body-2') }); - const secondPost = await postIdOf(page, '.post.op'); - - const firstReport = await page.request.post(`${app.baseURL}/report`, { - form: { _csrf: csrf, post_id: String(postId), thread_id: String(threadId), board: 'pub', reason: 'audit report one' }, - maxRedirects: 0, - }); - expect(firstReport.status()).toBe(303); - const secondCsrf = await publicCsrf(page, app, `/pub/thread/${secondThread}`); - const secondReport = await page.request.post(`${app.baseURL}/report`, { - form: { _csrf: secondCsrf, post_id: String(secondPost), thread_id: String(secondThread), board: 'pub', reason: 'audit report two' }, - maxRedirects: 0, - }); - expect(secondReport.status()).toBe(303); - expect(tableCount(app, 'reports')).toBe(2); - - const firstReportId = Number(sqliteQuery(app, `SELECT id FROM reports WHERE reason = 'audit report one' ORDER BY id DESC LIMIT 1;`)); - const secondReportId = Number(sqliteQuery(app, `SELECT id FROM reports WHERE reason = 'audit report two' ORDER BY id DESC LIMIT 1;`)); - expect(firstReportId).toBeGreaterThan(0); - expect(secondReportId).toBeGreaterThan(0); - - await adminLogin(page, app); - const resolveCsrf = await adminCsrf(page, app); - const resolved = await page.request.post(`${app.baseURL}/admin/report/resolve`, { - form: { _csrf: resolveCsrf, report_id: String(firstReportId) }, - maxRedirects: 0, - }); - expect(resolved.status()).toBe(303); - expect(sqliteQuery(app, `SELECT status FROM reports WHERE id = ${firstReportId};`)).toBe('resolved'); - expect(sqliteQuery(app, `SELECT status FROM reports WHERE id = ${secondReportId};`)).toBe('open'); - }); - }); - - test('poll voting enforces CSRF, invalid option ids, duplicate votes, and cross-board gates', async ({ page, app }, testInfo) => { - // Preconditions: a poll on public /pub and a poll on a board that becomes post_password. - actor('anonymous'); - await page.goto(`${app.baseURL}/pub`); - await openPostForm(page, 'pub'); - await page.locator('.poll-creator > summary').click(); - await page.locator('textarea[name="body"]').fill(marker('poll-body')); - await page.locator('input[name="poll_question"]').fill('Audit poll question'); - await page.getByRole('textbox', { name: 'poll option 1', exact: true }).fill('Audit option one'); - await page.getByRole('textbox', { name: 'poll option 2', exact: true }).fill('Audit option two'); - await Promise.all([ - page.waitForURL(/\/pub\/thread\/\d+/), - page.getByRole('button', { name: /post thread/i }).click(), - ]); - const pubPollId = Number(sqliteQuery(app, 'SELECT id FROM polls ORDER BY id DESC LIMIT 1;')); - const optionOne = Number(sqliteQuery(app, `SELECT id FROM poll_options WHERE poll_id = ${pubPollId} ORDER BY id ASC LIMIT 1;`)); - const optionTwo = Number(sqliteQuery(app, `SELECT id FROM poll_options WHERE poll_id = ${pubPollId} ORDER BY id DESC LIMIT 1;`)); - const threadCsrf = await publicCsrf(page, app, page.url().replace(app.baseURL, '')); - expect(pubPollId).toBeGreaterThan(0); - expect(optionOne).toBeGreaterThan(0); - expect(optionTwo).toBeGreaterThan(optionOne); - - await test.step('a voted option is persisted exactly once and duplicates are rejected', async () => { - const voted = await page.request.post(`${app.baseURL}/vote`, { - form: { _csrf: threadCsrf, option_id: String(optionOne) }, - maxRedirects: 0, - }); - expect(voted.status()).toBe(303); - expect(tableCount(app, 'poll_votes', `poll_id = ${pubPollId} AND option_id = ${optionOne}`)).toBe(1); - - const duplicate = await page.request.post(`${app.baseURL}/vote`, { - form: { _csrf: threadCsrf, option_id: String(optionTwo) }, - maxRedirects: 0, - }); - expect(duplicate.status()).toBe(400); - expect(await expectSafeResponse(duplicate)).toContain('you have already voted'); - expect(tableCount(app, 'poll_votes', `poll_id = ${pubPollId}`)).toBe(1); - }); - - await test.step('zero and extreme option ids are rejected without votes', async () => { - const zero = await page.request.post(`${app.baseURL}/vote`, { - form: { _csrf: threadCsrf, option_id: '0' }, - maxRedirects: 0, - }); - expect(zero.status()).toBe(400); - expect(await expectSafeResponse(zero)).toContain('Invalid poll option.'); - - const huge = await page.request.post(`${app.baseURL}/vote`, { - form: { _csrf: threadCsrf, option_id: '9223372036854775807' }, - maxRedirects: 0, - }); - expect(huge.status()).toBe(404); - expect(await expectSafeResponse(huge)).toContain('Poll option not found.'); - expect(tableCount(app, 'poll_votes', `poll_id = ${pubPollId}`)).toBe(1); - }); - - await test.step('an option id from a locked board redirects to unlock and records no vote', async () => { - const lockedBoard = uniqueShort('pollock', testInfo); - app.createBoardCli({ short: lockedBoard, name: 'Locked Poll' }); - await page.goto(`${app.baseURL}/${lockedBoard}`); - await openPostForm(page, lockedBoard); - await page.locator('.poll-creator > summary').click(); - await page.locator('textarea[name="body"]').fill(marker('locked-poll-body')); - await page.locator('input[name="poll_question"]').fill('Locked poll question'); - await page.getByRole('textbox', { name: 'poll option 1', exact: true }).fill('Locked option one'); - await page.getByRole('textbox', { name: 'poll option 2', exact: true }).fill('Locked option two'); - await Promise.all([ - page.waitForURL(new RegExp(`/${lockedBoard}/thread/\\d+`)), - page.getByRole('button', { name: /post thread/i }).click(), - ]); - const lockedPollId = Number(sqliteQuery(app, 'SELECT id FROM polls ORDER BY id DESC LIMIT 1;')); - const lockedOption = Number(sqliteQuery(app, `SELECT id FROM poll_options WHERE poll_id = ${lockedPollId} ORDER BY id ASC LIMIT 1;`)); - - setBoardFixtureSettings(app, lockedBoard, { - accessMode: 'post_password', - accessPasswordHash: adminPasswordHash(app), - }); - const response = await page.request.post(`${app.baseURL}/vote`, { - form: { _csrf: threadCsrf, option_id: String(lockedOption) }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(response.headers()['location']).toContain(`/${lockedBoard}/unlock?return_to=`); - expect(tableCount(app, 'poll_votes', `poll_id = ${lockedPollId}`)).toBe(0); - }); - }); - - test('script, event-handler, javascript:, and data: payloads render as inert escaped text', async ({ page, app }, testInfo) => { - // Preconditions: public board; markers are unique to this test. - actor('anonymous'); - const scriptMarker = marker('XSS_MARKER_script'); - const imageMarker = marker('XSS_MARKER_img'); - const jsMarker = marker('XSS_MARKER_js'); - const dataMarker = marker('XSS_MARKER_data'); - const svgMarker = marker('XSS_MARKER_svg'); - const idMarker = marker('XSS_MARKER_id'); - const subject = marker('xss-subject'); - const payload = [ - ``, - ``, - `javascript:window.${jsMarker}=3`, - `data:text/html,`, - `">`, - `${idMarker} bold`, - "quote payload ' \" ` backtick", - ].join('\n'); - - await expectNoDialog(page, async () => { - await createThread(page, app, 'pub', { subject, body: payload }); - }); - - const postBody = page.locator('.post.op .post-body'); - await expect(postBody).toBeVisible(); - const rendered = await postBody.innerText(); - for (const raw of [` Board', - description: 'quotes " \' and ', - nsfw: true, - allowImages: true, - allowVideo: true, - allowAudio: true, - allowPdf: true, - allowArchive: true, - maxImageSizeMb: 2, - maxVideoSizeMb: 3, - maxAudioSizeMb: 4, - }); - - await expectNoDialog(page, async () => { - await page.goto(`${app.baseURL}/?nsfw=${short}`); - await expectSafePage(page); - }); - expect(await page.locator('script:text("alert(1)")').count()).toBe(0); - await app.restart(); - await page.goto(`${app.baseURL}/admin/panel`); - await adminLogin(page, app); - await expect(page.locator(`#board-${short}`)).toContainText('/' + short + '/'); - - const csrf = await adminCsrf(page, app); - const panel = await page.request.get(`${app.baseURL}/admin/panel`).then((response) => response.text()); - const boardId = Number(panel.match(new RegExp(`id="board-${short}"[\\s\\S]*?name="board_id" value="(\\d+)"`))?.[1]); - const deleteResponse = await page.request.post(`${app.baseURL}/admin/board/delete`, { - form: { _csrf: csrf, board_id: String(boardId) }, - maxRedirects: 0, - }); - expect(deleteResponse.status()).toBe(303); - await page.goto(app.baseURL); - await expect(page.locator(`a[href="/${short}"], a[href="/${short}/catalog"]`)).toHaveCount(0); - }); - - test('rejects invalid, traversal-like, and duplicate board short names safely', async ({ page, app }, testInfo) => { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const validShort = uniqueShort('dup', testInfo); - await createBoard(page, app, { short: validShort, name: 'Duplicate Source' }); - - for (const shortName of ['', '../etc', 'bad/name', 'toolongname', validShort]) { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: csrf, - short_name: shortName, - name: 'Invalid Board', - description: 'must fail', - }, - maxRedirects: 0, - }); - expect([400, 409, 422]).toContain(response.status()); - await expectSafeResponse(response); - } - }); -}); diff --git a/tests/e2e/auto-compress.spec.ts b/tests/e2e/auto-compress.spec.ts deleted file mode 100644 index 0720d6f2..00000000 --- a/tests/e2e/auto-compress.spec.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import * as zlib from 'node:zlib'; -import type { Page } from '@playwright/test'; -import { - boardId, - expect, - expectSafePage, - expectSafeResponse, - publicCsrf, - setBoardFixtureSettings, - sqliteQuery, - test, - type RustChanServer, -} from './helpers'; - -const IMAGE_LIMIT = 256 * 1024; -const VIDEO_LIMIT = 700 * 1024; -const IMPOSSIBLE_LIMIT = 1024; - -const toolchainSkipReason = requiredMediaToolchainSkipReason(); -test.skip(!!toolchainSkipReason, toolchainSkipReason || ''); - -test.describe('auto-compress oversized uploads', () => { - test('oversized image is compressed, accepted, served, and previewed', async ({ page, app, javaScriptEnabled }, testInfo) => { - test.skip(!javaScriptEnabled, 'client compression requires JavaScript; direct rejection is covered separately'); - test.setTimeout(180_000); - const board = uniqueBoard('acimg', testInfo.workerIndex); - app.createBoardCli({ short: board, name: 'Auto Image' }); - setBoardFixtureSettings(app, board, { - allowImages: true, - allowVideo: false, - maxImageSizeBytes: IMAGE_LIMIT, - maxVideoSizeBytes: IMAGE_LIMIT, - maxAudioSizeBytes: IMAGE_LIMIT, - }); - const fixtures = await createAutoCompressFixtures(app, testInfo.workerIndex); - expect(fs.statSync(fixtures.compressibleImage).size).toBeGreaterThan(IMAGE_LIMIT); - - const form = await openThreadForm(page, app, board, 'image auto-compress'); - const fileInput = form.locator('input[type="file"]').first(); - await fileInput.setInputFiles(fixtures.compressibleImage); - await expect(page.locator('#compress-modal')).toBeVisible(); - await page.getByRole('button', { name: /auto-compress/i }).click(); - await expectCompressedSelection(fileInput, IMAGE_LIMIT); - await expect(form.locator('.auto-compress-status')).toContainText(/Auto-compressed/); - await expect(page.locator('#compress-modal')).toBeHidden({ timeout: 90_000 }); - - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/\\d+`)), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - await expectSafePage(page); - - const media = await expectServedMediaUnderLimit(page, app, IMAGE_LIMIT, /image\//); - await expectSafeResponse(media.response); - const thumb = page.locator('[data-media-thumb="1"]').first(); - await expect(thumb).toBeVisible(); - await expect.poll(async () => thumb.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0)).toBe(true); - - await page.goto(`${app.baseURL}/${board}/catalog`); - await expectSafePage(page); - await expect(page.locator('.catalog-item')).toHaveCount(1); - await expect(page.locator('[data-media-thumb="1"]').first()).toBeVisible(); - }); - - for (const withAudio of [false, true]) { - test(`oversized video ${withAudio ? 'with audio' : 'without audio'} compresses and plays or reports unsupported capture clearly`, async ({ page, app, javaScriptEnabled }, testInfo) => { - test.skip(!javaScriptEnabled, 'client compression requires JavaScript; direct rejection is covered separately'); - test.setTimeout(240_000); - const board = uniqueBoard('acvid', testInfo.workerIndex); - app.createBoardCli({ short: board, name: 'Auto Video' }); - setBoardFixtureSettings(app, board, { - allowImages: false, - allowVideo: true, - maxImageSizeBytes: VIDEO_LIMIT, - maxVideoSizeBytes: VIDEO_LIMIT, - maxAudioSizeBytes: VIDEO_LIMIT, - }); - const fixtures = await createAutoCompressFixtures(app, testInfo.workerIndex, withAudio); - expect(fs.statSync(fixtures.video).size).toBeGreaterThan(VIDEO_LIMIT); - - const form = await openThreadForm(page, app, board, 'video auto-compress'); - const fileInput = form.locator('input[type="file"]').first(); - await fileInput.setInputFiles(fixtures.video); - await expect(page.locator('#compress-modal')).toBeVisible(); - await page.getByRole('button', { name: /auto-compress/i }).click(); - const canCapture = await page.evaluate(() => { - const v = document.createElement('video'); - return typeof v.captureStream === 'function' || typeof (v as any).mozCaptureStream === 'function'; - }); - if (!canCapture) { - await expect(page.locator('#compress-progress-text')).toContainText('Video compression is not supported in this browser'); - await expect(page.locator('#compress-progress-text')).toBeVisible(); - await page.getByRole('button', { name: 'Close', exact: true }).click(); - await expect(page.locator('#compress-modal')).toBeHidden(); - expect(await fileInput.evaluate((input: HTMLInputElement) => input.files?.length)).toBe(0); - expect(postCount(app, board)).toBe(0); - await expectNoUploadStaging(app); - return; - } - await expectCompressedSelection(fileInput, VIDEO_LIMIT); - await expect(form.locator('.auto-compress-status')).toContainText(/Auto-compressed/); - await expect(page.locator('#compress-modal')).toBeHidden({ timeout: 140_000 }); - - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/\\d+`)), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - await expectSafePage(page); - - const media = await expectServedMediaUnderLimit(page, app, VIDEO_LIMIT, /video\/webm/); - await expectSafeResponse(media.response); - const videoThumb = page.getByRole('link', { name: /video thumbnail/i }).first(); - await expect(videoThumb).toBeVisible(); - await videoThumb.click(); - const video = page.locator('video.media-expanded-video').first(); - await expect(video).toBeVisible(); - await expectBrowserCanDecodeVideo(page, `${app.baseURL}${media.href}`); - await video.evaluate(async (element: HTMLVideoElement) => { element.muted = true; await element.play(); }); - await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)).toBeGreaterThan(0); - await video.evaluate((element: HTMLVideoElement) => element.pause()); - const mediaPath = path.join(app.dataDir, new URL(media.response.url()).pathname); - const probe = spawnSync(process.env.RUSTCHAN_E2E_FFPROBE_PATH ?? 'ffprobe', ['-v', 'error', '-show_entries', 'stream=codec_type', '-of', 'json', mediaPath], { encoding: 'utf8' }); - expect(probe.status, probe.stderr).toBe(0); - expect(JSON.parse(probe.stdout).streams.some(stream => stream.codec_type === 'audio')).toBe(withAudio); - - const thumbSrc = await page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - expect(thumbSrc).toBeTruthy(); - const thumbResponse = await page.request.get(`${app.baseURL}${thumbSrc}`); - expect(thumbResponse.status()).toBe(200); - expect(thumbResponse.headers()['content-type']).toContain('image/webp'); - }); - - } - - test('direct oversized upload without client compression rejects cleanly', async ({ page, app }, testInfo) => { - const board = uniqueBoard('acnojs', testInfo.workerIndex); - app.createBoardCli({ short: board, name: 'No Auto' }); - setBoardFixtureSettings(app, board, { - allowImages: true, - allowVideo: true, - maxImageSizeBytes: 64 * 1024, - maxVideoSizeBytes: 64 * 1024, - maxAudioSizeBytes: 64 * 1024, - }); - const fixtures = await createAutoCompressFixtures(app, testInfo.workerIndex); - const csrf = await publicCsrf(page, app, `/${board}`); - const before = postCount(app, board); - - const response = await page.request.post(`${app.baseURL}/${board}`, { - multipart: { - _csrf: csrf, - submission_token: `direct-${Date.now()}`, - body: 'direct oversized upload', - file: { - name: 'oversized.png', - mimeType: 'image/png', - buffer: await fsp.readFile(fixtures.stubbornImage), - }, - }, - maxRedirects: 0, - }); - - expect(response.status()).toBe(413); - const text = await expectSafeResponse(response); - expect(text).toMatch(/File too large|Maximum upload size/i); - expect(postCount(app, board)).toBe(before); - await expectNoUploadStaging(app); - }); - - test('still-too-large browser compression fails without creating posts or staged files', async ({ page, app, javaScriptEnabled }, testInfo) => { - test.skip(!javaScriptEnabled, 'client compression requires JavaScript; direct rejection is covered separately'); - test.setTimeout(120_000); - const board = uniqueBoard('acfail', testInfo.workerIndex); - app.createBoardCli({ short: board, name: 'Auto Fail' }); - setBoardFixtureSettings(app, board, { - allowImages: true, - allowVideo: false, - maxImageSizeBytes: IMPOSSIBLE_LIMIT, - maxVideoSizeBytes: IMPOSSIBLE_LIMIT, - maxAudioSizeBytes: IMPOSSIBLE_LIMIT, - }); - const fixtures = await createAutoCompressFixtures(app, testInfo.workerIndex); - const before = postCount(app, board); - - const form = await openThreadForm(page, app, board, 'image impossible auto-compress'); - await form.locator('input[type="file"]').first().setInputFiles(fixtures.stubbornImage); - await expect(page.locator('#compress-modal')).toBeVisible(); - await page.getByRole('button', { name: /auto-compress/i }).click(); - await expect(page.locator('#compress-progress-text')).toContainText(/Could not compress|Error:/, { timeout: 90_000 }); - await expect(page.locator('#compress-progress-text')).toBeVisible(); - - expect(postCount(app, board)).toBe(before); - await expectNoUploadStaging(app); - await expectSafePage(page); - }); -}); - -async function openThreadForm(page, app: RustChanServer, board: string, body: string) { - await page.goto(`${app.baseURL}/${board}`); - await expectSafePage(page); - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - const form = page.locator(`form[action="/${board}"]`).first(); - await form.locator('textarea[name="body"]').fill(body); - return form; -} - -async function expectCompressedSelection(fileInput, limit: number): Promise { - await expect.poll(async () => fileInput.evaluate((input: HTMLInputElement) => { - const done = document.getElementById('compress-done-actions'); - if (done && getComputedStyle(done).display !== 'none') throw new Error(document.getElementById('compress-progress-text')?.textContent || 'Compression failed'); - return ({ - size: input.files?.[0]?.size ?? 0, - name: input.files?.[0]?.name ?? '', - compressed: input.dataset.autoCompressed, - }); }), { timeout: 120_000, intervals: [500, 1_000, 2_000] }).toMatchObject({ - compressed: '1', - }); - const selected = await fileInput.evaluate((input: HTMLInputElement) => ({ - size: input.files?.[0]?.size ?? 0, - name: input.files?.[0]?.name ?? '', - })); - expect(selected.name).toContain('_compressed.'); - expect(selected.size).toBeGreaterThan(0); - expect(selected.size).toBeLessThanOrEqual(limit); -} - -async function expectServedMediaUnderLimit(page, app: RustChanServer, limit: number, contentType: RegExp) { - const href = await page.locator('.file-info a').first().getAttribute('href'); - expect(href).toBeTruthy(); - const response = await page.request.get(`${app.baseURL}${href}`); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toMatch(contentType); - const body = await response.body(); - expect(body.length).toBeGreaterThan(0); - expect(body.length).toBeLessThanOrEqual(limit); - return { href: href ?? '', response, body }; -} - -async function expectBrowserCanDecodeVideo(page: Page, src: string): Promise { - const decoded = await page.evaluate(async (url) => { - const video = document.createElement('video'); - video.muted = true; - video.playsInline = true; - video.preload = 'metadata'; - video.src = url; - document.body.appendChild(video); - - try { - return await new Promise((resolve) => { - let settled = false; - const finish = (value: boolean) => { - if (settled) return; - settled = true; - window.clearTimeout(timer); - resolve(value); - }; - const timer = window.setTimeout(() => finish(false), 15_000); - const hasVideoFrame = () => video.readyState >= 1 && video.videoWidth > 0 && video.videoHeight > 0; - video.onloadedmetadata = () => finish(hasVideoFrame()); - video.onloadeddata = () => finish(hasVideoFrame()); - video.onerror = () => finish(false); - video.load(); - }); - } finally { - video.removeAttribute('src'); - video.load(); - video.remove(); - } - }, src); - - expect(decoded).toBe(true); -} - -async function expectNoUploadStaging(app: RustChanServer): Promise { - const pending = path.join(app.dataDir, 'boards', '.pending'); - const entries = await fsp.readdir(pending).catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return []; - throw error; - }); - expect(entries).toEqual([]); -} - -function postCount(app: RustChanServer, board: string): number { - const id = boardId(app, board); - return Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE board_id = ${id};`)); -} - -async function createAutoCompressFixtures( - app: RustChanServer, - workerIndex: number, - withAudio = false, -): Promise<{ compressibleImage: string; stubbornImage: string; video: string }> { - const dir = path.join(app.fixtureDir, `auto-compress-${workerIndex}`); - await fsp.mkdir(dir, { recursive: true }); - const compressibleImage = path.join(dir, 'oversized-gradient.bmp'); - const stubbornImage = path.join(dir, 'oversized-noise.png'); - const video = path.join(dir, 'oversized-video.webm'); - await fsp.writeFile(compressibleImage, bmpRgb(1200, 900, (x, y, channel) => { - if (channel === 0) return (x * 255) / 1199; - if (channel === 1) return (y * 255) / 899; - return ((x + y) * 255) / (1199 + 899); - })); - await fsp.writeFile(stubbornImage, pngRgba(900, 900, (index) => { - if (index % 4 === 3) return 255; - return (index * 1103515245 + 12345) >>> 16; - })); - runTool(process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg', [ - '-hide_banner', - '-loglevel', - 'error', - '-y', - '-f', - 'lavfi', - '-i', - 'testsrc2=size=640x360:rate=24:duration=6', - ...(withAudio ? ['-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', '-c:a', 'libopus'] : []), - '-c:v', - 'libvpx-vp9', - '-b:v', - '3500k', - '-crf', - '10', - '-pix_fmt', - 'yuv420p', - video, - ]); - return { compressibleImage, stubbornImage, video }; -} - -function requiredMediaToolchainSkipReason(): string | undefined { - if (process.env.RUSTCHAN_E2E_MEDIA_TOOLCHAIN !== '1') { - return 'opt-in real media auto-compress pass only; set RUSTCHAN_E2E_MEDIA_TOOLCHAIN=1'; - } - const ffmpeg = process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg'; - const ffprobe = process.env.RUSTCHAN_E2E_FFPROBE_PATH ?? 'ffprobe'; - if (spawnSync(ffmpeg, ['-version'], { stdio: 'ignore' }).status !== 0) { - return `ffmpeg is required for auto-compress live fixtures: ${ffmpeg}`; - } - if (spawnSync(ffprobe, ['-version'], { stdio: 'ignore' }).status !== 0) { - return `ffprobe is required for auto-compress live fixtures: ${ffprobe}`; - } - const encoders = spawnSync(ffmpeg, ['-hide_banner', '-encoders'], { encoding: 'utf8' }); - const names = (encoders.stdout ?? '').split('\n').map((line) => line.trim().split(/\s+/)).filter(([flags, name]) => /^[VA][A-Z.]{5}$/.test(flags ?? '') && name !== '=').map(([, name]) => name); - for (const encoder of ['png', 'libvpx-vp9', 'libopus']) { - if (encoders.status !== 0 || !names.includes(encoder)) { - return `ffmpeg encoder ${encoder} is required for auto-compress live verification`; - } - } - const muxers = spawnSync(ffmpeg, ['-hide_banner', '-muxers'], { encoding: 'utf8' }); - if (muxers.status !== 0 || !(muxers.stdout ?? '').split('\n').some((line) => /^\s*E\s+webm\s/.test(line))) { - return 'ffmpeg WebM muxer is required for auto-compress live verification'; - } - return undefined; -} - -function runTool(command: string, args: string[]): void { - const result = spawnSync(command, args, { encoding: 'utf8' }); - if (result.status !== 0) { - throw new Error([`failed to run ${command}`, result.stdout, result.stderr].filter(Boolean).join('\n')); - } -} - -function uniqueBoard(prefix: string, workerIndex: number): string { - return `${prefix}${workerIndex}${Date.now().toString(36).slice(-2)}`.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8); -} - -function bmpRgb(width: number, height: number, pixelByte: (x: number, y: number, channel: number) => number): Buffer { - const rowStride = Math.ceil((width * 3) / 4) * 4; - const pixelBytes = rowStride * height; - const out = Buffer.alloc(54 + pixelBytes); - out.write('BM', 0, 'ascii'); - out.writeUInt32LE(out.length, 2); - out.writeUInt32LE(54, 10); - out.writeUInt32LE(40, 14); - out.writeInt32LE(width, 18); - out.writeInt32LE(height, 22); - out.writeUInt16LE(1, 26); - out.writeUInt16LE(24, 28); - out.writeUInt32LE(pixelBytes, 34); - for (let y = 0; y < height; y += 1) { - const sourceY = height - 1 - y; - const row = 54 + y * rowStride; - for (let x = 0; x < width; x += 1) { - const offset = row + x * 3; - out[offset] = pixelByte(x, sourceY, 2) & 0xff; - out[offset + 1] = pixelByte(x, sourceY, 1) & 0xff; - out[offset + 2] = pixelByte(x, sourceY, 0) & 0xff; - } - } - return out; -} - -function pngRgba(width: number, height: number, pixelByte: (index: number) => number): Buffer { - const raw = Buffer.alloc((width * 4 + 1) * height); - let sourceIndex = 0; - for (let y = 0; y < height; y += 1) { - const rowStart = y * (width * 4 + 1); - raw[rowStart] = 0; - for (let x = 0; x < width * 4; x += 1) { - raw[rowStart + 1 + x] = pixelByte(sourceIndex) & 0xff; - sourceIndex += 1; - } - } - const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - return Buffer.concat([ - signature, - pngChunk('IHDR', ihdr), - pngChunk('IDAT', zlib.deflateSync(raw)), - pngChunk('IEND', Buffer.alloc(0)), - ]); -} - -function pngChunk(type: string, data: Buffer): Buffer { - const typeBytes = Buffer.from(type, 'ascii'); - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - typeBytes.copy(out, 4); - data.copy(out, 8); - out.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length); - return out; -} - -function crc32(data: Buffer): number { - let crc = 0xffffffff; - for (const byte of data) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - return (crc ^ 0xffffffff) >>> 0; -} diff --git a/tests/e2e/backup-restore.spec.ts b/tests/e2e/backup-restore.spec.ts deleted file mode 100644 index a3a91506..00000000 --- a/tests/e2e/backup-restore.spec.ts +++ /dev/null @@ -1,1071 +0,0 @@ -import { expectHttpError, test, expect, type Page, type TestInfo } from './diagnostics'; -import { spawnSync } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import zlib from 'node:zlib'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - adminCsrf, - adminLogin, - adminPasswordHash, - createReply, - createThread, - expectSafePage, - expectSafeResponse, - RustChanServer, - sqliteExec, - sqliteQuery, - unlockBoard, - updateBoardSettings, -} from './helpers'; - -const GIB = 1024 * 1024 * 1024; -const SPLIT_SIZE_OPTIONS_GIB = [1, 2, 4, 8, 16, 32, 64] as const; -const TOR_ENV = { - CHAN_TOR_SUPPORT: '1', - CHAN_TOR_BOOTSTRAP_TIMEOUT: '1', -}; - -type BackupStorageMode = 'directory' | 'split_zip'; - -type BackupFileEntry = { - logical_path: string; - runtime_logical_path?: string; - board?: string; - kind: string; - size: number; - sha256: string; - zip_part?: string; - zip_entry_path?: string; -}; - -type BackupManifest = { - format: string; - backup_id: string; - scope: string; - storage_mode: BackupStorageMode; - included_boards: Array<{ short_name: string; name: string }>; - includes: { - database: boolean; - settings: boolean; - uploads: boolean; - thumbnails: boolean; - tor_keys: boolean; - board_exports: boolean; - file_inventory: boolean; - }; - db_snapshot?: { path: string; size: number; sha256: string }; - files: BackupFileEntry[]; - parts: Array<{ - filename: string; - part_index: number; - total_parts: number; - size: number; - sha256: string; - target_part_size: number; - oversized: boolean; - }>; -}; - -type BackupMetadata = { - format: string; - backup_id: string; - scope: string; - storage_mode: BackupStorageMode; - created_at: number; - completed_at?: number; - total_size_bytes: number; - verified: boolean; - part_count: number; - includes_tor_keys: boolean; -}; - -type BackupArtifact = { - ref: string; - root: string; - metadata: BackupMetadata; - manifest: BackupManifest; -}; - -type RepresentativeSite = { - pubThread: number; - imgThread: number; - protectedThread: number; - adminHash: string; - extraUploadRelativePath: string; - globalFaviconLogicalPath: string; - globalFaviconRuntimePath: string; - globalBannerLogicalPath: string; - globalBannerRuntimePath: string; - customThemeSlug: string; -}; - -test.describe('live backup and restore coverage', () => { - test('manual directory full backups restore complete site state into a fresh restarted runtime', async ({ page }, testInfo) => { - const apps: RustChanServer[] = []; - try { - const source = await startApp(testInfo, { env: { CHAN_AUTO_FULL_BACKUP_COPIES: '20' } }); - apps.push(source); - const site = await seedRepresentativeSite(page, source); - - const backup = await createFullBackupViaAdmin(page, source, { storageMode: 'directory' }); - assertFullBackupArtifact(backup, { - storageMode: 'directory', - includesTorKeys: false, - requiredBoards: ['pub', 'img', 'sec'], - }); - assertRepresentativeAssetsInBackup(backup, site); - assertChecksumsValid(backup.root); - - const restored = await startApp(testInfo, { defaultData: false, admin: true }); - apps.push(restored); - await copyBackupToApp(backup, restored); - await restoreSavedFullBackup(page, restored, backup.ref); - await restored.restart(); - - await assertRestoredSiteUsable(page, restored, site); - } finally { - await stopApps(testInfo, apps); - } - }); - - test('manual split full backups cover every exposed split-size option and require complete parts for restore', async ({ page }, testInfo) => { - test.setTimeout(180_000); - const apps: RustChanServer[] = []; - try { - const source = await startApp(testInfo, { env: { CHAN_AUTO_FULL_BACKUP_COPIES: '30' } }); - apps.push(source); - const site = await seedRepresentativeSite(page, source); - await assertSplitSizeOptionsExposed(page, source); - - const splitBackups: BackupArtifact[] = []; - for (const splitSizeGib of SPLIT_SIZE_OPTIONS_GIB) { - const backup = await createFullBackupViaAdmin(page, source, { - storageMode: 'split_zip', - splitSizeGib, - }); - assertFullBackupArtifact(backup, { - storageMode: 'split_zip', - splitSizeGib, - includesTorKeys: false, - requiredBoards: ['pub', 'img', 'sec'], - }); - assertChecksumsValid(backup.root); - assertSplitPartsContainDeclaredEntries(backup); - splitBackups.push(backup); - } - - const targetBackup = splitBackups[0]; - const missingPartRestore = await startApp(testInfo, { defaultData: false, admin: true }); - apps.push(missingPartRestore); - await copyBackupToApp(targetBackup, missingPartRestore); - const firstPart = targetBackup.manifest.parts[0]; - expect(firstPart, 'split backup should have at least one declared part').toBeTruthy(); - await fsp.rm(path.join(backupRoot(missingPartRestore, targetBackup.ref), firstPart.filename)); - - const failedRestore = await restoreSavedFullBackup(page, missingPartRestore, targetBackup.ref, { - expectSuccess: false, - }); - expect(decodedLocation(failedRestore)).toMatch(/missing|not found|Inspect split ZIP part/i); - await page.goto(missingPartRestore.baseURL); - await expectSafePage(page); - expect(sqliteQuery(missingPartRestore, "SELECT COUNT(*) FROM boards WHERE short_name = 'pub';")).toBe('0'); - - await fsp.rm(backupRoot(missingPartRestore, targetBackup.ref), { recursive: true, force: true }); - await copyBackupToApp(targetBackup, missingPartRestore); - await restoreSavedFullBackup(page, missingPartRestore, targetBackup.ref); - await missingPartRestore.restart(); - - await assertRestoredSiteUsable(page, missingPartRestore, site); - } finally { - await stopApps(testInfo, apps); - } - }); - - test('saved board backups restore one board without replacing the rest of the site', async ({ page }, testInfo) => { - const apps: RustChanServer[] = []; - try { - const app = await startApp(testInfo, { env: { CHAN_AUTO_FULL_BACKUP_COPIES: '20' } }); - apps.push(app); - await seedRepresentativeSite(page, app); - const otherThread = await createThread(page, app, 'img', { - subject: 'board restore unaffected board', - body: 'this image board content should remain after board restore', - }); - - const boardBackup = await createBoardBackupViaAdmin(page, app, 'pub'); - assertBoardBackupArtifact(boardBackup, 'pub'); - assertChecksumsValid(boardBackup.root); - - const afterBackupThread = await createThread(page, app, 'pub', { - subject: 'board restore should remove this', - body: 'created after board backup', - }); - await restoreSavedBoardBackup(page, app, boardBackup.ref); - await app.restart(); - - await page.goto(`${app.baseURL}/pub/catalog`); - await expect(page.locator('body')).toContainText('backup public thread'); - await expect(page.locator('body')).not.toContainText('board restore should remove this'); - await expectSafePage(page); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${afterBackupThread};`)).toBe('0'); - - await page.goto(`${app.baseURL}/img/thread/${otherThread}`); - await expect(page.locator('body')).toContainText('this image board content should remain after board restore'); - await expectSafePage(page); - } finally { - await stopApps(testInfo, apps); - } - }); - - test('automated full backups write scheduled artifacts with the configured storage mode', async ({ page }, testInfo) => { - test.setTimeout(150_000); - const apps: RustChanServer[] = []; - try { - const app = await startApp(testInfo, { env: { CHAN_AUTO_FULL_BACKUP_COPIES: '5' } }); - apps.push(app); - await seedRepresentativeSite(page, app); - - const sentinel = await createFullBackupViaAdmin(page, app, { storageMode: 'directory' }); - ageBackupForScheduler(sentinel.root, 3 * 3600); - await updateAutomaticBackupSettings(page, app, { - intervalHours: 1, - copiesToKeep: 5, - storageMode: 'split_zip', - splitSizeGib: 1, - }); - - const scheduledRef = await waitForNewBackupRef(app, new Set([sentinel.ref]), 90_000); - // backup.json becomes visible before finalization writes and refreshes - // checksums. Wait for the finished artifact, including final metadata. - await expect(() => { - const scheduled = readBackupArtifact(app, scheduledRef); - expect(scheduled.metadata.total_size_bytes).toBeGreaterThan(0); - assertFullBackupArtifact(scheduled, { - storageMode: 'split_zip', - splitSizeGib: 1, - includesTorKeys: false, - requiredBoards: ['pub', 'img', 'sec'], - }); - assertChecksumsValid(scheduled.root); - }).toPass({ timeout: 10_000, intervals: [100, 250, 500] }); - } finally { - await stopApps(testInfo, apps); - } - }); - - test('Tor hidden service keys are included, excluded, and restored only when explicitly requested', async ({ page }, testInfo) => { - test.setTimeout(180_000); - const apps: RustChanServer[] = []; - try { - const source = await startApp(testInfo, { - env: { ...TOR_ENV, CHAN_AUTO_FULL_BACKUP_COPIES: '20' }, - }); - apps.push(source); - await seedRepresentativeSite(page, source); - await writeTorKeys(source, 'source'); - - await adminLogin(page, source); - await page.goto(`${source.baseURL}/admin/panel?open=full-backup-restore#full-backup-restore`); - await expect(page.locator('#full-backup-create-form input[name="include_tor_hidden_service_keys"]')).toHaveCount(1); - - const withoutTor = await createFullBackupViaAdmin(page, source, { - storageMode: 'directory', - includeTorKeys: false, - }); - assertFullBackupArtifact(withoutTor, { - storageMode: 'directory', - includesTorKeys: false, - requiredBoards: ['pub', 'img', 'sec'], - }); - assertNoTorKeysInBackup(withoutTor); - - const withTor = await createFullBackupViaAdmin(page, source, { - storageMode: 'directory', - includeTorKeys: true, - }); - assertFullBackupArtifact(withTor, { - storageMode: 'directory', - includesTorKeys: true, - requiredBoards: ['pub', 'img', 'sec'], - }); - assertTorKeysInBackup(withTor); - assertChecksumsValid(withTor.root); - - const restored = await startApp(testInfo, { - defaultData: false, - admin: true, - env: TOR_ENV, - }); - apps.push(restored); - await writeTorKeys(restored, 'current'); - const currentKeys = await readTorKeys(restored); - await copyBackupToApp(withoutTor, restored); - const noKeyFailure = await restoreSavedFullBackup(page, restored, withoutTor.ref, { - restoreTorKeys: true, - expectSuccess: false, - }); - expect(decodedLocation(noKeyFailure)).toContain('does not include Tor hidden service keys'); - expect(await readTorKeys(restored)).toEqual(currentKeys); - - await copyBackupToApp(withTor, restored); - await restoreSavedFullBackup(page, restored, withTor.ref); - expect(await readTorKeys(restored)).toEqual(currentKeys); - - await restoreSavedFullBackup(page, restored, withTor.ref, { restoreTorKeys: true }); - const restoredKeys = await readTorKeys(restored); - expect(restoredKeys).toEqual(await readTorKeys(source)); - assertTorKeyPathsStayInsideRuntime(restored); - } finally { - await stopApps(testInfo, apps); - } - }); - - test('restore rejects invalid archives and leaves the live site usable', async ({ page }, testInfo) => { - const apps: RustChanServer[] = []; - try { - const app = await startApp(testInfo); - apps.push(app); - const threadId = await createThread(page, app, 'pub', { - subject: 'restore failure baseline', - body: 'this should remain after failed restore', - }); - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const response = await page.request.post(`${app.baseURL}/admin/restore`, { - multipart: { - _csrf: csrf, - backup_file: { - name: 'not-a-backup.zip', - mimeType: 'application/zip', - buffer: Buffer.from('not a zip with ../traversal'), - }, - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect([200, 302, 303, 400]).toContain(response.status()); - if (response.status() === 200 || response.status() === 400) { - await expectSafeResponse(response); - } - await page.goto(`${app.baseURL}/pub/thread/${threadId}`); - await expect(page.locator('body')).toContainText('this should remain after failed restore'); - await expectSafePage(page); - } finally { - await stopApps(testInfo, apps); - } - }); -}); - -async function startApp( - testInfo: TestInfo, - options: { - defaultData?: boolean; - admin?: boolean; - env?: Record; - } = {}, -): Promise { - const app = await RustChanServer.create(undefined, { env: options.env ?? {} }); - try { - if (options.defaultData !== false) { - await app.initializeDefaultData(); - } else if (options.admin) { - app.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - } - await app.start(); - testInfo.annotations.push({ type: 'rustchan-root', description: app.rootDir }); - return app; - } catch (error) { - await app.dispose(); - throw error; - } -} - -async function stopApps(testInfo: TestInfo, apps: RustChanServer[]): Promise { - for (const app of apps.reverse()) { - if (testInfo.status !== testInfo.expectedStatus) { - await testInfo.attach(`rustchan-${app.port}.log`, { - body: await app.logs(), - contentType: 'text/plain', - }); - } - await app.dispose(); - } -} - -async function seedRepresentativeSite(page: Page, app: RustChanServer): Promise { - app.createBoardCli({ short: 'sec', name: 'Secure Board', description: 'Password-protected board' }); - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const siteSettings = await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: csrf, - site_name: 'RustChan Backup E2E', - site_subtitle: 'restored backup subtitle', - default_theme: 'blue-sky', - homepage_new_thread_badges_enabled: '1', - homepage_new_reply_badges_enabled: '1', - thread_new_reply_badges_enabled: '1', - banner_rotation_interval_minutes: '5', - }, - maxRedirects: 0, - }); - expect(siteSettings.status()).toBe(303); - await updateBoardSettings(page, app, 'sec', { - name: 'Secure Board', - description: 'Password-protected board', - accessMode: 'view_password', - accessPassword: 'secret-passphrase', - allowImages: true, - }); - - const files = app.fixtures(); - const pubThread = await createThread(page, app, 'pub', { - subject: 'backup public thread', - body: 'public op body before backup', - }); - await createReply(page, app, 'pub', pubThread, 'public reply before backup'); - const imgThread = await createThread(page, app, 'img', { - subject: 'backup image thread', - body: 'image op body before backup', - filePath: files.tinyPng, - }); - await createReply(page, app, 'img', imgThread, 'image reply before backup'); - const protectedThread = await createThread(page, app, 'sec', { - subject: 'backup protected thread', - body: 'protected board body before backup', - }); - await createReply(page, app, 'sec', protectedThread, 'protected reply before backup'); - - const extraUploadRelativePath = 'img/e2e-extra-split-payload.png'; - await writeDeterministicUpload(app, extraUploadRelativePath, 2 * 1024 * 1024); - const siteAssets = await seedGlobalSiteAssets(page, app); - const customThemeSlug = 'backup-custom'; - sqliteExec(app, [ - "INSERT INTO themes (slug, display_name, description, swatch_hex, enabled, sort_order, is_builtin, custom_css)", - "VALUES ('backup-custom', 'Backup Custom Theme', 'theme restored by backup smoke', '#336699', 1, 9999, 0, 'body { --backup-theme-smoke: #336699; }')", - "ON CONFLICT(slug) DO UPDATE SET display_name = excluded.display_name, custom_css = excluded.custom_css, enabled = 1;", - "INSERT INTO site_settings (key, value) VALUES ('default_theme', 'backup-custom')", - "ON CONFLICT(key) DO UPDATE SET value = excluded.value;", - ].join(' ')); - await expectMediaFilesExist(app); - - return { - pubThread, - imgThread, - protectedThread, - adminHash: adminPasswordHash(app), - extraUploadRelativePath, - globalFaviconLogicalPath: siteAssets.globalFaviconLogicalPath, - globalFaviconRuntimePath: siteAssets.globalFaviconRuntimePath, - globalBannerLogicalPath: siteAssets.globalBannerLogicalPath, - globalBannerRuntimePath: siteAssets.globalBannerRuntimePath, - customThemeSlug, - }; -} - -async function seedGlobalSiteAssets(page: Page, app: RustChanServer): Promise<{ - globalFaviconLogicalPath: string; - globalFaviconRuntimePath: string; - globalBannerLogicalPath: string; - globalBannerRuntimePath: string; -}> { - const assetDir = path.join(app.rootDir, 'site-asset-fixtures'); - await fsp.mkdir(assetDir, { recursive: true }); - const faviconPath = path.join(assetDir, 'e2e-favicon.png'); - const bannerPath = path.join(assetDir, 'e2e-banner.png'); - await fsp.writeFile(faviconPath, pngRgba(512, 512, (x, y) => [x % 256, y % 256, 96, 255])); - await fsp.writeFile(bannerPath, pngRgba(468, 60, (x, y) => [40 + (x % 90), 80 + (y % 90), 150, 255])); - const sameOriginHeaders = { - Origin: app.baseURL, - Referer: `${app.baseURL}/admin/panel`, - }; - - const faviconResponse = await page.request.post(`${app.baseURL}/admin/site/favicon`, { - multipart: { - _csrf: await adminCsrf(page, app), - favicon: { - name: 'e2e-favicon.png', - mimeType: 'image/png', - buffer: await fsp.readFile(faviconPath), - }, - }, - headers: sameOriginHeaders, - maxRedirects: 0, - }); - expect(faviconResponse.status()).toBe(303); - const globalFaviconRuntimePath = 'favicon-32x32.png'; - expect(fs.existsSync(path.join(app.dataDir, 'runtime', 'favicon', globalFaviconRuntimePath))).toBe(true); - - const bannerResponse = await page.request.post(`${app.baseURL}/admin/site/banner`, { - multipart: { - _csrf: await adminCsrf(page, app), - target_type: 'none', - enabled: '1', - show_on_index: '1', - show_on_catalog: '1', - banner: { - name: 'e2e-banner.png', - mimeType: 'image/png', - buffer: await fsp.readFile(bannerPath), - }, - }, - headers: sameOriginHeaders, - maxRedirects: 0, - }); - expect(bannerResponse.status()).toBe(303); - const bannerStorageKey = sqliteQuery( - app, - "SELECT storage_key FROM banner_assets WHERE scope_type = 'global' ORDER BY id DESC LIMIT 1;", - ).trim(); - expect(bannerStorageKey).toMatch(/^[0-9a-f]{32}$/); - const globalBannerRuntimePath = `global/${bannerStorageKey}.webp`; - expect(fs.existsSync(path.join(app.dataDir, 'runtime', 'banner', globalBannerRuntimePath))).toBe(true); - - return { - globalFaviconLogicalPath: `site-assets/favicon/${globalFaviconRuntimePath}`, - globalFaviconRuntimePath, - globalBannerLogicalPath: `site-assets/banner/${globalBannerRuntimePath}`, - globalBannerRuntimePath, - }; -} - -async function writeDeterministicUpload(app: RustChanServer, relativePath: string, bytes: number): Promise { - const target = path.join(app.dataDir, 'boards', relativePath); - await fsp.mkdir(path.dirname(target), { recursive: true }); - const buf = Buffer.alloc(bytes); - for (let index = 0; index < buf.length; index += 1) { - buf[index] = (index * 31 + 17) % 256; - } - await fsp.writeFile(target, buf); -} - -async function expectMediaFilesExist(app: RustChanServer): Promise { - await expect.poll(() => { - const count = Number(sqliteQuery(app, 'SELECT COUNT(*) FROM posts WHERE file_path IS NOT NULL;')); - return count; - }, { timeout: 10_000 }).toBeGreaterThan(0); - - const paths = sqliteQuery( - app, - "SELECT file_path FROM posts WHERE file_path IS NOT NULL UNION SELECT thumb_path FROM posts WHERE thumb_path IS NOT NULL;", - ).split('\n').filter(Boolean); - expect(paths.length).toBeGreaterThan(0); - for (const rel of paths) { - expect(fs.existsSync(path.join(app.dataDir, 'boards', rel)), `restored media path ${rel}`).toBe(true); - } -} - -async function assertRestoredSiteUsable(page: Page, app: RustChanServer, site: RepresentativeSite): Promise { - expect(sqliteQuery(app, "SELECT value FROM site_settings WHERE key = 'site_name';")).toBe('RustChan Backup E2E'); - expect(sqliteQuery(app, "SELECT value FROM site_settings WHERE key = 'site_subtitle';")).toBe('restored backup subtitle'); - expect(sqliteQuery(app, "SELECT access_mode FROM boards WHERE short_name = 'sec';")).toBe('view_password'); - expect(sqliteQuery(app, "SELECT COUNT(*) FROM boards WHERE short_name IN ('pub', 'img', 'sec');")).toBe('3'); - expect(adminPasswordHash(app)).toBe(site.adminHash); - await expectMediaFilesExist(app); - expect(fs.existsSync(path.join(app.dataDir, 'boards', site.extraUploadRelativePath))).toBe(true); - expect(fs.existsSync(path.join(app.dataDir, 'runtime', 'favicon', site.globalFaviconRuntimePath))).toBe(true); - expect(fs.existsSync(path.join(app.dataDir, 'runtime', 'banner', site.globalBannerRuntimePath))).toBe(true); - expect(sqliteQuery(app, `SELECT display_name FROM themes WHERE slug = '${site.customThemeSlug}';`)).toBe('Backup Custom Theme'); - - await page.context().clearCookies(); - await page.goto(app.baseURL); - await expect(page.locator('body')).toContainText('RustChan Backup E2E'); - await expect(page.locator('body')).toContainText('restored backup subtitle'); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', site.customThemeSlug); - await expectSafePage(page); - - const customThemeCss = await page.request.get(`${app.baseURL}/theme-css/${site.customThemeSlug}`); - expect(customThemeCss.status()).toBe(200); - expect(await customThemeCss.text()).toContain('--backup-theme-smoke'); - - await page.goto(`${app.baseURL}/pub/thread/${site.pubThread}`); - await expect(page.locator('body')).toContainText('backup public thread'); - await expect(page.locator('body')).toContainText('public reply before backup'); - await expectSafePage(page); - - await page.goto(`${app.baseURL}/img/thread/${site.imgThread}`); - await expect(page.locator('body')).toContainText('backup image thread'); - await expect(page.locator('body')).toContainText('image reply before backup'); - expect(await page.locator('a[href*="/img/"], img').count()).toBeGreaterThan(0); - await expectSafePage(page); - - await page.context().clearCookies(); - // The representative site keeps /sec/ behind a view password. Navigating to it - // without the unlock cookie is refused with 403, and that gate is asserted on - // the next line, so the refusal is expected rather than an unexpected error. - expectHttpError({ - method: 'GET', - path: '/sec', - status: 403, - reason: 'locked representative board must refuse an unlocked navigation', - }); - await page.goto(`${app.baseURL}/sec`); - await expect(page.locator('body')).toContainText(/password|unlock/i); - await unlockBoard(page, app, 'sec', 'secret-passphrase'); - await page.goto(`${app.baseURL}/sec/thread/${site.protectedThread}`); - await expect(page.locator('body')).toContainText('backup protected thread'); - await expect(page.locator('body')).toContainText('protected reply before backup'); - await expectSafePage(page); - - await adminLogin(page, app); - await expect(page.locator('body')).toContainText('RustChan Backup E2E'); -} - -async function assertSplitSizeOptionsExposed(page: Page, app: RustChanServer): Promise { - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=full-backup-restore#full-backup-restore`); - const expected = SPLIT_SIZE_OPTIONS_GIB.map(String); - const manual = await page - .locator('#full-backup-create-form select[name="split_zip_part_size_gib"] option') - .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); - const automatic = await page - .locator('form.full-backup-settings-form select[name="auto_full_backup_split_zip_part_size_gib"] option') - .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); - expect(manual).toEqual(expected); - expect(automatic).toEqual(expected); -} - -async function createFullBackupViaAdmin( - page: Page, - app: RustChanServer, - options: { - storageMode: BackupStorageMode; - splitSizeGib?: number; - includeTorKeys?: boolean; - }, -): Promise { - await adminLogin(page, app); - const before = new Set(await listBackupRefs(app)); - const csrf = await adminCsrf(page, app); - const form: Record = { - _csrf: csrf, - storage_mode: options.storageMode, - }; - if (options.storageMode === 'split_zip') { - form.split_zip_part_size_gib = String(options.splitSizeGib ?? 4); - } - if (options.includeTorKeys) { - form.include_tor_hidden_service_keys = '1'; - } - const response = await page.request.post(`${app.baseURL}/admin/backup/create`, { - form, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - const ref = await waitForNewBackupRef(app, before, 10_000); - return readBackupArtifact(app, ref); -} - -async function createBoardBackupViaAdmin(page: Page, app: RustChanServer, board: string): Promise { - await adminLogin(page, app); - const before = new Set(await listBackupRefs(app)); - const csrf = await adminCsrf(page, app); - const response = await page.request.post(`${app.baseURL}/admin/board/backup/create`, { - form: { - _csrf: csrf, - board_short: board, - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - const ref = await waitForNewBackupRef(app, before, 10_000); - return readBackupArtifact(app, ref); -} - -async function updateAutomaticBackupSettings( - page: Page, - app: RustChanServer, - settings: { - intervalHours: number; - copiesToKeep: number; - storageMode: BackupStorageMode; - splitSizeGib: number; - includeTorKeys?: boolean; - }, -): Promise { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const response = await page.request.post(`${app.baseURL}/admin/backup/settings`, { - form: { - _csrf: csrf, - auto_full_backup_interval_hours: String(settings.intervalHours), - auto_full_backup_copies_to_keep: String(settings.copiesToKeep), - auto_full_backup_storage_mode: settings.storageMode, - auto_full_backup_split_zip_part_size_gib: String(settings.splitSizeGib), - ...(settings.includeTorKeys ? { auto_full_backup_include_tor_hidden_service_keys: '1' } : {}), - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); -} - -async function restoreSavedFullBackup( - page: Page, - app: RustChanServer, - backupRef: string, - options: { restoreTorKeys?: boolean; expectSuccess?: boolean } = {}, -): Promise { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const response = await page.request.post(`${app.baseURL}/admin/backup/restore-saved`, { - form: { - _csrf: csrf, - filename: backupRef, - ...(options.restoreTorKeys ? { restore_tor_hidden_service_keys: '1' } : {}), - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - const location = response.headers().location ?? ''; - if (options.expectSuccess === false) { - expect(location).toContain('restore_error='); - return location; - } - expect(location).toContain('restored=1'); - return location; -} - -async function restoreSavedBoardBackup(page: Page, app: RustChanServer, backupRef: string): Promise { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const response = await page.request.post(`${app.baseURL}/admin/board/backup/restore-saved`, { - form: { - _csrf: csrf, - filename: backupRef, - }, - headers: { Origin: app.baseURL }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(response.headers().location ?? '').toContain('open=board-backup-restore'); -} - -async function listBackupRefs(app: RustChanServer): Promise { - const root = path.join(app.dataDir, 'backups'); - const entries = await fsp.readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return []; - throw error; - }); - return entries - .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'backup.json'))) - .map((entry) => entry.name) - .sort(); -} - -async function waitForNewBackupRef(app: RustChanServer, before: Set, timeout: number): Promise { - let latest: string | undefined; - await expect.poll(async () => { - const refs = await listBackupRefs(app); - const created = refs.filter((ref) => !before.has(ref)); - latest = created.sort().at(-1); - return created.length; - }, { timeout, intervals: [250, 500, 1_000, 2_000] }).toBeGreaterThan(0); - if (!latest) { - throw new Error('new backup was not found'); - } - return latest; -} - -function backupRoot(app: RustChanServer, backupRef: string): string { - return path.join(app.dataDir, 'backups', backupRef); -} - -function readBackupArtifact(app: RustChanServer, backupRef: string): BackupArtifact { - const root = backupRoot(app, backupRef); - return { - ref: backupRef, - root, - metadata: readJson(path.join(root, 'backup.json')), - manifest: readJson(path.join(root, 'manifest.json')), - }; -} - -async function copyBackupToApp(backup: BackupArtifact, app: RustChanServer): Promise { - const destination = backupRoot(app, backup.ref); - await fsp.rm(destination, { recursive: true, force: true }); - await fsp.mkdir(path.dirname(destination), { recursive: true }); - await fsp.cp(backup.root, destination, { recursive: true }); -} - -function assertFullBackupArtifact( - backup: BackupArtifact, - expected: { - storageMode: BackupStorageMode; - splitSizeGib?: number; - includesTorKeys: boolean; - requiredBoards: string[]; - }, -): void { - expect(backup.metadata.format).toBe('rustchan-backup-v4'); - expect(backup.metadata.backup_id).toBe(backup.ref); - expect(backup.metadata.scope).toBe('full_site'); - expect(backup.metadata.storage_mode).toBe(expected.storageMode); - expect(backup.metadata.verified).toBe(true); - expect(backup.metadata.includes_tor_keys).toBe(expected.includesTorKeys); - expect(backup.manifest.format).toBe('rustchan-backup-v4'); - expect(backup.manifest.backup_id).toBe(backup.ref); - expect(backup.manifest.scope).toBe('full_site'); - expect(backup.manifest.storage_mode).toBe(expected.storageMode); - expect(backup.manifest.includes.database).toBe(true); - expect(backup.manifest.includes.settings).toBe(true); - expect(backup.manifest.includes.uploads).toBe(true); - expect(backup.manifest.includes.thumbnails).toBe(true); - expect(backup.manifest.includes.board_exports).toBe(true); - expect(backup.manifest.includes.tor_keys).toBe(expected.includesTorKeys); - expect(backup.manifest.db_snapshot?.path).toBe('db/rustchan.sqlite3'); - for (const board of expected.requiredBoards) { - expect(backup.manifest.included_boards.some((entry) => entry.short_name === board), `board /${board}/ in backup`).toBe(true); - } - assertManifestFilesPresentOrZipped(backup); - if (expected.storageMode === 'directory') { - expect(backup.manifest.parts).toHaveLength(0); - expect(backup.metadata.part_count).toBe(0); - } else { - expect(backup.manifest.parts.length).toBeGreaterThan(0); - expect(backup.metadata.part_count).toBe(backup.manifest.parts.length); - for (const part of backup.manifest.parts) { - expect(part.target_part_size).toBe((expected.splitSizeGib ?? 4) * GIB); - expect(fs.existsSync(path.join(backup.root, part.filename))).toBe(true); - expect(sha256File(path.join(backup.root, part.filename))).toBe(part.sha256); - } - } -} - -function assertRepresentativeAssetsInBackup(backup: BackupArtifact, site: RepresentativeSite): void { - const settings = backup.manifest.files.find((entry) => entry.logical_path === 'config/settings.toml'); - expect(settings?.kind).toBe('settings'); - for (const [logicalPath, kind] of [ - [site.globalFaviconLogicalPath, 'favicon'], - [site.globalBannerLogicalPath, 'banner'], - ] as const) { - const entry = backup.manifest.files.find((candidate) => candidate.logical_path === logicalPath); - expect(entry, `${logicalPath} should be included in full backup`).toBeTruthy(); - expect(entry?.kind).toBe(kind); - if (backup.manifest.storage_mode === 'directory') { - expect(fs.existsSync(path.join(backup.root, logicalPath)), `${logicalPath} payload`).toBe(true); - } - } - expect(backup.manifest.files.some((entry) => entry.logical_path.startsWith('boards/img/media/src/')), 'uploaded media should be inventoried').toBe(true); -} - -function assertBoardBackupArtifact(backup: BackupArtifact, board: string): void { - expect(backup.metadata.format).toBe('rustchan-backup-v4'); - expect(backup.metadata.scope).toBe('board'); - expect(backup.metadata.storage_mode).toBe('directory'); - expect(backup.metadata.includes_tor_keys).toBe(false); - expect(backup.manifest.scope).toBe('board'); - expect(backup.manifest.storage_mode).toBe('directory'); - expect(backup.manifest.includes.database).toBe(false); - expect(backup.manifest.includes.uploads).toBe(true); - expect(backup.manifest.included_boards.map((entry) => entry.short_name)).toEqual([board]); - expect(backup.manifest.files.every((entry) => entry.board === board || entry.logical_path.includes(`/${board}/`))).toBe(true); - assertManifestFilesPresentOrZipped(backup); -} - -function assertManifestFilesPresentOrZipped(backup: BackupArtifact): void { - for (const entry of backup.manifest.files) { - expect(entry.logical_path).not.toContain('..'); - expect(entry.logical_path.startsWith('/')).toBe(false); - if (backup.manifest.storage_mode === 'directory') { - const filePath = path.join(backup.root, entry.logical_path); - expect(fs.existsSync(filePath), `backup file ${entry.logical_path}`).toBe(true); - expect(sha256File(filePath), `sha256 for ${entry.logical_path}`).toBe(entry.sha256); - expect(entry.zip_part).toBeUndefined(); - } else { - expect(entry.zip_part, `zip part for ${entry.logical_path}`).toMatch(/^parts\/part-\d{4}\.zip$/); - expect(entry.zip_entry_path).toBe(entry.logical_path); - } - } -} - -function assertSplitPartsContainDeclaredEntries(backup: BackupArtifact): void { - const entriesByPart = new Map(); - for (const entry of backup.manifest.files) { - const part = entry.zip_part; - expect(part).toBeTruthy(); - const list = entriesByPart.get(part!) ?? []; - list.push(entry.zip_entry_path ?? entry.logical_path); - entriesByPart.set(part!, list); - } - for (const part of backup.manifest.parts) { - const zipPath = path.join(backup.root, part.filename); - const zipEntries = zipEntryNames(zipPath); - for (const expectedEntry of entriesByPart.get(part.filename) ?? []) { - expect(zipEntries, `${part.filename} contains ${expectedEntry}`).toContain(expectedEntry); - } - } -} - -function assertChecksumsValid(root: string): void { - const checksumsPath = path.join(root, 'checksums.sha256'); - expect(fs.existsSync(checksumsPath)).toBe(true); - const lines = fs.readFileSync(checksumsPath, 'utf8').split(/\r?\n/).filter(Boolean); - expect(lines.length).toBeGreaterThanOrEqual(3); - for (const line of lines) { - const match = line.match(/^([a-f0-9]{64}) (.+)$/); - expect(match, `checksum line ${line}`).toBeTruthy(); - const [, expectedHash, relativePath] = match!; - expect(relativePath).not.toBe('checksums.sha256'); - const filePath = path.join(root, relativePath); - expect(fs.existsSync(filePath), `checksum target ${relativePath}`).toBe(true); - expect(sha256File(filePath), `checksum for ${relativePath}`).toBe(expectedHash); - } -} - -function pngRgba(width: number, height: number, pixel: (x: number, y: number) => [number, number, number, number]): Buffer { - const raw = Buffer.alloc((width * 4 + 1) * height); - for (let y = 0; y < height; y += 1) { - const rowStart = y * (width * 4 + 1); - raw[rowStart] = 0; - for (let x = 0; x < width; x += 1) { - const [r, g, b, a] = pixel(x, y); - const offset = rowStart + 1 + x * 4; - raw[offset] = r; - raw[offset + 1] = g; - raw[offset + 2] = b; - raw[offset + 3] = a; - } - } - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - return Buffer.concat([ - Buffer.from('\x89PNG\r\n\x1a\n', 'binary'), - pngChunk('IHDR', ihdr), - pngChunk('IDAT', zlib.deflateSync(raw)), - pngChunk('IEND', Buffer.alloc(0)), - ]); -} - -function pngChunk(type: string, data: Buffer): Buffer { - const typeBuffer = Buffer.from(type, 'ascii'); - const length = Buffer.alloc(4); - length.writeUInt32BE(data.length, 0); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0); - return Buffer.concat([length, typeBuffer, data, crc]); -} - -function crc32(buffer: Buffer): number { - let crc = 0xffffffff; - for (const byte of buffer) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - return (crc ^ 0xffffffff) >>> 0; -} - -function assertNoTorKeysInBackup(backup: BackupArtifact): void { - expect(backup.manifest.files.some((entry) => entry.kind === 'tor_key')).toBe(false); - expect(fs.existsSync(path.join(backup.root, 'tor-keys'))).toBe(false); -} - -function assertTorKeysInBackup(backup: BackupArtifact): void { - const torEntries = backup.manifest.files.filter((entry) => entry.kind === 'tor_key'); - expect(torEntries.length).toBeGreaterThanOrEqual(2); - for (const entry of torEntries) { - expect(entry.logical_path).toMatch(/^tor-keys\//); - expect(entry.logical_path).not.toContain('..'); - } -} - -function ageBackupForScheduler(root: string, ageSeconds: number): void { - const now = Math.floor(Date.now() / 1000); - const createdAt = now - ageSeconds - 60; - const completedAt = now - ageSeconds; - const manifestPath = path.join(root, 'manifest.json'); - const metadataPath = path.join(root, 'backup.json'); - const manifest = readJson(manifestPath); - const metadata = readJson(metadataPath); - manifest.created_at = createdAt; - manifest.completed_at = completedAt; - metadata.created_at = createdAt; - metadata.completed_at = completedAt; - fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); -} - -async function writeTorKeys(app: RustChanServer, label: string): Promise { - const dir = torKeyDir(app); - await fsp.mkdir(dir, { recursive: true }); - await fsp.writeFile(path.join(dir, 'hs_ed25519_secret_key'), `${label}-secret-key\n`, { mode: 0o600 }); - await fsp.writeFile(path.join(dir, 'hs_ed25519_public_key'), `${label}-public-key\n`, { mode: 0o600 }); - await fsp.mkdir(path.join(dir, 'nested'), { recursive: true }); - await fsp.writeFile(path.join(dir, 'nested', 'authorized_clients'), `${label}-client\n`, { mode: 0o600 }); -} - -async function readTorKeys(app: RustChanServer): Promise> { - const dir = torKeyDir(app); - const files = await listFilesRecursive(dir); - const result: Record = {}; - for (const file of files) { - const rel = path.relative(dir, file).split(path.sep).join('/'); - result[rel] = await fsp.readFile(file, 'utf8'); - } - return result; -} - -function assertTorKeyPathsStayInsideRuntime(app: RustChanServer): void { - const dir = torKeyDir(app); - for (const file of fs.readdirSync(dir, { recursive: true })) { - const fullPath = path.resolve(dir, String(file)); - expect(fullPath.startsWith(path.resolve(dir))).toBe(true); - expect(fullPath).not.toContain(`..${path.sep}`); - } -} - -function torKeyDir(app: RustChanServer): string { - return path.join(app.dataDir, 'runtime', 'tor', 'state', 'keystore'); -} - -async function listFilesRecursive(root: string): Promise { - const entries = await fsp.readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return []; - throw error; - }); - const files: string[] = []; - for (const entry of entries) { - const fullPath = path.join(root, entry.name); - if (entry.isDirectory()) { - files.push(...await listFilesRecursive(fullPath)); - } else if (entry.isFile()) { - files.push(fullPath); - } - } - return files.sort(); -} - -function zipEntryNames(zipPath: string): string[] { - const result = spawnSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }); - if (result.status !== 0) { - throw new Error(`unzip -Z1 ${zipPath} failed: ${result.stderr || result.stdout}`); - } - return result.stdout.split(/\r?\n/).filter(Boolean); -} - -function readJson(filePath: string): T { - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; -} - -function sha256File(filePath: string): string { - return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); -} - -function decodedLocation(location: string): string { - return decodeURIComponent(location.replace(/\+/g, ' ')); -} diff --git a/tests/e2e/board-settings-phase2.spec.ts b/tests/e2e/board-settings-phase2.spec.ts deleted file mode 100644 index e1a606f2..00000000 --- a/tests/e2e/board-settings-phase2.spec.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { expectHttpError } from './diagnostics'; -import fs from 'node:fs'; -import path from 'node:path'; -import { - adminCsrf, - adminLogin, - boardId, - createBoard, - createThread, - expect, - expectSafePage, - expectSafeResponse, - publicCsrf, - sqliteQuery, - test, - uniqueShort, - unlockBoard, - updateBoardSettings, -} from './helpers'; - -test.describe('phase 2 board settings depth', () => { - test('browser admin flow creates boards and persists deep settings across access modes', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'browser-generic board settings coverage runs on Chromium first'); - - await adminLogin(page, app); - const publicBoard = uniqueShort('bpub', testInfo); - await page.goto(`${app.baseURL}/admin/panel?open=boards#boards`); - const createForm = page.locator('form[action="/admin/board/create"]'); - await createForm.locator('input[name="short_name"]').fill(publicBoard); - await createForm.locator('input[name="name"]').fill('Phase 2 Public'); - await createForm.locator('input[name="description"]').fill('created from the browser admin form'); - await Promise.all([ - page.waitForURL(/\/admin\/panel/), - createForm.getByRole('button', { name: /^create$/i }).click(), - ]); - await page.goto(`${app.baseURL}/admin/panel?open=board-${publicBoard}#board-${publicBoard}`); - await expect(page.locator(`#board-${publicBoard}`)).toContainText(`/${publicBoard}/`); - - const boardCard = page.locator(`#board-${publicBoard}`); - await boardCard.evaluate((node) => { - if (node instanceof HTMLDetailsElement) node.open = true; - }); - const settingsForm = boardCard.locator('form.board-settings-form'); - await settingsForm.locator('input[name="name"]').fill('Phase 2 Public Edited'); - await settingsForm.locator('input[name="description"]').fill(`long description ${'D'.repeat(280)}`); - await settingsForm.locator('input[name="bump_limit"]').fill('2'); - await settingsForm.locator('input[name="max_threads"]').fill('4'); - await settingsForm.locator('input[name="max_archived_threads"]').fill('6'); - await settingsForm.locator('input[name="post_cooldown_secs"]').fill('1'); - await settingsForm.locator('select[name="access_mode"]').selectOption('public'); - await settingsForm.locator('input[name="nsfw"]').check(); - await settingsForm.locator('input[name="allow_archive"]').check(); - await settingsForm.locator('input[name="allow_images"]').uncheck(); - await settingsForm.locator('input[name="allow_video"]').uncheck(); - await settingsForm.locator('input[name="allow_audio"]').check(); - await settingsForm.locator('input[name="allow_pdf"]').check(); - await settingsForm.locator('input[name="allow_any_files"]').check(); - await settingsForm.locator('input[name="allow_tripcodes"]').uncheck(); - await settingsForm.locator('input[name="allow_editing"]').check(); - await settingsForm.locator('input[name="allow_self_delete"]').check(); - await settingsForm.locator('input[name="allow_video_embeds"]').uncheck(); - await settingsForm.locator('input[name="show_poster_ids"]').check(); - await settingsForm.locator('input[name="collapse_greentext"]').check(); - await Promise.all([ - page.waitForURL(/\/admin\/panel/), - settingsForm.getByRole('button', { name: /^save settings$/i }).click(), - ]); - - // Assert the browser save itself before any API re-post. Previously the - // API call below satisfied the database assertion even if this form had - // silently failed. - const afterUiSave = boardSettingsRow(app, publicBoard); - expect(afterUiSave[0], 'board name saved through the browser form').toBe('Phase 2 Public Edited'); - expect(afterUiSave[2], 'nsfw checked through the browser form').toBe('1'); - expect(afterUiSave[3], 'allow_images unchecked through the browser form').toBe('0'); - expect(afterUiSave[8], 'allow_tripcodes unchecked through the browser form').toBe('0'); - expect(afterUiSave[9], 'allow_editing checked through the browser form').toBe('1'); - - await postBoardSettings(page, app, publicBoard, { - name: 'Phase 2 Public Edited', - description: `long description ${'D'.repeat(280)}`, - nsfw: true, - allowImages: false, - allowVideo: false, - allowAudio: true, - allowPdf: true, - allowAnyFiles: true, - allowArchive: true, - allowTripcodes: false, - allowEditing: true, - allowSelfDelete: true, - allowVideoEmbeds: false, - showPosterIds: true, - collapseGreentext: true, - bumpLimit: 2, - maxThreads: 4, - maxArchivedThreads: 6, - postCooldownSecs: 1, - defaultTheme: 'terminal', - bannerMode: 'none', - accessMode: 'public', - }); - expect(boardSettingsRow(app, publicBoard)).toEqual([ - 'Phase 2 Public Edited', - `long description ${'D'.repeat(280)}`.slice(0, 256), - '1', - '0', - '0', - '1', - '1', - '1', - '0', - '1', - '1', - '0', - '1', - '1', - '2', - '4', - '6', - '1', - 'terminal', - 'none', - 'public', - ]); - - await page.goto(`${app.baseURL}/${publicBoard}`); - await expectSafePage(page); - const uploadDeniedCsrf = await postFormCsrf(page, publicBoard); - const uploadDenied = await page.request.post(`${app.baseURL}/${publicBoard}`, { - multipart: { - _csrf: uploadDeniedCsrf, - submission_token: `image-denied-${Date.now()}`, - subject: 'image denied', - body: 'this should not accept images', - file: { - name: 'tiny.png', - mimeType: 'image/png', - buffer: fs.readFileSync(app.fixtures().tinyPng), - }, - }, - headers: publicSameOriginHeaders(app, `/${publicBoard}`), - maxRedirects: 0, - }); - expect([400, 415, 422]).toContain(uploadDenied.status()); - await expectSafeResponse(uploadDenied); - - const viewBoard = uniqueShort('bview', testInfo); - const postBoard = uniqueShort('bpost', testInfo); - await createBoard(page, app, { short: viewBoard, name: 'View Protected' }); - await createBoard(page, app, { short: postBoard, name: 'Post Protected' }); - await updateBoardSettings(page, app, viewBoard, { - accessMode: 'view_password', - accessPassword: 'view-pass', - allowArchive: true, - }); - await updateBoardSettings(page, app, postBoard, { - accessMode: 'post_password', - accessPassword: 'post-pass', - allowArchive: true, - }); - - await page.context().clearCookies(); - // The view-password gate is the subject of the next assertion. - expectHttpError({ - method: 'GET', - path: `/${viewBoard}`, - status: 403, - reason: 'view-password board refuses an unlocked navigation', - }); - await page.goto(`${app.baseURL}/${viewBoard}`); - await expect(page.locator('body')).toContainText(/password protected/i); - await unlockBoard(page, app, viewBoard, 'view-pass'); - await expectSafePage(page); - - await page.context().clearCookies(); - await page.goto(`${app.baseURL}/${postBoard}`); - await expectSafePage(page); - await expect(page.locator('#board-access-gate')).toContainText(/posting is password protected|unlock posting/i); - const deniedPostCsrf = await postFormCsrf(page, postBoard); - const deniedPost = await page.request.post(`${app.baseURL}/${postBoard}`, { - multipart: { - _csrf: deniedPostCsrf, - submission_token: `post-password-denied-${Date.now()}`, - subject: 'denied', - body: 'missing post password', - }, - headers: publicSameOriginHeaders(app, `/${postBoard}`), - maxRedirects: 0, - }); - expect([302, 303]).toContain(deniedPost.status()); - expect(deniedPost.headers().location).toContain(`/${postBoard}/unlock`); - await unlockBoard(page, app, postBoard, 'post-pass'); - const postPasswordThread = await createThread(page, app, postBoard, { - subject: 'post password accepted', - body: 'posting works after unlock', - }); - await expect(page).toHaveURL(new RegExp(`/${postBoard}/thread/${postPasswordThread}`)); - }); - - test('invalid board inputs, upload caps, delete cleanup, and safe redirects fail closed', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'board validation and cleanup coverage runs on Chromium first'); - - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const duplicate = uniqueShort('bdup', testInfo); - await createBoard(page, app, { short: duplicate, name: 'Duplicate Source' }); - - for (const shortName of ['', '../etc', 'bad/name', 'waytoolong', duplicate]) { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: csrf, - short_name: shortName, - name: 'Invalid Board', - description: 'must fail safely', - }, - maxRedirects: 0, - }); - expect([400, 409, 422]).toContain(response.status()); - await expectSafeResponse(response); - } - - const capBoard = uniqueShort('bcap', testInfo); - await createBoard(page, app, { short: capBoard, name: 'Cap Board' }); - const beforeCaps = sqliteQuery( - app, - `SELECT max_image_size || '|' || max_video_size || '|' || max_audio_size || '|' || max_pdf_size FROM boards WHERE short_name = '${capBoard}';`, - ); - const badCaps = await postRawBoardSettings(page, app, capBoard, { - max_image_size_mb: '0', - max_video_size_mb: '50', - max_audio_size_mb: '150', - max_pdf_size_mb: '8', - }); - expect(badCaps.status()).toBe(400); - await expectSafeResponse(badCaps); - expect(sqliteQuery( - app, - `SELECT max_image_size || '|' || max_video_size || '|' || max_audio_size || '|' || max_pdf_size FROM boards WHERE short_name = '${capBoard}';`, - )).toBe(beforeCaps); - - const redirectBoard = uniqueShort('bred', testInfo); - await createBoard(page, app, { short: redirectBoard, name: 'Redirect Board' }); - const safeRedirect = await page.request.post(`${app.baseURL}/admin/board/reorder`, { - form: { - _csrf: await adminCsrf(page, app), - board_id: String(boardId(app, redirectBoard)), - direction: 'up', - return_to: '//evil.example/phish', - }, - maxRedirects: 0, - }); - expect(safeRedirect.status()).toBe(303); - expect(safeRedirect.headers().location).toBe('/admin/panel'); - - const cleanupBoard = uniqueShort('bdel', testInfo); - await createBoard(page, app, { short: cleanupBoard, name: 'Delete Cleanup' }); - await createThread(page, app, cleanupBoard, { - subject: 'delete cleanup', - body: 'board upload directory should disappear', - filePath: app.fixtures().tinyPng, - }); - const boardDir = path.join(app.dataDir, 'boards', cleanupBoard); - expect(fs.existsSync(boardDir)).toBe(true); - const deleteResponse = await page.request.post(`${app.baseURL}/admin/board/delete`, { - form: { - _csrf: await adminCsrf(page, app), - board_id: String(boardId(app, cleanupBoard)), - }, - maxRedirects: 0, - }); - expect(deleteResponse.status()).toBe(303); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM boards WHERE short_name = '${cleanupBoard}';`)).toBe('0'); - expect(fs.existsSync(boardDir)).toBe(false); - // A deleted board must no longer resolve; the 404 page is asserted next. - expectHttpError({ - method: 'GET', - path: `/${cleanupBoard}`, - status: 404, - reason: 'deleted board route must 404 instead of serving stale content', - }); - await page.goto(`${app.baseURL}/${cleanupBoard}`); - await expect(page.locator('body')).not.toContainText('delete cleanup'); - }); -}); - -type BoardSettingsPost = { - name?: string; - description?: string; - nsfw?: boolean; - allowImages?: boolean; - allowVideo?: boolean; - allowAudio?: boolean; - allowPdf?: boolean; - allowAnyFiles?: boolean; - allowArchive?: boolean; - allowTripcodes?: boolean; - allowEditing?: boolean; - allowSelfDelete?: boolean; - allowVideoEmbeds?: boolean; - showPosterIds?: boolean; - collapseGreentext?: boolean; - bumpLimit?: number; - maxThreads?: number; - maxArchivedThreads?: number; - postCooldownSecs?: number; - defaultTheme?: string; - bannerMode?: 'inherit' | 'none' | 'override'; - accessMode?: 'public' | 'view_password' | 'post_password'; - accessPassword?: string; -}; - -async function postBoardSettings( - page: Parameters[0], - app: Parameters[1], - short: string, - settings: BoardSettingsPost, -): Promise { - const response = await postRawBoardSettings(page, app, short, { - name: settings.name ?? `${short} board`, - description: settings.description ?? '', - bump_limit: String(settings.bumpLimit ?? 300), - max_threads: String(settings.maxThreads ?? 150), - max_archived_threads: String(settings.maxArchivedThreads ?? 150), - post_cooldown_secs: String(settings.postCooldownSecs ?? 0), - max_image_size_mb: '8', - max_video_size_mb: '50', - max_audio_size_mb: '150', - max_pdf_size_mb: '8', - default_theme: settings.defaultTheme ?? '', - banner_mode: settings.bannerMode ?? 'inherit', - access_mode: settings.accessMode ?? 'public', - access_password: settings.accessPassword ?? '', - nsfw: settings.nsfw, - allow_images: settings.allowImages, - allow_video: settings.allowVideo, - allow_audio: settings.allowAudio, - allow_pdf: settings.allowPdf, - allow_any_files: settings.allowAnyFiles, - allow_archive: settings.allowArchive, - allow_tripcodes: settings.allowTripcodes, - allow_editing: settings.allowEditing, - allow_self_delete: settings.allowSelfDelete, - allow_video_embeds: settings.allowVideoEmbeds, - show_poster_ids: settings.showPosterIds, - collapse_greentext: settings.collapseGreentext, - }); - expect(response.status()).toBe(303); -} - -async function postRawBoardSettings( - page: Parameters[0], - app: Parameters[1], - short: string, - fields: Record, -) { - const form: Record = { - _csrf: await adminCsrf(page, app), - board_id: String(boardId(app, short)), - name: typeof fields.name === 'string' ? fields.name : `${short} board`, - description: typeof fields.description === 'string' ? fields.description : '', - bump_limit: typeof fields.bump_limit === 'string' ? fields.bump_limit : '300', - max_threads: typeof fields.max_threads === 'string' ? fields.max_threads : '150', - max_archived_threads: typeof fields.max_archived_threads === 'string' ? fields.max_archived_threads : '150', - post_cooldown_secs: typeof fields.post_cooldown_secs === 'string' ? fields.post_cooldown_secs : '0', - max_image_size_mb: typeof fields.max_image_size_mb === 'string' ? fields.max_image_size_mb : '8', - max_video_size_mb: typeof fields.max_video_size_mb === 'string' ? fields.max_video_size_mb : '50', - max_audio_size_mb: typeof fields.max_audio_size_mb === 'string' ? fields.max_audio_size_mb : '150', - max_pdf_size_mb: typeof fields.max_pdf_size_mb === 'string' ? fields.max_pdf_size_mb : '8', - default_theme: typeof fields.default_theme === 'string' ? fields.default_theme : '', - banner_mode: typeof fields.banner_mode === 'string' ? fields.banner_mode : 'inherit', - access_mode: typeof fields.access_mode === 'string' ? fields.access_mode : 'public', - access_password: typeof fields.access_password === 'string' ? fields.access_password : '', - }; - for (const name of [ - 'nsfw', - 'allow_images', - 'allow_video', - 'allow_audio', - 'allow_pdf', - 'allow_any_files', - 'allow_archive', - 'allow_tripcodes', - 'allow_editing', - 'allow_self_delete', - 'allow_video_embeds', - 'show_poster_ids', - 'collapse_greentext', - ]) { - if (fields[name] === true) form[name] = '1'; - } - return page.request.post(`${app.baseURL}/admin/board/settings`, { form, maxRedirects: 0 }); -} - -function boardSettingsRow(app: Parameters[0], short: string): string[] { - return sqliteQuery( - app, - [ - 'SELECT name, description, nsfw, allow_images, allow_video, allow_audio, allow_pdf, allow_any_files,', - 'allow_tripcodes, allow_editing, allow_self_delete, allow_video_embeds, show_poster_ids, collapse_greentext,', - 'bump_limit, max_threads, max_archived_threads, post_cooldown_secs, default_theme, banner_mode, access_mode', - `FROM boards WHERE short_name = '${short}'`, - ].join(' '), - ).split('|'); -} - -function publicSameOriginHeaders(app: Parameters[1], pathPart: string): Record { - return { - Origin: app.baseURL, - Referer: `${app.baseURL}${pathPart}`, - }; -} - -async function postFormCsrf(page: Parameters[0], board: string): Promise { - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"], [data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - const postInput = page.locator(`form[action="/${board}"] input[name="_csrf"]`).first(); - if (await postInput.count() > 0) { - const value = await postInput.inputValue(); - if (value.length > 0) return value; - } - const values = await page.locator('input[name="_csrf"]').evaluateAll((inputs) => - inputs - .map((input) => input instanceof HTMLInputElement ? input.value : '') - .filter((value) => value.length > 0), - ); - expect(values.length, `csrf token for /${board}/`).toBeGreaterThan(0); - return values[0]; -} diff --git a/tests/e2e/button-sizing.config.ts b/tests/e2e/button-sizing.config.ts deleted file mode 100644 index e82c5243..00000000 --- a/tests/e2e/button-sizing.config.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import { rebrandedFirefoxExecutable } from './firefox-rebrand'; - -const executablePath = rebrandedFirefoxExecutable(); -const firefoxOptions = executablePath ? { launchOptions: { executablePath } } : {}; - -export default defineConfig({ - testDir: '.', - testMatch: 'button-sizing.spec.ts', - globalSetup: './global-setup.ts', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: 0, - workers: process.env.CI ? 2 : undefined, - timeout: 90_000, - expect: { - timeout: 10_000, - }, - outputDir: '../../test-results/e2e/button-sizing-artifacts', - reporter: [ - ['list'], - ['html', { outputFolder: '../../playwright-report/button-sizing', open: 'never' }], - ], - use: { - actionTimeout: 15_000, - navigationTimeout: 20_000, - javaScriptEnabled: true, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - projects: [ - { - name: 'firefox-desktop', - use: { ...devices['Desktop Firefox'], ...firefoxOptions }, - }, - { - name: 'firefox-mobile', - use: { - ...devices['Desktop Firefox'], ...firefoxOptions, - viewport: { width: 390, height: 844 }, - }, - }, - { - name: 'webkit-desktop', - use: { ...devices['Desktop Safari'] }, - }, - { - name: 'webkit-mobile', - use: { ...devices['iPhone 13'] }, - }, - ], -}); diff --git a/tests/e2e/button-sizing.spec.ts b/tests/e2e/button-sizing.spec.ts deleted file mode 100644 index 270bda4b..00000000 --- a/tests/e2e/button-sizing.spec.ts +++ /dev/null @@ -1,393 +0,0 @@ -import type { Locator, Page, TestInfo } from '@playwright/test'; -import { - ADMIN_PASSWORD, - RustChanServer, - adminPasswordHash, - createReply, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - setThreadFixtureState, - test, - unlockBoard, - uniqueShort, - isNoJsProject, -} from './helpers'; - -type ControlMetrics = { - name: string; - width: number; - height: number; - top: number; - bottom: number; - left: number; - right: number; - borderTopWidth: string; - backgroundColor: string; - display: string; -}; - -const DESKTOP_MIN_ACTION_HEIGHT = 30; -const MOBILE_MIN_ACTION_HEIGHT = 38; -// Browser layout reports sub-pixel geometry (Firefox can measure a 30px -// min-height control as 29.999999). The check below guards against controls -// that collapse below the intended interactive size, so a half-pixel tolerance -// absorbs layout rounding without accepting a smaller control. -const GEOMETRY_TOLERANCE_PX = 0.5; - -test.describe('public button and action-control sizing', () => { - test('public action controls stay consistently sized across scripted and no-JS controls', async ({ page, app }, testInfo) => { - const fixture = await seedPublicButtonSizingFixture(page, app, testInfo); - await page.goto(`${app.baseURL}/`); - await expectSafePage(page); - await expectControlStable(page.locator('.home-btn'), 'home button'); - await expectControlStable(page.locator('#theme-picker-btn'), 'theme picker button'); - await expectControlStable(page.locator('.user-preferences-summary'), 'preferences button'); - - await inspectPreferencesControls(page, testInfo); - - await page.goto(`${app.baseURL}/${fixture.board}`); - await expectSafePage(page); - await expectControlStable(page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first(), 'new thread toggle'); - await inspectThreadListControls(page, testInfo); - - await page.goto(`${app.baseURL}/${fixture.board}/catalog`); - await expectSafePage(page); - await inspectCatalogControls(page, testInfo); - - await page.goto(`${app.baseURL}/${fixture.board}/thread/${fixture.threadId}`); - await expectSafePage(page); - await inspectThreadPageControls(page, testInfo); - await inspectReportFlow(page, testInfo); - await inspectEditDeleteFlows(page, app.baseURL, fixture.board, testInfo); - - await page.goto(`${app.baseURL}/${fixture.board}/archive`); - await expectSafePage(page); - await expectControlStable(page.locator(`a[href="/${fixture.board}/thread/${fixture.archivedThreadId}"]`).first(), 'archive thread link'); - - await page.goto(`${app.baseURL}/${fixture.passwordBoard}/unlock`); - await expectSafePage(page); - await expectControlStable(page.getByRole('button', { name: /unlock board/i }), 'password board submit'); - await unlockBoard(page, app, fixture.passwordBoard, ADMIN_PASSWORD); - await expect(page).toHaveURL(new RegExp(`/${fixture.passwordBoard}`)); - }); -}); - -async function seedPublicButtonSizingFixture( - page: Page, - app: RustChanServer, - testInfo: TestInfo, -): Promise<{ board: string; passwordBoard: string; threadId: number; archivedThreadId: number }> { - const board = uniqueShort('btn', testInfo); - const passwordBoard = uniqueShort('pw', testInfo); - app.createBoardCli({ short: board, name: 'Button Sizing', description: 'Public button sizing fixture' }); - app.createBoardCli({ short: passwordBoard, name: 'Password Buttons', description: 'Password prompt fixture' }); - setBoardFixtureSettings(app, board, { - allowEditing: true, - allowSelfDelete: true, - allowImages: true, - allowArchive: true, - maxThreads: 10, - maxArchivedThreads: 4, - }); - setBoardFixtureSettings(app, passwordBoard, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(app), - }); - - const threadId = await createThread(page, app, board, { - subject: 'post action controls', - body: 'OP with media for button sizing', - filePath: app.fixtures().tinyPng, - }); - const opPostId = await firstPostId(page); - await createReply(page, app, board, threadId, `quoting the OP >>${opPostId}`); - await createReply(page, app, board, threadId, 'second reply with own edit and delete controls'); - - const lockedThreadId = await createThread(page, app, board, { - subject: 'locked public state', - body: 'locked state remains visible', - }); - setThreadFixtureState(app, lockedThreadId, { locked: true }); - - const archivedThreadId = await createThread(page, app, board, { - subject: 'archived public state', - body: 'archived state remains visible', - }); - setThreadFixtureState(app, archivedThreadId, { archived: true }); - - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expectSafePage(page); - return { board, passwordBoard, threadId, archivedThreadId }; -} - -async function firstPostId(page: Page): Promise { - const id = await page.locator('.post').first().getAttribute('id'); - const postId = Number(id?.replace(/^p/, '')); - expect(Number.isInteger(postId)).toBeTruthy(); - return postId; -} - -async function inspectPreferencesControls(page: Page, testInfo: TestInfo): Promise { - await page.locator('.user-preferences-summary').click(); - if (isNoJs(testInfo)) { - const preferences = page.locator('.user-preferences-noscript'); - await expect(preferences).toBeVisible(); - await expectControlStable( - preferences.locator('button[name="theme"]').first(), - 'no-JS preferences theme button', - ); - await expectControlStable( - preferences.locator('button[name="show_activity_badges"]').first(), - 'no-JS preferences activity button', - ); - await page.locator('.user-preferences-summary').click(); - return; - } - await expect(page.locator('.user-preferences-form')).toBeVisible(); - await expectControlStable(page.locator('.user-preferences-form select[name="theme"]'), 'preferences theme select'); - await expectControlStable(page.locator('.user-preferences-form input[type="checkbox"]').first(), 'preferences checkbox'); - if (isMobile(testInfo)) { - await expectControlStable(page.locator('.user-preferences-mobile-close'), 'mobile preferences close'); - } - await page.keyboard.press('Escape'); -} - -async function inspectThreadListControls(page: Page, testInfo: TestInfo): Promise { - await expectControlStable(page.locator('.board-nav-link[href$="/catalog"]').first(), 'catalog nav link'); - const paginationLink = page.locator('.pagination a').first(); - if (await paginationLink.count()) { - await expectControlStable(paginationLink, 'pagination link'); - } - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first().click(); - const submit = page.locator('.post-form button[type="submit"]:visible').first(); - await expectControlStable(submit, 'new thread submit'); - await expectUsableActionHeight(submit, testInfo); -} - -async function inspectCatalogControls(page: Page, testInfo: TestInfo): Promise { - if (isNoJs(testInfo)) { - await expect(page.locator('.catalog-thread-menu-toggle').first()).toBeHidden(); - const actions = page.locator('.catalog-thread-fallback-actions').first(); - await expectControlStable(actions.locator('.catalog-thread-fallback-summary'), 'catalog fallback actions summary'); - await expectControlStable(actions.locator('.catalog-thread-fallback-submit').first(), 'catalog fallback action submit'); - await expectReportFallbackControls(actions, 'catalog'); - return; - } - - await page.locator('.catalog-item').first().hover(); - const toggle = page.locator('.catalog-thread-menu-toggle').first(); - await expectControlStable(toggle, 'catalog thread menu toggle'); - await toggle.click(); - const menuItem = page.locator('.catalog-thread-menu-item').first(); - await expect(menuItem).toBeVisible(); - await expectControlStable(menuItem, 'catalog thread menu item'); - await expectNoAwkwardWrap(menuItem, 'catalog thread menu item'); -} - -async function inspectThreadPageControls(page: Page, testInfo: TestInfo): Promise { - if (isNoJsProject(testInfo)) { - await expectControlStable(page.getByRole('link', { name: '[ Update now ]', exact: true }).first(), 'native thread refresh link'); - } else { - await expectControlStable(page.locator('.thread-nav-btn[data-action="fetch-updates"]').first(), 'thread update button'); - await expectControlStable(page.locator('.autoupdate-label input[type="checkbox"]').first(), 'autoupdate checkbox'); - } - await expectControlStable(page.locator('.post-num').first(), 'quote/post number link'); - - const imagePreview = page.locator('.media-preview').first(); - await expectControlStable(imagePreview, 'media preview control', { checkWrap: false }); - if (isNoJs(testInfo)) { - await expect(imagePreview).toHaveAttribute('href', /\/boards\//); - } else { - await imagePreview.click(); - await expect(page.locator('.media-expanded-image, .media-expanded-video, .media-expanded-pdf').first()).toBeVisible(); - const closeButton = page.locator('.media-close-btn').first(); - if (await closeButton.isVisible()) { - await expectControlStable(closeButton, 'media close button'); - } - } - - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first().click(); - const submit = page.locator('.post-form button[type="submit"]:visible').first(); - await expectControlStable(submit, 'reply submit'); - await expectUsableActionHeight(submit, testInfo); - - const controls = page.locator('.reply .post-controls').filter({ has: page.locator('.edit-btn') }).first(); - await expect(controls).toBeVisible(); - const edit = controls.locator('.edit-btn').first(); - const del = controls.locator('.del-btn').first(); - if (isNoJs(testInfo)) { - await expect(page.locator('.post-controls .report-btn').first()).toBeHidden(); - const report = controls.locator('.report-fallback-summary').first(); - const editMetrics = await metrics(edit, 'edit'); - const deleteMetrics = await metrics(del, 'delete'); - const reportMetrics = await metrics(report, 'report fallback'); - - expectSimilarHeight(reportMetrics, editMetrics, 2); - expectSimilarHeight(reportMetrics, deleteMetrics, 2); - await expectUsableActionHeight(report, testInfo); - await expectReportFallbackControls(controls, 'thread'); - return; - } - - const report = controls.locator('.report-btn').first(); - const editMetrics = await metrics(edit, 'edit'); - const deleteMetrics = await metrics(del, 'delete'); - const reportMetrics = await metrics(report, 'report'); - - expectSimilarHeight(reportMetrics, editMetrics, 2); - expectSimilarHeight(reportMetrics, deleteMetrics, 2); - if (!isMobile(testInfo)) { - expectSameRow(reportMetrics, editMetrics, 5); - expectSameRow(reportMetrics, deleteMetrics, 5); - } - expect(reportMetrics.width).toBeLessThanOrEqual(deleteMetrics.width + 16); - expect(reportMetrics.width).toBeGreaterThanOrEqual(editMetrics.width - 8); - expect(reportMetrics.borderTopWidth, 'report action should share post-action border chrome').toBe(editMetrics.borderTopWidth); - expect(reportMetrics.backgroundColor, 'report action should share post-action background').toBe(editMetrics.backgroundColor); - await expectUsableActionHeight(edit, testInfo); - await expectUsableActionHeight(del, testInfo); - await expectUsableActionHeight(report, testInfo); -} - -async function inspectReportFlow(page: Page, testInfo: TestInfo): Promise { - if (isNoJs(testInfo)) { - const fallback = page.locator('.post-controls .report-fallback-form').first(); - await openDetailsIfClosed(fallback.locator('.report-fallback-details').first()); - await expectControlStable(fallback.locator('.report-fallback-reason'), 'report fallback reason'); - await expectControlStable(fallback.locator('.report-fallback-submit'), 'report fallback submit'); - await expectUsableActionHeight(fallback.locator('.report-fallback-submit'), testInfo); - return; - } - - await page.locator('.post-controls .report-btn').first().click(); - await expect(page.locator('#report-modal')).toBeVisible(); - const cancel = page.locator('#report-modal .compress-cancel-btn'); - const submit = page.locator('#report-submit-btn'); - await expectControlStable(cancel, 'report modal cancel'); - await expectControlStable(submit, 'report modal submit'); - expectSimilarHeight(await metrics(cancel, 'report cancel'), await metrics(submit, 'report submit'), 2); - await expectUsableActionHeight(submit, testInfo); - await cancel.click(); - await expect(page.locator('#report-modal')).toBeHidden(); -} - -async function inspectEditDeleteFlows(page: Page, baseURL: string, board: string, testInfo: TestInfo): Promise { - const threadUrl = page.url(); - const edit = page.locator('.post-controls .edit-btn').first(); - if (isNoJs(testInfo)) { - const editHref = await edit.getAttribute('href'); - expect(editHref).toBeTruthy(); - await page.goto(`${baseURL}${editHref}`); - await expectSafePage(page); - await expectControlStable(page.locator('.self-action-form button[type="submit"]'), 'edit fallback save'); - await expectControlStable(page.locator('.self-action-form .edit-btn'), 'edit fallback cancel'); - } else { - await edit.click(); - await expect(page.locator('#edit-modal')).toBeVisible(); - await expectControlStable(page.locator('#edit-modal-form button[type="submit"]'), 'edit modal save'); - await expectControlStable(page.locator('#edit-modal-form [data-action="close-edit-modal"]'), 'edit modal cancel'); - await page.locator('#edit-modal-form [data-action="close-edit-modal"]').click(); - await expect(page.locator('#edit-modal')).toBeHidden(); - } - - await page.goto(threadUrl); - const href = await page.locator('.post-controls .del-btn').first().getAttribute('href'); - expect(href).toBeTruthy(); - await page.goto(`${baseURL}${href}`); - await expectSafePage(page); - await expectControlStable(page.locator('.self-action-form button[type="submit"]'), 'delete confirmation submit'); - await expectControlStable(page.locator('.self-action-form .edit-btn'), 'delete confirmation cancel'); - await page.goto(`${baseURL}/${board}`); -} - -async function expectReportFallbackControls(root: Locator, label: string): Promise { - const fallback = root.locator('.report-fallback-form').first(); - await expect(fallback).toBeVisible(); - await expectControlStable(fallback.locator('.report-fallback-summary'), `${label} report fallback summary`); - await openDetailsIfClosed(fallback.locator('.report-fallback-details').first()); - await expectControlStable(fallback.locator('.report-fallback-reason'), `${label} report fallback reason`); - await expectControlStable(fallback.locator('.report-fallback-submit'), `${label} report fallback submit`); -} - -async function openDetailsIfClosed(details: Locator): Promise { - const open = await details.evaluate((element) => element instanceof HTMLDetailsElement && element.open); - if (!open) { - await details.locator('summary').click(); - } -} - -async function expectControlStable(locator: Locator, name: string, options: { checkWrap?: boolean } = {}): Promise { - const box = await locator.boundingBox(); - expect(box, `${name} should be rendered`).not.toBeNull(); - expect(box!.width, `${name} should not collapse horizontally`).toBeGreaterThan(0); - expect(box!.height, `${name} should not collapse vertically`).toBeGreaterThan(0); - if (options.checkWrap !== false) { - await expectNoAwkwardWrap(locator, name); - } -} - -async function expectNoAwkwardWrap(locator: Locator, name: string): Promise { - const wrapped = await locator.evaluate((element) => { - if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) { - return false; - } - const style = window.getComputedStyle(element); - const lineHeight = Number.parseFloat(style.lineHeight); - const fontSize = Number.parseFloat(style.fontSize); - const usableLineHeight = Number.isFinite(lineHeight) ? lineHeight : fontSize * 1.2; - // Inline links have no client box; their rendered bounds still expose wrapping. - if (style.display === 'inline') { - return element.getBoundingClientRect().height > Math.ceil(usableLineHeight * 3.2); - } - return ( - element.scrollWidth > element.clientWidth + 1 || - element.scrollHeight > Math.ceil(usableLineHeight * 3.2) - ); - }); - expect(wrapped, `${name} should not wrap into an oversized multi-line control`).toBeFalsy(); -} - -async function expectUsableActionHeight(locator: Locator, testInfo: TestInfo): Promise { - const box = await locator.boundingBox(); - expect(box).not.toBeNull(); - const minimum = isMobile(testInfo) ? MOBILE_MIN_ACTION_HEIGHT : DESKTOP_MIN_ACTION_HEIGHT; - expect(box!.height).toBeGreaterThanOrEqual(minimum - GEOMETRY_TOLERANCE_PX); -} - -async function metrics(locator: Locator, name: string): Promise { - return locator.evaluate((element, metricName) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return { - name: metricName, - width: rect.width, - height: rect.height, - top: rect.top, - bottom: rect.bottom, - left: rect.left, - right: rect.right, - borderTopWidth: style.borderTopWidth, - backgroundColor: style.backgroundColor, - display: style.display, - }; - }, name); -} - -function expectSimilarHeight(actual: ControlMetrics, expected: ControlMetrics, tolerance: number): void { - expect(Math.abs(actual.height - expected.height), `${actual.name} height should match ${expected.name}`).toBeLessThanOrEqual(tolerance); -} - -function expectSameRow(actual: ControlMetrics, expected: ControlMetrics, tolerance: number): void { - expect(Math.abs(actual.top - expected.top), `${actual.name} top should align with ${expected.name}`).toBeLessThanOrEqual(tolerance); - expect(Math.abs(actual.bottom - expected.bottom), `${actual.name} bottom should align with ${expected.name}`).toBeLessThanOrEqual(tolerance); -} - -function isMobile(testInfo: TestInfo): boolean { - return testInfo.project.name.includes('mobile'); -} - -function isNoJs(testInfo: TestInfo): boolean { - return isNoJsProject(testInfo); -} diff --git a/tests/e2e/captcha.spec.ts b/tests/e2e/captcha.spec.ts deleted file mode 100644 index 774226d2..00000000 --- a/tests/e2e/captcha.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { expectHttpError, newAuditedContext } from './diagnostics'; -import { - expect, - expectSafePage, - setBoardFixtureSettings, - test, - uniqueShort, -} from './helpers'; - -test.describe('CAPTCHA posting flow', () => { - test('renders image CAPTCHA and rejects bad answers in Chromium and WebKit', async ({ page, app }, testInfo) => { - test.skip(!['chromium', 'webkit'].includes(testInfo.project.name), 'Chromium/WebKit CAPTCHA pass'); - - const board = uniqueShort('cap', testInfo); - app.createBoardCli({ short: board, name: 'Captcha Board' }); - setBoardFixtureSettings(app, board, { allowCaptcha: true, postCooldownSecs: 0 }); - - await page.goto(`${app.baseURL}/${board}`); - await expectSafePage(page); - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - const form = page.locator(`form[action="/${board}"]`).first(); - const captchaId = await form.locator('input[name="captcha_id"]').getAttribute('value'); - expect(captchaId).toMatch(/^[a-f0-9]{32}$/); - await expect(form.locator('input[name="captcha_answer"]')).toBeVisible(); - await expect(form.locator('.captcha-image')).toBeVisible(); - - const imageSrc = await form.locator('.captcha-image').getAttribute('src'); - expect(imageSrc).toMatch(/^\/captcha\/[a-f0-9]{32}\?board=/); - const image = await page.request.get(`${app.baseURL}${imageSrc}`); - expect(image.status()).toBe(200); - expect(image.headers()['content-type']).toContain('image/png'); - expect(image.headers()['cache-control']).toMatch(/private/i); - expect(image.headers()['cache-control']).toMatch(/no-store/i); - expect((await image.body()).length).toBeGreaterThan(100); - - await form.locator('input[name="subject"]').fill('bad captcha'); - await form.locator('textarea[name="body"]').fill('this should not post'); - await form.locator('input[name="captcha_answer"]').fill('WRONG'); - // The rejection surface of the CAPTCHA check is the subject here. It is - // observed as a 422 document response on WebKit and via the enhanced client - // path on Chromium, so the declaration is optional per engine. - expectHttpError({ - method: 'POST', - path: `/${board}`, - status: 422, - optional: true, - reason: 'wrong CAPTCHA answer is rejected and re-rendered with an error', - }); - await form.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/CAPTCHA verification failed/i); - await expect(form.locator('.captcha-refresh-link')).toHaveAttribute('href', new RegExp(`^/${board}\\?captcha_refresh=[a-f0-9]{32}$`)); - }); - - test('no-JS users can reload for a fresh challenge and see server-rendered errors', async ({ browser, app }, testInfo) => { - test.skip(!['chromium', 'webkit'].includes(testInfo.project.name), 'Chromium/WebKit no-JS CAPTCHA pass'); - - const board = uniqueShort('cns', testInfo); - app.createBoardCli({ short: board, name: 'Captcha NoJS' }); - setBoardFixtureSettings(app, board, { allowCaptcha: true, postCooldownSecs: 0 }); - - const context = await newAuditedContext(browser, { javaScriptEnabled: false }); - const page = await context.newPage(); - await page.goto(`${app.baseURL}/${board}`); - await expect(page.locator('html')).toHaveClass(/no-js/); - await expectSafePage(page); - - const form = page.locator(`form[action="/${board}"]`).first(); - const firstCaptchaId = await form.locator('input[name="captcha_id"]').getAttribute('value'); - expect(firstCaptchaId).toMatch(/^[a-f0-9]{32}$/); - await form.locator('.captcha-refresh-link').click(); - await page.waitForURL(new RegExp(`/${board}\\?captcha_refresh=[a-f0-9]{32}$`)); - const refreshedCaptchaId = await page - .locator(`form[action="/${board}"] input[name="captcha_id"]`) - .first() - .getAttribute('value'); - expect(refreshedCaptchaId).toMatch(/^[a-f0-9]{32}$/); - expect(refreshedCaptchaId).not.toBe(firstCaptchaId); - - const refreshedForm = page.locator(`form[action="/${board}"]`).first(); - await refreshedForm.locator('input[name="subject"]').fill('no js captcha'); - await refreshedForm.locator('textarea[name="body"]').fill('server rendered captcha failure'); - await refreshedForm.locator('input[name="captcha_answer"]').fill('WRONG'); - // The server-rendered CAPTCHA error page is exactly what this test asserts. - expectHttpError({ - method: 'POST', - path: `/${board}`, - status: 422, - reason: 'wrong CAPTCHA answer is rejected with the server-rendered error page', - }); - await refreshedForm.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/CAPTCHA verification failed/i); - const errorCaptchaId = await page - .locator(`form[action="/${board}"] input[name="captcha_id"]`) - .first() - .getAttribute('value'); - expect(errorCaptchaId).toMatch(/^[a-f0-9]{32}$/); - expect(errorCaptchaId).not.toBe(refreshedCaptchaId); - await expectSafePage(page); - await context.close(); - }); -}); diff --git a/tests/e2e/comprehensive-ui-audit.spec.ts b/tests/e2e/comprehensive-ui-audit.spec.ts deleted file mode 100644 index 810301e7..00000000 --- a/tests/e2e/comprehensive-ui-audit.spec.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { test, expect, adminLogin, createBoard, createThreadViaRequest, createReplyViaRequest, setBoardFixtureSettings, setThreadFixtureState, sqliteExec, sqliteQuery } from './helpers'; -import { expectConsoleError, expectHttpError, expectTransportFailure } from './diagnostics'; -import { expectNoHorizontalOverflow } from './phase4-helpers'; - -const widths = [320, 360, 390, 430, 768, 1024, 1280, 1440]; - -test('audit: long public surfaces and expanded admin sections fit every release viewport', async ({ page, app }, testInfo) => { - test.setTimeout(180_000); - const token = 'LongUnbrokenContent'.repeat(10); - setBoardFixtureSettings(app, 'pub', { name: token, description: token, allowArchive: true, postCooldownSecs: 0 }); - for (let i = 0; i < 12; i++) app.createBoardCli({ short: `nav${i}`, name: `Navigation ${i} ${token}` }); - await app.restart(); - const id = await createThreadViaRequest(page, app, 'pub', { subject: token, body: `${token}\nhttps://example.test/${token}\n> ${token}` }); - for (let i = 0; i < 12; i++) await createReplyViaRequest(page, app, 'pub', id, `${i} ${token}\n>>${id}`); - const archived = await createThreadViaRequest(page, app, 'pub', { subject: 'archived thread', body: token }); - setThreadFixtureState(app, archived, { archived: true, locked: true }); - sqliteExec(app, `UPDATE threads SET reply_count=2147483647 WHERE id=${id}`); - expectHttpError({ - method: 'GET', - path: '/missing-board', - status: 404, - times: 'any', - reason: 'every release viewport intentionally loads the missing-board 404 page in this loop', - }); - for (const width of widths) { - await page.setViewportSize({ width, height: 844 }); - for (const route of ['/', '/pub', '/pub/catalog', '/pub/hidden', '/pub/archive', `/pub/thread/${id}`, `/pub/thread/${archived}`, '/pub/search?q=' + token, '/missing-board', '/admin']) { - await page.goto(app.baseURL + route); - await expectNoHorizontalOverflow(page, `${width}px ${route}`, 1); - if (route === `/pub/thread/${id}`) await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('2147483647'); - if (width === 320 || width === 1440) await page.screenshot({ path: testInfo.outputPath(`${width}-${route.replace(/[^a-z0-9]/gi, '_').slice(0, 90)}.png`) }); - } - await page.goto(app.baseURL); - const preferences = page.locator('.user-preferences-panel'); - await preferences.locator('summary').first().click(); - await expectNoHorizontalOverflow(page, `${width}px expanded preferences`, 1); - if (width === 320) await page.screenshot({ path: testInfo.outputPath('320-preferences.png') }); - await page.goto(app.baseURL); - const mobile = page.locator('.mobile-board-menu'); - if (await mobile.isVisible()) { - await mobile.locator('summary').click(); - await expectNoHorizontalOverflow(page, `${width}px expanded navigation`, 1); - } - } - await adminLogin(page, app); - // Expand cards within each real task workspace. Hidden tasks intentionally - // remain hidden until selected through the existing navigation. - for (const width of widths) { - await page.setViewportSize({ width, height: 844 }); - for (const section of ['site-settings', 'boards', 'moderation', 'appearance', 'backups', 'maintenance']) { - await page.locator(`.admin-section-index a[href="#${section}"]`).click(); - await page.locator(`#${section} details:not([data-admin-diagnostics])`).evaluateAll(details => { - for (const element of details) { - if (element instanceof HTMLDetailsElement) element.open = true; - } - }); - await expectNoHorizontalOverflow(page, `${width}px expanded admin ${section}`, 1); - const clipped = await page.locator(`#${section}.admin-section, #${section} .admin-section`).evaluateAll(sections => sections - .filter(section => section.getClientRects().length && section.scrollWidth - section.clientWidth > 1) - .map(section => ({ id: section.id, overflow: section.scrollWidth - section.clientWidth }))); - expect(clipped, `${width}px ${section} cards must not clip their content`).toEqual([]); - if ([320, 768, 1440].includes(width)) { - await page.locator(`#${section}`).locator('h2, summary').first().scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath(`${width}-admin-${section}.png`) }); - } - } - } -}); - -test('audit: live pin changes preserve selected uploads and update admin actions', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'live pin controls'); - const id = await createThreadViaRequest(page, app, 'pub', { body: 'pin transition baseline' }); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/pub/thread/${id}`); - await page.locator('[data-action="toggle-post-form"]').first().click(); - const upload = page.locator('#post-form-wrap input[type="file"]').first(); - await upload.setInputFiles(app.fixtures().tinyPng); - await page.locator('#reply-body').fill('draft across pin changes'); - for (const sticky of [true, false]) { - setThreadFixtureState(app, id, { sticky }); - await page.locator('[data-action="fetch-updates"]').first().click(); - await expect(page.locator('.post.op .thread-state-badge-pin')).toHaveCount(sticky ? 1 : 0); - await expect(page.locator('.admin-toolbar input[name="action"]').first()).toHaveValue(sticky ? 'unsticky' : 'sticky'); - await expect(page.locator('#reply-body')).toHaveValue('draft across pin changes'); - await expect.poll(() => upload.evaluate((input: HTMLInputElement) => input.files?.length)).toBe(1); - } -}); - -test('audit: malformed admin hash does not stop initialization or section navigation', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'JavaScript fragment decoding'); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel#%`); - await page.locator('#live-log > details > summary').click(); - await expect(page.locator('[data-admin-live-log-controls]')).not.toHaveAttribute('hidden', ''); - await page.locator('.admin-section-index a[href="#boards"]').click(); - await expect(page.locator('#boards details').first()).toHaveAttribute('open', ''); -}); - -test('audit: delayed preference save cannot overwrite a newer selection', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'enhanced preference saves'); - await page.goto(app.baseURL); - await page.locator('.user-preferences-summary').click(); - let release!: () => void; - const gate = new Promise(resolve => { release = resolve; }); - let started!: () => void; - const firstStarted = new Promise(resolve => { started = resolve; }); - let requests = 0; - await page.route(`${app.baseURL}/preferences`, async route => { - requests++; - if (requests === 1) { started(); await gate; } - await route.continue(); - }); - try { - const select = page.locator('.user-preferences-form select[name="theme"]'); - await select.selectOption('blue-sky'); - await firstStarted; - await select.selectOption('terminal'); - await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'blue-sky'); - // The latest selection remains pending until the older request is settled. - await expect(page.locator('.user-preferences-status')).toHaveText('Saving…'); - release(); - await expect(page.locator('.user-preferences-status')).toHaveText('Saved.'); - await expect.poll(async () => (await page.context().cookies()).find(c => c.name === 'rustchan_theme')?.value).toBe('terminal'); - await page.reload(); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'terminal'); - } finally { release(); await page.unrouteAll({ behavior: 'wait' }); } -}); - -test('audit: stale cross-board preview response does not replace the current quote', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'quote hover previews'); - const first = await createThreadViaRequest(page, app, 'txt', { body: 'first quote content' }); - const second = await createThreadViaRequest(page, app, 'txt', { body: 'second quote content' }); - const firstPost = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id=${first}`)); - const secondPost = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id=${second}`)); - const thread = await createThreadViaRequest(page, app, 'pub', { body: `>>>/txt/${firstPost}\n>>>/txt/${secondPost}` }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - let release!: () => void; - const gate = new Promise(resolve => { release = resolve; }); - let started!: () => void; - const firstStarted = new Promise(resolve => { started = resolve; }); - await page.route(`${app.baseURL}/api/post/txt/${firstPost}`, async route => { started(); await gate; await route.continue(); }); - try { - await page.locator(`#thread-posts a.crosslink[data-pid="${firstPost}"]`).hover(); - await firstStarted; - await page.locator('.site-header').hover({ position: { x: 4, y: 4 } }); - await expect(page.locator('#ql-popup')).toBeHidden(); - await page.locator(`#thread-posts a.crosslink[data-pid="${secondPost}"]`).hover(); - await expect(page.locator('#ql-popup')).toContainText('second quote content'); - const response = page.waitForResponse(`${app.baseURL}/api/post/txt/${firstPost}`); - release(); - await (await response).finished(); - // Rewritten href is applied by the same response callback that updates the popup. - await expect(page.locator(`#thread-posts a.crosslink[data-pid="${firstPost}"]`)).toHaveAttribute('href', `/txt/thread/${first}#p${firstPost}`); - await expect(page.locator('#ql-popup')).toContainText('second quote content'); - await expect(page.locator('#ql-popup')).not.toContainText('first quote content'); - } finally { release(); await page.unrouteAll({ behavior: 'wait' }); } -}); - -test('audit: valid cross-board quotes are not marked missing or wired as local quotes', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'quote enhancement'); - const other = await createThreadViaRequest(page, app, 'txt', { body: 'real external-board post' }); - const post = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id=${other}`)); - const thread = await createThreadViaRequest(page, app, 'pub', { body: `>>>/txt/${post}` }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - const link = page.locator('#thread-posts a.crosslink'); - await expect(link).not.toHaveClass(/missing-post-ref/); - await link.click(); - await expect(page).toHaveURL(`${app.baseURL}/txt/thread/${other}#p${post}`); - await expect(page.locator('.post-body')).toContainText('real external-board post'); -}); - -test('audit: thread updates refresh both desktop and mobile board navigation', async ({ page, context, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'live navigation refresh'); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'live navigation baseline' }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - const admin = await context.newPage(); - try { await createBoard(admin, app, { short: 'newnav', name: 'New navigation board' }); } - finally { await admin.close(); } - await page.locator('[data-action="fetch-updates"]').first().click(); - await expect(page.locator('nav.board-list a[href="/newnav/catalog"]')).toHaveCount(1); - await page.setViewportSize({ width: 320, height: 844 }); - await page.locator('.mobile-board-menu > summary').click(); - await expect(page.locator('.mobile-board-menu-panel a[href="/newnav/catalog"]')).toBeVisible(); - await expectNoHorizontalOverflow(page, 'updated mobile navigation', 1); -}); - -test('audit: thread updates reflect lock and unlock transitions while preserving the draft', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'live thread state'); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'lock transition baseline' }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - const toggle = page.locator('[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) await toggle.click(); - await page.locator('#reply-body').fill('draft survives a moderator lock'); - setThreadFixtureState(app, thread, { locked: true }); - await page.locator('[data-action="fetch-updates"]').first().click(); - await expect(page.locator('.locked-notice')).toContainText('locked'); - await expect(page.locator('#post-form-wrap form.post-form')).toHaveCount(0); - await expect(page.locator('.self-action-controls')).toHaveCount(0); - setThreadFixtureState(app, thread, { locked: false }); - await page.locator('[data-action="fetch-updates"]').first().click(); - await expect(page.locator('#reply-body')).toHaveValue('draft survives a moderator lock'); - await expect(page.locator('.locked-notice')).toHaveCount(0); -}); - -test('audit: closed threads hide unavailable owner edit and delete actions', async ({ page, app }) => { - setBoardFixtureSettings(app, 'pub', { allowEditing: true, allowSelfDelete: true, allowArchive: true }); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'owner controls baseline' }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - await expect(page.locator('.self-action-controls .edit-btn')).toBeVisible(); - await expect(page.locator('.self-action-controls .del-btn')).toBeVisible(); - for (const state of [{ locked: true, archived: false }, { locked: true, archived: true }]) { - setThreadFixtureState(app, thread, state); - await page.reload(); - await expect(page.locator('.locked-notice')).toBeVisible(); - await expect(page.locator('.self-action-controls')).toHaveCount(0); - } - setThreadFixtureState(app, thread, { locked: false, archived: false }); - await createReplyViaRequest(page, app, 'pub', thread, 'an OP with replies cannot be self-deleted'); - await page.reload(); - await expect(page.locator('.post.op .edit-btn')).toBeVisible(); - await expect(page.locator('.post.op .del-btn')).toHaveCount(0); -}); - -test('audit: report dialog keeps forward and reverse keyboard focus inside its controls', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'modal keyboard interaction'); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'keyboard modal baseline' }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - const trigger = page.locator('.report-btn').first(); - await trigger.click(); - const dialog = page.locator('#report-modal'); - const first = dialog.locator('#report-reason'); - const last = dialog.getByRole('button').last(); - await first.focus(); - await page.keyboard.press('Shift+Tab'); - await expect(last).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(first).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(dialog).toBeHidden(); - await expect(trigger).toBeFocused(); -}); - -test('audit: transient cross-board preview failures can be retried without reloading', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'quote previews'); - const target = await createThreadViaRequest(page, app, 'txt', { body: 'quote after network recovery' }); - const post = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id=${target}`)); - const thread = await createThreadViaRequest(page, app, 'pub', { body: `>>>/txt/${post}` }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - const url = `${app.baseURL}/api/post/txt/${post}`; - await page.route(url, route => route.abort('failed')); - expectTransportFailure({ - url, - reason: 'the deliberately aborted cross-board preview must surface as a failed preview', - }); - expectConsoleError({ - pattern: /net::ERR_FAILED/, - reason: 'Chromium logs the aborted resource; WebKit reports only the transport failure', - optional: true, - }); - const link = page.locator('#thread-posts a.crosslink'); - await link.hover(); - await expect(page.locator('#ql-popup')).not.toContainText('loading'); - await page.unroute(url); - await link.click(); - await expect(page).toHaveURL(`${app.baseURL}/txt/thread/${target}#p${post}`); - await expect(page.locator('.post-body')).toContainText('quote after network recovery'); -}); - -test('audit: new reply jump control supports keyboard activation', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'live new reply notification'); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'long post line\n'.repeat(70) }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - await createReplyViaRequest(page, app, 'pub', thread, 'new bottom reply'); - await page.locator('[data-action="fetch-updates"]').first().click(); - const pill = page.locator('#new-replies-pill'); - await expect(pill).toBeVisible(); - await expect(pill).toHaveRole('button'); - await pill.focus(); - await expect(pill).toBeFocused(); - await page.keyboard.press('Enter'); - await expect(pill).toBeHidden(); - await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(500); -}); - -test('audit: manual update failure does not promise an automatic retry', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'thread update feedback'); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'manual retry feedback' }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - await page.route(`${app.baseURL}/pub/thread/${thread}/updates**`, route => route.abort('failed')); - expectTransportFailure({ - url: `${app.baseURL}/pub/thread/${thread}/updates`, - reason: 'the deliberately aborted manual update must surface as "Update failed."', - }); - expectConsoleError({ - pattern: /net::ERR_FAILED/, - reason: 'Chromium logs the aborted resource; WebKit reports only the transport failure', - optional: true, - }); - await page.locator('[data-action="fetch-updates"]').first().click(); - const status = page.locator('[data-role="autoupdate-status"]').first(); - await expect(status).toContainText('Update failed.'); - await expect(status).not.toContainText('Retrying in'); - await expect(status).toContainText('Update now'); -}); - -test('audit: live replies remove an OP delete action that the server can no longer honor', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'live owner controls'); - setBoardFixtureSettings(app, 'pub', { allowEditing: true, allowSelfDelete: true }); - const thread = await createThreadViaRequest(page, app, 'pub', { body: 'live OP ownership' }); - await page.goto(`${app.baseURL}/pub/thread/${thread}`); - await expect(page.locator('.post.op .del-btn')).toBeVisible(); - await createReplyViaRequest(page, app, 'pub', thread, 'first reply changes OP permissions'); - await page.locator('[data-action="fetch-updates"]').first().click(); - await expect(page.locator('.post.reply')).toHaveCount(1); - await expect(page.locator('.post.op .del-btn')).toHaveCount(0); - await expect(page.locator('.post.op .edit-btn')).toBeVisible(); -}); - -test('audit: archive navigation and controls honor saved visitor preferences', async ({ page, app }) => { - setBoardFixtureSettings(app, 'pub', { allowArchive: true }); - await page.context().addCookies([ - { name: 'rustchan_hide_nsfw', value: '1', url: app.baseURL }, - { name: 'rustchan_preferred_view', value: 'index', url: app.baseURL }, - { name: 'rustchan_video_audio', value: 'mute', url: app.baseURL }, - { name: 'rustchan_activity_badges', value: '0', url: app.baseURL }, - ]); - await page.goto(`${app.baseURL}/pub/archive`); - await expect(page.locator('.board-list a[href="/nsfw/catalog"]')).toHaveCount(0); - await expect(page.locator('.board-list a[href="/pub"]')).toHaveCount(1); - await expect(page.locator('.user-preferences-form input[name="hide_nsfw_boards"]')).toBeChecked(); - await expect(page.locator('.user-preferences-form input[name="video_audio"][value="mute"]')).toBeChecked(); - await expect(page.locator('.user-preferences-form input[name="show_activity_badges"]')).not.toBeChecked(); -}); - -test('audit: no-JS catalog clearly identifies unavailable client-only controls', async ({ page, app, javaScriptEnabled }) => { - test.skip(javaScriptEnabled, 'no-JS enhancement boundary'); - await createThreadViaRequest(page, app, 'pub', { body: 'visible no-JS catalog comment' }); - await page.goto(`${app.baseURL}/pub/catalog`); - await expect(page.locator('#catalog-sort')).toBeDisabled(); - await expect(page.locator('#catalog-show-comment')).toBeDisabled(); - // Playwright's aggregate element text omits noscript descendants. - const explanation = page.locator('.catalog-controls noscript p'); - await expect(explanation).toBeVisible(); - await expect(explanation).toContainText('require JavaScript'); - await expect(page.locator('#catalog-show-comment')).toHaveValue('on'); - await expect(page.locator('.catalog-comment')).toBeVisible(); -}); - -test('audit: catalog controls still work when browser storage is unavailable', async ({ page, app, javaScriptEnabled }) => { - test.skip(!javaScriptEnabled, 'client controls with unavailable storage'); - await page.addInitScript(() => { - Object.defineProperty(window, 'sessionStorage', { get() { throw new DOMException('Storage denied', 'SecurityError'); } }); - }); - await createThreadViaRequest(page, app, 'pub', { body: 'catalog with denied storage' }); - await page.goto(`${app.baseURL}/pub/catalog`); - await expect(page.locator('#catalog-sort')).toBeEnabled(); - await expect(page.locator('#catalog-show-comment')).toBeEnabled(); - await expect(page.locator('#catalog-sort')).toHaveValue('bump'); - await expect(page.locator('#catalog-show-comment')).toHaveValue('off'); - await page.locator('#catalog-show-comment').selectOption('on'); - await expect(page.locator('.catalog-comment')).toBeVisible(); -}); diff --git a/tests/e2e/console-headless.spec.ts b/tests/e2e/console-headless.spec.ts deleted file mode 100644 index f0384141..00000000 --- a/tests/e2e/console-headless.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { expect, test } from './diagnostics'; -import { ADMIN_PASSWORD, ADMIN_USERNAME, adminLogin, RustChanServer } from './helpers'; - -function expectHeadlessOutput(logs: string): void { - // A redirected process must not emit Ratatui cursor/alternate-screen/paste - // sequences or block on the interactive first-run form. Do not strip ANSI: - // that would hide a regression in the terminal ownership guard. - expect(logs).not.toMatch(/[\u001b\u009b]/); - expect(logs).not.toContain('First-run setup'); - expect(logs).not.toContain('RustChan console'); - expect(logs).not.toMatch(/Device not configured|Failed to render console frame/); -} - -test.describe('terminal console / browser boundary', () => { - test.beforeEach(async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'headless process coverage runs once in Chromium'); - }); - - test('fresh headless startup keeps web setup available without terminal prompts', async ({ page, request }) => { - const app = await RustChanServer.create(undefined, { - // Inherited terminal capability/size variables must not enable the TUI - // when the child streams are redirected. - env: { RUST_LOG: 'info', TERM: 'xterm-256color', COLUMNS: '40', LINES: '10' }, - }); - try { - await app.start(); - expect(app.process?.stdin).toBeNull(); - expect((await request.get(`${app.baseURL}/readyz`)).status()).toBe(200); - await page.goto(`${app.baseURL}/setup`); - await expect(page.getByRole('heading', { name: 'RustChan setup' })).toBeVisible(); - await expect.poll(() => app.logs()).toContain('No admin accounts exist'); - expectHeadlessOutput(await app.logs()); - } finally { - await app.dispose(); - } - }); - - test('CLI-seeded administration survives headless restart with readable logs', async ({ page, request }) => { - const app = await RustChanServer.create(undefined, { env: { RUST_LOG: 'info' } }); - try { - app.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - app.createBoardCli({ short: 'pub', name: 'Headless Board' }); - await app.start(); - const firstChild = app.process; - expect(firstChild).toBeDefined(); - await adminLogin(page, app); - await expect(page.locator('#board-pub')).toContainText('Headless Board'); - - await app.restart(); - - expect(firstChild?.exitCode).toBe(0); - expect(firstChild?.signalCode).toBeNull(); - expect(app.process?.pid).not.toBe(firstChild?.pid); - expect((await request.get(`${app.baseURL}/readyz`)).status()).toBe(200); - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator('body')).toContainText('Headless Board'); - await adminLogin(page, app); - await expect(page.locator('#board-pub')).toContainText('Headless Board'); - - await app.stop(); - const logs = await app.logs(); - expect(logs).toContain('HTTP server listening'); - expect(logs).not.toContain('No admin accounts exist'); - expectHeadlessOutput(logs); - } finally { - await app.dispose(); - } - }); -}); diff --git a/tests/e2e/csrf-navigation.spec.ts b/tests/e2e/csrf-navigation.spec.ts deleted file mode 100644 index b1dd5903..00000000 --- a/tests/e2e/csrf-navigation.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { newAuditedContext, newAuditedPage } from './diagnostics'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - adminCsrf, - adminLogin, - adminLogout, - createThread, - expect, - expectSafePage, - expectSafeResponse, - publicCsrf, - sqliteQuery, - test, - uniqueShort, -} from './helpers'; - -test.describe('CSRF, Origin, Referer, and browser navigation regressions', () => { - test('forms include CSRF and missing or invalid CSRF is rejected for mutations', async ({ page, browser, app }, testInfo) => { - await page.goto(`${app.baseURL}/pub`); - await expect(page.locator('form[action="/pub"] input[name="_csrf"]')).toHaveCount(1); - await adminLogin(page, app); - - for (const csrf of ['', 'invalid.csrf']) { - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: csrf, - short_name: uniqueShort('csrf', testInfo), - name: 'CSRF Fail', - description: '', - }, - maxRedirects: 0, - }); - expect(response.status()).toBe(403); - await expectSafeResponse(response); - } - - const loggedOut = await newAuditedPage(browser); - const loginScopeCsrf = await publicCsrf(loggedOut, app, '/admin'); - const loginScoped = await loggedOut.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: loginScopeCsrf, - short_name: uniqueShort('login', testInfo), - name: 'Wrong Scope', - description: '', - }, - maxRedirects: 0, - }); - expect(loginScoped.status()).toBe(403); - await loggedOut.close(); - }); - - test('session-scoped admin CSRF cannot be reused across sessions or after logout', async ({ page, browser, app }, testInfo) => { - await adminLogin(page, app); - const csrfFromSessionA = await adminCsrf(page, app); - - const contextB = await newAuditedContext(browser); - const pageB = await contextB.newPage(); - await pageB.goto(`${app.baseURL}/admin`); - await pageB.getByLabel('Username').fill(ADMIN_USERNAME); - await pageB.getByLabel('Password').fill(ADMIN_PASSWORD); - await Promise.all([ - pageB.waitForURL(/\/admin\/panel/), - pageB.getByRole('button', { name: 'authenticate' }).click(), - ]); - - const crossSession = await pageB.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: csrfFromSessionA, - short_name: uniqueShort('xcsrf', testInfo), - name: 'Cross Session', - description: '', - }, - maxRedirects: 0, - }); - expect(crossSession.status()).toBe(403); - await contextB.close(); - - await adminLogout(page); - const afterLogout = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: csrfFromSessionA, - short_name: uniqueShort('logout', testInfo), - name: 'After Logout', - description: '', - }, - maxRedirects: 0, - }); - expect(afterLogout.status()).toBe(403); - }); - - test('Origin, Referer, null Origin, loopback aliases, and GET mutations follow policy', async ({ page, app }, testInfo) => { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const body = { - _csrf: csrf, - short_name: uniqueShort('orig', testInfo), - name: 'Origin Check', - description: '', - }; - - const badOrigin = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: body, - headers: { Origin: 'http://evil.example' }, - maxRedirects: 0, - }); - expect(badOrigin.status()).toBe(403); - await expectSafeResponse(badOrigin); - - const badReferer = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { ...body, short_name: uniqueShort('ref', testInfo) }, - headers: { Referer: 'http://evil.example/path' }, - maxRedirects: 0, - }); - expect(badReferer.status()).toBe(403); - - const nullLoopback = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { ...body, short_name: uniqueShort('null', testInfo) }, - headers: { Origin: 'null' }, - maxRedirects: 0, - }); - expect(nullLoopback.status()).toBe(303); - - const alias = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { ...body, short_name: uniqueShort('loop', testInfo) }, - headers: { - Host: `localhost:${app.port}`, - Origin: `http://127.0.0.1:${app.port}`, - }, - maxRedirects: 0, - }); - expect(alias.status()).toBe(303); - - const getMutation = await page.request.get(`${app.baseURL}/admin/board/create`); - expect([404, 405]).toContain(getMutation.status()); - await expectSafeResponse(getMutation); - }); - - test('back-forward and refresh around POST redirects do not duplicate mutations', async ({ page, app }) => { - const threadId = await createThread(page, app, 'pub', { - subject: 'navigation csrf', - body: 'initial post', - }); - const postCount = await page.locator('.post').count(); - await page.reload(); - await expect(page.locator('.post')).toHaveCount(postCount); - // Establish history with a real link. Firefox can retain native POST - // redirects/fragments as extra entries, so counting those is not portable. - await page.getByRole('link', { name: '[ Return ]', exact: true }).first().click(); - await expect(page).toHaveURL(/\/pub$/); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(new RegExp(`/pub/thread/${threadId}`)); - await expect(page.locator('.post')).toHaveCount(postCount); - await expectSafePage(page); - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(/\/pub$/); - await expect(page.locator(`#t${threadId}`)).toBeVisible(); - expect(Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id=${threadId}`))).toBe(postCount); - }); -}); diff --git a/tests/e2e/diagnostics.ts b/tests/e2e/diagnostics.ts deleted file mode 100644 index d2e848dc..00000000 --- a/tests/e2e/diagnostics.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { - test as base, - expect, - type Browser, - type BrowserContext, - type BrowserContextOptions, - type Page, - type TestInfo, - type WebError, -} from '@playwright/test'; - -export * from '@playwright/test'; - -/** - * Hardened browser diagnostics for the RustChan E2E suite. - * - * Contract (documented in tests/e2e/README.md § Diagnostics): - * - Uncaught page errors fail the test unless declared with expectPageError. - * - Console errors fail the test unless declared with expectConsoleError. - * - HTTP 4xx/5xx responses from page traffic fail the test unless declared with - * expectHttpError. `page.request.*` traffic is out of scope by construction: - * Playwright does not emit context response events for APIRequestContext - * responses, so API-driven negative tests need no allowlist. - * - Transport failures fail the test unless declared with - * expectTransportFailure. Navigation-cancelled requests (net::ERR_ABORTED) - * are counted and reported but are not failures, because navigating away or - * closing a page cancels in-flight requests by design. - * - Declarations are per test and must name a kind plus method/path/status or a - * message pattern. Declarations are consumed by observed events. A - * declaration that never matches fails the test, so expectations cannot rot - * into a blanket allowlist. - * - All records carry actor, project, test, and timestamp, plus x-request-id - * and Location where the server provided them, so browser evidence can be - * joined with server-side artifacts. - */ - -export type DiagnosticKind = - | 'page-error' - | 'console-error' - | 'console-warning' - | 'transport-failure' - | 'request-aborted' - | 'http-error' - | 'http-3xx' - | 'asset-error' - | 'note'; - -export type DiagnosticRecord = { - ts: string; - kind: DiagnosticKind; - actor: string; - project: string; - test: string; - worker: number; - method?: string; - path?: string; - url?: string; - status?: number; - contentType?: string; - location?: string; - requestId?: string; - detail?: string; -}; - -export type ErrorExpectation = { - kind: 'http-error' | 'console-error' | 'transport-failure' | 'page-error'; - method?: string | RegExp; - path?: string | RegExp; - status?: number | readonly number[]; - pattern?: string | RegExp; - url?: string | RegExp; - optional?: boolean; - /** - * How many observations this declaration may consume. Defaults to 1. Use a - * number for a bounded retry loop, or 'any' for a legitimate repeated surface - * such as "every theme rendered the missing-board 404 page". 'any' must still - * name an exact method/path/status, so it cannot become a blanket allowlist. - */ - times?: number | 'any'; - reason: string; - actor?: string; -}; - -export type TimingRecord = { - name: string; - actor: string; - project: string; - test: string; - durationMs: number; - ok: boolean; -}; - -type Diagnostics = { - records: DiagnosticRecord[]; - pageErrors: string[]; - serverErrors: string[]; - expectations: ErrorExpectation[]; - timing: TimingRecord[]; - contexts: Set; - actor: string; - actors: Set; - testLabel: string; - project: string; - worker: number; - failures: string[]; -}; - -const activeDiagnostics = new WeakMap(); - -const DEEP = process.env.RUSTCHAN_E2E_DEEP_DIAGNOSTICS === '1'; -const TIMING = process.env.RUSTCHAN_E2E_TIMING === '1' || DEEP; -const MAX_DEEP_ARTIFACTS = Number(process.env.RUSTCHAN_E2E_DEEP_DIAGNOSTICS_MAX ?? '400'); -let deepArtifacts = 0; - -/** - * Project names configured with JavaScript disabled in playwright.config.ts. - * A spec that disables JavaScript locally with `test.use({ javaScriptEnabled: - * false })` is not visible here: Playwright does not expose describe-level `use` - * overrides to fixtures. Such specs already pass `javaScriptEnabled: false` - * explicitly when they create a context (see firefox-nojs-public.spec.ts, - * captcha.spec.ts, activity-notifications.spec.ts, theme-history.spec.ts). - * Any new spec that disables JavaScript locally MUST do the same; that - * requirement is documented in tests/e2e/README.md and checked by - * audit-harness-contract.spec.ts for the firefox-nojs project. - */ -export const NO_JS_PROJECTS = new Set(['firefox-nojs', 'chromium-nojs']); - -/** True when a manually created context must enable JavaScript for this project. */ -export function javaScriptEnabledFor(testInfo: TestInfo): boolean { - return !NO_JS_PROJECTS.has(testInfo.project.name); -} - -function now(): string { - return new Date().toISOString(); -} - -/** Remove credentials, session cookie values, and CSRF tokens from exported text. */ -export function redact(text: string): string { - return text - .replace(/(_csrf|csrf_token)(["'\s=:]|")+([^"'&\s;]+)/gi, '$1$2[redacted]') - .replace(/(set-cookie:\s*[^=\n;]+=)[^;\n]+/gi, '$1[redacted]') - .replace(/\b(rustchan_admin_session|rustchan_visitor_id|deletion_token|rustchan_owned_posts|rustchan_access_[A-Za-z0-9_]+)=[^;,\s"']+/g, '$1=[redacted]') - .replace(/"((?:access_password|password_hash|cookie_secret|admin_password))"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"') - .replace(/(--password[= ]+)\S+/g, '$1[redacted]'); -} - -function current(): Diagnostics { - const diagnostics = activeDiagnostics.get(base.info()); - if (!diagnostics) { - throw new Error('Browser diagnostics are only available inside a test body'); - } - return diagnostics; -} - -function record(partial: Omit): void { - const diagnostics = activeDiagnostics.get(base.info()); - if (!diagnostics) return; - diagnostics.records.push({ - ts: now(), - actor: diagnostics.actor, - project: diagnostics.project, - test: diagnostics.testLabel, - worker: diagnostics.worker, - ...partial, - }); -} - -function describeRequest(request: { method(): string; url(): string }): { method: string; path: string; url: string } { - const url = request.url(); - let path = url; - try { - const parsed = new URL(url); - path = `${parsed.pathname}${parsed.search}`; - } catch { - // data:/blob:/about: URLs are recorded verbatim. - } - return { method: request.method(), path, url }; -} - -const ASSET_CONTENT_TYPE_EXPECTATIONS: Array<{ match: (path: string) => boolean; type: string }> = [ - { match: path => path.startsWith('/static/') && path.split('?')[0].endsWith('.css'), type: 'text/css' }, - { match: path => path.startsWith('/static/') && path.split('?')[0].endsWith('.js'), type: 'javascript' }, - { match: path => path.startsWith('/theme-css/'), type: 'text/css' }, -]; - -/** - * Markers for a request cancelled by navigation or teardown rather than by a - * transport problem. Each engine reports this differently: Chromium - * `net::ERR_ABORTED`, WebKit `cancelled`, Firefox `NS_BINDING_ABORTED`. - */ -const NAVIGATION_CANCEL_MARKERS = [ - 'ERR_ABORTED', // Chromium - 'cancelled', // WebKit - 'Frame load interrupted', // WebKit, e.g. an embedded PDF frame replaced by navigation - 'NS_BINDING_ABORTED', // Firefox -]; - -/** True when a request-failure text means "cancelled", not "transport problem". */ -export function isNavigationCancel(failureText: string): boolean { - const normalised = failureText.toLowerCase(); - return NAVIGATION_CANCEL_MARKERS.some(marker => normalised.includes(marker.toLowerCase())); -} - -function observeContext(context: BrowserContext, diagnostics: Diagnostics): void { - if (diagnostics.contexts.has(context)) return; - diagnostics.contexts.add(context); - context.on('weberror', (error: WebError) => { - const message = error.error().message; - diagnostics.pageErrors.push(message); - record({ kind: 'page-error', url: error.page()?.url(), detail: message }); - }); - context.on('console', message => { - const kind = message.type() === 'error' ? 'console-error' : message.type() === 'warning' ? 'console-warning' : null; - if (!kind) return; - record({ kind, url: message.location().url, detail: message.text() }); - }); - context.on('requestfailed', request => { - const failure = request.failure()?.errorText ?? 'unknown failure'; - const { method, path, url } = describeRequest(request); - record({ - kind: isNavigationCancel(failure) ? 'request-aborted' : 'transport-failure', - method, - path, - url, - detail: failure, - }); - }); - context.on('response', response => { - const request = response.request(); - const { method, path, url } = describeRequest(request); - const status = response.status(); - const headers = response.headers(); - const contentType = headers['content-type']; - const location = headers.location; - const requestId = headers['x-request-id']; - if (status >= 500) { - diagnostics.serverErrors.push(`${status} ${method} ${path}`); - } - if (status >= 400) { - record({ kind: 'http-error', method, path, url, status, contentType, location, requestId }); - return; - } - if (status >= 300 && status !== 304) { - record({ kind: 'http-3xx', method, path, url, status, location, requestId }); - return; - } - if (status === 200 && contentType) { - const expected = ASSET_CONTENT_TYPE_EXPECTATIONS.find(entry => entry.match(path)); - if (expected && !contentType.includes(expected.type)) { - record({ - kind: 'asset-error', - method, - path, - url, - status, - contentType, - detail: `expected content-type containing ${expected.type}, received ${contentType}`, - }); - } - } - }); -} - -/** Close pages owned by a disposable runtime before its listener stops. */ -export async function closeAuditedPagesForOrigin(origin: string): Promise { - // Helpers are also used by local scripts outside a Playwright test body. - let info: TestInfo; - try { info = base.info(); } catch { return; } - const diagnostics = activeDiagnostics.get(info); - if (!diagnostics) return; - const pages = [...diagnostics.contexts].flatMap(context => context.pages()) - .filter(page => !page.isClosed() && page.url().startsWith(origin + '/')); - await Promise.all(pages.map(page => page.close())); -} - -/** Create a diagnostics-observed context that inherits the project JavaScript mode. */ -export async function newAuditedContext(browser: Browser, options?: BrowserContextOptions): Promise { - const context = await browser.newContext({ - javaScriptEnabled: javaScriptEnabledFor(base.info()), - ...options, - }); - observeContext(context, current()); - return context; -} - -/** Create a diagnostics-observed page whose context inherits the project JavaScript mode. */ -export async function newAuditedPage(browser: Browser) { - const page = await browser.newPage({ javaScriptEnabled: javaScriptEnabledFor(base.info()) }); - observeContext(page.context(), current()); - return page; -} - -/** Label subsequent events with the acting user/role for evidence correlation. */ -export function actor(label: string): void { - const diagnostics = current(); - diagnostics.actor = label; - diagnostics.actors.add(label); -} - -/** Record a redacted evidence note that is attached to the report but never fails a test. */ -export function noteEvent(kind: string, detail: string): void { - record({ kind: 'note', detail: `${kind}: ${redact(detail)}` }); -} - -export function diagnosticRecords(): readonly DiagnosticRecord[] { - return current().records; -} - -export function diagnosticTimings(): readonly TimingRecord[] { - return current().timing; -} - -function matchesStringOrRegExp(value: string | undefined, pattern: string | RegExp | undefined): boolean { - if (pattern === undefined) return true; - if (value === undefined) return false; - if (pattern instanceof RegExp) return pattern.test(value); - return value === pattern || value.startsWith(pattern); -} - -function matchStatus(status: number, expected: number | readonly number[] | undefined): boolean { - if (expected === undefined) return true; - if (Array.isArray(expected)) return (expected as readonly number[]).includes(status); - return status === (expected as number); -} - -function matchesExpectation(expectation: ErrorExpectation, value: DiagnosticRecord): boolean { - switch (expectation.kind) { - case 'http-error': - return value.kind === 'http-error' - && matchStatus(value.status ?? 0, expectation.status) - && matchesStringOrRegExp(value.method, expectation.method) - && matchesStringOrRegExp(value.path, expectation.path); - case 'console-error': - return value.kind === 'console-error' - && matchesStringOrRegExp(value.detail, expectation.pattern) - && matchesStringOrRegExp(value.url, expectation.url); - case 'page-error': - return value.kind === 'page-error' - && matchesStringOrRegExp(value.detail, expectation.pattern); - case 'transport-failure': - return value.kind === 'transport-failure' - && matchesStringOrRegExp(value.url, expectation.url) - && matchesStringOrRegExp(value.detail, expectation.pattern); - } -} - -function declare(expectations: ErrorExpectation[]): void { - current().expectations.push(...expectations); -} - -/** Declare one expected HTTP error response produced by page traffic. */ -export function expectHttpError(expectation: Omit, 'kind'>): void { - declare([{ ...expectation, kind: 'http-error' }]); -} - -/** Declare one expected console error message. */ -export function expectConsoleError(expectation: Omit, 'kind'>): void { - declare([{ ...expectation, kind: 'console-error' }]); -} - -/** Declare one expected uncaught page error. */ -export function expectPageError(expectation: Omit, 'kind'>): void { - declare([{ ...expectation, kind: 'page-error' }]); -} - -/** Declare one expected transport failure (DNS, refusal, TLS, timeout). */ -export function expectTransportFailure(expectation: Omit, 'kind'>): void { - declare([{ ...expectation, kind: 'transport-failure' }]); -} - -/** Fail the current test with a redacted diagnostic message. Usable from helpers. */ -export function reportDiagnosticFailure(message: string): void { - current().failures.push(redact(message)); -} - -/** - * Measure an action and the point at which its visible result is asserted. The - * caller owns the assertion so a slow-but-wrong result still fails the test. - */ -export async function measureStep(name: string, action: () => Promise): Promise { - const started = Date.now(); - let ok = true; - try { - return await action(); - } catch (error) { - ok = false; - throw error; - } finally { - if (TIMING) { - const diagnostics = activeDiagnostics.get(base.info()); - diagnostics?.timing.push({ - name, - actor: diagnostics.actor, - project: diagnostics.project, - test: diagnostics.testLabel, - durationMs: Date.now() - started, - ok, - }); - } - } -} - -export const DEEP_JOURNEY = 'deep-journey'; - -/** - * Assert the page is renderable, then (only in deep mode or for tests annotated - * `deep-journey`) attach a bounded screenshot of the successful step. - */ -export async function journeyStep(testInfo: TestInfo, page: Page, name: string): Promise { - await measureStep(`step:${name}`, async () => { - await expect(page.locator('body')).toBeVisible(); - }); - const deep = DEEP || testInfo.annotations.some(annotation => annotation.type === DEEP_JOURNEY); - if (!deep || deepArtifacts >= MAX_DEEP_ARTIFACTS) return; - deepArtifacts += 1; - await testInfo.attach(`journey-${String(deepArtifacts).padStart(3, '0')}-${name}.png`, { - body: await page.screenshot(), - contentType: 'image/png', - }); -} - -/** - * Pure declaration matcher, exported so `audit-harness-contract.spec.ts` can - * verify the strictness rules directly instead of relying on teardown ordering. - * Declarations are consumed by observed events in observation order; each - * declaration matches at most one event. - */ -export function declarationStatus( - records: readonly DiagnosticRecord[], - expectations: readonly ErrorExpectation[], -): { - consumed: number[]; - matchedRecords: number[]; - unmatched: number[]; - pairs: Array<[expectationIndex: number, recordIndex: number]>; -} { - const consumed = new Set(); - const matchedRecords = new Set(); - const pairs: Array<[number, number]> = []; - const remaining = expectations.map(expectation => ( - expectation.times === 'any' ? Number.POSITIVE_INFINITY : Math.max(1, expectation.times ?? 1) - )); - for (const [recordIndex, value] of records.entries()) { - const expectationIndex = expectations.findIndex( - (expectation, index) => remaining[index] > 0 && matchesExpectation(expectation, value), - ); - if (expectationIndex >= 0) { - remaining[expectationIndex] -= 1; - consumed.add(expectationIndex); - matchedRecords.add(recordIndex); - pairs.push([expectationIndex, recordIndex]); - } - } - return { - consumed: [...consumed].sort((a, b) => a - b), - matchedRecords: [...matchedRecords].sort((a, b) => a - b), - pairs: pairs.sort((a, b) => a[0] - b[0]), - unmatched: expectations - .map((expectation, index) => (consumed.has(index) ? -1 : index)) - .filter(index => index >= 0), - }; -} - -/** - * Console messages that are a direct consequence of an already-declared HTTP - * error or transport failure. Chromium logs - * "Failed to load resource: the server responded with a status of 404" for a 404 - * *document* navigation as well as for subresources, so requiring a second - * console declaration for the same response would be noise rather than - * strictness. Derivation is exact: the console message's URL or text must - * literally contain the declared record's URL or path. An undeclared 4xx or - * transport failure still fails the test, and its console message with it. - */ -function consoleMessagesExplainedByDeclaredRecords( - diagnostics: Diagnostics, - pairs: ReadonlyArray<[number, number]>, - matchedRecords: ReadonlySet, -): Set { - const explained = new Set(); - for (const [expectationIndex, recordIndex] of pairs) { - const kind = diagnostics.expectations[expectationIndex].kind; - if (kind !== 'http-error' && kind !== 'transport-failure') continue; - const declared = diagnostics.records[recordIndex]; - const keys = [declared.url, declared.path].filter((key): key is string => typeof key === 'string' && key.length > 0); - if (keys.length === 0) continue; - diagnostics.records.forEach((candidate, index) => { - if (candidate.kind !== 'console-error' || matchedRecords.has(index) || explained.has(index)) return; - const haystacks = [candidate.url, candidate.detail].filter((value): value is string => typeof value === 'string'); - if (keys.some(key => haystacks.some(value => value.includes(key)))) explained.add(index); - }); - } - return explained; -} - -/** - * True when a console message the *current* test already declared or that is a - * direct consequence of a declared record. `phase4-helpers.watchClientErrors` - * uses this so a page-level "no client errors" check does not re-report a - * surface the test has deliberately exercised. - */ -export function consoleErrorIsDeclared(text: string, url?: string): boolean { - const diagnostics = activeDiagnostics.get(base.info()); - if (!diagnostics) return false; - const status = declarationStatus(diagnostics.records, diagnostics.expectations); - const matched = new Set(status.matchedRecords); - const explained = consoleMessagesExplainedByDeclaredRecords(diagnostics, status.pairs, matched); - const haystacks = [url, text].filter((value): value is string => typeof value === 'string' && value.length > 0); - return diagnostics.records.some((record, index) => { - if (record.kind !== 'console-error') return false; - if (matched.has(index) || explained.has(index)) { - return haystacks.some(value => value === record.detail || value.includes(record.url ?? '\u0000')); - } - return false; - }); -} - -function formatRecord(value: DiagnosticRecord): string { - const where = [value.method, value.path ?? value.url].filter(Boolean).join(' '); - const suffix = [ - value.status === undefined ? '' : String(value.status), - value.contentType === undefined ? '' : `ct=${value.contentType}`, - value.location === undefined ? '' : `-> ${value.location}`, - value.requestId === undefined ? '' : `req=${value.requestId}`, - ].filter(Boolean).join(' '); - return `${value.kind} [${value.actor}] ${where} ${suffix} ${value.detail ?? ''}`.replace(/\s+/g, ' ').trim(); -} - -function summarise(diagnostics: Diagnostics): Record { - const counts: Record = {}; - for (const value of diagnostics.records) { - counts[value.kind] = (counts[value.kind] ?? 0) + 1; - } - return counts; -} - -export const test = base.extend<{ browserDiagnostics: void }>({ - browserDiagnostics: [async ({ context }, use, testInfo) => { - const diagnostics: Diagnostics = { - records: [], - pageErrors: [], - serverErrors: [], - expectations: [], - timing: [], - contexts: new Set(), - actor: 'anonymous', - actors: new Set(['anonymous']), - testLabel: testInfo.titlePath.filter(part => !part.includes('.spec.')).join(' › '), - project: testInfo.project.name, - worker: testInfo.workerIndex, - failures: [], - }; - activeDiagnostics.set(testInfo, diagnostics); - observeContext(context, diagnostics); - await use(); - activeDiagnostics.delete(testInfo); - - // Consume declarations in observation order; each declaration matches at most one event. - const status = declarationStatus(diagnostics.records, diagnostics.expectations); - const matchedRecords = new Set(status.matchedRecords); - const explainedConsole = consoleMessagesExplainedByDeclaredRecords(diagnostics, status.pairs, matchedRecords); - - const aborted = diagnostics.records.filter(value => value.kind === 'request-aborted'); - const legacyText = diagnostics.records.map(formatRecord).join('\n'); - - const shouldAttach = diagnostics.records.length > 0 - || diagnostics.failures.length > 0 - || testInfo.status !== testInfo.expectedStatus; - if (shouldAttach) { - await testInfo.attach('browser-diagnostics.json', { - body: JSON.stringify({ - project: diagnostics.project, - test: diagnostics.testLabel, - worker: diagnostics.worker, - actors: [...diagnostics.actors], - counts: summarise(diagnostics), - explainedConsoleMessages: [...explainedConsole].map(index => diagnostics.records[index]?.detail), - requestIds: diagnostics.records.map(value => value.requestId).filter(Boolean), - expectations: diagnostics.expectations, - records: diagnostics.records.map(value => ({ - ...value, - detail: value.detail === undefined ? undefined : redact(value.detail), - })), - }, null, 2), - contentType: 'application/json', - }); - await testInfo.attach('browser-diagnostics.txt', { - body: redact(legacyText) || 'No browser errors recorded', - contentType: 'text/plain', - }); - } - if (TIMING && diagnostics.timing.length > 0) { - await testInfo.attach('timings.json', { - body: JSON.stringify(diagnostics.timing, null, 2), - contentType: 'application/json', - }); - } - - const failures: string[] = [...diagnostics.failures]; - const unmatchedExpectations = status.unmatched - .map(index => diagnostics.expectations[index]) - .filter(expectation => expectation.optional !== true); - - const unexpected = diagnostics.records.filter((value, index) => ( - (value.kind === 'http-error' || value.kind === 'asset-error') && !matchedRecords.has(index) - )); - const unexpectedTransport = diagnostics.records.filter((value, index) => ( - value.kind === 'transport-failure' && !matchedRecords.has(index) - )); - const unexpectedConsole = diagnostics.records.filter((value, index) => ( - value.kind === 'console-error' && !matchedRecords.has(index) && !explainedConsole.has(index) - )); - const unexpectedPageErrors = diagnostics.pageErrors.filter(message => ( - !diagnostics.records.some((value, index) => value.kind === 'page-error' && value.detail === message && matchedRecords.has(index)) - )); - - if (unexpectedPageErrors.length) { - failures.push(`uncaught browser exceptions: ${JSON.stringify(unexpectedPageErrors)}`); - } - if (diagnostics.serverErrors.length) { - failures.push(`unexpected HTTP 5xx responses: ${JSON.stringify(diagnostics.serverErrors)}`); - } - if (unexpected.length) { - failures.push(`unexpected HTTP error responses: ${JSON.stringify(unexpected.map(formatRecord))}`); - } - if (unexpectedConsole.length) { - failures.push(`unexpected console errors: ${JSON.stringify(unexpectedConsole.map(value => value.detail))}`); - } - if (unexpectedTransport.length) { - failures.push(`unexpected transport failures: ${JSON.stringify(unexpectedTransport.map(value => `${value.method} ${value.url} ${value.detail}`))}`); - } - if (unexpected.length || unexpectedConsole.length || unexpectedTransport.length || unexpectedPageErrors.length) { - failures.push(`observed browser events:\n${legacyText || '(none)'}`); - } - if (unmatchedExpectations.length && testInfo.status === testInfo.expectedStatus) { - failures.push(`declared browser-error expectations that never matched: ${JSON.stringify(unmatchedExpectations)}`); - } - expect(failures.join('\n\n'), 'browser diagnostics contract').toBe(''); - void aborted; - }, { auto: true }], -}); diff --git a/tests/e2e/firefox-launch.spec.ts b/tests/e2e/firefox-launch.spec.ts deleted file mode 100644 index 779874d1..00000000 --- a/tests/e2e/firefox-launch.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { needsFirefoxIdentityIsolation, rebrandApplicationIni } from './firefox-rebrand'; - -test('Firefox accommodation is scoped to the affected OS and original identity', async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'configuration check runs once'); - const ini = '[App]\nVendor=Mozilla\nName=Firefox\nVersion=153.0\n'; - expect(needsFirefoxIdentityIsolation('darwin', '27.0.0', ini)).toBe(true); - expect(needsFirefoxIdentityIsolation('darwin', '26.0.0', ini)).toBe(false); - expect(needsFirefoxIdentityIsolation('linux', '27.0.0', ini)).toBe(false); - const isolated = rebrandApplicationIni(ini); - expect(isolated).toContain('Version=153.0'); - expect(needsFirefoxIdentityIsolation('darwin', '27.0.0', isolated)).toBe(false); - expect(() => rebrandApplicationIni(isolated)).toThrow(/identity changed/); -}); - -test('configured browser launches and respects JavaScript mode', async ({ page }, testInfo) => { - await page.goto('data:text/html,

launch probe

'); - await expect(page.getByText('launch probe')).toBeVisible(); - if (testInfo.project.use.javaScriptEnabled === false) { - await expect(page.locator('html')).not.toHaveAttribute('data-js', 'on'); - } else { - await expect(page.locator('html')).toHaveAttribute('data-js', 'on'); - } -}); diff --git a/tests/e2e/firefox-nojs-public.spec.ts b/tests/e2e/firefox-nojs-public.spec.ts deleted file mode 100644 index 5b0fb5b6..00000000 --- a/tests/e2e/firefox-nojs-public.spec.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { expectHttpError, newAuditedContext } from './diagnostics'; -import { - adminPasswordHash, - expect, - expectSafePage, - expectSafeResponse, - setBoardFixtureSettings, - setPostFixtureCreatedAt, - setSiteFixtureSettings, - setThreadFixtureState, - test, - threadIdFromUrl, - uniqueShort, -} from './helpers'; -import fsp from 'node:fs/promises'; -import type { Page } from '@playwright/test'; - -test.beforeEach(async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'firefox-nojs', 'Firefox/no-JS regression project only'); -}); - -test.describe('Firefox no-JS public regression', () => { - test('public browsing, anchors, quotes, posting validation, and readable errors are server-rendered', async ({ page, app }) => { - await page.goto(app.baseURL); - await expectSafePage(page); - await expect(page.locator('html')).toHaveClass(/no-js/); - await expect(page.locator('.board-card-link[href="/pub"], .board-card-link[href="/pub/catalog"]').first()).toBeVisible(); - - await page.goto(`${app.baseURL}/pub`); - await expectSafePage(page); - await expect(page.locator('form[action="/pub"] textarea[name="body"]')).toBeVisible(); - - const threadId = await createThreadNoJs(page, app.baseURL, 'pub', { - name: 'Alice#secret', - subject: 'Firefox no JS public thread', - body: 'OP body with >>123 and https://example.test/no-js', - }); - const op = page.locator('.post.op').first(); - const opPostId = await postId(op); - await expect(op.locator('.subject')).toContainText('Firefox no JS public thread'); - await expect(op.locator('.name')).toContainText('Alice'); - await expect(op.locator('.tripcode')).toContainText(/^!/); - await expect(op.locator(`a.post-num[href="#p${opPostId}"]`)).toBeVisible(); - - await createReplyNoJs(page, app.baseURL, 'pub', threadId, { - name: 'Bob', - body: `replying to >>${opPostId}`, - }); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - await expect(page.locator(`.quotelink[href="#p${opPostId}"]`).first()).toBeVisible(); - - await page.goto(`${app.baseURL}/pub/catalog`); - await expectSafePage(page); - await expect(page.locator(`a[href="/pub/thread/${threadId}"]`).first()).toBeVisible(); - await page.goto(`${app.baseURL}/pub/thread/${threadId}#p${opPostId}`); - await expect(page.locator(`#p${opPostId}`)).toBeVisible(); - await page.goBack(); - await expect(page).toHaveURL(/\/pub\/catalog/); - - await page.goto(`${app.baseURL}/pub?page=999`); - await expectSafePage(page); - await expect(page.locator('body')).not.toContainText(/blank|panic|stack backtrace/i); - - await page.goto(`${app.baseURL}/pub`); - const emptyForm = page.locator('form[action="/pub"]').first(); - await emptyForm.locator('textarea[name="body"]').fill(''); - // The empty submission is expected to be rejected with the rendered - // validation page; the assertion below checks the server-rendered error. - expectHttpError({ - method: 'POST', - path: '/pub', - status: 422, - reason: 'empty thread submission is validation, asserted through the rendered error banner', - }); - await emptyForm.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/body|empty|required|attached file/i); - await expectSafePage(page); - - const missing = await page.request.get(`${app.baseURL}/pub/thread/999999999`); - expect([404, 410]).toContain(missing.status()); - await expectSafeResponse(missing); - }); - - test('own-post edit and delete fallbacks use ownership cookies and fail closed', async ({ page, browser, app }, testInfo) => { - const board = uniqueShort('own', testInfo); - app.createBoardCli({ short: board, name: 'Own Post Fallback' }); - setBoardFixtureSettings(app, board, { - allowEditing: true, - allowSelfDelete: true, - postCooldownSecs: 0, - }); - - const threadId = await createThreadNoJs(page, app.baseURL, board, { - subject: 'own controls', - body: 'original own-post body', - }); - const opPostId = await postId(page.locator('.post.op').first()); - const editHref = await page.locator('.self-action-controls .edit-btn').first().getAttribute('href'); - expect(editHref).toBe(`/${board}/post/${opPostId}/edit`); - - await page.goto(`${app.baseURL}${editHref}`); - await expect(page.locator('form[action$="/edit"] textarea[name="body"]')).toBeVisible(); - await page.locator('textarea[name="body"]').fill('edited through no-js fallback'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}#p${opPostId}`)), - page.getByRole('button', { name: /save edit/i }).click(), - ]); - await expect(page.locator(`#p${opPostId}`)).toContainText('edited through no-js fallback'); - - const other = await newAuditedContext(browser, { javaScriptEnabled: false }); - const otherPage = await other.newPage(); - // The denial is the subject: a browser without the ownership grant must be - // refused the edit form. - expectHttpError({ - method: 'GET', - path: editHref!, - status: 403, - reason: 'an unrelated browser must not open the edit form', - }); - await otherPage.goto(`${app.baseURL}${editHref}`); - await expect(otherPage.locator('body')).toContainText(/permission|available|browser|forbidden/i); - await other.close(); - - const expiredId = await createThreadNoJs(page, app.baseURL, board, { - subject: 'expired grant', - body: 'created then expired', - }); - const expiredPostId = await postId(page.locator('.post.op').first()); - setPostFixtureCreatedAt(app, expiredPostId, Math.floor(Date.now() / 1000) - 120); - // The closed edit window is the subject of this step. - expectHttpError({ - method: 'GET', - path: `/${board}/post/${expiredPostId}/edit`, - status: 403, - reason: 'the 60-second edit window has closed', - }); - await page.goto(`${app.baseURL}/${board}/post/${expiredPostId}/edit`); - await expect(page.locator('body')).toContainText(/edit window|closed|forbidden/i); - - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - const deleteHref = await page.locator(`#p${opPostId} .self-action-controls .del-btn`).getAttribute('href'); - expect(deleteHref).toBe(`/${board}/post/${opPostId}/delete`); - await page.goto(`${app.baseURL}${deleteHref}`); - await expect(page.locator('form[action$="/delete"]')).toBeVisible(); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/catalog|/${board}/thread/${threadId}|/${board}`)), - page.getByRole('button', { name: /delete post/i }).click(), - ]); - await expectSafePage(page); - await page.goto(`${app.baseURL}/${board}/thread/${expiredId}`); - await expect(page.locator('body')).toContainText('created then expired'); - }); - - test('media upload, render, headers, disabled policy, size, and traversal checks work without JavaScript', async ({ page, app }, testInfo) => { - const files = app.fixtures(); - const pdfBoard = uniqueShort('pdf', testInfo); - const anyBoard = uniqueShort('any', testInfo); - const smallBoard = uniqueShort('lim', testInfo); - app.createBoardCli({ short: pdfBoard, name: 'PDF NoJS' }); - app.createBoardCli({ short: anyBoard, name: 'Any NoJS' }); - app.createBoardCli({ short: smallBoard, name: 'Small Uploads' }); - setBoardFixtureSettings(app, pdfBoard, { allowPdf: true }); - setBoardFixtureSettings(app, anyBoard, { allowAnyFiles: true }); - setBoardFixtureSettings(app, smallBoard, { maxImageSizeBytes: 128 }); - - await createThreadNoJs(page, app.baseURL, 'img', { subject: 'image', body: 'image body', filePath: files.tinyPng }); - const imageHref = await mediaHref(page, 'tiny.png'); - expect(imageHref).toMatch(/^\/boards\/img\//); - await expect(page.locator('[data-media-thumb="1"], .media-preview').first()).toBeVisible(); - const imageResponse = await page.request.get(`${app.baseURL}${imageHref}`); - expect(imageResponse.status()).toBe(200); - expect(imageResponse.headers()['x-content-type-options']).toBe('nosniff'); - - await createThreadNoJs(page, app.baseURL, 'vid', { subject: 'video', body: 'video body', filePath: files.fakeMp4 }); - await expect(page.locator('.video-container, .file-container')).toContainText('tiny.mp4'); - await createThreadNoJs(page, app.baseURL, 'aud', { subject: 'audio', body: 'audio body', filePath: files.fakeOgg }); - await expect(page.locator('.audio-container, .file-container')).toContainText('tiny.ogg'); - await createThreadNoJs(page, app.baseURL, pdfBoard, { subject: 'pdf', body: 'pdf body', filePath: files.tinyPdf }); - await expect(page.locator('.pdf-container, .file-container')).toContainText('tiny.pdf'); - const pdfHref = await mediaHref(page, 'tiny.pdf'); - const pdfResponse = await page.request.get(`${app.baseURL}${pdfHref}`); - expect(pdfResponse.status()).toBe(200); - expect(pdfResponse.headers()['content-type']).toMatch(/application\/pdf|octet-stream/i); - expect(pdfResponse.headers()['x-content-type-options']).toBe('nosniff'); - - await createThreadNoJs(page, app.baseURL, anyBoard, { subject: 'any file', body: 'any body', filePath: files.invalid }); - await expect(page.locator('.file-download, .file-container').first()).toContainText('invalid.txt'); - const anyHref = await mediaHref(page, 'invalid.txt'); - expect(anyHref).toMatch(new RegExp(`^/boards/${anyBoard}/`)); - - await page.goto(`${app.baseURL}/txt`); - const disabledUpload = await page.request.post(`${app.baseURL}/txt`, { - multipart: { - _csrf: await page.locator('input[name="_csrf"], #csrf_global').first().getAttribute('value') ?? '', - submission_token: 'disabled-image-upload', - body: 'disabled image', - file: { - name: 'tiny.png', - mimeType: 'image/png', - buffer: await fsp.readFile(files.tinyPng), - }, - }, - maxRedirects: 0, - }); - expect([400, 422]).toContain(disabledUpload.status()); - await expectSafeResponse(disabledUpload); - - await page.goto(`${app.baseURL}/img`); - // The image-only board rejecting a PDF is the tested contract. - expectHttpError({ - method: 'POST', - path: '/img', - status: 422, - reason: 'an image-only board rejects a PDF upload with the rendered error', - }); - await submitThreadNoJs(page, 'img', { body: 'pdf rejected', filePath: files.tinyPdf }); - await expect(page.locator('.post-error-banner').first()).toContainText(/pdf uploads are disabled|only accepts|not allowed/i); - - await page.goto(`${app.baseURL}/${smallBoard}`); - // The board image-size limit rejection is the tested contract. - expectHttpError({ - method: 'POST', - path: `/${smallBoard}`, - status: 413, - reason: 'the oversized upload exceeds the board image limit', - }); - await submitThreadNoJs(page, smallBoard, { body: 'too large', filePath: files.oversized }); - await expect(page.locator('.post-error-banner, body').first()).toContainText(/too large|maximum/i); - - const traversal = await page.request.get(`${app.baseURL}/boards/img/../../settings.toml`); - expect([400, 403, 404]).toContain(traversal.status()); - await expectSafeResponse(traversal); - }); - - test('fixture-driven board/site toggles, password boards, thread states, and defaults stay public-only', async ({ page, browser, app }, testInfo) => { - setSiteFixtureSettings(app, { - siteName: 'NoJS RustChan', - siteSubtitle: 'server rendered access', - defaultTheme: 'deep-orbit', - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: true, - threadNewReplyBadgesEnabled: true, - }); - await app.restart(); - - await page.goto(app.baseURL); - await expect(page.locator('body')).toContainText('NoJS RustChan'); - await expect(page.locator('body')).toContainText('server rendered access'); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'deep-orbit'); - await expect(page.locator('.nsfw-badge').first()).toBeVisible(); - - const viewBoard = uniqueShort('view', testInfo); - const postBoard = uniqueShort('post', testInfo); - app.createBoardCli({ short: viewBoard, name: 'View Password' }); - app.createBoardCli({ short: postBoard, name: 'Post Password' }); - const passwordHash = adminPasswordHash(app); - setBoardFixtureSettings(app, viewBoard, { - accessMode: 'view_password', - accessPasswordHash: passwordHash, - }); - setBoardFixtureSettings(app, postBoard, { - accessMode: 'post_password', - accessPasswordHash: passwordHash, - }); - - // A wrong board password and the pre-unlock content denial are the tested - // contract for a view-password board. The GET denial repeats for the fresh - // browser context below. - expectHttpError({ - method: 'GET', - path: `/${viewBoard}`, - status: 403, - times: 2, - reason: 'a view-password board denies content before and without the access cookie', - }); - expectHttpError({ - method: 'POST', - path: `/${viewBoard}/unlock`, - status: 403, - reason: 'a wrong board password is rejected', - }); - await page.goto(`${app.baseURL}/${viewBoard}`); - await expect(page.locator('body')).toContainText(/password protected/i); - await page.locator('input[name="password"]').fill('wrong-password'); - await page.getByRole('button', { name: /unlock board/i }).click(); - await expect(page.locator('body')).toContainText(/invalid|wrong|password/i); - await page.locator('input[name="password"]').fill('AdminPass123!'); - await Promise.all([ - page.waitForURL(new RegExp(`/${viewBoard}`)), - page.getByRole('button', { name: /unlock board/i }).click(), - ]); - await expectSafePage(page); - - const fresh = await newAuditedContext(browser, { javaScriptEnabled: false }); - const freshPage = await fresh.newPage(); - await freshPage.goto(`${app.baseURL}/${viewBoard}`); - await expect(freshPage.locator('body')).toContainText(/password protected/i); - await fresh.close(); - - await page.goto(`${app.baseURL}/${postBoard}`); - await expect(page.locator('#board-access-gate')).toContainText(/posting is password protected|unlock posting/i); - const deniedPost = await page.request.post(`${app.baseURL}/${postBoard}`, { - multipart: { - _csrf: await page.locator('input[name="_csrf"], #csrf_global').first().getAttribute('value') ?? '', - submission_token: 'post-password-denied', - body: 'blocked post password', - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(deniedPost.status()); - expect(deniedPost.headers()['location']).toContain(`/${postBoard}/unlock`); - await page.goto(`${app.baseURL}/${postBoard}/unlock`); - await page.locator('input[name="password"]').fill('AdminPass123!'); - await Promise.all([ - page.waitForURL(new RegExp(`/${postBoard}$`)), - page.getByRole('button', { name: /unlock posting|unlock board/i }).click(), - ]); - await expect(page.locator('form[action="/' + postBoard + '"] textarea[name="body"]')).toBeVisible(); - await createThreadNoJs(page, app.baseURL, postBoard, { subject: 'post password ok', body: 'created after unlock' }); - - const stateBoard = uniqueShort('state', testInfo); - app.createBoardCli({ short: stateBoard, name: 'State Board' }); - setBoardFixtureSettings(app, stateBoard, { allowArchive: true, maxThreads: 1, maxArchivedThreads: 2 }); - const lockedThread = await createThreadNoJs(page, app.baseURL, stateBoard, { subject: 'locked thread', body: 'locked body' }); - setThreadFixtureState(app, lockedThread, { locked: true }); - await page.goto(`${app.baseURL}/${stateBoard}/thread/${lockedThread}`); - await expect(page.locator('body')).toContainText(/locked/i); - await expect(page.locator('form[action$="/thread/' + lockedThread + '"]')).toHaveCount(0); - - const archivedThread = await createThreadNoJs(page, app.baseURL, stateBoard, { subject: 'archived thread', body: 'archived body' }); - setThreadFixtureState(app, archivedThread, { locked: true, archived: true }); - await page.goto(`${app.baseURL}/${stateBoard}/archive`); - await expectSafePage(page); - await expect(page.locator('body')).toContainText('archived thread'); - }); - - test('themes, no-JS preferences, activity read state, and degraded HTTP behavior are safe', async ({ page, browser, app }, testInfo) => { - await page.goto(app.baseURL); - const labels = await page.locator('.user-preferences-noscript-form button[name="theme"]').evaluateAll((nodes) => - nodes.map((node) => node.textContent?.trim() ?? '').filter(Boolean), - ); - expect(labels.slice(0, 3)).toEqual(['Forest', 'Blue Sky', 'Deep Orbit']); - - for (const slug of ['forest', 'blue-sky', 'deep-orbit', 'terminal', 'dorfic', 'chanclassic', 'aero', 'neoncubicle', 'fluorogrid']) { - await page.goto(`${app.baseURL}/theme/${slug}?return_to=/pub`); - await expect(page).toHaveURL(/\/pub$/); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', slug); - const css = await page.request.get(`${app.baseURL}/theme-css/${slug}`); - expect(css.status()).toBe(200); - expect(css.headers()['content-type']).toMatch(/text\/css/i); - } - - await page.goto(`${app.baseURL}/pub`); - await page.locator('.user-preferences-summary').click(); - const noJsPreferences = page.locator('.user-preferences-noscript'); - await expect(noJsPreferences).toBeVisible(); - await Promise.all([ - page.waitForResponse((response) => response.url() === `${app.baseURL}/preferences` - && response.request().method() === 'POST'), - noJsPreferences.locator('button[name="theme"][value="forest"]').click(), - ]); - await expect(page).toHaveURL(/\/pub$/); - await page.locator('.user-preferences-summary').click(); - await Promise.all([ - page.waitForResponse((response) => response.url() === `${app.baseURL}/preferences` - && response.request().method() === 'POST'), - page.locator('.user-preferences-noscript button[name="show_activity_badges"][value="0"]').click(), - ]); - await expect(page).toHaveURL(/\/pub$/); - const cookies = await page.context().cookies(); - expect(cookies.find((cookie) => cookie.name === 'rustchan_activity_badges')?.value).toBe('0'); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'forest'); - - const activityBoard = uniqueShort('act', testInfo); - app.createBoardCli({ short: activityBoard, name: 'Activity Board' }); - setBoardFixtureSettings(app, activityBoard, { postCooldownSecs: 0 }); - await createThreadNoJs(page, app.baseURL, activityBoard, { subject: 'activity baseline', body: 'baseline activity' }); - const reader = await newAuditedContext(browser, { javaScriptEnabled: false }); - const readerPage = await reader.newPage(); - await readerPage.goto(`${app.baseURL}/${activityBoard}`); - await expectSafePage(readerPage); - - const writer = await newAuditedContext(browser, { javaScriptEnabled: false }); - const writerPage = await writer.newPage(); - await writerPage.goto(`${app.baseURL}/${activityBoard}`); - await createThreadNoJs(writerPage, app.baseURL, activityBoard, { subject: 'activity appears', body: 'new activity' }); - await writer.close(); - - await readerPage.goto(app.baseURL); - await expect(readerPage.locator('.board-card-new-thread-badge').first()).toContainText(/1 New Threads/i); - await readerPage.goto(`${app.baseURL}/${activityBoard}`); - await readerPage.goto(app.baseURL); - await expect(readerPage.locator('.board-card-new-thread-badge')).toHaveCount(0); - await reader.close(); - - const csrfFailure = await page.request.post(`${app.baseURL}/pub`, { - multipart: { - submission_token: 'missing-csrf', - body: 'csrf should fail', - }, - maxRedirects: 0, - }); - expect(csrfFailure.status()).toBe(403); - await expectSafeResponse(csrfFailure); - - const directPostGet = await page.request.get(`${app.baseURL}/pub/post/1/delete`, { maxRedirects: 0 }); - expect([403, 404]).toContain(directPostGet.status()); - await expectSafeResponse(directPostGet); - - const updates = await page.request.get(`${app.baseURL}/pub/thread/1/updates?since=0`, { maxRedirects: 0 }); - expect([200, 404]).toContain(updates.status()); - await expectSafeResponse(updates); - }); -}); - -async function createThreadNoJs( - page: Page, - baseURL: string, - board: string, - options: { name?: string; subject?: string; body?: string; filePath?: string } = {}, -): Promise { - await page.goto(`${baseURL}/${board}`); - await submitThreadNoJs(page, board, options); - await page.waitForURL(new RegExp(`/${board}/thread/\\d+`)); - await expectSafePage(page); - return threadIdFromUrl(page.url()); -} - -async function submitThreadNoJs( - page: Page, - board: string, - options: { name?: string; subject?: string; body?: string; filePath?: string } = {}, -): Promise { - const form = page.locator(`form[action="/${board}"]`).first(); - await expect(form.locator('textarea[name="body"]')).toBeVisible(); - if (options.name !== undefined) { - await form.locator('input[name="name"]').fill(options.name); - } - if (options.subject !== undefined) { - await form.locator('input[name="subject"]').fill(options.subject); - } - await form.locator('textarea[name="body"]').fill(options.body ?? `thread body ${Date.now()}`); - if (options.filePath) { - await fsp.access(options.filePath); - await form.locator('input[type="file"]').first().setInputFiles(options.filePath); - } - await form.getByRole('button', { name: /post thread/i }).click(); -} - -async function createReplyNoJs( - page: Page, - baseURL: string, - board: string, - threadId: number, - options: { name?: string; body?: string; filePath?: string } = {}, -): Promise { - await page.goto(`${baseURL}/${board}/thread/${threadId}`); - await expectSafePage(page); - const form = page.locator(`form[action="/${board}/thread/${threadId}"]`).first(); - await expect(form.locator('textarea[name="body"]')).toBeVisible(); - if (options.name !== undefined) { - await form.locator('input[name="name"]').fill(options.name); - } - await form.locator('textarea[name="body"]').fill(options.body ?? `reply body ${Date.now()}`); - if (options.filePath) { - await fsp.access(options.filePath); - await form.locator('input[type="file"]').first().setInputFiles(options.filePath); - } - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - form.getByRole('button', { name: /post reply/i }).click(), - ]); - await expectSafePage(page); -} - -async function postId(locator: ReturnType): Promise { - const id = await locator.getAttribute('id'); - const numeric = Number(id?.replace(/^p/, '')); - if (!Number.isInteger(numeric) || numeric <= 0) { - throw new Error(`invalid post id: ${id}`); - } - return numeric; -} - -async function mediaHref(page: Page, fileName: string): Promise { - const href = await page.locator(`.file-info a[title="${fileName}"]`).first().getAttribute('href'); - if (!href) { - throw new Error(`media link for ${fileName} not found`); - } - return href; -} diff --git a/tests/e2e/firefox-rebrand.ts b/tests/e2e/firefox-rebrand.ts deleted file mode 100644 index 35963bd8..00000000 --- a/tests/e2e/firefox-rebrand.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Playwright #42768: macOS 27 TCC denies Gecko's shared Firefox app-data root, - * independently of the temporary -profile directory. Until upstream rebrands - * its bundle, use the SAME browser revision with a separate app identity. - * -app must precede Playwright's arguments, hence the generated launcher. - * https://github.com/microsoft/playwright/issues/42768 - */ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { createHash } from 'node:crypto'; -import { firefox } from '@playwright/test'; - -const workDir = path.resolve(__dirname, '../../output/playwright/firefox-rebranded'); - -export function needsFirefoxIdentityIsolation(platform: string, kernelRelease: string, ini: string): boolean { - return platform === 'darwin' && Number(kernelRelease.split('.')[0]) >= 27 - && /^Name=Firefox$/m.test(ini) && /^Vendor=Mozilla$/m.test(ini); -} - -export function rebrandApplicationIni(contents: string): string { - if (!/^Name=Firefox$/m.test(contents) || !/^Vendor=Mozilla$/m.test(contents)) { - throw new Error('Firefox identity changed; re-evaluate the #42768 launch accommodation.'); - } - return contents.replace(/^Name=Firefox$/m, 'Name=PlaywrightFirefox') - .replace(/^Vendor=Mozilla$/m, 'Vendor=Playwright'); -} - -function shellQuote(value: string): string { - return "'" + value.replace(/'/g, "'\\''") + "'"; -} - -/** Automatic on affected OS/bundle identities; stock launch everywhere else. */ -export function rebrandedFirefoxExecutable(): string | undefined { - if (process.platform !== 'darwin' || Number(os.release().split('.')[0]) < 27) return undefined; - const executable = firefox.executablePath(); - const sourceBundle = path.resolve(path.dirname(executable), '../..'); - const sourceIni = path.join(sourceBundle, 'Contents/Resources/application.ini'); - // Let Playwright itself report a missing browser with its install instructions. - if (!fs.existsSync(sourceIni)) return undefined; - const ini = fs.readFileSync(sourceIni, 'utf8'); - if (!needsFirefoxIdentityIsolation(process.platform, os.release(), ini)) return undefined; - // An upgrade must explicitly revisit this temporary upstream accommodation. - const version = require('@playwright/test/package.json').version as string; - const [major, minor] = version.split('.').map(Number); - if (major > 1 || minor >= 64) { - throw new Error(`Playwright ${version} still has the shared Firefox identity. Re-evaluate #42768 before extending the accommodation.`); - } - const key = createHash('sha256').update(`${executable}:${fs.statSync(executable).mtimeMs}:${ini}:v2`).digest('hex').slice(0, 20); - const cache = path.join(workDir, key); - const bundle = path.join(cache, 'Nightly.app'); - const browserIni = path.join(bundle, 'Contents/Resources/browser/application.ini'); - const launcher = path.join(cache, 'firefox.sh'); - const patched = rebrandApplicationIni(ini); - const script = `#!/bin/sh\nexec ${shellQuote(path.join(bundle, 'Contents/MacOS/firefox'))} -app ${shellQuote(browserIni)} "$@"\n`; - if (fs.existsSync(launcher)) { - if (fs.readFileSync(launcher, 'utf8') !== script || fs.readFileSync(browserIni, 'utf8') !== patched) { - throw new Error('Cached Firefox launcher/identity mismatch; remove output/playwright/firefox-rebranded and rerun.'); - } - return launcher; - } - fs.mkdirSync(workDir, { recursive: true }); - const staging = fs.mkdtempSync(path.join(workDir, '.building-')); - try { - fs.cpSync(sourceBundle, path.join(staging, 'Nightly.app'), { recursive: true }); - fs.writeFileSync(path.join(staging, 'Nightly.app/Contents/Resources/browser/application.ini'), patched); - fs.writeFileSync(path.join(staging, 'firefox.sh'), script, { mode: 0o755 }); - try { - fs.renameSync(staging, cache); - } catch (error) { - // Another test process can publish the same complete cache first. - if (!fs.existsSync(launcher)) throw error; - } - } finally { - fs.rmSync(staging, { recursive: true, force: true }); - } - return launcher; -} diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts deleted file mode 100644 index ecf7357c..00000000 --- a/tests/e2e/global-setup.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -const repoRoot = path.resolve(__dirname, '../..'); - -export default async function globalSetup() { - fs.mkdirSync(path.join(repoRoot, 'test-results/e2e'), { recursive: true }); - fs.mkdirSync(path.join(repoRoot, 'playwright-report'), { recursive: true }); - - if (process.env.RUSTCHAN_UPLOAD_BASE_URL) { - throw new Error('This local-only suite requires isolated runtimes. Unset RUSTCHAN_UPLOAD_BASE_URL.'); - } - if (process.env.RUSTCHAN_E2E_MEDIA_TOOLCHAIN === '1') { - const preflight = spawnSync(process.execPath, ['tests/e2e/media-toolchain-check.mjs'], { - cwd: repoRoot, - stdio: 'inherit', - env: process.env, - }); - if (preflight.status !== 0) throw new Error('The opt-in media matrix requires the codecs reported by the toolchain preflight.'); - } - const fixture = spawnSync('cargo', ['test', '--locked', '--test', 'update_ui_fixtures'], { - cwd: repoRoot, stdio: 'inherit', env: process.env, - }); - if (fixture.status !== 0) throw new Error('Native update renderer fixture generation failed.'); - - if (process.env.RUSTCHAN_E2E_SKIP_BUILD === '1') { - return; - } - - const result = spawnSync('cargo', ['build', '--locked', '--bin', 'rustchan-cli'], { - cwd: repoRoot, - stdio: 'inherit', - env: { - ...process.env, - CHAN_TOR_SUPPORT: '0', - CHAN_AUTO_FULL_BACKUP_HOURS: '0', - CHAN_AUTO_VACUUM_HOURS: '0', - CHAN_WAL_CHECKPOINT_SECS: '0', - CHAN_POLL_CLEANUP_HOURS: '0', - }, - }); - - if (result.status !== 0) { - throw new Error(`cargo build --bin rustchan-cli failed with status ${result.status}`); - } -} diff --git a/tests/e2e/helpers-lifecycle.spec.ts b/tests/e2e/helpers-lifecycle.spec.ts deleted file mode 100644 index 6716d292..00000000 --- a/tests/e2e/helpers-lifecycle.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { expect, test } from './diagnostics'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import net from 'node:net'; -import { RustChanServer } from './helpers'; - -async function portAcceptsConnections(port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host: '127.0.0.1', port }); - let settled = false; - const finish = (connected: boolean) => { - if (settled) { - return; - } - settled = true; - socket.destroy(); - resolve(connected); - }; - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); - socket.setTimeout(1_000, () => finish(false)); - }); -} - -test.describe('RustChan fixture lifecycle', () => { - test.beforeEach(async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'fixture lifecycle runs once in Chromium'); - }); - - test('dispose removes temporary roots and supports explicit preservation', async () => { - const disposable = await RustChanServer.create(); - const disposableRoot = disposable.rootDir; - try { - await disposable.dispose(); - expect(fs.existsSync(disposableRoot)).toBe(false); - } finally { - await fsp.rm(disposableRoot, { recursive: true, force: true }); - } - - const preserved = await RustChanServer.create(undefined, { preserveRoot: true }); - const preservedRoot = preserved.rootDir; - try { - await preserved.dispose(); - expect(fs.existsSync(preservedRoot)).toBe(true); - } finally { - await fsp.rm(preservedRoot, { recursive: true, force: true }); - } - }); - - test('dispose closes owned pages and stops the process before removing its root', async ({ page }) => { - const app = await RustChanServer.create(); - const rootDir = app.rootDir; - try { - await app.start(); - const child = app.process; - if (!child) { - throw new Error('RustChan fixture did not retain its started child process'); - } - expect(await portAcceptsConnections(app.port)).toBe(true); - await page.goto(app.baseURL + '/healthz'); - const unrelated = await page.context().newPage(); - await unrelated.goto('data:text/html,

unrelated page

'); - - await app.dispose(); - expect(page.isClosed(), 'owned browser page closes before server disposal').toBe(true); - expect(unrelated.isClosed(), 'other origins stay available').toBe(false); - await unrelated.close(); - - expect(app.process).toBeUndefined(); - // Headless fixtures shut down through SIGTERM, not the TUI's Q/Enter - // confirmation. An escalation to SIGKILL must not pass this regression. - expect(child.exitCode).toBe(0); - expect(child.signalCode).toBeNull(); - expect(await portAcceptsConnections(app.port)).toBe(false); - expect(fs.existsSync(rootDir)).toBe(false); - } finally { - if (app.process) { - await app.dispose(); - } - if (!app.process) { - await fsp.rm(rootDir, { recursive: true, force: true }); - } - } - }); -}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts deleted file mode 100644 index 1e495669..00000000 --- a/tests/e2e/helpers.ts +++ /dev/null @@ -1,1250 +0,0 @@ -import { test as base, expect, closeAuditedPagesForOrigin, type APIResponse, type Page, type TestInfo, type WorkerInfo } from './diagnostics'; -import { spawn, spawnSync, type ChildProcess, type ChildProcessByStdio } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import net from 'node:net'; -import path from 'node:path'; -import type { Readable } from 'node:stream'; -import * as zlib from 'node:zlib'; - -export const ADMIN_USERNAME = 'admin'; -export const ADMIN_PASSWORD = 'AdminPass123!'; - -/** - * Project names configured with JavaScript disabled. Adding a no-JS project must - * not silently re-enable JavaScript-dependent tests in it, so every gate that - * special-cased `firefox-nojs` uses this predicate instead of a bare name check. - */ -export const NO_JS_PROJECT_NAMES: readonly string[] = ['firefox-nojs', 'chromium-nojs']; - -/** True when the current project runs with JavaScript disabled. */ -export function isNoJsProject(testInfo: TestInfo): boolean { - return NO_JS_PROJECT_NAMES.includes(testInfo.project.name); -} - -/** - * Expected `[ERROR]` lines in the fixture server log, declared per test with - * expectServerLogError() and consumed by the serverLogOnFailure fixture. This - * lives here rather than in diagnostics.ts so the app fixture owns the - * server-log contract without a circular import. - */ -type ServerLogExpectation = { pattern: RegExp; reason: string; optional?: boolean; times?: number | 'any'; matched?: number }; -const serverLogExpectations = new WeakMap(); - -/** - * Declare one expected `[ERROR]` line in the fixture server log, for example a - * fault-injected maintenance failure. An unused declaration fails the test, so - * declarations cannot rot into a blanket "server errors are fine" switch. - */ -export function expectServerLogError(expectation: { pattern: RegExp; reason: string; optional?: boolean; times?: number | 'any' }): void { - const list = serverLogExpectations.get(base.info()); - if (!list) { - throw new Error('expectServerLogError() must be called inside a test body'); - } - list.push({ ...expectation }); -} - -const repoRoot = path.resolve(__dirname, '../..'); -const sourceBinary = path.join(repoRoot, 'target/debug/rustchan-cli'); - -type SeedBoard = { - short: string; - name: string; - description?: string; - nsfw?: boolean; - noImages?: boolean; - noVideos?: boolean; - audio?: boolean; -}; - -type BoardSettings = { - name?: string; - description?: string; - nsfw?: boolean; - allowImages?: boolean; - allowVideo?: boolean; - allowAudio?: boolean; - allowPdf?: boolean; - allowAnyFiles?: boolean; - allowArchive?: boolean; - allowEditing?: boolean; - allowSelfDelete?: boolean; - allowCaptcha?: boolean; - accessMode?: 'public' | 'view_password' | 'post_password'; - accessPassword?: string; - clearAccessPassword?: boolean; - defaultTheme?: string; - maxImageSizeMb?: number; - maxVideoSizeMb?: number; - maxAudioSizeMb?: number; - maxPdfSizeMb?: number; - bumpLimit?: number; - maxThreads?: number; - maxArchivedThreads?: number; - postCooldownSecs?: number; -}; - -type BoardFixtureSettings = BoardSettings & { - maxImageSizeBytes?: number; - maxVideoSizeBytes?: number; - maxAudioSizeBytes?: number; - maxPdfSizeBytes?: number; - accessPasswordHash?: string; - defaultTheme?: string; -}; - -type SiteFixtureSettings = { - siteName?: string; - siteSubtitle?: string; - defaultTheme?: string; - homepageNewThreadBadgesEnabled?: boolean; - homepageNewReplyBadgesEnabled?: boolean; - threadNewReplyBadgesEnabled?: boolean; -}; - -type ServerOptions = { - env?: Record; - preserveRoot?: boolean; -}; - -type StartOptions = { - ready?: (child: ChildProcess) => Promise; - cwd?: string; -}; - -export type FixtureFiles = { - tinyPng: string; - spoofedPng: string; - fakeMp4: string; - fakeOgg: string; - tinyPdf: string; - invalid: string; - oversized: string; - oddNamePng: string; -}; - -export class RustChanServer { - readonly rootDir: string; - readonly binDir: string; - readonly binaryPath: string; - readonly dataDir: string; - readonly logPath: string; - readonly fixtureDir: string; - readonly port: number; - readonly baseURL: string; - readonly mediaToolchain: boolean; - readonly envOverrides: Record; - readonly preserveRoot: boolean; - private portReservation?: net.Server; - private logStream?: fs.WriteStream; - process?: ChildProcessByStdio; - - private constructor( - rootDir: string, - port: number, - mediaToolchain: boolean, - envOverrides: Record, - preserveRoot: boolean, - portReservation: net.Server, - ) { - this.rootDir = rootDir; - this.binDir = path.join(rootDir, 'bin'); - this.binaryPath = path.join(this.binDir, 'rustchan-cli'); - this.dataDir = path.join(this.binDir, 'rustchan-data'); - this.logPath = path.join(rootDir, 'server.log'); - this.fixtureDir = path.join(rootDir, 'fixtures'); - this.port = port; - this.baseURL = `http://127.0.0.1:${port}`; - this.mediaToolchain = mediaToolchain; - this.envOverrides = envOverrides; - this.preserveRoot = preserveRoot; - this.portReservation = portReservation; - } - - static async create(workerInfo?: WorkerInfo, options: ServerOptions = {}): Promise { - const runtimeRoot = path.join(repoRoot, 'output/playwright/runtimes'); - await fsp.mkdir(runtimeRoot, { recursive: true }); - const rootDir = await fsp.mkdtemp(path.join(runtimeRoot, `rustchan-e2e-${workerInfo?.workerIndex ?? 'manual'}-`)); - const preserveRoot = options.preserveRoot ?? process.env.RUSTCHAN_E2E_PRESERVE_ROOTS === '1'; - let app: RustChanServer | undefined; - try { - const { port, server: portReservation } = await reserveFreePort(); - app = new RustChanServer( - rootDir, - port, - process.env.RUSTCHAN_E2E_MEDIA_TOOLCHAIN === '1', - options.env ?? {}, - preserveRoot, - portReservation, - ); - await fsp.mkdir(app.binDir, { recursive: true }); - await fsp.copyFile(sourceBinary, app.binaryPath); - await fsp.chmod(app.binaryPath, 0o755); - await app.createFixtureFiles(); - return app; - } catch (error) { - if (app) { - await app.dispose().catch((cleanupError: unknown) => { - console.error(`failed to dispose incomplete RustChan fixture ${rootDir}: ${String(cleanupError)}`); - }); - } else { - await removeFixtureRoot(rootDir, preserveRoot).catch((cleanupError: unknown) => { - console.error(`failed to remove incomplete RustChan fixture ${rootDir}: ${String(cleanupError)}`); - }); - } - throw error; - } - } - - get env(): NodeJS.ProcessEnv { - return { - ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('CHAN_'))), - RUSTCHAN_SPAWNED: '1', - RUST_BACKTRACE: '1', - CHAN_HOST: '127.0.0.1', - CHAN_PORT: String(this.port), - CHAN_BIND: `127.0.0.1:${this.port}`, - CHAN_TOR_SUPPORT: '0', - CHAN_REQUIRE_FFMPEG: this.mediaToolchain ? '1' : '0', - CHAN_FFMPEG_PATH: this.mediaToolchain - ? (process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg') - : '__rustchan_e2e_no_ffmpeg__', - CHAN_HTTPS_COOKIES: '0', - CHAN_ENABLE_ANY_FILE_UPLOADS_FEATURE: '1', - CHAN_RATE_GETS: '1000', - CHAN_RATE_WINDOW: '1', - CHAN_AUTO_FULL_BACKUP_HOURS: '0', - CHAN_AUTO_FULL_BACKUP_STORAGE_MODE: 'directory', - CHAN_AUTO_VACUUM_HOURS: '0', - CHAN_WAL_CHECKPOINT_SECS: '0', - CHAN_POLL_CLEANUP_HOURS: '0', - CHAN_WAVEFORM_CACHE_MAX_MB: '1', - CHAN_JOB_QUEUE_CAPACITY: '50', - CHAN_PUBLIC_HOSTS: 'localhost,127.0.0.1,::1', - // Deep mode enables request-level trace lines in the runtime's - // dependency log so browser timings can be joined to server behaviour. - // The application does not log method/path/request-id at INFO, so the - // join uses timestamps plus the x-request-id response header. RUST_LOG is - // only injected when deep mode is on: an empty RUST_LOG would change the - // default log filter and could hide the startup lines other tests assert. - ...(process.env.RUSTCHAN_E2E_DEEP_DIAGNOSTICS === '1' - ? { RUST_LOG: 'info,tower_http::trace=trace' } - : {}), - ...this.envOverrides, - }; - } - - dbPath(): string { - return path.join(this.dataDir, 'chan.db'); - } - - async initializeDefaultData(): Promise { - this.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - const boards: SeedBoard[] = [ - { short: 'pub', name: 'Public Board', description: 'General public discussion' }, - { short: 'img', name: 'Images', description: 'Image upload board' }, - { short: 'vid', name: 'Video', description: 'Video upload board' }, - { short: 'aud', name: 'Audio', description: 'Audio upload board', audio: true }, - { short: 'nsfw', name: 'NSFW Board', description: 'Adult test board', nsfw: true }, - { short: 'txt', name: 'Text Only', description: 'Uploads disabled', noImages: true, noVideos: true }, - ]; - for (const board of boards) { - this.createBoardCli(board); - } - } - - createBoardCli(board: SeedBoard): void { - const args = ['admin', 'create-board', board.short, board.name, board.description ?? '']; - if (board.nsfw) args.push('--nsfw'); - if (board.noImages) args.push('--no-images'); - if (board.noVideos) args.push('--no-videos'); - if (board.audio) args.push('--audio'); - this.runCli(args); - } - - runCli(args: string[]): void { - for (let attempt = 0; attempt < 3; attempt += 1) { - const result = spawnSync(this.binaryPath, args, { - cwd: this.binDir, - // CLI commands only need one connection; avoid eight concurrent WAL - // initializers competing within the one-second pool startup bound. - env: { ...this.env, CHAN_DB_POOL_SIZE: '1' }, - encoding: 'utf8', - killSignal: 'SIGKILL', - timeout: 30_000, - }); - fs.appendFileSync(this.logPath, [ - `$ ${this.binaryPath} ${args.join(' ')}`, - result.stdout, - result.stderr, - ].join('\n')); - if (!result.error && result.status === 0) { - return; - } - // run_admin initializes the pool before dispatching the command. Only - // retry that startup timeout: no admin mutation has executed yet, and - // retrying any later failure could duplicate a partially completed write. - if (!result.error && attempt < 2 && args[0] === 'admin' - && result.stderr.startsWith('Error: Failed to build database pool\n') - && result.stderr.includes('timed out waiting for connection')) { - fs.appendFileSync(this.logPath, `Retrying fixture database pool startup (${attempt + 1}/2)\n`); - continue; - } - throw new Error(`rustchan-cli ${args.join(' ')} failed: ${result.error?.message || result.stderr || result.stdout}`); - } - } - - async start(options: StartOptions = {}): Promise { - if (this.process) { - return; - } - await this.releasePortReservation(); - const out = fs.createWriteStream(this.logPath, { flags: 'a' }); - this.logStream = out; - // Browser fixtures must stay headless even when Playwright itself runs in - // a terminal. Ratatui and the first-run terminal wizard require both stdin - // and stdout to be TTYs; never inherit those streams or send TUI shortcuts. - const child = spawn(this.binaryPath, ['serve'], { - cwd: options.cwd ?? this.binDir, - env: this.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - this.process = child; - child.stdout.pipe(out, { end: false }); - child.stderr.pipe(out, { end: false }); - child.once('exit', (code, signal) => { - fs.appendFileSync(this.logPath, `\n[server exited code=${code} signal=${signal}]\n`); - }); - // Keep a bounded diagnostic tail so a terminal-initialization regression - // reports its actual error rather than only a readiness timeout/exit code. - let startupOutput = ''; - const captureStartup = (chunk: Buffer) => { - startupOutput = (startupOutput + chunk.toString('utf8')).slice(-8_192); - }; - child.stdout.on('data', captureStartup); - child.stderr.on('data', captureStartup); - try { - if (options.ready) await options.ready(child); - else await waitForReady(this.baseURL, child); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `${message}\nRecent server output:\n${startupOutput.trim() || '(no output captured)'}`, - { cause: error }, - ); - } finally { - child.stdout.removeListener('data', captureStartup); - child.stderr.removeListener('data', captureStartup); - } - } - - async stop(): Promise { - const child = this.process; - if (child && !childHasExited(child)) { - child.kill('SIGTERM'); - if (!(await waitForChildExit(child, 5_000))) { - child.kill('SIGKILL'); - if (!(await waitForChildExit(child, 2_000))) { - throw new Error( - `rustchan fixture process ${child.pid ?? 'unknown'} did not exit after SIGTERM and SIGKILL`, - ); - } - } - } - if (child) { - this.process = undefined; - } - await this.closeLogStream(); - } - - async dispose(): Promise { - // Standalone runtimes have the same lifetime ordering as the app fixture: - // cancel their browser polling while the server is still reachable. - await closeAuditedPagesForOrigin(this.baseURL); - // If the child cannot be stopped, retain both its handle and fixture root so - // callers can retry cleanup without leaving an untracked live process. - await this.stop(); - let firstError: unknown; - try { - await this.releasePortReservation(); - } catch (error) { - firstError = error; - } - try { - await removeFixtureRoot(this.rootDir, this.preserveRoot); - } catch (error) { - firstError ??= error; - } - if (firstError) { - throw firstError; - } - } - - async restart(): Promise { - await this.stop(); - await this.start(); - } - - private async releasePortReservation(): Promise { - const reservation = this.portReservation; - if (!reservation) { - return; - } - this.portReservation = undefined; - await new Promise((resolve, reject) => { - reservation.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } - - private async closeLogStream(): Promise { - const stream = this.logStream; - this.logStream = undefined; - if (!stream || stream.closed) { - return; - } - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - stream.removeListener('error', onError); - reject(error); - }; - stream.once('error', onError); - stream.end(() => { - stream.removeListener('error', onError); - resolve(); - }); - }); - } - - async logs(): Promise { - try { - return await fsp.readFile(this.logPath, 'utf8'); - } catch (error) { - // Keep failure artifacts honest: report the read failure instead of an - // empty string that looks like an application which logged nothing. - return `<>`; - } - } - - /** Byte offset of the server log, used to isolate one test's log output. */ - logSize(): number { - try { - return fs.statSync(this.logPath).size; - } catch { - return 0; - } - } - - /** Server log content appended since `offset`. */ - async logsSince(offset: number): Promise { - const bytes = await fsp.readFile(this.logPath).catch(() => Buffer.alloc(0)); - return bytes.subarray(Math.max(0, Math.min(offset, bytes.length))).toString('utf8'); - } - - /** Dependency log (request/codec/migration lines) if the runtime wrote one. */ - async dependencyLogs(offset = 0): Promise { - const text = await fsp.readFile(path.join(this.dataDir, 'logs', 'dep_log.log'), 'utf8').catch(() => ''); - return text.slice(Math.max(0, Math.min(offset, text.length))); - } - - fixtures(): FixtureFiles { - return { - tinyPng: path.join(this.fixtureDir, 'tiny.png'), - spoofedPng: path.join(this.fixtureDir, 'spoofed.png'), - fakeMp4: path.join(this.fixtureDir, 'tiny.mp4'), - fakeOgg: path.join(this.fixtureDir, 'tiny.ogg'), - tinyPdf: path.join(this.fixtureDir, 'tiny.pdf'), - invalid: path.join(this.fixtureDir, 'invalid.txt'), - oversized: path.join(this.fixtureDir, 'oversized.bin'), - oddNamePng: path.join(this.fixtureDir, '../fixtures/name with spaces unicode-é quotes \' \" .. slash.png'), - }; - } - - private async createFixtureFiles(): Promise { - await fsp.mkdir(this.fixtureDir, { recursive: true }); - const tinyPng = pngRgba(1, 1, (index) => [0, 200, 64, 255][index % 4]); - await fsp.writeFile(path.join(this.fixtureDir, 'tiny.png'), tinyPng); - await fsp.writeFile(path.join(this.fixtureDir, 'spoofed.png'), Buffer.from('not actually a png')); - if (this.mediaToolchain) { - await this.createRealMediaFixtures(); - } else { - const media = path.join(repoRoot, 'tests/fixtures/media'); - await fsp.copyFile(path.join(media, 'video.mp4'), path.join(this.fixtureDir, 'tiny.mp4')); - await fsp.copyFile(path.join(media, 'tone.ogg'), path.join(this.fixtureDir, 'tiny.ogg')); - } - await fsp.copyFile(path.join(repoRoot, 'tests/fixtures/media/simple.pdf'), path.join(this.fixtureDir, 'tiny.pdf')); - await fsp.writeFile(path.join(this.fixtureDir, 'invalid.txt'), 'plain text is not an accepted media file'); - // Store PNG data without compression so its wire size exceeds 1 MiB. - await fsp.writeFile(path.join(this.fixtureDir, 'oversized.bin'), pngRgba(900, 900, (index) => (index * 37 + 19) % 256, 0)); - await fsp.writeFile(this.fixtures().oddNamePng, tinyPng); - } - - private async createRealMediaFixtures(): Promise { - const ffmpeg = process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg'; - runFixtureTool(ffmpeg, [ - '-hide_banner', - '-loglevel', - 'error', - '-y', - '-f', - 'lavfi', - '-i', - 'testsrc2=size=160x120:rate=12:duration=3', - '-f', - 'lavfi', - '-i', - 'sine=frequency=440:duration=3', - '-c:v', - 'mpeg4', - '-b:v', - '900k', - '-pix_fmt', - 'yuv420p', - '-c:a', - 'aac', - '-b:a', - '128k', - '-movflags', - '+faststart', - path.join(this.fixtureDir, 'tiny.mp4'), - ]); - runFixtureTool(ffmpeg, [ - '-hide_banner', - '-loglevel', - 'error', - '-y', - '-f', - 'lavfi', - '-i', - 'sine=frequency=880:duration=2', - '-c:a', - 'libopus', - path.join(this.fixtureDir, 'tiny.ogg'), - ]); - } -} - -export const test = base.extend<{ app: RustChanServer; serverLogOnFailure: void }>({ - app: [async ({ context }, use, workerInfo) => { - const app = await RustChanServer.create(workerInfo); - try { - await app.initializeDefaultData(); - await app.start(); - await use(app); - } finally { - // Cancel page reloads and background polling while the server is alive. - // Keep the context for Playwright teardown so late diagnostics failures - // can still retain their browser traces. - try { - await Promise.all(context.pages().filter(page => !page.isClosed()) - .map(page => page.close())); - } finally { - await app.dispose(); - } - } - }, { timeout: 120_000 }], - - serverLogOnFailure: [async ({ app }, use, testInfo: TestInfo) => { - const offset = app.logSize(); - // Initialize the per-test declaration list before the test body so - // expectServerLogError() can be called from inside the test. - serverLogExpectations.set(testInfo, serverLogExpectations.get(testInfo) ?? []); - const expectations = serverLogExpectations.get(testInfo); - await use(undefined); - - const delta = await app.logsSince(offset); - const errorLines = delta.split('\n').filter(line => line.includes('[ERROR]')); - const consumedLines = new Set(); - for (const expectation of expectations ?? []) { - const capacity = expectation.times === 'any' ? Number.POSITIVE_INFINITY : Math.max(1, expectation.times ?? 1); - let used = 0; - errorLines.forEach((line, index) => { - if (used >= capacity || consumedLines.has(index)) return; - if (expectation.pattern.test(line)) { - consumedLines.add(index); - used += 1; - } - }); - expectation.matched = used; - } - const unexpectedErrors = errorLines.filter((line, index) => !consumedLines.has(index)); - const unmatchedDeclarations = (expectations ?? []) - .filter(expectation => (expectation.matched ?? 0) === 0 && expectation.optional !== true); - - const failed = testInfo.status !== testInfo.expectedStatus; - if (delta.trim().length > 0 || failed) { - await testInfo.attach('rustchan-server-delta.log', { - body: delta || '(no new server output during this test)', - contentType: 'text/plain', - }); - if (failed) { - await testInfo.attach('rustchan-server.log', { body: await app.logs(), contentType: 'text/plain' }); - await testInfo.attach('rustchan-dep.log', { body: await app.dependencyLogs(), contentType: 'text/plain' }); - } - } - if (unexpectedErrors.length > 0) { - expect(unexpectedErrors, `unexpected server [ERROR] lines:\n${unexpectedErrors.join('\n')}`).toEqual([]); - } - if (unmatchedDeclarations.length > 0) { - expect(unmatchedDeclarations.map(item => item.reason), 'declared server-log expectations that never matched').toEqual([]); - } - }, { auto: true }], -}); - -export { expect }; - -export async function gotoAppPage(page: Page, url: string): Promise { - await page.goto(url, { waitUntil: 'domcontentloaded' }); -} - -export async function adminLogin(page: Page, app: RustChanServer): Promise { - // WebKit can apply autofocus after DOMContentLoaded and steal the password - // fill into the username field. Let the login page finish loading first. - await page.goto(`${app.baseURL}/admin`, { waitUntil: 'load' }); - if (page.url().includes('/admin/panel')) { - await expectSafePage(page, { allowAdminInternals: true }); - return; - } - const username = page.getByLabel('Username'); - const password = page.getByLabel('Password'); - await expect(username).toBeFocused(); - await username.fill(ADMIN_USERNAME); - await password.fill(ADMIN_PASSWORD); - await expect(username).toHaveValue(ADMIN_USERNAME); - await expect(password).toHaveValue(ADMIN_PASSWORD); - await Promise.all([ - page.waitForURL(/\/admin\/panel/), - page.getByRole('button', { name: 'authenticate' }).click(), - ]); - await expectSafePage(page, { allowAdminInternals: true }); -} - -export async function adminLogout(page: Page): Promise { - const form = page.locator('form.admin-panel-logout, form[action="/admin/logout"]').first(); - await Promise.all([ - // Match the exact login path. The previous /\/admin/ pattern also matched - // /admin/panel, the pre-logout URL, so it could resolve before the logout - // POST had any effect. - page.waitForURL(url => new URL(url).pathname.replace(/\/+$/, '') === '/admin', { waitUntil: 'domcontentloaded' }), - form.getByRole('button', { name: /logout/i }).click(), - ]); - await expect(page.getByLabel('Username'), 'logout returns to the login form').toBeVisible(); -} - -/** - * Assert an authenticated admin page really rendered instead of silently - * redirecting to the login form. Use after an admin action whose success would - * otherwise be "proved" by any 200 response or by a /\/admin/ URL match. - */ -export async function expectAdminPanel(page: Page): Promise { - await expect(page, 'admin action should land on the admin panel').toHaveURL(/\/admin\/panel/); - await expect(page.locator('form[action="/admin/login"]'), 'admin panel must not be the login form').toHaveCount(0); - await expect(page.locator('.admin-panel'), 'authenticated panel must be present').toBeVisible(); -} - -export async function createBoard(page: Page, app: RustChanServer, board: SeedBoard): Promise { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - const form = new URLSearchParams({ - _csrf: csrf, - short_name: board.short, - name: board.name, - description: board.description ?? '', - }); - if (board.nsfw) form.set('nsfw', '1'); - if (board.audio) form.set('allow_audio', '1'); - const response = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: Object.fromEntries(form), - maxRedirects: 0, - }); - expect([303, 409]).toContain(response.status()); -} - -export async function updateBoardSettings( - page: Page, - app: RustChanServer, - short: string, - settings: BoardSettings, -): Promise { - await adminLogin(page, app); - const html = await adminPanelHtml(page, app); - const csrf = extractCsrf(html); - const boardId = extractBoardId(html, short); - const form = new URLSearchParams({ - _csrf: csrf, - board_id: String(boardId), - name: settings.name ?? displayNameFor(short), - description: settings.description ?? `${short} e2e board`, - bump_limit: String(settings.bumpLimit ?? 300), - max_threads: String(settings.maxThreads ?? 150), - max_archived_threads: String(settings.maxArchivedThreads ?? 150), - post_cooldown_secs: String(settings.postCooldownSecs ?? 0), - max_image_size_mb: String(settings.maxImageSizeMb ?? 8), - max_video_size_mb: String(settings.maxVideoSizeMb ?? 50), - max_audio_size_mb: String(settings.maxAudioSizeMb ?? 150), - max_pdf_size_mb: String(settings.maxPdfSizeMb ?? 8), - default_theme: settings.defaultTheme ?? '', - banner_mode: 'inherit', - access_mode: settings.accessMode ?? 'public', - access_password: settings.accessPassword ?? '', - }); - setCheck(form, 'nsfw', settings.nsfw ?? false); - setCheck(form, 'allow_images', settings.allowImages ?? true); - setCheck(form, 'allow_video', settings.allowVideo ?? true); - setCheck(form, 'allow_audio', settings.allowAudio ?? false); - setCheck(form, 'allow_pdf', settings.allowPdf ?? false); - setCheck(form, 'allow_any_files', settings.allowAnyFiles ?? false); - setCheck(form, 'allow_archive', settings.allowArchive ?? false); - setCheck(form, 'allow_tripcodes', true); - setCheck(form, 'allow_editing', settings.allowEditing ?? false); - setCheck(form, 'allow_self_delete', settings.allowSelfDelete ?? false); - setCheck(form, 'allow_video_embeds', true); - setCheck(form, 'show_poster_ids', false); - setCheck(form, 'collapse_greentext', false); - if (settings.clearAccessPassword) form.set('clear_access_password', '1'); - - const response = await page.request.post(`${app.baseURL}/admin/board/settings`, { - form: Object.fromEntries(form), - maxRedirects: 0, - }); - expect(response.status()).toBe(303); -} - -export async function createThread( - page: Page, - app: RustChanServer, - board: string, - options: { subject?: string; body?: string; filePath?: string } = {}, -): Promise { - await gotoAppPage(page, `${app.baseURL}/${board}`); - await expectSafePage(page); - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - await waitForPostFormScroll(page); - } - const form = page.locator(`form[action="/${board}"]`).first(); - await expect(form.locator('input[name="subject"]')).toBeVisible(); - await form.locator('input[name="subject"]').fill(options.subject ?? `subject ${Date.now()}`); - await form.locator('textarea[name="body"]').fill(options.body ?? `thread body ${Date.now()}`); - if (options.filePath) { - await form.locator('input[type="file"]').first().setInputFiles(options.filePath); - } - const threadUrl = new RegExp(`/${board}/thread/\\d+`); - const submit = form.getByRole('button', { name: /post thread/i }); - await submit.scrollIntoViewIfNeeded(); - await Promise.all([ - page.waitForURL(threadUrl, { waitUntil: 'domcontentloaded' }), - submit.click(), - ]); - await expectSafePage(page); - return threadIdFromUrl(page.url()); -} - -export async function createReply( - page: Page, - app: RustChanServer, - board: string, - threadId: number, - body = `reply body ${Date.now()}`, -): Promise { - await gotoAppPage(page, `${app.baseURL}/${board}/thread/${threadId}`); - await expectSafePage(page); - const toggle = page.locator('[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - await waitForPostFormScroll(page); - } - const form = page.locator(`form[action="/${board}/thread/${threadId}"]`).first(); - await form.locator('textarea[name="body"]').fill(body); - const replyPath = `/${board}/thread/${threadId}`; - const postCountBefore = Number(sqliteQuery( - app, - `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`, - )); - const [response] = await Promise.all([ - page.waitForResponse((candidate) => ( - candidate.request().method() === 'POST' - && new URL(candidate.url()).pathname === replyPath - )), - form.getByRole('button', { name: /post reply/i }).click(), - ]); - expect([302, 303]).toContain(response.status()); - await expect.poll( - () => Number(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE thread_id = ${threadId};`)), - ).toBe(postCountBefore + 1); - await expect(page.locator('.post')).toHaveCount(postCountBefore + 1); - await expectSafePage(page); -} - -async function waitForPostFormScroll(page: Page): Promise { - // Mobile opening schedules a smooth scroll. Filling and clicking immediately - // can race that scroll and move the button between pointer down and up. - await expect.poll(() => page.evaluate(() => { - if (!document.documentElement.classList.contains('js') || window.innerWidth > 700) return 0; - const form = document.getElementById('post-form-wrap'); - if (!form) throw new Error('Post form is missing'); - const top = form.getBoundingClientRect().top + window.scrollY; - const target = Math.min(top, document.documentElement.scrollHeight - window.innerHeight); - return Math.abs(window.scrollY - Math.max(0, target)); - }), { message: 'mobile post-form opening scroll reaches its destination' }).toBeLessThanOrEqual(2); -} - -export async function createThreadViaRequest( - page: Page, - app: RustChanServer, - board: string, - options: { subject?: string; body?: string } = {}, -): Promise { - const csrf = await publicCsrf(page, app, `/${board}`); - const response = await page.request.post(`${app.baseURL}/${board}`, { - multipart: { - _csrf: csrf, - submission_token: uniqueSubmissionToken(board), - subject: options.subject ?? `subject ${Date.now()}`, - body: options.body ?? `thread body ${Date.now()}`, - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(response.status()); - const location = response.headers().location ?? ''; - return threadIdFromUrl(location); -} - -export async function createReplyViaRequest( - page: Page, - app: RustChanServer, - board: string, - threadId: number, - body = `reply body ${Date.now()}`, -): Promise { - const csrf = await publicCsrf(page, app, `/${board}/thread/${threadId}`); - const response = await page.request.post(`${app.baseURL}/${board}/thread/${threadId}`, { - multipart: { - _csrf: csrf, - submission_token: uniqueSubmissionToken(`${board}-${threadId}`), - body, - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(response.status()); -} - -export async function unlockBoard(page: Page, app: RustChanServer, board: string, password: string): Promise { - await gotoAppPage(page, `${app.baseURL}/${board}/unlock`); - await page.locator('input[name="password"]').fill(password); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}`)), - page.getByRole('button', { name: /unlock board|unlock posting/i }).click(), - ]); - await expectSafePage(page); -} - -export async function expectSafePage(page: Page, options: { allowAdminInternals?: boolean } = {}): Promise { - await expect(page.locator('body')).toBeVisible(); - const body = await page.locator('body').innerText(); - expect(body).not.toMatch(/thread panicked|stack backtrace|SQLITE_|database is locked/i); - if (!options.allowAdminInternals) { - expect(body).not.toMatch(/\/Users\/|rustchan-data|target\/debug/i); - } -} - -export async function expectSafeResponse(response: APIResponse): Promise { - const text = await response.text(); - expect(text).not.toMatch(/thread panicked|stack backtrace|SQLITE_|database is locked/i); - return text; -} - -export async function expectNoDialog(page: Page, action: () => Promise): Promise { - let dialogSeen = false; - page.once('dialog', async (dialog) => { - dialogSeen = true; - await dialog.dismiss(); - }); - await action(); - expect(dialogSeen).toBe(false); -} - -export async function adminCsrf(page: Page, app: RustChanServer): Promise { - return extractCsrf(await adminPanelHtml(page, app)); -} - -export async function publicCsrf(page: Page, app: RustChanServer, pathPart = '/'): Promise { - const response = await page.request.get(`${app.baseURL}${pathPart}`); - const html = await response.text(); - return extractCsrf(html); -} - -export function threadIdFromUrl(url: string): number { - const match = url.match(/\/thread\/(\d+)/); - if (!match) throw new Error(`no thread id in ${url}`); - return Number(match[1]); -} - -export function uniqueShort(prefix: string, testInfo: TestInfo): string { - const stem = prefix.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 3) || 'b'; - const hash = createHash('sha1') - .update([ - testInfo.project.name, - testInfo.file, - testInfo.title, - testInfo.workerIndex, - testInfo.repeatEachIndex, - testInfo.retry, - prefix, - ].join('\0')) - .digest('hex'); - return `${stem}${hash.slice(0, 8 - stem.length)}`; -} - -function uniqueSubmissionToken(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -export function extractCsrf(html: string): string { - const match = html.match(/name="_csrf"\s+value="([^"]+)"/); - if (!match) throw new Error('CSRF token not found'); - return decodeHtml(match[1]); -} - -export async function createStandaloneApp(options: { admin?: boolean; boards?: SeedBoard[]; env?: Record } = {}): Promise { - const app = await RustChanServer.create(undefined, {env:options.env}); - try { - if (options.admin) { - app.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - } - for (const board of options.boards ?? []) { - app.createBoardCli(board); - } - await app.start(); - return app; - } catch (error) { - await app.dispose(); - throw error; - } -} - -export function setBoardFixtureSettings(app: RustChanServer, short: string, settings: BoardFixtureSettings): void { - const assignments: string[] = []; - addSqlBool(assignments, 'nsfw', settings.nsfw); - addSqlBool(assignments, 'allow_images', settings.allowImages); - addSqlBool(assignments, 'allow_video', settings.allowVideo); - addSqlBool(assignments, 'allow_audio', settings.allowAudio); - addSqlBool(assignments, 'allow_pdf', settings.allowPdf); - addSqlBool(assignments, 'allow_any_files', settings.allowAnyFiles); - addSqlBool(assignments, 'allow_archive', settings.allowArchive); - addSqlBool(assignments, 'allow_editing', settings.allowEditing); - addSqlBool(assignments, 'allow_self_delete', settings.allowSelfDelete); - addSqlBool(assignments, 'allow_captcha', settings.allowCaptcha); - addSqlInt(assignments, 'bump_limit', settings.bumpLimit); - addSqlInt(assignments, 'max_threads', settings.maxThreads); - addSqlInt(assignments, 'max_archived_threads', settings.maxArchivedThreads); - addSqlInt(assignments, 'post_cooldown_secs', settings.postCooldownSecs); - addSqlInt(assignments, 'max_image_size', settings.maxImageSizeBytes ?? mibToBytes(settings.maxImageSizeMb)); - addSqlInt(assignments, 'max_video_size', settings.maxVideoSizeBytes ?? mibToBytes(settings.maxVideoSizeMb)); - addSqlInt(assignments, 'max_audio_size', settings.maxAudioSizeBytes ?? mibToBytes(settings.maxAudioSizeMb)); - addSqlInt(assignments, 'max_pdf_size', settings.maxPdfSizeBytes ?? mibToBytes(settings.maxPdfSizeMb)); - addSqlText(assignments, 'name', settings.name); - addSqlText(assignments, 'description', settings.description); - addSqlText(assignments, 'default_theme', settings.defaultTheme); - if (settings.accessMode) { - assignments.push(`access_mode = ${sqlLiteral(settings.accessMode)}`); - } - if (settings.accessPasswordHash) { - assignments.push(`access_password_hash = ${sqlLiteral(settings.accessPasswordHash)}`); - } - if (settings.clearAccessPassword) { - assignments.push("access_password_hash = ''"); - } - if (assignments.length === 0) { - return; - } - sqliteExec(app, `UPDATE boards SET ${assignments.join(', ')} WHERE short_name = ${sqlLiteral(short)};`); -} - -export function setThreadFixtureState( - app: RustChanServer, - threadId: number, - state: { locked?: boolean; sticky?: boolean; archived?: boolean; createdAt?: number; bumpedAt?: number }, -): void { - const assignments: string[] = []; - addSqlBool(assignments, 'locked', state.locked); - addSqlBool(assignments, 'sticky', state.sticky); - addSqlBool(assignments, 'archived', state.archived); - addSqlInt(assignments, 'created_at', state.createdAt); - addSqlInt(assignments, 'bumped_at', state.bumpedAt); - if (assignments.length === 0) { - return; - } - sqliteExec(app, `UPDATE threads SET ${assignments.join(', ')} WHERE id = ${threadId};`); -} - -export function setPostFixtureCreatedAt(app: RustChanServer, postId: number, createdAt: number): void { - sqliteExec(app, `UPDATE posts SET created_at = ${createdAt} WHERE id = ${postId};`); -} - -export function setSiteFixtureSettings(app: RustChanServer, settings: SiteFixtureSettings): void { - const entries: [string, string | undefined][] = [ - ['site_name', settings.siteName], - ['site_subtitle', settings.siteSubtitle], - ['default_theme', settings.defaultTheme], - ['homepage_new_thread_badges_enabled', boolText(settings.homepageNewThreadBadgesEnabled)], - ['homepage_new_reply_badges_enabled', boolText(settings.homepageNewReplyBadgesEnabled)], - ['thread_new_reply_badges_enabled', boolText(settings.threadNewReplyBadgesEnabled)], - ]; - for (const [key, value] of entries) { - if (value === undefined) { - continue; - } - sqliteExec(app, [ - 'INSERT INTO site_settings (key, value)', - `VALUES (${sqlLiteral(key)}, ${sqlLiteral(value)})`, - 'ON CONFLICT(key) DO UPDATE SET value = excluded.value;', - ].join(' ')); - } -} - -export function adminPasswordHash(app: RustChanServer): string { - const hash = sqliteQuery(app, "SELECT password_hash FROM admin_users WHERE username = 'admin' LIMIT 1;"); - if (!hash) { - throw new Error('admin password hash not found'); - } - return hash.trim(); -} - -export function boardId(app: RustChanServer, short: string): number { - const id = Number(sqliteQuery(app, `SELECT id FROM boards WHERE short_name = ${sqlLiteral(short)} LIMIT 1;`)); - if (!Number.isInteger(id) || id <= 0) { - throw new Error(`board /${short}/ not found`); - } - return id; -} - -export function sqliteExec(app: RustChanServer, sql: string): void { - const result = spawnSync('sqlite3', [app.dbPath(), `PRAGMA busy_timeout=5000; ${sql}`], { - encoding: 'utf8', - }); - if (result.status !== 0) { - throw new Error(`sqlite fixture update failed: ${result.stderr || result.stdout}`); - } -} - -export function sqliteQuery(app: RustChanServer, sql: string): string { - const result = spawnSync('sqlite3', ['-batch', '-noheader', app.dbPath(), sql], { - encoding: 'utf8', - }); - if (result.status !== 0) { - throw new Error(`sqlite fixture query failed: ${result.stderr || result.stdout}`); - } - return result.stdout.trim(); -} - -async function adminPanelHtml(page: Page, app: RustChanServer): Promise { - const response = await page.request.get(`${app.baseURL}/admin/panel`); - expect(response.status()).toBe(200); - return response.text(); -} - -function extractBoardId(html: string, short: string): number { - const escaped = short.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const details = new RegExp(`]+id="board-${escaped}"[\\s\\S]*?'); -} - -function childHasExited(child: ChildProcess): boolean { - return child.exitCode !== null || child.signalCode !== null; -} - -async function waitForChildExit( - child: ChildProcess, - timeoutMs: number, -): Promise { - if (childHasExited(child)) { - return true; - } - return new Promise((resolve) => { - let settled = false; - const finish = (exited: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - child.removeListener('exit', onExit); - resolve(exited); - }; - const onExit = () => finish(true); - const timer = setTimeout(() => finish(childHasExited(child)), timeoutMs); - child.once('exit', onExit); - if (childHasExited(child)) { - finish(true); - } - }); -} - -async function removeFixtureRoot(rootDir: string, preserveRoot: boolean): Promise { - if (preserveRoot) { - console.warn(`preserving RustChan E2E fixture root: ${rootDir}`); - return; - } - await fsp.rm(rootDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100, - }); -} - -async function reserveFreePort(): Promise<{ port: number; server: net.Server }> { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('failed to allocate port'))); - return; - } - resolve({ port: address.port, server }); - }); - }); -} - -async function waitForReady(baseURL: string, child: ChildProcess): Promise { - const deadline = Date.now() + 60_000; - let lastError: unknown; - while (Date.now() < deadline) { - if (childHasExited(child)) { - throw new Error(`rustchan exited before ready with code ${child.exitCode} signal ${child.signalCode}`); - } - try { - const response = await fetch(`${baseURL}/readyz`, { signal: AbortSignal.timeout(1_000) }); - if (response.ok) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 150)); - } - throw new Error(`rustchan did not become ready: ${String(lastError)}`); -} - -function pngRgba(width: number, height: number, pixelByte: (index: number) => number, compressionLevel = zlib.constants.Z_DEFAULT_COMPRESSION): Buffer { - const raw = Buffer.alloc((width * 4 + 1) * height); - let sourceIndex = 0; - for (let y = 0; y < height; y += 1) { - const rowStart = y * (width * 4 + 1); - raw[rowStart] = 0; - for (let x = 0; x < width * 4; x += 1) { - raw[rowStart + 1 + x] = pixelByte(sourceIndex); - sourceIndex += 1; - } - } - const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - return Buffer.concat([ - signature, - pngChunk('IHDR', ihdr), - pngChunk('IDAT', zlib.deflateSync(raw, { level: compressionLevel })), - pngChunk('IEND', Buffer.alloc(0)), - ]); -} - -function pngChunk(type: string, data: Buffer): Buffer { - const typeBytes = Buffer.from(type, 'ascii'); - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - typeBytes.copy(out, 4); - data.copy(out, 8); - out.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length); - return out; -} - -function crc32(data: Buffer): number { - let crc = 0xffffffff; - for (const byte of data) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - return (crc ^ 0xffffffff) >>> 0; -} - -function runFixtureTool(command: string, args: string[]): void { - const result = spawnSync(command, args, { encoding: 'utf8' }); - if (result.status !== 0) { - throw new Error([ - `failed to create media fixture with ${command}`, - result.stdout, - result.stderr, - ].filter(Boolean).join('\n')); - } -} diff --git a/tests/e2e/maintenance-backup-phase2.spec.ts b/tests/e2e/maintenance-backup-phase2.spec.ts deleted file mode 100644 index 0efb52a3..00000000 --- a/tests/e2e/maintenance-backup-phase2.spec.ts +++ /dev/null @@ -1,362 +0,0 @@ -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import { - adminCsrf, - adminLogin, - createThread, - expect, - expectSafePage, - expectSafeResponse, - expectServerLogError, - test, -} from './helpers'; - -test.describe('phase 2 admin maintenance and backup progress', () => { - test('DB check, repair status, progress polling, stale job protection, and concurrent maintenance blocking work through admin UI routes', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'maintenance progress coverage runs on Chromium first'); - - await adminLogin(page, app); - const loggedOut = await page.context().browser()!.newContext(); - const loggedOutPage = await loggedOut.newPage(); - const publicRepair = await loggedOutPage.request.get(`${app.baseURL}/admin/db/repair`, { maxRedirects: 0 }); - expect([302, 303, 403]).toContain(publicRepair.status()); - await loggedOut.close(); - - await page.goto(`${app.baseURL}/admin/db/repair`); - await expect(page.locator('body')).toContainText(/No maintenance rebuild is running/i); - await expectSafePage(page); - - const check = await page.request.post(`${app.baseURL}/admin/db/check`, { - form: { _csrf: await adminCsrf(page, app) }, - maxRedirects: 0, - }); - expect(check.status()).toBe(200); - const checkBody = await expectSafeResponse(check); - expect(checkBody).toContain('Database health checks passed'); - expectNoHostPathLeak(checkBody, app); - - const protectedBackupRef = await createSavedFullBackup(page, app); - await seedMaintenancePayload(app); - await assertRepairBlockedDuringBackup(page, app, protectedBackupRef); - - const runningResponse = await page.request.post(`${app.baseURL}/admin/db/repair`, { - form: { _csrf: await adminCsrf(page, app) }, - maxRedirects: 0, - }); - expect(runningResponse.status()).toBe(200); - const runningBody = await runningResponse.text(); - expect(runningBody).toContain('data-db-repair-progress'); - const jobId = Number(runningBody.match(/data-db-repair-job-id="(\d+)"/)?.[1]); - expect(Number.isInteger(jobId) && jobId > 0).toBe(true); - await waitForRepairRunning(page, app, jobId); - - const staleProgress = await page.request.get(`${app.baseURL}/admin/db/repair/progress?job_id=${jobId + 9999}`); - expect(staleProgress.status()).toBe(200); - const staleJson = await staleProgress.json(); - expect(staleJson.state).toBe('stale'); - expect(staleJson.done).toBe(true); - - const finalProgress = await waitForRepairDone(page, app, jobId); - // A fresh, intact database must rebuild successfully. The previous - // assertion accepted 'failed' and the text "maintenance rebuild failed", so - // a permanently broken repair could pass its own test. - expect(finalProgress.state, `repair progress payload: ${JSON.stringify(finalProgress)}`).toBe('finished'); - expect(finalProgress.done).toBe(true); - const status = await page.request.get(`${app.baseURL}/admin/db/repair/status?job_id=${jobId}`); - expect(status.status()).toBe(200); - const statusBody = await expectSafeResponse(status); - expect(statusBody, 'completed repair status page').toContain('Maintenance completed'); - expect(statusBody, 'a failed rebuild must not be reported as success').not.toMatch(/maintenance rebuild failed/i); - expectNoHostPathLeak(statusBody.replace(/Backup path:<\/strong>[^<]+[^<]+<\/code>/g, ''), app); - - const staleStatus = await page.request.get(`${app.baseURL}/admin/db/repair/status?job_id=${jobId + 9999}`); - expect(staleStatus.status()).toBe(200); - const staleStatusBody = await expectSafeResponse(staleStatus); - expect(staleStatusBody).toContain('no longer the current status'); - expectNoHostPathLeak(staleStatusBody, app); - }); - - test('backup create, progress JSON, saved row, download, corrupt restore failure, cleanup, and delete are safe', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'backup UI/progress coverage runs on Chromium first'); - - const sentinelThread = await createThread(page, app, 'pub', { - subject: 'backup progress sentinel', - body: 'live site should survive corrupt restore', - }); - await adminLogin(page, app); - const beforeRefs = await savedBackupRefs(app); - const create = await page.request.post(`${app.baseURL}/admin/backup/create`, { - form: { - _csrf: await adminCsrf(page, app), - storage_mode: 'split_zip', - split_zip_part_size_gib: '4', - }, - maxRedirects: 0, - }); - expect(create.status()).toBe(303); - const afterRefs = await savedBackupRefs(app); - const createdRefs = afterRefs.filter((item) => !beforeRefs.includes(item)); - expect(createdRefs.length).toBe(1); - const backupRef = createdRefs[0]; - - const progress = await page.request.get(`${app.baseURL}/admin/backup/progress`); - expect(progress.status()).toBe(200); - const progressJson = await progress.json(); - expect(progressJson.phase).toBe(5); - expect(progressJson.files_done).toBeGreaterThanOrEqual(0); - - await page.goto(`${app.baseURL}/admin/panel?open=full-backup-restore#full-backup-restore`); - await expect(page.locator('#full-backup-restore')).toContainText(backupRef); - await expectSafePage(page, { allowAdminInternals: true }); - - const downloadPart = splitZipPartName(app, backupRef); - const download = await page.request.get(`${app.baseURL}/admin/backup/download/full/${backupRef}?part=${downloadPart}`, { - maxRedirects: 0, - }); - expect(download.status()).toBe(200); - expect(download.headers()['content-type']).toContain('application/zip'); - expect(download.headers()['content-disposition']).toContain('attachment'); - expect(Number(download.headers()['content-length'] ?? '0')).toBeGreaterThan(0); - - // The corrupt archive below is the subject of this test, and the server - // records its rejection as an [ERROR] line; that line is expected here. - expectServerLogError({ - pattern: /Full restore failed - route: \/admin\/restore, error: Bad request: Invalid zip/, - reason: 'corrupt-archive restore rejection is asserted by this test', - }); - const corruptRestore = await page.request.post(`${app.baseURL}/admin/restore`, { - multipart: { - _csrf: await adminCsrf(page, app), - backup_file: { - name: 'corrupt.zip', - mimeType: 'application/zip', - buffer: Buffer.from('not a zip archive'), - }, - }, - headers: { - 'X-Requested-With': 'XMLHttpRequest', - ...adminSameOriginHeaders(app), - }, - maxRedirects: 0, - }); - expect([200, 400, 409, 422]).toContain(corruptRestore.status()); - const corruptBody = await expectSafeResponse(corruptRestore); - expect(corruptBody).toMatch(/Invalid zip|restore/i); - expectNoHostPathLeak(corruptBody, app); - expect(await runtimeTmpFileCount(app)).toBe(0); - - await page.goto(`${app.baseURL}/pub/thread/${sentinelThread}`); - await expect(page.locator('body')).toContainText('live site should survive corrupt restore'); - await expectSafePage(page); - - const deleteBackup = await page.request.post(`${app.baseURL}/admin/backup/delete`, { - form: { - _csrf: await adminCsrf(page, app), - kind: 'full', - filename: backupRef, - }, - maxRedirects: 0, - }); - expect(deleteBackup.status()).toBe(303); - expect(fs.existsSync(path.join(app.dataDir, 'backups', backupRef))).toBe(false); - }); - - test('fault-injected repair failure and pre-repair backup failure remain manual e2e cases', async ({}, testInfo) => { - test.skip( - testInfo.project.name !== 'chromium', - 'manual note is attached to the Chromium phase-2 run only', - ); - test.skip(true, 'normal e2e binary does not expose the cfg(test) pre-repair backup failure hook or a deterministic hard repair failure trigger'); - }); -}); - -async function seedMaintenancePayload(app: { dataDir: string }): Promise { - const dir = path.join(app.dataDir, 'boards', 'pub', 'phase2-maintenance-payload'); - await fsp.mkdir(dir, { recursive: true }); - for (let i = 0; i < 96; i += 1) { - const chunk = Buffer.alloc(512 * 1024); - let state = 0x9e3779b9 ^ i; - for (let offset = 0; offset < chunk.length; offset += 1) { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - chunk[offset] = state & 0xff; - } - await fsp.writeFile(path.join(dir, `payload-${i}.bin`), chunk); - } -} - -async function waitForRepairRunning( - page: Parameters[0], - app: Parameters[1], - jobId: number, -): Promise { - let last: { state: string; done: boolean } = { state: 'starting', done: false }; - await expect.poll(async () => { - const response = await page.request.get(`${app.baseURL}/admin/db/repair/progress?job_id=${jobId}`); - expect(response.status()).toBe(200); - last = await response.json(); - return last.state === 'running' && !last.done ? 'running' : `${last.done ? 'done' : 'pending'}:${last.state}`; - }, { timeout: 15_000, intervals: [100, 250, 500] }).toBe('running'); -} - -async function waitForRepairDone( - page: Parameters[0], - app: Parameters[1], - jobId: number, -): Promise<{ state: string; done: boolean }> { - let last: { state: string; done: boolean } = { state: 'starting', done: false }; - await expect.poll(async () => { - const response = await page.request.get(`${app.baseURL}/admin/db/repair/progress?job_id=${jobId}`); - expect(response.status()).toBe(200); - last = await response.json(); - return last.done; - }, { timeout: 45_000, intervals: [250, 500, 1_000] }).toBe(true); - return last; -} - -async function assertRepairBlockedDuringBackup( - page: Parameters[0], - app: Parameters[1], - protectedBackupRef: string, -): Promise { - const createBackup = page.request.post(`${app.baseURL}/admin/backup/create`, { - form: { - _csrf: await adminCsrf(page, app), - storage_mode: 'split_zip', - split_zip_part_size_gib: '4', - }, - maxRedirects: 0, - timeout: 60_000, - }); - await waitForBackupInProgress(page, app); - const blockedRepair = await page.request.post(`${app.baseURL}/admin/db/repair`, { - form: { _csrf: await adminCsrf(page, app) }, - maxRedirects: 0, - }); - expect(blockedRepair.status()).toBe(409); - const conflictBody = await expectSafeResponse(blockedRepair); - expect(conflictBody).toContain('already running'); - expectNoHostPathLeak(conflictBody, app); - - const blockedDelete = await page.request.post(`${app.baseURL}/admin/backup/delete`, { - form: { - _csrf: await adminCsrf(page, app), - kind: 'full', - filename: protectedBackupRef, - }, - maxRedirects: 0, - }); - expect(blockedDelete.status()).toBe(409); - const deleteConflictBody = await expectSafeResponse(blockedDelete); - expect(deleteConflictBody).toContain('already running'); - expect(fs.existsSync(path.join(app.dataDir, 'backups', protectedBackupRef))).toBe(true); - - const blockedRestore = await page.request.post(`${app.baseURL}/admin/backup/restore-saved`, { - form: { - _csrf: await adminCsrf(page, app), - filename: protectedBackupRef, - }, - maxRedirects: 0, - }); - expect(blockedRestore.status()).toBe(409); - const restoreConflictBody = await expectSafeResponse(blockedRestore); - expect(restoreConflictBody).toContain('already running'); - - const created = await createBackup; - expect(created.status()).toBe(303); -} - -async function createSavedFullBackup( - page: Parameters[0], - app: Parameters[1], -): Promise { - const beforeRefs = await savedBackupRefs(app); - const response = await page.request.post(`${app.baseURL}/admin/backup/create`, { - form: { - _csrf: await adminCsrf(page, app), - storage_mode: 'directory', - }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - const createdRefs = (await savedBackupRefs(app)).filter((item) => !beforeRefs.includes(item)); - expect(createdRefs).toHaveLength(1); - return createdRefs[0]; -} - -async function waitForBackupInProgress( - page: Parameters[0], - app: Parameters[1], -): Promise { - let lastPhase = 0; - await expect.poll(async () => { - const response = await page.request.get(`${app.baseURL}/admin/backup/progress`); - expect(response.status()).toBe(200); - const progress = await response.json() as { phase: number }; - lastPhase = progress.phase; - return progress.phase > 0 && progress.phase < 5; - }, { - message: `backup should stay in progress for concurrency check; last phase ${lastPhase}`, - timeout: 15_000, - intervals: [100, 250, 500], - }).toBe(true); -} - -async function savedBackupRefs(app: { dataDir: string }): Promise { - const root = path.join(app.dataDir, 'backups'); - const entries = await fsp.readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return []; - throw error; - }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .filter((name) => name !== 'full' && name !== 'boards') - .sort(); -} - -function splitZipPartName(app: { dataDir: string }, backupRef: string): string { - const manifestPath = path.join(app.dataDir, 'backups', backupRef, 'manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { - parts?: Array<{ filename: string }>; - }; - const part = manifest.parts?.[0]?.filename; - expect(part).toMatch(/^parts\/part-\d{4}\.zip$/); - return path.basename(part!); -} - -function adminSameOriginHeaders(app: Parameters[1]): Record { - return { - Origin: app.baseURL, - Referer: `${app.baseURL}/admin/panel`, - }; -} - -async function runtimeTmpFileCount(app: { dataDir: string }): Promise { - const root = path.join(app.dataDir, 'runtime', 'tmp'); - let count = 0; - async function walk(dir: string): Promise { - const entries = await fsp.readdir(dir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return []; - throw error; - }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(fullPath); - } else if (entry.isFile()) { - count += 1; - } - } - } - await walk(root); - return count; -} - -function expectNoHostPathLeak(text: string, app: { dataDir: string }): void { - expect(text).not.toContain('/Users/'); - expect(text).not.toContain(app.dataDir); - expect(text).not.toContain('target/debug'); -} diff --git a/tests/e2e/media-toolchain-check.mjs b/tests/e2e/media-toolchain-check.mjs deleted file mode 100644 index 52db43cc..00000000 --- a/tests/e2e/media-toolchain-check.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -// FFmpeg extracts PNG frames; RustChan itself encodes their WebP thumbnails. -const REQUIRED_ENCODERS = ['png', 'libvpx-vp9', 'libopus']; - -if (process.argv.includes('--self-test')) { - runSelfTest(); -} else { - runRealCheck(); -} - -function runRealCheck() { - const ffmpeg = process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg'; - const errors = checkToolchain(ffmpeg); - if (errors.length > 0) fail(errors.join('\n')); -} - -function runSelfTest() { - const missing = checkToolchain('/definitely/not/rustchan-ffmpeg'); - assert(missing.some((message) => message.includes('not usable')), 'missing ffmpeg should be visible'); - - const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'rustchan-media-check-')); - try { - const broken = path.join(temp, 'ffmpeg-broken'); - fs.writeFileSync(broken, '#!/bin/sh\necho broken >&2\nexit 2\n'); - fs.chmodSync(broken, 0o755); - const brokenErrors = checkToolchain(broken); - assert(brokenErrors.some((message) => message.includes('not usable')), 'broken ffmpeg should be visible'); - - const noCodecs = path.join(temp, 'ffmpeg-no-codecs'); - fs.writeFileSync(noCodecs, '#!/bin/sh\nif [ "$1" = "-version" ]; then exit 0; fi\nprintf "Encoders:\\n V..... png\\n"\n'); - fs.chmodSync(noCodecs, 0o755); - const codecErrors = checkToolchain(noCodecs); - assert(codecErrors.some((message) => message.includes('missing required encoder')), 'missing codecs should be visible'); - - const nativeWebp = path.join(temp, 'ffmpeg-video-only'); - fs.writeFileSync(nativeWebp, '#!/bin/sh\nif [ "$1" = "-version" ]; then exit 0; fi\nif [ "$2" = "-muxers" ]; then printf " E webm WebM\\n"; exit 0; fi\nprintf " V..... png PNG\\n V..... libvpx-vp9 VP9\\n A..... libopus Opus\\n"\n'); - fs.chmodSync(nativeWebp, 0o755); - assert(checkToolchain(nativeWebp).length === 0, 'video pass must not require an FFmpeg WebP encoder'); - } finally { - fs.rmSync(temp, { recursive: true, force: true }); - } -} - -function checkToolchain(ffmpeg) { - const errors = []; - if (!run(ffmpeg, ['-version']).ok) errors.push(`FFmpeg '${ffmpeg}' is not usable.`); - const encoders = run(ffmpeg, ['-hide_banner', '-encoders']); - if (encoders.ok) { - const names = encoders.stdout.split('\n').map((line) => line.trim().split(/\s+/)).filter(([flags, name]) => /^[VA][A-Z.]{5}$/.test(flags ?? '') && name !== '=').map(([, name]) => name); - const missing = REQUIRED_ENCODERS.filter((encoder) => !names.includes(encoder)); - if (missing.length > 0) errors.push(`FFmpeg is missing required encoder support: ${missing.join(', ')}.`); - } else { - errors.push(`Could not inspect FFmpeg encoders: ${encoders.detail}`); - } - const muxers = run(ffmpeg, ['-hide_banner', '-muxers']); - if (!muxers.ok || !muxers.stdout.split('\n').some((line) => /^\s*E\s+webm\s/.test(line))) errors.push('FFmpeg is missing the WebM muxer.'); - return errors; -} - -function run(command, args) { - const result = spawnSync(command, args, { encoding: 'utf8' }); - return { - ok: result.status === 0, - stdout: result.stdout ?? '', - detail: [result.error?.message, result.stdout, result.stderr].filter(Boolean).join('\n').trim(), - }; -} - -function assert(condition, message) { - if (!condition) fail(`media-toolchain-check self-test failed: ${message}`); -} - -function fail(message) { - console.error(message); - process.exit(1); -} diff --git a/tests/e2e/media-toolchain.spec.ts b/tests/e2e/media-toolchain.spec.ts deleted file mode 100644 index f4c75d96..00000000 --- a/tests/e2e/media-toolchain.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Page, TestInfo } from '@playwright/test'; -import { spawnSync } from 'node:child_process'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import { expectNoHorizontalOverflow } from './phase4-helpers'; -import { - createThread, - expect, - expectSafePage, - expectSafeResponse, - sqliteQuery, - test, - updateBoardSettings, -} from './helpers'; - -test.skip(process.env.RUSTCHAN_E2E_MEDIA_TOOLCHAIN !== '1', 'opt-in real media toolchain pass only'); - -test.describe('real media toolchain', () => { - test('AV1 in WebM, Matroska and MP4 converts to verified VP9 WebM', async ({ page, app }) => { - test.setTimeout(180_000); - const ffmpeg = process.env.RUSTCHAN_E2E_FFMPEG_PATH ?? 'ffmpeg'; - const ffprobe = process.env.RUSTCHAN_E2E_FFPROBE_PATH ?? 'ffprobe'; - const decoders = spawnSync(ffmpeg, ['-hide_banner', '-decoders'], { encoding: 'utf8' }); - test.skip(decoders.status !== 0 || !/\b(?:av1|libdav1d|libaom-av1)\b/.test(decoders.stdout ?? ''), 'installed FFmpeg lacks an AV1 decoder'); - test.skip(spawnSync(ffprobe, ['-version'], { stdio: 'ignore' }).status !== 0, 'optional FFprobe oracle is unavailable'); - const media = path.resolve(__dirname, '../fixtures/media'); - for (const ext of ['webm', 'mkv', 'mp4']) { - const input = path.join(app.fixtureDir, `av1-input.${ext}`); - const args = ['-hide_banner', '-loglevel', 'error', '-i', path.join(media, `av1.${ext}`)]; - // Add AAC only to MP4 to verify conversion to WebM-compatible Opus audio. - if (ext === 'mp4') args.push('-i', path.join(media, 'tone.m4a'), '-map', '0:v:0', '-map', '1:a:0'); - // Harmless source metadata makes the result smaller under the existing size policy. - args.push('-c', 'copy', '-metadata', `comment=${'fixture padding '.repeat(512)}`, '-y', input); - const fixture = spawnSync(ffmpeg, args, { encoding: 'utf8', timeout: 30_000 }); - expect(fixture.status, fixture.stderr).toBe(0); - await createThread(page, app, 'vid', { - subject: `AV1 ${ext}`, body: 'AV1 container and codec conversion regression', filePath: input, - }); - await expect.poll(async () => { - await page.reload(); - await expectSafePage(page); - return page.locator('.file-info a').first().getAttribute('href'); - }, { timeout: 90_000, intervals: [1_000, 2_000] }).toMatch(ext === 'webm' ? /\.vp9\.webm$/ : /\.webm$/); - const href = await page.locator('.file-info a').first().getAttribute('href'); - const response = await page.request.get(`${app.baseURL}${href}`); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toContain('video/webm'); - const output = path.join(app.fixtureDir, `verified-${ext}.webm`); - await fsp.writeFile(output, await response.body()); - const probe = spawnSync(ffprobe, ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', output], { encoding: 'utf8', timeout: 30_000 }); - expect(probe.status, probe.stderr).toBe(0); - const info = JSON.parse(probe.stdout); - const video = info.streams.find((stream: { codec_type: string }) => stream.codec_type === 'video'); - expect(video.codec_name).toBe('vp9'); - expect(video.pix_fmt).toBe('yuv420p'); - expect([video.width, video.height]).toEqual([64, 64]); - expect(Number(info.format.duration)).toBeGreaterThan(0); - expect(info.streams.filter((stream: { codec_type: string }) => stream.codec_type === 'audio').map((stream: { codec_name: string }) => stream.codec_name)).toEqual(ext === 'mp4' ? ['opus'] : []); - expect(sqliteQuery(app, "SELECT mime_type || '|' || media_processing_state || '|' || COALESCE(media_processing_error, '') FROM posts ORDER BY id DESC LIMIT 1;")).toBe('video/webm||'); - expect(Number(sqliteQuery(app, 'SELECT file_size FROM posts ORDER BY id DESC LIMIT 1;'))).toBe((await fsp.stat(output)).size); - } - }); - - test('uploads use real thumbnails, transcodes, waveform previews, and PDF fallback safely', async ({ page, app, javaScriptEnabled }, testInfo) => { - test.setTimeout(180_000); - const files = app.fixtures(); - - await createThread(page, app, 'img', { - subject: 'real image', - body: 'thumbnail from real image', - filePath: files.tinyPng, - }); - await expect(page.locator('[data-media-thumb="1"]').first()).toBeVisible(); - const imageThumb = await page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - expect(imageThumb).toBeTruthy(); - const imageThumbResponse = await page.request.get(`${app.baseURL}${imageThumb}`); - expect(imageThumbResponse.status()).toBe(200); - expect(imageThumbResponse.headers()['content-type']).toContain('image/webp'); - if (javaScriptEnabled) await page.locator('.image-preview').click(); - await inspectMediaLayout(page, testInfo, 'image'); - - await createThread(page, app, 'vid', { - subject: 'real video', - body: 'video upload with background transcode', - filePath: files.fakeMp4, - }); - await expect(page.locator('.video-container')).toContainText('tiny.mp4'); - const originalVideoHref = await page.locator('.file-info a').first().getAttribute('href'); - expect(originalVideoHref).toMatch(/\.mp4$/); - const videoThumb = await page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - expect(videoThumb).toBeTruthy(); - const videoThumbResponse = await page.request.get(`${app.baseURL}${videoThumb}`); - expect(videoThumbResponse.status()).toBe(200); - expect(videoThumbResponse.headers()['content-type']).toContain('image/webp'); - - await expect.poll(async () => { - await page.reload(); - await expectSafePage(page); - return page.locator('.file-info a').first().getAttribute('href'); - }, { timeout: 90_000, intervals: [1_000, 2_000, 5_000] }).toMatch(/\.webm$/); - const transcodedVideoHref = await page.locator('.file-info a').first().getAttribute('href'); - const transcodedVideo = await page.request.get(`${app.baseURL}${transcodedVideoHref}`); - expect(transcodedVideo.status()).toBe(200); - expect(transcodedVideo.headers()['content-type']).toContain('video/webm'); - await expect(page.locator('video source')).toHaveAttribute('type', 'video/webm'); - const staleMp4 = await page.request.get(`${app.baseURL}${originalVideoHref}`, { maxRedirects: 0 }); - expect([301, 308]).toContain(staleMp4.status()); - if (javaScriptEnabled) await page.locator('.video-preview').click(); - await inspectMediaLayout(page, testInfo, 'video'); - - await createThread(page, app, 'aud', { - subject: 'real audio', - body: 'audio upload with waveform', - filePath: files.fakeOgg, - }); - await expect(page.locator('.audio-container')).toContainText('tiny.ogg'); - await expect.poll(async () => { - await page.reload(); - await expectSafePage(page); - return page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - }, { timeout: 90_000, intervals: [1_000, 2_000, 5_000] }).toMatch(/\.png$/); - const waveformThumb = await page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - const waveformResponse = await page.request.get(`${app.baseURL}${waveformThumb}`); - expect(waveformResponse.status()).toBe(200); - expect(waveformResponse.headers()['content-type']).toContain('image/png'); - await inspectMediaLayout(page, testInfo, 'audio'); - - const pdfBoard = `rpdf${testInfo.workerIndex}${Date.now().toString(36).slice(-3)}`.slice(0, 8); - app.createBoardCli({ short: pdfBoard, name: 'Real PDF Board' }); - await updateBoardSettings(page, app, pdfBoard, { allowPdf: true }); - await createThread(page, app, pdfBoard, { - subject: 'real pdf', - body: 'pdf upload', - filePath: files.tinyPdf, - }); - await expect(page.locator('.pdf-container')).toContainText('tiny.pdf'); - const pdfThumb = await page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - expect(pdfThumb).toBeTruthy(); - const pdfThumbResponse = await page.request.get(`${app.baseURL}${pdfThumb}`); - expect(pdfThumbResponse.status()).toBe(200); - const pdfThumbContentType = pdfThumbResponse.headers()['content-type']; - expect(pdfThumbContentType).toContain('image/webp'); - const pdfHref = await page.locator('.file-info a').first().getAttribute('href'); - const pdfResponse = await page.request.get(`${app.baseURL}${pdfHref}`); - expect(pdfResponse.status()).toBe(200); - expect(pdfResponse.headers()['content-type']).toContain('application/pdf'); - await expect(page.locator('iframe.media-expanded-pdf')).toHaveAttribute('data-src', pdfHref ?? ''); - await expectSafeResponse(pdfResponse); - if (javaScriptEnabled) await page.locator('.pdf-preview').click(); - await inspectMediaLayout(page, testInfo, 'pdf'); - }); -}); - -async function inspectMediaLayout(page: Page, testInfo: TestInfo, kind: string): Promise { - for (const width of [320, 360, 390, 430, 768, 1024, 1280, 1440]) { - await page.setViewportSize({ width, height: 844 }); - await expectNoHorizontalOverflow(page, `${kind} at ${width}px`, 1); - if (width === 320 || width === 1440) { - await page.locator('.file-container').first().scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath(`${kind}-${width}.png`) }); - } - } -} diff --git a/tests/e2e/media.spec.ts b/tests/e2e/media.spec.ts deleted file mode 100644 index 1d966de8..00000000 --- a/tests/e2e/media.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { expectConsoleError, expectHttpError } from './diagnostics'; -import { - createThread, - expect, - expectSafePage, - expectSafeResponse, - test, - updateBoardSettings, -} from './helpers'; -import fsp from 'node:fs/promises'; - -test.describe('media upload handling', () => { - test('image uploads render previews and disabled boards reject them safely', async ({ page, app }) => { - const files = app.fixtures(); - const threadId = await createThread(page, app, 'img', { - subject: 'image ok', - body: 'image upload', - filePath: files.tinyPng, - }); - await expect(page.locator('.file-container')).toContainText('tiny.png'); - await expect(page.locator('[data-media-thumb="1"]').first()).toBeVisible(); - const mediaHref = await page.locator('.file-info a').first().getAttribute('href'); - expect(mediaHref).toMatch(/^\/boards\/img\//); - const media = await page.request.get(`${app.baseURL}${mediaHref}`); - expect(media.status()).toBe(200); - - await page.goto(`${app.baseURL}/txt`); - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').click(); - const form = page.locator('form[action="/txt"]').first(); - const csrf = await form.locator('input[name="_csrf"]').getAttribute('value'); - const disabledUpload = await page.request.post(`${app.baseURL}/txt`, { - multipart: { - _csrf: csrf ?? '', - submission_token: `disabled-upload-${Date.now()}`, - body: 'not allowed here', - file: { - name: 'tiny.png', - mimeType: 'image/png', - buffer: await fsp.readFile(files.tinyPng), - }, - }, - maxRedirects: 0, - }); - expect([400, 422]).toContain(disabledUpload.status()); - await expectSafeResponse(disabledUpload); - await expectSafePage(page); - - await page.goto(`${app.baseURL}/img/thread/${threadId}`); - await expectSafePage(page); - }); - - test('invalid, spoofed, oversized, and traversal-like media paths are rejected safely', async ({ page, app }, testInfo) => { - const files = app.fixtures(); - const board = `lim${testInfo.workerIndex}${Date.now().toString(36).slice(-4)}`.slice(0, 8); - app.createBoardCli({ short: board, name: 'Limits' }); - await updateBoardSettings(page, app, board, { maxImageSizeMb: 1 }); - - expectHttpError({ - method: 'POST', - path: `/${board}`, - status: 415, - times: 2, - reason: 'both spoofed and invalid uploads in the loop are rejected as unsupported media', - }); - for (const filePath of [files.spoofedPng, files.invalid]) { - await page.goto(`${app.baseURL}/${board}`); - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').click(); - const form = page.locator(`form[action="/${board}"]`).first(); - await form.locator('textarea[name="body"]').fill('bad file'); - await form.locator('input[type="file"]').setInputFiles(filePath); - await form.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/file type|not allowed|accepted/i); - await expectSafePage(page); - } - - expect((await fsp.stat(files.oversized)).size).toBeGreaterThan(1024 * 1024); - await page.goto(`${app.baseURL}/${board}`); - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').click(); - await page.locator(`form[action="/${board}"] textarea[name="body"]`).fill('oversized'); - await page.locator(`form[action="/${board}"] input[type="file"]`).setInputFiles(files.oversized); - expectHttpError({ - method: 'POST', - path: `/${board}`, - status: 413, - reason: 'the uncompressed PNG exceeds the board upload size limit', - }); - await page.locator(`form[action="/${board}"] button[type="submit"]`).click(); - await expect(page.locator('.post-error-banner:not([hidden])').first()).toContainText(/too large|maximum/i); - await expectSafePage(page); - - const traversal = await page.request.get(`${app.baseURL}/boards/${board}/../../settings.toml`); - expect([400, 403, 404]).toContain(traversal.status()); - await expectSafeResponse(traversal); - const missing = await page.request.get(`${app.baseURL}/boards/${board}/missing.png`); - expect(missing.status()).toBe(404); - await expectSafeResponse(missing); - }); - - test('video, audio, PDF, and unusual filenames follow board media policy', async ({ page, app }, testInfo) => { - // WebKit logs its own native media-control internals to the console, e.g. - // "Button failed to load, iconName = pip-placard, layoutTraits = - // [MacOSLayoutTraits Inline], src = blob:...". These messages come from the - // engine's media chrome, not from RustChan, and their count depends on which - // controls WebKit decides to build. Declared narrowly (engine-internal - // message only, `times: 'any'`) so it cannot hide an application console - // error; verified against the raw run output. - expectConsoleError({ - pattern: /^Button failed to load, iconName = [a-z-]+-placard, layoutTraits = \[MacOSLayoutTraits Inline\], src = blob:/, - times: 'any', - optional: true, - reason: 'WebKit internal native-media-control logging, not application output; absent on Chromium', - }); - const files = app.fixtures(); - const pdfBoard = `pdf${testInfo.workerIndex}${Date.now().toString(36).slice(-4)}`.slice(0, 8); - app.createBoardCli({ short: pdfBoard, name: 'PDF Board' }); - await updateBoardSettings(page, app, pdfBoard, { allowPdf: true }); - - await createThread(page, app, 'vid', { subject: 'video', body: 'video upload', filePath: files.fakeMp4 }); - await expect(page.locator('.video-container, .file-container')).toContainText('tiny.mp4'); - - await createThread(page, app, 'aud', { subject: 'audio', body: 'audio upload', filePath: files.fakeOgg }); - await expect(page.locator('.audio-container, .file-container')).toContainText('tiny.ogg'); - - await createThread(page, app, pdfBoard, { subject: 'pdf', body: 'pdf upload', filePath: files.tinyPdf }); - await expect(page.locator('.pdf-container, .file-container')).toContainText('tiny.pdf'); - - await createThread(page, app, 'img', { subject: 'odd name', body: 'odd filename', filePath: files.oddNamePng }); - await expect(page.locator('.file-container')).toContainText('name with spaces'); - - await page.goto(`${app.baseURL}/img`); - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').click(); - await page.locator('form[action="/img"] textarea[name="body"]').fill('pdf rejected'); - await page.locator('form[action="/img"] input[type="file"]').setInputFiles(files.tinyPdf); - expectHttpError({ - method: 'POST', - path: '/img', - status: 422, - reason: 'a PDF upload on an image-only board is rejected with a validation error', - }); - await page.locator('form[action="/img"] button[type="submit"]').click(); - await expect(page.locator('.post-error-banner:not([hidden])').first()).toContainText(/pdf uploads are disabled/i); - }); -}); diff --git a/tests/e2e/mobile-nojs.spec.ts b/tests/e2e/mobile-nojs.spec.ts deleted file mode 100644 index 9b813975..00000000 --- a/tests/e2e/mobile-nojs.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - adminCsrf, - adminLogin, - createReply, - createThread, - expect, - expectSafePage, - test, -} from './helpers'; - -test.describe('mobile responsive smoke', () => { - test.use({ viewport: { width: 390, height: 844 } }); - - test('home, board, thread composer, media preview, admin login, and board edit are usable on mobile', async ({ page, app }) => { - const threadId = await createThread(page, app, 'img', { - subject: 'mobile media', - body: 'mobile preview', - filePath: app.fixtures().tinyPng, - }); - await page.goto(app.baseURL); - await expectSafePage(page); - await page.goto(`${app.baseURL}/img`); - await expectSafePage(page); - await page.goto(`${app.baseURL}/img/thread/${threadId}`); - await expectSafePage(page); - await expect(page.locator('[data-action="toggle-post-form"]')).toBeVisible(); - await expect(page.locator('.media-preview').first()).toBeVisible(); - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); - expect(overflow).toBeLessThanOrEqual(8); - - await page.goto(`${app.baseURL}/admin`); - await expect(page.getByLabel('Username')).toBeVisible(); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=board-pub#board-pub`); - await expect(page.locator('#board-pub summary')).toBeVisible(); - await expect(page.locator('#board-pub input[name="name"]').first()).toBeVisible(); - }); -}); - -test.describe('no-JS fallback', () => { - test.use({ javaScriptEnabled: false }); - - test('public navigation, thread creation, admin login, settings POST, and CSRF work without JavaScript', async ({ page, app }) => { - await page.goto(app.baseURL); - await expectSafePage(page); - await page.goto(`${app.baseURL}/pub`); - await expectSafePage(page); - - const form = page.locator('form[action="/pub"]').first(); - await form.locator('input[name="subject"]').fill('no js thread'); - await form.locator('textarea[name="body"]').fill('created with JavaScript disabled'); - await Promise.all([ - page.waitForURL(/\/pub\/thread\/\d+/), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - await expectSafePage(page); - - const threadUrl = page.url(); - const threadId = Number(threadUrl.match(/\/thread\/(\d+)/)?.[1]); - const csrf = await page.locator(`form[action="/pub/thread/${threadId}"] input[name="_csrf"]`).first().getAttribute('value'); - const reply = await page.request.post(`${app.baseURL}/pub/thread/${threadId}`, { - multipart: { - _csrf: csrf ?? '', - submission_token: 'no-js-reply', - body: 'no js reply via same browser session', - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(reply.status()); - await page.reload(); - await expect(page.locator('body')).toContainText('no js reply via same browser session'); - - await adminLogin(page, app); - const adminToken = await adminCsrf(page, app); - const settings = await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: adminToken, - site_name: 'No JS RustChan', - site_subtitle: 'forms still work', - default_theme: 'forest', - homepage_new_thread_badges_enabled: '1', - homepage_new_reply_badges_enabled: '1', - thread_new_reply_badges_enabled: '1', - }, - maxRedirects: 0, - }); - expect(settings.status()).toBe(303); - }); -}); diff --git a/tests/e2e/moderation-phase2.spec.ts b/tests/e2e/moderation-phase2.spec.ts deleted file mode 100644 index 3f56cf86..00000000 --- a/tests/e2e/moderation-phase2.spec.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { - adminCsrf, - adminLogin, - createReply, - createThread, - expect, - expectSafePage, - expectSafeResponse, - publicCsrf, - sqliteExec, - sqliteQuery, - test, - uniqueShort, - isNoJsProject, -} from './helpers'; - -test.describe('phase 2 moderation, report, and ban lifecycle', () => { - test('JS report modal, duplicate reports, resolution, deletion, bans, appeals, and mod log are coherent', async ({ page, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'JS moderation lifecycle coverage runs on Chromium first'); - - const board = uniqueShort('mod', testInfo); - app.createBoardCli({ short: board, name: 'Moderation Phase 2' }); - const threadId = await createThread(page, app, board, { - subject: 'moderation report target', - body: 'OP remains visible while a reply is moderated', - }); - await createReply(page, app, board, threadId, 'reply selected for report lifecycle'); - const replyPostId = Number(sqliteQuery( - app, - `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 0 ORDER BY id DESC LIMIT 1;`, - )); - const replyIpHash = sqliteQuery(app, `SELECT ip_hash FROM posts WHERE id = ${replyPostId};`); - expect(replyIpHash).toMatch(/^[0-9a-f]{64}$/i); - - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expectSafePage(page); - await expect(page.locator('body')).not.toContainText(replyIpHash); - const reportButton = page.locator(`#p${replyPostId} .report-btn`); - await reportButton.click(); - await expect(page.locator('#report-modal')).toBeVisible(); - await page.locator('#report-reason').fill('modal report reason '.repeat(20)); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - page.locator('#report-submit-btn').click(), - ]); - // Assert the outcome surface created by this action rather than a regex - // whose second alternative is the reply body that exists regardless. - await expect(page.locator('.post-success-banner').first()).toContainText(/report submitted/i); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM reports WHERE post_id = ${replyPostId} AND status = 'open';`)).toBe('1'); - expect(Number(sqliteQuery(app, `SELECT length(reason) FROM reports WHERE post_id = ${replyPostId};`))).toBeLessThanOrEqual(256); - - const duplicate = await page.request.post(`${app.baseURL}/report`, { - form: { - _csrf: await publicCsrf(page, app, `/${board}/thread/${threadId}`), - post_id: String(replyPostId), - thread_id: String(threadId), - board, - reason: 'duplicate report should not create another open row', - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(duplicate.status()); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM reports WHERE post_id = ${replyPostId} AND status = 'open';`)).toBe('1'); - - const loggedOutContext = await page.context().browser()!.newContext(); - const loggedOutBan = await loggedOutContext.request.post(`${app.baseURL}/admin/ban/add`, { - form: { - _csrf: 'missing-session', - ip_hash: 'a'.repeat(64), - reason: 'logged out moderation attempt', - duration_hours: '1', - }, - maxRedirects: 0, - }); - expect(loggedOutBan.status()).toBe(403); - await expectSafeResponse(loggedOutBan); - await loggedOutContext.close(); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=reports#reports`); - await expect(page.locator('#reports')).toContainText('reply selected for report lifecycle'); - await expect(page.locator('#reports')).toContainText(replyIpHash.slice(0, 16)); - const reportId = sqliteQuery(app, `SELECT id FROM reports WHERE post_id = ${replyPostId} AND status = 'open' LIMIT 1;`); - const csrf = await adminCsrf(page, app); - const csrfDenied = await page.request.post(`${app.baseURL}/admin/report/resolve`, { - form: { _csrf: 'invalid', report_id: reportId }, - maxRedirects: 0, - }); - expect(csrfDenied.status()).toBe(403); - await expectSafeResponse(csrfDenied); - const resolve = await page.request.post(`${app.baseURL}/admin/report/resolve`, { - form: { _csrf: csrf, report_id: reportId }, - maxRedirects: 0, - }); - expect(resolve.status()).toBe(303); - expect(sqliteQuery(app, `SELECT status FROM reports WHERE id = ${reportId};`)).toBe('resolved'); - - const deleteTargetThread = await createThread(page, app, board, { - subject: 'delete report target', - body: 'delete target op', - }); - await createReply(page, app, board, deleteTargetThread, 'reply selected for admin delete'); - const deletePostId = sqliteQuery( - app, - `SELECT id FROM posts WHERE thread_id = ${deleteTargetThread} AND is_op = 0 ORDER BY id DESC LIMIT 1;`, - ); - await adminLogin(page, app); - const deleteCsrfDenied = await page.request.post(`${app.baseURL}/admin/post/delete`, { - form: { - _csrf: 'invalid', - post_id: deletePostId, - board, - }, - maxRedirects: 0, - }); - expect(deleteCsrfDenied.status()).toBe(403); - await expectSafeResponse(deleteCsrfDenied); - const deletePost = await page.request.post(`${app.baseURL}/admin/post/delete`, { - form: { - _csrf: await adminCsrf(page, app), - post_id: deletePostId, - board: `//evil.example/${board}`, - }, - maxRedirects: 0, - }); - expect(deletePost.status()).toBe(303); - expect(deletePost.headers().location).toBe(`/${board}/thread/${deleteTargetThread}`); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${deletePostId};`)).toBe('0'); - - await page.goto(`${app.baseURL}/admin/mod-log`); - await expectSafePage(page, { allowAdminInternals: true }); - await expect(page.locator('body')).toContainText('resolve_report'); - await expect(page.locator('body')).toContainText('delete_post'); - await expect(page.locator('body')).not.toContainText('/Users/'); - - const longReason = `phase 2 ban reason ${'R'.repeat(700)}`; - const banCsrfDenied = await page.request.post(`${app.baseURL}/admin/ban/add`, { - form: { - _csrf: 'invalid', - ip_hash: replyIpHash, - reason: longReason, - duration_hours: '1', - }, - maxRedirects: 0, - }); - expect(banCsrfDenied.status()).toBe(403); - await expectSafeResponse(banCsrfDenied); - const ban = await page.request.post(`${app.baseURL}/admin/ban/add`, { - form: { - _csrf: await adminCsrf(page, app), - ip_hash: replyIpHash, - reason: longReason, - duration_hours: '1', - }, - maxRedirects: 0, - }); - expect(ban.status()).toBe(303); - await page.goto(`${app.baseURL}/${board}`); - const bannedPostCsrf = await postFormCsrf(page, board); - const bannedPost = await page.request.post(`${app.baseURL}/${board}`, { - multipart: { - _csrf: bannedPostCsrf, - submission_token: `banned-${Date.now()}`, - subject: 'banned thread', - body: 'posting should be denied while banned', - }, - headers: publicSameOriginHeaders(app, `/${board}`), - maxRedirects: 0, - }); - expect(bannedPost.status()).toBe(403); - const bannedBody = await expectSafeResponse(bannedPost); - expect(bannedBody).toContain('phase 2 ban reason'); - expect(bannedBody).not.toContain('/Users/'); - - await page.goto(`${app.baseURL}/banned?reason=${encodeURIComponent('phase 2 ban reason')}`); - await page.locator('textarea[name="reason"]').fill('appeal message '.repeat(45)); - await Promise.all([ - page.waitForURL(/\/appeal|\/$/), - page.getByRole('button', { name: /submit appeal/i }).click(), - ]); - await expect(page.locator('body')).toContainText(/appeal has been submitted|appeal submitted/i); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=reports#appeals`); - await expect(page.locator('body')).toContainText('appeal message'); - const appealId = sqliteQuery(app, `SELECT id FROM ban_appeals WHERE ip_hash = '${replyIpHash}' AND status = 'open' LIMIT 1;`); - const acceptAppeal = await page.request.post(`${app.baseURL}/admin/appeal/accept`, { - form: { - _csrf: await adminCsrf(page, app), - appeal_id: appealId, - ip_hash: replyIpHash, - }, - maxRedirects: 0, - }); - expect(acceptAppeal.status()).toBe(303); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM bans WHERE ip_hash = '${replyIpHash}';`)).toBe('0'); - - await page.goto(`${app.baseURL}/${board}`); - const postAfterUnbanCsrf = await postFormCsrf(page, board); - const postAfterUnban = await page.request.post(`${app.baseURL}/${board}`, { - multipart: { - _csrf: postAfterUnbanCsrf, - submission_token: `unbanned-${Date.now()}`, - subject: 'unbanned thread', - body: 'posting should work after appeal acceptance', - }, - headers: publicSameOriginHeaders(app, `/${board}`), - maxRedirects: 0, - }); - expect([302, 303]).toContain(postAfterUnban.status()); - - const expiredBan = await page.request.post(`${app.baseURL}/admin/ban/add`, { - form: { - _csrf: await adminCsrf(page, app), - ip_hash: replyIpHash, - reason: 'expired ban should not apply', - duration_hours: '1', - }, - maxRedirects: 0, - }); - expect(expiredBan.status()).toBe(303); - sqliteExec(app, `UPDATE bans SET expires_at = strftime('%s','now') - 10 WHERE ip_hash = '${replyIpHash}';`); - await page.goto(`${app.baseURL}/${board}`); - const expiredAllowedCsrf = await postFormCsrf(page, board); - const expiredAllowed = await page.request.post(`${app.baseURL}/${board}`, { - multipart: { - _csrf: expiredAllowedCsrf, - submission_token: `expired-ban-${Date.now()}`, - subject: 'expired ban allowed', - body: 'posting should work after ban expiry', - }, - headers: publicSameOriginHeaders(app, `/${board}`), - maxRedirects: 0, - }); - expect([302, 303]).toContain(expiredAllowed.status()); - }); - - test('no-JS report fallback posts through the plain form when JavaScript is disabled', async ({ page, app }, testInfo) => { - test.skip(!isNoJsProject(testInfo), 'plain report fallback is covered by the no-JS projects'); - - const board = uniqueShort('mnj', testInfo); - app.createBoardCli({ short: board, name: 'Moderation No JS' }); - const threadId = await createThread(page, app, board, { - subject: 'no js report target', - body: 'reported without scripts', - }); - const postId = sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 1 LIMIT 1;`); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - const fallback = page.locator(`#p${postId} form.report-fallback-form`); - await fallback.locator('summary').click(); - await fallback.locator('input[name="reason"]').fill('no js fallback reason'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - fallback.getByRole('button', { name: /submit report/i }).click(), - ]); - await expectSafePage(page); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM reports WHERE post_id = ${postId} AND status = 'open';`)).toBe('1'); - }); - -}); - -function publicSameOriginHeaders(app: Parameters[1], pathPart: string): Record { - return { - Origin: app.baseURL, - Referer: `${app.baseURL}${pathPart}`, - }; -} - -async function postFormCsrf(page: Parameters[0], board: string): Promise { - const toggle = page.locator('.post-toggle-btn[data-action="toggle-post-form"], [data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - const postInput = page.locator(`form[action="/${board}"] input[name="_csrf"]`).first(); - if (await postInput.count() > 0) { - const value = await postInput.inputValue(); - if (value.length > 0) return value; - } - const values = await page.locator('input[name="_csrf"]').evaluateAll((inputs) => - inputs - .map((input) => input instanceof HTMLInputElement ? input.value : '') - .filter((value) => value.length > 0), - ); - expect(values.length, `csrf token for /${board}/`).toBeGreaterThan(0); - return values[0]; -} diff --git a/tests/e2e/native-cache.config.ts b/tests/e2e/native-cache.config.ts deleted file mode 100644 index 4fdca77f..00000000 --- a/tests/e2e/native-cache.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import base from '../../playwright.config'; -import { defineConfig } from '@playwright/test'; -import path from 'node:path'; - -export default defineConfig({ - ...base, - testDir: '.', - globalSetup: './global-setup.ts', - testMatch: 'release-audit.spec.ts', - grep: /natural multi-board/, - workers: 1, - outputDir: path.resolve(base.outputDir ?? 'test-results/native-cache'), - reporter: [ - ['list'], - ['html', { outputFolder: path.resolve(process.env.RUSTCHAN_AUDIT_OUTPUT ?? 'output/playwright/native-cache', 'html'), open: 'never' }], - ['json', { outputFile: path.resolve(process.env.RUSTCHAN_AUDIT_OUTPUT ?? 'output/playwright/native-cache', 'results.json') }], - ], - use: { ...base.use, trace: 'off', video: 'off' }, - projects: base.projects?.filter(project => project.name !== 'firefox-nojs').map(project => ({ - ...project, - use: { - ...project.use, - ...(project.name === 'chromium' ? { launchOptions: { ignoreDefaultArgs: ['--disable-back-forward-cache'] } } : {}), - }, - })), -}); diff --git a/tests/e2e/nojs-enhancements.spec.ts b/tests/e2e/nojs-enhancements.spec.ts deleted file mode 100644 index a0a1383d..00000000 --- a/tests/e2e/nojs-enhancements.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { test, expect, adminLogin, createThreadViaRequest, createReplyViaRequest, sqliteQuery } from './helpers'; -import { expectNoHorizontalOverflow } from './phase4-helpers'; - -test.beforeEach(async ({ javaScriptEnabled }) => { - test.skip(javaScriptEnabled, 'server-rendered enhancement boundaries'); -}); - -test('no-JS thread refresh uses a normal link and does not offer automatic updates', async ({ page, app }) => { - const id = await createThreadViaRequest(page, app, 'pub', { body: 'no-JS update baseline' }); - for (const width of [320, 360, 390, 430, 768, 1024, 1280, 1440]) { - await page.setViewportSize({ width, height: 844 }); - await page.goto(`${app.baseURL}/pub/thread/${id}`); - await expect(page.locator('[data-action="fetch-updates"]').first()).toBeHidden(); - await expect(page.locator('.autoupdate-label').first()).toBeHidden(); - await createReplyViaRequest(page, app, 'pub', id, `reply at ${width}px`); - await page.getByRole('link', { name: '[ Update now ]', exact: true }).first().click(); - await expect(page.locator('.post.reply').last()).toContainText(`reply at ${width}px`); - await expectNoHorizontalOverflow(page, `no-JS refresh ${width}px`); - } -}); - -test('no-JS poll creation supports additional options without inert add buttons', async ({ page, app }, testInfo) => { - await page.setViewportSize({ width: 320, height: 844 }); - await page.goto(`${app.baseURL}/pub`); - await page.locator('.poll-creator > summary').click(); - await expect(page.locator('.poll-add-btn')).toBeHidden(); - await page.getByText('More poll options', { exact: true }).click(); - await page.locator('textarea[name="body"]').fill('poll from no-JS UI'); - await page.locator('input[name="poll_question"]').fill('Choose a third option?'); - await page.getByRole('textbox', { name: 'poll option 1', exact: true }).fill('First'); - await page.getByRole('textbox', { name: 'poll option 2', exact: true }).fill('Second'); - await page.getByRole('textbox', { name: 'poll option 3', exact: true }).fill('Third'); - await expectNoHorizontalOverflow(page, 'no-JS extra poll options'); - await page.screenshot({ path: testInfo.outputPath('nojs-poll-320.png') }); - await page.getByRole('button', { name: /post thread/i }).click(); - await expect(page).toHaveURL(/\/pub\/thread\/\d+/); - await expect(page.locator('.poll-container')).toContainText('Third'); -}); - -test('no-JS poster IDs expose identity without advertising an unavailable highlight action', async ({ page, app }) => { - const id = await createThreadViaRequest(page, app, 'pub', { body: 'poster identity' }); - await page.goto(`${app.baseURL}/pub/thread/${id}`); - await expect(page.locator('.poster-id-btn').first()).toBeVisible(); - await expect(page.locator('.poster-id-btn').first()).toBeDisabled(); -}); - -test('no-JS admin diagnostics and job counts do not advertise unavailable client actions', async ({ page, app }, testInfo) => { - await page.setViewportSize({ width: 320, height: 844 }); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=site-health#site-health`); - const toggles = page.locator('[data-admin-health-toggle]'); - expect(await toggles.count()).toBeGreaterThan(0); - for (const toggle of await toggles.all()) await expect(toggle).toBeDisabled(); - await page.locator('[data-admin-diagnostics] > summary').click(); - await expect(page.locator('[data-admin-diagnostics-copy]')).toBeHidden(); - await expect(page.locator('[data-admin-diagnostics-close]')).toBeHidden(); - await expect(page.locator('[data-admin-diagnostics-text]')).toBeVisible(); - await expectNoHorizontalOverflow(page, 'no-JS admin diagnostics'); - await page.screenshot({ path: testInfo.outputPath('nojs-diagnostics-320.png') }); - await page.locator('[data-admin-diagnostics] > summary').click(); - await expect(page.locator('[data-admin-diagnostics-text]')).toBeHidden(); -}); - -test('no-JS admin IP reports submit through a validated native form', async ({ page, app }, testInfo) => { - const id = await createThreadViaRequest(page, app, 'pub', { body: 'admin IP report target' }); - const hash = sqliteQuery(app, `SELECT ip_hash FROM posts WHERE thread_id=${id} LIMIT 1`); - await page.setViewportSize({ width: 320, height: 844 }); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/ip/${hash}`); - const fallback = page.locator('form[action="/admin/ip/report"]').first(); - await expect(fallback).toBeVisible(); - await fallback.locator('input[name="reason"]').fill('No-JS admin report regression'); - await expectNoHorizontalOverflow(page, 'no-JS admin IP report'); - const section = page.locator('.admin-section'); - expect(await section.evaluate(el => el.scrollWidth - el.clientWidth), 'admin card content must fit without clipping').toBeLessThanOrEqual(1); - const submit = fallback.getByRole('button', { name: 'report', exact: true }); - await expect(submit).toBeInViewport({ ratio: 1 }); - await page.screenshot({ path: testInfo.outputPath('nojs-ip-report-320.png') }); - await fallback.getByRole('button', { name: 'report', exact: true }).click(); - await expect(page).toHaveURL(/\/admin\//); - expect(sqliteQuery(app, "SELECT COUNT(*) FROM reports WHERE reason LIKE 'No-JS admin report regression%'")).toBe('1'); -}); diff --git a/tests/e2e/non-video-media.spec.ts b/tests/e2e/non-video-media.spec.ts deleted file mode 100644 index 5b50dd32..00000000 --- a/tests/e2e/non-video-media.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import path from 'node:path'; -import { expectConsoleError } from './diagnostics'; -import { createThread, expect, expectSafePage, test } from './helpers'; - -// The default harness deliberately points FFmpeg at an unavailable executable. -test('common audio waveforms finish in the worker without FFmpeg', async ({ page, app }, testInfo) => { - test.setTimeout(180_000); - const media = path.resolve(__dirname, '../fixtures/media'); - const audioFiles = ['tone.mp3', 'tone.flac', 'tone.wav', 'tone.ogg', 'tone.aac', 'tone.m4a', 'tone-alac.m4a', 'tone.opus', 'speech-mode.opus', 'surround.opus', 'opus-surround-distinct.opus', 'opus-surround-padded-gain.opus', 'opus-surround-discrete.opus', 'audio.webm']; - const declareNativeControlRender = () => { - if (process.platform !== 'darwin' - || testInfo.project.name !== 'mobile-webkit' - || testInfo.project.use.deviceScaleFactor !== 3) return; - // macOS WebKit's native controls request @3x PNG placards for this iPhone - // profile, but its bundle ships only @1x/@2x. These native messages have - // no script source URL; application console errors remain undeclared. - // Register only one native control set for each test-owned thread render - // or poll reload. A valid slower worker may require additional reloads. - const port = new URL(app.baseURL).port; - for (const [icon, perRender] of [ - ['invalid-placard', 1], ['pip-placard', 2], ['airplay-placard', 1], - ] as const) { - expectConsoleError({ - pattern: new RegExp(`^Button failed to load, iconName = ${icon}, layoutTraits = \\[MacOSLayoutTraits Inline\\], src = blob:http://127\\.0\\.0\\.1:${port}/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), - url: /^$/, - times: perRender, - optional: true, - reason: 'macOS WebKit native media controls lack DPR3 PNG placard assets', - }); - } - }; - for (const file of audioFiles) { - declareNativeControlRender(); - await createThread(page, app, 'aud', { - subject: `Rust waveform ${file}`, - body: 'common audio decoder and atomic waveform job', - filePath: path.join(media, file), - }); - await expect(page.locator('.audio-container')).toBeVisible(); - await expect.poll(async () => { - declareNativeControlRender(); - await page.reload(); - await expectSafePage(page); - return page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - }, { timeout: 20_000, intervals: [100, 250, 500] }).toMatch(/\.png$/); - const src = await page.locator('[data-media-thumb="1"]').first().getAttribute('src'); - const response = await page.request.get(`${app.baseURL}${src}`); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toContain('image/png'); - const pixels = await response.body(); - expect(pixels.readUInt32BE(16)).toBe(250); - expect(pixels.readUInt32BE(20)).toBe(125); - } -}); diff --git a/tests/e2e/password-boards.spec.ts b/tests/e2e/password-boards.spec.ts deleted file mode 100644 index e5c58a10..00000000 --- a/tests/e2e/password-boards.spec.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { expectHttpError, newAuditedContext } from './diagnostics'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - adminPasswordHash, - createReply, - createThread, - expect, - expectSafePage, - expectSafeResponse, - RustChanServer, - setBoardFixtureSettings, - sqliteQuery, - test, - uniqueShort, - unlockBoard, - updateBoardSettings, -} from './helpers'; - -test.describe('password-protected boards', () => { - test('view-password boards require per-context unlock and resist back-forward bypasses', async ({ page, browser, app }, testInfo) => { - const board = uniqueShort('view', testInfo); - app.createBoardCli({ short: board, name: 'View Password' }); - const threadId = await createThread(page, app, board, { - subject: 'view protected subject', - body: 'view protected body', - }); - await updateBoardSettings(page, app, board, { - accessMode: 'view_password', - accessPassword: 'view-secret', - }); - await page.context().clearCookies(); - - expectHttpError({ - method: 'GET', - path: `/${board}`, - status: 403, - reason: 'a view-password board without the unlock cookie renders the gate with HTTP 403', - }); - await page.goto(`${app.baseURL}/${board}`); - await expect(page.locator('body')).toContainText(/password protected/i); - await expect(page.locator('input[name="password"]')).toBeVisible(); - await page.locator('input[name="password"]').fill('wrong'); - expectHttpError({ - method: 'POST', - path: `/${board}/unlock`, - status: 403, - reason: 'a wrong board password is rejected by the unlock form', - }); - await page.getByRole('button', { name: /unlock board/i }).click(); - await expect(page.locator('body')).toContainText(/invalid|wrong|password/i); - await expectSafePage(page); - - await unlockBoard(page, app, board, 'view-secret'); - await expect(page).toHaveURL(new RegExp(`/${board}`)); - await expectSafePage(page); - await page.goto(`${app.baseURL}/${board}`); - await expect(page.locator('body')).toContainText('view protected body'); - await page.goto(`${app.baseURL}/${board}/catalog`); - await expect(page.locator('body')).toContainText('view protected body'); - await page.locator(`a[href="/${board}/thread/${threadId}"]`).first().click(); - await expect(page).toHaveURL(new RegExp(`/${board}/thread/${threadId}`)); - await expect(page.locator('body')).toContainText('view protected body'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('body')).toContainText('view protected body'); - await page.getByRole('link', { name: '[ Return ]', exact: true }).first().click(); - await expect(page).toHaveURL(new RegExp(`/${board}$`)); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(new RegExp(`/${board}/thread/${threadId}$`)); - await expect(page.locator('body')).toContainText('view protected body'); - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(new RegExp(`/${board}$`)); - await expect(page.locator('body')).toContainText('view protected body'); - await page.context().clearCookies(); - await page.goto(app.baseURL); - expectHttpError({ - method: 'GET', - path: `/${board}`, - status: 403, - reason: 'back-forward to the board after cookie loss is refused again', - }); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('input[name="password"]')).toBeVisible(); - await expect(page.locator('body')).not.toContainText('view protected body'); - await expectSafePage(page); - - const otherContext = await newAuditedContext(browser); - const other = await otherContext.newPage(); - expectHttpError({ - method: 'GET', - path: `/${board}`, - status: 403, - reason: 'a separate browser context does not inherit the unlock cookie and is refused', - }); - await other.goto(`${app.baseURL}/${board}`); - await expect(other.locator('body')).toContainText(/password protected/i); - await expect(other.locator('input[name="password"]')).toBeVisible(); - await otherContext.close(); - }); - - test('post-password boards allow viewing but require password for threads and replies', async ({ page, app }, testInfo) => { - const board = uniqueShort('post', testInfo); - app.createBoardCli({ short: board, name: 'Post Password' }); - await updateBoardSettings(page, app, board, { - accessMode: 'post_password', - accessPassword: 'post-secret', - allowEditing: true, - allowSelfDelete: true, - }); - await page.context().clearCookies(); - - await page.goto(`${app.baseURL}/${board}`); - await expectSafePage(page); - await expect(page.locator('#board-access-gate')).toContainText(/posting is password protected|unlock posting/i); - - const denied = await page.request.post(`${app.baseURL}/${board}`, { - multipart: { - _csrf: await page.locator('input[name="_csrf"]').first().getAttribute('value') ?? '', - submission_token: 'missing-password', - body: 'should not post', - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(denied.status()); - expect(denied.headers()['location']).toContain(`/${board}/unlock`); - - await unlockBoard(page, app, board, 'post-secret'); - const threadId = await createThread(page, app, board, { - subject: 'post password ok', - body: 'created after unlock', - }); - const editHref = await page.locator('.self-action-controls .edit-btn').first().getAttribute('href'); - expect(editHref).toBeTruthy(); - const deleteOnlyThreadId = await createThread(page, app, board, { - subject: 'post password delete gate', - body: 'delete should require posting unlock', - }); - const deleteHref = await page.locator('.self-action-controls .del-btn').first().getAttribute('href'); - expect(deleteHref).toBeTruthy(); - await createReply(page, app, board, threadId, 'reply after unlock'); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('#board-access-gate')).toHaveCount(0); - - await page.context().clearCookies({ name: `rustchan_board_access_${board}` }); - expectHttpError({ - method: 'GET', - path: editHref!, - status: 403, - reason: 'without the posting unlock the own-post edit page is refused', - }); - await page.goto(`${app.baseURL}${editHref}`); - await expect(page.locator('body')).toContainText(/password|unlock/i); - await expect(page.locator('body')).not.toContainText('created after unlock'); - expectHttpError({ - method: 'GET', - path: deleteHref!, - status: 403, - reason: 'without the posting unlock the own-post delete page is refused', - }); - await page.goto(`${app.baseURL}${deleteHref}`); - await expect(page.locator('body')).toContainText(/password|unlock/i); - await expect(page.locator('body')).not.toContainText('delete should require posting unlock'); - await unlockBoard(page, app, board, 'post-secret'); - await page.goto(`${app.baseURL}/${board}/thread/${deleteOnlyThreadId}`); - await expect(page.locator('body')).toContainText('delete should require posting unlock'); - }); - - test('view-password boards do not leak protected content before unlock', async ({ page, app }, testInfo) => { - const board = uniqueShort('leak', testInfo); - app.createBoardCli({ short: board, name: 'Leak Audit' }); - const threadId = await createThread(page, app, board, { - subject: 'leak audit subject', - body: 'leak audit body', - filePath: app.fixtures().tinyPng, - }); - const mediaHref = await page.locator('.post.op .file-info a').first().getAttribute('href'); - expect(mediaHref).toMatch(/^\/boards\//); - expect(mediaHref).toContain(`/boards/${board}/`); - const postId = Number(sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 1 LIMIT 1;`)); - expect(postId).toBeGreaterThan(0); - - await updateBoardSettings(page, app, board, { - accessMode: 'view_password', - accessPassword: 'leak-secret', - allowArchive: true, - }); - await page.context().clearCookies(); - - const forbiddenHtml = [ - `/${board}`, - `/${board}/catalog`, - `/${board}/hidden`, - `/${board}/archive`, - `/${board}/search?q=leak`, - `/${board}/thread/${threadId}`, - `/${board}/thread/999999`, - ]; - for (const path of forbiddenHtml) { - const response = await page.request.get(`${app.baseURL}${path}`, { maxRedirects: 0 }); - expect(response.status(), path).toBe(403); - const body = await expectSafeResponse(response); - expect(body, path).not.toContain('leak audit body'); - expect(body, path).not.toContain('leak audit subject'); - } - - const homepage = await page.request.get(app.baseURL); - expect(homepage.status()).toBe(200); - const homepageBody = await expectSafeResponse(homepage); - expect(homepageBody).not.toContain('leak audit body'); - expect(homepageBody).not.toContain('leak audit subject'); - - const preview = await page.request.get(`${app.baseURL}/api/post/${board}/${postId}`); - expect(preview.status()).toBe(404); - const previewBody = await expectSafeResponse(preview); - expect(previewBody).not.toContain('leak audit body'); - - const postRedirect = await page.request.get(`${app.baseURL}/${board}/post/${postId}`, { maxRedirects: 0 }); - expect([302, 303]).toContain(postRedirect.status()); - expect(postRedirect.headers()['location']).toContain(`/${board}/unlock`); - expect(await postRedirect.text()).not.toContain('leak audit body'); - - const updates = await page.request.get(`${app.baseURL}/${board}/thread/${threadId}/updates?since=0`); - expect(updates.status()).toBe(403); - expect(await updates.text()).not.toContain('leak audit body'); - - const mediaUrl = new URL(mediaHref, app.baseURL); - mediaUrl.searchParams.set('access-check', String(postId)); - const media = await page.request.get(mediaUrl.toString()); - expect(media.status()).toBe(403); - expect(await media.text()).not.toContain('leak audit body'); - }); - - test('password changes retire the old password and accept the new one', async ({ page, app }, testInfo) => { - const board = uniqueShort('pwchg', testInfo); - app.createBoardCli({ short: board, name: 'Password Change' }); - await updateBoardSettings(page, app, board, { - accessMode: 'view_password', - accessPassword: 'old-secret', - }); - // Leave the polling admin panel before removing its session cookie. - await page.goto('about:blank'); - await page.context().clearCookies(); - await unlockBoard(page, app, board, 'old-secret'); - - await updateBoardSettings(page, app, board, { - accessMode: 'view_password', - accessPassword: 'new-secret', - }); - // Leave the polling admin panel before removing its session cookie. - await page.goto('about:blank'); - await page.context().clearCookies(); - const fresh = await page.context().browser()!.newContext(); - const freshPage = await fresh.newPage(); - await freshPage.goto(`${app.baseURL}/${board}/unlock`); - await freshPage.locator('input[name="password"]').fill('old-secret'); - await freshPage.getByRole('button', { name: /unlock board/i }).click(); - await expect(freshPage.locator('body')).toContainText(/invalid|wrong|password/i); - await freshPage.locator('input[name="password"]').fill('new-secret'); - await Promise.all([ - freshPage.waitForURL(new RegExp(`/${board}`)), - freshPage.getByRole('button', { name: /unlock board/i }).click(), - ]); - await expectSafePage(freshPage); - await fresh.close(); - }); - - test('mobile WebKit persists board unlock over HTTP localhost when HTTPS cookies are configured', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'mobile-webkit', 'mobile WebKit cookie persistence regression'); - - const secureCookieApp = await RustChanServer.create(undefined, { - env: { CHAN_HTTPS_COOKIES: '1' }, - }); - const board = uniqueShort('mwview', testInfo); - const postBoard = uniqueShort('mwpost', testInfo); - const spoofBoard = uniqueShort('mwspuf', testInfo); - try { - secureCookieApp.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - secureCookieApp.createBoardCli({ short: board, name: 'Mobile WebKit View Password' }); - secureCookieApp.createBoardCli({ short: postBoard, name: 'Mobile WebKit Post Password' }); - secureCookieApp.createBoardCli({ short: spoofBoard, name: 'Mobile WebKit Spoofed Headers' }); - setBoardFixtureSettings(secureCookieApp, board, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(secureCookieApp), - }); - setBoardFixtureSettings(secureCookieApp, postBoard, { - accessMode: 'post_password', - accessPasswordHash: adminPasswordHash(secureCookieApp), - }); - setBoardFixtureSettings(secureCookieApp, spoofBoard, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(secureCookieApp), - }); - await secureCookieApp.start(); - - await page.context().clearCookies(); - await page.goto(`${secureCookieApp.baseURL}/${board}/unlock`); - await expect(page.locator('input[name="password"]')).toBeVisible(); - await page.locator('input[name="password"]').fill(ADMIN_PASSWORD); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/catalog$`)), - page.getByRole('button', { name: /unlock board/i }).click(), - ]); - await expect(page.locator('input[name="password"]')).toHaveCount(0); - await expectSafePage(page); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('input[name="password"]')).toHaveCount(0); - await page.goto(`${secureCookieApp.baseURL}/${board}`); - await expect(page.locator('input[name="password"]')).toHaveCount(0); - - const cookies = await page.context().cookies(secureCookieApp.baseURL); - const unlockCookie = cookies.find((cookie) => cookie.name === `rustchan_board_access_${board}`); - expect(unlockCookie).toBeTruthy(); - expect(unlockCookie?.httpOnly).toBe(true); - expect(unlockCookie?.sameSite).toBe('Lax'); - expect(unlockCookie?.secure).toBe(false); - - await page.context().clearCookies(); - await page.goto(`${secureCookieApp.baseURL}/${postBoard}`); - await expectSafePage(page); - await expect(page.locator('#board-access-gate')).toContainText(/posting is password protected|unlock posting/i); - await unlockBoard(page, secureCookieApp, postBoard, ADMIN_PASSWORD); - const threadId = await createThread(page, secureCookieApp, postBoard, { - subject: 'mobile post password ok', - body: 'mobile thread after unlock', - }); - await createReply(page, secureCookieApp, postBoard, threadId, 'mobile reply after unlock'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator('#board-access-gate')).toHaveCount(0); - - const postCookies = await page.context().cookies(secureCookieApp.baseURL); - const postUnlockCookie = postCookies.find((cookie) => cookie.name === `rustchan_board_access_${postBoard}`); - expect(postUnlockCookie).toBeTruthy(); - expect(postUnlockCookie?.httpOnly).toBe(true); - expect(postUnlockCookie?.sameSite).toBe('Lax'); - expect(postUnlockCookie?.secure).toBe(false); - - await page.context().clearCookies(); - await page.goto(`${secureCookieApp.baseURL}/${spoofBoard}/unlock`); - const csrf = await page.locator('input[name="_csrf"]').first().getAttribute('value'); - const spoofedUnlock = await page.request.post(`${secureCookieApp.baseURL}/${spoofBoard}/unlock`, { - form: { - _csrf: csrf ?? '', - password: ADMIN_PASSWORD, - return_to: `/${spoofBoard}`, - }, - headers: { - origin: `https://127.0.0.1:${secureCookieApp.port}`, - referer: `https://127.0.0.1:${secureCookieApp.port}/${spoofBoard}/unlock`, - 'x-forwarded-proto': 'https', - }, - maxRedirects: 0, - }); - expect([302, 303]).toContain(spoofedUnlock.status()); - const accessSetCookie = spoofedUnlock - .headersArray() - .filter((header) => header.name.toLowerCase() === 'set-cookie') - .map((header) => header.value) - .find((value) => value.includes(`rustchan_board_access_${spoofBoard}=`)); - expect(accessSetCookie).toBeTruthy(); - expect(accessSetCookie).not.toMatch(/;\s*Secure/i); - - await page.goto(`${secureCookieApp.baseURL}/${spoofBoard}`); - await expect(page.locator('input[name="password"]')).toHaveCount(0); - await expectSafePage(page); - } finally { - await secureCookieApp.dispose(); - } - }); -}); diff --git a/tests/e2e/phase3-activity-cache.spec.ts b/tests/e2e/phase3-activity-cache.spec.ts deleted file mode 100644 index da564a1e..00000000 --- a/tests/e2e/phase3-activity-cache.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { newAuditedContext } from './diagnostics'; -import type { TestInfo } from '@playwright/test'; -import { - ADMIN_PASSWORD, - adminPasswordHash, - createReply, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - test, - uniqueShort, - unlockBoard, - type RustChanServer, -} from './helpers'; -import { - boardCard, - expectBoardBadge, - expectBoardBadgeCleared, - expectThreadSummaryBadge, - expectThreadSummaryBadgeCleared, - restartWithActivitySettings, - seedReaderBaselines, - writerPage, -} from './activity-helpers'; - -const BF_CACHE_PROJECTS = new Set(['chromium', 'firefox', 'webkit', 'mobile-firefox', 'mobile-webkit']); -const NO_JS_PROJECTS = new Set(['firefox-nojs', 'chromium-nojs']); - -test.describe('phase 3 activity cache and browser restore behavior', () => { - test('activity cookies survive restart and BFCache restore does not resurrect cleared badges', async ({ - page, - browser, - app, - }, testInfo) => { - test.skip( - !BF_CACHE_PROJECTS.has(testInfo.project.name), - 'BFCache and restart activity coverage is sampled on Chromium and mobile WebKit', - ); - - await restartWithActivitySettings(app, { - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: true, - threadNewReplyBadgesEnabled: true, - }); - const board = await createActivityBoard(app, testInfo, 'bf'); - const baselineThread = await createThread(page, app, board, { - subject: 'phase 3 baseline', - body: 'reader baseline before activity', - }); - await seedReaderBaselines(page, app, [[board, baselineThread]]); - - const writer = await writerPage(browser, testInfo); - try { - await createReply(writer.page, app, board, baselineThread, 'phase 3 unread reply before restart'); - await createThread(writer.page, app, board, { - subject: 'phase 3 unread thread before restart', - body: 'unread thread survives restart in reader cookies', - }); - } finally { - await writer.context.close(); - } - - await page.goto(app.baseURL); - await expectBoardBadge(page, board, '.board-card-new-thread-badge', /1 New Threads/i); - await expectBoardBadge(page, board, '.board-card-new-reply-badge', /1 New Replies/i); - - await app.restart(); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expectBoardBadge(page, board, '.board-card-new-thread-badge', /1 New Threads/i); - await expectBoardBadge(page, board, '.board-card-new-reply-badge', /1 New Replies/i); - - await page.goto(`${app.baseURL}/${board}`); - await expectThreadSummaryBadge(page, baselineThread, /1 New/i); - await page.goto(`${app.baseURL}/${board}/thread/${baselineThread}`); - await expectSafePage(page); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expectThreadSummaryBadgeCleared(page, baselineThread); - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expectSafePage(page); - - await page.goto(app.baseURL); - await expectBoardBadgeCleared(page, board, '.board-card-new-thread-badge'); - await expectBoardBadgeCleared(page, board, '.board-card-new-reply-badge'); - await app.restart(); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expectBoardBadgeCleared(page, board, '.board-card-new-thread-badge'); - await expectBoardBadgeCleared(page, board, '.board-card-new-reply-badge'); - }); - - test('protected board activity cookies do not leak badges after the unlock cookie is removed', async ({ - page, - browser, - app, - }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'protected activity leak audit runs once in Chromium'); - - await restartWithActivitySettings(app, { - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: true, - threadNewReplyBadgesEnabled: true, - }); - const board = await createProtectedActivityBoard(app, testInfo); - - const writer = await writerPage(browser, testInfo); - let threadId = 0; - try { - await unlockBoard(writer.page, app, board, ADMIN_PASSWORD); - threadId = await createThread(writer.page, app, board, { - subject: 'protected baseline', - body: 'protected baseline body', - }); - } finally { - await writer.context.close(); - } - - await unlockBoard(page, app, board, ADMIN_PASSWORD); - await seedReaderBaselines(page, app, [[board, threadId]]); - await page.context().clearCookies({ name: `rustchan_board_access_${board}` }); - - const secondWriter = await writerPage(browser, testInfo); - try { - await unlockBoard(secondWriter.page, app, board, ADMIN_PASSWORD); - await createReply(secondWriter.page, app, board, threadId, 'protected unread reply'); - await createThread(secondWriter.page, app, board, { - subject: 'protected unread thread', - body: 'protected unread thread body', - }); - } finally { - await secondWriter.context.close(); - } - - await page.goto(app.baseURL); - const card = boardCard(page, board); - await expect(card.locator('.new-activity-badge')).toHaveCount(0); - await expect(card.locator('a.board-card-link')).toHaveAttribute('href', `/${board}/unlock`); - await expect(page.locator('body')).not.toContainText('protected unread reply'); - await expect(page.locator('body')).not.toContainText('protected unread thread'); - }); - - test('Firefox no-JS activity badges clear through plain navigation', async ({ page, browser, app }, testInfo) => { - test.skip(!NO_JS_PROJECTS.has(testInfo.project.name), 'no-JS activity fallback is Firefox-specific signal'); - - await restartWithActivitySettings(app, { - homepageNewThreadBadgesEnabled: true, - homepageNewReplyBadgesEnabled: true, - threadNewReplyBadgesEnabled: true, - }); - const board = await createActivityBoard(app, testInfo, 'nj'); - const threadId = await createThread(page, app, board, { - subject: 'phase 3 no-js baseline', - body: 'baseline before no-js unread activity', - }); - await seedReaderBaselines(page, app, [[board, threadId]]); - - const writer = await newAuditedContext(browser, { javaScriptEnabled: false }); - try { - const writerTab = await writer.newPage(); - await createReply(writerTab, app, board, threadId, 'phase 3 no-js unread reply'); - } finally { - await writer.close(); - } - - await page.goto(`${app.baseURL}/${board}`); - await expectThreadSummaryBadge(page, threadId, /1 New/i); - await page.locator(`#t${threadId} a[href="/${board}/thread/${threadId}"]`).first().click(); - await expectSafePage(page); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expectThreadSummaryBadgeCleared(page, threadId); - }); -}); - -async function createActivityBoard(app: RustChanServer, testInfo: TestInfo, prefix: string): Promise { - const board = uniqueShort(prefix, testInfo); - await app.stop(); - app.createBoardCli({ - short: board, - name: `Phase 3 Activity ${board}`, - description: `Phase 3 activity board ${board}`, - }); - setBoardFixtureSettings(app, board, { postCooldownSecs: 0 }); - await app.start(); - return board; -} - -async function createProtectedActivityBoard(app: RustChanServer, testInfo: TestInfo): Promise { - const board = uniqueShort('pa', testInfo); - await app.stop(); - app.createBoardCli({ - short: board, - name: `Phase 3 Protected ${board}`, - description: 'Protected activity must not leak while locked', - }); - setBoardFixtureSettings(app, board, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(app), - postCooldownSecs: 0, - }); - await app.start(); - return board; -} diff --git a/tests/e2e/phase3-media-runtime.spec.ts b/tests/e2e/phase3-media-runtime.spec.ts deleted file mode 100644 index 2f805204..00000000 --- a/tests/e2e/phase3-media-runtime.spec.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { expectHttpError } from './diagnostics'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import type { Page } from '@playwright/test'; -import { - adminPasswordHash, - createThread, - expect, - expectSafePage, - expectSafeResponse, - setBoardFixtureSettings, - sqliteExec, - sqliteQuery, - test, - uniqueShort, - updateBoardSettings, - type RustChanServer, -} from './helpers'; - -const SCRIPTED_MEDIA_PROJECTS = new Set(['chromium', 'webkit', 'mobile-webkit']); -const MEDIA_CONTROL_PROJECTS = new Set(['chromium']); -const DOWNLOAD_HEADER_PROJECTS = new Set(['chromium', 'firefox']); -const NO_JS_PROJECTS = new Set(['firefox-nojs', 'chromium-nojs']); - -test.describe('phase 3 media viewer runtime behavior', () => { - test('image viewer expands, collapses, falls back for missing thumbs, and keeps long filename links usable', async ({ page, app }, testInfo) => { - test.skip( - !SCRIPTED_MEDIA_PROJECTS.has(testInfo.project.name), - 'scripted media viewer coverage is sampled on Chromium, WebKit, and mobile WebKit', - ); - - const longImage = path.join( - app.fixtureDir, - `phase3-${'long-filename-'.repeat(7)}image.png`, - ); - await fsp.copyFile(app.fixtures().tinyPng, longImage); - - const threadId = await createThread(page, app, 'img', { - subject: 'phase 3 long image filename', - body: `long image filename ${'bodyword'.repeat(24)}`, - filePath: longImage, - }); - const postId = opPostId(app, threadId); - const post = page.locator(`#p${postId}`); - const fileLink = post.locator('.file-info a').first(); - - const title = await fileLink.getAttribute('title'); - expect(title).toContain('phase3-long-filename'); - expect(title).not.toMatch(/[\\/"']/); - await expect(fileLink).toContainText('(...)'); - await expect(post.locator('.image-preview')).toBeVisible(); - await expect(post.locator('.media-expanded-image')).not.toBeVisible(); - - await post.locator('.image-preview').click(); - await expect(post.locator('.media-expanded-image')).toBeVisible(); - await expect(post.locator('.media-expanded-image')).toHaveAttribute('src', /\/boards\/img\//); - await expect(post.locator('.media-close-btn')).toBeVisible(); - - await post.locator('.media-expanded-image').click(); - await expect(post.locator('.media-expanded-image')).not.toBeVisible(); - await expect(post.locator('.image-preview')).toBeVisible(); - - sqliteExec( - app, - `UPDATE posts SET thumb_path = 'img/thumbs/phase3-missing-${postId}.webp' WHERE id = ${postId};`, - ); - // The missing thumbnail is the degradation path under test, so its 404 is - // expected and asserted through the rendered fallback below. - expectHttpError({ - method: 'GET', - path: `/boards/img/thumbs/phase3-missing-${postId}.webp`, - status: 404, - reason: 'thumbnail file is intentionally absent to exercise the fallback', - }); - await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.locator(`#p${postId} .media-thumb-fallback`)).toBeVisible(); - - const href = await page.locator(`#p${postId} .file-info a`).first().getAttribute('href'); - expect(href).toBeTruthy(); - await fsp.rm(path.join(app.dataDir, href!.replace(/^\/boards\//, 'boards/')), { force: true }); - const missingOriginal = await page.request.get(`${app.baseURL}${href}`); - expect(missingOriginal.status()).toBe(404); - await expectSafeResponse(missingOriginal); - }); - - test('video, audio, PDF, and pending media refresh render controls without pixel assertions', async ({ page, app }, testInfo) => { - test.skip( - !MEDIA_CONTROL_PROJECTS.has(testInfo.project.name), - 'DOM media-control and refresh assertions run once in Chromium', - ); - - const videoThread = await createThread(page, app, 'vid', { - subject: 'phase 3 pending video', - body: 'video starts with pending preview state', - filePath: app.fixtures().fakeMp4, - }); - const videoPostId = opPostId(app, videoThread); - sqliteExec( - app, - [ - 'UPDATE posts', - "SET thumb_path = NULL, media_type = 'video', mime_type = 'video/mp4',", - " media_processing_state = 'pending', media_processing_error = NULL", - `WHERE id = ${videoPostId};`, - ].join(' '), - ); - await page.goto(`${app.baseURL}/vid/thread/${videoThread}`); - await expect(page.locator(`#p${videoPostId}`)).toHaveAttribute('data-media-processing-state', 'pending'); - await expect(page.locator(`#p${videoPostId}`)).toContainText(/processing media|Preview still processing/i); - - const videoThumb = await writeBoardFile(app, 'vid', `thumbs/phase3-video-${videoPostId}.png`, await fsp.readFile(app.fixtures().tinyPng)); - sqliteExec( - app, - [ - 'UPDATE posts', - `SET thumb_path = ${sqlString(videoThumb)}, media_processing_state = '', media_processing_error = NULL`, - `WHERE id = ${videoPostId};`, - ].join(' '), - ); - await page.locator('[data-action="fetch-updates"]').first().click(); - await expect(page.locator(`#p${videoPostId} .video-container video[controls]`)).toHaveCount(1); - await expect(page.locator(`#p${videoPostId}`)).not.toHaveAttribute('data-media-processing-state', 'pending'); - - await page.locator(`#p${videoPostId} .video-preview`).click(); - await expect(page.locator(`#p${videoPostId} video.media-expanded-video`)).toBeVisible(); - - const audioThread = await createThread(page, app, 'aud', { - subject: 'phase 3 audio controls', - body: 'audio controls should render even with deterministic fixture media', - filePath: app.fixtures().fakeOgg, - }); - const audioPostId = opPostId(app, audioThread); - const audioThumb = await writeBoardFile(app, 'aud', `thumbs/phase3-audio-${audioPostId}.png`, await fsp.readFile(app.fixtures().tinyPng)); - sqliteExec( - app, - `UPDATE posts SET thumb_path = ${sqlString(audioThumb)}, media_type = 'audio', mime_type = 'audio/ogg' WHERE id = ${audioPostId};`, - ); - await page.goto(`${app.baseURL}/aud/thread/${audioThread}`); - await expect(page.locator(`#p${audioPostId} audio.audio-player[controls]`)).toHaveCount(1); - await expect(page.locator(`#p${audioPostId} source[type="audio/ogg"]`)).toHaveCount(1); - - const pdfBoard = uniqueShort('p3pdf', testInfo); - app.createBoardCli({ short: pdfBoard, name: 'Phase 3 PDF' }); - await updateBoardSettings(page, app, pdfBoard, { allowPdf: true }); - const pdfThread = await createThread(page, app, pdfBoard, { - subject: 'phase 3 pdf viewer', - body: 'pdf inline viewer should be same-origin framed', - filePath: app.fixtures().tinyPdf, - }); - const pdfPostId = opPostId(app, pdfThread); - const pdfThumb = await writeBoardFile(app, pdfBoard, `thumbs/phase3-pdf-${pdfPostId}.svg`, Buffer.from(pdfThumbSvg())); - sqliteExec( - app, - `UPDATE posts SET thumb_path = ${sqlString(pdfThumb)}, media_type = 'pdf', mime_type = 'application/pdf' WHERE id = ${pdfPostId};`, - ); - await page.goto(`${app.baseURL}/${pdfBoard}/thread/${pdfThread}`); - await expect(page.locator(`#p${pdfPostId} .pdf-container iframe.media-expanded-pdf`)).toHaveAttribute('data-src', /\/boards\//); - await page.locator(`#p${pdfPostId} .pdf-preview`).click(); - await expect(page.locator(`#p${pdfPostId} iframe.media-expanded-pdf`)).toBeVisible(); - await expect(page.locator(`#p${pdfPostId} iframe.media-expanded-pdf`)).toHaveAttribute('src', /\/boards\//); - }); - - test('Firefox no-JS users keep direct media links without the JS viewer', async ({ page, app }, testInfo) => { - test.skip(!NO_JS_PROJECTS.has(testInfo.project.name), 'no-JS fallback is Firefox-specific signal'); - - const threadId = await createThread(page, app, 'img', { - subject: 'phase 3 no-js image', - body: 'direct media links stay usable with JavaScript disabled', - filePath: app.fixtures().tinyPng, - }); - await page.goto(`${app.baseURL}/img/thread/${threadId}`); - await expectSafePage(page); - - const previewHref = await page.locator('.image-preview').first().getAttribute('href'); - const originalHref = await page.locator('.file-info a').first().getAttribute('href'); - expect(previewHref).toBe(originalHref); - expect(originalHref).toMatch(/^\/boards\/img\//); - await expect(page.locator('.file-info a').first()).toHaveAttribute('target', '_blank'); - if (!originalHref) { - throw new Error('original media href not found'); - } - - const original = await page.request.get(`${app.baseURL}${originalHref}`); - expect(original.status()).toBe(200); - expect(original.headers()['content-type']).toContain(originalHref.endsWith('.webp') ? 'image/webp' : 'image/png'); - }); -}); - -test.describe('phase 3 downloads, range requests, and media headers', () => { - test('uploaded media headers, browser downloads, stale transcode redirects, and protected denials are safe', async ({ page, app }, testInfo) => { - test.skip( - !DOWNLOAD_HEADER_PROJECTS.has(testInfo.project.name), - 'download and header flow coverage runs in Chromium and Firefox', - ); - - const imageThread = await createThread(page, app, 'img', { - subject: 'phase 3 image headers', - body: 'image header checks', - filePath: app.fixtures().tinyPng, - }); - const imageHref = await firstFileHref(page); - - const videoThread = await createThread(page, app, 'vid', { - subject: 'phase 3 video headers', - body: 'video range checks', - filePath: app.fixtures().fakeMp4, - }); - const videoHref = await firstFileHref(page); - - const audioThread = await createThread(page, app, 'aud', { - subject: 'phase 3 audio headers', - body: 'audio header checks', - filePath: app.fixtures().fakeOgg, - }); - const audioHref = await firstFileHref(page); - - const pdfBoard = uniqueShort('hdrpdf', testInfo); - app.createBoardCli({ short: pdfBoard, name: 'Header PDF' }); - await updateBoardSettings(page, app, pdfBoard, { allowPdf: true }); - await createThread(page, app, pdfBoard, { - subject: 'phase 3 pdf headers', - body: 'pdf header checks', - filePath: app.fixtures().tinyPdf, - }); - const pdfHref = await firstFileHref(page); - - const anyBoard = uniqueShort('any', testInfo); - app.createBoardCli({ short: anyBoard, name: 'Generic Attachments' }); - await updateBoardSettings(page, app, anyBoard, { allowAnyFiles: true }); - const genericFile = path.join(app.fixtureDir, 'phase3-generic-download.bin'); - await fsp.writeFile(genericFile, Buffer.from('phase 3 generic attachment\n')); - const genericThread = await createThread(page, app, anyBoard, { - subject: 'phase 3 generic attachment', - body: 'generic downloads should be attachments', - filePath: genericFile, - }); - const genericHref = await firstFileHref(page); - - await expectContentHeaders(page, app, imageHref, { - type: imageHref.endsWith('.webp') ? 'image/webp' : 'image/png', - disposition: false, - }); - await expectContentHeaders(page, app, videoHref, { - type: 'video/mp4', - disposition: false, - videoTranscode: true, - }); - await expectContentHeaders(page, app, audioHref, { - type: audioHref.endsWith('.opus') ? 'audio/opus' : 'audio/ogg', - disposition: false, - }); - const pdf = await expectContentHeaders(page, app, pdfHref, { - type: 'application/pdf', - disposition: false, - }); - expect(pdf.headers()['x-frame-options']).toBe('SAMEORIGIN'); - expect(pdf.headers()['content-security-policy']).toContain("frame-ancestors 'self'"); - - const generic = await expectContentHeaders(page, app, genericHref, { - type: 'application/octet-stream', - disposition: true, - }); - expect(generic.headers()['content-disposition']).toMatch(/^attachment; filename="[^"\\/]+\.bin"$/); - - const range = await page.request.get(`${app.baseURL}${videoHref}`, { - headers: { Range: 'bytes=0-3' }, - }); - expect([200, 206]).toContain(range.status()); - if (range.status() === 206) { - expect(range.headers()['content-range']).toMatch(/^bytes 0-3\//); - } - expect(range.headers()['x-content-type-options']).toBe('nosniff'); - - await page.goto(`${app.baseURL}/${anyBoard}/thread/${genericThread}`); - const [download] = await Promise.all([ - page.waitForEvent('download'), - page.locator('.file-download .file-info a').first().click(), - ]); - expect(download.suggestedFilename()).toMatch(/^[^"\\/]+\.bin$/); - expect(await download.path()).toBeTruthy(); - - const staleWebmRel = await writeBoardFile(app, 'vid', 'phase3-stale-transcode.webm', Buffer.from('webm')); - expect(staleWebmRel).toBe('vid/phase3-stale-transcode.webm'); - const stale = await page.request.get(`${app.baseURL}/boards/vid/phase3-stale-transcode.mp4`, { - maxRedirects: 0, - }); - expect([301, 308]).toContain(stale.status()); - expect(stale.headers().location).toBe('/boards/vid/phase3-stale-transcode.webm'); - - const protectedBoard = uniqueShort('pmedia', testInfo); - await restartWithProtectedBoard(app, protectedBoard); - await createThread(page, app, protectedBoard, { - subject: 'protected media', - body: 'protected media download denial', - filePath: app.fixtures().tinyPng, - }); - const protectedHref = await firstFileHref(page); - await page.context().clearCookies(); - const denied = await page.request.get(`${app.baseURL}${protectedHref}`, { maxRedirects: 0 }); - expect(denied.status()).toBe(403); - const deniedBody = await expectSafeResponse(denied); - expect(deniedBody).not.toContain('protected media download denial'); - - for (const threadId of [imageThread, videoThread, audioThread]) { - expect(Number.isInteger(threadId)).toBe(true); - } - }); -}); - -function opPostId(app: RustChanServer, threadId: number): number { - const raw = sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${threadId} AND is_op = 1 LIMIT 1;`); - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) { - throw new Error(`OP post for thread ${threadId} not found`); - } - return id; -} - -async function firstFileHref(page: Page): Promise { - const href = await page.locator('.file-info a[href^="/boards/"]').first().getAttribute('href'); - if (!href) { - throw new Error('media href not found'); - } - return href; -} - -async function expectContentHeaders( - page: Page, - app: RustChanServer, - href: string, - options: { type: string; disposition: boolean; videoTranscode?: boolean }, -) { - const response = await page.request.get(`${app.baseURL}${href}`); - expect(response.status(), href).toBe(200); - // A completed worker redirects the originally rendered MP4 URL to WebM. - const expectedType = options.videoTranscode && new URL(response.url()).pathname.endsWith('.webm') - ? 'video/webm' - : options.type; - expect(response.headers()['content-type']).toContain(expectedType); - expect(response.headers()['x-content-type-options']).toBe('nosniff'); - expect(response.headers()['cache-control']).toBeTruthy(); - if (options.disposition) { - expect(response.headers()['content-disposition']).toContain('attachment'); - } else { - expect(response.headers()['content-disposition'] ?? '').toBe(''); - } - return response; -} - -async function writeBoardFile( - app: RustChanServer, - board: string, - rel: string, - bytes: Buffer, -): Promise { - const logical = `${board}/${rel}`; - const target = path.join(app.dataDir, 'boards', logical); - await fsp.mkdir(path.dirname(target), { recursive: true }); - await fsp.writeFile(target, bytes); - return logical; -} - -async function restartWithProtectedBoard(app: RustChanServer, board: string): Promise { - await app.stop(); - app.createBoardCli({ short: board, name: 'Protected Phase 3 Media' }); - setBoardFixtureSettings(app, board, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(app), - postCooldownSecs: 0, - }); - await app.start(); -} - -function sqlString(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function pdfThumbSvg(): string { - return [ - '', - '', - '', - 'PDF', - '', - ].join(''); -} diff --git a/tests/e2e/phase3-mobile-webkit-runtime.spec.ts b/tests/e2e/phase3-mobile-webkit-runtime.spec.ts deleted file mode 100644 index f307ab7f..00000000 --- a/tests/e2e/phase3-mobile-webkit-runtime.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import type { Page, TestInfo } from '@playwright/test'; -import { - ADMIN_PASSWORD, - adminLogin, - adminPasswordHash, - createReply, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - test, - uniqueShort, - unlockBoard, - type RustChanServer, -} from './helpers'; -import { expectNoHorizontalOverflow } from './phase4-helpers'; - -test.describe('phase 3 mobile WebKit runtime coverage', () => { - test('login, protected unlock, posting, own-post controls, media viewer, admin settings, and long pages stay usable', async ({ - page, - app, - }, testInfo) => { - test.skip(testInfo.project.name !== 'mobile-webkit', 'mobile WebKit is the focused mobile runtime'); - - await page.setViewportSize({ width: 390, height: 844 }); - const board = await restartWithProtectedMobileBoard(app, testInfo); - await unlockBoard(page, app, board, ADMIN_PASSWORD); - - const longImage = path.join( - app.fixtureDir, - `phase3-mobile-${'very-long-file-name-'.repeat(5)}asset.png`, - ); - await fsp.copyFile(app.fixtures().tinyPng, longImage); - - const threadId = await createThread(page, app, board, { - subject: `phase 3 mobile ${'title '.repeat(18)}`, - body: `mobile long post ${'longunbrokenword'.repeat(18)}\n${'second line '.repeat(28)}`, - filePath: longImage, - }); - await expectSafePage(page); - await expect(page.locator('.self-action-controls .edit-btn').first()).toBeVisible(); - await expect(page.locator('.self-action-controls .del-btn').first()).toBeVisible(); - await expect(page.locator('.image-preview').first()).toBeVisible(); - await expectNoHorizontalOverflow(page, 'mobile owned thread'); - - await page.locator('.image-preview').first().click(); - await expect(page.locator('.media-expanded-image').first()).toBeVisible(); - await expect(page.locator('.media-close-btn').first()).toBeVisible(); - await page.locator('.media-close-btn').first().click(); - await expect(page.locator('.image-preview').first()).toBeVisible(); - - for (let i = 0; i < 10; i += 1) { - await createReply( - page, - app, - board, - threadId, - `mobile reply ${i} ${'replylongword'.repeat(14)}`, - ); - } - await page.goto(`${app.baseURL}/${board}/thread/${threadId}#bottom`); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('10'); - await expect(page.locator('.thread-nav-bottom')).toBeVisible(); - await expectNoHorizontalOverflow(page, 'mobile long thread'); - await expectReachable(page, '.thread-nav-bottom a[href="#top"]'); - await expectReachable(page, '[data-action="toggle-post-form"]'); - - await page.goto(`${app.baseURL}/admin`); - await expectNoHorizontalOverflow(page, 'mobile admin login'); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel#site-settings`); - const siteForm = page.locator('form[action="/admin/site/settings"]').first(); - await expect(siteForm.locator('input[name="site_name"]')).toBeVisible(); - await siteForm.locator('input[name="site_name"]').fill('Phase 3 Mobile WebKit'); - await expectReachable(page, 'form[action="/admin/site/settings"] button[type="submit"]'); - await expectNoHorizontalOverflow(page, 'mobile admin settings'); - }); -}); - -async function restartWithProtectedMobileBoard(app: RustChanServer, testInfo: TestInfo): Promise { - const board = uniqueShort('mw', testInfo); - await app.stop(); - app.createBoardCli({ - short: board, - name: `Phase 3 Mobile ${'Board '.repeat(8)}${board}`, - description: `Narrow layout protected board ${'description '.repeat(12)}`, - }); - setBoardFixtureSettings(app, board, { - accessMode: 'view_password', - accessPasswordHash: adminPasswordHash(app), - allowEditing: true, - allowSelfDelete: true, - postCooldownSecs: 0, - }); - await app.start(); - return board; -} - -async function expectReachable(page: Page, selector: string): Promise { - const locator = page.locator(selector).first(); - await expect(locator).toBeVisible(); - const box = await locator.boundingBox(); - expect(box, `${selector} should have a bounding box`).toBeTruthy(); - const viewport = page.viewportSize(); - expect(viewport, 'viewport should be configured').toBeTruthy(); - expect(box!.x + box!.width).toBeGreaterThan(0); - expect(box!.x).toBeLessThan(viewport!.width); - expect(box!.y + box!.height).toBeGreaterThan(0); -} diff --git a/tests/e2e/phase3-static-proxy-tor.spec.ts b/tests/e2e/phase3-static-proxy-tor.spec.ts deleted file mode 100644 index 942aa0e2..00000000 --- a/tests/e2e/phase3-static-proxy-tor.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { APIResponse } from '@playwright/test'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - RustChanServer, - adminCsrf, - adminLogin, - createThread, - expect, - expectSafePage, - expectSafeResponse, - extractCsrf, - test, -} from './helpers'; - -const CHROMIUM_ONLY = new Set(['chromium']); - -test.describe('phase 3 static and runtime headers', () => { - test('static assets, dynamic pages, media, and missing assets use safe cache and framing headers', async ({ - page, - app, - }, testInfo) => { - test.skip(!CHROMIUM_ONLY.has(testInfo.project.name), 'static/runtime header matrix runs once in Chromium'); - - const home = await page.request.get(app.baseURL); - expect(home.status()).toBe(200); - expect(home.headers()['content-security-policy']).toContain("frame-ancestors 'none'"); - expect(home.headers()['x-frame-options']).toBe('SAMEORIGIN'); - expect(home.headers()['x-content-type-options']).toBe('nosniff'); - expect(home.headers()['cache-control']).toContain('no-cache'); - - const html = await home.text(); - const staticUrls = Array.from(html.matchAll(/(?:href|src)="(\/static\/[^"]+)"/g)).map((match) => match[1]); - expect(staticUrls.length).toBeGreaterThanOrEqual(3); - for (const url of staticUrls) { - const response = await page.request.get(`${app.baseURL}${url}`); - expect(response.status(), url).toBe(200); - expect(response.headers()['x-content-type-options']).toBe('nosniff'); - expect(response.headers()['cache-control'], url).toContain('immutable'); - if (url.endsWith('.css') || url.includes('.css?')) { - expect(response.headers()['content-type']).toContain('text/css'); - } - if (url.endsWith('.js') || url.includes('.js?')) { - expect(response.headers()['content-type']).toContain('application/javascript'); - } - } - - const unversionedCss = await page.request.get(`${app.baseURL}/static/style.css`); - expect(unversionedCss.status()).toBe(200); - expect(unversionedCss.headers()['cache-control']).not.toContain('immutable'); - const unversionedJs = await page.request.get(`${app.baseURL}/static/main.js`); - expect(unversionedJs.status()).toBe(200); - expect(unversionedJs.headers()['cache-control']).not.toContain('immutable'); - - const admin = await page.request.get(`${app.baseURL}/admin`); - expect(admin.status()).toBe(200); - expect(admin.headers()['cache-control']).toContain('no-store'); - expect(admin.headers()['content-security-policy']).toContain("frame-ancestors 'none'"); - - const threadId = await createThread(page, app, 'img', { - subject: 'phase 3 runtime media headers', - body: 'media cache and framing headers', - filePath: app.fixtures().tinyPng, - }); - const mediaHref = await page.locator('.file-info a[href^="/boards/"]').first().getAttribute('href'); - expect(mediaHref).toBeTruthy(); - const media = await page.request.get(`${app.baseURL}${mediaHref}`); - expect(media.status()).toBe(200); - expect(media.headers()['cache-control']).toContain('immutable'); - expect(media.headers()['x-content-type-options']).toBe('nosniff'); - - await page.goto(`${app.baseURL}/img/thread/${threadId}`); - await expectSafePage(page); - - const missingStatic = await page.request.get(`${app.baseURL}/static/phase3-missing.js`); - expect([404, 405]).toContain(missingStatic.status()); - expect(missingStatic.headers()['x-content-type-options']).toBe('nosniff'); - await expectNoSecretLeak(missingStatic, app); - - const missingMedia = await page.request.get(`${app.baseURL}/boards/img/phase3-missing-media.png`); - expect(missingMedia.status()).toBe(404); - expect(missingMedia.headers()['x-content-type-options']).toBe('nosniff'); - await expectNoSecretLeak(missingMedia, app); - }); -}); - -test.describe('phase 3 proxy, public-host, and secure-cookie checks', () => { - test('trusted forwarded HTTPS sets secure admin cookies and HSTS only for configured public hosts', async ({ - page, - }, testInfo) => { - test.skip(!CHROMIUM_ONLY.has(testInfo.project.name), 'request-level proxy/TLS checks run once in Chromium'); - - const proxyApp = await RustChanServer.create(undefined, { - env: { - CHAN_BEHIND_PROXY: '1', - CHAN_HTTPS_COOKIES: '1', - CHAN_PUBLIC_HOSTS: 'example.test', - CHAN_TRUSTED_PROXY_CIDRS: '127.0.0.1/32', - }, - }); - try { - await proxyApp.initializeDefaultData(); - await proxyApp.start(); - - const publicHost = await page.request.get(`${proxyApp.baseURL}/admin`, { - headers: { - Host: 'example.test', - 'x-forwarded-proto': 'https', - }, - }); - expect(publicHost.status()).toBe(200); - expect(publicHost.headers()['strict-transport-security']).toContain('max-age=31536000'); - const csrfCookie = setCookiePair(publicHost, 'csrf_token='); - const csrf = extractCsrf(await publicHost.text()); - const login = await page.request.post(`${proxyApp.baseURL}/admin/login`, { - form: { - username: ADMIN_USERNAME, - password: ADMIN_PASSWORD, - _csrf: csrf, - }, - headers: { - Host: 'example.test', - Cookie: csrfCookie, - Origin: 'https://example.test', - Referer: 'https://example.test/admin', - 'x-forwarded-proto': 'https', - }, - maxRedirects: 0, - }); - expect(login.status()).toBe(303); - expect(setCookieHeader(login, 'chan_admin_session=')).toMatch(/;\s*Secure/i); - - const loopbackHost = await page.request.get(`${proxyApp.baseURL}/admin`, { - headers: { - Host: '127.0.0.1', - 'x-forwarded-proto': 'https', - }, - }); - expect(loopbackHost.status()).toBe(200); - expect(loopbackHost.headers()['strict-transport-security'] ?? '').toBe(''); - - const plainLoginPage = await page.request.get(`${proxyApp.baseURL}/admin`, { - headers: { Host: 'example.test' }, - }); - const plainCsrfCookie = setCookiePair(plainLoginPage, 'csrf_token='); - const plainLogin = await page.request.post(`${proxyApp.baseURL}/admin/login`, { - form: { - username: ADMIN_USERNAME, - password: ADMIN_PASSWORD, - _csrf: extractCsrf(await plainLoginPage.text()), - }, - headers: { - Host: 'example.test', - Cookie: plainCsrfCookie, - Origin: `http://example.test`, - Referer: `http://example.test/admin`, - }, - maxRedirects: 0, - }); - expect(plainLogin.status()).toBe(303); - expect(setCookieHeader(plainLogin, 'chan_admin_session=')).not.toMatch(/;\s*Secure/i); - } finally { - await proxyApp.dispose(); - } - }); - - test('local TLS listener, self-signed browser trust, and HTTP redirect host rejection remain opt-in', async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'manual note is attached to the Chromium phase-3 run only'); - test.skip( - true, - 'requires deterministic local TLS ports/cert trust or redirect listener config; covered by Rust unit tests and should be run manually in release validation', - ); - }); -}); - -test.describe('phase 3 Tor and onion UI checks', () => { - test('Tor-disabled runtime keeps home/admin layouts stable and hides Tor-key backup controls', async ({ - page, - app, - }, testInfo) => { - test.skip(!CHROMIUM_ONLY.has(testInfo.project.name), 'Tor-disabled UI check runs once in Chromium'); - - await page.goto(app.baseURL); - await expect(page.locator('.index-onion-section')).toHaveCount(0); - await expectSafePage(page); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=full-backup-restore#full-backup-restore`); - await expect(page.locator('input[name="include_tor_hidden_service_keys"]')).toHaveCount(0); - await expect(page.locator('input[name="restore_tor_hidden_service_keys"]')).toHaveCount(0); - await expect(page.locator('body')).toContainText(/Tor bootstrap state|Tor/i); - await expectSafePage(page, { allowAdminInternals: true }); - - const csrf = await adminCsrf(page, app); - const backupSettings = await page.request.post(`${app.baseURL}/admin/backup/settings`, { - form: { - _csrf: csrf, - auto_full_backup_interval_hours: '0', - auto_full_backup_copies_to_keep: '2', - auto_full_backup_storage_mode: 'directory', - }, - maxRedirects: 0, - }); - expect(backupSettings.status()).toBe(303); - }); - - test('active onion pill, copy behavior, Onion-Location, and already-onion suppression remain opt-in', async ({}, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'manual note is attached to the Chromium phase-3 run only'); - test.skip( - true, - 'requires starting Arti/Tor and waiting for a live onion address; default e2e keeps Tor disabled for deterministic local runs', - ); - }); -}); - -async function expectNoSecretLeak(response: APIResponse, app: { dataDir: string }): Promise { - const body = await expectSafeResponse(response); - expect(body).not.toContain(app.dataDir); - expect(body).not.toMatch(/\/Users\/|target\/debug|cookie_secret|CHAN_/i); -} - -function setCookiePair(response: APIResponse, prefix: string): string { - return setCookieHeader(response, prefix).split(';')[0]; -} - -function setCookieHeader(response: APIResponse, prefix: string): string { - const header = response - .headersArray() - .filter((item) => item.name.toLowerCase() === 'set-cookie') - .map((item) => item.value) - .find((value) => value.startsWith(prefix)); - if (!header) { - throw new Error(`set-cookie ${prefix} not found`); - } - return header; -} diff --git a/tests/e2e/phase4-accessibility-progressive.spec.ts b/tests/e2e/phase4-accessibility-progressive.spec.ts deleted file mode 100644 index 2dec1703..00000000 --- a/tests/e2e/phase4-accessibility-progressive.spec.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - adminLogin, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - test, - uniqueShort, - isNoJsProject, -} from './helpers'; -import { - expectFocusVisible, - expectNamedInteractiveControls, - expectNoCoveredCenters, - expectNoHorizontalOverflow, - expectSafeBody, - expectUsableTarget, - phase4SkipUnless, - watchClientErrors, -} from './phase4-helpers'; -import { expectTransportFailure } from './diagnostics'; - -test.describe('phase 4 accessibility basics and progressive enhancement', () => { - test.beforeEach(async ({}, testInfo) => { - phase4SkipUnless( - testInfo, - ['chromium', 'mobile-webkit', 'firefox-nojs', 'chromium-nojs'], - 'phase 4 accessibility coverage runs on Chromium, mobile WebKit, and no-JS Firefox', - ); - }); - - test('home, board, catalog, thread, posting, preferences, and admin forms expose keyboardable named controls', async ({ page, app }, testInfo) => { - const assertNoClientErrors = watchClientErrors(page); - const board = uniqueShort('a11y', testInfo); - app.createBoardCli({ short: board, name: 'Accessibility Board', description: 'keyboard and label coverage' }); - setBoardFixtureSettings(app, board, { - allowImages: true, - allowEditing: true, - allowSelfDelete: true, - postCooldownSecs: 0, - }); - const threadId = await createThread(page, app, board, { - subject: 'accessibility thread', - body: 'thread body for keyboard checks', - filePath: app.fixtures().tinyPng, - }); - - await page.goto(app.baseURL); - await expectSafePage(page); - await expectNamedInteractiveControls(page, 'body', 'home page'); - await expectFocusVisible(page.getByRole('link', { name: /home/i }).first(), 'home link'); - await expectUsableTarget(page.locator(`.board-card-link[href="/${board}/catalog"], .board-card-link[href="/${board}"]`).first(), 'board card link', testInfo); - - await page.goto(`${app.baseURL}/${board}`); - await expectSafeBody(page, 'board index'); - await expectNoHorizontalOverflow(page, 'board index'); - await expectNamedInteractiveControls(page, 'main', 'board index'); - const postToggle = page.locator('[data-action="toggle-post-form"]').first(); - await expectFocusVisible(postToggle, 'new thread toggle'); - await postToggle.focus(); - await page.keyboard.press('Enter'); - const threadForm = page.locator(`form[action="/${board}"]`).first(); - await expect(threadForm).toBeVisible(); - await expectUsableTarget(threadForm.getByLabel('subject'), 'thread subject field', testInfo); - await expectUsableTarget(threadForm.getByLabel('body'), 'thread body field', testInfo); - await expectUsableTarget(threadForm.getByLabel('upload'), 'thread upload field', testInfo); - - await page.goto(`${app.baseURL}/${board}/catalog`); - await expectSafeBody(page, 'catalog'); - await expectNoHorizontalOverflow(page, 'catalog'); - await expectNamedInteractiveControls(page, 'main', 'catalog'); - - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expectSafeBody(page, 'thread'); - await expectNoHorizontalOverflow(page, 'thread'); - await expectNamedInteractiveControls(page, 'main', 'thread page'); - const replyToggle = page.locator('[data-action="toggle-post-form"]').first(); - if (await replyToggle.isVisible()) { - await replyToggle.click(); - } - const replyForm = page.locator(`form[action="/${board}/thread/${threadId}"]`).first(); - await expectUsableTarget(replyForm.getByLabel('body'), 'reply body field', testInfo); - await expectNoCoveredCenters(page.locator('.post-controls button:visible, .post-controls a:visible'), 'thread post controls'); - - if (testInfo.project.name === 'mobile-webkit') { - await expect(page.locator('.post-controls .report-btn').first()).toBeVisible(); - await expect(page.locator('.self-action-controls .edit-btn').first()).toBeVisible(); - await expect(page.locator('.self-action-controls .del-btn').first()).toBeVisible(); - } - - await page.goto(`${app.baseURL}/admin`); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNamedInteractiveControls(page, '.admin-login-form', 'admin login form'); - await expectFocusVisible(page.getByLabel('Username'), 'admin username field'); - await expectFocusVisible(page.getByLabel('Password'), 'admin password field'); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=site-settings#site-settings`); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'admin settings'); - await expectFocusVisible(page.locator('select[name="default_theme"]').first(), 'admin default theme picker'); - await expectNamedInteractiveControls(page, '#site-settings', 'admin site settings'); - - assertNoClientErrors(); - }); - - test('report, edit, delete confirmation, preferences, and media controls close cleanly and restore focus', async ({ page, app }, testInfo) => { - test.skip(isNoJsProject(testInfo), 'scripted modal behavior is covered by no-JS fallback forms separately'); - - const board = uniqueShort('dlg', testInfo); - app.createBoardCli({ short: board, name: 'Dialog Board', description: 'dialog coverage' }); - setBoardFixtureSettings(app, board, { - allowImages: true, - allowEditing: true, - allowSelfDelete: true, - postCooldownSecs: 0, - }); - const threadId = await createThread(page, app, board, { - subject: 'dialog controls', - body: 'owned post with media for dialog coverage', - filePath: app.fixtures().tinyPng, - }); - - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - const reportTrigger = page.locator('.post-controls .report-btn').first(); - await reportTrigger.focus(); - await reportTrigger.click(); - await expect(page.locator('#report-modal')).toBeVisible(); - await expect(page.locator('#report-modal').getByLabel('reason')).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(page.locator('#report-modal')).toBeHidden(); - await expect(reportTrigger).toBeFocused(); - - const editTrigger = page.locator('.self-action-controls .edit-btn').first(); - await editTrigger.focus(); - await editTrigger.click(); - await expect(page.locator('#edit-modal.is-open')).toBeVisible(); - await expect(page.getByLabel('edit post body')).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(page.locator('#edit-modal')).toBeHidden(); - await expect(editTrigger).toBeFocused(); - - const deleteTrigger = page.locator('.self-action-controls .del-btn').first(); - await deleteTrigger.focus(); - await deleteTrigger.click(); - await expect(page.locator('#confirm-modal')).toBeVisible(); - await expect(page.locator('#confirm-modal-cancel')).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(page.locator('#confirm-modal')).toBeHidden(); - await expect(deleteTrigger).toBeFocused(); - - const preferences = page.locator('.user-preferences-panel > summary').first(); - await preferences.focus(); - await page.keyboard.press('Enter'); - await expect(page.locator('.user-preferences-panel[open]')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.locator('.user-preferences-panel[open]')).toHaveCount(0); - - const preview = page.locator('.media-preview').first(); - await preview.click(); - await expect(page.locator('.media-expanded-image').first()).toBeVisible(); - const closeMedia = page.locator('.media-close-btn').first(); - await expectUsableTarget(closeMedia, 'expanded media close button', testInfo); - await closeMedia.click(); - await expect(preview).toBeVisible(); - }); - - test('critical public flows remain usable without optional main JavaScript', async ({ page, context, app }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium', 'broken optional-JS degradation is checked once in Chromium'); - - const board = uniqueShort('pe', testInfo); - app.createBoardCli({ short: board, name: 'Progressive Board', description: 'optional JS degradation' }); - // main.js is deliberately blocked to exercise optional-JS degradation, so - // both its transport failure and the console message it produces are - // expected surfaces for this test. - expectTransportFailure({ - url: /\/static\/main\.js/, - times: 'any', - reason: 'optional main.js is deliberately aborted for every load in this test', - }); - await context.route('**/static/main.js*', (route) => route.abort()); - await page.goto(`${app.baseURL}/${board}`); - await expectSafeBody(page, 'board without main.js'); - await page.locator('[data-action="toggle-post-form"]').first().click(); - await expect(page.locator('#post-form-wrap:target, #post-form-wrap').first()).toBeVisible(); - const threadForm = page.locator(`form[action="/${board}"]`).first(); - await expect(threadForm.getByLabel('subject')).toBeVisible(); - await threadForm.getByLabel('subject').fill('no optional js thread'); - await threadForm.getByLabel('body').fill('form opened through the anchor fallback'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/\\d+`)), - page.getByRole('button', { name: /post thread/i }).click(), - ]); - await expect(page.locator('body')).toContainText('form opened through the anchor fallback'); - }); -}); diff --git a/tests/e2e/phase4-empty-error-permission.spec.ts b/tests/e2e/phase4-empty-error-permission.spec.ts deleted file mode 100644 index 11b8f37c..00000000 --- a/tests/e2e/phase4-empty-error-permission.spec.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { - adminCsrf, - adminLogin, - createReply, - createThread, - expect, - expectServerLogError, - publicCsrf, - setThreadFixtureState, - sqliteExec, - sqliteQuery, - test, - uniqueShort, - updateBoardSettings, -} from './helpers'; -import { expectHttpError } from './diagnostics'; -import { - expectNoHorizontalOverflow, - expectSafeBody, - expectSafeHeaders, - expectSafeHtmlResponse, - expectUsableTarget, - phase4SkipUnless, -} from './phase4-helpers'; -import type { Page } from '@playwright/test'; - -test.describe('phase 4 empty, error, and permission states', () => { - test.beforeEach(async ({}, testInfo) => { - phase4SkipUnless( - testInfo, - ['chromium', 'firefox-nojs', 'mobile-webkit', 'chromium-nojs'], - 'phase 4 error-state coverage runs on Chromium, no-JS Firefox, and mobile WebKit', - ); - }); - - test('standalone 404, 400-ish, 403, locked-board, and banned pages stay clear and safe', async ({ page, app }, testInfo) => { - const missing = await page.request.get(`${app.baseURL}/definitely-missing-phase4`, { maxRedirects: 0 }); - expect(missing.status()).toBe(404); - await expectSafeHeaders(missing, '404'); - const missingText = await expectSafeHtmlResponse(missing, '404'); - expect(missingText).toContain('error 404'); - expect(missingText).toContain('return home'); - - expectHttpError({ - method: 'GET', - path: '/definitely-missing-phase4', - status: 404, - reason: 'the standalone 404 page itself is the subject of this test', - }); - await page.goto(`${app.baseURL}/definitely-missing-phase4`); - await expectSafeBody(page, '404 page'); - await expect(page.getByRole('link', { name: /return home|home/i }).first()).toBeVisible(); - await expectNoHorizontalOverflow(page, '404 page'); - - const badThread = await page.request.get(`${app.baseURL}/pub/thread/not-a-number`, { maxRedirects: 0 }); - expect([400, 404]).toContain(badThread.status()); - await expectSafeHeaders(badThread, 'bad thread route'); - await expectSafeHtmlResponse(badThread, 'bad thread route'); - - const protectedBoard = uniqueShort('lock', testInfo); - app.createBoardCli({ - short: protectedBoard, - name: 'Locked Phase Four Board', - description: 'Permission page coverage', - }); - await updateBoardSettings(page, app, protectedBoard, { - accessMode: 'view_password', - accessPassword: 'phase4-pass', - allowImages: true, - }); - await page.context().clearCookies(); - - const lockedCatalog = await page.request.get(`${app.baseURL}/${protectedBoard}/catalog`, { maxRedirects: 0 }); - expect(lockedCatalog.status()).toBe(403); - await expectSafeHeaders(lockedCatalog, 'locked board catalog'); - const lockedHtml = await expectSafeHtmlResponse(lockedCatalog, 'locked board catalog'); - expect(lockedHtml).toContain('password protected board'); - expect(lockedHtml).toContain('return home'); - - expectHttpError({ - method: 'GET', - path: `/${protectedBoard}/catalog`, - status: 403, - reason: 'a locked board renders its password gate page with a 403', - }); - await page.goto(`${app.baseURL}/${protectedBoard}/catalog`); - await expectSafeBody(page, 'locked board page'); - await expectUsableTarget(page.getByLabel('board password'), 'locked board password field', testInfo); - await page.getByLabel('board password').fill('wrong-pass'); - expectHttpError({ - method: 'POST', - path: `/${protectedBoard}/unlock`, - status: 403, - reason: 'a wrong board password is rejected with a 403 gate page', - }); - await page.getByRole('button', { name: /unlock board/i }).click(); - await expect(page.locator('.post-error-banner')).toContainText(/incorrect|password|try/i); - await expectSafeBody(page, 'locked board wrong password'); - - await page.goto(`${app.baseURL}/banned?reason=phase4%20ban%20reason%20%2Ftmp%2Fsecret`); - await expectSafeBody(page, 'banned page'); - await expect(page.getByRole('heading', { name: /you are banned/i })).toBeVisible(); - await expect(page.locator('body')).toContainText('phase4 ban reason /tmp/secret'); - await expect(page.getByRole('button', { name: /submit appeal/i })).toBeVisible(); - }); - - test('posting, invalid upload, locked or archived thread, deleted post or thread, and active ban errors stay safe', async ({ page, app }, testInfo) => { - const board = uniqueShort('err', testInfo); - app.createBoardCli({ short: board, name: 'Error State Board', description: 'phase 4 error fixtures' }); - await updateBoardSettings(page, app, board, { - allowImages: true, - allowEditing: true, - allowSelfDelete: true, - allowArchive: true, - postCooldownSecs: 0, - }); - - await page.goto(`${app.baseURL}/${board}`); - await openPostForm(page); - const invalidUploadForm = page.locator(`form[action="/${board}"]`).first(); - await invalidUploadForm.getByLabel('subject').fill('invalid upload'); - await invalidUploadForm.getByLabel('body').fill('invalid upload body'); - await invalidUploadForm.locator('input[type="file"]').setInputFiles(app.fixtures().invalid); - expectHttpError({ - method: 'POST', - path: new RegExp(`^/${board}$`), - status: 415, - reason: 'the invalid upload is rejected and re-renders the post form with an error', - }); - await invalidUploadForm.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/file|media|type|invalid|accepted/i); - await expectSafeBody(page, 'invalid upload state'); - - const threadId = await createThread(page, app, board, { - subject: 'thread state errors', - body: 'thread that will be locked and archived', - }); - await createReply(page, app, board, threadId, 'reply before closure'); - - setThreadFixtureState(app, threadId, { locked: true }); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expect(page.locator('body')).toContainText(/locked/i); - const lockedReply = await postReplyDirect(page, app.baseURL, board, threadId, 'reply rejected by locked thread'); - expect([403, 422]).toContain(lockedReply.status()); - await expectSafeHtmlResponse(lockedReply, 'locked thread reply'); - - setThreadFixtureState(app, threadId, { archived: true }); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expect(page.locator('body')).toContainText(/archived|locked/i); - const archivedReply = await postReplyDirect(page, app.baseURL, board, threadId, 'reply rejected by archived thread'); - expect([403, 422]).toContain(archivedReply.status()); - await expectSafeHtmlResponse(archivedReply, 'archived thread reply'); - - const deletedPostThread = await createThread(page, app, board, { - subject: 'deleted post fixture', - body: 'post row will disappear', - }); - const deletedPostId = await firstVisiblePostId(page); - sqliteExec(app, [ - `DELETE FROM post_submissions WHERE post_id = ${deletedPostId};`, - `DELETE FROM posts WHERE id = ${deletedPostId};`, - ].join('\n')); - const deletedPost = await page.request.get(`${app.baseURL}/${board}/post/${deletedPostId}/edit`, { maxRedirects: 0 }); - expect([404, 410]).toContain(deletedPost.status()); - await expectSafeHtmlResponse(deletedPost, 'deleted post edit'); - - sqliteExec(app, [ - `DELETE FROM post_submissions WHERE thread_id = ${deletedPostThread};`, - `DELETE FROM posts WHERE thread_id = ${deletedPostThread};`, - `DELETE FROM threads WHERE id = ${deletedPostThread};`, - ].join('\n')); - const deletedThread = await page.request.get(`${app.baseURL}/${board}/thread/${deletedPostThread}`, { maxRedirects: 0 }); - expect([404, 410]).toContain(deletedThread.status()); - await expectSafeHeaders(deletedThread, 'deleted thread'); - await expectSafeHtmlResponse(deletedThread, 'deleted thread'); - - const bannedThread = await createThread(page, app, board, { - subject: 'ban target', - body: 'this browser will be banned before replying', - }); - const ipHash = sqliteQuery(app, `SELECT ip_hash FROM posts WHERE thread_id = ${bannedThread} ORDER BY id ASC LIMIT 1;`); - sqliteExec(app, `INSERT INTO bans (ip_hash, reason, expires_at) VALUES ('${ipHash}', 'phase4 active ban', NULL);`); - const bannedReply = await postReplyDirect(page, app.baseURL, board, bannedThread, 'banned reply should not land'); - expect([302, 303, 403]).toContain(bannedReply.status()); - if ([302, 303].includes(bannedReply.status())) { - expect(bannedReply.headers().location ?? '').toContain('/banned'); - await page.goto(`${app.baseURL}${bannedReply.headers().location}`); - await expect(page.locator('body')).toContainText(/you are banned|phase4 active ban/i); - } else { - await expectSafeHtmlResponse(bannedReply, 'banned reply'); - } - }); - - test('invalid admin forms and restore uploads fail closed with useful admin navigation', async ({ page, app }) => { - await adminLogin(page, app); - const csrf = await adminCsrf(page, app); - - const invalidBoard = await page.request.post(`${app.baseURL}/admin/board/create`, { - form: { - _csrf: csrf, - short_name: '../bad', - name: 'Invalid Admin Board', - description: 'should fail closed', - }, - maxRedirects: 0, - }); - expect([400, 409, 422]).toContain(invalidBoard.status()); - await expectSafeHtmlResponse(invalidBoard, 'invalid admin board form'); - - expectServerLogError({ - pattern: /Full restore failed - route: \/admin\/restore, error: Bad request: Invalid zip/, - reason: 'the intentionally invalid full-backup upload is rejected and logged by the server', - }); - const invalidFullRestore = await page.request.post(`${app.baseURL}/admin/restore`, { - multipart: { - _csrf: csrf, - backup_file: { - name: 'phase4-not-a-full-backup.zip', - mimeType: 'application/zip', - buffer: Buffer.from('not a zip backup'), - }, - }, - headers: { Origin: app.baseURL, Referer: `${app.baseURL}/admin/panel` }, - maxRedirects: 0, - }); - expect([200, 303, 400]).toContain(invalidFullRestore.status()); - await expectSafeHtmlResponse(invalidFullRestore, 'invalid full restore'); - if (invalidFullRestore.status() === 303) { - await page.goto(`${app.baseURL}${invalidFullRestore.headers().location ?? '/admin/panel'}`); - await expectSafeBody(page, 'invalid full restore admin page'); - await expect(page.locator('body')).toContainText(/restore|invalid|zip|backup/i); - await expect(page.getByRole('link', { name: /admin/i }).first()).toBeVisible(); - } - - expectServerLogError({ - pattern: /Board restore failed - route: \/admin\/board\/restore, error: Bad request: Invalid board\.json: missing field `version`/, - reason: 'the intentionally invalid board-backup upload is rejected and logged by the server', - }); - const invalidBoardRestore = await page.request.post(`${app.baseURL}/admin/board/restore`, { - multipart: { - _csrf: await adminCsrf(page, app), - backup_file: { - name: 'phase4-not-a-board-backup.json', - mimeType: 'application/json', - buffer: Buffer.from('{"not":"a board backup"}'), - }, - }, - headers: { Origin: app.baseURL, Referer: `${app.baseURL}/admin/panel` }, - maxRedirects: 0, - }); - expect([200, 303, 400]).toContain(invalidBoardRestore.status()); - await expectSafeHtmlResponse(invalidBoardRestore, 'invalid board restore'); - }); -}); - -async function openPostForm(page: Page): Promise { - const toggle = page.locator('[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } -} - -async function postReplyDirect(page: Page, baseURL: string, board: string, threadId: number, body: string) { - const csrf = await publicCsrf(page, { baseURL } as Parameters[1], `/${board}/thread/${threadId}`); - return page.request.post(`${baseURL}/${board}/thread/${threadId}`, { - multipart: { - _csrf: csrf, - submission_token: `phase4-${Date.now()}-${Math.random()}`, - body, - }, - maxRedirects: 0, - }); -} - -async function firstVisiblePostId(page: Page): Promise { - const id = await page.locator('.post').first().getAttribute('id'); - const numeric = Number(id?.replace(/^p/, '')); - expect(Number.isInteger(numeric) && numeric > 0).toBe(true); - return numeric; -} diff --git a/tests/e2e/phase4-helpers.ts b/tests/e2e/phase4-helpers.ts deleted file mode 100644 index ad0cd3e5..00000000 --- a/tests/e2e/phase4-helpers.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { consoleErrorIsDeclared } from './diagnostics'; -import { expect, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test'; - -export const PHASE4_BROWSER_PROJECTS = new Set([ - 'chromium', - 'webkit', - 'firefox', - 'mobile-webkit', - 'firefox-nojs', -]); - -export const BUILTIN_THEMES = [ - ['forest', 'Forest'], - ['blue-sky', 'Blue Sky'], - ['deep-orbit', 'Deep Orbit'], - ['terminal', 'Terminal'], - ['dorfic', 'DORFic'], - ['chanclassic', 'ChanClassic'], - ['aero', 'Frutiger Aero'], - ['neoncubicle', 'NeonCubicle'], - ['fluorogrid', 'FluoroGrid'], -] as const; - -const UNSAFE_BODY_PATTERNS = [ - /thread panicked/i, - /stack backtrace/i, - /SQLITE_/i, - /database is locked/i, - /cookie_secret/i, - /password_hash/i, - /\/Users\//i, - /\/var\/folders\//i, - /\/tmp\/rustchan/i, - /target\/debug/i, - /rustchan-data/i, - /SECRET_SENTINEL_DO_NOT_LEAK/i, -]; - -export function phase4SkipUnless(testInfo: TestInfo, projects: string[], reason: string): void { - testInfo.skip(!projects.includes(testInfo.project.name), reason); -} - -export function isMobileProject(testInfo: TestInfo): boolean { - return testInfo.project.name.includes('mobile'); -} - -export async function expectSafeText(text: string, label: string): Promise { - for (const pattern of UNSAFE_BODY_PATTERNS) { - expect(text, `${label} should not leak ${pattern}`).not.toMatch(pattern); - } -} - -export async function expectSafeBody(page: Page, label = 'page'): Promise { - await expect(page.locator('body'), `${label} body should render`).toBeVisible(); - await expectSafeText(await page.locator('body').innerText(), label); -} - -export async function expectSafeHtmlResponse(response: APIResponse, label: string): Promise { - const text = await response.text(); - await expectSafeText(text, label); - return text; -} - -export async function expectSafeHeaders(response: APIResponse, label: string): Promise { - const headers = response.headers(); - expect(headers['x-content-type-options'], `${label} should use nosniff`).toBe('nosniff'); - expect(headers['referrer-policy'], `${label} should limit referrers`).toBe('same-origin'); - expect(headers['x-frame-options'], `${label} should frame-protect pages`).toBe('SAMEORIGIN'); - expect(headers['permissions-policy'], `${label} should restrict device permissions`).toContain('camera=()'); - expect(headers['content-security-policy'], `${label} should emit CSP`).toContain("frame-ancestors 'none'"); -} - -export function watchClientErrors(page: Page): () => void { - const errors: Array<{ text: string; url?: string }> = []; - page.on('console', (message) => { - if (message.type() === 'error') { - errors.push({ text: message.text(), url: message.location().url }); - } - }); - page.on('pageerror', (error) => { - errors.push({ text: error.message }); - }); - return () => { - // Only the browser's own ResizeObserver loop notice is tolerated here. - // Resource 4xx messages were previously filtered out with a broad allowlist, - // which hid missing assets. Expected console failures must now be declared - // through diagnostics.expectConsoleError, or be a direct consequence of a - // declared HTTP error / transport failure; the strict diagnostics fixture - // enforces the same rule for the whole test. - const unexpected = errors - .filter((message) => !/ResizeObserver loop/i.test(message.text)) - .filter((message) => !consoleErrorIsDeclared(message.text, message.url)) - .map((message) => message.text); - expect(unexpected).toEqual([]); - }; -} - -export async function expectNoHorizontalOverflow(page: Page, label: string, tolerance = 1): Promise { - const overflow = await page.evaluate(() => { - const doc = document.documentElement; - const body = document.body; - return Math.max(doc.scrollWidth - doc.clientWidth, body.scrollWidth - body.clientWidth); - }); - expect(overflow, `${label} should not create unusable horizontal scrolling`).toBeLessThanOrEqual(tolerance); -} - -export async function expectFocusVisible(locator: Locator, label: string): Promise { - await locator.evaluate(element => { - // Autofocused fields need an unfocused baseline. - if (document.activeElement === element) (element as HTMLElement).blur(); - }); - // `:focus-visible` affordances only match after a keyboard interaction, so a - // programmatic focus() that follows a pointer click would misreport the - // unfocused style. Enter keyboard modality first (see admin-polish.spec.ts). - await locator.page().keyboard.press('Tab'); - await expectFiniteAnimationsSettled(locator); - const before = await locator.evaluate(element => { - const style = getComputedStyle(element); - return { boxShadow: style.boxShadow, borderColor: style.borderColor }; - }); - await locator.focus(); - await expect(locator).toBeFocused(); - await expectFiniteAnimationsSettled(locator); - const focus = await locator.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - outlineStyle: style.outlineStyle, - outlineWidth: Number.parseFloat(style.outlineWidth || '0'), - boxShadow: style.boxShadow, - borderColor: style.borderColor, - }; - }); - expect( - (focus.outlineStyle !== 'none' && focus.outlineWidth > 0) - || (focus.boxShadow !== 'none' && focus.boxShadow !== before.boxShadow) - || focus.borderColor !== before.borderColor, - `${label} should expose a visible focus affordance: ${JSON.stringify({ before, focus })}`, - ).toBeTruthy(); -} - -async function expectFiniteAnimationsSettled(locator: Locator): Promise { - // Poll from the test process: page-side animation promises can remain pending - // in Firefox when the page's JavaScript is disabled. - await expect.poll(() => locator.evaluate(element => element.getAnimations().some(animation => - animation.playState === 'running' && Number.isFinite(animation.effect?.getComputedTiming().endTime), - )), { message: 'finite focus transitions should settle' }).toBe(false); -} - -export async function expectUsableTarget(locator: Locator, label: string, testInfo: TestInfo): Promise { - await expect(locator, `${label} should be visible`).toBeVisible(); - const box = await locator.boundingBox(); - expect(box, `${label} should have layout bounds`).not.toBeNull(); - expect(box!.width, `${label} should not collapse horizontally`).toBeGreaterThan(0); - expect(box!.height, `${label} should be large enough to target`).toBeGreaterThanOrEqual( - isMobileProject(testInfo) ? 36 : 24, - ); -} - -export async function expectNoCoveredCenters(locator: Locator, label: string): Promise { - const covered = await locator.evaluateAll((elements) => elements - .filter((element) => { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; - }) - .map((element, index) => { - const rect = element.getBoundingClientRect(); - const points = [ - [0.5, 0.5], - [0.3, 0.5], - [0.7, 0.5], - [0.5, 0.3], - [0.5, 0.7], - ]; - let hasPointInViewport = false; - const hasReachablePoint = points.some(([xRatio, yRatio]) => { - const x = rect.left + rect.width * xRatio; - const y = rect.top + rect.height * yRatio; - if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { - return false; - } - hasPointInViewport = true; - const top = document.elementFromPoint(x, y); - return top !== null && (element === top || element.contains(top) || top.contains(element)); - }); - if (!hasPointInViewport) { - return null; - } - return hasReachablePoint ? null : index + 1; - }) - .filter((index): index is number => index !== null)); - expect(covered, `${label} controls should expose a reachable hit point`).toEqual([]); -} - -export async function expectNamedInteractiveControls(page: Page, rootSelector: string, label: string): Promise { - const missing = await page.locator(rootSelector).evaluate((root) => { - function isVisible(element: Element): boolean { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - } - - function textOf(id: string): string { - return (document.getElementById(id)?.textContent || '').trim(); - } - - function associatedLabelText(control: Element): string { - if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement) { - const labels = Array.from(control.labels || []); - return labels.map((item) => item.textContent || '').join(' ').trim(); - } - return ''; - } - - function accessibleName(control: Element): string { - const aria = control.getAttribute('aria-label') || ''; - const labelledBy = (control.getAttribute('aria-labelledby') || '') - .split(/\s+/) - .map(textOf) - .join(' ') - .trim(); - const label = associatedLabelText(control); - const title = control.getAttribute('title') || ''; - const placeholder = control.getAttribute('placeholder') || ''; - const value = control instanceof HTMLInputElement && ['submit', 'button', 'reset'].includes(control.type) - ? control.value - : ''; - const imageAlt = Array.from(control.querySelectorAll('img[alt]')) - .map((image) => image.getAttribute('alt') || '') - .join(' ') - .trim(); - const text = control.textContent || ''; - return [aria, labelledBy, label, title, placeholder, value, imageAlt, text].join(' ').trim(); - } - - return Array.from(root.querySelectorAll('a[href], button, input, select, textarea, summary')) - .filter((element) => { - if (!isVisible(element)) return false; - if (element instanceof HTMLInputElement && element.type === 'hidden') return false; - return true; - }) - .filter((element) => accessibleName(element).length === 0) - .map((element) => { - const id = element.getAttribute('id'); - const name = element.getAttribute('name'); - const type = element instanceof HTMLInputElement ? element.type : element.tagName.toLowerCase(); - return [type, id ? `#${id}` : '', name ? `[name="${name}"]` : ''].filter(Boolean).join(''); - }); - }); - expect(missing, `${label} should not expose unnamed interactive controls`).toEqual([]); -} - -export async function expectReadableContrast(page: Page, selector: string, label: string, minimum = 3): Promise { - const ratio = await page.locator(selector).first().evaluate((element) => { - function parseRgb(value: string): [number, number, number, number] | null { - const match = value.match(/rgba?\(([^)]+)\)/); - if (!match) return null; - const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); - if (parts.length < 3) return null; - return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]; - } - - function relativeLuminance([r, g, b]: [number, number, number]): number { - const channel = (value: number) => { - const scaled = value / 255; - return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); - } - - function contrast(fg: [number, number, number], bg: [number, number, number]): number { - const lighter = Math.max(relativeLuminance(fg), relativeLuminance(bg)); - const darker = Math.min(relativeLuminance(fg), relativeLuminance(bg)); - return (lighter + 0.05) / (darker + 0.05); - } - - function composite(top: [number, number, number, number], bottom: [number, number, number]): [number, number, number] { - const alpha = top[3]; - return [ - top[0] * alpha + bottom[0] * (1 - alpha), - top[1] * alpha + bottom[1] * (1 - alpha), - top[2] * alpha + bottom[2] * (1 - alpha), - ]; - } - - const foreground = parseRgb(window.getComputedStyle(element).color); - const layers: Array<[number, number, number, number]> = []; - let current: Element | null = element; - while (current) { - const parsed = parseRgb(window.getComputedStyle(current).backgroundColor); - if (parsed && parsed[3] > 0) { - layers.push(parsed); - } - current = current.parentElement; - } - let background: [number, number, number] = [255, 255, 255]; - for (const layer of layers.reverse()) { - background = composite(layer, background); - } - if (!foreground) return 0; - return contrast([foreground[0], foreground[1], foreground[2]], background); - }); - expect(ratio, `${label} contrast ratio`).toBeGreaterThanOrEqual(minimum); -} diff --git a/tests/e2e/phase4-responsive-long-content.spec.ts b/tests/e2e/phase4-responsive-long-content.spec.ts deleted file mode 100644 index 74be4504..00000000 --- a/tests/e2e/phase4-responsive-long-content.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { - adminLogin, - createReply, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - sqliteExec, - test, - uniqueShort, -} from './helpers'; -import { - expectNamedInteractiveControls, - expectNoCoveredCenters, - expectNoHorizontalOverflow, - expectSafeBody, - phase4SkipUnless, - watchClientErrors, -} from './phase4-helpers'; - -test.describe('phase 4 responsive and long-content stress', () => { - test.beforeEach(async ({}, testInfo) => { - phase4SkipUnless( - testInfo, - ['chromium', 'mobile-webkit'], - 'responsive long-content coverage runs on desktop Chromium and mobile WebKit', - ); - }); - - test('long board metadata, titles, posts, filenames, counters, and admin rows stay usable without overlap', async ({ page, app }, testInfo) => { - const assertNoClientErrors = watchClientErrors(page); - const board = uniqueShort('long', testInfo); - const longToken = 'phase4longtoken'.repeat(12); - app.createBoardCli({ - short: board, - name: `Long Name ${longToken}`, - description: `Description with URLs https://example.test/${longToken} and dense text ${'wide '.repeat(50)}`, - }); - setBoardFixtureSettings(app, board, { - name: `Long Board Name ${longToken}`, - description: `Long board description ${longToken} ${'wrapping content '.repeat(40)}`, - allowImages: true, - allowArchive: true, - postCooldownSecs: 0, - maxThreads: 200, - maxArchivedThreads: 200, - }); - - const longFile = path.join(app.fixtureDir, `${'very-long-upload-file-name-'.repeat(5)}phase4.png`); - fs.copyFileSync(app.fixtures().tinyPng, longFile); - - const threadIds: number[] = []; - for (let index = 0; index < 11; index += 1) { - const threadId = await createThread(page, app, board, { - subject: `Subject ${index} ${longToken}`, - body: [ - `Long body ${index}`, - 'unbroken_' + 'x'.repeat(180), - `quoted >>>/${board}/${index}`, - 'paragraph '.repeat(120), - ].join('\n'), - filePath: index === 0 ? longFile : undefined, - }); - threadIds.push(threadId); - if (index === 0) { - for (let reply = 0; reply < 14; reply += 1) { - await createReply( - page, - app, - board, - threadId, - `deep reply ${reply} ${'replyword'.repeat(20)}\n>>${reply + 1}`, - ); - } - } - } - - await page.goto(app.baseURL); - await expectSafePage(page); - await expectNoHorizontalOverflow(page, 'home with long board card'); - await expectNamedInteractiveControls(page, 'main', 'home with long board card'); - - await page.goto(`${app.baseURL}/${board}`); - await expectSafeBody(page, 'long board index'); - await expectNoHorizontalOverflow(page, 'long board index'); - await expect(page.locator('.pagination')).toContainText(/page 1 \/ 2/i); - await expect(page.getByRole('link', { name: /\[next\]/i })).toBeVisible(); - await expectNoCoveredCenters( - page.locator('.thread .subject a:visible, .thread .post-body a:visible, .thread .thread-footer a:visible, .pagination a:visible, .post-toggle-btn:visible'), - 'long board controls', - ); - - await page.goto(`${app.baseURL}/${board}?page=2`); - await expectSafeBody(page, 'long board second page'); - await expectNoHorizontalOverflow(page, 'long board second page'); - await expect(page.locator('.pagination')).toContainText(/page 2 \/ 2/i); - - await page.goto(`${app.baseURL}/${board}/catalog`); - await expectSafeBody(page, 'long catalog'); - await expectNoHorizontalOverflow(page, 'long catalog'); - await expectNoCoveredCenters(page.locator('.catalog-item a:visible, .catalog-thread-menu-toggle:visible'), 'catalog controls'); - - await page.goto(`${app.baseURL}/${board}/thread/${threadIds[0]}`); - await expectSafeBody(page, 'long thread'); - await expectNoHorizontalOverflow(page, 'long thread'); - await expect(page.locator('.post')).toHaveCount(15); - await expect(page.locator('.file-info a').first()).toContainText(/very-long-upload-fil/); - await expect(page.locator('.file-info a').first()).toHaveAttribute('href', /\.webp$/); - await expectNoCoveredCenters(page.locator('.thread-nav a:visible, .thread-nav button:visible, .post-controls button:visible'), 'long thread controls'); - - const longReason = `phase4 reason ${longToken} ${'policy '.repeat(40)}`; - sqliteExec(app, [ - "INSERT INTO bans (ip_hash, reason, expires_at)", - `VALUES ('${'a'.repeat(64)}', '${longReason.replaceAll("'", "''")}', NULL);`, - "INSERT INTO word_filters (pattern, replacement)", - `VALUES ('${'needle'.repeat(20)}', '${'replacement'.repeat(18)}');`, - ].join(' ')); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=reports#reports`); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'admin moderation with long rows'); - await expectNoCoveredCenters( - page.locator('#active-bans button:visible, #word-filters button:visible, #reports a:visible'), - 'admin moderation long-row controls', - ); - - assertNoClientErrors(); - }); -}); diff --git a/tests/e2e/phase4-stress-release-smoke.spec.ts b/tests/e2e/phase4-stress-release-smoke.spec.ts deleted file mode 100644 index 86c56056..00000000 --- a/tests/e2e/phase4-stress-release-smoke.spec.ts +++ /dev/null @@ -1,359 +0,0 @@ -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import { - ADMIN_PASSWORD, - ADMIN_USERNAME, - RustChanServer, - adminCsrf, - adminLogin, - createBoard, - createReply, - createThread, - expect, - expectSafePage, - setBoardFixtureSettings, - sqliteQuery, - test, - uniqueShort, - unlockBoard, - updateBoardSettings, -} from './helpers'; -import { - expectNoHorizontalOverflow, - expectSafeBody, - expectSafeHeaders, - phase4SkipUnless, - watchClientErrors, -} from './phase4-helpers'; -import type { Page, TestInfo } from '@playwright/test'; - -test.describe('phase 4 high-volume and release-smoke journeys', () => { - test('busy public, protected, admin, search, API, update, and pagination surfaces remain usable', async ({ page, app }, testInfo) => { - phase4SkipUnless(testInfo, ['chromium'], 'high-volume browser stress runs once in Chromium'); - test.setTimeout(150_000); - const assertNoClientErrors = watchClientErrors(page); - const busy = uniqueShort('busy', testInfo); - const protectedBoard = uniqueShort('prot', testInfo); - - app.createBoardCli({ short: busy, name: 'Busy Public Board', description: 'many threads and replies' }); - app.createBoardCli({ short: protectedBoard, name: 'Protected Busy Board', description: 'must not leak content' }); - setBoardFixtureSettings(app, busy, { - allowImages: true, - allowAudio: true, - allowPdf: true, - defaultTheme: 'fluorogrid', - postCooldownSecs: 0, - maxThreads: 200, - maxArchivedThreads: 200, - }); - setBoardFixtureSettings(app, protectedBoard, { - allowImages: true, - postCooldownSecs: 0, - maxThreads: 200, - maxArchivedThreads: 200, - }); - - for (let index = 0; index < 12; index += 1) { - app.createBoardCli({ - short: uniqueShort(`b${index}`, testInfo), - name: `Auxiliary Board ${index}`, - description: `auxiliary board for home stress ${index}`, - }); - } - - const threadIds: number[] = []; - for (let index = 0; index < 14; index += 1) { - const threadId = await createThread(page, app, busy, { - subject: `busy thread ${index}`, - body: `busy instance body ${index} ${'reply counter '.repeat(30)}`, - filePath: index % 5 === 0 ? app.fixtures().tinyPng : undefined, - }); - threadIds.push(threadId); - for (let reply = 0; reply < (index === 0 ? 8 : 2); reply += 1) { - await createReply(page, app, busy, threadId, `busy reply ${index}/${reply} ${'content '.repeat(25)}`); - } - } - - const protectedThread = await createThread(page, app, protectedBoard, { - subject: 'protected phase4 sentinel', - body: 'PROTECTED_PHASE4_SENTINEL should never leak to public pages', - filePath: app.fixtures().tinyPng, - }); - await updateBoardSettings(page, app, protectedBoard, { - name: 'Protected Busy Board', - description: 'must not leak content', - accessMode: 'view_password', - accessPassword: 'protected-pass', - allowImages: true, - }); - await page.context().clearCookies(); - - await page.goto(app.baseURL); - await expectSafePage(page); - await expectNoHorizontalOverflow(page, 'busy home'); - await expect(page.locator('body')).not.toContainText('PROTECTED_PHASE4_SENTINEL'); - expect(await page.locator('.board-card').count()).toBeGreaterThanOrEqual(14); - await expect(page.getByRole('link', { name: new RegExp(`/${busy}/`) })).toBeVisible(); - await expect(page.getByRole('link', { name: new RegExp(`/${protectedBoard}/`) })).toBeVisible(); - - await page.goto(`${app.baseURL}/${busy}`); - await expectSafeBody(page, 'busy board'); - await expect(page.locator('.pagination')).toContainText(/page 1 \/ 2/i); - await expectNoHorizontalOverflow(page, 'busy board'); - await page.getByRole('link', { name: /\[next\]/i }).click(); - await expect(page).toHaveURL(new RegExp(`/${busy}\\?page=2`)); - await expectSafeBody(page, 'busy board page 2'); - - await page.goto(`${app.baseURL}/${busy}/catalog`); - await expectSafeBody(page, 'busy catalog'); - await expect(page.locator('.catalog-item')).toHaveCount(14); - await expectNoHorizontalOverflow(page, 'busy catalog'); - - await page.goto(`${app.baseURL}/${busy}/thread/${threadIds[0]}`); - await expectSafeBody(page, 'busy thread'); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('8'); - const postId = Number((await page.locator('.post').first().getAttribute('id'))?.replace(/^p/, '')); - expect(Number.isInteger(postId)).toBe(true); - - const api = await page.request.get(`${app.baseURL}/api/post/${busy}/${postId}`); - expect(api.status()).toBe(200); - await expectSafeHeaders(api, 'post preview API'); - expect(await api.text()).toContain('busy instance body'); - - const updates = await page.request.get(`${app.baseURL}/${busy}/thread/${threadIds[0]}/updates?since=0`); - expect(updates.status()).toBe(200); - await expectSafeHeaders(updates, 'thread updates'); - expect(await updates.text()).toContain('reply_count'); - - await page.goto(`${app.baseURL}/${busy}/search?q=busy`); - await expectSafeBody(page, 'busy search'); - await expect(page.locator('body')).toContainText(/results|busy/i); - await expect(page.locator('body')).not.toContainText('PROTECTED_PHASE4_SENTINEL'); - - const deniedProtectedThread = await page.request.get(`${app.baseURL}/${protectedBoard}/thread/${protectedThread}`, { maxRedirects: 0 }); - expect(deniedProtectedThread.status()).toBe(403); - expect(await deniedProtectedThread.text()).not.toContain('PROTECTED_PHASE4_SENTINEL'); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel`); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, 'busy admin dashboard'); - await expect(page.locator('body')).toContainText(/site health|boards|moderation/i); - - assertNoClientErrors(); - }); - - test('fresh runtime can become a usable site, moderate content, back up, and restore', async ({ page }, testInfo) => { - phase4SkipUnless(testInfo, ['chromium'], 'full release smoke runs once in Chromium'); - test.setTimeout(180_000); - - const app = await RustChanServer.create(undefined, { env: { CHAN_AUTO_FULL_BACKUP_COPIES: '10' } }); - try { - app.runCli(['admin', 'create-admin', ADMIN_USERNAME, ADMIN_PASSWORD]); - await app.start(); - testInfo.annotations.push({ type: 'phase4-release-root', description: app.rootDir }); - - await page.goto(app.baseURL); - await expectSafePage(page); - await adminLogin(page, app); - - const board = uniqueShort('rel', testInfo); - await createBoard(page, app, { short: board, name: 'Release Smoke', description: 'fresh usable site' }); - await updateBoardSettings(page, app, board, { - name: 'Release Smoke', - description: 'fresh usable site configured through admin', - allowImages: true, - allowEditing: true, - allowSelfDelete: true, - allowArchive: true, - defaultTheme: 'aero', - postCooldownSecs: 0, - }); - - const threadId = await createThread(page, app, board, { - subject: 'release smoke thread', - body: 'fresh runtime thread body', - filePath: app.fixtures().tinyPng, - }); - await createReply(page, app, board, threadId, 'fresh runtime reply'); - await page.goto(`${app.baseURL}/${board}/catalog`); - await expect(page.locator(`a[href="/${board}/thread/${threadId}"]`).first()).toBeVisible(); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expect(page.locator('body')).toContainText('fresh runtime reply'); - - const editButton = page.locator('.self-action-controls .edit-btn').first(); - await editButton.click(); - await page.getByLabel('edit post body').fill('fresh runtime thread body edited'); - await page.locator('#edit-modal-form').getByRole('button', { name: /save edit/i }).click(); - await expect(page.locator('body')).toContainText('fresh runtime thread body edited'); - - const deleteThreadId = await createThread(page, app, board, { - subject: 'release smoke self delete', - body: 'delete this own OP', - }); - const deletePostId = Number((await page.locator('.post').first().getAttribute('id'))?.replace(/^p/, '')); - await page.locator('.self-action-controls .del-btn').first().click(); - await page.locator('#confirm-modal-continue').click(); - await expect.poll(() => sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${deletePostId};`)).toBe('0'); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${deleteThreadId};`)).toBe('0'); - - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await page.locator('.post-controls .report-btn').first().click(); - await page.locator('#report-modal').getByLabel('reason').fill('release smoke report'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - page.locator('#report-submit-btn').click(), - ]); - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=reports#reports`); - const reportRow = page.locator('#reports tbody tr').filter({ hasText: 'release smoke report' }).first(); - await expect(reportRow).toBeVisible(); - await reportRow.getByRole('button', { name: /resolve/i }).click(); - await expect(page.locator('body')).toContainText(/report resolved|no open reports/i); - - const backupRef = await createFullBackupSmoke(page, app); - await restoreSavedFullBackupSmoke(page, app, backupRef); - await app.restart(); - await page.goto(`${app.baseURL}/${board}/thread/${threadId}`); - await expectSafePage(page); - await expect(page.locator('body')).toContainText('fresh runtime thread body edited'); - } finally { - if (testInfo.status !== testInfo.expectedStatus) { - await testInfo.attach('phase4-release-smoke.log', { - body: await app.logs(), - contentType: 'text/plain', - }); - } - await app.dispose(); - } - }); - - test('no-JS public posting, upload, own edit or delete, report, unlock, and moderation smoke works', async ({ page, app }, testInfo) => { - phase4SkipUnless(testInfo, ['firefox-nojs', 'chromium-nojs'], 'no-JS release smoke runs in the no-JS projects'); - const board = uniqueShort('njs', testInfo); - app.createBoardCli({ short: board, name: 'No JS Release', description: 'no-js release smoke' }); - await updateBoardSettings(page, app, board, { - name: 'No JS Release', - description: 'no-js release smoke', - accessMode: 'post_password', - accessPassword: 'no-js-pass', - allowImages: true, - allowEditing: true, - allowSelfDelete: true, - postCooldownSecs: 0, - }); - await page.context().clearCookies(); - - await unlockBoard(page, app, board, 'no-js-pass'); - await page.goto(`${app.baseURL}/${board}`); - const form = page.locator(`form[action="/${board}"]`).first(); - await form.getByLabel('subject').fill('no js release thread'); - await form.getByLabel('body').fill('created with scripts disabled'); - await form.getByLabel('upload').setInputFiles(app.fixtures().tinyPng); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/\\d+`)), - form.getByRole('button', { name: /post thread/i }).click(), - ]); - const threadId = Number(page.url().match(/\/thread\/(\d+)/)?.[1]); - expect(Number.isInteger(threadId)).toBe(true); - - const replyForm = page.locator(`form[action="/${board}/thread/${threadId}"]`).first(); - await replyForm.getByLabel('body').fill('no-js reply'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - replyForm.getByRole('button', { name: /post reply/i }).click(), - ]); - await expect(page.locator('body')).toContainText('no-js reply'); - - const postId = Number((await page.locator('.post').first().getAttribute('id'))?.replace(/^p/, '')); - await page.goto(`${app.baseURL}/${board}/post/${postId}/edit`); - await page.getByLabel('edit post body').fill('edited without scripts'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - page.getByRole('button', { name: /save edit/i }).click(), - ]); - await expect(page.locator('body')).toContainText('edited without scripts'); - - await page.locator('.report-fallback-summary').first().click(); - await page.locator('.report-fallback-reason').first().fill('no-js release report'); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/thread/${threadId}`)), - page.locator('.report-fallback-submit').first().click(), - ]); - - const deleteThreadId = await createThread(page, app, board, { - subject: 'no-js delete target', - body: 'delete through no-js confirmation page', - }); - const deletePostId = Number((await page.locator('.post').first().getAttribute('id'))?.replace(/^p/, '')); - await page.goto(`${app.baseURL}/${board}/post/${deletePostId}/delete`); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/catalog|/${board}`)), - page.getByRole('button', { name: /delete post/i }).click(), - ]); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${deleteThreadId};`)).toBe('0'); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=reports#reports`); - const reportRow = page.locator('#reports tbody tr').filter({ hasText: 'no-js release report' }).first(); - await expect(reportRow).toBeVisible(); - await reportRow.getByRole('button', { name: /resolve/i }).click(); - await expect(page.locator('body')).toContainText(/report resolved|no open reports/i); - }); -}); - -async function createFullBackupSmoke(page: Page, app: RustChanServer): Promise { - await adminLogin(page, app); - const before = new Set(await listBackupRefs(app)); - const response = await page.request.post(`${app.baseURL}/admin/backup/create`, { - form: { - _csrf: await adminCsrf(page, app), - storage_mode: 'directory', - }, - headers: { Origin: app.baseURL, Referer: `${app.baseURL}/admin/panel` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - return waitForNewBackupRef(app, before); -} - -async function restoreSavedFullBackupSmoke(page: Page, app: RustChanServer, backupRef: string): Promise { - await adminLogin(page, app); - const response = await page.request.post(`${app.baseURL}/admin/backup/restore-saved`, { - form: { - _csrf: await adminCsrf(page, app), - filename: backupRef, - }, - headers: { Origin: app.baseURL, Referer: `${app.baseURL}/admin/panel` }, - maxRedirects: 0, - }); - expect(response.status()).toBe(303); - expect(response.headers().location ?? '').toContain('restored=1'); -} - -async function listBackupRefs(app: RustChanServer): Promise { - const root = path.join(app.dataDir, 'backups'); - const entries = await fsp.readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return []; - throw error; - }); - return entries - .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'backup.json'))) - .map((entry) => entry.name) - .sort(); -} - -async function waitForNewBackupRef(app: RustChanServer, before: Set): Promise { - let latest: string | undefined; - await expect.poll(async () => { - const refs = await listBackupRefs(app); - const created = refs.filter((ref) => !before.has(ref)); - latest = created.sort().at(-1); - return created.length; - }, { timeout: 15_000, intervals: [250, 500, 1_000, 2_000] }).toBeGreaterThan(0); - if (!latest) { - throw new Error('new backup was not found'); - } - return latest; -} diff --git a/tests/e2e/phase4-theme-contrast.spec.ts b/tests/e2e/phase4-theme-contrast.spec.ts deleted file mode 100644 index 5e45a49b..00000000 --- a/tests/e2e/phase4-theme-contrast.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - adminCsrf, - adminLogin, - expect, - expectSafePage, - test, - uniqueShort, - updateBoardSettings, -} from './helpers'; -import { - BUILTIN_THEMES, - expectNoHorizontalOverflow, - expectReadableContrast, - expectSafeBody, - phase4SkipUnless, - watchClientErrors, -} from './phase4-helpers'; -import { expectHttpError } from './diagnostics'; - -test.describe('phase 4 built-in theme and contrast polish', () => { - test.beforeEach(async ({}, testInfo) => { - phase4SkipUnless(testInfo, ['chromium'], 'theme polish coverage runs once in Chromium'); - }); - - test('theme picker order, selected state, site default, board override, and inherit behavior stay stable', async ({ page, app }, testInfo) => { - await page.goto(app.baseURL); - await expectSafeBody(page, 'home theme picker'); - const pickerOptions = await page.locator('.user-preferences-form select[name="theme"] option').evaluateAll((options) => options.map((option) => ({ - value: (option as HTMLOptionElement).value, - label: option.textContent?.trim() ?? '', - selected: (option as HTMLOptionElement).selected, - }))); - expect(pickerOptions.map((option) => [option.value, option.label])).toEqual(BUILTIN_THEMES); - expect(pickerOptions.find((option) => option.value === 'forest')?.selected).toBe(true); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'forest'); - - await page.goto(`${app.baseURL}/theme/blue-sky?return_to=/`); - await page.waitForURL(app.baseURL + '/'); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'blue-sky'); - const selectedAfterCookie = await page.locator('.user-preferences-form select[name="theme"]').inputValue(); - expect(selectedAfterCookie).toBe('blue-sky'); - - await adminLogin(page, app); - const siteSettings = await page.request.post(`${app.baseURL}/admin/site/settings`, { - form: { - _csrf: await adminCsrf(page, app), - site_name: 'RustChan Phase 4 Themes', - site_subtitle: 'theme default smoke', - default_theme: 'deep-orbit', - homepage_new_thread_badges_enabled: '1', - homepage_new_reply_badges_enabled: '1', - thread_new_reply_badges_enabled: '1', - banner_rotation_interval_minutes: '5', - }, - maxRedirects: 0, - }); - expect(siteSettings.status()).toBe(303); - - await page.context().clearCookies(); - await page.goto(app.baseURL); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'deep-orbit'); - expect(await page.locator('.user-preferences-form select[name="theme"]').inputValue()).toBe('deep-orbit'); - - const board = uniqueShort('theme', testInfo); - app.createBoardCli({ short: board, name: 'Theme Override Board', description: 'board theme override coverage' }); - await updateBoardSettings(page, app, board, { - name: 'Theme Override Board', - description: 'board theme override coverage', - defaultTheme: 'chanclassic', - }); - await page.context().clearCookies(); - await page.goto(`${app.baseURL}/${board}`); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'chanclassic'); - - await updateBoardSettings(page, app, board, { - name: 'Theme Override Board', - description: 'board inherits the site default again', - defaultTheme: '', - }); - await page.context().clearCookies(); - await page.goto(`${app.baseURL}/${board}`); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', 'deep-orbit'); - - await adminLogin(page, app); - await page.goto(`${app.baseURL}/admin/panel?open=theme-catalog#theme-catalog`); - const adminOptions = await page.locator('select[name="default_theme"]').first().locator('option').evaluateAll((options) => options.map((option) => [ - (option as HTMLOptionElement).value, - option.textContent?.trim() ?? '', - ])); - expect(adminOptions).toEqual(BUILTIN_THEMES); - expect(await page.locator('select[name="default_theme"]').first().inputValue()).toBe('deep-orbit'); - }); - - test('built-in themes keep core public and admin states readable without console errors', async ({ page, app }) => { - // Each theme posts an intentionally empty thread body to reach the - // validation-error state whose contrast is the subject of this loop, so the - // 422 re-render is expected once per theme. - expectHttpError({ - method: 'POST', - path: '/pub', - status: 422, - times: 'any', - reason: 'empty-body validation error state is the readability subject of this loop', - }); - const assertNoClientErrors = watchClientErrors(page); - await adminLogin(page, app); - - for (const [slug, label] of BUILTIN_THEMES) { - await page.goto(`${app.baseURL}/theme/${slug}?return_to=/pub`); - await page.waitForURL(`${app.baseURL}/pub`); - await expectSafePage(page, { allowAdminInternals: true }); - await expect(page.locator('html')).toHaveAttribute('data-active-theme', slug); - await expectNoHorizontalOverflow(page, `${label} board`); - await expectReadableContrast(page, 'body', `${label} body text`, 4.2); - await expectReadableContrast(page, '.board-nav-link', `${label} board nav link`, 3); - await expectReadableContrast(page, '.post-toggle-btn', `${label} post toggle`, 3); - - const toggle = page.locator('[data-action="toggle-post-form"]').first(); - if (await toggle.isVisible()) { - await toggle.click(); - } - await expectReadableContrast(page, 'input[name="subject"]', `${label} post subject input`, 3); - await expectReadableContrast(page, 'textarea[name="body"]', `${label} post body input`, 3); - const placeholder = await page.locator('input[name="name"]').first().evaluate((element) => { - const style = window.getComputedStyle(element, '::placeholder'); - return { color: style.color, opacity: style.opacity }; - }); - expect(placeholder.color, `${label} placeholder color`).toMatch(/rgb/); - expect(Number.parseFloat(placeholder.opacity || '1'), `${label} placeholder opacity`).toBeGreaterThan(0); - - await page.locator(`form[action="/pub"]`).first().getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toBeVisible(); - await expectReadableContrast(page, '.post-error-banner', `${label} post error`, 3); - - await page.goto(`${app.baseURL}/admin/panel?open=site-settings#site-settings`); - await expectSafePage(page, { allowAdminInternals: true }); - await expectNoHorizontalOverflow(page, `${label} admin settings`); - await expectReadableContrast(page, '.admin-panel', `${label} admin panel`, 4.2); - await expectReadableContrast(page, 'select[name="default_theme"]', `${label} admin theme select`, 3); - await expectReadableContrast(page, '#site-settings button[type="submit"]', `${label} admin save button`, 3); - } - - assertNoClientErrors(); - }); -}); diff --git a/tests/e2e/posting-thread.spec.ts b/tests/e2e/posting-thread.spec.ts deleted file mode 100644 index f1aec96e..00000000 --- a/tests/e2e/posting-thread.spec.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { expectHttpError, newAuditedPage } from './diagnostics'; -import { - createReply, - createThread, - expect, - expectNoDialog, - expectSafePage, - expectSafeResponse, - setPostFixtureCreatedAt, - setThreadFixtureState, - sqliteExec, - sqliteQuery, - test, - uniqueShort, - updateBoardSettings, - isNoJsProject, -} from './helpers'; -import type { Page } from '@playwright/test'; - -test.describe('logged-out navigation and posting', () => { - test('home, board, catalog, thread, assets, theme, and back-forward navigation are stable', async ({ page, app }) => { - await page.goto(app.baseURL); - await expectSafePage(page); - await expect(page.locator('.board-card-link[href="/pub/catalog"], .board-card-link[href="/pub"]').first()).toBeVisible(); - - const css = await page.request.get(`${app.baseURL}/static/style.css`); - expect(css.status()).toBe(200); - const js = await page.request.get(`${app.baseURL}/static/main.js`); - expect(js.status()).toBe(200); - - await page.goto(`${app.baseURL}/theme/blue-sky`); - await page.goto(`${app.baseURL}/pub`); - await expectSafePage(page); - const threadId = await createThread(page, app, 'pub', { - subject: 'Navigation thread', - body: 'body for catalog and back-forward checks', - }); - - await page.goto(`${app.baseURL}/pub/catalog`); - await expectSafePage(page); - await expect(page.locator(`a[href="/pub/thread/${threadId}"]`).first()).toBeVisible(); - await page.goto(`${app.baseURL}/pub/thread/${threadId}`); - await expectSafePage(page); - await page.goBack(); - await expect(page).toHaveURL(/\/pub\/catalog/); - await expectSafePage(page); - await page.goForward(); - await expect(page).toHaveURL(new RegExp(`/pub/thread/${threadId}`)); - await expectSafePage(page); - }); - - test('thread creation, reply, escaping, quote-ish text, unicode, and refresh do not duplicate posts', async ({ page, app }) => { - const payload = '\n>>123\nhttps://example.test/a?b=c\nlongword_' + - 'x'.repeat(80) + '\nemoji 😀 quotes "` and bidi \u202E'; - await expectNoDialog(page, async () => { - const threadId = await createThread(page, app, 'pub', { - subject: '', - body: payload, - }); - await expect(page.locator('body')).toContainText(''); - expect(await page.locator('script:text("alert(1)")').count()).toBe(0); - await createReply(page, app, 'pub', threadId, 'reply with **bold** and __italic__'); - await expect(page.locator('[data-role="thread-reply-count"]').first()).toHaveText('1'); - const postCount = await page.locator('.post').count(); - await page.reload(); - await expect(page.locator('.post')).toHaveCount(postCount); - }); - }); - - test('empty, oversized, duplicate, invalid id, and stale-form posting errors stay safe', async ({ page, app }) => { - await page.goto(`${app.baseURL}/pub`); - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').click(); - const form = page.locator('form[action="/pub"]').first(); - await form.locator('textarea[name="body"]').fill(''); - expectHttpError({ - method: 'POST', - path: '/pub', - status: 422, - reason: 'an empty-body thread submission is rejected and the form re-renders', - }); - await form.getByRole('button', { name: /post thread/i }).click(); - await expect(page.locator('.post-error-banner').first()).toContainText(/body|empty|required|attached file/i); - await expectSafePage(page); - - await page.goto(`${app.baseURL}/pub`); - await page.locator('.post-toggle-btn[data-action="toggle-post-form"]').click(); - const longForm = page.locator('form[action="/pub"]').first(); - const csrf = await longForm.locator('input[name="_csrf"]').getAttribute('value'); - const oversized = await page.request.post(`${app.baseURL}/pub`, { - multipart: { - _csrf: csrf ?? '', - submission_token: `oversized-${Date.now()}`, - body: 'x'.repeat(5000), - }, - maxRedirects: 0, - }); - expect([400, 422]).toContain(oversized.status()); - await expectSafeResponse(oversized); - await expectSafePage(page); - - const threadId = await createThread(page, app, 'pub', { subject: 'duplicate guard', body: 'one submit' }); - const count = await page.locator('.post').count(); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(/\/pub(?:#post-form-wrap)?$/); - await page.goForward({ waitUntil: 'domcontentloaded' }); - await expect(page).toHaveURL(new RegExp(`/pub/thread/${threadId}`)); - await expect(page.locator('.post')).toHaveCount(count); - - const invalidBoard = await page.request.get(`${app.baseURL}/../../etc/passwd`); - expect([400, 404]).toContain(invalidBoard.status()); - await expectSafeResponse(invalidBoard); - const invalidThread = await page.request.get(`${app.baseURL}/pub/thread/999999999`); - expect([404, 410]).toContain(invalidThread.status()); - await expectSafeResponse(invalidThread); - - await page.goto(`${app.baseURL}/pub/thread/${threadId}`); - const replyCsrf = await page.locator('form[action$="/thread/' + threadId + '"] input[name="_csrf"]').first().getAttribute('value'); - const staleBody = 'stale direct reply'; - const response = await page.request.post(`${app.baseURL}/pub/thread/${threadId}`, { - multipart: { - _csrf: replyCsrf ?? '', - submission_token: 'stale-reuse-check', - body: staleBody, - }, - maxRedirects: 0, - }); - // A wide allowlist ([303, 403, 422]) accepted success and failure as - // equivalent. Assert the exact outcome and the persisted consequence. - if (response.status() === 303) { - expect( - sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body = '${staleBody}';`), - 'a successful stale submit must create exactly one reply', - ).toBe('1'); - } else { - expect([400, 403, 422], `unexpected stale-reply status ${response.status()}`).toContain(response.status()); - expect( - sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE body = '${staleBody}';`), - 'a rejected stale submit must not create a reply', - ).toBe('0'); - } - }); - - test('own edit modal and delete confirmation are limited to the posting browser context', async ({ page, browser, app }, testInfo) => { - test.skip(isNoJsProject(testInfo), 'scripted own-post modal flow; no-JS fallback is covered separately'); - - const board = uniqueShort('ed', testInfo); - app.createBoardCli({ short: board, name: 'Edit Delete' }); - await updateBoardSettings(page, app, board, { - allowEditing: true, - allowSelfDelete: true, - }); - const threadId = await createThread(page, app, board, { subject: 'editable', body: 'original body' }); - const opPostId = await visiblePostId(page, '.post.op'); - const ownCookie = (await page.context().cookies(app.baseURL)).find((cookie) => cookie.name === 'rustchan_owned_posts'); - expect(ownCookie).toBeTruthy(); - expect(ownCookie?.httpOnly).toBe(true); - expect(ownCookie?.sameSite).toBe('Lax'); - expect(ownCookie?.secure).toBe(false); - - const editHref = await page.locator('.self-action-controls .edit-btn').first().getAttribute('href'); - expect(editHref).toBe(`/${board}/post/${opPostId}/edit`); - await page.locator(`#p${opPostId} .self-action-controls .edit-btn`).click(); - await expect(page.locator('#edit-modal.is-open')).toBeVisible(); - await page.locator('#edit-modal-body').fill('edited body through modal'); - await page.locator('#edit-modal-form').getByRole('button', { name: /save edit/i }).click(); - await expect(page.locator(`#p${opPostId}`)).toContainText('edited body through modal'); - - const other = await newAuditedPage(browser); - expectHttpError({ - method: 'GET', - path: `/${board}/post/${opPostId}/edit`, - status: 403, - reason: 'a second browser without the ownership cookie cannot open the edit page', - }); - await other.goto(`${app.baseURL}${editHref}`); - await expect(other.locator('body')).toContainText(/not allowed|expired|forbidden|edit/i); - await other.close(); - - const deleteThreadId = await createThread(page, app, board, { - subject: 'delete through modal', - body: 'delete target body', - }); - const deletePostId = await visiblePostId(page, '.post.op'); - await page.locator(`#p${deletePostId} .self-action-controls .del-btn`).click(); - await expect(page.locator('#confirm-modal')).toBeVisible(); - await Promise.all([ - page.waitForURL(new RegExp(`/${board}/catalog|/${board}`)), - page.locator('#confirm-modal-continue').click(), - ]); - await expectSafePage(page); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM threads WHERE id = ${deleteThreadId};`)).toBe('0'); - }); - - test('own-post controls deny expired, replied, locked, archived, and deleted posts safely', async ({ page, app }, testInfo) => { - const board = uniqueShort('deny', testInfo); - app.createBoardCli({ short: board, name: 'Own Post Denials' }); - await updateBoardSettings(page, app, board, { - allowEditing: true, - allowSelfDelete: true, - }); - - const repliedThread = await createThread(page, app, board, { - subject: 'op with replies', - body: 'starter should not self-delete after replies', - }); - const repliedOpId = await visiblePostId(page, '.post.op'); - await createReply(page, app, board, repliedThread, 'reply blocks OP self-delete'); - expectHttpError({ - method: 'POST', - path: `/${board}/post/${repliedOpId}/delete`, - status: 403, - reason: 'a thread starter cannot self-delete once replies exist', - }); - await page.goto(`${app.baseURL}/${board}/post/${repliedOpId}/delete`); - await page.getByRole('button', { name: /delete post/i }).click(); - await expect(page.locator('body')).toContainText(/thread starter before anyone replies/i); - expect(sqliteQuery(app, `SELECT COUNT(*) FROM posts WHERE id = ${repliedOpId};`)).toBe('1'); - - const lockedThread = await createThread(page, app, board, { - subject: 'locked own-post', - body: 'locked action denied', - }); - const lockedPostId = await visiblePostId(page, '.post.op'); - const lockedEditHref = await page.locator(`#p${lockedPostId} .self-action-controls .edit-btn`).getAttribute('href'); - const lockedDeleteHref = await page.locator(`#p${lockedPostId} .self-action-controls .del-btn`).getAttribute('href'); - expect(lockedEditHref).toBeTruthy(); - expect(lockedDeleteHref).toBeTruthy(); - setThreadFixtureState(app, lockedThread, { locked: true }); - expectHttpError({ - method: 'GET', - path: `/${board}/post/${lockedPostId}/edit`, - status: 403, - reason: 'a locked thread refuses the own-post edit page', - }); - await page.goto(`${app.baseURL}${lockedEditHref}`); - await expect(page.locator('body')).toContainText(/locked or archived/i); - expectHttpError({ - method: 'GET', - path: `/${board}/post/${lockedPostId}/delete`, - status: 403, - reason: 'a locked thread refuses the own-post delete page', - }); - await page.goto(`${app.baseURL}${lockedDeleteHref}`); - await expect(page.locator('body')).toContainText(/locked or archived/i); - - const archivedThread = await createThread(page, app, board, { - subject: 'archived own-post', - body: 'archived action denied', - }); - const archivedPostId = await visiblePostId(page, '.post.op'); - const archivedEditHref = await page.locator(`#p${archivedPostId} .self-action-controls .edit-btn`).getAttribute('href'); - expect(archivedEditHref).toBeTruthy(); - setThreadFixtureState(app, archivedThread, { archived: true }); - expectHttpError({ - method: 'GET', - path: `/${board}/post/${archivedPostId}/edit`, - status: 403, - reason: 'an archived thread refuses the own-post edit page', - }); - await page.goto(`${app.baseURL}${archivedEditHref}`); - await expect(page.locator('body')).toContainText(/locked or archived/i); - - const expiredThread = await createThread(page, app, board, { - subject: 'expired own-post', - body: 'expired action denied', - }); - const expiredPostId = await visiblePostId(page, '.post.op'); - setPostFixtureCreatedAt(app, expiredPostId, Math.floor(Date.now() / 1000) - 120); - expectHttpError({ - method: 'GET', - path: `/${board}/post/${expiredPostId}/edit`, - status: 403, - reason: 'an expired edit window refuses the own-post edit page', - }); - await page.goto(`${app.baseURL}/${board}/post/${expiredPostId}/edit`); - await expect(page.locator('body')).toContainText(/edit window|closed|forbidden/i); - expectHttpError({ - method: 'GET', - path: `/${board}/post/${expiredPostId}/delete`, - status: 403, - reason: 'an expired self-delete window refuses the own-post delete page', - }); - await page.goto(`${app.baseURL}/${board}/post/${expiredPostId}/delete`); - await expect(page.locator('body')).toContainText(/self-delete window|closed|forbidden/i); - - const deletedThread = await createThread(page, app, board, { - subject: 'deleted own-post', - body: 'deleted action denied', - }); - const deletedPostId = await visiblePostId(page, '.post.op'); - const deletedEditHref = await page.locator(`#p${deletedPostId} .self-action-controls .edit-btn`).getAttribute('href'); - expect(deletedEditHref).toBeTruthy(); - sqliteExec(app, [ - `DELETE FROM post_submissions WHERE thread_id = ${deletedThread};`, - `DELETE FROM posts WHERE thread_id = ${deletedThread};`, - `DELETE FROM threads WHERE id = ${deletedThread};`, - ].join('\n')); - const deletedEdit = await page.request.get(`${app.baseURL}${deletedEditHref}`, { maxRedirects: 0 }); - expect([404, 410]).toContain(deletedEdit.status()); - await expectSafeResponse(deletedEdit); - }); -}); - -async function visiblePostId(page: Page, selector: string): Promise { - const id = await page.locator(selector).first().getAttribute('id'); - const numeric = Number(id?.replace(/^p/, '')); - expect(Number.isInteger(numeric) && numeric > 0, `post id for ${selector}`).toBe(true); - return numeric; -} diff --git a/tests/e2e/public-catalog-polish.spec.ts b/tests/e2e/public-catalog-polish.spec.ts deleted file mode 100644 index 12d8fb96..00000000 --- a/tests/e2e/public-catalog-polish.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { - createThreadViaRequest, - createReplyViaRequest, - expect, - isNoJsProject, - setThreadFixtureState, - sqliteExec, - sqliteQuery, - test, -} from './helpers'; - -test('catalog last reply includes sage replies without changing bump order', async ({ page, app }, testInfo) => { - test.skip(isNoJsProject(testInfo), 'Catalog sorting is an existing JavaScript enhancement with a native bump-order fallback'); - const old = await createThreadViaRequest(page, app, 'pub', { subject: 'Older discussion with sage reply' }); - const recent = await createThreadViaRequest(page, app, 'pub', { subject: 'Recently bumped discussion' }); - const now = Math.floor(Date.now() / 1000); - setThreadFixtureState(app, old, { createdAt: now - 20, bumpedAt: now - 20 }); - setThreadFixtureState(app, recent, { createdAt: now - 10, bumpedAt: now - 10 }); - sqliteExec(app, `UPDATE posts SET created_at = ${now - 20} WHERE thread_id = ${old}; UPDATE posts SET created_at = ${now - 10} WHERE thread_id = ${recent};`); - await page.goto(`${app.baseURL}/pub/thread/${old}`); - await page.locator('[data-action="toggle-post-form"]').first().click(); - const form = page.locator(`form[action="/pub/thread/${old}"]`); - await form.locator('textarea[name="body"]').fill('Latest reply deliberately uses sage'); - await form.locator('input[name="sage"]').check(); - await form.locator('button[type="submit"]').click(); - await expect.poll(() => Number(sqliteQuery(app, `SELECT reply_count FROM threads WHERE id = ${old};`))).toBe(1); - expect(Number(sqliteQuery(app, `SELECT bumped_at FROM threads WHERE id = ${old};`))).toBe(now - 20); - await page.goto(`${app.baseURL}/pub/catalog`); - const firstLink = page.locator('#catalog-grid .catalog-item').first().locator('.catalog-card-link').first(); - await page.locator('#catalog-sort').selectOption('bump'); - await expect(firstLink).toHaveAttribute('href', `/pub/thread/${recent}`); - await page.locator('#catalog-sort').selectOption('last_reply'); - const directory = path.resolve(process.env.RUSTCHAN_AUDIT_OUTPUT ?? 'output/playwright/public-polish/catalog', 'screenshots'); - await fs.mkdir(directory, { recursive: true }); - await page.screenshot({ path: path.join(directory, `${testInfo.project.name}-catalog-last-reply.png`), fullPage: true }); - await expect(firstLink).toHaveAttribute('href', `/pub/thread/${old}`); - await page.reload(); - await expect(page.locator('#catalog-sort')).toHaveValue('last_reply'); - await expect(firstLink).toHaveAttribute('href', `/pub/thread/${old}`); - await page.locator('#catalog-sort').selectOption('replies'); - await expect(firstLink).toHaveAttribute('href', `/pub/thread/${old}`); - await page.locator('#catalog-sort').selectOption('created'); - await expect(firstLink).toHaveAttribute('href', `/pub/thread/${recent}`); -}); - -test('search pagination preserves a Unicode query and stable results across browser Back', async ({ page, app }, testInfo) => { - const query = 'café 漢字'; - const thread = await createThreadViaRequest(page, app, 'pub', { body: `${query} page marker 0` }); - for (let index = 1; index < 23; index += 1) { - await createReplyViaRequest(page, app, 'pub', thread, `${query} page marker ${index}`); - } - await page.goto(`${app.baseURL}/pub/search`); - await page.locator('#board-search-input').fill(query); - await page.locator('.board-search-form button[type="submit"]').click(); - await expect(page.locator('.post')).toHaveCount(20); - await expect(page.locator('.board-search-summary-results')).toHaveText('23 results'); - const firstPageIds = await page.locator('.post').evaluateAll(elements => elements.map(element => element.id)); - const expectedIds = sqliteQuery(app, `SELECT id FROM posts WHERE thread_id = ${thread} ORDER BY created_at DESC, id DESC;`).split('\n').map(id => `p${id}`); - expect(firstPageIds).toEqual(expectedIds.slice(0, 20)); - await page.locator('.pagination a').filter({ hasText: '[next]' }).click(); - await expect(page.locator('#board-search-input')).toHaveValue(query); - const secondURL = new URL(page.url()); - expect(secondURL.searchParams.get('q')).toBe(query); - expect(secondURL.searchParams.get('page')).toBe('2'); - await expect(page.locator('.post')).toHaveCount(3); - const secondPageIds = await page.locator('.post').evaluateAll(elements => elements.map(element => element.id)); - expect(secondPageIds).toEqual(expectedIds.slice(20)); - expect(new Set([...firstPageIds, ...secondPageIds]).size).toBe(23); - await page.locator('.post-num').first().click(); - await expect(page).toHaveURL(new RegExp(`/pub/thread/${thread}#${secondPageIds[0]}$`)); - await page.goBack(); - await expect(page.locator('#board-search-input')).toHaveValue(query); - expect(new URL(page.url()).searchParams.get('page')).toBe('2'); - await expect(page.locator('.post')).toHaveCount(3); - const directory = path.resolve(process.env.RUSTCHAN_AUDIT_OUTPUT ?? 'output/playwright/public-polish/catalog', 'screenshots'); - await fs.mkdir(directory, { recursive: true }); - await page.screenshot({ path: path.join(directory, `${testInfo.project.name}-search-page-restored.png`), fullPage: true }); -}); diff --git a/tests/e2e/public-polish-native.spec.ts b/tests/e2e/public-polish-native.spec.ts deleted file mode 100644 index 8c3efa25..00000000 --- a/tests/e2e/public-polish-native.spec.ts +++ /dev/null @@ -1,244 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { expectHttpError, type Page, type TestInfo } from './diagnostics'; -import { createThread, expect, setBoardFixtureSettings, setThreadFixtureState, sqliteExec, sqliteQuery, test } from './helpers'; - -async function captureNativeError(page: Page, testInfo: TestInfo, name: string): Promise { - const screenshots = path.resolve('output/playwright/public-polish'); - await fs.mkdir(screenshots, { recursive: true }); - await page.screenshot({ path: path.join(screenshots, `${process.env.PUBLIC_POLISH_STAGE ?? 'after'}-native-${name}-${testInfo.project.name}.png`), fullPage: true }); -} - -test.describe('public posting recovery with native forms', () => { - test.use({ javaScriptEnabled: false }); - - test('CAPTCHA rejection preserves poll choices and explains attachment reselection', async ({ page, app }, testInfo) => { - setBoardFixtureSettings(app, 'img', { allowCaptcha: true, postCooldownSecs: 0 }); - await page.goto(`${app.baseURL}/img`); - const form = page.locator('form[action="/img"]').first(); - await form.locator('[name="name"]').fill('匿名 '); - await form.locator('[name="subject"]').fill('A poll & a recoverable draft'); - await form.locator('[name="body"]').fill('>>123\n> A nested quote\nA draft that must survive rejection.'); - await form.locator('details.poll-creator > summary').click(); - await form.locator('[name="poll_question"]').fill('Which & why?'); - await form.locator('[name="poll_option"]').nth(0).fill('日本語 &