fix(cabana): translate hook ValidationError phrase keys in the request locale

- One shared localizer for ValidationError and ForbiddenError, tested in
  writeCRUDError's order; replaces the ForbiddenError-only one.
- Applied on the CRUD and relation service transactions, the detached bulk
  branch, runAction, the record read (ShowRecord), and the reorder,
  singleton and relation-options routes, which localized neither type.
- Only strings containing "::" are looked up, so translated text passes
  through unchanged and logs no missing-key warning.
- Copy-on-write: the plugin's error value is never modified.
- README and backend docs describe the localized ValidationError.
This commit is contained in:
2026-10-19 12:53:17 +02:00
parent d785958df2
commit ce8d7efb67
15 changed files with 376 additions and 67 deletions

View File

@@ -52,7 +52,7 @@ Use cases:
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) significant fraction digits (trailing zeros beyond the scale are accepted unchanged), or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
- Tag lists: a `type: taglist` field in `fields.yaml` edits a list of tags (WinterCMS TagList). `mode: array` binds a JSON column (a type with `sql.Scanner` and `driver.Valuer`, such as `lagoon.Jsonable[[]string]`) and travels as a JSON array of strings; `mode: string`, the default, binds a string column. `options` is a YAML list of suggested tags; `separator` (`comma` or `space`, string mode only) and `customTags` are refused on other types, and an unknown mode or separator, options that are not a list, the money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset` or a column of the wrong Go type fails boot naming the field. A save trims every tag, drops empty ones and keeps a repeated tag once; in array mode null or an empty list stores `[]`, in string mode the tags are split on the separator and stored joined by `,` or a single space (null stores an empty string). A value of the wrong shape is a 422 on the field with `backend::lang.taglist.invalid`, and with `customTags: false` a tag outside `options` is a 422 with `backend::lang.taglist.not_allowed` (`:tag`), on controller forms, settings forms and relation forms alike; a taglist in a repeater row fails boot. The record GET serves the stored list (array mode) or string (string mode). The schema serves `mode`, `options`, `separator` and `customTags` on the field; the admin edits the tags as chips.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Refusals: a lifecycle hook, a relation hook, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
@@ -308,7 +308,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.FieldPreset` | A text or mltext field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
| `cabana.FormField.Separator` / `cabana.FormField.CustomTags` | A `type: taglist` field's string-mode `separator` (`comma` or `space`, empty in array mode) and its `customTags` as `fields.yaml` declares it (nil means the default, true); both omitted from the JSON when unset. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A hook or a `pact.AdminAction` may return a `cabana.ValidationError` to answer 422; its `Details` messages may be translation keys, translated in the request locale. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id`, the widget's `values` and an optional `payload` (any JSON value, at most 64 KiB, widget route only). Unknown keys are refused. |
| `cabana.AdminActionResult` | Answer of an action route: the localized `message`, the filtered `fill` object and, when the action returned one, `data` exactly as returned. |
@@ -388,3 +388,5 @@ The Phase 18 pieces (money field and column, write-only settings secrets, riched
The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Docker-free unit tests in `taglist_field_test.go` (TestTaglistFieldCompile, TestTaglistFieldValidate for the save check in both modes, TestTaglistFieldHydrate for the record GET) and `reorder_flat_test.go` (TestReorderFlatCompile, which also checks the localized list schema, and TestReorderFlatNodes for the save's node check), and by Postgres tests in `catalog_smoke_test.go` (TestTaglistFieldSmoke, TestReorderFlatSmoke with a partitioned subtest) and `reorder_test.go` (TestReorderPostgres for the tree reorder). The admin specs are `admin/tests/form/TaglistField.test.ts` and `admin/tests/views/ReorderView.test.ts`. `scripts/check-phase18.1.sh` runs them by name.
A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage.
The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`).

View File

@@ -269,7 +269,8 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
}
// runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422 and a *ForbiddenError a 403; any other error is
// *ValidationError is a 422 and a *ForbiddenError a 403, both with their
// messages localized in the request locale; any other error is
// logged and answered with the generic 500 body, never the error text. The
// result's Fill passes the fill filter; its Data bypasses that filter but not
// the size cap: it is encoded once and embedded as-is, and a value above
@@ -279,15 +280,12 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
result, err := action.Run(ctx, input)
if err != nil {
// A validation failure is a 422 and a refusal (D-27) a 403, both
// with the action's messages localized.
var invalid *ValidationError
if errors.As(err, &invalid) {
writeCRUDError(w, err)
return
}
// A refusal (D-27) is a 403 with the action's localized message.
var refused *ForbiddenError
if errors.As(err, &refused) {
writeCRUDError(w, localizeForbidden(ctx, tr, err))
if errors.As(err, &invalid) || errors.As(err, &refused) {
writeCRUDError(w, localizeControllerError(ctx, tr, err))
return
}
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)

View File

@@ -61,7 +61,12 @@ type BulkActionResult struct {
Affected int `json:"affected"`
}
// ValidationError is a D-10 validation_failed failure.
// ValidationError is a D-10 validation_failed failure, answered 422. Details
// maps a field name to its messages (a string or a list), each a phrase key
// or text. A key returned by controller code (a lifecycle hook, an action, a
// reorder hook, a singleton record) is translated in the request locale
// before the response is written, like a ForbiddenError; text is written as
// given.
type ValidationError struct {
Details map[string]any
}
@@ -83,55 +88,93 @@ type ForbiddenError struct {
func (e *ForbiddenError) Error() string { return "forbidden" }
// localizeForbidden returns err with a *ForbiddenError's Message and Details
// strings translated in the request locale; any other error, and a nil one,
// is returned unchanged. The plugin's error value is never modified: it may
// be a shared variable.
func localizeForbidden(ctx context.Context, tr *phrasebook.Translator, err error) error {
var refused *ForbiddenError
if err == nil || !errors.As(err, &refused) || refused == nil {
return err
// localizeControllerError returns err with the messages of a plugin-authored
// refusal translated in the request locale: the Details of a
// *ValidationError, or the Message and Details of a *ForbiddenError. The two
// types are tested in writeCRUDError's order, so the error localized is the
// one written. Any other error, a nil one, and a ValidationError without
// Details are returned unchanged. The plugin's error value is never modified:
// it may be a shared package variable read by concurrent requests in other
// locales, so a new error, map and slices are built.
func localizeControllerError(ctx context.Context, tr *phrasebook.Translator, err error) error {
if err == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
var invalid *ValidationError
if errors.As(err, &invalid) {
if invalid == nil || len(invalid.Details) == 0 {
return err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
return &ValidationError{Details: localizeDetails(ctx, tr, invalid.Details)}
}
var refused *ForbiddenError
if !errors.As(err, &refused) || refused == nil {
return err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: translateKey(ctx, tr, refused.Message)}
out := &ForbiddenError{Message: localizeDetail(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = make(map[string]any, len(refused.Details))
for field, value := range refused.Details {
switch messages := value.(type) {
case string:
out.Details[field] = []string{translateKey(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = translateKey(ctx, tr, text)
}
out.Details[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = translateKey(ctx, tr, text)
} else {
list[i] = item
}
}
out.Details[field] = list
default:
out.Details[field] = value
out.Details = localizeDetails(ctx, tr, refused.Details)
}
return out
}
// localizeDetails is the copy-on-write loop over a refusal's Details: a
// string becomes a one-message list, a []string or []any becomes a new slice
// with its strings localized, and any other value is passed through. The
// input map and its slices are never written to.
func localizeDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
out := make(map[string]any, len(details))
for field, value := range details {
switch messages := value.(type) {
case string:
out[field] = []string{localizeDetail(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = localizeDetail(ctx, tr, text)
}
out[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = localizeDetail(ctx, tr, text)
} else {
list[i] = item
}
}
out[field] = list
default:
out[field] = value
}
}
return out
}
// localizeDetail translates one refusal message when it is a phrase key and
// returns any other text unchanged. Every phrasebook key is
// vendor.plugin::group.path and the lookup is exact, so text without "::"
// can never resolve; skipping it keeps already-translated messages (lagoon
// rule messages, framework text) byte-identical and keeps the development
// log free of a missing-key warning per message. An empty message stays
// empty.
func localizeDetail(ctx context.Context, tr *phrasebook.Translator, text string) string {
if !strings.Contains(text, "::") {
return text
}
return translateKey(ctx, tr, text)
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError that comes out of it, so a refusal leaves the
// service ready to be written.
// localizes a *ValidationError or *ForbiddenError that comes out of it, so a
// refusal leaves the service ready to be written.
func (s CRUDService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
return localizeControllerError(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
@@ -369,7 +412,7 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
if action.Detached {
result, err := s.detachedBulkAction(ctx, cc, name, action, ids)
return result, localizeForbidden(ctx, s.tr, err)
return result, localizeControllerError(ctx, s.tr, err)
}
var result BulkActionResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
@@ -456,7 +499,9 @@ func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (
return res.Data, nil
}
// ShowRecord is Show plus the relation labels of the record (D-18).
// ShowRecord is Show plus the relation labels of the record (D-18). A
// ValidationError or ForbiddenError from a controller hook it runs is
// returned with its messages localized in the request locale.
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")
@@ -493,7 +538,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
return err
})
if err != nil {
return RecordResult{}, err
// FormRecordTitle, a record action's Applies and the
// RelationLockProvider may refuse the read with phrase keys.
return RecordResult{}, localizeControllerError(ctx, s.tr, err)
}
return result, nil
}

View File

@@ -772,12 +772,12 @@ func derefValue(v any) any {
}
// localizeNotices turns the notices of a save into the response's
// meta.notices in the request locale, the way localizeForbidden localizes a
// refusal: Message is a phrase key or text, resolved with Choice when Count
// is above zero and Get otherwise; without a translator the text is kept. At
// most maxFieldNotices are kept per field and locale and each message is cut
// to maxNoticeRunes. The result is nil when there is no notice, so the
// response carries no notices key.
// meta.notices in the request locale, the way localizeControllerError
// localizes a refusal: Message is a phrase key or text, resolved with Choice
// when Count is above zero and Get otherwise; without a translator the text
// is kept. At most maxFieldNotices are kept per field and locale and each
// message is cut to maxNoticeRunes. The result is nil when there is no
// notice, so the response carries no notices key.
func localizeNotices(ctx context.Context, tr *phrasebook.Translator, notices []pluginNotice) []FieldNotice {
if len(notices) == 0 {
return nil

View File

@@ -0,0 +1,189 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
const (
menuURLScheme = "acme.menu::lang.errors.url_scheme"
menuTarget = "acme.menu::lang.errors.target"
menuMissing = "acme.menu::lang.errors.missing"
)
var menuTexts = map[string][2]string{
"en": {"The link must start with http or https.", "Choose where the link opens."},
"pl": {"Link musi zaczynać się od http lub https.", "Wybierz, gdzie otwiera się link."},
}
// menuTranslator has the acme.menu error keys in en and pl.
func menuTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
cat := phrasebook.NewCatalog()
files := fstest.MapFS{}
for locale, texts := range menuTexts {
files["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: []byte(fmt.Sprintf("errors:\n url_scheme: %q\n target: %q\n", texts[0], texts[1]))}
}
if err := cat.Load("acme.menu", files); err != nil {
t.Fatal(err)
}
return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
}
// menuInvalid is a ValidationError with every Details shape a plugin uses.
func menuInvalid() *ValidationError {
return &ValidationError{Details: map[string]any{
"url": []string{menuURLScheme},
"target": menuTarget,
"mixed": []any{menuURLScheme, 7},
"title": []string{"The title field is required."},
"nested": map[string]any{"pl": []string{menuURLScheme}},
"absent": []string{menuMissing},
}}
}
func TestLocalizeControllerErrorValidation(t *testing.T) {
tr := menuTranslator(t)
shared := menuInvalid()
before := menuInvalid()
for _, locale := range []string{"pl", "en", "pl"} {
ctx := towel.WithLocale(context.Background(), locale)
got := localizeControllerError(ctx, tr, shared)
var ve *ValidationError
if !errors.As(got, &ve) {
t.Fatalf("%s: got %T, want *ValidationError", locale, got)
}
if ve == shared {
t.Fatalf("%s: the plugin's value was returned, not a copy", locale)
}
want := map[string]any{
"url": []string{menuTexts[locale][0]},
"target": []string{menuTexts[locale][1]},
"mixed": []any{menuTexts[locale][0], 7},
"title": []string{"The title field is required."},
"nested": map[string]any{"pl": []string{menuURLScheme}},
"absent": []string{menuMissing},
}
if !reflect.DeepEqual(ve.Details, want) {
t.Fatalf("%s: details = %#v, want %#v", locale, ve.Details, want)
}
// New slices: writing into the result does not reach the plugin's value.
ve.Details["url"].([]string)[0] = "changed"
ve.Details["title"].([]string)[0] = "changed"
ve.Details["mixed"].([]any)[0] = "changed"
if !reflect.DeepEqual(shared, before) {
t.Fatalf("%s: the plugin's value was modified: %#v", locale, shared.Details)
}
}
}
func TestLocalizeControllerErrorUnchanged(t *testing.T) {
tr := menuTranslator(t)
ctx := towel.WithLocale(context.Background(), "pl")
if got := localizeControllerError(ctx, tr, nil); got != nil {
t.Fatalf("nil error = %v", got)
}
plain := errors.New("x")
if got := localizeControllerError(ctx, tr, plain); got != plain {
t.Fatalf("plain error = %#v, want the identical value", got)
}
empty := &ValidationError{}
if got := localizeControllerError(ctx, tr, empty); got != empty {
t.Fatalf("nil details = %#v, want the identical value", got)
}
noDetails := &ValidationError{Details: map[string]any{}}
if got := localizeControllerError(ctx, tr, noDetails); got != noDetails {
t.Fatalf("empty details = %#v, want the identical value", got)
}
var nilVE *ValidationError
if got := localizeControllerError(ctx, tr, nilVE); got != error(nilVE) {
t.Fatalf("nil *ValidationError = %#v", got)
}
// A wrapped ValidationError is found and localized.
got := localizeControllerError(ctx, tr, fmt.Errorf("save: %w", &ValidationError{Details: map[string]any{"url": []string{menuURLScheme}}}))
ve, ok := got.(*ValidationError)
if !ok || !reflect.DeepEqual(ve.Details, map[string]any{"url": []string{menuTexts["pl"][0]}}) {
t.Fatalf("wrapped = %#v", got)
}
// Without a translator the keys stay.
got = localizeControllerError(nil, nil, &ValidationError{Details: map[string]any{"url": menuURLScheme}})
if ve, ok := got.(*ValidationError); !ok || !reflect.DeepEqual(ve.Details, map[string]any{"url": []string{menuURLScheme}}) {
t.Fatalf("nil translator = %#v", got)
}
}
func TestLocalizeControllerErrorForbidden(t *testing.T) {
tr := menuTranslator(t)
shared := &ForbiddenError{Message: menuTarget, Details: map[string]any{"url": []string{menuURLScheme}, "plain": "Text."}}
for _, locale := range []string{"en", "pl"} {
got := localizeControllerError(towel.WithLocale(context.Background(), locale), tr, shared)
fe, ok := got.(*ForbiddenError)
if !ok || fe == shared {
t.Fatalf("%s: got %#v", locale, got)
}
want := &ForbiddenError{Message: menuTexts[locale][1], Details: map[string]any{"url": []string{menuTexts[locale][0]}, "plain": []string{"Text."}}}
if !reflect.DeepEqual(fe, want) {
t.Fatalf("%s: got %#v, want %#v", locale, fe, want)
}
}
if shared.Message != menuTarget || !reflect.DeepEqual(shared.Details, map[string]any{"url": []string{menuURLScheme}, "plain": "Text."}) {
t.Fatalf("the plugin's value was modified: %#v", shared)
}
// An empty Message stays empty and no Details stay nil.
got := localizeControllerError(context.Background(), tr, &ForbiddenError{})
if fe, ok := got.(*ForbiddenError); !ok || fe.Message != "" || fe.Details != nil {
t.Fatalf("empty refusal = %#v", got)
}
}
func TestWriteCRUDErrorLocalized(t *testing.T) {
tr := menuTranslator(t)
err := localizeControllerError(towel.WithLocale(context.Background(), "pl"), tr, menuInvalid())
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
var env ErrorEnvelope
if jerr := json.Unmarshal(rec.Body.Bytes(), &env); jerr != nil {
t.Fatal(jerr)
}
if rec.Code != http.StatusUnprocessableEntity || env.Error.Code != "validation_failed" {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !reflect.DeepEqual(env.Error.Details["url"], []any{menuTexts["pl"][0]}) {
t.Fatalf("details.url = %#v", env.Error.Details["url"])
}
}
// TestLocalizeControllerErrorPlainTextNotLookedUp: text without "::" is
// never looked up, so the development log has no missing-key warning for it;
// an unknown key does warn, which proves the capture works. Not parallel: it
// swaps the default logger.
func TestLocalizeControllerErrorPlainTextNotLookedUp(t *testing.T) {
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
tr := menuTranslator(t)
ctx := towel.WithLocale(context.Background(), "en")
localizeControllerError(ctx, tr, &ValidationError{Details: map[string]any{"title": []string{"The title field is required."}, "body": "Too long."}})
if strings.Contains(buf.String(), "missing translation key") {
t.Fatalf("plain text was looked up: %s", buf.String())
}
localizeControllerError(ctx, tr, &ValidationError{Details: map[string]any{"url": []string{menuMissing}}})
if !strings.Contains(buf.String(), "missing translation key") {
t.Fatalf("an unknown key logged nothing; the capture does not work: %q", buf.String())
}
}

View File

@@ -561,6 +561,10 @@ const rosterLocked = "Locked"
// copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// rosterInvalid is a shared validation failure whose message is a phrase
// key: the framework must localize a copy and never write into it.
var rosterInvalid = &cabana.ValidationError{Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}}}
// FormVirtualFields lists the form fields that are not columns of the form:
// the password pair and the create-only notify checkbox.
func (rosterController) FormVirtualFields() []string {
@@ -622,8 +626,9 @@ func (rosterController) FormBeforeDelete(_ context.Context, model any) error {
}
// FormBeforeUpdate stores a submitted password, refuses the reserved name
// with a ForbiddenError naming the field, and fails with a plain error for
// the name Boom.
// with a ForbiddenError naming the field, rejects the name Rejected with the
// shared rosterInvalid ValidationError, and fails with a plain error for the
// name Boom.
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
switch model.(*rosterPerson).Name {
@@ -634,6 +639,8 @@ func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error
}
case "Silent":
return &cabana.ForbiddenError{}
case "Rejected":
return rosterInvalid
case "Boom":
return fmt.Errorf("the roster database said hunter2")
}

View File

@@ -148,6 +148,27 @@ func TestForbiddenLocalized(t *testing.T) {
}
}
// TestValidationErrorLocalized: a hook's ValidationError whose message is a
// phrase key is a 422 with the message in the request locale, and the
// plugin's shared value keeps its key.
func TestValidationErrorLocalized(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
for locale, want := range map[string]string{
"en": "This name is reserved.",
"pl": "Ta nazwa jest zastrzeżona.",
} {
rec := rosterLocale(env, http.MethodPut, rosterPath(id, ""), `{"name":"Rejected"}`, locale)
got := rosterError(t, rec)
if rec.Code != http.StatusUnprocessableEntity || got.Code != "validation_failed" || !reflect.DeepEqual(got.Details, map[string]any{"name": []any{want}}) {
t.Fatalf("%s: status=%d error=%+v", locale, rec.Code, got)
}
}
if !reflect.DeepEqual(rosterInvalid.Details, map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}}) {
t.Fatalf("the plugin's error value was modified: %+v", rosterInvalid.Details)
}
}
// TestForbiddenRollsBack: nothing of a refused request stays, also when the
// refusal comes after rows were written.
func TestForbiddenRollsBack(t *testing.T) {

View File

@@ -868,9 +868,10 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError a relation hook returned (D-27).
// localizes a *ValidationError or *ForbiddenError (D-27) a relation hook
// returned.
func (s RelationService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
return localizeControllerError(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// Link links eligible related records to the parent and never restamps

View File

@@ -388,7 +388,8 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController
}
locked, _, err := relationLocks(ctx, cc, field)
if err != nil {
return nil, ListMeta{}, err
// A refusal from the RelationLockProvider carries plugin phrase keys.
return nil, ListMeta{}, localizeControllerError(ctx, s.tr, err)
}
markLocked(rows, locked)
last := 1

View File

@@ -297,7 +297,9 @@ func (s *service) reorderGet(w http.ResponseWriter, r *http.Request) {
// every node, runs the controller's optional ReorderAfterSave hook (pact) with
// the partition (its error rolls the reorder back) and answers the new tree.
// The transaction is a lagoon.Transaction, so a hook can register work with
// lagoon.AfterCommit that runs only once the new order is committed.
// lagoon.AfterCommit that runs only once the new order is committed. A
// ValidationError (422) or ForbiddenError (403) from the hook is answered with
// its messages localized in the request locale.
func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.reorder == nil || cc.singleton {
@@ -359,6 +361,8 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
tree, err = s.reorderTree(txCtx, tx, cc, in.Partition, false)
return err
})
// A refusal from ReorderAfterSave carries plugin phrase keys.
err = localizeControllerError(ctx, s.translator(), err)
if err != nil {
s.writeReorderError(w, r, cc, err)
return

View File

@@ -9,6 +9,7 @@ import (
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -35,6 +36,12 @@ func (hookShelfPlugin) Requires() []string { return nil }
func (hookShelfPlugin) Register(*backpack.App) error { return nil }
func (hookShelfPlugin) Boot(*backpack.App) error { return nil }
func (hookShelfPlugin) AdminFS() fs.FS { return shelfFS }
// LangFS serves the acme.shelf refusal messages of
// TestReorderHookRefusalLocalized.
func (hookShelfPlugin) LangFS() fs.FS {
return fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("errors:\n order: \"This order is not allowed.\"\n locked: \"This shelf is locked.\"\n")}}
}
func (p hookShelfPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{hookedPins{hook: p.hook}}
}
@@ -217,6 +224,35 @@ func TestReorderHookErrorRollsBack(t *testing.T) {
}
}
// TestReorderHookRefusalLocalized: a ValidationError or ForbiddenError from
// ReorderAfterSave whose messages are phrase keys is answered with the
// sentences in the request locale, never the keys, and the reorder is rolled
// back.
func TestReorderHookRefusalLocalized(t *testing.T) {
for _, tc := range []struct {
name string
err error
status int
want string
key string
}{
{"validation error", &cabana.ValidationError{Details: map[string]any{"nodes": []string{"acme.shelf::lang.errors.order"}}}, http.StatusUnprocessableEntity, `"nodes":["This order is not allowed."]`, "acme.shelf::lang.errors.order"},
{"forbidden", &cabana.ForbiddenError{Message: "acme.shelf::lang.errors.locked"}, http.StatusForbidden, `"message":"This shelf is locked."`, "acme.shelf::lang.errors.locked"},
} {
t.Run(tc.name, func(t *testing.T) {
env := newShelfHookEnv(t, hookShelfPlugin{hook: func(context.Context, *uint64) error { return tc.err }})
before := sorts(t, env.db)
code, body := env.swap(t)
if code != tc.status || !strings.Contains(body, tc.want) || strings.Contains(body, tc.key) {
t.Fatalf("status=%d want %d body=%s", code, tc.status, body)
}
if after := sorts(t, env.db); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("sort values changed: before %v after %v", before, after)
}
})
}
}
func TestReorderWithoutHookUnchanged(t *testing.T) {
plain := newShelfHookEnv(t, shelfPlugin{})
code, plainBody := plain.swap(t)

View File

@@ -103,7 +103,8 @@ func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) {
if !errors.As(err, &missing) {
slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err)
}
writeCRUDError(w, err)
// A refusal from SingletonRecord carries plugin phrase keys.
writeCRUDError(w, localizeControllerError(ctx, s.translator(), err))
return
}
WriteData(w, http.StatusOK, result, nil)