feat: SPA Roles screens
This commit is contained in:
1 parent
907949aa12
commit
3db490a615
47 files changed
+6947
-9
No files matched your search
@@ -0,0 +1,881 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import RoleEditorView from '../RoleEditorView.vue'
|
||||
import { radiosOf } from '@/components/__tests__/roleKit'
|
||||
import {
|
||||
Backend,
|
||||
CATALOGUE,
|
||||
buttonNamed,
|
||||
grants,
|
||||
mountRoleView,
|
||||
role,
|
||||
textOf,
|
||||
type Handler,
|
||||
} from './roleKit'
|
||||
import { adminAccess, customAccess, ownerAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toastAdd = vi.fn()
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
})
|
||||
|
||||
const JONAS = { id: 2, name: 'Jonas Weber' }
|
||||
|
||||
const CONTRIBUTOR = role({
|
||||
id: 4,
|
||||
name: 'Contributor',
|
||||
description: 'Creates content and edits only their own.',
|
||||
version: 2,
|
||||
members: 4,
|
||||
grants: grants('content.read, content.create, content.update=own, content.update:case-studies'),
|
||||
})
|
||||
|
||||
const WRITER = role({
|
||||
id: 3,
|
||||
name: 'Writer',
|
||||
built_in: true,
|
||||
version: 1,
|
||||
members: 5,
|
||||
grants: grants('content.read, content.create, content.update'),
|
||||
})
|
||||
|
||||
const OWNER_ROLE = role({ id: 1, name: 'Owner', built_in: true, locked: true, members: 1 })
|
||||
|
||||
const TYPES = [
|
||||
{
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
fields: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 'case-studies',
|
||||
name: 'Case studies',
|
||||
icon: null,
|
||||
fields: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function refuse(status: number, code: string, details: Record<string, string> = {}) {
|
||||
return () => {
|
||||
throw new ApiError(status, undefined, code, { code: code as never, ...details })
|
||||
}
|
||||
}
|
||||
|
||||
function backendFor(loaded: RoleItem | null, extra: Record<string, Handler> = {}) {
|
||||
return new Backend({
|
||||
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
|
||||
'GET /v1/content-type': () => TYPES,
|
||||
...(loaded ? { [`GET /v1/role/${loaded.id}`]: () => loaded } : {}),
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
async function open(
|
||||
path: string,
|
||||
backend: Backend,
|
||||
access: Access = ownerAccess(),
|
||||
options: { desktop?: boolean; userId?: number } = {},
|
||||
) {
|
||||
wrapper = await mountRoleView(RoleEditorView, path, { access, backend, ...options })
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd.mockReset()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function page() {
|
||||
return textOf(document.body)
|
||||
}
|
||||
|
||||
function nameInput() {
|
||||
return document.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="For example, Contributor"]',
|
||||
)!
|
||||
}
|
||||
|
||||
function descriptionInput() {
|
||||
return document.querySelector<HTMLInputElement>('input[placeholder="What this role is for"]')!
|
||||
}
|
||||
|
||||
async function type(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function choose(name: string, scope: 'None' | 'Own' | 'All') {
|
||||
const radio = radiosOf(document.body, name).find((candidate) => textOf(candidate) === scope)!
|
||||
radio.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function toggle(name: string) {
|
||||
document.body.querySelector<HTMLElement>(`[role="switch"][aria-label="${name}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function press(label: string) {
|
||||
buttonNamed(label)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function checked(name: string) {
|
||||
return textOf(
|
||||
radiosOf(document.body, name).find(
|
||||
(radio) => radio.getAttribute('aria-checked') === 'true',
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
function saveBar() {
|
||||
return document.body.querySelector('[data-testid="role-save-bar"]')
|
||||
}
|
||||
|
||||
function toastTitles() {
|
||||
return toastAdd.mock.calls.map((call) => ({
|
||||
title: call[0].title,
|
||||
description: call[0].description,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Role editor: New role (empty)', () => {
|
||||
it('starts with nothing granted, a disabled Save and the hint', async () => {
|
||||
await open('/settings/roles/new', backendFor(null))
|
||||
|
||||
const text = page()
|
||||
expect(text).toContain('New role')
|
||||
expect(text).toContain('Name your role to save it')
|
||||
expect(buttonNamed('Save changes')!.disabled).toBe(true)
|
||||
expect(nameInput().value).toBe('')
|
||||
expect(descriptionInput().value).toBe('')
|
||||
for (const copy of [
|
||||
'Content permissions',
|
||||
'Quick presets',
|
||||
'Read only',
|
||||
'Write own',
|
||||
'Full content',
|
||||
'All content types',
|
||||
'Articles',
|
||||
'Events',
|
||||
'General permissions',
|
||||
'Expand all',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(checked('All content types: Read')).toBe('None')
|
||||
expect(checked('Events: Delete')).toBe('None')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Editing a custom role', () => {
|
||||
it('fills the fields and shows the member count, Custom and the Case studies Reset link', async () => {
|
||||
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||
|
||||
expect(nameInput().value).toBe('Contributor')
|
||||
expect(descriptionInput().value).toBe('Creates content and edits only their own.')
|
||||
expect(page()).toContain('4 members')
|
||||
expect(page()).toContain('Custom')
|
||||
const row = document.body.querySelector('[data-testid="row-case-studies"]')!
|
||||
expect(textOf(row)).toContain('Custom')
|
||||
expect(buttonNamed('Reset', row)).toBeDefined()
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('Reset removes the typed grants and the row follows All content types again', async () => {
|
||||
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||
expect(checked('Case studies: Update')).toBe('All')
|
||||
|
||||
buttonNamed(
|
||||
'Reset',
|
||||
document.body.querySelector('[data-testid="row-case-studies"]')!,
|
||||
)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(checked('Case studies: Update')).toBe('Own')
|
||||
expect(
|
||||
textOf(document.body.querySelector('[data-testid="row-case-studies"]')!),
|
||||
).not.toContain('Custom')
|
||||
expect(page()).toContain('1 unsaved change')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Unsaved changes', () => {
|
||||
it('counts three cell changes, marks them, tags the preview and Discard restores', async () => {
|
||||
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||
|
||||
await choose('Events: Read', 'None')
|
||||
await choose('Events: Delete', 'Own')
|
||||
await toggle('Events: Create')
|
||||
|
||||
expect(page()).toContain('3 unsaved changes')
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="changed-marker"]').length,
|
||||
).toBeGreaterThanOrEqual(3)
|
||||
expect(page()).toContain('Changed')
|
||||
|
||||
await press('Discard')
|
||||
|
||||
expect(saveBar()).toBeNull()
|
||||
expect(checked('Events: Read')).toBe('All')
|
||||
expect(checked('Events: Delete')).toBe('None')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Saving then saved', () => {
|
||||
it('sends the PUT with the current version, dims while in flight and closes the bar on 200', async () => {
|
||||
let release: (value: unknown) => void = () => undefined
|
||||
const inFlight = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const backend = backendFor(CONTRIBUTOR, { 'PUT /v1/role/4': () => inFlight })
|
||||
await open('/settings/roles/4', backend)
|
||||
await choose('All content types: Update', 'All')
|
||||
|
||||
await press('Save changes')
|
||||
|
||||
const put = backend.callsTo('PUT', '/v1/role/4')[0]!
|
||||
expect(put.body).toEqual({
|
||||
name: 'Contributor',
|
||||
description: 'Creates content and edits only their own.',
|
||||
grants: [
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.create', scope: 'all' },
|
||||
{ key: 'content.update', scope: 'all' },
|
||||
],
|
||||
version: 2,
|
||||
})
|
||||
expect(page()).toContain('Saving…')
|
||||
expect(buttonNamed('Discard')!.disabled).toBe(true)
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
|
||||
release({
|
||||
...CONTRIBUTOR,
|
||||
version: 3,
|
||||
grants: grants('content.read, content.create, content.update'),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(saveBar()).toBeNull()
|
||||
expect(toastTitles()).toEqual([
|
||||
{ title: 'Role saved', description: 'Contributor is updated for 4 members.' },
|
||||
])
|
||||
await choose('All content types: Update', 'Own')
|
||||
await press('Save changes')
|
||||
expect((backend.callsTo('PUT', '/v1/role/4')[1]!.body as { version: number }).version).toBe(
|
||||
3,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Creating', () => {
|
||||
it.each([
|
||||
['nothing granted', false, []],
|
||||
[
|
||||
'the Write own preset',
|
||||
true,
|
||||
[
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.create', scope: 'all' },
|
||||
{ key: 'content.update', scope: 'own' },
|
||||
],
|
||||
],
|
||||
])('saves a new role named Contributor with %s', async (_label, preset, expected) => {
|
||||
const backend = backendFor(null, {
|
||||
'POST /v1/role/': () => role({ id: 9, name: 'Contributor', version: 1 }),
|
||||
'GET /v1/role/9': () => role({ id: 9, name: 'Contributor', version: 1 }),
|
||||
})
|
||||
await open('/settings/roles/new', backend)
|
||||
await type(nameInput(), 'Contributor')
|
||||
if (preset) {
|
||||
await press('Write own')
|
||||
}
|
||||
|
||||
await press('Save changes')
|
||||
|
||||
const post = backend.callsTo('POST', '/v1/role/')[0]!
|
||||
expect(post.body).toEqual({ name: 'Contributor', description: '', grants: expected })
|
||||
expect(toastTitles()).toEqual([
|
||||
{ title: 'Role created', description: 'Contributor is ready. Assign it from Users.' },
|
||||
])
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/settings/roles/9'))
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Presets', () => {
|
||||
it.each([
|
||||
['Read only', ['All', 'None', 'None', 'None']],
|
||||
['Write own', ['All', 'On', 'Own', 'None']],
|
||||
['Full content', ['All', 'On', 'All', 'All']],
|
||||
])('%s sets every type row and counts unsaved changes', async (preset, expected) => {
|
||||
await open('/settings/roles/new', backendFor(null))
|
||||
|
||||
await press(preset)
|
||||
|
||||
expect(buttonNamed(preset)!.getAttribute('aria-pressed')).toBe('true')
|
||||
for (const type of ['Events', 'Articles']) {
|
||||
const [read, create, update, deleteScope] = expected
|
||||
expect(checked(`${type}: Read`)).toBe(read)
|
||||
const create_ = document.body.querySelector(
|
||||
`[role="switch"][aria-label="${type}: Create"]`,
|
||||
)!
|
||||
expect(create_.getAttribute('aria-checked')).toBe(create === 'On' ? 'true' : 'false')
|
||||
expect(checked(`${type}: Update`)).toBe(update === 'On' ? 'All' : update)
|
||||
expect(checked(`${type}: Delete`)).toBe(deleteScope)
|
||||
}
|
||||
expect(page()).toMatch(/\d+ unsaved changes?/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Errors on save', () => {
|
||||
async function failWith(handler: Handler, edit?: () => Promise<void>) {
|
||||
const backend = backendFor(CONTRIBUTOR, { 'PUT /v1/role/4': handler })
|
||||
await open('/settings/roles/4', backend)
|
||||
await choose('Events: Delete', 'Own')
|
||||
await edit?.()
|
||||
await press('Save changes')
|
||||
return backend
|
||||
}
|
||||
|
||||
it('sends nothing for an empty name and reads the field error and the count', async () => {
|
||||
const backend = await failWith(
|
||||
() => undefined,
|
||||
() => type(nameInput(), ''),
|
||||
)
|
||||
|
||||
expect(backend.callsTo('PUT', '/v1/role/4')).toHaveLength(0)
|
||||
expect(page()).toContain('Give the role a name.')
|
||||
expect(page()).toContain('1 problem to fix')
|
||||
})
|
||||
|
||||
it('maps 400 invalid_role_name to the same field error and keeps the edits', async () => {
|
||||
await failWith(refuse(400, 'invalid_role_name'))
|
||||
|
||||
expect(page()).toContain('Give the role a name.')
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
})
|
||||
|
||||
it('maps 409 role_name_taken to the named field error', async () => {
|
||||
await failWith(refuse(409, 'role_name_taken'), () => type(nameInput(), 'Writer'))
|
||||
|
||||
expect(page()).toContain('A role named "Writer" already exists. Choose a different name.')
|
||||
expect(page()).toContain('1 problem to fix')
|
||||
expect(nameInput().value).toBe('Writer')
|
||||
})
|
||||
|
||||
it('maps 400 built_in_name to the built-in message', async () => {
|
||||
await failWith(refuse(400, 'built_in_name'))
|
||||
|
||||
expect(page()).toContain('Built-in roles keep their name.')
|
||||
})
|
||||
|
||||
it('shows the Conflict state after re-reading the role, and Reload role replaces the form', async () => {
|
||||
let reads = 0
|
||||
const backend = new Backend({
|
||||
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
|
||||
'GET /v1/content-type': () => TYPES,
|
||||
'GET /v1/role/4': () =>
|
||||
++reads === 1
|
||||
? CONTRIBUTOR
|
||||
: {
|
||||
...CONTRIBUTOR,
|
||||
version: 3,
|
||||
name: 'Contributor',
|
||||
grants: grants('content.read'),
|
||||
updated_at: '2026-10-27T11:02:00Z',
|
||||
updated_by: JONAS,
|
||||
},
|
||||
'PUT /v1/role/4': refuse(409, 'role_conflict'),
|
||||
})
|
||||
await open('/settings/roles/4', backend)
|
||||
await choose('Events: Delete', 'Own')
|
||||
await choose('Events: Read', 'None')
|
||||
|
||||
await press('Save changes')
|
||||
|
||||
expect(page()).toContain('This role changed while you were editing')
|
||||
expect(page()).toContain(
|
||||
'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.',
|
||||
)
|
||||
expect(page()).toContain('2 unsaved changes. Save is off until you reload')
|
||||
expect(buttonNamed('Save changes')!.disabled).toBe(true)
|
||||
expect(buttonNamed('Review differences')).toBeDefined()
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
|
||||
await press('Review differences')
|
||||
expect(document.body.querySelector('[data-testid="role-differences"]')).not.toBeNull()
|
||||
|
||||
await press('Reload role')
|
||||
|
||||
expect(page()).not.toContain('This role changed while you were editing')
|
||||
expect(checked('Events: Delete')).toBe('None')
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('turns the page read-only with Your access changed on 403 missing_permission, and Reload page refreshes', async () => {
|
||||
const backend = await failWith(
|
||||
refuse(403, 'missing_permission', { permission: 'roles.manage' }),
|
||||
)
|
||||
|
||||
expect(page()).toContain('Your access changed')
|
||||
expect(page()).toContain(
|
||||
"An owner changed your role while this page was open. You can still see roles, but you can't change them. Your edits weren't saved.",
|
||||
)
|
||||
expect(saveBar()).toBeNull()
|
||||
expect(radiosOf(document.body, 'Events: Read')[0]!.getAttribute('aria-disabled')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(nameInput().disabled).toBe(true)
|
||||
expect(buttonNamed('Reload page')).toBeDefined()
|
||||
|
||||
await press('Reload page')
|
||||
|
||||
expect(backend.callsTo('GET', '/v1/role/4').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('shows the escalation refusal and keeps the edits', async () => {
|
||||
await failWith(refuse(403, 'escalation', { escalationKey: 'content.publish' }))
|
||||
|
||||
expect(page()).toContain("You can't grant that")
|
||||
expect(page()).toContain('You can only grant what your role has.')
|
||||
expect(page()).toContain('Publish entries')
|
||||
expect(checked('Events: Delete')).toBe('Own')
|
||||
})
|
||||
|
||||
it('turns read-only with This role is locked on 403 role_locked', async () => {
|
||||
await failWith(refuse(403, 'role_locked'))
|
||||
|
||||
expect(page()).toContain('This role is locked')
|
||||
expect(page()).toContain(
|
||||
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
|
||||
)
|
||||
expect(saveBar()).toBeNull()
|
||||
expect(nameInput().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it("shows You can't change your own role on 403 own_role", async () => {
|
||||
await failWith(refuse(403, 'own_role'))
|
||||
|
||||
expect(page()).toContain("You can't change your own role")
|
||||
expect(page()).toContain('Ask another owner or admin.')
|
||||
})
|
||||
|
||||
it('shows a generic toast for another failure', async () => {
|
||||
await failWith(refuse(500, 'boom'))
|
||||
|
||||
expect(toastTitles()).toHaveLength(1)
|
||||
expect(saveBar()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Read-only editor', () => {
|
||||
it('warns the owner about the 5 members of Writer, tags Built-in and offers Reset to default', async () => {
|
||||
await open('/settings/roles/3', backendFor(WRITER))
|
||||
|
||||
expect(page()).toContain('This role has 5 members')
|
||||
expect(page()).toContain(
|
||||
'Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default.',
|
||||
)
|
||||
expect(page()).toContain('Built-in')
|
||||
expect(nameInput().disabled).toBe(false)
|
||||
expect(buttonNamed('Reset to default')).toBeDefined()
|
||||
})
|
||||
|
||||
it('locks everything for an admin and offers Duplicate role', async () => {
|
||||
await open('/settings/roles/3', backendFor(WRITER), adminAccess(), { userId: 7 })
|
||||
|
||||
expect(page()).toContain('Only the owner can edit built-in roles')
|
||||
expect(page()).toContain(
|
||||
'You can read what Writer allows. To change it, duplicate it into a custom role.',
|
||||
)
|
||||
expect(nameInput().disabled).toBe(true)
|
||||
expect(radiosOf(document.body, 'Events: Read')[0]!.getAttribute('aria-disabled')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(saveBar()).toBeNull()
|
||||
expect(page()).not.toContain('Reset to default')
|
||||
expect(page()).not.toContain('Updates as you edit.')
|
||||
expect(page()).toContain('A summary of what this role allows.')
|
||||
|
||||
await press('Duplicate role')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings/roles/new'))
|
||||
await vi.waitFor(() => expect(nameInput().disabled).toBe(false))
|
||||
expect(checked('All content types: Update')).toBe('All')
|
||||
})
|
||||
|
||||
it('shows the banner and no Save bar for roles.view only', async () => {
|
||||
await open(
|
||||
'/settings/roles/4',
|
||||
backendFor(CONTRIBUTOR),
|
||||
customAccess('roles.view', 'Viewer', 9),
|
||||
)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="read-only-banner"]')).not.toBeNull()
|
||||
expect(nameInput().disabled).toBe(true)
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the own-role message when the person opens their own role', async () => {
|
||||
await open(
|
||||
'/settings/roles/4',
|
||||
backendFor(CONTRIBUTOR),
|
||||
customAccess('roles.view, roles.manage', 'Contributor', 4),
|
||||
)
|
||||
|
||||
expect(page()).toContain(
|
||||
"You can't change the permissions of your own role. Ask another owner or admin.",
|
||||
)
|
||||
expect(nameInput().disabled).toBe(true)
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('locks the Owner role for anyone with roles.view and offers Go to Users', async () => {
|
||||
await open('/settings/roles/1', backendFor(OWNER_ROLE), ownerAccess())
|
||||
|
||||
expect(page()).toContain('The Owner role is locked')
|
||||
expect(page()).toContain(
|
||||
'It always has every permission. To hand it to someone else, transfer ownership from Users.',
|
||||
)
|
||||
expect(page()).toContain('Locked')
|
||||
expect(buttonNamed('Go to Users')).toBeDefined()
|
||||
expect(nameInput().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('gates owner-only editing on the permission, not on a role name', async () => {
|
||||
const renamed = { ...WRITER, id: 8, name: 'Renamed built-in' }
|
||||
await open('/settings/roles/8', backendFor(renamed), adminAccess(), { userId: 7 })
|
||||
|
||||
expect(page()).toContain('Only the owner can edit built-in roles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: General permissions', () => {
|
||||
it('lists the groups with "x of y enabled" and Expand all opens them', async () => {
|
||||
const loaded = { ...CONTRIBUTOR, grants: grants('activity.view, activity.export') }
|
||||
await open('/settings/roles/4', backendFor(loaded))
|
||||
|
||||
expect(page()).toContain('Activity')
|
||||
expect(page()).toContain('2 of 2 enabled')
|
||||
expect(page()).not.toContain('Read the log of who did what.')
|
||||
|
||||
await press('Expand all')
|
||||
|
||||
expect(page()).toContain('See activity')
|
||||
expect(page()).toContain('Read the log of who did what.')
|
||||
expect(page()).toContain('Export activity')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Owner-only items', () => {
|
||||
it('keeps them disabled and off for an admin and never sends their key', async () => {
|
||||
const backend = backendFor(CONTRIBUTOR, {
|
||||
'PUT /v1/role/4': () => ({ ...CONTRIBUTOR, version: 3 }),
|
||||
})
|
||||
await open('/settings/roles/4', backend, adminAccess(), { userId: 7 })
|
||||
await press('Expand all')
|
||||
|
||||
const toggleSwitch = document.body.querySelector(
|
||||
'[role="switch"][aria-label="Apply software updates"]',
|
||||
)!
|
||||
expect(toggleSwitch.getAttribute('aria-checked')).toBe('false')
|
||||
expect(toggleSwitch.hasAttribute('disabled')).toBe(true)
|
||||
expect(document.body.querySelector('[data-testid="owner-only-tag"]')).not.toBeNull()
|
||||
|
||||
await choose('Events: Delete', 'Own')
|
||||
await press('Save changes')
|
||||
|
||||
const sent = JSON.stringify(backend.callsTo('PUT', '/v1/role/4')[0]!.body)
|
||||
expect(sent).not.toContain('system.apply_updates')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Escalation locks', () => {
|
||||
it('locks Publish All when the session can publish only its own, and keeps None and Own', async () => {
|
||||
await open(
|
||||
'/settings/roles/new',
|
||||
backendFor(null),
|
||||
customAccess(
|
||||
'roles.view, roles.manage, schema.view, content.read, content.publish=own',
|
||||
'Editor',
|
||||
9,
|
||||
),
|
||||
)
|
||||
|
||||
const radios = radiosOf(document.body, 'Events: Publish')
|
||||
const all = radios.find((radio) => textOf(radio) === 'All')!
|
||||
expect(all.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.getElementById(all.getAttribute('aria-describedby')!)!.textContent).toBe(
|
||||
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
|
||||
)
|
||||
for (const label of ['None', 'Own']) {
|
||||
expect(
|
||||
radios.find((radio) => textOf(radio) === label)!.getAttribute('aria-disabled'),
|
||||
).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('locks a cell for a typed override in the session', async () => {
|
||||
await open(
|
||||
'/settings/roles/new',
|
||||
backendFor(null),
|
||||
customAccess(
|
||||
'roles.view, roles.manage, schema.view, content.read, content.update, content.update:events=none',
|
||||
'Editor',
|
||||
9,
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
radiosOf(document.body, 'Events: Update')
|
||||
.find((radio) => textOf(radio) === 'Own')!
|
||||
.getAttribute('aria-disabled'),
|
||||
).toBe('true')
|
||||
expect(
|
||||
radiosOf(document.body, 'Case studies: Update')
|
||||
.find((radio) => textOf(radio) === 'Own')!
|
||||
.getAttribute('aria-disabled'),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Plain-language preview', () => {
|
||||
it('updates live and tags the changed line', async () => {
|
||||
await open(
|
||||
'/settings/roles/4',
|
||||
backendFor({
|
||||
...CONTRIBUTOR,
|
||||
grants: grants('content.read, content.create, content.update=own'),
|
||||
}),
|
||||
)
|
||||
const preview = () => textOf(document.body.querySelector('[data-testid="role-preview"]')!)
|
||||
|
||||
expect(preview()).toContain('What this role can do')
|
||||
expect(preview()).toContain('Updates as you edit.')
|
||||
expect(preview()).toContain(
|
||||
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
|
||||
)
|
||||
expect(preview()).toContain('Applies to 4 members, and to the API keys they created.')
|
||||
expect(preview()).not.toContain('Changed')
|
||||
|
||||
await choose('All content types: Update', 'All')
|
||||
|
||||
expect(preview()).toContain('edit any entry')
|
||||
expect(preview()).toContain('Changed')
|
||||
})
|
||||
|
||||
it('reads No access to entries for a new role with nothing granted', async () => {
|
||||
await open('/settings/roles/new', backendFor(null))
|
||||
|
||||
const preview = textOf(document.body.querySelector('[data-testid="role-preview"]')!)
|
||||
expect(preview).toContain('No access to entries.')
|
||||
expect(preview).toContain(
|
||||
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a bottom sheet with Close from the sticky bar on mobile and draws one card per type', async () => {
|
||||
await open('/settings/roles/new', backendFor(null), ownerAccess(), { desktop: false })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="card-events"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[role="grid"]')).toBeNull()
|
||||
|
||||
await press('What this role can do')
|
||||
|
||||
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||
expect(textOf(sheet)).toContain('Updates as you edit.')
|
||||
buttonNamed('Close', sheet)!.click()
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Duplicate', () => {
|
||||
it('opens prefilled with the source grants, an empty name, and saves with no built_in or locked field', async () => {
|
||||
const backend = backendFor(WRITER, {
|
||||
'POST /v1/role/': () => role({ id: 10, name: 'Writer copy' }),
|
||||
'GET /v1/role/10': () => role({ id: 10, name: 'Writer copy' }),
|
||||
})
|
||||
await open('/settings/roles/new?from=3', backend, adminAccess(), { userId: 7 })
|
||||
|
||||
expect(nameInput().value).toBe('')
|
||||
expect(checked('All content types: Read')).toBe('All')
|
||||
expect(checked('All content types: Update')).toBe('All')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/roles/new')
|
||||
|
||||
await type(nameInput(), 'Writer copy')
|
||||
await press('Save changes')
|
||||
|
||||
const body = backend.callsTo('POST', '/v1/role/')[0]!.body as Record<string, unknown>
|
||||
expect(Object.keys(body).sort()).toEqual(['description', 'grants', 'name'])
|
||||
expect(body.grants).toEqual([
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.create', scope: 'all' },
|
||||
{ key: 'content.update', scope: 'all' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: stale responses', () => {
|
||||
it('drops a slow response for a role the user already navigated away from', async () => {
|
||||
let release: (value: RoleItem) => void = () => {}
|
||||
const slow = new Promise<RoleItem>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const backend = backendFor(CONTRIBUTOR, { 'GET /v1/role/3': () => slow })
|
||||
await open('/settings/roles/3', backend)
|
||||
|
||||
await router.push('/settings/roles/4')
|
||||
await flushPromises()
|
||||
expect(nameInput().value).toBe('Contributor')
|
||||
|
||||
release(WRITER)
|
||||
await flushPromises()
|
||||
|
||||
expect(nameInput().value).toBe('Contributor')
|
||||
expect(buttonNamed('Reset to default')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Reset to default', () => {
|
||||
it('is absent for a custom role and for people without roles.edit_owner_only', async () => {
|
||||
await open('/settings/roles/4', backendFor(CONTRIBUTOR))
|
||||
expect(buttonNamed('Reset to default')).toBeUndefined()
|
||||
wrapper!.unmount()
|
||||
|
||||
await open('/settings/roles/3', backendFor(WRITER), adminAccess(), { userId: 7 })
|
||||
expect(buttonNamed('Reset to default')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('posts the reset with the version and takes the seeded grants', async () => {
|
||||
const seeded = { ...WRITER, version: 2, grants: grants('content.read') }
|
||||
const backend = backendFor(WRITER, { 'POST /v1/role/3/reset': () => seeded })
|
||||
await open('/settings/roles/3', backend)
|
||||
|
||||
await press('Reset to default')
|
||||
const confirm = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
|
||||
).find((candidate) => textOf(candidate) === 'Reset to default')!
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.callsTo('POST', '/v1/role/3/reset')[0]!.body).toEqual({ version: 1 })
|
||||
expect(checked('All content types: Update')).toBe('None')
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the Conflict state on 409 role_conflict', async () => {
|
||||
const backend = backendFor(WRITER, {
|
||||
'POST /v1/role/3/reset': refuse(409, 'role_conflict'),
|
||||
})
|
||||
await open('/settings/roles/3', backend)
|
||||
|
||||
await press('Reset to default')
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||
.find((candidate) => textOf(candidate) === 'Reset to default')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(page()).toContain('This role changed while you were editing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: Create a Contributor in under a minute', () => {
|
||||
it('follows the six steps', async () => {
|
||||
const backend = backendFor(null, {
|
||||
'POST /v1/role/': () => role({ id: 11, name: 'Contributor' }),
|
||||
'GET /v1/role/11': () => role({ id: 11, name: 'Contributor' }),
|
||||
})
|
||||
await open('/settings/roles/new', backend)
|
||||
|
||||
await type(nameInput(), 'Contributor')
|
||||
await type(descriptionInput(), 'Writes posts and edits only their own')
|
||||
await press('Write own')
|
||||
expect(page()).toContain(
|
||||
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
|
||||
)
|
||||
await press('Save changes')
|
||||
|
||||
expect(backend.callsTo('POST', '/v1/role/')[0]!.body).toEqual({
|
||||
name: 'Contributor',
|
||||
description: 'Writes posts and edits only their own',
|
||||
grants: [
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.create', scope: 'all' },
|
||||
{ key: 'content.update', scope: 'own' },
|
||||
],
|
||||
})
|
||||
expect(toastTitles()).toEqual([
|
||||
{ title: 'Role created', description: 'Contributor is ready. Assign it from Users.' },
|
||||
])
|
||||
expect(saveBar()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: missing role', () => {
|
||||
it('offers Back to Roles when the role is gone', async () => {
|
||||
const backend = backendFor(null, { 'GET /v1/role/99': refuse(404, 'role_not_found') })
|
||||
await open('/settings/roles/99', backend)
|
||||
|
||||
const back = Array.from(document.querySelectorAll('a')).find(
|
||||
(link) => link.textContent?.trim() === 'Back to Roles',
|
||||
)
|
||||
expect(back?.getAttribute('href')).toBe('/settings/roles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role editor: unknown reset failure', () => {
|
||||
it('toasts the reset failure and keeps the grants', async () => {
|
||||
const backend = backendFor(WRITER, {
|
||||
'POST /v1/role/3/reset': () => {
|
||||
throw new Error('network down')
|
||||
},
|
||||
})
|
||||
await open('/settings/roles/3', backend)
|
||||
|
||||
await press('Reset to default')
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||
.find((candidate) => textOf(candidate) === 'Reset to default')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
const added = toastAdd.mock.calls.map((call) =>
|
||||
typeof call[0] === 'function' ? call[0]() : call[0],
|
||||
)
|
||||
expect(added).toHaveLength(1)
|
||||
expect(added[0]).toMatchObject({
|
||||
id: 'role-reset-failed',
|
||||
color: 'error',
|
||||
title: "Couldn't reset the role. Try again in a moment.",
|
||||
})
|
||||
expect(page()).not.toContain('This role changed while you were editing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,365 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import RolesListView from '../RolesListView.vue'
|
||||
import { Backend, buttonNamed, grants, mountRoleView, role, textOf } from './roleKit'
|
||||
import { customAccess, ownerAccess, adminAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn() }) }
|
||||
})
|
||||
|
||||
const OWNER = role({
|
||||
id: 1,
|
||||
name: 'Owner',
|
||||
built_in: true,
|
||||
locked: true,
|
||||
members: 1,
|
||||
description: 'Everything.',
|
||||
})
|
||||
const ADMIN = role({
|
||||
id: 2,
|
||||
name: 'Admin',
|
||||
built_in: true,
|
||||
members: 2,
|
||||
description: 'Everything except owner-only.',
|
||||
})
|
||||
const WRITER = role({
|
||||
id: 3,
|
||||
name: 'Writer',
|
||||
built_in: true,
|
||||
members: 5,
|
||||
description: 'Writes content.',
|
||||
})
|
||||
const CONTRIBUTOR = role({
|
||||
id: 4,
|
||||
name: 'Contributor',
|
||||
members: 4,
|
||||
description: 'Creates content and edits only their own.',
|
||||
grants: grants('content.read, content.update=own'),
|
||||
})
|
||||
const LEAD = role({
|
||||
id: 5,
|
||||
name: 'Content lead',
|
||||
members: 1,
|
||||
description: 'Edits and publishes all content.',
|
||||
})
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function listBackend(items = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD], extra = {}) {
|
||||
return new Backend({ 'GET /v1/role/': () => items, ...extra })
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
function rows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="role-row"]'))
|
||||
}
|
||||
|
||||
function rowOf(name: string) {
|
||||
return rows().find((row) => textOf(row).startsWith(name))!
|
||||
}
|
||||
|
||||
async function openMenu(name: string) {
|
||||
rowOf(name).querySelector<HTMLElement>('button[aria-label^="Actions for"]')!.click()
|
||||
await flushPromises()
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
}
|
||||
|
||||
function item(items: HTMLElement[], label: string) {
|
||||
return items.find((candidate) => textOf(candidate).startsWith(label))!
|
||||
}
|
||||
|
||||
describe('Roles list: Default', () => {
|
||||
it('shows the header, the columns and a row per role with its type badge', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain('Who can do what on this site. Each user has one role.')
|
||||
for (const column of ['Role', 'Description', 'Members', 'Type']) {
|
||||
expect(text).toContain(column)
|
||||
}
|
||||
expect(rows()).toHaveLength(5)
|
||||
expect(textOf(rowOf('Owner'))).toContain('Locked')
|
||||
expect(textOf(rowOf('Owner'))).toContain('Built-in')
|
||||
expect(textOf(rowOf('Writer'))).toContain('Built-in')
|
||||
expect(textOf(rowOf('Contributor'))).toContain('Custom')
|
||||
expect(textOf(rowOf('Contributor'))).toContain('4')
|
||||
expect(textOf(rowOf('Content lead'))).toContain('Custom')
|
||||
expect(buttonNamed('New role')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('draws each role as a card on mobile', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend(),
|
||||
desktop: false,
|
||||
})
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="role-card"]')).toHaveLength(5)
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('opens a role from its name', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
expect(rowOf('Contributor').querySelector('a')!.getAttribute('href')).toBe(
|
||||
'/settings/roles/4',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles list: Loading, only built-ins, error', () => {
|
||||
it('shows skeleton rows after 150 ms with New role disabled, then the list', async () => {
|
||||
vi.useFakeTimers()
|
||||
let release: (value: unknown) => void = () => undefined
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const backend = new Backend({ 'GET /v1/role/': () => pending })
|
||||
const mounting = mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(document.body.querySelectorAll('[data-testid="role-skeleton"]')).toHaveLength(0)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
wrapper = await mounting
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="role-skeleton"]').length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(buttonNamed('New role')!.disabled).toBe(true)
|
||||
|
||||
release([OWNER, ADMIN, WRITER, CONTRIBUTOR])
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="role-skeleton"]')).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('reads No custom roles yet when only the built-ins are listed', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend([OWNER, ADMIN, WRITER]),
|
||||
})
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain('No custom roles yet')
|
||||
expect(text).toContain(
|
||||
'The three built-in roles cover most sites. Create a role when someone needs something in between.',
|
||||
)
|
||||
expect(buttonNamed('New role')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it("reads Couldn't load roles on a failure and Try again re-requests the list", async () => {
|
||||
let attempts = 0
|
||||
const backend = new Backend({
|
||||
'GET /v1/role/': () => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
throw new ApiError(500)
|
||||
}
|
||||
return [OWNER, ADMIN, WRITER, CONTRIBUTOR]
|
||||
},
|
||||
})
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend,
|
||||
})
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain("Couldn't load roles")
|
||||
expect(text).toContain('GET /v1/role failed. Check your connection and try again.')
|
||||
|
||||
buttonNamed('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
|
||||
expect(rows()).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles list: No permission to edit', () => {
|
||||
it('disables New role and shows the banner for roles.view without roles.manage', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: customAccess('roles.view', 'Viewer'),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
expect(buttonNamed('New role')!.disabled).toBe(true)
|
||||
const banner = document.body.querySelector('[data-testid="read-only-banner"]')!
|
||||
expect(textOf(banner)).toContain('You can see roles but not change them')
|
||||
expect(textOf(banner)).toContain('Ask an owner or admin if a role needs to change.')
|
||||
})
|
||||
|
||||
it('shows no banner to someone who manages roles', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
expect(document.body.querySelector('[data-testid="read-only-banner"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles list: Row menu', () => {
|
||||
it('offers Edit, Duplicate and an enabled Delete on a custom role', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: customAccess('roles.view, roles.manage', 'Manager', 9),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
const items = await openMenu('Contributor')
|
||||
|
||||
expect(items.map((entry) => textOf(entry).split(' ')[0])).toEqual([
|
||||
'Edit',
|
||||
'Duplicate',
|
||||
'Delete',
|
||||
])
|
||||
expect(item(items, 'Delete').getAttribute('aria-disabled')).not.toBe('true')
|
||||
expect(item(items, 'Duplicate').getAttribute('aria-disabled')).not.toBe('true')
|
||||
})
|
||||
|
||||
it('disables Delete on a built-in role with the reason', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
const items = await openMenu('Writer')
|
||||
|
||||
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item(items, 'Delete').getAttribute('aria-describedby')).toMatch(/\S+/)
|
||||
expect(
|
||||
document.getElementById(item(items, 'Delete').getAttribute('aria-describedby')!)!
|
||||
.textContent,
|
||||
).toBe("Built-in roles can't be deleted. Duplicate it to make a custom role.")
|
||||
expect(item(items, 'Edit').getAttribute('aria-disabled')).not.toBe('true')
|
||||
})
|
||||
|
||||
it('disables Delete on the locked Owner role', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: customAccess('roles.view, roles.manage', 'Manager', 9),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
const items = await openMenu('Owner')
|
||||
|
||||
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('disables Duplicate and Delete for roles.view only and still offers Edit', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: customAccess('roles.view', 'Viewer', 9),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
const items = await openMenu('Contributor')
|
||||
|
||||
expect(item(items, 'Edit').getAttribute('aria-disabled')).not.toBe('true')
|
||||
expect(item(items, 'Duplicate').getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item(items, 'Delete').getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('opens the editor from Edit and a prefilled editor from Duplicate', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: adminAccess(),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
item(await openMenu('Writer'), 'Edit').click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/settings/roles/3'))
|
||||
|
||||
await router.push('/settings/roles')
|
||||
item(await openMenu('Writer'), 'Duplicate').click()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/roles/new?from=3'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles list: Delete', () => {
|
||||
it('opens the blocked dialog for a role with members', async () => {
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend: listBackend(),
|
||||
})
|
||||
|
||||
item(await openMenu('Contributor'), 'Delete').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body.querySelector('[role="alertdialog"]')!)).toContain(
|
||||
'4 members have this role. Move them to another role first. Then Contributor can be deleted.',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes the row once the role without members is deleted', async () => {
|
||||
const intern = role({ id: 6, name: 'Intern', members: 0 })
|
||||
const backend = listBackend([OWNER, ADMIN, WRITER, intern], {
|
||||
'DELETE /v1/role/6': () => undefined,
|
||||
})
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend,
|
||||
})
|
||||
|
||||
item(await openMenu('Intern'), 'Delete').click()
|
||||
await flushPromises()
|
||||
buttonNamed('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.callsTo('DELETE', '/v1/role/6')).toHaveLength(1)
|
||||
expect(backend.callsTo('POST', '/v1/role/6/reassign')).toHaveLength(0)
|
||||
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
|
||||
})
|
||||
|
||||
it('refreshes the list when the role is already gone', async () => {
|
||||
const intern = role({ id: 6, name: 'Intern', members: 0 })
|
||||
let listed = 0
|
||||
const backend = new Backend({
|
||||
'GET /v1/role/': () =>
|
||||
++listed === 1 ? [OWNER, ADMIN, WRITER, intern] : [OWNER, ADMIN, WRITER],
|
||||
'DELETE /v1/role/6': () => {
|
||||
throw new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' })
|
||||
},
|
||||
})
|
||||
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
|
||||
access: ownerAccess(),
|
||||
backend,
|
||||
})
|
||||
|
||||
item(await openMenu('Intern'), 'Delete').click()
|
||||
await flushPromises()
|
||||
buttonNamed('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
|
||||
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { setViewportWidth, DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { Access, Grant } from '@/lib/permissions'
|
||||
import type { PermissionGroupItem, RoleItem } from '@/lib/roles'
|
||||
|
||||
export interface Call {
|
||||
method: string
|
||||
path: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export type Handler = (call: Call) => unknown
|
||||
|
||||
export class Backend {
|
||||
calls: Call[] = []
|
||||
routes: Record<string, Handler>
|
||||
|
||||
constructor(routes: Record<string, Handler>) {
|
||||
this.routes = routes
|
||||
}
|
||||
|
||||
request = vi.fn(async (path: string, options: RequestInit = {}) => {
|
||||
const method = options.method ?? 'GET'
|
||||
const call: Call = {
|
||||
method,
|
||||
path,
|
||||
body: typeof options.body === 'string' ? JSON.parse(options.body) : undefined,
|
||||
}
|
||||
this.calls.push(call)
|
||||
const handler = this.routes[`${method} ${path}`]
|
||||
if (handler === undefined) {
|
||||
throw new Error(`Unmocked ${method} ${path}`)
|
||||
}
|
||||
return handler(call)
|
||||
})
|
||||
|
||||
callsTo(method: string, path: string) {
|
||||
return this.calls.filter((call) => call.method === method && call.path === path)
|
||||
}
|
||||
}
|
||||
|
||||
export function role(overrides: Partial<RoleItem> & Pick<RoleItem, 'id' | 'name'>): RoleItem {
|
||||
return {
|
||||
description: '',
|
||||
built_in: false,
|
||||
locked: false,
|
||||
version: 1,
|
||||
grants: [],
|
||||
members: 0,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function grants(spec: string): Grant[] {
|
||||
return spec.split(',').map((part) => {
|
||||
const [key, scope = 'all'] = part.trim().split('=')
|
||||
return { key: key!, scope: scope as Grant['scope'] }
|
||||
})
|
||||
}
|
||||
|
||||
export const CATALOGUE: PermissionGroupItem[] = [
|
||||
{
|
||||
key: 'content',
|
||||
items: ['read', 'create', 'update', 'delete', 'publish'].map((action) => ({
|
||||
key: `content.${action}`,
|
||||
scope_kind: action === 'create' ? ('toggle' as const) : ('scoped' as const),
|
||||
owner_only: false,
|
||||
per_type: true,
|
||||
})),
|
||||
},
|
||||
{
|
||||
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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
items: [
|
||||
{
|
||||
key: 'system.apply_updates',
|
||||
scope_kind: 'toggle',
|
||||
owner_only: true,
|
||||
per_type: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export async function mountRoleView(
|
||||
view: object,
|
||||
path: string,
|
||||
{
|
||||
access,
|
||||
backend,
|
||||
desktop = true,
|
||||
userId = 1,
|
||||
}: { access: Access; backend: Backend; desktop?: boolean; userId?: number },
|
||||
) {
|
||||
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { id: userId, verified: true, username: 'marta', displayName: '', access }
|
||||
session.status = 'ready'
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
id: userId,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
access,
|
||||
}),
|
||||
request: backend.request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
const wrapper: VueWrapper = mount(() => h(UApp, null, () => h(view)), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
export function textOf(element: Element) {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function buttonNamed(label: string, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(candidate) => textOf(candidate) === label,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user