1139 lines
47 KiB
Go
1139 lines
47 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(), plainAuthors())
|
|
purgeSettings := newMockPurgeSettingsRepo()
|
|
restartJobs := &fakeJobStore{}
|
|
env.container = &api.UseCases{
|
|
Token: env.token,
|
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
|
|
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
|
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
|
|
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
|
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
|
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(), plainAuthors()),
|
|
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
|
|
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors()),
|
|
MergeMediaUseCase: mergeMedia,
|
|
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites(), plainAuthors()),
|
|
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
|
|
}
|