docs(07): revise plans after checker review
This commit is contained in:
@@ -30,7 +30,10 @@ must_haves:
|
||||
- "A user can activate an account via the authenticated activate route (fire-and-forget, always 200, per ApiController.php:180-196's missing if-check) and via the public activate-by-code route (which DOES check the result, per ApiController.php:208-245)"
|
||||
- "An authenticated user can update their name/surname/email, change their password (current-password verified, D-20 tokens-valid-after invalidation with the presenting token exempted), upload/remove an avatar, and toggle marketing consent"
|
||||
- "Reset and activation codes compare in constant time and expire per a configured TTL with a null-issued-at cutover row counted as issued now (D-15)"
|
||||
- "mail.activate/mail.restore/mail.reactivate render and send through postcard.Send, with the caller picking the -en sibling from preferred_locale (C-05)"
|
||||
- "mail.activate/mail.restore/mail.reactivate render and send through postcard.Send, with the caller picking the -en sibling from preferred_locale via mailTemplate/resolveMailLocale (C-05)"
|
||||
- "change-password ports the normal (self-set-password) branch only; a has_self_set_password=false row 500s with an opaque body rather than being silently treated as self-set, since no Go path can create such a row and the social-login OTP branch is deferred, per D-03"
|
||||
- "Avatar upload and remove are ported on the Phase 5 attachment machinery (system_files, Thumb): payload avatar/avatar_url (128 thumb)/has_avatar are real, bounded by the existing per-group MaxBytesReader upload cap, per D-04"
|
||||
- "Mail is sent inline through postcard.Send behind a small seam Phase 11 later swaps for a River job; forgot-password always returns its enumeration-safe 200 body and logs a send failure instead of surfacing it, per D-18"
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
|
||||
provides: "IssueResetCode/IssueActivationCode/VerifyResetCode/VerifyActivationCode with constant-time compare and TTL"
|
||||
@@ -106,6 +109,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
</files>
|
||||
<read_first>
|
||||
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (sha256-hex hashing convention; this task's compare is constant-time string compare, not hash compare — codes are short and never hashed at rest, matching PHP's plain-text `reset_password_code`/`activation_code` columns),
|
||||
../fonoteka.go/plugins/golem15/user/classes/mail.go (07-02 Task 2 — mailTemplate/resolveMailLocale, the locale-suffix helper every mail send in this plan must route through, per C-05/P4 D-08),
|
||||
../fonoteka.go/plugins/golem15/user/models/user.go (from 07-02 — ResetPasswordCode/ResetPasswordCodeIssuedAt/ActivationCode/ActivationCodeIssuedAt fields),
|
||||
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 180-245, 638-715 (activate/activateByCode/forgotPassword/resetPassword — already read this session),
|
||||
/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php lines 209-292 (activation/reset code semantics, no expiry — already read this session),
|
||||
@@ -127,7 +131,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
Create `classes/codes.go`: `func newCode() (string, error)` — 32 random bytes via `crypto/rand`, hex-encoded (64 chars; PHP's own generation algorithm need not be reproduced bit-for-bit, only the stored plain-text format and the `"{id}!{code}"` link format matter for cutover compatibility, D-15). `func IssueResetCode(ctx, db *gorm.DB, user *models.User) (code string, err error)` and `IssueActivationCode(...)` — generate, `UpdateColumns` the code + its issued-at column to `time.Now()`, return the raw code. `func VerifyResetCode(ctx, db, userID uint, code string) (*models.User, bool)` and `VerifyActivationCode(...)`: load the user by id (do not filter `deleted_at` for activation — a trashed user restoring via a matching activation code is a real PHP path, `attemptActivation`'s trashed branch), compute the TTL cutoff (`issuedAt` = the column's value, or `time.Now()` when the column is `NULL` — D-15's cutover rule — compared against `time.Now()`), reject if past cutoff, `subtle.ConstantTimeCompare([]byte(code), []byte(stored)) == 1` (pad/compare lengths safely — `ConstantTimeCompare` requires equal-length slices; a length mismatch is simply "not equal", checked before calling it, still without a data-dependent branch on the code's actual bytes) . On success for reset: also flip `has_self_set_password=true` (D-15/k7ut351s-equivalent parity, mirrors PHP's `attemptResetPassword`) and clear the code; caller sets the new password. On success for activation: set `is_activated=true`, `activated_at=now`, clear `activation_code`; if the user was soft-deleted, `db.Unscoped().Model(user).Update("deleted_at", nil)` first (the trashed branch).
|
||||
|
||||
In `controllers/api_controller.go`, add:
|
||||
- `ForgotPassword(app)`: validate `email` (`required|email|between:6,255`) → 422 on failure. Look up by email; if found, `IssueResetCode`, build the reset link (`golem15.user.reset_url_base` config, default `<app.url>/reset-password`, `?code=<id>!<code>`), send `mail.restore` (vars `name, link, code`) through `postcard.Mailer` resolved via `app.Lookup[postcard.Mailer]()` — log-and-continue on a send error (D-18, never surface it). Always return `{"message":"If that email exists, a reset link has been sent."}`,200 regardless of any branch above.
|
||||
- `ForgotPassword(app)`: validate `email` (`required|email|between:6,255`) → 422 on failure. Look up by email; if found, `IssueResetCode`, build the reset link (`golem15.user.reset_url_base` config, default `<app.url>/reset-password`, `?code=<id>!<code>`), send `mailTemplate("golem15.user::mail.restore", resolveMailLocale(ctx, user))` (vars `name, link, code`) through `postcard.Mailer` resolved via `app.Lookup[postcard.Mailer]()` — log-and-continue on a send error (D-18, never surface it). `mailTemplate`/`resolveMailLocale` are the 07-02 Task 2 helpers (`classes/mail.go`) — reuse them here verbatim, do not hardcode `"golem15.user::mail.restore"` as the final template name. Always return `{"message":"If that email exists, a reset link has been sent."}`,200 regardless of any branch above.
|
||||
- `ResetPassword(app)`: validate `code` (`required`) and `password` (`required|between:8,255|confirmed`) → 422 on failure with the flattened-errors envelope. Split `code` on `!`; not exactly 2 parts → `{"error":"Invalid reset code"}`,422. `VerifyResetCode`; failure → `{"error":"Invalid or expired reset code"}`,422. Success: hash the new password, save, set `TokensValidAfter = time.Now()` (D-20 — unauthenticated flow, no presenting token to exempt), `{"message":"Password has been reset"}`,200.
|
||||
- `Activate(app)`: per-handler Bearer-only auth (`bouncer.NewJWTGuard(secret, users, blacklist).Authenticate(r)`, failure → the standard `{"error":true,"message":"Unauthorized"}`,401). Call `VerifyActivationCode(ctx, db, user.ID, r.Form.Get("code"))` and IGNORE its boolean result — always return `{"user": <apiArray>}`,200 (reproducing the missing `if` at ApiController.php:184, confirmed this session — this is a deliberate PHP quirk, not a bug to silently fix).
|
||||
- `ActivateByCode(app)`: no auth. Validate `code` (`required`) → 422. Split on `!`; not 2 parts → `{"error":"Invalid activation code"}`,422. Load user by id; `VerifyActivationCode` fails or user missing → `{"error":"This activation link is invalid or has expired"}`,422. Success: mint a token (`bouncer.Mint`, `issuerURL` = this endpoint's own URL), `{"message":"Account activated","token":token,"user":<apiArray>}`,200.
|
||||
@@ -143,6 +147,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
- `Activate` (authenticated) returns 200 `{"user":{...}}` even when the supplied code is wrong, with `is_activated` unchanged — no `if` guard on the boolean result
|
||||
- `ActivateByCode` on a valid `"{id}!{code}"` returns 200 with a fresh token and `is_activated:true`
|
||||
- A row with `reset_password_code_issued_at = NULL` inserted directly via SQL is treated as issued now and accepts its code within one full TTL
|
||||
- `ForgotPassword`'s `mail.restore` template-name computation calls `mailTemplate`/`resolveMailLocale`, not a hardcoded string literal
|
||||
- `go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate'` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>All four handlers reproduce their PHP status/body pairs including the asymmetric authenticated-activate no-op-on-failure behavior; codes compare in constant time and honor the TTL and null-issued-at cutover rule.</done>
|
||||
@@ -222,6 +227,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
- Test (avatar upload): a multipart POST with a valid small JPEG under `avatar` returns 200 `{"message":"Avatar updated","user":{...,"has_avatar":true,"avatar_url":"<128 thumb URL>"}}`; a `.svg` (or any non-`jpeg,jpg,png,webp,gif` sniffed content type) returns 422 `{"error":"...","errors":{"avatar":[...]}}`; an oversized file (>4000 KB) returns 422 with an avatar-size error; a request with no `avatar` field returns 422.
|
||||
- Test (avatar remove): removing an existing avatar returns 200 `{"message":"Avatar removed","user":{...,"has_avatar":false}}` and the underlying `system_files` row plus its blob are gone; removing when none exists returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`.
|
||||
- Test (mail): `postcard`'s `memory` driver captures a `mail.activate` send with `Vars["link"]` containing the generated activation code, when `Register`'s `user`-activation-mode branch runs (wire this test through the shared `postcard.Mailer` the plugin now publishes/resolves, matching the Phase 4 `memory`-driver assertion pattern).
|
||||
- Test (mail locale suffix, BLOCKER fix): register two users, one with `PreferredLocale="pl"` and one with `PreferredLocale="en"`, and trigger `ForgotPassword` for each. The `memory` driver's captured `Message.Template` is exactly `"golem15.user::mail.restore"` for the `pl` user and exactly `"golem15.user::mail.restore-en"` for the `en` user -- proving `mailTemplate`/`resolveMailLocale` actually drive the real send, not just the pure-function unit test from 07-02. Repeat the same assertion shape for `mail.activate` (Register, user-mode) and `mail.reactivate` (Login, soft-delete restore) so all three call sites are covered by an end-to-end locale-suffix test, not just one.
|
||||
- Test (console command): `user:require-password-change alice@example.com` sets `must_change_password=true` for that user and prints a success line; an unknown email returns a command error, not a panic; the command is discoverable via `pact.HasCommands`.
|
||||
</behavior>
|
||||
<action>
|
||||
@@ -247,9 +253,10 @@ func attach.PartitionDirectory(diskName string) string
|
||||
- A `.svg` (or any content type outside jpeg/jpg/png/webp/gif by sniffed MIME) returns 422 with an `errors.avatar` key
|
||||
- `RemoveAvatar` with no existing avatar returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`
|
||||
- The `postcard` memory driver records a `mail.activate` send with a non-empty `link`/`code` var when register's user-mode branch runs
|
||||
- A `pl`-locale user's forgot-password send captures `Message.Template == "golem15.user::mail.restore"`; an `en`-locale user's captures `"golem15.user::mail.restore-en"` -- same pl/en pair proven for mail.activate and mail.reactivate
|
||||
- `user:require-password-change alice@example.com` sets `must_change_password=true` for that row and is listed by `Commands()`
|
||||
</acceptance_criteria>
|
||||
<done>Avatar upload/remove round-trip through Phase 5's attach primitives with the exact D-04 payload shape; all three mail templates render and send through the memory driver in tests; the console command sets must_change_password and is discoverable via Commands().</done>
|
||||
<done>Avatar upload/remove round-trip through Phase 5's attach primitives with the exact D-04 payload shape; all three mail templates render and send through the memory driver in tests, with pl/en locale-suffix selection proven end to end for all three call sites; the console command sets must_change_password and is discoverable via Commands().</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
Reference in New Issue
Block a user