- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
353 lines
14 KiB
Go
353 lines
14 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"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"
|
|
)
|
|
|
|
// TestPhase10CookieAuth pins the D-19 session transport: cookie login and
|
|
// refresh never put the JWT in a body, Bearer clients keep the Phase 9 body,
|
|
// a cookie refresh needs the CSRF header, and logout blacklists the jti and
|
|
// expires the cookie.
|
|
func TestPhase10CookieAuth(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "p10cookie", "p10cookie@example.test", adminTestPassword, true, false)
|
|
creds := map[string]string{"login": "p10cookie", "password": adminTestPassword}
|
|
|
|
login := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, true)
|
|
if login.Code != http.StatusOK {
|
|
t.Fatalf("cookie login status=%d body=%s", login.Code, login.Body.String())
|
|
}
|
|
first := phase10Cookie(t, login, cabana.DefaultAdminPrefix)
|
|
phase10AssertCookieBody(t, login, first.Value)
|
|
|
|
bearer := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, false)
|
|
if bearer.Code != http.StatusOK {
|
|
t.Fatalf("bearer login status=%d body=%s", bearer.Code, bearer.Body.String())
|
|
}
|
|
phase10AssertBearerBody(t, bearer)
|
|
if got := bearer.Header().Values("Set-Cookie"); len(got) != 0 {
|
|
t.Fatalf("bearer login set cookies: %q", got)
|
|
}
|
|
bearerToken := accessToken(t, bearer.Body.Bytes())
|
|
|
|
noHeader := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, false)
|
|
if noHeader.Code != http.StatusForbidden || phase10ErrorCode(t, noHeader) != "forbidden" {
|
|
t.Fatalf("cookie refresh without header status=%d body=%s", noHeader.Code, noHeader.Body.String())
|
|
}
|
|
if got := noHeader.Header().Values("Set-Cookie"); len(got) != 0 {
|
|
t.Fatalf("refused refresh set cookies: %q", got)
|
|
}
|
|
|
|
refreshed := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, true)
|
|
if refreshed.Code != http.StatusOK {
|
|
t.Fatalf("cookie refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
|
}
|
|
second := phase10Cookie(t, refreshed, cabana.DefaultAdminPrefix)
|
|
if second.Value == first.Value {
|
|
t.Fatal("cookie refresh did not rotate the token")
|
|
}
|
|
phase10AssertCookieBody(t, refreshed, second.Value)
|
|
if stale := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, first, true); stale.Code != http.StatusUnauthorized {
|
|
t.Fatalf("rotated-out cookie status=%d body=%s", stale.Code, stale.Body.String())
|
|
}
|
|
if me := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); me.Code != http.StatusOK {
|
|
t.Fatalf("rotated cookie /auth/me status=%d body=%s", me.Code, me.Body.String())
|
|
}
|
|
|
|
bearerRefresh := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), bearerToken, nil)
|
|
if bearerRefresh.Code != http.StatusOK {
|
|
t.Fatalf("bearer refresh status=%d body=%s", bearerRefresh.Code, bearerRefresh.Body.String())
|
|
}
|
|
phase10AssertBearerBody(t, bearerRefresh)
|
|
if got := bearerRefresh.Header().Values("Set-Cookie"); len(got) != 0 {
|
|
t.Fatalf("bearer refresh set cookies: %q", got)
|
|
}
|
|
|
|
logout := phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, second, true)
|
|
if logout.Code != http.StatusOK {
|
|
t.Fatalf("cookie logout status=%d body=%s", logout.Code, logout.Body.String())
|
|
}
|
|
var expired *http.Cookie
|
|
for _, c := range logout.Result().Cookies() {
|
|
if c.Name == cabana.AdminCookieName {
|
|
expired = c
|
|
}
|
|
}
|
|
if expired == nil || expired.MaxAge >= 0 || expired.Value != "" || expired.Path != cabana.DefaultAdminPrefix {
|
|
t.Fatalf("logout cookie = %+v, want an expiring %s with Path %s", expired, cabana.AdminCookieName, cabana.DefaultAdminPrefix)
|
|
}
|
|
if after := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); after.Code != http.StatusUnauthorized {
|
|
t.Fatalf("logged-out cookie status=%d body=%s", after.Code, after.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPhase10Prefix pins backend.uri normalization and validation, moves the
|
|
// whole admin surface with a custom prefix, rejects reserved controller
|
|
// vendor segments and refuses an insecure admin cookie in production.
|
|
func TestPhase10Prefix(t *testing.T) {
|
|
t.Run("normalization", func(t *testing.T) {
|
|
for raw, want := range map[string]string{
|
|
" /acme-admin/ ": "/acme-admin",
|
|
"": "/backend",
|
|
"acme": "/acme",
|
|
"/a/b_c/": "/a/b_c",
|
|
} {
|
|
got, err := cabana.AdminPrefix(phase10App(t, "development", map[string]any{"backend.uri": raw}))
|
|
if err != nil || got != want {
|
|
t.Fatalf("AdminPrefix(%q) = %q, %v; want %q", raw, got, err, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("invalid values fail activation", func(t *testing.T) {
|
|
for _, raw := range []string{"/", "/Admin", "/a b", "/../x", "//", "/-x"} {
|
|
app := phase10App(t, "development", map[string]any{"backend.uri": raw})
|
|
_, err := cabana.Activate(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
|
|
if err == nil || !strings.Contains(err.Error(), "backend.uri") {
|
|
t.Fatalf("backend.uri %q: err=%v, want an activation error naming backend.uri", raw, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("custom prefix moves the surface", func(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
|
phase10Set(t, cfg, "backend.uri", "/acme-admin")
|
|
phase10Set(t, cfg, "app.url", "https://app.test")
|
|
})
|
|
insertAdmin(t, gdb, "p10prefix", "p10prefix@example.test", adminTestPassword, true, false)
|
|
creds := map[string]string{"login": "p10prefix", "password": adminTestPassword}
|
|
api := "/acme-admin/api/v1"
|
|
|
|
login := phase10Send(t, h, http.MethodPost, api+"/auth/login", creds, nil, true)
|
|
if login.Code != http.StatusOK {
|
|
t.Fatalf("custom prefix login status=%d body=%s", login.Code, login.Body.String())
|
|
}
|
|
phase10Cookie(t, login, "/acme-admin")
|
|
|
|
bearer := phase10Send(t, h, http.MethodPost, api+"/auth/login", creds, nil, false)
|
|
token := accessToken(t, bearer.Body.Bytes())
|
|
if iss, _ := jwtClaims(t, token)["iss"].(string); iss != "https://app.test/acme-admin/api/v1/auth/login" {
|
|
t.Fatalf("issuer = %q", iss)
|
|
}
|
|
if me := getAuth(t, h, api+"/auth/me", token); me.Code != http.StatusOK {
|
|
t.Fatalf("custom prefix /auth/me status=%d", me.Code)
|
|
}
|
|
|
|
shell := getAuth(t, h, "/acme-admin", "")
|
|
if shell.Code != http.StatusOK || !strings.Contains(shell.Body.String(), `content="/acme-admin"`) {
|
|
t.Fatalf("custom prefix shell status=%d body=%s", shell.Code, shell.Body.String())
|
|
}
|
|
deep := getAuth(t, h, "/acme-admin/acme/demo/widgets", "")
|
|
if deep.Code != http.StatusOK || !strings.Contains(deep.Body.String(), `content="/acme-admin"`) {
|
|
t.Fatalf("custom prefix deep link status=%d", deep.Code)
|
|
}
|
|
missing := getAuth(t, h, api+"/nope", "")
|
|
if missing.Code != http.StatusNotFound || phase10ErrorCode(t, missing) != "not_found" {
|
|
t.Fatalf("custom prefix API miss status=%d body=%s", missing.Code, missing.Body.String())
|
|
}
|
|
old := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, false)
|
|
if old.Code == http.StatusOK {
|
|
t.Fatalf("default prefix still answers under a custom backend.uri: %s", old.Body.String())
|
|
}
|
|
if shell := getAuth(t, h, cabana.DefaultAdminPrefix, ""); shell.Code == http.StatusOK {
|
|
t.Fatal("default prefix still serves the SPA under a custom backend.uri")
|
|
}
|
|
})
|
|
|
|
t.Run("reserved controller vendor segments", func(t *testing.T) {
|
|
for _, vendor := range []string{"api", "assets", "login", "settings"} {
|
|
app := phase10App(t, "development", nil)
|
|
_, err := cabana.Activate(app, []party.Plugin{reservedPlugin{vendor: vendor}})
|
|
if err == nil || !strings.Contains(err.Error(), vendor+".demo.widgets") || !strings.Contains(err.Error(), "reserved") {
|
|
t.Fatalf("vendor %q: err=%v, want a reserved-segment activation error", vendor, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("cookie_secure", func(t *testing.T) {
|
|
prod := phase10App(t, "production", map[string]any{"backend.cookie_secure": false})
|
|
if _, err := cabana.Activate(prod, []party.Plugin{demoPlugin{fsys: demoFS()}}); err == nil || !strings.Contains(err.Error(), "backend.cookie_secure") {
|
|
t.Fatalf("production cookie_secure=false: err=%v", err)
|
|
}
|
|
if _, err := cabana.Activate(phase10App(t, "production", nil), []party.Plugin{demoPlugin{fsys: demoFS()}}); err != nil {
|
|
t.Fatalf("production default cookie_secure: %v", err)
|
|
}
|
|
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
|
phase10Set(t, cfg, "backend.cookie_secure", false)
|
|
})
|
|
insertAdmin(t, gdb, "p10insecure", "p10insecure@example.test", adminTestPassword, true, false)
|
|
login := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), map[string]string{"login": "p10insecure", "password": adminTestPassword}, nil, true)
|
|
if login.Code != http.StatusOK {
|
|
t.Fatalf("insecure-cookie login status=%d body=%s", login.Code, login.Body.String())
|
|
}
|
|
for _, c := range login.Result().Cookies() {
|
|
if c.Name == cabana.AdminCookieName && (c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteStrictMode) {
|
|
t.Fatalf("development cookie_secure=false cookie = %+v, want HttpOnly SameSite=Strict without Secure", c)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func phase10Send(t *testing.T, h http.Handler, method, path string, body any, cookie *http.Cookie, ajax bool) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader *bytes.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
} else {
|
|
reader = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, path, reader)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if ajax {
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
}
|
|
if cookie != nil {
|
|
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func phase10Cookie(t *testing.T, rec *httptest.ResponseRecorder, path string) *http.Cookie {
|
|
t.Helper()
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name != cabana.AdminCookieName {
|
|
continue
|
|
}
|
|
if c.Value == "" || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != path || c.MaxAge <= 0 {
|
|
t.Fatalf("session cookie = %+v, want HttpOnly Secure SameSite=Strict Path=%s with a Max-Age", c, path)
|
|
}
|
|
return c
|
|
}
|
|
t.Fatalf("no %s cookie; Set-Cookie=%q body=%s", cabana.AdminCookieName, rec.Header().Values("Set-Cookie"), rec.Body.String())
|
|
return nil
|
|
}
|
|
|
|
func phase10AssertCookieBody(t *testing.T, rec *httptest.ResponseRecorder, token string) {
|
|
t.Helper()
|
|
var body struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data["token_type"] != "cookie" {
|
|
t.Fatalf("cookie body = %s", rec.Body.String())
|
|
}
|
|
if n, ok := body.Data["expires_in"].(float64); !ok || n <= 0 {
|
|
t.Fatalf("cookie body expires_in = %v", body.Data["expires_in"])
|
|
}
|
|
if _, ok := body.Data["access_token"]; ok || strings.Contains(rec.Body.String(), token) || strings.Contains(rec.Body.String(), "eyJ") {
|
|
t.Fatalf("cookie body carries a token: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func phase10AssertBearerBody(t *testing.T, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
var body struct {
|
|
Data map[string]any `json:"data"`
|
|
Meta map[string]any `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _ := body.Data["access_token"].(string)
|
|
if len(body.Data) != 2 || token == "" || body.Data["token_type"] != "bearer" || len(body.Meta) != 0 {
|
|
t.Fatalf("bearer body = %s, want the Phase 9 {access_token, token_type: bearer} shape", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func phase10ErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("error json: %v body=%s", err, rec.Body.String())
|
|
}
|
|
return body.Error.Code
|
|
}
|
|
|
|
func phase10Set(t *testing.T, cfg *compass.Config, key string, value any) {
|
|
t.Helper()
|
|
if err := cfg.Set(key, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// phase10App is a database-free app for activation checks.
|
|
func phase10App(t *testing.T, env string, values map[string]any) *backpack.App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-phase10\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: []string{
|
|
"SUMMER_ENV=" + env,
|
|
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, value := range values {
|
|
phase10Set(t, cfg, key, value)
|
|
}
|
|
return backpack.New(cfg)
|
|
}
|
|
|
|
// reservedPlugin owns a controller whose vendor segment collides with an SPA
|
|
// or API segment under the admin prefix.
|
|
type reservedPlugin struct{ vendor string }
|
|
|
|
func (p reservedPlugin) ID() string { return p.vendor + ".demo" }
|
|
func (p reservedPlugin) Requires() []string { return nil }
|
|
func (p reservedPlugin) Register(*backpack.App) error { return nil }
|
|
func (p reservedPlugin) Boot(*backpack.App) error { return nil }
|
|
func (p reservedPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{reservedController{vendor: p.vendor}}
|
|
}
|
|
func (p reservedPlugin) AdminFS() fs.FS {
|
|
return fstest.MapFS{
|
|
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte(fmt.Sprintf("list: ~/plugins/%s/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\n", p.vendor))},
|
|
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
|
|
}
|
|
}
|
|
|
|
type reservedController struct{ vendor string }
|
|
|
|
func (c reservedController) ID() string { return c.vendor + ".demo.widgets" }
|
|
func (reservedController) ModelName() string { return "Widget" }
|
|
func (reservedController) ConfigDir() string { return "controllers/widgets" }
|