Files
RenewCMS/test/api_keys_http_test.go
T

1138 lines
46 KiB
Go

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(), newMockVariantFileRepo(), recorder, plainSites())
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites()),
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),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites()),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites()),
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{}
var envelope struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("expected an article envelope, got %q: %v", rec.Body.String(), err)
}
for _, entry := range envelope.Items {
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] != 6 || got[1] != 5 {
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
}