Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions
6b4868b261 chore: bump version to v0.1.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 58s
2026-04-17 12:34:44 +00:00
e8c2789435 RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé

Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.

### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)

### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions

### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)

## Tickets Lesstime

- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur

## Test plan

- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: MALIO-DEV/Coltura#7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-04-17 12:34:38 +00:00
17 changed files with 161 additions and 212 deletions

View File

@@ -8,6 +8,8 @@ declare(strict_types=1);
* This file defines the sidebar sections displayed in the frontend.
* Each item references the module that owns it via the `module` key.
* Items whose module is not active (see config/modules.php) are filtered out.
* Items may also declare a `permission` key (RBAC permission code) : the item
* is hidden from users who do not hold that permission.
*
* This config is decoupled from the modules themselves: you can freely
* move an item from one section to another without touching the module code.
@@ -33,16 +35,18 @@ return [
'module' => 'core',
],
[
'label' => 'sidebar.core.roles',
'to' => '/admin/roles',
'icon' => 'mdi:shield-account-outline',
'module' => 'core',
'label' => 'sidebar.core.roles',
'to' => '/admin/roles',
'icon' => 'mdi:shield-account-outline',
'module' => 'core',
'permission' => 'core.roles.view',
],
[
'label' => 'sidebar.core.users',
'to' => '/admin/users',
'icon' => 'mdi:account-group-outline',
'module' => 'core',
'label' => 'sidebar.core.users',
'to' => '/admin/users',
'icon' => 'mdi:account-group-outline',
'module' => 'core',
'permission' => 'core.users.view',
],
[
'label' => 'sidebar.general.logout',

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.29'
app.version: '0.1.31'

View File

@@ -24,7 +24,7 @@
"suppliers": "Répertoire fournisseurs"
},
"core": {
"roles": "Gestion des roles",
"roles": "Gestion des rôles",
"users": "Utilisateurs"
}
},
@@ -63,33 +63,32 @@
},
"admin": {
"roles": {
"title": "Gestion des roles",
"newRole": "Nouveau role",
"editRole": "Modifier le role",
"createRole": "Creer un role",
"noRoles": "Aucun role configure",
"title": "Gestion des rôles",
"newRole": "Nouveau rôle",
"editRole": "Modifier le rôle",
"createRole": "Créer un rôle",
"noRoles": "Aucun rôle configuré",
"table": {
"label": "Libelle",
"label": "Libellé",
"code": "Code",
"permissions": "Permissions",
"system": "Systeme",
"actions": "Actions"
"system": "Système"
},
"form": {
"label": "Libelle",
"label": "Libellé",
"code": "Code",
"description": "Description",
"permissions": "Permissions"
},
"delete": {
"title": "Supprimer le role",
"message": "Etes-vous sur de vouloir supprimer le role \"{label}\" ? Cette action est irreversible.",
"systemTooltip": "Role systeme non supprimable"
"title": "Supprimer le rôle",
"message": "Êtes-vous sûr de vouloir supprimer le rôle \"{label}\" ? Cette action est irréversible.",
"systemTooltip": "Rôle système non supprimable"
},
"toast": {
"created": "Role cree avec succes",
"updated": "Role mis a jour avec succes",
"deleted": "Role supprime avec succes"
"created": "Rôle créé avec succès",
"updated": "Rôle mis à jour avec succès",
"deleted": "Rôle supprimé avec succès"
},
"permissions": {
"selectAll": "Tout selectionner",
@@ -103,23 +102,22 @@
"username": "Nom d'utilisateur",
"admin": "Administrateur",
"roles": "Roles",
"directPermissions": "Permissions directes",
"actions": "Actions"
"directPermissions": "Permissions directes"
},
"drawer": {
"title": "Permissions de {username}",
"selfWarning": "Vous modifiez vos propres droits",
"adminToggle": "Administrateur (bypass total)",
"rolesSection": "Roles",
"rolesSection": "Rôles",
"directPermissionsSection": "Permissions directes",
"summarySection": "Resume des permissions effectives",
"summarySection": "Résumé des permissions effectives",
"noEffectivePermissions": "Aucune permission effective",
"sourceRole": "via {role}",
"sourceDirect": "Direct",
"lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin"
},
"toast": {
"updated": "Permissions mises a jour avec succes"
"updated": "Permissions mises à jour avec succès"
}
}
}

View File

