Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code ». Le catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le semis pouvait attribuer quoi que ce soit. Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le sien, et l'interdire empêcherait de corriger un rôle trop large. Le verrouillage ensuite : au moins un rôle doit conserver la gestion des droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul recours serait une intervention en base. Un rôle naît sans aucune capacité : en hériter de celles de son créateur distribuerait des droits que personne n'a demandés. Le niveau propriétaire se délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus — ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux. Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est cochée » n'aurait rien dit du critère. Trois défauts d'isolation des tests corrigés au passage, tous du même genre — un état qui s'accumule d'une exécution à l'autre : - la file des congés désignait « la première ligne de ce salarié » pour son nettoyage, et tombait sur une ligne laissée par un passage antérieur ; - l'écran de conservation tronquait à quarante pièces triées par ancienneté, faisant disparaître les pièces échues derrière celles dont il n'y a rien à dire — corrigé dans le produit, pas seulement dans le test ; - le test de verrouillage de période consommait un mois par exécution sans jamais le rendre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
172 lines
4.8 KiB
TypeScript
172 lines
4.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import type { Actor } from '@/domain/access/authorize';
|
||
import {
|
||
escalationRefusal,
|
||
lockoutRefusal,
|
||
OWNER_LEVEL_CAPABILITY,
|
||
REFUSAL_MESSAGES,
|
||
ROLE_ADMIN_CAPABILITY,
|
||
slugifyRoleKey,
|
||
type EditRefusal,
|
||
} from '@/domain/access/role-editing';
|
||
|
||
function actor(permissions: string[]): Actor {
|
||
return {
|
||
membershipId: 'm1',
|
||
accountId: 'a1',
|
||
userId: 'u1',
|
||
roleKey: 'admin',
|
||
permissions: new Set(permissions),
|
||
scope: { allLocations: true, locationIds: [], teamIds: [] },
|
||
};
|
||
}
|
||
|
||
describe('escalade', () => {
|
||
it('laisse accorder ce qu’on détient', () => {
|
||
expect(
|
||
escalationRefusal({
|
||
actor: actor(['planning.publish', ROLE_ADMIN_CAPABILITY]),
|
||
previous: new Set(),
|
||
next: new Set(['planning.publish']),
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('refuse d’accorder ce qu’on n’a pas', () => {
|
||
// Sans cette règle, la première personne autorisée à éditer un rôle
|
||
// s'accorde l'accès aux rémunérations en trois clics.
|
||
expect(
|
||
escalationRefusal({
|
||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||
previous: new Set(),
|
||
next: new Set(['members.salary.view']),
|
||
}),
|
||
).toBe('ESCALATION');
|
||
});
|
||
|
||
it('traite le niveau propriétaire à part', () => {
|
||
expect(
|
||
escalationRefusal({
|
||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||
previous: new Set(),
|
||
next: new Set([OWNER_LEVEL_CAPABILITY]),
|
||
}),
|
||
).toBe('OWNER_LEVEL');
|
||
});
|
||
|
||
it('laisse un propriétaire déléguer son niveau', () => {
|
||
expect(
|
||
escalationRefusal({
|
||
actor: actor([ROLE_ADMIN_CAPABILITY, OWNER_LEVEL_CAPABILITY]),
|
||
previous: new Set(),
|
||
next: new Set([OWNER_LEVEL_CAPABILITY]),
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('laisse retirer une capacité qu’on ne détient pas', () => {
|
||
// Réduire un droit n'a jamais élargi le sien : interdire ce retrait
|
||
// empêcherait de corriger un rôle trop large.
|
||
expect(
|
||
escalationRefusal({
|
||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||
previous: new Set(['members.salary.view', 'planning.publish']),
|
||
next: new Set(['planning.publish']),
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('ignore une capacité déjà présente', () => {
|
||
// Enregistrer un rôle sans y toucher ne doit pas échouer parce qu'il porte
|
||
// une capacité que l'éditeur n'a pas.
|
||
expect(
|
||
escalationRefusal({
|
||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||
previous: new Set(['members.salary.view']),
|
||
next: new Set(['members.salary.view']),
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('verrouillage', () => {
|
||
it('accepte tant qu’un rôle garde la gestion des droits', () => {
|
||
expect(
|
||
lockoutRefusal({
|
||
rolesAfter: [
|
||
{ id: 'r1', permissions: new Set([ROLE_ADMIN_CAPABILITY]) },
|
||
{ id: 'r2', permissions: new Set(['planning.view']) },
|
||
],
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('refuse le retrait du dernier', () => {
|
||
// Une organisation qui s'enferme dehors n'a plus d'autre recours qu'une
|
||
// intervention en base.
|
||
expect(
|
||
lockoutRefusal({
|
||
rolesAfter: [
|
||
{ id: 'r1', permissions: new Set(['planning.view']) },
|
||
{ id: 'r2', permissions: new Set(['planning.publish']) },
|
||
],
|
||
}),
|
||
).toBe('LAST_ADMIN');
|
||
});
|
||
|
||
it('se juge sur l’ensemble, pas sur le rôle édité', () => {
|
||
// Retirer la capacité d'un rôle est permis si un autre la conserve.
|
||
expect(
|
||
lockoutRefusal({
|
||
rolesAfter: [
|
||
{ id: 'r1', permissions: new Set() },
|
||
{ id: 'r2', permissions: new Set([ROLE_ADMIN_CAPABILITY]) },
|
||
],
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('clé de rôle', () => {
|
||
it('translittère et met en minuscules', () => {
|
||
expect(slugifyRoleKey('Responsable de secteur')).toBe(
|
||
'responsable-de-secteur',
|
||
);
|
||
expect(slugifyRoleKey('Chef d’équipe régionale')).toBe(
|
||
'chef-d-equipe-regionale',
|
||
);
|
||
});
|
||
|
||
it('ne laisse pas de tiret aux extrémités', () => {
|
||
expect(slugifyRoleKey(' — Extra — ')).toBe('extra');
|
||
});
|
||
|
||
it('rend une chaîne vide quand rien n’est utilisable', () => {
|
||
// L'appelant refuse alors la création plutôt que de fabriquer une clé
|
||
// arbitraire que personne ne reconnaîtra.
|
||
expect(slugifyRoleKey('!!!')).toBe('');
|
||
});
|
||
|
||
it('borne la longueur', () => {
|
||
expect(slugifyRoleKey('a'.repeat(80)).length).toBe(40);
|
||
});
|
||
});
|
||
|
||
describe('messages de refus', () => {
|
||
it('en fournit un pour chaque motif', () => {
|
||
const refusals: EditRefusal[] = [
|
||
'ESCALATION',
|
||
'OWNER_LEVEL',
|
||
'LAST_ADMIN',
|
||
'SYSTEM_ROLE',
|
||
'DUPLICATE_KEY',
|
||
'ROLE_IN_USE',
|
||
'NOT_FOUND',
|
||
];
|
||
for (const refusal of refusals) {
|
||
expect(REFUSAL_MESSAGES[refusal]).toBeTruthy();
|
||
}
|
||
});
|
||
});
|