From 8a1a52915b5779c698bd459d9472c186658941a4 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 22 Sep 2026 20:38:01 +0200 Subject: [PATCH] fix(07): revise 07-07-PLAN.md for checker-found seeding blockers --- .../07-07-PLAN.md | 61 +++++++++++++------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/.planning/phases/07-user-plugin-and-authentication/07-07-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-07-PLAN.md index 0d2b8f9..b4031ae 100644 --- a/.planning/phases/07-user-plugin-and-authentication/07-07-PLAN.md +++ b/.planning/phases/07-user-plugin-and-authentication/07-07-PLAN.md @@ -10,6 +10,7 @@ files_modified: - ../fonoteka.go/parity/manifest.yaml - ../fonoteka.go/parity/parity_test.go - ../fonoteka.go/parity/parity_contract_test.go + - ../fonoteka.go/parity/user_api_seed_test.go - ../fonoteka.go/parity/nuxt_flow_test.go - ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api__reused.yaml - ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml @@ -26,14 +27,17 @@ must_haves: - "All 15 /_user/api/v1 routes and both nuxt flows (nuxt-auth, nuxt-auth-lock) replay green against the Go backend" - "Fetch after logout is 401 in both the re-recorded PHP corpus (file-cache backed, matching production CACHE_DRIVER/blacklist_enabled behavior) and Go, with byte-identical bodies" - "An authenticated activate or activate-by-code call against an already-activated user reproduces PHP's production Winter error page byte-for-byte in Go (500, text/html), matching the real throw path in Winter's User::attemptActivation, not a blanket 'wrong code' rule" + - "No route replay depends on the unported /_fonoteka/api/v1/onboarding/* endpoints -- the 15 user-api routes and the new nuxt-flow test seed their own state directly against Postgres" - "go vet and go test ./... -race are green in both summercms.go and fonoteka.go, including updated activation and corpus-count tests" artifacts: - path: "../fonoteka.go/parity/manifest.yaml" - provides: "15 user-api route entries flipped from status: pending to status: ported, each only after every one of its cases replays green" + provides: "15 user-api route entries flipped from status: pending to status: ported, each with seed_hook: user-api, only after every one of its cases replays green" - path: "../fonoteka.go/plugins/golem15/user/controllers/winter_error_page.html" provides: "byte-exact copy of the recorded Winter production error page, embedded and reused by Activate and ActivateByCode" - path: "../fonoteka.go/plugins/golem15/user/classes/codes.go" provides: "IsAlreadyActivated helper distinguishing PHP's uncaught already-active throw from the ordinary wrong/expired code path" + - path: "../fonoteka.go/parity/user_api_seed_test.go" + provides: "seedUserAPI: a direct-DB (not HTTP-onboarding) seed hook that makes Alice login-ready for the 15 user-api routes" - path: "../fonoteka.go/parity/nuxt_flow_test.go" provides: "an in-process replay gate for fixtures/nuxt/nuxt-auth.yaml and nuxt-auth-lock.yaml against the real Go backend, mirroring TestParityCorpus's replayPortedRoute primitives" key_links: @@ -49,6 +53,10 @@ must_haves: to: "../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api__reused.yaml" via: "CACHE_DRIVER=file makes PHP's jwt-auth blacklist persist across requests, matching production blacklist_enabled=true" pattern: "CACHE_DRIVER" + - from: "../fonoteka.go/parity/manifest.yaml" + to: "../fonoteka.go/parity/user_api_seed_test.go" + via: "seed_hook: user-api replaces the broken m.Seed HTTP-onboarding fallback" + pattern: "seed_hook: user-api" --- @@ -56,10 +64,11 @@ Close the single critical gap from 07-VERIFICATION.md: the 15 recorded `/_user/a This is a single plan per the plan-count checkpoint decision: handler fixes, fixture corrections (via re-recording, never hand-editing), the green replay, the manifest flips, and the unit-test updates for the changed behavior all land here, with the test-update task ordered last. This exceeds the usual 2-3-task guidance for a plan because the user explicitly locked "one plan" for this gap closure at the plan-count checkpoint; splitting further was rejected. -Two decisions are locked and MUST NOT be re-litigated by the executor: +Three decisions are locked and MUST NOT be re-litigated by the executor: 1. **Fetch-after-logout stays 401 in Go.** Production PHP DOES blacklist on logout (`AuthManager::logout` calls `JWTAuth::invalidate(true)`, `blacklist_enabled` defaults true in `config/jwt.php:227`, production `.env` has `CACHE_DRIVER=file`). The parity harness previously ran the isolated PHP with `CACHE_DRIVER=array`, under which Laravel's built-in dev server does not persist cached state (including the jwt-auth blacklist) across separate requests, so the corpus recorded 200 after logout. That recording is a harness artifact, not the contract — CONTEXT.md D-07 and D-14 already lock Go's 401. The fix is to switch the isolated PHP instance to a persistent cache driver and RE-RECORD the affected fixtures, not to change the Go handler. -2. **Wrong-code activate is not simply "return 200."** Winter's `User::attemptActivation` (`vendor/winter/storm/src/Auth/Models/User.php:223-238`) only returns `false` for a wrong/expired code on a NOT-yet-activated user (harmless, no exception). It THROWS `Exception('User is already active!')` uncaught whenever the target user IS ALREADY ACTIVATED, regardless of whether the presented code is right or wrong — and neither `ApiController::activate()` nor `ApiController::activateByCode()` catches a plain `\Exception` around that call, so the throw reaches Laravel's global handler and renders the generic production error page (500, `text/html`) under `APP_DEBUG=false`. The two recorded fixtures (`POST .../activate` with `{{jwt:alice}}`, `POST .../activate-by-code` with `1!nope`) both hit this path because Alice is already activated from the moment the seed's onboarding/bootstrap call creates her. The fix in Go is therefore keyed on "is this user already activated", not on "is the code wrong." +2. **Wrong-code activate is not simply "return 200."** Winter's `User::attemptActivation` (`vendor/winter/storm/src/Auth/Models/User.php:223-238`) only returns `false` for a wrong/expired code on a NOT-yet-activated user (harmless, no exception). It THROWS `Exception('User is already active!')` uncaught whenever the target user IS ALREADY ACTIVATED, regardless of whether the presented code is right or wrong — and neither `ApiController::activate()` nor `ApiController::activateByCode()` catches a plain `\Exception` around that call, so the throw reaches Laravel's global handler and renders the generic production error page (500, `text/html`) under `APP_DEBUG=false`. The two recorded fixtures (`POST .../activate` with `{{jwt:alice}}`, `POST .../activate-by-code` with `1!nope`) both hit this path because Alice is already activated. The fix in Go is therefore keyed on "is this user already activated", not on "is the code wrong." +3. **Seeding for these 15 routes and both nuxt flows must not go through `/_fonoteka/api/v1/onboarding/*`.** That endpoint group is entirely unimplemented in Go (`plugins/golem15/fonoteka/routes.go:33` registers it with an empty closure — no handler is mounted), and its own manifest entries are themselves still `status: pending` with a recorded 409-for-an-empty-body case, not the 200 a real org-creation call needs. The manifest's global `seed:` block (`spec/fixture: seed/bootstrap.yaml`) POSTs to that unported endpoint, so any route relying on it (via an empty `seed_hook`) would fail before its own case ever runs. Every currently-`ported` route avoids this by declaring its own `seed_hook` (`genres`), which seeds via a direct GORM upsert instead of HTTP. This plan gives the 15 user-api routes and the new nuxt-flow test the same kind of direct-DB seeding — never the `m.Seed` HTTP path. Purpose: prove the PHP contract is actually the acceptance test for the user plugin, not just a design intent — AUTH-01 stays blocked until this replay is green. Output: a green `TestParityCorpus` (22 ported / 147 pending / 0 failing), a green nuxt-flow replay, corrected fixtures, corrected Go handlers, and updated unit tests. @@ -87,13 +96,17 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the - type Step struct { Response Response; ... } - type Response struct { Body Body } -- Body is a string type holding verbatim bytes - func tide.OpenStore(path string) (*Store, error) -- "" means memory-only -- func tide.ReplayFlow(ctx context.Context, flow Flow, cfg ReplayConfig) (Report, error) +- func tide.ReplayFlow(ctx context.Context, flow Flow, cfg ReplayConfig) (Result, error) -- Result{OK bool, Steps []StepResult}; ReplayFlow returns a non-nil *tide.MismatchError whenever any step's diff is non-empty, so checking the returned err (exactly as replayPortedRoute already does) is sufficient -- no separate result.OK check is needed. + +WARNING confirmed this session: the manifest's global `seed:` block (`spec/fixture: seed/bootstrap.yaml`) is NOT a usable seeding path for the 15 user-api routes or the new nuxt-flow test. That flow's first two steps call `GET /_fonoteka/api/v1/onboarding/status` and `POST /_fonoteka/api/v1/onboarding/bootstrap`, and `plugins/golem15/fonoteka/routes.go:33` registers the onboarding group with an EMPTY closure (`r.Group(..., func(g pact.Router) {})`) -- no handler is mounted there at all. The manifest's own onboarding entries (`manifest.yaml` lines 2103-2139) are themselves still `status: pending`, and their recorded `bootstrap` case is a 409 for an empty `{}` body, not the 200 a real org-creation body like `bootstrap.yaml`'s would produce. `replayPortedRoute` only falls into this `m.Seed` HTTP-replay branch when `route.SeedHook == ""`; giving a route its own `seed_hook` skips that branch entirely. Every one of the 7 currently-ported routes already avoids this trap via `seed_hook: genres` (a direct GORM upsert, see `genres_seed_test.go`) -- Task 2 gives the 15 user-api routes and the nuxt-flow test the equivalent, for the same reason. + +`summercms.go/tide/normalize.go`'s `isIDKey`/`maskLeaf`: any JSON key literally named `id`, or ending in `_id` (and not `_at`), is masked to the literal string `""` on BOTH sides before `normalizeJSON` diffs a JSON body. This means the literal `"id":5`/`"id":6` values recorded in `nuxt-auth.yaml`/`nuxt-auth-lock.yaml` do NOT need to be reproduced exactly by Go's replay -- only that a valid JSON integer is present there, and that every OTHER field (email, `must_change_password`, `is_activated`, etc.) is correct. ../fonoteka.go/parity/db_capture.go (already reviewed this session, package main): captureUserCode(ctx, store *tide.Store, key, email, column string, read codeReader) error and postgresCodeReader(db *sql.DB) codeReader already exist and are unit-tested by db_capture_test.go, but neither is wired into TestParityCorpus's replay path yet -- that wiring is part of this plan's Task 2. ../fonoteka.go/parity/parity_test.go (already reviewed this session): parityDB(t) *sql.DB returns ONE shared testcontainers Postgres connection for the entire TestParityCorpus run (not reset between route subtests) -- so a user created by an earlier route's replay (e.g. register's user-mode case creating "Ada") is still present in the database when a later route's replay runs (e.g. activate-by-code), because `for _, route := range m.Routes` executes t.Run subtests sequentially in manifest file order and register (manifest line ~3053) already precedes activate-by-code (line ~3129) and forgot-password (line ~3081) already precedes reset-password (line ~3097). replayPortedRoute's tide.Store is fresh (memory-only) PER ROUTE, so {{code:activate}}/{{code:reset}} must be re-populated via a live DB read (postgresCodeReader), not carried over from an earlier route's captured vars. -../fonoteka.go/parity/synthetic_test.go seedHooks map[string]seedHookFunc (already reviewed): the existing pattern for route-ID-or-name-keyed setup functions invoked before a route's cases replay -- the new DB-capture wiring for activate-by-code/reset-password should follow this same shape (a small keyed map or an equivalent per-route switch inside replayPortedRoute), not a new mechanism. +../fonoteka.go/parity/synthetic_test.go seedHooks map[string]seedHookFunc (already reviewed): the existing pattern for name-keyed setup functions invoked before a route's cases replay -- Task 2's new "user-api" hook and the new DB-capture wiring for activate-by-code/reset-password both follow this same shape (an addition to this map, or an equivalent keyed helper alongside it), not a new mechanism. @@ -142,11 +155,11 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the - Task 2: Fix the already-activated quirk, wire DB-capture into replay, and close every remaining diff for the 15 routes and both nuxt flows + Task 2: Fix the already-activated quirk, seed the 15 routes and both nuxt flows without the unported onboarding endpoint, and close every remaining diff ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/winter_error_page.html, ../fonoteka.go/plugins/golem15/user/classes/codes.go, - ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/nuxt_flow_test.go, ../fonoteka.go/parity/manifest.yaml + ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/user_api_seed_test.go, ../fonoteka.go/parity/nuxt_flow_test.go, ../fonoteka.go/parity/manifest.yaml ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go lines 305-385 (Activate, ActivateByCode) and lines 744-774 (authenticate -- already loads a fresh *models.User row, so Activate needs no extra DB read) and lines 902-912 (splitCode); @@ -156,11 +169,14 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate_user-api.yaml and POST___user_api_v1_activate-by-code_user-api__invalid.yaml (full files, already read this session and confirmed byte-identical HTML bodies apart from the request; both use an already-activated user -- Alice, and Alice via user id 1 respectively); ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate-by-code_user-api.yaml (full file, already read this session: the SUCCESS case, using a distinct not-yet-activated user "Ada" / activate@parity.test, id 3, with {{code:activate}} substituted into the request); summercms.go/tide/diff.go lines 1-27 (compareBodies: non-JSON content types like text/html compare via diffBytes, which requires an EXACT byte match including any trailing newline -- there is no normalizer hook for non-JSON bodies, so the embedded HTML must be copied verbatim from the fixture, never hand-retyped); + summercms.go/tide/normalize.go lines 112-121 (isIDKey -- confirms "id" and "*_id" JSON keys are masked to "" before comparison; see the WARNING/id-masking note in <interfaces>); ../fonoteka.go/parity/db_capture.go (full file, 124 lines, already read this session: captureUserCode(ctx, store, key, email, column string, read codeReader) error and postgresCodeReader(db *sql.DB) codeReader already exist and are unit-tested but are not yet called from TestParityCorpus's replay path); - ../fonoteka.go/parity/parity_test.go lines 1-256 (full replay harness, already read this session: seedHooks map, invokeSeedHook, replayPortedRoute's per-route tide.Store and shared-across-subtests parityDB(t)); - ../fonoteka.go/parity/genres_seed_test.go lines 1-60 (seedGenres -- the existing precedent for a route-keyed setup function called before a route's cases replay; the new DB-capture wiring should follow this same shape); - ../fonoteka.go/parity/manifest.yaml lines 2980-3236 (the 15 auth_group: user-api entries, all currently status: pending, and their exact id: strings, needed verbatim for the manifest flip and for parity_contract_test.go's allow-list in Task 3); - ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml (full file) and ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth-lock.yaml (full file) -- neither is currently replayed by any Go test (confirmed this session: no go file references nuxt-auth or even the pre-existing working nuxt-browse.yaml anywhere in the repo; nuxt flows have only ever been recorded, never replayed in-process against Go). + ../fonoteka.go/parity/parity_test.go lines 1-256 (full replay harness, already read this session: seedHooks map, invokeSeedHook, replayPortedRoute's per-route tide.Store and shared-across-subtests parityDB(t); the m.Seed HTTP-onboarding fallback branch this task must NOT use for these routes); + ../fonoteka.go/parity/genres_seed_test.go (full file, 173 lines, already read this session: seedGenres, upsertParityAlice -- upsertParityAlice creates Alice with Password:"" and never sets IsActivated, since it only exists to mint a bypass JWT for read-only genre routes; the new user-api seed hook must upgrade whatever row this leaves behind with a real password hash and IsActivated:true, not assume Alice is already login-ready; mintTestJWT is the existing test-JWT-minting helper to reuse for jwt:alice); + ../fonoteka.go/parity/genre_smoke_test.go line 142 (an existing raw-SQL "INSERT INTO users (email, password, must_change_password) VALUES (...)" precedent for directly seeding a user row inside a parity test -- that one leaves password empty; the new lock-user insert needs a real bcrypt hash since it goes through the real Login handler, so use gdb.Create(&models.User{...}) with bouncer.HashPassword instead of raw SQL); + ../fonoteka.go/plugins/golem15/fonoteka/routes.go line 33 (onboarding group registered with an empty closure -- confirms no handler is mounted, see the WARNING in <interfaces>); + ../fonoteka.go/parity/manifest.yaml lines 2103-2139 (the onboarding/status and onboarding/bootstrap entries, both still status: pending; the bootstrap case is recorded for an EMPTY {} body at 409, not a real org-creation body) and lines 2980-3236 (the 15 auth_group: user-api entries, all currently status: pending with no seed_hook, and their exact id: strings, needed verbatim for the manifest flip and for parity_contract_test.go's allow-list in Task 3); + ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml (full file -- its own first step registers its own "nuxt@parity.test" user through the real Register handler; it needs no pre-seeded identity) and ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth-lock.yaml (full file, already read this session: its first step logs in "lock@parity.test"/"parity-alice-pass" expecting must_change_password:true and is_activated:true -- a user this flow itself never creates) -- neither flow is currently replayed by any Go test (confirmed this session: no go file references nuxt-auth, nuxt-auth-lock, or even the pre-existing working nuxt-browse.yaml anywhere in the repo). In classes/codes.go, add an exported IsAlreadyActivated(ctx context.Context, db *gorm.DB, userID uint) (bool, error) that calls the existing unexported takeUser(ctx, db, userID, false) (scoped, matching PHP's UserModel::find()) and returns (user.IsActivated, nil), or (false, nil) when the user does not exist, or (false, err) on a real DB error. Do not change VerifyActivationCode's existing signature or behavior -- it stays exactly as-is for the not-yet-activated path. @@ -173,11 +189,13 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the In ActivateByCode: after splitCode succeeds, call classes.IsAlreadyActivated(codeContext(r.Context(), app), gdb, id) before calling VerifyActivationCode. On a real error, writeOpaque500. If already activated, call writeWinterErrorPage and return. Otherwise proceed exactly as today (VerifyActivationCode returns 422 on failure, mints a token and returns 200 on success). - Wire the D-12 two-step DB-capture into the replay path for the activate-by-code and reset-password routes: extend parity_test.go (or a small new helper alongside replayPortedRoute) with a route-ID-keyed map (mirroring seedHooks's shape) that, for "POST /_user/api/v1/activate-by-code user-api", calls captureUserCode(ctx, store, "code:activate", "activate@parity.test", "activation_code", postgresCodeReader(db)) before that route's cases replay, and for "POST /_user/api/v1/reset-password user-api" calls the equivalent for "code:reset"/"reset_password_code" against whichever identity that case's own recorded fixture implies (determine this from POST___user_api_v1_reset-password_user-api.yaml's known values and, if the specific email is not otherwise recoverable, register and forgot-password a fresh identity as part of this same setup step, mirroring how register's user-mode case creates Ada for activate-by-code). Confirm via the shared, sequential parityDB(t) connection (manifest order already has register before activate-by-code, and forgot-password before reset-password, so the prerequisite row already exists in Postgres by the time each capture runs) that this captures a real, freshly-issued code rather than a stale one. + Give the 15 user-api routes their own seed_hook instead of the broken m.Seed HTTP-onboarding fallback (see the WARNING in <interfaces>). Add ../fonoteka.go/parity/user_api_seed_test.go with a new seedUserAPI(ctx context.Context, db *sql.DB, store *tide.Store) error that mirrors seedGenres: call the existing upsertParityAlice(ctx, gdb) (already in the same package via genres_seed_test.go, so Alice keeps whatever id she already has from the earlier genres-hooked routes' replay), then, only if her Password field is still empty (the state upsertParityAlice leaves her in when she is freshly created), hash "parity-alice-pass" via bouncer.HashPassword(10, ...) and update both Password and IsActivated:true on her row directly via gdb (matching what the fetch/login/activate fixtures need: a real bcrypt-verifiable password and an already-activated account) -- skip the hash/update entirely if a later call finds her already in this state, so repeated invocations across the 15 routes stay cheap and idempotent. Mint and store.Set("jwt:alice", ...) via the existing mintTestJWT helper exactly as seedGenres does, since several of the 15 routes' fixtures use Authorization: Bearer {{jwt:alice}} directly. Register seedUserAPI in parity_test.go's seedHooks map under the key "user-api", and add seed_hook: user-api to all 15 manifest.yaml entries under auth_group: user-api. - Add ../fonoteka.go/parity/nuxt_flow_test.go with a new TestUserAPINuxtFlows (skipped under -short, matching parityDB's existing convention) that opens the same parityDB(t) / applyPluginMigrations / httptest.NewServer(newTarget(t, db)) triple replayPortedRoute already uses, replays m.Seed's bootstrap flow first (creating Alice, exactly as replayPortedRoute's "else if m.Seed != nil" branch does), then calls tide.ReplayFlow against fixtures/nuxt/nuxt-auth.yaml and separately against fixtures/nuxt/nuxt-auth-lock.yaml, failing the test on any reported diff. + Wire the D-12 two-step DB-capture into the replay path for the activate-by-code and reset-password routes: extend parity_test.go (or a small new helper alongside replayPortedRoute) with a route-ID-keyed map that, for "POST /_user/api/v1/activate-by-code user-api", calls captureUserCode(ctx, store, "code:activate", "activate@parity.test", "activation_code", postgresCodeReader(db)) before that route's cases replay (after its own seed_hook: user-api has already run), and for "POST /_user/api/v1/reset-password user-api" calls the equivalent for "code:reset"/"reset_password_code" against whichever identity that case's own recorded fixture implies (determine this from POST___user_api_v1_reset-password_user-api.yaml's known values and, if the specific email is not otherwise recoverable, register and forgot-password a fresh identity as part of this same setup step, mirroring how register's user-mode case creates Ada for activate-by-code). Confirm via the shared, sequential parityDB(t) connection (manifest order already has register before activate-by-code, and forgot-password before reset-password, so the prerequisite row already exists in Postgres by the time each capture runs) that this captures a real, freshly-issued code rather than a stale one. - Run "go test ./parity/... -run 'TestParityCorpus|TestUserAPINuxtFlows|TestDBCapture'" WITHOUT -short (needs testcontainers Postgres) with the 15 user-api routes still marked status: pending first, to sanity-check nothing here is broken before flipping status. Then, working route by route, flip ONE route's manifest.yaml entry from status: pending to status: ported, re-run the same test command, and read the failure diff for any case that does not pass. Fix the Go handler behavior to match the recorded PHP body -- NEVER edit a fixture to make Go pass (fixtures only change via Task 1's re-recording pattern). Repeat until all 15 routes and both nuxt flows are green. Known-correct-already (per 07-05-SUMMARY, confirm rather than re-derive): every login/fetch/register/update success payload contains feedback_widget_hidden:false; register disabled/throttled already returns the opaque {"error":"Internal server error"},500 via writeSafe500; the 6th rapid failed login (A2) is byte-identical to the 1st. + Add ../fonoteka.go/parity/nuxt_flow_test.go with a new TestUserAPINuxtFlows (skipped under -short, matching parityDB's existing convention) that opens the same parityDB(t) / applyPluginMigrations / httptest.NewServer(newTarget(t, db)) triple replayPortedRoute already uses. Do NOT replay m.Seed's bootstrap.yaml (it needs the unported onboarding endpoint). fixtures/nuxt/nuxt-auth.yaml needs no pre-seeding beyond the migrations already applied -- its own first step registers its own "nuxt@parity.test" user through the real Register handler. Before replaying fixtures/nuxt/nuxt-auth-lock.yaml, insert its missing prerequisite directly: gdb.Create(&models.User{Email: "lock@parity.test", Password: <bouncer.HashPassword(10, "parity-alice-pass")>, IsActivated: true, MustChangePassword: true}) (mirroring genre_smoke_test.go line 142's raw-SQL precedent, but through GORM so a real bcrypt hash can be set). The recorded fixture bodies' literal "id":5/"id":6 values do not need to be reproduced exactly -- tide's normalizeJSON masks every "id"/"*_id" key to "" before comparing (see <interfaces>), so whichever auto-increment id Postgres actually assigns to the Nuxt user and the lock user is fine as long as the OTHER fields (email, must_change_password, is_activated) match. Call tide.ReplayFlow separately against fixtures/nuxt/nuxt-auth.yaml and (after inserting the lock user) fixtures/nuxt/nuxt-auth-lock.yaml, failing the test on any returned error (ReplayFlow already returns a non-nil *tide.MismatchError on any diff, matching the same err-only check replayPortedRoute already uses). + + Run "go test ./parity/... -run 'TestParityCorpus|TestUserAPINuxtFlows|TestDBCapture'" WITHOUT -short (needs testcontainers Postgres) with the 15 user-api routes still marked status: pending first, to sanity-check the new seed hook and nuxt-flow test compile and run cleanly before flipping any route status. Then, working route by route, flip ONE route's manifest.yaml entry from status: pending to status: ported, re-run the same test command, and read the failure diff for any case that does not pass. Fix the Go handler behavior to match the recorded PHP body -- NEVER edit a fixture to make Go pass (fixtures only change via Task 1's re-recording pattern). Repeat until all 15 routes and both nuxt flows are green. Known-correct-already (per 07-05-SUMMARY, confirm rather than re-derive): every login/fetch/register/update success payload contains feedback_widget_hidden:false; register disabled/throttled already returns the opaque {"error":"Internal server error"},500 via writeSafe500; the 6th rapid failed login (A2) is byte-identical to the 1st. go test ./parity/... -run "TestParityCorpus|TestUserAPINuxtFlows" -v 2>&1 | tail -80 @@ -187,12 +205,13 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the - api_controller.go's Activate returns 500 text/html (the embedded page) when the authenticated user is already activated, and 200 with the unchanged payload when not yet activated regardless of code correctness - api_controller.go's ActivateByCode returns 500 text/html when the targeted user is already activated, 422 when not-yet-activated with a wrong/expired code, and 200+token+user on a correct code for a not-yet-activated user - classes/codes.go exports IsAlreadyActivated with the signature (ctx, db, userID) (bool, error) - - parity/nuxt_flow_test.go exists, is skipped under -short, and replays both fixtures/nuxt/nuxt-auth.yaml and fixtures/nuxt/nuxt-auth-lock.yaml against the real Go backend + - user_api_seed_test.go exists, is registered in seedHooks under the key "user-api", and all 15 manifest.yaml entries under auth_group: user-api carry seed_hook: user-api -- none relies on m.Seed's onboarding-based HTTP bootstrap + - parity/nuxt_flow_test.go exists, is skipped under -short, inserts lock@parity.test directly (real bcrypt hash, is_activated true, must_change_password true) before replaying nuxt-auth-lock.yaml, and replays both fixtures/nuxt/nuxt-auth.yaml and fixtures/nuxt/nuxt-auth-lock.yaml against the real Go backend without touching the onboarding endpoints - "grep -A1 'auth_group: user-api' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: ported'" equals 15 and the same query for "status: pending" equals 0 - "go test ./parity/... -run TestParityCorpus -v" (without -short) reports zero failing cases for any user-api route or the two nuxt flows - no fixture file's response body was edited by hand during this task; every fixture change traces back to Task 1's re-recording - All 15 /_user/api/v1 routes are status: ported in manifest.yaml with every case replaying green; both nuxt flows replay green via the new nuxt_flow_test.go; the already-activated quirk is reproduced byte-for-byte in Go for both activation handlers; the two-step D-12 flows resolve their codes via a live Postgres read during replay. + All 15 /_user/api/v1 routes are status: ported in manifest.yaml with seed_hook: user-api and every case replaying green; both nuxt flows replay green via the new nuxt_flow_test.go without touching the unported onboarding endpoints; the already-activated quirk is reproduced byte-for-byte in Go for both activation handlers; the two-step D-12 flows resolve their codes via a live Postgres read during replay. @@ -206,7 +225,7 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the ../fonoteka.go/plugins/golem15/user/classes/codes_test.go lines 87-98 (TestCodes's own VerifyActivationCode call uses a fresh, never-activated "pending" user and is unaffected by this plan -- confirm rather than re-derive, and add a companion assertion for the new IsAlreadyActivated helper instead of changing this existing flow); ../fonoteka.go/plugins/golem15/user/session_test.go lines 359-398 (loginToken, insertUser, postJSON, bearer -- the exact test helper signatures to reuse for the new already-activated sub-test); ../fonoteka.go/parity/parity_test.go lines 23-24 (expectedPHPRoutes = 169, expectedPortedRoutes = 7 -- only expectedPortedRoutes changes, to 22; the total stays 169) and lines 90-149 (TestParityCorpus's "coverage" subtest, which asserts cov.Ported/cov.Passing/cov.Pending against these two constants); - ../fonoteka.go/parity/parity_contract_test.go lines 59-95 (TestParityContract's ported-route switch allow-list at lines 64-70, its route.SeedHook != "genres" check at line 72-73 -- which currently applies unconditionally to every ported route and must be scoped so it only requires "genres" for the pre-existing 7 fonoteka routes, not for the 15 user-api routes which use no seed_hook at all -- and the "honest-counts" hardcoded 169/7/162 at lines 92-95, which becomes 169/22/147); + ../fonoteka.go/parity/parity_contract_test.go lines 59-95 (TestParityContract's ported-route switch allow-list at lines 64-70, its route.SeedHook != "genres" check at line 72-73 -- which currently applies unconditionally to every ported route and must be widened so it accepts "genres" for the 7 pre-existing fonoteka routes AND "user-api" for the 15 new ones, the exact hook names Task 2 assigns -- and the "honest-counts" hardcoded 169/7/162 at lines 92-95, which becomes 169/22/147); ../fonoteka.go/parity/check_corpus.go line 17 (expectedRouteCount = 169 -- confirm this stays unchanged; it counts total recorded routes, not ported ones). @@ -218,7 +237,7 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the In parity_test.go, change expectedPortedRoutes from 7 to 22. - In parity_contract_test.go, add all 15 user-api route IDs to the switch-case allow-list at lines 64-70 (the exact "id:" strings from manifest.yaml, e.g. "POST /_user/api/v1/login user-api" through "GET /_user/api/v1/oauth-providers user-api"). Restructure the route.SeedHook != "genres" check so it only applies to routes whose auth_group is the pre-existing fonoteka one that actually uses the genres hook (the 7 routes already in the allow-list before this plan) -- the 15 user-api routes carry no seed_hook and must not fail this assertion. Change the "honest-counts" sub-test's hardcoded values from Recorded 169 / Ported 7 / Pending 162 to Recorded 169 / Ported 22 / Pending 147. + In parity_contract_test.go, add all 15 user-api route IDs to the switch-case allow-list at lines 64-70 (the exact "id:" strings from manifest.yaml, e.g. "POST /_user/api/v1/login user-api" through "GET /_user/api/v1/oauth-providers user-api"). Change the route.SeedHook != "genres" check so it accepts route.SeedHook == "genres" for the 7 pre-existing fonoteka routes and route.SeedHook == "user-api" for the 15 user-api routes (for example switch on route.AuthGroup, or on which arm of the route-ID switch matched, rather than comparing against one hardcoded string) -- the 15 user-api routes carry seed_hook: user-api per Task 2, not "genres", and must not fail this assertion. Change the "honest-counts" sub-test's hardcoded values from Recorded 169 / Ported 7 / Pending 162 to Recorded 169 / Ported 22 / Pending 147. Run "go vet ./..." and "go test ./... -race" in fonoteka.go (including ./plugins/golem15/user/..., ./plugins/golem15/fonoteka/..., and ./parity/... without -short) and in summercms.go. Run "go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets" one final time. Write the SUMMARY, explicitly carrying forward the decision that PHP DOES blacklist in production and the previous "PHP does not blacklist" note in STATE.md/prior SUMMARYs was a harness artifact of CACHE_DRIVER=array, now corrected. @@ -230,7 +249,7 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the - a new test proves Activate also returns the 500 HTML page for an already-activated user with a bearer token - IsAlreadyActivated has direct unit coverage for the true, false, and not-found cases - parity_test.go's expectedPortedRoutes reads 22 - - parity_contract_test.go's ported-route switch lists all 22 route IDs (7 existing plus 15 user-api), its SeedHook check no longer fails for a user-api route, and its honest-counts sub-test reads Recorded 169 / Ported 22 / Pending 147 + - parity_contract_test.go's ported-route switch lists all 22 route IDs (7 existing plus 15 user-api), its SeedHook check accepts "genres" for the 7 and "user-api" for the 15 without failing either, and its honest-counts sub-test reads Recorded 169 / Ported 22 / Pending 147 - "go vet ./... && go test ./... -race" exits 0 in both summercms.go and fonoteka.go, including nested plugin packages and ./parity/... run without -short - "go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets" passes - 07-07-SUMMARY.md exists and carries forward the corrected "PHP does blacklist in production" finding @@ -248,6 +267,7 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the | Parity recorder → isolated PHP (persistent cache) | Re-recording now persists PHP-side cache/session state across requests inside the shared `$PHP_ROOT` checkout, not just the isolated SQLite DB under `$PARITY_ROOT` | | Parity recorder → committed fixtures | Recorded bodies (including the newly blacklisted-token 401 body) cross from a private, secret-bearing capture session into a public, committed artifact | | Go production error page → HTTP client | The embedded Winter error page is served verbatim to any caller who triggers the already-activated path; it must never gain a stack trace or internal path beyond what PHP itself renders under `APP_DEBUG=false` | +| Parity test seeding → Postgres | The new direct-DB seed hooks (seedUserAPI, the lock-user insert) write real bcrypt-hashed credentials into the shared test Postgres instance; those credentials must never be logged or leak into a committed fixture | ## STRIDE Threat Register @@ -258,6 +278,7 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the | T-07-22 | Information Disclosure | `winter_error_page.html` (embedded 500 body) | mitigate | Bytes are copied verbatim from a fixture recorded under `APP_DEBUG=false` (already confirmed to contain no stack trace or file path); the executor must not re-record any activation fixture under a debug-enabled PHP instance | | T-07-23 | Repudiation | Logout blacklist enforcement | accept (no new risk) | No change to `bouncer`'s blacklist logic; `TestSessionSequence` and `bouncer/phase07_coverage_test.go` continue to assert 401 after logout unchanged by this plan | | T-07-24 | Tampering | Manifest status flips (`pending` → `ported`) | mitigate | Task 2's acceptance criteria require every case of a route to replay green before its manifest entry flips; `TestParityCorpus`'s existing `ported-mismatch`/`ported-mutated-response` subtests continue to catch a falsely-flipped route | +| T-07-25 | Information Disclosure | Test-only credentials minted by `seedUserAPI`/the lock-user insert | accept | These are hardcoded, well-known, non-production test passwords (`parity-alice-pass`) already used and accepted throughout the existing Phase 2/7 parity corpus; they exist only inside an ephemeral testcontainers Postgres instance, never in a committed fixture body verbatim as a raw credential | @@ -266,7 +287,7 @@ tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the -All 15 `/_user/api/v1` routes and both `nuxt-auth`/`nuxt-auth-lock` client flows are `status: ported` in `parity/manifest.yaml` and replay byte-for-byte green against the Go backend; AUTH-01 is unblocked; no PHP contract was weakened to make Go pass. +All 15 `/_user/api/v1` routes and both `nuxt-auth`/`nuxt-auth-lock` client flows are `status: ported` in `parity/manifest.yaml` and replay byte-for-byte green against the Go backend, seeded entirely through direct-DB hooks rather than the unported onboarding endpoint; AUTH-01 is unblocked; no PHP contract was weakened to make Go pass.