Skip to content

Commit f2b070a

Browse files
authored
Merge pull request #183 from mathiscapart/feat/unitlead-socle
feat(admin): #128 — unités encadrées (UnitLead), périmètre d'un chef
2 parents 7550188 + a04a865 commit f2b070a

17 files changed

Lines changed: 518 additions & 32 deletions

File tree

‎DECISIONS.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,3 +816,19 @@ Deux défauts laissés par #97.
816816
- Les protections liées à l'âge ne changent pas : aucune connexion sous 15 ans (`canEnableLogin`, `MIN_LOGIN_AGE`), règles de messagerie privée (`dm-policy.ts`), consentement parental, comptes enfants. Un compte de moins de 15 ans peut porter un rôle d'encadrement, mais ne peut pas se connecter.
817817
- Un chef mineur hérite des droits du rôle CHEF, notamment la messagerie privée avec les jeunes de son unité dans les limites de `dm-policy.ts`. Comme le signale #128, ce point reste à examiner avant de livrer.
818818
- Corriger une date de naissance n'impose plus de retirer d'abord les rôles d'encadrement.
819+
820+
## D-045 — #128 : le périmètre d'un chef est la table `UnitLead`, pas `User.unit`
821+
822+
**Contexte** : le périmètre d'unité (`inUnitScope`, `canActOnUnit`) comparait `user.unit === targetUnit`. Un compagnon, membre des Compagnons et chef chez les Louveteaux, ne pouvait pas être représenté, ni un chef de deux unités. L'issue proposait une table `UserUnit` (membre / encadrant).
823+
824+
**Choix** (option E, décision du responsable, étape 2 de #128) :
825+
- `User.unit` reste l'unité d'**appartenance**, unique. Une table `UnitLead (userId, unit)` porte les unités **encadrées**. Pas de table `UserUnit`.
826+
- Le périmètre d'un CHEF = ses `UnitLead`, chargées dans `ledUnits` par `getCurrentUser()`. Fail-closed : un CHEF sans `UnitLead` n'encadre rien, même avec une `unit`. ADMIN et RG restent non bornés. Les rôles restent globaux, sans paramètre d'âge (D-044).
827+
- Les autres rôles (secrétaire, trésorier…) gardent dans `inUnitScope` leur périmètre d'avant : leur unité d'appartenance. Il ne sert qu'au bilan des présences (`member.view`), qui relève d'une étape suivante. Décision validée par Mathis : dette connue, voir ROLES-06 (`TODO(ROLES-06)` dans `inUnitScope`).
828+
- La migration `20261007163359_unit_lead` donne à chaque compte non anonymisé qui porte CHEF et une unité `UnitLead(unit)` : les chefs actuels gardent exactement leurs droits.
829+
- Un seul point d'écriture, `writeLeadUnits` (`src/modules/admin/actions.ts`), verrouillé par `src/lib/unit-lead-writes.test.ts`. L'anonymisation est la seule exception, et elle ne fait que supprimer.
830+
- Cohérence avec le rôle (`leadUnitsForRoles`) : retirer CHEF supprime les `UnitLead`, et attribuer CHEF à un compte sans `UnitLead` lui fait encadrer son unité. L'approbation repart de zéro : le chef encadre l'unité choisie dans le dialogue. `setUserUnit` ne touche jamais au périmètre. `setUserLeadUnits` définit les unités encadrées d'un CHEF (audit `USER_LEAD_UNITS_CHANGED`).
831+
832+
**Conséquences** :
833+
- Changer l'unité d'un chef (`setUserUnit`) ne déplace plus son périmètre. Tant qu'aucun écran n'appelle `setUserLeadUnits`, le recours est d'enregistrer ses rôles sans CHEF puis avec : il encadre alors sa nouvelle unité.
834+
- Restent sur `User.unit`, à migrer dans les étapes suivantes : `dm-policy` (chef de son unité), le routage des notifications de modération (`selectReportRecipients`), les audiences d'annonces et de salons, le tableau de bord, l'iCal et les présences. Pour un chef dont `UnitLead` = `unit` (tous les chefs repris), rien ne change.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- CreateTable
2+
CREATE TABLE "UnitLead" (
3+
"id" TEXT NOT NULL PRIMARY KEY,
4+
"userId" TEXT NOT NULL,
5+
"unit" TEXT NOT NULL,
6+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
CONSTRAINT "UnitLead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
8+
);
9+
10+
-- CreateIndex
11+
CREATE INDEX "UnitLead_unit_idx" ON "UnitLead"("unit");
12+
13+
-- CreateIndex
14+
CREATE UNIQUE INDEX "UnitLead_userId_unit_key" ON "UnitLead"("userId", "unit");
15+
16+
-- #128 — reprise des chefs actuels : jusqu'ici le périmètre d'un CHEF était
17+
-- son unité d'appartenance (`User.unit`). Chaque compte qui porte CHEF dans
18+
-- `roles` et une unité reçoit `UnitLead(userId, unit)` : il garde exactement
19+
-- les mêmes droits. Les comptes anonymisés (DELETED) sont exclus (RGPD, cf.
20+
-- anonymize.ts).
21+
--
22+
-- Base vierge (P3018) : `User` est vide, les deux requêtes n'insèrent rien.
23+
-- `roles` illisible : le CASE évite que `json_each` fasse échouer la migration
24+
-- (donc le démarrage de la prod) ; ce compte n'a de toute façon aucun droit
25+
-- (`effectiveRoles` est fail-closed).
26+
-- Idempotent : `OR IGNORE` sur des ids dérivés du compte, comme D-038.
27+
-- Traçabilité : une ligne d'audit par compte repris, sans acteur humain
28+
-- (`userId` = le compte lui-même).
29+
30+
INSERT OR IGNORE INTO "AuditLog" ("id", "action", "userId", "metadata", "createdAt")
31+
SELECT
32+
'mig128_' || u."id",
33+
'USER_LEAD_UNITS_CHANGED',
34+
u."id",
35+
json_object('targetUserId', u."id", 'units', json_array(u."unit"), 'migration', '20261007163359_unit_lead'),
36+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
37+
FROM "User" u
38+
WHERE u."unit" IS NOT NULL
39+
AND u."status" != 'DELETED'
40+
AND CASE WHEN json_valid(u."roles")
41+
THEN EXISTS (SELECT 1 FROM json_each(u."roles") r WHERE r."value" = 'CHEF')
42+
ELSE 0 END;
43+
44+
INSERT OR IGNORE INTO "UnitLead" ("id", "userId", "unit", "createdAt")
45+
SELECT
46+
'mig128_' || u."id",
47+
u."id",
48+
u."unit",
49+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
50+
FROM "User" u
51+
WHERE u."unit" IS NOT NULL
52+
AND u."status" != 'DELETED'
53+
AND CASE WHEN json_valid(u."roles")
54+
THEN EXISTS (SELECT 1 FROM json_each(u."roles") r WHERE r."value" = 'CHEF')
55+
ELSE 0 END;

‎prisma/schema.prisma‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,30 @@ model User {
113113
socialBracketId String?
114114
socialBracket SocialBracket? @relation("UserSocialBracket", fields: [socialBracketId], references: [id], onDelete: SetNull)
115115
auditLogs AuditLog[]
116+
// #128 — unités encadrées (périmètre d'un CHEF), distinctes de `unit`.
117+
unitLeads UnitLead[]
116118
117119
@@index([status])
118120
@@index([role])
119121
@@index([socialBracketId])
120122
}
121123

124+
// #128 — unités ENCADRÉES par un compte : seul périmètre d'un CHEF
125+
// (`ledUnits`, cf. src/lib/permissions.ts). `User.unit` reste l'unité
126+
// d'appartenance. Un compagnon (membre des Compagnons) peut ainsi encadrer les
127+
// Louveteaux. Écrit uniquement par src/modules/admin/actions.ts (cf.
128+
// unit-lead-writes.test.ts), effacé par l'anonymisation.
129+
model UnitLead {
130+
id String @id @default(cuid())
131+
userId String
132+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
133+
unit String // Unit (src/lib/enums.ts)
134+
createdAt DateTime @default(now())
135+
136+
@@unique([userId, unit])
137+
@@index([unit])
138+
}
139+
122140
// US-F — tranche de quotient familial (tarification solidaire SGDF).
123141
// Définies une fois pour le groupe (T1…T5). Chaque famille est rangée dans une
124142
// tranche stable, appliquée automatiquement aux événements ET aux cotisations.

‎prisma/seed-branches.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ async function main() {
246246
emailVerified: true,
247247
unit: CHEF_RENFORT.unit,
248248
birthDate: CHEF_RENFORT.birthDate,
249+
// #128 — un CHEF n'a de périmètre que par ses unités encadrées.
250+
unitLeads: { create: { unit: CHEF_RENFORT.unit } },
249251
},
250252
});
251253
console.log(` chef de renfort créé : ${CHEF_RENFORT.email}`);

