# QA procedure: API keys Package: `test` (black-box). No live server, no real SQLite file, no real SMTP. ## Setup shared by every case 1. Call `validator.InitValidator()` once per test. 2. Add in-memory fakes with unique type names across the package: `mockAPIKeyRepo` (implements `apikey.Repository` including `RevokeAllByUser`, with a `fail` switch per method such as `failNext("TouchLastUsed")`, a `touchCalls` counter and a `revokeAllCalls` log of `(userId, at)`) and `stubKeyGenerator` (deterministic raw/prefix/hash). Reuse `mockUserRepo`, `stubHasher` and the article, media and invite fakes. 3. HTTP tests use the real SHA-256 generator, so key format and hashing are real. Unit-level tests use `stubKeyGenerator`. 4. Build the handler stack from a `*api.UseCases` container holding fakes and mount `api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). It is the most faithful way to test the middleware order and the exclusions. If a nil use case breaks a handler constructor, fall back to a fresh `chi.NewRouter()` that mirrors `api/router.go`: `jwtauth.Verifier`, the key middleware, `jwtauth.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser` (and `RequireOwner` on the invite group) for the key-accepting routes (including `GET /auth/me`); `RequireSession` ahead of `jwtauth.Verifier` on `/api-key`, `POST /auth/verify`, `DELETE /auth/register` and `/system`; and the public auth routes with neither. The test token is `jwtauth.New("HS256", []byte("test-secret"), nil)`. 5. Inject a fake clock (`now func() time.Time`) into the key use cases and `ResetPasswordUseCase`. 6. Seed users: owner 1 "marta", admin 2 "dev", admin 3 "pat" (all verified), unverified 4 "quinn". 7. Helpers: `cookieFor(userId, tokenVersion)` mints a jwt cookie with `token.Encode(map[string]any{"user_id": id, "token_version": v})`; `bearer(raw)` sets the header. Create real keys through `POST /v1/api-key` with the cookie so the shown-once value is genuine. ## POST /v1/api-key 8. Admin 2 cookie, `{"name":"ci-deploy"}` -> `201`, `Cache-Control: no-store`, key set is exactly `id`, `name`, `prefix`, `key`, `created_at`, `expires_at`; `key` matches `^rcms_[A-Za-z0-9_-]{43}$`; prefix is `key[:12]`; stored `HashedKey` equals the generator's `Hash(key)`; `UserID` 2; the raw key is stored nowhere in the fake. 9. Two creations give two different keys and ids. 10. With `expires_at` 30 days ahead -> `201`, echoed instant. 11. Invalid bodies (table): not-json -> `400` `apperr.BodyErrorMessage`; `{}`, `{"name":""}` -> `400`, body contains `'Name'` and `required`; 61-char name -> `400`, contains `max`; `expires_at` "yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`. Each: repository still empty. 12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite. 13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`; stale `token_version` -> `401`. Each: repository empty. ## Shown once 14. After case 8, `GET /v1/api-key` -> entry keys exactly `id`, `user_id`, `name`, `prefix`, `status`, `created_at`, `last_used_at`, `expires_at`, `revoked_at`; assert the response bytes contain neither the raw key nor the stored hash. Also assert the raw key and hash are absent from the `DELETE` response and from every error body in this file. 15. There is no route that returns a key again: `GET /v1/api-key/{id}` -> `404` or `405` (whatever the router yields; assert not `200`). ## Authentication 16. Table over `GET /v1/settings`, `GET /v1/user`, `PATCH /v1/user/me`, `POST /v1/article`, `PATCH /v1/article/{id}/publish`, `DELETE /v1/article/{id}`, `GET /v1/media`, `DELETE /v1/media/{id}` with only the Bearer key of admin 2 -> `200` each; the stored side effects (author, display name) belong to user 2. 17. Public article routes: seed offline article 5, online article 6. Key -> list has both, `GET /5` is `200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article` -> `401` `apperr.UnauthorizedErrorMessage`. 18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403` `apperr.OwnerRequiredErrorMessage`, no invite and no mail. 19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only): unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from the user fake; creator unverified (set `IsVerified=false` in the fake); `Bearer rcms_`; one character changed; the stored hash sent as the key. Assert each also leaves `touchCalls == 0`. Without an `rcms_` key the existing `jwtauth.Authenticator` response is unchanged: assert status `401`. 20. A key whose `ExpiresAt` equals the clock's now is rejected; one second later than now is accepted. 21. Cookie path unchanged: valid cookie -> `200`; valid jwt in `Authorization: Bearer` with no cookie -> `200`; stale `token_version` -> `401`; no credentials on a protected route -> `401`. 22. Precedence: valid key of admin 3 plus an owner cookie on `POST /v1/user/invite` -> `403` (acts as admin 3); `Bearer rcms_unknown` plus an owner cookie on `GET /v1/settings` -> `401`. 23. Role is read live: change the stored role of user 2 from admin to owner in the fake (test-only) and assert the key is now owner-capable, then back, and assert it is not. This pins "never more rights than the creator's current rights". 24. `GET /v1/auth/me` with only admin 2's key -> `200`, body byte-equal to the body admin 2's cookie gets on the same route, describing user 2. `Bearer rcms_unknown` on `GET /v1/auth/me` -> `401` `apperr.UnauthorizedErrorMessage`. ## Session-only routes (RequireSession) 25. Table, each with body `apperr.SessionRequiredErrorMessage`, status `403`, assert the underlying use cases (key repository, verify, cancel registration, update status/check/download/restart) were not invoked and the key repository is unchanged: - owner 1's active key on `POST /v1/api-key`, `GET /v1/api-key`, `DELETE /v1/api-key/1`, `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `GET /v1/system/update`, `POST /v1/system/update/check`, `POST /v1/system/update/download`, `POST /v1/system/update/restart`; - a revoked key on `GET /v1/api-key`; - an unknown well-formed `rcms_...` key on `POST /v1/system/update/check`; - the bare string `rcms_` on `DELETE /v1/auth/register`. The 403 comes before any authentication, so assert the same 403 for an owner key and a garbage key. 26. Session routes unchanged for sessions: owner cookie on `POST /v1/api-key`, `GET /v1/api-key`, `GET /v1/system/update`, `POST /v1/system/update/check` -> the same status and body as before this feature; no credentials -> status `401` (existing Authenticator); a jwt in `Authorization: Bearer` (not starting with `rcms_`) -> the same result as the cookie. 27. Public auth routes with an owner key attached: `POST /v1/auth/login` with wrong credentials -> `403` `apperr.LogsErrorMessage`, no `jwt` Set-Cookie; `POST /v1/auth/password-reset/confirm` with a wrong code -> the same response as without the key, and the key is not rejected with `SessionRequiredErrorMessage`. The user's stored password hash and `TokenVersion` are unchanged. ## Password reset revokes keys 28. Seed user 2 with active keys "a" and "b" (each `200` on `GET /v1/settings`), user 3 with key "c", and a reset code for user 2 (hash of "reset-code-1", unexpired). `POST /v1/auth/password-reset/confirm` `{"email":"dev@example.com","code":"reset-code-1","password":"newpassword1"}` -> `200`; then keys "a" and "b" get `401` `apperr.UnauthorizedErrorMessage` on `GET /v1/settings`. 29. Same seed: key "c" still gets `200` and has no `revoked_at`. 30. Same seed with the fake clock at T: log in as user 2 with the new password, `GET /v1/api-key` with the new cookie; "a" and "b" have status `revoked` and `revoked_at` equal to T. 31. A key of user 2 already revoked at T0 keeps `revoked_at` T0 after a reset at a later T; the active key gets T. 32. Failed confirms (table: wrong code; expired code; unknown email) -> status not `200`; key "a" has no `revoked_at`, still `200` on `GET /v1/settings`, and `revokeAllCalls` is empty. ## Listing 33. Admin 2 sees only own keys; owner 1 sees all, newest first, each with its `user_id`. 34. Status table: active never used (`active`, nulls), active used (timestamp kept), expired (`expired`), revoked (`revoked`, `revoked_at` set), expired-and-revoked (`revoked`). ## Revoke 35. Own key -> `204`, empty body; the key then gets `401` on `GET /v1/settings`; the list shows `revoked`. 36. Second `DELETE` -> `204`, `revoked_at` unchanged. 37. Admin 2 revoking admin 3's key -> `404` `apperr.APIKeyNotFoundErrorMessage`; the key still works. Owner revoking it -> `204`; it then gets `401`. 38. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`; unknown id -> `404`. 39. Repository failure on `Revoke` -> `500` "Internal server error"; the key still works. ## last_used_at 40. Use-case level with the fake clock: first auth at T writes T; at T+30s no write (`touchCalls` unchanged); at T+61s writes T+61s. 41. `TouchLastUsed` failing -> the request still returns `200`. 42. Rejected keys (case 19) never write. ## Rate limit 43. Mirror a group using the new per-key limiter with a limit of 2 (an exported constructor taking max attempts and window): third request with key A -> `429`, body `apperr.TooManyRequestsErrorMessage`, `Retry-After` header; key B and a cookie request still `200`; invalid keys stay `401`, never `429`. 44. Through `api.InitBackendRoutes`, send 121 requests with one key to `GET /v1/settings` -> the 121st is `429`, the first 120 are `200`. ## CORS 45. Through `api.InitRoutes` with `CorsAllowedOrigins` `["https://app.example.com"]`: `OPTIONS /v1/article/1` with `Origin` and `Access-Control-Request-Method: PATCH` -> success, `Access-Control-Allow-Methods` includes `PATCH`; with `TRACE` it is not allowed. ## Use case level (no HTTP) 46. `CreateAPIKey` with `stubKeyGenerator`: repository receives the hash and the prefix and the user id; the raw key appears only in the return value; a generator error -> returned, nothing stored; past expiry -> `errors.Is(err, apikey.ErrExpiryNotInFuture)`. 47. `AuthenticateAPIKey`: each invalid case in 19 and 20 -> `errors.Is(err, apikey.ErrAPIKeyInvalid)`; a valid key returns the user; an unexpected repository error is returned and is not `ErrAPIKeyInvalid`. 48. `ListAPIKeys` for admin and owner; `RevokeAPIKey` own, other's (`ErrAPIKeyNotFound` for admin), owner any, unknown (`ErrAPIKeyNotFound`). 49. `ResetPasswordUseCase` built over `mockUserRepo`, `stubHasher`, `mockAPIKeyRepo` and a fake clock at T: a successful reset for user 2 -> exactly one `RevokeAllByUser(2, T)` and user 3's keys untouched; a wrong or expired code -> the existing sentinel and `revokeAllCalls` empty; `RevokeAllByUser` failing -> that error is returned (the password was already changed). 50. `mockAPIKeyRepo.RevokeAllByUser` semantics: sets `RevokedAt` only on that user's not-yet-revoked keys. 51. SHA-256 generator: raw matches the regex, `Hash(raw)` equals the generated hash, two generated keys differ, prefix is the first 12 characters. ## Manual verification (not exercised by the Go tests) - Real SQLite: start `task dev:api`, confirm `AutoMigrate` creates the api_keys table with a unique index on the hashed key, create a key, restart, and check it still authenticates. - With `curl`, run `Authorization: Bearer ` against a running server for an article create, a media upload, an owner invite and `GET /v1/auth/me`, and confirm `PATCH` works from a browser origin listed in `CORS_ALLOWED_ORIGINS`. - Confirm the full key is shown only in the creation response and appears in no server log (`ENVIRONMENT=development` enables httplog). - A real reverse proxy: confirm `Authorization` is forwarded and `Retry-After` reaches the client. - A real password reset by email: confirm the keys stop working after the link is used.