test(07): complete UAT - 11 passed, 1 issues

This commit is contained in:
Jakub Zych
2026-09-23 10:33:16 +02:00
parent 1bd9f9e056
commit ab84becf16
2 changed files with 151 additions and 0 deletions

View File

@@ -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.

View File

@@ -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 <email> 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"