From ba37da927abc30d8a900b4bc3a72221452d8e9e6 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 8 Jun 2026 06:45:01 -0700 Subject: [PATCH] =?UTF-8?q?fix(=C2=A722.4a):=20scope=20RFCView=20entry-det?= =?UTF-8?q?ail=20fetch=20to=20its=20collection=20(v0.52.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §9 deployed-environment E2E harness (0.52.0), run against a PPE host with per-collection-isolated content, surfaced a latent multi-collection bug: RFCView computed the collection id from the route but called getRFC(pid, slug) without it, so a named-collection entry was always fetched via the project default-collection route — which 404s for an entry that exists only in a named collection ("Error: Not found"; metadata panel absent). Local/Tier-1 stacks masked it (same slug also reachable via the default collection). Thread cid through all three getRFC call sites; re-run the load effect on collection change. Harness/test-infra (not in the deployed artifact): - e2e: pre-record cookie consent via addInitScript (lib/fixtures.js) so the bottom-fixed consent banner can't intercept catalog row-select clicks on the slower deployed edge. - testing/seed-ppe.sh: fail loudly on any non-2xx Gitea response (a swallowed 403 org-repo create had reached the deploy as a 502). - testing/ppe-deploy-and-test.sh: seed via the Keychain admin token (write:organization needed to create the PPE repos); store the E2E secret newline-free; read EXPECT_VERSION from VERSION. Patch bump 0.52.0 → 0.52.1; CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 +++++++++++++++++++ VERSION | 2 +- e2e/lib/fixtures.js | 40 +++++++++++++++++++++++++++++ e2e/metadata.spec.js | 2 +- frontend/package.json | 2 +- frontend/src/components/RFCView.jsx | 17 ++++++++---- testing/ppe-deploy-and-test.sh | 22 +++++++++++++--- testing/seed-ppe.sh | 36 ++++++++++++++++++-------- 8 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 e2e/lib/fixtures.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ccd8a9..f2fad16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,32 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.52.1 — 2026-06-08 + +**Patch — collection-scoped entry-detail fetch fix (no operator action +required).** + +Caught by the §9 deployed-environment E2E harness (0.52.0) running against +a PPE host whose content is cleanly isolated per collection: + +- **Entry detail in a named collection 404'd ("Error: Not found") and its + metadata panel never rendered.** `RFCView` computed the collection id + from the route but called `getRFC(pid, slug)` without it, so an entry was + always fetched via the project's *default*-collection route + (`/api/projects//rfcs/`). For an entry that lives only in a + named collection that route 404s. The bug was latent since the + multi-collection work — local/Tier-1 stacks masked it because the same + slug was also reachable through the default collection; a deployment with + per-collection-isolated content surfaces it. Fixed: all three `getRFC` + call sites in `RFCView` now pass the collection id (and the load effect + re-runs on collection change). + +Test-only (not in the deployed artifact): the deployed-env E2E harness now +pre-records cookie consent via `addInitScript` so the bottom-fixed consent +banner can't intercept catalog row-select clicks on the slower deployed +edge, and `testing/seed-ppe.sh` fails loudly on any non-2xx Gitea response +(a swallowed 403 had let a missing-repo seed reach the deploy as a 502). + ## 0.52.0 — 2026-06-07 **Minor — deployed-environment E2E harness (new opt-in test-auth surface; diff --git a/VERSION b/VERSION index 4f9b378..d06c2d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.0 +0.52.1 \ No newline at end of file diff --git a/e2e/lib/fixtures.js b/e2e/lib/fixtures.js new file mode 100644 index 0000000..40505bd --- /dev/null +++ b/e2e/lib/fixtures.js @@ -0,0 +1,40 @@ +// Shared Playwright fixtures for the deployed-environment harness. +// +// Pre-record a cookie-consent choice via addInitScript so the §14.5 +// cookie-consent banner NEVER renders. The banner is fixed to the bottom +// of the viewport and intercepts pointer events over the catalog footer +// (the row-select checkboxes SLICE-5 clicks). The previous approach — +// dismiss it after navigation (lib/ui.js dismissCookies) — raced the +// banner's render on the slower deployed edge (PPE): dismissCookies ran +// before the banner mounted, found nothing to remove, and the banner then +// appeared and swallowed the row clicks. Recording consent at +// document-start (before the app's scripts read `hasChosen()`) means the +// banner's `open` state initialises false and it never mounts — no race. +// +// Storage shape mirrors lib/consent.js (LS_KEY 'rfc-app.cookie-consent.v1'; +// a non-null recorded_at == "the user has chosen"). Environment-agnostic: +// the init script runs on whatever origin the test navigates to (PPE or +// the Tier-1 localhost stack). +import { test as base, expect } from '@playwright/test' + +const CONSENT = JSON.stringify({ + essential: true, + analytics: false, + other: false, + recorded_at: '2000-01-01T00:00:00.000Z', +}) + +export const test = base.extend({ + context: async ({ context }, use) => { + await context.addInitScript((value) => { + try { + window.localStorage.setItem('rfc-app.cookie-consent.v1', value) + } catch { + // localStorage unavailable — fall back to lib/ui.js dismissCookies. + } + }, CONSENT) + await use(context) + }, +}) + +export { expect } diff --git a/e2e/metadata.spec.js b/e2e/metadata.spec.js index d465225..b50e4ef 100644 --- a/e2e/metadata.spec.js +++ b/e2e/metadata.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test' +import { test, expect } from './lib/fixtures.js' import { signIn, OWNER_EMAIL } from './lib/auth.js' import { dismissCookies } from './lib/ui.js' diff --git a/frontend/package.json b/frontend/package.json index 53e930a..6844759 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.52.0", + "version": "0.52.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 5600fa6..3d12773 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -131,7 +131,14 @@ export default function RFCView({ viewer }) { const [drawerOpen, setDrawerOpen] = useState(false) useEffect(() => { - getRFC(pid, slug).then(entry => { + // §22.4a: an entry in a NAMED collection must be fetched collection- + // scoped. Omitting `cid` falls back to the project's DEFAULT-collection + // route (/api/projects//rfcs/), which 404s for an entry that + // lives only in a named collection — surfacing as "Error: Not found" and + // a missing metadata panel. (Tier-1 masked this when the same slug was + // also reachable via the default collection; PPE's isolated content + // exposed it.) + getRFC(pid, slug, cid).then(entry => { setEntry(entry) // v0.15.0 — analytics: fire RFC Viewed once per slug load. // We key on the slug param rather than the loaded entry so a @@ -146,7 +153,7 @@ export default function RFCView({ viewer }) { setSelectedModel(def || models?.[0]?.id || '') }) .catch(() => {}) - }, [slug, pid]) + }, [slug, pid, cid]) // §22.4a SLICE-4: load the collection's metadata field schema for the // detail panel. Independent of the entry load; the panel reads the entry's @@ -194,12 +201,12 @@ export default function RFCView({ viewer }) { setActionError(null) try { const res = await unretireRFC(slug) - getRFC(pid, slug).then(setEntry).catch(() => {}) + getRFC(pid, slug, cid).then(setEntry).catch(() => {}) if (res?.state) navigate(entryPath(pid, slug)) } catch (err) { setActionError(err.message) } - }, [slug, navigate, pid]) + }, [slug, navigate, pid, cid]) // Load main view + branch view whenever slug/branch changes. useEffect(() => { @@ -985,7 +992,7 @@ export default function RFCView({ viewer }) { onCompleted={() => { setShowGraduateDialog(false) // The catalog row and the RFC view now reflect `active`. - getRFC(pid, slug).then(setEntry).catch(() => {}) + getRFC(pid, slug, cid).then(setEntry).catch(() => {}) getRFCMain(slug).then(setMainView).catch(() => {}) }} /> diff --git a/testing/ppe-deploy-and-test.sh b/testing/ppe-deploy-and-test.sh index b5d75b4..0711e7c 100755 --- a/testing/ppe-deploy-and-test.sh +++ b/testing/ppe-deploy-and-test.sh @@ -23,7 +23,7 @@ set -euo pipefail REPO_ROOT="$HOME/git/wiggleverse.org/ben.stull/rfc-app" FLOTILLA="$HOME/git/wiggleverse.org/wiggleverse/flotilla-core/.venv/bin/flotilla-core" PPE_HOST="https://rfc-ppe.wiggleverse.org" -EXPECT_VERSION="0.52.0" +EXPECT_VERSION="$(cat "$REPO_ROOT/VERSION")" BOT_SECRET_PROJECT="wiggleverse-ohm" BOT_SECRET_ID="ohm-rfc-app-gitea-bot-token" E2E_SECRET_PROJECT="rfc-app-ppe" @@ -38,10 +38,19 @@ if ! gcloud secrets list --project="$E2E_SECRET_PROJECT" --limit=1 >/dev/null 2> fi echo "gcloud OK" -echo "== 1. create + seed PPE repos (bot token; never echoed) ==" -GITEA_TOKEN="$(gcloud secrets versions access latest --secret="$BOT_SECRET_ID" --project="$BOT_SECRET_PROJECT")" \ +echo "== 1. create + seed PPE repos (Keychain admin token; never echoed) ==" +# Seeding CREATES the two org repos (rfc-registry-ppe, rfc-app-ppe-content), +# which needs a write:organization-scoped token. The SM bot token is +# write:repository only (org create → 403), so use the operator's Keychain +# admin PAT (wgl-gitea-token-, legacy fallback ohm-gitea-token). The +# token stays in the env var — never echoed (§6.3). +SEED_TOKEN="$(security find-generic-password -s "wgl-gitea-token-git.wiggleverse.org" -w 2>/dev/null \ + || security find-generic-password -s "ohm-gitea-token" -w 2>/dev/null)" +[ -n "$SEED_TOKEN" ] || { echo "no Keychain Gitea token found" >&2; exit 1; } +GITEA_TOKEN="$SEED_TOKEN" \ RESEED="${RESEED:-1}" \ bash "$REPO_ROOT/testing/seed-ppe.sh" +unset SEED_TOKEN GITEA_TOKEN echo "== 2. ensure E2E test-auth secret exists ==" if gcloud secrets describe "$E2E_SECRET_ID" --project="$E2E_SECRET_PROJECT" >/dev/null 2>&1; then @@ -49,7 +58,12 @@ if gcloud secrets describe "$E2E_SECRET_ID" --project="$E2E_SECRET_PROJECT" >/de "$FLOTILLA" secret bind rfc-app-ppe E2E_TEST_AUTH_SECRET "$E2E_SECRET_PROJECT/$E2E_SECRET_ID@latest" else echo "creating E2E secret (random, via stdin — bytes never echoed)" - openssl rand -hex 32 | "$FLOTILLA" secret set rfc-app-ppe E2E_TEST_AUTH_SECRET + # `printf %s "$(...)"` stores EXACTLY 64 hex bytes with NO trailing newline. + # A bare `openssl rand -hex 32 | ...` stores 65 bytes (the trailing \n), + # which then rode into the VM .env and made the server's secret differ from + # the runner's command-substitution-stripped value → /auth/test/login 404 + # (compare_digest mismatch). Keep it newline-free. + printf '%s' "$(openssl rand -hex 32)" | "$FLOTILLA" secret set rfc-app-ppe E2E_TEST_AUTH_SECRET fi echo "== 3. deploy rfc-app-ppe ==" diff --git a/testing/seed-ppe.sh b/testing/seed-ppe.sh index 2ce267a..5f7453d 100755 --- a/testing/seed-ppe.sh +++ b/testing/seed-ppe.sh @@ -44,6 +44,22 @@ RESEED="${RESEED:-0}" api() { curl -s -H "Authorization: token $TOKEN" "$@"; } +# mutate