feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

View File

@@ -1,9 +1,19 @@
package cabana
// Admin API annotations. swag reads these with the handler package so
// docs/openapi.json lists every D-09 route. The functions are not mounted;
// service.mount in http.go is the runtime route table, and
// TestPhase09PermissionMatrix fails if the two lists diverge.
// @title SummerCMS Admin API
// @version 1
// @description Framework admin API consumed by the embedded admin SPA. Every path is relative to {backend.uri}/api/v1 (for example /backend/api/v1). The SPA authenticates with the HttpOnly summer_admin cookie set by a login that sends X-Requested-With: XMLHttpRequest, and sends that header on every request; CLI clients and tests send the BackendBearer Authorization header instead.
// @BasePath /
// @securityDefinitions.apikey BackendBearer
// @in header
// @name Authorization
// @description Backend admin bearer token. Send "Bearer {access_token}".
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
// are generated from (D-15). The functions are not mounted; service.mount in
// http.go is the runtime route table, and TestPhase09PermissionMatrix plus
// TestPhase09ContractInventory fail if the two lists diverge.
// ErrorBody is one D-10 error object.
type ErrorBody struct {
@@ -32,41 +42,84 @@ type SuccessEnvelope struct {
Meta SuccessMeta `json:"meta"`
}
// AdminLoginData is the admin login payload.
// AdminLoginData is the admin login and refresh payload. Bearer transport
// carries access_token; cookie transport (X-Requested-With: XMLHttpRequest)
// carries token_type "cookie" and expires_in, never the token.
type AdminLoginData struct {
AccessToken string `json:"access_token"`
AccessToken string `json:"access_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in,omitempty"`
}
// AdminLoginEnvelope is the admin login success body.
type AdminLoginEnvelope struct {
Data AdminLoginData `json:"data"`
Meta SuccessMeta `json:"meta"`
// Envelope is the typed D-10 success envelope.
type Envelope[T any] struct {
Data T `json:"data"`
Meta SuccessMeta `json:"meta"`
}
// AdminLogin documents POST /_admin/api/v1/auth/login.
// ListEnvelope is the typed D-10 paginated envelope (Phase 9 D-11 meta).
type ListEnvelope[T any] struct {
Data T `json:"data"`
Meta ListMeta `json:"meta"`
}
// AdminRecord is one admin record: a string-keyed map read through its
// list or form schema (D-16).
type AdminRecord map[string]any
// AdminLoginRequest is the admin login body. Either login or email
// identifies the backend user.
type AdminLoginRequest struct {
Login string `json:"login,omitempty"`
Email string `json:"email,omitempty"`
Password string `json:"password"`
}
// AdminRoleSummary is the role attached to an admin profile.
type AdminRoleSummary struct {
ID uint `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
// AdminProfile is the GET /auth/me payload.
type AdminProfile struct {
ID uint `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsSuperuser bool `json:"is_superuser"`
Role *AdminRoleSummary `json:"role,omitempty"`
}
// AdminLogin documents POST /auth/login.
//
// @Summary Admin login
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} AdminLoginEnvelope
// @Param body body AdminLoginRequest true "Credentials"
// @Param X-Requested-With header string false "XMLHttpRequest selects cookie transport"
// @Success 200 {object} Envelope[AdminLoginData]
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/login [post]
// @Router /auth/login [post]
func AdminLogin() {}
// AdminRefresh documents POST /_admin/api/v1/auth/refresh.
// AdminRefresh documents POST /auth/refresh.
//
// @Summary Refresh an admin token
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} AdminLoginEnvelope
// @Param X-Requested-With header string false "XMLHttpRequest; required unless a Bearer token is sent"
// @Success 200 {object} Envelope[AdminLoginData]
// @Failure 403 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/refresh [post]
// @Router /auth/refresh [post]
func AdminRefresh() {}
// AdminLogout documents POST /_admin/api/v1/auth/logout.
// AdminLogout documents POST /auth/logout.
//
// @Summary Admin logout
// @Tags admin
@@ -74,33 +127,33 @@ func AdminRefresh() {}
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/logout [post]
// @Router /auth/logout [post]
func AdminLogout() {}
// AdminMe documents GET /_admin/api/v1/auth/me.
// AdminMe documents GET /auth/me.
//
// @Summary Current admin
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/me [get]
// @Router /auth/me [get]
func AdminMe() {}
// AdminNavigation documents GET /_admin/api/v1/navigation.
// AdminNavigation documents GET /navigation.
//
// @Summary Admin navigation
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[[]NavigationEntry]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/navigation [get]
// @Router /navigation [get]
func AdminNavigation() {}
// AdminSettingsList documents GET /_admin/api/v1/settings.
// AdminSettingsList documents GET /settings.
//
// @Summary List admin settings
// @Tags admin
@@ -109,10 +162,10 @@ func AdminNavigation() {}
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings [get]
// @Router /settings [get]
func AdminSettingsList() {}
// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema.
// AdminSettingsSchema documents GET /settings/{code}/schema.
//
// @Summary Admin settings schema
// @Tags admin
@@ -123,10 +176,10 @@ func AdminSettingsList() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code}/schema [get]
// @Router /settings/{code}/schema [get]
func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}.
// AdminSettingsGet documents GET /settings/{code}.
//
// @Summary Read admin settings
// @Tags admin
@@ -137,10 +190,10 @@ func AdminSettingsSchema() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [get]
// @Router /settings/{code} [get]
func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}.
// AdminSettingsPut documents PUT /settings/{code}.
//
// @Summary Update admin settings
// @Tags admin
@@ -152,7 +205,7 @@ func AdminSettingsGet() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [put]
// @Router /settings/{code} [put]
func AdminSettingsPut() {}
// AdminListSchema documents the list schema route.
@@ -164,11 +217,11 @@ func AdminSettingsPut() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[ListSchema]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get]
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
@@ -184,7 +237,7 @@ func AdminListSchema() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get]
// @Router /{vendor}/{plugin}/{controller}/schema/form [get]
func AdminFormSchema() {}
// AdminRelationSchema documents the relation schema route.
@@ -201,7 +254,7 @@ func AdminFormSchema() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
// @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
func AdminRelationSchema() {}
// AdminList documents the record list route.
@@ -213,11 +266,16 @@ func AdminRelationSchema() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Param search query string false "Search term"
// @Param sort query string false "Sort column"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get]
// @Router /{vendor}/{plugin}/{controller} [get]
func AdminList() {}
// AdminCreate documents the record create route.
@@ -234,7 +292,7 @@ func AdminList() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post]
// @Router /{vendor}/{plugin}/{controller} [post]
func AdminCreate() {}
// AdminBulkDelete documents the bulk delete route.
@@ -251,7 +309,7 @@ func AdminCreate() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post]
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
func AdminBulkDelete() {}
// AdminShow documents the record show route.
@@ -268,7 +326,7 @@ func AdminBulkDelete() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get]
// @Router /{vendor}/{plugin}/{controller}/{id} [get]
func AdminShow() {}
// AdminUpdate documents the record update route.
@@ -286,7 +344,7 @@ func AdminShow() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put]
// @Router /{vendor}/{plugin}/{controller}/{id} [put]
func AdminUpdate() {}
// AdminDelete documents the record delete route.
@@ -303,7 +361,7 @@ func AdminUpdate() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete]
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
func AdminDelete() {}
// AdminRelationLinked documents the linked-relation route.
@@ -320,7 +378,7 @@ func AdminDelete() {}
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
func AdminRelationLinked() {}
// AdminRelationCandidates documents the relation candidate route.
@@ -337,7 +395,7 @@ func AdminRelationLinked() {}
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
func AdminRelationCandidates() {}
// AdminRelationLink documents the relation link route.
@@ -356,7 +414,7 @@ func AdminRelationCandidates() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
func AdminRelationLink() {}
// AdminRelationUnlink documents the relation unlink route.
@@ -375,5 +433,5 @@ func AdminRelationLink() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
func AdminRelationUnlink() {}

