/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>
89 lines
3.7 KiB
Go
89 lines
3.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"
|
|
)
|
|
|
|
// wrongTokSentinel is a presented credential distinctive enough that any
|
|
// occurrence in a log line is the raw token, not a coincidence.
|
|
const wrongTokSentinel = "wrong-tok-sentinel-q7zx"
|
|
|
|
// 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, and the
|
|
// presented token appears nowhere in the log as a bare value (PR #181
|
|
// review: asserting on the "Bearer " header form could never fail, since
|
|
// the chassis logs fields, not headers).
|
|
func TestBuildMuxRejectionLogLineOmitsTheBarePresentedToken(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 "+wrongTokSentinel)
|
|
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(), wrongTokSentinel, "the rejected token is logged as a bare value")
|
|
assert.NotContains(t, buf.String(), routesTok, "the valid static token is logged")
|
|
}
|
|
|
|
// The `mcp auth accepted` line leaves out the valid static token.
|
|
func TestBuildMuxAcceptedLogLineOmitsTheBareStaticToken(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), "GET", "/pending", "Bearer "+routesTok)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, buf.String(), "mcp auth accepted")
|
|
assert.NotContains(t, buf.String(), routesTok, "the accepted static token is logged as a bare value")
|
|
}
|
|
|
|
// 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")
|
|
}
|