// Permission editor (UI-SPEC S5, D-16), the component on its own. Fixtures // are neutral acme.roster.* data; no application names appear in framework // tests. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import type { FormField } from '../../src/api/types' import { setBundle } from '../../src/app/i18n' import { permissionValues } from '../../src/components/form/control' import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue' import { editablePayload } from '../../src/components/form/formState' import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed' import { keydown, resetState } from '../helpers' const strings = { 'backend::lang.permissioneditor.allow': { other: 'Allow' }, 'backend::lang.permissioneditor.inherit': { other: 'Inherit' }, 'backend::lang.permissioneditor.deny': { other: 'Deny' }, 'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' }, 'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' }, 'backend::lang.permissioneditor.other': { other: 'Other' }, } /** The roster permissions field: Content (2), Reports (1, locked), untabbed (1). */ function field(change: Partial = {}): FormField { return { ...clone(rosterFormSchemaFixture.data.fields.find((item) => item.name === 'permissions')!), ...change } } function mountEditor(props: { field?: FormField; modelValue?: unknown; invalid?: boolean; describedBy?: string } = {}) { return mount(PermissionEditorField, { props: { field: props.field ?? field(), modelValue: props.modelValue ?? {}, controlId: 'p', invalid: props.invalid, describedBy: props.describedBy }, attachTo: document.body, }) } const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`) const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`) const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]') const box = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[role="checkbox"]') const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0]) beforeEach(() => { resetState() setBundle({ ...langFixture.data, ...strings }, 'en') }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('permission editor component (UI-SPEC S5, D-16)', () => { it('groups the options into sections by tab, in the order sent, untabbed ones last as Other', () => { const options = [ { code: 'z.first', label: 'Z first', tab: 'Zeta' }, { code: 'loose.one', label: 'Loose one' }, { code: 'a.one', label: 'A one', tab: 'Alpha' }, { code: 'z.second', label: 'Z second', tab: 'Zeta' }, { code: 'loose.two', label: '' }, ] const wrapper = mountEditor({ field: field({ permissionOptions: options }) }) const sections = wrapper.findAll('[data-permission-section]') expect(sections.map((section) => section.find('h3 span').text())).toEqual(['Zeta', 'Alpha', 'Other']) expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([ ['z.first', 'z.second'], ['a.one'], ['loose.one', 'loose.two'], ]) // Every section is a group named by its heading; a label falls back to the code. expect(sections.every((section) => section.attributes('role') === 'group' && section.attributes('aria-labelledby') === section.find('h3').attributes('id'))).toBe(true) expect(row(wrapper, 'loose.two').text()).toContain('loose.two') }) it('has no Other section when every option has a tab', () => { const wrapper = mountEditor({ field: field({ permissionOptions: [{ code: 'a.one', label: 'A', tab: 'Alpha' }] }) }) expect(wrapper.findAll('[data-permission-section]').map((section) => section.find('h3 span').text())).toEqual(['Alpha']) }) it('shows the empty state without options, keeping the control id for the label', () => { for (const permissionOptions of [[], undefined]) { const wrapper = mountEditor({ field: field({ permissionOptions }) }) const empty = wrapper.find('[data-permission-empty]') expect(empty.text()).toBe('No permissions are defined yet.') expect(empty.attributes('id')).toBe('p') expect(wrapper.find('[data-permission-editor]').exists()).toBe(false) wrapper.unmount() } }) it('names the group by the field label, or by the field name without one', () => { const labelled = mountEditor() expect(labelled.find('[data-permission-editor]').attributes('aria-labelledby')).toBe('p-label') expect(labelled.find('[data-permission-editor]').attributes('aria-label')).toBeUndefined() const bare = mountEditor({ field: field({ label: '' }) }) expect(bare.find('[data-permission-editor]').attributes('aria-label')).toBe('permissions') expect(bare.find('[data-permission-editor]').attributes('aria-labelledby')).toBeUndefined() }) it('shows the invalid border and points at the error line', async () => { const wrapper = mountEditor({ invalid: true, describedBy: 'p-error' }) const editor = wrapper.find('[data-permission-editor]') expect(editor.classes()).toContain('border-danger') expect(editor.classes()).not.toContain('border-border') expect(editor.attributes('aria-describedby')).toBe('p-error') await wrapper.setProps({ invalid: false }) expect(wrapper.find('[data-permission-editor]').classes()).toContain('border-border') }) it('shows stored values: allow, deny, and inherit for a code without one', () => { const wrapper = mountEditor({ modelValue: { 'posts.edit': 1, 'posts.publish': -1 } }) expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('radio') expect(checked(wrapper, 'posts.edit').text()).toBe('Allow') expect(checked(wrapper, 'posts.publish').text()).toBe('Deny') expect(checked(wrapper, 'misc.beta').text()).toBe('Inherit') // Values the mode does not know read as inherit. for (const modelValue of [{ 'posts.edit': 2 }, { 'posts.edit': 'yes' }, { 'posts.edit': null }, null, 'x', [1]]) { const odd = mountEditor({ modelValue }) expect(checked(odd, 'posts.edit').text()).toBe('Inherit') odd.unmount() } }) it('shows a locked row with its stored value, a lock and its text, and a disabled control', async () => { const wrapper = mountEditor({ modelValue: { 'reports.export': 1 } }) const locked = row(wrapper, 'reports.export') expect(locked.attributes('data-locked')).toBe('') expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.') expect(locked.find('svg').attributes('aria-hidden')).toBe('true') expect(checked(wrapper, 'reports.export').text()).toBe('Allow') const group = locked.find('[role="radiogroup"]') expect(group.attributes('aria-disabled')).toBe('true') expect(group.attributes('aria-describedby')).toBe(locked.find('[data-permission-locked]').attributes('id')) // An unlocked row has neither. expect(row(wrapper, 'posts.edit').attributes('data-locked')).toBeUndefined() expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false) }) it('is read-only as a whole when the field is: every control disabled, no lock shown', async () => { for (const change of [{ readOnly: true }, { attributes: { readonly: true } }] as Partial[]) { const wrapper = mountEditor({ field: field(change), modelValue: { 'posts.edit': 1 } }) expect(wrapper.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true) expect(wrapper.find('[data-locked]').exists()).toBe(false) expect(wrapper.find('[data-permission-locked]').exists()).toBe(false) await segment(wrapper, 'posts.edit', '-1').trigger('click') expect(emitted(wrapper)).toEqual([]) wrapper.unmount() } const boxes = mountEditor({ field: field({ mode: 'checkbox', readOnly: true }), modelValue: {} }) expect(boxes.findAll('[role="checkbox"]').every((item) => item.attributes('aria-disabled') === 'true')).toBe(true) await box(boxes, 'posts.edit').trigger('click') expect(emitted(boxes)).toEqual([]) }) it('checkbox mode: one labelled checkbox per row and an Allow column heading', () => { const wrapper = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'posts.edit': 1, 'posts.publish': -1 } }) expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('checkbox') expect(wrapper.find('[role="radiogroup"]').exists()).toBe(false) expect(wrapper.findAll('[data-permission-section] h3 [aria-hidden="true"]').map((item) => item.text())).toEqual(['Allow', 'Allow', 'Allow']) expect(box(wrapper, 'posts.edit').attributes('aria-checked')).toBe('true') // A stored deny is not a checkbox value: it shows unchecked. expect(box(wrapper, 'posts.publish').attributes('aria-checked')).toBe('false') const label = row(wrapper, 'posts.edit').find('label') expect(label.attributes('for')).toBe(box(wrapper, 'posts.edit').attributes('id')) expect(box(wrapper, 'reports.export').attributes('aria-disabled')).toBe('true') }) it('backstop: radio mode emits 1 and -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent', async () => { // Radio: a stored code that is not offered (legacy.code) and an // out-of-set value are dropped from everything the control emits. const stored = { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': 7, 'reports.export': -1 } const radio = mountEditor({ modelValue: stored }) await segment(radio, 'posts.publish', '-1').trigger('click') expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1 }) await radio.setProps({ modelValue: emitted(radio).at(-1) }) await segment(radio, 'misc.beta', '1').trigger('click') expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 }) await radio.setProps({ modelValue: emitted(radio).at(-1) }) // Inherit is the absence of the code, never a 0. await segment(radio, 'posts.edit', '0').trigger('click') expect(emitted(radio).at(-1)).toEqual({ 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 }) expect(Object.values(emitted(radio).at(-1) as Record).every((value) => value === 1 || value === -1)).toBe(true) // A locked row cannot change: by click, and by the keyboard. const before = emitted(radio).length for (const value of ['1', '0', '-1']) { await segment(radio, 'reports.export', value).trigger('click') } keydown(segment(radio, 'reports.export', '-1').element, 'ArrowLeft') keydown(segment(radio, 'reports.export', '-1').element, ' ') await flushPromises() expect(emitted(radio)).toHaveLength(before) expect(segment(radio, 'reports.export', '1').attributes('disabled')).toBeDefined() // Checkbox: checked is 1, unchecked is absent; a stored deny is dropped. const checkbox = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 } }) await box(checkbox, 'misc.beta').trigger('click') expect(emitted(checkbox).at(-1)).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 }) await checkbox.setProps({ modelValue: emitted(checkbox).at(-1) }) await box(checkbox, 'posts.edit').trigger('click') expect(emitted(checkbox).at(-1)).toEqual({ 'reports.export': 1, 'misc.beta': 1 }) const settled = emitted(checkbox).length await box(checkbox, 'reports.export').trigger('click') expect(emitted(checkbox)).toHaveLength(settled) // The save body is reduced the same way, whatever the form state holds. const radioField = field() expect(permissionValues(radioField, { ...stored, 'misc.beta': 0, extra: -1 })).toEqual({ 'posts.edit': 1, 'reports.export': -1 }) expect(editablePayload([radioField], { permissions: stored }, 'update')).toEqual({ permissions: { 'posts.edit': 1, 'reports.export': -1 } }) const checkboxField = field({ mode: 'checkbox' }) expect(editablePayload([checkboxField], { permissions: { 'posts.edit': 1, 'posts.publish': -1, 'legacy.code': 1 } }, 'update')).toEqual({ permissions: { 'posts.edit': 1 }, }) // Nothing stored still sends the field, as an empty object. expect(editablePayload([radioField], {}, 'update')).toEqual({ permissions: {} }) // A field with no options can send no code at all. expect(permissionValues(field({ permissionOptions: [] }), stored)).toEqual({}) }) })