Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions internal/httpapi/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -1127,9 +1127,8 @@ func (s *Server) handleActivityUsage(w http.ResponseWriter, r *http.Request) {
// the value hot-reloads with config.
func (s *Server) usageCacheTTL() time.Duration {
def := time.Duration(config.DefaultObservabilityConfig().UsageCacheTTL)
cfgIface := s.controller.GetCurrentConfig()
cfg, ok := cfgIface.(*config.Config)
if !ok || cfg == nil || cfg.Observability == nil {
cfg := s.controller.GetCurrentConfig()
if cfg == nil || cfg.Observability == nil {
return def
}
if d := time.Duration(cfg.Observability.UsageCacheTTL); d > 0 {
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/activity_call_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type callParityController struct {
snap *internalRuntime.UsageAggregate
}

func (m *callParityController) GetCurrentConfig() any {
func (m *callParityController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: "test-key", Observability: config.DefaultObservabilityConfig()}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/activity_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type mockActivityController struct {
activities []*storage.ActivityRecord
}

func (m *mockActivityController) GetCurrentConfig() any {
func (m *mockActivityController) GetCurrentConfig() *config.Config {
return &config.Config{
APIKey: m.apiKey,
}
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/activity_summary_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type summaryScopeController struct {
activities []*storage.ActivityRecord
}

func (m *summaryScopeController) GetCurrentConfig() any {
func (m *summaryScopeController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: "test-key"}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/activity_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type mockUsageController struct {
scanCalls int
}

func (m *mockUsageController) GetCurrentConfig() any {
func (m *mockUsageController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: m.apiKey, Observability: config.DefaultObservabilityConfig()}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/agent_token_gating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type adminConfigController struct {
apiKey string
}

func (c *adminConfigController) GetCurrentConfig() interface{} {
func (c *adminConfigController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: c.apiKey}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/httpapi/annotation_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func TestHandleAnnotationCoverage(t *testing.T) {
srv := NewServer(ctrl, logger, nil)

req := httptest.NewRequest("GET", "/api/v1/annotations/coverage", nil)
req.Header.Set("X-API-Key", mockControllerAPIKey)
// Add API key bypass by setting request source to socket (trusted)
req.Header.Set("X-Request-Source", "socket")
w := httptest.NewRecorder()
Expand Down Expand Up @@ -140,6 +141,7 @@ func TestAnnotationCoverage_EmptyServers(t *testing.T) {
srv := NewServer(ctrl, logger, nil)

req := httptest.NewRequest("GET", "/api/v1/annotations/coverage", nil)
req.Header.Set("X-API-Key", mockControllerAPIKey)
req.Header.Set("X-Request-Source", "socket")
w := httptest.NewRecorder()

Expand Down Expand Up @@ -195,6 +197,7 @@ func TestAnnotationCoverage_TitleOnlyNotCounted(t *testing.T) {
srv := NewServer(ctrl, logger, nil)

req := httptest.NewRequest("GET", "/api/v1/annotations/coverage", nil)
req.Header.Set("X-API-Key", mockControllerAPIKey)
req.Header.Set("X-Request-Source", "socket")
w := httptest.NewRecorder()

Expand Down
44 changes: 43 additions & 1 deletion internal/httpapi/auth_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -47,7 +48,7 @@ type testControllerWithConfig struct {
cfg *config.Config
}

func (m *testControllerWithConfig) GetCurrentConfig() interface{} {
func (m *testControllerWithConfig) GetCurrentConfig() *config.Config {
return m.cfg
}

Expand Down Expand Up @@ -399,3 +400,44 @@ func TestAPIKeyAuth_NoTokenStore_RejectsAgentToken(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, w.Code,
"Agent token should be rejected when token store is not configured")
}

// --- SEC-02: the middleware must fail CLOSED when it cannot read a config ---

// failClosedController models a ServerController that hands the middleware no
// configuration at all. apiKeyAuthMiddleware used to read that as a "testing
// scenario" and forward the request unauthenticated; there is no configuration
// to authenticate against, so the only safe answer is to refuse.
//
// reachedHandler is the real oracle: asserting only on the status code passes
// vacuously if some unrelated 503 fires before routing.
type failClosedController struct {
baseController
reachedHandler atomic.Bool
}

func (c *failClosedController) GetCurrentConfig() *config.Config { return nil }

// GetAllServers is the sentinel: GET /api/v1/servers falls back to it because
// baseController has no management service.
func (c *failClosedController) GetAllServers() ([]map[string]interface{}, error) {
c.reachedHandler.Store(true)
return []map[string]interface{}{}, nil
}

func TestAPIKeyAuth_NilConfigFailsClosed(t *testing.T) {
logger := zap.NewNop().Sugar()
ctrl := &failClosedController{}
srv := NewServer(ctrl, logger, nil)

// A plain TCP request with no credentials of any kind.
req := httptest.NewRequest("GET", "/api/v1/servers", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusServiceUnavailable, w.Code,
"an unreadable config must refuse the request, not forward it unauthenticated")
assert.Contains(t, w.Body.String(), "cannot authenticate",
"the 503 body must explain why, not just carry the right status code")
assert.False(t, ctrl.reachedHandler.Load(),
"the handler must NOT run: a nil config means the request was never authenticated")
}
2 changes: 1 addition & 1 deletion internal/httpapi/auth_provider_probe_personal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type providerProbePersonalController struct {
cfg *config.Config
}

func (m *providerProbePersonalController) GetCurrentConfig() any { return m.cfg }
func (m *providerProbePersonalController) GetCurrentConfig() *config.Config { return m.cfg }
func (m *providerProbePersonalController) GetConfig() (*config.Config, error) {
return m.cfg, nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/code_exec_route_timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type codeExecDeadlineController struct {
hasDL bool
}

func (c *codeExecDeadlineController) GetCurrentConfig() interface{} { return c.cfg }
func (c *codeExecDeadlineController) GetCurrentConfig() *config.Config { return c.cfg }

func (c *codeExecDeadlineController) CallTool(ctx context.Context, _ string, _ map[string]interface{}) (interface{}, error) {
c.deadline, c.hasDL = ctx.Deadline()
Expand Down
5 changes: 3 additions & 2 deletions internal/httpapi/code_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/management"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -70,7 +71,7 @@ func (m *mockController) GetConfigPath() string { return ""
func (m *mockController) GetLogDir() string { return "" }
func (m *mockController) TriggerOAuthLogin(serverName string) error { return nil }
func (m *mockController) GetSecretResolver() interface{} { return nil }
func (m *mockController) GetCurrentConfig() interface{} { return nil }
func (m *mockController) GetCurrentConfig() *config.Config { return nil }
func (m *mockController) NotifySecretsChanged(ctx context.Context, operation, secretName string) error {
return nil
}
Expand Down Expand Up @@ -109,7 +110,7 @@ func (m *mockController) RemoveRegistrySourceRef(_ string) (*config.RegistryEntr
func (m *mockController) EditRegistrySourceRef(_, _, _, _ string) (*config.RegistryEntry, *contracts.RegistryAddError, error) {
return nil, nil, nil
}
func (m *mockController) GetManagementService() interface{} { return nil }
func (m *mockController) GetManagementService() management.Service { return nil }
func (m *mockController) GetRuntime() interface{} { return nil }
func (m *mockController) GetSessions(limit, offset int) (interface{}, int, error) { return nil, 0, nil }
func (m *mockController) GetSessionByID(id string) (interface{}, error) { return nil, nil }
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/code_scripts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type codeScriptsController struct {
configPath string
}

func (c *codeScriptsController) GetCurrentConfig() interface{} {
func (c *codeScriptsController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: c.apiKey}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/concurrency_shed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type shedController struct {
err error
}

func (m *shedController) GetCurrentConfig() any {
func (m *shedController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: m.apiKey}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/config_apply_error_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type applyErrController struct {
err error
}

func (m *applyErrController) GetCurrentConfig() any { return &config.Config{APIKey: "k"} }
func (m *applyErrController) GetCurrentConfig() *config.Config { return &config.Config{APIKey: "k"} }
func (m *applyErrController) GetConfig() (*config.Config, error) {
return &config.Config{Listen: "127.0.0.1:8080"}, nil
}
Expand Down
4 changes: 3 additions & 1 deletion internal/httpapi/config_patch_removed_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ type removedKeysController struct {
applied int
}

func (m *removedKeysController) GetCurrentConfig() any { return &config.Config{APIKey: "test-key"} }
func (m *removedKeysController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: "test-key"}
}
func (m *removedKeysController) GetConfig() (*config.Config, error) {
return m.live, nil
}
Expand Down
21 changes: 18 additions & 3 deletions internal/httpapi/contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/management"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight"
internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
Expand All @@ -28,7 +29,7 @@ import (
type MockServerController struct{}

// mockManagementService provides a test implementation of management service methods
type mockManagementService struct{}
type mockManagementService struct{ management.Service }

func (m *mockManagementService) ListServers(ctx context.Context) ([]*contracts.Server, *contracts.ServerStats, error) {
return []*contracts.Server{
Expand Down Expand Up @@ -91,7 +92,7 @@ func (m *mockManagementService) TriggerOAuthLogout(ctx context.Context, name str

func (m *MockServerController) IsRunning() bool { return true }
func (m *MockServerController) GetListenAddress() string { return ":8080" }
func (m *MockServerController) GetManagementService() interface{} {
func (m *MockServerController) GetManagementService() management.Service {
return &mockManagementService{}
}
func (m *MockServerController) GetUpstreamStats() map[string]interface{} {
Expand Down Expand Up @@ -236,7 +237,15 @@ func (m *MockServerController) GetSecretResolver() *secret.Resolver { return nil
func (m *MockServerController) NotifySecretsChanged(_ context.Context, _, _ string) error {
return nil
}
func (m *MockServerController) GetCurrentConfig() interface{} { return map[string]interface{}{} }

// mockControllerAPIKey is the admin key every request through a
// MockServerController-backed server must present: since SEC-02 the auth
// middleware refuses a request it cannot authenticate instead of forwarding it.
const mockControllerAPIKey = "mock-controller-admin-key"

func (m *MockServerController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: mockControllerAPIKey}
}

// Tool call history methods
func (m *MockServerController) GetToolCalls(_ int, _ int, _ storage.ToolCallScope) ([]*contracts.ToolCallRecord, int, error) {
Expand Down Expand Up @@ -436,6 +445,7 @@ func TestAPIContractCompliance(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Create request
req := httptest.NewRequest(tt.method, tt.path, http.NoBody)
req.Header.Set("X-API-Key", mockControllerAPIKey)
w := httptest.NewRecorder()

// Execute request
Expand Down Expand Up @@ -572,6 +582,7 @@ func TestEndpointResponseTypes(t *testing.T) {
for _, tt := range actionTests {
t.Run(tt.path, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, http.NoBody)
req.Header.Set("X-API-Key", mockControllerAPIKey)
w := httptest.NewRecorder()

server.ServeHTTP(w, req)
Expand All @@ -598,6 +609,7 @@ func TestEndpointResponseTypes(t *testing.T) {
// Test login endpoint (Spec 020: returns OAuthStartResponse instead of ServerActionResponse)
t.Run("/api/v1/servers/test-server/login", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/v1/servers/test-server/login", http.NoBody)
req.Header.Set("X-API-Key", mockControllerAPIKey)
w := httptest.NewRecorder()

server.ServeHTTP(w, req)
Expand Down Expand Up @@ -632,6 +644,7 @@ func TestInfoEndpointReturnsVersion(t *testing.T) {
server := NewServer(controller, logger, nil)

req := httptest.NewRequest("GET", "/api/v1/info", http.NoBody)
req.Header.Set("X-API-Key", mockControllerAPIKey)
w := httptest.NewRecorder()

server.ServeHTTP(w, req)
Expand Down Expand Up @@ -676,6 +689,7 @@ func TestInfoEndpointIncludesUpdateInfo(t *testing.T) {
server := NewServer(controller, logger, nil)

req := httptest.NewRequest("GET", "/api/v1/info", http.NoBody)
req.Header.Set("X-API-Key", mockControllerAPIKey)
w := httptest.NewRecorder()

server.ServeHTTP(w, req)
Expand Down Expand Up @@ -748,6 +762,7 @@ func BenchmarkAPIResponseMarshaling(b *testing.B) {
server := NewServer(controller, logger, nil)

req := httptest.NewRequest("GET", "/api/v1/servers", http.NoBody)
req.Header.Set("X-API-Key", mockControllerAPIKey)

b.ResetTimer()
for i := 0; i < b.N; i++ {
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/diagnostics_fix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type mockFixController struct {
apiKey string
}

func (m *mockFixController) GetCurrentConfig() any {
func (m *mockFixController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: m.apiKey}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/diagnostics_per_server_redact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type mockPerServerDiagController struct {
reveal bool
}

func (m *mockPerServerDiagController) GetCurrentConfig() any {
func (m *mockPerServerDiagController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: m.apiKey}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/docker_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type mockDockerStatusController struct {
isolationEnabled bool
}

func (m *mockDockerStatusController) GetCurrentConfig() any {
func (m *mockDockerStatusController) GetCurrentConfig() *config.Config {
return &config.Config{APIKey: m.apiKey}
}

Expand Down
Loading
Loading