Files
summercms/modules/surf/dispatch_smoke_test.go
Jakub Zych 1bd91a4522 feat(19.7-01): in-process dispatch marker and compiled handler lookup
- bouncer.Dispatch, WithDispatch, Dispatched: context-only pre-resolved
  principal marker honoured by Registry.Middleware for listed guard names
- cabana.ResolveBackend honours a backend-kind marker; additive
  ResolveBackendToken, ScopeLimitFor and BackendPrincipal resolvers
- surf.Router.Handler and compileHandlers: one compiled chain per route,
  shared by BuildRouter validation and compile
- smoke tests TestDispatchSmoke, TestDispatchedSmoke, TestDispatchMarkerSmoke
- READMEs and docs/services/{authentication,routing}.md updated
2026-10-22 16:00:19 +02:00

143 lines
4.7 KiB
Go

package surf
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Smoke test for the in-process dispatch tracer of phase 19.7 (D-17): a
// request carrying the bouncer dispatch marker runs a guarded route through
// surf's compiled chain as the pre-resolved principal, while nothing an HTTP
// request can carry is treated as one.
// refusingGuard never authenticates a request and writes its own 401.
type refusingGuard struct{}
func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
return nil, nil, errors.New("acme: no token")
}
func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`))
}
// dispatchPlugin registers bouncer guard "acme" as middleware "acme.auth"
// and one guarded route.
type dispatchPlugin struct{}
func (dispatchPlugin) ID() string { return "acme.items" }
func (dispatchPlugin) Requires() []string { return nil }
func (dispatchPlugin) Register(*backpack.App) error { return nil }
func (dispatchPlugin) Boot(*backpack.App) error { return nil }
func (p dispatchPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
func (dispatchPlugin) Routes(r pact.Router) error {
r.Group("/acme", Use("acme.auth"), func(g pact.Router) {
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
user, _ := bouncer.User(req.Context())
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"user":` + itoa(user.ID) + `,"item":"` + req.PathValue("id") + `"}`))
})
})
return nil
}
func itoa(n uint) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func TestDispatchSmoke(t *testing.T) {
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits})
router, err := BuildRouter(app, []party.Plugin{dispatchPlugin{}})
if err != nil {
t.Fatal(err)
}
if _, ok := router.Handler("GET", "/acme/items/{id}/missing"); ok {
t.Fatal("Handler found an unregistered route")
}
h, ok := router.Handler("GET", "/acme/items/{id}")
if !ok || h == nil {
t.Fatal("Handler did not find GET /acme/items/{id} after BuildRouter")
}
var nilRouter *Router
if _, ok := nilRouter.Handler("GET", "/acme/items/{id}"); ok {
t.Fatal("nil router found a handler")
}
serve := func(set func(*http.Request) *http.Request) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/acme/items/7", nil)
req.SetPathValue("id", "7")
req.Pattern = "GET /acme/items/{id}"
req = set(req)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
marker := func(guards ...string) func(*http.Request) *http.Request {
return func(req *http.Request) *http.Request {
d := &bouncer.Dispatch{Guards: guards, Principal: &bouncer.Principal{ID: 42}}
return req.WithContext(bouncer.WithDispatch(req.Context(), d))
}
}
rec := serve(marker("acme"))
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"user":42,"item":"7"}` {
t.Fatalf("dispatch with the marker = %d %s", rec.Code, rec.Body.String())
}
if rec := serve(func(req *http.Request) *http.Request { return req }); rec.Code != http.StatusUnauthorized {
t.Fatalf("request without the marker = %d %s", rec.Code, rec.Body.String())
}
forged := serve(func(req *http.Request) *http.Request {
req.Header.Set("X-Summer-Dispatch", "42")
req.Header.Set("Authorization", "Dispatch 42")
req.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "42"})
return req
})
if forged.Code != http.StatusUnauthorized {
t.Fatalf("forged headers = %d %s", forged.Code, forged.Body.String())
}
if rec := serve(marker("other")); rec.Code != http.StatusUnauthorized {
t.Fatalf("marker for another guard = %d %s", rec.Code, rec.Body.String())
}
// The assembled handler serves the same route over HTTP unchanged.
full, err := router.compile()
if err != nil {
t.Fatal(err)
}
rec = httptest.NewRecorder()
full.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/items/7", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("HTTP request without a token = %d %s", rec.Code, rec.Body.String())
}
}