- 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)
170 lines
6.6 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|