feat: SPA Roles screens
This commit is contained in:
1 parent
907949aa12
commit
3db490a615
47 files changed
+6947
-9
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectiveScope, formatGrantKey, parseGrantKey } from '@/lib/permissions'
|
||||
import { setScope, scopeAt, isCustomRow, resetRow } from '@/lib/roleForm'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
import { baseKey } from '@/lib/permissionNames'
|
||||
|
||||
describe('parseGrantKey and formatGrantKey', () => {
|
||||
it('parses a bare key without a type', () => {
|
||||
expect(parseGrantKey('content.read')).toEqual({ base: 'content.read' })
|
||||
})
|
||||
|
||||
it('parses a typed key', () => {
|
||||
expect(parseGrantKey('content.read:events')).toEqual({
|
||||
base: 'content.read',
|
||||
typeKey: 'events',
|
||||
})
|
||||
})
|
||||
|
||||
it('splits on the first colon only so a type key may contain a colon', () => {
|
||||
expect(parseGrantKey('content.update:ns:case-studies')).toEqual({
|
||||
base: 'content.update',
|
||||
typeKey: 'ns:case-studies',
|
||||
})
|
||||
})
|
||||
|
||||
it('formats and parses back to the same parts', () => {
|
||||
const key = formatGrantKey('content.update', 'ns:case-studies')
|
||||
|
||||
expect(key).toBe('content.update:ns:case-studies')
|
||||
expect(parseGrantKey(key)).toEqual({ base: 'content.update', typeKey: 'ns:case-studies' })
|
||||
expect(formatGrantKey('content.update')).toBe('content.update')
|
||||
})
|
||||
|
||||
it('keeps a colon type key working through the form, locks and names', () => {
|
||||
const grants = setScope({}, 'content.update', 'own', 'ns:a')
|
||||
|
||||
expect(grants).toEqual({ 'content.update:ns:a': 'own' })
|
||||
expect(scopeAt(grants, 'content.update', 'ns:a')).toBe('own')
|
||||
expect(
|
||||
effectiveScope([{ key: 'content.update:ns:a', scope: 'own' }], 'content.update', {
|
||||
typeKey: 'ns:a',
|
||||
}),
|
||||
).toBe('own')
|
||||
expect(isCustomRow(grants, 'ns:a')).toBe(true)
|
||||
expect(resetRow(grants, 'ns:a')).toEqual({})
|
||||
expect(baseKey('content.update:ns:a')).toBe('content.update')
|
||||
const held: string[] = []
|
||||
canCover({ grants: [{ key: 'content.update:ns:a', scope: 'own' }] }, (key, options) => {
|
||||
held.push(`${key}|${options?.typeKey}`)
|
||||
return 'all'
|
||||
})
|
||||
expect(held).toEqual(['content.update|ns:a'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canCover, lockReason, lockedScopes } from '@/lib/grantLocks'
|
||||
import { effectiveScope, type Grant } from '@/lib/permissions'
|
||||
import { grantsFrom } from '@/test/access'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
function scopeFor(spec: string) {
|
||||
const grants: Grant[] = grantsFrom(spec)
|
||||
return (key: string, options?: { typeKey?: string }) => effectiveScope(grants, key, options)
|
||||
}
|
||||
|
||||
function role(spec: string): Pick<RoleItem, 'grants'> {
|
||||
return { grants: grantsFrom(spec) }
|
||||
}
|
||||
|
||||
describe('lockedScopes', () => {
|
||||
it('locks nothing when the caller holds All', () => {
|
||||
expect(lockedScopes('all')).toEqual([])
|
||||
})
|
||||
|
||||
it('locks All when the caller holds only Own', () => {
|
||||
expect(lockedScopes('own')).toEqual(['all'])
|
||||
})
|
||||
|
||||
it('keeps None available and locks Own and All when the caller holds nothing', () => {
|
||||
expect(lockedScopes('none')).toEqual(['own', 'all'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lockReason', () => {
|
||||
it('names the action when the caller holds Own', () => {
|
||||
expect(lockReason('content.publish', 'own')).toBe(
|
||||
"You can only grant what your role has. Your role can publish only entries you created, so All isn't available.",
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the generic reason for non-content keys', () => {
|
||||
expect(lockReason('media.delete', 'own')).toBe('You can only grant what your role has.')
|
||||
})
|
||||
|
||||
it('has no reason when the caller holds All', () => {
|
||||
expect(lockReason('content.publish', 'all')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a typed none in the session locks the matching cell through the scope lookup', () => {
|
||||
const scopeOf = scopeFor('content.update=all, content.update:article=none')
|
||||
|
||||
expect(lockedScopes(scopeOf('content.update', { typeKey: 'article' }))).toEqual([
|
||||
'own',
|
||||
'all',
|
||||
])
|
||||
expect(lockedScopes(scopeOf('content.update', { typeKey: 'events' }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canCover', () => {
|
||||
it('covers a role whose every grant is within the caller scope', () => {
|
||||
expect(
|
||||
canCover(
|
||||
role('content.read=all, content.update=own'),
|
||||
scopeFor('content.read, content.update=all'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a role that grants more than the caller holds', () => {
|
||||
expect(canCover(role('content.publish=all'), scopeFor('content.publish=own'))).toBe(false)
|
||||
expect(canCover(role('content.read'), scopeFor('content.update'))).toBe(false)
|
||||
})
|
||||
|
||||
it('checks typed grants against the caller typed scope', () => {
|
||||
const scopeOf = scopeFor('content.update=all, content.update:article=own')
|
||||
|
||||
expect(canCover(role('content.update:article=all'), scopeOf)).toBe(false)
|
||||
expect(canCover(role('content.update:events=all'), scopeOf)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activePreset,
|
||||
applyPreset,
|
||||
differences,
|
||||
emptyForm,
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
isCustomRow,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
unsavedCount,
|
||||
} from '@/lib/roleForm'
|
||||
|
||||
const CONTRIBUTOR = {
|
||||
name: 'Contributor',
|
||||
description: 'Creates content and edits only their own.',
|
||||
grants: [
|
||||
{ key: 'content.read', scope: 'all' as const },
|
||||
{ key: 'content.create', scope: 'all' as const },
|
||||
{ key: 'content.update', scope: 'own' as const },
|
||||
{ key: 'content.update:case-studies', scope: 'all' as const },
|
||||
],
|
||||
}
|
||||
|
||||
const NO_OWNER_ONLY = new Set<string>()
|
||||
|
||||
describe('formFromRole', () => {
|
||||
it('fills name, description and the grants by key', () => {
|
||||
const form = formFromRole(CONTRIBUTOR)
|
||||
|
||||
expect(form.name).toBe('Contributor')
|
||||
expect(form.description).toBe('Creates content and edits only their own.')
|
||||
expect(form.grants['content.update:case-studies']).toBe('all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeAt', () => {
|
||||
it('reads the typed grant first, then the wildcard, then none', () => {
|
||||
const { grants } = formFromRole(CONTRIBUTOR)
|
||||
|
||||
expect(scopeAt(grants, 'content.update', 'case-studies')).toBe('all')
|
||||
expect(scopeAt(grants, 'content.update', 'events')).toBe('own')
|
||||
expect(scopeAt(grants, 'content.delete', 'events')).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grantsToPayload', () => {
|
||||
it('sends wildcard grants and typed grants, omits none wildcards, keeps typed none', () => {
|
||||
const grants = setScope(
|
||||
setScope(
|
||||
{ 'content.read': 'all', 'content.delete': 'none' },
|
||||
'content.update',
|
||||
'none',
|
||||
'events',
|
||||
),
|
||||
'content.publish',
|
||||
'own',
|
||||
)
|
||||
|
||||
expect(grantsToPayload(grants, NO_OWNER_ONLY)).toEqual([
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.update:events', scope: 'none' },
|
||||
{ key: 'content.publish', scope: 'own' },
|
||||
])
|
||||
})
|
||||
|
||||
it('never sends an owner-only key', () => {
|
||||
const payload = grantsToPayload(
|
||||
{ 'content.read': 'all', 'system.apply_updates': 'all' },
|
||||
new Set(['system.apply_updates']),
|
||||
)
|
||||
|
||||
expect(payload).toEqual([{ key: 'content.read', scope: 'all' }])
|
||||
})
|
||||
|
||||
it('never puts an owner-only key in a custom-role payload', () => {
|
||||
const ownerOnly = [
|
||||
'settings.delete_site',
|
||||
'roles.edit_owner_only',
|
||||
'system.apply_updates',
|
||||
'system.edit_retention',
|
||||
'system.transfer_ownership',
|
||||
]
|
||||
const grants = Object.fromEntries([
|
||||
['content.read', 'all'],
|
||||
...ownerOnly.map((key) => [key, 'all']),
|
||||
])
|
||||
|
||||
const payload = grantsToPayload(grants, new Set(ownerOnly))
|
||||
|
||||
expect(payload).toEqual([{ key: 'content.read', scope: 'all' }])
|
||||
})
|
||||
|
||||
it('sends nothing for a new role with nothing granted', () => {
|
||||
expect(grantsToPayload(emptyForm().grants, NO_OWNER_ONLY)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom rows', () => {
|
||||
it('marks a type with a typed grant as custom and Reset removes its typed grants', () => {
|
||||
const { grants } = formFromRole(CONTRIBUTOR)
|
||||
|
||||
expect(isCustomRow(grants, 'case-studies')).toBe(true)
|
||||
expect(isCustomRow(grants, 'events')).toBe(false)
|
||||
|
||||
const reset = resetRow(grants, 'case-studies')
|
||||
|
||||
expect(isCustomRow(reset, 'case-studies')).toBe(false)
|
||||
expect(scopeAt(reset, 'content.update', 'case-studies')).toBe('own')
|
||||
})
|
||||
|
||||
it('treats a typed none as a custom row', () => {
|
||||
expect(isCustomRow({ 'content.read:events': 'none' }, 'events')).toBe(true)
|
||||
})
|
||||
|
||||
it('setting the All content types row clears the typed grants of that action', () => {
|
||||
const next = setScope({ 'content.update:events': 'all' }, 'content.update', 'own')
|
||||
|
||||
expect(next).toEqual({ 'content.update': 'own' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('presets', () => {
|
||||
it.each([
|
||||
['read_only', ['all', 'none', 'none', 'none', 'none']],
|
||||
['write_own', ['all', 'all', 'own', 'none', 'none']],
|
||||
['full_content', ['all', 'all', 'all', 'all', 'all']],
|
||||
] as const)('%s sets every content row and is then the active preset', (preset, expected) => {
|
||||
const grants = applyPreset({ 'content.update:events': 'none', 'media.view': 'all' }, preset)
|
||||
|
||||
expect(
|
||||
['read', 'create', 'update', 'delete', 'publish'].map((action) =>
|
||||
scopeAt(grants, `content.${action}`, 'events'),
|
||||
),
|
||||
).toEqual(expected)
|
||||
expect(grants['media.view']).toBe('all')
|
||||
expect(activePreset(grants)).toBe(preset)
|
||||
})
|
||||
|
||||
it('has no active preset once a type row is customised', () => {
|
||||
const grants = setScope(applyPreset({}, 'write_own'), 'content.update', 'all', 'events')
|
||||
|
||||
expect(activePreset(grants)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsavedCount', () => {
|
||||
it('counts changed entries (a cleared typed override included), one each for name and description', () => {
|
||||
const initial = formFromRole(CONTRIBUTOR)
|
||||
const current = {
|
||||
...initial,
|
||||
name: 'Contributor 2',
|
||||
grants: setScope(
|
||||
setScope(initial.grants, 'content.update', 'all'),
|
||||
'content.delete',
|
||||
'own',
|
||||
),
|
||||
}
|
||||
|
||||
expect(unsavedCount(initial, initial)).toBe(0)
|
||||
expect(unsavedCount(current, initial)).toBe(4)
|
||||
})
|
||||
|
||||
it('counts a Write own preset on an empty form', () => {
|
||||
const current = { ...emptyForm(), grants: applyPreset({}, 'write_own') }
|
||||
|
||||
expect(unsavedCount(current, emptyForm())).toBe(3)
|
||||
})
|
||||
|
||||
it('Discard returns to zero', () => {
|
||||
const initial = formFromRole(CONTRIBUTOR)
|
||||
|
||||
expect(unsavedCount({ ...initial, grants: { ...initial.grants } }, initial)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('differences', () => {
|
||||
it('lists keys that differ between two forms', () => {
|
||||
const ours = formFromRole(CONTRIBUTOR)
|
||||
const theirs = formFromRole({
|
||||
...CONTRIBUTOR,
|
||||
grants: [{ key: 'content.read', scope: 'all' }],
|
||||
})
|
||||
|
||||
expect(
|
||||
differences(ours, theirs)
|
||||
.map((entry) => entry.key)
|
||||
.sort(),
|
||||
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyPreset, setScope } from '@/lib/roleForm'
|
||||
import { buildRolePreview } from '@/lib/rolePreview'
|
||||
import { setLocale } from '@/i18n'
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'article', name: 'Articles' },
|
||||
{ key: 'events', name: 'Events' },
|
||||
{ key: 'case-studies', name: 'Case studies' },
|
||||
]
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('buildRolePreview content', () => {
|
||||
it('reads the Contributor sentence from the Write own grants', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: applyPreset({}, 'write_own'),
|
||||
types: TYPES,
|
||||
members: 4,
|
||||
})
|
||||
|
||||
expect(preview.content).toEqual([
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All content types',
|
||||
sentence:
|
||||
"Can read any entry, create entries and edit only their own. Can't delete and publish.",
|
||||
changed: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('says No access to entries when nothing is granted', () => {
|
||||
const preview = buildRolePreview({ grants: {}, types: TYPES })
|
||||
|
||||
expect(preview.content[0]).toMatchObject({
|
||||
label: 'All content types',
|
||||
sentence: 'No access to entries.',
|
||||
})
|
||||
expect(preview.noAccess).toBe(
|
||||
'No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.',
|
||||
)
|
||||
expect(preview.everything).toEqual([])
|
||||
})
|
||||
|
||||
it('writes one line per group of types that share permissions', () => {
|
||||
const grants = setScope(applyPreset({}, 'write_own'), 'content.update', 'all', 'events')
|
||||
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => line.label)).toEqual([
|
||||
'Articles, Case studies',
|
||||
'Events',
|
||||
])
|
||||
expect(preview.content[1]!.sentence).toContain('edit any entry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview everything else', () => {
|
||||
it('reads the Schema, Media and Authors sentences and lists the areas without access', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: {
|
||||
...applyPreset({}, 'write_own'),
|
||||
'schema.view': 'all',
|
||||
'media.view': 'all',
|
||||
'media.upload': 'all',
|
||||
'authors.view': 'all',
|
||||
'authors.edit': 'own',
|
||||
},
|
||||
types: TYPES,
|
||||
})
|
||||
|
||||
expect(preview.everything.map((line) => [line.label, line.sentence])).toEqual([
|
||||
['Schema', 'Can see content types and fields.'],
|
||||
['Media', 'Can browse the media library and upload files.'],
|
||||
['Authors', 'Can see authors and edit authors, only their own.'],
|
||||
])
|
||||
expect(preview.noAccess).toBe('No access to Activity, Users and invites, Settings, Roles.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview own scope without an own wording', () => {
|
||||
it('never renders the all wording for an own grant on a phrase without an own variant', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: { 'users.view': 'own', 'users.invite': 'all', 'media.delete': 'own' },
|
||||
types: TYPES,
|
||||
})
|
||||
|
||||
const all = [...preview.everything.map((line) => line.sentence), preview.noAccess ?? '']
|
||||
const text = all.join(' ')
|
||||
expect(text).not.toContain('see users')
|
||||
expect(text).toContain('invite users')
|
||||
expect(preview.everything.find((line) => line.id === 'media')?.sentence).toContain(
|
||||
'only their own',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview Changed tagging and members', () => {
|
||||
it('tags the line whose sentence differs from the initial grants', () => {
|
||||
const initial = applyPreset({}, 'write_own')
|
||||
const grants = setScope(initial, 'content.update', 'all')
|
||||
|
||||
const preview = buildRolePreview({ grants, types: TYPES, initial, members: 4 })
|
||||
|
||||
expect(preview.content[0]!.changed).toBe(true)
|
||||
expect(preview.content[0]!.sentence).toContain('edit any entry')
|
||||
})
|
||||
|
||||
it('tags nothing when the grants equal the initial ones', () => {
|
||||
const initial = applyPreset({}, 'write_own')
|
||||
|
||||
expect(
|
||||
buildRolePreview({ grants: initial, types: TYPES, initial }).content[0]!.changed,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('tags nothing without initial grants', () => {
|
||||
expect(
|
||||
buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES }).content[0]!
|
||||
.changed,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('states the member count, in the singular for one', () => {
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 4 }).members).toBe(
|
||||
'Applies to 4 members, and to the API keys they created.',
|
||||
)
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 1 }).members).toBe(
|
||||
'Applies to 1 member, and to the API keys they created.',
|
||||
)
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user