Files
summercms/modules/cabana/dispatch_marker_test.go
Jakub Zych 3bbbe6c11e test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go,
  marker; plugins, consumers, docker, admin and docs are stubs until the
  later tasks fill them)
- modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes
  plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver,
  routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged
- TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres
  part through the screens harness, which now keeps its app)
2026-10-23 13:31:41 +02:00

170 lines
6.6 KiB
Go

package cabana_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
)
// journalShaped is a plugin route shaped like a WinterCMS-style API that
// resolves its admin inside the handler (no route guard): it answers the
// principal cabana.ResolveBackend gives it.
type journalShaped struct{ app *backpack.App }
func (journalShaped) ID() string { return "acme.posts" }
func (journalShaped) Requires() []string { return nil }
func (journalShaped) Register(*backpack.App) error { return nil }
func (journalShaped) Boot(*backpack.App) error { return nil }
func (p journalShaped) Routes(r pact.Router) error {
r.Get("/acme/posts", func(w http.ResponseWriter, req *http.Request) {
req, principal, err := cabana.ResolveBackend(p.app, req, "acme.posts")
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
user, _ := bouncer.User(req.Context())
_ = json.NewEncoder(w).Encode(map[string]any{"principal": principal.ID, "stored": user.ID})
})
return nil
}
func markerApp(t *testing.T) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
// TestDispatchMarker (phase 19.7 D-17, the cabana side): ResolveBackend
// answers a backend marker's principal and refuses a frontend one; a
// plugin route that resolves its admin inside the handler sees the
// dispatched principal through surf's compiled chain; and (Postgres) the
// assembled admin API honours a marker only in-process: the same request
// sent over HTTP, with any dispatch-looking header or cookie, is 401.
func TestDispatchMarker(t *testing.T) {
marker := func(r *http.Request, p *bouncer.Principal) *http.Request {
return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p}))
}
t.Run("ResolveBackend backend and frontend markers", func(t *testing.T) {
app := backpack.New(nil)
_, p, err := cabana.ResolveBackend(app, marker(httptest.NewRequest(http.MethodGet, "/x", nil), &bouncer.Principal{ID: 42, Backend: true}))
if err != nil || p.ID != 42 {
t.Fatalf("backend marker = %+v, %v", p, err)
}
if _, _, err := cabana.ResolveBackend(app, marker(httptest.NewRequest(http.MethodGet, "/x", nil), &bouncer.Principal{ID: 7})); !errors.Is(err, cabana.ErrUnauthenticated) {
t.Fatalf("frontend marker: err = %v", err)
}
other := httptest.NewRequest(http.MethodGet, "/x", nil)
other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 42, Backend: true}}))
if _, _, err := cabana.ResolveBackend(app, other); !errors.Is(err, cabana.ErrUnauthenticated) {
t.Fatalf("marker for another guard: err = %v", err)
}
})
t.Run("plugin route resolving inside its handler", func(t *testing.T) {
app := markerApp(t)
router, err := surf.BuildRouter(app, []party.Plugin{journalShaped{app: app}})
if err != nil {
t.Fatal(err)
}
h, ok := router.Handler(http.MethodGet, "/acme/posts")
if !ok {
t.Fatal("no compiled handler for GET /acme/posts")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, marker(httptest.NewRequest(http.MethodGet, "/acme/posts", nil), &bouncer.Principal{ID: 42, Backend: true}))
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if rec.Code != http.StatusOK || got["principal"] != 42.0 || got["stored"] != 42.0 {
t.Fatalf("dispatched = %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/posts", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("without the marker = %d", rec.Code)
}
})
// The Postgres part runs on a Docker host (the gate's docker stage);
// under -short it is not created, so no subtest is reported as skipped.
if testing.Short() {
return
}
t.Run("admin API honours a marker only in-process", func(t *testing.T) {
env := newScreensEnv(t)
admin, _ := env.admin(t, env.unique("marker"), false, `{}`)
principal, err := cabana.BackendPrincipal(context.Background(), env.app, admin.ID)
if err != nil || principal == nil || !principal.Backend {
t.Fatalf("BackendPrincipal = %+v, %v", principal, err)
}
// In-process: the marker carries the admin through the backend guard.
req, err := env.request(http.MethodGet, "/auth/me", "", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, marker(req, principal))
if rec.Code != http.StatusOK {
t.Fatalf("in-process marker = %d %s", rec.Code, rec.Body.String())
}
var me struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil || me.Data.ID != admin.ID {
t.Fatalf("/auth/me answered %s", rec.Body.String())
}
// Over HTTP the marker never arrives: the client's context is not
// the server's, and no header, cookie or query string stands in.
srv := httptest.NewServer(env.h)
defer srv.Close()
out, err := http.NewRequestWithContext(bouncer.WithDispatch(context.Background(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: principal}),
http.MethodGet, srv.URL+adminAPI("/auth/me")+"?dispatch=backend", nil)
if err != nil {
t.Fatal(err)
}
out.Header.Set("X-Summer-Dispatch", "backend")
out.Header.Set("Authorization", "Dispatch backend")
out.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "backend"})
res, err := http.DefaultClient.Do(out)
if err != nil {
t.Fatal(err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("over HTTP = %d, want 401", res.StatusCode)
}
// A frontend marker is not an admin, in-process either.
req, _ = env.request(http.MethodGet, "/auth/me", "", nil)
rec = httptest.NewRecorder()
env.h.ServeHTTP(rec, marker(req, &bouncer.Principal{ID: admin.ID}))
if rec.Code == http.StatusOK {
t.Fatalf("frontend marker reached /auth/me: %s", rec.Body.String())
}
})
}