feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import PermissionGroup from '../PermissionGroup.vue'
import { radiosOf, renderInApp } from './roleKit'
import { effectiveScope } from '@/lib/permissions'
import { grantsFrom } from '@/test/access'
import type { PermissionGroupItem } from '@/lib/roles'
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
const ACTIVITY: PermissionGroupItem = {
key: 'activity',
items: [
{ key: 'activity.view', scope_kind: 'scoped', owner_only: false, per_type: false },
{ key: 'activity.export', scope_kind: 'toggle', owner_only: false, per_type: false },
],
}
const SYSTEM: PermissionGroupItem = {
key: 'system',
items: [
{ key: 'system.apply_updates', scope_kind: 'toggle', owner_only: true, per_type: false },
{ key: 'system.edit_retention', scope_kind: 'toggle', owner_only: true, per_type: false },
],
}
const CALLER = (spec: string) => {
const grants = grantsFrom(spec)
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
}
function mountGroup(group: PermissionGroupItem, props: Record<string, unknown> = {}) {
const changes: { key: string; scope: string }[] = []
wrapper = renderInApp(PermissionGroup, {
group,
grants: { 'activity.view': 'all', 'activity.export': 'all' },
initial: { 'activity.view': 'all', 'activity.export': 'all' },
callerScope: CALLER('activity.view, activity.export, system.apply_updates'),
open: true,
onChange: (change: { key: string; scope: string }) => changes.push(change),
...props,
})
return { changes }
}
describe('PermissionGroup', () => {
it('reads "x of y enabled" from the grants', () => {
mountGroup(ACTIVITY)
expect(wrapper!.text()).toContain('2 of 2 enabled')
})
it('counts only the enabled items', () => {
mountGroup(ACTIVITY, { grants: { 'activity.view': 'own' } })
expect(wrapper!.text()).toContain('1 of 2 enabled')
})
it('shows the human names and descriptions of the design', () => {
mountGroup(ACTIVITY)
const text = wrapper!.text()
expect(text).toContain('See activity')
expect(text).toContain('Read the log of who did what.')
expect(text).toContain('Export activity')
expect(text).toContain('Download the log as a CSV file.')
})
it('renders scoped rows as None, Own, All and toggle rows as a switch', async () => {
const { changes } = mountGroup(ACTIVITY)
expect(radiosOf(wrapper!, 'See activity')).toHaveLength(3)
expect(wrapper!.find('[role="switch"][aria-label="Export activity"]').exists()).toBe(true)
radiosOf(wrapper!, 'See activity')[1]!.click()
await wrapper!.find('[role="switch"][aria-label="Export activity"]').trigger('click')
expect(changes).toEqual([
{ key: 'activity.view', scope: 'own' },
{ key: 'activity.export', scope: 'none' },
])
})
it('announces a change in a polite live region', async () => {
mountGroup(ACTIVITY)
radiosOf(wrapper!, 'See activity')[1]!.click()
await flushPromises()
const live = wrapper!.find('[role="status"][aria-live="polite"]')
expect(live.text()).toBe('See activity, set to Own.')
})
it('hides the rows while collapsed', () => {
mountGroup(ACTIVITY, { open: false })
expect(wrapper!.text()).not.toContain('Read the log of who did what.')
expect(wrapper!.text()).toContain('2 of 2 enabled')
})
it('disables owner-only items, keeps them off and tags them Owner only', async () => {
const { changes } = mountGroup(SYSTEM, { grants: {}, initial: {} })
expect(wrapper!.findAll('[data-testid="owner-only-tag"]')).toHaveLength(2)
const toggle = wrapper!.find('[role="switch"][aria-label="Apply software updates"]')
expect(toggle.attributes('aria-checked')).toBe('false')
expect(toggle.attributes('disabled')).toBeDefined()
await toggle.trigger('click')
expect(changes).toEqual([])
expect(wrapper!.text()).toContain('0 of 2 enabled')
})
it('locks All on a scoped row the caller holds only as Own', () => {
mountGroup(ACTIVITY, { callerScope: CALLER('activity.view=own, activity.export') })
const all = radiosOf(wrapper!, 'See activity')[2]!
expect(all.getAttribute('aria-disabled')).toBe('true')
})
it('disables everything when read-only', () => {
mountGroup(ACTIVITY, { readOnly: true })
for (const radio of radiosOf(wrapper!, 'See activity')) {
expect(radio.getAttribute('aria-disabled')).toBe('true')
}
})
})