Files
RenewCMS/features/stale_token_invalidation.qa.md
T

6.5 KiB

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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)

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