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