/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>
68 lines
2.7 KiB
Go
68 lines
2.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// hyperguild#155: since #149 every REST route sits behind BearerMiddleware,
|
|
// and chassis v0.2.0 refused without logging, so an unknown caller got
|
|
// silent 401s. The chassis now writes an `mcp auth rejected` line.
|
|
func TestBuildMuxLogsARESTAuthRejection(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
|
|
code, _ := do(t, testMux(t), "POST", "/query", "Bearer wrong")
|
|
require.Equal(t, http.StatusUnauthorized, code)
|
|
assert.Contains(t, buf.String(), "mcp auth rejected")
|
|
assert.Contains(t, buf.String(), "path=/query")
|
|
assert.NotContains(t, buf.String(), "Bearer wrong", "the credential itself is never logged")
|
|
}
|
|
|
|
// An authenticated REST request is labelled with the REST mux's pattern:
|
|
// the inner mux sets it on the same *http.Request the outer middleware reads.
|
|
func TestMetricsLabelAnAuthenticatedRESTRequestWithItsRoutePattern(t *testing.T) {
|
|
mux, reg := testMuxAndRegistry(t)
|
|
code, _ := do(t, mux, "GET", "/pending", "Bearer "+routesTok)
|
|
require.Equal(t, http.StatusOK, code)
|
|
|
|
rec := httptest.NewRecorder()
|
|
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
|
assert.Contains(t, rec.Body.String(), `path="GET /pending"`)
|
|
assert.NotContains(t, rec.Body.String(), `path="/"`)
|
|
}
|
|
|
|
// hyperguild#155: a request refused by REST auth never reaches the REST mux,
|
|
// so it was labelled path="/" and a 4xx on /query could not be told from one
|
|
// on /write. It is now labelled with the REST pattern it was addressed to.
|
|
func TestMetricsLabelARejectedRESTRequestWithItsRoutePattern(t *testing.T) {
|
|
mux, reg := testMuxAndRegistry(t)
|
|
code, _ := do(t, mux, "POST", "/query", "")
|
|
require.Equal(t, http.StatusUnauthorized, code)
|
|
|
|
rec := httptest.NewRecorder()
|
|
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
|
assert.Contains(t, rec.Body.String(), `path="POST /query",status="4xx"`)
|
|
}
|
|
|
|
// A path no REST route matches keeps the catch-all label, so an attacker
|
|
// probing random URLs cannot grow the series set.
|
|
func TestMetricsLabelARejectedUnknownPathWithTheCatchAll(t *testing.T) {
|
|
mux, reg := testMuxAndRegistry(t)
|
|
code, _ := do(t, mux, "POST", "/no-such-route-zq", "")
|
|
require.Equal(t, http.StatusUnauthorized, code)
|
|
|
|
rec := httptest.NewRecorder()
|
|
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
|
assert.Contains(t, rec.Body.String(), `path="/",status="4xx"`)
|
|
assert.NotContains(t, rec.Body.String(), "no-such-route-zq")
|
|
}
|