package main import ( "bytes" "context" "io" "log/slog" "net/http" "net/http/httptest" "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mathiasbq/hyperguild/ingestion/internal/metrics" ) // fakeIssuer answers OIDC discovery with 502 for the first fail requests, as // ingress does while dex is not yet serving on a cold boot (#83), then with a // real discovery document and an empty JWKS. func fakeIssuer(t *testing.T, fail int32) (*httptest.Server, *atomic.Int32) { t.Helper() var discoveries atomic.Int32 mux := http.NewServeMux() var srv *httptest.Server mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { if discoveries.Add(1) <= fail { w.WriteHeader(http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"issuer":"`+srv.URL+`","jwks_uri":"`+srv.URL+`/keys"}`) }) mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"keys":[]}`) }) srv = httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, &discoveries } // #83 retried discovery at boot because chassis v0.2.0 failed construction on // a 502. Since chassis v0.7 (mcp-chassis#4) the validator is built anyway and // resolves the issuer lazily, so boot no longer waits or exits on it // (hyperguild#155). An unresolved issuer is logged at boot. func TestBuildJWTValidatorReturnsAValidatorWhileDiscovery502s(t *testing.T) { srv, _ := fakeIssuer(t, 1000) var buf bytes.Buffer v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain") require.NoError(t, err) assert.NotNil(t, v) assert.Contains(t, buf.String(), "jwt issuer not resolved at boot") } func TestBuildJWTValidatorLogsNothingAgainstAHealthyIssuer(t *testing.T) { srv, _ := fakeIssuer(t, 0) var buf bytes.Buffer v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain") require.NoError(t, err) assert.NotNil(t, v) assert.NotContains(t, buf.String(), "not resolved") } func TestBuildJWTValidatorIsNilWithoutAnIssuer(t *testing.T) { v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), "", "brain") require.NoError(t, err) assert.Nil(t, v) } // With the issuer unresolved, a JWT on a REST route answers 503 (retry), not // 401: the token may be fine, the server just cannot check it yet. func TestBuildMuxAnswers503ToAJWTWhileTheIssuerIsUnresolved(t *testing.T) { srv, _ := fakeIssuer(t, 1000) v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain") require.NoError(t, err) mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t), okHandler("mcp"), metrics.New(), nil) require.NoError(t, err) code, _ := do(t, mux, "GET", "/pending", "Bearer aaa.bbb.ccc") assert.Equal(t, http.StatusServiceUnavailable, code) } // PR #183 review: /mcp shares REST's bearer, but only the static token was // asserted on it. With the issuer unresolved, a JWT on /mcp answers 503 too, // so /mcp's middleware was built with the JWT validator. func TestBuildMuxAnswers503ToAJWTOnMCPWhileTheIssuerIsUnresolved(t *testing.T) { srv, _ := fakeIssuer(t, 1000) v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain") require.NoError(t, err) mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t), okHandler("mcp"), metrics.New(), nil) require.NoError(t, err) code, _ := do(t, mux, "POST", "/mcp", "Bearer aaa.bbb.ccc") assert.Equal(t, http.StatusServiceUnavailable, code) } // PR #183 review: a 401 on /mcp names the protected-resource metadata in // WWW-Authenticate (what the claude.ai OAuth connector discovers from), so // /mcp's middleware was built with resourceMetadataURL. func TestBuildMuxMCP401CarriesResourceMetadataInWWWAuthenticate(t *testing.T) { const rm = "https://brain.example/.well-known/oauth-protected-resource" mux, err := buildMux(restAuth{token: routesTok, realm: "brain", resourceMetadataURL: rm}, testHandler(t), okHandler("mcp"), metrics.New(), nil) require.NoError(t, err) rec := httptest.NewRecorder() mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) require.Equal(t, http.StatusUnauthorized, rec.Code) assert.Contains(t, rec.Header().Get("WWW-Authenticate"), `resource_metadata="`+rm+`"`) }