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()) } }) }