package test import ( "RenewCMS/api" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/update" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/useCases" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "net/http/httptest" "regexp" "sort" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) const ( apiKeyRateLimitPerMinute = 120 apiKeyRunningVersion = "v2.2.0-beta" apiKeyOwnerId = 1 apiKeyDevId = 2 apiKeyPatId = 3 apiKeyQuinnId = 4 ) var apiKeyRawPattern = regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`) type apiKeyEnv struct { users *mockUserRepo keys *mockAPIKeyRepo articles *mockArticleRepo images *mockImageRepo invites *inviteFakeRepo mail *mockMailRepo feed *fakeReleaseFeed updates *fakeUpdateStore token *jwtauth.JWTAuth generator *hashing.SHA256KeyGenerator now time.Time settings config.Settings container *api.UseCases router http.Handler issued int } func newAPIKeyEnv(t *testing.T) *apiKeyEnv { t.Helper() return newAPIKeyEnvRecording(t, discardRecorder) } func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { t.Helper() validator.InitValidator() env := &apiKeyEnv{ users: newMockUserRepo(), keys: newMockAPIKeyRepo(), articles: newMockArticleRepo(), images: newMockImageRepo(), invites: newInviteFakeRepo(), updates: &fakeUpdateStore{}, token: jwtauth.New("HS256", []byte("test-secret"), nil), generator: hashing.NewSHA256KeyGenerator(), now: time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC), settings: config.Settings{AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 1000, Window: time.Minute}}, } env.invites.users = env.users env.feed = &fakeReleaseFeed{release: update.Release{ Tag: "v2.3.0-beta", URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta", PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC), Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}}, }} env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleOwner} env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleAdmin, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)} env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, Role: user.RoleAdmin} env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleAdmin} env.users.nextID = 4 clock := func() time.Time { return env.now } hasher := &stubHasher{} mail := newMockMailRepo() env.mail = mail renderer := &fakeBodyRenderer{} mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), recorder) purgeSettings := newMockPurgeSettingsRepo() restartJobs := &fakeJobStore{} env.container = &api.UseCases{ Token: env.token, CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder), GetUserUseCase: useCases.NewGetUserUseCase(env.users), CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder), CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder), RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites), GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), recorder), MergeMediaUseCase: mergeMedia, RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder), RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now), GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock), } router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings)) env.router = router return env } func (e *apiKeyEnv) issueKey(userId uint32, name string, mutate ...func(*apikey.APIKey)) (string, uint32) { e.issued++ raw, prefix, hashed := e.generator.Generate() draft := apikey.APIKey{UserID: userId, Name: name, Prefix: prefix, HashedKey: hashed, CreatedAt: e.now.Add(time.Duration(e.issued) * time.Second)} for _, apply := range mutate { apply(&draft) } stored, _ := e.keys.Create(draft) return raw, stored.ID } type apiKeyRequest struct { method string path string body string bearer string cookieUser uint32 cookieVersion uint32 origin string } func (e *apiKeyEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie { t.Helper() _, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) if err != nil { t.Fatalf("failed to encode jwt: %v", err) } return &http.Cookie{Name: "jwt", Value: tokenString} } func (e *apiKeyEnv) send(t *testing.T, r apiKeyRequest) *httptest.ResponseRecorder { t.Helper() var req *http.Request if r.body == "" { req = httptest.NewRequest(r.method, r.path, nil) } else { req = httptest.NewRequest(r.method, r.path, strings.NewReader(r.body)) req.Header.Set("Content-Type", "application/json") } if r.bearer != "" { req.Header.Set("Authorization", "Bearer "+r.bearer) } if r.cookieUser != 0 { req.AddCookie(e.cookie(t, r.cookieUser, r.cookieVersion)) } rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) return rec } func (e *apiKeyEnv) withKey(t *testing.T, method, path, body, raw string) *httptest.ResponseRecorder { t.Helper() return e.send(t, apiKeyRequest{method: method, path: path, body: body, bearer: raw}) } func (e *apiKeyEnv) withCookie(t *testing.T, method, path, body string, userId uint32) *httptest.ResponseRecorder { t.Helper() return e.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: userId}) } func (e *apiKeyEnv) anonymous(t *testing.T, method, path, body string) *httptest.ResponseRecorder { t.Helper() return e.send(t, apiKeyRequest{method: method, path: path, body: body}) } func (e *apiKeyEnv) seedArticle(id uint32, online bool) { e.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: "x", IsOnline: online} } func (e *apiKeyEnv) seedImage(id uint32) { e.images.images[id] = image.Image{ID: id, Filename: "pic.png", ContentType: "image/png", Path: "/static/uploadedImages/" + strconv.Itoa(int(id)) + ".png", UploadedByID: apiKeyDevId} } func apiKeyExpiringAt(at time.Time) func(*apikey.APIKey) { return func(d *apikey.APIKey) { d.ExpiresAt = &at } } func apiKeyRevokedAt(at time.Time) func(*apikey.APIKey) { return func(d *apikey.APIKey) { d.RevokedAt = &at } } func apiKeyUsedAt(at time.Time) func(*apikey.APIKey) { return func(d *apikey.APIKey) { d.LastUsedAt = &at } } func assertAPIKeyMessage(t *testing.T, rec *httptest.ResponseRecorder, status int, message string) { t.Helper() if rec.Code != status { t.Fatalf("expected status %d, got %d: %s", status, rec.Code, rec.Body.String()) } if message == "" { return } if got := strings.TrimSpace(rec.Body.String()); got != message { t.Errorf("expected body %q, got %q", message, got) } } func assertAPIKeyStatus(t *testing.T, rec *httptest.ResponseRecorder, status int) { t.Helper() if rec.Code != status { t.Fatalf("expected status %d, got %d: %s", status, rec.Code, rec.Body.String()) } } func apiKeyDecodeObject(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() var object map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &object); err != nil { t.Fatalf("expected a JSON object, got %q: %v", rec.Body.String(), err) } return object } func apiKeyDecodeList(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any { t.Helper() var list []map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { t.Fatalf("expected a JSON array, got %q: %v", rec.Body.String(), err) } return list } func apiKeySortedFields(object map[string]any) []string { names := make([]string, 0, len(object)) for name := range object { names = append(names, name) } sort.Strings(names) return names } func apiKeyAssertExactFields(t *testing.T, object map[string]any, want ...string) { t.Helper() sort.Strings(want) if got := apiKeySortedFields(object); strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("expected exactly the fields %v, got %v", want, got) } } func apiKeySHA256Hex(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func apiKeyListedByName(t *testing.T, rec *httptest.ResponseRecorder) map[string]map[string]any { t.Helper() byName := map[string]map[string]any{} for _, entry := range apiKeyDecodeList(t, rec) { byName[entry["name"].(string)] = entry } return byName } func apiKeyEntryNames(t *testing.T, rec *httptest.ResponseRecorder) []string { t.Helper() names := []string{} for _, entry := range apiKeyDecodeList(t, rec) { names = append(names, entry["name"].(string)) } return names } func TestAPIKeysHttp(t *testing.T) { t.Run("Creating a key returns the full key exactly once", func(t *testing.T) { env := newAPIKeyEnv(t) rec := env.withCookie(t, http.MethodPost, "/v1/api-key", `{"name": "ci-deploy"}`, apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusCreated) if got := rec.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("expected Cache-Control no-store, got %q", got) } body := apiKeyDecodeObject(t, rec) apiKeyAssertExactFields(t, body, "id", "name", "prefix", "key", "created_at", "expires_at") raw, _ := body["key"].(string) if body["name"] != "ci-deploy" || body["expires_at"] != nil || !apiKeyRawPattern.MatchString(raw) { t.Errorf("unexpected body %v", body) } if body["prefix"] != raw[:12] { t.Errorf("expected prefix %q, got %v", raw[:12], body["prefix"]) } stored := env.keys.sorted() if len(stored) != 1 || stored[0].UserID != apiKeyDevId || stored[0].HashedKey != apiKeySHA256Hex(raw) { t.Fatalf("unexpected stored records %+v", stored) } if stored[0].Prefix != raw[:12] || stored[0].HashedKey == raw { t.Errorf("the raw key must not be stored") } later := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId) if strings.Contains(later.Body.String(), raw) || strings.Contains(later.Body.String(), stored[0].HashedKey) { t.Errorf("neither the raw key nor its hash may appear in a later response") } }) t.Run("The key can't be retrieved afterwards", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyDevId, "ci-deploy") rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusOK) list := apiKeyDecodeList(t, rec) if len(list) != 1 { t.Fatalf("expected one entry, got %d", len(list)) } apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at") if strings.Contains(rec.Body.String(), raw) || strings.Contains(rec.Body.String(), env.keys.sorted()[0].HashedKey) { t.Errorf("neither the raw key nor its hash may appear in the body") } if list[0]["prefix"] != raw[:12] { t.Errorf("expected prefix %q, got %v", raw[:12], list[0]["prefix"]) } }) t.Run("Creating a key with an expiry", func(t *testing.T) { env := newAPIKeyEnv(t) expiry := env.now.Add(30 * 24 * time.Hour) rec := env.withCookie(t, http.MethodPost, "/v1/api-key", `{"name": "temp", "expires_at": "`+expiry.Format(time.RFC3339)+`"}`, apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusCreated) got, err := time.Parse(time.RFC3339, apiKeyDecodeObject(t, rec)["expires_at"].(string)) if err != nil || !got.Equal(expiry) { t.Errorf("expected expires_at %v, got %v (%v)", expiry, got, err) } }) t.Run("Creating a key with an invalid body is refused and creates nothing", func(t *testing.T) { env := newAPIKeyEnv(t) past := env.now.Add(-time.Hour).Format(time.RFC3339) cases := []struct { name string body string message string contains []string }{ {"body not-json", `not-json`, apperr.BodyErrorMessage, nil}, {"empty object", `{}`, "", []string{"Name", "required"}}, {"empty name", `{"name": ""}`, "", []string{"Name", "required"}}, {"61 character name", `{"name": "` + strings.Repeat("a", 61) + `"}`, "", []string{"Name", "max"}}, {"expires_at not a date", `{"name": "x", "expires_at": "yesterday"}`, apperr.BodyErrorMessage, nil}, {"expires_at in the past", `{"name": "x", "expires_at": "` + past + `"}`, apperr.APIKeyExpiryErrorMessage, nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := env.withCookie(t, http.MethodPost, "/v1/api-key", tc.body, apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusBadRequest) if tc.message != "" && strings.TrimSpace(rec.Body.String()) != tc.message { t.Errorf("expected body %q, got %q", tc.message, rec.Body.String()) } for _, fragment := range tc.contains { if !strings.Contains(rec.Body.String(), fragment) { t.Errorf("expected body to contain %q, got %q", fragment, rec.Body.String()) } } if len(env.keys.keys) != 0 { t.Errorf("expected no key in the repository") } }) } }) t.Run("A request body can't choose the owner or the role of a new key", func(t *testing.T) { env := newAPIKeyEnv(t) rec := env.withCookie(t, http.MethodPost, "/v1/api-key", `{"name": "sneaky", "user_id": 1, "role": "owner"}`, apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusCreated) stored := env.keys.sorted() if len(stored) != 1 || stored[0].UserID != apiKeyDevId { t.Fatalf("expected the key to belong to user 2, got %+v", stored) } raw := apiKeyDecodeObject(t, rec)["key"].(string) invite := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) assertAPIKeyMessage(t, invite, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) }) t.Run("Key management needs a verified real session", func(t *testing.T) { cases := []struct { name string request apiKeyRequest status int message string }{ {"no credentials", apiKeyRequest{}, http.StatusUnauthorized, ""}, {"a jwt cookie for user 4", apiKeyRequest{cookieUser: apiKeyQuinnId}, http.StatusForbidden, apperr.VerificationRequiredErrorMessage}, {"a stale token_version cookie", apiKeyRequest{cookieUser: apiKeyDevId, cookieVersion: 5}, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := newAPIKeyEnv(t) tc.request.method, tc.request.path, tc.request.body = http.MethodPost, "/v1/api-key", `{"name": "x"}` assertAPIKeyMessage(t, env.send(t, tc.request), tc.status, tc.message) if len(env.keys.keys) != 0 { t.Errorf("expected no key in the repository") } }) } }) t.Run("A valid key acts as its creator on every key-accepting route", func(t *testing.T) { cases := []struct { method string path string body string status int }{ {http.MethodGet, "/v1/settings", "", http.StatusOK}, {http.MethodGet, "/v1/user", "", http.StatusOK}, {http.MethodPatch, "/v1/user/me", `{"display_name": "Dev"}`, http.StatusOK}, {http.MethodPost, "/v1/article", `{"title": "A valid title", "body": "x"}`, http.StatusCreated}, {http.MethodPatch, "/v1/article/1/publish", "", http.StatusOK}, {http.MethodDelete, "/v1/article/1", "", http.StatusNoContent}, {http.MethodGet, "/v1/media", "", http.StatusOK}, {http.MethodDelete, "/v1/media/1", "", http.StatusNoContent}, } for _, tc := range cases { t.Run(tc.method+" "+tc.path, func(t *testing.T) { env := newAPIKeyEnv(t) env.seedArticle(1, false) env.seedImage(1) raw, _ := env.issueKey(apiKeyDevId, "ci") rec := env.withKey(t, tc.method, tc.path, tc.body, raw) assertAPIKeyStatus(t, rec, tc.status) if tc.path == "/v1/user/me" && env.users.users[apiKeyDevId].DisplayName != "Dev" { t.Errorf("expected the display name of user 2 updated, got %+v", env.users.users) } }) } }) t.Run("A key can read its creator through GET /v1/auth/me", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyDevId, "ci") viaKey := env.withKey(t, http.MethodGet, "/v1/auth/me", "", raw) viaCookie := env.withCookie(t, http.MethodGet, "/v1/auth/me", "", apiKeyDevId) assertAPIKeyStatus(t, viaKey, http.StatusOK) if viaKey.Body.String() != viaCookie.Body.String() { t.Errorf("expected the same body as the cookie, got %q vs %q", viaKey.Body.String(), viaCookie.Body.String()) } if body := apiKeyDecodeObject(t, viaKey); body["username"] != "dev" || body["id"] != float64(apiKeyDevId) { t.Errorf("expected user 2 dev, got %v", body) } }) t.Run("A key that can't authenticate gets 401 on GET /v1/auth/me", func(t *testing.T) { env := newAPIKeyEnv(t) assertAPIKeyMessage(t, env.withKey(t, http.MethodGet, "/v1/auth/me", "", "rcms_unknown"), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) }) t.Run("A key sees offline articles on the public article routes, an anonymous caller does not", func(t *testing.T) { env := newAPIKeyEnv(t) env.seedArticle(5, false) env.seedArticle(6, true) raw, _ := env.issueKey(apiKeyDevId, "ci") ids := func(rec *httptest.ResponseRecorder) []float64 { result := []float64{} for _, entry := range apiKeyDecodeList(t, rec) { result = append(result, entry["id"].(float64)) } return result } withKey := env.withKey(t, http.MethodGet, "/v1/article", "", raw) assertAPIKeyStatus(t, withKey, http.StatusOK) if got := ids(withKey); len(got) != 2 || got[0] != 5 || got[1] != 6 { t.Errorf("expected articles 5 and 6, got %v", got) } anonymous := env.anonymous(t, http.MethodGet, "/v1/article", "") if got := ids(anonymous); len(got) != 1 || got[0] != 6 { t.Errorf("expected only article 6, got %v", got) } assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/article/5", "", raw), http.StatusOK) assertAPIKeyStatus(t, env.anonymous(t, http.MethodGet, "/v1/article/5", ""), http.StatusNotFound) }) t.Run("An invalid key on a public article route is refused, not treated as anonymous", func(t *testing.T) { env := newAPIKeyEnv(t) env.seedArticle(6, true) assertAPIKeyMessage(t, env.withKey(t, http.MethodGet, "/v1/article", "", "rcms_unknown"), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) }) t.Run("An owner's key can do owner-only things", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyOwnerId, "ci") rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) assertAPIKeyStatus(t, rec, http.StatusCreated) if len(env.invites.invites) != 1 || env.invites.invites[1].InvitedBy != apiKeyOwnerId { t.Errorf("expected an invite invited by 1, got %+v", env.invites.invites) } }) t.Run("An admin's key can not do owner-only things", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyDevId, "ci") rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) if len(env.invites.invites) != 0 { t.Errorf("expected no invite") } }) t.Run("A key that can't authenticate gets the same 401 as a bad jwt", func(t *testing.T) { cases := []struct { name string credential func(env *apiKeyEnv) string }{ {"no key stored for it", func(env *apiKeyEnv) string { return "rcms_unknownunknownunknownunknownunknownunknown1" }}, {"a key whose expires_at is one second ago", func(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyDevId, "k", apiKeyExpiringAt(env.now.Add(-time.Second))) return raw }}, {"a key that was revoked", func(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyDevId, "k", apiKeyRevokedAt(env.now.Add(-time.Minute))) return raw }}, {"a key of user 1 whose user record was deleted", func(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyOwnerId, "k") delete(env.users.users, apiKeyOwnerId) return raw }}, {"a key of an unverified user", func(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyQuinnId, "k") return raw }}, {"nothing after the prefix", func(env *apiKeyEnv) string { return "rcms_" }}, {"a key with one character changed", func(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyDevId, "k") last := raw[len(raw)-1] replacement := byte('A') if last == 'A' { replacement = 'B' } return raw[:len(raw)-1] + string(replacement) }}, {"a key's hash passed instead of the key", func(env *apiKeyEnv) string { env.issueKey(apiKeyDevId, "k") return env.keys.sorted()[0].HashedKey }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := newAPIKeyEnv(t) credential := tc.credential(env) rec := env.withKey(t, http.MethodGet, "/v1/settings", "", credential) message := apperr.UnauthorizedErrorMessage if tc.name == "a key's hash passed instead of the key" { message = "" } assertAPIKeyMessage(t, rec, http.StatusUnauthorized, message) if len(env.keys.touchCalls) != 0 { t.Errorf("a rejected key must never record last_used_at") } }) } }) t.Run("Key behaviour does not change the jwt cookie path", func(t *testing.T) { env := newAPIKeyEnv(t) assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/settings", "", apiKeyDevId), http.StatusOK) bearerJwt := env.cookie(t, apiKeyDevId, 0).Value assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", bearerJwt), http.StatusOK) stale := env.send(t, apiKeyRequest{method: http.MethodGet, path: "/v1/settings", cookieUser: apiKeyDevId, cookieVersion: 7}) assertAPIKeyStatus(t, stale, http.StatusUnauthorized) }) t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyPatId, "ci") invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId}) assertAPIKeyStatus(t, invite, http.StatusForbidden) settings := env.send(t, apiKeyRequest{method: http.MethodGet, path: "/v1/settings", bearer: "rcms_unknown", cookieUser: apiKeyOwnerId}) assertAPIKeyMessage(t, settings, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) }) t.Run("Session-only routes refuse any rcms_ credential with 403", func(t *testing.T) { cases := []struct { name string credential func(env *apiKeyEnv) string method string path string body string }{ {"owner key POST /v1/api-key", apiKeyOfOwner, http.MethodPost, "/v1/api-key", `{"name": "child"}`}, {"owner key GET /v1/api-key", apiKeyOfOwner, http.MethodGet, "/v1/api-key", ""}, {"owner key DELETE /v1/api-key/1", apiKeyOfOwner, http.MethodDelete, "/v1/api-key/1", ""}, {"owner key POST /v1/auth/verify", apiKeyOfOwner, http.MethodPost, "/v1/auth/verify", `{"code": "x"}`}, {"owner key DELETE /v1/auth/register", apiKeyOfOwner, http.MethodDelete, "/v1/auth/register", ""}, {"owner key GET /v1/system/update", apiKeyOfOwner, http.MethodGet, "/v1/system/update", ""}, {"owner key POST /v1/system/update/check", apiKeyOfOwner, http.MethodPost, "/v1/system/update/check", ""}, {"owner key POST /v1/system/update/download", apiKeyOfOwner, http.MethodPost, "/v1/system/update/download", ""}, {"owner key POST /v1/system/update/restart", apiKeyOfOwner, http.MethodPost, "/v1/system/update/restart", ""}, {"revoked key GET /v1/api-key", func(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyDevId, "k", apiKeyRevokedAt(env.now.Add(-time.Minute))) return raw }, http.MethodGet, "/v1/api-key", ""}, {"unknown key POST /v1/system/update/check", func(env *apiKeyEnv) string { return "rcms_unknownunknownunknownunknownunknownunknown1" }, http.MethodPost, "/v1/system/update/check", ""}, {"nothing after the prefix DELETE /v1/auth/register", func(env *apiKeyEnv) string { return "rcms_" }, http.MethodDelete, "/v1/auth/register", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := newAPIKeyEnv(t) credential := tc.credential(env) keysBefore := len(env.keys.keys) rec := env.withKey(t, tc.method, tc.path, tc.body, credential) assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.SessionRequiredErrorMessage) if len(env.keys.keys) != keysBefore || env.keys.revokeCalls != 0 || env.feed.calls != 0 || env.updates.saves != 0 { t.Errorf("expected no use case behind the route to run") } if _, ok := env.users.users[apiKeyOwnerId]; !ok { t.Errorf("expected user 1 untouched") } }) } }) t.Run("The session-only routes still work as before with a session", func(t *testing.T) { cases := []struct { method string path string body string status int }{ {http.MethodPost, "/v1/api-key", `{"name": "x"}`, http.StatusCreated}, {http.MethodGet, "/v1/api-key", "", http.StatusOK}, {http.MethodGet, "/v1/system/update", "", http.StatusOK}, {http.MethodPost, "/v1/system/update/check", "", http.StatusOK}, } for _, tc := range cases { t.Run(tc.method+" "+tc.path, func(t *testing.T) { env := newAPIKeyEnv(t) assertAPIKeyStatus(t, env.withCookie(t, tc.method, tc.path, tc.body, apiKeyOwnerId), tc.status) assertAPIKeyStatus(t, env.anonymous(t, tc.method, tc.path, tc.body), http.StatusUnauthorized) }) } }) t.Run("A key never obtains a session through the public auth routes", func(t *testing.T) { cases := []struct { name string path string body string }{ {"login", "/v1/auth/login", `{"username": "marta", "password": "wrong-pass-1"}`}, {"password-reset confirm", "/v1/auth/password-reset/confirm", `{"email": "marta@example.com", "code": "nope", "password": "newpassword1"}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyOwnerId, "ci") passwordBefore := env.users.users[apiKeyOwnerId].Password without := env.anonymous(t, http.MethodPost, tc.path, tc.body) with := env.withKey(t, http.MethodPost, tc.path, tc.body, raw) if with.Code != without.Code || with.Body.String() != without.Body.String() { t.Errorf("expected the same response as without the key, got %d %q vs %d %q", with.Code, with.Body.String(), without.Code, without.Body.String()) } if len(with.Result().Header.Values("Set-Cookie")) != 0 { t.Errorf("expected no jwt cookie") } if env.users.users[apiKeyOwnerId].Password != passwordBefore { t.Errorf("expected the password unchanged") } }) } }) const resetBody = `{"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"}` t.Run("A successful password reset revokes every key of that user", func(t *testing.T) { env := newAPIKeyEnv(t) a, _ := env.issueKey(apiKeyDevId, "a") b, _ := env.issueKey(apiKeyDevId, "b") assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", a), http.StatusOK) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", b), http.StatusOK) assertAPIKeyStatus(t, env.anonymous(t, http.MethodPost, "/v1/auth/password-reset/confirm", resetBody), http.StatusOK) assertAPIKeyMessage(t, env.withKey(t, http.MethodGet, "/v1/settings", "", a), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) assertAPIKeyMessage(t, env.withKey(t, http.MethodGet, "/v1/settings", "", b), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) }) t.Run("A password reset leaves other users' keys untouched", func(t *testing.T) { env := newAPIKeyEnv(t) env.issueKey(apiKeyDevId, "a") c, cId := env.issueKey(apiKeyPatId, "c") assertAPIKeyStatus(t, env.anonymous(t, http.MethodPost, "/v1/auth/password-reset/confirm", resetBody), http.StatusOK) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", c), http.StatusOK) if env.keys.keys[cId].RevokedAt != nil { t.Errorf("expected key c to have no revoked_at") } }) t.Run("Keys revoked by a password reset show as revoked", func(t *testing.T) { env := newAPIKeyEnv(t) env.issueKey(apiKeyDevId, "a") env.issueKey(apiKeyDevId, "b") assertAPIKeyStatus(t, env.anonymous(t, http.MethodPost, "/v1/auth/password-reset/confirm", resetBody), http.StatusOK) login := env.anonymous(t, http.MethodPost, "/v1/auth/login", `{"username": "dev", "password": "newpassword1"}`) assertAPIKeyStatus(t, login, http.StatusOK) cookie := jwtCookieFromResponse(t, login) req := httptest.NewRequest(http.MethodGet, "/v1/api-key", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() env.router.ServeHTTP(rec, req) assertAPIKeyStatus(t, rec, http.StatusOK) entries := apiKeyListedByName(t, rec) for _, name := range []string{"a", "b"} { entry := entries[name] revoked, err := time.Parse(time.RFC3339Nano, entry["revoked_at"].(string)) if entry["status"] != "revoked" || err != nil || !revoked.Equal(env.now) { t.Errorf("expected %s revoked at %v, got %v", name, env.now, entry) } } }) t.Run("A password reset does not move the revoked_at of a key that was already revoked", func(t *testing.T) { env := newAPIKeyEnv(t) t0 := env.now.Add(-24 * time.Hour) _, oldId := env.issueKey(apiKeyDevId, "old", apiKeyRevokedAt(t0)) _, aId := env.issueKey(apiKeyDevId, "a") assertAPIKeyStatus(t, env.anonymous(t, http.MethodPost, "/v1/auth/password-reset/confirm", resetBody), http.StatusOK) if got := env.keys.keys[oldId].RevokedAt; got == nil || !got.Equal(t0) { t.Errorf("expected key old to keep revoked_at %v, got %v", t0, got) } if got := env.keys.keys[aId].RevokedAt; got == nil || !got.Equal(env.now) { t.Errorf("expected key a revoked at %v, got %v", env.now, got) } }) t.Run("A failed password reset confirmation revokes nothing", func(t *testing.T) { cases := []struct { name string expired bool body string }{ {"wrong code", false, `{"email": "dev@example.com", "code": "wrong", "password": "newpassword1"}`}, {"expired code", true, resetBody}, {"unknown email", false, `{"email": "nobody@example.com", "code": "reset-code-1", "password": "newpassword1"}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := newAPIKeyEnv(t) a, aId := env.issueKey(apiKeyDevId, "a") if tc.expired { dev := env.users.users[apiKeyDevId] dev.PasswordResetExpiration = env.now.Add(-time.Hour) env.users.users[apiKeyDevId] = dev } rec := env.anonymous(t, http.MethodPost, "/v1/auth/password-reset/confirm", tc.body) if rec.Code == http.StatusOK { t.Fatalf("expected a failure, got 200") } if env.keys.keys[aId].RevokedAt != nil { t.Errorf("expected key a to have no revoked_at") } assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", a), http.StatusOK) }) } }) t.Run("Listing as an admin shows only that admin's keys", func(t *testing.T) { env := newAPIKeyEnv(t) env.issueKey(apiKeyDevId, "a") env.issueKey(apiKeyDevId, "b") env.issueKey(apiKeyPatId, "c") rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusOK) if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "b,a" { t.Errorf("expected exactly b and a, got %s", got) } }) t.Run("Listing as the owner shows every user's keys, with the creator's id", func(t *testing.T) { env := newAPIKeyEnv(t) env.issueKey(apiKeyDevId, "a") env.issueKey(apiKeyDevId, "b") env.issueKey(apiKeyPatId, "c") env.issueKey(apiKeyOwnerId, "d") rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId) assertAPIKeyStatus(t, rec, http.StatusOK) if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "d,c,b,a" { t.Errorf("expected d,c,b,a newest first, got %s", got) } creators := map[string]float64{"a": apiKeyDevId, "b": apiKeyDevId, "c": apiKeyPatId, "d": apiKeyOwnerId} for name, entry := range apiKeyListedByName(t, rec) { if entry["user_id"] != creators[name] { t.Errorf("expected key %s to carry user_id %v, got %v", name, creators[name], entry["user_id"]) } } }) t.Run("The list reports each key's state", func(t *testing.T) { env := newAPIKeyEnv(t) usedFiveMinutesAgo := env.now.Add(-5 * time.Minute) oneHourAgo := env.now.Add(-time.Hour) env.issueKey(apiKeyDevId, "never-used") env.issueKey(apiKeyDevId, "used", apiKeyUsedAt(usedFiveMinutesAgo)) env.issueKey(apiKeyDevId, "expired", apiKeyExpiringAt(oneHourAgo), apiKeyUsedAt(usedFiveMinutesAgo)) env.issueKey(apiKeyDevId, "revoked", apiKeyRevokedAt(oneHourAgo), apiKeyUsedAt(usedFiveMinutesAgo)) env.issueKey(apiKeyDevId, "both", apiKeyExpiringAt(oneHourAgo), apiKeyRevokedAt(oneHourAgo)) entries := apiKeyListedByName(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)) check := func(name string, status string, revoked bool, lastUsed *time.Time) { entry := entries[name] if entry["status"] != status { t.Errorf("%s: expected status %s, got %v", name, status, entry["status"]) } if (entry["revoked_at"] != nil) != revoked { t.Errorf("%s: unexpected revoked_at %v", name, entry["revoked_at"]) } switch { case lastUsed == nil && entry["last_used_at"] != nil: t.Errorf("%s: expected last_used_at null, got %v", name, entry["last_used_at"]) case lastUsed != nil: got, _ := time.Parse(time.RFC3339Nano, entry["last_used_at"].(string)) if !got.Equal(*lastUsed) { t.Errorf("%s: expected last_used_at %v, got %v", name, *lastUsed, got) } } } check("never-used", "active", false, nil) check("used", "active", false, &usedFiveMinutesAgo) check("expired", "expired", false, &usedFiveMinutesAgo) check("revoked", "revoked", true, &usedFiveMinutesAgo) check("both", "revoked", true, nil) }) t.Run("Revoking a key ends it immediately", func(t *testing.T) { env := newAPIKeyEnv(t) raw, id := env.issueKey(apiKeyDevId, "ci-deploy") assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusNoContent) if rec.Body.Len() != 0 { t.Errorf("expected an empty body, got %q", rec.Body.String()) } assertAPIKeyMessage(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) listed := apiKeyListedByName(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)) if listed["ci-deploy"]["status"] != "revoked" { t.Errorf("expected the key listed as revoked, got %v", listed["ci-deploy"]) } }) t.Run("Revoking twice is idempotent", func(t *testing.T) { env := newAPIKeyEnv(t) revokedTime := env.now.Add(-time.Hour) _, id := env.issueKey(apiKeyDevId, "ci", apiKeyRevokedAt(revokedTime)) rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusNoContent) if got := env.keys.keys[id].RevokedAt; got == nil || !got.Equal(revokedTime) { t.Errorf("expected revoked_at to stay %v, got %v", revokedTime, got) } }) t.Run("An admin can't revoke someone else's key, and learns nothing about it", func(t *testing.T) { env := newAPIKeyEnv(t) raw, id := env.issueKey(apiKeyPatId, "ci") rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId) assertAPIKeyMessage(t, rec, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) }) t.Run("The owner can revoke any key", func(t *testing.T) { env := newAPIKeyEnv(t) raw, id := env.issueKey(apiKeyPatId, "ci") assertAPIKeyStatus(t, env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyOwnerId), http.StatusNoContent) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusUnauthorized) }) t.Run("Revoking with a bad id", func(t *testing.T) { cases := []struct { id string status int message string }{ {"abc", http.StatusBadRequest, apperr.IdUint32ErrorMessage}, {"-1", http.StatusBadRequest, apperr.IdUint32ErrorMessage}, {"4294967296", http.StatusBadRequest, apperr.IdUint32ErrorMessage}, {"999", http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage}, } for _, tc := range cases { t.Run(tc.id, func(t *testing.T) { env := newAPIKeyEnv(t) assertAPIKeyMessage(t, env.withCookie(t, http.MethodDelete, "/v1/api-key/"+tc.id, "", apiKeyDevId), tc.status, tc.message) }) } }) t.Run("Using a key records last_used_at, at most once per minute", func(t *testing.T) { env := newAPIKeyEnv(t) raw, id := env.issueKey(apiKeyDevId, "ci") start := env.now assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) if got := env.keys.keys[id].LastUsedAt; got == nil || !got.Equal(start) { t.Fatalf("expected last_used_at %v, got %v", start, got) } env.now = start.Add(30 * time.Second) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) if len(env.keys.touchCalls) != 1 || !env.keys.keys[id].LastUsedAt.Equal(start) { t.Errorf("expected no write at +30s, got %d writes", len(env.keys.touchCalls)) } env.now = start.Add(61 * time.Second) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) if got := env.keys.keys[id].LastUsedAt; !got.Equal(start.Add(61 * time.Second)) { t.Errorf("expected last_used_at at +61s, got %v", got) } }) t.Run("A failure to record last_used_at doesn't fail the request", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyDevId, "ci") env.keys.fail.failNext("TouchLastUsed") assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) }) t.Run("A rejected key never records last_used_at", func(t *testing.T) { env := newAPIKeyEnv(t) expired, _ := env.issueKey(apiKeyDevId, "expired", apiKeyExpiringAt(env.now.Add(-time.Second))) revoked, _ := env.issueKey(apiKeyDevId, "revoked", apiKeyRevokedAt(env.now.Add(-time.Second))) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", expired), http.StatusUnauthorized) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", revoked), http.StatusUnauthorized) if len(env.keys.touchCalls) != 0 { t.Errorf("expected no last_used_at write") } }) t.Run("A repository failure while authenticating a key answers 500", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyDevId, "ci") env.keys.fail.failNext("GetByHashedKey") assertAPIKeyMessage(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusInternalServerError, "Internal server error") }) t.Run("A repository failure on create, list or revoke answers 500 and leaves the key usable", func(t *testing.T) { env := newAPIKeyEnv(t) env.keys.fail.failNext("Create") rec := env.withCookie(t, http.MethodPost, "/v1/api-key", `{"name":"ci"}`, apiKeyDevId) assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error") if len(env.keys.keys) != 0 { t.Errorf("expected no stored key") } env.keys.fail.failNext("ListByUser") assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId), http.StatusInternalServerError, "Internal server error") env.keys.fail.failNext("ListAll") assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusInternalServerError, "Internal server error") raw, id := env.issueKey(apiKeyDevId, "ci") env.keys.fail.failNext("Revoke") rec = env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId) assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error") assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) }) t.Run("Each key has its own rate limit", func(t *testing.T) { env := newAPIKeyEnv(t) a, _ := env.issueKey(apiKeyDevId, "a") b, _ := env.issueKey(apiKeyDevId, "b") for i := 0; i < apiKeyRateLimitPerMinute; i++ { if rec := env.withKey(t, http.MethodGet, "/v1/settings", "", a); rec.Code != http.StatusOK { t.Fatalf("request %d: expected 200, got %d", i+1, rec.Code) } } limited := env.withKey(t, http.MethodGet, "/v1/settings", "", a) assertAPIKeyMessage(t, limited, http.StatusTooManyRequests, apperr.TooManyRequestsErrorMessage) if limited.Header().Get("Retry-After") == "" { t.Errorf("expected a Retry-After header") } assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", b), http.StatusOK) assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/settings", "", apiKeyDevId), http.StatusOK) }) t.Run("The per-key rate limit is shared across the routers", func(t *testing.T) { env := newAPIKeyEnv(t) raw, _ := env.issueKey(apiKeyDevId, "a") for i := 0; i < apiKeyRateLimitPerMinute/2; i++ { env.withKey(t, http.MethodGet, "/v1/settings", "", raw) env.withKey(t, http.MethodGet, "/v1/media", "", raw) } assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/user", "", raw), http.StatusTooManyRequests) }) t.Run("The rate limit does not apply to unauthenticated requests", func(t *testing.T) { env := newAPIKeyEnv(t) for i := 0; i < apiKeyRateLimitPerMinute+1; i++ { if rec := env.withKey(t, http.MethodGet, "/v1/settings", "", "rcms_unknown"); rec.Code != http.StatusUnauthorized { t.Fatalf("request %d: expected 401, got %d", i+1, rec.Code) } } }) t.Run("The CORS preflight allows PATCH", func(t *testing.T) { env := newAPIKeyEnv(t) settings := env.settings settings.CorsAllowedOrigins = []string{"https://app.example.com"} router := api.InitRoutes(env.container, settings) preflight := func(method string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodOptions, "/v1/article/1", nil) req.Header.Set("Origin", "https://app.example.com") req.Header.Set("Access-Control-Request-Method", method) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } allowed := preflight(http.MethodPatch) if allowed.Code >= http.StatusBadRequest { t.Errorf("expected a successful preflight, got %d", allowed.Code) } if !strings.Contains(allowed.Header().Get("Access-Control-Allow-Methods"), "PATCH") { t.Errorf("expected PATCH to be allowed, got %q", allowed.Header().Get("Access-Control-Allow-Methods")) } if got := preflight("TRACE").Header().Get("Access-Control-Allow-Methods"); got != "" { t.Errorf("expected TRACE not allowed, got %q", got) } }) } func apiKeyOfOwner(env *apiKeyEnv) string { raw, _ := env.issueKey(apiKeyOwnerId, "ci") return raw }