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:
533
modules/cabana/admin_openapi.go
Normal file
533
modules/cabana/admin_openapi.go
Normal 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() {}
|
||||
7
modules/cabana/admin_paths_test.go
Normal file
7
modules/cabana/admin_paths_test.go
Normal 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
506
modules/cabana/auth.go
Normal 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
559
modules/cabana/auth_test.go
Normal 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
250
modules/cabana/bulk_test.go
Normal 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
185
modules/cabana/commands.go
Normal 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)
|
||||
}
|
||||
197
modules/cabana/commands_test.go
Normal file
197
modules/cabana/commands_test.go
Normal 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
177
modules/cabana/contracts.go
Normal 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
932
modules/cabana/crud.go
Normal 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")
|
||||
500
modules/cabana/crud_lifecycle_test.go
Normal file
500
modules/cabana/crud_lifecycle_test.go
Normal 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
471
modules/cabana/crud_test.go
Normal 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
45
modules/cabana/csrf.go
Normal 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
|
||||
}
|
||||
13
modules/cabana/export_test.go
Normal file
13
modules/cabana/export_test.go
Normal 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
|
||||
}
|
||||
168
modules/cabana/filter_options_test.go
Normal file
168
modules/cabana/filter_options_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
356
modules/cabana/filter_schema.go
Normal file
356
modules/cabana/filter_schema.go
Normal 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
|
||||
}
|
||||
632
modules/cabana/form_schema.go
Normal file
632
modules/cabana/form_schema.go
Normal 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)
|
||||
}
|
||||
566
modules/cabana/form_schema_test.go
Normal file
566
modules/cabana/form_schema_test.go
Normal 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
778
modules/cabana/http.go
Normal 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
24
modules/cabana/lang.go
Normal 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})
|
||||
}
|
||||
504
modules/cabana/list_schema.go
Normal file
504
modules/cabana/list_schema.go
Normal 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
|
||||
}
|
||||
597
modules/cabana/list_schema_test.go
Normal file
597
modules/cabana/list_schema_test.go
Normal 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
257
modules/cabana/messages.go
Normal 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
|
||||
}
|
||||
354
modules/cabana/messages_test.go
Normal file
354
modules/cabana/messages_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
213
modules/cabana/metadata_settings_test.go
Normal file
213
modules/cabana/metadata_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
94
modules/cabana/navigation.go
Normal file
94
modules/cabana/navigation.go
Normal 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,
|
||||
}
|
||||
}
|
||||
494
modules/cabana/openapi_conformance_test.go
Normal file
494
modules/cabana/openapi_conformance_test.go
Normal 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
|
||||
`),
|
||||
}
|
||||
}
|
||||
116
modules/cabana/phase09_contract_test.go
Normal file
116
modules/cabana/phase09_contract_test.go
Normal 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
|
||||
}
|
||||
352
modules/cabana/phase10_auth_test.go
Normal file
352
modules/cabana/phase10_auth_test.go
Normal 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" }
|
||||
483
modules/cabana/phase10_coverage_test.go
Normal file
483
modules/cabana/phase10_coverage_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
154
modules/cabana/phase10_csrf_test.go
Normal file
154
modules/cabana/phase10_csrf_test.go
Normal 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
49
modules/cabana/prefix.go
Normal 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
544
modules/cabana/query.go
Normal 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
|
||||
}
|
||||
437
modules/cabana/query_test.go
Normal file
437
modules/cabana/query_test.go
Normal 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{}
|
||||
144
modules/cabana/refresh_revocation_test.go
Normal file
144
modules/cabana/refresh_revocation_test.go
Normal 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
290
modules/cabana/registry.go
Normal 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
843
modules/cabana/relation.go
Normal 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())
|
||||
}
|
||||
652
modules/cabana/relation_field.go
Normal file
652
modules/cabana/relation_field.go
Normal 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)
|
||||
})
|
||||
}
|
||||
732
modules/cabana/relation_field_test.go
Normal file
732
modules/cabana/relation_field_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
189
modules/cabana/relation_test.go
Normal file
189
modules/cabana/relation_test.go
Normal 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
58
modules/cabana/schema.go
Normal 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)
|
||||
}
|
||||
247
modules/cabana/schema_types.go
Normal file
247
modules/cabana/schema_types.go
Normal 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)
|
||||
}
|
||||
311
modules/cabana/security_coverage_test.go
Normal file
311
modules/cabana/security_coverage_test.go
Normal 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
|
||||
}
|
||||
106
modules/cabana/security_test.go
Normal file
106
modules/cabana/security_test.go
Normal 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
308
modules/cabana/settings.go
Normal 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)
|
||||
}
|
||||
20
modules/cabana/testdata/list/all_columns.yaml
vendored
Normal file
20
modules/cabana/testdata/list/all_columns.yaml
vendored
Normal 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
|
||||
17
modules/cabana/testdata/list/all_filters.yaml
vendored
Normal file
17
modules/cabana/testdata/list/all_filters.yaml
vendored
Normal 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
|
||||
Reference in New Issue
Block a user