@@ -15,6 +15,7 @@ import (
1515
1616 mcpparser "github.com/stacklok/toolhive/pkg/mcp"
1717 "github.com/stacklok/toolhive/pkg/vmcp"
18+ authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
1819 "github.com/stacklok/toolhive/pkg/vmcp/mocks"
1920)
2021
@@ -915,6 +916,36 @@ func TestBackendChanged(t *testing.T) {
915916 new : vmcp.Backend {BaseURL : "http://new-svc:9090" , TransportType : "streamable-http" },
916917 expected : true ,
917918 },
919+ {
920+ name : "auth config added" ,
921+ old : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" },
922+ new : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" ,
923+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeUpstreamInject }},
924+ expected : true ,
925+ },
926+ {
927+ name : "auth config removed" ,
928+ old : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" ,
929+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeUpstreamInject }},
930+ new : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" },
931+ expected : true ,
932+ },
933+ {
934+ name : "auth config type changed" ,
935+ old : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" ,
936+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeUpstreamInject }},
937+ new : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" ,
938+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeTokenExchange }},
939+ expected : true ,
940+ },
941+ {
942+ name : "auth config equal" ,
943+ old : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" ,
944+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeUpstreamInject }},
945+ new : vmcp.Backend {BaseURL : "http://svc:8080" , TransportType : "sse" ,
946+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeUpstreamInject }},
947+ expected : false ,
948+ },
918949 }
919950
920951 for _ , tt := range tests {
@@ -987,6 +1018,63 @@ func TestMonitor_UpdateBackends_PropertyChange(t *testing.T) {
9871018 assert .Equal (t , 1 , summary .Healthy , "backend should be healthy" )
9881019}
9891020
1021+ // TestMonitor_UpdateBackends_AuthConfigChange is the regression test for #6509:
1022+ // a backend that gains outgoing auth after the monitor started must have its
1023+ // check goroutine restarted, so the 401 from its credential-less probe is
1024+ // classified against the current auth config (the expected auth challenge,
1025+ // healthy) instead of the stale auth-less copy (unauthenticated).
1026+ func TestMonitor_UpdateBackends_AuthConfigChange (t * testing.T ) {
1027+ t .Parallel ()
1028+
1029+ ctrl := gomock .NewController (t )
1030+ t .Cleanup (ctrl .Finish )
1031+
1032+ mockClient := mocks .NewMockBackendClient (ctrl )
1033+
1034+ // The backend enforces auth on its MCP endpoint: every probe fails with an
1035+ // authentication error, because health probes deliberately carry no credentials.
1036+ mockClient .EXPECT ().
1037+ ListCapabilities (gomock .Any (), gomock .Any ()).
1038+ Return (nil , errors .Join (vmcp .ErrAuthenticationFailed , errors .New ("initialize: 401" ))).
1039+ AnyTimes ()
1040+
1041+ initialBackends := []vmcp.Backend {
1042+ {ID : "backend-1" , Name : "Backend 1" , BaseURL : "http://svc:8080" , TransportType : "sse" },
1043+ }
1044+
1045+ config := MonitorConfig {
1046+ CheckInterval : 50 * time .Millisecond ,
1047+ UnhealthyThreshold : 1 ,
1048+ Timeout : 10 * time .Millisecond ,
1049+ }
1050+
1051+ monitor , err := NewMonitor (mockClient , initialBackends , config )
1052+ require .NoError (t , err )
1053+
1054+ ctx := context .Background ()
1055+ require .NoError (t , monitor .Start (ctx ))
1056+ t .Cleanup (func () { _ = monitor .Stop () })
1057+
1058+ // Without an auth config the 401 means misconfiguration: unauthenticated.
1059+ require .Eventually (t , func () bool {
1060+ status , ok := monitor .QueryBackendStatus ("backend-1" )
1061+ return ok && status == vmcp .BackendUnauthenticated
1062+ }, 500 * time .Millisecond , 10 * time .Millisecond , "backend-1 should be classified unauthenticated while auth-less" )
1063+
1064+ // The backend gains outgoing auth (e.g. an externalAuthConfigRef reconciled
1065+ // into the registry). URL and transport are unchanged.
1066+ monitor .UpdateBackends ([]vmcp.Backend {
1067+ {ID : "backend-1" , Name : "Backend 1" , BaseURL : "http://svc:8080" , TransportType : "sse" ,
1068+ AuthConfig : & authtypes.BackendAuthStrategy {Type : authtypes .StrategyTypeUpstreamInject }},
1069+ })
1070+
1071+ // The restarted check loop probes with the auth-carrying copy: the same 401
1072+ // is now the expected auth challenge and classifies healthy.
1073+ require .Eventually (t , func () bool {
1074+ return monitor .IsBackendHealthy ("backend-1" )
1075+ }, 500 * time .Millisecond , 10 * time .Millisecond , "backend-1 should classify healthy once its auth config reaches the check loop" )
1076+ }
1077+
9901078func TestMonitor_CircuitBreakerDisabled (t * testing.T ) {
9911079 t .Parallel ()
9921080
0 commit comments