An authenticated REST request to an unregistered path reached the inner mux, which left Pattern empty, and metrics.Middleware fell back to the raw URL path: any token holder could create one Prometheus series per unique path. The fallback is now the fixed label "unmatched"; real routes keep their pattern. Tests: buildMux with an authenticated random path, and the middleware unit test; the timing test now routes through a ServeMux as production does. Fixes #185. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
6.8 KiB
Go
175 lines
6.8 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 through buildMux, the function main calls, with
|
|
// every allowlisted public pattern (stub handlers), a stub MCP server and a
|
|
// static token only: no JWT validator and no resourceMetadataURL (the tests
|
|
// in jwt_validator_test.go pass those). It does not use main's own restAuth
|
|
// or public map (hyperguild#182). 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/JWT on /capture, HMAC on the webhook,
|
|
// client secret on /oauth/token) or is meant to be open (OAuth metadata).
|
|
// /mcp and /metrics are not public routes: buildMux builds them itself.
|
|
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"`)
|
|
}
|
|
|
|
// hyperguild#185: an authenticated request to an unregistered path reached
|
|
// the inner REST mux, which left Pattern empty, and the metrics label fell
|
|
// back to the raw URL path: one Prometheus series per unique path any token
|
|
// holder sends. It is now labelled with a fixed catch-all.
|
|
func TestBuildMuxLabelsAnUnregisteredPathWithAFixedLabel(t *testing.T) {
|
|
r := testMux(t)
|
|
code, _ := do(t, r, "GET", "/zqrandom-185-segment", "Bearer "+routesTok)
|
|
require.Equal(t, http.StatusNotFound, code)
|
|
_, body := do(t, r, "GET", "/metrics", "")
|
|
assert.NotContains(t, body, "zqrandom-185-segment", "the raw path became a metrics label")
|
|
assert.Contains(t, body, `path="unmatched"`)
|
|
}
|