508 lines
16 KiB
TypeScript
508 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { defineComponent, h } from 'vue'
|
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
import UApp from '@nuxt/ui/components/App.vue'
|
|
import router from '@/router'
|
|
import { ApiError, useApi } from '@/composables/useApi'
|
|
import type { UserListItem } from '@/composables/useUsers'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import { accessFor, adminAccess, customAccess, ownerAccess, roleRefFor } from '@/test/access'
|
|
import type { Access } from '@/lib/permissions'
|
|
import { CONTRIBUTOR, WORLD } from '@/components/__tests__/userRolesKit'
|
|
import UsersView from '../UsersView.vue'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
let toastAdd: ReturnType<typeof vi.fn>
|
|
|
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
|
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
|
})
|
|
|
|
const LIST_PATH = '/v1/user/'
|
|
const ROLE_PATH = '/v1/role/'
|
|
const JONAS_EMAIL = 'jonas@renewbuild.example'
|
|
const OWNER_HINT = "The owner can't be changed or removed. Transfer ownership first."
|
|
const OWN_ROW_HINT = "You can't change your own role. Ask another owner or admin."
|
|
|
|
function accepted(
|
|
id: number,
|
|
username: string,
|
|
name: string,
|
|
email: string,
|
|
role: string,
|
|
extra: Record<string, unknown> = {},
|
|
): UserListItem {
|
|
return {
|
|
kind: 'user',
|
|
id,
|
|
username,
|
|
display_name: name,
|
|
email,
|
|
role: roleRefFor(role),
|
|
status: 'accepted',
|
|
created_at: '2026-06-18T09:00:00Z',
|
|
...extra,
|
|
} as UserListItem
|
|
}
|
|
|
|
function fixture(): UserListItem[] {
|
|
return [
|
|
{
|
|
kind: 'invite',
|
|
id: 2,
|
|
email: 'sam.oliveira@renew.example',
|
|
role: { id: 4, name: 'Contributor', built_in: false, locked: false },
|
|
status: 'expired',
|
|
created_at: '2026-08-03T09:00:00Z',
|
|
},
|
|
{
|
|
kind: 'invite',
|
|
id: 3,
|
|
email: 'pending@renew.example',
|
|
status: 'pending',
|
|
created_at: '2026-09-27T09:00:00Z',
|
|
},
|
|
accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'member', {
|
|
role_assigned_by: { id: 1, name: 'Marta Alves' },
|
|
}),
|
|
accepted(3, 'lea', 'Lea Martin', 'lea@renewbuild.example', 'admin'),
|
|
accepted(2, 'jonas', 'Jonas Weber', JONAS_EMAIL, 'admin'),
|
|
accepted(1, 'marta', 'Marta Alves', 'marta@renewbuild.example', 'owner'),
|
|
]
|
|
}
|
|
|
|
function without(access: Access, key: string): Access {
|
|
return { ...access, permissions: access.permissions.filter((grant) => grant.key !== key) }
|
|
}
|
|
|
|
function setViewport(desktop: boolean) {
|
|
window.matchMedia = ((query: string) => ({
|
|
matches: desktop,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: () => undefined,
|
|
removeEventListener: () => undefined,
|
|
addListener: () => undefined,
|
|
removeListener: () => undefined,
|
|
dispatchEvent: () => false,
|
|
})) as unknown as typeof window.matchMedia
|
|
}
|
|
|
|
let requestMock: ReturnType<typeof vi.fn>
|
|
let getMe: ReturnType<typeof vi.fn>
|
|
let wrapper: VueWrapper | null = null
|
|
let answers: Record<string, (options?: RequestInit) => Promise<unknown>> = {}
|
|
let listItems: UserListItem[] = []
|
|
|
|
async function mountPage(access: Access = ownerAccess(), sessionId = 1, desktop = true) {
|
|
setViewport(desktop)
|
|
setActivePinia(createPinia())
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, username: 'me', id: sessionId, displayName: '', access }
|
|
session.status = 'ready'
|
|
requestMock = vi.fn((path: string, options?: RequestInit) => {
|
|
const key = `${options?.method ?? 'GET'} ${path}`
|
|
if (answers[key]) {
|
|
return answers[key]!(options)
|
|
}
|
|
if (key === `GET ${LIST_PATH}`) {
|
|
return Promise.resolve(listItems)
|
|
}
|
|
if (key === `GET ${ROLE_PATH}`) {
|
|
return Promise.resolve(WORLD)
|
|
}
|
|
throw new Error(`Unexpected request: ${key}`)
|
|
})
|
|
getMe = vi.fn().mockResolvedValue({ verified: true, id: sessionId, access: adminAccess() })
|
|
vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock } as unknown as ReturnType<
|
|
typeof useApi
|
|
>)
|
|
await router.push('/users')
|
|
wrapper = mount(defineComponent({ render: () => h(UApp, null, () => h(UsersView)) }), {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
await flushPromises()
|
|
}
|
|
|
|
function calls(key: string) {
|
|
return requestMock.mock.calls.filter(
|
|
([path, options]) => `${options?.method ?? 'GET'} ${path}` === key,
|
|
)
|
|
}
|
|
|
|
function trigger(name: string) {
|
|
return document.body.querySelector<HTMLElement>(`[aria-label="Actions for ${name}"]`)
|
|
}
|
|
|
|
async function openMenu(name: string) {
|
|
trigger(name)!.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
function menuItems() {
|
|
return [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
}
|
|
|
|
function labelOfItem(item: HTMLElement) {
|
|
return item.querySelector('span span')?.textContent?.trim() ?? ''
|
|
}
|
|
|
|
function menuItem(label: string) {
|
|
return menuItems().find((item) => labelOfItem(item) === label)
|
|
}
|
|
|
|
function menuLabels() {
|
|
return menuItems().map(labelOfItem)
|
|
}
|
|
|
|
function buttonNamed(label: string) {
|
|
return [...document.body.querySelectorAll('button')].find(
|
|
(candidate) => candidate.textContent?.trim() === label,
|
|
)
|
|
}
|
|
|
|
function rowOf(email: string) {
|
|
return [...document.body.querySelectorAll<HTMLElement>('[data-testid="user-row"]')].find(
|
|
(row) => row.textContent?.includes(email),
|
|
)!
|
|
}
|
|
|
|
function dialog() {
|
|
return document.body.querySelector<HTMLElement>('[role="alertdialog"], [role="dialog"]')
|
|
}
|
|
|
|
async function click(target: HTMLElement | undefined) {
|
|
target?.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
function radio(name: string) {
|
|
return [...dialog()!.querySelectorAll('label')]
|
|
.find((label) => label.querySelector('[data-slot="label"]')?.textContent?.includes(name))
|
|
?.querySelector<HTMLButtonElement>('[role="radio"]')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
toastAdd = vi.fn()
|
|
answers = {}
|
|
listItems = fixture()
|
|
Element.prototype.scrollIntoView = vi.fn()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
wrapper?.unmount()
|
|
wrapper = null
|
|
await router.push('/login')
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('What the Users page offers per grant', () => {
|
|
it.each([
|
|
['users.view', false, false],
|
|
['users.view, users.invite', true, false],
|
|
['users.view, users.change_role, roles.view', false, true],
|
|
['users.view, users.invite, users.change_role, roles.view', true, true],
|
|
])('grants %s: invite %s, menu %s', async (grants, invite, menu) => {
|
|
await mountPage(customAccess(grants, 'Custom'), 9)
|
|
|
|
expect(Boolean(buttonNamed('Invite user'))).toBe(invite)
|
|
expect(trigger('Ines Duarte') !== null).toBe(menu)
|
|
if (menu) {
|
|
await openMenu('Ines Duarte')
|
|
expect(menuLabels()).toEqual(['Change role'])
|
|
}
|
|
})
|
|
|
|
it('offers the Owner the invite button and Change role', async () => {
|
|
await mountPage()
|
|
|
|
expect(buttonNamed('Invite user')).toBeInstanceOf(HTMLButtonElement)
|
|
await openMenu('Ines Duarte')
|
|
expect(menuLabels()).toEqual(['Change role'])
|
|
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
|
|
})
|
|
|
|
it('keeps invite rows to Cancel invite and Renew invite only', async () => {
|
|
await mountPage()
|
|
|
|
expect(trigger('sam.oliveira@renew.example')).toBeNull()
|
|
expect(buttonNamed('Renew invite')).toBeInstanceOf(HTMLButtonElement)
|
|
expect(buttonNamed('Cancel invite')).toBeInstanceOf(HTMLButtonElement)
|
|
})
|
|
|
|
it('shows no Assigned by text in the table or the cards', async () => {
|
|
await mountPage()
|
|
expect(document.body.textContent).not.toContain('Assigned by')
|
|
wrapper?.unmount()
|
|
await mountPage(ownerAccess(), 1, false)
|
|
expect(document.body.textContent).not.toContain('Assigned by')
|
|
expect(document.body.textContent).not.toContain('Marta Alves assigned')
|
|
})
|
|
})
|
|
|
|
describe('Your own row and the owner row', () => {
|
|
it('offers Change role disabled with the reason on the row marked you', async () => {
|
|
await mountPage(adminAccess(), 2)
|
|
await openMenu('Jonas Weber')
|
|
|
|
expect(menuLabels()).toEqual(['Change role'])
|
|
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
|
expect(menuItem('Change role')?.textContent).toContain(OWN_ROW_HINT)
|
|
})
|
|
|
|
it('gives an Admin the owner row with Change role disabled and no Transfer ownership', async () => {
|
|
await mountPage(adminAccess(), 2)
|
|
await openMenu('Marta Alves')
|
|
|
|
expect(menuLabels()).toEqual(['Change role'])
|
|
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
|
expect(menuItem('Change role')?.textContent).toContain(OWNER_HINT)
|
|
})
|
|
|
|
it('gives the Owner on their own row Change role disabled and Transfer ownership enabled', async () => {
|
|
await mountPage()
|
|
await openMenu('Marta Alves')
|
|
|
|
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership'])
|
|
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
|
expect(menuItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
|
|
})
|
|
|
|
it('gives users.view alone no menu on the owner row', async () => {
|
|
await mountPage(customAccess('users.view', 'Viewer'), 9)
|
|
|
|
expect(trigger('Marta Alves')).toBeNull()
|
|
})
|
|
|
|
it('hides Transfer ownership everywhere without system.transfer_ownership', async () => {
|
|
await mountPage(without(ownerAccess(), 'system.transfer_ownership'))
|
|
await openMenu('Marta Alves')
|
|
|
|
expect(menuLabels()).toEqual(['Change role'])
|
|
})
|
|
})
|
|
|
|
function sheetItems() {
|
|
return [...document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]')]
|
|
}
|
|
|
|
function sheetLabels() {
|
|
return sheetItems().map((item) => item.textContent?.trim() ?? '')
|
|
}
|
|
|
|
function sheetItem(label: string) {
|
|
return sheetItems().find((item) => item.textContent?.trim() === label)
|
|
}
|
|
|
|
describe('The mobile Users menu', () => {
|
|
it('lists Change role disabled with the reason on the owner card', async () => {
|
|
await mountPage(adminAccess(), 2, false)
|
|
|
|
expect(document.body.querySelector('[data-testid="user-card"]')).not.toBeNull()
|
|
await openMenu('Marta Alves')
|
|
expect(sheetLabels()).toEqual(['Change role'])
|
|
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
|
expect(document.body.querySelector('[role="menu"]')?.textContent).toContain(OWNER_HINT)
|
|
})
|
|
|
|
it('offers Transfer ownership to the Owner on their own card and opens the dialog', async () => {
|
|
await mountPage(ownerAccess(), 1, false)
|
|
|
|
await openMenu('Marta Alves')
|
|
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership'])
|
|
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
|
expect(sheetItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
|
|
await click(sheetItem('Transfer ownership'))
|
|
expect(dialog()?.textContent).toContain('Transfer ownership')
|
|
})
|
|
|
|
it('offers a plain Change role on another member card', async () => {
|
|
await mountPage(ownerAccess(), 1, false)
|
|
|
|
await openMenu('Ines Duarte')
|
|
expect(sheetLabels()).toEqual(['Change role'])
|
|
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
|
|
await click(sheetItem('Change role'))
|
|
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('Changing without roles.view', () => {
|
|
it('disables Change role with a hint and never reads the roles', async () => {
|
|
await mountPage(customAccess('users.view, users.change_role', 'Custom'), 9)
|
|
await openMenu('Ines Duarte')
|
|
|
|
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
|
|
await click(menuItem('Change role'))
|
|
expect(dialog()).toBeNull()
|
|
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Changing the role', () => {
|
|
it('updates only that row from the answer and does not reload the list', async () => {
|
|
answers['PUT /v1/user/4/role'] = () =>
|
|
Promise.resolve(
|
|
accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'admin', {
|
|
role: {
|
|
id: CONTRIBUTOR.id,
|
|
name: 'Contributor',
|
|
built_in: false,
|
|
locked: false,
|
|
},
|
|
}),
|
|
)
|
|
await mountPage()
|
|
await openMenu('Ines Duarte')
|
|
await click(menuItem('Change role'))
|
|
|
|
expect(dialog()?.textContent).toContain('Change role for Ines Duarte')
|
|
await click(radio('Contributor'))
|
|
await click(
|
|
[...dialog()!.querySelectorAll('button')].find(
|
|
(b) => b.textContent?.trim() === 'Change role',
|
|
),
|
|
)
|
|
|
|
expect(calls('PUT /v1/user/4/role')).toHaveLength(1)
|
|
expect(JSON.parse(calls('PUT /v1/user/4/role')[0]![1].body)).toEqual({
|
|
role_id: CONTRIBUTOR.id,
|
|
})
|
|
expect(rowOf('ines.duarte@renew.example').textContent).toContain('Contributor')
|
|
expect(rowOf(JONAS_EMAIL).textContent).toContain('admin')
|
|
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(1)
|
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
|
expect(dialog()).toBeNull()
|
|
})
|
|
|
|
it('re-reads the list and closes on 409 user_role_changed', async () => {
|
|
answers['PUT /v1/user/4/role'] = () =>
|
|
Promise.reject(
|
|
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
|
|
)
|
|
await mountPage()
|
|
await openMenu('Ines Duarte')
|
|
await click(menuItem('Change role'))
|
|
await click(radio('Contributor'))
|
|
await click(
|
|
[...dialog()!.querySelectorAll('button')].find(
|
|
(b) => b.textContent?.trim() === 'Change role',
|
|
),
|
|
)
|
|
|
|
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
|
|
expect(dialog()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Transferring ownership', () => {
|
|
it('refreshes the session then the list and shows the toast', async () => {
|
|
answers['POST /v1/site/transfer-ownership'] = () =>
|
|
Promise.resolve({
|
|
owner: { id: 2, name: 'Jonas Weber' },
|
|
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
|
|
})
|
|
await mountPage()
|
|
await openMenu('Marta Alves')
|
|
await click(menuItem('Transfer ownership'))
|
|
await click(radio('Jonas Weber'))
|
|
await click(
|
|
[...dialog()!.querySelectorAll('button')].find(
|
|
(b) => b.textContent?.trim() === 'Continue',
|
|
),
|
|
)
|
|
const input = dialog()!.querySelector('input')!
|
|
input.value = JONAS_EMAIL
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
await click(
|
|
[...dialog()!.querySelectorAll('button')].find(
|
|
(b) => b.textContent?.trim() === 'Transfer ownership',
|
|
),
|
|
)
|
|
|
|
expect(JSON.parse(calls('POST /v1/site/transfer-ownership')[0]![1].body)).toEqual({
|
|
user_id: 2,
|
|
confirm_email: JONAS_EMAIL,
|
|
})
|
|
expect(getMe).toHaveBeenCalledTimes(1)
|
|
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
|
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
|
expect(dialog()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Invite rows keep their role', () => {
|
|
it('keeps the Contributor role on a renewed invite', async () => {
|
|
answers['POST /v1/user/invite/2/renew'] = () =>
|
|
Promise.resolve({
|
|
id: 2,
|
|
kind: 'invite',
|
|
email: 'sam.oliveira@renew.example',
|
|
status: 'pending',
|
|
created_at: '2026-10-01T09:00:00Z',
|
|
expires_at: '2026-10-04T09:00:00Z',
|
|
})
|
|
await mountPage()
|
|
await click(buttonNamed('Renew invite'))
|
|
|
|
const row = rowOf('sam.oliveira@renew.example')
|
|
expect(row.textContent).toContain('Contributor')
|
|
expect(row.textContent).toContain('Pending')
|
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('shows the escalation refusal and leaves the row Expired', async () => {
|
|
answers['POST /v1/user/invite/2/renew'] = () =>
|
|
Promise.reject(new ApiError(403, undefined, 'escalation', { code: 'escalation' }))
|
|
await mountPage()
|
|
await click(buttonNamed('Renew invite'))
|
|
|
|
expect(rowOf('sam.oliveira@renew.example').textContent).toContain('Expired')
|
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
|
const toast = toastAdd.mock.calls[0]![0]
|
|
const content = typeof toast === 'function' ? toast() : toast
|
|
expect(content.title).toBe("You can't grant that")
|
|
expect(content.description).toBe('You can only grant what your role has.')
|
|
})
|
|
|
|
it('puts the invite at the top with the chosen role badge', async () => {
|
|
answers['POST /v1/user/invite'] = () =>
|
|
Promise.resolve({
|
|
id: 8,
|
|
kind: 'invite',
|
|
email: 'lea.martin@renew.example',
|
|
role: { id: 3, name: 'Writer', built_in: true, locked: false },
|
|
status: 'pending',
|
|
created_at: '2026-10-01T09:00:00Z',
|
|
expires_at: '2026-10-04T09:00:00Z',
|
|
})
|
|
await mountPage()
|
|
await click(buttonNamed('Invite user'))
|
|
const input = document.body.querySelector<HTMLInputElement>(
|
|
'input[placeholder="name@example.com"]',
|
|
)!
|
|
input.value = 'lea.martin@renew.example'
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
await click(buttonNamed('Send invite'))
|
|
|
|
expect(JSON.parse(calls('POST /v1/user/invite')[0]![1].body)).toEqual({
|
|
email: 'lea.martin@renew.example',
|
|
role_id: 3,
|
|
})
|
|
const first = document.body.querySelector<HTMLElement>('[data-testid="user-row"]')!
|
|
expect(first.textContent).toContain('lea.martin@renew.example')
|
|
expect(first.textContent).toContain('writer')
|
|
expect(accessFor('owner')).toBeDefined()
|
|
})
|
|
})
|