feat: roles SPA foundations
This commit is contained in:
1 parent
80f7037d3a
commit
907949aa12
180 files changed
+4308
-835
No files matched your search
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import AcceptInviteView from '../AcceptInviteView.vue'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
@@ -346,7 +347,8 @@ describe('Success', () => {
|
||||
it('shows the panel and toast, then navigates to /home after 800 ms', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||
...withAccept(() => mockResponse(200, { message: 'ok' })),
|
||||
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
|
||||
'GET /v1/auth/me': () =>
|
||||
mockResponse(200, { is_verified: true, access: ownerAccess() }),
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
unmountActivity,
|
||||
} from './activityHarness'
|
||||
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -360,14 +361,14 @@ describe('The Person menu', () => {
|
||||
id: 3,
|
||||
username: 'marta',
|
||||
display_name: 'Marta Alves',
|
||||
role: 'owner',
|
||||
role: roleRefFor('owner'),
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 4,
|
||||
username: 'jonas',
|
||||
display_name: '',
|
||||
role: 'admin',
|
||||
role: roleRefFor('admin'),
|
||||
},
|
||||
{
|
||||
kind: 'invite',
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('Navigation', () => {
|
||||
expect(router.currentRoute.value.meta).toEqual({
|
||||
auth: 'verified',
|
||||
title: 'Activity',
|
||||
access: { key: 'activity.view' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { ApiKey, CreatedApiKey } from '@/composables/useApiKeys'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import ApiKeysView from '../ApiKeysView.vue'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { accessFor, customAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -166,6 +169,7 @@ interface Handlers {
|
||||
interface MountOptions {
|
||||
desktop?: boolean
|
||||
role?: string | null
|
||||
access?: Access
|
||||
username?: string
|
||||
displayName?: string
|
||||
}
|
||||
@@ -174,6 +178,7 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
|
||||
const {
|
||||
desktop = true,
|
||||
role = 'owner',
|
||||
access,
|
||||
username = 'marta',
|
||||
displayName = 'Marta Alves',
|
||||
} = options
|
||||
@@ -186,7 +191,7 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
|
||||
username,
|
||||
id: 1,
|
||||
displayName,
|
||||
role: sessionRole as 'owner' | 'admin' | undefined,
|
||||
access: access ?? accessFor(sessionRole),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
@@ -209,7 +214,7 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
|
||||
verified: true,
|
||||
username,
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
access: accessFor(sessionRole),
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
@@ -490,9 +495,13 @@ describe('Owner: list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin: own keys only', () => {
|
||||
it('shows two rows with no Created by column and the admin description', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { role: 'admin' })
|
||||
function ownAccess() {
|
||||
return customAccess('apikeys.view=own, apikeys.create, apikeys.revoke=own', 'Key holder')
|
||||
}
|
||||
|
||||
describe('Own keys only', () => {
|
||||
it('shows two rows with no Created by column and the own-keys description', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { access: ownAccess() })
|
||||
|
||||
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
|
||||
'Name Key Status Created Last used Expires',
|
||||
@@ -510,7 +519,7 @@ describe('Admin: own keys only', () => {
|
||||
it('reads the cards without " by {name}" below md', async () => {
|
||||
await mountPage(
|
||||
{ list: () => Promise.resolve(adminFixture()) },
|
||||
{ role: 'admin', desktop: false, username: 'jonas' },
|
||||
{ access: ownAccess(), desktop: false, username: 'jonas' },
|
||||
)
|
||||
|
||||
expect(rowTexts()[0]).toBe(
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { customAccess, writerAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import {
|
||||
articleOf,
|
||||
createMetaBackend,
|
||||
mountMeta,
|
||||
textOf,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
void router
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const SESSION_USER = 1
|
||||
|
||||
function grants(publish: string, remove: string, update: string): Access {
|
||||
return customAccess(
|
||||
`content.read:article=all, content.create:article=all, content.publish:article=${publish}, content.delete:article=${remove}, content.update:article=${update}`,
|
||||
'Contributor',
|
||||
)
|
||||
}
|
||||
|
||||
async function mountArticles(access: Access, articles = [articleOf(1, 'Article 1')]) {
|
||||
const backend: MetaBackend = createMetaBackend()
|
||||
backend.articles = articles
|
||||
backend.me.access = access
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openMenu(title: string) {
|
||||
document.body.querySelector<HTMLElement>(`button[aria-label="Actions for ${title}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
|
||||
(item) => textOf(item).startsWith(label),
|
||||
)
|
||||
}
|
||||
|
||||
function isDisabled(item: HTMLElement | undefined) {
|
||||
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
|
||||
}
|
||||
|
||||
describe('Article controls under all and none', () => {
|
||||
it('offers every control to a role that holds all of them', async () => {
|
||||
await mountArticles(grants('all', 'all', 'all'))
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(menuItem('Publish')).toBeDefined()
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(false)
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(textOf(document.body)).not.toContain('Ask an editor to publish.')
|
||||
})
|
||||
|
||||
it('hides Publish, keeps the Draft status with the editor hint and disables Delete', async () => {
|
||||
await mountArticles(grants('none', 'none', 'all'))
|
||||
|
||||
expect(document.body.querySelector('[data-testid="publish-hint"]')?.textContent).toBe(
|
||||
'Ask an editor to publish.',
|
||||
)
|
||||
await openMenu('Article 1')
|
||||
expect(menuItem('Publish')).toBeUndefined()
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't delete articles.")
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
})
|
||||
|
||||
it('disables Edit as well when the role cannot update', async () => {
|
||||
await mountArticles(grants('none', 'none', 'none'))
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't edit articles.")
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Writer built-in Articles list', () => {
|
||||
it('reads the Writer header, enables Edit and disables Delete with its reason', async () => {
|
||||
await mountArticles(writerAccess())
|
||||
|
||||
expect(textOf(document.body)).toContain(
|
||||
'You can write and edit all articles. Publishing is done by an editor.',
|
||||
)
|
||||
await openMenu('Article 1')
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't delete articles.")
|
||||
expect(menuItem('Publish')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Article controls under own scope', () => {
|
||||
const OWN = grants('own', 'own', 'own')
|
||||
const articles = [
|
||||
articleOf(1, 'Article 1', { created_by: SESSION_USER }),
|
||||
articleOf(2, 'Article 2', { created_by: 9 }),
|
||||
]
|
||||
|
||||
it('makes Edit, Delete and Publish available on the article the person created', async () => {
|
||||
await mountArticles(OWN, articles)
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(false)
|
||||
expect(menuItem('Publish')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Edit and Delete and hides Publish on an article by someone else', async () => {
|
||||
await mountArticles(OWN, articles)
|
||||
await openMenu('Article 2')
|
||||
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(true)
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain('You can only edit content you created.')
|
||||
expect(menuItem('Publish')).toBeUndefined()
|
||||
expect(document.body.querySelector('tr [data-testid="publish-hint"]')?.textContent).toBe(
|
||||
'Ask an editor to publish.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A stale menu', () => {
|
||||
it('disables Delete with its reason once the session loses the permission', async () => {
|
||||
await mountArticles(grants('all', 'all', 'all'))
|
||||
const session = useSessionStore()
|
||||
session.session = { ...session.session!, access: grants('all', 'none', 'all') }
|
||||
await flushPromises()
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't delete articles.")
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import type { Article } from '@/composables/useArticles'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
@@ -137,7 +138,7 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
|
||||
overlayMock = createOverlayMock()
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
...handlers,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
const TOTAL = 312
|
||||
@@ -62,7 +63,7 @@ function installFetch() {
|
||||
async function mountAt(path: string) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(ArticleView, {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__te
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const TOTAL = 312
|
||||
const PLASTER_EVERY = 20
|
||||
@@ -102,7 +103,7 @@ async function mountAt(path: string) {
|
||||
setActivePinia(createPinia())
|
||||
overlayMock = createOverlayMock()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(ArticleView, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accessFor } from '@/test/access'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
@@ -156,7 +157,7 @@ describe('Articles: the Site column and the Site filter', () => {
|
||||
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
|
||||
withAuthors(2, ['Inès Duarte'], { site_id: 1 }),
|
||||
])
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
backend.me.access = accessFor(role)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual([
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { CacheStats } from '@/composables/useVariantCache'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import CachePage from '../CachePage.vue'
|
||||
import { accessFor, customAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -111,6 +113,7 @@ async function mountPage(
|
||||
options: {
|
||||
desktop?: boolean
|
||||
role?: string | null
|
||||
access?: Access
|
||||
routes?: Record<string, Handler>
|
||||
} = {},
|
||||
) {
|
||||
@@ -124,7 +127,7 @@ async function mountPage(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
role: sessionRole as 'owner' | 'admin' | undefined,
|
||||
access: options.access ?? accessFor(sessionRole),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
@@ -142,7 +145,7 @@ async function mountPage(
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
access: accessFor(sessionRole),
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
@@ -263,11 +266,11 @@ describe('Rule: Cache screen, access and header', () => {
|
||||
routes: role ? { [STATS]: () => Promise.reject(new ApiError(403)) } : {},
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access")
|
||||
expect(document.body.textContent).toContain("You don't have access to this page")
|
||||
expect(document.body.textContent).toContain(
|
||||
'Only admins can manage image variants. Ask an admin if you need one changed.',
|
||||
"doesn't include it. Ask an owner or admin if you need it.",
|
||||
)
|
||||
expect(findButton('Back to Home')).toBeDefined()
|
||||
expect(findButton('Go to Home') ?? findButton('Go to Articles')).toBeDefined()
|
||||
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(document.body.querySelector('h1:not([data-slot="title"])')).toBeNull()
|
||||
expect(calls(STATS)).toHaveLength(role ? 1 : 0)
|
||||
@@ -821,3 +824,21 @@ describe('Rule: Accessibility and vocabulary', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, settings.edit_general', () => {
|
||||
it('lets a session with settings.edit_general purge and shows no read-only banner', async () => {
|
||||
await mountPage({ access: customAccess('settings.view, settings.edit_general') })
|
||||
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'You can read these settings but not change them',
|
||||
)
|
||||
expect(findButton('Purge all variants')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('locks the purge controls behind the banner with settings.view alone', async () => {
|
||||
await mountPage({ access: customAccess('settings.view') })
|
||||
|
||||
expect(document.body.textContent).toContain('You can read these settings but not change them')
|
||||
expect(findButton('Purge all variants')?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -42,7 +42,7 @@ function main() {
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return [...main().querySelectorAll('thead th')].map(text)
|
||||
return [...main().querySelectorAll('thead th')].map(text).filter((label) => label !== '')
|
||||
}
|
||||
|
||||
function rows() {
|
||||
@@ -50,7 +50,7 @@ function rows() {
|
||||
}
|
||||
|
||||
function cells(row: Element) {
|
||||
return [...row.querySelectorAll('td')].map(text)
|
||||
return [...row.querySelectorAll('td')].slice(0, -1).map(text)
|
||||
}
|
||||
|
||||
function skeletonRows() {
|
||||
|
||||
@@ -38,7 +38,7 @@ function seed(value: string, key = KEY) {
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return [...document.body.querySelectorAll('thead th')].map(text)
|
||||
return [...document.body.querySelectorAll('thead th')].map(text).filter((label) => label !== '')
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
|
||||
@@ -92,7 +92,7 @@ function mountWithThumbnail(
|
||||
})
|
||||
}
|
||||
|
||||
const headers = () => [...document.body.querySelectorAll('thead th')]
|
||||
const headers = () => [...document.body.querySelectorAll('thead th')].slice(0, -1)
|
||||
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
|
||||
const thumbCell = (row: number) => rowsOf()[row]!.querySelectorAll('td')[2]!
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Navigation actions', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['user', 'editor', 'unknown'])(
|
||||
it.each(['member', 'editor'])(
|
||||
'does not render Edit content type for the role %s',
|
||||
async (role) => {
|
||||
await mountList('/content/events', { role })
|
||||
@@ -292,7 +292,7 @@ describe('French', () => {
|
||||
expect(text(main().querySelector('[data-testid="entry-toolbar"]'))).toBe(
|
||||
'Trier par Dernière mise à jour Colonnes Modifier le type de contenu',
|
||||
)
|
||||
expect([...main().querySelectorAll('thead th')].map(text).slice(-3)).toEqual([
|
||||
expect([...main().querySelectorAll('thead th')].map(text).slice(-4, -1)).toEqual([
|
||||
'Créé',
|
||||
'Modifié',
|
||||
'Modifié par',
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { customAccess } from '@/test/access'
|
||||
import {
|
||||
eventEntries,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
lookupNames,
|
||||
mountList,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
void router
|
||||
|
||||
const SESSION_USER = 1
|
||||
|
||||
function entryBy(id: number, createdBy: number) {
|
||||
return { ...eventEntries[0]!, id, created_by: createdBy, updated_by: createdBy }
|
||||
}
|
||||
|
||||
const entries = [entryBy(1, SESSION_USER), entryBy(2, 9), entryBy(3, 0)]
|
||||
|
||||
function access(update: string, remove: string, create = 'content.create:events=all') {
|
||||
return customAccess(
|
||||
`content.read:events=all, ${create}, content.update:events=${update}, content.delete:events=${remove}`,
|
||||
'Contributor',
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function rowOf(id: number) {
|
||||
return [...main().querySelectorAll('tbody tr')].find(
|
||||
(row) => text(row.querySelector('td'))?.trim() === String(id),
|
||||
)!
|
||||
}
|
||||
|
||||
async function mountWith(update: string, remove: string, create?: string) {
|
||||
await mountList('/content/events', {
|
||||
access: access(update, remove, create),
|
||||
list: (_key, query) => listPage(entries, 3, query),
|
||||
lookup: lookupNames,
|
||||
})
|
||||
await vi.waitFor(() => expect(main().querySelectorAll('tbody tr')).toHaveLength(3))
|
||||
}
|
||||
|
||||
async function openMenu(id: number) {
|
||||
rowOf(id).querySelector<HTMLElement>(`button[aria-label="Actions for #${id}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((item) =>
|
||||
text(item).startsWith(label),
|
||||
)
|
||||
}
|
||||
|
||||
function disabled(item: HTMLElement | undefined) {
|
||||
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
|
||||
}
|
||||
|
||||
function viewOnly(id: number) {
|
||||
return rowOf(id).querySelector('[data-testid="view-only-mark"]')
|
||||
}
|
||||
|
||||
describe('Entry list rows under each scope', () => {
|
||||
it.each([
|
||||
{ update: 'own', remove: 'own', row: 1, mark: false, edit: true, del: true, reason: '' },
|
||||
{
|
||||
update: 'own',
|
||||
remove: 'own',
|
||||
row: 2,
|
||||
mark: true,
|
||||
edit: false,
|
||||
del: false,
|
||||
reason: 'You can only edit content you created.',
|
||||
},
|
||||
{
|
||||
update: 'own',
|
||||
remove: 'own',
|
||||
row: 3,
|
||||
mark: true,
|
||||
edit: false,
|
||||
del: false,
|
||||
reason: 'You can only edit content you created.',
|
||||
},
|
||||
{ update: 'all', remove: 'all', row: 2, mark: false, edit: true, del: true, reason: '' },
|
||||
{
|
||||
update: 'all',
|
||||
remove: 'none',
|
||||
row: 1,
|
||||
mark: false,
|
||||
edit: true,
|
||||
del: false,
|
||||
reason: "Your role can't delete entries.",
|
||||
},
|
||||
{
|
||||
update: 'none',
|
||||
remove: 'none',
|
||||
row: 1,
|
||||
mark: true,
|
||||
edit: false,
|
||||
del: false,
|
||||
reason: "Your role can't edit entries.",
|
||||
},
|
||||
])(
|
||||
'update $update, delete $remove, row $row: mark $mark, edit $edit, delete $del',
|
||||
async ({ update, remove, row, mark, edit, del, reason }) => {
|
||||
await mountWith(update, remove)
|
||||
|
||||
expect(viewOnly(row) !== null).toBe(mark)
|
||||
await openMenu(row)
|
||||
expect(disabled(menuItem('Edit'))).toBe(!edit)
|
||||
expect(disabled(menuItem('Delete'))).toBe(!del)
|
||||
if (reason) {
|
||||
expect(document.body.textContent).toContain(reason)
|
||||
}
|
||||
if (update === 'none') {
|
||||
expect(document.body.textContent).toContain("Your role can't delete entries.")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the View only mark with a lock icon and an accessible name', async () => {
|
||||
await mountWith('own', 'own')
|
||||
|
||||
const mark = viewOnly(2)!
|
||||
expect(text(mark)).toBe('View only')
|
||||
expect(mark.getAttribute('aria-label')).toBe('View only')
|
||||
expect(mark.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The entry list explains the own scope', () => {
|
||||
it('reads the own-scope line, the note and keeps New entry for a role that can create', async () => {
|
||||
await mountWith('own', 'own')
|
||||
|
||||
expect(text(main())).toContain('You can create entries and edit your own.')
|
||||
expect(text(main())).toContain('Entries by others are view only')
|
||||
expect(text(main())).toContain('Your role lets you edit entries you created.')
|
||||
expect([...main().querySelectorAll('button')].some((b) => text(b) === 'New entry')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('hides New entry when the role cannot create', async () => {
|
||||
await mountWith('own', 'own', 'content.create:events=none')
|
||||
|
||||
expect([...main().querySelectorAll('button')].some((b) => text(b) === 'New entry')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(text(main())).not.toContain('You can create entries and edit your own.')
|
||||
})
|
||||
|
||||
it('shows no note for a role that edits everything', async () => {
|
||||
await mountWith('all', 'all')
|
||||
|
||||
expect(text(main())).not.toContain('Entries by others are view only')
|
||||
})
|
||||
|
||||
it('marks entries by others on the mobile cards too', async () => {
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
access: access('own', 'own'),
|
||||
list: (_key, query) => listPage(entries, 3, query),
|
||||
lookup: lookupNames,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(main().querySelectorAll('[data-testid="view-only-mark"]')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -63,10 +63,10 @@ describe('Language: every string of the sites screen has a French entry', () =>
|
||||
})
|
||||
|
||||
it('translates the 403 page', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'member' })
|
||||
|
||||
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
|
||||
expect(document.body.textContent).toContain('Retour aux paramètres')
|
||||
expect(document.body.textContent).toContain('Vous n’avez pas accès à cette page')
|
||||
expect(document.body.textContent).toContain('Aller aux articles')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import AuthorsView from '../AuthorsView.vue'
|
||||
import SitesView from '../SitesView.vue'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
@@ -32,7 +33,7 @@ async function mountWith(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createAppRouter } from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
@@ -54,7 +55,9 @@ function installFetch(handlers: Record<string, Handler> = {}) {
|
||||
async function boot(path: string, signedIn: boolean, handlers: Record<string, Handler> = {}) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = signedIn ? { verified: true, username: 'marta', displayName: '' } : null
|
||||
session.session = signedIn
|
||||
? { verified: true, access: ownerAccess(), username: 'marta', displayName: '' }
|
||||
: null
|
||||
session.status = 'ready'
|
||||
installFetch(handlers)
|
||||
const router = createAppRouter(createMemoryHistory())
|
||||
@@ -167,7 +170,12 @@ describe('Title follows navigation', () => {
|
||||
const { router, session } = await boot('/login', false)
|
||||
expect(document.title).toBe('Log in · RenewCMS')
|
||||
|
||||
session.session = { verified: true, username: 'marta', displayName: '' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
|
||||
@@ -296,6 +304,7 @@ describe('Not-found layout', () => {
|
||||
'GET /v1/auth/me': () =>
|
||||
mockResponse(200, {
|
||||
is_verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
display_name: 'Marta',
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type MetaRole,
|
||||
} from './metaTestKit'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { customAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -178,6 +179,20 @@ describe('Add author: "Create author…" only for roles with author rights', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add author: the row follows authors.create', () => {
|
||||
it.each([
|
||||
{ grants: 'content.read, content.update, authors.view, authors.create', shown: true },
|
||||
{ grants: 'content.read, content.update, authors.view', shown: false },
|
||||
])('grants $grants: row shown $shown', async ({ grants, shown }) => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.me.access = customAccess(grants)
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
expect(createRow() !== null).toBe(shown)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add author: no match', () => {
|
||||
it.each([
|
||||
{ role: 'owner', create: true },
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -158,7 +159,7 @@ async function mountEditor(
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
|
||||
@@ -9,6 +9,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { fixture } from './mediaTestKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -62,7 +63,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = vi.fn(async (url: string) => {
|
||||
if (url === '/v1/article/12?format=html') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accessFor } from '@/test/access'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
@@ -278,7 +279,7 @@ describe('Editor section: the Site select', () => {
|
||||
'is above the authors with the canonical host as helper text for a %s',
|
||||
async (role) => {
|
||||
const backend = backendWith('several', ['Inès Duarte'], 2)
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
backend.me.access = accessFor(role)
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport
|
||||
import { typeInto } from './directoryTestKit'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
function reply(status: number, body: unknown = null, text = '') {
|
||||
return {
|
||||
@@ -70,7 +71,7 @@ async function mountEditor() {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push('/article/12/edit')
|
||||
wrapper = mount(RootComponent, {
|
||||
|
||||
@@ -10,6 +10,7 @@ import RootComponent from '@/App.vue'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { fixture, textOf } from './mediaTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
const HERO_BODY =
|
||||
@@ -116,7 +117,7 @@ async function mountEditor(options: EditorOptions = {}) {
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
access: accessFor(options.role ?? 'owner'),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const variants = options.variants ?? (() => ({ status: 200, body: { items: CATALOG } }))
|
||||
@@ -385,7 +386,7 @@ describe('The Variant select', () => {
|
||||
})
|
||||
|
||||
it('sends no variants request for a session that is not owner or admin', async () => {
|
||||
const wrapper = await mountEditor({ role: 'user' })
|
||||
const wrapper = await mountEditor({ role: 'member' })
|
||||
await openPicker(wrapper)
|
||||
|
||||
await selectTile('north-wall.jpg')
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -97,7 +98,7 @@ async function mountEditor(
|
||||
overlayMock = createOverlayMock()
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
@@ -632,7 +633,7 @@ describe('Session expired mid-save keeps the draft and restores it after logging
|
||||
expect(router.currentRoute.value.query.reason).toBe('expired')
|
||||
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/article/12?format=html': () =>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { customAccess } from '@/test/access'
|
||||
import { button, entry42, main, mountEditor, text, unmountAll } from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
void router
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const OWN_UPDATE = customAccess(
|
||||
'content.read:events=all, content.update:events=own, content.delete:events=own',
|
||||
'Contributor',
|
||||
)
|
||||
|
||||
describe('Direct link to an entry the role can only view', () => {
|
||||
it('shows the entry with the View only mark and no Save or Delete action', async () => {
|
||||
await mountEditor('/content/events/42', { access: OWN_UPDATE })
|
||||
|
||||
expect(text(main())).toContain('Lime plaster workshop')
|
||||
expect(main().querySelector('[data-testid="view-only-mark"]')).not.toBeNull()
|
||||
expect(button('Save')).toBeUndefined()
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
expect(main().querySelector('button[type="submit"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the form editable for an entry the role created', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
access: OWN_UPDATE,
|
||||
entry: () => ({ ...entry42, created_by: 1 }),
|
||||
})
|
||||
|
||||
expect(main().querySelector('[data-testid="view-only-mark"]')).toBeNull()
|
||||
expect(button('Save')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows no View only mark for a role that updates every entry', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
access: customAccess('content.read:events=all, content.update:events=all'),
|
||||
})
|
||||
|
||||
expect(main().querySelector('[data-testid="view-only-mark"]')).toBeNull()
|
||||
expect(button('Save')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import type { ActivityItem } from '@/lib/activity'
|
||||
import HomeView from '../HomeView.vue'
|
||||
import { anonymous, makeItem, target } from './activityFixtures'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
|
||||
const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
|
||||
@@ -53,7 +54,7 @@ let calls: string[] = []
|
||||
async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record<string, Handler>) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', role }
|
||||
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
||||
session.status = 'ready'
|
||||
calls = []
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
@@ -123,7 +124,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
|
||||
})
|
||||
|
||||
describe('Home, regular user, own events only and no actor name', () => {
|
||||
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
|
||||
it.each([['editor' as const]])('shows only the time for %s', async (role) => {
|
||||
await mountHome(role as undefined, {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
||||
@@ -294,7 +295,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
|
||||
describe('Home card, empty when there are no events yet', () => {
|
||||
it.each([
|
||||
['owner' as const, 'Actions on your site will appear here.'],
|
||||
[undefined, 'Your actions will appear here.'],
|
||||
['editor', 'Your actions will appear here.'],
|
||||
])('says so for %s', async (role, text) => {
|
||||
await mountHome(role as 'owner', {
|
||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||
@@ -387,3 +388,13 @@ describe('Home card, the French interface', () => {
|
||||
expect(card().text()).toContain('Aucune activité pour le moment')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, not shown without activity.view', () => {
|
||||
it('renders no recent activity card and makes no activity request', async () => {
|
||||
await mountHome(undefined, {
|
||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
||||
})
|
||||
|
||||
expect(card().exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__te
|
||||
import HomeView from '../HomeView.vue'
|
||||
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
|
||||
import { articleEnvelope, articleListUrl } from './articleEnvelope'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
@@ -33,7 +34,7 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', role }
|
||||
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
||||
session.status = 'ready'
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
verifying,
|
||||
} from './updateTestKit'
|
||||
import { articleEnvelope, articleListUrl } from './articleEnvelope'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
@@ -33,7 +34,7 @@ let wrapper: VueWrapper | null = null
|
||||
async function mountHome(role: UserRole, status: UpdateStatus) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', role }
|
||||
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
||||
session.status = 'ready'
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
|
||||
|
||||
@@ -371,17 +371,20 @@ describe('The greeting is identical across every load state at a fixed hour', ()
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(loadingWrapper.text()).toContain('Good morning, marta')
|
||||
loadingWrapper.unmount()
|
||||
|
||||
const defaultWrapper = await mountHome({
|
||||
[HOME_ARTICLES_ROUTE]: () =>
|
||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
||||
})
|
||||
expect(defaultWrapper.text()).toContain('Good morning, marta')
|
||||
defaultWrapper.unmount()
|
||||
|
||||
const emptyWrapper = await mountHome({
|
||||
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
|
||||
})
|
||||
expect(emptyWrapper.text()).toContain('Good morning, marta')
|
||||
emptyWrapper.unmount()
|
||||
|
||||
const errorWrapper = await mountHome({
|
||||
[HOME_ARTICLES_ROUTE]: () => mockResponse(500),
|
||||
@@ -423,7 +426,11 @@ describe('The greeting names the person by display name, else username', () => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName: '' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
[HOME_ARTICLES_ROUTE]: () =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ImageVariant } from '@/composables/useImageVariants'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { deferred, setViewport, stubBrowser, variant } from './variantsTestKit'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -110,7 +111,7 @@ async function mountPage(options: MountOptions = {}) {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
role: sessionRole,
|
||||
access: accessFor(sessionRole),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
@@ -122,6 +123,7 @@ async function mountPage(options: MountOptions = {}) {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const SEARCH_LABEL = 'Search the API reference'
|
||||
const FLASH_MS = 1500
|
||||
@@ -35,7 +36,7 @@ async function mountIntegration(width = DESKTOP_WIDTH, hash = '') {
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(`/integration${hash}`)
|
||||
const wrapper = mount(IntegrationView, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import LoginView from '../LoginView.vue'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
@@ -318,6 +319,7 @@ describe('A successful log in navigates to the right destination', () => {
|
||||
{
|
||||
path: '/login?redirect=/article/12/edit',
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
destination: '/article/12/edit',
|
||||
},
|
||||
])(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { customAccess } from '@/test/access'
|
||||
import { findButton, mountPage, settle, textOf, unmountPage } from './mediaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
void router
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
function checkboxes() {
|
||||
return document.body.querySelectorAll('[role="checkbox"]')
|
||||
}
|
||||
|
||||
describe('Media controls by grants', () => {
|
||||
it('is read-only with media.view only: no upload, no selection', async () => {
|
||||
await mountPage({}, '/media', undefined, { access: customAccess('media.view') })
|
||||
await settle()
|
||||
|
||||
expect(findButton('Upload')).toBeUndefined()
|
||||
expect(checkboxes()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers upload with media.upload and still no selection', async () => {
|
||||
await mountPage({}, '/media', undefined, {
|
||||
access: customAccess('media.view, media.upload'),
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(findButton('Upload')).toBeDefined()
|
||||
expect(checkboxes()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers selection with media.delete at all', async () => {
|
||||
await mountPage({}, '/media', undefined, {
|
||||
access: customAccess('media.view, media.delete=all'),
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(checkboxes().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('treats media.delete at own like none', async () => {
|
||||
await mountPage({}, '/media', undefined, {
|
||||
access: customAccess('media.view, media.delete=own'),
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(checkboxes()).toHaveLength(0)
|
||||
expect(textOf(document.body)).not.toContain('Purge duplicates')
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
@@ -46,7 +47,7 @@ async function mountUnverified(handlers: Handlers, url = '/media') {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
xhrFor,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -846,7 +847,7 @@ describe('Session errors on the upload calls are global', () => {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
await chooseFiles([photo('a.jpg')])
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import MediaView from '../MediaView.vue'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -311,7 +312,7 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
|
||||
@@ -5,6 +5,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import PendingView from '../PendingView.vue'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
@@ -243,7 +244,8 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
|
||||
const { wrapper } = await mountPending({
|
||||
'DELETE /v1/auth/register': () => mockResponse(403),
|
||||
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
|
||||
'GET /v1/auth/me': () =>
|
||||
mockResponse(200, { is_verified: true, access: ownerAccess() }),
|
||||
})
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: false }
|
||||
@@ -259,6 +261,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
|
||||
expect(session.session).toMatchObject({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
displayName: '',
|
||||
})
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
|
||||
@@ -7,6 +7,7 @@ import App from '@/App.vue'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -47,6 +48,7 @@ function createOverlayMock() {
|
||||
const MARTA: Session = {
|
||||
id: 1,
|
||||
verified: true,
|
||||
access: accessFor('noschema'),
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: '',
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('The overflow menu', () => {
|
||||
await mountAt('/schema/recipes', { role: 'user' })
|
||||
|
||||
expect(more()).toBeNull()
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(text(main())).toContain("You don't have access to this page")
|
||||
})
|
||||
|
||||
it('is enabled on a saved type and disabled while the PUT is in flight', async () => {
|
||||
@@ -447,7 +447,7 @@ describe('Other answers of DELETE', () => {
|
||||
await click(dialogButton(DELETE))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(text(main())).toContain("You don't have access to this page")
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('The count cannot be loaded', () => {
|
||||
it('shows the access page when the count answers 403', async () => {
|
||||
await mountAt('/schema/events', { entries: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(text(main())).toContain("You don't have access to this page")
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('Roles and sessions in edit mode', () => {
|
||||
it('tells a verified user who is neither to go home, leaving the URL', async () => {
|
||||
await mountAt('/schema/events', { role: 'user' })
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(text(main())).toContain("You don't have access to this page")
|
||||
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/events')
|
||||
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
|
||||
|
||||
@@ -719,7 +719,7 @@ describe('Other answers of the PUT', () => {
|
||||
it('shows the access page for 403', async () => {
|
||||
await answered(failWithBody(403, 'Only owners and admins'))
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(text(main())).toContain("You don't have access to this page")
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -531,7 +531,7 @@ describe('The builder is for owners and admins', () => {
|
||||
it('keeps the forbidden state and renders no relation control', async () => {
|
||||
await mountAt('/schema/events', { role: 'user', content: types })
|
||||
|
||||
expect(text(el('#dashboard-content'))).toContain("You don't have access to Schema")
|
||||
expect(text(el('#dashboard-content'))).toContain("You don't have access to this page")
|
||||
expect(el('[data-testid="relation-picker"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -351,7 +351,7 @@ describe('The server refuses a key or a field', () => {
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(text(main())).toContain("You don't have access to this page")
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ addCollection(lucide)
|
||||
afterEach(unmountAll)
|
||||
|
||||
const NOT_FOUND_TITLE = 'Content type not found'
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
const ACCESS_TITLE = "You don't have access to this page"
|
||||
|
||||
function newTypeButtons() {
|
||||
return all('button').filter((candidate) => text(candidate) === 'New content type')
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
const ACCESS_TITLE = "You don't have access to this page"
|
||||
const NOT_FOUND_TITLE = 'Content type not found'
|
||||
const NOT_FOUND_TEXT = 'It may have been deleted, or the address may be mistyped.'
|
||||
const NOTE =
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
const ACCESS_TITLE = "You don't have access to this page"
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('Schema list: forbidden', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(text(main())).toContain(
|
||||
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
|
||||
"Your role, , doesn't include it. Ask an owner or admin if you need it.",
|
||||
)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
expect(document.body.querySelector('a[href="/schema"]')).toBeNull()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accessFor } from '@/test/access'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
@@ -243,7 +244,7 @@ describe('Dismiss x: behaviour', () => {
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
backend.me.access = accessFor(role)
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
|
||||
|
||||
@@ -321,21 +321,20 @@ describe.each([
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: sessions', () => {
|
||||
it('shows the owner-only toast when the PATCH answers 403', async () => {
|
||||
it('shows no owner-only toast of its own when the PATCH answers 403 missing_permission', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
|
||||
backend.responders['PATCH /v1/site/1'] = failWith(403, 'missing_permission')
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
|
||||
})
|
||||
|
||||
it('shows the 403 page to an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
it('shows the 403 page to a writer', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'member' })
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||
expect(document.body.textContent).toContain("You don't have access to this page")
|
||||
expect(document.body.textContent).not.toContain('Title template')
|
||||
expect(vi.mocked(useApi)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -34,7 +35,7 @@ let wrapper: VueWrapper | null = null
|
||||
async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.session = { verified: true, username: 'marta', id: 1, access: accessFor(role) }
|
||||
session.status = 'ready'
|
||||
const request = vi.fn((path: string) => {
|
||||
if (path === '/v1/settings/') {
|
||||
@@ -51,6 +52,7 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
@@ -135,10 +137,10 @@ describe('Settings, admin, container', () => {
|
||||
it('keeps the admin view with no command block', async () => {
|
||||
await mountSettings('admin', newerAvailable({ container: true }))
|
||||
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardText()).toContain('Only the owner can apply updates.')
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
expect(updatesCard().querySelector('code')).toBeNull()
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual([])
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual(['Apply update'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -346,7 +346,10 @@ describe('Media cleanup card: save failures', () => {
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
expect(switchError()).toBeNull()
|
||||
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
|
||||
expect(textOf(cardEl())).toContain('You can read these settings but not change them')
|
||||
expect(textOf(cardEl())).toContain(
|
||||
'Ask the owner to change them, or ask for a role that can edit settings.',
|
||||
)
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
})
|
||||
@@ -365,7 +368,7 @@ describe('Media cleanup card: read-only', () => {
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(textOf(card.querySelector('[role="status"]'))).toBe(
|
||||
'Only owners and admins can change this.',
|
||||
'You can read these settings but not change themAsk the owner to change them, or ask for a role that can edit settings.',
|
||||
)
|
||||
expect(card.querySelector('[role="separator"]')).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Purge now')
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -129,7 +130,7 @@ async function mountSettings(options: MountOptions) {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: options.role,
|
||||
access: accessFor(options.role),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status)
|
||||
@@ -167,7 +168,7 @@ async function mountSettings(options: MountOptions) {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.meAfterRefresh ?? options.role,
|
||||
access: accessFor(options.meAfterRefresh ?? options.role),
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock })
|
||||
await router.push('/settings')
|
||||
@@ -935,7 +936,7 @@ describe("Settings, another owner's tab never takes the toast", () => {
|
||||
await mountSettings({ role: 'admin', status: restarting() })
|
||||
|
||||
expect(overlayElement()).toBeNull()
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(cardButtons()).toEqual(['Apply update'])
|
||||
const before = callsTo(STATUS_PATH).length
|
||||
await tick(3000)
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(before)
|
||||
@@ -1090,8 +1091,8 @@ describe('Settings, 403 and 401 on Restart', () => {
|
||||
description: 'Ask the owner of this site to update RenewCMS.',
|
||||
}),
|
||||
)
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(cardText()).toContain('Only the owner can apply updates.')
|
||||
expect(cardButtons()).toEqual(['Apply update'])
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { WorkspaceSettings } from '@/composables/useSettings'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -60,7 +61,7 @@ async function mountPage(
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.session = { verified: true, username: 'marta', id: 1, access: accessFor(role) }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
if (path === SETTINGS_PATH && init?.method === 'PATCH') {
|
||||
@@ -74,7 +75,9 @@ async function mountPage(
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
|
||||
getMeMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, username: 'marta', id: 1, access: accessFor(role) })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: getMeMock,
|
||||
request: requestMock,
|
||||
@@ -151,18 +154,28 @@ afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings retention is hidden for everyone but the owner', () => {
|
||||
it.each([['admin' as const], ['editor' as const], [undefined]])(
|
||||
'renders nothing for %s',
|
||||
async (role) => {
|
||||
await mountPage(role, () => Promise.resolve(settings()))
|
||||
describe('Settings retention is read-only without system.edit_retention', () => {
|
||||
it('shows the card locked with the Owner only tag and the admin helper for an admin', async () => {
|
||||
await mountPage('admin', () => Promise.resolve(settings()))
|
||||
|
||||
expect(retention()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Keep activity for')
|
||||
expect(document.body.textContent).toContain('Media cleanup')
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
expect(retention()).not.toBeNull()
|
||||
expect(text()).toContain('Owner only')
|
||||
expect(text()).toContain(
|
||||
'Admins can read the log. Only the owner can change how long it is kept.',
|
||||
)
|
||||
expect(text()).not.toContain('Only owners can change it.')
|
||||
const select = document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="retention-select-days"]',
|
||||
)
|
||||
expect(select?.disabled).toBe(true)
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows no Owner only tag to the owner', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).not.toContain('Owner only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, Default with 90 days', () => {
|
||||
@@ -440,7 +453,7 @@ describe('Settings retention, save error reverts the control and Try again retri
|
||||
expect(text()).not.toContain('Events older than')
|
||||
})
|
||||
|
||||
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
|
||||
it('refreshes the session on a 403 and locks the card for a demoted owner', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
@@ -450,13 +463,13 @@ describe('Settings retention, save error reverts the control and Try again retri
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: 'admin',
|
||||
access: accessFor('admin'),
|
||||
})
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(retention()).toBeNull()
|
||||
expect(text()).toContain('Owner only')
|
||||
})
|
||||
|
||||
it('shows no card alert on a 401', async () => {
|
||||
@@ -790,7 +803,7 @@ describe('Settings retention size limit, save errors revert only the size select
|
||||
expect(text()).not.toContain('Events over')
|
||||
})
|
||||
|
||||
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
|
||||
it('refreshes the session on a 403 and locks the card for a demoted owner', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
|
||||
@@ -800,12 +813,12 @@ describe('Settings retention size limit, save errors revert only the size select
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: 'admin',
|
||||
access: accessFor('admin'),
|
||||
})
|
||||
|
||||
await chooseSize(10)
|
||||
|
||||
expect(retention()).toBeNull()
|
||||
expect(text()).toContain('Owner only')
|
||||
})
|
||||
|
||||
it('shows no card alert on a 401', async () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -81,7 +82,7 @@ async function mountSettings(options: MountOptions) {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: options.role,
|
||||
access: accessFor(options.role),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status)
|
||||
@@ -106,7 +107,7 @@ async function mountSettings(options: MountOptions) {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.meAfterRefresh ?? options.role,
|
||||
access: accessFor(options.meAfterRefresh ?? options.role),
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: getMeMock,
|
||||
@@ -530,8 +531,8 @@ describe('Settings, 403 role changed on Update', () => {
|
||||
}),
|
||||
)
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(cardText()).toContain('Only the owner can apply updates.')
|
||||
expect(cardButtons()).toEqual(['Apply update'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -638,10 +639,8 @@ describe('Settings, admin view during a job', () => {
|
||||
await tick(5000)
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
||||
expect(cardText()).toContain(
|
||||
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
|
||||
)
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(cardText()).toContain('Only the owner can apply updates.')
|
||||
expect(cardButtons()).toEqual(['Apply update'])
|
||||
expect(progressbar()).toBeNull()
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
import { accessFor, customAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -49,6 +51,7 @@ let wrapper: VueWrapper | null = null
|
||||
|
||||
interface MountOptions {
|
||||
role: UserRole
|
||||
access?: Access
|
||||
status?: Handler | UpdateStatus
|
||||
check?: Handler | UpdateStatus
|
||||
meAfterRefresh?: UserRole
|
||||
@@ -69,7 +72,7 @@ async function mountSettings(options: MountOptions) {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: options.role,
|
||||
access: options.access ?? accessFor(options.role),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status, () => Promise.resolve(upToDate()))
|
||||
@@ -94,7 +97,7 @@ async function mountSettings(options: MountOptions) {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.meAfterRefresh ?? options.role,
|
||||
access: accessFor(options.meAfterRefresh ?? options.role),
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: getMeMock,
|
||||
@@ -363,10 +366,13 @@ describe('Settings, admin view', () => {
|
||||
await mountSettings({ role: 'admin', status: newerAvailable() })
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
||||
expect(cardText()).toContain(
|
||||
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
|
||||
expect(cardText()).toContain('Only the owner can apply updates.')
|
||||
expect(cardButtons()).toEqual(['Apply update'])
|
||||
expect(cardText()).toContain('Owner only')
|
||||
const apply = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Apply update',
|
||||
)
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(apply?.disabled).toBe(true)
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -383,6 +389,30 @@ describe('Settings, admin view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, Software card follows system.apply_updates', () => {
|
||||
it('disables the Apply action with settings.view alone and shows the Owner only tag', async () => {
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
access: customAccess('settings.view'),
|
||||
status: newerAvailable(),
|
||||
})
|
||||
|
||||
expect(cardText()).toContain('Owner only')
|
||||
expect(updatesCard().querySelector('button')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('offers the check action with system.apply_updates and no Owner only tag', async () => {
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
access: customAccess('settings.view, system.apply_updates'),
|
||||
status: upToDate(),
|
||||
})
|
||||
|
||||
expect(cardText()).not.toContain('Owner only')
|
||||
expect(cardText()).toContain('Check for updates')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, Role changed (403)', () => {
|
||||
it('toasts, reloads the status and renders the admin view', async () => {
|
||||
let reads = 0
|
||||
@@ -408,8 +438,8 @@ describe('Settings, Role changed (403)', () => {
|
||||
)
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(2)
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(cardText()).toContain('Only the owner can apply updates.')
|
||||
expect(cardButtons()).toEqual(['Apply update'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -30,14 +31,14 @@ function deferred<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
|
||||
async function mountPage(settings: () => Promise<unknown>, role: string | undefined = 'owner') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
access: accessFor(role),
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
...(role ? { role } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => {
|
||||
@@ -50,7 +51,12 @@ async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'adm
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
access: accessFor('owner'),
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings')
|
||||
@@ -192,8 +198,10 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
|
||||
it.each([401, 403])('shows no error alert of its own on a %i', async (status) => {
|
||||
await mountPage(() => Promise.reject(new ApiError(status)))
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(tryAgainButton()).toBeUndefined()
|
||||
const alerts = Array.from(document.body.querySelectorAll('[role="alert"]')).filter(
|
||||
(alert) => !alert.closest('[data-testid="retention-card"]'),
|
||||
)
|
||||
expect(alerts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -218,13 +226,21 @@ describe('Publishing card: the owner manages sites from Settings', () => {
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
|
||||
})
|
||||
|
||||
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
|
||||
it('shows the Publishing card, the Manage sites row and the Sites entry to an admin', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
|
||||
|
||||
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Manage sites')
|
||||
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('Manage sites')
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(nav.textContent).not.toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
|
||||
expect(nav.textContent).toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hides the Publishing card and the Sites entry from a role without settings.manage_sites', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'editor')
|
||||
|
||||
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')
|
||||
expect(nav?.querySelector('a[href="/settings/sites"]') ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -104,7 +105,7 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
|
||||
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
role: 'verified',
|
||||
access: accessFor('verified'),
|
||||
})
|
||||
backend.article.authors = []
|
||||
backend.articles = [backend.article]
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
type Handler = () => Response
|
||||
|
||||
@@ -54,6 +55,7 @@ async function mountAt(path: string) {
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { customAccess } from '@/test/access'
|
||||
import {
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
@@ -239,33 +240,28 @@ describe('Sites: set as default', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('closes and shows the owner-only toast on a 403', async () => {
|
||||
it('closes and leaves the generic missing-permission toast to the API client on a 403', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(
|
||||
403,
|
||||
'Only the owner can do this.',
|
||||
)
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(403, 'missing_permission')
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['admin', '/settings/sites'],
|
||||
['member', '/settings/sites'],
|
||||
['admin', '/settings/sites?edit=journal'],
|
||||
['member', '/settings/sites?edit=journal'],
|
||||
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
|
||||
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
|
||||
it('explains instead of redirecting, opens no panel and offers Go to Articles', async () => {
|
||||
const backend = await mountDirectory('sites', path, { role })
|
||||
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toContain("You don't have access to Sites")
|
||||
expect(text).toContain("You don't have access to this page")
|
||||
expect(text).toContain(
|
||||
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
|
||||
"Your role, Writer, doesn't include it. Ask an owner or admin if you need it.",
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(dialog()).toBeNull()
|
||||
@@ -275,9 +271,9 @@ describe.each([
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
|
||||
|
||||
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
await click('Back to Settings')
|
||||
await click('Go to Articles')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/settings')
|
||||
expect(push).toHaveBeenCalledWith('/article')
|
||||
push.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -288,7 +284,7 @@ describe('Sites: the API answers 403 where the SPA expected the owner', () => {
|
||||
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||
expect(document.body.textContent).toContain("You don't have access to this page")
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -300,8 +296,7 @@ describe('Sites: the API answers 403 where the SPA expected the owner', () => {
|
||||
await chooseMenuItem('Delete…')
|
||||
await click('Delete site', dialog()!)
|
||||
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -316,13 +311,45 @@ describe('Sites: the sidebar for the owner and for an admin', () => {
|
||||
expect(link.getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('has no Publishing group and no Sites entry for an admin', async () => {
|
||||
it('shows the Publishing group and the Sites entry for an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(textOf(nav)).toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('has no Publishing group and no Sites entry for a writer', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'member' })
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(textOf(nav)).not.toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables Delete site with the Owner only tag for an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
await openRowMenu('Archive')
|
||||
|
||||
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||
(candidate) => candidate.textContent?.includes('Delete…'),
|
||||
)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.textContent).toContain('Owner only')
|
||||
})
|
||||
|
||||
it('enables Delete site with settings.delete_site', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', {
|
||||
access: customAccess('settings.view, settings.manage_sites, settings.delete_site'),
|
||||
})
|
||||
await openRowMenu('Archive')
|
||||
|
||||
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||
(candidate) => candidate.textContent?.includes('Delete…'),
|
||||
)!
|
||||
expect(item.getAttribute('aria-disabled')).not.toBe('true')
|
||||
expect(item.textContent).not.toContain('Owner only')
|
||||
})
|
||||
})
|
||||
|
||||
void useApi
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { UserListItem } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import UsersView from '../UsersView.vue'
|
||||
import { accessFor, roleRefFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -58,7 +59,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 3,
|
||||
username: 'ines',
|
||||
email: 'ines.duarte@renew.example',
|
||||
role: 'admin',
|
||||
role: roleRefFor('admin'),
|
||||
display_name: 'Inès Duarte',
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
@@ -68,7 +69,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 2,
|
||||
username: 'jonas',
|
||||
email: 'jonas@renew.example',
|
||||
role: 'admin',
|
||||
role: roleRefFor('admin'),
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
@@ -78,7 +79,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
role: 'owner',
|
||||
role: roleRefFor('owner'),
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
@@ -157,7 +158,7 @@ async function mountPage(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: sessionRole,
|
||||
access: accessFor(sessionRole),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, options?: RequestInit) => {
|
||||
@@ -180,7 +181,7 @@ async function mountPage(
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
access: accessFor(sessionRole),
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
@@ -291,8 +292,9 @@ describe.each([
|
||||
{ name: 'below md', desktop: false },
|
||||
])('Non-owners see no row actions ($name)', ({ desktop }) => {
|
||||
it.each([
|
||||
{ role: 'admin', present: false },
|
||||
{ role: 'member', present: false },
|
||||
{ role: null, present: false },
|
||||
{ role: 'admin', present: true },
|
||||
{ role: 'owner', present: true },
|
||||
])('role $role shows the actions: $present', async ({ role, present }) => {
|
||||
await mountPage({}, desktop, role)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { CreatedInvite, UserListItem } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import UsersView from '../UsersView.vue'
|
||||
import { accessFor, roleRefFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -38,6 +39,7 @@ function defaultFixture(): UserListItem[] {
|
||||
kind: 'invite',
|
||||
id: 2,
|
||||
email: 'sam.oliveira@renew.example',
|
||||
role: roleRefFor('admin'),
|
||||
status: 'pending',
|
||||
created_at: '2026-09-27T09:00:00Z',
|
||||
},
|
||||
@@ -46,6 +48,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 1,
|
||||
email: 'p.huber@gmail.com',
|
||||
display_name: 'Paul Huber',
|
||||
role: roleRefFor('admin'),
|
||||
status: 'expired',
|
||||
created_at: '2026-08-03T09:00:00Z',
|
||||
},
|
||||
@@ -54,7 +57,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 3,
|
||||
username: 'ines',
|
||||
email: 'ines.duarte@renew.example',
|
||||
role: 'admin',
|
||||
role: roleRefFor('admin'),
|
||||
display_name: 'Inès Duarte',
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
@@ -64,7 +67,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 2,
|
||||
username: 'jonas',
|
||||
email: 'jonas@renew.example',
|
||||
role: 'admin',
|
||||
role: roleRefFor('admin'),
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
@@ -74,7 +77,7 @@ function defaultFixture(): UserListItem[] {
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
role: 'owner',
|
||||
role: roleRefFor('owner'),
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
@@ -91,6 +94,7 @@ function inviteFixture(email = VALID_EMAIL): CreatedInvite {
|
||||
id: 3,
|
||||
kind: 'invite',
|
||||
email,
|
||||
role: roleRefFor('admin'),
|
||||
status: 'pending',
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
@@ -139,7 +143,7 @@ async function mountPage(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: role ?? undefined,
|
||||
access: accessFor(role ?? undefined),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => {
|
||||
@@ -156,7 +160,7 @@ async function mountPage(
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: role ?? undefined,
|
||||
access: accessFor(role ?? undefined),
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
@@ -368,7 +372,7 @@ describe('Display names in the Users list', () => {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
@@ -436,10 +440,10 @@ describe('Only you — nobody has been invited yet', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-owners cannot invite', () => {
|
||||
describe('Roles without users.invite cannot invite', () => {
|
||||
it.each([
|
||||
{ name: 'admin on desktop', role: 'admin', desktop: true },
|
||||
{ name: 'admin below md', role: 'admin', desktop: false },
|
||||
{ name: 'a writer on desktop', role: 'member', desktop: true },
|
||||
{ name: 'a writer below md', role: 'member', desktop: false },
|
||||
{ name: 'a missing role on desktop', role: null, desktop: true },
|
||||
{ name: 'an unknown role below md', role: 'guest', desktop: false },
|
||||
])('renders the list but no Invite user for $name', async ({ role, desktop }) => {
|
||||
@@ -449,8 +453,8 @@ describe('Non-owners cannot invite', () => {
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('hides the Only-you hint for an admin', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'admin')
|
||||
it('hides the Only-you hint for a writer', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'member')
|
||||
|
||||
expect(rows()).toHaveLength(1)
|
||||
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||
@@ -458,12 +462,12 @@ describe('Non-owners cannot invite', () => {
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'shows no hint for an admin on marta and jonas alone (desktop %s)',
|
||||
'shows no hint for a writer on marta and jonas alone (desktop %s)',
|
||||
async (desktop) => {
|
||||
await mountPage(
|
||||
{ list: () => Promise.resolve(defaultFixture().slice(3)) },
|
||||
desktop,
|
||||
'admin',
|
||||
'member',
|
||||
)
|
||||
|
||||
expect(rows()).toHaveLength(2)
|
||||
|
||||
@@ -44,8 +44,7 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const FORBIDDEN_TEXT =
|
||||
'Only admins can manage image variants. Ask an admin if you need one changed.'
|
||||
const FORBIDDEN_TEXT = "doesn't include it. Ask an owner or admin if you need it."
|
||||
const FOOTER = '6 variants · 1,284 generated files · 212 MB'
|
||||
const SERVER_TEXT =
|
||||
"The server couldn't finish. Try again. If it keeps happening, check the server log."
|
||||
@@ -84,9 +83,9 @@ function statusOf(name: string) {
|
||||
}
|
||||
|
||||
function expectForbidden() {
|
||||
expect(document.body.textContent).toContain("You don't have access")
|
||||
expect(document.body.textContent).toContain("You don't have access to this page")
|
||||
expect(document.body.textContent).toContain(FORBIDDEN_TEXT)
|
||||
expect(findButton('Back to Home')!.disabled).toBe(false)
|
||||
expect((findButton('Go to Home') ?? findButton('Go to Articles'))!.disabled).toBe(false)
|
||||
expect(pageTitle()).toBeNull()
|
||||
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(variantsTable()).toBeUndefined()
|
||||
@@ -118,8 +117,6 @@ describe('Rule: Only admins and owners see the page', () => {
|
||||
expectForbidden()
|
||||
expect(calls(LIST)).toHaveLength(0)
|
||||
expect(iconNames(document.body)).toContain('i-lucide-lock')
|
||||
const back = findButton('Back to Home')!
|
||||
expect(iconOf(back)).toBe('i-lucide-arrow-left')
|
||||
})
|
||||
|
||||
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
|
||||
@@ -131,10 +128,10 @@ describe('Rule: Only admins and owners see the page', () => {
|
||||
expect(calls(LIST)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('Back to Home leaves the page', async () => {
|
||||
it('Go to Home leaves the page', async () => {
|
||||
await mountPage({ role: null })
|
||||
|
||||
await click(findButton('Back to Home'))
|
||||
await click(findButton('Go to Home'))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { customAccess } from '@/test/access'
|
||||
import { findButton, mountPage, stubBrowser, unmountPage } from './variantsTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
stubBrowser()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountPage()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Variants, settings.edit_general', () => {
|
||||
it('shows Add variant and no read-only banner with settings.edit_general', async () => {
|
||||
await mountPage({ access: customAccess('settings.view, settings.edit_general') })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="add-variant"]')).not.toBeNull()
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'You can read these settings but not change them',
|
||||
)
|
||||
expect(findButton('Add variant')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Add variant and shows the read-only banner with settings.view alone', async () => {
|
||||
await mountPage({ access: customAccess('settings.view') })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="add-variant"]')).toBeNull()
|
||||
expect(document.body.textContent).toContain('You can read these settings but not change them')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import VerifyView from '../VerifyView.vue'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
@@ -95,7 +96,8 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
it('shows the success panel, then navigates after 800ms once session.retry resolves', async () => {
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(200),
|
||||
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
|
||||
'GET /v1/auth/me': () =>
|
||||
mockResponse(200, { is_verified: true, access: ownerAccess() }),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Your account is verified')
|
||||
@@ -116,6 +118,7 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
const session = useSessionStore()
|
||||
expect(session.session).toMatchObject({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
displayName: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ActivityView from '../ActivityView.vue'
|
||||
import { activityItems, pageOf, summaryDefault } from './activityFixtures'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
@@ -91,7 +92,7 @@ export async function mountActivity(options: MountOptions = {}) {
|
||||
verified,
|
||||
username: 'ines',
|
||||
displayName: 'Inès Duarte',
|
||||
role: role as 'owner' | undefined,
|
||||
access: accessFor(role),
|
||||
}
|
||||
session.status = 'ready'
|
||||
|
||||
@@ -122,7 +123,7 @@ export async function mountActivity(options: MountOptions = {}) {
|
||||
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified, role }),
|
||||
getMe: vi.fn().mockResolvedValue({ verified, access: accessFor(role) }),
|
||||
request,
|
||||
requestBlob,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import AuthorsView from '../AuthorsView.vue'
|
||||
import SitesView from '../SitesView.vue'
|
||||
import { accessFor, ownerAccess, roleRefFor } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
|
||||
export interface Call {
|
||||
method: string
|
||||
@@ -155,7 +157,7 @@ export function usersFixture() {
|
||||
username,
|
||||
display_name: display,
|
||||
email,
|
||||
role,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
})
|
||||
@@ -329,6 +331,7 @@ export function pending<T>() {
|
||||
|
||||
export interface MountOptions {
|
||||
role?: string
|
||||
access?: Access
|
||||
width?: number
|
||||
backend?: Backend
|
||||
author?: MeAuthor
|
||||
@@ -357,7 +360,7 @@ export async function mountDirectory(
|
||||
id: 1,
|
||||
email: options.email ?? 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
access: options.access ?? accessFor(options.role ?? 'owner'),
|
||||
...(options.author ? { author: options.author } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
@@ -387,7 +390,9 @@ export async function mountDirectory(
|
||||
return { body: result, total: (result as { total: number }).total }
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
getMe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, access: ownerAccess(), username: 'marta', id: 1 }),
|
||||
request,
|
||||
requestPaged,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import MediaView from '../MediaView.vue'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
type Row = [
|
||||
number,
|
||||
@@ -392,7 +394,7 @@ export async function mountPage(
|
||||
handlers: Handlers = {},
|
||||
url = '/media',
|
||||
width = DESKTOP_WIDTH,
|
||||
options: { realApi?: boolean; role?: string; app?: boolean } = {},
|
||||
options: { realApi?: boolean; role?: string; access?: Access; app?: boolean } = {},
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
@@ -402,7 +404,7 @@ export async function mountPage(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
access: options.access ?? accessFor(options.role ?? 'owner'),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
@@ -414,6 +416,7 @@ export async function mountPage(
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
toasts,
|
||||
pending,
|
||||
} from './directoryTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
export { failWith, networkFailure } from './directoryTestKit'
|
||||
|
||||
export type MetaRole = 'owner' | 'admin' | 'verified'
|
||||
@@ -124,7 +125,7 @@ export function createMetaBackend(
|
||||
username: 'marta',
|
||||
email: MARTA_EMAIL,
|
||||
displayName: 'Marta Alves',
|
||||
role: options.role === 'verified' ? undefined : (options.role ?? 'owner'),
|
||||
access: accessFor(options.role ?? 'owner'),
|
||||
...(options.author ? { author: options.author } : {}),
|
||||
},
|
||||
}
|
||||
@@ -289,7 +290,7 @@ export async function mountMeta(
|
||||
id: backend.me.id,
|
||||
email: backend.me.email,
|
||||
displayName: backend.me.displayName,
|
||||
role: backend.me.role,
|
||||
access: backend.me.access,
|
||||
...(backend.me.author ? { author: backend.me.author } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/use
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
export const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
export const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
@@ -196,9 +197,7 @@ function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknow
|
||||
|
||||
export async function mountSettings(
|
||||
mocks: PurgeMocks = {},
|
||||
options: { role?: string | undefined; realApi?: boolean } = {
|
||||
role: 'owner',
|
||||
},
|
||||
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
@@ -209,7 +208,7 @@ export async function mountSettings(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.role as never,
|
||||
access: accessFor(options.role),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = installPurgeApi(mocks)
|
||||
@@ -227,7 +226,7 @@ export async function mountSettings(
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: purgeKit.meRole,
|
||||
access: accessFor(purgeKit.meRole),
|
||||
}))
|
||||
if (options.realApi) {
|
||||
const actual =
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
||||
|
||||
@@ -167,6 +169,7 @@ export interface SchemaMount {
|
||||
entry?: (key: string, id: number) => unknown
|
||||
writeEntry?: (write: EntryWrite) => unknown
|
||||
role?: string
|
||||
access?: Access
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
update?: (body: PostedRequest['body'], key: string) => unknown
|
||||
entries?: (key: string) => unknown
|
||||
@@ -221,7 +224,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
id: 1,
|
||||
email: 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
access: options.access ?? accessFor(options.role ?? 'owner'),
|
||||
}
|
||||
session.status = options.status ?? 'ready'
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
@@ -289,7 +292,11 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
getMe:
|
||||
options.status === 'checking'
|
||||
? vi.fn(() => new Promise(() => undefined))
|
||||
: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
|
||||
: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
}),
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useApi } from '@/composables/useApi'
|
||||
import type { ImageVariant } from '@/composables/useImageVariants'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import VariantsPage from '../VariantsPage.vue'
|
||||
import { accessFor } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
|
||||
export const MB = 1048576
|
||||
export const LIST = 'GET /v1/image-variants'
|
||||
@@ -19,6 +21,7 @@ export type Handler = (body?: Record<string, unknown>) => Promise<unknown>
|
||||
export interface MountOptions {
|
||||
desktop?: boolean
|
||||
role?: string | null
|
||||
access?: Access
|
||||
routes?: Record<string, Handler>
|
||||
}
|
||||
|
||||
@@ -127,7 +130,7 @@ export async function mountPage(options: MountOptions = {}) {
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
role: sessionRole as 'owner' | 'admin' | undefined,
|
||||
access: options.access ?? accessFor(sessionRole),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
@@ -147,7 +150,7 @@ export async function mountPage(options: MountOptions = {}) {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
access: options.access ?? accessFor(sessionRole),
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user