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 = {}) { 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') } }) })