proxy-pool/internal/controller/admin/handler_test.go
youfak 4de3ffb85f
Some checks are pending
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
feat: add ephemeral proxy activity pool
2026-07-29 12:51:18 +08:00

313 lines
12 KiB
Go

package admin
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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 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"),
}
}