diff --git a/.gitignore b/.gitignore index 72b4286..a230432 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ server/priv/static/ # Lexicon codecs generated by codegen (run `make gen`) shared/src/at_record/gen/ + +# e2e: playwright artifacts + the devnet's pinned external clones/secrets +e2e/test-results/ +e2e/playwright-report/ +e2e/devnet/jetstream/ +e2e/devnet/microcosm-rs/ +e2e/devnet/.env diff --git a/Makefile b/Makefile index fd7b9a8..c309776 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ SHELL := bash GLEAM_DIRS := shared/src shared/test server/src server/test web/src web/test -.PHONY: help gen build dev run test format check clean docker-build up down logs +.PHONY: help gen build dev run test format check clean docker-build up down logs \ + e2e-setup e2e-up e2e-down e2e help: ## list available targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ @@ -54,3 +55,24 @@ down: ## stop the docker server logs: ## follow the docker server logs docker compose logs -f + +e2e-setup: ## clone the devnet's pinned external deps + generate its secrets (run once) + cd e2e/devnet && ./setup.sh + cd e2e && npm install && npx playwright install --with-deps chromium + +e2e-up: ## fresh devnet: wipe state, boot, wait healthy, seed the fixed test accounts + cd e2e/devnet && docker compose down -v + cd e2e/devnet && docker compose up -d --build + @echo "waiting for the devnet PDS to become healthy..." + @for i in $$(seq 1 60); do \ + st=$$(docker inspect -f '{{.State.Health.Status}}' at-record-devnet-pds-1 2>/dev/null); \ + [ "$$st" = "healthy" ] && break; \ + sleep 2; \ + done + cd e2e/devnet && ./create-test-accounts.sh + +e2e-down: ## tear down the devnet and wipe its state + cd e2e/devnet && docker compose down -v + +e2e: e2e-up ## full cycle: fresh devnet + run the e2e suite against it (at-record's own dev server must already be running — see e2e/README.md) + cd e2e && npm test diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..e51719b --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,56 @@ +# at-record e2e + +Real end-to-end tests against a fully local, deterministic atproto network +(a PDS, jetstream, Constellation, Slingshot, Caddy for TLS on the test +handles) — not the public network, so no indexing lag and no dependency on +real accounts. + +These exist for what the unit tests structurally can't cover: the real +OAuth wire flow (PAR/PKCE/DPoP against a real PDS), and cross-account +behavior that depends on real Constellation indexing (adoption, the +edit-inbox). Business logic itself is already covered fast and in isolation +by `server/test/promotion_test.gleam` and friends via injected `Deps` +stubs — don't duplicate that here. + +## One-time setup + +```sh +make e2e-setup # clones jetstream + microcosm-rs at pinned commits, generates devnet secrets +``` + +## Running + +Three things need to be running: + +1. The devnet: `make e2e-up` (wipes any previous devnet state for a clean + slate, boots fresh, waits for the PDS to be healthy, seeds the fixed + `alice.test`/`bob.test` accounts). +2. at-record's own dev server, pointed at the devnet: + ```sh + SLINGSHOT_URL=http://localhost:6790 CONSTELLATION_URL=http://localhost:6789 ./scripts/dev.sh + ``` +3. The test suite: `make e2e` (this also does step 1 for you — running it + standalone is only useful if you want to poke at the devnet by hand + first). + +`make e2e-down` tears the devnet down and wipes its volumes when you're done. + +## Why fixed test handles, not generated per run + +Caddy's TLS termination and the PDS's network alias are declared per-handle +in `devnet/docker-compose.yml` and `devnet/Caddyfile` (atproto's +bidirectional handle verification needs a real, if locally-CA-signed, TLS +endpoint for each handle — see the devnet's own README for why). Adding a +handle means declaring it in both places, not just calling +`create-test-accounts.sh` with a new name. `make e2e-up` wipes and reseeds +the same two accounts fresh each run instead, which is enough isolation for +now — revisit if tests start needing more than two accounts at once. + +## Pinned dependencies + +`devnet/setup.sh` clones `bluesky-social/jetstream` and +`microcosm.blue/microcosm-rs` at specific commits, not `main`. Both are +under active, fast-moving development with no published container image +matching their current `main` — pinning is what keeps this reproducible. +Re-pin deliberately (edit the SHAs in `setup.sh`, `rm -rf` the old clones, +re-run `make e2e-setup`) when there's a reason to, not by accident. diff --git a/e2e/devnet/.gitignore b/e2e/devnet/.gitignore new file mode 100644 index 0000000..032cbdd --- /dev/null +++ b/e2e/devnet/.gitignore @@ -0,0 +1,3 @@ +.env +jetstream/ +microcosm-rs/ diff --git a/e2e/devnet/Caddyfile b/e2e/devnet/Caddyfile new file mode 100644 index 0000000..eb1b78d --- /dev/null +++ b/e2e/devnet/Caddyfile @@ -0,0 +1,9 @@ +{ + # Caddy auto-generates and manages its own local root CA for `tls + # internal` — no mkcert or other external tooling needed. +} + +alice.test, bob.test { + tls internal + reverse_proxy pds:3000 +} diff --git a/e2e/devnet/create-test-accounts.sh b/e2e/devnet/create-test-accounts.sh new file mode 100755 index 0000000..f0d72c5 --- /dev/null +++ b/e2e/devnet/create-test-accounts.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Creates the fixed pool of e2e test accounts. Handles are well-known (not +# generated per run) because Caddy's TLS termination and the PDS network +# alias are declared per-handle in docker-compose.yml/Caddyfile — adding an +# account means adding it there too, not just running this script. +set -euo pipefail + +PASSWORD="${TEST_ACCOUNT_PASSWORD:-e2e-test-password}" + +create() { + local handle="$1" + echo "creating $handle..." + docker exec at-record-devnet-pds-1 goat pds admin account create \ + --handle "$handle" --password "$PASSWORD" --email "${handle%.test}@example.com" \ + || echo " (already exists, or creation failed — check above)" +} + +create alice.test +create bob.test diff --git a/e2e/devnet/docker-compose.yml b/e2e/devnet/docker-compose.yml new file mode 100644 index 0000000..3db6757 --- /dev/null +++ b/e2e/devnet/docker-compose.yml @@ -0,0 +1,167 @@ +name: at-record-devnet + +# A tranquil (isolated, deterministic) local atproto stack for testing +# cross-account at-record features that depend on Constellation indexing: +# adoption (adopt-or-mint), the edit-inbox. Point at-record's +# SLINGSHOT_URL/CONSTELLATION_URL at this compose's slingshot/constellation +# ports instead of the public services. See README.md. +# +# Slingshot is required even with one PDS hosting every test account: our +# OAuth login resolves handles via Slingshot's bespoke resolveMiniDoc +# endpoint, which a raw PDS doesn't implement (a PDS only serves the +# standard com.atproto.* surface for its own repos). + +services: + pds: + image: ghcr.io/bluesky-social/pds:0.4 + ports: + - "2584:3000" + # Also on its "native" port: PDS_HOSTNAME=localhost + PDS_PORT=3000 + # get baked into each account's DID doc service endpoint + # (http://localhost:3000) at creation time, and at-record's server + # runs on the host (not in this compose network) — it needs + # localhost:3000 to actually reach the PDS to complete OAuth. + - "3000:3000" + volumes: + - pds-data:/pds + environment: + PDS_HOSTNAME: localhost + PDS_PORT: "3000" + # Lets the plain-http local resource URL past the https-only check, and + # relaxes SSRF protection so the PDS can call other containers by + # service name (jetstream, constellation) instead of a public host. + PDS_DEV_MODE: "true" + PDS_DATA_DIRECTORY: /pds + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks + PDS_BLOB_UPLOAD_LIMIT: "104857600" + PDS_DID_PLC_URL: https://plc.directory + PDS_BSKY_APP_VIEW_URL: https://api.bsky.app + PDS_BSKY_APP_VIEW_DID: did:web:api.bsky.app + PDS_REPORT_SERVICE_URL: https://mod.bsky.app + PDS_REPORT_SERVICE_DID: did:plc:ar7c4by46qjdydhdevvrndac + # No crawlers: jetstream below pulls straight from this PDS, so there's + # no relay to notify. + PDS_CRAWLERS: "" + PDS_INVITE_REQUIRED: "false" + PDS_RATE_LIMITS_ENABLED: "false" + LOG_ENABLED: "true" + PDS_JWT_SECRET: ${PDS_JWT_SECRET} + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} + healthcheck: + # com.atproto.server.describeServer is a required, unauthenticated + # endpoint on every PDS — a safer bet than guessing at a health path. + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/xrpc/com.atproto.server.describeServer').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 5s + retries: 20 + + # jetstream's image runs as a non-root distroless user; named volumes are + # created root-owned, so this one-shot container fixes ownership before + # jetstream tries to mkdir into it. + jetstream-data-init: + image: busybox + command: ["chown", "-R", "65532:65532", "/data"] + volumes: + - jetstream-data:/data + + jetstream: + build: ./jetstream + depends_on: + pds: + condition: service_healthy + jetstream-data-init: + condition: service_completed_successfully + ports: + - "8090:8080" + volumes: + - jetstream-data:/data + environment: + # A single PDS serves listRepos/subscribeRepos/getRepo for its own + # hosted accounts identically to how a relay would — no separate relay + # needed for a one-PDS devnet. + JETSTREAM_RELAY_URL: http://pds:3000 + JETSTREAM_DATA_DIR: /data + JETSTREAM_LOG_LEVEL: info + + constellation: + build: + context: ./microcosm-rs + dockerfile: constellation/Dockerfile + depends_on: + - jetstream + ports: + - "6789:6789" + command: + - "--jetstream=ws://jetstream:8080/subscribe" + - "--backend=memory" + - "--bind=0.0.0.0:6789" + + # PDS_HOSTNAME=localhost already makes the PDS default its + # serviceHandleDomains to [".test"], so it self-verifies any .test + # handle it hosts — but it only serves plain HTTP internally, and atproto's + # bidirectional handle check fetches https:///.well-known/atproto-did. + # Caddy terminates real (locally-CA-signed) TLS for the test handles and + # proxies to the PDS over plain HTTP, closing that gap without touching + # real DNS or a real cert. + caddy: + image: caddy:2 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + networks: + default: + aliases: + - alice.test + - bob.test + + # Caddy's `tls internal` CA is generated into its own data volume; export + # the root cert so slingshot (below) can be told to trust it. + caddy-ca-export: + image: caddy:2 + depends_on: + - caddy + volumes: + - caddy-data:/data:ro + - caddy-ca:/export + entrypoint: ["sh", "-c"] + command: + - | + for i in $(seq 1 30); do + f=/data/caddy/pki/authorities/local/root.crt + [ -f "$$f" ] && cp "$$f" /export/local-ca.crt && exit 0 + sleep 1 + done + echo "caddy never generated its local CA" >&2 + exit 1 + + slingshot: + build: + context: ./microcosm-rs + dockerfile: slingshot/Dockerfile + depends_on: + caddy-ca-export: + condition: service_completed_successfully + jetstream: + condition: service_started + ports: + - "6790:8080" + volumes: + - slingshot-cache:/cache + - caddy-ca:/ca:ro + # slingshot's image is debian-slim (has update-ca-certificates, unlike + # jetstream/constellation's distroless base) — trust Caddy's local CA + # before starting the actual binary. + entrypoint: ["sh", "-c"] + command: + - | + cp /ca/local-ca.crt /usr/local/share/ca-certificates/devnet-local-ca.crt + update-ca-certificates + exec slingshot --jetstream=ws://jetstream:8080/subscribe --cache-dir=/cache --bind=0.0.0.0:8080 + +volumes: + pds-data: + jetstream-data: + slingshot-cache: + caddy-data: + caddy-ca: diff --git a/e2e/devnet/gen-secrets.sh b/e2e/devnet/gen-secrets.sh new file mode 100755 index 0000000..5f302e0 --- /dev/null +++ b/e2e/devnet/gen-secrets.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Generates fresh secrets into .env (gitignored). Re-run to rotate; existing +# accounts on the PDS volume become unreachable if you rotate JWT_SECRET or +# the PLC rotation key after accounts already exist, so only run this once +# per devnet lifetime (delete the pds-data volume too if you do rotate). +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +if [ -f .env ]; then + echo ".env already exists; delete it first if you want to regenerate." >&2 + exit 1 +fi + +jwt_secret=$(openssl rand --hex 16) +admin_password=$(openssl rand --hex 16) +plc_rotation_key=$(openssl ecparam --name secp256k1 --genkey --noout --outform DER \ + | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32) + +cat > .env <=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..44ae38d --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "at-record-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.48.0" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..bcf8e21 --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,18 @@ +import { defineConfig, devices } from "@playwright/test"; + +// The devnet + at-record's own dev server are both started separately (see +// README) — tests assume they're already up, rather than managing them via +// webServer, since the devnet's docker compose lifecycle is heavier than +// Playwright's own server-start convention fits well. +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + retries: 0, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:8080", + viewport: { width: 390, height: 844 }, + trace: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/e2e/tests/adoption.spec.js b/e2e/tests/adoption.spec.js new file mode 100644 index 0000000..6849a49 --- /dev/null +++ b/e2e/tests/adoption.spec.js @@ -0,0 +1,51 @@ +import { test, expect } from "@playwright/test"; +import { loginAs } from "../fixtures/login.js"; + +const PASSWORD = process.env.TEST_ACCOUNT_PASSWORD || "e2e-test-password"; + +async function addFirstSearchResult(page, query) { + await page.goto("/add"); + await page.fill(".autocomplete input", query); + await page.waitForSelector(".suggestions li", { timeout: 15_000 }); + await page.click(".suggestions li >> nth=0"); + // The write itself round-trips through Constellation + Slingshot to check + // for an adoption candidate before minting/adopting — a fixed short delay + // isn't reliable for that, so wait for the actual POST to resolve. + const saved = page.waitForResponse( + (r) => r.url().endsWith("/api/shelf") && r.request().method() === "POST", + { timeout: 15_000 }, + ); + await page.click("text=SAVE TO CRATE"); + await saved; +} + +// The core catalog-authority guarantee: adding a release someone else in +// the network already published adopts their record via a verified +// Constellation backlink, instead of minting a duplicate. +test("bob adopts alice's already-published release instead of minting a duplicate", async ({ + browser, +}) => { + const alice = await browser.newContext(); + const alicePage = await alice.newPage(); + await loginAs(alicePage, "alice.test", PASSWORD); + + await addFirstSearchResult(alicePage, "aphex twin selected ambient works"); + + // Constellation indexes off the local jetstream firehose — not instant. + await alicePage.waitForTimeout(3000); + + const bob = await browser.newContext(); + const bobPage = await bob.newPage(); + await loginAs(bobPage, "bob.test", PASSWORD); + await addFirstSearchResult(bobPage, "aphex twin selected ambient works"); + + await bobPage.goto("/"); + await bobPage.click(".cover-card"); + + await expect(bobPage.locator(".titleblock__via")).toContainText( + "via @alice.test", + ); + + await alice.close(); + await bob.close(); +});