check / check (push) Waiting to run
The new setting image_requests_per_minute (PIXA_IMAGE_REQUESTS_PER_MINUTE), default 600, caps the GET and HEAD requests one client sends to /v1/image/ and /v1/e/ per minute; the next is answered 429 with Retry-After. It uses the login limit's RateLimit middleware, so clients are told apart the same way, and sits in the image routes' group behind the CORS middleware, so a preflight is not counted. 0 turns it off; a negative or non-integer value stops startup. TestEnvironmentSetsEveryKey gains the new variable, under the owner's standing approval in #64. To stay within the linters' limits, the sentry_dsn check moves unchanged into validateSentryDSN, and the cache_max_bytes explicit flag is set in one line. Model: opus-5-5
153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/pixa/internal/config"
|
|
)
|
|
|
|
// requestImageFrom sends method path to s from remoteAddr and returns the
|
|
// response. The image handlers refuse an unsigned image URL with 401 and a
|
|
// token they cannot decrypt with 400, so either status shows the limit let
|
|
// the request through.
|
|
func requestImageFrom(
|
|
t *testing.T, s *Server, method, path, remoteAddr string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
|
|
req.RemoteAddr = remoteAddr
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestImageRequestLimitRefusesRequestOverLimit verifies that GET and HEAD on
|
|
// both image routes count toward one limit per client, and that the request
|
|
// after image_requests_per_minute of them is refused with 429 and a
|
|
// Retry-After header.
|
|
func TestImageRequestLimitRefusesRequestOverLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
requests := []struct {
|
|
method string
|
|
path string
|
|
want int
|
|
}{
|
|
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
|
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
|
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
|
}
|
|
|
|
s := newTestServer(t)
|
|
s.config.ImageRequestsPerMinute = len(requests)
|
|
s.SetupRoutes()
|
|
|
|
for _, tc := range requests {
|
|
rec := requestImageFrom(t, s, tc.method, tc.path, firstClient)
|
|
if rec.Code != tc.want {
|
|
t.Fatalf("%s %s within the limit status = %d, want %d",
|
|
tc.method, tc.path, rec.Code, tc.want)
|
|
}
|
|
}
|
|
|
|
rec := requestImageFrom(t, s, http.MethodHead, encryptedImagePath, firstClient)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("request over the limit status = %d, want %d",
|
|
rec.Code, http.StatusTooManyRequests)
|
|
}
|
|
|
|
retryAfter := rec.Header().Get("Retry-After")
|
|
|
|
seconds, err := strconv.Atoi(retryAfter)
|
|
if err != nil || seconds <= 0 {
|
|
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
|
retryAfter)
|
|
}
|
|
}
|
|
|
|
// TestImageRequestLimitCountsClientsSeparately verifies that one client going
|
|
// over the limit does not limit another.
|
|
func TestImageRequestLimitCountsClientsSeparately(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
s.config.ImageRequestsPerMinute = 1
|
|
s.SetupRoutes()
|
|
|
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("first client's first request status = %d, want %d",
|
|
rec.Code, http.StatusUnauthorized)
|
|
}
|
|
|
|
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("first client's second request status = %d, want %d",
|
|
rec.Code, http.StatusTooManyRequests)
|
|
}
|
|
|
|
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, secondClient)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("second client's first request status = %d, want %d",
|
|
rec.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
// TestImageRequestLimitZeroLetsEveryRequestThrough verifies that with
|
|
// image_requests_per_minute set to 0 no image request is refused with 429,
|
|
// not even past the default limit.
|
|
func TestImageRequestLimitZeroLetsEveryRequestThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
s.config.ImageRequestsPerMinute = 0
|
|
s.SetupRoutes()
|
|
|
|
for request := range config.DefaultImageRequestsPerMinute + 1 {
|
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("request %d status = %d, want %d",
|
|
request+1, rec.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestImageRequestLimitDoesNotCountPreflight verifies that a browser's CORS
|
|
// preflight request to an image route is answered without being counted, so
|
|
// a client that has sent several still gets its image request through.
|
|
func TestImageRequestLimitDoesNotCountPreflight(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
s.config.AccessControlAllowOrigin = config.DefaultAccessControlAllowOrigin
|
|
s.config.ImageRequestsPerMinute = 1
|
|
s.SetupRoutes()
|
|
|
|
for range 3 {
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodOptions, unsignedImagePath, nil)
|
|
req.RemoteAddr = firstClient
|
|
req.Header.Set("Origin", "https://app.example.com")
|
|
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("image request after preflights status = %d, want %d",
|
|
rec.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|