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)
This commit is contained in:
@@ -141,6 +141,7 @@ func (p grantPlugin) Permissions() []pact.Permission { return p.grants }
|
||||
// extra plugins and the admin's own screens, on the shared Postgres.
|
||||
type screensEnv struct {
|
||||
h http.Handler
|
||||
app *backpack.App
|
||||
gdb *gorm.DB
|
||||
stamp string
|
||||
spy *pingSpy
|
||||
@@ -182,7 +183,7 @@ func newScreensEnv(t *testing.T, extra ...party.Plugin) *screensEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy}
|
||||
return &screensEnv{h: h, app: app, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy}
|
||||
}
|
||||
|
||||
// unique is name plus the env stamp and a per-env sequence number.
|
||||
|
||||
169
modules/cabana/dispatch_marker_test.go
Normal file
169
modules/cabana/dispatch_marker_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user