Closes #155
Reviewed by pr-gatekeeper: #181 (comment) (block, round 1) and re-review at e05a13a (approve).
Co-authored-by: Mathias <mthbqv@gmail.com>
112 lines
3.9 KiB
Go
112 lines
3.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/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) 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()
|
|
h := testHandler(t)
|
|
public := map[string]http.Handler{}
|
|
for p := range publicRoutes {
|
|
public[p] = okHandler("public " + p)
|
|
}
|
|
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public)
|
|
require.NoError(t, err)
|
|
return mux
|
|
}
|
|
|
|
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{})
|
|
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
|
|
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, 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, nil)
|
|
assert.Error(t, err)
|
|
}
|