package admin import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "proxy-pool/internal/domain/authorization" "proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpsecurity" ) func TestHandlerReturnsStatusWithoutSensitiveDetails(t *testing.T) { t.Parallel() service := &stubService{status: Status{ ConfigVersion: "cfg-2", SnapshotVersion: 7, Upstreams: []UpstreamStatus{{Name: "provider-a", Enabled: true, Available: 11}}, Workers: []WorkerStatus{{ID: "worker-a", Zone: "cn-east", Connected: true, SnapshotVersion: 7}}, }} handler := mustHandler(t, service) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } var response Status if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode response: %v", err) } if response.ConfigVersion != "cfg-2" || response.SnapshotVersion != 7 || len(response.Upstreams) != 1 { t.Fatalf("unexpected status response: %+v", response) } } func TestHandlerEnforcesReadAndWritePermissions(t *testing.T) { t.Parallel() service := &stubService{} readHandler, err := NewHandler(service, scopedAuthorizer{permissions: []string{authorization.AdminRead}}, Options{MaxBodyBytes: 1024}) if err != nil { t.Fatalf("NewHandler(read) error = %v", err) } status := httptest.NewRecorder() readHandler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)) if status.Code != http.StatusOK || service.statusCalls != 1 { t.Fatalf("read status = %d calls=%d", status.Code, service.statusCalls) } mutation := httptest.NewRecorder() readHandler.ServeHTTP(mutation, httptest.NewRequest(http.MethodPost, "/api/v1/config/reload", nil)) if mutation.Code != http.StatusForbidden || service.lastReload != (ReloadCommand{}) || !strings.Contains(mutation.Body.String(), `"code":"FORBIDDEN"`) { t.Fatalf("read mutation response = status %d command %+v body=%s", mutation.Code, service.lastReload, mutation.Body.String()) } writeHandler, err := NewHandler(service, scopedAuthorizer{permissions: []string{authorization.AdminWrite}}, Options{MaxBodyBytes: 1024}) if err != nil { t.Fatalf("NewHandler(write) error = %v", err) } audit := httptest.NewRecorder() writeHandler.ServeHTTP(audit, httptest.NewRequest(http.MethodGet, "/api/v1/audit", nil)) if audit.Code != http.StatusForbidden || service.auditCalls != 0 || !strings.Contains(audit.Body.String(), `"code":"FORBIDDEN"`) { t.Fatalf("write audit response = status %d calls=%d body=%s", audit.Code, service.auditCalls, audit.Body.String()) } } func TestHandlerReadsBoundedAuditPage(t *testing.T) { t.Parallel() occurredAt := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC) service := &stubService{audit: AuditPage{Records: []AuditRecord{{ ID: 8, RequestID: "req-8", ActorID: "admin:alice", Action: "switch_routing", ResourceType: "routing", ResourceName: "checkout", Changed: true, Version: 12, Reason: "capacity", OccurredAt: occurredAt, }}}} handler := mustHandler(t, service) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/audit?afterId=7&limit=2", nil) request.Header.Set(httpapi.HeaderRequestID, "req-audit-page") handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } if service.auditCalls != 1 || service.lastAudit != (AuditQuery{AfterID: 7, Limit: 2}) { t.Fatalf("ReadAudit() calls=%d query=%+v", service.auditCalls, service.lastAudit) } if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID != "req-audit-page" { t.Fatalf("response request ID = %q, want req-audit-page", requestID) } var response AuditPage if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode response: %v", err) } if len(response.Records) != 1 || response.Records[0].ID != 8 || !response.Records[0].OccurredAt.Equal(occurredAt) { t.Fatalf("unexpected audit response: %+v", response) } } func TestHandlerUsesDefaultAuditLimitAndRejectsInvalidAuditQueries(t *testing.T) { t.Parallel() tests := []struct { name string path string wantStatus int wantQuery AuditQuery }{ {name: "default", path: "/api/v1/audit", wantStatus: http.StatusOK, wantQuery: AuditQuery{Limit: defaultAuditPageSize}}, {name: "unknown field", path: "/api/v1/audit?beforeId=1", wantStatus: http.StatusBadRequest}, {name: "duplicate field", path: "/api/v1/audit?limit=1&limit=2", wantStatus: http.StatusBadRequest}, {name: "zero limit", path: "/api/v1/audit?limit=0", wantStatus: http.StatusBadRequest}, {name: "oversized limit", path: "/api/v1/audit?limit=1001", wantStatus: http.StatusBadRequest}, {name: "invalid cursor", path: "/api/v1/audit?afterId=nope", wantStatus: http.StatusBadRequest}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() service := &stubService{} handler := mustHandler(t, service) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil)) if recorder.Code != test.wantStatus { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.wantStatus, recorder.Body.String()) } if test.wantStatus == http.StatusOK { if service.auditCalls != 1 || service.lastAudit != test.wantQuery { t.Fatalf("ReadAudit() calls=%d query=%+v, want %+v", service.auditCalls, service.lastAudit, test.wantQuery) } return } if service.auditCalls != 0 || !strings.Contains(recorder.Body.String(), `"code":"INVALID_AUDIT_QUERY"`) { t.Fatalf("invalid audit query reached service or returned wrong problem: calls=%d body=%s", service.auditCalls, recorder.Body.String()) } }) } } func TestNewHandlerRejectsMissingDependenciesAndInvalidLimit(t *testing.T) { t.Parallel() if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) { t.Fatalf("NewHandler(nil) error = %v, want %v", err, ErrInvalidHandler) } if _, err := NewHandler(&stubService{}, allowAuthorizer{}, Options{}); !errors.Is(err, ErrInvalidHandler) { t.Fatalf("NewHandler(zero limit) error = %v, want %v", err, ErrInvalidHandler) } if _, err := NewHandler(&stubService{}, nil, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) { t.Fatalf("NewHandler(nil authorizer) error = %v, want %v", err, ErrInvalidHandler) } } func TestHandlerAuthorizesBeforeRouting(t *testing.T) { t.Parallel() service := &stubService{} handler, err := NewHandler(service, rejectAuthorizer{}, Options{MaxBodyBytes: 1024}) if err != nil { t.Fatalf("NewHandler() error = %v", err) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/missing", nil)) if recorder.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusUnauthorized, recorder.Body.String()) } if recorder.Header().Get("WWW-Authenticate") != `Basic realm="proxy-pool"` { t.Fatalf("challenge = %q", recorder.Header().Get("WWW-Authenticate")) } if service.statusCalls != 0 { t.Fatalf("status calls = %d, want 0", service.statusCalls) } } func TestHandlerUsesHTTPProtectionAuthenticationContract(t *testing.T) { t.Parallel() service := &stubService{status: Status{ConfigVersion: "cfg-1"}} protection, err := httpsecurity.New(httpsecurity.Config{ Authentication: httpsecurity.Authentication{Mode: httpsecurity.ModeBearer, Token: "admin-token"}, }, nil) if err != nil { t.Fatalf("httpsecurity.New() error = %v", err) } handler, err := NewHandler(service, protection, Options{MaxBodyBytes: 1024}) if err != nil { t.Fatalf("NewHandler() error = %v", err) } unauthorized := httptest.NewRecorder() handler.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)) if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("WWW-Authenticate") != `Bearer realm="proxy-pool"` { t.Fatalf("unauthorized response = status %d challenge %q", unauthorized.Code, unauthorized.Header().Get("WWW-Authenticate")) } if service.statusCalls != 0 { t.Fatalf("status calls after rejection = %d, want 0", service.statusCalls) } authorized := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) request.Header.Set("Authorization", "Bearer admin-token") handler.ServeHTTP(authorized, request) if authorized.Code != http.StatusOK || service.statusCalls != 1 { t.Fatalf("authorized response = status %d calls %d", authorized.Code, service.statusCalls) } } func TestHandlerEnablesAndDisablesUpstream(t *testing.T) { t.Parallel() service := &stubService{mutation: MutationResult{Changed: true, Version: 8}} handler := mustHandler(t, service) for _, test := range []struct { path string enabled bool }{ {path: "/api/v1/upstreams/provider-a/enable", enabled: true}, {path: "/api/v1/upstreams/provider-a/disable", enabled: false}, } { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, test.path, nil) request.Header.Set(httpapi.HeaderRequestID, "req-admin") handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("%s status = %d, want %d; body=%s", test.path, recorder.Code, http.StatusOK, recorder.Body.String()) } if service.lastUpstream.Name != "provider-a" || service.lastUpstream.Enabled != test.enabled || service.lastUpstream.RequestID != "req-admin" { t.Fatalf("unexpected service call: %+v", service.lastUpstream) } if service.lastUpstream.ActorID != "admin:test" || service.lastUpstream.SourceIP != "192.0.2.10" { t.Fatalf("upstream actor = (%q, %q)", service.lastUpstream.ActorID, service.lastUpstream.SourceIP) } if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID != "req-admin" { t.Fatalf("response request ID = %q, want req-admin", requestID) } } } func TestHandlerSwitchesRoutingWithStrictJSON(t *testing.T) { t.Parallel() service := &stubService{mutation: MutationResult{Changed: true, Version: 9}} handler := mustHandler(t, service) request := httptest.NewRequest(http.MethodPost, "/api/v1/routing/checkout/switch", strings.NewReader( `{"expectedCurrent":"provider-a","target":"provider-b","reason":"capacity"}`, )) request.Header.Set("Content-Type", httpapi.JSONContentType) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } if service.lastSwitch.Name != "checkout" || service.lastSwitch.ExpectedCurrent != "provider-a" || service.lastSwitch.Target != "provider-b" { t.Fatalf("unexpected switch call: command=%+v", service.lastSwitch) } if service.lastSwitch.ActorID != "admin:test" || service.lastSwitch.SourceIP != "192.0.2.10" { t.Fatalf("switch actor = (%q, %q)", service.lastSwitch.ActorID, service.lastSwitch.SourceIP) } } func TestHandlerReloadsConfigurationWithCommandRequestID(t *testing.T) { t.Parallel() service := &stubService{mutation: MutationResult{Changed: true, Version: 10}} handler := mustHandler(t, service) request := httptest.NewRequest(http.MethodPost, "/api/v1/config/reload", nil) request.Header.Set(httpapi.HeaderRequestID, "req-reload") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } if service.lastReload.RequestID != "req-reload" { t.Fatalf("reload command = %+v", service.lastReload) } if service.lastReload.ActorID != "admin:test" || service.lastReload.SourceIP != "192.0.2.10" { t.Fatalf("reload actor = (%q, %q)", service.lastReload.ActorID, service.lastReload.SourceIP) } if !strings.Contains(recorder.Body.String(), `"requestId":"req-reload"`) { t.Fatalf("unexpected reload response %q", recorder.Body.String()) } } func TestHandlerMapsServiceErrorsToProblemContract(t *testing.T) { t.Parallel() tests := []struct { name string serviceErr error wantStatus int wantCode string }{ {name: "not found", serviceErr: ErrNotFound, wantStatus: http.StatusNotFound, wantCode: "NOT_FOUND"}, {name: "conflict", serviceErr: ErrConflict, wantStatus: http.StatusConflict, wantCode: "CONFLICT"}, {name: "invalid configuration", serviceErr: ErrInvalidConfiguration, wantStatus: http.StatusUnprocessableEntity, wantCode: "INVALID_CONFIGURATION"}, {name: "unavailable", serviceErr: ErrUnavailable, wantStatus: http.StatusServiceUnavailable, wantCode: "SERVICE_UNAVAILABLE"}, {name: "internal", serviceErr: errors.New("database password=secret"), wantStatus: http.StatusInternalServerError, wantCode: "INTERNAL_ERROR"}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() handler := mustHandler(t, &stubService{err: test.serviceErr}) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/reload", nil) request.Header.Set(httpapi.HeaderRequestID, "req-error") handler.ServeHTTP(recorder, request) if recorder.Code != test.wantStatus { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.wantStatus, recorder.Body.String()) } if body := recorder.Body.String(); !strings.Contains(body, `"code":"`+test.wantCode+`"`) || strings.Contains(body, "password") { t.Fatalf("unexpected problem body %q", body) } }) } } func TestHandlerRejectsInvalidTransportRequests(t *testing.T) { t.Parallel() tests := []struct { name string method string path string body string content string requestID string wantStatus int }{ {name: "method", method: http.MethodPut, path: "/api/v1/config/reload", wantStatus: http.StatusMethodNotAllowed}, {name: "audit method", method: http.MethodPost, path: "/api/v1/audit", wantStatus: http.StatusMethodNotAllowed}, {name: "unknown route", method: http.MethodGet, path: "/missing", wantStatus: http.StatusNotFound}, {name: "invalid name", method: http.MethodPost, path: "/api/v1/upstreams//enable", wantStatus: http.StatusNotFound}, {name: "unknown JSON field", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: `{"expectedCurrent":"a","target":"b","extra":1}`, content: httpapi.JSONContentType, wantStatus: http.StatusBadRequest}, {name: "unsupported media type", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: `{}`, content: "text/plain", wantStatus: http.StatusUnsupportedMediaType}, {name: "oversized body", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: strings.Repeat(" ", 1100) + `{}`, content: httpapi.JSONContentType, wantStatus: http.StatusRequestEntityTooLarge}, {name: "invalid switch fields", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: `{"expectedCurrent":"a"}`, content: httpapi.JSONContentType, wantStatus: http.StatusUnprocessableEntity}, {name: "invalid request ID", method: http.MethodPost, path: "/api/v1/config/reload", requestID: strings.Repeat("x", 129), wantStatus: http.StatusBadRequest}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() handler := mustHandler(t, &stubService{}) recorder := httptest.NewRecorder() request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) request.Header.Set("Content-Type", test.content) request.Header.Set(httpapi.HeaderRequestID, test.requestID) handler.ServeHTTP(recorder, request) if recorder.Code != test.wantStatus { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.wantStatus, recorder.Body.String()) } if contentType := recorder.Header().Get("Content-Type"); contentType != httpapi.ProblemContentType { t.Fatalf("Content-Type = %q, want %q", contentType, httpapi.ProblemContentType) } if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID == "" { t.Fatal("X-Request-ID response header is empty") } }) } } func mustHandler(t *testing.T, service Service) *Handler { t.Helper() handler, err := NewHandler(service, allowAuthorizer{}, Options{MaxBodyBytes: 1024}) if err != nil { t.Fatalf("NewHandler() error = %v", err) } return handler } type stubService struct { status Status audit AuditPage mutation MutationResult err error lastAudit AuditQuery lastUpstream SetUpstreamCommand lastSwitch SwitchCommand lastReload ReloadCommand statusCalls int auditCalls int } func (service *stubService) Status(context.Context) (Status, error) { service.statusCalls++ return service.status, service.err } func (service *stubService) ReadAudit(_ context.Context, query AuditQuery) (AuditPage, error) { service.auditCalls++ service.lastAudit = query return service.audit, service.err } func (service *stubService) SetUpstreamEnabled(_ context.Context, command SetUpstreamCommand) (MutationResult, error) { service.lastUpstream = command return service.mutation, service.err } func (service *stubService) SwitchRouting(_ context.Context, command SwitchCommand) (MutationResult, error) { service.lastSwitch = command return service.mutation, service.err } func (service *stubService) ReloadConfiguration(_ context.Context, command ReloadCommand) (MutationResult, error) { service.lastReload = command return service.mutation, service.err } type allowAuthorizer struct{} func (allowAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) { return httpsecurity.Identity{ClientID: "admin:test", SourceIP: "192.0.2.10"}, nil } type scopedAuthorizer struct{ permissions []string } func (authorizer scopedAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) { return httpsecurity.Identity{ClientID: "admin:scoped", SourceIP: "192.0.2.20", Permissions: authorizer.permissions}, nil } type rejectAuthorizer struct{} func (rejectAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) { return httpsecurity.Identity{}, &httpsecurity.HTTPError{ StatusCode: http.StatusUnauthorized, Code: "UNAUTHORIZED", Header: http.Header{"WWW-Authenticate": []string{`Basic realm="proxy-pool"`}}, Cause: errors.New("credential secret"), } }