/mcp's bearer was built in main.go and the buildMux tests used stubs, so dropping it would have kept CI green. buildMux now takes the MCP handler and wraps it with the same BearerMiddleware and credential as the REST API. "/mcp" and "GET /metrics" left the public allowlist, so passing either as a public route is refused. buildMux also registers GET /metrics and returns the metrics-wrapped handler, so main passes its result to ListenAndServe unchanged. Flip-tested: registering /mcp unwrapped turns TestBuildMuxRefusesMCPWithoutAValidToken red, and dropping the metrics wrap turns the four metrics tests red. Comments now claim only what is tested. The ListenAndServe line and the symlink tripwire wiring in main (#154 comment) are still untested and tracked in #182. Closes #154 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.9 KiB
Go
159 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
)
|
|
|
|
const routesTok = "routes-test-token"
|
|
|
|
func okHandler(body string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
|
|
}
|
|
|
|
// testMux builds the router exactly as main does: buildMux with the full
|
|
// public set main registers (stub handlers), a stub MCP server, and real
|
|
// bearer config. hyperguild#149: the REST API is authenticated as a whole;
|
|
// the PR #152 review showed a test of a COPY of the router let main.go
|
|
// regress unseen.
|
|
func testMux(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
mux, _ := testMuxAndRegistry(t)
|
|
return mux
|
|
}
|
|
|
|
func testMuxAndRegistry(t *testing.T) (http.Handler, *metrics.Registry) {
|
|
t.Helper()
|
|
public := map[string]http.Handler{}
|
|
for p := range publicRoutes {
|
|
public[p] = okHandler("public " + p)
|
|
}
|
|
reg := metrics.New()
|
|
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, testHandler(t), okHandler("mcp"), reg, public)
|
|
require.NoError(t, err)
|
|
return mux, reg
|
|
}
|
|
|
|
func testHandler(t *testing.T) *api.Handler {
|
|
t.Helper()
|
|
return api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
|
}
|
|
|
|
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
|
|
if authz != "" {
|
|
req.Header.Set("Authorization", authz)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Body.String()
|
|
}
|
|
|
|
func TestBuildMuxRefusesRESTRoutesWithoutAValidToken(t *testing.T) {
|
|
r := testMux(t)
|
|
for _, rt := range []struct{ method, path string }{
|
|
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
|
|
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
|
|
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
|
|
} {
|
|
code, _ := do(t, r, rt.method, rt.path, "")
|
|
assert.Equal(t, http.StatusUnauthorized, code, "%s %s without a token", rt.method, rt.path)
|
|
code, _ = do(t, r, rt.method, rt.path, "Bearer wrong")
|
|
assert.Equal(t, http.StatusUnauthorized, code, "%s %s with a wrong token", rt.method, rt.path)
|
|
}
|
|
}
|
|
|
|
func TestBuildMuxServesRESTRoutesToAValidToken(t *testing.T) {
|
|
r := testMux(t)
|
|
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
}
|
|
|
|
func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
|
|
code, _ := do(t, testMux(t), "POST", "/some-future-route", "")
|
|
assert.Equal(t, http.StatusUnauthorized, code)
|
|
}
|
|
|
|
// buildMux registers public handlers as given, without adding auth: each
|
|
// public route owns its own (bearer on /mcp, HMAC on the webhook, client
|
|
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata).
|
|
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
|
|
r := testMux(t)
|
|
for p := range publicRoutes {
|
|
method, path, ok := strings.Cut(p, " ")
|
|
if !ok {
|
|
method, path = "GET", p
|
|
}
|
|
code, body := do(t, r, method, path, "")
|
|
assert.Equal(t, http.StatusOK, code, p)
|
|
assert.Equal(t, "public "+p, body, p)
|
|
}
|
|
}
|
|
|
|
// The allowlist is the control: a REST pattern registered as public would win
|
|
// over the authenticated catch-all (more specific pattern), so it is refused.
|
|
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
|
|
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
|
// "/mcp" and "GET /metrics" are built by buildMux itself (hyperguild#154):
|
|
// passing either as public would register them unauthenticated or twice.
|
|
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics", "/mcp", "GET /metrics"} {
|
|
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, okHandler("mcp"), metrics.New(),
|
|
map[string]http.Handler{p: okHandler("x")})
|
|
assert.Error(t, err, "public pattern %q must be refused", p)
|
|
}
|
|
}
|
|
|
|
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
|
|
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
|
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, okHandler("mcp"), metrics.New(), nil)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// hyperguild#154: /mcp's bearer was built in main.go, untested; the buildMux
|
|
// tests used stubs. buildMux now wraps the MCP handler itself, with the same
|
|
// credential as the REST API.
|
|
func TestBuildMuxRefusesMCPWithoutAValidToken(t *testing.T) {
|
|
r := testMux(t)
|
|
for _, authz := range []string{"", "Bearer wrong"} {
|
|
code, body := do(t, r, "POST", "/mcp", authz)
|
|
assert.Equal(t, http.StatusUnauthorized, code, "authz %q", authz)
|
|
assert.NotEqual(t, "mcp", body)
|
|
}
|
|
code, body := do(t, r, "POST", "/mcp", "Bearer "+routesTok)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "mcp", body)
|
|
}
|
|
|
|
func TestBuildMuxRefusesANilMCPHandlerOrRegistry(t *testing.T) {
|
|
auth := restAuth{token: routesTok, realm: "brain"}
|
|
_, err := buildMux(auth, testHandler(t), nil, metrics.New(), nil)
|
|
assert.Error(t, err)
|
|
_, err = buildMux(auth, testHandler(t), okHandler("mcp"), nil, nil)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// hyperguild#154: main wrapped buildMux's result in the metrics middleware
|
|
// before ListenAndServe, untested. buildMux now returns the handler main
|
|
// serves as-is, metrics included: a request through it is observed and
|
|
// exposed on its own GET /metrics.
|
|
func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
|
|
r := testMux(t)
|
|
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
|
|
require.Equal(t, http.StatusOK, code)
|
|
code, body := do(t, r, "GET", "/metrics", "")
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, body, `path="GET /pending"`)
|
|
}
|