@@ -40,14 +40,9 @@
</template>
<script setup lang="ts">
const { t } = useI18n()
import type { EffectivePermission } from '~/shared/types/rbac'
interface EffectivePermission {
code: string
label: string
module: string
sources: string[]
}
const { t } = useI18n()
const props = defineProps<{
permissions: EffectivePermission[]

View File

@@ -30,13 +30,7 @@
</template>
<script setup lang="ts">
interface Permission {
id: number
code: string
label: string
module: string
orphan: boolean
}
import type { Permission } from '~/shared/types/rbac'
const props = defineProps<{
module: string

View File

@@ -22,6 +22,8 @@
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="confirm"
/>
@@ -55,6 +57,14 @@ function cancel() {
function confirm() {
emit('confirm')
}
// Fermer la modale avec la touche Escape
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<style scoped>

View File

@@ -19,8 +19,7 @@
:label="t('admin.roles.form.code')"
input-class="w-full"
required
:readonly="isEditMode && role?.isSystem"
:hint="isEditMode && role?.isSystem ? t('admin.roles.delete.systemTooltip') : ''"
:readonly="isEditMode"
/>
<MalioInputTextArea
@@ -54,8 +53,18 @@
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="secondary"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
@@ -70,22 +79,7 @@
</template>
<script setup lang="ts">
interface Permission {
id: number
code: string
label: string
module: string
orphan: boolean
}
interface Role {
id: number
code: string
label: string
description: string | null
isSystem: boolean
permissions: (Permission | string)[]
}
import type { Permission, Role } from '~/shared/types/rbac'
interface PermissionModule {
module: string
@@ -103,6 +97,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
const saving = ref(false)
@@ -136,7 +131,7 @@ const permissionsByModule = computed<PermissionModule[]>(() => {
async function loadPermissions() {
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ 'orphan': false, itemsPerPage: 200 },
{ 'orphan': false, itemsPerPage: 999 },
{ toast: false },
)
allPermissions.value = data.member
@@ -198,19 +193,24 @@ function handleToggleAll(module: string, selected: boolean) {
async function handleSave() {
saving.value = true
try {
const body = {
label: form.value.label,
code: form.value.code,
description: form.value.description || null,
permissions: Array.from(selectedPermissionIds.value).map(id => `/api/permissions/${id}`),
}
const permissions = Array.from(selectedPermissionIds.value).map(id => `/api/permissions/${id}`)
if (isEditMode.value && props.role) {
await api.patch(`/roles/${props.role.id}`, body, {
// Le code est immuable apres creation (garde backend RoleProcessor)
await api.patch(`/roles/${props.role.id}`, {
label: form.value.label,
description: form.value.description || null,
permissions,
}, {
toastSuccessMessage: t('admin.roles.toast.updated'),
})
} else {
await api.post('/roles', body, {
await api.post('/roles', {
label: form.value.label,
code: form.value.code,
description: form.value.description || null,
permissions,
}, {
toastSuccessMessage: t('admin.roles.toast.created'),
})
}

View File

@@ -76,7 +76,7 @@
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
:label="t('common.cancel')"
variant="secondary"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
@@ -91,37 +91,7 @@
</template>
<script setup lang="ts">
interface Permission {
id: number
code: string
label: string
module: string
orphan: boolean
}
interface Role {
id: number
code: string
label: string
description: string | null
isSystem: boolean
permissions: (Permission | string)[]
}
interface UserListItem {
id: number
username: string
isAdmin: boolean
roles: string[]
directPermissions: string[]
}
interface EffectivePermission {
code: string
label: string
module: string
sources: string[]
}
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
interface PermissionModule {
module: string
@@ -216,7 +186,7 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
async function loadData() {
const [rolesData, permsData] = await Promise.all([
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 200 }, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
@@ -276,6 +246,10 @@ async function handleSave() {
}, {
toastSuccessMessage: t('admin.users.toast.updated'),
})
// Rafraichir les donnees du user courant si auto-edition
if (isSelfEdit.value) {
await auth.refreshUser()
}
emit('saved')
emit('update:modelValue', false)
} finally {

View File

@@ -9,6 +9,7 @@
v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
@@ -19,7 +20,7 @@
:columns="columns"
:items="roleItems"
:total-items="roles.length"
:row-clickable="true"
:row-clickable="canManage"
:empty-message="t('admin.roles.noRoles')"
@row-click="onRowClick"
>
@@ -37,25 +38,6 @@
{{ t('admin.roles.table.system') }}
</span>
</template>
<template #cell-actions="{ item }">
<div class="flex items-center justify-end gap-2" @click.stop>
<MalioButtonIcon
v-if="can('core.roles.manage')"
icon="mdi:pencil-outline"
:aria-label="t('common.edit')"
variant="ghost"
@click="openEditDrawer(getRoleById(item.id as number)!)"
/>
<MalioButtonIcon
v-if="can('core.roles.manage')"
icon="mdi:delete-outline"
:aria-label="t('common.delete')"
variant="ghost"
:disabled="item.isSystem as boolean"
@click="confirmDelete(getRoleById(item.id as number)!)"
/>
</div>
</template>
</MalioDataTable>
<!-- Drawer creation/edition -->
@@ -63,6 +45,7 @@
v-model="drawerOpen"
:role="selectedRole"
@saved="onRoleSaved"
@delete="onDeleteRequest"
/>
<!-- Modale de suppression -->
@@ -76,26 +59,12 @@
</template>
<script setup lang="ts">
interface Permission {
id: number
code: string
label: string
module: string
orphan: boolean
}
interface Role {
id: number
code: string
label: string
description: string | null
isSystem: boolean
permissions: (Permission | string)[]
}
import type { Role } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
@@ -107,7 +76,6 @@ const columns = [
{ key: 'code', label: t('admin.roles.table.code') },
{ key: 'permissions', label: t('admin.roles.table.permissions') },
{ key: 'system', label: t('admin.roles.table.system') },
{ key: 'actions', label: t('admin.roles.table.actions') },
]
// Transformer les roles en items compatibles MalioDataTable
@@ -119,7 +87,6 @@ const roleItems = computed(() =>
permissions: role.permissions.length,
isSystem: role.isSystem,
system: '', // colonne geree par le slot
actions: '', // colonne geree par le slot
}))
)
@@ -162,9 +129,9 @@ function openEditDrawer(role: Role) {
drawerOpen.value = true
}
function confirmDelete(role: Role) {
if (role.isSystem) return
roleToDelete.value = role
function onDeleteRequest() {
if (!selectedRole.value || selectedRole.value.isSystem) return
roleToDelete.value = selectedRole.value
deleteModalOpen.value = true
}
@@ -177,6 +144,7 @@ async function handleDelete() {
})
deleteModalOpen.value = false
roleToDelete.value = null
drawerOpen.value = false
await loadRoles()
} finally {
deleting.value = false

View File

@@ -25,17 +25,6 @@
{{ t('admin.users.table.admin') }}
</span>
</template>
<template #cell-actions="{ item }">
<div class="flex items-center justify-end gap-2" @click.stop>
<MalioButtonIcon
v-if="canManage"
icon="mdi:shield-edit-outline"
:aria-label="t('common.edit')"
variant="ghost"
@click="openDrawer(getUserById(item.id as number)!)"
/>
</div>
</template>
</MalioDataTable>
<!-- Drawer RBAC -->
@@ -48,13 +37,7 @@
</template>
<script setup lang="ts">
interface UserListItem {
id: number
username: string
isAdmin: boolean
roles: string[]
directPermissions: string[]
}
import type { UserListItem } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
@@ -62,7 +45,7 @@ const { can } = usePermissions()
useHead({ title: t('admin.users.title') })
const canManage = can('core.users.manage')
const canManage = computed(() => can('core.users.manage'))
const users = ref<UserListItem[]>([])
const loading = ref(false)
@@ -74,7 +57,6 @@ const columns = [
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
{ key: 'actions', label: t('admin.users.table.actions') },
]
const userItems = computed(() =>
@@ -84,7 +66,6 @@ const userItems = computed(() =>
admin: user.isAdmin,
roles: user.roles.length,
directPermissions: user.directPermissions.length,
actions: '',
}))
)

View File

@@ -0,0 +1,31 @@
export interface Permission {
id: number
code: string
label: string
module: string
orphan: boolean
}
export interface Role {
id: number
code: string
label: string
description: string | null
isSystem: boolean
permissions: (Permission | string)[]
}
export interface UserListItem {
id: number
username: string
isAdmin: boolean
roles: string[]
directPermissions: string[]
}
export interface EffectivePermission {
code: string
label: string
module: string
sources: string[]
}

View File

@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate test-db-setup
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -83,6 +83,15 @@ build-without-cache:
migration-migrate:
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
# Cree et initialise la base de test utilisee par PHPUnit
# (le suffixe "_test" est applique automatiquement par Doctrine en APP_ENV=test)
# Ordre : fixtures -> sync-permissions, car fixtures:load purge la table permission
test-db-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
@@ -100,6 +109,7 @@ db-reset:
$(MAKE) migration-migrate
$(MAKE) fixtures
$(MAKE) sync-permissions
$(MAKE) test-db-setup
# Restart la bdd
db-restart:

View File

@@ -34,7 +34,6 @@ final class CoreModule
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
];
}
}

View File

@@ -19,11 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
operations: [
new GetCollection(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view')",
security: "is_granted('ROLE_USER')",
),
new Get(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view')",
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -53,6 +53,13 @@ final class AdminHeadcountGuard implements AdminHeadcountGuardInterface
* La verification est volontairement conservative (<=1) pour couvrir
* le cas defensif ou la base serait deja dans un etat incoherent (0 admin).
*
* TOCTOU accepte : la verification n'utilise pas de verrou pessimiste
* (SELECT ... FOR UPDATE). Deux demotions concurrentes pourraient donc
* passer le garde simultanement. Ce risque est accepte dans le contexte
* PME/CRM ou les operations d'administration sont rares et mono-operateur.
* Si la concurrence admin devient un enjeu, ajouter un verrou pessimiste
* sur countAdmins() ou une contrainte CHECK en base.
*
* @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1
*/
private function checkAdminHeadcount(): void

View File

@@ -7,6 +7,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<object>
@@ -16,10 +17,10 @@ class SidebarProvider implements ProviderInterface
/** @var list<string> */
private readonly array $activeModuleIds;
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string}>}> */
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
private readonly array $sidebarConfig;
public function __construct()
public function __construct(private readonly Security $security)
{
$configDir = dirname(__DIR__, 5).'/config';
@@ -58,6 +59,18 @@ class SidebarProvider implements ProviderInterface
continue;
}
// Filtrage par permission RBAC : si l'item declare une permission
// requise et que l'utilisateur courant ne la possede pas, l'item
// est masque et sa route ajoutee aux routes desactivees.
$requiredPermission = $item['permission'] ?? null;
if (null !== $requiredPermission && !$this->security->isGranted($requiredPermission)) {
if (isset($item['to'])) {
$disabledRoutes[] = $item['to'];
}
continue;
}
$items[] = [
'label' => $item['label'],
'to' => $item['to'],

View File

@@ -166,51 +166,16 @@ final class PermissionApiTest extends AbstractApiTestCase
self::assertResponseStatusCodeSame(401);
}
public function testNonAdminReturns403(): void
public function testStandardUserCanListPermissions(): void
{
// Le catalogue de permissions est accessible a tout utilisateur authentifie.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/permissions');
self::assertResponseStatusCodeSame(403);
}
// --- Tests voter RBAC : non-admin avec / sans permission ---
public function testListPermissionsAsUserWithViewPermissionReturns200(): void
{
// Un non-admin portant core.permissions.view doit pouvoir lister.
$credentials = $this->createUserWithPermission('core.permissions.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('GET', '/api/permissions');
self::assertResponseIsSuccessful();
}
public function testListPermissionsAsStandardUserReturns403(): void
{
// alice n'a aucune permission RBAC : acces refuse.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/permissions');
self::assertResponseStatusCodeSame(403);
}
public function testGetPermissionAsUserWithViewPermissionReturns200(): void
{
// Recupere l'id d'une permission existante pour construire l'URL GET item.
$permission = $this->getEm()->getRepository(Permission::class)
->findOneBy(['code' => 'test.core.users.view'])
;
self::assertNotNull($permission);
$credentials = $this->createUserWithPermission('core.permissions.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('GET', '/api/permissions/'.$permission->getId());
self::assertResponseIsSuccessful();
}
public function testGetPermissionAsStandardUserReturns403(): void
public function testStandardUserCanGetPermission(): void
{
$permission = $this->getEm()->getRepository(Permission::class)
->findOneBy(['code' => 'test.core.users.view'])
@@ -220,7 +185,7 @@ final class PermissionApiTest extends AbstractApiTestCase
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/permissions/'.$permission->getId());
self::assertResponseStatusCodeSame(403);
self::assertResponseIsSuccessful();
}
private function cleanupTestPermissions(): void