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