package httpapi import ( "context" "errors" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "data-terminal-cloud/internal/deviceauth" "data-terminal-cloud/internal/identity" ) type fakeRateLimiter struct { result RateLimitResult err error calls int scopes []string } func (f *fakeRateLimiter) Allow(_ context.Context, scope, identifier string) (RateLimitResult, error) { f.calls++ f.scopes = append(f.scopes, scope+":"+identifier) return f.result, f.err } func TestRateLimitRejectsBusinessRequest(t *testing.T) { limiter := &fakeRateLimiter{result: RateLimitResult{Allowed: false, RetryAfter: 1500 * time.Millisecond}} handler := Middleware(slog.Default(), "test", 1024, limiter, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("downstream handler must not be called") })) request := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil) request.RemoteAddr = "192.0.2.1:1234" recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "2" { t.Fatalf("status = %d, retry-after = %q", recorder.Code, recorder.Header().Get("Retry-After")) } if !strings.Contains(recorder.Body.String(), "RATE_LIMITED") { t.Fatalf("unexpected body: %s", recorder.Body.String()) } } func TestUserRateLimitUsesVerifiedUserAndSelectedTenant(t *testing.T) { limiter := &fakeRateLimiter{result: RateLimitResult{Allowed: true, Remaining: 7}} called := false handler := UserRateLimit(slog.Default(), limiter, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) request := httptest.NewRequest(http.MethodGet, "/api/v1/households", nil) request.Header.Set("X-Tenant-Id", "tenant-public-id") request = request.WithContext(identity.WithPrincipal(request.Context(), identity.Principal{Issuer: "issuer", Subject: "user-subject"})) handler.ServeHTTP(httptest.NewRecorder(), request) if !called || strings.Join(limiter.scopes, ",") != "user:issuer\x00user-subject,tenant:tenant-public-id" { t.Fatalf("called=%t scopes=%q", called, limiter.scopes) } } func TestDeviceRateLimitRejectsVerifiedDeviceScopeBeforeTenant(t *testing.T) { limiter := &fakeRateLimiter{result: RateLimitResult{Allowed: false, RetryAfter: time.Second}} handler := DeviceRateLimit(slog.Default(), limiter, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("downstream handler must not be called") })) request := httptest.NewRequest(http.MethodGet, "/device/v2/config", nil) request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{ID: "device-internal", TenantID: "tenant-internal"})) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("X-RateLimit-Scope") != "device" { t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header()) } if strings.Join(limiter.scopes, ",") != "device:device-internal" { t.Fatalf("scopes=%q", limiter.scopes) } } func TestRateLimitSkipsHealthAndFailsOpen(t *testing.T) { limiter := &fakeRateLimiter{err: errors.New("redis unavailable")} downstreamCalls := 0 handler := Middleware(slog.Default(), "test", 1024, limiter, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { downstreamCalls++ writer.WriteHeader(http.StatusNoContent) })) for _, path := range []string{"/health/live", "/api/v1/items"} { recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) if recorder.Code != http.StatusNoContent { t.Fatalf("path %s status = %d", path, recorder.Code) } } if downstreamCalls != 2 || limiter.calls != 1 { t.Fatalf("downstream calls = %d, limiter calls = %d", downstreamCalls, limiter.calls) } } func TestBodyLimitRejectsKnownOversizedRequestBeforeHandler(t *testing.T) { called := false handler := Middleware(slog.Default(), "test", 16, nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) request := httptest.NewRequest(http.MethodPost, "/api/v1/items", strings.NewReader(strings.Repeat("x", 17))) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusRequestEntityTooLarge || called { t.Fatalf("status=%d called=%t body=%s", recorder.Code, called, recorder.Body.String()) } if !strings.Contains(recorder.Body.String(), "REQUEST_BODY_TOO_LARGE") { t.Fatalf("unexpected body: %s", recorder.Body.String()) } }