koprogo_api/infrastructure/web/middleware/scope_guard.rs
1//! `scope_guard` middleware — Story 1.3 (refonte UX multi-rôle ACP).
2//!
3//! Reads the optional scope hint provided by the client (header
4//! `X-Scope-AcpId` *or* query parameter `?acp_id=...`), resolves the
5//! caller's effective `AcpCaller` from JWT, and injects an `AcpScope`
6//! into the request extensions for downstream handlers to consume.
7//!
8//! Refuses the request early (without hitting the handler) if the
9//! caller attempts to address an ACP outside their scope.
10//!
11//! Design:
12//! - The middleware is a thin `Transform` registered on `/buildings` /
13//! `/acps` routes; it consults `AppState::list_acps_use_case` to
14//! verify the scope.
15//! - For routes that don't need a forced ACP scope (e.g. admin listing
16//! "all"), the middleware is permissive: no scope hint → no
17//! restriction, the use-case will fall back to the role-derived
18//! default scope.
19//!
20//! Error semantics (cf. architecture §6.3) :
21//! - 401 `Unauthorized` if no/invalid JWT
22//! - 403 `AcpNotInScope { acp_id }` if scope forged out of perimeter
23//! - 400 `Validation` if header/query are malformed *and* the role
24//! needs an explicit scope id (non-admin)
25
26use std::future::{ready, Future, Ready};
27use std::pin::Pin;
28use std::sync::Arc;
29
30use actix_web::{
31 body::{EitherBody, MessageBody},
32 dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
33 http::StatusCode,
34 web, Error, HttpMessage, HttpResponse, ResponseError,
35};
36use serde_json::json;
37use thiserror::Error;
38use uuid::Uuid;
39
40use crate::application::error::AppError;
41use crate::application::use_cases::acp_use_cases::{AcpCaller, AcpUseCases};
42use crate::application::use_cases::building_use_cases::BuildingUseCases;
43use crate::application::use_cases::call_for_funds_use_cases::CallForFundsUseCases;
44use crate::application::use_cases::convocation_use_cases::ConvocationUseCases;
45use crate::application::use_cases::document_use_cases::DocumentUseCases;
46use crate::application::use_cases::gamification_use_cases::ChallengeUseCases;
47use crate::application::use_cases::local_exchange_use_cases::LocalExchangeUseCases;
48use crate::application::use_cases::notice_use_cases::NoticeUseCases;
49use crate::application::use_cases::owner_contribution_use_cases::OwnerContributionUseCases;
50use crate::application::use_cases::owner_use_cases::OwnerUseCases;
51use crate::application::use_cases::poll_use_cases::PollUseCases;
52use crate::application::use_cases::quote_use_cases::QuoteUseCases;
53use crate::application::use_cases::resource_booking_use_cases::ResourceBookingUseCases;
54use crate::application::use_cases::shared_object_use_cases::SharedObjectUseCases;
55use crate::application::use_cases::skill_use_cases::SkillUseCases;
56use crate::application::use_cases::technical_spec_use_cases::TechnicalSpecUseCases;
57use crate::application::use_cases::ticket_use_cases::TicketUseCases;
58use crate::application::use_cases::unit_use_cases::UnitUseCases;
59use crate::infrastructure::web::app_state::AppState;
60use crate::infrastructure::web::AuthenticatedUser;
61
62/// Header name accepted as a scope hint. Case-insensitive per HTTP RFC.
63pub const SCOPE_ACP_HEADER: &str = "X-Scope-AcpId";
64
65/// Resolved scope context, injected into request extensions by the
66/// `ScopeGuard` middleware. Handlers read it via
67/// `req.extensions().get::<AcpScope>()` (or via an extractor in a
68/// follow-up story).
69#[derive(Debug, Clone)]
70pub struct AcpScope {
71 /// Caller derived from JWT (mapped by the same convention as
72 /// `acp_handlers::caller_from_user`).
73 pub caller: AcpCaller,
74 /// ACP id explicitly requested by the client (header/query).
75 /// `None` = use role-derived default scope.
76 pub requested_acp_id: Option<Uuid>,
77 /// `true` if the middleware has verified the caller is allowed to
78 /// see the requested ACP. Always `true` when `requested_acp_id` is
79 /// `None` (no forging possible).
80 pub allowed: bool,
81}
82
83/// Errors surfaced by `ScopeGuard`. Mapped to HTTP via `ResponseError`.
84#[derive(Debug, Error)]
85pub enum ScopeGuardError {
86 #[error("Unauthorized — missing or invalid JWT")]
87 Unauthorized,
88
89 #[error("ACP {acp_id} not in scope")]
90 AcpNotInScope { acp_id: Uuid },
91
92 #[error("Validation error: {0}")]
93 Validation(String),
94
95 #[error("Internal error: {0}")]
96 Internal(String),
97}
98
99impl ScopeGuardError {
100 pub fn kind(&self) -> &'static str {
101 match self {
102 ScopeGuardError::Unauthorized => "unauthorized",
103 ScopeGuardError::AcpNotInScope { .. } => "acp_not_in_scope",
104 ScopeGuardError::Validation(_) => "validation",
105 ScopeGuardError::Internal(_) => "internal",
106 }
107 }
108}
109
110impl ResponseError for ScopeGuardError {
111 fn status_code(&self) -> StatusCode {
112 match self {
113 ScopeGuardError::Unauthorized => StatusCode::UNAUTHORIZED,
114 ScopeGuardError::AcpNotInScope { .. } => StatusCode::FORBIDDEN,
115 ScopeGuardError::Validation(_) => StatusCode::BAD_REQUEST,
116 ScopeGuardError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
117 }
118 }
119
120 fn error_response(&self) -> HttpResponse {
121 HttpResponse::build(self.status_code()).json(json!({
122 "error": self.to_string(),
123 "kind": self.kind(),
124 }))
125 }
126}
127
128impl From<AppError> for ScopeGuardError {
129 fn from(err: AppError) -> Self {
130 match err {
131 AppError::AcpNotInScope { acp_id } => ScopeGuardError::AcpNotInScope { acp_id },
132 AppError::Unauthorized | AppError::InvalidCredentials | AppError::TokenError(_) => {
133 ScopeGuardError::Unauthorized
134 }
135 AppError::Validation(s) => ScopeGuardError::Validation(s),
136 other => ScopeGuardError::Internal(other.to_string()),
137 }
138 }
139}
140
141// ============================================================================
142// Pure helpers (used by middleware AND tested by BDD/unit tests without
143// actix machinery).
144// ============================================================================
145
146/// Map a `UserRoleString + organization_id + user_id` triple to an
147/// `AcpCaller`. Same convention as `acp_handlers::caller_from_user` —
148/// duplicated here to keep the middleware free of handler imports.
149pub fn caller_from_role(role: &str, organization_id: Option<Uuid>, user_id: Uuid) -> AcpCaller {
150 match role.to_lowercase().as_str() {
151 "superadmin" => AcpCaller::SuperAdmin,
152 "admin" => match organization_id {
153 Some(org) => AcpCaller::Admin {
154 organization_id: org,
155 },
156 None => AcpCaller::SuperAdmin,
157 },
158 "syndic" | "accountant" => match organization_id {
159 Some(org) => AcpCaller::Syndic {
160 organization_id: org,
161 },
162 None => AcpCaller::Owner { user_id },
163 },
164 _ => AcpCaller::Owner { user_id },
165 }
166}
167
168/// Extract the requested ACP id from headers or query string.
169/// Header `X-Scope-AcpId` takes precedence over `?acp_id=`.
170/// Returns `Err(Validation)` if a value is present but malformed.
171pub fn extract_requested_acp_id(
172 header_value: Option<&str>,
173 query_value: Option<&str>,
174) -> Result<Option<Uuid>, ScopeGuardError> {
175 let raw = header_value.or(query_value);
176 match raw {
177 None => Ok(None),
178 Some(s) if s.trim().is_empty() => Ok(None),
179 Some(s) => Uuid::parse_str(s.trim())
180 .map(Some)
181 .map_err(|_| ScopeGuardError::Validation(format!("invalid acp_id: {}", s))),
182 }
183}
184
185/// Decide whether the caller is allowed to *attach* the requested scope
186/// id, without hitting the DB. Returns:
187/// - `Ok(None)` : caller has no requested scope → no enforcement needed
188/// - `Ok(Some(acp_id))` : the middleware must consult the use-case to
189/// verify `assert_caller_can_see(acp_id)`
190/// - `Err(Validation)` : the caller is non-admin AND owns no role-scope
191/// information (e.g. syndic with `organization_id = None` AND no
192/// explicit acp_id) — refuse rather than guess.
193pub fn requires_repository_check(
194 caller: &AcpCaller,
195 requested: Option<Uuid>,
196) -> Result<Option<Uuid>, ScopeGuardError> {
197 match (caller, requested) {
198 // SuperAdmin can pin any ACP, but we still want to verify it exists.
199 (AcpCaller::SuperAdmin, Some(id)) => Ok(Some(id)),
200 (AcpCaller::SuperAdmin, None) => Ok(None),
201
202 // Admin / Syndic with their own org are allowed if requested
203 // matches their org-derived scope or is None.
204 (AcpCaller::Admin { .. }, req) | (AcpCaller::Syndic { .. }, req) => Ok(req),
205
206 // Owner: every request must be checked via the use-case (no
207 // direct shortcut — story 1.3 conservatively refuses pinning
208 // until story 3.5 wires user_role_assignments.scope/scope_id).
209 (AcpCaller::Owner { .. }, req) => Ok(req),
210 }
211}
212
213/// Hotfix #603 — résout `building.acp_id -> acp.organization_id` et applique
214/// l'isolation multi-tenant sur les GET-by-id (building, budget, expense,
215/// meeting, resolution, unit, work_report).
216///
217/// Après #602 (`Building.organization_id -> acp_id`), `BuildingResponseDto`
218/// ne porte plus `organization_id` ; les 7 handlers ci-dessus ont perdu leur
219/// `user.verify_org_access(...)`. Ce helper recâble la chaîne en lookup ACP.
220///
221/// Sémantique :
222/// - SuperAdmin : toujours autorisé (bypass).
223/// - Sinon : `acp.organization_id` MUST == `user.organization_id`. Sinon
224/// `AppError::AcpNotInScope` (HTTP 403 via `ResponseError`).
225/// - ACP introuvable OU `acp.organization_id IS NULL` (auto-gérée) :
226/// refuse pour non-superadmin (conservateur — gouvernance ACP auto-gérée
227/// en story 4.x).
228pub async fn verify_acp_org_access(
229 user: &AuthenticatedUser,
230 acp_id: Uuid,
231 acp_use_cases: &AcpUseCases,
232) -> Result<(), AppError> {
233 if user.is_superadmin() {
234 return Ok(());
235 }
236
237 let acp = acp_use_cases
238 .find_acp(acp_id)
239 .await?
240 .ok_or(AppError::AcpNotInScope { acp_id })?;
241
242 let acp_org_id = acp
243 .organization_id
244 .ok_or(AppError::AcpNotInScope { acp_id })?;
245
246 user.verify_org_access(acp_org_id)
247 .map_err(|_| AppError::AcpNotInScope { acp_id })
248}
249
250/// Isolation multi-tenant sur les ÉCRITURES qui désignent un immeuble par le
251/// CORPS de la requête.
252///
253/// `verify_acp_org_access` ci-dessus protège les lectures par identifiant
254/// (Hotfix #603). Le côté écriture n'avait pas d'équivalent : 29 routes
255/// `POST`/`PUT` acceptent un `building_id` dans leur DTO et aucune ne
256/// vérifiait qu'il appartient à l'organisation de l'appelant.
257///
258/// Mesuré en conditions réelles le 2026-09-02 entre deux cabinets syndics
259/// indépendants. Le cabinet B a pu, sur l'immeuble du cabinet A :
260///
261/// POST /expenses → 201, dépense de 50 000 € VISIBLE dans la
262/// liste des charges de l'immeuble de A ;
263/// POST /call-for-funds → 201, puis `send` → 200, générant une
264/// quote-part de 25 000 € réclamée à une
265/// copropriétaire de A.
266///
267/// Les lectures, elles, répondaient bien 403 dans toutes les directions : la
268/// faille était strictement du côté écriture.
269///
270/// La garde existante `dto.organization_id = <celle du JWT>` ne protège pas
271/// de cela — elle empêche d'ESTAMPILLER l'enregistrement au nom d'autrui, pas
272/// de le RATTACHER au patrimoine d'autrui. Le champ contrôlé n'était pas le
273/// bon.
274///
275/// Sémantique : superadmin passe ; sinon l'immeuble doit exister et son ACP
276/// appartenir à l'organisation de l'appelant. Un immeuble introuvable est
277/// refusé plutôt qu'ignoré — accepter une écriture pointant vers le vide
278/// créerait un orphelin invisible.
279pub async fn verify_building_org_access(
280 user: &AuthenticatedUser,
281 building_id: Uuid,
282 building_use_cases: &BuildingUseCases,
283 acp_use_cases: &AcpUseCases,
284) -> Result<(), AppError> {
285 if user.is_superadmin() {
286 return Ok(());
287 }
288
289 let building = building_use_cases
290 .get_building(building_id)
291 .await
292 .map_err(AppError::from)?
293 .ok_or(AppError::NotFound(format!(
294 "Building not found: {building_id}"
295 )))?;
296
297 let acp_id = Uuid::parse_str(&building.acp_id)
298 .map_err(|_| AppError::Internal("Invalid building.acp_id format".to_string()))?;
299
300 verify_acp_org_access(user, acp_id, acp_use_cases).await
301}
302
303/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **convocation**.
304///
305/// La convocation porte directement son `building_id` : le saut est unique.
306///
307/// `GET /convocations/{id}/recipients` sert la liste NOMINATIVE des
308/// copropriétaires convoqués, avec leur adresse de courriel et le mode d'envoi
309/// retenu. C'est un fichier de personnes, et il était servi à qui connaissait
310/// un identifiant de convocation.
311///
312/// `tracking-summary` en dit davantage encore : qui a ouvert le courriel et
313/// quand. Une donnée de comportement, pas seulement d'identité.
314pub async fn verify_convocation_org_access(
315 user: &AuthenticatedUser,
316 convocation_id: Uuid,
317 convocation_use_cases: &ConvocationUseCases,
318 building_use_cases: &BuildingUseCases,
319 acp_use_cases: &AcpUseCases,
320) -> Result<(), AppError> {
321 if user.is_superadmin() {
322 return Ok(());
323 }
324
325 let convocation = convocation_use_cases
326 .get_convocation(convocation_id)
327 .await
328 .map_err(AppError::from)?;
329
330 verify_building_org_access(
331 user,
332 convocation.building_id,
333 building_use_cases,
334 acp_use_cases,
335 )
336 .await
337}
338
339/// Vérifie le mandat de l'appelant sur l'ACP dont relève un **document**.
340///
341/// Remonte document → immeuble → ACP → organisation.
342///
343/// Un document de copropriété n'est pas un fichier anodin : l'acte de base,
344/// les procès-verbaux d'assemblée et les factures nominatives passent par là.
345/// `GET /documents/{id}/download` servait le contenu même du fichier à qui
346/// connaissait son identifiant.
347///
348/// Les deux routes de rattachement — vers une assemblée, vers une dépense —
349/// sont des **écritures** : elles permettaient de raccrocher le document d'un
350/// cabinet au dossier d'un autre.
351pub async fn verify_document_org_access(
352 user: &AuthenticatedUser,
353 document_id: Uuid,
354 document_use_cases: &DocumentUseCases,
355 building_use_cases: &BuildingUseCases,
356 acp_use_cases: &AcpUseCases,
357) -> Result<(), AppError> {
358 if user.is_superadmin() {
359 return Ok(());
360 }
361
362 let document = document_use_cases
363 .get_document(document_id)
364 .await
365 .map_err(AppError::from)?;
366
367 verify_building_org_access(
368 user,
369 document.building_id,
370 building_use_cases,
371 acp_use_cases,
372 )
373 .await
374}
375
376/// Vérifie le mandat de l'appelant sur l'ACP dont relève un **lot**.
377///
378/// Remonte lot → immeuble → ACP → organisation.
379///
380/// `GET /units/{id}/etats-dates` sert les états datés d'un lot : le document
381/// remis au notaire lors d'une vente, qui porte les arriérés du vendeur et
382/// l'état du fonds de réserve. C'est une pièce financière nominative.
383/// Vérifie le mandat de l'appelant sur l'organisation d'une **réservation**.
384///
385/// ── Ce que cette garde protège ─────────────────────────────────────────────
386///
387/// Une réservation de ressource commune dit qui a réservé la salle, la buanderie
388/// ou le parking visiteur, et **quand**. Ce n'est pas une donnée neutre : elle
389/// dit aussi qui n'était pas chez lui à ce moment-là.
390///
391/// Trois routes agissent sur une réservation par son seul identifiant —
392/// `complete`, `no-show`, `confirm` — et deux la lisent. Aucune ne reçoit
393/// d'immeuble : la chaîne réservation → immeuble → ACP → organisation doit
394/// donc être remontée ici.
395///
396/// Elles prenaient `_auth: AuthenticatedUser`, l'identité soulignée d'un
397/// underscore pour dire qu'on ne s'en sert pas (#772).
398///
399/// ── Pourquoi elle délègue plutôt que de comparer ──────────────────────────
400///
401/// Comme les huit autres, elle finit par appeler `verify_building_org_access`,
402/// qui remonte à l'ACP. Refaire la comparaison ici dupliquerait la règle de
403/// cloisonnement en un endroit de plus — et c'est cette duplication, recopiée
404/// à la main dans chaque gestionnaire, que l'issue #772 désigne comme la cause
405/// première de la fuite.
406/// Vérifie le mandat de l'appelant sur l'organisation d'un **devis**.
407///
408/// ── Ce que cette garde protège ─────────────────────────────────────────────
409///
410/// Un devis dit qui a soumis quel prix pour quels travaux. Le lire hors de son
411/// ACP, c'est lire la concurrence — un entrepreneur ayant un compte sur la
412/// plateforme y verrait les offres de ses concurrents, montants compris.
413///
414/// Neuf routes agissent sur un devis par son seul identifiant : le lire, le
415/// soumettre, l'examiner, le retirer, le noter, le supprimer. Aucune ne reçoit
416/// d'immeuble, d'où la remontée devis → immeuble → ACP → organisation.
417///
418/// ── Ce qu'elle ne fait PAS ────────────────────────────────────────────────
419///
420/// Elle vérifie le **périmètre**, pas le droit d'agir. Examiner un devis
421/// (`review`) ou noter un prestataire n'appartient pas à tout membre de l'ACP —
422/// ce sont des actes de gestion. Ce garde ne dit donc pas « cet utilisateur
423/// peut le faire », il dit « ce devis n'est pas celui d'une autre
424/// copropriété ».
425///
426/// Confondre les deux serait le défaut que l'issue #772 décrit : un garde qui
427/// vérifie la mauvaise chose est pire qu'un garde absent, puisqu'il fait
428/// croire la route protégée.
429pub async fn verify_quote_org_access(
430 user: &AuthenticatedUser,
431 quote_id: Uuid,
432 quote_use_cases: &QuoteUseCases,
433 building_use_cases: &BuildingUseCases,
434 acp_use_cases: &AcpUseCases,
435) -> Result<(), AppError> {
436 if user.is_superadmin() {
437 return Ok(());
438 }
439
440 let quote = quote_use_cases
441 .get_quote(quote_id)
442 .await
443 .map_err(AppError::from)?
444 .ok_or(AppError::NotFound(format!("Quote not found: {quote_id}")))?;
445
446 let building_id = Uuid::parse_str("e.building_id)
447 .map_err(|_| AppError::Internal("Invalid quote.building_id format".to_string()))?;
448
449 verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
450}
451
452/// Vérifie le mandat de l'appelant sur l'organisation d'un **échange local**.
453///
454/// ── Ce que cette garde protège ─────────────────────────────────────────────
455///
456/// Le système d'échange local — le SEL — enregistre qui rend quel service à
457/// qui, et pour combien de crédits. Six routes agissent sur un échange par son
458/// seul identifiant : le demander, le démarrer, le clore, l'annuler, noter le
459/// prestataire, noter le demandeur.
460///
461/// Les deux dernières comptent particulièrement : une note engage la
462/// réputation d'un voisin dans sa propre copropriété. La poser depuis une
463/// autre ACP n'a aucun sens légitime.
464///
465/// ── Pourquoi elle délègue ─────────────────────────────────────────────────
466///
467/// Comme les dix autres gardes du module, elle remonte jusqu'à l'immeuble puis
468/// confie la comparaison à `verify_building_org_access`. C'est la duplication
469/// de cette comparaison, recopiée à la main dans chaque gestionnaire, que
470/// l'issue #772 désigne comme la cause première de la fuite.
471pub async fn verify_exchange_org_access(
472 user: &AuthenticatedUser,
473 exchange_id: Uuid,
474 exchange_use_cases: &LocalExchangeUseCases,
475 building_use_cases: &BuildingUseCases,
476 acp_use_cases: &AcpUseCases,
477) -> Result<(), AppError> {
478 if user.is_superadmin() {
479 return Ok(());
480 }
481
482 let exchange = exchange_use_cases
483 .get_exchange(exchange_id)
484 .await
485 .map_err(AppError::from)?;
486
487 verify_building_org_access(
488 user,
489 exchange.building_id,
490 building_use_cases,
491 acp_use_cases,
492 )
493 .await
494}
495
496/// Vérifie le mandat de l'appelant sur l'organisation d'un **ticket**.
497///
498/// ── Ce que cette garde ajoute à ce qui existait ───────────────────────────
499///
500/// `syndic_response_handlers` appelait déjà `require_syndic_or_superadmin`, et
501/// ce contrôle est juste : répondre à un ticket est un acte de gestion, pas de
502/// copropriétaire.
503///
504/// Mais il vérifie le RÔLE, et rien d'autre. Un syndic de l'organisation A y
505/// passait pour répondre au ticket d'un copropriétaire de l'organisation B —
506/// et sa réponse s'y inscrivait, signée de son nom.
507///
508/// Les deux contrôles sont donc nécessaires et ne se remplacent pas : l'un dit
509/// « vous avez qualité pour cela », l'autre « ce ticket est bien le vôtre ».
510/// C'est la distinction que l'issue #772 demande de tenir, et que le tableau
511/// de bord du conseil (#816) illustrait déjà.
512pub async fn verify_ticket_org_access(
513 user: &AuthenticatedUser,
514 ticket_id: Uuid,
515 ticket_use_cases: &TicketUseCases,
516 building_use_cases: &BuildingUseCases,
517 acp_use_cases: &AcpUseCases,
518) -> Result<(), AppError> {
519 if user.is_superadmin() {
520 return Ok(());
521 }
522
523 let ticket = ticket_use_cases
524 .get_ticket(ticket_id)
525 .await
526 .map_err(AppError::from)?
527 .ok_or(AppError::NotFound(format!("Ticket not found: {ticket_id}")))?;
528
529 verify_building_org_access(user, ticket.building_id, building_use_cases, acp_use_cases).await
530}
531
532/// Vérifie le mandat de l'appelant sur l'ACP d'une **fiche technique**.
533///
534/// ── Pourquoi l'ACP, et non l'immeuble ─────────────────────────────────────
535///
536/// `TechnicalSpec` porte `acp_id: Uuid` **obligatoire** et
537/// `building_id: Option<Uuid>`. Une fiche peut donc concerner l'ACP entière
538/// sans viser un immeuble précis — un marché d'entretien couvrant tout le
539/// patrimoine, par exemple.
540///
541/// Remonter par l'immeuble aurait laissé sans contrôle toutes les fiches dont
542/// il est absent. Le périmètre juste est celui que l'entité rend obligatoire,
543/// et c'est l'ACP.
544///
545/// C'est le genre de choix qu'on ne peut pas recopier d'un garde voisin : les
546/// onze autres remontent à l'immeuble parce que leurs entités le portent
547/// toujours. Ici, suivre le modèle aurait produit un trou pour les fiches sans
548/// immeuble — soit exactement les plus larges (#772).
549pub async fn verify_technical_spec_org_access(
550 user: &AuthenticatedUser,
551 spec_id: Uuid,
552 spec_use_cases: &TechnicalSpecUseCases,
553 acp_use_cases: &AcpUseCases,
554) -> Result<(), AppError> {
555 if user.is_superadmin() {
556 return Ok(());
557 }
558
559 let spec = spec_use_cases.get(spec_id).await?;
560 verify_acp_org_access(user, spec.acp_id, acp_use_cases).await
561}
562
563/// Vérifie le mandat de l'appelant sur l'ACP d'un **appel de fonds**.
564///
565/// Un appel de fonds engage l'argent des copropriétaires : il dit combien
566/// chacun doit, et pour quoi. `POST /call-for-funds/{id}/send` le leur envoie
567/// — un acte qui, hors de son ACP, écrirait à des personnes qu'on n'a pas à
568/// contacter, au nom d'une copropriété qui n'est pas la sienne.
569///
570/// Le périmètre est l'ACP : `CallForFunds` porte `acp_id` obligatoire et ne
571/// vise pas d'immeuble. C'est cohérent avec l'Art. 3.86, où le fonds de
572/// roulement et le fonds de réserve appartiennent à l'association, non aux
573/// immeubles qu'elle regroupe.
574/// Vérifie le mandat de l'appelant sur l'ACP d'une **quote-part**.
575///
576/// `PUT /owner-contributions/{id}/mark-paid` déclare qu'un copropriétaire
577/// nommé a payé. C'est l'écriture la plus lourde de conséquence du produit
578/// après la clôture d'un vote : elle éteint une dette, et son absence de
579/// contrôle permettait de le faire dans la comptabilité d'une autre
580/// copropriété.
581///
582/// Le périmètre est l'ACP : `OwnerContribution` porte `acp_id` obligatoire et
583/// `unit_id: Option<Uuid>` — une quote-part peut n'être rattachée à aucun lot
584/// précis, notamment lors d'une régularisation.
585pub async fn verify_contribution_org_access(
586 user: &AuthenticatedUser,
587 contribution_id: Uuid,
588 contribution_use_cases: &OwnerContributionUseCases,
589 acp_use_cases: &AcpUseCases,
590) -> Result<(), AppError> {
591 if user.is_superadmin() {
592 return Ok(());
593 }
594
595 let contribution = contribution_use_cases
596 .get_contribution(contribution_id)
597 .await
598 .map_err(AppError::from)?
599 .ok_or(AppError::NotFound(format!(
600 "Contribution not found: {contribution_id}"
601 )))?;
602
603 verify_acp_org_access(user, contribution.acp_id, acp_use_cases).await
604}
605
606pub async fn verify_call_for_funds_org_access(
607 user: &AuthenticatedUser,
608 cff_id: Uuid,
609 cff_use_cases: &CallForFundsUseCases,
610 acp_use_cases: &AcpUseCases,
611) -> Result<(), AppError> {
612 if user.is_superadmin() {
613 return Ok(());
614 }
615
616 let cff = cff_use_cases
617 .get_call_for_funds(cff_id)
618 .await
619 .map_err(AppError::from)?
620 .ok_or(AppError::NotFound(format!(
621 "Call for funds not found: {cff_id}"
622 )))?;
623
624 verify_acp_org_access(user, cff.acp_id, acp_use_cases).await
625}
626
627/// Vérifie le mandat de l'appelant sur l'organisation d'un **sondage**.
628///
629/// Un sondage recueille l'avis des copropriétaires sur une question qui les
630/// concerne. Quatre routes agissent sur lui par son seul identifiant : lire
631/// ses résultats, le publier, le clore, l'annuler.
632///
633/// Lire les résultats d'un sondage d'une autre ACP, c'est apprendre ce que des
634/// voisins qui ne sont pas les vôtres pensent d'un sujet qui ne vous regarde
635/// pas. Le publier ou le clore depuis l'extérieur serait pire : cela
636/// interromprait une consultation en cours.
637///
638/// Le périmètre est l'immeuble — `Poll.building_id` est obligatoire — et non
639/// l'ACP, contrairement aux appels de fonds et aux fiches techniques. Une
640/// consultation porte sur la vie d'un bâtiment, pas sur le patrimoine d'une
641/// association.
642pub async fn verify_poll_org_access(
643 user: &AuthenticatedUser,
644 poll_id: Uuid,
645 poll_use_cases: &PollUseCases,
646 building_use_cases: &BuildingUseCases,
647 acp_use_cases: &AcpUseCases,
648) -> Result<(), AppError> {
649 if user.is_superadmin() {
650 return Ok(());
651 }
652
653 let poll = poll_use_cases
654 .get_poll(poll_id)
655 .await
656 .map_err(AppError::from)?;
657
658 let building_id = Uuid::parse_str(&poll.building_id)
659 .map_err(|_| AppError::Internal("Invalid poll.building_id format".to_string()))?;
660
661 verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
662}
663
664/// Vérifie le mandat de l'appelant sur l'immeuble d'une **annonce**.
665///
666/// Épingler ou désépingler une annonce la met en tête du tableau d'affichage
667/// de la copropriété. Le faire depuis une autre ACP, c'est décider de ce que
668/// des voisins qui ne sont pas les vôtres verront en premier.
669pub async fn verify_notice_org_access(
670 user: &AuthenticatedUser,
671 notice_id: Uuid,
672 notice_use_cases: &NoticeUseCases,
673 building_use_cases: &BuildingUseCases,
674 acp_use_cases: &AcpUseCases,
675) -> Result<(), AppError> {
676 if user.is_superadmin() {
677 return Ok(());
678 }
679
680 let notice = notice_use_cases
681 .get_notice(notice_id)
682 .await
683 .map_err(AppError::from)?;
684
685 verify_building_org_access(user, notice.building_id, building_use_cases, acp_use_cases).await
686}
687
688/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **compétence**.
689///
690/// Une offre de compétence nomme une personne et décrit ce qu'elle sait faire.
691/// C'est une donnée personnelle au sens du RGPD, et elle appartient à la
692/// communauté d'un immeuble, pas au premier venu qui en connaît l'identifiant.
693///
694/// `GET /skills/{id}` ne prenait AUCUNE identité (#845).
695pub async fn verify_skill_org_access(
696 user: &AuthenticatedUser,
697 skill_id: Uuid,
698 skill_use_cases: &SkillUseCases,
699 building_use_cases: &BuildingUseCases,
700 acp_use_cases: &AcpUseCases,
701) -> Result<(), AppError> {
702 if user.is_superadmin() {
703 return Ok(());
704 }
705
706 let skill = skill_use_cases
707 .get_skill(skill_id)
708 .await
709 .map_err(AppError::from)?;
710
711 verify_building_org_access(user, skill.building_id, building_use_cases, acp_use_cases).await
712}
713
714/// Vérifie le mandat de l'appelant sur l'ACP dont relève un **objet partagé**.
715///
716/// Prêter une perceuse à ses voisins ne revient pas à l'annoncer à qui connaît
717/// un UUID : l'annonce nomme son propriétaire et, indirectement, son adresse.
718///
719/// `GET /shared-objects/{id}` ne prenait AUCUNE identité (#845).
720pub async fn verify_shared_object_org_access(
721 user: &AuthenticatedUser,
722 object_id: Uuid,
723 shared_object_use_cases: &SharedObjectUseCases,
724 building_use_cases: &BuildingUseCases,
725 acp_use_cases: &AcpUseCases,
726) -> Result<(), AppError> {
727 if user.is_superadmin() {
728 return Ok(());
729 }
730
731 let objet = shared_object_use_cases
732 .get_shared_object(object_id)
733 .await
734 .map_err(AppError::from)?;
735
736 verify_building_org_access(user, objet.building_id, building_use_cases, acp_use_cases).await
737}
738
739/// Vérifie le mandat de l'appelant sur l'organisation d'un **défi**.
740///
741/// ── Pourquoi l'organisation, et non l'immeuble ────────────────────────────
742///
743/// `Challenge` porte `organization_id` obligatoire et `building_id: Option`,
744/// dont le commentaire d'origine dit tout : « None = organization-wide ». Un
745/// défi énergétique peut concerner tout un cabinet de syndic, plusieurs
746/// copropriétés à la fois.
747///
748/// Remonter par l'immeuble aurait laissé sans contrôle exactement les défis
749/// les plus larges — le même piège que pour les fiches techniques, dont
750/// `building_id` est optionnel aussi.
751pub async fn verify_challenge_org_access(
752 user: &AuthenticatedUser,
753 challenge_id: Uuid,
754 challenge_use_cases: &ChallengeUseCases,
755) -> Result<(), AppError> {
756 if user.is_superadmin() {
757 return Ok(());
758 }
759
760 let challenge = challenge_use_cases
761 .get_challenge(challenge_id)
762 .await
763 .map_err(AppError::from)?;
764
765 user.verify_org_access(challenge.organization_id)
766 .map_err(AppError::Forbidden)
767}
768
769pub async fn verify_booking_org_access(
770 user: &AuthenticatedUser,
771 booking_id: Uuid,
772 booking_use_cases: &ResourceBookingUseCases,
773 building_use_cases: &BuildingUseCases,
774 acp_use_cases: &AcpUseCases,
775) -> Result<(), AppError> {
776 if user.is_superadmin() {
777 return Ok(());
778 }
779
780 let booking = booking_use_cases
781 .get_booking(booking_id)
782 .await
783 .map_err(AppError::from)?;
784
785 verify_building_org_access(user, booking.building_id, building_use_cases, acp_use_cases).await
786}
787
788pub async fn verify_unit_org_access(
789 user: &AuthenticatedUser,
790 unit_id: Uuid,
791 unit_use_cases: &UnitUseCases,
792 building_use_cases: &BuildingUseCases,
793 acp_use_cases: &AcpUseCases,
794) -> Result<(), AppError> {
795 if user.is_superadmin() {
796 return Ok(());
797 }
798
799 let unit = unit_use_cases
800 .get_unit(unit_id)
801 .await
802 .map_err(AppError::from)?
803 .ok_or(AppError::NotFound(format!("Unit not found: {unit_id}")))?;
804
805 let building_id = Uuid::parse_str(&unit.building_id)
806 .map_err(|_| AppError::Internal("Invalid unit.building_id format".to_string()))?;
807
808 verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
809}
810
811/// Vérifie le mandat de l'appelant sur l'organisation d'un **copropriétaire**.
812///
813/// ── Pourquoi cette garde-ci compte plus que les autres ─────────────────────
814///
815/// Les routes portées par un copropriétaire servent, nominativement, ce qu'une
816/// personne doit et ce qu'elle a payé :
817///
818/// ```text
819/// GET /owners/{id}/payments montants et dates
820/// GET /owners/{id}/payments/total ce qu'elle a versé
821/// GET /owners/{id}/payment-methods ses instruments enregistrés
822/// GET /owners/{id}/payment-reminders ses rappels, donc ses retards
823/// GET /owners/{id}/distributions sa quote-part de chaque charge
824/// GET /owners/{id}/total-due ce qu'elle doit
825/// ```
826///
827/// Un identifiant de copropriétaire suffisait à les obtenir. Ce n'est pas un
828/// écart de périmètre, c'est la situation financière d'une personne nommée
829/// servie à qui la demande — au sens du RGPD, une violation de données.
830///
831/// ── Le chemin est court, et c'est ce qui le rend sûr ───────────────────────
832///
833/// `Owner` porte directement son `organization_id` (`domain/copropriete/owner.rs:9`).
834/// Pas de chaîne à remonter, donc pas de chaîne à recopier de travers — c'est
835/// la recopie manuelle d'une chaîne de quatre sauts qui avait laissé fuir les
836/// bulletins de vote nominatifs (RN-2, issue #772).
837///
838/// Sémantique : superadmin passe ; sinon le copropriétaire doit exister et
839/// relever de l'organisation de l'appelant. Un copropriétaire introuvable est
840/// refusé plutôt qu'ignoré.
841pub async fn verify_owner_org_access(
842 user: &AuthenticatedUser,
843 owner_id: Uuid,
844 owner_use_cases: &OwnerUseCases,
845) -> Result<(), AppError> {
846 if user.is_superadmin() {
847 return Ok(());
848 }
849
850 let owner = owner_use_cases
851 .get_owner(owner_id)
852 .await
853 .map_err(AppError::from)?
854 .ok_or(AppError::NotFound(format!("Owner not found: {owner_id}")))?;
855
856 let org_id = Uuid::parse_str(&owner.organization_id)
857 .map_err(|_| AppError::Internal("Invalid owner.organization_id format".to_string()))?;
858
859 user.verify_org_access(org_id)
860 .map_err(|_| AppError::Forbidden("Owner outside your organization".to_string()))
861}
862
863/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **assemblée**.
864///
865/// Remonte AG → immeuble → ACP → organisation. Cette chaîne était recopiée à
866/// la main dans chaque gestionnaire qui en avait besoin, et c'est ainsi
867/// qu'elle a fini par manquer : `GET /meetings/{id}/resolutions` et
868/// `GET /resolutions/{id}/votes` rendaient `200` sur les données d'une autre
869/// copropriété, bulletins nominatifs compris (RN-2, issue #772).
870///
871/// Une AG introuvable est refusée, jamais ignorée : c'est précisément le cas
872/// où l'on ne sait pas à qui elle appartient.
873pub async fn verify_meeting_org_access(
874 user: &AuthenticatedUser,
875 meeting_id: Uuid,
876 meeting_use_cases: &crate::application::use_cases::MeetingUseCases,
877 building_use_cases: &BuildingUseCases,
878 acp_use_cases: &AcpUseCases,
879) -> Result<(), AppError> {
880 if user.is_superadmin() {
881 return Ok(());
882 }
883
884 let meeting = meeting_use_cases
885 .get_meeting(meeting_id)
886 .await
887 .map_err(AppError::from)?
888 .ok_or(AppError::NotFound(format!(
889 "Meeting not found: {meeting_id}"
890 )))?;
891
892 verify_building_org_access(user, meeting.building_id, building_use_cases, acp_use_cases).await
893}
894
895/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **dépense**.
896///
897/// Remonte dépense → immeuble → ACP → organisation. Même motif que
898/// `verify_meeting_org_access` : une dépense porte des montants et souvent des
899/// pièces jointes nominatives — factures, devis — qui n'ont pas à circuler
900/// entre cabinets.
901pub async fn verify_expense_org_access(
902 user: &AuthenticatedUser,
903 expense_id: Uuid,
904 expense_use_cases: &crate::application::use_cases::ExpenseUseCases,
905 building_use_cases: &BuildingUseCases,
906 acp_use_cases: &AcpUseCases,
907) -> Result<(), AppError> {
908 if user.is_superadmin() {
909 return Ok(());
910 }
911
912 let depense = expense_use_cases
913 .get_expense(expense_id)
914 .await
915 .map_err(AppError::from)?
916 .ok_or(AppError::NotFound(format!(
917 "Expense not found: {expense_id}"
918 )))?;
919
920 let building_id = Uuid::parse_str(&depense.building_id)
921 .map_err(|_| AppError::Internal("Invalid expense.building_id format".to_string()))?;
922
923 verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
924}
925
926// ============================================================================
927// Actix middleware
928// ============================================================================
929
930/// `ScopeGuard` Actix middleware factory.
931///
932/// Wrap your routes with:
933/// ```rust,ignore
934/// use actix_web::web;
935/// use koprogo_api::infrastructure::web::middleware::ScopeGuard;
936///
937/// cfg.service(
938/// web::scope("/buildings")
939/// .wrap(ScopeGuard::new())
940/// // ... .service(...)
941/// );
942/// ```
943#[derive(Clone, Default)]
944pub struct ScopeGuard;
945
946impl ScopeGuard {
947 pub fn new() -> Self {
948 Self
949 }
950}
951
952impl<S, B> Transform<S, ServiceRequest> for ScopeGuard
953where
954 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
955 S::Future: 'static,
956 B: MessageBody + 'static,
957{
958 type Response = ServiceResponse<EitherBody<B>>;
959 type Error = Error;
960 type InitError = ();
961 type Transform = ScopeGuardMiddleware<S>;
962 type Future = Ready<Result<Self::Transform, Self::InitError>>;
963
964 fn new_transform(&self, service: S) -> Self::Future {
965 ready(Ok(ScopeGuardMiddleware {
966 service: Arc::new(service),
967 }))
968 }
969}
970
971pub struct ScopeGuardMiddleware<S> {
972 service: Arc<S>,
973}
974
975impl<S, B> Service<ServiceRequest> for ScopeGuardMiddleware<S>
976where
977 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
978 S::Future: 'static,
979 B: MessageBody + 'static,
980{
981 type Response = ServiceResponse<EitherBody<B>>;
982 type Error = Error;
983 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
984
985 forward_ready!(service);
986
987 fn call(&self, req: ServiceRequest) -> Self::Future {
988 let service = self.service.clone();
989
990 // 1. Extract JWT claims.
991 let app_state = match req.app_data::<web::Data<AppState>>() {
992 Some(s) => s.clone(),
993 None => {
994 let err = ScopeGuardError::Internal("AppState missing".into());
995 let resp = req.into_response(err.error_response().map_into_right_body());
996 return Box::pin(async move { Ok(resp) });
997 }
998 };
999
1000 let auth_header = req
1001 .headers()
1002 .get("Authorization")
1003 .and_then(|h| h.to_str().ok())
1004 .map(|s| s.to_string());
1005
1006 let token = match auth_header.as_deref() {
1007 Some(h) if h.starts_with("Bearer ") => {
1008 h.trim_start_matches("Bearer ").trim().to_string()
1009 }
1010 _ => {
1011 let err = ScopeGuardError::Unauthorized;
1012 let resp = req.into_response(err.error_response().map_into_right_body());
1013 return Box::pin(async move { Ok(resp) });
1014 }
1015 };
1016
1017 let claims = match app_state.auth_use_cases.verify_token(&token) {
1018 Ok(c) => c,
1019 Err(_) => {
1020 let err = ScopeGuardError::Unauthorized;
1021 let resp = req.into_response(err.error_response().map_into_right_body());
1022 return Box::pin(async move { Ok(resp) });
1023 }
1024 };
1025
1026 let user_id = match Uuid::parse_str(&claims.sub) {
1027 Ok(u) => u,
1028 Err(_) => {
1029 let err = ScopeGuardError::Unauthorized;
1030 let resp = req.into_response(err.error_response().map_into_right_body());
1031 return Box::pin(async move { Ok(resp) });
1032 }
1033 };
1034
1035 let caller = caller_from_role(&claims.role, claims.organization_id, user_id);
1036
1037 // 2. Extract requested acp_id from header OR query string.
1038 let header_val = req
1039 .headers()
1040 .get(SCOPE_ACP_HEADER)
1041 .and_then(|v| v.to_str().ok())
1042 .map(|s| s.to_string());
1043 // Parse `?acp_id=` ourselves to avoid double-Deserialize collisions
1044 // with arbitrary handlers' Query<T> extractors.
1045 let query_val = req.query_string().split('&').find_map(|kv| {
1046 let mut it = kv.splitn(2, '=');
1047 match (it.next(), it.next()) {
1048 (Some("acp_id"), Some(v)) => Some(v.to_string()),
1049 _ => None,
1050 }
1051 });
1052
1053 let requested_acp_id =
1054 match extract_requested_acp_id(header_val.as_deref(), query_val.as_deref()) {
1055 Ok(v) => v,
1056 Err(err) => {
1057 let resp = req.into_response(err.error_response().map_into_right_body());
1058 return Box::pin(async move { Ok(resp) });
1059 }
1060 };
1061
1062 let check = match requires_repository_check(&caller, requested_acp_id) {
1063 Ok(v) => v,
1064 Err(err) => {
1065 let resp = req.into_response(err.error_response().map_into_right_body());
1066 return Box::pin(async move { Ok(resp) });
1067 }
1068 };
1069
1070 // 3. Optional repository check.
1071 Box::pin(async move {
1072 if let Some(acp_id) = check {
1073 if let Err(app_err) = app_state
1074 .acp_use_cases
1075 .assert_can_see_acp(&caller, acp_id)
1076 .await
1077 {
1078 let guard_err = ScopeGuardError::from(app_err);
1079 let resp = req.into_response(guard_err.error_response().map_into_right_body());
1080 return Ok(resp);
1081 }
1082 }
1083
1084 // 4. Inject AcpScope into request extensions for handlers.
1085 req.extensions_mut().insert(AcpScope {
1086 caller: caller.clone(),
1087 requested_acp_id,
1088 allowed: true,
1089 });
1090
1091 let res = service.call(req).await?;
1092 Ok(res.map_into_left_body())
1093 })
1094 }
1095}
1096
1097// ============================================================================
1098// Tests — taxonomie 4-cat (CRITICAL.md §3). Pure helpers only ; the
1099// Transform integration is exercised end-to-end via the BDD harness
1100// `tests/features/list_buildings_role_based.feature`.
1101// ============================================================================
1102
1103#[cfg(test)]
1104mod tests {
1105 use super::*;
1106
1107 // ----- @happy --------------------------------------------------------------
1108
1109 #[test]
1110 fn happy_caller_from_role_admin() {
1111 let org = Uuid::new_v4();
1112 let c = caller_from_role("admin", Some(org), Uuid::new_v4());
1113 assert!(matches!(c, AcpCaller::Admin { organization_id } if organization_id == org));
1114 }
1115
1116 #[test]
1117 fn happy_extract_acp_id_from_header() {
1118 let id = Uuid::new_v4();
1119 let s = id.to_string();
1120 let got = extract_requested_acp_id(Some(&s), None).unwrap();
1121 assert_eq!(got, Some(id));
1122 }
1123
1124 #[test]
1125 fn happy_extract_acp_id_from_query_when_no_header() {
1126 let id = Uuid::new_v4();
1127 let s = id.to_string();
1128 let got = extract_requested_acp_id(None, Some(&s)).unwrap();
1129 assert_eq!(got, Some(id));
1130 }
1131
1132 // ----- @edge ---------------------------------------------------------------
1133
1134 #[test]
1135 fn edge_header_takes_precedence_over_query() {
1136 let h = Uuid::new_v4();
1137 let q = Uuid::new_v4();
1138 let got = extract_requested_acp_id(Some(&h.to_string()), Some(&q.to_string())).unwrap();
1139 assert_eq!(got, Some(h));
1140 }
1141
1142 #[test]
1143 fn edge_empty_header_treated_as_none() {
1144 let got = extract_requested_acp_id(Some(""), None).unwrap();
1145 assert!(got.is_none());
1146 }
1147
1148 #[test]
1149 fn edge_super_admin_with_no_scope_is_unrestricted() {
1150 let r = requires_repository_check(&AcpCaller::SuperAdmin, None).unwrap();
1151 assert!(r.is_none());
1152 }
1153
1154 // ----- @security -----------------------------------------------------------
1155
1156 #[test]
1157 fn security_owner_requested_acp_must_be_checked_via_repo() {
1158 let user = Uuid::new_v4();
1159 let target = Uuid::new_v4();
1160 let r =
1161 requires_repository_check(&AcpCaller::Owner { user_id: user }, Some(target)).unwrap();
1162 assert_eq!(r, Some(target));
1163 }
1164
1165 #[test]
1166 fn security_caller_from_role_unknown_falls_back_to_owner() {
1167 let uid = Uuid::new_v4();
1168 let c = caller_from_role("contractor", Some(Uuid::new_v4()), uid);
1169 assert!(matches!(c, AcpCaller::Owner { user_id } if user_id == uid));
1170 }
1171
1172 // ----- @negative -----------------------------------------------------------
1173
1174 #[test]
1175 fn negative_malformed_header_returns_validation_error() {
1176 let err = extract_requested_acp_id(Some("not-a-uuid"), None).unwrap_err();
1177 assert!(matches!(err, ScopeGuardError::Validation(_)));
1178 }
1179
1180 #[test]
1181 fn negative_scope_guard_error_kind_strings_are_stable() {
1182 assert_eq!(ScopeGuardError::Unauthorized.kind(), "unauthorized");
1183 assert_eq!(
1184 ScopeGuardError::AcpNotInScope {
1185 acp_id: Uuid::nil()
1186 }
1187 .kind(),
1188 "acp_not_in_scope"
1189 );
1190 assert_eq!(ScopeGuardError::Validation("x".into()).kind(), "validation");
1191 }
1192
1193 #[test]
1194 fn negative_apperror_acp_not_in_scope_maps_to_scopeguard_acp_not_in_scope() {
1195 let id = Uuid::new_v4();
1196 let g = ScopeGuardError::from(AppError::AcpNotInScope { acp_id: id });
1197 match g {
1198 ScopeGuardError::AcpNotInScope { acp_id } => assert_eq!(acp_id, id),
1199 other => panic!("expected AcpNotInScope, got {:?}", other),
1200 }
1201 }
1202
1203 #[test]
1204 fn negative_apperror_unauthorized_maps_to_scopeguard_unauthorized() {
1205 let g = ScopeGuardError::from(AppError::Unauthorized);
1206 assert!(matches!(g, ScopeGuardError::Unauthorized));
1207 }
1208}