View File

@@ -0,0 +1,7 @@
package cabana
// adminAPI composes a full admin API path under the default prefix, so tests
// follow the backend.uri scheme (D-03) instead of hardcoding it.
func adminAPI(rel string) string {
return DefaultAdminPrefix + adminAPIVersion + rel
}

View File

@@ -172,12 +172,39 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuth(r, "success", user.ID)
if isAjax(r) {
// Cookie transport (D-19): the SPA never sees the token.
s.writeSessionCookie(w, token)
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
return
}
WriteData(w, http.StatusOK, map[string]string{
"access_token": token,
"token_type": "bearer",
}, map[string]any{})
}
// cookieLoginData is the login/refresh body under cookie transport: no token,
// only its type and the access lifetime in seconds.
func cookieLoginData(ttl time.Duration) AdminLoginData {
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
}
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
// Max-Age is the refresh window, because refresh accepts an expired access
// token until iat plus refresh_ttl.
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: AdminCookieName,
Value: token,
Path: s.adminPrefix(),
MaxAge: int(s.refreshTTL / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
@@ -247,23 +274,19 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
}
func profileOf(user BackendUser) map[string]any {
data := map[string]any{
"id": user.ID,
"login": user.Login,
"email": user.Email,
"first_name": user.FirstName,
"last_name": user.LastName,
"is_superuser": user.IsSuperuser,
func profileOf(user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
IsSuperuser: user.IsSuperuser,
}
if user.Role.ID != 0 {
data["role"] = map[string]any{
"id": user.Role.ID,
"code": user.Role.Code,
"name": user.Role.Name,
}
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
return data
return profile
}
func bearerToken(r *http.Request) string {
@@ -402,15 +425,18 @@ func adminLoginWindow(app *backpack.App) (int, int) {
return maxAttempts, decayMinutes
}
func adminIssuer(app *backpack.App) string {
// adminIssuer is app.url plus the admin API login path. JWT verification does
// not check iss, so tokens minted under an earlier prefix stay valid until
// they expire.
func adminIssuer(app *backpack.App, prefix string) string {
base := ""
if app != nil && app.Config != nil {
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
}
if base == "" {
return "/_admin/api/v1/auth/login"
if prefix == "" {
prefix = DefaultAdminPrefix
}
return base + "/_admin/api/v1/auth/login"
return base + prefix + adminAPIVersion + "/auth/login"
}
// dummyPasswordHash keeps a missing-user login on the bcrypt path.

View File

@@ -42,7 +42,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
login := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "life",
"password": adminTestPassword,
})
@@ -63,7 +63,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if !stamped.Valid {
t.Fatal("successful login did not stamp last_login")
}
byEmail := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"email": "life@example.test",
"password": adminTestPassword,
})
@@ -71,12 +71,12 @@ func TestAdminAuthLifecycle(t *testing.T) {
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
}
emailToken := accessToken(t, byEmail.Body.Bytes())
me := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
if me.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
}
assertSafeProfile(t, me.Body.Bytes(), user)
refreshed := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", emailToken, nil)
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
if refreshed.Code != http.StatusOK {
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
}
@@ -87,7 +87,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if jwtAudience(t, next) != "backend" {
t.Fatal("refreshed token lost the backend audience")
}
oldMe := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
if oldMe.Code != http.StatusUnauthorized {
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
}
@@ -98,14 +98,14 @@ func TestAdminAuthLifecycle(t *testing.T) {
if blacklisted != 1 {
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
}
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", next, nil)
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
if out.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
}
if strings.Contains(out.Body.String(), next) {
t.Fatal("logout body contains the token")
}
after := getAuth(t, h, "/_admin/api/v1/auth/me", next)
after := getAuth(t, h, adminAPI("/auth/me"), next)
if after.Code != http.StatusUnauthorized {
t.Fatalf("me after logout status=%d", after.Code)
}
@@ -113,7 +113,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
t.Fatal(err)
}
fresh := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "life", "password": adminTestPassword,
}).Body.Bytes())
// Login mints after the cutoff, so this token is current. Move the cutoff
@@ -121,7 +121,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
t.Fatal(err)
}
stale := getAuth(t, h, "/_admin/api/v1/auth/me", fresh)
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
}
@@ -131,9 +131,9 @@ func TestAdminInactive(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword})
wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"})
right := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": adminTestPassword})
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
assertSameOpaque(t, unknown, wrong, right)
var stamped sql.NullTime
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
@@ -148,8 +148,8 @@ func TestAdminDeleted(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword})
deleted := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "deleted", "password": adminTestPassword})
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
assertSameOpaque(t, unknown, deleted)
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
@@ -160,14 +160,14 @@ func TestAdminBlacklist(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
token := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "revoke", "password": adminTestPassword,
}).Body.Bytes())
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", token, nil)
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
if out.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
}
again := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", token, nil)
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
if again.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
}
@@ -192,7 +192,7 @@ func TestAdminLoginThrottle(t *testing.T) {
})
var last *httptest.ResponseRecorder
for i := 0; i < 3; i++ {
last = postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "throttle-user", "password": adminTestPassword,
})
}
@@ -217,16 +217,16 @@ func TestAdminAuthLogging(t *testing.T) {
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
ok := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": adminTestPassword})
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
token := accessToken(t, ok.Body.Bytes())
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
buf.Reset()
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": "not-the-password"})
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
buf.Reset()
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "missing-logged", "password": "not-the-password"})
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
failedUnknown := buf.String()
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
t.Fatalf("unknown login log = %s", failedUnknown)
@@ -236,7 +236,7 @@ func TestAdminAuthLogging(t *testing.T) {
}
buf.Reset()
denied := getAuth(t, h, "/_admin/api/v1/acme/demo/widgets", token)
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
if denied.Code != http.StatusForbidden {
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
}
@@ -551,3 +551,9 @@ func jwtClaims(t *testing.T, token string) map[string]any {
func itoa(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
// adminAPI mirrors the internal helper in admin_paths_test.go for this
// external test package: a full admin API path under the default prefix.
func adminAPI(rel string) string {
return cabana.DefaultAdminPrefix + "/api/v1" + rel
}

View File

@@ -18,7 +18,7 @@ import (
func TestBulkDeleteEmpty(t *testing.T) {
cap := &captureRouter{}
(&service{}).mount(cap)
key := "POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete"
key := "POST " + adminAPI("/{vendor}/{plugin}/{controller}/bulk-delete")
mw, ok := cap.middleware[key]
if !ok || !containsString(mw, "backend") {
t.Fatalf("missing %s in %v", key, cap.routes)

View File

@@ -107,7 +107,7 @@ func TestAdminResetPasswordCommand(t *testing.T) {
t.Fatal(err)
}
guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/auth/me", nil)
req := httptest.NewRequest(http.MethodGet, adminAPI("/auth/me"), nil)
req.Header.Set("Authorization", "Bearer "+token)
if _, err := guard.Authenticate(req); err != nil {
t.Fatalf("token before reset: %v", err)

View File

@@ -104,10 +104,10 @@ func TestCRUDRecordRoutes(t *testing.T) {
cap := &captureRouter{}
(&service{}).mount(cap)
for _, want := range []string{
"POST /_admin/api/v1/{vendor}/{plugin}/{controller}",
"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
"POST " + adminAPI("/{vendor}/{plugin}/{controller}"),
"GET " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
"PUT " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
"DELETE " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
} {
mw, ok := cap.middleware[want]
if !ok {

45
cabana/csrf.go Normal file
View File

@@ -0,0 +1,45 @@
package cabana
import (
"net/http"
"strings"
)
const (
// requestedWithHeader is the custom header the admin SPA sends on every
// request. A cross-site form or navigation cannot set it, and a
// cross-origin fetch that sets it needs a CORS preflight the admin API
// never answers (D-19).
requestedWithHeader = "X-Requested-With"
requestedWithAjax = "XMLHttpRequest"
)
// requireAjax refuses a state-changing admin request that is not
// Bearer-authenticated and does not carry X-Requested-With: XMLHttpRequest.
// It runs before the wrapped handler, so a refused request is never decoded,
// never looks up a controller and never reaches the database. The response
// uses the fixed D-10 code forbidden.
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !csrfSafe(r) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
next(w, r)
}
}
func csrfSafe(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
if bearerToken(r) != "" {
return true
}
return isAjax(r)
}
func isAjax(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get(requestedWithHeader)) == requestedWithAjax
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/boardwalk"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
@@ -20,10 +21,12 @@ import (
"gorm.io/gorm"
)
// Routes is the raw admin API mounted by surf.BuildRouter.
// Routes is the raw admin API and SPA mounted by surf.BuildRouter. Prefix is
// the normalized backend.uri every admin route lives under.
type Routes struct {
Middleware pact.Middleware
Mount func(r pact.Router)
Prefix string
}
type service struct {
@@ -38,6 +41,21 @@ type service struct {
loginDecay int
issuer string
bl bouncer.BlacklistStore
prefix string
spa http.Handler
}
// adminPrefix returns the mount path; a zero service uses the default.
func (s *service) adminPrefix() string {
if s == nil || s.prefix == "" {
return DefaultAdminPrefix
}
return s.prefix
}
// apiBase is the admin API root: the prefix plus /api/v1 (D-03).
func (s *service) apiBase() string {
return s.adminPrefix() + adminAPIVersion
}
// Activate compiles admin controllers and, when any exist, requires
@@ -54,6 +72,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
prefix, err := AdminPrefix(app)
if err != nil {
return nil, err
}
reg, err := compileRegistry(items)
if err != nil {
return nil, err
@@ -72,7 +94,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
}
}
bl := adminBlacklist(app)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated, AdminCookieName)
if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err
@@ -93,10 +115,31 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
bcryptCost: adminBcryptCost(app),
loginMax: loginMax,
loginDecay: loginDecay,
issuer: adminIssuer(app),
issuer: adminIssuer(app, prefix),
bl: bl,
prefix: prefix,
}
return &Routes{Middleware: mw, Mount: svc.mount}, nil
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
if err != nil {
return nil, fmt.Errorf("cabana: admin SPA: %w", err)
}
svc.spa = spa
return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil
}
func writeNotFound(w http.ResponseWriter, _ *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
}
// serveSPA answers GET {prefix} and GET {prefix}/{path...} from the embedded
// build. API paths that no route matched fall through to it and receive the
// D-10 not_found envelope, never index.html.
func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) {
if s == nil || s.spa == nil {
writeNotFound(w, r)
return
}
s.spa.ServeHTTP(w, r)
}
func writeUnauthenticated(w http.ResponseWriter, _ error) {
@@ -121,12 +164,15 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
func (s *service) mount(r pact.Router) {
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
api := s.apiBase()
r.GroupRaw(api+"/auth", nil, func(g pact.Router) {
// Login is exempt from the CSRF header: without it the response is a
// Bearer body and no cookie is set, so a cross-site post gains nothing.
g.Post("/login", s.login, throttle)
g.Post("/refresh", s.refresh)
g.Post("/refresh", requireAjax(s.refresh))
})
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", s.logout)
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", requireAjax(s.logout))
g.Get("/auth/me", s.me)
g.Get("/navigation", s.navigation)
g.Get("/settings", s.settingsList)
@@ -134,7 +180,7 @@ func (s *service) mount(r pact.Router) {
constrainSetting(g)
g.Get("/settings/{code}", s.settingsGet)
constrainSetting(g)
g.Put("/settings/{code}", s.settingsPut)
g.Put("/settings/{code}", requireAjax(s.settingsPut))
constrainSetting(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g)
@@ -144,25 +190,31 @@ func (s *service) mount(r pact.Router) {
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list)
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}", s.create)
g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", s.bulkDelete)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
constrainController(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update)
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
constrainController(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
constrainRelation(g)
})
// The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard.
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
g.Get("", s.serveSPA)
g.Get("/{path...}", s.serveSPA)
})
}
func constrainController(g pact.Router) {

View File

@@ -9,14 +9,15 @@ import (
"testing"
)
// TestPhase09ContractInventory fails when the committed OpenAPI document
// drops a D-09 route or a protected route's 401 response.
// TestPhase09ContractInventory fails when the committed framework admin
// OpenAPI document (admin/openapi/admin.json, D-15) drops an admin API route
// or a protected route's 401 response. Paths are prefix-relative (D-03).
func TestPhase09ContractInventory(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json"))
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.json"))
raw, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read %s: %v", specPath, err)
@@ -37,11 +38,16 @@ func TestPhase09ContractInventory(t *testing.T) {
t.Fatal("openapi is missing the BackendBearer scheme")
}
public := map[string]bool{
"POST /_admin/api/v1/auth/login": true,
"POST /_admin/api/v1/auth/refresh": true,
"POST /auth/login": true,
"POST /auth/refresh": true,
}
seen := map[string]bool{}
apiRoutes := 0
for _, route := range phase09Routes {
if route.spa {
continue
}
apiRoutes++
method, path, ok := splitRoute(route.key)
if !ok {
t.Fatalf("bad route key %s", route.key)
@@ -76,8 +82,11 @@ func TestPhase09ContractInventory(t *testing.T) {
t.Fatalf("%s has no 401 response", key)
}
}
if len(seen) != len(phase09Routes) {
t.Fatalf("contract routes=%d want %d", len(seen), len(phase09Routes))
if len(seen) != apiRoutes {
t.Fatalf("contract routes=%d want %d", len(seen), apiRoutes)
}
if len(spec.Paths) != len(pathsOf(phase09Routes)) {
t.Fatalf("openapi lists %d paths, the mounted API has %d", len(spec.Paths), len(pathsOf(phase09Routes)))
}
}
@@ -89,3 +98,20 @@ func splitRoute(key string) (method, path string, ok bool) {
}
return "", "", false
}
func pathsOf(routes []struct {
key string
public bool
spa bool
}) map[string]bool {
out := map[string]bool{}
for _, route := range routes {
if route.spa {
continue
}
if _, path, ok := splitRoute(route.key); ok {
out[path] = true
}
}
return out
}

49
cabana/prefix.go Normal file
View File

@@ -0,0 +1,49 @@
package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/backpack"
)
// DefaultAdminPrefix is the admin mount path when backend.uri is unset.
// It matches WinterCMS's backendUri default.
const DefaultAdminPrefix = "/backend"
// AdminCookieName carries the admin JWT for the embedded SPA (D-19).
const AdminCookieName = "summer_admin"
// adminAPIVersion is appended to the prefix for every admin API route.
const adminAPIVersion = "/api/v1"
var adminPrefixPattern = regexp.MustCompile(`^(/[a-z0-9][a-z0-9_-]*)+$`)
// AdminPrefix reads backend.uri and returns the normalized admin mount path.
// Spaces are trimmed, a leading slash is added and trailing slashes are
// removed; an empty value falls back to DefaultAdminPrefix. Every segment
// must be lowercase letters, digits, '-' or '_' and start with a letter or
// digit, so "/" alone, uppercase, spaces and dot segments are rejected.
func AdminPrefix(app *backpack.App) (string, error) {
raw := ""
if app != nil && app.Config != nil {
raw = app.Config.String("backend.uri")
}
return normalizeAdminPrefix(raw)
}
func normalizeAdminPrefix(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return DefaultAdminPrefix, nil
}
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
value = strings.TrimRight(value, "/")
if value == "" || !adminPrefixPattern.MatchString(value) {
return "", fmt.Errorf("cabana: backend.uri %q is invalid: use one or more lowercase path segments such as /backend (set SUMMER_BACKEND__URI)", raw)
}
return value, nil
}

View File

@@ -12,35 +12,49 @@ import (
"git.golem15.com/golem15/summercms/pact"
)
// phase09Routes is the D-09 admin surface mounted by service.mount.
// A handler added outside this set, or a protected handler missing the
// backend guard, fails TestPhase09PermissionMatrix.
// phase09Routes is the admin surface mounted by service.mount. API keys are
// method plus the path relative to {backend.uri}/api/v1 (D-03); spa entries
// are the public SPA shell routes relative to {backend.uri} and are not part
// of the OpenAPI inventory. A handler added outside this set, or a protected
// handler missing the backend guard, fails TestPhase09PermissionMatrix.
var phase09Routes = []struct {
key string
public bool
spa bool
}{
{"POST /_admin/api/v1/auth/login", true},
{"POST /_admin/api/v1/auth/refresh", true},
{"POST /_admin/api/v1/auth/logout", false},
{"GET /_admin/api/v1/auth/me", false},
{"GET /_admin/api/v1/navigation", false},
{"GET /_admin/api/v1/settings", false},
{"GET /_admin/api/v1/settings/{code}/schema", false},
{"GET /_admin/api/v1/settings/{code}", false},
{"PUT /_admin/api/v1/settings/{code}", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name}", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
{"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
{"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}", false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false},
{"POST /auth/login", true, false},
{"POST /auth/refresh", true, false},
{"POST /auth/logout", false, false},
{"GET /auth/me", false, false},
{"GET /navigation", false, false},
{"GET /settings", false, false},
{"GET /settings/{code}/schema", false, false},
{"GET /settings/{code}", false, false},
{"PUT /settings/{code}", false, false},
{"GET /{vendor}/{plugin}/{controller}/schema/list", false, false},
{"GET /{vendor}/{plugin}/{controller}/schema/form", false, false},
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false},
{"GET /{vendor}/{plugin}/{controller}", false, false},
{"POST /{vendor}/{plugin}/{controller}", false, false},
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false},
{"GET /{vendor}/{plugin}/{controller}/{id}", false, false},
{"PUT /{vendor}/{plugin}/{controller}/{id}", false, false},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false},
{"GET ", true, true},
{"GET /{path...}", true, true},
}
// mountedKey is the full mounted route key for an inventory entry.
func mountedKey(key string, spa bool) string {
method, rel, _ := strings.Cut(key, " ")
if spa {
return method + " " + DefaultAdminPrefix + rel
}
return method + " " + adminAPI(rel)
}
func TestPhase09PermissionMatrix(t *testing.T) {
@@ -57,9 +71,10 @@ func TestPhase09PermissionMatrix(t *testing.T) {
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
}
for _, route := range phase09Routes {
mw, ok := got[route.key]
key := mountedKey(route.key, route.spa)
mw, ok := got[key]
if !ok {
t.Fatalf("missing mounted route %s", route.key)
t.Fatalf("missing mounted route %s in %v", key, router.routes)
}
hasBackend := false
for _, name := range mw {
@@ -257,7 +272,7 @@ func phase09DeniedService() *service {
}
func phase09Request(principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/_admin/api/v1/acme/demo/widgets/1/relations/editors/link", strings.NewReader(`{}`))
req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/widgets/1/relations/editors/link"), strings.NewReader(`{}`))
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")

View File

@@ -77,7 +77,7 @@ func TestSecretRedaction(t *testing.T) {
}
func controllerRequest(principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/acme/demo/widgets", nil)
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")