fix: resetting the password or cancelling a registration ends existing sessions: a token issued before the change is refused
This commit is contained in:
1 parent
81c4b62175
commit
f3ec3d2fa2
15 files changed
+844
-13
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# QA procedure: stale_token_invalidation
|
||||
|
||||
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`) and reusing the
|
||||
router/handler wiring pattern from `test/account_verification_http_test.go` and
|
||||
`test/reset_password_http_test.go` (or equivalent).
|
||||
|
||||
## Test infrastructure changes needed
|
||||
|
||||
1. `mockUserRepo` needs a `TokenVersion uint32` field on its stored `user.User` values, and its
|
||||
`ResetPassword` fake must increment it by 1 alongside setting the password hash and clearing the
|
||||
reset code, mirroring the production contract this slice adds.
|
||||
2. HTTP-level checks wire `mockUserRepo`, `useCases.NewResetPasswordUseCase(repo, hasher)`,
|
||||
`useCases.NewAuthenticateUserUseCase(repo, hasher)`, `useCases.NewGetUserUseCase(repo)`, the
|
||||
article use cases, and the rest of the existing container into `auth.NewHandler(...)` and
|
||||
`article.NewHandler(...)` with a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||
3. Build two `chi.NewRouter()` instances (or one router mounting both) that mirror
|
||||
`api/router.go`:
|
||||
- the article protected group: `jwtauth.Verifier(token)` → (mounted once, top-level, per
|
||||
`InitBackendRoutes`) → inside the group: `jwtauth.Authenticator(token)` →
|
||||
`middleware.RequireCurrentToken(getUserUseCase)` → `middleware.RequireVerifiedUser(getUserUseCase)`
|
||||
→ `POST /article`;
|
||||
- the auth protected group: `jwtauth.Verifier(token)` → `jwtauth.Authenticator(token)` →
|
||||
`middleware.RequireCurrentToken(getUserUseCase)` → `POST /verify`, `DELETE /register`;
|
||||
- the auth public routes `POST /login`, `POST /password-reset/confirm`, `POST /register` mounted
|
||||
unprotected, as today.
|
||||
4. Call `validator.InitValidator()` once per test.
|
||||
5. Mint jwt cookies directly with `token.Encode(map[string]any{"user_id": uint32(7), "token_version":
|
||||
uint32(0)})` for "pre-reset" cookies; decode a `Set-Cookie` response header's jwt with
|
||||
`jwtauth.New(...).Decode(tokenString)` or by parsing the JWT payload to assert its
|
||||
`token_version` claim, mirroring how `secureAttributeFromResponse` in
|
||||
`test/runtime_settings_injection_http_test.go` already parses `Set-Cookie`.
|
||||
|
||||
## HTTP-level checks
|
||||
|
||||
6. Seed user id 7 "alice", `is_verified: true`, `TokenVersion: 0`, `PasswordResetCode` = bcrypt hash
|
||||
of "reset-code-1", `PasswordResetExpiration` one hour ahead. Mint a jwt cookie encoding
|
||||
`{"user_id": 7, "token_version": 0}`.
|
||||
- `POST /v1/article` with that cookie and a valid body → assert `200`.
|
||||
- `POST /v1/auth/password-reset/confirm` with
|
||||
`{"email":"alice@example.com","code":"reset-code-1","password":"newpassword1"}` → assert `200`
|
||||
and `repo.users[7].TokenVersion == 1`.
|
||||
- `POST /v1/article` again with the *same* pre-reset cookie → assert `401`, body equals
|
||||
`apperr.UnauthorizedErrorMessage`, and the article use case's create path was not invoked
|
||||
(article count in the fake repo unchanged).
|
||||
- `POST /v1/auth/login` with `{"username":"alice","password":"newpassword1"}` → assert `200`, a
|
||||
`jwt` `Set-Cookie` is present, and decoding it shows `token_version == 1`.
|
||||
- `POST /v1/article` with that new cookie → assert `200`.
|
||||
7. Same seed as case 6 minus the article calls. After the password reset:
|
||||
- `POST /v1/auth/verify` with the pre-reset cookie and `{"code":"anything"}` → assert `401`, body
|
||||
equals `apperr.UnauthorizedErrorMessage`.
|
||||
- `DELETE /v1/auth/register` with the pre-reset cookie → assert `401`, body equals
|
||||
`apperr.UnauthorizedErrorMessage`, and `repo.Get(7)` still succeeds.
|
||||
8. Double-reset case: seed as in 6, reset once (`TokenVersion` → 1), log in (capture "interim"
|
||||
cookie, `token_version: 1`), re-seed `PasswordResetCode` to bcrypt hash of "reset-code-2" with a
|
||||
fresh expiration, reset again with `{"email":"alice@example.com","code":"reset-code-2","password":"newerpassword1"}`
|
||||
→ assert `TokenVersion == 2`. Assert both the original (`token_version: 0`) and interim
|
||||
(`token_version: 1`) cookies now get `401` on `POST /v1/article`, and a jwt minted after logging
|
||||
in with `newerpassword1` (`token_version: 2`) gets `200`.
|
||||
9. Isolation case: seed user id 7 "alice" as in 6 and user id 9 "bob", `is_verified: true`,
|
||||
`TokenVersion: 0`. Mint a cookie for user id 9 encoding `token_version: 0`. Reset alice's
|
||||
password. Assert `repo.users[9].TokenVersion` is still `0`, and `POST /v1/article` with bob's
|
||||
cookie still returns `200`.
|
||||
10. Cancelled-registration regression, table-driven over `POST /v1/auth/verify` (body
|
||||
`{"code":"anything"}`) and `DELETE /v1/auth/register` (no body): seed user id 7 "alice",
|
||||
`is_verified: false`, `TokenVersion: 0`. Mint a cookie encoding `{"user_id":7,"token_version":0}`.
|
||||
`DELETE /v1/auth/register` with it → assert `200`. Then send the pre-cancellation cookie against
|
||||
each of the two routes in turn (fresh repo/router state per sub-case, or re-running the cancel
|
||||
step) → assert `401` and body `apperr.UnauthorizedErrorMessage` for both.
|
||||
11. Missing-claim case: seed user id 7, `is_verified: true`, `TokenVersion: 0`. Mint a jwt with
|
||||
`token.Encode(map[string]any{"user_id": uint32(7)})` (no `token_version` key at all).
|
||||
`POST /v1/article` → assert `401`, body `apperr.UnauthorizedErrorMessage`.
|
||||
12. Wrong-type case: mint a jwt with
|
||||
`token.Encode(map[string]any{"user_id": uint32(7), "token_version": "0"})` (string, not a
|
||||
number). `POST /v1/article` → assert `401`, body `apperr.UnauthorizedErrorMessage`, and confirm
|
||||
the test process does not panic (no `recover` needed — a crash would fail the test run outright).
|
||||
|
||||
## Use-case-level checks (no HTTP)
|
||||
|
||||
13. `mockUserRepo` seeded with user id 3, email "alice@example.com", `TokenVersion: 5`,
|
||||
`PasswordResetCode` = bcrypt hash of "reset-code-1", expiration one hour ahead. Build
|
||||
`ResetPasswordUseCase` directly over the fake. Call
|
||||
`ResetPassword("alice@example.com", "reset-code-1", "newpassword1")` → assert `err == nil` and
|
||||
`repo.users[3].TokenVersion == 6`.
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
|
||||
- The SPA behaviour of being logged out / redirected to login when an API call comes back `401`
|
||||
after a password reset or a cancelled registration (wherever that's handled globally, e.g. an
|
||||
axios/fetch interceptor) is out of scope for this slice's automated test and should be checked by
|
||||
hand against a running dev build.
|
||||
- Confirming against the real SQLite file that `TokenVersion` persists correctly through
|
||||
`gorm.AutoMigrate` for existing rows (defaulting to `0`) is a manual check against a real
|
||||
migration, not re-verified by the in-memory fakes here.
|
||||
Reference in new issue
Block a user