Skip to main content

koprogo_api/application/
error.rs

1//! Application-level error type.
2//!
3//! `AppError` is the typed error used across all use cases and handlers,
4//! replacing the legacy `Result<_, String>` pattern (cf. issues #425, #427).
5//!
6//! Migration started in story AUTH-001 (auth_use_cases.rs).
7//!
8//! # Design
9//!
10//! - `thiserror` for ergonomic error definitions.
11//! - `actix_web::ResponseError` impl maps each variant to the right HTTP status.
12//! - `From<String>` is intentionally provided as a transition convenience for
13//!   repositories still returning `Result<_, String>`. Variants should be used
14//!   directly when a specific error semantic applies.
15//!
16//! # Anti-patterns explicitly avoided
17//!
18//! - Leaking sensitive data in error messages exposed to clients (DB connection
19//!   strings, internal IPs, stack traces). The `error_response()` body returns
20//!   a structured payload; redaction policy will be enforced in a follow-up RFC
21//!   (see #429 §6 and `astro-svelte-expert.memory.md`).
22//! - Returning generic `Internal` for everything (defeats the purpose of typed
23//!   errors and HTTP status discrimination).
24
25use actix_web::{http::StatusCode, HttpResponse, ResponseError};
26use serde_json::json;
27use thiserror::Error;
28
29/// Application-level error.
30///
31/// Each variant maps to a specific HTTP status code via `ResponseError`.
32/// See module-level docs for usage guidelines.
33#[derive(Error, Debug)]
34pub enum AppError {
35    /// Input validation failed (bad request payload, missing fields, format errors).
36    #[error("Validation error: {0}")]
37    Validation(String),
38
39    /// Authentication required but not provided / token missing.
40    #[error("Authentication required")]
41    Unauthorized,
42
43    /// Provided credentials are invalid.
44    /// Used uniformly for "email not found" AND "wrong password" to prevent
45    /// username enumeration attacks.
46    #[error("Invalid credentials")]
47    InvalidCredentials,
48
49    /// Token expired, malformed, or revoked.
50    #[error("Token error: {0}")]
51    TokenError(String),
52
53    /// User is authenticated but lacks the required role/permission.
54    #[error("Access forbidden: {0}")]
55    Forbidden(String),
56
57    /// User account exists but is deactivated.
58    /// NOTE: returning a distinct error from `InvalidCredentials` may leak
59    /// account existence — security review needed for `auth/login` flow.
60    #[error("Account deactivated")]
61    AccountDeactivated,
62
63    /// Resource not found (e.g., user by id, building by id).
64    #[error("Resource not found: {0}")]
65    NotFound(String),
66
67    /// Conflict (e.g., email already in use, ownership total > 100%).
68    #[error("Conflict: {0}")]
69    Conflict(String),
70
71    /// Rate limit exceeded.
72    #[error("Rate limit exceeded")]
73    RateLimited,
74
75    /// Database error (sqlx, connection, query). Internal — not surfaced verbatim to clients.
76    #[error("Database error: {0}")]
77    Database(String),
78
79    /// Cryptographic error (bcrypt, JWT signing).
80    #[error("Cryptographic error: {0}")]
81    Crypto(String),
82
83    /// Catch-all for legacy `Result<_, String>` propagation.
84    /// Should be reduced over time as repositories migrate.
85    #[error("Internal server error: {0}")]
86    Internal(String),
87}
88
89impl AppError {
90    /// Stable string identifier for the error kind.
91    /// Used in `error_response` JSON payload and logging.
92    pub fn kind(&self) -> &'static str {
93        match self {
94            AppError::Validation(_) => "validation",
95            AppError::Unauthorized => "unauthorized",
96            AppError::InvalidCredentials => "invalid_credentials",
97            AppError::TokenError(_) => "token_error",
98            AppError::Forbidden(_) => "forbidden",
99            AppError::AccountDeactivated => "account_deactivated",
100            AppError::NotFound(_) => "not_found",
101            AppError::Conflict(_) => "conflict",
102            AppError::RateLimited => "rate_limited",
103            AppError::Database(_) => "database",
104            AppError::Crypto(_) => "crypto",
105            AppError::Internal(_) => "internal",
106        }
107    }
108}
109
110impl ResponseError for AppError {
111    fn status_code(&self) -> StatusCode {
112        match self {
113            AppError::Validation(_) => StatusCode::BAD_REQUEST,
114            AppError::Unauthorized | AppError::InvalidCredentials | AppError::TokenError(_) => {
115                StatusCode::UNAUTHORIZED
116            }
117            AppError::Forbidden(_) | AppError::AccountDeactivated => StatusCode::FORBIDDEN,
118            AppError::NotFound(_) => StatusCode::NOT_FOUND,
119            AppError::Conflict(_) => StatusCode::CONFLICT,
120            AppError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
121            AppError::Database(_) | AppError::Crypto(_) | AppError::Internal(_) => {
122                StatusCode::INTERNAL_SERVER_ERROR
123            }
124        }
125    }
126
127    fn error_response(&self) -> HttpResponse {
128        // Public-facing message: short and non-leaky for internal variants.
129        let public_message = match self {
130            AppError::Database(_) | AppError::Crypto(_) | AppError::Internal(_) => {
131                "Internal server error".to_string()
132            }
133            other => other.to_string(),
134        };
135
136        HttpResponse::build(self.status_code()).json(json!({
137            "error": public_message,
138            "kind": self.kind(),
139        }))
140    }
141}
142
143/// Transition convenience: convert legacy `String` errors from repositories
144/// into `AppError::Internal`. Should be used sparingly via `.map_err(AppError::from)`
145/// at the boundary; prefer dedicated variants when the error semantic is known.
146impl From<String> for AppError {
147    fn from(s: String) -> Self {
148        AppError::Internal(s)
149    }
150}
151
152impl From<&str> for AppError {
153    fn from(s: &str) -> Self {
154        AppError::Internal(s.to_string())
155    }
156}
157
158impl From<bcrypt::BcryptError> for AppError {
159    fn from(e: bcrypt::BcryptError) -> Self {
160        AppError::Crypto(e.to_string())
161    }
162}
163
164impl From<jsonwebtoken::errors::Error> for AppError {
165    fn from(e: jsonwebtoken::errors::Error) -> Self {
166        AppError::TokenError(e.to_string())
167    }
168}
169
170// ============================================================================
171// Tests — taxonomie 4 catégories obligatoire (cf. CRITICAL.md règle #3, #427)
172// ============================================================================
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    // ------------------------------------------------------------------------
179    // @happy — chemin nominal
180    // ------------------------------------------------------------------------
181
182    #[test]
183    fn happy_validation_error_maps_to_400() {
184        let e = AppError::Validation("email required".into());
185        assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
186        assert_eq!(e.kind(), "validation");
187    }
188
189    #[test]
190    fn happy_invalid_credentials_maps_to_401() {
191        let e = AppError::InvalidCredentials;
192        assert_eq!(e.status_code(), StatusCode::UNAUTHORIZED);
193        assert_eq!(e.kind(), "invalid_credentials");
194    }
195
196    #[test]
197    fn happy_not_found_maps_to_404() {
198        let e = AppError::NotFound("user 123".into());
199        assert_eq!(e.status_code(), StatusCode::NOT_FOUND);
200        assert_eq!(e.kind(), "not_found");
201    }
202
203    #[test]
204    fn happy_conflict_maps_to_409() {
205        let e = AppError::Conflict("email already in use".into());
206        assert_eq!(e.status_code(), StatusCode::CONFLICT);
207    }
208
209    // ------------------------------------------------------------------------
210    // @edge — bornes, conversions, cas limites
211    // ------------------------------------------------------------------------
212
213    #[test]
214    fn edge_from_string_defaults_to_internal() {
215        let e: AppError = "legacy error".to_string().into();
216        match e {
217            AppError::Internal(msg) => assert_eq!(msg, "legacy error"),
218            other => panic!("expected Internal, got {:?}", other),
219        }
220    }
221
222    #[test]
223    fn edge_from_str_defaults_to_internal() {
224        let e: AppError = "static err".into();
225        match e {
226            AppError::Internal(msg) => assert_eq!(msg, "static err"),
227            other => panic!("expected Internal, got {:?}", other),
228        }
229    }
230
231    #[test]
232    fn edge_empty_validation_message_still_produces_400() {
233        let e = AppError::Validation(String::new());
234        assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
235    }
236
237    #[test]
238    fn edge_kind_is_stable_string_for_each_variant() {
239        // Exhaustive: every variant returns a non-empty stable kind string.
240        let variants = [
241            AppError::Validation("".into()),
242            AppError::Unauthorized,
243            AppError::InvalidCredentials,
244            AppError::TokenError("".into()),
245            AppError::Forbidden("".into()),
246            AppError::AccountDeactivated,
247            AppError::NotFound("".into()),
248            AppError::Conflict("".into()),
249            AppError::RateLimited,
250            AppError::Database("".into()),
251            AppError::Crypto("".into()),
252            AppError::Internal("".into()),
253        ];
254        for v in variants {
255            assert!(!v.kind().is_empty(), "kind() empty for {:?}", v);
256        }
257    }
258
259    // ------------------------------------------------------------------------
260    // @security — RBAC, auth, leakage
261    // ------------------------------------------------------------------------
262
263    #[test]
264    fn security_rate_limited_maps_to_429() {
265        let e = AppError::RateLimited;
266        assert_eq!(e.status_code(), StatusCode::TOO_MANY_REQUESTS);
267        assert_eq!(e.kind(), "rate_limited");
268    }
269
270    #[test]
271    fn security_forbidden_maps_to_403_not_404() {
272        // Returning 403 (not 404) on Forbidden tells the client the resource
273        // exists but is denied — acceptable when the existence is not a secret.
274        // For secret resources, use NotFound instead.
275        let e = AppError::Forbidden("requires syndic role".into());
276        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
277    }
278
279    #[test]
280    fn security_token_error_maps_to_401_not_403() {
281        // Token errors are auth failures, not authz failures.
282        let e = AppError::TokenError("expired".into());
283        assert_eq!(e.status_code(), StatusCode::UNAUTHORIZED);
284    }
285
286    #[test]
287    fn security_database_error_message_is_not_leaked_in_response_body() {
288        // Sensitive internal details (connection strings, IPs, stack traces) MUST
289        // not leak to clients. error_response replaces the message with a generic one.
290        let e = AppError::Database(
291            "PostgreSQL: connection refused 192.168.1.5:5432 user=admin password=...".into(),
292        );
293        let resp = e.error_response();
294        let body = resp.into_body();
295        // We can't easily extract the JSON body in tests without deserialization,
296        // but we know error_response uses the public_message branch for Database.
297        // Sanity check at least: status code is 500 (internal).
298        let _ = body;
299        assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
300        // Direct test of the public message logic:
301        let public = match &e {
302            AppError::Database(_) => "Internal server error".to_string(),
303            other => other.to_string(),
304        };
305        assert_eq!(public, "Internal server error");
306    }
307
308    // ------------------------------------------------------------------------
309    // @negative — défaillance correcte (pas de panic, erreur typée)
310    // ------------------------------------------------------------------------
311
312    #[test]
313    fn negative_internal_variant_maps_to_500() {
314        let e = AppError::Internal("oops".into());
315        assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
316        assert_eq!(e.kind(), "internal");
317    }
318
319    #[test]
320    fn negative_account_deactivated_maps_to_403() {
321        let e = AppError::AccountDeactivated;
322        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
323        assert_eq!(e.kind(), "account_deactivated");
324    }
325
326    #[test]
327    fn negative_crypto_error_maps_to_500_not_401() {
328        // bcrypt failures are server-side issues, not auth failures.
329        let e = AppError::Crypto("hash format invalid".into());
330        assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
331    }
332
333    #[test]
334    fn negative_display_format_includes_message() {
335        // thiserror Display impl must include the wrapped message for logs.
336        let e = AppError::Database("connection refused".into());
337        let s = format!("{}", e);
338        assert!(
339            s.contains("connection refused"),
340            "Display should include detail: {}",
341            s
342        );
343    }
344}