feat: integration v2 and API key PART 1/3
This commit is contained in:
1 parent
7e9bd9ba30
commit
844842fdae
40 files changed
+3782
-64
No files matched your search
@@ -0,0 +1,171 @@
|
||||
# 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 <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.
|
||||
Reference in new issue
Block a user