package admin import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" "github.com/proxy-pool/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 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 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) } } 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 !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: "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 mutation MutationResult err error lastUpstream SetUpstreamCommand lastSwitch SwitchCommand lastReload ReloadCommand statusCalls int } func (service *stubService) Status(context.Context) (Status, error) { service.statusCalls++ return service.status, 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) Check(context.Context, *http.Request) error { return nil } type rejectAuthorizer struct{} func (rejectAuthorizer) Check(context.Context, *http.Request) error { return &httpsecurity.HTTPError{ StatusCode: http.StatusUnauthorized, Code: "UNAUTHORIZED", Header: http.Header{"WWW-Authenticate": []string{`Basic realm="proxy-pool"`}}, Cause: errors.New("credential secret"), } }