PR #183 review (comment 15474): - TestBuildMuxAnswers503ToAJWTOnMCPWhileTheIssuerIsUnresolved: building /mcp's middleware with a nil validator turns it red. - TestBuildMuxMCP401CarriesResourceMetadataInWWWAuthenticate: building it with resourceMetadataURL "" turns it red. - main.go, routes.go and the testMux comment now say exactly what is untested: main's restAuth fields and public map passed to buildMux, and ListenAndServe (hyperguild#182). Stale public-route list in the TestBuildMuxDoesNotWrapPublicRoutes comment corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.5 KiB
Go
112 lines
4.5 KiB
Go
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+`"`)
|
|
}
|