Files
RenewCMS/features/stale_token_invalidation.qa.md

94 lines
6.5 KiB
Markdown

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