feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4308 -835

No files matched your search

@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it } 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 RouteAccessGate from '@/components/RouteAccessGate.vue'
import { useSessionStore, type SessionStatus } from '@/stores/session'
import { customAccess, noAccess } from '@/test/access'
import { setLocale } from '@/i18n'
let wrapper: VueWrapper | null = null
interface Setup {
grants: string
role?: string
path: string
status?: SessionStatus
verified?: boolean
}
async function open({
grants,
role = 'Contributor',
path,
status = 'ready',
verified = true,
}: Setup) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: 4, verified, displayName: '', access: customAccess(grants, role) }
session.status = status
await router.push(path).catch(() => undefined)
wrapper = mount(
() =>
h(UApp, null, () =>
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' }, 'PAGE')),
),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return wrapper
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
setLocale('en')
document.body.innerHTML = ''
await router.push('/login')
})
const NO_ACCESS_TITLE = "You don't have access to this page"
describe('A direct link to a page the role lacks', () => {
it.each([
['content.read=all', 'Contributor', '/schema', 'Go to Articles'],
['content.read=all', 'Contributor', '/schema/new', 'Go to Articles'],
['content.read=all', 'Contributor', '/users', 'Go to Articles'],
['content.read=all', 'Contributor', '/settings/sites', 'Go to Articles'],
['content.read=all', 'Contributor', '/integration/api-keys', 'Go to Articles'],
[
'content.read=all, content.read:events=none',
'Contributor',
'/content/events',
'Go to Articles',
],
['media.view', 'Media', '/schema', 'Go to Home'],
])(
'%s opening %s shows the page-level 403 and keeps the URL',
async (grants, role, path, action) => {
const view = await open({ grants, role, path })
expect(router.currentRoute.value.fullPath).toBe(path)
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).toContain(NO_ACCESS_TITLE)
expect(view.text()).toContain(
`Your role, ${role}, doesn't include it. Ask an owner or admin if you need it.`,
)
const buttons = view
.findAll('[data-slot="root"] button')
.filter((button) => !button.element.closest('nav'))
expect(buttons.map((button) => button.text()).filter((label) => label !== '')).toEqual([
action,
])
},
)
})
describe('A page the role can use', () => {
it.each([
['schema.view', '/schema'],
['schema.edit', '/schema/new'],
['users.view', '/users'],
['settings.manage_sites', '/settings/sites'],
['content.read:events=own', '/content/events'],
['content.create:events=all', '/content/events/new'],
['apikeys.view=own', '/integration/api-keys'],
['settings.view', '/settings/media/cache'],
])('%s opens %s without a 403', async (grants, path) => {
const view = await open({ grants, path })
expect(view.find('[data-testid="page"]').exists()).toBe(true)
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
})
})
describe('The guard', () => {
it('shows neither the page nor the 403 while the session is checking', async () => {
const view = await open({ grants: 'media.view', path: '/schema', status: 'checking' })
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
useSessionStore().status = 'ready'
await flushPromises()
expect(view.text()).toContain(NO_ACCESS_TITLE)
})
it('leaves an unverified session to the pending redirect', async () => {
const view = await open({ grants: '', path: '/schema', verified: false })
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
})
it('switches to the 403 state when grants are lowered while the page is open', async () => {
const view = await open({ grants: 'schema.view', path: '/schema' })
expect(view.find('[data-testid="page"]').exists()).toBe(true)
const session = useSessionStore()
session.session = { ...session.session!, access: noAccess('Contributor') }
await flushPromises()
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).toContain(NO_ACCESS_TITLE)
expect(router.currentRoute.value.fullPath).toBe('/schema')
})
it('shows the gate copy in French', async () => {
setLocale('fr')
const view = await open({ grants: 'content.read=all', path: '/users' })
expect(view.text()).toContain('Vous n’avez pas accès à cette page')
})
})