koprogo_api/application/
error.rs1use actix_web::{http::StatusCode, HttpResponse, ResponseError};
26use serde_json::json;
27use thiserror::Error;
28
29#[derive(Error, Debug)]
34pub enum AppError {
35 #[error("Validation error: {0}")]
37 Validation(String),
38
39 #[error("Authentication required")]
41 Unauthorized,
42
43 #[error("Invalid credentials")]
47 InvalidCredentials,
48
49 #[error("Token error: {0}")]
51 TokenError(String),
52
53 #[error("Access forbidden: {0}")]
55 Forbidden(String),
56
57 #[error("Account deactivated")]
61 AccountDeactivated,
62
63 #[error("Resource not found: {0}")]
65 NotFound(String),
66
67 #[error("Conflict: {0}")]
69 Conflict(String),
70
71 #[error("Rate limit exceeded")]
73 RateLimited,
74
75 #[error("Database error: {0}")]
77 Database(String),
78
79 #[error("Cryptographic error: {0}")]
81 Crypto(String),
82
83 #[error("Internal server error: {0}")]
86 Internal(String),
87}
88
89impl AppError {
90 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 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
143impl 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#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[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 #[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 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 #[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 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 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 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 let _ = body;
299 assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
300 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 #[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 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 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}