feat(12.1-01): cabana.ForbiddenError answers a refused write with 403

- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:53:34 +02:00
parent 61d5fc72ad
commit 71073bc8a2
23 changed files with 486 additions and 48 deletions

View File

@@ -1,6 +1,7 @@
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
// row state badges (UI-SPEC S4, D-12).
// row state badges (UI-SPEC S4, D-12) and the forbidden save banner (UI-SPEC
// S6, D-27).
// Fixtures are neutral acme.roster.* data; no application
// names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -9,7 +10,15 @@ import { setBundle } from '../../src/app/i18n'
import RecordActions from '../../src/components/form/RecordActions.vue'
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
import ToastHost from '../../src/components/ui/Toast.vue'
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
import {
clone,
formSchemaFixture,
langFixture,
recordFixture,
rosterListFixture,
rosterListSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const LIST = `${API}/acme/roster/people`
@@ -32,6 +41,7 @@ const strings = {
'backend::lang.form.action_stale': {
other: 'This action no longer applies to this record. The page has been refreshed.',
},
'backend::lang.form.forbidden': { other: 'You do not have permission to make this change. Nothing was saved.' },
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
@@ -545,3 +555,131 @@ describe('row state (UI-SPEC S4, D-12)', () => {
expect(none.text()).toBe('')
})
})
describe('forbidden save (UI-SPEC S6, D-27)', () => {
const WIDGETS = `${API}/acme/demo/widgets`
const RECORD = `${WIDGETS}/1`
function formRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${WIDGETS}/schema/form`]: { body: formSchemaFixture },
[`GET ${RECORD}`]: { body: recordFixture },
...overrides,
}
}
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
status: 403,
body: { error: { code: 'forbidden', message, details } },
})
const banner = (wrapper: VueWrapper) => wrapper.find('[data-forbidden-banner]')
const nameInput = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('#field-name')
it('shows a persistent alert banner with the server message, keeps the values and marks the named field', async () => {
const long = 'You may not rename this widget because it belongs to a maker you cannot manage. '.repeat(3).trim()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
formRoutes({ [`PUT ${RECORD}`]: refused(long, { name: ['This name is reserved.'] }) }),
{ attach: true },
)
await nameInput(wrapper).setValue('Reserved')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(banner(wrapper).attributes('role')).toBe('alert')
expect(banner(wrapper).text()).toBe(long)
expect(banner(wrapper).find('svg').exists()).toBe(true)
// A long message wraps; it is never truncated.
expect(banner(wrapper).html()).not.toContain('truncate')
// It is a banner, not a toast, and the 422 banner does not show.
expect(wrapper.find('[data-tone]').exists()).toBe(false)
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
// Nothing was saved: the typed value and the dirty state stay.
expect(nameInput(wrapper).element.value).toBe('Reserved')
expect(wrapper.text()).toContain('This name is reserved.')
expect(nameInput(wrapper).attributes('aria-invalid')).toBe('true')
expect(document.activeElement).toBe(nameInput(wrapper).element)
// Leaving still asks: the form is dirty.
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expect(dialog()).not.toBeNull()
await press('cancel')
expect(banner(wrapper).exists()).toBe(true)
})
it('falls back to the framework text when the server sends no message, without field marks', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes({ [`PUT ${RECORD}`]: refused('') }), {
attach: true,
})
await nameInput(wrapper).setValue('Other')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(banner(wrapper).text()).toBe('You do not have permission to make this change. Nothing was saved.')
expect(nameInput(wrapper).attributes('aria-invalid')).not.toBe('true')
expect(nameInput(wrapper).element.value).toBe('Other')
})
it('refuses a create the same way', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
formRoutes({ [`POST ${WIDGETS}`]: refused('You may not add widgets here.') }),
{ attach: true },
)
await nameInput(wrapper).setValue('New widget')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(banner(wrapper).text()).toBe('You may not add widgets here.')
expect(nameInput(wrapper).element.value).toBe('New widget')
expect(router.currentRoute.value.name).toBe('create')
})
it('clears the banner when the next save attempt starts', async () => {
let attempt = 0
let release: ((reply: Reply) => void) | undefined
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
formRoutes({
[`PUT ${RECORD}`]: () => {
attempt += 1
if (attempt === 1) {
return refused('You may not rename this widget.')
}
return new Promise<Reply>((resolve) => {
release = resolve
})
},
}),
{ attach: true },
)
await nameInput(wrapper).setValue('Reserved')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(banner(wrapper).exists()).toBe(true)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
// The second request is still running; the banner is already gone.
expect(release).toBeDefined()
expect(banner(wrapper).exists()).toBe(false)
release?.({ body: recordFixture })
await flushPromises()
expect(banner(wrapper).exists()).toBe(false)
})
it('toasts a refused delete with the server message or the fallback', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/1',
formRoutes({ [`DELETE ${RECORD}`]: refused('') }),
{ attach: true },
)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
expect(banner(wrapper).exists()).toBe(false)
expect(router.currentRoute.value.name).toBe('record')
})
})