Files
RenewCMS/features/api_keys.qa.md
T

12 KiB

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

  1. 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.
  2. Two creations give two different keys and ids.
  3. With expires_at 30 days ahead -> 201, echoed instant.
  4. 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.
  5. 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.
  6. Access table: no credentials -> 401; user 4 cookie -> 403 apperr.VerificationRequiredErrorMessage; stale token_version -> 401. Each: repository empty.

Shown once

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

  1. 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.
  2. 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.
  3. Owner key POST /v1/user/invite -> 201, invited_by 1. Admin key -> 403 apperr.OwnerRequiredErrorMessage, no invite and no mail.
  4. 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.
  5. A key whose ExpiresAt equals the clock's now is rejected; one second later than now is accepted.
  6. 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.
  7. 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.
  8. 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".
  9. 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)

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

  1. 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.
  2. Same seed: key "c" still gets 200 and has no revoked_at.
  3. 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.
  4. 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.
  5. 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

  1. Admin 2 sees only own keys; owner 1 sees all, newest first, each with its user_id.
  2. Status table: active never used (active, nulls), active used (timestamp kept), expired (expired), revoked (revoked, revoked_at set), expired-and-revoked (revoked).

Revoke

  1. Own key -> 204, empty body; the key then gets 401 on GET /v1/settings; the list shows revoked.
  2. Second DELETE -> 204, revoked_at unchanged.
  3. Admin 2 revoking admin 3's key -> 404 apperr.APIKeyNotFoundErrorMessage; the key still works. Owner revoking it -> 204; it then gets 401.
  4. Ids abc, -1, 4294967296 -> 400 apperr.IdUint32ErrorMessage; unknown id -> 404.
  5. Repository failure on Revoke -> 500 "Internal server error"; the key still works.

last_used_at

  1. 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.
  2. TouchLastUsed failing -> the request still returns 200.
  3. Rejected keys (case 19) never write.

Rate limit

  1. 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.
  2. Through api.InitBackendRoutes, send 121 requests with one key to GET /v1/settings -> the 121st is 429, the first 120 are 200.

CORS

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

  1. 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).
  2. 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.
  3. ListAPIKeys for admin and owner; RevokeAPIKey own, other's (ErrAPIKeyNotFound for admin), owner any, unknown (ErrAPIKeyNotFound).
  4. 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).
  5. mockAPIKeyRepo.RevokeAllByUser semantics: sets RevokedAt only on that user's not-yet-revoked keys.
  6. 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 <key> 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.