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:
@@ -269,13 +269,15 @@ A hook or an action stops a write by returning an error. Which error decides wha
|
||||
|
||||
| Error | Answer |
|
||||
|-------|--------|
|
||||
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field. |
|
||||
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field; each message that is a translation key is translated in the request locale. |
|
||||
| `cabana.ForbiddenError` | 403 `forbidden`, with its `Message` and its `Details`. |
|
||||
| any other error | The opaque 500. The error is logged on the server and its text never reaches the client. |
|
||||
|
||||
Return a `cabana.ForbiddenError` when the signed-in administrator may open the screen but may not make this particular change, for example editing a record that needs a higher permission. `Message` is a translation key or text; cabana translates it, and every `Details` message, in the request locale. `Details` maps a field name to a list of messages, as a `cabana.ValidationError` does, and may be left out. With an empty `Message` the admin shows its own text.
|
||||
|
||||
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. A `cabana.ForbiddenError` works the same from the form hooks, the relation hooks, and bulk, record, toolbar and widget actions.
|
||||
A `cabana.ValidationError` message may be a translation key too. A hook that returns `acme.blog::lang.errors.slug_taken` on the `slug` field shows the administrator the sentence in their language; text that is not a key, such as an already translated message, is sent as written. cabana translates a copy, so the error can be a shared package variable.
|
||||
|
||||
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. Both errors work the same, translated in the request locale, from the form hooks, the relation hooks, bulk, record, toolbar and widget actions, the reorder hook (`pact.ReorderAfterSave`) and the singleton record (`pact.AdminSingleton`).
|
||||
|
||||
## Toolbar actions
|
||||
|
||||
@@ -441,7 +443,7 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se
|
||||
|
||||
- The ids are resolved and row-locked through the controller's `pact.ListExtendQuery` scope inside one transaction. `Run` receives the loaded records in `pact.AdminBulkActionInput`, never the ids, so an id outside the scope cannot reach plugin code.
|
||||
- A selection that matches no row in the scope answers `affected: 0` without calling `Run`. A selection of which only a part is in the scope answers 409 `conflict` and changes nothing.
|
||||
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500.
|
||||
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 with its messages translated in the request locale, and any other error the opaque 500.
|
||||
- The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
|
||||
- `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`.
|
||||
- An action with `Detached: true` runs outside the framework transaction. Its records are loaded through the same scope, with the same empty and 409 rules, but without row locks, and `cabana.TxFromContext` reports no transaction. `Run` opens the short transactions it needs (`lagoon.Transaction`). Set it on an action that calls an external service, so that no transaction or row lock is held across the call. What the action saved before a later failure stays saved.
|
||||
|
||||
@@ -284,7 +284,7 @@ A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../re
|
||||
- A duplicate id, a parent outside the set, a cycle and a node deeper than `maxDepth` are 422 `validation_failed` on `nodes`; a partitioned page without `partition` is a 422 on `partition`.
|
||||
- Otherwise the parent and sort columns of every node are written, without running the model's save hooks, and the answer is the new tree.
|
||||
|
||||
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
|
||||
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. A `cabana.ValidationError` is answered 422 and a `cabana.ForbiddenError` 403, both with their messages translated in the request locale; any other error is the opaque 500. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
|
||||
|
||||
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ lookup:
|
||||
- `action` names an action the controller registers through `pact.HasAdminActions`.
|
||||
- `fill` lists the writable scalar fields of the same form the action may write back.
|
||||
|
||||
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field.
|
||||
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field; its messages may be translation keys, translated in the request locale.
|
||||
|
||||
### Widget element contract
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
189
modules/cabana/localize_error_test.go
Normal file
189
modules/cabana/localize_error_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user