‎prisma/seed.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { db } from "../src/lib/db";
66
import type { AccountStatus, Role, Unit } from "../src/lib/enums";
77
import { PRIVACY_VERSION, TERMS_VERSION } from "../src/lib/legal/versions";
88
import { canonicalPair } from "../src/modules/communication/dm";
9+
import { leadUnitsForRoles } from "../src/modules/admin/unit-lead";
910
import { resolveConcernedUnit } from "../src/modules/communication/moderation-policy";
1011
import { computeTiers } from "../src/modules/finance/tiers";
1112

@@ -149,20 +150,24 @@ interface SeedUserInput {
149150
// US-32 — « casquettes » supplémentaires cumulées au rôle principal (ex. un
150151
// chef également trésorier). `role` reste le rôle principal affiché.
151152
extraRoles?: Role[];
153+
// #128 — unités encadrées, si elles diffèrent de la règle par défaut
154+
// (`leadUnitsForRoles` : un CHEF encadre son unité d'appartenance).
155+
leadUnits?: Unit[];
152156
}
153157

154158
/**
155159
* Crée un user via better-auth (hash password via scrypt + crée la row Account),
156160
* puis met à jour role / status / emailVerified (champs `input: false`).
157161
*/
158162
async function seedUser(input: SeedUserInput) {
159-
const user = await createCredentialUser(input);
160-
return db.user.update({
161-
where: { id: user.id },
163+
const created = await createCredentialUser(input);
164+
const roles = [input.role, ...(input.extraRoles ?? [])];
165+
const user = await db.user.update({
166+
where: { id: created.id },
162167
data: {
163168
// US-32 — rôles unifiés : `roles` est la source ; `role` reste un miroir.
164169
role: input.role,
165-
roles: JSON.stringify([input.role, ...(input.extraRoles ?? [])]),
170+
roles: JSON.stringify(roles),
166171
status: input.status,
167172
emailVerified: input.status === "ACTIVE",
168173
// SEC-08 (Vuln 2) — `unit` est `input: false` côté better-auth.
@@ -172,6 +177,14 @@ async function seedUser(input: SeedUserInput) {
172177
birthDate: input.birthDate,
173178
},
174179
});
180+
// #128 — même règle que l'approbation et la migration : un CHEF encadre son
181+
// unité, sauf unités encadrées explicites. Sans cela, aucun chef seedé
182+
// n'aurait de périmètre.
183+
const leadUnits = input.leadUnits ?? leadUnitsForRoles(roles, input.unit ?? null, []);
184+
if (leadUnits.length > 0) {
185+
await db.unitLead.createMany({ data: leadUnits.map((unit) => ({ userId: user.id, unit })) });
186+
}
187+
return user;
175188
}
176189

177190
async function main() {
@@ -255,6 +268,7 @@ async function main() {
255268
db.event.deleteMany(),
256269
// Comptes et traces
257270
db.familyLink.deleteMany(),
271+
db.unitLead.deleteMany(),
258272
db.consent.deleteMany(),
259273
db.auditLog.deleteMany(),
260274
db.session.deleteMany(),
@@ -312,6 +326,20 @@ async function main() {
312326
phone: "06 11 22 33 44",
313327
});
314328

329+
// #128 — un compagnon membre des Compagnons qui encadre les Louveteaux :
330+
// il agit sur les Louveteaux, pas sur les Compagnons. Mineur, il reste chef.
331+
await seedUser({
332+
email: "lucas.moreau@example.invalid",
333+
password: SEED_PASSWORD,
334+
firstName: "Lucas",
335+
lastName: "Moreau",
336+
birthDate: yearsAgo(17),
337+
role: "CHEF",
338+
status: "ACTIVE",
339+
unit: "COMPAGNONS",
340+
leadUnits: ["LOUVETEAUX"],
341+
});
342+
315343
// RGPD-05 (D-033) — deux inscriptions refusées pour recetter la rétention :
316344
// l'une au-delà des 30 jours, anonymisée au premier passage du scheduler,
317345
// l'autre récente, visible dans le filtre « Refusés ».

‎src/app/(app)/admin/audit/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const ACTION_LABEL: Record<string, string> = {
4343
USER_SUSPENDED: "Compte suspendu",
4444
USER_ROLE_CHANGED: "Rôle modifié",
4545
USER_UNIT_CHANGED: "Unité modifiée",
46+
USER_LEAD_UNITS_CHANGED: "Unités encadrées modifiées",
4647
USER_BIRTHDATE_CHANGED: "Date de naissance corrigée",
4748
USER_SESSION_REVOKED: "Session déconnectée (sécurité)",
4849
EQUIPMENT_CREATED: "Article créé",
@@ -91,6 +92,7 @@ const ACTION_TONE: Record<string, string> = {
9192
USER_SUSPENDED: "bg-fire-soft text-fire-ink",
9293
USER_ROLE_CHANGED: "bg-sky-soft text-sky-ink",
9394
USER_UNIT_CHANGED: "bg-sky-soft text-sky-ink",
95+
USER_LEAD_UNITS_CHANGED: "bg-sky-soft text-sky-ink",
9496
USER_BIRTHDATE_CHANGED: "bg-sky-soft text-sky-ink",
9597
EQUIPMENT_CREATED: "bg-forest-soft text-forest-ink",
9698
EQUIPMENT_UPDATED: "bg-sand text-earth",

‎src/lib/anonymize.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ export async function anonymizeUserInTx(tx: Tx, userId: string): Promise<void> {
9999
// donc rien n'est perdu pour les autres en la supprimant.
100100
await tx.notification.deleteMany({ where: { userId } });
101101

102+
// #128 — unités encadrées : comme pour les abonnements push, le cascade de
103+
// la relation ne joue pas puisque la ligne `User` reste. Un compte effacé
104+
// n'encadre plus rien.
105+
await tx.unitLead.deleteMany({ where: { userId } });
106+
102107
// Jetons de vérification / réinitialisation, indexés par email. Ils expirent
103108
// seuls, mais `identifier` EST l'adresse email — donc de la PII en clair.
104109
if (before?.email) {

‎src/lib/enums.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ export const AUDIT_ACTIONS = [
355355
"USER_SUSPENDED",
356356
"USER_ROLE_CHANGED",
357357
"USER_UNIT_CHANGED",
358+
// #128 — unités encadrées (UnitLead) d'un compte, cf. setUserLeadUnits.
359+
"USER_LEAD_UNITS_CHANGED",
358360
// SAFE-01 — correction admin d'une date de naissance (from/to en métadonnées).
359361
"USER_BIRTHDATE_CHANGED",
360362
"USER_PROFILE_UPDATED",

‎src/lib/get-current-user.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export async function getCurrentUser() {
4343
// messagerie privée) : cf. src/modules/communication/dm-policy.ts.
4444
birthDate: true,
4545
canLogin: true,
46+
// #128 — unités encadrées : périmètre d'un CHEF (`ledUnits`, cf.
47+
// inUnitScope / canActOnUnit dans src/lib/permissions.ts).
48+
unitLeads: { select: { unit: true } },
4649
},
4750
});
4851
if (
@@ -54,7 +57,8 @@ export async function getCurrentUser() {
5457
redirect("/login");
5558
}
5659

57-
return user;
60+
const { unitLeads, ...fields } = user;
61+
return { ...fields, ledUnits: unitLeads.map((l) => l.unit) };
5862
}
5963

6064
export type CurrentUser = Awaited<ReturnType<typeof getCurrentUser>>;

‎src/lib/permissions.test.ts‎

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ describe("inUnitScope — périmètre d'unité", () => {
281281
role: "CHEF",
282282
roles: ["CHEF"],
283283
unit: "LOUVETEAUX",
284+
ledUnits: ["LOUVETEAUX"],
284285
status: "ACTIVE" as const,
285286
};
286287

@@ -330,7 +331,7 @@ describe("scopedUnits", () => {
330331
it("ne renvoie que sa branche pour un CHEF", () => {
331332
expect(
332333
scopedUnits(
333-
{ role: "CHEF", roles: ["CHEF"], unit: "SCOUTS", status: "ACTIVE" },
334+
{ role: "CHEF", roles: ["CHEF"], unit: "SCOUTS", ledUnits: ["SCOUTS"], status: "ACTIVE" },
334335
catalog,
335336
),
336337
).toEqual(["SCOUTS"]);
@@ -354,6 +355,7 @@ describe("canActOnUnit — périmètre conscient du rôle qui porte le droit", (
354355
role: "CHEF",
355356
roles: ["CHEF"],
356357
unit: "SCOUTS",
358+
ledUnits: ["SCOUTS"],
357359
status: "ACTIVE" as const,
358360
};
359361

@@ -455,7 +457,7 @@ describe("can — member.family.manage", () => {
455457

456458
describe("canActOnUnit — rattachement familial borné à la branche du jeune (#83)", () => {
457459
const chef = (unit: string | null) =>
458-
({ role: "CHEF", roles: ["CHEF"], unit, status: "ACTIVE" as const });
460+
({ role: "CHEF", roles: ["CHEF"], unit, ledUnits: unit ? [unit] : [], status: "ACTIVE" as const });
459461
const active = (roles: string[]) =>
460462
({ role: roles[0], roles, unit: null, status: "ACTIVE" as const });
461463

@@ -488,7 +490,7 @@ describe("canActOnUnit — rattachement familial borné à la branche du jeune (
488490

489491
describe("canReadPedagoNotes — notes de suivi sensibles (US-S07, #98)", () => {
490492
const user = (roles: string[], unit: string | null = null, status = "ACTIVE" as const) =>
491-
({ role: roles[0], roles, unit, status });
493+
({ role: roles[0], roles, unit, ledUnits: unit ? [unit] : [], status });
492494

493495
it("autorise un chef de la branche du jeune", () => {
494496
expect(canReadPedagoNotes(user(["CHEF"], "PIONNIERS"), "PIONNIERS")).toBe(true);
@@ -563,6 +565,7 @@ describe("RESPONSABLE_GROUPE — écriture sur tout le groupe (#173)", () => {
563565
role: "RESPONSABLE_GROUPE",
564566
roles: ["RESPONSABLE_GROUPE", ...extra],
565567
unit,
568+
ledUnits: unit ? [unit] : [],
566569
status,
567570
});
568571

@@ -830,3 +833,91 @@ describe("canSetEmailTo", () => {
830833
expect(canSetEmailTo(u("ADMIN"), "enfant-xyz@piloti.invalid")).toBe(true);
831834
});
832835
});
836+
837+
// #128 — le périmètre d'un chef est la liste de ses unités encadrées
838+
// (`UnitLead`), chargée dans `ledUnits`. `unit` reste l'unité d'APPARTENANCE :
839+
// elle n'ouvre plus aucun périmètre à elle seule.
840+
describe("ledUnits — périmètre d'encadrement (#128)", () => {
841+
const user = (roles: string[], unit: string | null, ledUnits?: string[]) => ({
842+
role: roles[0],
843+
roles,
844+
unit,
845+
ledUnits,
846+
status: "ACTIVE" as const,
847+
});
848+
const catalog = ["FARFADETS", "LOUVETEAUX", "SCOUTS", "PIONNIERS", "COMPAGNONS"] as const;
849+
850+
it("chef d'une unité : agit sur elle seule", () => {
851+
const chef = user(["CHEF"], "SCOUTS", ["SCOUTS"]);
852+
expect(inUnitScope(chef, "SCOUTS")).toBe(true);
853+
expect(inUnitScope(chef, "PIONNIERS")).toBe(false);
854+
expect(canActOnUnit(chef, "event.manage", "SCOUTS")).toBe(true);
855+
expect(canActOnUnit(chef, "event.manage", "PIONNIERS")).toBe(false);
856+
expect(scopedUnits(chef, catalog)).toEqual(["SCOUTS"]);
857+
});
858+
859+
it("chef de deux unités : agit sur les deux, pas au-delà", () => {
860+
const chef = user(["CHEF"], "ADULTES", ["LOUVETEAUX", "SCOUTS"]);
861+
expect(scopedUnits(chef, catalog)).toEqual(["LOUVETEAUX", "SCOUTS"]);
862+
expect(canActOnUnit(chef, "pedago.manage", "LOUVETEAUX")).toBe(true);
863+
expect(canActOnUnit(chef, "pedago.manage", "SCOUTS")).toBe(true);
864+
expect(canActOnUnit(chef, "pedago.manage", "PIONNIERS")).toBe(false);
865+
expect(canReadPedagoNotes(chef, "SCOUTS")).toBe(true);
866+
});
867+
868+
it("compagnon CHEF encadrant les Louveteaux : agit sur les Louveteaux, pas sur les Compagnons", () => {
869+
const compagnon = user(["CHEF"], "COMPAGNONS", ["LOUVETEAUX"]);
870+
expect(inUnitScope(compagnon, "LOUVETEAUX")).toBe(true);
871+
expect(inUnitScope(compagnon, "COMPAGNONS")).toBe(false);
872+
expect(canActOnUnit(compagnon, "event.manage", "LOUVETEAUX")).toBe(true);
873+
expect(canActOnUnit(compagnon, "event.manage", "COMPAGNONS")).toBe(false);
874+
expect(canReadPedagoNotes(compagnon, "LOUVETEAUX")).toBe(true);
875+
expect(canReadPedagoNotes(compagnon, "COMPAGNONS")).toBe(false);
876+
expect(scopedUnits(compagnon, catalog)).toEqual(["LOUVETEAUX"]);
877+
});
878+
879+
it("fail-closed : un CHEF sans UnitLead n'encadre aucune unité, même avec une `unit`", () => {
880+
for (const chef of [user(["CHEF"], "SCOUTS", []), user(["CHEF"], "SCOUTS")]) {
881+
expect(inUnitScope(chef, "SCOUTS")).toBe(false);
882+
expect(canActOnUnit(chef, "event.manage", "SCOUTS")).toBe(false);
883+
expect(canReadPedagoNotes(chef, "SCOUTS")).toBe(false);
884+
expect(scopedUnits(chef, catalog)).toEqual([]);
885+
}
886+
});
887+
888+
it("fail-closed : une ressource sans unité n'est à aucun chef", () => {
889+
expect(inUnitScope(user(["CHEF"], "SCOUTS", ["SCOUTS"]), null)).toBe(false);
890+
expect(canActOnUnit(user(["CHEF"], "SCOUTS", ["SCOUTS"]), "event.manage", null)).toBe(false);
891+
});
892+
893+
it("RG aussi CHEF : groupe entier pour ses droits de RG, pédagogique borné à ses UnitLead", () => {
894+
const rgChef = user(["RESPONSABLE_GROUPE", "CHEF"], "ADULTES", ["LOUVETEAUX"]);
895+
expect(scopedUnits(rgChef, catalog)).toEqual([...catalog]);
896+
expect(canActOnUnit(rgChef, "event.manage", "PIONNIERS")).toBe(true);
897+
expect(canActOnUnit(rgChef, "pedago.manage", "LOUVETEAUX")).toBe(true);
898+
expect(canActOnUnit(rgChef, "pedago.manage", "PIONNIERS")).toBe(false);
899+
expect(canReadPedagoNotes(rgChef, "PIONNIERS")).toBe(false);
900+
});
901+
902+
it("rôle transverse avec une unité (secrétaire) : périmètre inchangé, son unité d'appartenance", () => {
903+
// Bilan des présences (`member.view` + `scopedUnits`) : comportement
904+
// d'avant #128, à trancher avec les présences (étape suivante).
905+
const secretaire = user(["SECRETAIRE"], "ADULTES");
906+
expect(scopedUnits(secretaire, [...catalog, "ADULTES"])).toEqual(["ADULTES"]);
907+
expect(scopedUnits(user(["TRESORIER"], null), catalog)).toEqual([]);
908+
});
909+
910+
it("CHEF aussi secrétaire, sans UnitLead : le rôle CHEF borne, aucune unité", () => {
911+
const chefSec = user(["CHEF", "SECRETAIRE"], "SCOUTS", []);
912+
expect(scopedUnits(chefSec, catalog)).toEqual([]);
913+
expect(scopedUnits(user(["CHEF", "SECRETAIRE"], "SCOUTS", ["LOUVETEAUX"]), catalog)).toEqual(["LOUVETEAUX"]);
914+
});
915+
916+
it("ADMIN : non borné, sans aucun UnitLead", () => {
917+
const admin = user(["ADMIN"], null, []);
918+
expect(scopedUnits(admin, catalog)).toEqual([...catalog]);
919+
expect(inUnitScope(admin, null)).toBe(true);
920+
expect(canActOnUnit(admin, "pedago.manage", "COMPAGNONS")).toBe(true);
921+
expect(canReadPedagoNotes(admin, "COMPAGNONS")).toBe(true);
922+
});
923+
});

0 commit comments

Comments
 (0)