feat: roles SPA foundations
This commit is contained in:
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')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { defineComponent, h } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { useApi, ApiError } from '@/composables/useApi'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -86,7 +87,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
})
|
||||
|
||||
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
@@ -102,7 +103,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
})
|
||||
|
||||
it('A verified-only route admits a verified session', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
@@ -341,7 +342,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
])(
|
||||
'Placeholder verified route $path renders inside DashboardShell with title $title',
|
||||
async ({ path, title }) => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
@@ -360,9 +361,13 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
|
||||
it('The /media route is verified, titled Media and renders MediaView inside DashboardShell', async () => {
|
||||
const route = router.getRoutes().find((candidate) => candidate.path === '/media')
|
||||
expect(route?.meta).toEqual({ auth: 'verified', title: 'Media' })
|
||||
expect(route?.meta).toEqual({
|
||||
auth: 'verified',
|
||||
title: 'Media',
|
||||
access: { key: 'media.view' },
|
||||
})
|
||||
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
|
||||
+108
-36
@@ -1,3 +1,4 @@
|
||||
import { defineComponent, h, type Component } from 'vue'
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
@@ -8,9 +9,34 @@ import {
|
||||
} from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { installDocumentTitle } from '@/composables/documentTitle'
|
||||
import { ARTICLE_TYPE_KEY, type RouteAccess } from '@/lib/routeAccess'
|
||||
|
||||
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
|
||||
|
||||
export function routeAccessOf(route: RouteLocationNormalized): RouteAccess | undefined {
|
||||
return route.meta.access as RouteAccess | undefined
|
||||
}
|
||||
|
||||
const gatedViews = new Map<string, Promise<Component>>()
|
||||
|
||||
function gated(key: string, load: () => Promise<{ default: Component }>) {
|
||||
return () => {
|
||||
let resolved = gatedViews.get(key)
|
||||
if (resolved === undefined) {
|
||||
resolved = Promise.all([load(), import('@/components/RouteAccessGate.vue')]).then(
|
||||
([view, gate]) =>
|
||||
defineComponent({
|
||||
name: 'GatedRoute',
|
||||
setup: () => () =>
|
||||
h(gate.default, null, { default: () => h(view.default) }),
|
||||
}),
|
||||
)
|
||||
gatedViews.set(key, resolved)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -59,70 +85,116 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/article',
|
||||
component: () => import('@/views/ArticleView.vue'),
|
||||
meta: { auth: 'verified', title: 'Articles' },
|
||||
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Articles',
|
||||
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/article/new',
|
||||
component: () => import('@/views/ArticleView.vue'),
|
||||
meta: { auth: 'verified', title: 'Articles' },
|
||||
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Articles',
|
||||
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/article/:id/edit',
|
||||
component: () => import('@/views/EditorView.vue'),
|
||||
meta: { auth: 'verified', title: 'Editor' },
|
||||
component: gated('@/views/EditorView.vue', () => import('@/views/EditorView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Editor',
|
||||
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/media',
|
||||
component: () => import('@/views/MediaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Media' },
|
||||
component: gated('@/views/MediaView.vue', () => import('@/views/MediaView.vue')),
|
||||
meta: { auth: 'verified', title: 'Media', access: { key: 'media.view' } },
|
||||
},
|
||||
{
|
||||
path: '/authors',
|
||||
component: () => import('@/views/AuthorsView.vue'),
|
||||
meta: { auth: 'verified', title: 'Authors' },
|
||||
component: gated('@/views/AuthorsView.vue', () => import('@/views/AuthorsView.vue')),
|
||||
meta: { auth: 'verified', title: 'Authors', access: { key: 'authors.view' } },
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: () => import('@/views/UsersView.vue'),
|
||||
meta: { auth: 'verified', title: 'Users' },
|
||||
component: gated('@/views/UsersView.vue', () => import('@/views/UsersView.vue')),
|
||||
meta: { auth: 'verified', title: 'Users', access: { key: 'users.view' } },
|
||||
},
|
||||
{
|
||||
path: '/schema',
|
||||
component: () => import('@/views/SchemaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
component: gated('@/views/SchemaView.vue', () => import('@/views/SchemaView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Schema',
|
||||
needsBuiltinTypes: true,
|
||||
access: { key: 'schema.view' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/schema/new',
|
||||
component: () => import('@/views/SchemaBuilderView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema' },
|
||||
component: gated(
|
||||
'@/views/SchemaBuilderView.vue',
|
||||
() => import('@/views/SchemaBuilderView.vue'),
|
||||
),
|
||||
meta: { auth: 'verified', title: 'Schema', access: { key: 'schema.edit' } },
|
||||
},
|
||||
{
|
||||
path: '/schema/:key',
|
||||
component: () => import('@/views/SchemaTypeView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
component: gated('@/views/SchemaTypeView.vue', () => import('@/views/SchemaTypeView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Schema',
|
||||
needsBuiltinTypes: true,
|
||||
access: { key: 'schema.view' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/content/:key',
|
||||
component: () => import('@/views/ContentListView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
component: gated(
|
||||
'@/views/ContentListView.vue',
|
||||
() => import('@/views/ContentListView.vue'),
|
||||
),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Content',
|
||||
access: { key: 'content.read', typeParam: 'key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/content/:key/new',
|
||||
name: 'entry-new',
|
||||
component: () => import('@/views/EntryEditorView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
component: gated(
|
||||
'@/views/EntryEditorView.vue',
|
||||
() => import('@/views/EntryEditorView.vue'),
|
||||
),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Content',
|
||||
access: { key: 'content.create', typeParam: 'key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/content/:key/:id',
|
||||
name: 'entry-edit',
|
||||
component: () => import('@/views/EntryEditorView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
component: gated(
|
||||
'@/views/EntryEditorView.vue',
|
||||
() => import('@/views/EntryEditorView.vue'),
|
||||
),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Content',
|
||||
access: { key: 'content.read', typeParam: 'key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
component: () => import('@/views/ActivityView.vue'),
|
||||
meta: { auth: 'verified', title: 'Activity' },
|
||||
component: gated('@/views/ActivityView.vue', () => import('@/views/ActivityView.vue')),
|
||||
meta: { auth: 'verified', title: 'Activity', access: { key: 'activity.view' } },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
@@ -136,28 +208,28 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/integration/api-keys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { auth: 'verified', title: 'Integration' },
|
||||
component: gated('@/views/ApiKeysView.vue', () => import('@/views/ApiKeysView.vue')),
|
||||
meta: { auth: 'verified', title: 'Integration', access: { key: 'apikeys.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/SettingsView.vue', () => import('@/views/SettingsView.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/variants',
|
||||
component: () => import('@/views/VariantsPage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/VariantsPage.vue', () => import('@/views/VariantsPage.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/sites',
|
||||
component: () => import('@/views/SitesView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/cache',
|
||||
component: () => import('@/views/CachePage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
|
||||
Reference in new issue
Block a user