diff --git a/.planning/debug/avatar-bucket-not-published.md b/.planning/debug/avatar-bucket-not-published.md new file mode 100644 index 0000000..73279c6 --- /dev/null +++ b/.planning/debug/avatar-bucket-not-published.md @@ -0,0 +1,34 @@ +# DEBUG: Avatar upload 500 on assembled app + +**Discovered:** 2026-09-23 during `$gsd-verify-work 7` +**Status:** diagnosed +**Goal:** find_root_cause_only + +## Symptoms + +- POST `/_user/api/v1/avatar` with a JPEG multipart against `app.Handler` returns `500 {"error":true,"message":"Internal server error"}` +- Profile update, marketing consent, and change-password on the same handler succeed +- `TestUploadAvatar` / `TestRemoveAvatar` pass + +## Reproduction + +1. Boot `app.Handler` with parity `testConfig` (app.yaml + http.yaml + JWT secret) +2. Register, then `curl -F avatar=@tiny.jpg` +3. Observe 500 + +## Root Cause + +Phase 5 shipped `attach.OpenBucket` / `attach.Publish` but never called them from serve. Phase 5 verification recorded that as info because there was no HTTP upload yet. Phase 7 added `UploadAvatar`, which does `app.Lookup[*blob.Bucket]()` and `writeOpaque500` when the lookup fails. + +Neither boot path publishes the bucket: + +- `fonoteka.go/app/app.go` `Handler` — `lagoon.Publish` then `party.Activate` then `surf.Assemble` +- `surf.ServeCommand` — `lagoon.OpenFromApp` / `lagoon.Publish` then `Assemble` + +Unit tests call `publishAvatarBucket` (memblob) themselves. The ported parity fixture is `avatar-missing` (JSON `{}` → 422), so `TestParityCorpus` never uploads a file. + +## Suggested Fix + +1. Open and publish the bucket next to `lagoon.Publish` in `ServeCommand` and `app.Handler`. Empty `storage.uploads.bucket_url` already fails loud. +2. Set `storage.uploads.bucket_url=mem://` on assembled-test configs. +3. Add a Handler-level multipart upload test so this cannot regress behind the 422 fixture. diff --git a/.planning/phases/07-user-plugin-and-authentication/07-UAT.md b/.planning/phases/07-user-plugin-and-authentication/07-UAT.md new file mode 100644 index 0000000..2561b9b --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-UAT.md @@ -0,0 +1,117 @@ +--- +status: diagnosed +phase: 07-user-plugin-and-authentication +source: [07-01-SUMMARY.md, 07-02-SUMMARY.md, 07-03-SUMMARY.md, 07-04-SUMMARY.md, 07-05-SUMMARY.md, 07-06-SUMMARY.md, 07-07-SUMMARY.md] +started: 2026-09-23T08:13:12Z +updated: 2026-09-23T08:32:06Z +--- + +## Current Test + +[testing complete] + +## Tests + +### 1. Session loop — register, login, fetch, refresh, logout +expected: POST /_user/api/v1/register (auto mode) returns {token,user}. Login returns a JWT whose sub is the user id, prv is the hardcoded User class hash, and iss is the request URL. Fetch returns that user. Refresh returns a new token. Logout forever-blacklists the jti; a following fetch with that bearer is 401. +result: pass +reported: | + Curled the assembled app.Handler. Register 200 with token+user. Login JWT sub=user id, prv=a867434cbc213adfbe78a02bed7082a6bd99c883, iss ends with /_user/api/v1/login. Fetch 200. Refresh returns a new token. Logout {"message":"Logged out"}. Fetch after logout 401. + +### 2. Invalid login shares one body +expected: Wrong password, an unknown email, and a suspended account all return the same 401 body {"error":true,"message":"Nieprawidłowy email lub hasło"} (or the English Invalid email or password equivalent). No account enumeration. +result: pass +reported: | + Wrong password, unknown email, and the sixth attempt after five failures all returned 401 {"error":true,"message":"Nieprawidłowy email lub hasło"}. + +### 3. Forgot-password is enumeration-safe; reset invalidates older tokens +expected: POST forgot-password always answers 200 {"message":"If that email exists, a reset link has been sent."} whether the email exists or not. Reset consumes {id}!{code}, stores the new hash, and sets tokens_valid_after so older JWTs fail. +result: pass +reported: | + Known and unknown emails both returned 200 {"message":"If that email exists, a reset link has been sent."}. Reset with {id}!{code} returned {"message":"Password has been reset"}. Old JWT fetch 401. New password login 200. + +### 4. Activation and already-activated Winter page +expected: Public activate-by-code with a valid {id}!{code} activates (or restores a soft-deleted user) and returns a token. Already-activated Activate and ActivateByCode serve the embedded Winter production error page (500, text/html), not a JSON 422. +result: pass +reported: | + Valid activate-by-code 200 with token and is_activated true. Reusing that code and authenticated activate on the now-activated user both returned 500 text/html starting with the Polish Winter error page. + +### 5. Profile, password change, marketing consent, and avatar +expected: Authenticated update, change-password, and marketing-consent write the signed-in row. A password change keeps the presenting token and invalidates older ones. Avatar upload sniffs the first bytes, stores an attach.File, and fills has_avatar plus a 128px avatar_url; remove clears them. +result: issue +reported: "Profile update, marketing consent, and change-password work over curl (presenting JWT kept, older JWT 401 after a 2s iat gap). POST /_user/api/v1/avatar against the assembled app.Handler returns 500 {\"error\":true,\"message\":\"Internal server error\"}." +severity: blocker + +### 6. Organisation fields arrive through GetApiArrayEvent +expected: Login, fetch, and register user objects include organisation_id, organisation_role, must_change_password, and preferred_locale from fonoteka's GetApiArray listener. The user plugin does not import fonoteka. +result: pass +reported: | + Login/register payloads include organisation_id, organisation_role, must_change_password, preferred_locale. go list -deps on the user module does not import plugins/golem15/fonoteka. + +### 7. Personal API tokens — mint, list, revoke, scope ceiling +expected: POST /_fonoteka/api/v1/tokens mints an inv_ secret shown once. GET lists tokens without the secret. DELETE is owner-scoped (missing or foreign id is the same 404). A scope outside read, write, and ai is 422 before insert. A read token on a write route is 403. +result: pass +reported: | + POST tokens with scopes=["admin"] is 422. Mint 201 returns inv_ secret once, no token_hash. GET list omits the secret. Foreign and missing DELETE are both 404 {"error":"Token not found"}. Revoke 200 {"data":{"revoked":true}}. InvScope write-without-read is 403 in TestInvScope; no write HTTP route is mounted yet. + +### 8. Password-change lock with locale exemption +expected: A locked user gets 423 {"error":"Password change required","must_change_password":true} on genres and tokens. GET/PUT /_fonoteka/api/v1/me/locale still succeed. After change-password the lock is gone and genres succeed. +result: pass +reported: | + lock@uat.test login has must_change_password true. GET genres and GET tokens are 423 with the exact lock body. GET me/locale is 200. After change-password, GET genres is 200. + +### 9. Locale persist and per-request resolution +expected: GET/PUT me/locale persist pl or en (empty preferred_locale is JSON null). Per-request locale is preferred_locale, then Accept-Language, then app.locale — including while the password lock is active. +result: pass +reported: | + GET me/locale is {"preferred_locale":null}. PUT en then pl persist. PUT de is 422. Locale GET succeeded while the lock was active (test 8). Invalid-login messages used the app locale (pl). + +### 10. Register modes and production-safe errors +expected: auto/not-required returns {token,user}; user mode returns {message:'Activation email sent'} and sends activation mail with link and code; admin mode returns {}. With allow_registration=false or after the per-IP register limit, production returns {"error":"Internal server error"} at 500. +result: pass +reported: | + Curl auto-mode register returned token+user. TestRegisterUserModeMail, TestRegisterAdminMode, TestRegisterDisabled, and TestRegisterThrottle passed. + +### 11. user:require-password-change console command +expected: user:require-password-change sets must_change_password so the 423 lock is reachable without SQL. +result: pass +reported: | + TestRequirePasswordChange passed: the command sets must_change_password and rejects an unknown email. + +### 12. User-API parity corpus is ported +expected: The 15 /_user/api/v1 routes and both nuxt-auth / nuxt-auth-lock flows replay green against Go and are status: ported. Corpus inventory is recorded 169, ported 22, pending 147, failing 0. +result: pass +reported: | + go test ./parity/ -run 'TestParityCorpus|TestUserAPINuxtFlows' passed. expectedPHPRoutes=169, expectedPortedRoutes=22. + +## Summary + +total: 12 +passed: 11 +issues: 1 +pending: 0 +skipped: 0 +blocked: 0 + +## Gaps + +- truth: "Avatar upload sniffs the first bytes, stores an attach.File, and fills has_avatar plus a 128px avatar_url; remove clears them" + status: failed + reason: "User reported: Profile update, marketing consent, and change-password work over curl (presenting JWT kept, older JWT 401 after a 2s iat gap). POST /_user/api/v1/avatar against the assembled app.Handler returns 500 {\"error\":true,\"message\":\"Internal server error\"}." + severity: blocker + test: 5 + root_cause: "app.Handler and surf.ServeCommand never call attach.OpenBucket/Publish, so Lookup[*blob.Bucket] fails and UploadAvatar writes an opaque 500. Unit tests pass only because they publish a memblob by hand. The ported avatar fixture is the missing-file 422, so replay never uploaded a file." + artifacts: + - path: "fonoteka.go/app/app.go" + issue: "Handler publishes *sql.DB/*gorm.DB then Activate/Assemble; it never opens or publishes *blob.Bucket" + - path: "summercms.go/surf/serve.go" + issue: "ServeCommand publishes the DB then Assemble; it never calls attach.OpenBucket/Publish" + - path: "fonoteka.go/plugins/golem15/user/controllers/api_controller.go" + issue: "UploadAvatar returns writeOpaque500 when the bucket is missing (lines 1153-1157)" + - path: "fonoteka.go/parity/migrate_test.go" + issue: "testConfig writes app.yaml/http.yaml only; storage.uploads.bucket_url is unset" + missing: + - "Call attach.OpenBucket + attach.Publish from surf.ServeCommand and app.Handler (fail boot on empty bucket_url)" + - "Give parity/test configs a mem:// storage.uploads.bucket_url so assembled tests boot" + - "Add an assembled-app upload test (JPEG/PNG multipart) that asserts 200 has_avatar and avatar_url, then remove" + debug_session: ".planning/debug/avatar-bucket-not-published.md"