12 KiB
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
- Call
validator.InitValidator()once per test. - Add in-memory fakes with unique type names across the package:
mockAPIKeyRepo(implementsapikey.RepositoryincludingRevokeAllByUser, with afailswitch per method such asfailNext("TouchLastUsed"), atouchCallscounter and arevokeAllCallslog of(userId, at)) andstubKeyGenerator(deterministic raw/prefix/hash). ReusemockUserRepo,stubHasherand the article, media and invite fakes. - HTTP tests use the real SHA-256 generator, so key format and hashing are real. Unit-level tests
use
stubKeyGenerator. - Build the handler stack from a
*api.UseCasescontainer holding fakes and mountapi.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 freshchi.NewRouter()that mirrorsapi/router.go:jwtauth.Verifier, the key middleware,jwtauth.Authenticator,RequireCurrentToken,RequireVerifiedUser(andRequireOwneron the invite group) for the key-accepting routes (includingGET /auth/me);RequireSessionahead ofjwtauth.Verifieron/api-key,POST /auth/verify,DELETE /auth/registerand/system; and the public auth routes with neither. The test token isjwtauth.New("HS256", []byte("test-secret"), nil). - Inject a fake clock (
now func() time.Time) into the key use cases andResetPasswordUseCase. - Seed users: owner 1 "marta", admin 2 "dev", admin 3 "pat" (all verified), unverified 4 "quinn".
- Helpers:
cookieFor(userId, tokenVersion)mints a jwt cookie withtoken.Encode(map[string]any{"user_id": id, "token_version": v});bearer(raw)sets the header. Create real keys throughPOST /v1/api-keywith the cookie so the shown-once value is genuine.
POST /v1/api-key
- Admin 2 cookie,
{"name":"ci-deploy"}->201,Cache-Control: no-store, key set is exactlyid,name,prefix,key,created_at,expires_at;keymatches^rcms_[A-Za-z0-9_-]{43}$; prefix iskey[:12]; storedHashedKeyequals the generator'sHash(key);UserID2; the raw key is stored nowhere in the fake. - Two creations give two different keys and ids.
- With
expires_at30 days ahead ->201, echoed instant. - Invalid bodies (table): not-json ->
400apperr.BodyErrorMessage;{},{"name":""}->400, body contains'Name'andrequired; 61-char name ->400, containsmax;expires_at"yesterday" ->400BodyErrorMessage; pastexpires_at->400apperr.APIKeyExpiryErrorMessage. Each: repository still empty. - Body
{"name":"sneaky","user_id":1,"role":"owner"}with admin 2 ->201; storedUserID2; that key onPOST /v1/user/invite->403apperr.OwnerRequiredErrorMessage, no invite. - Access table: no credentials ->
401; user 4 cookie ->403apperr.VerificationRequiredErrorMessage; staletoken_version->401. Each: repository empty.
Shown once
- After case 8,
GET /v1/api-key-> entry keys exactlyid,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 theDELETEresponse and from every error body in this file. - There is no route that returns a key again:
GET /v1/api-key/{id}->404or405(whatever the router yields; assert not200).
Authentication
- 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 ->200each; the stored side effects (author, display name) belong to user 2. - Public article routes: seed offline article 5, online article 6. Key -> list has both,
GET /5is200. No credentials -> list has only 6,GET /5is404.Bearer rcms_unknownonGET /v1/article->401apperr.UnauthorizedErrorMessage. - Owner key
POST /v1/user/invite->201,invited_by1. Admin key ->403apperr.OwnerRequiredErrorMessage, no invite and no mail. - Table of 401 cases on
GET /v1/settings, all with bodyapperr.UnauthorizedErrorMessageexactly (the stored-hash case has norcms_prefix, so it takes the jwt path: assert status401only): unknown well-formed key; expired (clock one second pastexpires_at); revoked; creator deleted from the user fake; creator unverified (setIsVerified=falsein the fake);Bearer rcms_; one character changed; the stored hash sent as the key. Assert each also leavestouchCalls == 0. Without anrcms_key the existingjwtauth.Authenticatorresponse is unchanged: assert status401. - A key whose
ExpiresAtequals the clock's now is rejected; one second later than now is accepted. - Cookie path unchanged: valid cookie ->
200; valid jwt inAuthorization: Bearerwith no cookie ->200; staletoken_version->401; no credentials on a protected route ->401. - Precedence: valid key of admin 3 plus an owner cookie on
POST /v1/user/invite->403(acts as admin 3);Bearer rcms_unknownplus an owner cookie onGET /v1/settings->401. - 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".
GET /v1/auth/mewith 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_unknownonGET /v1/auth/me->401apperr.UnauthorizedErrorMessage.
Session-only routes (RequireSession)
- Table, each with body
apperr.SessionRequiredErrorMessage, status403, 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 onPOST /v1/system/update/check; - the bare string
rcms_onDELETE /v1/auth/register. The 403 comes before any authentication, so assert the same 403 for an owner key and a garbage key.
- owner 1's active key on
- 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 -> status401(existing Authenticator); a jwt inAuthorization: Bearer(not starting withrcms_) -> the same result as the cookie. - Public auth routes with an owner key attached:
POST /v1/auth/loginwith wrong credentials ->403apperr.LogsErrorMessage, nojwtSet-Cookie;POST /v1/auth/password-reset/confirmwith a wrong code -> the same response as without the key, and the key is not rejected withSessionRequiredErrorMessage. The user's stored password hash andTokenVersionare unchanged.
Password reset revokes keys
- Seed user 2 with active keys "a" and "b" (each
200onGET /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" get401apperr.UnauthorizedErrorMessageonGET /v1/settings. - Same seed: key "c" still gets
200and has norevoked_at. - Same seed with the fake clock at T: log in as user 2 with the new password,
GET /v1/api-keywith the new cookie; "a" and "b" have statusrevokedandrevoked_atequal to T. - A key of user 2 already revoked at T0 keeps
revoked_atT0 after a reset at a later T; the active key gets T. - Failed confirms (table: wrong code; expired code; unknown email) -> status not
200; key "a" has norevoked_at, still200onGET /v1/settings, andrevokeAllCallsis empty.
Listing
- Admin 2 sees only own keys; owner 1 sees all, newest first, each with its
user_id. - Status table: active never used (
active, nulls), active used (timestamp kept), expired (expired), revoked (revoked,revoked_atset), expired-and-revoked (revoked).
Revoke
- Own key ->
204, empty body; the key then gets401onGET /v1/settings; the list showsrevoked. - Second
DELETE->204,revoked_atunchanged. - Admin 2 revoking admin 3's key ->
404apperr.APIKeyNotFoundErrorMessage; the key still works. Owner revoking it ->204; it then gets401. - Ids
abc,-1,4294967296->400apperr.IdUint32ErrorMessage; unknown id ->404. - Repository failure on
Revoke->500"Internal server error"; the key still works.
last_used_at
- Use-case level with the fake clock: first auth at T writes T; at T+30s no write
(
touchCallsunchanged); at T+61s writes T+61s. TouchLastUsedfailing -> the request still returns200.- Rejected keys (case 19) never write.
Rate limit
- 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, bodyapperr.TooManyRequestsErrorMessage,Retry-Afterheader; key B and a cookie request still200; invalid keys stay401, never429. - Through
api.InitBackendRoutes, send 121 requests with one key toGET /v1/settings-> the 121st is429, the first 120 are200.
CORS
- Through
api.InitRouteswithCorsAllowedOrigins["https://app.example.com"]:OPTIONS /v1/article/1withOriginandAccess-Control-Request-Method: PATCH-> success,Access-Control-Allow-MethodsincludesPATCH; withTRACEit is not allowed.
Use case level (no HTTP)
CreateAPIKeywithstubKeyGenerator: 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).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 notErrAPIKeyInvalid.ListAPIKeysfor admin and owner;RevokeAPIKeyown, other's (ErrAPIKeyNotFoundfor admin), owner any, unknown (ErrAPIKeyNotFound).ResetPasswordUseCasebuilt overmockUserRepo,stubHasher,mockAPIKeyRepoand a fake clock at T: a successful reset for user 2 -> exactly oneRevokeAllByUser(2, T)and user 3's keys untouched; a wrong or expired code -> the existing sentinel andrevokeAllCallsempty;RevokeAllByUserfailing -> that error is returned (the password was already changed).mockAPIKeyRepo.RevokeAllByUsersemantics: setsRevokedAtonly on that user's not-yet-revoked keys.- 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, confirmAutoMigratecreates the api_keys table with a unique index on the hashed key, create a key, restart, and check it still authenticates. - With
curl, runAuthorization: Bearer <key>against a running server for an article create, a media upload, an owner invite andGET /v1/auth/me, and confirmPATCHworks from a browser origin listed inCORS_ALLOWED_ORIGINS. - Confirm the full key is shown only in the creation response and appears in no server log
(
ENVIRONMENT=developmentenables httplog). - A real reverse proxy: confirm
Authorizationis forwarded andRetry-Afterreaches the client. - A real password reset by email: confirm the keys stop working after the link is used.