diff --git a/Makefile b/Makefile index 2e230ec..aea957b 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,18 @@ -.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install +.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install e2e-fresh +# Two-phase: run the Gitea seed to completion FIRST so it writes the bot token / +# OAuth creds into generated/.env.tier1.generated, THEN create the backend/web — +# compose snapshots env_file at container-create time, so the backend must be +# created after the seed has populated it. The touch seeds an empty placeholder +# for compose's up-front env_file existence check on a clean checkout. tier1-up: + touch testing/generated/.env.tier1.generated + docker compose -f testing/docker-compose.yml up --build -d gitea-seed + docker compose -f testing/docker-compose.yml wait gitea-seed docker compose -f testing/docker-compose.yml up --build -d tier1-down: + touch testing/generated/.env.tier1.generated docker compose -f testing/docker-compose.yml down -v tier1-logs: @@ -17,3 +26,8 @@ e2e-install: e2e: cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e + +# Canonical run: the metadata specs mutate the seeded corpus (edit/bulk write +# real commits), so they assume a freshly-seeded stack. This brings the stack +# down, back up (re-seeds), and runs the suite once — the shape CI uses. +e2e-fresh: tier1-down tier1-up e2e diff --git a/e2e/lib/auth.js b/e2e/lib/auth.js new file mode 100644 index 0000000..eecdabc --- /dev/null +++ b/e2e/lib/auth.js @@ -0,0 +1,17 @@ +import { waitForLatestOtc, clearMailpit } from './mailpit.js' + +// Sign in through the §6.2 OTC path, leaving the rfc_session cookie on the +// page's browser context (page.request shares the page context's cookie jar, +// so a subsequent page.goto is authenticated). The Tier-1 seed pre-provisions +// `e2e-owner@example.test` as a granted deployment owner, so signing in as that +// address yields write access to the metadata edit/bulk paths (SLICE-4/5). +export async function signIn(page, email) { + await clearMailpit() + const req = await page.request.post('/auth/otc/request', { data: { email } }) + if (!req.ok()) throw new Error(`otc request failed: ${req.status()}`) + const code = await waitForLatestOtc(email) + const verify = await page.request.post('/auth/otc/verify', { data: { email, code } }) + if (!verify.ok()) throw new Error(`otc verify failed: ${verify.status()}`) +} + +export const OWNER_EMAIL = 'e2e-owner@example.test' diff --git a/e2e/lib/ui.js b/e2e/lib/ui.js new file mode 100644 index 0000000..81c2659 --- /dev/null +++ b/e2e/lib/ui.js @@ -0,0 +1,10 @@ +// Dismiss the cookie-consent banner if it's showing. It is fixed to the bottom +// of the viewport and intercepts pointer events over the catalog footer (where +// row-select checkboxes live), so tests that click there must clear it first. +export async function dismissCookies(page) { + const banner = page.locator('.cookie-consent-banner') + if (await banner.count()) { + await page.getByRole('button', { name: 'Save choice' }).click().catch(() => {}) + await banner.waitFor({ state: 'hidden' }).catch(() => {}) + } +} diff --git a/e2e/metadata.spec.js b/e2e/metadata.spec.js new file mode 100644 index 0000000..d465225 --- /dev/null +++ b/e2e/metadata.spec.js @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test' +import { signIn, OWNER_EMAIL } from './lib/auth.js' +import { dismissCookies } from './lib/ui.js' + +// §22.4a Configurable Collection Metadata — end-to-end browser coverage for the +// three UI slices, against the Tier-1 stack's faceted `bdd` collection (seeded +// in testing/seed-gitea.sh with a fields: schema of priority(enum) + tags, and +// three entries: checkout-guest [P0], checkout-returning [P1], search-facets [P0]). +// +// Tests run in order against a freshly-seeded stack: SLICE-3 reads first, then +// SLICE-4 edits `checkout-returning`, then SLICE-5 bulk-edits the two P0 entries +// (distinct rows — no cross-test interference within one run). + +const BDD = '/p/ohm/c/bdd' + +// SLICE-3 (PUC-3) — faceted left-pane filtering, anonymous/read-only. +test('SLICE-3: faceted filter narrows the catalog by Priority', async ({ page }) => { + await page.goto(BDD) + await dismissCookies(page) + const catalog = page.locator('aside.catalog') + + // All three entries are listed initially. + await expect(catalog.getByText('Guest checkout')).toBeVisible() + await expect(catalog.getByText('Returning-customer checkout')).toBeVisible() + await expect(catalog.getByText('Faceted search')).toBeVisible() + + // The Priority facet renders with the seeded P0 count (2 entries). + const p0 = catalog.locator('.facet-value', { hasText: 'P0' }) + await expect(p0.locator('.facet-count')).toHaveText('2') + + // Selecting P0 re-fetches server-side and drops the lone P1 entry. + await p0.getByRole('checkbox').check() + await expect(catalog.getByText('Returning-customer checkout')).toHaveCount(0) + await expect(catalog.getByText('Guest checkout')).toBeVisible() + await expect(catalog.getByText('Faceted search')).toBeVisible() +}) + +// SLICE-4 (PUC-1) — single-entry metadata edit via the detail panel (authed). +test('SLICE-4: edit one entry\'s priority from the detail panel', async ({ page }) => { + await signIn(page, OWNER_EMAIL) + await page.goto(`${BDD}/e/checkout-returning`) + await dismissCookies(page) + + const panel = page.locator('.metadata-fields-panel') + await expect(panel).toBeVisible() + // Seeded at P1; change to P2 and save (direct sidecar commit, D7). + await expect(panel.locator('#mf-priority')).toHaveValue('P1') + await panel.locator('#mf-priority').selectOption('P2') + await panel.getByRole('button', { name: 'Save' }).click() + await expect(panel.getByText('Saved')).toBeVisible() + + // Persisted across a reload (read back from the committed sidecar). + await page.reload() + await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue('P2') +}) + +// SLICE-5 (PUC-2) — multi-select + bulk action bar, one commit (authed). +test('SLICE-5: bulk-set priority on multiple selected entries', async ({ page }) => { + await signIn(page, OWNER_EMAIL) + await page.goto(BDD) + await dismissCookies(page) + const catalog = page.locator('aside.catalog') + await expect(catalog.getByText('Guest checkout')).toBeVisible() + + // Select the two P0 entries. + await catalog.getByLabel('select Guest checkout').check() + await catalog.getByLabel('select Faceted search').check() + + // The sticky bulk bar appears with the selected count. + const bar = page.locator('.bulk-action-bar') + await expect(bar.getByText('2 selected')).toBeVisible() + + // Set priority P1 across both → one commit; a toast reports the result. + await bar.getByLabel('Set Priority').selectOption('P1') + await expect(page.getByText(/2 updated/)).toBeVisible() + + // The change is reflected server-side: the Priority P1 facet now counts the + // two newly-updated entries plus the pre-existing P1 (checkout-returning was + // P1 at seed; SLICE-4 may have moved it — assert at least the two we set). + await expect(catalog.locator('.facet-value', { hasText: 'P1' }).locator('.facet-count')) + .not.toHaveText('0') +}) diff --git a/testing/.env.tier1 b/testing/.env.tier1 index 3b4b4a1..51ead86 100644 --- a/testing/.env.tier1 +++ b/testing/.env.tier1 @@ -3,7 +3,8 @@ GITEA_BOT_USER=rfc-bot GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER GITEA_ORG=wiggleverse META_REPO=ohm-content -REGISTRY_REPO= +REGISTRY_REPO=rfc-registry +DEFAULT_PROJECT_ID=ohm OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER APP_URL=http://localhost:8080 @@ -19,3 +20,11 @@ EMAIL_FROM=rfc@example.test EMAIL_FROM_NAME=RFC Tier1 EMAIL_ENABLED=true TURNSTILE_REQUIRED=false +# Tier-1/e2e: disable the per-email OTC request cooldown so a test can sign the +# same account in more than once across specs without 429s. +OTC_REQUEST_COOLDOWN_SECONDS=0 +# Tier-1/e2e drives the auth endpoints repeatedly from one IP; lift the per-IP +# sliding-window budgets well above a single suite run (prod leaves these unset +# and keeps the secure defaults). +RATELIMIT_OTC_REQUEST_MAX=1000 +RATELIMIT_VERIFY_MAX=1000 diff --git a/testing/docker-compose.yml b/testing/docker-compose.yml index f329435..d5bc3b5 100644 --- a/testing/docker-compose.yml +++ b/testing/docker-compose.yml @@ -57,6 +57,7 @@ services: restart: "no" backend: + image: rfc-tier1-backend build: context: .. dockerfile: testing/backend.Dockerfile @@ -74,6 +75,30 @@ services: timeout: 3s retries: 30 + # Insert a granted deployment-owner user keyed by a known e2e email, so the + # OTC sign-in path (which provisions only `pending` contributors) yields an + # owner who can exercise the metadata edit/bulk write paths (SLICE-4/5). The + # owner identity (gitea_login='owner') matches OWNER_GITEA_LOGIN. Idempotent. + backend-seed: + image: rfc-tier1-backend + depends_on: + backend: + condition: service_healthy + volumes: + - backend-data:/data + entrypoint: ["python", "-c"] + command: + - | + import sqlite3 + c = sqlite3.connect("/data/rfc-app.db") + c.execute("""INSERT INTO users + (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state) + SELECT 9001, 'owner', 'e2e-owner@example.test', 'E2E Owner', '', 'owner', 'granted' + WHERE NOT EXISTS (SELECT 1 FROM users WHERE email='e2e-owner@example.test' COLLATE NOCASE)""") + c.commit(); c.close() + print("backend-seed: owner user ready") + restart: "no" + web: build: context: .. diff --git a/testing/seed-gitea.sh b/testing/seed-gitea.sh index 38db4f2..d74ac25 100755 --- a/testing/seed-gitea.sh +++ b/testing/seed-gitea.sh @@ -1,14 +1,21 @@ #!/usr/bin/env sh set -eu +# Tier-1 seed (§22-current). Stands up Gitea content + registry so the current +# three-tier app boots, plus a faceted **named collection** (a `.collection.yaml` +# with a `fields:` schema + entries carrying metadata) so the §22.4a metadata +# UI — faceted filter (SLICE-3), edit panel (SLICE-4), bulk bar (SLICE-5) — can +# be exercised end-to-end in a real browser. + GITEA="${GITEA_URL:-http://gitea:3000}" ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}" ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}" -ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.test}" ORG="${GITEA_ORG:-wiggleverse}" BOT_USER="${GITEA_BOT_USER:-rfc-bot}" BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}" CONTENT_REPO="${META_REPO:-ohm-content}" +REGISTRY_REPO="${REGISTRY_REPO:-rfc-registry}" +DEFAULT_PROJECT_ID="${DEFAULT_PROJECT_ID:-ohm}" APP_URL="${APP_URL:-http://localhost:8080}" WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}" OUT="${SEED_OUT:-/seed/.env.tier1.generated}" @@ -22,6 +29,21 @@ done auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; } +# Idempotency guard: if a prior run already wrote a bot token that still works +# against THIS gitea (the registry is readable), the stack is already seeded — +# skip entirely. This makes a second invocation a true no-op, so a later +# dependency-triggered re-run can't delete/remint the token the backend is +# already using (that mismatch 401s the registry mirror). A fresh `down -v` +# brings up a new gitea where the stale token fails, so the seed re-runs. +if [ -f "$OUT" ]; then + EXIST_TOK=$(sed -n 's/^GITEA_BOT_TOKEN=//p' "$OUT") + if [ -n "$EXIST_TOK" ] && curl -sf -H "Authorization: token $EXIST_TOK" \ + "$GITEA/api/v1/repos/$ORG/$REGISTRY_REPO/contents/projects.yaml?ref=main" >/dev/null 2>&1; then + echo "seed: existing token valid and registry present — already seeded, skipping" + exit 0 + fi +fi + echo "seed: ensuring bot user" auth_admin -X POST "$GITEA/api/v1/admin/users" \ -H 'Content-Type: application/json' \ @@ -34,55 +56,129 @@ auth_admin -X POST "$GITEA/api/v1/admin/users" \ -d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \ || echo "seed: owner exists, continuing" -echo "seed: minting bot access token" +echo "seed: minting bot access token (drop any prior 'tier1-bot' first — idempotent)" +curl -s -u "$BOT_USER:$BOT_PASS" -X DELETE "$GITEA/api/v1/users/$BOT_USER/tokens/tier1-bot" >/dev/null 2>&1 || true TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \ -H 'Content-Type: application/json' \ -d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \ | sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p') [ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; } +api() { curl -s -H "Authorization: token $TOKEN" "$@"; } + echo "seed: ensuring org $ORG (owned by bot)" -curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs" \ - -H 'Content-Type: application/json' \ - -d "{\"username\":\"$ORG\"}" || echo "seed: org exists, continuing" +api -X POST "$GITEA/api/v1/orgs" -H 'Content-Type: application/json' \ + -d "{\"username\":\"$ORG\"}" >/dev/null || echo "seed: org exists, continuing" -echo "seed: ensuring content repo $ORG/$CONTENT_REPO" -curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs/$ORG/repos" \ - -H 'Content-Type: application/json' \ - -d "{\"name\":\"$CONTENT_REPO\",\"auto_init\":true,\"default_branch\":\"main\"}" \ - || echo "seed: content repo exists, continuing" +ensure_repo() { + api -X POST "$GITEA/api/v1/orgs/$ORG/repos" -H 'Content-Type: application/json' \ + -d "{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\"}" >/dev/null \ + || echo "seed: repo $1 exists, continuing" +} -echo "seed: seeding one entry under rfcs/ so the catalog is non-empty" -B64=$(printf '%s' '--- +# put_file +put_file() { + _b64=$(printf '%s' "$3" | base64 | tr -d '\n') + api -X POST "$GITEA/api/v1/repos/$ORG/$1/contents/$2" \ + -H 'Content-Type: application/json' \ + -d "{\"message\":\"seed $2\",\"content\":\"$_b64\",\"branch\":\"main\"}" >/dev/null \ + || echo "seed: $1/$2 exists, continuing" +} + +register_webhook() { + api -X POST "$GITEA/api/v1/repos/$ORG/$1/hooks" \ + -H 'Content-Type: application/json' \ + -d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" >/dev/null \ + || echo "seed: webhook on $1 exists, continuing" +} + +echo "seed: ensuring content repo $ORG/$CONTENT_REPO and registry $ORG/$REGISTRY_REPO" +ensure_repo "$CONTENT_REPO" +ensure_repo "$REGISTRY_REPO" + +echo "seed: registry projects.yaml (default project '$DEFAULT_PROJECT_ID')" +put_file "$REGISTRY_REPO" "projects.yaml" "deployment: + name: Tier1 RFC + tagline: Tier-1 end-to-end deployment +projects: + - id: $DEFAULT_PROJECT_ID + name: OHM + type: document + content_repo: $CONTENT_REPO + visibility: public +" + +echo "seed: default-collection entry under rfcs/ (no fields — legacy/document path)" +put_file "$CONTENT_REPO" "rfcs/intro.md" "--- +slug: intro title: Intro -status: graduated +state: active id: RFC-0001 owners: [owner] --- # Intro -Seed entry for Tier-1 e2e. -' | base64 | tr -d '\n') -curl -s -H "Authorization: token $TOKEN" -X POST \ - "$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/contents/rfcs/intro.md" \ - -H 'Content-Type: application/json' \ - -d "{\"message\":\"seed intro\",\"content\":\"$B64\",\"branch\":\"main\"}" \ - || echo "seed: intro.md exists, continuing" +Seed entry for the default (document) collection. +" + +echo "seed: faceted named collection 'bdd' with a fields: schema (§22.4a)" +put_file "$CONTENT_REPO" "bdd/.collection.yaml" "type: bdd +visibility: public +name: BDD Scenarios +fields: + priority: + type: enum + values: [P0, P1, P2] + label: Priority + tags: + type: tags + label: Tags +" + +# Three entries with varied priority/tags so facets have counts and the bulk +# bar has multiple selectable rows. +put_file "$CONTENT_REPO" "bdd/rfcs/checkout-guest.md" "--- +slug: checkout-guest +title: Guest checkout +state: active +priority: P0 +tags: [checkout, payments] +--- + +Guest checkout scenario. +" +put_file "$CONTENT_REPO" "bdd/rfcs/checkout-returning.md" "--- +slug: checkout-returning +title: Returning-customer checkout +state: active +priority: P1 +tags: [checkout] +--- + +Returning-customer checkout scenario. +" +put_file "$CONTENT_REPO" "bdd/rfcs/search-facets.md" "--- +slug: search-facets +title: Faceted search +state: active +priority: P0 +tags: [search] +--- + +Faceted search scenario. +" echo "seed: registering OAuth application" -OAUTH_JSON=$(curl -sf -u "$ADMIN_USER:$ADMIN_PASS" -X POST "$GITEA/api/v1/user/applications/oauth2" \ +OAUTH_JSON=$(auth_admin -X POST "$GITEA/api/v1/user/applications/oauth2" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}") CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p') CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p') -echo "seed: registering webhook on content repo -> backend" -curl -s -H "Authorization: token $TOKEN" -X POST \ - "$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/hooks" \ - -H 'Content-Type: application/json' \ - -d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" \ - || echo "seed: webhook exists, continuing" +echo "seed: registering webhooks (content + registry) -> backend" +register_webhook "$CONTENT_REPO" +register_webhook "$REGISTRY_REPO" echo "seed: writing generated env to $OUT" cat > "$OUT" <<EOF