refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

View File

@@ -0,0 +1,533 @@
package cabana
// @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 {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
}
// ErrorEnvelope is the D-10 error envelope.
type ErrorEnvelope struct {
Error ErrorBody `json:"error"`
}
// SuccessMeta is the D-10 meta object.
type SuccessMeta struct {
Locale string `json:"locale,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
LastPage int `json:"last_page,omitempty"`
}
// AdminLogoutData is the POST /auth/logout payload.
type AdminLogoutData struct {
Status string `json:"status"`
}
// 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,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in,omitempty"`
}
// Envelope is the typed D-10 success envelope.
type Envelope[T any] struct {
Data T `json:"data"`
Meta SuccessMeta `json:"meta"`
}
// 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
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
// link and relation unlink.
type AdminIDsRequest struct {
IDs []uint64 `json:"ids"`
}
// 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
// @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 /auth/login [post]
func AdminLogin() {}
// AdminRefresh documents POST /auth/refresh.
//
// @Summary Refresh an admin token
// @Tags admin
// @Accept json
// @Produce json
// @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 /auth/refresh [post]
func AdminRefresh() {}
// LangBundle is the public string bundle: full backend::lang key to CLDR
// forms (D-20).
type LangBundle map[string]MessageForms
// AdminLang documents GET /lang.
//
// @Summary Admin UI strings
// @Description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
// @Tags admin
// @Produce json
// @Success 200 {object} Envelope[LangBundle]
// @Router /lang [get]
func AdminLang() {}
// AdminLogout documents POST /auth/logout.
//
// @Summary Admin logout
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} Envelope[AdminLogoutData]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /auth/logout [post]
func AdminLogout() {}
// AdminMe documents GET /auth/me.
//
// @Summary Current admin
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /auth/me [get]
func AdminMe() {}
// AdminNavigation documents GET /navigation.
//
// @Summary Admin navigation
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} Envelope[[]NavigationEntry]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /navigation [get]
func AdminNavigation() {}
// AdminSettingsList documents GET /settings.
//
// @Summary List admin settings
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} Envelope[[]SettingsEntry]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /settings [get]
func AdminSettingsList() {}
// AdminSettingsSchema documents GET /settings/{code}/schema.
//
// @Summary Admin settings schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param code path string true "Settings code"
// @Success 200 {object} Envelope[FormView]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /settings/{code}/schema [get]
func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /settings/{code}.
//
// @Summary Read admin settings
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param code path string true "Settings code"
// @Success 200 {object} Envelope[SettingsResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /settings/{code} [get]
func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /settings/{code}.
//
// @Summary Update admin settings
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param code path string true "Settings code"
// @Param body body AdminRecord true "Setting values keyed by field name"
// @Success 200 {object} Envelope[SettingsResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /settings/{code} [put]
func AdminSettingsPut() {}
// AdminListSchema documents the list schema route.
//
// @Summary Admin list schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} Envelope[ListSchema]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @Summary Admin form schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} Envelope[FormView]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/schema/form [get]
func AdminFormSchema() {}
// AdminRelationSchema documents the relation schema route.
//
// @Summary Admin relation schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param name path string true "Relation name"
// @Success 200 {object} Envelope[RelationSchema]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
func AdminRelationSchema() {}
// AdminFieldOptions documents the relation field options route (D-17).
//
// @Summary Relation field options
// @Description Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param field path string true "Relation field name"
// @Param search query string false "Case-insensitive label search"
// @Param page query integer false "Page"
// @Param per_page query integer false "Options per page (1-100, default 20)"
// @Success 200 {object} ListEnvelope[[]RelationOption]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
func AdminFieldOptions() {}
// AdminFilterOptions documents the model-backed filter options route (D-27).
//
// @Summary Filter scope options
// @Description Choices of a declared scope filter of the controller's list, from the model's FilterOptions. {scope} is the filter name used as filter[<scope>]; labels are localized.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param scope path string true "Filter name"
// @Success 200 {object} Envelope[[]FilterOption]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/filters/{scope}/options [get]
func AdminFilterOptions() {}
// AdminList documents the record list route.
//
// @Summary List admin records
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @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"
// @Param filter query object false "Filter values keyed by filter name, sent as filter[<name>]=<value>"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller} [get]
func AdminList() {}
// AdminCreate documents the record create route.
//
// @Summary Create an admin record
// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids"
// @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller} [post]
func AdminCreate() {}
// AdminBulkDelete documents the bulk delete route.
//
// @Summary Bulk-delete admin records
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
func AdminBulkDelete() {}
// AdminShow documents the record show route.
//
// @Summary Show an admin record
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [get]
func AdminShow() {}
// AdminUpdate documents the record update route.
//
// @Summary Update an admin record
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [put]
func AdminUpdate() {}
// AdminDelete documents the record delete route.
//
// @Summary Delete an admin record
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
func AdminDelete() {}
// AdminRelationLinked documents the linked-relation route.
//
// @Summary List linked relation records
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns"
// @Param sort query string false "Sort column (a sortable panel 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 (1-100, default 20)"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
func AdminRelationLinked() {}
// AdminRelationCandidates documents the relation candidate route.
//
// @Summary List relation candidates
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns"
// @Param sort query string false "Sort column (a sortable panel 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 (1-100, default 20)"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
func AdminRelationCandidates() {}
// AdminRelationLink documents the relation link route.
//
// @Summary Link relation records
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
func AdminRelationLink() {}
// AdminRelationUnlink documents the relation unlink route.
//
// @Summary Unlink relation records
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @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
}

506
modules/cabana/auth.go Normal file
View File

@@ -0,0 +1,506 @@
package cabana
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
const (
// backendJWTBlacklistTable matches the framework migration in lagoon.
// It is not the frontend jwt_blacklist table.
backendJWTBlacklistTable = "backend_jwt_blacklist"
msgInvalidCredentials = "Invalid credentials"
msgUnauthenticated = "Unauthenticated"
msgForbidden = "Forbidden"
msgNotFound = "Not found"
msgServerError = "Server error"
)
// BackendUsers loads activated backend principals. It never reads frontend users.
type BackendUsers struct {
DB *gorm.DB
Registry *Registry
}
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
if p.DB == nil || id == 0 {
return nil, nil
}
var user BackendUser
err := p.DB.WithContext(ctx).Preload("Role").First(&user, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !user.IsActivated {
return nil, nil
}
principal := principalFrom(user)
for code, allowed := range p.Registry.rolePermissions(user.Role.Code) {
if !allowed {
continue
}
if principal.PermissionGrants == nil {
principal.PermissionGrants = map[string]bool{}
}
principal.PermissionGrants[code] = true
}
return principal, nil
}
func principalFrom(user BackendUser) *bouncer.Principal {
principal := &bouncer.Principal{
ID: user.ID,
Backend: true,
IsSuperuser: user.IsSuperuser,
PermissionGrants: parseGrants(user.Role.Permissions),
}
if user.TokensValidAfter != nil {
principal.TokensValidAfter = *user.TokensValidAfter
}
return principal
}
func parseGrants(raw string) map[string]bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" || raw == "null" {
return nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
return nil
}
out := make(map[string]bool, len(decoded))
for code, value := range decoded {
if truthyGrant(value) {
out[code] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func truthyGrant(value any) bool {
switch v := value.(type) {
case bool:
return v
case float64:
return v == 1
case string:
return v == "1" || strings.EqualFold(v, "true")
case json.Number:
return v.String() == "1"
default:
return false
}
}
type loginBody struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
}
func (s *service) login(w http.ResponseWriter, r *http.Request) {
var body loginBody
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
if err := dec.Decode(&body); err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
return
}
identifier := strings.TrimSpace(body.Login)
if identifier == "" {
identifier = strings.TrimSpace(body.Email)
}
if identifier == "" || body.Password == "" {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
user, found, err := findBackendLogin(db.WithContext(r.Context()), identifier)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
hash := dummyPasswordHash
if found && user.Password != "" {
hash = user.Password
}
ok := bouncer.CheckPassword(hash, body.Password)
if !found || !ok || !user.IsActivated {
id := uint(0)
if found {
id = user.ID
}
s.logAuth(r, "failed", id)
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
return
}
now := time.Now().UTC()
if err := db.WithContext(r.Context()).Model(&BackendUser{}).Where("id = ?", user.ID).Update("last_login", now).Error; err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
if bouncer.NeedsRehash(user.Password, s.bcryptCost) {
if next, err := bouncer.HashPassword(s.bcryptCost, body.Password); err == nil {
_ = db.WithContext(r.Context()).Model(&BackendUser{}).Where("id = ?", user.ID).Update("password", next).Error
}
}
token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
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, s.sessionCookie(token, int(s.refreshTTL/time.Second)))
}
// expireSessionCookie tells the browser to drop the admin cookie.
func (s *service) expireSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, s.sessionCookie("", -1))
}
func (s *service) sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: AdminCookieName,
Value: value,
Path: s.adminPrefix(),
MaxAge: maxAge,
HttpOnly: true,
Secure: !s.insecureCookie,
SameSite: http.SameSiteStrictMode,
}
}
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
raw, fromCookie := sessionToken(r)
if raw == "" {
s.logAuth(r, "failed", 0)
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
next, err := bouncer.RefreshAudienceFor(r.Context(), s.users, s.secret, raw, bouncer.AudienceBackend, s.refreshTTL, s.bl, s.grace, s.issuer)
if err != nil {
// A subject the guard would refuse (deactivated, deleted, or cut off
// by tokens_valid_after) ends the browser session. Other failures,
// including a provider error, leave the cookie alone.
if fromCookie && errors.Is(err, bouncer.ErrSubjectRejected) {
s.expireSessionCookie(w)
}
s.logAuth(r, "failed", 0)
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
s.logAuth(r, "success", 0)
if fromCookie {
// A cookie-authenticated request never receives a token in its body.
s.writeSessionCookie(w, next)
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
return
}
WriteData(w, http.StatusOK, map[string]string{
"access_token": next,
"token_type": "bearer",
}, map[string]any{})
}
// logout blacklists the presented token's jti and always expires the admin
// cookie, so a browser session ends even when only the Bearer was revoked.
func (s *service) logout(w http.ResponseWriter, r *http.Request) {
raw, _ := sessionToken(r)
_, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend)
if err != nil || jti == "" {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if s.bl != nil {
expiresAt := iat.Add(s.refreshTTL).Add(time.Minute)
if until := exp.Add(time.Minute); until.After(expiresAt) {
expiresAt = until
}
if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
}
id := uint(0)
if principal, ok := bouncer.User(r.Context()); ok {
id = principal.ID
}
s.logAuth(r, "success", id)
s.expireSessionCookie(w)
WriteData(w, http.StatusOK, AdminLogoutData{Status: "logged_out"}, map[string]any{})
}
// sessionToken returns the admin JWT the same way the backend guard reads it:
// the Authorization Bearer header first, then the summer_admin cookie.
func sessionToken(r *http.Request) (token string, fromCookie bool) {
if raw := bearerToken(r); raw != "" {
return raw, false
}
if r == nil {
return "", false
}
if c, err := r.Cookie(AdminCookieName); err == nil {
if raw := strings.TrimSpace(c.Value); raw != "" {
return raw, true
}
}
return "", false
}
func (s *service) me(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var user BackendUser
err = db.WithContext(r.Context()).Preload("Role").First(&user, principal.ID).Error
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && !user.IsActivated) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
}
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 {
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
return profile
}
func bearerToken(r *http.Request) string {
if r == nil {
return ""
}
value := strings.TrimSpace(r.Header.Get("Authorization"))
token, ok := strings.CutPrefix(value, "Bearer ")
if !ok {
return ""
}
return strings.TrimSpace(token)
}
func (s *service) logAuth(r *http.Request, outcome string, adminID uint) {
remote := ""
method := ""
path := ""
if r != nil {
method = r.Method
if r.URL != nil {
path = r.URL.Path
}
remote = r.RemoteAddr
if host, _, err := net.SplitHostPort(remote); err == nil {
remote = host
}
}
args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote}
if adminID != 0 {
args = append(args, "admin_id", adminID)
}
slog.Default().Info("admin.auth", args...)
}
func adminBlacklist(app *backpack.App) bouncer.BlacklistStore {
var sqlDB *sql.DB
if app != nil {
if db, ok := app.Lookup[*sql.DB](); ok {
sqlDB = db
} else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
if db, err := gdb.DB(); err == nil {
sqlDB = db
}
}
}
if sqlDB == nil {
return nil
}
return bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable)
}
func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) {
email := strings.ToLower(identifier)
var user BackendUser
err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return BackendUser{}, false, nil
}
if err != nil {
return BackendUser{}, false, err
}
return user, true, nil
}
func (s *service) db() (*gorm.DB, error) {
if s == nil || s.app == nil {
return nil, errors.New("cabana: database is not configured")
}
db, ok := s.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("cabana: database is not configured")
}
return db, nil
}
func adminSecret(app *backpack.App) (string, error) {
secret := ""
if app != nil && app.Config != nil {
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
}
if secret == "" {
return "", errors.New("cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)")
}
return secret, nil
}
func adminTTL(app *backpack.App) time.Duration {
minutes := 60
if app != nil && app.Config != nil && app.Config.Int("admin.jwt.ttl") > 0 {
minutes = app.Config.Int("admin.jwt.ttl")
}
return time.Duration(minutes) * time.Minute
}
func adminRefreshTTL(app *backpack.App) time.Duration {
minutes := 20160
if app != nil && app.Config != nil && app.Config.Int("admin.jwt.refresh_ttl") > 0 {
minutes = app.Config.Int("admin.jwt.refresh_ttl")
}
return time.Duration(minutes) * time.Minute
}
func adminGrace(app *backpack.App) time.Duration {
seconds := 0
if app != nil && app.Config != nil && app.Config.Has("admin.jwt.blacklist_grace") {
seconds = app.Config.Int("admin.jwt.blacklist_grace")
}
if seconds < 0 {
seconds = 0
}
return time.Duration(seconds) * time.Second
}
func adminBcryptCost(app *backpack.App) int {
cost := 10
if app != nil && app.Config != nil && app.Config.Int("admin.password.bcrypt_cost") > 0 {
cost = app.Config.Int("admin.password.bcrypt_cost")
}
if cost < 4 || cost > 31 {
return 10
}
return cost
}
func adminLoginWindow(app *backpack.App) (int, int) {
maxAttempts, decayMinutes := 5, 1
if app != nil && app.Config != nil {
if n := app.Config.Int("admin.login.max_attempts"); n > 0 {
maxAttempts = n
}
if n := app.Config.Int("admin.login.decay_minutes"); n > 0 {
decayMinutes = n
}
}
return maxAttempts, decayMinutes
}
// adminCookieSecure reads backend.cookie_secure (default true). false drops
// the Secure attribute for plain-http development and is refused in the
// production environment.
func adminCookieSecure(app *backpack.App) (bool, error) {
if app == nil || app.Config == nil || !app.Config.Has("backend.cookie_secure") {
return true, nil
}
if app.Config.Bool("backend.cookie_secure") {
return true, nil
}
if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") {
return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment")
}
return false, nil
}
// 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 prefix == "" {
prefix = DefaultAdminPrefix
}
return base + prefix + adminAPIVersion + "/auth/login"
}
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
var dummyPasswordHash = func() string {
hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials")
if err != nil {
return ""
}
return hash
}()

559
modules/cabana/auth_test.go Normal file
View File

@@ -0,0 +1,559 @@
package cabana_test
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"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/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gorm.io/gorm"
)
const (
adminTestSecret = "summercms-test-only-admin-hs256-secret"
adminTestPassword = "lifecycle-secret"
)
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, adminAPI("/auth/login"), map[string]string{
"login": "life",
"password": adminTestPassword,
})
if login.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", login.Code, login.Body.String())
}
token := accessToken(t, login.Body.Bytes())
if aud := jwtAudience(t, token); aud != "backend" {
t.Fatalf("aud=%q", aud)
}
if strings.Contains(login.Body.String(), adminTestPassword) {
t.Fatal("login body contains the password")
}
var stamped sql.NullTime
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE id = ?`, user.ID).Scan(&stamped).Error; err != nil {
t.Fatal(err)
}
if !stamped.Valid {
t.Fatal("successful login did not stamp last_login")
}
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"email": "life@example.test",
"password": adminTestPassword,
})
if byEmail.Code != http.StatusOK {
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
}
emailToken := accessToken(t, byEmail.Body.Bytes())
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, adminAPI("/auth/refresh"), emailToken, nil)
if refreshed.Code != http.StatusOK {
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
}
next := accessToken(t, refreshed.Body.Bytes())
if next == emailToken {
t.Fatal("refresh returned the same token")
}
if jwtAudience(t, next) != "backend" {
t.Fatal("refreshed token lost the backend audience")
}
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())
}
var blacklisted int
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, emailToken)).Scan(&blacklisted).Error; err != nil {
t.Fatal(err)
}
if blacklisted != 1 {
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
}
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, adminAPI("/auth/me"), next)
if after.Code != http.StatusUnauthorized {
t.Fatalf("me after logout status=%d", after.Code)
}
cutoff := time.Now().Add(time.Minute)
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, 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
// past it to prove a stale principal fails closed.
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, adminAPI("/auth/me"), fresh)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
}
}
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, 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 {
t.Fatal(err)
}
if stamped.Valid {
t.Fatal("inactive login stamped last_login")
}
}
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, 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())
}
}
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, adminAPI("/auth/login"), map[string]string{
"login": "revoke", "password": adminTestPassword,
}).Body.Bytes())
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, adminAPI("/auth/refresh"), token, nil)
if again.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
}
var n int
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, token)).Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("admin blacklist rows=%d, want the revoked jti in backend_jwt_blacklist", n)
}
}
func TestAdminLoginThrottle(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, func(cfg *compass.Config) {
if err := cfg.Set("admin.login.max_attempts", 2); err != nil {
t.Fatal(err)
}
if err := cfg.Set("admin.login.decay_minutes", 1); err != nil {
t.Fatal(err)
}
})
var last *httptest.ResponseRecorder
for i := 0; i < 3; i++ {
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "throttle-user", "password": adminTestPassword,
})
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("third login status=%d body=%s, want 429 from the fixed-window limiter", last.Code, last.Body.String())
}
}
func TestAdminAuthLogging(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
var publisherID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
t.Fatalf("publisher role: id=%d err=%v", publisherID, err)
}
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("role_id", publisherID).Error; err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
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, 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, 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)
}
if strings.Contains(failedUnknown, `"admin_id"`) || strings.Contains(failedUnknown, "not-the-password") {
t.Fatalf("unknown login log disclosed an id or password: %s", failedUnknown)
}
buf.Reset()
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())
}
assertLog(t, &buf, "denied", user.ID, adminTestPassword, token)
}
func assertLog(t *testing.T, buf *bytes.Buffer, outcome string, id uint, secret, token string) {
t.Helper()
text := buf.String()
if !strings.Contains(text, `"outcome":"`+outcome+`"`) || !strings.Contains(text, `"admin_id":`+itoa(id)) {
t.Fatalf("log %q missing outcome %s admin %d", text, outcome, id)
}
if secret != "" && strings.Contains(text, secret) {
t.Fatalf("log contains a credential: %s", text)
}
if token != "" && strings.Contains(text, token) {
t.Fatalf("log contains a bearer token: %s", text)
}
if strings.Contains(text, "$2a$") || strings.Contains(text, adminTestSecret) {
t.Fatalf("log contains a hash or signing secret: %s", text)
}
}
func assertSameOpaque(t *testing.T, recs ...*httptest.ResponseRecorder) {
t.Helper()
if len(recs) == 0 {
t.Fatal("no responses")
}
want := recs[0].Body.String()
for _, rec := range recs {
if rec.Code != http.StatusUnauthorized || rec.Body.String() != want {
t.Fatalf("opaque mismatch %d %s vs %s", rec.Code, rec.Body.String(), want)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != "unauthenticated" || body.Error.Message == "" {
t.Fatalf("error = %+v", body.Error)
}
}
}
func assertSafeProfile(t *testing.T, raw []byte, user cabana.BackendUser) {
t.Helper()
if strings.Contains(string(raw), adminTestPassword) || strings.Contains(string(raw), "permissions") || strings.Contains(string(raw), "$2") {
t.Fatalf("profile leaked sensitive data: %s", raw)
}
var body struct {
Data 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 struct {
Code string `json:"code"`
Name string `json:"name"`
} `json:"role"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body.Data.ID != user.ID || body.Data.Login != user.Login || body.Data.Email != user.Email || body.Data.FirstName != "Ada" || body.Data.LastName != "Admin" || body.Data.IsSuperuser || body.Data.Role.Code != "developer" || body.Data.Role.Name != "Developer" {
t.Fatalf("profile = %+v", body.Data)
}
}
var (
adminOnce sync.Once
adminSQL *sql.DB
adminGDB *gorm.DB
adminErr error
)
func adminGorm(t *testing.T) *gorm.DB {
t.Helper()
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
adminOnce.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("cabana"),
postgres.WithUsername("cabana"),
postgres.WithPassword("cabana"),
postgres.BasicWaitStrategies(),
testcontainers.WithEnv(map[string]string{
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
}),
)
if err != nil {
adminErr = err
return
}
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
adminErr = err
return
}
db, err := sql.Open("pgx", dsn)
if err != nil {
adminErr = err
return
}
gdb, err := lagoon.Use(ctx, db)
if err != nil {
adminErr = err
return
}
if err := lagoon.Migrate(gdb, nil); err != nil {
adminErr = err
return
}
adminSQL = db
adminGDB = gdb
})
if adminErr != nil {
t.Fatalf("postgres: %v", adminErr)
}
return adminGDB
}
func adminHandler(t *testing.T, gdb *gorm.DB, mutate func(*compass.Config)) http.Handler {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-auth\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=development",
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
},
})
if err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
t.Fatal(err)
}
if mutate != nil {
mutate(cfg)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
if err != nil {
t.Fatal(err)
}
return h
}
func insertAdmin(t *testing.T, gdb *gorm.DB, login, email, password string, activated, deleted bool) cabana.BackendUser {
t.Helper()
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("developer role: %v id=%d", err, roleID)
}
user := cabana.BackendUser{
FirstName: "Ada",
LastName: "Admin",
Login: login,
Email: email,
Password: hash,
IsActivated: activated,
RoleID: &roleID,
}
if err := gdb.Create(&user).Error; err != nil {
t.Fatal(err)
}
if deleted {
if err := gdb.Delete(&user).Error; err != nil {
t.Fatal(err)
}
}
return user
}
type demoPlugin struct{ fsys fs.FS }
func (p demoPlugin) ID() string { return "acme.demo" }
func (p demoPlugin) Requires() []string { return nil }
func (p demoPlugin) Register(*backpack.App) error { return nil }
func (p demoPlugin) Boot(*backpack.App) error { return nil }
func (p demoPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{demoController{}}
}
func (p demoPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}}
}
func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
type demoController struct{}
func (demoController) ID() string { return "acme.demo.widgets" }
func (demoController) ModelName() string { return "Widget" }
func (demoController) ConfigDir() string { return "controllers/widgets" }
func (demoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
func demoFS() fs.FS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
}
}
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return postAuth(t, h, http.MethodPost, path, "", raw)
}
func postAuth(t *testing.T, h http.Handler, method, path, token string, body []byte) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func getAuth(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
return postAuth(t, h, http.MethodGet, path, token, nil)
}
func accessToken(t *testing.T, raw []byte) string {
t.Helper()
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("token json: %v body=%s", err, raw)
}
if body.Data.AccessToken == "" {
t.Fatalf("missing access_token: %s", raw)
}
return body.Data.AccessToken
}
func jwtAudience(t *testing.T, token string) string {
t.Helper()
claims := jwtClaims(t, token)
switch v := claims["aud"].(type) {
case string:
return v
case []any:
if len(v) == 1 {
s, _ := v[0].(string)
return s
}
}
t.Fatalf("aud=%v", claims["aud"])
return ""
}
func jwtID(t *testing.T, token string) string {
t.Helper()
id, _ := jwtClaims(t, token)["jti"].(string)
if id == "" {
t.Fatal("token has no jti")
}
return id
}
func jwtClaims(t *testing.T, token string) map[string]any {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token segments=%d", len(parts))
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
var claims map[string]any
if err := json.Unmarshal(raw, &claims); err != nil {
t.Fatal(err)
}
return claims
}
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
}

250
modules/cabana/bulk_test.go Normal file
View File

@@ -0,0 +1,250 @@
package cabana
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
func TestBulkDeleteEmpty(t *testing.T) {
cap := &captureRouter{}
(&service{}).mount(cap)
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)
}
_, httpSvc, _, db, hooks := hookFixture(t)
hooks.perms = []string{"acme.demo.records"}
forbidden := crudBulk(httpSvc, []byte(`{"ids":[1]}`), principalCtx(hooks, &bouncer.Principal{ID: 4}))
if forbidden.Code != http.StatusForbidden || hooks.allocs.Load() != 0 || hooks.queries.Load() != 0 {
t.Fatalf("forbidden=%d allocs=%d queries=%d body=%s", forbidden.Code, hooks.allocs.Load(), hooks.queries.Load(), forbidden.Body.String())
}
ctx := principalCtx(hooks, superUser())
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":null}`} {
rec := crudBulk(httpSvc, []byte(body), ctx)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"validation_failed"`) || !strings.Contains(rec.Body.String(), "The ids field is required.") {
t.Fatalf("body %s => %d %s", body, rec.Code, rec.Body.String())
}
}
bad := crudBulk(httpSvc, []byte(`{"ids":["nope",1]}`), ctx)
if bad.Code != http.StatusUnprocessableEntity || !strings.Contains(bad.Body.String(), `"validation_failed"`) {
t.Fatalf("bad ids=%d %s", bad.Code, bad.Body.String())
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d", n)
}
}
func TestBulkDeleteDuplicates(t *testing.T) {
svc, _, cc, db, hooks := hookFixture(t)
rows := seedCrud(t, db, 2)
var got []uint
ctx := idCtx(hooks, &got)
res, err := svc.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{rows[1].ID, rows[1].ID, rows[0].ID, float64(rows[0].ID)}})
if err != nil || res.Deleted != 2 {
t.Fatalf("bulk=%+v err=%v", res, err)
}
if !sameUints(got, []uint{rows[0].ID, rows[1].ID}) {
t.Fatalf("hook ids=%v, want each id once in ascending order", got)
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d", n)
}
}
func TestBulkDeleteOrder(t *testing.T) {
svc, _, cc, db, hooks := hookFixture(t)
rows := seedCrud(t, db, 3)
var got []uint
ctx := idCtx(hooks, &got)
res, err := svc.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{rows[2].ID, rows[0].ID, rows[1].ID}})
if err != nil || res.Deleted != 3 {
t.Fatalf("bulk=%+v err=%v", res, err)
}
want := []uint{rows[0].ID, rows[1].ID, rows[2].ID}
sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
if !sameUints(got, want) {
t.Fatalf("hook ids=%v want %v", got, want)
}
}
func TestBulkDeleteIdempotent(t *testing.T) {
svc, _, cc, db, hooks := hookFixture(t)
rows := seedCrud(t, db, 2)
var got []uint
ctx := idCtx(hooks, &got)
first, err := svc.BulkDelete(ctx, cc, BulkDeleteInput{IDs: idsOf(rows)})
if err != nil || first.Deleted != 2 {
t.Fatalf("first=%+v err=%v", first, err)
}
got = nil
again, err := svc.BulkDelete(ctx, cc, BulkDeleteInput{IDs: idsOf(rows)})
if err != nil || again.Deleted != 0 || len(got) != 0 {
t.Fatalf("repeat=%+v err=%v hooks=%v", again, err, got)
}
hooks.trackScope = true
outside := seedCrud(t, db, 1)
if err := db.Model(&outside[0]).Update("scope_id", 2).Error; err != nil {
t.Fatal(err)
}
got = nil
scoped := context.WithValue(ctx, scopeKey{}, uint(1))
res, err := svc.BulkDelete(scoped, cc, BulkDeleteInput{IDs: []any{outside[0].ID}})
if err != nil || res.Deleted != 0 || len(got) != 0 {
t.Fatalf("out of scope=%+v err=%v hooks=%v", res, err, got)
}
if n := countCrud(t, db); n != 1 {
t.Fatalf("rows=%d, out-of-scope delete changed the table", n)
}
}
func TestBulkDeleteRollback(t *testing.T) {
svc, httpSvc, cc, db, hooks := hookFixture(t)
rows := seedCrud(t, db, 2)
var got []uint
ctx := idCtx(hooks, &got)
if _, err := svc.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{rows[0].ID, uint(999999)}}); err == nil || len(got) != 0 || countCrud(t, db) != 2 {
t.Fatalf("mixed missing err=%v hooks=%v rows=%d", err, got, countCrud(t, db))
}
rec := crudBulk(httpSvc, []byte(fmt.Sprintf(`{"ids":[%d,999999]}`, rows[0].ID)), ctx)
if rec.Code != http.StatusConflict || strings.Contains(rec.Body.String(), "999999") {
t.Fatalf("mixed http=%d %s", rec.Code, rec.Body.String())
}
if countCrud(t, db) != 2 {
t.Fatal("mixed http committed a delete")
}
hooks.trackScope = true
if err := db.Model(&rows[1]).Update("scope_id", 2).Error; err != nil {
t.Fatal(err)
}
got = nil
scoped := context.WithValue(ctx, scopeKey{}, uint(1))
if _, err := svc.BulkDelete(scoped, cc, BulkDeleteInput{IDs: []any{rows[0].ID, rows[1].ID}}); err == nil || len(got) != 0 {
t.Fatalf("mixed scope err=%v hooks=%v", err, got)
}
if loadCrud(t, db, rows[0].Name).ScopeID != 1 || loadCrud(t, db, rows[1].Name).ScopeID != 2 {
t.Fatal("mixed scope committed")
}
if err := db.Model(&rows[1]).Update("scope_id", 1).Error; err != nil {
t.Fatal(err)
}
hooks.trackScope = false
got = nil
failCtx := context.WithValue(ctx, failIDsKey{}, map[uint]bool{rows[1].ID: true})
_, err := svc.BulkDelete(failCtx, cc, BulkDeleteInput{IDs: idsOf(rows)})
if err == nil || strings.Contains(err.Error(), "secret-hook-boom") || !strings.Contains(err.Error(), "acme.demo.records") {
t.Fatalf("hook err=%v", err)
}
if countCrud(t, db) != 2 {
t.Fatal("hook failure committed a partial delete")
}
out := httptest.NewRecorder()
writeCRUDError(out, err)
if out.Code != http.StatusInternalServerError || strings.Contains(out.Body.String(), "secret-hook-boom") {
t.Fatalf("hook http=%d %s", out.Code, out.Body.String())
}
cancelCtx, cancel := context.WithCancel(ctx)
cancelCtx = context.WithValue(cancelCtx, cancelKey{}, cancel)
cancelCtx = context.WithValue(cancelCtx, cancelOnKey{}, rows[1].ID)
got = nil
if _, err := svc.BulkDelete(cancelCtx, cc, BulkDeleteInput{IDs: idsOf(rows)}); err == nil || countCrud(t, db) != 2 {
t.Fatalf("cancel err=%v rows=%d", err, countCrud(t, db))
}
}
func TestBulkDeleteConcurrent(t *testing.T) {
svc, _, cc, db, hooks := hookFixture(t)
rows := seedCrud(t, db, 3)
var got []uint
ctx := context.WithValue(idCtx(hooks, &got), bulkSlowKey{}, true)
in := BulkDeleteInput{IDs: idsOf(rows)}
var wg sync.WaitGroup
start := make(chan struct{})
type outcome struct {
res BulkResult
err error
}
outs := make([]outcome, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
outs[i].res, outs[i].err = svc.BulkDelete(ctx, cc, in)
}(i)
}
close(start)
wg.Wait()
sum := 0
for i, out := range outs {
if out.err != nil || (out.res.Deleted != 0 && out.res.Deleted != 3) {
t.Fatalf("worker %d = %+v err=%v", i, out.res, out.err)
}
sum += out.res.Deleted
}
if sum != 3 || !sameUints(got, []uint{rows[0].ID, rows[1].ID, rows[2].ID}) || countCrud(t, db) != 0 {
t.Fatalf("sum=%d hooks=%v rows=%d", sum, got, countCrud(t, db))
}
}
func seedCrud(t *testing.T, db *gorm.DB, n int) []crudRow {
t.Helper()
rows := make([]crudRow, n)
for i := range rows {
rows[i] = crudRow{Name: fmt.Sprintf("row-%d", i), ScopeID: 1}
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
func idsOf(rows []crudRow) []any {
out := make([]any, len(rows))
for i := range rows {
out[i] = rows[i].ID
}
return out
}
func idCtx(hooks *hookController, got *[]uint) context.Context {
return context.WithValue(principalCtx(hooks, superUser()), idSinkKey{}, got)
}
func sameUints(got, want []uint) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func crudBulk(svc *service, body []byte, ctx context.Context) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "records")
if ctx != nil {
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
svc.bulkDelete(rec, req)
return rec
}

185
modules/cabana/commands.go Normal file
View File

@@ -0,0 +1,185 @@
package cabana
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// RuntimeCommands returns the operator commands for backend administrators.
func RuntimeCommands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{
{
Name: "admin:create",
Description: "Create an activated backend administrator",
Flags: []bonfire.Flag{
{Name: "email", Description: "Admin email"},
{Name: "password", Description: "Admin password"},
{Name: "login", Description: "Login; defaults to the lower-cased email"},
{Name: "superuser", Description: "Grant superuser", Bare: true},
{Name: "role", Description: "Role code"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminCreate(ctx, app, in, out)
},
},
{
Name: "admin:reset-password",
Description: "Reset a backend administrator password and revoke existing tokens",
Args: []bonfire.Arg{{
Name: "identifier",
Description: "Login or email",
Required: true,
}},
Flags: []bonfire.Flag{{
Name: "password",
Description: "New password",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminResetPassword(ctx, app, in, out)
},
},
}
}
func adminCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
email := strings.ToLower(strings.TrimSpace(flagValue(in, "email")))
password := flagValue(in, "password")
if email == "" || !strings.Contains(email, "@") || strings.TrimSpace(password) == "" {
return errors.New("cabana: email and password are required")
}
login := strings.TrimSpace(flagValue(in, "login"))
if login == "" {
login = email
}
roleCode := strings.TrimSpace(flagValue(in, "role"))
superuser := flagValue(in, "superuser") == "true"
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
roleID, err := roleIDByCode(tx, roleCode)
if err != nil {
return err
}
var existing int64
if err := tx.Model(&BackendUser{}).Where("login = ? OR lower(email) = ?", login, email).Count(&existing).Error; err != nil {
return err
}
if existing > 0 {
return errors.New("cabana: admin already exists")
}
hash, err := bouncer.HashPassword(adminBcryptCost(app), password)
if err != nil {
return err
}
now := time.Now().UTC()
user := BackendUser{
Login: login,
Email: email,
Password: hash,
IsActivated: true,
IsSuperuser: superuser,
RoleID: roleID,
ActivatedAt: &now,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
out.Success(fmt.Sprintf("created admin %s <%s>", user.Login, user.Email))
return nil
})
})
}
func adminResetPassword(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
identifier := ""
if value, ok := in.Argument("identifier"); ok {
identifier = strings.TrimSpace(value)
}
if identifier == "" && len(in.Args()) > 0 {
identifier = strings.TrimSpace(in.Args()[0])
}
password := flagValue(in, "password")
if identifier == "" || strings.TrimSpace(password) == "" {
return errors.New("cabana: identifier and password are required")
}
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var users []BackendUser
err := tx.Where("login = ? OR lower(email) = ?", identifier, strings.ToLower(identifier)).Find(&users).Error
if err != nil {
return err
}
if len(users) == 0 {
return errors.New("cabana: admin not found")
}
if len(users) > 1 {
return errors.New("cabana: ambiguous admin")
}
hash, err := bouncer.HashPassword(adminBcryptCost(app), password)
if err != nil {
return err
}
cutoff := time.Now().UTC().Add(time.Second)
if err := tx.Model(&BackendUser{}).Where("id = ?", users[0].ID).Updates(map[string]any{
"password": hash,
"tokens_valid_after": cutoff,
}).Error; err != nil {
return err
}
out.Success(fmt.Sprintf("reset password for %s", users[0].Login))
return nil
})
})
}
func roleIDByCode(tx *gorm.DB, code string) (*uint, error) {
if code == "" {
return nil, nil
}
var ids []uint
if err := tx.Model(&BackendUserRole{}).Where("code = ?", code).Pluck("id", &ids).Error; err != nil {
return nil, err
}
switch len(ids) {
case 0:
return nil, fmt.Errorf("cabana: unknown role %q", code)
case 1:
return &ids[0], nil
default:
return nil, fmt.Errorf("cabana: ambiguous role %q", code)
}
}
func flagValue(in bonfire.Input, name string) string {
if in == nil {
return ""
}
value, _ := in.Flag(name)
return value
}
func withAdminDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error {
if app == nil {
return errors.New("cabana: app is nil")
}
if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return err
}
defer sqlDB.Close()
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
return err
}
return fn(gdb)
}

View File

@@ -0,0 +1,197 @@
package cabana_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"os"
"path/filepath"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"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/lagoon"
"gorm.io/gorm"
)
func TestAdminCreateCommand(t *testing.T) {
gdb := adminGorm(t)
app := commandApp(t, gdb)
create := commandByName(t, cabana.RuntimeCommands(app), "admin:create")
var buf bytes.Buffer
out := bonfire.NewOutput(nil, &buf, &buf)
const password = "correct-horse-battery"
if err := create.Run(context.Background(), flagInput{flags: map[string]string{
"email": "Ada@Example.Test",
"password": password,
"role": "developer",
"superuser": "true",
}}, out); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), password) || strings.Contains(buf.String(), "$2") || strings.Contains(buf.String(), adminTestSecret) {
t.Fatalf("create output leaked a secret: %s", buf.String())
}
if !strings.Contains(buf.String(), "ada@example.test") {
t.Fatalf("create output = %s", buf.String())
}
var user cabana.BackendUser
if err := gdb.Preload("Role").Where("login = ?", "ada@example.test").First(&user).Error; err != nil {
t.Fatal(err)
}
if user.Email != "ada@example.test" || !user.IsActivated || !user.IsSuperuser || user.Role.Code != "developer" || user.ActivatedAt == nil {
t.Fatalf("created admin = %+v role=%+v", user, user.Role)
}
if !bouncer.CheckPassword(user.Password, password) {
t.Fatal("stored password hash does not match")
}
buf.Reset()
if err := create.Run(context.Background(), flagInput{flags: map[string]string{
"email": "editor@example.test",
"password": password,
"login": "editor",
"role": "publisher",
}}, out); err != nil {
t.Fatal(err)
}
var editor cabana.BackendUser
if err := gdb.Where("login = ?", "editor").First(&editor).Error; err != nil {
t.Fatal(err)
}
if editor.IsSuperuser || editor.Email != "editor@example.test" {
t.Fatalf("editor = %+v", editor)
}
if err := create.Run(context.Background(), flagInput{flags: map[string]string{
"email": "third@example.test", "password": password, "role": "missing-role",
}}, out); err == nil || !strings.Contains(err.Error(), "unknown role") {
t.Fatalf("unknown role err = %v", err)
}
if err := gdb.Exec(`INSERT INTO backend_user_roles (name, code, is_system, created_at, updated_at) VALUES ('Editor A', 'shared', FALSE, NOW(), NOW()), ('Editor B', 'shared', FALSE, NOW(), NOW())`).Error; err != nil {
t.Fatal(err)
}
err := create.Run(context.Background(), flagInput{flags: map[string]string{
"email": "fourth@example.test", "password": password, "role": "shared",
}}, out)
if err == nil || !strings.Contains(err.Error(), "ambiguous role") || strings.Contains(err.Error(), password) {
t.Fatalf("ambiguous role err = %v", err)
}
}
func TestAdminResetPasswordCommand(t *testing.T) {
gdb := adminGorm(t)
app := commandApp(t, gdb)
reset := commandByName(t, cabana.RuntimeCommands(app), "admin:reset-password")
const original = "original-password-value"
const updated = "replacement-password-value"
hash, err := bouncer.HashPassword(10, original)
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil {
t.Fatal(err)
}
user := cabana.BackendUser{Login: "resetme", Email: "resetme@example.test", Password: hash, IsActivated: true, RoleID: &roleID}
if err := gdb.Create(&user).Error; err != nil {
t.Fatal(err)
}
token, _, err := bouncer.MintAudience(adminTestSecret, itoa(user.ID), "https://app.test/_admin/api/v1/auth/login", time.Hour, bouncer.AudienceBackend)
if err != nil {
t.Fatal(err)
}
guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, 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)
}
var buf bytes.Buffer
out := bonfire.NewOutput(nil, &buf, &buf)
if err := reset.Run(context.Background(), flagInput{
args: []string{"ResetMe@Example.Test"},
flags: map[string]string{"password": updated},
}, out); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), updated) || strings.Contains(buf.String(), original) || strings.Contains(buf.String(), "$2") || strings.Contains(buf.String(), token) {
t.Fatalf("reset output leaked a secret: %s", buf.String())
}
if !strings.Contains(buf.String(), "resetme") {
t.Fatalf("reset output = %s", buf.String())
}
var fresh cabana.BackendUser
if err := gdb.First(&fresh, user.ID).Error; err != nil {
t.Fatal(err)
}
if bouncer.CheckPassword(fresh.Password, original) || !bouncer.CheckPassword(fresh.Password, updated) {
t.Fatal("password hash was not replaced")
}
if fresh.TokensValidAfter == nil {
t.Fatal("reset did not advance tokens_valid_after")
}
if _, err := guard.Authenticate(req); err == nil {
t.Fatal("token issued before reset still authenticated")
}
}
func commandByName(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command {
t.Helper()
for _, cmd := range cmds {
if cmd.Name == name && cmd.Run != nil {
return cmd
}
}
t.Fatalf("%s was not registered", name)
return bonfire.Command{}
}
func commandApp(t *testing.T, gdb *gorm.DB) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-admin\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=development",
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
},
})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
return app
}
type flagInput struct {
args []string
flags map[string]string
}
func (f flagInput) Args() []string { return f.args }
func (f flagInput) Argument(name string) (string, bool) {
if name == "identifier" && len(f.args) > 0 {
return f.args[0], true
}
return "", false
}
func (f flagInput) Flag(name string) (string, bool) {
value, ok := f.flags[name]
return value, ok && value != ""
}
func (f flagInput) Flags(string) []string { return nil }

177
modules/cabana/contracts.go Normal file
View File

@@ -0,0 +1,177 @@
package cabana
import (
"encoding/json"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// BackendUserRole is the Winter backend_user_roles row.
type BackendUserRole struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code string `gorm:"column:code"`
Description string `gorm:"column:description"`
Permissions string `gorm:"column:permissions"`
IsSystem bool `gorm:"column:is_system"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (BackendUserRole) TableName() string { return "backend_user_roles" }
// BackendUser is the Winter backend_users row. It is not a frontend user.
// Nullable Winter columns are mapped so a copied row round-trips without a
// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column.
type BackendUser struct {
ID uint `gorm:"column:id;primaryKey"`
FirstName string `gorm:"column:first_name"`
LastName string `gorm:"column:last_name"`
Login string `gorm:"column:login"`
Email string `gorm:"column:email"`
Password string `gorm:"column:password"`
ActivationCode string `gorm:"column:activation_code"`
PersistCode string `gorm:"column:persist_code"`
ResetPasswordCode string `gorm:"column:reset_password_code"`
Permissions string `gorm:"column:permissions"`
IsActivated bool `gorm:"column:is_activated"`
IsSuperuser bool `gorm:"column:is_superuser"`
RoleID *uint `gorm:"column:role_id"`
ActivatedAt *time.Time `gorm:"column:activated_at"`
LastLogin *time.Time `gorm:"column:last_login"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
Role BackendUserRole
}
func (BackendUser) TableName() string { return "backend_users" }
// Option is a dropdown choice shared with later schema plans.
type Option = pact.Option
// WritableField is one schema field bound to a model fill key at activation.
// FillKey comes from the model column, not from request JSON.
type WritableField struct {
Name string
FillKey string
}
// CompiledController is one admin controller after YAML compilation.
type CompiledController struct {
PluginID string
Controller pact.AdminController
List *ListSchema
Form *FormSchema
Relations map[string]*CompiledRelation
Writable []WritableField
// FieldRelations are the form's `type: relation` fields keyed by field name.
FieldRelations map[string]*CompiledFieldRelation
}
// Registry is the immutable controller map keyed by controller ID.
type Registry struct {
byID map[string]*CompiledController
permissions map[string]pact.Permission
roleGrants map[string]map[string]bool
navigation []pact.NavigationItem
settings map[string]*CompiledSetting
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
func (r *Registry) Get(id string) (*CompiledController, bool) {
if r == nil {
return nil, false
}
cc, ok := r.byID[id]
return cc, ok && cc != nil
}
// Setting returns one compiled singleton setting by stable code.
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
if r == nil {
return nil, false
}
setting, ok := r.settings[code]
return setting, ok && setting != nil
}
// Allows reports whether principal satisfies every required permission code.
// A nil principal fails. Superusers pass. An empty requirement list allows
// any authenticated principal. Grants ending in ".*" match by prefix.
func Allows(principal *bouncer.Principal, required []string) bool {
if principal == nil {
return false
}
if principal.IsSuperuser || len(required) == 0 {
return true
}
for _, code := range required {
if !granted(principal.PermissionGrants, code) {
return false
}
}
return true
}
func granted(grants map[string]bool, code string) bool {
if grants[code] {
return true
}
for key, on := range grants {
if !on || !strings.HasSuffix(key, ".*") {
continue
}
prefix := strings.TrimSuffix(key, "*")
if strings.HasPrefix(code, prefix) {
return true
}
}
return false
}
func requiredOf(ctl pact.AdminController) []string {
if p, ok := ctl.(pact.AdminPermissioned); ok && p != nil {
return p.RequiredPermissions()
}
return nil
}
// WriteData writes a D-10 success envelope.
func WriteData(w http.ResponseWriter, status int, data, meta any) {
if meta == nil {
meta = map[string]any{}
}
writeJSON(w, status, map[string]any{"data": data, "meta": meta})
}
// WriteError writes a D-10 error envelope. details is always an object.
func WriteError(w http.ResponseWriter, status int, code, message string) {
WriteErrorDetails(w, status, code, message, nil)
}
// WriteErrorDetails writes a D-10 error envelope with field messages.
func WriteErrorDetails(w http.ResponseWriter, status int, code, message string, details map[string]any) {
if details == nil {
details = map[string]any{}
}
writeJSON(w, status, map[string]any{
"error": map[string]any{
"code": code,
"message": message,
"details": details,
},
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}

932
modules/cabana/crud.go Normal file
View File

@@ -0,0 +1,932 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"reflect"
"sort"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CRUDService runs schema-projected record and bulk operations.
type CRUDService struct {
DB *gorm.DB
}
// RecordInput is a decoded JSON object. Keys are untrusted.
type RecordInput struct {
Body map[string]any
}
// BulkDeleteInput is the bulk-delete body.
type BulkDeleteInput struct {
IDs []any `json:"ids"`
}
// BulkResult is the deleted-count payload.
type BulkResult struct {
Deleted int `json:"deleted"`
}
// ValidationError is a D-10 validation_failed failure.
type ValidationError struct {
Details map[string]any
}
func (e *ValidationError) Error() string { return "validation_failed" }
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
type CapabilityError struct {
ControllerID string
}
func (e *CapabilityError) Error() string {
if e == nil {
return "cabana: missing Fill/Validate capability"
}
return "cabana: controller " + e.ControllerID + ": missing Fill/Validate capability"
}
type recordNotFound struct{}
func (recordNotFound) Error() string { return "cabana: not found" }
type hasRules interface {
Rules() map[string]string
}
// ProjectWritableFields copies only activation-bound writable keys.
// Unknown keys, case variants, nested objects, and protected columns are dropped.
func ProjectWritableFields(cc *CompiledController, body map[string]any) map[string]any {
out := map[string]any{}
if cc == nil || body == nil {
return out
}
allowed := map[string]string{}
for _, field := range cc.Writable {
if field.FillKey == "" || protectedFillKey(field.Name) || protectedFillKey(field.FillKey) {
continue
}
allowed[field.Name] = field.FillKey
}
for key, val := range body {
fillKey, ok := allowed[key]
if !ok || nestedValue(val) {
continue
}
out[fillKey] = val
}
return out
}
// BindWritableFields records schema field names onto model column fill keys.
// Protected columns are omitted. A scalar field with no column fails activation.
func BindWritableFields(cc *CompiledController) error {
if cc == nil || cc.Form == nil {
return nil
}
id := ""
if cc.Controller != nil {
id = cc.Controller.ID()
}
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil
}
cols := modelColumns(src.NewRecord())
bindings := make([]WritableField, 0, len(cc.Form.Fields))
for _, field := range cc.Form.Fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
continue
}
if _, known := cols[field.Name]; !known {
return errors.New("cabana: controller " + id + ": field " + field.Name + " is not a model column")
}
bindings = append(bindings, WritableField{Name: field.Name, FillKey: field.Name})
}
cc.Writable = bindings
return nil
}
// Create persists a projected record after Fill and Validate.
func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) {
res, err := s.save(ctx, cc, nil, in, false)
return res.Data, err
}
// Update persists a projected change after Fill and Validate.
func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) {
res, err := s.save(ctx, cc, id, in, true)
return res.Data, err
}
// CreateRecord is Create plus the relation labels of the saved record (D-18).
func (s CRUDService) CreateRecord(ctx context.Context, cc *CompiledController, in RecordInput) (RecordResult, error) {
return s.save(ctx, cc, nil, in, false)
}
// UpdateRecord is Update plus the relation labels of the saved record (D-18).
func (s CRUDService) UpdateRecord(ctx context.Context, cc *CompiledController, id any, in RecordInput) (RecordResult, error) {
return s.save(ctx, cc, id, in, true)
}
// Delete removes one scoped record. A missing or out-of-scope id is a successful no-op.
func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) {
if s.DB == nil {
return BulkResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if _, err := newWritableModel(cc); err != nil {
return BulkResult{}, err
}
var result BulkResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
var missing recordNotFound
if errors.As(err, &missing) {
result.Deleted = 0
return nil
}
return err
}
if err := deleteRecord(ctx, tx, cc, target); err != nil {
return err
}
result.Deleted = 1
return nil
})
if err != nil {
return BulkResult{}, err
}
return result, nil
}
// BulkDelete removes a normalized id set in one transaction.
// An empty selection is validation_failed. A wholly absent selection is a
// successful no-op. A mixed present/absent selection conflicts and rolls back.
func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in BulkDeleteInput) (BulkResult, error) {
if s.DB == nil {
return BulkResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
ids, err := normalizeIDs(in.IDs)
if err != nil {
return BulkResult{}, err
}
if _, err := newWritableModel(cc); err != nil {
return BulkResult{}, err
}
var result BulkResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
proto, err := newWritableModel(cc)
if err != nil {
return err
}
rows, err := lockScoped(ctx, tx, cc, proto, ids)
if err != nil {
return err
}
if len(rows) == 0 {
result.Deleted = 0
return nil
}
if len(rows) != len(ids) {
return partialSelection{}
}
for _, row := range rows {
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
if err := deleteRecord(ctx, tx, cc, row); err != nil {
return err
}
}
result.Deleted = len(rows)
return nil
})
if err != nil {
return BulkResult{}, err
}
return result, nil
}
// Show loads one scoped record. Missing and out-of-scope ids are identical.
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
res, err := s.ShowRecord(ctx, cc, id)
if err != nil {
return nil, err
}
return res.Data, nil
}
// ShowRecord is Show plus the relation labels of the record (D-18).
func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id any) (RecordResult, error) {
if s.DB == nil {
return RecordResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
var result RecordResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
result, err = projectFullRecord(ctx, tx, cc, target)
return err
})
if err != nil {
return RecordResult{}, err
}
return result, nil
}
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name, and their labels.
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model)
meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil {
return RecordResult{}, err
}
return RecordResult{Data: data, Meta: meta}, nil
}
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (RecordResult, error) {
if s.DB == nil {
return RecordResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if _, err := newWritableModel(cc); err != nil {
return RecordResult{}, err
}
op := "create"
if update {
op = "update"
}
// Writable relation keys are lifted before scalar projection (which drops
// every nested value); only keys present in the body are applied.
relations, err := liftRelationValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
var result RecordResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
if update {
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
}
projected := projectOperation(cc, in.Body, op)
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
if hook, ok := target.(lagoon.HasBeforeValidate); ok && hook != nil {
if err := hook.BeforeValidate(tx); err != nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
}
rules := mergedRules(cc, target)
msgs, err := lagoon.Validate(ctx, tx, target, rules, valuesForRules(target, rules), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)}
}
if update {
err = formBeforeUpdate(ctx, cc, target)
} else {
err = formBeforeCreate(ctx, cc, target)
}
if err != nil {
return err
}
// D-18: submitted ids pass the same scoped query as the options
// endpoint; belongsTo keys land before the row write, pivot rows after.
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
return err
}
if err := assignBelongsTo(cc, target, relations); err != nil {
return err
}
if update {
err = tx.Save(target).Error
} else {
err = tx.Create(target).Error
}
if err != nil {
return lifecycleFailure(cc, err)
}
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
return err
}
if update {
err = formAfterUpdate(ctx, cc, target)
} else {
err = formAfterCreate(ctx, cc, target)
}
if err != nil {
return err
}
result, err = projectFullRecord(ctx, tx, cc, target)
return err
})
if err != nil {
return RecordResult{}, err
}
return result, nil
}
func writeCRUDError(w http.ResponseWriter, err error) {
var ve *ValidationError
if errors.As(err, &ve) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)
return
}
var missing recordNotFound
if errors.As(err, &missing) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
var partial partialSelection
if errors.As(err, &partial) {
WriteError(w, http.StatusConflict, "conflict", "Conflict")
return
}
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
func newWritableModel(cc *CompiledController) (any, error) {
id := controllerID(cc)
if cc == nil || cc.Controller == nil {
return nil, &CapabilityError{ControllerID: id}
}
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil {
return nil, &CapabilityError{ControllerID: id}
}
model := src.NewRecord()
if model == nil {
return nil, &CapabilityError{ControllerID: id}
}
if _, ok := model.(lagoon.HasFillable); !ok {
return nil, &CapabilityError{ControllerID: id}
}
if _, ok := model.(hasRules); !ok {
return nil, &CapabilityError{ControllerID: id}
}
return model, nil
}
func controllerID(cc *CompiledController) string {
if cc == nil || cc.Controller == nil {
return ""
}
return cc.Controller.ID()
}
func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error {
col := primaryColumn(dest)
q := tx.WithContext(ctx)
if cc != nil {
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
if next := ext.FormExtendQuery(ctx, q); next != nil {
q = next
}
}
}
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).
Take(dest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return recordNotFound{}
}
if err != nil {
return lifecycleFailure(cc, err)
}
return nil
}
type lifecycleError struct {
ControllerID string
}
func (e *lifecycleError) Error() string {
if e == nil {
return "cabana: lifecycle failed"
}
return "cabana: controller " + e.ControllerID + " failed"
}
func lifecycleFailure(cc *CompiledController, err error) error {
if err == nil {
return nil
}
var missing recordNotFound
if errors.As(err, &missing) {
return err
}
var invalid *ValidationError
if errors.As(err, &invalid) {
return err
}
var closed *CapabilityError
if errors.As(err, &closed) {
return err
}
var partial partialSelection
if errors.As(err, &partial) {
return err
}
var life *lifecycleError
if errors.As(err, &life) {
return err
}
return &lifecycleError{ControllerID: controllerID(cc)}
}
type partialSelection struct{}
func (partialSelection) Error() string { return "cabana: partial selection" }
func normalizeIDs(raw []any) ([]uint, error) {
if len(raw) == 0 {
return nil, &ValidationError{Details: map[string]any{"ids": []string{"The ids field is required."}}}
}
seen := map[uint]struct{}{}
ids := make([]uint, 0, len(raw))
for _, item := range raw {
n, err := asUint(item)
if err != nil {
return nil, &ValidationError{Details: map[string]any{"ids": []string{"The ids field must be a list of integers."}}}
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
ids = append(ids, n)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids, nil
}
func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto any, ids []uint) ([]any, error) {
mt := reflect.TypeOf(proto)
if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct {
return nil, &CapabilityError{ControllerID: controllerID(cc)}
}
holder := reflect.New(reflect.SliceOf(mt.Elem()))
q := tx.WithContext(ctx).Model(proto)
if cc != nil {
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
}
col := primaryColumn(proto)
vals := make([]any, len(ids))
for i, id := range ids {
vals[i] = castPK(proto, id)
}
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}).
Order(clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: false}).
Find(holder.Interface()).Error
if err != nil {
return nil, lifecycleFailure(cc, err)
}
values := holder.Elem()
out := make([]any, values.Len())
for i := 0; i < values.Len(); i++ {
out[i] = values.Index(i).Addr().Interface()
}
sort.SliceStable(out, func(i, j int) bool { return pkUint(out[i]) < pkUint(out[j]) })
return out, nil
}
func pkUint(model any) uint {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return 0
}
v = v.Elem()
}
field := fieldByColumn(v, primaryColumn(model))
if !field.IsValid() || !field.CanInterface() {
return 0
}
n, err := asUint(field.Interface())
if err != nil {
return 0
}
return n
}
func formBeforeCreate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormBeforeCreate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormBeforeCreate(ctx, model))
}
func formAfterCreate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormAfterCreate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormAfterCreate(ctx, model))
}
func formBeforeUpdate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormBeforeUpdate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormBeforeUpdate(ctx, model))
}
func formAfterUpdate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormAfterUpdate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormAfterUpdate(ctx, model))
}
func deleteRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) error {
if hook, ok := cc.Controller.(pact.FormBeforeDelete); ok && hook != nil {
if err := lifecycleFailure(cc, hook.FormBeforeDelete(ctx, model)); err != nil {
return err
}
}
if err := tx.WithContext(ctx).Delete(model).Error; err != nil {
return lifecycleFailure(cc, err)
}
if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil {
if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil {
return err
}
}
return nil
}
func decodeObject(r *http.Request) (map[string]any, error) {
dec := json.NewDecoder(r.Body)
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return nil, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
}
if body == nil {
body = map[string]any{}
}
return body, nil
}
func pathID(r *http.Request) (uint, error) {
raw := strings.TrimSpace(r.PathValue("id"))
n, err := strconv.ParseUint(raw, 10, 64)
if raw == "" || err != nil {
return 0, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
}
return uint(n), nil
}
func projectOperation(cc *CompiledController, body map[string]any, op string) map[string]any {
projected := ProjectWritableFields(cc, body)
if op == "" || cc == nil {
return projected
}
filtered := map[string]any{}
for _, field := range cc.Writable {
if !contextAllows(cc, field.Name, op) {
continue
}
if val, ok := projected[field.FillKey]; ok {
filtered[field.FillKey] = val
}
}
return filtered
}
func fillAllowed(cc *CompiledController, model any, op string) []string {
fillable, ok := model.(lagoon.HasFillable)
if !ok || fillable == nil {
return nil
}
allowed := map[string]struct{}{}
for _, key := range fillable.Fillable() {
if !protectedFillKey(key) {
allowed[key] = struct{}{}
}
}
out := make([]string, 0, len(allowed))
if cc == nil {
return out
}
for _, field := range cc.Writable {
if protectedFillKey(field.FillKey) {
continue
}
if _, ok := allowed[field.FillKey]; !ok || !contextAllows(cc, field.Name, op) {
continue
}
out = append(out, field.FillKey)
}
return out
}
func mergedRules(cc *CompiledController, model any) map[string]string {
out := map[string]string{}
if rules, ok := model.(hasRules); ok && rules != nil {
for key, rule := range rules.Rules() {
out[key] = rule
}
}
if cc == nil || cc.Form == nil {
return out
}
for _, field := range cc.Form.Fields {
// Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill.
if field.Required && scalarFormField(field.Type) {
out[field.Name] = mergeRequired(out[field.Name])
}
}
return out
}
func mergeRequired(rule string) string {
if strings.TrimSpace(rule) == "" {
return "required"
}
for _, tok := range strings.Split(rule, "|") {
name, _, _ := strings.Cut(strings.TrimSpace(tok), ":")
if name == "required" {
return rule
}
}
return rule + "|required"
}
func valuesForRules(model any, rules map[string]string) map[string]any {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return map[string]any{}
}
v = v.Elem()
}
out := map[string]any{}
for field := range rules {
f := fieldByColumn(v, field)
if f.IsValid() && f.CanInterface() {
out[field] = f.Interface()
}
}
return out
}
func validationDetails(msgs map[string][]string) map[string]any {
out := make(map[string]any, len(msgs))
for key, messages := range msgs {
out[key] = messages
}
return out
}
func projectRecord(cc *CompiledController, model any) map[string]any {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return map[string]any{}
}
v = v.Elem()
}
out := map[string]any{}
if id := fieldByColumn(v, primaryColumn(model)); id.IsValid() && id.CanInterface() {
out["id"] = id.Interface()
}
if cc == nil {
return out
}
for _, field := range cc.Writable {
if protectedFillKey(field.FillKey) {
continue
}
f := fieldByColumn(v, field.FillKey)
if f.IsValid() && f.CanInterface() {
out[field.Name] = f.Interface()
}
}
return out
}
func modelColumns(model any) map[string]struct{} {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
cols := map[string]struct{}{}
if t == nil || t.Kind() != reflect.Struct {
return cols
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
name := gormColumn(field)
if name != "" {
cols[name] = struct{}{}
}
}
return cols
}
func scalarFormField(typ string) bool {
switch typ {
case "text", "textarea", "number", "checkbox", "switch", "dropdown":
return true
default:
return false
}
}
func protectedFillKey(key string) bool {
switch strings.ToLower(key) {
case "id", "created_at", "updated_at", "deleted_at",
"owner_id", "user_id", "collection_id", "organisation_id", "organization_id",
"scope_id", "role_id", "permissions",
"is_superuser", "is_system", "is_activated", "password":
return true
default:
return false
}
}
func nestedValue(val any) bool {
switch val.(type) {
case map[string]any, []any:
return true
default:
return false
}
}
func contextAllows(cc *CompiledController, name, op string) bool {
if op == "" || cc == nil || cc.Form == nil {
return true
}
for _, field := range cc.Form.Fields {
if field.Name != name {
continue
}
if field.Context == nil || len(field.Context.values) == 0 {
return true
}
for _, value := range field.Context.values {
if value == op {
return true
}
}
return false
}
return true
}
func coercePK(model any, id any) (any, error) {
n, err := asUint(id)
if err != nil {
return nil, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
}
return castPK(model, n), nil
}
func castPK(model any, n uint) any {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return n
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !strings.Contains(field.Tag.Get("gorm"), "primaryKey") {
continue
}
switch field.Type.Kind() {
case reflect.Uint:
return uint(n)
case reflect.Uint32:
return uint32(n)
case reflect.Uint64:
return uint64(n)
case reflect.Int:
return int(n)
case reflect.Int64:
return int64(n)
case reflect.String:
return strconv.FormatUint(uint64(n), 10)
default:
return n
}
}
return n
}
func asUint(id any) (uint, error) {
switch n := id.(type) {
case uint:
return n, nil
case uint32:
return uint(n), nil
case uint64:
if uint64(uint(n)) != n {
return 0, errBadID
}
return uint(n), nil
case int:
if n < 0 {
return 0, errBadID
}
return uint(n), nil
case int64:
if n < 0 {
return 0, errBadID
}
return uint(n), nil
case float64:
if n < 0 || n != math.Trunc(n) || n > math.MaxUint32 && strconv.IntSize == 32 {
return 0, errBadID
}
return uint(n), nil
case json.Number:
i, err := n.Int64()
if err != nil || i < 0 {
return 0, errBadID
}
return uint(i), nil
case string:
i, err := strconv.ParseUint(n, 10, 64)
if err != nil {
return 0, errBadID
}
return uint(i), nil
default:
return 0, errBadID
}
}
var errBadID = errors.New("cabana: bad id")

View File

@@ -0,0 +1,500 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
type scopeKey struct{}
type hookController struct {
log *[]string
perms []string
trackScope bool
fail string
queries atomic.Int32
allocs atomic.Int32
}
func (c *hookController) ID() string { return "acme.demo.records" }
func (c *hookController) ModelName() string { return "Record" }
func (c *hookController) ConfigDir() string { return "controllers/records" }
func (c *hookController) RequiredPermissions() []string {
if c == nil || c.perms == nil {
return nil
}
return c.perms
}
func (c *hookController) NewRecord() any {
if c != nil {
c.allocs.Add(1)
}
return &crudRow{}
}
func (c *hookController) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return c.FormExtendQuery(ctx, db)
}
func (c *hookController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
if c != nil {
c.queries.Add(1)
}
if c == nil || !c.trackScope || db == nil {
return db
}
scope, _ := ctx.Value(scopeKey{}).(uint)
return db.Where("scope_id = ?", scope)
}
func (c *hookController) FormBeforeCreate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_create")
if c.trackScope {
if row, ok := model.(*crudRow); ok {
if scope, ok := ctx.Value(scopeKey{}).(uint); ok {
row.ScopeID = scope
}
}
}
return c.hookErr("form_before_create")
}
func (c *hookController) FormAfterCreate(ctx context.Context, model any) error {
recordHook(ctx, "form_after_create")
return c.hookErr("form_after_create")
}
func (c *hookController) FormBeforeUpdate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_update")
return c.hookErr("form_before_update")
}
func (c *hookController) FormAfterUpdate(ctx context.Context, model any) error {
recordHook(ctx, "form_after_update")
return c.hookErr("form_after_update")
}
func (c *hookController) FormBeforeDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_before_delete")
return c.hookErr("form_before_delete")
}
func (c *hookController) FormAfterDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_after_delete")
return c.hookErr("form_after_delete")
}
func (c *hookController) hookErr(name string) error {
if c != nil && c.fail == name {
return errHookBoom
}
return nil
}
var errHookBoom = errString("secret-hook-boom")
type errString string
func (e errString) Error() string { return string(e) }
func TestCRUDRecordRoutes(t *testing.T) {
cap := &captureRouter{}
(&service{}).mount(cap)
for _, want := range []string{
"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 {
t.Fatalf("missing %s in %v", want, cap.routes)
}
if !containsString(mw, "backend") {
t.Fatalf("%s middleware=%v, want backend before the handler", want, mw)
}
}
_, httpSvc, _, db, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
created := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":"Ada","note":"n"}`), ctx)
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", created.Code, created.Body.String())
}
body := decodeData(t, created.Body.Bytes())
if body["name"] != "Ada" || body["note"] != "n" || body["id"] == nil {
t.Fatalf("create data=%#v", body)
}
blank := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":""}`), ctx)
if blank.Code != http.StatusUnprocessableEntity || !strings.Contains(blank.Body.String(), `"validation_failed"`) || !strings.Contains(blank.Body.String(), "The name field is required.") {
t.Fatalf("blank create=%d %s", blank.Code, blank.Body.String())
}
id := uintString(body["id"])
shown := crudCall(httpSvc, http.MethodGet, id, nil, ctx)
if shown.Code != http.StatusOK || decodeData(t, shown.Body.Bytes())["name"] != "Ada" {
t.Fatalf("show=%d %s", shown.Code, shown.Body.String())
}
missing := crudCall(httpSvc, http.MethodGet, "999999", nil, ctx)
if missing.Code != http.StatusNotFound || !strings.Contains(missing.Body.String(), `"not_found"`) {
t.Fatalf("missing show=%d %s", missing.Code, missing.Body.String())
}
bad := crudCall(httpSvc, http.MethodGet, "nope", nil, ctx)
if bad.Code != http.StatusUnprocessableEntity || !strings.Contains(bad.Body.String(), `"validation_failed"`) {
t.Fatalf("bad id=%d %s", bad.Code, bad.Body.String())
}
updated := crudCall(httpSvc, http.MethodPut, id, []byte(`{"name":"Bea"}`), ctx)
if updated.Code != http.StatusOK || decodeData(t, updated.Body.Bytes())["name"] != "Bea" {
t.Fatalf("update=%d %s", updated.Code, updated.Body.String())
}
*hooks.log = nil
deleted := crudCall(httpSvc, http.MethodDelete, id, nil, ctx)
if deleted.Code != http.StatusOK || deletedCount(t, deleted.Body.Bytes()) != 1 {
t.Fatalf("delete=%d %s", deleted.Code, deleted.Body.String())
}
if strings.Join(*hooks.log, ",") != "form_before_delete,before_delete,after_delete,form_after_delete" {
t.Fatalf("delete hooks=%v", *hooks.log)
}
*hooks.log = nil
again := crudCall(httpSvc, http.MethodDelete, id, nil, ctx)
if again.Code != http.StatusOK || deletedCount(t, again.Body.Bytes()) != 0 || len(*hooks.log) != 0 {
t.Fatalf("repeat delete=%d %s hooks=%v", again.Code, again.Body.String(), *hooks.log)
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d after delete", n)
}
}
func TestCRUDPermissions(t *testing.T) {
_, httpSvc, _, db, hooks := hookFixture(t)
hooks.perms = []string{"acme.demo.records"}
denied := &bouncer.Principal{ID: 4, PermissionGrants: map[string]bool{"other.code": true}}
unauth := crudCall(httpSvc, http.MethodPost, "", []byte(`{`), principalCtx(hooks, nil))
if unauth.Code != http.StatusUnauthorized || !strings.Contains(unauth.Body.String(), `"unauthenticated"`) {
t.Fatalf("unauth=%d %s", unauth.Code, unauth.Body.String())
}
forbidden := crudCall(httpSvc, http.MethodPost, "", []byte(`{`), principalCtx(hooks, denied))
if forbidden.Code != http.StatusForbidden || !strings.Contains(forbidden.Body.String(), `"forbidden"`) {
t.Fatalf("forbidden=%d %s", forbidden.Code, forbidden.Body.String())
}
badID := crudCall(httpSvc, http.MethodGet, "nope", nil, principalCtx(hooks, denied))
if badID.Code != http.StatusForbidden {
t.Fatalf("bad id before permission=%d %s", badID.Code, badID.Body.String())
}
if hooks.allocs.Load() != 0 || hooks.queries.Load() != 0 {
t.Fatalf("allocs=%d queries=%d, permission did not run first", hooks.allocs.Load(), hooks.queries.Load())
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("denied request persisted %d rows", n)
}
ok := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":"Ada"}`), principalCtx(hooks, superUser()))
if ok.Code != http.StatusCreated {
t.Fatalf("superuser create=%d %s", ok.Code, ok.Body.String())
}
}
func TestCRUDScope(t *testing.T) {
_, httpSvc, _, db, hooks := hookFixture(t)
hooks.trackScope = true
in := &crudRow{Name: "In", ScopeID: 1}
out := &crudRow{Name: "Out", ScopeID: 2}
if err := db.Create(in).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(out).Error; err != nil {
t.Fatal(err)
}
ctx := principalCtx(hooks, superUser())
ctx = context.WithValue(ctx, scopeKey{}, uint(1))
shown := crudCall(httpSvc, http.MethodGet, idString(in.ID), nil, ctx)
if shown.Code != http.StatusOK || decodeData(t, shown.Body.Bytes())["name"] != "In" {
t.Fatalf("in scope show=%d %s", shown.Code, shown.Body.String())
}
if _, ok := decodeData(t, shown.Body.Bytes())["scope_id"]; ok {
t.Fatalf("show leaked scope_id: %s", shown.Body.String())
}
missing := crudCall(httpSvc, http.MethodGet, "999999", nil, ctx)
hidden := crudCall(httpSvc, http.MethodGet, idString(out.ID), nil, ctx)
if missing.Code != http.StatusNotFound || hidden.Code != http.StatusNotFound || missing.Body.String() != hidden.Body.String() {
t.Fatalf("missing=%d %s hidden=%d %s", missing.Code, missing.Body.String(), hidden.Code, hidden.Body.String())
}
hacked := crudCall(httpSvc, http.MethodPut, idString(out.ID), []byte(`{"name":"hacked"}`), ctx)
if hacked.Code != http.StatusNotFound || hacked.Body.String() != missing.Body.String() {
t.Fatalf("out of scope update=%d %s", hacked.Code, hacked.Body.String())
}
if got := loadCrud(t, db, "Out"); got.Name != "Out" || got.ScopeID != 2 {
t.Fatalf("out of scope row changed: %+v", got)
}
*hooks.log = nil
gone := crudCall(httpSvc, http.MethodDelete, "999999", nil, ctx)
hiddenDelete := crudCall(httpSvc, http.MethodDelete, idString(out.ID), nil, ctx)
if gone.Code != http.StatusOK || hiddenDelete.Code != http.StatusOK || deletedCount(t, gone.Body.Bytes()) != 0 || deletedCount(t, hiddenDelete.Body.Bytes()) != 0 || gone.Body.String() != hiddenDelete.Body.String() {
t.Fatalf("delete missing=%d %s hidden=%d %s", gone.Code, gone.Body.String(), hiddenDelete.Code, hiddenDelete.Body.String())
}
if len(*hooks.log) != 0 {
t.Fatalf("delete hooks ran for absent rows: %v", *hooks.log)
}
if got := loadCrud(t, db, "Out"); got.ScopeID != 2 {
t.Fatalf("out of scope delete removed %+v", got)
}
}
func TestCRUDHooks(t *testing.T) {
svc, _, cc, _, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "note": "n"}})
if err != nil {
t.Fatalf("create err=%v", err)
}
if got := strings.Join(*hooks.log, ","); got != "before_validate,form_before_create,before_save,before_create,after_create,after_save,form_after_create" {
t.Fatalf("create hooks=%s", got)
}
if countHooks(*hooks.log, "form_before_create") != 1 || countHooks(*hooks.log, "before_create") != 1 || countHooks(*hooks.log, "form_after_create") != 1 {
t.Fatalf("create hook repeated: %v", *hooks.log)
}
*hooks.log = nil
if _, err := svc.Update(ctx, cc, rec["id"], RecordInput{Body: map[string]any{"name": "Bea"}}); err != nil {
t.Fatalf("update err=%v", err)
}
if got := strings.Join(*hooks.log, ","); got != "before_validate,form_before_update,before_save,before_update,after_update,after_save,form_after_update" {
t.Fatalf("update hooks=%s", got)
}
*hooks.log = nil
if _, err := svc.Delete(ctx, cc, rec["id"]); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := strings.Join(*hooks.log, ","); got != "form_before_delete,before_delete,after_delete,form_after_delete" {
t.Fatalf("delete hooks=%s", got)
}
}
func TestCRUDRollback(t *testing.T) {
svc, httpSvc, cc, db, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
hooks.fail = "form_after_create"
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
if err == nil {
t.Fatal("after-create hook failure was ignored")
}
if strings.Contains(err.Error(), "secret-hook-boom") || !strings.Contains(err.Error(), "acme.demo.records") {
t.Fatalf("hook error=%v, want opaque controller context", err)
}
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "secret-hook-boom") || strings.Contains(rec.Body.String(), "acme.demo.records") {
t.Fatalf("http hook error=%d %s", rec.Code, rec.Body.String())
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d after failed create", n)
}
hooks.fail = ""
created, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
if err != nil {
t.Fatal(err)
}
hooks.fail = "form_before_update"
if _, err := svc.Update(ctx, cc, created["id"], RecordInput{Body: map[string]any{"name": "hacked"}}); err == nil || strings.Contains(err.Error(), "secret-hook-boom") {
t.Fatalf("update hook err=%v", err)
}
if got := loadCrud(t, db, "Ada"); got.Name != "Ada" {
t.Fatalf("failed update committed %+v", got)
}
hooks.fail = "form_after_update"
if _, err := svc.Update(ctx, cc, created["id"], RecordInput{Body: map[string]any{"note": "later"}}); err == nil {
t.Fatal("after-update hook failure was ignored")
}
if got := loadCrud(t, db, "Ada"); got.Note != "n" && got.Note != "" {
t.Fatalf("failed after-update committed note %q", got.Note)
}
if got := loadCrud(t, db, "Ada"); got.Note == "later" {
t.Fatal("after-update wrote note")
}
*hooks.log = nil
failCtx := context.WithValue(ctx, failDeleteKey{}, true)
if _, err := svc.Delete(failCtx, cc, created["id"]); err == nil || strings.Contains(err.Error(), "secret-hook-boom") {
t.Fatalf("delete hook err=%v", err)
}
if !containsString(*hooks.log, "before_delete") {
t.Fatalf("delete lifecycle did not run: %v", *hooks.log)
}
if got := loadCrud(t, db, "Ada"); got.Name != "Ada" {
t.Fatalf("failed delete removed %+v", got)
}
_ = httpSvc
}
type captureRouter struct {
prefix string
mw []string
routes []string
middleware map[string][]string
}
func (c *captureRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
c.GroupRaw(prefix, middleware, fn)
}
func (c *captureRouter) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) {
if c.middleware == nil {
c.middleware = map[string][]string{}
}
child := &captureRouter{
prefix: c.prefix + prefix,
mw: append(append([]string{}, c.mw...), middleware...),
middleware: c.middleware,
}
fn(child)
c.routes = append(c.routes, child.routes...)
}
func (c *captureRouter) Get(path string, _ http.HandlerFunc, _ ...string) { c.add("GET", path) }
func (c *captureRouter) Post(path string, _ http.HandlerFunc, _ ...string) {
c.add("POST", path)
}
func (c *captureRouter) Put(path string, _ http.HandlerFunc, _ ...string) { c.add("PUT", path) }
func (c *captureRouter) Patch(path string, _ http.HandlerFunc, _ ...string) {
c.add("PATCH", path)
}
func (c *captureRouter) Delete(path string, _ http.HandlerFunc, _ ...string) {
c.add("DELETE", path)
}
func (c *captureRouter) Where(string, string) {}
func (c *captureRouter) WhereIn(string, ...string) {}
func (c *captureRouter) add(method, path string) {
full := method + " " + c.prefix + path
c.routes = append(c.routes, full)
if c.middleware == nil {
c.middleware = map[string][]string{}
}
c.middleware[full] = append([]string{}, c.mw...)
}
func hookFixture(t *testing.T) (CRUDService, *service, *CompiledController, *gorm.DB, *hookController) {
t.Helper()
crudSvc, cc, db := crudFixture(t)
hooks := &hookController{log: &[]string{}}
cc.Controller = hooks
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
httpSvc := &service{app: app, reg: &Registry{byID: map[string]*CompiledController{
cc.Controller.ID(): cc,
}}}
return crudSvc, httpSvc, cc, db, hooks
}
func principalCtx(hooks *hookController, principal *bouncer.Principal) context.Context {
ctx := context.WithValue(context.Background(), hookSinkKey{}, hooks.log)
if principal != nil {
principal.Backend = true
ctx = bouncer.WithUser(ctx, principal)
}
return ctx
}
func superUser() *bouncer.Principal {
return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
}
func crudCall(svc *service, method, id string, body []byte, ctx context.Context) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/", bytes.NewReader(body))
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "records")
if id != "" {
req.SetPathValue("id", id)
}
if ctx != nil {
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodGet:
svc.show(rec, req)
case http.MethodPut:
svc.update(rec, req)
case http.MethodDelete:
svc.deleteRecord(rec, req)
default:
rec.Code = http.StatusNotImplemented
}
return rec
}
func decodeData(t *testing.T, raw []byte) map[string]any {
t.Helper()
var body struct {
Data map[string]any `json:"data"`
Meta map[string]any `json:"meta"`
}
if err := jsonUnmarshal(raw, &body); err != nil {
t.Fatalf("json: %v body=%s", err, raw)
}
if body.Meta == nil {
t.Fatalf("meta is null: %s", raw)
}
return body.Data
}
func deletedCount(t *testing.T, raw []byte) float64 {
t.Helper()
var body struct {
Data struct {
Deleted float64 `json:"deleted"`
} `json:"data"`
}
if err := jsonUnmarshal(raw, &body); err != nil {
t.Fatalf("json: %v body=%s", err, raw)
}
return body.Data.Deleted
}
func jsonUnmarshal(raw []byte, dest any) error {
return json.Unmarshal(raw, dest)
}
func uintString(id any) string {
switch n := id.(type) {
case uint:
return idString(n)
case float64:
return idString(uint(n))
case int:
return idString(uint(n))
default:
return ""
}
}
func idString(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
func countHooks(log []string, name string) int {
n := 0
for _, item := range log {
if item == name {
n++
}
}
return n
}
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}

471
modules/cabana/crud_test.go Normal file
View File

@@ -0,0 +1,471 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"gorm.io/gorm"
)
const crudFormConfig = `name: records
form: ~/plugins/acme/demo/models/record/fields.yaml
modelClass: Record
`
const crudFields = `fields:
name:
label: Name
type: text
required: true
note:
label: Note
type: text
id:
label: ID
type: number
created_at:
label: Created
type: text
scope_id:
label: Scope
type: number
is_system:
label: System
type: switch
`
const crudListConfig = `modelClass: Record
list: ~/plugins/acme/demo/models/record/columns.yaml
recordsPerPage: 20
showSearch: true
`
const crudColumns = `columns:
name:
label: Name
searchable: true
`
type clearNameKey struct{}
var crudRuleSet = map[string]string{"name": "required"}
type crudRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Note string `gorm:"column:note"`
ScopeID uint `gorm:"column:scope_id"`
IsSystem bool `gorm:"column:is_system"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (crudRow) TableName() string { return "cabana_crud_rows" }
func (crudRow) Fillable() []string { return []string{"name", "note"} }
func (crudRow) Rules() map[string]string { return crudRuleSet }
func (r *crudRow) BeforeValidate(tx *gorm.DB) error {
ctx := txContext(tx)
recordHook(ctx, "before_validate")
if clear, _ := ctx.Value(clearNameKey{}).(bool); clear {
if r.Name == "" {
return errors.New("fill did not run before validate")
}
r.Name = ""
}
return nil
}
func (r *crudRow) BeforeSave(tx *gorm.DB) error {
recordHook(txContext(tx), "before_save")
return nil
}
func (r *crudRow) BeforeCreate(tx *gorm.DB) error {
recordHook(txContext(tx), "before_create")
return nil
}
func (r *crudRow) AfterCreate(tx *gorm.DB) error {
recordHook(txContext(tx), "after_create")
return nil
}
func (r *crudRow) AfterSave(tx *gorm.DB) error {
recordHook(txContext(tx), "after_save")
return nil
}
func (r *crudRow) BeforeUpdate(tx *gorm.DB) error {
recordHook(txContext(tx), "before_update")
return nil
}
func (r *crudRow) AfterUpdate(tx *gorm.DB) error {
recordHook(txContext(tx), "after_update")
return nil
}
func (r *crudRow) BeforeDelete(tx *gorm.DB) error {
ctx := txContext(tx)
if _, ok := ctx.Value(bulkSlowKey{}).(bool); ok {
time.Sleep(300 * time.Millisecond)
}
recordHook(ctx, "before_delete")
appendDeletedID(ctx, r.ID)
if fail, _ := ctx.Value(failDeleteKey{}).(bool); fail {
return errors.New("secret-hook-boom")
}
if ids, ok := ctx.Value(failIDsKey{}).(map[uint]bool); ok && ids[r.ID] {
return errors.New("secret-hook-boom")
}
if id, ok := ctx.Value(cancelOnKey{}).(uint); ok && r.ID == id {
if cancel, ok := ctx.Value(cancelKey{}).(context.CancelFunc); ok {
cancel()
}
return context.Canceled
}
return nil
}
func (r *crudRow) AfterDelete(tx *gorm.DB) error {
recordHook(txContext(tx), "after_delete")
return nil
}
type hookSinkKey struct{}
type failDeleteKey struct{}
type failIDsKey struct{}
type idSinkKey struct{}
type bulkSlowKey struct{}
type cancelOnKey struct{}
type cancelKey struct{}
var hookMu sync.Mutex
func txContext(tx *gorm.DB) context.Context {
if tx != nil && tx.Statement != nil && tx.Statement.Context != nil {
return tx.Statement.Context
}
return context.Background()
}
func recordHook(ctx context.Context, name string) {
if ctx == nil {
return
}
hookMu.Lock()
defer hookMu.Unlock()
sink, _ := ctx.Value(hookSinkKey{}).(*[]string)
if sink == nil {
return
}
*sink = append(*sink, name)
}
func appendDeletedID(ctx context.Context, id uint) {
if ctx == nil {
return
}
hookMu.Lock()
defer hookMu.Unlock()
sink, _ := ctx.Value(idSinkKey{}).(*[]uint)
if sink == nil {
return
}
*sink = append(*sink, id)
}
type crudNoRules struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (crudNoRules) TableName() string { return "cabana_crud_rows" }
func (crudNoRules) Fillable() []string { return []string{"name", "note"} }
type crudNoFill struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (crudNoFill) TableName() string { return "cabana_crud_rows" }
func (crudNoFill) Rules() map[string]string { return map[string]string{"name": "required"} }
type crudController struct{ rec func() any }
func (c crudController) ID() string { return "acme.demo.records" }
func (c crudController) ModelName() string { return "Record" }
func (c crudController) ConfigDir() string { return "controllers/records" }
func (c crudController) NewRecord() any {
if c.rec != nil {
return c.rec()
}
return &crudRow{}
}
func TestCRUDFillValidate(t *testing.T) {
svc, cc, db := crudFixture(t)
ctx := context.Background()
t.Run("empty name is 422 and does not persist", func(t *testing.T) {
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": ""}})
assertValidation(t, err, "name", "The name field is required.")
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d, validation must not persist", n)
}
})
t.Run("yaml required merges when model rules are empty", func(t *testing.T) {
prev := crudRuleSet
crudRuleSet = map[string]string{}
t.Cleanup(func() { crudRuleSet = prev })
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"note": "only"}})
assertValidation(t, err, "name", "The name field is required.")
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d, yaml required was skipped", n)
}
})
t.Run("validate runs after fill", func(t *testing.T) {
clearCtx := context.WithValue(ctx, clearNameKey{}, true)
_, err := svc.Create(clearCtx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
assertValidation(t, err, "name", "The name field is required.")
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d, validate ran before BeforeValidate mutated the model", n)
}
})
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "note": "kept"}})
if err != nil || rec["name"] != "Ada" || rec["note"] != "kept" {
t.Fatalf("create=%#v err=%v, want filled name and note", rec, err)
}
stored := loadCrud(t, db, "Ada")
if stored.Note != "kept" {
t.Fatalf("stored note=%q, Fill did not persist the writable field", stored.Note)
}
updated, err := svc.Update(ctx, cc, stored.ID, RecordInput{Body: map[string]any{"name": "Bea"}})
if err != nil || updated["name"] != "Bea" {
t.Fatalf("update=%#v err=%v", updated, err)
}
if _, err := svc.Update(ctx, cc, stored.ID, RecordInput{Body: map[string]any{"name": ""}}); err == nil {
t.Fatal("blank update name succeeded")
} else {
assertValidation(t, err, "name", "The name field is required.")
}
if got := loadCrud(t, db, "Bea"); got.Note != "kept" || got.Name != "Bea" {
t.Fatalf("row after rejected update = %+v", got)
}
}
func TestCRUDWritableProjection(t *testing.T) {
cc := crudCompiled(t)
gotKeys := map[string]bool{}
for _, field := range cc.Writable {
gotKeys[field.Name] = true
if field.FillKey == "" || field.FillKey != field.Name {
t.Fatalf("binding %+v is not an explicit model fill key", field)
}
}
for _, banned := range []string{"id", "created_at", "scope_id", "is_system"} {
if gotKeys[banned] {
t.Fatalf("protected field %s was bound writable: %+v", banned, cc.Writable)
}
}
if !gotKeys["name"] || !gotKeys["note"] {
t.Fatalf("writable = %+v, want name and note", cc.Writable)
}
body := crudBody(t, `{"name":"Ada","Name":"Nope","note":"n","id":9,"ID":8,"created_at":"1999-01-01T00:00:00Z","CreatedAt":"1999-01-01T00:00:00Z","scope_id":4,"is_system":true,"IsSystem":true,"unknown":"x","meta":{"name":"hidden","id":1,"is_system":true}}`)
got := ProjectWritableFields(cc, body)
if len(got) != 2 || got["name"] != "Ada" || got["note"] != "n" {
t.Fatalf("projected = %#v, want only name and note", got)
}
}
func TestCRUDRejectsProtectedFields(t *testing.T) {
svc, cc, db := crudFixture(t)
body := crudBody(t, `{"name":"Ada","note":"kept","id":424242,"ID":7,"Id":7,"created_at":"1999-01-01T00:00:00Z","CreatedAt":"1999-01-01T00:00:00Z","createdAt":"1999-01-01T00:00:00Z","scope_id":9,"ScopeID":9,"is_system":true,"IsSystem":true,"isSystem":true,"meta":{"id":1,"scope_id":3,"is_system":true,"name":"hidden"},"nested":{"created_at":"1999-01-01T00:00:00Z"}}`)
rec, err := svc.Create(context.Background(), cc, RecordInput{Body: body})
if err != nil {
t.Fatalf("create err=%v", err)
}
if rec["name"] != "Ada" {
t.Fatalf("create=%#v", rec)
}
stored := loadCrud(t, db, "Ada")
if stored.ID == 0 || stored.ID == 424242 || stored.ID == 7 || stored.ScopeID != 0 || stored.IsSystem || stored.CreatedAt.Year() == 1999 || stored.Note != "kept" {
t.Fatalf("protected fields mutated the row: %+v", stored)
}
if _, err := svc.Update(context.Background(), cc, stored.ID, RecordInput{Body: crudBody(t, `{"note":"still","id":424242,"scope_id":9,"is_system":true,"created_at":"1999-01-01T00:00:00Z","meta":{"is_system":true}}`)}); err != nil {
t.Fatalf("update err=%v", err)
}
stored = loadCrud(t, db, "Ada")
if stored.ID == 424242 || stored.ScopeID != 0 || stored.IsSystem || stored.CreatedAt.Year() == 1999 || stored.Note != "still" || stored.Name != "Ada" {
t.Fatalf("update mutated protected fields: %+v", stored)
}
}
func TestCRUDCapabilityFailure(t *testing.T) {
svc, cc, db := crudFixture(t)
ctx := context.Background()
t.Run("missing rules", func(t *testing.T) {
bare := *cc
bare.Controller = crudController{rec: func() any { return &crudNoRules{} }}
_, err := svc.Create(ctx, &bare, RecordInput{Body: map[string]any{"name": "Ada"}})
assertCapability(t, err, bare.Controller.ID())
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d, missing Rules persisted", n)
}
})
t.Run("missing fillable", func(t *testing.T) {
bare := *cc
bare.Controller = crudController{rec: func() any { return &crudNoFill{} }}
_, err := svc.Create(ctx, &bare, RecordInput{Body: map[string]any{"name": "Ada"}})
assertCapability(t, err, bare.Controller.ID())
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d, missing Fillable persisted", n)
}
})
t.Run("provider error", func(t *testing.T) {
prev := crudRuleSet
crudRuleSet = map[string]string{"name": "nope"}
t.Cleanup(func() { crudRuleSet = prev })
_, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}})
assertCapability(t, err, cc.Controller.ID())
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "nope") || strings.Contains(rec.Body.String(), "unrecognized") {
t.Fatalf("response leaked provider error: %s", rec.Body.String())
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d, provider error persisted", n)
}
})
}
func crudFixture(t *testing.T) (CRUDService, *CompiledController, *gorm.DB) {
t.Helper()
crudRuleSet = map[string]string{"name": "required"}
_, db := newListService(t)
if err := db.Migrator().DropTable(&crudRow{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&crudRow{}); err != nil {
t.Fatal(err)
}
return CRUDService{DB: db}, crudCompiled(t), db
}
func crudCompiled(t *testing.T) *CompiledController {
t.Helper()
reg, err := compileRegistry([]controllerRef{{
plugin: formPlugin{fsys: crudFS()},
ctl: crudController{},
}})
if err != nil {
t.Fatalf("registry: %v", err)
}
cc, ok := reg.Get("acme.demo.records")
if !ok || cc.Form == nil {
t.Fatalf("compiled controller missing form: %+v", cc)
}
return cc
}
func crudFS() fstest.MapFS {
return fstest.MapFS{
"controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(crudListConfig)},
"controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(crudFormConfig)},
"models/record/columns.yaml": &fstest.MapFile{Data: []byte(crudColumns)},
"models/record/fields.yaml": &fstest.MapFile{Data: []byte(crudFields)},
}
}
func assertValidation(t *testing.T, err error, field, message string) {
t.Helper()
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err=%v, want validation_failed", err)
}
got, _ := ve.Details[field].([]string)
if len(got) != 1 || got[0] != message {
t.Fatalf("details[%s]=%#v, want %q", field, ve.Details[field], message)
}
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s, want 422", rec.Code, rec.Body.String())
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != "validation_failed" || body.Error.Message != "Validation failed" {
t.Fatalf("envelope=%s", rec.Body.String())
}
msgs, _ := body.Error.Details[field].([]any)
if len(msgs) != 1 || msgs[0] != message {
t.Fatalf("http details=%#v", body.Error.Details)
}
}
func assertCapability(t *testing.T, err error, controllerID string) {
t.Helper()
var ce *CapabilityError
if !errors.As(err, &ce) || ce.ControllerID != controllerID || !strings.Contains(err.Error(), controllerID) {
t.Fatalf("err=%v, want capability failure for %s", err, controllerID)
}
}
func countCrud(t *testing.T, db *gorm.DB) int64 {
t.Helper()
var n int64
if err := db.Model(&crudRow{}).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func loadCrud(t *testing.T, db *gorm.DB, name string) crudRow {
t.Helper()
var row crudRow
if err := db.Where("name = ?", name).Take(&row).Error; err != nil {
t.Fatalf("load %s: %v", name, err)
}
return row
}
func crudBody(t *testing.T, raw string) map[string]any {
t.Helper()
dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
t.Fatal(err)
}
return body
}

45
modules/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

@@ -0,0 +1,13 @@
package cabana
// AdminAPIRoutes exposes the admin API route inventory (method plus
// prefix-relative path) to the external conformance test.
func AdminAPIRoutes() []string {
out := make([]string, 0, len(phase09Routes))
for _, route := range phase09Routes {
if !route.spa {
out = append(out, route.key)
}
}
return out
}

View File

@@ -0,0 +1,168 @@
package cabana
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
const filterOptionsScopes = `scopes:
grouped:
label: Group
modelClass: Group
nameFrom: name
scope: filterByGroup
activated:
label: Active
type: switch
column: active
`
// panicOptionsRow proves the provider never runs for a denied principal.
type panicOptionsRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
}
func (panicOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
func (panicOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB {
return db
}
func (panicOptionsRow) FilterOptions(string) []Option {
panic("FilterOptions ran for a denied request")
}
// scopeOnlyRow has a filter scope but no choices source.
type scopeOnlyRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
}
func (scopeOnlyRow) FilterScopes() []string { return []string{"filterByGroup"} }
func (scopeOnlyRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB {
return db
}
func filterOptionsService(t *testing.T, rec any, perms []string, tr *phrasebook.Translator) *service {
t.Helper()
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: rec}
list, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, filterOptionsScopes))
if err != nil {
t.Fatal(err)
}
app := backpack.New(nil)
if tr != nil {
if err := app.Publish(tr); err != nil {
t.Fatal(err)
}
}
permCtl := permissionedFilterController{filterController: ctl, perms: perms}
return &service{app: app, reg: &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {Controller: permCtl, List: list},
}}}
}
type permissionedFilterController struct {
filterController
perms []string
}
func (c permissionedFilterController) RequiredPermissions() []string { return c.perms }
func filterOptionsCall(svc *service, scope, locale string, principal *bouncer.Principal) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets/filters/"+scope+"/options"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")
req.SetPathValue("id", "filters")
req.SetPathValue("segment", scope)
req.SetPathValue("name", "options")
ctx := req.Context()
if locale != "" {
ctx = towel.WithLocale(ctx, locale)
}
if principal != nil {
principal.Backend = true
ctx = bouncer.WithUser(ctx, principal)
}
rec := httptest.NewRecorder()
svc.nestedGet(rec, req.WithContext(ctx))
return rec
}
func filterOptionsLang() fstest.MapFS {
return fstest.MapFS{
"lang/pl/lang.yaml": {Data: []byte("group_four: Grupa cztery\n")},
"lang/en/lang.yaml": {Data: []byte("group_four: Group four\n")},
}
}
func TestPhase10FilterOptions(t *testing.T) {
app := backpack.New(nil)
demo := langPlugin{id: "demo", lang: filterOptionsLang()}
if err := phrasebook.Activate(app, []langPlugin{demo}); err != nil {
t.Fatal(err)
}
tr, _ := app.Lookup[*phrasebook.Translator]()
svc := filterOptionsService(t, &filterRow{}, []string{"acme.demo.access"}, tr)
granted := &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"acme.demo.access": true}}
rec := filterOptionsCall(svc, "grouped", "pl", granted)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []FilterOption `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatal(err)
}
if body.Meta.Locale != "pl" || len(body.Data) != 2 || body.Data[0] != (FilterOption{Value: "4", Label: "Grupa cztery"}) || body.Data[1] != (FilterOption{Value: "9", Label: "Group nine"}) {
t.Fatalf("options=%+v locale=%q", body.Data, body.Meta.Locale)
}
if en := filterOptionsCall(svc, "grouped", "en", granted); !strings.Contains(en.Body.String(), `"label":"Group four"`) {
t.Fatalf("en labels=%s", en.Body.String())
}
for _, scope := range []string{"activated", "missing", "filterByGroup", "Grouped"} {
rec := filterOptionsCall(svc, scope, "", granted)
if rec.Code != http.StatusNotFound {
t.Fatalf("%s status=%d body=%s", scope, rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "not_found")
}
locked := filterOptionsService(t, &panicOptionsRow{}, []string{"acme.demo.access"}, tr)
denied := filterOptionsCall(locked, "grouped", "", &bouncer.Principal{ID: 2})
if denied.Code != http.StatusForbidden {
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
}
assertErrorCode(t, denied.Body.Bytes(), "forbidden")
anonymous := filterOptionsCall(locked, "grouped", "", nil)
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("anonymous status=%d body=%s", anonymous.Code, anonymous.Body.String())
}
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &scopeOnlyRow{}}
_, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, filterOptionsScopes))
if err == nil || !strings.Contains(err.Error(), "grouped") || !strings.Contains(err.Error(), "FilterOptions") || !strings.Contains(err.Error(), "D-27") {
t.Fatalf("scope filter without FilterOptions err=%v", err)
}
}

View File

@@ -0,0 +1,356 @@
package cabana
import (
"fmt"
"io/fs"
"net/http"
"path"
"reflect"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
)
var filterScopeKeys = map[string]struct{}{
"label": {}, "type": {}, "column": {}, "options": {},
"modelClass": {}, "nameFrom": {}, "scope": {},
}
type filterDocument struct {
Scopes scopeMap `yaml:"scopes"`
}
type scopeMap struct {
items []ListFilter
}
func compileFilters(pluginID string, ctl pact.AdminController, fsys fs.FS, dir, ref string) ([]ListFilter, error) {
file, err := filterAssetPath(pluginID, dir, ref)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), "config_filter.yaml", err)
}
raw, err := readAsset(fsys, file)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
var doc filterDocument
if err := decodeStrict(raw, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
filters := doc.Scopes.items
if filters == nil {
filters = []ListFilter{}
}
provider := filterProvider(ctl)
for _, filter := range filters {
if err := validateFilter(ctl, provider, filter); err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
}
return filters, nil
}
func filterAssetPath(pluginID, dir, ref string) (string, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", fmt.Errorf("filter file is empty")
}
if strings.Contains(ref, "..") {
return "", fmt.Errorf("filter path escapes the plugin")
}
if strings.HasPrefix(ref, "~/") || strings.Contains(ref, "/") {
return assetPath(pluginID, ref)
}
return path.Join(dir, ref), nil
}
func (m *scopeMap) UnmarshalYAML(node ast.Node) error {
node = unwrapNode(node)
if _, ok := node.(*ast.NullNode); ok || node == nil {
m.items = []ListFilter{}
return nil
}
mapping, ok := node.(*ast.MappingNode)
if !ok {
return fmt.Errorf("scopes must be a mapping")
}
items := make([]ListFilter, 0, len(mapping.Values))
seen := map[string]struct{}{}
for _, entry := range mapping.Values {
name, err := nodeString(unwrapNode(entry.Key))
if err != nil || !identifier(name) {
return fmt.Errorf("filter name %q is not an identifier", nodeText(entry.Key))
}
if _, dup := seen[name]; dup {
return fmt.Errorf("duplicate filter %s", name)
}
seen[name] = struct{}{}
filter, err := compileScopeNode(name, unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("filter %s: %w", name, err)
}
items = append(items, filter)
}
m.items = items
return nil
}
func compileScopeNode(name string, node ast.Node) (ListFilter, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return ListFilter{}, fmt.Errorf("must be a mapping")
}
values := map[string]ast.Node{}
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return ListFilter{}, err
}
if key == "conditions" {
return ListFilter{}, fmt.Errorf("conditions is not supported; use a column or a registered model scope")
}
if _, known := filterScopeKeys[key]; !known {
return ListFilter{}, fmt.Errorf("unknown field %s", key)
}
if _, dup := values[key]; dup {
return ListFilter{}, fmt.Errorf("duplicate field %s", key)
}
values[key] = unwrapNode(entry.Value)
}
filter := ListFilter{Name: name}
var err error
if node, ok := values["label"]; ok {
filter.Label, err = nodeString(node)
if err != nil {
return ListFilter{}, fmt.Errorf("label: %w", err)
}
}
if node, ok := values["type"]; ok {
filter.Type, err = nodeString(node)
if err != nil {
return ListFilter{}, fmt.Errorf("type: %w", err)
}
}
if node, ok := values["column"]; ok {
filter.Column, err = nodeString(node)
if err != nil || !identifier(filter.Column) {
return ListFilter{}, fmt.Errorf("column %q is not an identifier", filter.Column)
}
}
if node, ok := values["modelClass"]; ok {
filter.ModelClass, err = nodeString(node)
if err != nil || strings.TrimSpace(filter.ModelClass) == "" {
return ListFilter{}, fmt.Errorf("modelClass is empty")
}
}
if node, ok := values["nameFrom"]; ok {
filter.NameFrom, err = nodeString(node)
if err != nil || !identifier(filter.NameFrom) {
return ListFilter{}, fmt.Errorf("nameFrom %q is not an identifier", filter.NameFrom)
}
}
if node, ok := values["scope"]; ok {
filter.Scope, err = nodeString(node)
if err != nil || !identifier(filter.Scope) {
return ListFilter{}, fmt.Errorf("scope %q is not an identifier", filter.Scope)
}
}
if node, ok := values["options"]; ok {
filter.Options, err = compileFilterOptions(node)
if err != nil {
return ListFilter{}, fmt.Errorf("options: %w", err)
}
}
if filter.Type == "" && filter.Scope != "" {
filter.Type = "scope"
}
switch filter.Type {
case "switch":
if filter.Column == "" {
return ListFilter{}, fmt.Errorf("column is required")
}
if len(filter.Options) == 0 {
on := jsonScalar{raw: []byte("true")}
off := jsonScalar{raw: []byte("false")}
filter.TrueValue = &on
filter.FalseValue = &off
}
case "daterange":
if filter.Column == "" {
return ListFilter{}, fmt.Errorf("column is required")
}
case "scope":
if filter.Scope == "" || filter.ModelClass == "" || filter.NameFrom == "" {
return ListFilter{}, fmt.Errorf("scope, modelClass, and nameFrom are required")
}
default:
if filter.Type == "" {
return ListFilter{}, fmt.Errorf("type is required")
}
return ListFilter{}, fmt.Errorf("unsupported filter type %s", filter.Type)
}
return filter, nil
}
func compileFilterOptions(node ast.Node) ([]FormOption, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return nil, fmt.Errorf("must be a map")
}
options := make([]FormOption, 0, len(mapping.Values))
for _, entry := range mapping.Values {
value, err := nodeScalar(unwrapNode(entry.Key))
if err != nil {
return nil, fmt.Errorf("value: %w", err)
}
label, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return nil, fmt.Errorf("label: %w", err)
}
options = append(options, FormOption{Value: value, Label: label})
}
return options, nil
}
func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter ListFilter) error {
switch filter.Type {
case "switch", "daterange":
if _, _, check := listModelContract(ctl); check {
ft, ok := modelColumnType(ctl, filter.Column)
if !ok {
return fmt.Errorf("column %s is not on the model", filter.Column)
}
if filter.Type == "switch" && !isBoolType(ft) {
return fmt.Errorf("column %s is not a boolean", filter.Column)
}
if filter.Type == "daterange" && !isTimeType(ft) {
return fmt.Errorf("column %s is not a date", filter.Column)
}
}
case "scope":
if provider == nil {
return fmt.Errorf("scope %s requires FilterScope", filter.Scope)
}
registered := false
for _, name := range provider.FilterScopes() {
if name == filter.Scope {
registered = true
break
}
}
if !registered {
return fmt.Errorf("scope %s is not registered", filter.Scope)
}
if _, ok := provider.(pact.FilterOptions); !ok {
return fmt.Errorf("scope filter %s needs FilterOptions on the model to serve its choices (D-27)", filter.Name)
}
}
return nil
}
// FilterOption is one model-backed filter choice (D-27). Values stay the
// strings the model returns; they are sent back as filter[<name>].
type FilterOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// filterOptions serves GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
// for a declared scope filter of the controller's list. {scope} is the filter
// name (the filter[<name>] key); the model receives the filter's scope method.
func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("scope")
var filter *ListFilter
if cc.List != nil && identifier(name) {
for i := range cc.List.Filters {
if cc.List.Filters[i].Name == name && cc.List.Filters[i].Type == "scope" {
filter = &cc.List.Filters[i]
break
}
}
}
if filter == nil {
writeNotFound(w, r)
return
}
provider, ok := filterProvider(cc.Controller).(pact.FilterOptions)
if !ok || provider == nil {
writeNotFound(w, r)
return
}
tr := s.translator()
locale := schemaLocale(r.Context(), tr)
ctx := towel.WithLocale(r.Context(), locale)
provided := provider.FilterOptions(filter.Scope)
out := make([]FilterOption, 0, len(provided))
for _, opt := range provided {
out = append(out, FilterOption{Value: opt.Value, Label: translateKey(ctx, tr, opt.Label)})
}
WriteData(w, http.StatusOK, out, map[string]any{"locale": locale})
})
}
func filterProvider(ctl pact.AdminController) pact.FilterScope {
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
rec := src.NewRecord()
provider, ok := rec.(pact.FilterScope)
if !ok || provider == nil {
return nil
}
return provider
}
func modelColumnType(ctl pact.AdminController, column string) (reflect.Type, bool) {
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil, false
}
t := reflect.TypeOf(src.NewRecord())
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return nil, false
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" || isListRelation(field.Type) {
continue
}
name := gormColumn(field)
if name == "" {
name = field.Name
}
if name == column {
return field.Type, true
}
}
return nil, false
}
func isTimeType(t reflect.Type) bool {
if t == nil {
return false
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t == reflect.TypeOf(time.Time{})
}
func isBoolType(t reflect.Type) bool {
if t == nil {
return false
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t.Kind() == reflect.Bool
}

View File

@@ -0,0 +1,632 @@
package cabana
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"context"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
)
var (
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
}
formSizes = map[string]struct{}{
"tiny": {}, "small": {}, "large": {}, "huge": {}, "giant": {},
}
formFieldKeys = map[string]struct{}{
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
}
)
type formConfigDocument struct {
Name string `yaml:"name"`
Form string `yaml:"form"`
ModelClass string `yaml:"modelClass"`
DefaultRedirect string `yaml:"defaultRedirect"`
Create *formRedirects `yaml:"create"`
Update *formRedirects `yaml:"update"`
Messages *formMessageKeys `yaml:"messages"`
}
type formRedirects struct {
Redirect string `yaml:"redirect"`
RedirectClose string `yaml:"redirectClose"`
}
type formFieldsFile struct {
Fields fieldMap `yaml:"fields"`
}
type fieldMap struct {
items []FormField
}
// CompileForm compiles config_form.yaml and the model fields.yaml it names.
// The returned schema is safe to cache: labels stay as source keys.
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
if ctl == nil {
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
}
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
}
cfgPath := dir + "/config_form.yaml"
raw, err := readAsset(fsys, cfgPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
var doc formConfigDocument
if err := decodeStrict(raw, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
if doc.ModelClass != ctl.ModelName() {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
if strings.TrimSpace(doc.Form) == "" {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("form file is empty"))
}
fieldsPath, err := assetPath(pluginID, doc.Form)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
fieldsRaw, err := readAsset(fsys, fieldsPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
fields, err := decodeFields(fieldsRaw)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
if fields == nil {
fields = []FormField{}
}
if err := requireDropdownProvider(ctl, fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
schema := &FormSchema{
Name: doc.Name,
ModelClass: doc.ModelClass,
Fields: fields,
redirects: FormRedirects{Default: doc.DefaultRedirect},
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
if doc.Create != nil {
schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose}
}
if doc.Update != nil {
schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose}
}
return schema, nil
}
// Localize copies the cached schema and resolves display strings for one request.
// It does not mutate s. Option values keep the scalar captured at boot.
func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) {
if s == nil {
return nil, fmt.Errorf("cabana: nil form schema")
}
if ctx == nil {
ctx = context.Background()
}
locale := schemaLocale(ctx, tr)
ctx = towel.WithLocale(ctx, locale)
fields := make([]FormField, len(s.Fields))
for i, src := range s.Fields {
field := src
field.Label = translateKey(ctx, tr, src.Label)
field.Comment = translateKey(ctx, tr, src.Comment)
field.Tab = translateKey(ctx, tr, src.Tab)
field.EmptyOption = translateKey(ctx, tr, src.EmptyOption)
options, err := localizeOptions(ctx, tr, src, provider)
if err != nil {
return nil, err
}
field.Options = options
fields[i] = field
}
if fields == nil {
fields = []FormField{}
}
return &FormView{
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,
Meta: FormMeta{Locale: locale},
}, nil
}
// CanonicalJSON is one localized schema response.
func (v *FormView) CanonicalJSON() ([]byte, error) {
if v == nil {
return nil, fmt.Errorf("cabana: nil form view")
}
out := *v
if out.Fields == nil {
out.Fields = []FormField{}
}
return json.Marshal(out)
}
// CanonicalJSON is the stable, locale-neutral form contract.
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
if s == nil {
return nil, fmt.Errorf("cabana: nil form schema")
}
out := *s
if out.Fields == nil {
out.Fields = []FormField{}
}
return json.Marshal(out)
}
func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error {
var method string
for _, field := range fields {
if field.optionsMethod != "" {
method = field.optionsMethod
break
}
}
if method == "" || dropdownProvider(ctl) != nil {
return nil
}
return fmt.Errorf("dropdown method %s requires DropdownOptions", method)
}
func dropdownProvider(ctl pact.AdminController) pact.DropdownOptionsProvider {
if ctl == nil {
return nil
}
if p, ok := ctl.(pact.DropdownOptionsProvider); ok && p != nil {
return p
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
rec := src.NewRecord()
p, ok := rec.(pact.DropdownOptionsProvider)
if !ok || p == nil {
return nil
}
return p
}
func schemaLocale(ctx context.Context, tr *phrasebook.Translator) string {
if loc, ok := towel.Locale(ctx); ok {
if primary := primaryLanguage(loc); primary != "" {
return primary
}
}
if tr != nil {
if loc := strings.TrimSpace(tr.Locale()); loc != "" {
return loc
}
}
return "en"
}
func primaryLanguage(header string) string {
header = strings.TrimSpace(header)
if header == "" {
return ""
}
if i := strings.IndexAny(header, ",;"); i >= 0 {
header = strings.TrimSpace(header[:i])
}
return header
}
func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string {
if key == "" || tr == nil {
return key
}
return tr.Get(ctx, key, nil)
}
func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider) ([]FormOption, error) {
if src.optionsMethod != "" {
if provider == nil {
return nil, fmt.Errorf("cabana: dropdown method %s requires DropdownOptions", src.optionsMethod)
}
provided := provider.DropdownOptions(src.Name)
opts := make([]FormOption, len(provided))
for i, opt := range provided {
raw, err := json.Marshal(opt.Value)
if err != nil {
return nil, err
}
opts[i] = FormOption{Value: jsonScalar{raw: raw}, Label: translateKey(ctx, tr, opt.Label)}
}
return opts, nil
}
if len(src.Options) == 0 {
return nil, nil
}
opts := make([]FormOption, len(src.Options))
for i, opt := range src.Options {
opt.Label = translateKey(ctx, tr, opt.Label)
opts[i] = opt
}
return opts, nil
}
func decodeFields(raw []byte) ([]FormField, error) {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
var doc formFieldsFile
if err := dec.Decode(&doc); err != nil {
return nil, normalizeYAMLError(err)
}
if doc.Fields.items == nil {
return []FormField{}, nil
}
return doc.Fields.items, nil
}
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
node = unwrapNode(node)
if _, ok := node.(*ast.NullNode); ok || node == nil {
m.items = []FormField{}
return nil
}
mapping, ok := node.(*ast.MappingNode)
if !ok {
return fmt.Errorf("fields must be a mapping")
}
items := make([]FormField, 0, len(mapping.Values))
seen := map[string]struct{}{}
for _, entry := range mapping.Values {
name, err := nodeString(unwrapNode(entry.Key))
if err != nil || !identifier(name) {
return fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
}
if _, dup := seen[name]; dup {
return fmt.Errorf("duplicate field %s", name)
}
seen[name] = struct{}{}
field, err := compileFieldNode(name, unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("field %s: %w", name, err)
}
items = append(items, field)
}
m.items = items
return nil
}
func compileFieldNode(name string, node ast.Node) (FormField, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return FormField{}, fmt.Errorf("must be a mapping")
}
values := map[string]ast.Node{}
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return FormField{}, err
}
if _, known := formFieldKeys[key]; !known {
return FormField{}, fmt.Errorf("unknown field %s", key)
}
if _, dup := values[key]; dup {
return FormField{}, fmt.Errorf("duplicate field %s", key)
}
values[key] = unwrapNode(entry.Value)
}
typ, err := nodeString(values["type"])
if err != nil || typ == "" {
return FormField{}, fmt.Errorf("type is required")
}
if typ == "partial" {
return FormField{}, fmt.Errorf("type partial is not supported")
}
if _, ok := formFieldTypes[typ]; !ok {
return FormField{}, fmt.Errorf("unsupported type %s", typ)
}
field := FormField{Name: name, Type: typ}
if node, ok := values["label"]; ok {
field.Label, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("label: %w", err)
}
}
if node, ok := values["comment"]; ok {
field.Comment, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("comment: %w", err)
}
}
if node, ok := values["span"]; ok {
field.Span, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("span: %w", err)
}
if _, ok := formSpans[field.Span]; !ok {
return FormField{}, fmt.Errorf("unsupported span %s", field.Span)
}
}
if node, ok := values["tab"]; ok {
field.Tab, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("tab: %w", err)
}
}
if node, ok := values["size"]; ok {
field.Size, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("size: %w", err)
}
if _, ok := formSizes[field.Size]; !ok {
return FormField{}, fmt.Errorf("unsupported size %s", field.Size)
}
}
if node, ok := values["context"]; ok {
ctx, err := compileContext(node)
if err != nil {
return FormField{}, err
}
field.Context = ctx
}
if node, ok := values["nameFrom"]; ok {
field.NameFrom, err = nodeString(node)
if err != nil || !identifier(field.NameFrom) {
return FormField{}, fmt.Errorf("nameFrom %q is not an identifier", field.NameFrom)
}
}
if node, ok := values["emptyOption"]; ok {
field.EmptyOption, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("emptyOption: %w", err)
}
}
if node, ok := values["relation"]; ok {
field.Relation, err = nodeString(node)
if err != nil || !identifier(field.Relation) {
return FormField{}, fmt.Errorf("relation %q is not an identifier", field.Relation)
}
}
if typ == "relation-manager" && field.Relation == "" {
return FormField{}, fmt.Errorf("relation is required")
}
if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node)
if err != nil {
return FormField{}, fmt.Errorf("required: %w", err)
}
}
if node, ok := values["default"]; ok {
scalar, err := nodeScalar(node)
if err != nil {
return FormField{}, fmt.Errorf("default: %w", err)
}
field.Default = &scalar
}
if node, ok := values["attributes"]; ok {
attrs, err := compileAttributes(node)
if err != nil {
return FormField{}, fmt.Errorf("attributes: %w", err)
}
field.Attributes = attrs
}
if node, ok := values["options"]; ok {
if err := compileOptions(node, &field); err != nil {
return FormField{}, fmt.Errorf("options: %w", err)
}
}
return field, nil
}
func compileContext(node ast.Node) (*fieldContext, error) {
switch n := node.(type) {
case *ast.StringNode:
if !identifier(n.Value) {
return nil, fmt.Errorf("context %q is not an identifier", n.Value)
}
return &fieldContext{single: true, values: []string{n.Value}}, nil
case *ast.SequenceNode:
values := sequenceValues(n)
out := make([]string, 0, len(values))
for _, item := range values {
text, err := nodeString(unwrapNode(item))
if err != nil || !identifier(text) {
return nil, fmt.Errorf("context %q is not an identifier", nodeText(item))
}
out = append(out, text)
}
return &fieldContext{values: out}, nil
default:
return nil, fmt.Errorf("context must be a string or a list")
}
}
func compileAttributes(node ast.Node) (map[string]jsonScalar, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return nil, fmt.Errorf("must be a mapping")
}
out := make(map[string]jsonScalar, len(mapping.Values))
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil || !identifier(key) {
return nil, fmt.Errorf("key %q is not an identifier", nodeText(entry.Key))
}
scalar, err := nodeScalar(unwrapNode(entry.Value))
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
out[key] = scalar
}
return out, nil
}
func compileOptions(node ast.Node, field *FormField) error {
switch n := node.(type) {
case *ast.StringNode:
if !identifier(n.Value) {
return fmt.Errorf("method %q is not an identifier", n.Value)
}
field.optionsMethod = n.Value
return nil
case *ast.MappingNode:
options := make([]FormOption, 0, len(n.Values))
for _, entry := range n.Values {
value, err := nodeScalar(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("value: %w", err)
}
label, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("label: %w", err)
}
options = append(options, FormOption{Value: value, Label: label})
}
field.Options = options
return nil
default:
return fmt.Errorf("must be a map or a method name")
}
}
func sequenceValues(n *ast.SequenceNode) []ast.Node {
if len(n.Values) > 0 {
return n.Values
}
out := make([]ast.Node, 0, len(n.Entries))
for _, entry := range n.Entries {
if entry != nil && entry.Value != nil {
out = append(out, entry.Value)
}
}
return out
}
func unwrapNode(node ast.Node) ast.Node {
for node != nil {
switch n := node.(type) {
case *ast.MappingKeyNode:
node = n.Value
case *ast.TagNode:
node = n.Value
case *ast.AnchorNode:
node = n.Value
default:
return node
}
}
return nil
}
func nodeString(node ast.Node) (string, error) {
switch n := unwrapNode(node).(type) {
case *ast.StringNode:
return n.Value, nil
case nil:
return "", fmt.Errorf("missing string")
default:
return "", fmt.Errorf("want a string, got %T", n)
}
}
func nodeBool(node ast.Node) (bool, error) {
n, ok := unwrapNode(node).(*ast.BoolNode)
if !ok {
return false, fmt.Errorf("want a boolean")
}
return n.Value, nil
}
func nodeScalar(node ast.Node) (jsonScalar, error) {
switch n := unwrapNode(node).(type) {
case *ast.StringNode:
raw, err := json.Marshal(n.Value)
return jsonScalar{raw: raw}, err
case *ast.BoolNode:
if n.Value {
return jsonScalar{raw: []byte("true")}, nil
}
return jsonScalar{raw: []byte("false")}, nil
case *ast.IntegerNode:
return integerScalar(n.Value)
case *ast.FloatNode:
if n.Value == float64(int64(n.Value)) {
return jsonScalar{raw: []byte(strconv.FormatInt(int64(n.Value), 10))}, nil
}
raw, err := json.Marshal(n.Value)
return jsonScalar{raw: raw}, err
default:
return jsonScalar{}, fmt.Errorf("want a scalar, got %T", n)
}
}
func integerScalar(v any) (jsonScalar, error) {
switch n := v.(type) {
case int64:
return jsonScalar{raw: []byte(strconv.FormatInt(n, 10))}, nil
case uint64:
return jsonScalar{raw: []byte(strconv.FormatUint(n, 10))}, nil
case int:
return jsonScalar{raw: []byte(strconv.Itoa(n))}, nil
default:
return jsonScalar{}, fmt.Errorf("want an integer, got %T", v)
}
}
func nodeText(node ast.Node) string {
if node == nil {
return ""
}
if token := node.GetToken(); token != nil {
return token.Value
}
return ""
}
func normalizeYAMLError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
if strings.Contains(msg, "already defined") {
return fmt.Errorf("duplicate field: %w", err)
}
return err
}
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
}
cfgPath := path.Join(dir, "config_form.yaml")
if _, err := fs.Stat(fsys, cfgPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
return CompileForm(pluginID, ctl, fsys)
}

View File

@@ -0,0 +1,566 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"io/fs"
"strings"
"sync"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
const formConfig = `name: demo.form
form: ~/plugins/acme/demo/models/widget/fields.yaml
modelClass: Widget
defaultRedirect: acme/demo/widgets
create:
redirect: acme/demo/widgets/update/:id
redirectClose: acme/demo/widgets
update:
redirect: acme/demo/widgets
redirectClose: acme/demo/widgets
`
const allFieldsYAML = `fields:
title:
label: demo.lang.title
comment: demo.lang.title_comment
span: left
type: text
required: true
tab: demo.lang.main
context: create
default: Untitled
body:
label: demo.lang.body
span: full
type: textarea
size: small
count:
label: demo.lang.count
span: right
type: number
default: 3
attributes:
min: 0
readonly: false
step: 1
active:
label: demo.lang.active
span: left
type: checkbox
default: false
enabled:
label: demo.lang.enabled
type: switch
default: 0
format:
label: demo.lang.format
type: dropdown
required: true
emptyOption: demo.lang.none
options:
vinyl: Vinyl
2: Two
true: "Yes"
artist:
label: demo.lang.artist
span: right
type: relation
nameFrom: name
emptyOption: demo.lang.pick
editors:
label: demo.lang.editors
type: relation-manager
relation: editors
tab: demo.lang.editors_tab
span: full
context:
- update
- create
`
const allFieldsJSON = `{"name":"demo.form","modelClass":"Widget","fields":[` +
`{"name":"title","type":"text","label":"demo.lang.title","comment":"demo.lang.title_comment","span":"left","tab":"demo.lang.main","context":"create","required":true,"default":"Untitled"},` +
`{"name":"body","type":"textarea","label":"demo.lang.body","span":"full","size":"small"},` +
`{"name":"count","type":"number","label":"demo.lang.count","span":"right","default":3,"attributes":{"min":0,"readonly":false,"step":1}},` +
`{"name":"active","type":"checkbox","label":"demo.lang.active","span":"left","default":false},` +
`{"name":"enabled","type":"switch","label":"demo.lang.enabled","default":0},` +
`{"name":"format","type":"dropdown","label":"demo.lang.format","emptyOption":"demo.lang.none","required":true,"options":[{"value":"vinyl","label":"Vinyl"},{"value":2,"label":"Two"},{"value":true,"label":"Yes"}]},` +
`{"name":"artist","type":"relation","label":"demo.lang.artist","span":"right","nameFrom":"name","emptyOption":"demo.lang.pick"},` +
`{"name":"editors","type":"relation-manager","label":"demo.lang.editors","span":"full","tab":"demo.lang.editors_tab","context":["update","create"],"relation":"editors"}]}`
func TestFormSchemaCompile(t *testing.T) {
got := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML)
if got != allFieldsJSON {
t.Fatalf("compiled form =\n%s\nwant\n%s", got, allFieldsJSON)
}
again := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML)
if again != got {
t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again)
}
for _, want := range []string{`"default":false`, `"default":0`, `"value":2`, `"value":true`, `"value":"vinyl"`} {
if !strings.Contains(got, want) {
t.Fatalf("compiled form missing scalar %s:\n%s", want, got)
}
}
if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) || strings.Contains(got, `"fields":null`) {
t.Fatalf("scalar type or null fields drifted:\n%s", got)
}
t.Run("list-only controller still activates", func(t *testing.T) {
_, err := compileRegistry([]controllerRef{{
plugin: formPlugin{fsys: fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
}},
ctl: schemaController{model: "Widget"},
}})
if err != nil {
t.Fatalf("list-only registry: %v", err)
}
})
t.Run("field name case is exact", func(t *testing.T) {
raw := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, "fields:\n Name:\n label: Name\n type: text\n")
if !strings.Contains(raw, `"name":"Name"`) || strings.Contains(raw, `"name":"name"`) {
t.Fatalf("field name was folded: %s", raw)
}
})
}
func TestFormSchemaEmpty(t *testing.T) {
cfg := strings.Replace(formConfig, "name: demo.form", "name: empty", 1)
got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields: {}\n")
if got != `{"name":"empty","modelClass":"Widget","fields":[]}` {
t.Fatalf("empty form = %s", got)
}
if strings.Contains(got, `"fields":null`) {
t.Fatal("empty fields serialized as null")
}
}
func TestFormSchemaSingle(t *testing.T) {
cfg := strings.Replace(formConfig, "name: demo.form", "name: one", 1)
got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields:\n title:\n label: Title\n type: text\n")
want := `{"name":"one","modelClass":"Widget","fields":[{"name":"title","type":"text","label":"Title"}]}`
if got != want {
t.Fatalf("single form =\n%s\nwant\n%s", got, want)
}
}
func TestFormSchemaOrdering(t *testing.T) {
fields := `fields:
zeta:
label: Z
type: text
alpha:
label: A
type: text
mid:
label: M
type: number
`
first := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields)
second := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields)
if first != second {
t.Fatalf("order changed between compiles:\n%s\n%s", first, second)
}
zeta := strings.Index(first, `"name":"zeta"`)
alpha := strings.Index(first, `"name":"alpha"`)
mid := strings.Index(first, `"name":"mid"`)
if zeta < 0 || alpha < 0 || mid < 0 || !(zeta < alpha && alpha < mid) {
t.Fatalf("source order lost: %s", first)
}
}
func TestFormSchemaRejects(t *testing.T) {
cases := []struct {
name string
model string
config string
fields string
want []string
}{
{
name: "unknown key",
config: formConfig,
fields: "fields:\n title:\n type: text\n cssClass: bad\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "cssClass"},
},
{
name: "unknown type",
config: formConfig,
fields: "fields:\n title:\n type: widget\n label: Title\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "widget"},
},
{
name: "duplicate name",
config: formConfig,
fields: "fields:\n title:\n type: text\n label: A\n title:\n type: text\n label: B\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "duplicate"},
},
{
name: "path escape",
config: strings.Replace(formConfig, "form: ~/plugins/acme/demo/models/widget/fields.yaml", "form: ../../etc/passwd", 1),
fields: "fields: {}\n",
want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "escape"},
},
{
name: "mismatched modelClass",
model: "Widget",
config: strings.Replace(formConfig, "modelClass: Widget", "modelClass: widget", 1),
fields: "fields: {}\n",
want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "modelClass"},
},
{
name: "partial",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n tab: Editors\n span: full\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial"},
},
{
name: "partial path",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n path: $/golem15/acme/controllers/collections/_editors.htm\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"},
},
{
name: "missing fields",
config: strings.Replace(formConfig, "models/widget/fields.yaml", "models/widget/missing.yaml", 1),
fields: "fields: {}\n",
want: []string{"acme.demo", "acme.demo.widgets", "missing.yaml"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
model := tc.model
if model == "" {
model = "Widget"
}
_, err := CompileForm("acme.demo", schemaController{model: model}, formFS(tc.config, tc.fields))
if err == nil {
t.Fatal("expected boot error")
}
msg := err.Error()
for _, want := range tc.want {
if want == "duplicate" {
if strings.Contains(msg, "duplicate") || strings.Contains(msg, "already defined") {
continue
}
t.Fatalf("error %q missing duplicate", msg)
}
if want == "escape" {
if strings.Contains(msg, "escape") || strings.Contains(msg, "..") {
continue
}
t.Fatalf("error %q missing escape", msg)
}
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
})
}
t.Run("bad form fails activation", func(t *testing.T) {
fsys := formFS(formConfig, "fields:\n editors:\n type: partial\n tab: Editors\n")
fsys = withList(fsys)
_, err := compileRegistry([]controllerRef{{
plugin: &formPlugin{fsys: fsys},
ctl: schemaController{model: "Widget"},
}})
if err == nil || !strings.Contains(err.Error(), "partial") {
t.Fatalf("activation err = %v", err)
}
})
}
type schemaController struct{ model string }
func (c schemaController) ID() string { return "acme.demo.widgets" }
func (c schemaController) ModelName() string {
if c.model == "" {
return "Widget"
}
return c.model
}
func (c schemaController) ConfigDir() string { return "controllers/widgets" }
type formPlugin struct{ fsys fs.FS }
func (p formPlugin) ID() string { return "acme.demo" }
func (p formPlugin) Requires() []string { return nil }
func (p formPlugin) Register(*backpack.App) error { return nil }
func (p formPlugin) Boot(*backpack.App) error { return nil }
func (p formPlugin) AdminFS() fs.FS { return p.fsys }
func formFS(config, fields string) fs.FS {
return fstest.MapFS{
"controllers/widgets/config_form.yaml": &fstest.MapFile{Data: []byte(config)},
"models/widget/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
}
func withList(fsys fs.FS) fs.FS {
base, ok := fsys.(fstest.MapFS)
if !ok {
return fsys
}
base["controllers/widgets/config_list.yaml"] = &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")}
base["models/widget/columns.yaml"] = &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")}
return base
}
func compileFormJSON(t *testing.T, ctl schemaController, config, fields string) string {
t.Helper()
schema, err := CompileForm("acme.demo", ctl, formFS(config, fields))
if err != nil {
t.Fatalf("compile: %v", err)
}
raw, err := schema.CanonicalJSON()
if err != nil {
t.Fatalf("json: %v", err)
}
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
t.Fatalf("compact: %v\n%s", err, raw)
}
return buf.String()
}
func TestFormSchemaLocalization(t *testing.T) {
schema := mustCompileForm(t, schemaController{model: "Widget"}, strings.Replace(formConfig, "name: demo.form", "name: acme.demo::lang.form", 1), localizedFieldsYAML)
before := mustCanonical(t, schema)
en := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "en", Fallback: "en"})
pl := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"})
enJSON := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), en, nil)
plJSON := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), pl, nil)
if enJSON == plJSON {
t.Fatalf("locales collapsed:\n%s", enJSON)
}
for _, want := range []string{`"locale":"en"`, `"label":"Title"`, `"comment":"Comment"`, `"tab":"Tab"`, `"emptyOption":"None"`, `"name":"Form"`} {
if !strings.Contains(enJSON, want) {
t.Fatalf("en JSON missing %s:\n%s", want, enJSON)
}
}
for _, want := range []string{`"locale":"pl"`, `"label":"Tytuł"`, `"comment":"Komentarz"`, `"tab":"Zakładka"`, `"emptyOption":"Brak"`, `"name":"Formularz"`} {
if !strings.Contains(plJSON, want) {
t.Fatalf("pl JSON missing %s:\n%s", want, plJSON)
}
}
if !strings.Contains(enJSON, `"label":"missing.label"`) || !strings.Contains(plJSON, `"label":"missing.label"`) {
t.Fatalf("raw key fallback lost:\nen %s\npl %s", enJSON, plJSON)
}
after := mustCanonical(t, schema)
if before != after {
t.Fatalf("localization mutated the cached schema:\n%s\n%s", before, after)
}
t.Run("parent and app locale", func(t *testing.T) {
app := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"})
parent := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl-PL,en;q=0.5"), app, nil)
if !strings.Contains(parent, `"locale":"pl-PL"`) || !strings.Contains(parent, `"label":"Tytuł"`) {
t.Fatalf("Accept-Language parent fallback = %s", parent)
}
fallback := localizeJSON(t, schema, context.Background(), app, nil)
if !strings.Contains(fallback, `"locale":"pl"`) || !strings.Contains(fallback, `"name":"Formularz"`) {
t.Fatalf("app.locale fallback = %s", fallback)
}
})
}
func TestFormSchemaDropdownOptions(t *testing.T) {
schema := mustCompileForm(t, schemaController{model: "Widget"}, formConfig, optionMapYAML)
tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"})
got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil)
vinyl := strings.Index(got, `{"value":"vinyl","label":"Stal"}`)
two := strings.Index(got, `{"value":2,"label":"Dwa"}`)
yes := strings.Index(got, `{"value":true,"label":"Yes"}`)
if vinyl < 0 || two < 0 || yes < 0 || !(vinyl < two && two < yes) {
t.Fatalf("option order or scalar type drifted: %s", got)
}
if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) {
t.Fatalf("option values became strings: %s", got)
}
methodSchema := mustCompileForm(t, providerController{schemaController: schemaController{model: "Widget"}}, formConfig, methodFieldsYAML)
methodJSON := localizeJSON(t, methodSchema, towel.WithLocale(context.Background(), "pl"), tr, providerController{})
if !strings.Contains(methodJSON, `{"value":"vinyl","label":"Stal"}`) || !strings.Contains(methodJSON, `{"value":"cd","label":"Drewno"}`) {
t.Fatalf("method options = %s", methodJSON)
}
if strings.Contains(methodJSON, "getFormatOptions") {
t.Fatalf("method name leaked into the response: %s", methodJSON)
}
source := mustCanonical(t, methodSchema)
if strings.Contains(source, "getFormatOptions") {
t.Fatalf("cached schema stored the method name in JSON: %s", source)
}
modelSchema := mustCompileForm(t, sourceController{schemaController: schemaController{model: "Widget"}, rec: modelOptions{}}, formConfig, "fields:\n format:\n type: dropdown\n options: getFormatOptions\n")
modelJSON := localizeJSON(t, modelSchema, towel.WithLocale(context.Background(), "pl"), tr, modelOptions{})
if !strings.Contains(modelJSON, `{"value":"lp","label":"Stal"}`) {
t.Fatalf("model provider options = %s", modelJSON)
}
for _, method := range []string{"getFormatOptions", "GetFormatOptions"} {
_, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n format:\n type: dropdown\n options: "+method+"\n"))
if err == nil || !strings.Contains(err.Error(), method) || !strings.Contains(err.Error(), "acme.demo.widgets") || !strings.Contains(err.Error(), "models/widget/fields.yaml") {
t.Fatalf("method %s err = %v", method, err)
}
}
}
func TestFormSchemaLocaleIsolation(t *testing.T) {
schema := mustCompileForm(t, schemaController{model: "Widget"}, strings.Replace(formConfig, "name: demo.form", "name: acme.demo::lang.form", 1), localizedFieldsYAML)
before := mustCanonical(t, schema)
tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "en", Fallback: "en"})
enWant := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), tr, nil)
plWant := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil)
var wg sync.WaitGroup
errCh := make(chan string, 32)
for i := 0; i < 16; i++ {
wg.Add(2)
go func() {
defer wg.Done()
got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), tr, nil)
if got != enWant {
errCh <- "en drifted: " + got
}
}()
go func() {
defer wg.Done()
got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil)
if got != plWant {
errCh <- "pl drifted: " + got
}
}()
}
wg.Wait()
close(errCh)
for msg := range errCh {
t.Fatal(msg)
}
if after := mustCanonical(t, schema); after != before {
t.Fatal("concurrent localization mutated the cache")
}
}
const localizedFieldsYAML = `fields:
title:
label: acme.demo::lang.title
comment: acme.demo::lang.comment
tab: acme.demo::lang.tab
emptyOption: acme.demo::lang.empty
type: dropdown
options:
vinyl: acme.demo::lang.vinyl
note:
label: missing.label
type: text
`
const optionMapYAML = `fields:
format:
label: acme.demo::lang.title
type: dropdown
emptyOption: acme.demo::lang.empty
options:
vinyl: acme.demo::lang.vinyl
2: acme.demo::lang.two
true: "Yes"
`
const methodFieldsYAML = `fields:
Format:
label: acme.demo::lang.title
type: dropdown
options: getFormatOptions
`
func formCatalog(t *testing.T) *phrasebook.Catalog {
t.Helper()
cat := phrasebook.NewCatalog()
err := cat.Load("acme.demo", fstest.MapFS{
"lang/en/lang.yaml": &fstest.MapFile{Data: []byte("form: Form\ntitle: Title\ncomment: Comment\ntab: Tab\nempty: None\nvinyl: Vinyl\ncd: CD\ntwo: Two\n")},
"lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("form: Formularz\ntitle: Tytuł\ncomment: Komentarz\ntab: Zakładka\nempty: Brak\nvinyl: Stal\ncd: Drewno\ntwo: Dwa\n")},
})
if err != nil {
t.Fatalf("catalog: %v", err)
}
return cat
}
func mustCompileForm(t *testing.T, ctl pact.AdminController, config, fields string) *FormSchema {
t.Helper()
schema, err := CompileForm("acme.demo", ctl, formFS(config, fields))
if err != nil {
t.Fatalf("compile: %v", err)
}
return schema
}
func mustCanonical(t *testing.T, schema *FormSchema) string {
t.Helper()
raw, err := schema.CanonicalJSON()
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func localizeJSON(t *testing.T, schema *FormSchema, ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) string {
t.Helper()
view, err := schema.Localize(ctx, tr, provider)
if err != nil {
t.Fatalf("localize: %v", err)
}
raw, err := view.CanonicalJSON()
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
t.Fatalf("compact: %v\n%s", err, raw)
}
return buf.String()
}
type providerController struct{ schemaController }
func (providerController) DropdownOptions(field string) []pact.Option {
if field != "Format" {
return nil
}
return []pact.Option{
{Value: "vinyl", Label: "acme.demo::lang.vinyl"},
{Value: "cd", Label: "acme.demo::lang.cd"},
}
}
type sourceController struct {
schemaController
rec any
}
func (s sourceController) NewRecord() any { return s.rec }
type modelOptions struct{}
func (modelOptions) DropdownOptions(field string) []pact.Option {
if field != "format" {
return nil
}
return []pact.Option{{Value: "lp", Label: "acme.demo::lang.vinyl"}}
}
var _ party.Plugin = formPlugin{}

778
modules/cabana/http.go Normal file
View File

@@ -0,0 +1,778 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/boardwalk"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// 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 {
app *backpack.App
reg *Registry
secret string
ttl time.Duration
refreshTTL time.Duration
grace time.Duration
bcryptCost int
loginMax int
loginDecay int
issuer string
bl bouncer.BlacklistStore
users bouncer.UserProvider // the backend guard's provider, reused by refresh
prefix string
spa http.Handler
// insecureCookie drops Secure from the admin cookie (backend.cookie_secure
// false, development only); the zero value keeps the cookie Secure.
insecureCookie bool
}
// 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
// admin.jwt.secret. No controllers means no admin routes and no secret check.
func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
items, err := collectControllers(plugins)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
if err := checkReservedSegments(items); err != nil {
return nil, err
}
secret, err := adminSecret(app)
if err != nil {
return nil, err
}
prefix, err := AdminPrefix(app)
if err != nil {
return nil, err
}
secureCookie, err := adminCookieSecure(app)
if err != nil {
return nil, err
}
reg, err := compileRegistry(items)
if err != nil {
return nil, err
}
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
if app == nil {
return nil, errors.New("cabana: app is nil")
}
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
if err := validateMessageKeys(reg, tr); err != nil {
return nil, err
}
}
guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil {
guards = bouncer.NewRegistry()
if err := app.Publish(guards); err != nil {
return nil, err
}
}
bl := adminBlacklist(app)
users := lazyBackendUsers{app: app, reg: reg}
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err
}
}
mw, err := guards.Middleware("backend")
if err != nil {
return nil, err
}
loginMax, loginDecay := adminLoginWindow(app)
svc := &service{
app: app,
reg: reg,
secret: secret,
ttl: adminTTL(app),
refreshTTL: adminRefreshTTL(app),
grace: adminGrace(app),
bcryptCost: adminBcryptCost(app),
loginMax: loginMax,
loginDecay: loginDecay,
issuer: adminIssuer(app, prefix),
bl: bl,
users: users,
prefix: prefix,
insecureCookie: !secureCookie,
}
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) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
}
type lazyBackendUsers struct {
app *backpack.App
reg *Registry
}
func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
if p.app == nil {
return nil, errors.New("cabana: database is not configured")
}
db, ok := p.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("cabana: database is not configured")
}
return (BackendUsers{DB: db, Registry: p.reg}).FindByID(ctx, id)
}
func (s *service) mount(r pact.Router) {
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
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", requireAjax(s.refresh))
})
// The string bundle is public: the login screen needs it before auth.
r.GroupRaw(api, nil, func(g pact.Router) {
g.Get("/lang", s.langBundle)
})
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)
g.Get("/settings/{code}/schema", s.settingsSchema)
constrainSetting(g)
g.Get("/settings/{code}", s.settingsGet)
constrainSetting(g)
g.Put("/settings/{code}", requireAjax(s.settingsPut))
constrainSetting(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema)
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/relation/{name}", s.relationSchema)
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list)
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
constrainController(g)
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}", requireAjax(s.update))
constrainController(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
constrainController(g)
// Six-segment GET routes share one pattern: ServeMux rejects the
// relation list next to the field options route (neither is more
// specific), so nestedGet dispatches on the literal segments.
g.Get("/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}", s.nestedGet)
constrainNested(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
constrainRelation(g)
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) {
g.Where("vendor", "[A-Za-z0-9_-]+")
g.Where("plugin", "[A-Za-z0-9_-]+")
g.Where("controller", "[A-Za-z0-9_-]+")
}
func constrainRelation(g pact.Router) {
constrainController(g)
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainNested(g pact.Router) {
constrainController(g)
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
// nestedGet serves the logical routes
//
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
// GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
//
// A numeric id never equals a literal segment, so the dispatch is unambiguous.
// Anything else is the D-10 not_found envelope, as an unmatched API path.
func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
id, segment, name := r.PathValue("id"), r.PathValue("segment"), r.PathValue("name")
switch {
case id == "fields" && name == "options":
r.SetPathValue("field", segment)
s.fieldOptions(w, r)
case id == "filters" && name == "options":
r.SetPathValue("scope", segment)
s.filterOptions(w, r)
case segment == "relations":
s.relationLinked(w, r)
default:
writeNotFound(w, r)
}
}
func constrainSetting(g pact.Router) {
g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*")
}
func (s *service) navigation(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
navigation, _ := s.reg.Metadata(r.Context(), principal, s.translator())
WriteData(w, http.StatusOK, navigation, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
}
func (s *service) settingsList(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
_, settings := s.reg.Metadata(r.Context(), principal, s.translator())
WriteData(w, http.StatusOK, settings, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
}
func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
view, err := setting.Form.Localize(r.Context(), s.translator(), nil)
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, view, map[string]any{"locale": view.Meta.Locale})
})
}
func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db}).Get(r.Context(), setting)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
body, err := decodeObject(r)
if err != nil {
writeCRUDError(w, err)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db}).Put(r.Context(), setting, body)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func (s *service) protectSetting(w http.ResponseWriter, r *http.Request, fn func(*CompiledSetting)) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
setting, exists := s.reg.Setting(r.PathValue("code"))
if !exists {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !Allows(principal, setting.Item.Permissions) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fn(setting)
}
func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr, err := relationOf(cc, r.PathValue("name"))
if err != nil {
writeCRUDError(w, err)
return
}
tr := s.translator()
view := cr.Schema.Localize(r.Context(), tr)
meta := map[string]any{}
if locale := schemaLocale(r.Context(), tr); locale != "" {
meta["locale"] = locale
}
WriteData(w, http.StatusOK, view, meta)
})
}
func relationQueryFromRequest(r *http.Request) RelationQuery {
q := r.URL.Query()
return RelationQuery{Search: q.Get("search"), Sort: q.Get("sort"), Dir: q.Get("dir"), Page: q.Get("page"), PerPage: q.Get("per_page")}
}
func (s *service) relationLinked(w http.ResponseWriter, r *http.Request) {
s.relationList(w, r, false)
}
func (s *service) relationCandidates(w http.ResponseWriter, r *http.Request) {
s.relationList(w, r, true)
}
func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidates bool) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var result *RelationResult
if candidates {
result, err = svc.Candidates(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
} else {
result, err = svc.Linked(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
}
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result.Data, map[string]any{"page": result.Meta.Page, "per_page": result.Meta.PerPage, "total": result.Meta.Total, "last_page": result.Meta.LastPage})
})
}
func (s *service) relationLink(w http.ResponseWriter, r *http.Request) {
s.relationMutation(w, r, true)
}
func (s *service) relationUnlink(w http.ResponseWriter, r *http.Request) {
s.relationMutation(w, r, false)
}
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
in, err := decodeRelationMutation(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var result RelationMutationResult
if link {
result, err = svc.Link(r.Context(), cc, r.PathValue("name"), id, in)
} else {
result, err = svc.Unlink(r.Context(), cc, r.PathValue("name"), id, in)
}
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
dec := json.NewDecoder(r.Body)
dec.UseNumber()
dec.DisallowUnknownFields()
var in RelationMutationInput
if err := dec.Decode(&in); err != nil {
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
return in, nil
}
func (s *service) relations() (RelationService, error) {
db, err := s.db()
if err != nil {
return RelationService{}, err
}
return RelationService{DB: db}, nil
}
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.Form == nil {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
view, err := cc.Form.Localize(r.Context(), s.translator(), dropdownProvider(cc.Controller))
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
meta := map[string]any{}
if view.Meta.Locale != "" {
meta["locale"] = view.Meta.Locale
}
WriteData(w, http.StatusOK, view, meta)
})
}
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
schema := cc.List
if schema == nil {
schema = &ListSchema{}
}
view, err := schema.Localize(r.Context(), s.translator())
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
meta := map[string]any{}
if view.Meta != nil {
meta["locale"] = view.Meta.Locale
}
WriteData(w, http.StatusOK, view, meta)
})
}
func (s *service) translator() *phrasebook.Translator {
if s == nil || s.app == nil {
return nil
}
tr, ok := s.app.Lookup[*phrasebook.Translator]()
if !ok {
return nil
}
return tr
}
func (s *service) show(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.ShowRecord(r.Context(), cc, id)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
})
}
func (s *service) create(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
body, err := decodeObject(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
})
}
func (s *service) update(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
body, err := decodeObject(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
})
}
func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
in, err := decodeBulk(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := svc.BulkDelete(r.Context(), cc, in)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func decodeBulk(r *http.Request) (BulkDeleteInput, error) {
dec := json.NewDecoder(r.Body)
dec.UseNumber()
var in BulkDeleteInput
if err := dec.Decode(&in); err != nil {
return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
}
return in, nil
}
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := svc.Delete(r.Context(), cc, id)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func (s *service) crud() (CRUDService, error) {
db, err := s.db()
if err != nil {
return CRUDService{}, err
}
return CRUDService{DB: db}, nil
}
func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r))
var invalid *ListValidationError
if errors.As(err, &invalid) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details)
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, result.Data, map[string]any{
"page": result.Meta.Page,
"per_page": result.Meta.PerPage,
"total": result.Meta.Total,
"last_page": result.Meta.LastPage,
})
})
}
// protect runs after the backend guard. Controller lookup precedes permission
// evaluation, and schema/SQL run only inside fn.
func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) {
id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller")
cc, ok := s.reg.Get(id)
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
principal, _ := bouncer.User(r.Context())
if principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if !Allows(principal, requiredOf(cc.Controller)) {
s.logAuth(r, "denied", principal.ID)
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fn(cc)
}
func projectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return map[string]any{}
}
v = v.Elem()
}
out := make(map[string]any, len(cols)+1)
if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() {
out["id"] = id.Interface()
}
for _, col := range cols {
if col.Relation != "" {
if value, ok := relatedSelect(v, controller, col); ok {
out[col.Key] = value
}
continue
}
field := fieldByColumn(v, col.Key)
if !field.IsValid() || !field.CanInterface() {
continue
}
out[col.Key] = field.Interface()
}
return out
}
func fieldByColumn(v reflect.Value, column string) reflect.Value {
if v.Kind() != reflect.Struct {
return reflect.Value{}
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
if gormColumn(field) == column || strings.EqualFold(field.Name, column) {
return v.Field(i)
}
}
return reflect.Value{}
}
func gormColumn(field reflect.StructField) string {
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
part = strings.TrimSpace(part)
if name, ok := strings.CutPrefix(part, "column:"); ok {
return name
}
}
return ""
}
func uitoa(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}

24
modules/cabana/lang.go Normal file
View File

@@ -0,0 +1,24 @@
package cabana
import (
"net/http"
)
// adminLangPrefix is the only namespace the public bundle serves (D-20).
const adminLangPrefix = "backend::lang."
// langBundle serves GET {prefix}/api/v1/lang: every backend::lang key as CLDR
// forms for the request locale, with the fallback locale's keys underneath.
// It is public (the login screen needs it) and never includes another
// namespace.
func (s *service) langBundle(w http.ResponseWriter, r *http.Request) {
tr := s.translator()
locale := schemaLocale(r.Context(), tr)
data := map[string]map[string]string{}
if tr != nil {
locale = tr.Resolved(locale, adminLangPrefix)
data = tr.Bundle(locale, adminLangPrefix)
}
w.Header().Set("Cache-Control", "no-cache")
WriteData(w, http.StatusOK, data, map[string]any{"locale": locale})
}

View File

@@ -0,0 +1,504 @@
package cabana
import (
"context"
"fmt"
"io/fs"
"path"
"reflect"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"gorm.io/gorm"
)
const listSearchTerm = "search"
var listColumnTypes = map[string]struct{}{
"text": {}, "datetime": {}, "switch": {},
}
type listDocument struct {
List string `yaml:"list"`
ModelClass string `yaml:"modelClass"`
Title string `yaml:"title"`
RecordURL string `yaml:"recordUrl"`
NoRecordsMessage string `yaml:"noRecordsMessage"`
RecordsPerPage int `yaml:"recordsPerPage"`
PerPageOptions []int `yaml:"perPageOptions"`
ShowCheckboxes bool `yaml:"showCheckboxes"`
ShowSetup bool `yaml:"showSetup"`
ShowSorting *bool `yaml:"showSorting"`
ShowSearch bool `yaml:"showSearch"`
DefaultSort *listSortDocument `yaml:"defaultSort"`
Toolbar *listToolbar `yaml:"toolbar"`
Filter string `yaml:"filter"`
Messages *listMessageKeys `yaml:"messages"`
}
type listSortDocument struct {
Column string `yaml:"column"`
Direction string `yaml:"direction"`
}
type listToolbar struct {
Buttons toolbarButtons `yaml:"buttons"`
Search *struct {
Prompt string `yaml:"prompt"`
Mode string `yaml:"mode"`
} `yaml:"search"`
}
type columnsDocument struct {
Columns yaml.MapSlice `yaml:"columns"`
}
type columnDocument struct {
Label string `yaml:"label"`
Searchable bool `yaml:"searchable"`
Sortable *bool `yaml:"sortable"`
Type string `yaml:"type"`
Relation string `yaml:"relation"`
Select string `yaml:"select"`
}
// CompileListSchema compiles config_list.yaml and the columns.yaml it names.
func CompileListSchema(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
return compileList(pluginID, ctl, fsys)
}
// CompileList compiles one controller's config_list.yaml and columns.yaml.
func CompileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
return compileList(pluginID, ctl, fsys)
}
func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
if ctl == nil {
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
}
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
}
cfgPath := path.Join(dir, "config_list.yaml")
raw, err := readAsset(fsys, cfgPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
var doc listDocument
if err := decodeStrict(raw, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
if doc.ModelClass != ctl.ModelName() {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
if strings.TrimSpace(doc.List) == "" {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("list file is empty"))
}
colPath, err := assetPath(pluginID, doc.List)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
columns, err := compileColumns(pluginID, ctl, fsys, colPath)
if err != nil {
return nil, err
}
per := doc.RecordsPerPage
if per < 1 {
per = 20
}
options, err := compilePageOptions(per, doc.PerPageOptions)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
showSearch := doc.ShowSearch
prompt := ""
if doc.Toolbar != nil && doc.Toolbar.Search != nil {
showSearch = true
prompt = doc.Toolbar.Search.Prompt
if mode := doc.Toolbar.Search.Mode; mode != "" && mode != "all" && mode != "any" && mode != "exact" {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
}
}
buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
sort, err := compileDefaultSort(doc.DefaultSort, columns)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
showSorting := true
if doc.ShowSorting != nil {
showSorting = *doc.ShowSorting
}
rowActions := []RowAction{}
if doc.RecordURL != "" {
rowActions = append(rowActions, RowAction{
Name: "update",
Label: "backend::lang.form.update",
URL: doc.RecordURL,
})
}
bulk := []BulkAction{}
if doc.ShowCheckboxes {
bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"})
}
filters := []ListFilter{}
if strings.TrimSpace(doc.Filter) != "" {
filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter)
if err != nil {
return nil, err
}
}
schema := &ListSchema{
Title: doc.Title,
ModelClass: doc.ModelClass,
RecordURL: doc.RecordURL,
NoRecordsMessage: doc.NoRecordsMessage,
RecordsPerPage: per,
PerPageOptions: options,
ShowSearch: showSearch,
ShowSetup: doc.ShowSetup,
ShowCheckboxes: doc.ShowCheckboxes,
ShowSorting: showSorting,
SearchTerm: listSearchTerm,
SearchPrompt: prompt,
DefaultSort: sort,
ToolbarButtons: buttons,
Columns: columns,
Filters: filters,
RowActions: rowActions,
BulkActions: bulk,
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, schema.listMessageKeySet())
schema.Messages = &keys
return schema, nil
}
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) {
colRaw, err := readAsset(fsys, colPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
}
var cols columnsDocument
if err := decodeStrict(colRaw, &cols); err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
}
modelCols, modelRels, checkModel := listModelContract(ctl)
compiled := make([]ListColumn, 0, len(cols.Columns))
seen := map[string]struct{}{}
for _, item := range cols.Columns {
key, ok := item.Key.(string)
if !ok || !identifier(key) {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column key %v is not an identifier", item.Key))
}
if _, dup := seen[key]; dup {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("duplicate column %s", key))
}
seen[key] = struct{}{}
encoded, err := yaml.Marshal(item.Value)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
}
var spec columnDocument
if err := decodeStrict(encoded, &spec); err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err))
}
if spec.Type != "" {
if _, known := listColumnTypes[spec.Type]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", spec.Type))
}
}
if spec.Relation != "" || spec.Select != "" {
if !identifier(spec.Relation) {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("relation %q is not an identifier", spec.Relation))
}
if !identifier(spec.Select) {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("select %q is not an identifier", spec.Select))
}
if checkModel {
if _, known := modelRels[spec.Relation]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("relation %s is not on the model", spec.Relation))
}
}
} else if checkModel {
if _, known := modelCols[key]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s is not on the model", key))
}
}
sortable := spec.Relation == ""
if spec.Sortable != nil {
sortable = *spec.Sortable
}
compiled = append(compiled, ListColumn{
Key: key,
Label: spec.Label,
Searchable: spec.Searchable,
Sortable: sortable,
Type: spec.Type,
Relation: spec.Relation,
Select: spec.Select,
})
}
return compiled, nil
}
func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
if len(declared) == 0 {
return []int{recordsPerPage}, nil
}
options := make([]int, len(declared))
seen := map[int]struct{}{}
found := false
for i, n := range declared {
if n < 1 {
return nil, fmt.Errorf("perPageOptions must be positive")
}
if _, dup := seen[n]; dup {
return nil, fmt.Errorf("duplicate perPageOptions %d", n)
}
seen[n] = struct{}{}
options[i] = n
if n == recordsPerPage {
found = true
}
}
if !found {
return nil, fmt.Errorf("recordsPerPage %d is not in perPageOptions", recordsPerPage)
}
return options, nil
}
// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in
// actions in display order. A scalar (Winter's partial name) is rejected
// with a pointer at the list syntax.
type toolbarButtons struct {
items []string
}
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
node = unwrapNode(node)
switch n := node.(type) {
case nil, *ast.NullNode:
b.items = nil
return nil
case *ast.SequenceNode:
values := sequenceValues(n)
items := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, item := range values {
action, err := nodeString(unwrapNode(item))
if err != nil {
return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)")
}
if _, ok := toolbarActions[action]; !ok {
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
}
if _, dup := seen[action]; dup {
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
}
seen[action] = struct{}{}
items = append(items, action)
}
b.items = items
return nil
default:
return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node))
}
}
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
if toolbar == nil || len(toolbar.Buttons.items) == 0 {
return []string{}, nil
}
out := append([]string(nil), toolbar.Buttons.items...)
for _, action := range out {
if action == "delete" && !showCheckboxes {
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
}
}
return out, nil
}
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
if doc == nil {
return nil, nil
}
var match *ListColumn
for i := range columns {
if columns[i].Key == doc.Column {
match = &columns[i]
break
}
}
if match == nil {
return nil, fmt.Errorf("default sort column %s is not a list column", doc.Column)
}
if !match.Sortable {
return nil, fmt.Errorf("default sort column %s is not sortable", doc.Column)
}
if doc.Direction != "asc" && doc.Direction != "desc" {
return nil, fmt.Errorf("default sort direction %s must be asc or desc", doc.Direction)
}
return &ListSort{Column: doc.Column, Direction: doc.Direction}, nil
}
func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[string]struct{}, bool) {
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil, nil, false
}
model := src.NewRecord()
if model == nil {
return nil, nil, false
}
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return nil, nil, false
}
cols := map[string]struct{}{}
rels := map[string]struct{}{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
if isListRelation(field.Type) {
rels[field.Name] = struct{}{}
// Winter YAML spells relations in lowercase (relation: genre)
// while the Go field is exported (Genre).
rels[strings.ToLower(field.Name)] = struct{}{}
continue
}
name := gormColumn(field)
if name == "" {
name = field.Name
}
cols[name] = struct{}{}
}
return cols, rels, true
}
func isListRelation(t reflect.Type) bool {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() == reflect.Slice {
t = t.Elem()
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
}
if t.Kind() != reflect.Struct {
return false
}
if t == reflect.TypeOf(time.Time{}) {
return false
}
if t == reflect.TypeOf(gorm.DeletedAt{}) {
return false
}
return true
}
// Localize copies the cached list schema and resolves display strings for one request.
// Column, filter, and scope identifiers and option values are not translated.
func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*ListSchema, error) {
if s == nil {
return nil, fmt.Errorf("cabana: nil list schema")
}
if ctx == nil {
ctx = context.Background()
}
locale := schemaLocale(ctx, tr)
ctx = towel.WithLocale(ctx, locale)
out := *s
out.Title = translateKey(ctx, tr, s.Title)
out.NoRecordsMessage = translateKey(ctx, tr, s.NoRecordsMessage)
out.SearchPrompt = translateKey(ctx, tr, s.SearchPrompt)
out.PerPageOptions = append([]int(nil), s.PerPageOptions...)
out.ToolbarButtons = append([]string(nil), s.ToolbarButtons...)
if s.DefaultSort != nil {
sort := *s.DefaultSort
out.DefaultSort = &sort
}
out.Columns = localizeListColumns(ctx, tr, s.Columns)
out.Filters = localizeListFilters(ctx, tr, s.Filters)
out.RowActions = localizeRowActions(ctx, tr, s.RowActions)
out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions)
messages := localizeMessages[listMessageKeys, ListMessages](ctx, tr, s.listMessageKeySet())
out.Messages = &messages
out.Meta = &FormMeta{Locale: locale}
return &out, nil
}
func localizeListColumns(ctx context.Context, tr *phrasebook.Translator, cols []ListColumn) []ListColumn {
out := make([]ListColumn, len(cols))
for i, col := range cols {
col.Label = translateKey(ctx, tr, col.Label)
out[i] = col
}
if out == nil {
out = []ListColumn{}
}
return out
}
func localizeListFilters(ctx context.Context, tr *phrasebook.Translator, filters []ListFilter) []ListFilter {
out := make([]ListFilter, len(filters))
for i, filter := range filters {
filter.Label = translateKey(ctx, tr, filter.Label)
if len(filter.Options) > 0 {
opts := make([]FormOption, len(filter.Options))
for j, opt := range filter.Options {
opt.Label = translateKey(ctx, tr, opt.Label)
opts[j] = opt
}
filter.Options = opts
}
out[i] = filter
}
if out == nil {
out = []ListFilter{}
}
return out
}
func localizeRowActions(ctx context.Context, tr *phrasebook.Translator, actions []RowAction) []RowAction {
out := make([]RowAction, len(actions))
for i, action := range actions {
action.Label = translateKey(ctx, tr, action.Label)
out[i] = action
}
if out == nil {
out = []RowAction{}
}
return out
}
func localizeBulkActions(ctx context.Context, tr *phrasebook.Translator, actions []BulkAction) []BulkAction {
out := make([]BulkAction, len(actions))
for i, action := range actions {
action.Label = translateKey(ctx, tr, action.Label)
out[i] = action
}
if out == nil {
out = []BulkAction{}
}
return out
}

View File

@@ -0,0 +1,597 @@
package cabana
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
const allColumnsConfig = `title: demo.lang.widgets
modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordUrl: acme/demo/widgets/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
perPageOptions:
- 20
- 50
- 100
showSetup: true
showCheckboxes: true
showSorting: true
defaultSort:
column: name
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
`
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}`
// defaultListMessagesJSON is a compiled list's messages block when the YAML
// declares none: every key is a framework default phrase key (D-13).
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}`
func TestListSchemaCompile(t *testing.T) {
columns := readListFixture(t, "testdata/list/all_columns.yaml")
got := compileListJSON(t, schemaController{model: "Widget"}, allColumnsConfig, columns)
if got != allColumnsJSON {
t.Fatalf("compiled list =\n%s\nwant\n%s", got, allColumnsJSON)
}
again := compileListJSON(t, schemaController{model: "Widget"}, allColumnsConfig, columns)
if again != got {
t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again)
}
for _, want := range []string{
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"]`, `"filters":[]`,
} {
if !strings.Contains(got, want) {
t.Fatalf("compiled list missing %s:\n%s", want, got)
}
}
t.Run("winter genre list still compiles", func(t *testing.T) {
const config = `list: ~/plugins/golem15/acme/models/genre/columns.yaml
modelClass: Golem15\Acme\Models\Genre
title: golem15.acme::lang.genre.label_plural
recordUrl: golem15/acme/genres/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
`
const cols = `columns:
name:
label: golem15.acme::lang.genre.name
searchable: true
slug:
label: golem15.acme::lang.genre.slug
searchable: true
`
ctl := schemaController{model: `Golem15\Acme\Models\Genre`}
fsys := fstest.MapFS{
"controllers/genres/config_list.yaml": &fstest.MapFile{Data: []byte(config)},
"models/genre/columns.yaml": &fstest.MapFile{Data: []byte(cols)},
}
genre := genreConfigController{schemaController: ctl}
if _, err := CompileList("golem15.acme", genre, fsys); err != nil {
t.Fatalf("genre list: %v", err)
}
})
}
func TestListSchemaEmpty(t *testing.T) {
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
modelClass: Widget
recordsPerPage: 20
`
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
if got != want {
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
}
for _, nullKey := range []string{`"columns":null`, `"filters":null`, `"rowActions":null`, `"bulkActions":null`, `"perPageOptions":null`, `"toolbarButtons":null`} {
if strings.Contains(got, nullKey) {
t.Fatalf("empty collection serialized as null: %s", nullKey)
}
}
}
func TestListSchemaSingle(t *testing.T) {
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
modelClass: Widget
recordsPerPage: 20
`
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
if got != want {
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
}
}
func TestListSchemaOrdering(t *testing.T) {
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
modelClass: Widget
recordsPerPage: 20
perPageOptions:
- 100
- 20
- 50
`
const columns = `columns:
zeta:
label: Z
type: text
alpha:
label: A
type: datetime
mid:
label: M
type: switch
`
first := compileListJSON(t, schemaController{model: "Widget"}, config, columns)
second := compileListJSON(t, schemaController{model: "Widget"}, config, columns)
if first != second {
t.Fatalf("order changed between compiles:\n%s\n%s", first, second)
}
zeta := strings.Index(first, `"key":"zeta"`)
alpha := strings.Index(first, `"key":"alpha"`)
mid := strings.Index(first, `"key":"mid"`)
if zeta < 0 || alpha < 0 || mid < 0 || !(zeta < alpha && alpha < mid) {
t.Fatalf("source order lost: %s", first)
}
if !strings.Contains(first, `"perPageOptions":[100,20,50]`) {
t.Fatalf("perPageOptions were reordered: %s", first)
}
}
func TestListSchemaRejects(t *testing.T) {
columns := readListFixture(t, "testdata/list/all_columns.yaml")
cases := []struct {
name string
model string
config string
columns string
want []string
}{
{
name: "duplicate column",
config: allColumnsConfig,
columns: "columns:\n name:\n label: A\n name:\n label: B\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "duplicate"},
},
{
name: "unknown key",
config: allColumnsConfig,
columns: "columns:\n name:\n label: Name\n cssClass: bad\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "cssClass"},
},
{
name: "unsupported type",
config: allColumnsConfig,
columns: "columns:\n name:\n label: Name\n type: partial\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "partial"},
},
{
name: "sql select",
config: allColumnsConfig,
columns: "columns:\n genre:\n label: Genre\n relation: genre\n select: \"@.name\"\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/columns.yaml", "select"},
},
{
name: "invalid default column",
config: strings.Replace(allColumnsConfig, "column: name", "column: missing", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "missing"},
},
{
name: "invalid default direction",
config: strings.Replace(allColumnsConfig, "direction: asc", "direction: ASC", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "ASC"},
},
{
name: "path escape",
config: strings.Replace(allColumnsConfig, "list: ~/plugins/acme/demo/models/widget/columns.yaml", "list: ../../etc/passwd", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "escape"},
},
{
name: "mismatched modelClass",
model: "Widget",
config: strings.Replace(allColumnsConfig, "modelClass: Widget", "modelClass: widget", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "modelClass"},
},
{
name: "unsupported action",
config: strings.Replace(allColumnsConfig, "buttons: [create, delete]", "buttons: [create, drop_database]", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"},
},
{
name: "invalid page size",
config: strings.Replace(allColumnsConfig, " - 100\n", " - 0\n", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "perPageOptions"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
model := tc.model
if model == "" {
model = "Widget"
}
_, err := CompileList("acme.demo", schemaController{model: model}, listFS(tc.config, tc.columns))
if err == nil {
t.Fatal("expected boot error")
}
msg := err.Error()
for _, want := range tc.want {
if want == "duplicate" {
if strings.Contains(msg, "duplicate") || strings.Contains(msg, "already defined") {
continue
}
t.Fatalf("error %q missing duplicate", msg)
}
if want == "escape" {
if strings.Contains(msg, "escape") || strings.Contains(msg, "..") {
continue
}
t.Fatalf("error %q missing escape", msg)
}
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
})
}
}
type genreConfigController struct{ schemaController }
func (c genreConfigController) ConfigDir() string { return "controllers/genres" }
func (c genreConfigController) ID() string { return "golem15.acme.genres" }
func listFS(config, columns string) fstest.MapFS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte(config)},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte(columns)},
}
}
func compileListJSON(t *testing.T, ctl pact.AdminController, config, columns string) string {
t.Helper()
schema, err := CompileList("acme.demo", ctl, listFS(config, columns))
if err != nil {
t.Fatalf("compile: %v", err)
}
raw, err := json.Marshal(schema)
if err != nil {
t.Fatalf("json: %v", err)
}
return string(raw)
}
func readListFixture(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(name)
if err != nil {
t.Fatalf("fixture %s: %v", name, err)
}
return string(raw)
}
const filterListConfig = `title: demo.lang.widgets
modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
filter: config_filter.yaml
`
const filterColumns = `columns:
name:
label: Name
searchable: true
`
const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
func TestListSchemaFilter(t *testing.T) {
filters := readListFixture(t, "testdata/list/all_filters.yaml")
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &filterRow{}}
got := compileFilterJSON(t, ctl, filterListConfig, filterColumns, filters)
if got != allFiltersJSON {
t.Fatalf("compiled filters =\n%s\nwant\n%s", got, allFiltersJSON)
}
again := compileFilterJSON(t, ctl, filterListConfig, filterColumns, filters)
if again != got {
t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again)
}
if strings.Contains(got, `"value":"true"`) || strings.Contains(got, `"value":"false"`) || strings.Contains(got, "conditions") {
t.Fatalf("filter values lost their type or carried a condition: %s", got)
}
yes := strings.Index(got, `{"value":true,"label":"demo.lang.yes"}`)
no := strings.Index(got, `{"value":false,"label":"demo.lang.no"}`)
if yes < 0 || no < 0 || yes > no {
t.Fatalf("switch option order drifted: %s", got)
}
t.Run("empty", func(t *testing.T) {
raw := compileFilterJSON(t, ctl, filterListConfig, filterColumns, "scopes: {}\n")
if !strings.Contains(raw, `"filters":[]`) || strings.Contains(raw, `"filters":null`) {
t.Fatalf("empty filters = %s", raw)
}
})
t.Run("single", func(t *testing.T) {
const one = `scopes:
activated:
label: Activated
type: switch
column: active
`
raw := compileFilterJSON(t, ctl, filterListConfig, filterColumns, one)
want := `"filters":[{"name":"activated","label":"Activated","type":"switch","column":"active","trueValue":true,"falseValue":false}]`
if !strings.Contains(raw, want) {
t.Fatalf("single filter =\n%s\nwant substring\n%s", raw, want)
}
})
t.Run("labels localize", func(t *testing.T) {
const localized = `scopes:
activated:
label: acme.demo::lang.activated
type: switch
column: active
options:
true: acme.demo::lang.yes
false: acme.demo::lang.no
grouped:
label: acme.demo::lang.grouped
modelClass: Group
nameFrom: name
scope: filterByGroup
`
schema, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, localized))
if err != nil {
t.Fatalf("compile: %v", err)
}
before, err := json.Marshal(schema)
if err != nil {
t.Fatal(err)
}
tr := phrasebook.NewTranslator(filterCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"})
view, err := schema.Localize(towel.WithLocale(context.Background(), "pl"), tr)
if err != nil {
t.Fatalf("localize: %v", err)
}
raw, err := json.Marshal(view)
if err != nil {
t.Fatal(err)
}
got := string(raw)
for _, want := range []string{`"locale":"pl"`, `"label":"Aktywny"`, `"label":"Tak"`, `"label":"Grupa"`, `"column":"active"`, `"scope":"filterByGroup"`, `"value":true`} {
if !strings.Contains(got, want) {
t.Fatalf("localized filters missing %s:\n%s", want, got)
}
}
if strings.Contains(got, `"value":"true"`) || strings.Contains(got, "acme.demo::lang.activated") {
t.Fatalf("localization changed a value or left a label key: %s", got)
}
after, err := json.Marshal(schema)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) || strings.Contains(string(after), "Aktywny") {
t.Fatalf("localization mutated the cached schema:\n%s", after)
}
})
}
func TestListSchemaScope(t *testing.T) {
const scopes = `scopes:
grouped:
label: Group
modelClass: Group
nameFrom: name
scope: filterByGroup
`
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &filterRow{}}
raw := compileFilterJSON(t, ctl, filterListConfig, filterColumns, scopes)
if !strings.Contains(raw, `"type":"scope"`) || !strings.Contains(raw, `"scope":"filterByGroup"`) || strings.Contains(raw, "DestroyAll") {
t.Fatalf("registered scope = %s", raw)
}
t.Run("missing provider", func(t *testing.T) {
_, err := CompileList("acme.demo", schemaController{model: "Widget"}, filterFS(filterListConfig, filterColumns, scopes))
if err == nil || !strings.Contains(err.Error(), "filterByGroup") || !strings.Contains(err.Error(), "FilterScope") || !strings.Contains(err.Error(), "config_filter.yaml") {
t.Fatalf("missing provider err = %v", err)
}
})
t.Run("case sensitive scope", func(t *testing.T) {
body := strings.Replace(scopes, "scope: filterByGroup", "scope: FilterByGroup", 1)
_, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, body))
if err == nil || !strings.Contains(err.Error(), "FilterByGroup") {
t.Fatalf("case variant err = %v", err)
}
})
t.Run("unknown column", func(t *testing.T) {
const body = `scopes:
activated:
label: Activated
type: switch
column: missing
`
_, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, body))
if err == nil || !strings.Contains(err.Error(), "missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("unknown column err = %v", err)
}
})
}
func TestListSchemaRejectsRawCondition(t *testing.T) {
cases := []struct {
name string
body string
want []string
}{
{
name: "conditions list",
body: `scopes:
activated:
label: Activated
type: switch
column: active
conditions:
- "active = true"
`,
want: []string{"acme.demo", "acme.demo.widgets", "config_filter.yaml", "conditions"},
},
{
name: "conditions string",
body: `scopes:
created:
label: Created
type: daterange
conditions: "created_at >= ':after'"
`,
want: []string{"conditions", "config_filter.yaml"},
},
{
name: "arbitrary method",
body: `scopes:
grouped:
label: Group
modelClass: Group
nameFrom: name
scope: DestroyAll
`,
want: []string{"DestroyAll", "config_filter.yaml"},
},
{
name: "duplicate filter",
body: `scopes:
activated:
label: A
type: switch
column: active
activated:
label: B
type: switch
column: active
`,
want: []string{"duplicate", "config_filter.yaml"},
},
{
name: "unknown type",
body: `scopes:
activated:
label: Activated
type: checkbox
column: active
`,
want: []string{"checkbox", "config_filter.yaml"},
},
}
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &filterRow{}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, tc.body))
if err == nil {
t.Fatal("expected boot error")
}
msg := err.Error()
for _, want := range tc.want {
if want == "duplicate" {
if strings.Contains(msg, "duplicate") || strings.Contains(msg, "already defined") {
continue
}
t.Fatalf("error %q missing duplicate", msg)
}
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
})
}
}
type filterRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (filterRow) FilterScopes() []string { return []string{"filterByGroup"} }
// FilterOptions serves the model-backed scope's choices (D-27).
func (filterRow) FilterOptions(scope string) []pact.Option {
if scope != "filterByGroup" {
return nil
}
return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}}
}
func (filterRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != "filterByGroup" {
return db
}
return db.Where("group_id = ?", value)
}
type filterController struct {
schemaController
rec any
}
func (c filterController) NewRecord() any { return c.rec }
func filterFS(config, columns, filters string) fstest.MapFS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte(config)},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte(columns)},
"controllers/widgets/config_filter.yaml": &fstest.MapFile{Data: []byte(filters)},
}
}
func compileFilterJSON(t *testing.T, ctl pact.AdminController, config, columns, filters string) string {
t.Helper()
schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, filters))
if err != nil {
t.Fatalf("compile: %v", err)
}
raw, err := json.Marshal(schema)
if err != nil {
t.Fatalf("json: %v", err)
}
return string(raw)
}
func filterCatalog(t *testing.T) *phrasebook.Catalog {
t.Helper()
cat := phrasebook.NewCatalog()
err := cat.Load("acme.demo", fstest.MapFS{
"lang/en/lang.yaml": &fstest.MapFile{Data: []byte("activated: Activated\nyes: Yes\nno: No\ngrouped: Grouped\n")},
"lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("activated: Aktywny\nyes: Tak\nno: Nie\ngrouped: Grupa\n")},
})
if err != nil {
t.Fatalf("catalog: %v", err)
}
return cat
}

257
modules/cabana/messages.go Normal file
View File

@@ -0,0 +1,257 @@
package cabana
import (
"context"
"encoding/json"
"fmt"
"reflect"
"sort"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
// MessageForms is one controller message resolved to its CLDR plural forms
// (D-13). "other" is always present; the SPA picks a form with
// Intl.PluralRules and interpolates :count, :name and :term (D-24).
type MessageForms map[string]string
// MarshalJSON keeps an unset message an object, never null.
func (m MessageForms) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("{}"), nil
}
return json.Marshal(map[string]string(m))
}
// listMessageKeys is the config_list.yaml messages block: phrase keys.
type listMessageKeys struct {
RecordCount string `yaml:"recordCount"`
Create string `yaml:"create"`
SearchPrompt string `yaml:"searchPrompt"`
Empty string `yaml:"empty"`
EmptySearch string `yaml:"emptySearch"`
EmptySearchHint string `yaml:"emptySearchHint"`
Selected string `yaml:"selected"`
DeleteSelected string `yaml:"deleteSelected"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
}
// ListMessages is a list schema's copy, every key resolved (same field order
// as listMessageKeys).
type ListMessages struct {
RecordCount MessageForms `json:"recordCount"`
Create MessageForms `json:"create"`
SearchPrompt MessageForms `json:"searchPrompt"`
Empty MessageForms `json:"empty"`
EmptySearch MessageForms `json:"emptySearch"`
EmptySearchHint MessageForms `json:"emptySearchHint"`
Selected MessageForms `json:"selected"`
DeleteSelected MessageForms `json:"deleteSelected"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
}
// formMessageKeys is the config_form.yaml messages block: phrase keys.
type formMessageKeys struct {
Create string `yaml:"create"`
Update string `yaml:"update"`
Saved string `yaml:"saved"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
}
// FormMessages is a form's copy, every key resolved.
type FormMessages struct {
Create MessageForms `json:"create"`
Update MessageForms `json:"update"`
Saved MessageForms `json:"saved"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
}
// relationMessageKeys is one config_relation.yaml relation's messages block.
type relationMessageKeys struct {
Link string `yaml:"link"`
LinkHint string `yaml:"linkHint"`
CandidateSearch string `yaml:"candidateSearch"`
Linked string `yaml:"linked"`
UnlinkSelected string `yaml:"unlinkSelected"`
UnlinkConfirm string `yaml:"unlinkConfirm"`
Unlinked string `yaml:"unlinked"`
Empty string `yaml:"empty"`
}
// RelationMessages is a relation manager's copy, every key resolved.
type RelationMessages struct {
Link MessageForms `json:"link"`
LinkHint MessageForms `json:"linkHint"`
CandidateSearch MessageForms `json:"candidateSearch"`
Linked MessageForms `json:"linked"`
UnlinkSelected MessageForms `json:"unlinkSelected"`
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
Unlinked MessageForms `json:"unlinked"`
Empty MessageForms `json:"empty"`
}
// Framework defaults (D-13): every omitted key falls back to backend::lang.
var (
listMessageDefaults = listMessageKeys{
RecordCount: "backend::lang.messages.list.record_count",
Create: "backend::lang.messages.list.create",
SearchPrompt: "backend::lang.list.search_prompt",
Empty: "backend::lang.list.no_records",
EmptySearch: "backend::lang.messages.list.empty_search",
EmptySearchHint: "backend::lang.messages.list.empty_search_hint",
Selected: "backend::lang.messages.list.selected",
DeleteSelected: "backend::lang.list.delete_selected",
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
Deleted: "backend::lang.messages.list.deleted",
}
formMessageDefaults = formMessageKeys{
Create: "backend::lang.messages.form.create",
Update: "backend::lang.messages.form.update",
Saved: "backend::lang.messages.form.saved",
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
Deleted: "backend::lang.messages.form.deleted",
}
relationMessageDefaults = relationMessageKeys{
Link: "backend::lang.messages.relation.link",
LinkHint: "backend::lang.messages.relation.link_hint",
CandidateSearch: "backend::lang.messages.relation.candidate_search",
Linked: "backend::lang.messages.relation.linked",
UnlinkSelected: "backend::lang.messages.relation.unlink_selected",
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
Unlinked: "backend::lang.messages.relation.unlinked",
Empty: "backend::lang.messages.relation.empty",
}
)
// withDefaults fills every empty key of keys from defaults.
func withDefaults[K any](keys, defaults K) K {
out := keys
dst := reflect.ValueOf(&out).Elem()
src := reflect.ValueOf(defaults)
for i := 0; i < dst.NumField(); i++ {
if dst.Field(i).String() == "" {
dst.Field(i).SetString(src.Field(i).String())
}
}
return out
}
// messageKeyPairs lists (yaml name, phrase key) for every key, in field order.
func messageKeyPairs[K any](keys K) [][2]string {
v := reflect.ValueOf(keys)
t := v.Type()
out := make([][2]string, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
out = append(out, [2]string{t.Field(i).Tag.Get("yaml"), v.Field(i).String()})
}
return out
}
// localizeMessages resolves each key of K into the same-index MessageForms
// field of M. Without a translator, or for a key that cannot convert, the
// form is {"other": text} where text is the translation or the key itself.
func localizeMessages[K any, M any](ctx context.Context, tr *phrasebook.Translator, keys K) M {
var out M
dst := reflect.ValueOf(&out).Elem()
src := reflect.ValueOf(keys)
locale := schemaLocale(ctx, tr)
for i := 0; i < src.NumField(); i++ {
key := src.Field(i).String()
dst.Field(i).Set(reflect.ValueOf(messageForms(tr, locale, key)))
}
return out
}
func messageForms(tr *phrasebook.Translator, locale, key string) MessageForms {
if key == "" {
return MessageForms{"other": ""}
}
if tr == nil {
return MessageForms{"other": key}
}
if forms, ok := tr.Forms(locale, key); ok {
return MessageForms(forms)
}
return MessageForms{"other": tr.GetIn(locale, key, nil)}
}
// listMessageKeySet is the list's resolved key set: declared keys over the
// framework defaults, with searchPrompt and empty defaulting to the list's own
// toolbar.search.prompt and noRecordsMessage when those are set.
func (s *ListSchema) listMessageKeySet() listMessageKeys {
defaults := listMessageDefaults
if s.SearchPrompt != "" {
defaults.SearchPrompt = s.SearchPrompt
}
if s.NoRecordsMessage != "" {
defaults.Empty = s.NoRecordsMessage
}
return withDefaults(s.messageKeys, defaults)
}
// formMessageKeySet is the form's resolved key set; create defaults to the
// config_form.yaml name when it is set.
func (s *FormSchema) formMessageKeySet() formMessageKeys {
defaults := formMessageDefaults
if s.Name != "" {
defaults.Create = s.Name
}
return withDefaults(s.messageKeys, defaults)
}
func (s *RelationSchema) relationMessageKeySet() relationMessageKeys {
return withDefaults(s.messageKeys, relationMessageDefaults)
}
// validateMessageKeys fails activation when a controller's messages block (or
// a framework default) names a phrase key no locale defines (D-13). Defaults
// taken from the list's own search prompt, empty message or the form name are
// Winter strings that may be literal text, so they are not checked.
func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
if reg == nil || tr == nil {
return nil
}
ids := make([]string, 0, len(reg.byID))
for id := range reg.byID {
ids = append(ids, id)
}
sort.Strings(ids)
check := func(pluginID, id, file string, pairs [][2]string) error {
for _, pair := range pairs {
if pair[1] != "" && !tr.Has(pair[1]) {
return bootErr(pluginID, id, file, fmt.Errorf("messages.%s names missing phrase key %s", pair[0], pair[1]))
}
}
return nil
}
for _, id := range ids {
cc := reg.byID[id]
if cc.List != nil {
if err := check(cc.PluginID, id, "config_list.yaml", messageKeyPairs(withDefaults(cc.List.messageKeys, listMessageDefaults))); err != nil {
return err
}
}
if cc.Form != nil {
if err := check(cc.PluginID, id, "config_form.yaml", messageKeyPairs(withDefaults(cc.Form.messageKeys, formMessageDefaults))); err != nil {
return err
}
}
names := make([]string, 0, len(cc.Relations))
for name := range cc.Relations {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if schema := cc.Relations[name].Schema; schema != nil {
if err := check(cc.PluginID, id, "config_relation.yaml", messageKeyPairs(schema.relationMessageKeySet())); err != nil {
return fmt.Errorf("relation %s: %w", name, err)
}
}
}
}
return nil
}

View File

@@ -0,0 +1,354 @@
package cabana
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
type langPlugin struct {
id string
lang fs.FS
}
func (p langPlugin) ID() string { return p.id }
func (p langPlugin) LangFS() fs.FS { return p.lang }
// messagesTranslator activates phrasebook with the framework backend strings
// and an acme.demo catalog.
func messagesTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
app := backpack.New(nil)
demo := fstest.MapFS{
"lang/pl/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widżet"
few: ":count widżety"
many: ":count widżetów"
other: ":count widżetu"
create: Nowy widżet
saved: Widżet „:name” zapisany
link: Dodaj edytora
`)},
"lang/en/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widget"
other: ":count widgets"
create: New widget
saved: Widget “:name” saved
link: Add editor
`)},
}
if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok || tr == nil {
t.Fatal("translator missing")
}
return tr
}
const messagesListConfig = `modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
recordCount: acme.demo::lang.widgets.count
create: acme.demo::lang.widgets.create
`
func localeCtx(locale string) context.Context {
return towel.WithLocale(context.Background(), locale)
}
func TestPhase10Messages(t *testing.T) {
tr := messagesTranslator(t)
schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n"))
if err != nil {
t.Fatal(err)
}
pl, err := schema.Localize(localeCtx("pl"), tr)
if err != nil {
t.Fatal(err)
}
m := pl.Messages
if m == nil {
t.Fatal("localized list has no messages")
}
if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" {
t.Fatalf("plural recordCount=%v", m.RecordCount)
}
if m.Create["other"] != "Nowy widżet" {
t.Fatalf("plugin create=%v", m.Create)
}
for name, got := range map[string]MessageForms{
"deleteConfirm": m.DeleteConfirm,
"searchPrompt": m.SearchPrompt,
"empty": m.Empty,
"deleteSelected": m.DeleteSelected,
"selected": m.Selected,
} {
if got["other"] == "" || strings.Contains(got["other"], "::") {
t.Fatalf("default %s did not resolve: %v", name, got)
}
}
if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" {
t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt)
}
if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" {
t.Fatalf("default plural deleted=%v", m.Deleted)
}
en, err := schema.Localize(localeCtx("en"), tr)
if err != nil {
t.Fatal(err)
}
if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" {
t.Fatalf("en messages=%+v", en.Messages)
}
raw, err := json.Marshal(pl)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"messages":{"recordCount":{`) {
t.Fatalf("served list JSON has no messages: %s", raw)
}
if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" {
t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount)
}
t.Run("form", func(t *testing.T) {
config := `name: acme.demo::lang.widgets.create
form: ~/plugins/acme/demo/models/widget/fields.yaml
modelClass: Widget
defaultRedirect: acme/demo/widgets
create:
redirect: acme/demo/widgets/update/:id
redirectClose: acme/demo/widgets
update:
redirect: acme/demo/widgets
redirectClose: acme/demo/widgets
messages:
saved: acme.demo::lang.widgets.saved
`
form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n"))
if err != nil {
t.Fatal(err)
}
view, err := form.Localize(localeCtx("pl"), tr, nil)
if err != nil {
t.Fatal(err)
}
if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" {
t.Fatalf("form messages=%+v", view.Messages)
}
want := FormRedirects{Default: "acme/demo/widgets",
Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"},
Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}}
if view.Redirects != want {
t.Fatalf("redirects=%+v", view.Redirects)
}
raw, err := view.CanonicalJSON()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) {
t.Fatalf("form view JSON=%s", raw)
}
})
t.Run("relation", func(t *testing.T) {
yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
view := relations["editors"].Schema.Localize(localeCtx("pl"), tr)
if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" {
t.Fatalf("relation messages=%+v", view.Messages)
}
})
t.Run("unknown key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1)
if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") {
t.Fatalf("list err=%v", err)
}
form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n"
if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") {
t.Fatalf("form err=%v", err)
}
yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
compiled, _ := CompileForm("acme.demo", ctl, fsys)
if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") {
t.Fatalf("relation err=%v", err)
}
})
t.Run("missing phrase key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1)
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
err = validateMessageKeys(reg, tr)
if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("err=%v", err)
}
ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
if err := validateMessageKeys(ok, tr); err != nil {
t.Fatalf("declared keys failed: %v", err)
}
if err := validateMessageKeys(reg, nil); err != nil {
t.Fatalf("no translator must skip the check: %v", err)
}
})
}
func TestPhase10Toolbar(t *testing.T) {
base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n"
compile := func(buttons string, checkboxes bool) (*ListSchema, error) {
config := strings.Replace(base, "BUTTONS", buttons, 1)
if !checkboxes {
config = strings.Replace(config, "showCheckboxes: true\n", "", 1)
}
return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n"))
}
for _, tc := range []struct {
buttons string
want []string
}{
{"[create, delete]", []string{"create", "delete"}},
{"[delete, create]", []string{"delete", "create"}},
{"[create]", []string{"create"}},
{"[]", []string{}},
{"\n - delete\n - create", []string{"delete", "create"}},
} {
schema, err := compile(tc.buttons, true)
if err != nil {
t.Fatalf("%s: %v", tc.buttons, err)
}
if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want)
}
}
for _, tc := range []struct {
buttons string
checkboxes bool
want string
}{
{"list_toolbar", true, "toolbar.buttons must be a list"},
{"list_toolbar", true, `"list_toolbar" is not supported`},
{"[create, delete]", false, "showCheckboxes"},
{"[create, create]", true, "duplicate action create"},
{"[create, export]", true, "unsupported action export"},
{"[{create: true}]", true, "toolbar.buttons"},
} {
_, err := compile(tc.buttons, tc.checkboxes)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want)
}
}
// A controller without a compiled form cannot create: create is dropped.
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get("acme.demo.widgets")
if strings.Join(cc.List.ToolbarButtons, ",") != "delete" {
t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons)
}
}
func TestPhase10Bundle(t *testing.T) {
tr := messagesTranslator(t)
app := backpack.New(nil)
if err := app.Publish(tr); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
if locale != "" {
req = req.WithContext(towel.WithLocale(req.Context(), locale))
}
rec := httptest.NewRecorder()
svc.langBundle(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatal(err)
}
return body.Data, body.Meta.Locale, rec
}
pl, locale, rec := call("pl")
if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control"))
}
if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 {
t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"])
}
if len(pl) < 50 {
t.Fatalf("pl bundle has only %d keys", len(pl))
}
for key := range pl {
if !strings.HasPrefix(key, "backend::lang.") {
t.Fatalf("bundle leaked a key outside backend::lang: %s", key)
}
}
if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") {
t.Fatalf("bundle leaked another namespace: %s", rec.Body.String())
}
en, locale, _ := call("en-GB")
if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"])
}
fallback, locale, _ := call("de")
if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"])
}
// No translator: an empty bundle, still an envelope.
empty := &service{app: backpack.New(nil)}
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
out := httptest.NewRecorder()
empty.langBundle(out, req)
if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) {
t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String())
}
}

View File

@@ -0,0 +1,213 @@
package cabana
import (
"context"
"errors"
"io/fs"
"reflect"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
type contributionPlugin struct {
id string
permissions []pact.Permission
navigation []pact.NavigationItem
settings []pact.SettingsItem
fsys fs.FS
}
func (p contributionPlugin) ID() string { return p.id }
func (p contributionPlugin) Requires() []string { return nil }
func (p contributionPlugin) Register(*backpack.App) error { return nil }
func (p contributionPlugin) Boot(*backpack.App) error { return nil }
func (p contributionPlugin) Permissions() []pact.Permission { return p.permissions }
func (p contributionPlugin) Navigation() []pact.NavigationItem { return p.navigation }
func (p contributionPlugin) Settings() []pact.SettingsItem { return p.settings }
func (p contributionPlugin) AdminFS() fs.FS { return p.fsys }
type metadataController struct{ id string }
func (c metadataController) ID() string { return c.id }
func (metadataController) ModelName() string { return "Metadata" }
func (metadataController) ConfigDir() string { return "controllers/metadata" }
func (metadataController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
type singletonSetting struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (singletonSetting) TableName() string { return "cabana_singleton_settings" }
func (singletonSetting) Fillable() []string { return []string{"enabled"} }
func (singletonSetting) Rules() map[string]string { return map[string]string{"enabled": "boolean"} }
func TestMetadataPermissionRegistry(t *testing.T) {
plugin := metadataPlugin()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{plugin}); err != nil {
t.Fatal(err)
}
grants := reg.rolePermissions("developer")
if !grants["acme.demo.access"] || !grants["acme.demo.manage_settings"] {
t.Fatalf("developer grants = %#v", grants)
}
if grants := reg.rolePermissions("publisher"); len(grants) != 0 {
t.Fatalf("publisher grants = %#v", grants)
}
bad := metadataRegistry()
bad.byID["acme.demo.widgets"].Controller = metadataController{id: "acme.demo.widgets"}
bad.byID["acme.demo.widgets"].Controller = unknownPermissionController{}
if err := compileContributions(bad, []party.Plugin{plugin}); err == nil || !strings.Contains(err.Error(), "unknown permission") {
t.Fatalf("unknown permission error = %v", err)
}
duplicate := contributionPlugin{id: "other.demo", permissions: plugin.permissions}
if err := compileContributions(metadataRegistry(), []party.Plugin{plugin, duplicate}); err == nil || !strings.Contains(err.Error(), "duplicate permission") {
t.Fatalf("duplicate permission error = %v", err)
}
}
func TestMetadataFiltering(t *testing.T) {
plugin := metadataPlugin()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{plugin}); err != nil {
t.Fatal(err)
}
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
navigation, settings := reg.Metadata(context.Background(), developer, nil)
if len(navigation) != 1 || len(navigation[0].SideMenu) != 1 || navigation[0].Code != "demo" || navigation[0].SideMenu[0].Code != "widgets" {
t.Fatalf("developer navigation = %#v", navigation)
}
if len(settings) != 1 || settings[0].Code != "demo" || !reflect.DeepEqual(settings[0].Keywords, []string{"demo", "settings"}) {
t.Fatalf("developer settings = %#v", settings)
}
denied := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{}}
navigation, settings = reg.Metadata(context.Background(), denied, nil)
if navigation == nil || settings == nil || len(navigation) != 0 || len(settings) != 0 {
t.Fatalf("denied metadata = %#v %#v", navigation, settings)
}
frontend := &bouncer.Principal{ID: 1, PermissionGrants: reg.rolePermissions("developer")}
navigation, settings = reg.Metadata(context.Background(), frontend, nil)
if len(navigation) != 0 || len(settings) != 0 {
t.Fatalf("frontend principal leaked metadata = %#v %#v", navigation, settings)
}
}
func TestSettingsSingletonLifecycle(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&singletonSetting{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&singletonSetting{}); err != nil {
t.Fatal(err)
}
setting, err := compileSetting("acme.demo", metadataPlugin().settings[0], metadataFS())
if err != nil {
t.Fatal(err)
}
svc := SettingsService{DB: db}
missing, err := svc.Get(context.Background(), setting)
if err != nil || missing.Exists || missing.Data["enabled"] != false {
t.Fatalf("missing = %#v err=%v", missing, err)
}
assertSettingCount(t, db, 0)
if _, err := svc.Put(context.Background(), setting, map[string]any{}); err == nil {
t.Fatal("missing required setting succeeded")
}
created, err := svc.Put(context.Background(), setting, map[string]any{"enabled": true, "id": 99})
if err != nil || !created.Exists || created.Data["enabled"] != true {
t.Fatalf("created = %#v err=%v", created, err)
}
assertSettingCount(t, db, 1)
var before singletonSetting
if err := db.First(&before, 1).Error; err != nil {
t.Fatal(err)
}
replayed, err := svc.Put(context.Background(), setting, map[string]any{"enabled": true})
if err != nil || replayed.Data["enabled"] != true {
t.Fatalf("replay = %#v err=%v", replayed, err)
}
var after singletonSetting
if err := db.First(&after, 1).Error; err != nil {
t.Fatal(err)
}
if !after.UpdatedAt.Equal(before.UpdatedAt) || after.ID != 1 {
t.Fatalf("replay mutated singleton before=%+v after=%+v", before, after)
}
if _, err := svc.Put(context.Background(), setting, map[string]any{"enabled": "not-bool"}); err == nil {
t.Fatal("invalid boolean succeeded")
} else {
var invalid *ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("invalid boolean error = %T %v", err, err)
}
}
var preserved singletonSetting
if err := db.First(&preserved, 1).Error; err != nil || !preserved.Enabled {
t.Fatalf("failed update was not rolled back: %+v err=%v", preserved, err)
}
}
type unknownPermissionController struct{}
func (unknownPermissionController) ID() string { return "acme.demo.widgets" }
func (unknownPermissionController) ModelName() string { return "Metadata" }
func (unknownPermissionController) ConfigDir() string { return "controllers/metadata" }
func (unknownPermissionController) RequiredPermissions() []string {
return []string{"acme.demo.unknown"}
}
func metadataRegistry() *Registry {
return &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {Controller: metadataController{id: "acme.demo.widgets"}, Relations: map[string]*CompiledRelation{}},
}}
}
func metadataPlugin() contributionPlugin {
return contributionPlugin{
id: "acme.demo",
permissions: []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.manage_settings", Roles: []string{"developer"}},
},
navigation: []pact.NavigationItem{{
Code: "demo", Label: "Demo", Icon: "icon", Order: 20, Controller: "acme.demo.widgets",
Permissions: []string{"acme.demo.*"},
SideMenu: []pact.NavigationItem{{Code: "widgets", Label: "Widgets", Controller: "acme.demo.widgets", Permissions: []string{"acme.demo.access"}}},
}},
settings: []pact.SettingsItem{{
Code: "demo", Label: "Settings", Description: "Description", Category: "Demo", Icon: "icon-cog",
Model: "Settings", Order: 20, Keywords: []string{"demo", "settings"}, Permissions: []string{"acme.demo.manage_settings"},
Form: "models/settings/fields.yaml", NewModel: func() any { return &singletonSetting{} },
}},
fsys: metadataFS(),
}
}
func metadataFS() fs.FS {
return fstest.MapFS{"models/settings/fields.yaml": {Data: []byte("fields:\n enabled:\n type: switch\n default: 0\n required: true\n")}}
}
func assertSettingCount(t *testing.T, db *gorm.DB, want int64) {
t.Helper()
var got int64
if err := db.Model(&singletonSetting{}).Count(&got).Error; err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("settings count=%d want=%d", got, want)
}
}

View File

@@ -0,0 +1,94 @@
package cabana
import (
"context"
"sort"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
// NavigationEntry is one permission-filtered backend navigation item.
type NavigationEntry struct {
Code string `json:"code"`
Label string `json:"label"`
Icon string `json:"icon"`
Order int `json:"order"`
Controller string `json:"controller"`
SideMenu []NavigationEntry `json:"sideMenu"`
}
// SettingsEntry is one permission-filtered settings-list item.
type SettingsEntry struct {
Code string `json:"code"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Icon string `json:"icon"`
Order int `json:"order"`
Keywords []string `json:"keywords"`
Model string `json:"model"`
}
// Metadata returns only entries the backend principal may open. Denied
// entries are removed before any response value is constructed.
func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, tr *phrasebook.Translator) ([]NavigationEntry, []SettingsEntry) {
navigation := make([]NavigationEntry, 0)
settings := make([]SettingsEntry, 0)
if r == nil || principal == nil || !principal.Backend {
return navigation, settings
}
for _, item := range r.navigation {
children := make([]NavigationEntry, 0)
for _, child := range item.SideMenu {
if !Allows(principal, child.Permissions) {
continue
}
children = append(children, navigationView(ctx, tr, child, nil))
}
if !Allows(principal, item.Permissions) && len(children) == 0 {
continue
}
navigation = append(navigation, navigationView(ctx, tr, item, children))
}
sort.SliceStable(navigation, func(i, j int) bool {
if navigation[i].Order == navigation[j].Order {
return navigation[i].Code < navigation[j].Code
}
return navigation[i].Order < navigation[j].Order
})
for _, compiled := range r.settings {
item := compiled.Item
if !Allows(principal, item.Permissions) {
continue
}
keywords := append([]string(nil), item.Keywords...)
if keywords == nil {
keywords = []string{}
}
settings = append(settings, SettingsEntry{
Code: item.Code, Label: translateKey(ctx, tr, item.Label),
Description: translateKey(ctx, tr, item.Description),
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
Order: item.Order, Keywords: keywords, Model: item.Model,
})
}
sort.SliceStable(settings, func(i, j int) bool {
if settings[i].Order == settings[j].Order {
return settings[i].Code < settings[j].Code
}
return settings[i].Order < settings[j].Order
})
return navigation, settings
}
func navigationView(ctx context.Context, tr *phrasebook.Translator, item pact.NavigationItem, children []NavigationEntry) NavigationEntry {
if children == nil {
children = []NavigationEntry{}
}
return NavigationEntry{
Code: item.Code, Label: translateKey(ctx, tr, item.Label), Icon: item.Icon,
Order: item.Order, Controller: item.Controller, SideMenu: children,
}
}

View File

@@ -0,0 +1,494 @@
package cabana_test
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"testing/fstest"
"time"
"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/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
// conformCase is one inventoried admin route called for real through the
// assembled router. decode must accept the body with unknown fields
// disallowed, and ref is the success schema admin.json documents.
type conformCase struct {
key string
status int
ref string
call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
decode func(dec *json.Decoder) error
}
func into[T any]() func(*json.Decoder) error {
return func(dec *json.Decoder) error {
var out T
return dec.Decode(&out)
}
}
// TestPhase10OpenAPIConformance calls every route of the admin inventory
// against a PostgreSQL-backed fixture registry (acme names only), decodes
// each real response into the Go type its annotation documents with unknown
// fields disallowed, and checks admin/openapi/admin.json names that type for
// the same path, method and status (D-15, D-16; Pitfall 8).
func TestPhase10OpenAPIConformance(t *testing.T) {
env := newConformEnv(t)
spec := conformSpec(t)
cases := []conformCase{
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false)
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
{"POST /auth/refresh", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true)
e.token = accessToken(t, rec.Body.Bytes())
return rec
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
{"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/auth/me", nil, true)
}, into[cabana.Envelope[cabana.AdminProfile]]()},
{"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/lang", nil, false)
}, into[cabana.Envelope[cabana.LangBundle]]()},
{"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/navigation", nil, true)
}, into[cabana.Envelope[[]cabana.NavigationEntry]]()},
{"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings", nil, true)
}, into[cabana.Envelope[[]cabana.SettingsEntry]]()},
{"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]]()},
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
}, into[cabana.Envelope[cabana.SettingsResult]]()},
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
}, into[cabana.Envelope[cabana.SettingsResult]]()},
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
}, into[cabana.Envelope[cabana.ListSchema]]()},
{"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true)
}, into[cabana.Envelope[cabana.FormView]]()},
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", 200, "cabana.Envelope-cabana_RelationSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/relation/members", nil, true)
}, into[cabana.Envelope[cabana.RelationSchema]]()},
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.RelationOption]]()},
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
}, into[cabana.Envelope[[]cabana.FilterOption]]()},
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true)
e.gadgetID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.RecordEnvelope]()},
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
{"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.RecordEnvelope]()},
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true)
}, into[cabana.RecordEnvelope]()},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]]()},
}
inventory := map[string]bool{}
for _, key := range cabana.AdminAPIRoutes() {
inventory[key] = true
}
covered := map[string]bool{}
for _, tc := range cases {
if !inventory[tc.key] {
t.Fatalf("conformance case %s is not an inventoried admin route", tc.key)
}
if covered[tc.key] {
t.Fatalf("duplicate conformance case %s", tc.key)
}
covered[tc.key] = true
}
for key := range inventory {
if !covered[key] {
t.Fatalf("admin route %s has no conformance case", key)
}
}
for _, tc := range cases {
ok := t.Run(tc.key, func(t *testing.T) {
rec := tc.call(t, env)
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := tc.decode(dec); err != nil {
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
if got != tc.ref {
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
}
})
if !ok {
t.FailNow()
}
}
}
type conformSpecDoc struct {
Paths map[string]map[string]struct {
Responses map[string]struct {
Content map[string]struct {
Schema struct {
Ref string `json:"$ref"`
} `json:"schema"`
} `json:"content"`
} `json:"responses"`
} `json:"paths"`
}
func (d conformSpecDoc) ref(path, method, status string) string {
ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref
return strings.TrimPrefix(ref, "#/components/schemas/")
}
func conformSpec(t *testing.T) conformSpecDoc {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
raw, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.json"))
if err != nil {
t.Fatal(err)
}
var doc conformSpecDoc
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
return doc
}
type conformEnv struct {
h http.Handler
login string
token string
stamp string
groupID uint
memberID uint
gadgetID uint
}
func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool) *httptest.ResponseRecorder {
t.Helper()
var payload []byte
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
payload = raw
}
req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "pl")
if auth {
req.Header.Set("Authorization", "Bearer "+e.token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
if method == http.MethodPost && rel == "/auth/login" && rec.Code == http.StatusOK {
e.token = accessToken(t, rec.Body.Bytes())
}
return rec
}
func dataID(t *testing.T, raw []byte) uint {
t.Helper()
var body struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil || body.Data.ID == 0 {
t.Fatalf("no data.id in %s: %v", raw, err)
}
return body.Data.ID
}
func newConformEnv(t *testing.T) *conformEnv {
t.Helper()
gdb := adminGorm(t)
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformSettings{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
stamp := fmt.Sprintf("c%d", time.Now().UnixNano())
group := conformGroup{Title: "Group " + stamp}
member := conformMember{Email: "member-" + stamp + "@example.test"}
if err := gdb.Create(&group).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&member).Error; err != nil {
t.Fatal(err)
}
login := "conform-" + stamp
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-conform\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
plugins := []party.Plugin{conformPlugin{}}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
return &conformEnv{h: h, login: login, stamp: stamp, groupID: group.ID, memberID: member.ID}
}
type conformGadget struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
GroupID *uint `gorm:"column:group_id"`
Members []conformMember `gorm:"-"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
func (conformGadget) Fillable() []string { return []string{"name", "active"} }
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
func (conformGadget) FilterScope(_ string, db *gorm.DB, value any) *gorm.DB {
return db.Where("group_id = ?", value)
}
func (conformGadget) FilterOptions(string) []pact.Option {
return []pact.Option{{Value: "1", Label: "backend::lang.form.none"}, {Value: "2", Label: "Second"}}
}
type conformGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func (conformGroup) TableName() string { return "cabana_conform_groups" }
type conformMember struct {
ID uint `gorm:"column:id;primaryKey"`
Email string `gorm:"column:email"`
}
func (conformMember) TableName() string { return "cabana_conform_members" }
type conformGadgetMember struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID uint `gorm:"column:gadget_id"`
MemberID uint `gorm:"column:member_id"`
}
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
}
func (conformSettings) TableName() string { return "cabana_conform_settings" }
func (conformSettings) Fillable() []string { return []string{"enabled"} }
func (conformSettings) Rules() map[string]string { return map[string]string{} }
type conformPlugin struct{}
func (conformPlugin) ID() string { return "acme.conform" }
func (conformPlugin) Requires() []string { return nil }
func (conformPlugin) Register(*backpack.App) error { return nil }
func (conformPlugin) Boot(*backpack.App) error { return nil }
func (conformPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{conformController{}}
}
func (conformPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
}
func (conformPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{Code: "conform", Label: "Conform", Icon: "box", Order: 10, Controller: "acme.conform.gadgets",
Permissions: []string{"acme.conform.access"},
SideMenu: []pact.NavigationItem{{Code: "gadgets", Label: "Gadgets", Icon: "box", Controller: "acme.conform.gadgets", Permissions: []string{"acme.conform.access"}}}}}
}
func (conformPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{Code: "conform", Label: "Conform", Description: "Conform settings", Category: "Acme", Icon: "settings",
Model: "ConformSettings", Order: 10, Permissions: []string{"acme.conform.access"},
Form: "models/settings/fields.yaml", NewModel: func() any { return &conformSettings{} }}}
}
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
type conformController struct{}
func (conformController) ID() string { return "acme.conform.gadgets" }
func (conformController) ModelName() string { return "Gadget" }
func (conformController) ConfigDir() string { return "controllers/gadgets" }
func (conformController) RequiredPermissions() []string { return []string{"acme.conform.access"} }
func (conformController) NewRecord() any { return &conformGadget{} }
func (conformController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
}}
}
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
}
func conformFS() fs.FS {
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
return fstest.MapFS{
"controllers/gadgets/config_list.yaml": file(`list: ~/plugins/acme/conform/models/gadget/columns.yaml
modelClass: Gadget
title: Gadgets
recordUrl: acme/conform/gadgets/update/:id
recordsPerPage: 20
showCheckboxes: true
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
`),
"controllers/gadgets/config_filter.yaml": file(`scopes:
grouped:
label: Group
modelClass: Group
nameFrom: title
scope: filterByGroup
active:
label: Active
type: switch
column: active
`),
"controllers/gadgets/config_form.yaml": file(`name: New gadget
form: ~/plugins/acme/conform/models/gadget/fields.yaml
modelClass: Gadget
defaultRedirect: acme/conform/gadgets
create:
redirect: acme/conform/gadgets/update/:id
redirectClose: acme/conform/gadgets
update:
redirect: acme/conform/gadgets
redirectClose: acme/conform/gadgets
`),
"controllers/gadgets/config_relation.yaml": file(`members:
label: Members
view:
list:
columns:
email:
label: Email
toolbarButtons: link|unlink
showSearch: true
manage:
list:
columns:
email:
label: Email
showSearch: true
`),
"models/gadget/columns.yaml": file(`columns:
name:
label: Name
searchable: true
active:
label: Active
type: switch
`),
"models/gadget/fields.yaml": file(`fields:
name:
label: Name
type: text
required: true
span: left
active:
label: Active
type: switch
default: false
group:
label: Group
type: relation
nameFrom: title
emptyOption: backend::lang.form.none
members:
type: relation-manager
relation: members
context: [update]
`),
"models/settings/fields.yaml": file(`fields:
enabled:
label: Enabled
type: switch
default: 0
`),
}
}

View File

@@ -0,0 +1,116 @@
package cabana
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// 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), "..", "admin", "openapi", "admin.json"))
raw, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read %s: %v", specPath, err)
}
var spec struct {
Paths map[string]map[string]struct {
Responses map[string]json.RawMessage `json:"responses"`
Security []map[string]json.RawMessage `json:"security"`
} `json:"paths"`
Components struct {
SecuritySchemes map[string]json.RawMessage `json:"securitySchemes"`
} `json:"components"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatal(err)
}
if _, ok := spec.Components.SecuritySchemes["BackendBearer"]; !ok {
t.Fatal("openapi is missing the BackendBearer scheme")
}
public := map[string]bool{
"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)
}
method = strings.ToLower(method)
ops, ok := spec.Paths[path]
if !ok {
t.Fatalf("openapi missing %s", path)
}
op, ok := ops[method]
if !ok {
t.Fatalf("openapi missing %s %s", method, path)
}
key := route.key
if seen[key] {
t.Fatalf("duplicate contract route %s", key)
}
seen[key] = true
_, ok200 := op.Responses["200"]
_, ok201 := op.Responses["201"]
if !ok200 && !ok201 {
t.Fatalf("%s has no 200 or 201 response", key)
}
if route.public {
// Login and refresh answer 401; the public string bundle does not.
if _, ok := op.Responses["401"]; public[key] && !ok {
t.Fatalf("%s has no 401 response", key)
}
continue
}
if len(op.Security) == 0 {
t.Fatalf("%s has no BackendBearer security requirement", key)
}
if _, ok := op.Responses["401"]; !ok {
t.Fatalf("%s has no 401 response", key)
}
}
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)))
}
}
func splitRoute(key string) (method, path string, ok bool) {
for i := 0; i < len(key); i++ {
if key[i] == ' ' {
return key[:i], key[i+1:], key[i+1:] != ""
}
}
return "", "", false
}
func pathsOf(routes []adminRoute) 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
}

View File

@@ -0,0 +1,352 @@
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" }

View File

@@ -0,0 +1,483 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
// refreshSubjects is a map-backed bouncer.UserProvider for refresh tests
// that run without a database; err, when set, is returned for every lookup.
type refreshSubjects struct {
byID map[uint]*bouncer.Principal
err error
}
func (p refreshSubjects) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) {
if p.err != nil {
return nil, p.err
}
return p.byID[id], nil
}
// emptyOptionsRow is a filter source whose scope has no choices yet;
// literalOptionsRow's choice label is a literal rather than a phrase key.
type emptyOptionsRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
}
func (emptyOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
func (emptyOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db }
func (emptyOptionsRow) FilterOptions(string) []pact.Option { return []pact.Option{} }
type literalOptionsRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
}
func (literalOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
func (literalOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db }
func (literalOptionsRow) FilterOptions(string) []pact.Option {
return []pact.Option{{Value: "x", Label: "Literal label"}}
}
// TestPhase10Coverage fills the Phase 10 cabana branches the feature tests
// left without a named case: the mounted route table against the CSRF walk,
// relation and filter option edges, read-only relation labels, relation
// message defaults, the bundle's configured fallback and a cookie refresh of
// an expired access token inside the refresh window.
func TestPhase10Coverage(t *testing.T) {
t.Run("every unsafe mounted route is CSRF-walked", func(t *testing.T) {
router := &handlerRouter{handlers: map[string]http.HandlerFunc{}}
svc := phase09DeniedService()
svc.mount(router)
api := adminAPI("")
var unsafe, safe []string
for _, key := range router.order {
method, path, _ := strings.Cut(key, " ")
rel := strings.TrimPrefix(path, api)
switch method {
case http.MethodGet:
safe = append(safe, rel)
case http.MethodPost, http.MethodPut, http.MethodDelete:
unsafe = append(unsafe, method+" "+rel)
default:
t.Fatalf("unexpected method mounted: %s", key)
}
}
sort.Strings(unsafe)
want := []string{
"DELETE /{vendor}/{plugin}/{controller}/{id}",
"POST /auth/login",
"POST /auth/logout",
"POST /auth/refresh",
"POST /{vendor}/{plugin}/{controller}",
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
"PUT /settings/{code}",
"PUT /{vendor}/{plugin}/{controller}/{id}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 9 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.
for _, need := range []string{"/lang", "/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"} {
found := false
for _, rel := range safe {
found = found || rel == need
}
if !found {
t.Fatalf("GET %s is not mounted: %v", need, safe)
}
}
// A safe read with only the cookie is never refused by the CSRF check.
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only"})
rec := httptest.NewRecorder()
router.handlers[http.MethodGet+" "+adminAPI("/lang")](rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("cookie-only GET /lang status=%d", rec.Code)
}
})
t.Run("relation option edges", func(t *testing.T) {
svc, _, _ := p10Fixture(t)
decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []RelationOption `json:"data"`
Meta ListMeta `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data == nil {
t.Fatalf("data is null: %s", rec.Body.String())
}
return body.Data, body.Meta
}
all, allMeta := decode(p10Options(svc, "tags", "", p10Principal(true)))
for _, query := range []string{"search=", "search=%20%20"} {
rows, meta := decode(p10Options(svc, "tags", query, p10Principal(true)))
if len(rows) != len(all) || meta.Total != allMeta.Total {
t.Fatalf("%s is not a no-op search: %d rows, total %d", query, len(rows), meta.Total)
}
}
beyond, meta := decode(p10Options(svc, "tags", "page=9", p10Principal(true)))
if len(beyond) != 0 || meta.Page != 9 || meta.Total != allMeta.Total || meta.LastPage != allMeta.LastPage {
t.Fatalf("page beyond last: rows=%d meta=%+v", len(beyond), meta)
}
capped, cappedMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true)))
if cappedMeta.PerPage != 100 || len(capped) != len(all) {
t.Fatalf("per_page at the cap: %+v", cappedMeta)
}
above := p10Options(svc, "tags", "per_page=1000", p10Principal(true))
if above.Code != http.StatusUnprocessableEntity || !strings.Contains(above.Body.String(), `"per_page"`) {
t.Fatalf("per_page above the cap status=%d body=%s", above.Code, above.Body.String())
}
none, noneMeta := decode(p10Options(svc, "tags", "search=no-such-tag", p10Principal(true)))
if len(none) != 0 || noneMeta.Total != 0 || noneMeta.LastPage < 1 {
t.Fatalf("empty result: %d rows meta=%+v", len(none), noneMeta)
}
})
t.Run("filter option edges", func(t *testing.T) {
granted := &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"acme.demo.access": true}}
empty := filterOptionsCall(filterOptionsService(t, &emptyOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "", granted)
if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), `"data":[]`) {
t.Fatalf("no choices status=%d body=%s", empty.Code, empty.Body.String())
}
literal := filterOptionsCall(filterOptionsService(t, &literalOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "pl", granted)
if literal.Code != http.StatusOK || !strings.Contains(literal.Body.String(), `{"value":"x","label":"Literal label"}`) {
t.Fatalf("literal label status=%d body=%s", literal.Code, literal.Body.String())
}
})
t.Run("read-only relation label outside the options scope", func(t *testing.T) {
svc, db, seed := p10Fixture(t)
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "labelled"}), http.StatusCreated)
id := fmt.Sprintf("%d", p10ID(created.Data["id"]))
// The options hook hides every person (1 = 0), yet the label shows.
shown := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK)
if p10ID(shown.Data["person"]) != seed.person || len(shown.Meta.Labels["person"]) != 1 || shown.Meta.Labels["person"][0].Label != "admin@acme.test" {
t.Fatalf("read-only label data=%v labels=%+v", shown.Data["person"], shown.Meta.Labels["person"])
}
// A dangling foreign key keeps the value and gets no label.
if err := db.Delete(&p10Person{}, seed.person).Error; err != nil {
t.Fatal(err)
}
dangling := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK)
if labels, ok := dangling.Meta.Labels["person"]; !ok || len(labels) != 0 {
t.Fatalf("dangling person labels=%+v", dangling.Meta.Labels)
}
// Saving never writes the read-only key.
p10Decode(t, p10Save(svc, http.MethodPut, id, map[string]any{"name": "again", "person": 42}), http.StatusOK)
if stored := p10Stored(t, db, p10ID(created.Data["id"])); stored.UserID != seed.person {
t.Fatalf("read-only foreign key written: %+v", stored)
}
})
t.Run("relation messages default every key", func(t *testing.T) {
tr := messagesTranslator(t)
ctl := relationTestController{}
fsys := relationTestFS(validRelationYAML)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
for _, locale := range []string{"pl", "en"} {
view := relations["editors"].Schema.Localize(localeCtx(locale), tr)
raw, err := json.Marshal(view.Messages)
if err != nil {
t.Fatal(err)
}
var messages map[string]map[string]string
if err := json.Unmarshal(raw, &messages); err != nil {
t.Fatal(err)
}
for _, key := range []string{"link", "linkHint", "candidateSearch", "linked", "unlinkSelected", "unlinkConfirm", "unlinked", "empty"} {
if messages[key]["other"] == "" || strings.Contains(messages[key]["other"], "backend::") {
t.Fatalf("%s %s default = %v", locale, key, messages[key])
}
}
if locale == "pl" && len(messages["linked"]) != 4 {
t.Fatalf("pl linked is not plural: %v", messages["linked"])
}
}
if err := validateMessageKeys(&Registry{byID: map[string]*CompiledController{
"acme.demo.owners": {Controller: ctl, PluginID: "acme.demo", Relations: relations},
}}, tr); err != nil {
t.Fatalf("framework defaults failed validation: %v", err)
}
})
t.Run("form-less controller drops create from any toolbar", func(t *testing.T) {
config := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\ntoolbar:\n buttons: [create]\n"
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(config, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get("acme.demo.widgets")
if len(cc.List.ToolbarButtons) != 0 || cc.List.ToolbarButtons == nil {
t.Fatalf("toolbar=%#v, want an empty list", cc.List.ToolbarButtons)
}
})
t.Run("bundle falls back to app.fallback_locale", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: coverage\nlocale: pl\nfallback_locale: pl\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)
}
app := backpack.New(cfg)
if err := phrasebook.Activate(app, []langPlugin{}); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
for _, requested := range []string{"de", ""} {
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
if requested != "" {
req = req.WithContext(localeCtx(requested))
}
rec := httptest.NewRecorder()
svc.langBundle(rec, req)
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Meta.Locale != "pl" || body.Data["backend::lang.form.save"]["other"] != "Zapisz" {
t.Fatalf("requested %q: locale=%q save=%v", requested, body.Meta.Locale, body.Data["backend::lang.form.save"])
}
}
})
t.Run("cookie refresh of an expired access token inside the refresh window", func(t *testing.T) {
const secret = "phase10-coverage-secret"
svc := &service{
secret: secret,
ttl: 15 * time.Minute,
refreshTTL: 2 * time.Hour,
issuer: "https://app.test" + DefaultAdminPrefix,
bl: bouncer.NewMemoryBlacklist(),
users: refreshSubjects{byID: map[uint]*bouncer.Principal{5: {ID: 5, Backend: true}}},
}
sign := func(iat, exp time.Time, jti string) string {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "5", "aud": bouncer.AudienceBackend, "iss": svc.issuer, "jti": jti,
"iat": iat.Unix(), "nbf": iat.Unix(), "exp": exp.Unix(),
}).SignedString([]byte(secret))
if err != nil {
t.Fatal(err)
}
return tok
}
call := func(token string, cookie bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/refresh"), nil)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if cookie {
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: token})
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
requireAjax(svc.refresh)(rec, req)
return rec
}
now := time.Now()
expired := sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "expired-in-window")
if _, _, _, _, err := bouncer.VerifyClaimsAudience(expired, secret, bouncer.AudienceBackend); err == nil {
t.Fatal("fixture token is not expired")
}
rec := call(expired, true)
if rec.Code != http.StatusOK {
t.Fatalf("cookie refresh in window status=%d body=%s", rec.Code, rec.Body.String())
}
var next *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == AdminCookieName {
next = c
}
}
if next == nil || next.Value == "" || next.Value == expired || !next.HttpOnly || !next.Secure || next.SameSite != http.SameSiteStrictMode ||
next.Path != DefaultAdminPrefix || next.MaxAge != int((2*time.Hour)/time.Second) {
t.Fatalf("refreshed cookie = %+v", next)
}
if strings.Contains(rec.Body.String(), "access_token") || strings.Contains(rec.Body.String(), next.Value) {
t.Fatalf("cookie refresh put the token in the body: %s", rec.Body.String())
}
if _, _, _, _, err := bouncer.VerifyClaimsAudience(next.Value, secret, bouncer.AudienceBackend); err != nil {
t.Fatalf("refreshed cookie does not verify: %v", err)
}
// The old token is blacklisted by the rotation: a second refresh fails.
if again := call(expired, true); again.Code != http.StatusUnauthorized {
t.Fatalf("replayed refresh status=%d", again.Code)
}
bearer := call(sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "bearer-in-window"), false)
if bearer.Code != http.StatusOK || !strings.Contains(bearer.Body.String(), `"access_token"`) || len(bearer.Result().Cookies()) != 0 {
t.Fatalf("bearer refresh status=%d body=%s", bearer.Code, bearer.Body.String())
}
stale := call(sign(now.Add(-3*time.Hour), now.Add(-170*time.Minute), "outside-window"), true)
if stale.Code != http.StatusUnauthorized || len(stale.Result().Cookies()) != 0 {
t.Fatalf("refresh outside the window status=%d cookies=%v", stale.Code, stale.Result().Cookies())
}
missing := call("", true)
if missing.Code != http.StatusUnauthorized {
t.Fatalf("refresh without a token status=%d", missing.Code)
}
frontend := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "5", "aud": bouncer.AudienceUser, "jti": "frontend", "iat": now.Unix(), "exp": now.Add(time.Minute).Unix(),
})
frontendTok, err := frontend.SignedString([]byte(secret))
if err != nil {
t.Fatal(err)
}
if cross := call(frontendTok, true); cross.Code != http.StatusUnauthorized {
t.Fatalf("frontend token refreshed through the admin cookie: %d", cross.Code)
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"token_type"`)) {
t.Fatalf("cookie refresh body=%s", rec.Body.String())
}
})
t.Run("refresh enforces the guard's subject checks", func(t *testing.T) {
const secret = "phase10-subject-secret"
now := time.Now()
cutoff := now.Add(-10 * time.Minute)
subjects := refreshSubjects{byID: map[uint]*bouncer.Principal{
5: {ID: 5, Backend: true, TokensValidAfter: cutoff},
}}
newService := func(users bouncer.UserProvider) *service {
return &service{
secret: secret,
ttl: 15 * time.Minute,
refreshTTL: 2 * time.Hour,
issuer: "https://app.test" + DefaultAdminPrefix,
bl: bouncer.NewMemoryBlacklist(),
users: users,
}
}
sign := func(sub string, iat time.Time, jti string) string {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": sub, "aud": bouncer.AudienceBackend, "jti": jti,
"iat": iat.Unix(), "nbf": iat.Unix(), "exp": iat.Add(15 * time.Minute).Unix(),
}).SignedString([]byte(secret))
if err != nil {
t.Fatal(err)
}
return tok
}
call := func(svc *service, token string, cookie bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/refresh"), nil)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if cookie {
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: token})
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
requireAjax(svc.refresh)(rec, req)
return rec
}
errorCode := func(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
}
assertExpired := func(name string, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized || errorCode(rec) != "unauthenticated" {
t.Fatalf("%s: status=%d body=%s, want 401 unauthenticated", name, rec.Code, rec.Body.String())
}
var expired *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == AdminCookieName {
expired = c
}
}
if expired == nil || expired.Value != "" || expired.MaxAge >= 0 || expired.Path != DefaultAdminPrefix {
t.Fatalf("%s: cookie = %+v, want an expiring %s with Path %s", name, expired, AdminCookieName, DefaultAdminPrefix)
}
}
assertNoCookies := func(name string, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized || errorCode(rec) != "unauthenticated" {
t.Fatalf("%s: status=%d body=%s, want 401 unauthenticated", name, rec.Code, rec.Body.String())
}
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
t.Fatalf("%s: set cookies %q", name, got)
}
}
svc := newService(subjects)
assertExpired("pre-cutoff cookie", call(svc, sign("5", cutoff.Add(-time.Minute), "pre-cutoff-cookie"), true))
assertExpired("unknown subject cookie", call(svc, sign("6", now.Add(-time.Minute), "unknown-cookie"), true))
assertNoCookies("pre-cutoff bearer", call(svc, sign("5", cutoff.Add(-time.Minute), "pre-cutoff-bearer"), false))
failing := newService(refreshSubjects{err: errors.New("lookup failed")})
assertNoCookies("provider error cookie", call(failing, sign("5", now.Add(-time.Minute), "provider-error"), true))
ok := call(svc, sign("5", cutoff.Add(time.Minute), "post-cutoff-cookie"), true)
if ok.Code != http.StatusOK {
t.Fatalf("post-cutoff cookie refresh status=%d body=%s", ok.Code, ok.Body.String())
}
var rotated *http.Cookie
for _, c := range ok.Result().Cookies() {
if c.Name == AdminCookieName {
rotated = c
}
}
if rotated == nil || rotated.Value == "" || rotated.MaxAge <= 0 || rotated.Path != DefaultAdminPrefix {
t.Fatalf("post-cutoff rotated cookie = %+v", rotated)
}
})
}

View File

@@ -0,0 +1,154 @@
package cabana
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestPhase10CSRF walks every state-changing route service.mount registers.
// Except login, a request carrying only the admin cookie and no
// X-Requested-With header is refused with 403 forbidden before the handler
// runs: the body spy is never read and the service, which has no database,
// never answers with a database error. The same call with the header or with
// a Bearer token reaches the handler.
func TestPhase10CSRF(t *testing.T) {
router := &handlerRouter{handlers: map[string]http.HandlerFunc{}}
svc := phase09DeniedService()
svc.mount(router)
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
login := http.MethodPost + " " + adminAPI("/auth/login")
unsafe := 0
for _, key := range router.order {
method, path, _ := strings.Cut(key, " ")
if method != http.MethodPost && method != http.MethodPut && method != http.MethodDelete {
continue
}
if key == login {
continue
}
unsafe++
handler := router.handlers[key]
t.Run(key, func(t *testing.T) {
refused, spy := csrfRequest(method, path, super)
refused.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only-session"})
rec := httptest.NewRecorder()
handler(rec, refused)
if rec.Code != http.StatusForbidden {
t.Fatalf("cookie-only status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
if spy.reads != 0 {
t.Fatalf("refused request body was read %d times", spy.reads)
}
withHeader, _ := csrfRequest(method, path, super)
withHeader.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only-session"})
withHeader.Header.Set("X-Requested-With", "XMLHttpRequest")
rec = httptest.NewRecorder()
handler(rec, withHeader)
if rec.Code == http.StatusForbidden {
t.Fatalf("request with X-Requested-With was refused: %s", rec.Body.String())
}
withBearer, _ := csrfRequest(method, path, super)
withBearer.Header.Set("Authorization", "Bearer not-a-real-token")
rec = httptest.NewRecorder()
handler(rec, withBearer)
if rec.Code == http.StatusForbidden {
t.Fatalf("Bearer request was refused: %s", rec.Body.String())
}
})
}
// refresh, logout, settings put, create, bulk-delete, update, delete, link, unlink
if unsafe != 9 {
t.Fatalf("walked %d state-changing routes, want 9: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]
req, _ := csrfRequest(http.MethodPost, adminAPI("/auth/login"), nil)
rec := httptest.NewRecorder()
loginHandler(rec, req)
if rec.Code == http.StatusForbidden {
t.Fatalf("login without the header was refused: %s", rec.Body.String())
}
}
type readSpy struct {
r io.Reader
reads int
}
func (s *readSpy) Read(p []byte) (int, error) {
s.reads++
return s.r.Read(p)
}
func csrfRequest(method, path string, principal *bouncer.Principal) (*http.Request, *readSpy) {
spy := &readSpy{r: strings.NewReader(`{"ids":[1],"name":"csrf"}`)}
req := httptest.NewRequest(method, path, spy)
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")
req.SetPathValue("id", "1")
req.SetPathValue("name", "editors")
req.SetPathValue("code", "demo")
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req, spy
}
// handlerRouter records the handler mounted for every route, so tests can
// call exactly what service.mount registered.
type handlerRouter struct {
prefix string
handlers map[string]http.HandlerFunc
order []string
}
func (h *handlerRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
h.GroupRaw(prefix, middleware, fn)
}
func (h *handlerRouter) GroupRaw(prefix string, _ []string, fn func(pact.Router)) {
child := &handlerRouter{prefix: h.prefix + prefix, handlers: h.handlers}
fn(child)
h.order = append(h.order, child.order...)
}
func (h *handlerRouter) Get(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodGet, path, fn)
}
func (h *handlerRouter) Post(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodPost, path, fn)
}
func (h *handlerRouter) Put(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodPut, path, fn)
}
func (h *handlerRouter) Patch(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodPatch, path, fn)
}
func (h *handlerRouter) Delete(path string, fn http.HandlerFunc, _ ...string) {
h.add(http.MethodDelete, path, fn)
}
func (h *handlerRouter) Where(string, string) {}
func (h *handlerRouter) WhereIn(string, ...string) {}
func (h *handlerRouter) add(method, path string, fn http.HandlerFunc) {
key := method + " " + h.prefix + path
h.handlers[key] = fn
h.order = append(h.order, key)
}

49
modules/cabana/prefix.go Normal file
View File

@@ -0,0 +1,49 @@
package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/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
}

544
modules/cabana/query.go Normal file
View File

@@ -0,0 +1,544 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ListInput is the raw admin list query. Identifiers are matched later
// against the compiled schema; nothing here is SQL.
type ListInput struct {
Search string
Sort string
Dir string
Page string
PerPage string
Filters map[string]string
}
// ListMeta is the D-11 pagination block.
type ListMeta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
}
// ListResult is one page of projected rows.
type ListResult struct {
Data []map[string]any
Meta ListMeta
}
// ListValidationError is a D-10 validation_failed failure.
type ListValidationError struct {
Details map[string]any
}
func (e *ListValidationError) Error() string { return "validation_failed" }
func listInvalid(field, message string) *ListValidationError {
return &ListValidationError{Details: map[string]any{field: []string{message}}}
}
func listQueryFromRequest(r *http.Request) ListInput {
q := r.URL.Query()
filters := map[string]string{}
for key, vals := range q {
if !strings.HasPrefix(key, "filter[") || !strings.HasSuffix(key, "]") || len(key) <= len("filter[]") {
continue
}
name := key[len("filter[") : len(key)-1]
if len(vals) == 0 {
filters[name] = ""
continue
}
filters[name] = vals[len(vals)-1]
}
return ListInput{
Search: q.Get(listSearchTerm),
Sort: q.Get("sort"),
Dir: q.Get("dir"),
Page: q.Get("page"),
PerPage: q.Get("per_page"),
Filters: filters,
}
}
// ExecuteList runs an allowlisted search, sort, filter, and page against db.
func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in ListInput) (*ListResult, error) {
if cc == nil || cc.List == nil {
return nil, errors.New("cabana: list schema is missing")
}
if db == nil {
return nil, errors.New("cabana: database is not configured")
}
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil, errors.New("cabana: admin controller has no record source")
}
model := src.NewRecord()
mt := reflect.TypeOf(model)
if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct {
return nil, errors.New("cabana: admin model must be a pointer")
}
if ctx == nil {
ctx = context.Background()
}
page, per, err := normalizePage(cc.List, in)
if err != nil {
return nil, err
}
sortCol, sortDesc, err := normalizeSort(cc.List, in)
if err != nil {
return nil, err
}
q := db.WithContext(ctx).Model(model)
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
q, err = applyListSearch(q, cc.Controller, cc.List, model, in.Search)
if err != nil {
return nil, err
}
q, err = applyListFilters(q, cc.List, model, in.Filters)
if err != nil {
return nil, err
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, err
}
q = applyListOrder(q, cc.Controller, cc.List, model, sortCol, sortDesc)
for _, col := range cc.List.Columns {
if col.Relation == "" {
continue
}
if field, ok := relationFieldName(model, col.Relation); ok {
q = q.Preload(field)
}
}
offset := (page - 1) * per
slice := reflect.New(reflect.SliceOf(mt.Elem()))
if err := q.Offset(offset).Limit(per).Find(slice.Interface()).Error; err != nil {
return nil, err
}
values := slice.Elem()
data := make([]map[string]any, 0, values.Len())
for i := 0; i < values.Len(); i++ {
data = append(data, projectRow(values.Index(i).Addr().Interface(), cc.Controller, cc.List.Columns))
}
paged := lagoon.Paginate(data, page, per, total)
return &ListResult{
Data: paged.Data,
Meta: ListMeta{
Page: paged.Meta.CurrentPage,
PerPage: paged.Meta.PerPage,
Total: paged.Meta.Total,
LastPage: paged.Meta.LastPage,
},
}, nil
}
func normalizePage(schema *ListSchema, in ListInput) (int, int, error) {
page := 1
if in.Page != "" {
n, err := strconv.Atoi(in.Page)
if err != nil || n < 1 {
return 0, 0, listInvalid("page", "must be a positive integer")
}
page = n
}
per := schema.RecordsPerPage
if per < 1 {
per = 20
}
if in.PerPage != "" {
n, err := strconv.Atoi(in.PerPage)
if err != nil || !pageSizeAllowed(schema, n) {
return 0, 0, listInvalid("per_page", "is not an allowed page size")
}
per = n
}
return page, per, nil
}
func pageSizeAllowed(schema *ListSchema, n int) bool {
if n < 1 {
return false
}
options := schema.PerPageOptions
if len(options) == 0 {
return n == schema.RecordsPerPage || (schema.RecordsPerPage < 1 && n == 20)
}
for _, option := range options {
if option == n {
return true
}
}
return false
}
func normalizeSort(schema *ListSchema, in ListInput) (string, bool, error) {
if in.Sort == "" {
if schema.DefaultSort == nil {
return "", false, nil
}
return schema.DefaultSort.Column, schema.DefaultSort.Direction == "desc", nil
}
if !schema.ShowSorting {
return "", false, listInvalid("sort", "is not sortable")
}
var match *ListColumn
for i := range schema.Columns {
if schema.Columns[i].Key == in.Sort {
match = &schema.Columns[i]
break
}
}
if match == nil || !match.Sortable {
return "", false, listInvalid("sort", "is not a sortable column")
}
dir := in.Dir
if dir == "" {
dir = "asc"
}
if dir != "asc" && dir != "desc" {
return "", false, listInvalid("dir", "must be asc or desc")
}
return match.Key, dir == "desc", nil
}
func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, column string, desc bool) *gorm.DB {
table := tableName(model)
pk := primaryColumn(model)
if column != "" {
orderTable := table
orderColumn := column
for _, col := range schema.Columns {
if col.Key != column || col.Relation == "" {
continue
}
field, ok := relationFieldName(model, col.Relation)
if !ok {
continue
}
orderTable = field
orderColumn = listRelationColumn(controller, col.Relation, col.Select)
db = db.Joins(field)
break
}
db = db.Order(clause.OrderByColumn{
Column: clause.Column{Table: orderTable, Name: orderColumn},
Desc: desc,
})
}
if column != pk {
db = db.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}})
}
return db
}
func applyListSearch(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, term string) (*gorm.DB, error) {
term = strings.TrimSpace(term)
if term == "" {
return db, nil
}
var cols []ListColumn
for _, col := range schema.Columns {
if col.Searchable {
cols = append(cols, col)
}
}
if len(cols) == 0 {
return nil, listInvalid("search", "is not searchable")
}
pattern := "%" + escapeLike(strings.ToLower(term)) + "%"
joined := map[string]struct{}{}
parts := make([]string, 0, len(cols))
args := make([]any, 0, len(cols))
main := tableName(model)
for _, col := range cols {
table := main
column := col.Key
if col.Relation != "" {
field, ok := relationFieldName(model, col.Relation)
physical := listRelationColumn(controller, col.Relation, col.Select)
if !ok || !identifier(physical) {
return nil, listInvalid("search", "is not searchable")
}
// GORM aliases the joined association with the Go field name.
table = field
column = physical
if _, done := joined[field]; !done {
db = db.Joins(field)
joined[field] = struct{}{}
}
} else if !identifier(column) {
return nil, listInvalid("search", "is not searchable")
}
parts = append(parts, fmt.Sprintf("LOWER(%s) LIKE ? ESCAPE '\\'", qualifiedColumn(db, table, column)))
args = append(args, pattern)
}
return db.Where(strings.Join(parts, " OR "), args...), nil
}
func applyListFilters(db *gorm.DB, schema *ListSchema, model any, filters map[string]string) (*gorm.DB, error) {
if len(filters) == 0 {
return db, nil
}
byName := map[string]ListFilter{}
for _, filter := range schema.Filters {
byName[filter.Name] = filter
}
table := tableName(model)
var provider pact.FilterScope
if scope, ok := model.(pact.FilterScope); ok {
provider = scope
}
for name, raw := range filters {
filter, ok := byName[name]
if !ok || !identifier(name) {
return nil, listInvalid("filter", "is not a declared filter")
}
switch filter.Type {
case "switch":
value, ok := switchArgument(filter, raw)
if !ok {
return nil, listInvalid("filter", "is not a declared value")
}
db = db.Where(clause.Eq{Column: clause.Column{Table: table, Name: filter.Column}, Value: value})
case "daterange":
start, end, err := parseDateRange(raw)
if err != nil {
return nil, listInvalid("filter", "is not a valid date range")
}
db = db.Where(clause.Gte{Column: clause.Column{Table: table, Name: filter.Column}, Value: start})
db = db.Where(clause.Lt{Column: clause.Column{Table: table, Name: filter.Column}, Value: end})
case "scope":
arg, err := scopeArgument(raw)
if err != nil {
return nil, listInvalid("filter", "is not a declared value")
}
if provider == nil {
return nil, errors.New("cabana: filter scope provider is missing")
}
next := provider.FilterScope(filter.Scope, db, arg)
if next == nil {
return nil, errors.New("cabana: filter scope returned nil")
}
db = next
default:
return nil, listInvalid("filter", "is not a declared filter")
}
}
return db, nil
}
func switchArgument(filter ListFilter, raw string) (any, bool) {
if len(filter.Options) > 0 {
for _, opt := range filter.Options {
if string(opt.Value.raw) == raw {
return scalarValue(opt.Value), true
}
}
return nil, false
}
if filter.TrueValue != nil && string(filter.TrueValue.raw) == raw {
return scalarValue(*filter.TrueValue), true
}
if filter.FalseValue != nil && string(filter.FalseValue.raw) == raw {
return scalarValue(*filter.FalseValue), true
}
return nil, false
}
func scalarValue(s jsonScalar) any {
var v any
if err := json.Unmarshal(s.raw, &v); err != nil {
return string(s.raw)
}
return v
}
func parseDateRange(raw string) (time.Time, time.Time, error) {
startText, endText, ok := strings.Cut(raw, "..")
if !ok || startText == "" || endText == "" {
return time.Time{}, time.Time{}, errors.New("range")
}
start, err := time.ParseInLocation("2006-01-02", startText, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, err
}
end, err := time.ParseInLocation("2006-01-02", endText, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, err
}
if end.Before(start) {
return time.Time{}, time.Time{}, errors.New("range order")
}
return start, end.AddDate(0, 0, 1), nil
}
func scopeArgument(raw string) (any, error) {
if raw == "" || strings.ContainsAny(raw, " \t;'\"\\") || strings.Contains(raw, "--") {
return nil, errors.New("value")
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
return n, nil
}
if !identifier(raw) {
return nil, errors.New("value")
}
return raw, nil
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func qualifiedColumn(db *gorm.DB, table, column string) string {
return quotedIdent(db, table) + "." + quotedIdent(db, column)
}
func quotedIdent(db *gorm.DB, name string) string {
if db == nil || db.Dialector == nil || !identifier(name) && !strings.Contains(name, "_") {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
var b strings.Builder
db.Dialector.QuoteTo(&b, name)
return b.String()
}
func tableName(model any) string {
if model == nil {
return ""
}
if namer, ok := model.(interface{ TableName() string }); ok {
return namer.TableName()
}
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil {
return ""
}
ptr := reflect.New(t)
if namer, ok := ptr.Interface().(interface{ TableName() string }); ok {
return namer.TableName()
}
return ""
}
// relationFieldName resolves a Winter relation key onto the exported Go field.
// Exact names win; otherwise the match is case-insensitive, so relation: genre
// selects field Genre.
func relationFieldName(model any, relation string) (string, bool) {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct || relation == "" {
return "", false
}
if field, ok := t.FieldByName(relation); ok && isListRelation(field.Type) {
return field.Name, true
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" || !isListRelation(field.Type) {
continue
}
if strings.EqualFold(field.Name, relation) {
return field.Name, true
}
}
return "", false
}
func primaryColumn(model any) string {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return "id"
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
if strings.Contains(field.Tag.Get("gorm"), "primaryKey") {
if name := gormColumn(field); name != "" {
return name
}
return field.Name
}
}
return "id"
}
func relatedSelect(row reflect.Value, controller pact.AdminController, col ListColumn) (any, bool) {
for row.Kind() == reflect.Pointer {
if row.IsNil() {
return nil, false
}
row = row.Elem()
}
if row.Kind() != reflect.Struct || col.Relation == "" {
return nil, false
}
name, ok := relationFieldName(row.Interface(), col.Relation)
if !ok {
return nil, false
}
field := row.FieldByName(name)
if !field.IsValid() {
return nil, false
}
for field.Kind() == reflect.Pointer {
if field.IsNil() {
return nil, false
}
field = field.Elem()
}
if field.Kind() != reflect.Struct {
return nil, false
}
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
if !selected.IsValid() || !selected.CanInterface() {
return nil, false
}
return selected.Interface(), true
}
func listRelationColumn(controller pact.AdminController, relation, column string) string {
mapper, ok := controller.(pact.ListRelationColumnMapper)
if !ok || mapper == nil {
return column
}
mapped, ok := mapper.ListRelationColumn(relation, column)
if !ok || mapped == "" {
return column
}
return mapped
}

View File

@@ -0,0 +1,437 @@
package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
pgdriver "gorm.io/driver/postgres"
"gorm.io/gorm"
)
const queryListConfig = `modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
perPageOptions:
- 2
- 10
- 20
showSorting: true
defaultSort:
column: name
direction: asc
filter: config_filter.yaml
`
const queryColumns = `columns:
name:
label: Name
searchable: true
sortable: true
active:
label: Active
type: switch
sortable: true
artist:
label: Artist
relation: Artist
select: name
searchable: true
`
const queryFilters = `scopes:
active:
label: Active
type: switch
column: active
created:
label: Created
type: daterange
column: created_at
grouped:
label: Group
modelClass: Group
nameFrom: name
scope: filterByGroup
`
func TestListQueryContract(t *testing.T) {
svc, db := newListService(t)
seedListRows(t, db)
rec := getList(t, svc, "search=Other")
body := decodeList(t, rec, http.StatusOK)
if body.Meta.Total != 1 || len(body.Data) != 1 || rowID(body.Data[0]) != 4 {
t.Fatalf("search = total %d ids %v", body.Meta.Total, rowIDs(body.Data))
}
sorted := decodeList(t, getList(t, svc, "sort=name&dir=desc&per_page=2&page=1"), http.StatusOK)
if ids := rowIDs(sorted.Data); len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("desc page = %v, want [1 2]", ids)
}
related := decodeList(t, getList(t, svc, "search=Beatles"), http.StatusOK)
if related.Meta.Total != 1 || len(related.Data) != 1 || rowID(related.Data[0]) != 1 {
t.Fatalf("relation search = total %d ids %v", related.Meta.Total, rowIDs(related.Data))
}
}
func TestListQueryEmpty(t *testing.T) {
svc, db := newListService(t)
if err := db.Exec("DELETE FROM cabana_list_query_rows").Error; err != nil {
t.Fatal(err)
}
rec := getList(t, svc, "per_page=10")
if !json.Valid(rec.Body.Bytes()) || rec.Body.String() == "" {
t.Fatalf("body %s", rec.Body.String())
}
body := decodeList(t, rec, http.StatusOK)
if body.Data == nil || len(body.Data) != 0 || body.Meta.Page != 1 || body.Meta.PerPage != 10 || body.Meta.Total != 0 || body.Meta.LastPage != 1 {
t.Fatalf("empty envelope = %+v data %#v", body.Meta, body.Data)
}
if strings.Contains(rec.Body.String(), `"data":null`) {
t.Fatalf("empty data was null: %s", rec.Body.String())
}
}
func TestListQuerySingle(t *testing.T) {
svc, db := newListService(t)
resetListRows(t, db)
if err := db.Create(&queryRow{ID: 7, Name: "Only", Active: true, CreatedAt: time.Date(2024, 1, 2, 12, 0, 0, 0, time.UTC)}).Error; err != nil {
t.Fatal(err)
}
body := decodeList(t, getList(t, svc, "per_page=10"), http.StatusOK)
if len(body.Data) != 1 || body.Meta.Total != 1 || body.Meta.LastPage != 1 || body.Meta.PerPage != 10 || rowID(body.Data[0]) != 7 {
t.Fatalf("single = %+v ids %v", body.Meta, rowIDs(body.Data))
}
}
func TestListQueryAdjacent(t *testing.T) {
svc, db := newListService(t)
seedListRows(t, db)
page1 := decodeList(t, getList(t, svc, "sort=name&dir=asc&per_page=2&page=1"), http.StatusOK)
page2 := decodeList(t, getList(t, svc, "sort=name&dir=asc&per_page=2&page=2"), http.StatusOK)
if ids := rowIDs(page1.Data); len(ids) != 2 || ids[0] != 4 || ids[1] != 1 {
t.Fatalf("page 1 = %v, want [4 1]", ids)
}
if ids := rowIDs(page2.Data); len(ids) != 2 || ids[0] != 2 || ids[1] != 3 {
t.Fatalf("page 2 = %v, want [2 3]", ids)
}
seen := map[int]struct{}{}
for _, id := range append(rowIDs(page1.Data), rowIDs(page2.Data)...) {
if _, dup := seen[id]; dup {
t.Fatalf("duplicate id %d across adjacent pages", id)
}
seen[id] = struct{}{}
}
}
func TestListQueryFilters(t *testing.T) {
svc, db := newListService(t)
seedListRows(t, db)
active := decodeList(t, getList(t, svc, "filter[active]=true&per_page=10"), http.StatusOK)
if ids := rowIDs(active.Data); !sameIDs(ids, []int{1, 3, 4}) {
t.Fatalf("switch ids = %v", ids)
}
dates := decodeList(t, getList(t, svc, "filter[created]=2024-01-01..2024-01-31&per_page=10"), http.StatusOK)
if ids := rowIDs(dates.Data); !sameIDs(ids, []int{1, 2}) {
t.Fatalf("date ids = %v", ids)
}
scoped := decodeList(t, getList(t, svc, "filter[grouped]=9&per_page=10"), http.StatusOK)
if ids := rowIDs(scoped.Data); !sameIDs(ids, []int{3}) {
t.Fatalf("scope ids = %v", ids)
}
}
func TestListQueryRejectsInjection(t *testing.T) {
svc, db := newListService(t)
seedListRows(t, db)
before := countListRows(t, db)
cases := []struct {
name string
query string
field string
}{
{name: "sort injection", query: "sort=name%3Bdrop", field: "sort"},
{name: "sort case", query: "sort=Name", field: "sort"},
{name: "direction case", query: "sort=name&dir=DESC", field: "dir"},
{name: "unknown filter", query: "filter[missing]=1", field: "filter"},
{name: "page zero", query: "page=0", field: "page"},
{name: "page text", query: "page=foo", field: "page"},
{name: "per page cap", query: "per_page=999999", field: "per_page"},
{name: "bad switch", query: "filter[active]=maybe", field: "filter"},
{name: "bad range", query: "filter[created]=2024-02-01..2024-01-01", field: "filter"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := getList(t, svc, tc.query)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body listEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != "validation_failed" {
t.Fatalf("code=%s body=%s", body.Error.Code, rec.Body.String())
}
if _, ok := body.Error.Details[tc.field]; !ok {
t.Fatalf("details missing %s: %s", tc.field, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "drop table") {
t.Fatalf("error body echoed SQL: %s", rec.Body.String())
}
})
}
payload := decodeList(t, getList(t, svc, "search=%25%27+OR+%271%27%3D%271"), http.StatusOK)
if payload.Meta.Total != 0 {
t.Fatalf("injected search matched %d rows", payload.Meta.Total)
}
if countListRows(t, db) != before {
t.Fatalf("row count changed from %d to %d", before, countListRows(t, db))
}
t.Run("permission before query", func(t *testing.T) {
locked := *svc
locked.reg = &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {
Controller: queryController{perms: []string{"acme.demo.access"}},
List: svc.reg.byID["acme.demo.widgets"].List,
},
}}
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 4})
req.URL.RawQuery = "sort=name%3Bdrop"
locked.list(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
type queryArtist struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (queryArtist) TableName() string { return "cabana_list_query_artists" }
type queryRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
CreatedAt time.Time `gorm:"column:created_at"`
GroupID uint `gorm:"column:group_id"`
ArtistID *uint `gorm:"column:artist_id"`
Artist queryArtist `gorm:"foreignKey:ArtistID"`
}
func (queryRow) TableName() string { return "cabana_list_query_rows" }
func (queryRow) FilterScopes() []string { return []string{"filterByGroup"} }
// FilterOptions serves the grouped scope's choices (D-27).
func (queryRow) FilterOptions(scope string) []pact.Option {
if scope != "filterByGroup" {
return nil
}
return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}}
}
func (queryRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != "filterByGroup" {
return db
}
return db.Where("group_id = ?", value)
}
type queryController struct{ perms []string }
func (queryController) ID() string { return "acme.demo.widgets" }
func (queryController) ModelName() string { return "Widget" }
func (queryController) ConfigDir() string { return "controllers/widgets" }
func (queryController) NewRecord() any { return &queryRow{} }
func (c queryController) RequiredPermissions() []string {
return c.perms
}
var (
listPGOnce sync.Once
listPGDSN string
listPGErr error
)
func newListService(t *testing.T) (*service, *gorm.DB) {
t.Helper()
listPGOnce.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
ctr, err := tcpostgres.Run(ctx, "postgres:16-alpine",
tcpostgres.WithDatabase("cabana"),
tcpostgres.WithUsername("cabana"),
tcpostgres.WithPassword("cabana"),
tcpostgres.BasicWaitStrategies(),
)
if err != nil {
listPGErr = err
return
}
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
listPGErr = err
return
}
listPGDSN = dsn
})
if listPGErr != nil {
t.Fatalf("postgres: %v", listPGErr)
}
db, err := gorm.Open(pgdriver.Open(listPGDSN), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.Migrator().DropTable(&queryRow{}, &queryArtist{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&queryArtist{}, &queryRow{}); err != nil {
t.Fatal(err)
}
schema, err := CompileList("acme.demo", queryController{}, filterFS(queryListConfig, queryColumns, queryFilters))
if err != nil {
t.Fatalf("schema: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
svc := &service{app: app, reg: &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {Controller: queryController{}, List: schema},
}}}
return svc, db
}
func seedListRows(t *testing.T, db *gorm.DB) {
t.Helper()
resetListRows(t, db)
if err := db.Create(&queryArtist{ID: 8, Name: "Beatles"}).Error; err != nil {
t.Fatal(err)
}
artistID := uint(8)
rows := []queryRow{
{ID: 1, Name: "Same", Active: true, CreatedAt: time.Date(2024, 1, 2, 12, 0, 0, 0, time.UTC), GroupID: 4, ArtistID: &artistID},
{ID: 2, Name: "Same", Active: false, CreatedAt: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), GroupID: 4},
{ID: 3, Name: "Same", Active: true, CreatedAt: time.Date(2024, 2, 1, 12, 0, 0, 0, time.UTC), GroupID: 9},
{ID: 4, Name: "Other", Active: true, CreatedAt: time.Date(2023, 12, 31, 12, 0, 0, 0, time.UTC), GroupID: 4},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
}
func resetListRows(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec("DELETE FROM cabana_list_query_rows").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("DELETE FROM cabana_list_query_artists").Error; err != nil {
t.Fatal(err)
}
}
func countListRows(t *testing.T, db *gorm.DB) int64 {
t.Helper()
var n int64
if err := db.Model(&queryRow{}).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func getList(t *testing.T, svc *service, rawQuery string) *httptest.ResponseRecorder {
t.Helper()
req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true})
req.URL.RawQuery = rawQuery
rec := httptest.NewRecorder()
svc.list(rec, req)
return rec
}
type listEnvelope struct {
Data []map[string]any `json:"data"`
Meta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
} `json:"meta"`
Error struct {
Code string `json:"code"`
Details map[string]any `json:"details"`
} `json:"error"`
}
func decodeList(t *testing.T, rec *httptest.ResponseRecorder, status int) listEnvelope {
t.Helper()
if rec.Code != status {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body listEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
return body
}
func rowID(row map[string]any) int {
switch n := row["id"].(type) {
case float64:
return int(n)
case int:
return n
case uint:
return int(n)
default:
return 0
}
}
func rowIDs(rows []map[string]any) []int {
out := make([]int, len(rows))
for i, row := range rows {
out[i] = rowID(row)
}
return out
}
func sameIDs(got, want []int) bool {
if len(got) != len(want) {
return false
}
seen := map[int]int{}
for _, id := range got {
seen[id]++
}
for _, id := range want {
seen[id]--
if seen[id] < 0 {
return false
}
}
for _, n := range seen {
if n != 0 {
return false
}
}
return true
}
var _ pact.AdminRecordSource = queryController{}
var _ pact.AdminPermissioned = queryController{}
var _ pact.FilterScope = queryRow{}
var _ pact.FilterOptions = queryRow{}

View File

@@ -0,0 +1,144 @@
package cabana_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestAdminRefreshRevocation pins CR-01: POST {prefix}/api/v1/auth/refresh
// applies the backend guard's subject checks before minting. A token issued
// before `summer admin:reset-password` (tokens_valid_after), or held by a
// deactivated or soft-deleted admin, cannot be refreshed over either
// transport, and a refused cookie refresh expires summer_admin.
func TestAdminRefreshRevocation(t *testing.T) {
gdb := adminGorm(t)
cookieLogin := func(t *testing.T, h http.Handler, login string) *http.Cookie {
t.Helper()
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"),
map[string]string{"login": login, "password": adminTestPassword}, nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("cookie login status=%d body=%s", rec.Code, rec.Body.String())
}
return phase10Cookie(t, rec, cabana.DefaultAdminPrefix)
}
bearerLogin := func(t *testing.T, h http.Handler, login string) string {
t.Helper()
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"),
map[string]string{"login": login, "password": adminTestPassword}, nil, false)
if rec.Code != http.StatusOK {
t.Fatalf("bearer login status=%d body=%s", rec.Code, rec.Body.String())
}
return accessToken(t, rec.Body.Bytes())
}
resetPassword := func(t *testing.T, login string) {
t.Helper()
reset := commandByName(t, cabana.RuntimeCommands(commandApp(t, gdb)), "admin:reset-password")
var buf bytes.Buffer
if err := reset.Run(context.Background(), flagInput{
args: []string{login},
flags: map[string]string{"password": "rrev-replacement-password"},
}, bonfire.NewOutput(nil, &buf, &buf)); err != nil {
t.Fatalf("admin:reset-password: %v output=%s", err, buf.String())
}
}
assertRefusedWithExpiredCookie := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized || phase10ErrorCode(t, rec) != "unauthenticated" {
t.Fatalf("refresh status=%d body=%s, want 401 unauthenticated", rec.Code, rec.Body.String())
}
var expired *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == cabana.AdminCookieName {
expired = c
}
}
if expired == nil || expired.Value != "" || expired.MaxAge >= 0 || expired.Path != cabana.DefaultAdminPrefix {
t.Fatalf("refused refresh cookie = %+v, want an expiring %s with Path %s", expired, cabana.AdminCookieName, cabana.DefaultAdminPrefix)
}
}
t.Run("pre-reset cookie is refused and expired", func(t *testing.T) {
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "rrev-cookie", "rrev-cookie@example.test", adminTestPassword, true, false)
old := cookieLogin(t, h, "rrev-cookie")
resetPassword(t, "rrev-cookie")
if me := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, old, true); me.Code != http.StatusUnauthorized {
t.Fatalf("pre-reset cookie /auth/me status=%d body=%s", me.Code, me.Body.String())
}
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, old, true)
assertRefusedWithExpiredCookie(t, rec)
})
t.Run("pre-reset bearer is refused without cookies", func(t *testing.T) {
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "rrev-bearer", "rrev-bearer@example.test", adminTestPassword, true, false)
token := bearerLogin(t, h, "rrev-bearer")
resetPassword(t, "rrev-bearer")
rec := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
if rec.Code != http.StatusUnauthorized || phase10ErrorCode(t, rec) != "unauthenticated" {
t.Fatalf("pre-reset bearer refresh status=%d body=%s, want 401 unauthenticated", rec.Code, rec.Body.String())
}
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
t.Fatalf("bearer refresh set cookies: %q", got)
}
})
t.Run("deactivated admin is refused", func(t *testing.T) {
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "rrev-deactivated", "rrev-deactivated@example.test", adminTestPassword, true, false)
old := cookieLogin(t, h, "rrev-deactivated")
if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, old, true)
assertRefusedWithExpiredCookie(t, rec)
})
t.Run("soft-deleted admin is refused", func(t *testing.T) {
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "rrev-deleted", "rrev-deleted@example.test", adminTestPassword, true, false)
old := cookieLogin(t, h, "rrev-deleted")
if err := gdb.Delete(&user).Error; err != nil {
t.Fatal(err)
}
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, old, true)
assertRefusedWithExpiredCookie(t, rec)
})
t.Run("active admin still refreshes", func(t *testing.T) {
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "rrev-active", "rrev-active@example.test", adminTestPassword, true, false)
first := cookieLogin(t, h, "rrev-active")
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, true)
if rec.Code != http.StatusOK {
t.Fatalf("cookie refresh status=%d body=%s", rec.Code, rec.Body.String())
}
second := phase10Cookie(t, rec, cabana.DefaultAdminPrefix)
if second.Value == first.Value {
t.Fatal("cookie refresh did not rotate the token")
}
phase10AssertCookieBody(t, rec, second.Value)
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())
}
token := bearerLogin(t, h, "rrev-active")
bearer := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
if bearer.Code != http.StatusOK {
t.Fatalf("bearer refresh 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 refresh set cookies: %q", got)
}
})
}

290
modules/cabana/registry.go Normal file
View File

@@ -0,0 +1,290 @@
package cabana
import (
"fmt"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
type controllerRef struct {
plugin party.Plugin
ctl pact.AdminController
}
func collectControllers(plugins []party.Plugin) ([]controllerRef, error) {
var out []controllerRef
for _, p := range plugins {
src, ok := p.(pact.HasAdminControllers)
if !ok || p == nil {
continue
}
for _, ctl := range src.AdminControllers() {
if ctl == nil {
continue
}
id := ctl.ID()
if id != p.ID() && !strings.HasPrefix(id, p.ID()+".") {
return nil, fmt.Errorf("cabana: controller %s is not owned by plugin %s", id, p.ID())
}
out = append(out, controllerRef{plugin: p, ctl: ctl})
}
}
return out, nil
}
// reservedVendorSegments are the first path segments under the admin prefix
// that the SPA and API own. A controller ID maps to /{vendor}/{plugin}/..., so
// a vendor with one of these names would collide with them.
var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}
func checkReservedSegments(items []controllerRef) error {
for _, item := range items {
id := item.ctl.ID()
vendor, _, _ := strings.Cut(id, ".")
if reservedVendorSegments[vendor] {
return fmt.Errorf("cabana: controller %s uses the reserved admin path segment %q (reserved: api, assets, login, settings)", id, vendor)
}
}
return nil
}
func compileRegistry(items []controllerRef) (*Registry, error) {
byID := make(map[string]*CompiledController, len(items))
for _, item := range items {
id := item.ctl.ID()
if _, exists := byID[id]; exists {
return nil, fmt.Errorf("cabana: duplicate admin controller %s", id)
}
assets, ok := item.plugin.(pact.AdminAssets)
if !ok || assets == nil || assets.AdminFS() == nil {
return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID())
}
list, err := compileList(item.plugin.ID(), item.ctl, assets.AdminFS())
if err != nil {
return nil, err
}
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, assets.AdminFS())
if err != nil {
return nil, err
}
relations, err := compileRelations(item.plugin.ID(), item.ctl, assets.AdminFS(), form)
if err != nil {
return nil, err
}
fieldRelations, err := compileFieldRelations(item.plugin.ID(), item.ctl, form)
if err != nil {
return nil, err
}
if form == nil && list != nil {
// Without a compiled form there is nothing to create (D-14).
list.ToolbarButtons = withoutAction(list.ToolbarButtons, "create")
}
compiled := &CompiledController{
PluginID: item.plugin.ID(),
Controller: item.ctl,
List: list,
Form: form,
Relations: relations,
FieldRelations: fieldRelations,
}
if err := BindWritableFields(compiled); err != nil {
return nil, err
}
byID[id] = compiled
}
return &Registry{byID: byID}, nil
}
func withoutAction(actions []string, drop string) []string {
out := make([]string, 0, len(actions))
for _, action := range actions {
if action != drop {
out = append(out, action)
}
}
return out
}
func compileContributions(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
reg.permissions = map[string]pact.Permission{}
reg.roleGrants = map[string]map[string]bool{}
reg.settings = map[string]*CompiledSetting{}
seenNavigation := map[string]struct{}{}
for _, plugin := range plugins {
if plugin == nil {
continue
}
if src, ok := plugin.(pact.HasPermissions); ok && src != nil {
for _, permission := range src.Permissions() {
if !permissionCode(permission.Code, false) {
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
}
if _, exists := reg.permissions[permission.Code]; exists {
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
}
reg.permissions[permission.Code] = permission
for _, role := range permission.Roles {
role = strings.TrimSpace(role)
if role == "" {
return fmt.Errorf("cabana: permission %s has an empty role", permission.Code)
}
if reg.roleGrants[role] == nil {
reg.roleGrants[role] = map[string]bool{}
}
reg.roleGrants[role][permission.Code] = true
}
}
}
if src, ok := plugin.(pact.HasSettings); ok && src != nil {
assets, hasAssets := plugin.(pact.AdminAssets)
for _, item := range src.Settings() {
if !identifier(item.Code) {
return fmt.Errorf("cabana: plugin %s registered invalid setting %q", plugin.ID(), item.Code)
}
if _, exists := reg.settings[item.Code]; exists {
return fmt.Errorf("cabana: duplicate setting %s", item.Code)
}
if !hasAssets || assets == nil || assets.AdminFS() == nil {
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
}
compiled, err := compileSetting(plugin.ID(), item, assets.AdminFS())
if err != nil {
return err
}
reg.settings[item.Code] = compiled
}
}
if src, ok := plugin.(pact.HasNavigation); ok && src != nil {
for _, item := range src.Navigation() {
if err := validateNavigationShape(plugin.ID(), item, seenNavigation); err != nil {
return err
}
reg.navigation = append(reg.navigation, item)
}
}
}
for id, controller := range reg.byID {
if err := reg.validatePermissions("controller "+id, requiredOf(controller.Controller)); err != nil {
return err
}
for name, relation := range controller.Relations {
if err := reg.validatePermissions("relation "+id+"."+name, relation.RequiredPermissions); err != nil {
return err
}
}
}
for _, item := range reg.navigation {
if err := reg.validateNavigation(item); err != nil {
return err
}
}
for code, setting := range reg.settings {
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
return err
}
}
return nil
}
func permissionCode(code string, wildcard bool) bool {
parts := strings.Split(code, ".")
if len(parts) < 2 {
return false
}
for i, part := range parts {
if wildcard && i == len(parts)-1 && part == "*" {
return true
}
if !identifier(part) {
return false
}
}
return true
}
func (r *Registry) validatePermissions(owner string, permissions []string) error {
for _, code := range permissions {
if !permissionCode(code, true) || !r.permissionExists(code) {
return fmt.Errorf("cabana: %s references unknown permission %s", owner, code)
}
}
return nil
}
func (r *Registry) permissionExists(code string) bool {
if r == nil {
return false
}
if _, ok := r.permissions[code]; ok {
return true
}
if !strings.HasSuffix(code, ".*") {
return false
}
prefix := strings.TrimSuffix(code, "*")
for candidate := range r.permissions {
if strings.HasPrefix(candidate, prefix) {
return true
}
}
return false
}
func validateNavigationShape(pluginID string, item pact.NavigationItem, seen map[string]struct{}) error {
if !identifier(item.Code) {
return fmt.Errorf("cabana: plugin %s registered invalid navigation code %q", pluginID, item.Code)
}
if _, exists := seen[item.Code]; exists {
return fmt.Errorf("cabana: duplicate navigation code %s", item.Code)
}
seen[item.Code] = struct{}{}
childSeen := map[string]struct{}{}
for _, child := range item.SideMenu {
if !identifier(child.Code) {
return fmt.Errorf("cabana: navigation %s has invalid child code %q", item.Code, child.Code)
}
if _, exists := childSeen[child.Code]; exists {
return fmt.Errorf("cabana: navigation %s has duplicate child %s", item.Code, child.Code)
}
childSeen[child.Code] = struct{}{}
}
return nil
}
func (r *Registry) validateNavigation(item pact.NavigationItem) error {
if _, ok := r.byID[item.Controller]; !ok {
return fmt.Errorf("cabana: navigation %s references unknown controller %s", item.Code, item.Controller)
}
if err := r.validatePermissions("navigation "+item.Code, item.Permissions); err != nil {
return err
}
for _, child := range item.SideMenu {
if _, ok := r.byID[child.Controller]; !ok {
return fmt.Errorf("cabana: navigation %s.%s references unknown controller %s", item.Code, child.Code, child.Controller)
}
if err := r.validatePermissions("navigation "+item.Code+"."+child.Code, child.Permissions); err != nil {
return err
}
}
return nil
}
func (r *Registry) rolePermissions(role string) map[string]bool {
out := map[string]bool{}
if r == nil {
return out
}
for code, allowed := range r.roleGrants[role] {
if allowed {
out[code] = true
}
}
return out
}

843
modules/cabana/relation.go Normal file
View File

@@ -0,0 +1,843 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"reflect"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// AdminRelationContractProvider declares model-owned relation metadata.
// Framework code never guesses a plugin pivot table or foreign key.
type AdminRelationContractProvider interface {
AdminRelationContracts() []RelationContract
}
// RelationContract binds one compiled relation schema to target and pivot models.
type RelationContract struct {
Name string
NewRelated func() any
NewPivot func() any
ParentForeignKey string
RelatedForeignKey string
Columns map[string]string
HookPivotColumns []string
ExcludedRelatedIDs func(parent any) ([]uint, error)
}
// RelationColumn is one source-ordered relation list column.
type RelationColumn struct {
Key string `json:"key"`
Label string `json:"label"`
Searchable bool `json:"searchable"`
Sortable bool `json:"sortable"`
}
// RelationList is one view/manage list declaration.
type RelationList struct {
Columns []RelationColumn `json:"columns"`
}
// RelationPanel is one side of a relation manager.
type RelationPanel struct {
List RelationList `json:"list"`
ToolbarButtons []string `json:"toolbarButtons"`
ShowSearch bool `json:"showSearch"`
}
// RelationSchema is the cached locale-neutral relation contract.
type RelationSchema struct {
Name string `json:"name"`
Label string `json:"label"`
View RelationPanel `json:"view"`
Manage RelationPanel `json:"manage"`
// Messages is the relation manager's copy (D-13); the cached schema
// carries each phrase key as its own form.
Messages *RelationMessages `json:"messages"`
messageKeys relationMessageKeys
}
// CompiledRelation combines trusted YAML with model-owned metadata.
type CompiledRelation struct {
Schema *RelationSchema
Contract RelationContract
RequiredPermissions []string
}
// RelationQuery is the finite linked/candidate query contract.
type RelationQuery struct {
Search string
Sort string
Dir string
Page string
PerPage string
}
// RelationResult is a stable allocated page.
type RelationResult struct {
Data []map[string]any
Meta ListMeta
}
// RelationMutationInput is the only accepted relation write payload.
type RelationMutationInput struct {
IDs []any `json:"ids"`
}
// RelationMutationResult reports inserted/deleted pivot rows.
type RelationMutationResult struct {
Linked int `json:"linked,omitempty"`
Removed int `json:"removed,omitempty"`
}
// RelationService executes compiled relation reads and writes.
type RelationService struct{ DB *gorm.DB }
func (s RelationSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, s.relationMessageKeySet())
s.Messages = &keys
}
type alias RelationSchema
out := alias(s)
ensureRelationArrays(&out.View)
ensureRelationArrays(&out.Manage)
return json.Marshal(out)
}
func ensureRelationArrays(p *RelationPanel) {
if p.List.Columns == nil {
p.List.Columns = []RelationColumn{}
}
if p.ToolbarButtons == nil {
p.ToolbarButtons = []string{}
}
}
// Localize returns a request-local copy without mutating the cached keys.
func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator) *RelationSchema {
if s == nil {
return nil
}
out := *s
out.Label = translateKey(ctx, tr, s.Label)
out.View = localizeRelationPanel(ctx, tr, s.View)
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
out.Messages = &messages
return &out
}
func localizeRelationPanel(ctx context.Context, tr *phrasebook.Translator, src RelationPanel) RelationPanel {
out := src
out.List.Columns = append([]RelationColumn(nil), src.List.Columns...)
for i := range out.List.Columns {
out.List.Columns[i].Label = translateKey(ctx, tr, out.List.Columns[i].Label)
}
out.ToolbarButtons = append([]string(nil), src.ToolbarButtons...)
ensureRelationArrays(&out)
return out
}
type relationRoot struct {
Relations yaml.MapSlice `yaml:",inline"`
}
type relationDocument struct {
Label string `yaml:"label"`
View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"`
Messages *relationMessageKeys `yaml:"messages"`
}
type relationPanelDocument struct {
List struct {
Columns yaml.MapSlice `yaml:"columns"`
} `yaml:"list"`
ToolbarButtons string `yaml:"toolbarButtons"`
ShowSearch bool `yaml:"showSearch"`
}
type relationColumnDocument struct {
Label string `yaml:"label"`
Searchable *bool `yaml:"searchable"`
Sortable *bool `yaml:"sortable"`
}
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
fields := map[string]FormField{}
if form != nil {
for _, field := range form.Fields {
if field.Type == "relation-manager" {
fields[field.Relation] = field
}
}
}
if len(fields) == 0 {
return map[string]*CompiledRelation{}, nil
}
provider, ok := ctl.(AdminRelationContractProvider)
if !ok || provider == nil {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", errors.New("relation-manager requires AdminRelationContracts"))
}
contracts := map[string]RelationContract{}
for _, contract := range provider.AdminRelationContracts() {
if !identifier(contract.Name) {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", fmt.Errorf("relation contract name %q is not an identifier", contract.Name))
}
if _, dup := contracts[contract.Name]; dup {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", fmt.Errorf("duplicate relation contract %s", contract.Name))
}
if err := validateRelationContract(ctl, contract); err != nil {
return nil, bootErr(pluginID, ctl.ID(), "config_relation.yaml", err)
}
contracts[contract.Name] = contract
}
dir := strings.Trim(strings.TrimSpace(ctl.ConfigDir()), "/")
file := dir + "/config_relation.yaml"
raw, err := readAsset(fsys, file)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
var root yaml.MapSlice
if err := decodeStrict(raw, &root); err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
out := map[string]*CompiledRelation{}
for _, item := range root {
name, ok := item.Key.(string)
if !ok || !identifier(name) {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation name %v is not an identifier", item.Key))
}
if _, dup := out[name]; dup {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("duplicate relation %s", name))
}
if _, declared := fields[name]; !declared {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s has no relation-manager field", name))
}
contract, declared := contracts[name]
if !declared {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s has no model contract", name))
}
encoded, err := yaml.Marshal(item.Value)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, err)
}
var doc relationDocument
if err := decodeStrict(encoded, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err))
}
view, err := compileRelationPanel(doc.View, contract, true)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s view: %w", name, err))
}
manage, err := compileRelationPanel(doc.Manage, contract, false)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s manage: %w", name, err))
}
orderRelationColumns(raw, &view)
orderRelationColumns(raw, &manage)
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
schema.Messages = &keys
out[name] = &CompiledRelation{
Schema: schema,
Contract: contract,
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
}
}
for name := range fields {
if out[name] == nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s is missing", name))
}
}
for name := range contracts {
if out[name] == nil {
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation contract %s is orphaned", name))
}
}
return out, nil
}
func orderRelationColumns(raw []byte, panel *RelationPanel) {
if panel == nil || len(panel.List.Columns) < 2 {
return
}
source := string(raw)
sort.SliceStable(panel.List.Columns, func(i, j int) bool {
left := strings.Index(source, "\n "+panel.List.Columns[i].Key+":")
right := strings.Index(source, "\n "+panel.List.Columns[j].Key+":")
if left < 0 {
left = len(source)
}
if right < 0 {
right = len(source)
}
return left < right
})
}
func compileRelationPanel(doc relationPanelDocument, contract RelationContract, view bool) (RelationPanel, error) {
cols := make([]RelationColumn, 0, len(doc.List.Columns))
seen := map[string]struct{}{}
for _, item := range doc.List.Columns {
key, ok := item.Key.(string)
if !ok || !identifier(key) {
return RelationPanel{}, fmt.Errorf("column %v is not an identifier", item.Key)
}
if _, dup := seen[key]; dup {
return RelationPanel{}, fmt.Errorf("duplicate column %s", key)
}
seen[key] = struct{}{}
if _, ok := contract.Columns[key]; !ok {
return RelationPanel{}, fmt.Errorf("column %s is not in the model contract", key)
}
encoded, err := yaml.Marshal(item.Value)
if err != nil {
return RelationPanel{}, err
}
var spec relationColumnDocument
if err := decodeStrict(encoded, &spec); err != nil {
return RelationPanel{}, fmt.Errorf("column %s: %w", key, err)
}
searchable := doc.ShowSearch
if spec.Searchable != nil {
searchable = *spec.Searchable
}
sortable := true
if spec.Sortable != nil {
sortable = *spec.Sortable
}
cols = append(cols, RelationColumn{Key: key, Label: spec.Label, Searchable: searchable, Sortable: sortable})
}
buttons, err := compileRelationButtons(doc.ToolbarButtons, view)
if err != nil {
return RelationPanel{}, err
}
return RelationPanel{List: RelationList{Columns: cols}, ToolbarButtons: buttons, ShowSearch: doc.ShowSearch}, nil
}
func compileRelationButtons(raw string, view bool) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return []string{}, nil
}
parts := strings.Split(raw, "|")
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "link" && part != "unlink" {
return nil, fmt.Errorf("unsupported relation action %s", part)
}
if !view && part == "unlink" {
return nil, fmt.Errorf("manage panel cannot declare unlink")
}
if _, dup := seen[part]; dup {
return nil, fmt.Errorf("duplicate relation action %s", part)
}
seen[part] = struct{}{}
out = append(out, part)
}
return out, nil
}
func validateRelationContract(ctl pact.AdminController, contract RelationContract) error {
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
}
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
}
pivotCols := modelColumns(contract.NewPivot())
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
}
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
}
targetCols := modelColumns(contract.NewRelated())
for logical, physical := range contract.Columns {
if !identifier(logical) || !identifier(physical) {
return fmt.Errorf("relation %s has invalid column mapping", contract.Name)
}
if _, ok := targetCols[physical]; !ok {
return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical)
}
}
for _, col := range contract.HookPivotColumns {
if !identifier(col) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
}
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return fmt.Errorf("relation %s owner has no record source", contract.Name)
}
fieldName, ok := relationFieldName(src.NewRecord(), contract.Name)
if !ok {
return fmt.Errorf("relation %s is not on the owner model", contract.Name)
}
ownerType := reflect.TypeOf(src.NewRecord())
for ownerType.Kind() == reflect.Pointer {
ownerType = ownerType.Elem()
}
field, _ := ownerType.FieldByName(fieldName)
targetType := reflect.TypeOf(contract.NewRelated())
for targetType.Kind() == reflect.Pointer {
targetType = targetType.Elem()
}
fieldType := field.Type
for fieldType.Kind() == reflect.Pointer || fieldType.Kind() == reflect.Slice {
fieldType = fieldType.Elem()
}
if fieldType != targetType {
return fmt.Errorf("relation %s target model does not match owner field", contract.Name)
}
return nil
}
func protectedPivotColumn(col string, contract RelationContract) bool {
switch col {
case contract.ParentForeignKey, contract.RelatedForeignKey, "id", "created_at", "updated_at", "deleted_at":
return true
default:
return false
}
}
// Linked returns the current relation rows.
func (s RelationService) Linked(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationQuery) (*RelationResult, error) {
return s.query(ctx, cc, relation, ownerID, in, false)
}
// Candidates returns target rows eligible for linking.
func (s RelationService) Candidates(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationQuery) (*RelationResult, error) {
return s.query(ctx, cc, relation, ownerID, in, true)
}
func (s RelationService) query(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationQuery, candidates bool) (*RelationResult, error) {
if s.DB == nil {
return nil, errors.New("cabana: database is not configured")
}
cr, err := relationOf(cc, relation)
if err != nil {
return nil, err
}
page, per, sortKey, desc, err := normalizeRelationQuery(cr.Schema, candidates, in)
if err != nil {
return nil, err
}
var result *RelationResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
parent, err := newWritableModel(cc)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
return err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, candidates)
if err != nil {
return err
}
panel := cr.Schema.View
if candidates {
panel = cr.Schema.Manage
}
q = applyRelationSearch(q, cr, panel.List.Columns, in.Search)
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return err
}
q = applyRelationOrder(q, cr, target, panel.List.Columns, sortKey, desc)
rows := make([]map[string]any, 0)
selects := relationSelects(tx, cr, target, panel.List.Columns)
if err := q.Select(strings.Join(selects, ", ")).Offset((page - 1) * per).Limit(per).Scan(&rows).Error; err != nil {
return err
}
last := 0
if total > 0 {
last = int((total + int64(per) - 1) / int64(per))
}
result = &RelationResult{Data: rows, Meta: ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}}
return nil
})
return result, err
}
func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
target := cr.Contract.NewRelated()
targetTable := tableName(target)
pivotTable := tableName(cr.Contract.NewPivot())
pk := primaryColumn(target)
ownerPK := pkUint(parent)
q := tx.WithContext(ctx).Model(target)
if candidates {
if ext, ok := cc.Controller.(pact.RelationExtendManageQuery); ok && ext != nil {
if next := ext.RelationExtendManageQuery(ctx, cr.Contract.Name, q); next != nil {
q = next
}
}
if cr.Contract.ExcludedRelatedIDs != nil {
excluded, err := cr.Contract.ExcludedRelatedIDs(parent)
if err != nil {
return nil, nil, lifecycleFailure(cc, err)
}
if len(excluded) > 0 {
q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: targetTable, Name: pk}, Values: uintValues(excluded)}))
}
}
notExists := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, pivotTable) + " p WHERE p." + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk) + ")"
q = q.Where(notExists, ownerPK)
} else {
join := "JOIN " + quotedIdent(tx, pivotTable) + " p ON p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk)
q = q.Joins(join).Where("p."+quotedIdent(tx, cr.Contract.ParentForeignKey)+" = ?", ownerPK)
}
return q, target, nil
}
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
// a positive integer (default 1), per_page is 1..100 (default 20).
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
page, per := 1, 20
var err error
if rawPage != "" {
page, err = parsePositive(rawPage)
if err != nil {
return 0, 0, relationInvalid("page", "must be a positive integer")
}
}
if rawPerPage != "" {
per, err = parsePositive(rawPerPage)
if err != nil || per > 100 {
return 0, 0, relationInvalid("per_page", "must be between 1 and 100")
}
}
return page, per, nil
}
func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) {
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
if err != nil {
return 0, 0, "", false, err
}
panel := schema.View
if candidates {
panel = schema.Manage
}
sortKey := in.Sort
if sortKey == "" && len(panel.List.Columns) > 0 {
sortKey = panel.List.Columns[0].Key
}
if sortKey != "" {
valid := false
for _, col := range panel.List.Columns {
if col.Key == sortKey && col.Sortable {
valid = true
break
}
}
if !valid {
return 0, 0, "", false, relationInvalid("sort", "is not a sortable column")
}
}
dir := in.Dir
if dir == "" {
dir = "asc"
}
if dir != "asc" && dir != "desc" {
return 0, 0, "", false, relationInvalid("dir", "must be asc or desc")
}
return page, per, sortKey, dir == "desc", nil
}
func parsePositive(raw string) (int, error) {
n := 0
for _, r := range raw {
if r < '0' || r > '9' {
return 0, errors.New("not positive")
}
n = n*10 + int(r-'0')
}
if n < 1 {
return 0, errors.New("not positive")
}
return n, nil
}
func relationInvalid(field, message string) *ValidationError {
return &ValidationError{Details: map[string]any{field: []string{message}}}
}
func applyRelationSearch(q *gorm.DB, cr *CompiledRelation, cols []RelationColumn, term string) *gorm.DB {
term = strings.TrimSpace(term)
if term == "" {
return q
}
targetTable := tableName(cr.Contract.NewRelated())
parts := make([]string, 0)
args := make([]any, 0)
seen := map[string]struct{}{}
for _, col := range cols {
physical := cr.Contract.Columns[col.Key]
if !col.Searchable || physical == "" {
continue
}
if _, dup := seen[physical]; dup {
continue
}
seen[physical] = struct{}{}
parts = append(parts, quotedIdent(q, targetTable)+"."+quotedIdent(q, physical)+" ILIKE ?")
args = append(args, "%"+term+"%")
}
if len(parts) == 0 {
return q
}
return q.Where("("+strings.Join(parts, " OR ")+")", args...)
}
func applyRelationOrder(q *gorm.DB, cr *CompiledRelation, target any, cols []RelationColumn, sortKey string, desc bool) *gorm.DB {
table := tableName(target)
pk := primaryColumn(target)
if sortKey != "" {
q = q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: cr.Contract.Columns[sortKey]}, Desc: desc})
}
if cr.Contract.Columns[sortKey] != pk {
q = q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}})
}
return q
}
func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []RelationColumn) []string {
table := tableName(target)
out := []string{quotedIdent(db, table) + "." + quotedIdent(db, primaryColumn(target)) + " AS " + quotedIdent(db, "id")}
for _, col := range cols {
out = append(out, quotedIdent(db, table)+"."+quotedIdent(db, cr.Contract.Columns[col.Key])+" AS "+quotedIdent(db, col.Key))
}
return out
}
// Link inserts only currently eligible targets and never restamps existing rows.
func (s RelationService) Link(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
return RelationMutationResult{}, err
}
cr, err := relationOf(cc, relation)
if err != nil {
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
parent, err := newWritableModel(cc)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
return err
}
ownerPK := pkUint(parent)
pending, err := pendingRelationIDs(tx, cr, ownerPK, ids)
if err != nil || len(pending) == 0 {
return err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true)
if err != nil {
return err
}
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem()))
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil {
return err
}
if holder.Elem().Len() != len(pending) {
return relationInvalid("ids", "contains an ineligible target")
}
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
pivot := cr.Contract.NewPivot()
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, ownerPK); err != nil {
return err
}
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
return err
}
values := map[string]any{}
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
return lifecycleFailure(cc, err)
}
}
for key, value := range values {
if !stringSliceHasValue(cr.Contract.HookPivotColumns, key) || protectedPivotColumn(key, cr.Contract) {
return lifecycleFailure(cc, fmt.Errorf("relation hook wrote protected pivot column"))
}
if err := setModelColumn(pivot, key, value); err != nil {
return lifecycleFailure(cc, err)
}
}
if err := tx.WithContext(ctx).Create(pivot).Error; err != nil {
return err
}
result.Linked++
}
return nil
})
return result, err
}
// Unlink deletes explicit pivot models so their lifecycle hooks run.
func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) {
ids, err := normalizeIDs(in.IDs)
if err != nil {
return RelationMutationResult{}, err
}
cr, err := relationOf(cc, relation)
if err != nil {
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
parent, err := newWritableModel(cc)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
return err
}
proto := cr.Contract.NewPivot()
t := reflect.TypeOf(proto)
holder := reflect.New(reflect.SliceOf(t.Elem()))
q := tx.WithContext(ctx).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: pkUint(parent)}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}})
if err := q.Find(holder.Interface()).Error; err != nil {
return err
}
for i := 0; i < holder.Elem().Len(); i++ {
if err := tx.WithContext(ctx).Delete(holder.Elem().Index(i).Addr().Interface()).Error; err != nil {
return err
}
result.Removed++
}
return nil
})
return result, err
}
func relationOf(cc *CompiledController, name string) (*CompiledRelation, error) {
if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil {
return nil, recordNotFound{}
}
return cc.Relations[name], nil
}
func pendingRelationIDs(tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
var existing []uint
err := tx.Table(tableName(cr.Contract.NewPivot())).
Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: ownerID}).
Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}).
Pluck(cr.Contract.RelatedForeignKey, &existing).Error
if err != nil {
return nil, err
}
seen := map[uint]struct{}{}
for _, id := range existing {
seen[id] = struct{}{}
}
pending := make([]uint, 0, len(ids)-len(seen))
for _, id := range ids {
if _, ok := seen[id]; !ok {
pending = append(pending, id)
}
}
return pending, nil
}
func uintValues(ids []uint) []any {
out := make([]any, len(ids))
for i, id := range ids {
out[i] = id
}
return out
}
func stringSliceHasValue(values []string, key string) bool {
for _, value := range values {
if value == key {
return true
}
}
return false
}
func setModelColumn(model any, column string, value any) error {
v := reflect.ValueOf(model)
if v.Kind() != reflect.Pointer || v.IsNil() {
return errors.New("cabana: pivot model must be a pointer")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return errors.New("cabana: pivot model must be a struct pointer")
}
field := fieldByColumn(v, column)
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("cabana: pivot column %s is not writable", column)
}
return assignReflect(field, value)
}
func assignReflect(dst reflect.Value, value any) error {
if value == nil {
if dst.Kind() == reflect.Pointer {
dst.Set(reflect.Zero(dst.Type()))
return nil
}
return errors.New("cabana: nil pivot value")
}
src := reflect.ValueOf(value)
if src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
return nil
}
if dst.Kind() == reflect.Pointer {
if src.Kind() == reflect.Pointer && src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
return nil
}
ptr := reflect.New(dst.Type().Elem())
if src.Type().AssignableTo(dst.Type().Elem()) {
ptr.Elem().Set(src)
dst.Set(ptr)
return nil
}
if src.Type().ConvertibleTo(dst.Type().Elem()) {
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
dst.Set(ptr)
return nil
}
}
if src.Type().ConvertibleTo(dst.Type()) {
dst.Set(src.Convert(dst.Type()))
return nil
}
return fmt.Errorf("cabana: pivot value %T is not assignable to %s", value, dst.Type())
}

View File

@@ -0,0 +1,652 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"reflect"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// FieldRelationContract binds one fields.yaml `type: relation` field to its
// related model (D-17, D-18). Framework code never guesses a plugin table,
// pivot or foreign key: every name below comes from the controller.
type FieldRelationContract struct {
// Field is the fields.yaml key ("genre", "artists").
Field string
// Kind is "belongsTo" or "belongsToMany".
Kind string
// NewRelated returns a pointer to the related model.
NewRelated func() any
// ForeignKey is the belongsTo column on the parent model.
ForeignKey string
// NewPivot returns a pointer to the belongsToMany join model.
NewPivot func() any
// ParentForeignKey and RelatedForeignKey are the pivot columns.
ParentForeignKey string
RelatedForeignKey string
// OrderColumn is an optional pivot column set to the submitted array index.
OrderColumn string
// LabelColumn is the physical label column on the related model. Empty
// means the field's nameFrom, mapped through pact.ListRelationColumnMapper
// when the controller implements it.
LabelColumn string
}
// FieldRelationProvider is implemented by admin controllers whose form
// declares `type: relation` fields.
type FieldRelationProvider interface {
AdminFieldRelations() []FieldRelationContract
}
// RelationOption is one relation choice or label: the related primary key and
// its label column value.
type RelationOption struct {
Value uint `json:"value"`
Label string `json:"label"`
}
// RecordMeta is the record envelope meta: display labels per relation field,
// in the same order as the ids in data.
type RecordMeta struct {
Labels map[string][]RelationOption `json:"labels"`
}
// RecordEnvelope is the show, create and update response.
type RecordEnvelope struct {
Data AdminRecord `json:"data"`
Meta RecordMeta `json:"meta"`
}
// RecordResult is one projected record plus its relation labels.
type RecordResult struct {
Data map[string]any
Meta RecordMeta
}
// CompiledFieldRelation is one relation field after activation checks.
type CompiledFieldRelation struct {
Contract FieldRelationContract
// LabelColumn is the resolved physical label column.
LabelColumn string
// Multiple is true for belongsToMany.
Multiple bool
// ReadOnly is true for a belongsTo whose foreign key is a protected fill
// key (D-26): it is shown with its label but never written and has no
// options endpoint.
ReadOnly bool
// Nullable is true when a belongsTo foreign key accepts null.
Nullable bool
}
const (
relationKindBelongsTo = "belongsTo"
relationKindBelongsToMany = "belongsToMany"
)
// compileFieldRelations binds every `type: relation` field to exactly one
// controller contract and marks the compiled form fields multiple or
// read-only. Any mismatch is a boot error naming plugin, controller and field.
func compileFieldRelations(pluginID string, ctl pact.AdminController, form *FormSchema) (map[string]*CompiledFieldRelation, error) {
out := map[string]*CompiledFieldRelation{}
fail := func(field string, err error) error {
return bootErr(pluginID, ctl.ID(), "config_form.yaml", fmt.Errorf("field %s: %w", field, err))
}
indexes := map[string]int{}
var order []string
if form != nil {
for i, field := range form.Fields {
if field.Type == "relation" {
indexes[field.Name] = i
order = append(order, field.Name)
}
}
}
provider, hasProvider := ctl.(FieldRelationProvider)
var contracts []FieldRelationContract
if hasProvider && provider != nil {
contracts = provider.AdminFieldRelations()
}
if len(order) == 0 && len(contracts) == 0 {
return out, nil
}
byField := map[string]FieldRelationContract{}
for _, contract := range contracts {
if _, dup := byField[contract.Field]; dup {
return nil, fail(contract.Field, errors.New("duplicate relation contract"))
}
byField[contract.Field] = contract
if _, ok := indexes[contract.Field]; !ok {
return nil, fail(contract.Field, errors.New("relation contract names a field that is not a relation field in fields.yaml"))
}
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil, fail(order[0], errors.New("relation fields require an AdminRecordSource"))
}
parent := src.NewRecord()
for _, name := range order {
contract, declared := byField[name]
if !declared {
if !hasProvider {
return nil, fail(name, errors.New("type relation requires AdminFieldRelations on the controller"))
}
return nil, fail(name, errors.New("has no relation contract"))
}
field := &form.Fields[indexes[name]]
compiled, err := compileFieldRelation(ctl, *field, contract, parent)
if err != nil {
return nil, fail(name, err)
}
field.Multiple = compiled.Multiple
field.ReadOnly = compiled.ReadOnly
out[name] = compiled
}
return out, nil
}
func compileFieldRelation(ctl pact.AdminController, field FormField, contract FieldRelationContract, parent any) (*CompiledFieldRelation, error) {
if contract.NewRelated == nil || contract.NewRelated() == nil {
return nil, errors.New("relation contract requires a related model")
}
related := contract.NewRelated()
relatedCols := modelColumns(related)
if _, ok := relatedCols[primaryColumn(related)]; !ok {
return nil, fmt.Errorf("related model has no primary key column %s", primaryColumn(related))
}
label := strings.TrimSpace(contract.LabelColumn)
if label == "" {
label = field.NameFrom
if mapper, ok := ctl.(pact.ListRelationColumnMapper); ok && mapper != nil && label != "" {
if mapped, ok := mapper.ListRelationColumn(field.Name, label); ok && mapped != "" {
label = mapped
}
}
}
if !identifier(label) {
return nil, errors.New("relation needs nameFrom or a LabelColumn")
}
if _, ok := relatedCols[label]; !ok {
return nil, fmt.Errorf("related model is missing label column %s", label)
}
out := &CompiledFieldRelation{Contract: contract, LabelColumn: label}
parentCols := modelColumns(parent)
switch contract.Kind {
case relationKindBelongsTo:
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || contract.OrderColumn != "" {
return nil, errors.New("belongsTo cannot declare pivot columns")
}
if !identifier(contract.ForeignKey) {
return nil, errors.New("belongsTo requires a ForeignKey")
}
if _, ok := parentCols[contract.ForeignKey]; !ok {
return nil, fmt.Errorf("parent model is missing foreign key column %s", contract.ForeignKey)
}
fk, ok := structFieldByColumn(parent, contract.ForeignKey)
if !ok || !uintLike(fk.Type) {
return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey)
}
out.Nullable = fk.Type.Kind() == reflect.Pointer
out.ReadOnly = protectedFillKey(contract.ForeignKey)
case relationKindBelongsToMany:
if contract.ForeignKey != "" {
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
}
if contract.NewPivot == nil || contract.NewPivot() == nil {
return nil, errors.New("belongsToMany requires a pivot model")
}
pivot := contract.NewPivot()
if reflect.TypeOf(pivot).Kind() != reflect.Pointer || reflect.TypeOf(pivot).Elem().Kind() != reflect.Struct {
return nil, errors.New("pivot model must be a struct pointer")
}
pivotCols := modelColumns(pivot)
for _, col := range []string{contract.ParentForeignKey, contract.RelatedForeignKey} {
if !identifier(col) {
return nil, errors.New("belongsToMany requires ParentForeignKey and RelatedForeignKey")
}
if _, ok := pivotCols[col]; !ok {
return nil, fmt.Errorf("pivot model is missing column %s", col)
}
}
if contract.ParentForeignKey == contract.RelatedForeignKey {
return nil, errors.New("pivot foreign keys must differ")
}
if contract.OrderColumn != "" {
if !identifier(contract.OrderColumn) {
return nil, fmt.Errorf("order column %q is not an identifier", contract.OrderColumn)
}
if _, ok := pivotCols[contract.OrderColumn]; !ok {
return nil, fmt.Errorf("pivot model is missing order column %s", contract.OrderColumn)
}
if contract.OrderColumn == contract.ParentForeignKey || contract.OrderColumn == contract.RelatedForeignKey {
return nil, errors.New("order column must not be a pivot foreign key")
}
}
out.Multiple = true
default:
return nil, fmt.Errorf("unknown relation kind %s (want belongsTo or belongsToMany)", contract.Kind)
}
return out, nil
}
func structFieldByColumn(model any, column string) (reflect.StructField, bool) {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return reflect.StructField{}, false
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath == "" && gormColumn(field) == column {
return field, true
}
}
return reflect.StructField{}, false
}
func uintLike(t reflect.Type) bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.Uint, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int32, reflect.Int64:
return true
default:
return false
}
}
// writableFieldRelation returns a relation field that offers options and
// accepts values. Read-only and unknown fields are not found.
func writableFieldRelation(cc *CompiledController, name string) (*CompiledFieldRelation, bool) {
if cc == nil || !identifier(name) {
return nil, false
}
fr, ok := cc.FieldRelations[name]
if !ok || fr == nil || fr.ReadOnly {
return nil, false
}
return fr, true
}
// scopedRelationQuery is the one query that both serves options and
// revalidates submitted ids, so the options hook is never only cosmetic.
func scopedRelationQuery(ctx context.Context, db *gorm.DB, cc *CompiledController, field string, fr *CompiledFieldRelation) *gorm.DB {
q := db.WithContext(ctx).Model(fr.Contract.NewRelated())
if ext, ok := cc.Controller.(pact.RelationExtendOptionsQuery); ok && ext != nil {
if next := ext.RelationExtendOptionsQuery(ctx, field, q); next != nil {
q = next
}
}
return q
}
func labelExpression(db *gorm.DB, table, column string) string {
return "COALESCE(CAST(" + quotedIdent(db, table) + "." + quotedIdent(db, column) + " AS TEXT), '')"
}
// RelationOptions serves one page of a relation field's choices (D-17):
// scoped by RelationExtendOptionsQuery, searched case-insensitively on the
// label column, ordered by label then primary key.
func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController, field string, in RelationQuery) ([]RelationOption, ListMeta, error) {
if s.DB == nil {
return nil, ListMeta{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
fr, ok := writableFieldRelation(cc, field)
if !ok {
return nil, ListMeta{}, recordNotFound{}
}
page, per, err := normalizeRelationPage(in.Page, in.PerPage)
if err != nil {
return nil, ListMeta{}, err
}
related := fr.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
q := scopedRelationQuery(ctx, s.DB, cc, field, fr)
label := labelExpression(q, table, fr.LabelColumn)
if term := strings.TrimSpace(in.Search); term != "" {
q = q.Where(label+" ILIKE ? ESCAPE '\\'", "%"+escapeLike(term)+"%")
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, ListMeta{}, lifecycleFailure(cc, err)
}
rows := make([]RelationOption, 0)
err = q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label").
Order(label).
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
Offset((page - 1) * per).Limit(per).
Scan(&rows).Error
if err != nil {
return nil, ListMeta{}, lifecycleFailure(cc, err)
}
last := 1
if total > 0 {
last = int((total + int64(per) - 1) / int64(per))
}
return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil
}
// relationValue is one present, writable relation key lifted from a body.
type relationValue struct {
field string
fr *CompiledFieldRelation
ids []uint
null bool
}
// liftRelationValues takes the writable relation keys present in body. Read
// only fields, fields outside the operation's context and absent keys are
// skipped. Shape errors are one validation_failed with every bad field.
func liftRelationValues(cc *CompiledController, body map[string]any, op string) ([]relationValue, error) {
if cc == nil || len(cc.FieldRelations) == 0 || body == nil {
return nil, nil
}
names := make([]string, 0, len(cc.FieldRelations))
for name := range cc.FieldRelations {
names = append(names, name)
}
sort.Strings(names)
details := map[string]any{}
var out []relationValue
for _, name := range names {
fr := cc.FieldRelations[name]
if fr == nil || fr.ReadOnly || !contextAllows(cc, name, op) {
continue
}
raw, present := body[name]
if !present {
continue
}
value := relationValue{field: name, fr: fr}
if fr.Multiple {
items, ok := raw.([]any)
if !ok {
details[name] = []string{"The " + name + " field must be a list of ids."}
continue
}
seen := map[uint]struct{}{}
ids := make([]uint, 0, len(items))
bad := ""
for _, item := range items {
id, err := relationID(item)
if err != nil {
bad = "The " + name + " field must be a list of integer ids."
break
}
if _, dup := seen[id]; dup {
bad = "The " + name + " field contains a duplicate id."
break
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if bad != "" {
details[name] = []string{bad}
continue
}
value.ids = ids
} else if raw == nil {
if !fr.Nullable {
details[name] = []string{"The " + name + " field cannot be empty."}
continue
}
value.null = true
} else {
id, err := relationID(raw)
if err != nil {
details[name] = []string{"The " + name + " field must be an integer id."}
continue
}
value.ids = []uint{id}
}
out = append(out, value)
}
if len(details) > 0 {
return nil, &ValidationError{Details: details}
}
return out, nil
}
// relationID accepts a JSON integer only: no strings, booleans or fractions.
func relationID(v any) (uint, error) {
switch n := v.(type) {
case json.Number:
return asUint(n)
case float64:
if n != math.Trunc(n) {
return 0, errBadID
}
return asUint(n)
case int, int64, uint, uint32, uint64:
return asUint(n)
default:
return 0, errBadID
}
}
// checkRelationScope re-runs the scoped options query for every submitted id
// inside the save transaction. An id the query does not return (unknown or
// out of scope) is validation_failed on that field and rolls the save back.
func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController, values []relationValue) error {
details := map[string]any{}
for _, value := range values {
if len(value.ids) == 0 {
continue
}
related := value.fr.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
var found []uint
err := scopedRelationQuery(ctx, tx, cc, value.field, value.fr).
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(value.ids)}).
Pluck(quotedIdent(tx, table)+"."+quotedIdent(tx, pk), &found).Error
if err != nil {
return lifecycleFailure(cc, err)
}
have := map[uint]struct{}{}
for _, id := range found {
have[id] = struct{}{}
}
for _, id := range value.ids {
if _, ok := have[id]; !ok {
details[value.field] = []string{"The selected " + value.field + " is invalid."}
break
}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
// foreign key before the row write. Read-only keys never reach this point.
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
for _, value := range values {
if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) {
continue
}
var id any
if !value.null {
id = value.ids[0]
}
if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// syncBelongsToMany replaces the parent's pivot rows in submitted order: an
// explicit delete, then one bulk insert (Phase 5 join-table contract).
func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
parentPK := pkUint(model)
for _, value := range values {
if !value.fr.Multiple {
continue
}
c := value.fr.Contract
proto := c.NewPivot()
err := tx.WithContext(ctx).Unscoped().
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
Delete(proto).Error
if err != nil {
return lifecycleFailure(cc, err)
}
if len(value.ids) == 0 {
continue
}
rows := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(proto).Elem()), 0, len(value.ids))
for i, id := range value.ids {
pivot := c.NewPivot()
if err := setModelColumn(pivot, c.ParentForeignKey, parentPK); err != nil {
return lifecycleFailure(cc, err)
}
if err := setModelColumn(pivot, c.RelatedForeignKey, id); err != nil {
return lifecycleFailure(cc, err)
}
if c.OrderColumn != "" {
if err := setModelColumn(pivot, c.OrderColumn, i); err != nil {
return lifecycleFailure(cc, err)
}
}
rows = reflect.Append(rows, reflect.ValueOf(pivot).Elem())
}
holder := reflect.New(rows.Type())
holder.Elem().Set(rows)
if err := tx.WithContext(ctx).Create(holder.Interface()).Error; err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// projectRelationFields adds every relation field's value to data (belongsTo
// id or null; belongsToMany ids in pivot order, then related id) and returns
// the labels in the same order. Read-only fields are included.
func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, data map[string]any) (RecordMeta, error) {
meta := RecordMeta{Labels: map[string][]RelationOption{}}
if cc == nil || len(cc.FieldRelations) == 0 {
return meta, nil
}
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
v = v.Elem()
}
for name, fr := range cc.FieldRelations {
c := fr.Contract
var ids []uint
if fr.Multiple {
ids = []uint{}
q := tx.WithContext(ctx).Model(c.NewPivot()).
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: pkUint(model)})
if c.OrderColumn != "" {
q = q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.OrderColumn}})
}
if err := q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.RelatedForeignKey}}).Pluck(c.RelatedForeignKey, &ids).Error; err != nil {
return RecordMeta{}, lifecycleFailure(cc, err)
}
data[name] = ids
} else {
data[name] = nil
if id, ok := foreignKeyValue(v, c.ForeignKey); ok {
data[name] = id
ids = []uint{id}
}
}
labels, err := relationLabels(ctx, tx, fr, ids)
if err != nil {
return RecordMeta{}, lifecycleFailure(cc, err)
}
meta.Labels[name] = labels
}
return meta, nil
}
func foreignKeyValue(v reflect.Value, column string) (uint, bool) {
field := fieldByColumn(v, column)
if !field.IsValid() {
return 0, false
}
for field.Kind() == reflect.Pointer {
if field.IsNil() {
return 0, false
}
field = field.Elem()
}
if !field.CanInterface() {
return 0, false
}
id, err := asUint(field.Interface())
if err != nil || id == 0 {
return 0, false
}
return id, true
}
func relationLabels(ctx context.Context, tx *gorm.DB, fr *CompiledFieldRelation, ids []uint) ([]RelationOption, error) {
out := make([]RelationOption, 0, len(ids))
if len(ids) == 0 {
return out, nil
}
related := fr.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
var rows []RelationOption
err := tx.WithContext(ctx).Model(related).
Select(quotedIdent(tx, table) + "." + quotedIdent(tx, pk) + " AS value, " + labelExpression(tx, table, fr.LabelColumn) + " AS label").
Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(ids)}).
Scan(&rows).Error
if err != nil {
return nil, err
}
byID := make(map[uint]string, len(rows))
for _, row := range rows {
byID[row.Value] = row.Label
}
for _, id := range ids {
if label, ok := byID[id]; ok {
out = append(out, RelationOption{Value: id, Label: label})
}
}
return out, nil
}
// fieldOptions serves GET /{vendor}/{plugin}/{controller}/fields/{field}/options.
func (s *service) fieldOptions(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
field := r.PathValue("field")
if _, ok := writableFieldRelation(cc, field); !ok {
writeNotFound(w, r)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
q := r.URL.Query()
rows, meta, err := svc.RelationOptions(r.Context(), cc, field, RelationQuery{Search: q.Get("search"), Page: q.Get("page"), PerPage: q.Get("per_page")})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, rows, meta)
})
}

View File

@@ -0,0 +1,732 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
const p10FormConfig = `name: records
form: ~/plugins/acme/demo/models/record/fields.yaml
modelClass: Record
`
const p10ListConfig = `modelClass: Record
list: ~/plugins/acme/demo/models/record/columns.yaml
recordsPerPage: 20
`
const p10Columns = `columns:
name:
label: Name
searchable: true
`
const p10Fields = `fields:
name:
label: Name
type: text
required: true
group:
label: Group
type: relation
nameFrom: title
emptyOption: None
tags:
label: Tags
type: relation
nameFrom: label
person:
label: Person
type: relation
nameFrom: username
`
type p10Record struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
GroupID *uint `gorm:"column:group_id"`
UserID uint `gorm:"column:user_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (p10Record) TableName() string { return "cabana_p10_records" }
func (p10Record) Fillable() []string { return []string{"name"} }
func (p10Record) Rules() map[string]string { return map[string]string{"name": "required"} }
func (p10Group) TableName() string { return "cabana_p10_groups" }
func (p10Tag) TableName() string { return "cabana_p10_tags" }
func (p10RecordTag) TableName() string { return "cabana_p10_record_tags" }
func (p10Person) TableName() string { return "cabana_p10_people" }
func (p10Controller) ID() string { return "acme.demo.records" }
func (p10Controller) ModelName() string { return "Record" }
func (p10Controller) ConfigDir() string { return "controllers/records" }
func (p10Controller) NewRecord() any { return &p10Record{} }
func (c p10Controller) RequiredPermissions() []string { return c.perms }
type p10Group struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Scope string `gorm:"column:scope"`
}
type p10Tag struct {
ID uint `gorm:"column:id;primaryKey"`
Label *string `gorm:"column:label"`
Scope string `gorm:"column:scope"`
}
type p10RecordTag struct {
RecordID uint `gorm:"column:record_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
Position int `gorm:"column:position"`
}
type p10Person struct {
ID uint `gorm:"column:id;primaryKey"`
Email string `gorm:"column:email"`
}
// p10Controller serves the acme fixtures. Options are scoped to rows whose
// scope is "visible"; the person relation writes the protected user_id and is
// therefore read-only (D-26).
type p10Controller struct {
perms []string
mutate func([]FieldRelationContract) []FieldRelationContract
}
func (c p10Controller) AdminFieldRelations() []FieldRelationContract {
out := []FieldRelationContract{
{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"},
{Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &p10Tag{} }, NewPivot: func() any { return &p10RecordTag{} },
ParentForeignKey: "record_id", RelatedForeignKey: "tag_id", OrderColumn: "position"},
{Field: "person", Kind: "belongsTo", NewRelated: func() any { return &p10Person{} }, ForeignKey: "user_id", LabelColumn: "email"},
}
if c.mutate != nil {
out = c.mutate(out)
}
return out
}
func (p10Controller) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
switch field {
case "group", "tags":
return db.Where("scope = ?", "visible")
default:
return db.Where("1 = 0")
}
}
func (p10Controller) FormBeforeCreate(ctx context.Context, model any) error {
record, ok := model.(*p10Record)
if !ok {
return fmt.Errorf("unexpected model %T", model)
}
principal, _ := bouncer.User(ctx)
if principal != nil {
record.UserID = principal.ID
}
return nil
}
func p10FS() fstest.MapFS {
return fstest.MapFS{
"controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(p10ListConfig)},
"controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(p10FormConfig)},
"models/record/columns.yaml": &fstest.MapFile{Data: []byte(p10Columns)},
"models/record/fields.yaml": &fstest.MapFile{Data: []byte(p10Fields)},
}
}
func p10Compile(ctl p10Controller) (*Registry, error) {
return compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: ctl}})
}
type p10Seed struct {
groups map[string]uint
tags map[string]uint
person uint
}
func p10Fixture(t *testing.T) (*service, *gorm.DB, p10Seed) {
t.Helper()
_, db := newListService(t)
models := []any{&p10Record{}, &p10Group{}, &p10Tag{}, &p10RecordTag{}, &p10Person{}}
if err := db.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
reg, err := p10Compile(p10Controller{perms: []string{"acme.demo.access"}})
if err != nil {
t.Fatalf("registry: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
seed := p10Seed{groups: map[string]uint{}, tags: map[string]uint{}}
for _, g := range []p10Group{{Title: "Alpha", Scope: "visible"}, {Title: "Beta", Scope: "visible"}, {Title: "Hidden", Scope: "hidden"}} {
if err := db.Create(&g).Error; err != nil {
t.Fatal(err)
}
seed.groups[g.Title] = g.ID
}
for _, spec := range []struct{ label, scope string }{{"one", "visible"}, {"two", "visible"}, {"three", "visible"}, {"hidden", "hidden"}} {
label := spec.label
tag := p10Tag{Label: &label, Scope: spec.scope}
if err := db.Create(&tag).Error; err != nil {
t.Fatal(err)
}
seed.tags[spec.label] = tag.ID
}
person := p10Person{ID: 1, Email: "admin@acme.test"}
if err := db.Create(&person).Error; err != nil {
t.Fatal(err)
}
seed.person = person.ID
return &service{app: app, reg: reg}, db, seed
}
func p10Principal(granted bool) *bouncer.Principal {
p := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{}}
if granted {
p.PermissionGrants["acme.demo.access"] = true
}
return p
}
func p10Request(method, id, field, query string, body any, principal *bouncer.Principal) *http.Request {
var reader *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = bytes.NewReader(raw)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, "/", reader)
req.URL.RawQuery = query
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "records")
if id != "" {
req.SetPathValue("id", id)
}
if field != "" {
req.SetPathValue("field", field)
}
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}
func p10Save(svc *service, method, id string, body any) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := p10Request(method, id, "", "", body, p10Principal(true))
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodPut:
svc.update(rec, req)
case http.MethodGet:
svc.show(rec, req)
}
return rec
}
func p10Options(svc *service, field, query string, principal *bouncer.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
svc.fieldOptions(rec, p10Request(http.MethodGet, "", field, query, nil, principal))
return rec
}
type p10Envelope struct {
Data map[string]any `json:"data"`
Meta struct {
Labels map[string][]RelationOption `json:"labels"`
} `json:"meta"`
}
func p10Decode(t *testing.T, rec *httptest.ResponseRecorder, status int) p10Envelope {
t.Helper()
if rec.Code != status {
t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String())
}
var body p10Envelope
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.UseNumber()
if err := dec.Decode(&body); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
if body.Meta.Labels == nil {
t.Fatalf("meta.labels missing: %s", rec.Body.String())
}
return body
}
func p10ID(v any) uint {
n, ok := v.(json.Number)
if !ok {
return 0
}
i, err := n.Int64()
if err != nil || i < 0 {
return 0
}
return uint(i)
}
func p10IDs(v any) []uint {
items, ok := v.([]any)
if !ok {
return nil
}
out := make([]uint, 0, len(items))
for _, item := range items {
out = append(out, p10ID(item))
}
return out
}
func p10Pivot(t *testing.T, db *gorm.DB, recordID uint) []uint {
t.Helper()
var rows []p10RecordTag
if err := db.Where("record_id = ?", recordID).Order("position, tag_id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]uint, len(rows))
for i, row := range rows {
if row.Position != i {
t.Fatalf("pivot positions=%+v", rows)
}
out[i] = row.TagID
}
return out
}
func p10Stored(t *testing.T, db *gorm.DB, id uint) p10Record {
t.Helper()
var row p10Record
if err := db.First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
func sameUintSeq(got, want []uint) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func TestPhase10RelationOptions(t *testing.T) {
svc, db, seed := p10Fixture(t)
extra := []struct{ label, scope string }{
{"alpha first", "visible"}, {"ALPHA second", "visible"}, {"alpha hidden", "hidden"},
{"100%_off", "visible"}, {"100 off", "visible"}, {"same", "visible"}, {"same", "visible"},
}
ids := map[string][]uint{}
for _, spec := range extra {
label := spec.label
tag := p10Tag{Label: &label, Scope: spec.scope}
if err := db.Create(&tag).Error; err != nil {
t.Fatal(err)
}
ids[spec.label] = append(ids[spec.label], tag.ID)
}
for i := 0; i < 25; i++ {
label := fmt.Sprintf("bulk %02d", i)
if err := db.Create(&p10Tag{Label: &label, Scope: "visible"}).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&p10Tag{Scope: "visible"}).Error; err != nil {
t.Fatal(err)
}
visible := int64(3 + 6 + 25 + 1)
decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []RelationOption `json:"data"`
Meta ListMeta `json:"meta"`
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
return body.Data, body.Meta
}
all := p10Options(svc, "tags", "", p10Principal(true))
rows, meta := decode(all)
if meta.Page != 1 || meta.PerPage != 20 || meta.Total != visible || meta.LastPage != 2 || len(rows) != 20 {
t.Fatalf("default page meta=%+v rows=%d", meta, len(rows))
}
if !strings.Contains(all.Body.String(), `"value":`) || strings.Contains(all.Body.String(), `"value":"`) {
t.Fatalf("option values are not numbers: %s", all.Body.String())
}
// Label order follows the database collation; the "bulk NN" labels sort
// the same under every collation.
bulk, _ := decode(p10Options(svc, "tags", "search=bulk&per_page=100", p10Principal(true)))
if len(bulk) != 25 {
t.Fatalf("bulk rows=%d", len(bulk))
}
for i, row := range bulk {
if row.Label != fmt.Sprintf("bulk %02d", i) {
t.Fatalf("options not ordered by label: %+v", bulk)
}
}
if rows[0].Label != "" {
t.Fatalf("a null label must sort first as an empty string: %+v", rows[0])
}
for _, row := range rows {
if strings.Contains(row.Label, "hidden") {
t.Fatalf("scoped options leaked a hidden row: %+v", rows)
}
}
page2, meta2 := decode(p10Options(svc, "tags", "page=2", p10Principal(true)))
if meta2.Page != 2 || len(page2) != int(visible)-20 {
t.Fatalf("page 2 meta=%+v rows=%d", meta2, len(page2))
}
big, bigMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true)))
if bigMeta.PerPage != 100 || len(big) != int(visible) || bigMeta.LastPage != 1 {
t.Fatalf("per_page=100 meta=%+v rows=%d", bigMeta, len(big))
}
caseless, _ := decode(p10Options(svc, "tags", "search=Alpha", p10Principal(true)))
if len(caseless) != 2 || caseless[0].Value != ids["ALPHA second"][0] && caseless[0].Value != ids["alpha first"][0] {
t.Fatalf("case-insensitive search=%+v", caseless)
}
literal, _ := decode(p10Options(svc, "tags", "search=%25_", p10Principal(true)))
if len(literal) != 1 || literal[0].Label != "100%_off" {
t.Fatalf("LIKE metacharacters were not escaped: %+v", literal)
}
same, _ := decode(p10Options(svc, "tags", "search=same", p10Principal(true)))
if len(same) != 2 || same[0].Value != ids["same"][0] || same[1].Value != ids["same"][1] {
t.Fatalf("equal labels not ordered by id: %+v want %v", same, ids["same"])
}
groups, _ := decode(p10Options(svc, "group", "", p10Principal(true)))
if len(groups) != 2 || groups[0].Value != seed.groups["Alpha"] || groups[0].Label != "Alpha" || groups[1].Label != "Beta" {
t.Fatalf("group options=%+v", groups)
}
for _, tc := range []struct{ query, field string }{
{"per_page=101", "per_page"}, {"per_page=0", "per_page"}, {"page=0", "page"}, {"page=x", "page"},
} {
rec := p10Options(svc, "tags", tc.query, p10Principal(true))
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+tc.field+`"`) {
t.Fatalf("%s status=%d body=%s", tc.query, rec.Code, rec.Body.String())
}
}
for _, field := range []string{"name", "person", "nope", "Tags"} {
rec := p10Options(svc, field, "", p10Principal(true))
if rec.Code != http.StatusNotFound {
t.Fatalf("field %s status=%d body=%s", field, rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "not_found")
if strings.Contains(rec.Body.String(), "admin@acme.test") {
t.Fatalf("read-only options disclosed a label: %s", rec.Body.String())
}
}
// Permission is checked before any SQL: this service has no database, so
// reaching a query would be a 500, not a 403.
denied := &service{reg: svc.reg}
rec := p10Options(denied, "tags", "search=one", p10Principal(false))
if rec.Code != http.StatusForbidden {
t.Fatalf("denied status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
frontend := p10Principal(true)
frontend.Backend = false
rec = httptest.NewRecorder()
req := p10Request(http.MethodGet, "", "tags", "", nil, nil)
req = req.WithContext(bouncer.WithUser(req.Context(), frontend))
denied.fieldOptions(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("frontend principal status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPhase10RelationSave(t *testing.T) {
svc, db, seed := p10Fixture(t)
one, two, three := seed.tags["one"], seed.tags["two"], seed.tags["three"]
alpha, beta := seed.groups["Alpha"], seed.groups["Beta"]
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{
"name": "record", "group": alpha, "tags": []uint{three, one}, "person": 99, "user_id": 99,
}), http.StatusCreated)
id := p10ID(created.Data["id"])
if p10ID(created.Data["group"]) != alpha || !sameUintSeq(p10IDs(created.Data["tags"]), []uint{three, one}) {
t.Fatalf("created data=%v", created.Data)
}
if p10ID(created.Data["person"]) != seed.person {
t.Fatalf("read-only person value=%v want %d", created.Data["person"], seed.person)
}
if _, leaked := created.Data["user_id"]; leaked {
t.Fatalf("protected foreign key leaked: %v", created.Data)
}
labels := created.Meta.Labels
if len(labels["group"]) != 1 || labels["group"][0] != (RelationOption{Value: alpha, Label: "Alpha"}) {
t.Fatalf("group labels=%+v", labels["group"])
}
if len(labels["tags"]) != 2 || labels["tags"][0] != (RelationOption{Value: three, Label: "three"}) || labels["tags"][1].Value != one {
t.Fatalf("tag labels=%+v", labels["tags"])
}
if len(labels["person"]) != 1 || labels["person"][0] != (RelationOption{Value: seed.person, Label: "admin@acme.test"}) {
t.Fatalf("person labels=%+v", labels["person"])
}
stored := p10Stored(t, db, id)
if stored.GroupID == nil || *stored.GroupID != alpha || stored.UserID != seed.person {
t.Fatalf("stored=%+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) {
t.Fatalf("pivot=%v", got)
}
idText := fmt.Sprintf("%d", id)
p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed"}), http.StatusOK)
stored = p10Stored(t, db, id)
if stored.Name != "renamed" || stored.GroupID == nil || *stored.GroupID != alpha {
t.Fatalf("absent keys changed the relation: %+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) {
t.Fatalf("absent key changed the pivot: %v", got)
}
cleared := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": nil, "tags": []uint{two, one}}), http.StatusOK)
if cleared.Data["group"] != nil || len(cleared.Meta.Labels["group"]) != 0 || cleared.Meta.Labels["group"] == nil {
t.Fatalf("cleared group data=%v labels=%v", cleared.Data["group"], cleared.Meta.Labels)
}
if stored := p10Stored(t, db, id); stored.GroupID != nil {
t.Fatalf("null did not clear group_id: %+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{two, one}) {
t.Fatalf("replaced pivot=%v", got)
}
p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": beta}), http.StatusOK)
shown := p10Decode(t, p10Save(svc, http.MethodGet, idText, nil), http.StatusOK)
if p10ID(shown.Data["group"]) != beta || !sameUintSeq(p10IDs(shown.Data["tags"]), []uint{two, one}) {
t.Fatalf("show data=%v", shown.Data)
}
if len(shown.Meta.Labels["tags"]) != 2 || shown.Meta.Labels["tags"][0].Label != "two" || shown.Meta.Labels["group"][0].Label != "Beta" {
t.Fatalf("show labels=%+v", shown.Meta.Labels)
}
emptied := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "tags": []uint{}}), http.StatusOK)
if tags, ok := emptied.Data["tags"].([]any); !ok || len(tags) != 0 {
t.Fatalf("emptied tags=%#v", emptied.Data["tags"])
}
if got := p10Pivot(t, db, id); len(got) != 0 {
t.Fatalf("empty list left pivot rows %v", got)
}
}
func TestPhase10RelationForgedID(t *testing.T) {
svc, db, seed := p10Fixture(t)
one := seed.tags["one"]
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "keep", "group": seed.groups["Alpha"], "tags": []uint{one}}), http.StatusCreated)
id := p10ID(created.Data["id"])
idText := fmt.Sprintf("%d", id)
for _, tc := range []struct {
name string
body map[string]any
field string
}{
{"out of scope tag", map[string]any{"tags": []uint{seed.tags["hidden"]}}, "tags"},
{"unknown tag", map[string]any{"tags": []uint{999999}}, "tags"},
{"text tag", map[string]any{"tags": []any{"x"}}, "tags"},
{"fractional tag", map[string]any{"tags": []any{1.5}}, "tags"},
{"negative tag", map[string]any{"tags": []any{-1}}, "tags"},
{"duplicate tag", map[string]any{"tags": []uint{one, one}}, "tags"},
{"scalar for many", map[string]any{"tags": one}, "tags"},
{"out of scope group", map[string]any{"group": seed.groups["Hidden"]}, "group"},
{"list for one", map[string]any{"group": []uint{seed.groups["Beta"]}}, "group"},
{"text group", map[string]any{"group": "abc"}, "group"},
} {
t.Run(tc.name, func(t *testing.T) {
body := map[string]any{"name": "changed"}
for key, value := range tc.body {
body[key] = value
}
rec := p10Save(svc, http.MethodPut, idText, body)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "validation_failed")
var envelope struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if len(envelope.Error.Details[tc.field]) == 0 {
t.Fatalf("details missing %s: %s", tc.field, rec.Body.String())
}
stored := p10Stored(t, db, id)
if stored.Name != "keep" || stored.GroupID == nil || *stored.GroupID != seed.groups["Alpha"] {
t.Fatalf("rejected save committed: %+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{one}) {
t.Fatalf("rejected save changed the pivot: %v", got)
}
})
}
var before int64
if err := db.Model(&p10Record{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
rec := p10Save(svc, http.MethodPost, "", map[string]any{"name": "forged", "tags": []uint{one, seed.tags["hidden"]}})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("forged create status=%d body=%s", rec.Code, rec.Body.String())
}
var after int64
if err := db.Model(&p10Record{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
var pivots int64
if err := db.Model(&p10RecordTag{}).Count(&pivots).Error; err != nil {
t.Fatal(err)
}
if after != before || pivots != 1 {
t.Fatalf("forged create committed rows=%d->%d pivots=%d", before, after, pivots)
}
}
func TestPhase10RelationBoot(t *testing.T) {
reg, err := p10Compile(p10Controller{})
if err != nil {
t.Fatalf("valid contracts: %v", err)
}
cc, _ := reg.Get("acme.demo.records")
raw, err := json.Marshal(cc.Form.Fields)
if err != nil {
t.Fatal(err)
}
fields := map[string]map[string]any{}
var list []map[string]any
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatal(err)
}
for _, field := range list {
fields[field["name"].(string)] = field
}
if fields["tags"]["multiple"] != true || fields["tags"]["readOnly"] != nil {
t.Fatalf("tags field=%v", fields["tags"])
}
if fields["group"]["multiple"] != nil || fields["group"]["readOnly"] != nil {
t.Fatalf("group field=%v", fields["group"])
}
if fields["person"]["readOnly"] != true || fields["person"]["multiple"] != nil {
t.Fatalf("person field=%v", fields["person"])
}
for _, tc := range []struct {
name string
mutate func([]FieldRelationContract) []FieldRelationContract
want []string
}{
{"missing contract", func(in []FieldRelationContract) []FieldRelationContract { return in[1:] }, []string{"field group", "no relation contract"}},
{"missing foreign key column", func(in []FieldRelationContract) []FieldRelationContract {
in[0].ForeignKey = "missing_id"
return in
}, []string{"field group", "missing_id"}},
{"missing pivot column", func(in []FieldRelationContract) []FieldRelationContract {
in[1].OrderColumn = "rank"
return in
}, []string{"field tags", "rank"}},
{"missing label column", func(in []FieldRelationContract) []FieldRelationContract {
in[2].LabelColumn = "username"
return in
}, []string{"field person", "username"}},
{"unknown kind", func(in []FieldRelationContract) []FieldRelationContract {
in[0].Kind = "hasMany"
return in
}, []string{"field group", "unknown relation kind hasMany"}},
{"missing related model", func(in []FieldRelationContract) []FieldRelationContract {
in[0].NewRelated = nil
return in
}, []string{"field group", "related model"}},
{"duplicate contract", func(in []FieldRelationContract) []FieldRelationContract { return append(in, in[0]) }, []string{"field group", "duplicate"}},
{"orphan contract", func(in []FieldRelationContract) []FieldRelationContract {
return append(in, FieldRelationContract{Field: "extra", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"})
}, []string{"field extra", "not a relation field"}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := p10Compile(p10Controller{mutate: tc.mutate})
if err == nil {
t.Fatal("activation accepted a broken relation contract")
}
for _, want := range append([]string{"acme.demo", "acme.demo.records"}, tc.want...) {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err=%v missing %q", err, want)
}
}
})
}
_, err = compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: crudControllerLike{}}})
if err == nil || !strings.Contains(err.Error(), "field group") || !strings.Contains(err.Error(), "AdminFieldRelations") {
t.Fatalf("controller without contracts err=%v", err)
}
}
// crudControllerLike has the relation form but declares no contracts.
type crudControllerLike struct{}
func (crudControllerLike) ID() string { return "acme.demo.records" }
func (crudControllerLike) ModelName() string { return "Record" }
func (crudControllerLike) ConfigDir() string { return "controllers/records" }
func (crudControllerLike) NewRecord() any { return &p10Record{} }
func TestPhase10NestedGetDispatch(t *testing.T) {
svc, _, seed := p10Fixture(t)
call := func(id, segment, name string) *httptest.ResponseRecorder {
req := p10Request(http.MethodGet, id, "", "", nil, p10Principal(true))
req.SetPathValue("segment", segment)
req.SetPathValue("name", name)
rec := httptest.NewRecorder()
svc.nestedGet(rec, req)
return rec
}
options := call("fields", "group", "options")
if options.Code != http.StatusOK || !strings.Contains(options.Body.String(), fmt.Sprintf(`"value":%d`, seed.groups["Alpha"])) {
t.Fatalf("fields dispatch status=%d body=%s", options.Code, options.Body.String())
}
for _, tc := range [][3]string{{"fields", "group", "choices"}, {"5", "other", "x"}, {"filters", "group", "list"}} {
rec := call(tc[0], tc[1], tc[2])
if rec.Code != http.StatusNotFound {
t.Fatalf("%v status=%d body=%s", tc, rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "not_found")
}
}

View File

@@ -0,0 +1,189 @@
package cabana
import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
)
func TestRelationSchema(t *testing.T) {
ctl := relationTestController{}
fsys := relationTestFS(validRelationYAML)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
compiled := relations["editors"]
if compiled == nil || compiled.Contract.ParentForeignKey != "owner_id" || compiled.Contract.RelatedForeignKey != "target_id" {
t.Fatalf("compiled=%#v", compiled)
}
raw, err := json.Marshal(compiled.Schema)
if err != nil {
t.Fatal(err)
}
got := string(raw)
for _, want := range []string{`"name":"editors"`, `"key":"username"`, `"key":"email"`, `"toolbarButtons":["link","unlink"]`, `"showSearch":true`} {
if !strings.Contains(got, want) {
t.Fatalf("schema missing %s: %s", want, got)
}
}
if strings.Index(got, `"key":"username"`) >= strings.Index(got, `"key":"email"`) {
t.Fatalf("column order drifted: %s", got)
}
for _, tc := range []struct {
name string
yaml string
provider relationTestController
want string
}{
{name: "unknown key", yaml: strings.Replace(validRelationYAML, "showSearch: true", "showSearch: true\n conditions: unsafe", 1), want: "conditions"},
{name: "unsupported action", yaml: strings.Replace(validRelationYAML, "link|unlink", "link|execute", 1), want: "unsupported relation action execute"},
{name: "missing relation", yaml: "other:\n label: other\n view:\n list:\n columns: {}\n manage:\n list:\n columns: {}\n", want: "has no relation-manager field"},
{name: "bad target column", yaml: validRelationYAML, provider: relationTestController{badColumn: true}, want: "target is missing column missing"},
} {
t.Run(tc.name, func(t *testing.T) {
probe := tc.provider
form, err := CompileForm("acme.demo", probe, relationTestFS(tc.yaml))
if err != nil {
t.Fatal(err)
}
_, err = compileRelations("acme.demo", probe, relationTestFS(tc.yaml), form)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err=%v want=%q", err, tc.want)
}
})
}
}
func TestRelationEmpty(t *testing.T) {
raw, err := json.Marshal(RelationSchema{Name: "empty"})
if err != nil {
t.Fatal(err)
}
got := string(raw)
if strings.Count(got, `"columns":[]`) != 2 || strings.Count(got, `"toolbarButtons":[]`) != 2 || strings.Contains(got, "null") {
t.Fatalf("empty relation collections=%s", got)
}
}
func TestRelationSingle(t *testing.T) {
schema := RelationSchema{Name: "editors", View: RelationPanel{List: RelationList{Columns: []RelationColumn{{Key: "email", Label: "email", Searchable: true, Sortable: true}}}}}
raw, err := json.Marshal(schema)
if err != nil {
t.Fatal(err)
}
if strings.Count(string(raw), `"key":"email"`) != 1 {
t.Fatalf("single column=%s", raw)
}
}
func TestRelationStablePages(t *testing.T) {
schema := &RelationSchema{View: RelationPanel{List: RelationList{Columns: []RelationColumn{{Key: "username", Sortable: true}, {Key: "email", Sortable: true}}}}}
page, per, key, desc, err := normalizeRelationQuery(schema, false, RelationQuery{Page: "2", PerPage: "20", Sort: "username", Dir: "asc"})
if err != nil || page != 2 || per != 20 || key != "username" || desc {
t.Fatalf("normalized=%d/%d %q desc=%v err=%v", page, per, key, desc, err)
}
if _, _, _, _, err := normalizeRelationQuery(schema, false, RelationQuery{Sort: "Username"}); err == nil {
t.Fatal("case-changed sort survived")
}
}
func TestRelationCandidateExclusions(t *testing.T) {
contract := relationTestController{}.AdminRelationContracts()[0]
ids, err := contract.ExcludedRelatedIDs(&relationTestOwner{OwnerID: 41})
if err != nil || len(ids) != 1 || ids[0] != 41 {
t.Fatalf("excluded=%v err=%v", ids, err)
}
if _, err := contract.ExcludedRelatedIDs(&relationTestTarget{}); err == nil {
t.Fatal("wrong parent type survived")
}
}
const validRelationYAML = `editors:
label: demo.editors
view:
list:
columns:
username:
label: demo.username
email:
label: demo.email
toolbarButtons: link|unlink
showSearch: true
manage:
list:
columns:
username:
label: demo.username
email:
label: demo.email
showSearch: true
`
func relationTestFS(relation string) fstest.MapFS {
return fstest.MapFS{
"controllers/owners/config_form.yaml": {Data: []byte("name: owners\nform: ~/plugins/acme/demo/models/owner/fields.yaml\nmodelClass: Owner\n")},
"controllers/owners/config_relation.yaml": {Data: []byte(relation)},
"models/owner/fields.yaml": {Data: []byte("fields:\n editors:\n type: relation-manager\n relation: editors\n")},
}
}
type relationTestController struct{ badColumn bool }
func (relationTestController) ID() string { return "acme.demo.owners" }
func (relationTestController) ModelName() string { return "Owner" }
func (relationTestController) ConfigDir() string { return "controllers/owners" }
func (relationTestController) NewRecord() any { return &relationTestOwner{} }
func (c relationTestController) AdminRelationContracts() []RelationContract {
columns := map[string]string{"username": "username", "email": "email"}
if c.badColumn {
columns["email"] = "missing"
}
return []RelationContract{{
Name: "editors", NewRelated: func() any { return &relationTestTarget{} }, NewPivot: func() any { return &relationTestPivot{} },
ParentForeignKey: "owner_id", RelatedForeignKey: "target_id", Columns: columns,
HookPivotColumns: []string{"role"},
ExcludedRelatedIDs: func(parent any) ([]uint, error) {
owner, ok := parent.(*relationTestOwner)
if !ok {
return nil, &relationTestError{}
}
return []uint{owner.OwnerID}, nil
},
}}
}
type relationTestOwner struct {
ID uint `gorm:"column:id;primaryKey"`
OwnerID uint `gorm:"column:owner_id"`
Editors []relationTestTarget `gorm:"many2many:relation_test_pivots"`
}
func (*relationTestOwner) TableName() string { return "relation_test_owners" }
type relationTestTarget struct {
ID uint `gorm:"column:id;primaryKey"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
}
func (*relationTestTarget) TableName() string { return "relation_test_targets" }
type relationTestPivot struct {
ID uint `gorm:"column:id;primaryKey"`
OwnerID uint `gorm:"column:owner_id"`
TargetID uint `gorm:"column:target_id"`
Role string `gorm:"column:role"`
}
func (*relationTestPivot) TableName() string { return "relation_test_pivots" }
type relationTestError struct{}
func (*relationTestError) Error() string { return "wrong relation parent" }

58
modules/cabana/schema.go Normal file
View File

@@ -0,0 +1,58 @@
package cabana
import (
"bytes"
"fmt"
"io/fs"
"path"
"strings"
"github.com/goccy/go-yaml"
)
func decodeStrict(raw []byte, dest any) error {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(dest); err != nil {
return err
}
return nil
}
func readAsset(fsys fs.FS, name string) ([]byte, error) {
name = path.Clean(name)
if name == "." || strings.HasPrefix(name, "..") || strings.Contains(name, "..") {
return nil, fmt.Errorf("path escapes the plugin")
}
return fs.ReadFile(fsys, name)
}
func assetPath(pluginID, ref string) (string, error) {
ref = strings.TrimSpace(ref)
ref = strings.TrimPrefix(ref, "~/")
prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/"
ref = strings.TrimPrefix(ref, prefix)
ref = path.Clean(ref)
if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") {
return "", fmt.Errorf("list path escapes the plugin")
}
return ref, nil
}
func identifier(s string) bool {
if s == "" {
return false
}
for i, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
case i > 0 && r >= '0' && r <= '9':
default:
return false
}
}
return true
}
func bootErr(pluginID, controllerID, file string, err error) error {
return fmt.Errorf("cabana: admin schema %s/%s/%s: %w", pluginID, controllerID, file, err)
}

View File

@@ -0,0 +1,247 @@
package cabana
import (
"context"
"encoding/json"
"fmt"
)
// ListSort is the compiled default sort. Direction is exactly asc or desc.
type ListSort struct {
Column string `json:"column"`
Direction string `json:"direction"`
}
// ListColumn is one compiled columns.yaml entry, in file order.
// Omitted sortable defaults to true, matching Winter; searchable defaults to false.
type ListColumn struct {
Key string `json:"key"`
Label string `json:"label"`
Searchable bool `json:"searchable"`
Sortable bool `json:"sortable"`
Type string `json:"type,omitempty"`
Relation string `json:"relation,omitempty"`
Select string `json:"select,omitempty"`
}
// ListFilter is one compiled config_filter scope. Values stay typed scalars.
type ListFilter struct {
Name string `json:"name"`
Label string `json:"label,omitempty"`
Type string `json:"type"`
Column string `json:"column,omitempty"`
Scope string `json:"scope,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
NameFrom string `json:"nameFrom,omitempty"`
TrueValue *jsonScalar `json:"trueValue,omitempty"`
FalseValue *jsonScalar `json:"falseValue,omitempty"`
Options []FormOption `json:"options,omitempty"`
}
// RowAction is a per-record link derived from recordUrl.
type RowAction struct {
Name string `json:"name"`
Label string `json:"label,omitempty"`
URL string `json:"url,omitempty"`
}
// BulkAction is a checkbox action. It carries no SQL.
type BulkAction struct {
Name string `json:"name"`
Label string `json:"label,omitempty"`
}
// ListSchema is the boot-compiled list contract for one controller.
// Labels stay as source keys until a request localizes a copy.
type ListSchema struct {
Title string `json:"title,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
RecordURL string `json:"recordUrl,omitempty"`
NoRecordsMessage string `json:"noRecordsMessage,omitempty"`
RecordsPerPage int `json:"recordsPerPage"`
PerPageOptions []int `json:"perPageOptions"`
ShowSearch bool `json:"showSearch"`
ShowSetup bool `json:"showSetup"`
ShowCheckboxes bool `json:"showCheckboxes"`
ShowSorting bool `json:"showSorting"`
SearchTerm string `json:"searchTerm"`
SearchPrompt string `json:"searchPrompt,omitempty"`
DefaultSort *ListSort `json:"defaultSort,omitempty"`
ToolbarButtons []string `json:"toolbarButtons"`
Columns []ListColumn `json:"columns"`
Filters []ListFilter `json:"filters"`
RowActions []RowAction `json:"rowActions"`
BulkActions []BulkAction `json:"bulkActions"`
// Messages is the list's copy (D-13). The cached schema carries each
// phrase key as its own form; a response resolves them in its locale.
Messages *ListMessages `json:"messages"`
Meta *FormMeta `json:"meta,omitempty"`
messageKeys listMessageKeys
}
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
func (s ListSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, s.listMessageKeySet())
s.Messages = &keys
}
type alias ListSchema
out := alias(s)
if out.PerPageOptions == nil {
out.PerPageOptions = []int{}
}
if out.ToolbarButtons == nil {
out.ToolbarButtons = []string{}
}
if out.Columns == nil {
out.Columns = []ListColumn{}
}
if out.Filters == nil {
out.Filters = []ListFilter{}
}
if out.RowActions == nil {
out.RowActions = []RowAction{}
}
if out.BulkActions == nil {
out.BulkActions = []BulkAction{}
}
return json.Marshal(out)
}
// FormSchema is the locale-neutral form contract compiled once at boot.
// Display strings stay as source keys until a request localizes a copy.
type FormSchema struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
messageKeys formMessageKeys
redirects FormRedirects
}
// FormView is one request's localized form, including the locale actually used.
type FormView struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
// Messages is the form's copy resolved in the request locale (D-13).
Messages FormMessages `json:"messages"`
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
// them onto its routes.
Redirects FormRedirects `json:"redirects"`
Meta FormMeta `json:"meta"`
}
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.
type FormRedirects struct {
Default string `json:"default"`
Create FormRedirect `json:"create"`
Update FormRedirect `json:"update"`
}
// FormRedirect is one Winter redirect pair.
type FormRedirect struct {
Redirect string `json:"redirect"`
RedirectClose string `json:"redirectClose"`
}
// FormMeta reports the locale selected for a schema response.
type FormMeta struct {
Locale string `json:"locale"`
}
// FormField is one Winter field in source order. JSON keys keep Winter spelling.
type FormField struct {
Name string `json:"name"`
Type string `json:"type"`
Label string `json:"label,omitempty"`
Comment string `json:"comment,omitempty"`
Span string `json:"span,omitempty"`
Tab string `json:"tab,omitempty"`
Size string `json:"size,omitempty"`
Context *fieldContext `json:"context,omitempty"`
NameFrom string `json:"nameFrom,omitempty"`
EmptyOption string `json:"emptyOption,omitempty"`
Relation string `json:"relation,omitempty"`
Multiple bool `json:"multiple,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Required bool `json:"required,omitempty"`
Default *jsonScalar `json:"default,omitempty"`
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
Options []FormOption `json:"options,omitempty"`
optionsMethod string
}
// FormOption is one dropdown choice. Value keeps the YAML scalar's JSON type.
type FormOption struct {
Value jsonScalar `json:"value"`
Label string `json:"label"`
}
// jsonScalar is a JSON scalar that still emits false, 0, and empty string.
type jsonScalar struct {
raw json.RawMessage
}
func (s jsonScalar) MarshalJSON() ([]byte, error) {
if len(s.raw) == 0 {
return []byte("null"), nil
}
return s.raw, nil
}
// UnmarshalJSON accepts exactly a JSON scalar (string, number, boolean or
// null), so a served schema decodes back into its documented type.
func (s *jsonScalar) UnmarshalJSON(raw []byte) error {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
switch value.(type) {
case nil:
s.raw = nil
case string, float64, bool:
s.raw = append(json.RawMessage(nil), raw...)
default:
return fmt.Errorf("cabana: %s is not a JSON scalar", raw)
}
return nil
}
// fieldContext preserves a single Winter context string or a source-ordered list.
type fieldContext struct {
single bool
values []string
}
// UnmarshalJSON accepts the two served shapes: a string or a string list.
func (c *fieldContext) UnmarshalJSON(raw []byte) error {
var single string
if err := json.Unmarshal(raw, &single); err == nil {
*c = fieldContext{single: true, values: []string{single}}
return nil
}
var values []string
if err := json.Unmarshal(raw, &values); err != nil {
return fmt.Errorf("cabana: context must be a string or a list of strings")
}
*c = fieldContext{values: values}
return nil
}
func (c fieldContext) MarshalJSON() ([]byte, error) {
if c.single {
text := ""
if len(c.values) > 0 {
text = c.values[0]
}
return json.Marshal(text)
}
values := c.values
if values == nil {
values = []string{}
}
return json.Marshal(values)
}

View File

@@ -0,0 +1,311 @@
package cabana
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// adminRoute is one logical admin route. key is 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.
// mounted, when set, is the relative ServeMux key that serves the route:
// logical routes ServeMux cannot hold side by side share one dispatching
// pattern (service.nestedGet).
type adminRoute struct {
key string
public bool
spa bool
mounted string
}
// nestedGetRoute is the shared six-segment GET pattern.
const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"
// phase09Routes is the admin surface mounted by service.mount. A handler added
// outside this set, or a protected handler missing the backend guard, fails
// TestPhase09PermissionMatrix.
var phase09Routes = []adminRoute{
{key: "POST /auth/login", public: true},
{key: "POST /auth/refresh", public: true},
{key: "GET /lang", public: true},
{key: "POST /auth/logout"},
{key: "GET /auth/me"},
{key: "GET /navigation"},
{key: "GET /settings"},
{key: "GET /settings/{code}/schema"},
{key: "GET /settings/{code}"},
{key: "PUT /settings/{code}"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/list"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
{key: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", mounted: nestedGetRoute},
{key: "GET /{vendor}/{plugin}/{controller}"},
{key: "POST /{vendor}/{plugin}/{controller}"},
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", mounted: nestedGetRoute},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
{key: "GET ", public: true, spa: true},
{key: "GET /{path...}", public: true, spa: true},
}
// mountedKey is the full mounted route key for an inventory entry.
func mountedKey(route adminRoute) string {
key := route.key
if route.mounted != "" {
key = route.mounted
}
method, rel, _ := strings.Cut(key, " ")
if route.spa {
return method + " " + DefaultAdminPrefix + rel
}
return method + " " + adminAPI(rel)
}
func TestPhase09PermissionMatrix(t *testing.T) {
router := &captureRouter{}
(&service{}).mount(router)
got := map[string][]string{}
for _, key := range router.routes {
if _, exists := got[key]; exists {
t.Fatalf("route %s registered more than once", key)
}
got[key] = router.middleware[key]
}
want := map[string]bool{}
for _, route := range phase09Routes {
want[mountedKey(route)] = true
}
if len(got) != len(want) {
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(want), router.routes)
}
for _, route := range phase09Routes {
key := mountedKey(route)
mw, ok := got[key]
if !ok {
t.Fatalf("missing mounted route %s in %v", key, router.routes)
}
hasBackend := false
for _, name := range mw {
if name == "backend" {
hasBackend = true
}
}
if route.public && hasBackend {
t.Fatalf("%s is a public auth route but carries the backend guard", route.key)
}
if !route.public && !hasBackend {
t.Fatalf("%s is missing the backend guard: %v", route.key, mw)
}
}
svc := phase09DeniedService()
denied := &bouncer.Principal{ID: 4, Backend: true}
frontend := &bouncer.Principal{ID: 4, Backend: false, PermissionGrants: map[string]bool{"acme.demo.access": true}}
for _, call := range phase09ProtectedCalls() {
t.Run("denied "+call.name, func(t *testing.T) {
rec := httptest.NewRecorder()
call.fn(svc, rec, phase09Request(denied))
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
})
t.Run("frontend "+call.name, func(t *testing.T) {
rec := httptest.NewRecorder()
call.fn(svc, rec, phase09Request(frontend))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
})
}
for _, call := range []phase09Call{
{"navigation", (*service).navigation},
{"settings-list", (*service).settingsList},
} {
t.Run("filtered "+call.name, func(t *testing.T) {
rec := httptest.NewRecorder()
call.fn(svc, rec, phase09Request(denied))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"data":[]`) {
t.Fatalf("permissionless metadata was not an empty list: %s", rec.Body.String())
}
})
}
}
func TestPhase09SecurityCoverage(t *testing.T) {
t.Run("mass assignment", func(t *testing.T) {
cc := &CompiledController{Writable: []WritableField{
{Name: "name", FillKey: "name"},
{Name: "password", FillKey: "password"},
{Name: "role_id", FillKey: "role_id"},
}}
got := ProjectWritableFields(cc, map[string]any{
"name": "Ada",
"Name": "Case",
"password": "hunter2",
"role_id": 1,
"extra": "nope",
"nested": map[string]any{"id": 1},
})
if len(got) != 1 || got["name"] != "Ada" {
t.Fatalf("projected = %#v, want only name", got)
}
})
t.Run("identifier injection", func(t *testing.T) {
for _, raw := range []string{"", "1;drop", "1 OR 1", "../1", "-1", "1.5", "0x10"} {
req := phase09Request(nil)
req.SetPathValue("id", raw)
if _, err := pathID(req); err == nil {
t.Fatalf("path id %q was accepted", raw)
}
}
req := phase09Request(nil)
req.SetPathValue("id", "15")
id, err := pathID(req)
if err != nil || id != 15 {
t.Fatalf("id=%d err=%v", id, err)
}
})
t.Run("auth log redaction", func(t *testing.T) {
const secret = "summercms-test-only-admin-hs256-secret"
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature"
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
req := phase09Request(nil)
req.Header.Set("Authorization", "Bearer "+token)
req.URL.RawQuery = "password=" + secret
(&service{}).logAuth(req, "denied", 7)
logged := buf.String()
for _, leak := range []string{secret, token, "eyJ", "hunter2", "password="} {
if strings.Contains(logged, leak) {
t.Fatalf("auth log contains %q: %s", leak, logged)
}
}
if !strings.Contains(logged, `"outcome":"denied"`) || !strings.Contains(logged, `"admin_id":7`) {
t.Fatalf("auth log dropped the outcome: %s", logged)
}
})
t.Run("pivot body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/relations/editors/link", strings.NewReader(`{"ids":[1],"role":"developer"}`))
req.Header.Set("Content-Type", "application/json")
if _, err := decodeRelationMutation(req); err == nil {
t.Fatal("forged pivot field was accepted")
}
})
t.Run("hook failure rolls back", func(t *testing.T) {
svc, _, cc, db, hooks := hookFixture(t)
hooks.fail = "form_before_create"
ctx := principalCtx(hooks, superUser())
if _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "password": "hunter2"}}); err == nil {
t.Fatal("failing before-create hook was ignored")
}
if n := countCrud(t, db); n != 0 {
t.Fatalf("rows=%d after rejected create", n)
}
})
t.Run("permission before list query", func(t *testing.T) {
listSvc, _ := newListService(t)
locked := *listSvc
current := locked.reg.byID["acme.demo.widgets"]
locked.reg = &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {
Controller: queryController{perms: []string{"acme.demo.access"}},
List: current.List,
},
}}
rec := httptest.NewRecorder()
req := phase09Request(&bouncer.Principal{ID: 4, Backend: true})
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")
req.URL.RawQuery = "sort=name%3Bdrop"
locked.list(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "drop") {
t.Fatalf("denial body echoed the identifier: %s", rec.Body.String())
}
})
}
type phase09Call struct {
name string
fn func(*service, http.ResponseWriter, *http.Request)
}
func phase09ProtectedCalls() []phase09Call {
return []phase09Call{
{"list", (*service).list},
{"create", (*service).create},
{"bulk-delete", (*service).bulkDelete},
{"show", (*service).show},
{"update", (*service).update},
{"delete", (*service).deleteRecord},
{"list-schema", (*service).listSchema},
{"form-schema", (*service).formSchema},
{"relation-schema", (*service).relationSchema},
{"relation-linked", (*service).relationLinked},
{"field-options", (*service).fieldOptions},
{"filter-options", (*service).filterOptions},
{"nested-get", (*service).nestedGet},
{"relation-candidates", (*service).relationCandidates},
{"relation-link", (*service).relationLink},
{"relation-unlink", (*service).relationUnlink},
{"settings-schema", (*service).settingsSchema},
{"settings-get", (*service).settingsGet},
{"settings-put", (*service).settingsPut},
}
}
func phase09DeniedService() *service {
controller := orderController{perms: []string{"acme.demo.access"}}
return &service{reg: &Registry{
byID: map[string]*CompiledController{
"acme.demo.widgets": {Controller: controller, Relations: map[string]*CompiledRelation{}},
},
settings: map[string]*CompiledSetting{
"demo": {Item: pact.SettingsItem{Code: "demo", Permissions: []string{"acme.demo.manage_settings"}}},
},
}}
}
func phase09Request(principal *bouncer.Principal) *http.Request {
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")
req.SetPathValue("id", "1")
req.SetPathValue("name", "editors")
req.SetPathValue("segment", "relations")
req.SetPathValue("code", "demo")
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}

View File

@@ -0,0 +1,106 @@
package cabana
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
type orderController struct {
perms []string
}
func (orderController) ID() string { return "acme.demo.widgets" }
func (orderController) ModelName() string { return "Widget" }
func (orderController) ConfigDir() string { return "controllers/widgets" }
func (c orderController) RequiredPermissions() []string {
return c.perms
}
func TestAuthorizationOrder(t *testing.T) {
svc := &service{reg: &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {
Controller: orderController{perms: []string{"acme.demo.access"}},
List: &ListSchema{RecordsPerPage: 20, Columns: []ListColumn{}},
},
}}}
t.Run("permission before schema", func(t *testing.T) {
called := 0
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 4})
svc.protect(rec, req, func(*CompiledController) { called++ })
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if called != 0 {
t.Fatal("schema or database callback ran before permission denial")
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
})
t.Run("superuser reaches handler", func(t *testing.T) {
called := 0
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true})
svc.protect(rec, req, func(*CompiledController) { called++ })
if called != 1 {
t.Fatalf("superuser callback count=%d, want 1", called)
}
})
t.Run("unknown controller is not queried", func(t *testing.T) {
called := 0
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true})
req.SetPathValue("controller", "missing")
svc.protect(rec, req, func(*CompiledController) { called++ })
if rec.Code != http.StatusNotFound || called != 0 {
t.Fatalf("status=%d called=%d", rec.Code, called)
}
})
}
func TestSecretRedaction(t *testing.T) {
const secret = "summercms-test-only-admin-hs256-secret"
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
rec := httptest.NewRecorder()
writeUnauthenticated(rec, errors.New(secret+" "+token))
body := rec.Body.String()
if strings.Contains(body, secret) || strings.Contains(body, token) || strings.Contains(body, "eyJ") {
t.Fatalf("unauthorized body leaked credential material: %s", body)
}
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
}
func controllerRequest(principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")
if principal != nil {
principal.Backend = true
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}
func assertErrorCode(t *testing.T, raw []byte, code string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != code {
t.Fatalf("error code=%q, want %s; body %s", body.Error.Code, code, raw)
}
}
var _ pact.AdminPermissioned = orderController{}

308
modules/cabana/settings.go Normal file
View File

@@ -0,0 +1,308 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"reflect"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CompiledSetting is a singleton settings registration after strict schema
// compilation and writable-field binding.
type CompiledSetting struct {
PluginID string
Item pact.SettingsItem
Form *FormSchema
Writable []WritableField
}
// SettingsResult is the explicit missing/existing singleton response.
type SettingsResult struct {
Exists bool `json:"exists"`
Data map[string]any `json:"data"`
}
// SettingsService reads and transactionally updates compiled singleton rows.
type SettingsService struct{ DB *gorm.DB }
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
if item.NewModel == nil {
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
}
model := item.NewModel()
rv := reflect.ValueOf(model)
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("cabana: setting %s model must be a non-nil struct pointer", item.Code)
}
if _, ok := model.(lagoon.HasFillable); !ok {
return nil, fmt.Errorf("cabana: setting %s model has no Fillable", item.Code)
}
if _, ok := model.(hasRules); !ok {
return nil, fmt.Errorf("cabana: setting %s model has no Rules", item.Code)
}
formPath := strings.Trim(path.Clean(item.Form), "/")
if formPath == "." || strings.HasPrefix(formPath, "..") {
return nil, fmt.Errorf("cabana: setting %s form escapes the plugin", item.Code)
}
raw, err := readAsset(fsys, formPath)
if err != nil {
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
}
fields, err := decodeFields(raw)
if err != nil {
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
}
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
columns := modelColumns(model)
fillable := map[string]struct{}{}
for _, key := range model.(lagoon.HasFillable).Fillable() {
fillable[key] = struct{}{}
}
writable := make([]WritableField, 0, len(fields))
for _, field := range fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
continue
}
if _, ok := columns[field.Name]; !ok {
return nil, fmt.Errorf("cabana: setting %s field %s is not a model column", item.Code, field.Name)
}
if _, ok := fillable[field.Name]; !ok {
return nil, fmt.Errorf("cabana: setting %s field %s is not fillable", item.Code, field.Name)
}
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
}
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
}
// Get returns default-valued data without creating a missing singleton.
func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (SettingsResult, error) {
if s.DB == nil {
return SettingsResult{}, errors.New("cabana: database is not configured")
}
model, err := newSettingModel(setting)
if err != nil {
return SettingsResult{}, err
}
if err := applySettingDefaults(setting, model); err != nil {
return SettingsResult{}, err
}
err = s.DB.WithContext(ctx).Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: 1}).Take(model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return SettingsResult{Exists: false, Data: projectSetting(setting, model)}, nil
}
if err != nil {
return SettingsResult{}, err
}
return SettingsResult{Exists: true, Data: projectSetting(setting, model)}, nil
}
// Put validates and persists exactly one settings row. Identical replay is a
// successful no-op, including no updated_at churn.
func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body map[string]any) (SettingsResult, error) {
if s.DB == nil {
return SettingsResult{}, errors.New("cabana: database is not configured")
}
if err := requireSettingInputs(setting, body); err != nil {
return SettingsResult{}, err
}
var result SettingsResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
model, err := newSettingModel(setting)
if err != nil {
return err
}
pk := primaryColumn(model)
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.Eq{Column: clause.Column{Name: pk}, Value: 1}).Take(model).Error
exists := err == nil
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if !exists {
if err := applySettingDefaults(setting, model); err != nil {
return err
}
if err := setColumn(model, pk, uint(1)); err != nil {
return err
}
}
before := projectSetting(setting, model)
projected := projectSettingBody(setting, body)
allowed := make([]string, 0, len(setting.Writable))
for _, field := range setting.Writable {
allowed = append(allowed, field.FillKey)
}
if err := lagoon.Fill(model, allowed, projected, false); err != nil {
return &ValidationError{Details: map[string]any{"body": []string{"The settings payload is invalid."}}}
}
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
if err := hook.BeforeValidate(tx); err != nil {
return err
}
}
rules := settingRules(setting, model)
messages, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
if err != nil {
return err
}
if len(messages) > 0 {
return &ValidationError{Details: validationDetails(messages)}
}
after := projectSetting(setting, model)
if !exists {
if err := tx.Create(model).Error; err != nil {
return err
}
} else if !reflect.DeepEqual(before, after) {
if err := tx.Save(model).Error; err != nil {
return err
}
}
result = SettingsResult{Exists: true, Data: after}
return nil
})
return result, err
}
func newSettingModel(setting *CompiledSetting) (any, error) {
if setting == nil || setting.Item.NewModel == nil {
return nil, errors.New("cabana: setting model is not configured")
}
model := setting.Item.NewModel()
rv := reflect.ValueOf(model)
if model == nil || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return nil, errors.New("cabana: setting model is invalid")
}
return model, nil
}
func applySettingDefaults(setting *CompiledSetting, model any) error {
defaults := map[string]any{}
for _, field := range setting.Form.Fields {
if field.Default == nil {
continue
}
var value any
if err := json.Unmarshal(field.Default.raw, &value); err != nil {
return err
}
if field.Type == "switch" || field.Type == "checkbox" {
switch typed := value.(type) {
case float64:
value = typed != 0
case json.Number:
value = typed.String() != "0"
}
}
defaults[field.Name] = value
}
return lagoon.Fill(model, settingWritable(setting), defaults, true)
}
func settingWritable(setting *CompiledSetting) []string {
out := make([]string, 0, len(setting.Writable))
for _, field := range setting.Writable {
out = append(out, field.FillKey)
}
return out
}
func projectSettingBody(setting *CompiledSetting, body map[string]any) map[string]any {
out := map[string]any{}
if setting == nil {
return out
}
for _, field := range setting.Writable {
value, ok := body[field.Name]
if ok && !nestedValue(value) {
out[field.FillKey] = value
}
}
return out
}
func projectSetting(setting *CompiledSetting, model any) map[string]any {
out := map[string]any{}
value := reflect.ValueOf(model)
for value.IsValid() && value.Kind() == reflect.Pointer {
if value.IsNil() {
return out
}
value = value.Elem()
}
if !value.IsValid() || value.Kind() != reflect.Struct || setting == nil {
return out
}
for _, field := range setting.Writable {
v := fieldByColumn(value, field.FillKey)
if v.IsValid() && v.CanInterface() {
out[field.Name] = v.Interface()
}
}
return out
}
func requireSettingInputs(setting *CompiledSetting, body map[string]any) error {
details := map[string]any{}
for _, field := range setting.Form.Fields {
value, present := body[field.Name]
if present && nestedValue(value) {
details[field.Name] = []string{"The " + field.Name + " field is invalid."}
continue
}
if field.Required && !present {
details[field.Name] = []string{"The " + field.Name + " field is required."}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
func settingRules(setting *CompiledSetting, model any) map[string]string {
rules := map[string]string{}
if provider, ok := model.(hasRules); ok && provider != nil {
for key, rule := range provider.Rules() {
rules[key] = rule
}
}
for _, field := range setting.Form.Fields {
if field.Required {
rules[field.Name] = mergeRequired(rules[field.Name])
}
}
return rules
}
func setColumn(model any, column string, value any) error {
rv := reflect.ValueOf(model)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return errors.New("cabana: nil model")
}
rv = rv.Elem()
}
field := fieldByColumn(rv, column)
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("cabana: model has no writable %s column", column)
}
src := reflect.ValueOf(value)
if src.Type().AssignableTo(field.Type()) {
field.Set(src)
return nil
}
if src.Type().ConvertibleTo(field.Type()) {
field.Set(src.Convert(field.Type()))
return nil
}
return fmt.Errorf("cabana: cannot set %s", column)
}

View File

@@ -0,0 +1,20 @@
columns:
name:
label: demo.lang.name
type: text
searchable: true
sortable: true
created_at:
label: demo.lang.created
type: datetime
sortable: true
active:
label: demo.lang.active
type: switch
sortable: false
genre:
label: demo.lang.genre
relation: genre
select: name
searchable: true
sortable: false

View File

@@ -0,0 +1,17 @@
scopes:
activated:
label: demo.lang.activated
type: switch
column: active
options:
true: demo.lang.yes
false: demo.lang.no
created:
label: demo.lang.created_range
type: daterange
column: created_at
grouped:
label: demo.lang.grouped
modelClass: Group
nameFrom: name
scope: filterByGroup