refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types

This commit is contained in:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
1 parent 5d6f378fce
commit b39a41be88
124 files changed
+8816 -523

No files matched your search

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
describe('activity categories', () => {
@@ -9,3 +10,49 @@ describe('activity categories', () => {
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
})
})
describe('activity category names', () => {
afterEach(() => setLocale('en'))
it('lists the categories in the design order', () => {
expect([...ACTIVITY_CATEGORIES]).toEqual([
'articles',
'media',
'users',
'roles',
'api_keys',
'auth',
'system',
])
})
it('names them in English', () => {
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
'Articles',
'Media',
'Users and invites',
'Roles',
'API keys',
'Authentication',
'System',
])
})
it('names them in French', () => {
setLocale('fr')
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
'Articles',
'Médias',
'Utilisateurs et invitations',
'Rôles',
'Clés d’API',
'Authentification',
'Système',
])
})
it('never writes "Users & invites"', () => {
expect(ACTIVITY_CATEGORIES.map(categoryLabel).join()).not.toContain('&')
})
})
@@ -291,6 +291,7 @@ describe('The catalogue is complete in both languages', () => {
'role.reassign',
'user.change_role',
'user.transfer_ownership',
'user.remove',
].sort(),
)
})
@@ -380,3 +381,188 @@ describe('role and ownership sentences', () => {
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
})
})
describe('Roles sync: the six role events in EN and FR', () => {
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
const roleChange = {
field: 'role',
kind: 'text' as const,
before: 'Writer',
after: 'Content lead',
}
function event(overrides: Partial<ActivityItem>) {
return item({ category: 'roles', target: roleTarget('Intern'), ...overrides })
}
function both(source: ActivityItem) {
setLocale('en')
const english = sentenceText(source)
setLocale('fr')
const french = sentenceText(source)
return [english, french]
}
it.each([
['role.create', 'Contributor', 'Role Contributor created', 'Rôle Contributor créé'],
['role.edit', 'Contributor', 'Role Contributor edited', 'Rôle Contributor modifié'],
['role.delete', 'Intern', 'Role Intern deleted', 'Rôle Intern supprimé'],
])('success %s', (action, name, english, french) => {
expect(both(event({ action, target: roleTarget(name) }))).toEqual([english, french])
})
it.each([
[3, 'Moved 3 members from Intern to Writer', '3 membres déplacés de Intern vers Writer'],
[1, 'Moved 1 member from Intern to Writer', '1 membre déplacé de Intern vers Writer'],
])('success role.reassign with moved %s', (moved, english, french) => {
const source = event({
action: 'role.reassign',
params: { from: 4, to: 3, moved, to_name: 'Writer' },
})
expect(both(source)).toEqual([english, french])
setLocale('en')
expect(composeSentence(source).find((segment) => segment.strong)?.text).toBe('Intern')
})
it('success user.change_role', () => {
const source = event({
action: 'user.change_role',
category: 'users',
target: userTarget('Ines Duarte'),
changes: [roleChange],
})
expect(both(source)).toEqual([
"Ines Duarte's role changed from Writer to Content lead",
"Le rôle d'Ines Duarte est passé de Writer à Content lead",
])
})
it('success user.transfer_ownership', () => {
const source = event({
action: 'user.transfer_ownership',
category: 'users',
target: userTarget('Jonas Weber'),
})
expect(both(source)).toEqual([
'Ownership transferred from Marta Alves to Jonas Weber',
'Propriété transférée de Marta Alves à Jonas Weber',
])
})
it.each([
[
'role.create',
'Contributor',
'Failed to create the role Contributor',
'Échec de la création du rôle Contributor',
],
[
'role.edit',
'Contributor',
'Failed to edit the role Contributor',
'Échec de la modification du rôle Contributor',
],
[
'role.delete',
'Intern',
'Failed to delete the role Intern',
'Échec de la suppression du rôle Intern',
],
])('failed %s', (action, name, english, french) => {
expect(both(event({ action, outcome: 'failed', target: roleTarget(name) }))).toEqual([
english,
french,
])
})
it('failed role.reassign with the count and the destination', () => {
const source = event({
action: 'role.reassign',
outcome: 'failed',
params: { moved: 3, to_name: 'Writer' },
})
expect(both(source)).toEqual([
'Failed to move 3 members from Intern to Writer',
'Échec du déplacement de 3 membres de Intern vers Writer',
])
})
it('failed role.reassign for one member', () => {
const source = event({
action: 'role.reassign',
outcome: 'failed',
params: { moved: 1, to_name: 'Writer' },
})
expect(both(source)[0]).toBe('Failed to move 1 member from Intern to Writer')
})
it('failed user.change_role and user.transfer_ownership', () => {
expect(
both(
event({
action: 'user.change_role',
category: 'users',
outcome: 'failed',
target: userTarget('Ines Duarte'),
}),
),
).toEqual([
'Failed to change the role of Ines Duarte',
"Échec du changement de rôle d'Ines Duarte",
])
expect(
both(
event({
action: 'user.transfer_ownership',
category: 'users',
outcome: 'failed',
target: userTarget('Jonas Weber'),
}),
),
).toEqual([
'Failed to transfer ownership to Jonas Weber',
'Échec du transfert de la propriété à Jonas Weber',
])
})
it('writes user.remove with the success and the failed sentence', () => {
const target = userTarget('Ines Duarte')
const removed = event({ action: 'user.remove', category: 'users', target })
expect(both(removed)).toEqual(['Removed Ines Duarte', 'Ines Duarte retiré'])
expect(both({ ...removed, outcome: 'failed' })).toEqual([
'Failed to remove Ines Duarte',
"Échec du retrait d'Ines Duarte",
])
})
it.each([
['success', { moved: 3 }, 'Moved 3 members from Intern to another role'],
['success', { moved: 1 }, 'Moved 1 member from Intern to another role'],
['success', { to_name: 'Writer' }, 'Moved the members of the role Intern'],
['failed', {}, 'Failed to move the members of the role Intern'],
] as const)('role.reassign %s without all params: %j', (outcome, params, sentence) => {
const text = sentenceText(event({ action: 'role.reassign', outcome, params }))
expect(text).toBe(sentence)
expect(text).not.toContain('undefined')
})
it('ignores an empty destination name', () => {
const text = sentenceText(
event({ action: 'role.reassign', params: { moved: 2, to_name: ' ' } }),
)
expect(text).toBe('Moved 2 members from Intern to another role')
})
it('renders an unknown action as its name', () => {
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
})
})
+39
View File
@@ -251,6 +251,45 @@ describe('custom content types', () => {
})
})
describe('roles sync: permission-gated routes', () => {
const GATED = [
'POST /v1/content-type/',
'PUT /v1/content-type/{key}',
'DELETE /v1/content-type/{key}',
'GET /v1/builtin-type/',
'GET /v1/builtin-type/{key}',
'GET /v1/media/purge-settings',
'PATCH /v1/media/purge-settings',
'GET /v1/media/purge-preview',
'POST /v1/media/purge',
]
it('matches "permission" on the nine routes through the access chip and the description', () => {
const found = routes(search('permission').endpoints)
for (const route of GATED) {
expect(found).toContain(route)
}
})
it('matches "missing_permission" on the error code of the gated routes', () => {
const found = search('missing_permission').endpoints
for (const route of GATED) {
expect(routes(found)).toContain(route)
}
expect(found.find((hit) => routes([hit])[0] === GATED[0])!.via!.kind).toBe('error')
})
it('no longer matches "owner or admin" on the nine routes', () => {
const found = routes(search('owner or admin').endpoints)
for (const route of GATED) {
expect(found).not.toContain(route)
}
})
})
describe('suggestions', () => {
it('lists the five suggested rows in order', () => {
expect(routes(suggestedHits(index))).toEqual([
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { elideDe } from '@/lib/frenchElision'
afterEach(() => setLocale('en'))
describe('elideDe', () => {
it('elides before a vowel in French', () => {
setLocale('fr')
expect(elideDe('Le rôle de Ines Duarte a changé', 'Ines Duarte')).toBe(
"Le rôle d'Ines Duarte a changé",
)
})
it('keeps de before a consonant', () => {
setLocale('fr')
expect(elideDe('Le rôle de Jonas Weber a changé', 'Jonas Weber')).toBe(
'Le rôle de Jonas Weber a changé',
)
})
it('leaves English untouched', () => {
expect(elideDe("Ines Duarte's role", 'Ines Duarte')).toBe("Ines Duarte's role")
})
})
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
entryNounOf,
inverseNoun,
inverseSide,
kindFromInverseSide,
@@ -87,3 +88,14 @@ describe('the preview', () => {
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
})
})
describe('the entry noun of a type name', () => {
it.each([
['Events', 'an event', 'events'],
['Recipes', 'a recipe', 'recipes'],
['Press mentions', 'a press mention', 'press mentions'],
['Categories', 'a category', 'categories'],
])('turns %s into %s', (name, singular, plural) => {
expect(entryNounOf(name)).toEqual({ singular, plural })
})
})
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { roleDiffRows } from '@/lib/roleDiff'
import type { RoleForm } from '@/lib/roleForm'
const TYPES = [
{ key: 'article', name: 'Articles' },
{ key: 'events', name: 'Events' },
]
const CONTEXT = {
types: TYPES,
toggles: new Set(['activity.export']),
order: ['activity.view', 'activity.export'],
}
function form(grants: RoleForm['grants'], name = 'Contributor', description = ''): RoleForm {
return { name, description, grants }
}
afterEach(() => setLocale('en'))
describe('roleDiffRows', () => {
it('lists only the settings where the saved role and the edits disagree', () => {
const saved = form({ 'content.read': 'all', 'content.update:events': 'own' })
const yours = form({
'content.read': 'all',
'content.update:events': 'all',
'content.delete:events': 'own',
})
expect(
roleDiffRows(saved, yours, CONTEXT).map(({ setting, saved, yours }) => [
setting,
saved,
yours,
]),
).toEqual([
['Events · Update', 'Own', 'All'],
['Events · Delete', 'None', 'Own'],
])
})
it('shows nothing when an override equals the inherited value', () => {
const saved = form({ 'content.read': 'all' })
const yours = form({ 'content.read': 'all', 'content.read:events': 'all' })
expect(roleDiffRows(saved, yours, CONTEXT)).toEqual([])
})
it('shows the Create switch as On and Off', () => {
const rows = roleDiffRows(form({}), form({ 'content.create:events': 'all' }), CONTEXT)
expect(rows).toEqual([
{ id: 'content.create:events', setting: 'Events · Create', saved: 'Off', yours: 'On' },
])
})
it('names general permissions and keeps the catalogue order', () => {
const rows = roleDiffRows(
form({ 'activity.export': 'all' }),
form({ 'activity.view': 'own', 'activity.export': 'none' }),
CONTEXT,
)
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
['See activity', 'None', 'Own'],
['Export activity', 'On', 'Off'],
])
})
it('lists a changed name or description first', () => {
const rows = roleDiffRows(
form({}, 'Contributor', 'Old'),
form({}, 'Writer', 'New'),
CONTEXT,
)
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
['Name', 'Contributor', 'Writer'],
['Description', 'Old', 'New'],
])
})
it('falls back to one row per action when there are no types', () => {
const rows = roleDiffRows(form({}), form({ 'content.read': 'all' }), {
...CONTEXT,
types: [],
})
expect(rows.map((row) => row.setting)).toEqual(['All content types · Read'])
})
it('translates the scopes', () => {
setLocale('fr')
const rows = roleDiffRows(form({}), form({ 'content.update:events': 'own' }), CONTEXT)
expect(rows[0]).toMatchObject({ saved: 'Aucune', yours: 'Les siennes' })
})
})
+44
View File
@@ -7,6 +7,7 @@ import {
formFromRole,
grantsToPayload,
isCustomRow,
reapplyEdits,
resetRow,
scopeAt,
setScope,
@@ -191,3 +192,46 @@ describe('differences', () => {
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
})
})
describe('reapplyEdits', () => {
const initial = {
name: 'Contributor',
description: '',
grants: { 'content.read': 'all' } as const,
}
it('puts the edited cells back on top of the new saved role', () => {
const base = {
name: 'Contributor',
description: 'Saved',
grants: { 'content.read': 'own' } as const,
}
const edited = {
name: 'Contributor',
description: '',
grants: { 'content.read': 'all', 'content.delete:events': 'own' } as const,
}
const merged = reapplyEdits(base, initial, edited)
expect(merged).toEqual({
name: 'Contributor',
description: 'Saved',
grants: { 'content.read': 'own', 'content.delete:events': 'own' },
})
})
it('keeps an edited name and removes a cell the person reset', () => {
const withOverride = {
...initial,
grants: { ...initial.grants, 'content.update:events': 'own' } as const,
}
const base = { name: 'Contributor', description: '', grants: { ...withOverride.grants } }
const edited = { ...initial, name: 'Writer', grants: { 'content.read': 'all' } as const }
const merged = reapplyEdits(base, withOverride, edited)
expect(merged.name).toBe('Writer')
expect(merged.grants).toEqual({ 'content.read': 'all' })
})
})
+65 -1
View File
@@ -49,7 +49,7 @@ describe('buildRolePreview content', () => {
const preview = buildRolePreview({ grants, types: TYPES })
expect(preview.content.map((line) => line.label)).toEqual([
'Articles, Case studies',
'Articles and Case studies',
'Events',
])
expect(preview.content[1]!.sentence).toContain('edit any entry')
@@ -132,3 +132,67 @@ describe('buildRolePreview Changed tagging and members', () => {
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
})
})
describe('buildRolePreview Read = Own', () => {
const readOwn = {
'content.read': 'own',
'content.create': 'all',
'content.update': 'own',
} as const
it('writes one line per kind of type when Read is Own', () => {
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
expect(preview.content.map((line) => [line.label, line.sentence])).toEqual([
[
'Articles',
"Can read online articles and their own drafts, create entries and edit only their own. Can't delete and publish.",
],
[
'Events and Case studies',
"Can read only entries they created, create entries and edit only their own. Can't delete and publish.",
],
])
})
it('uses the custom type sentence for a custom type override', () => {
const grants = setScope({ 'content.read': 'all' }, 'content.read', 'own', 'events')
const preview = buildRolePreview({ grants, types: TYPES })
const events = preview.content.find((line) => line.label === 'Events')
expect(events?.sentence).toContain('read only entries they created')
})
it('uses the articles sentence alone when only Articles exists', () => {
const preview = buildRolePreview({
grants: { ...readOwn },
types: [{ key: 'article', name: 'Articles' }],
})
expect(preview.content).toHaveLength(1)
expect(preview.content[0]).toMatchObject({ label: 'All content types' })
expect(preview.content[0]!.sentence).toContain('read online articles and their own drafts')
})
it('never says read only their own', () => {
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
expect(JSON.stringify(preview)).not.toContain('read only their own')
})
it('keeps the Read All wording', () => {
const preview = buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES })
expect(preview.content[0]!.sentence).toContain('Can read any entry')
})
it('translates both sentences', () => {
setLocale('fr')
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
expect(preview.content[0]!.sentence).toContain('lire les articles en ligne')
expect(preview.content[1]!.sentence).toContain('créées par la personne')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setLocale } from '@/i18n'
import { userMenuItems, type UserMenuContext } from '@/lib/userMenu'
import type { AcceptedUser } from '@/lib/users'
import { roleRefFor } from '@/test/access'
function accepted(id: number, role: string): AcceptedUser {
return {
kind: 'user',
id,
username: `user${id}`,
display_name: `User ${id}`,
email: `user${id}@renew.example`,
role: roleRefFor(role),
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
}
}
function context(overrides: Partial<UserMenuContext> = {}): UserMenuContext {
return {
sessionUserId: 1,
canChangeRole: true,
canTransfer: false,
canViewRoles: true,
canRemove: true,
canCover: () => true,
changeRole: vi.fn(),
transfer: vi.fn(),
removeUser: vi.fn(),
...overrides,
}
}
afterEach(() => setLocale('en'))
function remove(user: AcceptedUser, overrides: Partial<UserMenuContext> = {}) {
return userMenuItems(user, context(overrides)).find((item) => item.key === 'remove-user')
}
describe('Remove user in the row menu', () => {
it('is enabled below Change role for an accepted user the person can cover', () => {
const items = userMenuItems(accepted(2, 'editor'), context())
expect(items.map((item) => item.key)).toEqual(['change-role', 'remove-user'])
expect(items[1]).toMatchObject({
label: 'Remove user',
icon: 'i-lucide-user-x',
destructive: true,
unavailable: false,
})
})
it('is the only item for a person who can remove but not change roles', () => {
const items = userMenuItems(accepted(2, 'editor'), context({ canChangeRole: false }))
expect(items.map((item) => item.key)).toEqual(['remove-user'])
})
it('is absent without the permission', () => {
expect(remove(accepted(2, 'editor'), { canRemove: false })).toBeUndefined()
})
it('is disabled on the owner row with the owner reason', () => {
expect(remove(accepted(3, 'owner'))).toMatchObject({
unavailable: true,
hint: "The owner can't be changed or removed. Transfer ownership first.",
})
})
it('is disabled on the row marked you', () => {
const item = remove(accepted(1, 'admin'))
expect(item?.unavailable).toBe(true)
expect(item?.hint).toBe("You can't remove yourself. Ask another owner or admin.")
})
it('is disabled with the escalation reason when the role cannot be covered', () => {
const item = remove(accepted(2, 'admin'), { canCover: () => false })
expect(item?.unavailable).toBe(true)
expect(item?.hint).toBe("Your role can't assign a role with more power than yours.")
})
it('selects through the context', () => {
const removeUser = vi.fn()
const user = accepted(2, 'editor')
remove(user, { removeUser })!.onSelect(null)
expect(removeUser).toHaveBeenCalledWith(user, null)
})
it('is absent for a user without a role', () => {
const user = { ...accepted(2, 'editor'), role: null }
expect(remove(user)).toBeUndefined()
})
it('reads in French', () => {
setLocale('fr')
expect(remove(accepted(2, 'editor'))?.label).toBe('Retirer l’utilisateur')
expect(remove(accepted(1, 'admin'))?.hint).toBe(
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
)
})
})