Skip to main content

headless_lms_server/domain/
error.rs

1/*!
2Contains error and result types for all the controllers.
3*/
4
5use std::panic::Location;
6
7use crate::domain::authorization::AuthorizedResponse;
8use actix_web::{
9    HttpResponse, HttpResponseBuilder, error,
10    http::{StatusCode, header::ContentType},
11};
12use backtrace::Backtrace;
13use derive_more::Display;
14use dpop_verifier::error::DpopError;
15use headless_lms_authorization::error::{AuthorizationError, AuthorizationErrorType};
16use headless_lms_base::error::{backend_error::BackendError, clean_format::ColorChoice};
17use headless_lms_chatbot::prelude::{ChatbotError, ChatbotErrorType};
18use headless_lms_models::{ModelError, ModelErrorType, prelude::UtilErrorType};
19use headless_lms_utils::error::util_error::{SisuErrorVariant, UtilError};
20use serde::{Deserialize, Serialize};
21use tracing_error::SpanTrace;
22
23use uuid::Uuid;
24
25const MISSING_EXERCISE_TYPE_DESCRIPTION: &str = "Missing exercise type for exercise task.";
26
27/**
28Used as the result types for all controllers.
29Only put information here that you want to be visible to users.
30
31See also [ControllerError] for documentation on how to return errors from controllers.
32*/
33pub type ControllerResult<T, E = ControllerError> = std::result::Result<AuthorizedResponse<T>, E>;
34
35/// The type of [ControllerError] that occured.
36#[derive(Debug, Display, Serialize, Deserialize)]
37pub enum ControllerErrorType {
38    /// HTTP status code 500.
39    #[display("Internal server error")]
40    InternalServerError,
41
42    /// HTTP status code 400.
43    #[display("Bad request")]
44    BadRequest,
45
46    /// HTTP status code 400.
47    #[display("Bad request")]
48    BadRequestWithData(ErrorMetadata),
49
50    /// HTTP status code 422 with a specific domain reason, so clients can branch
51    /// on a stable `message_key` instead of parsing the human-readable message.
52    #[display("Bad request")]
53    BadRequestWithReason(BadRequestReason),
54
55    /// HTTP status code 426. The client is too old and must be upgraded.
56    #[display("Upgrade required")]
57    UpgradeRequired,
58
59    /// HTTP status code 404.
60    #[display("Not found")]
61    NotFound,
62
63    /// HTTP status code 401. Needs to log in.
64    #[display("Unauthorized")]
65    Unauthorized,
66
67    /// HTTP status code 401 with a specific domain reason.
68    #[display("Unauthorized")]
69    UnauthorizedWithReason(UnauthorizedReason),
70
71    /// HTTP status code 403. Is logged in but is not allowed to access the resource.
72    #[display("Forbidden")]
73    Forbidden,
74
75    /// Varied response based on error
76    #[display("OAuthError")]
77    OAuthError(Box<OAuthErrorData>),
78
79    /// SISUERROR
80    #[display("SisuError")]
81    SisuError(SisuErrorType),
82}
83
84#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum UnauthorizedReason {
87    #[display("Chapter not open yet")]
88    ChapterNotOpenYet,
89    #[display("Authentication required for exam exercise")]
90    AuthenticationRequiredForExamExercise,
91}
92
93impl UnauthorizedReason {
94    /// Returns the stable message key for this unauthorized reason.
95    fn message_key(self) -> &'static str {
96        match self {
97            Self::ChapterNotOpenYet => "chapter_not_open_yet",
98            Self::AuthenticationRequiredForExamExercise => {
99                "authentication_required_for_exam_exercise"
100            }
101        }
102    }
103}
104
105/// Builds a 422 whose `message_key` the client keys its error handling on.
106pub fn bad_request_with_reason(reason: BadRequestReason, message: String) -> ControllerError {
107    controller_err!(BadRequestWithReason(reason), message)
108}
109
110/// Bad request reasons a client can branch on. Only `CourseSlugAlreadyTaken` has a web-frontend
111/// translation; the rest are consumed by the VSCode client, which renders its own message.
112#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
113#[serde(rename_all = "snake_case")]
114pub enum BadRequestReason {
115    #[display("Course slug already taken")]
116    CourseSlugAlreadyTaken,
117    #[display("Foreign key violation")]
118    ForeignKeyViolation,
119    /// The user is not enrolled on the course the requested exercise belongs to.
120    #[display("Not enrolled")]
121    NotEnrolled,
122    /// A submission named a file that was uploaded but has since been reaped.
123    #[display("Upload expired")]
124    UploadExpired,
125    /// A submission named a file the host has no upload record of for this exercise and user.
126    #[display("Unknown upload")]
127    UnknownUpload,
128    /// A submission named the same uploaded file more than once.
129    #[display("Duplicate upload")]
130    DuplicateUpload,
131}
132
133impl BadRequestReason {
134    /// Returns the stable message key for this bad request reason.
135    fn message_key(self) -> &'static str {
136        match self {
137            Self::CourseSlugAlreadyTaken => "course_slug_already_taken",
138            Self::ForeignKeyViolation => "foreign_key_violation",
139            Self::NotEnrolled => "not_enrolled",
140            Self::UploadExpired => "upload_expired",
141            Self::UnknownUpload => "unknown_upload",
142            Self::DuplicateUpload => "duplicate_upload",
143        }
144    }
145
146    /// Both slug indexes collapse into one reason: they guard the same user mistake.
147    fn from_database_constraint(constraint: &str) -> Option<Self> {
148        match constraint {
149            "courses_slug_key_when_not_deleted"
150            | "course_language_groups_slug_unique_non_deleted" => {
151                Some(Self::CourseSlugAlreadyTaken)
152            }
153            _ => None,
154        }
155    }
156}
157
158#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
159#[serde(rename_all = "snake_case")]
160pub enum SisuErrorType {
161    #[display("invalid course code")]
162    InvalidCourseCode,
163    #[display("generic sisu error")]
164    GenericSisuError,
165    #[display("sisu resource not found")]
166    SisuResourceNotFound,
167}
168
169impl SisuErrorType {
170    /// Returns the stable message key for this unauthorized reason.
171    fn message_key(self) -> &'static str {
172        match self {
173            Self::InvalidCourseCode => "invalid_course_code",
174            Self::GenericSisuError => "generic_sisu_error",
175            Self::SisuResourceNotFound => "sisu_resource_not_found",
176        }
177    }
178}
179
180/**
181Represents error messages that are sent in responses. Used as the error type in [ControllerError], which is used by all the controllers in the application.
182
183All the information in the error is meant to be seen by the user. The type of error is determined by the [ControllerErrorType] enum, which is stored inside this struct. The type of the error determines which HTTP status code will be sent to the user.
184
185## Examples
186
187### Usage without source error
188
189```no_run
190# use headless_lms_server::prelude::*;
191# fn random_function() -> ControllerResult<web::Json<()>> {
192#    let token = skip_authorize();
193#    let erroneous_condition = 1 == 1;
194if erroneous_condition {
195    return Err(ControllerError::new(
196        ControllerErrorType::BadRequest,
197        "Cannot create a new account when signed in.".to_string(),
198        None,
199    ));
200}
201# token.authorized_ok(web::Json(()))
202# }
203```
204
205### Usage with a source error
206
207Used when calling a function that returns an error that cannot be automatically converted to an ControllerError. (See `impl From<X>` implementations on this struct.)
208
209```no_run
210# use headless_lms_server::prelude::*;
211# fn some_function_returning_an_error() -> ControllerResult<web::Json<()>> {
212#    return Err(ControllerError::new(
213#         ControllerErrorType::BadRequest,
214#         "Cannot create a new account when signed in.".to_string(),
215#         None,
216#     ));
217# }
218#
219# fn random_function() -> ControllerResult<web::Json<()>> {
220#    let token = skip_authorize();
221#    let erroneous_condition = 1 == 1;
222some_function_returning_an_error().map_err(|original_error| {
223    ControllerError::new(
224        ControllerErrorType::InternalServerError,
225        "Could not read file".to_string(),
226        Some(original_error.into()),
227    )
228})?;
229# token.authorized_ok(web::Json(()))
230# }
231```
232
233### Example HTTP response from an error
234
235```json
236{
237    "title": "Internal Server Error",
238    "message": "pool timed out while waiting for an open connection",
239    "source": "source of error"
240}
241```
242*/
243pub struct ControllerError {
244    error_type: <ControllerError as BackendError>::ErrorType,
245    message: String,
246    /// Original error that caused this error.
247    source: Option<anyhow::Error>,
248    /// A trace of tokio tracing spans, generated automatically when the error is generated.
249    span_trace: Box<SpanTrace>,
250    /// Stack trace, generated automatically when the error is created.
251    backtrace: Box<Backtrace>,
252    /// Source location where the error was raised.
253    location: Option<&'static Location<'static>>,
254}
255
256// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
257headless_lms_base::impl_clean_debug!(
258    ControllerError,
259    [
260        ControllerError,
261        AuthorizationError,
262        ChatbotError,
263        ModelError,
264        UtilError
265    ]
266);
267
268impl std::error::Error for ControllerError {
269    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
270        self.source
271            .as_deref()
272            .map(|e| e as &(dyn std::error::Error + 'static))
273    }
274
275    fn cause(&self) -> Option<&dyn std::error::Error> {
276        self.source()
277    }
278}
279
280impl std::fmt::Display for ControllerError {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        write!(
283            f,
284            "ControllerError {:?} {:?}",
285            self.error_type, self.message
286        )
287    }
288}
289
290impl BackendError for ControllerError {
291    type ErrorType = ControllerErrorType;
292
293    fn backtrace(&self) -> Option<&Backtrace> {
294        Some(&self.backtrace)
295    }
296
297    fn error_type(&self) -> &Self::ErrorType {
298        &self.error_type
299    }
300
301    fn message(&self) -> &str {
302        &self.message
303    }
304
305    fn span_trace(&self) -> &SpanTrace {
306        &self.span_trace
307    }
308
309    fn location(&self) -> Option<&'static Location<'static>> {
310        self.location
311    }
312
313    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
314        error_type: Self::ErrorType,
315        message: M,
316        source_error: S,
317        backtrace: Backtrace,
318        span_trace: SpanTrace,
319        location: Option<&'static Location<'static>>,
320    ) -> Self {
321        Self {
322            error_type,
323            message: message.into(),
324            source: source_error.into(),
325            span_trace: Box::new(span_trace),
326            backtrace: Box::new(backtrace),
327            location,
328        }
329    }
330}
331
332#[derive(Debug, Serialize, Deserialize, Clone)]
333#[serde(rename_all = "snake_case")]
334pub enum ErrorMetadata {
335    BlockId(Uuid),
336}
337
338#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
339pub struct ApiErrorIssue {
340    pub path: Option<String>,
341    pub code: Option<String>,
342    pub message: String,
343}
344
345#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
346#[serde(rename_all = "snake_case")]
347pub enum ValidationIssueCode {
348    MissingExerciseType,
349}
350
351impl ValidationIssueCode {
352    /// Returns stable API-facing code value for validation issues.
353    fn as_api_code(self) -> String {
354        serde_json::to_value(self)
355            .ok()
356            .and_then(|v| v.as_str().map(|code| code.to_string()))
357            .unwrap_or_else(|| "unknown_validation_issue".to_string())
358    }
359}
360
361/// Canonical API error envelope returned for controlled application errors.
362#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
363pub struct ApiErrorResponse {
364    #[serde(rename = "type")]
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub error_type: Option<String>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub message_key: Option<String>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub message: Option<String>,
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub errors: Vec<ApiErrorIssue>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub metadata: Option<serde_json::Value>,
375}
376
377impl error::ResponseError for ControllerError {
378    fn error_response(&self) -> HttpResponse {
379        if let ControllerErrorType::InternalServerError = &self.error_type {
380            // Clean format, colored only on a TTY (Auto); the DB report below stays plain.
381            error!(
382                "Internal server error:\n{}",
383                self.clean_string(ColorChoice::Auto)
384            );
385
386            if let Some(pool) = crate::domain::internal_error_reporting::error_reporting_pool() {
387                let pool = pool.clone();
388                let message = self.message.clone();
389                let stack_trace = format!("{:?}", self);
390                let details = serde_json::json!({
391                    "kind": "controller_error",
392                    "controller_error_type": self.error_type.to_string(),
393                });
394
395                // Uses the main DB pool intentionally for best-effort reporting; this can amplify outage pressure.
396                actix_web::rt::spawn(async move {
397                    let mut conn = match tokio::time::timeout(
398                        std::time::Duration::from_millis(250),
399                        pool.acquire(),
400                    )
401                    .await
402                    {
403                        Ok(Ok(conn)) => conn,
404                        Ok(Err(err)) => {
405                            warn!(
406                                "internal error reporting skipped: failed to acquire pool connection: {err}"
407                            );
408                            return;
409                        }
410                        Err(_) => {
411                            warn!(
412                                "internal error reporting skipped: timed out acquiring pool connection"
413                            );
414                            return;
415                        }
416                    };
417                    let report = headless_lms_models::errors::NewErrorReport {
418                        service: "headless-lms".to_string(),
419                        error_source: Some(headless_lms_models::errors::ErrorSource::Backend),
420                        message,
421                        stack_trace: Some(stack_trace),
422                        path: None,
423                        app_version: None,
424                        details: Some(details),
425                    };
426                    if let Err(err) =
427                        headless_lms_models::errors::insert(&mut conn, None, &report).await
428                    {
429                        debug!("internal error reporting insert failed: {err}");
430                    }
431                });
432            }
433        }
434        if let ControllerErrorType::OAuthError(data) = &self.error_type {
435            if let Some(uri) = &data.redirect_uri
436                && let Ok(mut url) = url::Url::parse(uri)
437            {
438                {
439                    let mut qp = url.query_pairs_mut();
440                    qp.append_pair("error", &data.error);
441                    qp.append_pair("error_description", &data.error_description);
442                    if let Some(state) = &data.state {
443                        qp.append_pair("state", state);
444                    }
445                }
446                let loc = url.to_string();
447                return HttpResponse::Found()
448                    .append_header(("Location", loc))
449                    .finish();
450            }
451
452            let status = match data.error.as_str() {
453                "invalid_client" => StatusCode::UNAUTHORIZED,     // 401
454                "invalid_token" => StatusCode::UNAUTHORIZED,      // 401 (bearer)
455                "invalid_dpop_proof" => StatusCode::UNAUTHORIZED, // 401 (dpop)
456                "use_dpop_nonce" => StatusCode::UNAUTHORIZED,     // 401 (dpop)
457                "insufficient_scope" => StatusCode::FORBIDDEN,    // 403
458                _ => StatusCode::BAD_REQUEST,
459            };
460
461            let mut res = HttpResponse::build(status);
462            // Small helper to safely embed values in WWW-Authenticate auth-param strings.
463            fn escape_auth_param(s: &str) -> String {
464                s.replace('\\', "\\\\").replace('"', "\\\"")
465            }
466
467            match data.error.as_str() {
468                // OAuth2 Bearer challenges (RFC 6750 §3)
469                "invalid_client" | "invalid_token" | "insufficient_scope" | "invalid_request" => {
470                    let err = escape_auth_param(&data.error);
471                    let desc = escape_auth_param(&data.error_description);
472                    let hdr = format!(r#"Bearer error="{}", error_description="{}""#, err, desc);
473                    res.append_header(("WWW-Authenticate", hdr));
474                }
475
476                // DPoP auth challenges (RFC 9449 §12.2)
477                "invalid_dpop_proof" => {
478                    let err = escape_auth_param(&data.error);
479                    let desc = escape_auth_param(&data.error_description);
480                    let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
481                    res.append_header(("WWW-Authenticate", hdr));
482                }
483
484                "use_dpop_nonce" => {
485                    let err = escape_auth_param(&data.error);
486                    let desc = escape_auth_param(&data.error_description);
487                    let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
488                    res.append_header(("WWW-Authenticate", hdr));
489
490                    // Provide the server-generated nonce (clients must echo it in the next proof)
491                    if let Some(nonce) = &data.nonce {
492                        res.append_header(("DPoP-Nonce", nonce.clone()));
493                    }
494                }
495
496                _ => {}
497            }
498
499            // Prevent caching per RFC 6749 §5.1 (common practice for error responses too)
500            res.append_header(("Cache-Control", "no-store"))
501                .append_header(("Pragma", "no-cache"));
502
503            // OAuth token/introspection semantics are standardized around `error` and
504            // `error_description`; keep compatibility for protocol clients.
505            return res.json(serde_json::json!({
506                "error": data.error,
507                "error_description": data.error_description
508            }));
509        }
510
511        let status = self.status_code();
512
513        let metadata = match &self.error_type {
514            ControllerErrorType::BadRequestWithData(data) => Some(data.clone()),
515            _ => None,
516        };
517
518        let metadata_json =
519            metadata.map(|ErrorMetadata::BlockId(id)| serde_json::json!({ "block_id": id }));
520        let (error_type, message_key) = self.error_type_and_message_key();
521        let errors = self.validation_issues();
522        let message = Some(self.message.clone());
523
524        let error_response = ApiErrorResponse {
525            error_type: Some(error_type.to_string()),
526            message_key: Some(message_key.to_string()),
527            message,
528            errors,
529            metadata: metadata_json,
530        };
531
532        HttpResponseBuilder::new(status)
533            .append_header(ContentType::json())
534            .body(serde_json::to_string(&error_response).unwrap_or_else(|e| {
535                error!("Error while serialising error response: {e}");
536                r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
537            }))
538    }
539
540    fn status_code(&self) -> StatusCode {
541        match self.error_type {
542            ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
543            ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
544            ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
545            ControllerErrorType::BadRequestWithReason(_) => StatusCode::UNPROCESSABLE_ENTITY,
546            ControllerErrorType::UpgradeRequired => StatusCode::UPGRADE_REQUIRED,
547            ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
548            ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
549            ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
550            ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
551            ControllerErrorType::OAuthError(_) => StatusCode::OK,
552            ControllerErrorType::SisuError(SisuErrorType::InvalidCourseCode) => {
553                StatusCode::BAD_REQUEST
554            }
555            ControllerErrorType::SisuError(SisuErrorType::GenericSisuError) => {
556                StatusCode::BAD_GATEWAY
557            }
558            ControllerErrorType::SisuError(SisuErrorType::SisuResourceNotFound) => {
559                StatusCode::NOT_FOUND
560            }
561        }
562    }
563}
564
565impl ControllerError {
566    fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
567        match self.error_type {
568            ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
569            ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
570            ControllerErrorType::BadRequestWithData(_) => {
571                ("validation_error", "validation_error_with_metadata")
572            }
573            ControllerErrorType::BadRequestWithReason(reason) => {
574                ("validation_error", reason.message_key())
575            }
576            ControllerErrorType::UpgradeRequired => ("obsolete_client", "obsolete_client"),
577            ControllerErrorType::NotFound => ("not_found", "not_found"),
578            ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
579            ControllerErrorType::UnauthorizedWithReason(reason) => {
580                ("unauthorized", reason.message_key())
581            }
582            ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
583            ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
584            ControllerErrorType::SisuError(error_type) => ("sisu_error", error_type.message_key()),
585        }
586    }
587
588    /// Derives issue-level validation details from known backend validation cases.
589    fn validation_issues(&self) -> Vec<ApiErrorIssue> {
590        match &self.error_type {
591            ControllerErrorType::BadRequestWithData(_)
592                if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
593            {
594                vec![ApiErrorIssue {
595                    path: Some("exercise_type".to_string()),
596                    code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
597                    message: self.message.clone(),
598                }]
599            }
600            _ => Vec::new(),
601        }
602    }
603}
604
605#[derive(Debug, Serialize, Deserialize, Clone)]
606#[serde(rename_all = "snake_case")]
607pub struct OAuthErrorData {
608    pub error: String,
609    pub error_description: String,
610    pub redirect_uri: Option<String>,
611    pub state: Option<String>,
612    pub nonce: Option<String>,
613}
614
615pub enum OAuthErrorCode {
616    InvalidGrant,
617    InvalidRequest,
618    InvalidClient,
619    InvalidToken,
620    InsufficientScope,
621    InvalidScope,
622    UnauthorizedClient,
623    UnsupportedGrantType,
624    UnsupportedResponseType,
625    ServerError,
626    InvalidDpopProof,
627    UseDpopNonce,
628    // RFC 8628 Device Authorization Grant token-endpoint errors. All map to
629    // HTTP 400 (the default in `error_response`), which is RFC-compliant.
630    AuthorizationPending,
631    SlowDown,
632    ExpiredToken,
633    AccessDenied,
634}
635
636impl OAuthErrorCode {
637    pub fn as_str(&self) -> &'static str {
638        match self {
639            Self::InvalidGrant => "invalid_grant",
640            Self::InvalidRequest => "invalid_request",
641            Self::InvalidClient => "invalid_client",
642            Self::InvalidToken => "invalid_token",
643            Self::InsufficientScope => "insufficient_scope",
644            Self::InvalidScope => "invalid_scope",
645            Self::UnauthorizedClient => "unauthorized_client",
646            Self::UnsupportedGrantType => "unsupported_grant_type",
647            Self::UnsupportedResponseType => "unsupported_response_type",
648            Self::ServerError => "server_error",
649            Self::InvalidDpopProof => "invalid_dpop_proof",
650            Self::UseDpopNonce => "use_dpop_nonce",
651            Self::AuthorizationPending => "authorization_pending",
652            Self::SlowDown => "slow_down",
653            Self::ExpiredToken => "expired_token",
654            Self::AccessDenied => "access_denied",
655        }
656    }
657}
658
659impl From<anyhow::Error> for ControllerError {
660    fn from(err: anyhow::Error) -> ControllerError {
661        if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
662            return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
663        }
664
665        Self::new(
666            ControllerErrorType::InternalServerError,
667            err.to_string(),
668            Some(err),
669        )
670    }
671}
672
673impl From<uuid::Error> for ControllerError {
674    fn from(err: uuid::Error) -> ControllerError {
675        Self::new(
676            ControllerErrorType::BadRequest,
677            err.to_string(),
678            Some(err.into()),
679        )
680    }
681}
682
683impl From<sqlx::Error> for ControllerError {
684    fn from(err: sqlx::Error) -> ControllerError {
685        Self::new(
686            ControllerErrorType::InternalServerError,
687            err.to_string(),
688            Some(err.into()),
689        )
690    }
691}
692
693impl From<git2::Error> for ControllerError {
694    fn from(err: git2::Error) -> ControllerError {
695        Self::new(
696            ControllerErrorType::InternalServerError,
697            err.to_string(),
698            Some(err.into()),
699        )
700    }
701}
702
703impl From<actix_web::Error> for ControllerError {
704    fn from(err: actix_web::Error) -> Self {
705        Self::new(
706            ControllerErrorType::InternalServerError,
707            err.to_string(),
708            None,
709        )
710    }
711}
712
713impl From<actix_multipart::MultipartError> for ControllerError {
714    fn from(err: actix_multipart::MultipartError) -> Self {
715        Self::new(
716            ControllerErrorType::InternalServerError,
717            err.to_string(),
718            None,
719        )
720    }
721}
722
723impl From<jsonwebtoken::errors::Error> for ControllerError {
724    fn from(err: jsonwebtoken::errors::Error) -> Self {
725        Self::new(
726            ControllerErrorType::InternalServerError,
727            err.to_string(),
728            None,
729        )
730    }
731}
732
733impl From<ModelError> for ControllerError {
734    fn from(err: ModelError) -> Self {
735        let backtrace: Backtrace =
736            match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
737                Some(backtrace) => backtrace.clone(),
738                _ => Backtrace::new(),
739            };
740        let span_trace = err.span_trace().clone();
741        match err.error_type() {
742            ModelErrorType::RecordNotFound => Self::new_with_traces(
743                ControllerErrorType::NotFound,
744                err.to_string(),
745                Some(err.into()),
746                backtrace,
747                span_trace,
748            ),
749            ModelErrorType::NotFound => Self::new_with_traces(
750                ControllerErrorType::NotFound,
751                err.to_string(),
752                Some(err.into()),
753                backtrace,
754                span_trace,
755            ),
756            ModelErrorType::PreconditionFailed => Self::new_with_traces(
757                ControllerErrorType::BadRequest,
758                err.message().to_string(),
759                Some(err.into()),
760                backtrace,
761                span_trace,
762            ),
763            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
764                Self::new_with_traces(
765                    ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
766                    description.to_string(),
767                    Some(err.into()),
768                    backtrace,
769                    span_trace,
770                )
771            }
772            ModelErrorType::DatabaseConstraint {
773                constraint,
774                description,
775            } => Self::new_with_traces(
776                BadRequestReason::from_database_constraint(constraint)
777                    .map_or(ControllerErrorType::BadRequest, |reason| {
778                        ControllerErrorType::BadRequestWithReason(reason)
779                    }),
780                description.to_string(),
781                Some(err.into()),
782                backtrace,
783                span_trace,
784            ),
785            ModelErrorType::InvalidRequest => Self::new_with_traces(
786                ControllerErrorType::BadRequest,
787                err.message().to_string(),
788                Some(err.into()),
789                backtrace,
790                span_trace,
791            ),
792            ModelErrorType::ForeignKeyViolation => Self::new_with_traces(
793                ControllerErrorType::BadRequestWithReason(BadRequestReason::ForeignKeyViolation),
794                err.message().to_string(),
795                Some(err.into()),
796                backtrace,
797                span_trace,
798            ),
799            _ => Self::new_with_traces(
800                ControllerErrorType::InternalServerError,
801                err.to_string(),
802                Some(err.into()),
803                backtrace,
804                span_trace,
805            ),
806        }
807    }
808}
809
810impl From<AuthorizationError> for ControllerError {
811    fn from(err: AuthorizationError) -> Self {
812        // A check that failed because the models layer did is mapped like any other
813        // ModelError, so that e.g. authorizing against a nonexistent page still answers 404.
814        let err = match err.into_model_error() {
815            Ok(model_error) => return model_error.into(),
816            Err(err) => err,
817        };
818
819        let backtrace: Backtrace = match BackendError::backtrace(&err) {
820            Some(backtrace) => backtrace.clone(),
821            _ => Backtrace::new(),
822        };
823        let span_trace = err.span_trace().clone();
824        let error_type = match err.error_type() {
825            AuthorizationErrorType::Unauthorized => ControllerErrorType::Unauthorized,
826            AuthorizationErrorType::Forbidden => ControllerErrorType::Forbidden,
827            AuthorizationErrorType::InternalServerError | AuthorizationErrorType::Model => {
828                ControllerErrorType::InternalServerError
829            }
830        };
831        // `message()`, not `to_string()`: the message reaches the user verbatim, while the
832        // nested role and action detail stays reachable through the source chain.
833        let message = err.message().to_string();
834
835        Self::new_with_traces(error_type, message, Some(err.into()), backtrace, span_trace)
836    }
837}
838
839impl From<UtilError> for ControllerError {
840    fn from(err: UtilError) -> Self {
841        let backtrace: Backtrace =
842            match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
843                Some(backtrace) => backtrace.clone(),
844                _ => Backtrace::new(),
845            };
846        let span_trace = err.span_trace().clone();
847
848        match err.error_type() {
849            UtilErrorType::SisuClientError(SisuErrorVariant::GenericSisuError) => {
850                Self::new_with_traces(
851                    ControllerErrorType::SisuError(SisuErrorType::GenericSisuError),
852                    err.to_string(),
853                    Some(err.into()),
854                    backtrace,
855                    span_trace,
856                )
857            }
858            UtilErrorType::SisuClientError(SisuErrorVariant::InvalidCourseCode) => {
859                Self::new_with_traces(
860                    ControllerErrorType::SisuError(SisuErrorType::InvalidCourseCode),
861                    err.to_string(),
862                    Some(err.into()),
863                    backtrace,
864                    span_trace,
865                )
866            }
867            UtilErrorType::SisuClientError(SisuErrorVariant::SisuResourceNotFound) => {
868                Self::new_with_traces(
869                    ControllerErrorType::SisuError(SisuErrorType::SisuResourceNotFound),
870                    err.to_string(),
871                    Some(err.into()),
872                    backtrace,
873                    span_trace,
874                )
875            }
876            _ => Self::new_with_traces(
877                ControllerErrorType::InternalServerError,
878                err.to_string(),
879                Some(err.into()),
880                backtrace,
881                span_trace,
882            ),
883        }
884    }
885}
886
887impl From<serde_json::Error> for ControllerError {
888    fn from(err: serde_json::Error) -> Self {
889        Self::new(
890            ControllerErrorType::InternalServerError,
891            err.to_string(),
892            Some(err.into()),
893        )
894    }
895}
896
897impl From<base64::DecodeError> for ControllerError {
898    fn from(err: base64::DecodeError) -> Self {
899        Self::new(
900            ControllerErrorType::InternalServerError,
901            err.to_string(),
902            Some(err.into()),
903        )
904    }
905}
906
907impl From<std::string::FromUtf8Error> for ControllerError {
908    fn from(err: std::string::FromUtf8Error) -> Self {
909        Self::new(
910            ControllerErrorType::InternalServerError,
911            err.to_string(),
912            Some(err.into()),
913        )
914    }
915}
916
917impl From<pkcs8::spki::Error> for ControllerError {
918    fn from(err: pkcs8::spki::Error) -> Self {
919        Self::new(
920            ControllerErrorType::InternalServerError,
921            err.to_string(),
922            Some(err.into()),
923        )
924    }
925}
926
927impl From<dpop_verifier::error::DpopError> for ControllerError {
928    fn from(err: DpopError) -> Self {
929        let oauth_error = match &err {
930            DpopError::MultipleDpopHeaders
931            | DpopError::InvalidDpopHeader
932            | DpopError::MissingDpopHeader
933            | DpopError::MalformedJws
934            | DpopError::InvalidAlg(_)
935            | DpopError::UnsupportedAlg(_)
936            | DpopError::InvalidSignature
937            | DpopError::BadJwk(_)
938            | DpopError::MissingClaim(_)
939            | DpopError::InvalidMethod
940            | DpopError::HtmMismatch
941            | DpopError::MalformedHtu
942            | DpopError::HtuMismatch
943            | DpopError::AthMalformed
944            | DpopError::MissingAth
945            | DpopError::AthMismatch
946            | DpopError::FutureSkew
947            | DpopError::Stale
948            | DpopError::Replay
949            | DpopError::JtiTooLong
950            | DpopError::NonceMismatch
951            | DpopError::NonceStale
952            | DpopError::InvalidHmacConfig
953            | DpopError::MissingNonce => OAuthErrorData {
954                error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
955                error_description: err.to_string(),
956                redirect_uri: None,
957                state: None,
958                nonce: None,
959            },
960
961            DpopError::Store(e) => OAuthErrorData {
962                error: OAuthErrorCode::ServerError.as_str().into(),
963                error_description: format!("DPoP storage error: {e}"),
964                redirect_uri: None,
965                state: None,
966                nonce: None,
967            },
968
969            DpopError::UseDpopNonce { nonce } => OAuthErrorData {
970                error: OAuthErrorCode::UseDpopNonce.as_str().into(), // per RFC 9449 §12.2
971                error_description: "Server requires DPoP nonce".into(),
972                redirect_uri: None,
973                state: None,
974                nonce: Some(nonce.clone()),
975            },
976        };
977
978        ControllerError::new(
979            ControllerErrorType::OAuthError(Box::new(oauth_error)),
980            err.to_string(),
981            Some(err.into()),
982        )
983    }
984}
985
986#[derive(Debug, thiserror::Error)]
987pub enum PkceFlowError {
988    /// Request is malformed or missing a required PKCE parameter
989    #[error("{0}")]
990    InvalidRequest(&'static str),
991
992    /// PKCE check failed (e.g., code_verifier doesn't match stored challenge)
993    #[error("{0}")]
994    InvalidGrant(&'static str),
995
996    /// Server-side (DB/state) problem
997    #[error("{0}")]
998    ServerError(&'static str),
999}
1000
1001impl From<PkceFlowError> for ControllerError {
1002    fn from(err: PkceFlowError) -> Self {
1003        let data = match &err {
1004            PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
1005                error: OAuthErrorCode::InvalidRequest.as_str().into(),
1006                error_description: (*msg).into(),
1007                redirect_uri: None,
1008                state: None,
1009                nonce: None,
1010            },
1011            PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
1012                error: OAuthErrorCode::InvalidGrant.as_str().into(),
1013                error_description: (*msg).into(),
1014                redirect_uri: None,
1015                state: None,
1016                nonce: None,
1017            },
1018            PkceFlowError::ServerError(msg) => OAuthErrorData {
1019                error: OAuthErrorCode::ServerError.as_str().into(),
1020                error_description: (*msg).into(),
1021                redirect_uri: None,
1022                state: None,
1023                nonce: None,
1024            },
1025        };
1026
1027        ControllerError::new(
1028            ControllerErrorType::OAuthError(Box::new(data)),
1029            err.to_string(),
1030            Some(anyhow::anyhow!(err)),
1031        )
1032    }
1033}
1034
1035impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
1036    fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
1037        // Both BadLength and BadCharset are "invalid_request" per OAuth spec
1038        PkceFlowError::InvalidRequest("invalid code_verifier")
1039    }
1040}
1041
1042impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
1043    fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
1044        PkceFlowError::from(err).into()
1045    }
1046}
1047
1048impl From<ChatbotError> for ControllerError {
1049    fn from(err: ChatbotError) -> Self {
1050        // A failure that came from the models layer is mapped like any other ModelError, so
1051        // that e.g. a chatbot conversation referencing a deleted course answers 404.
1052        let err = match err.into_model_error() {
1053            Ok(model_error) => return model_error.into(),
1054            Err(err) => err,
1055        };
1056
1057        let backtrace: Backtrace = match BackendError::backtrace(&err) {
1058            Some(backtrace) => backtrace.clone(),
1059            _ => Backtrace::new(),
1060        };
1061        let span_trace = err.span_trace().clone();
1062        let error_type = match err.error_type() {
1063            // The one chatbot error the caller can fix, so the only one it is told about.
1064            ChatbotErrorType::InvalidToolAnswer => ControllerErrorType::BadRequest,
1065            ChatbotErrorType::InvalidMessageShape
1066            | ChatbotErrorType::InvalidToolName
1067            | ChatbotErrorType::InvalidToolArguments
1068            | ChatbotErrorType::ToolUseError
1069            | ChatbotErrorType::ChatbotModelError
1070            | ChatbotErrorType::ChatbotMessageSuggestError
1071            | ChatbotErrorType::UrlParse
1072            | ChatbotErrorType::TokioIo
1073            | ChatbotErrorType::SerdeJson
1074            | ChatbotErrorType::SqlxError
1075            | ChatbotErrorType::ReqwestError
1076            | ChatbotErrorType::Other
1077            | ChatbotErrorType::DeserializationError
1078            | ChatbotErrorType::AzureAISearchFilterError
1079            | ChatbotErrorType::UpstreamReportedError
1080            | ChatbotErrorType::ResponseIncomplete
1081            | ChatbotErrorType::StreamEndedEarly
1082            | ChatbotErrorType::UnexpectedProtocolShape
1083            | ChatbotErrorType::StreamInvariantViolation
1084            | ChatbotErrorType::ContentCleaning
1085            | ChatbotErrorType::AzureRequestBuildError
1086            | ChatbotErrorType::FailedAzureResponse
1087            | ChatbotErrorType::SisuDescriptionError
1088            | ChatbotErrorType::ChatbotUtilError => ControllerErrorType::InternalServerError,
1089        };
1090        let message = err.message().to_string();
1091
1092        Self::new_with_traces(error_type, message, Some(err.into()), backtrace, span_trace)
1093    }
1094}
1095
1096// Generate error creation macros for ControllerError
1097headless_lms_utils::define_err_macro!(
1098    controller_err,
1099    ControllerError,
1100    ControllerErrorType,
1101    ControllerErrorType,
1102    "Create a ControllerError with less boilerplate."
1103);
1104
1105/// Helper function for `.map_err()` chains to wrap any error as ControllerError.
1106///
1107/// This function creates a closure that converts any error into a `ControllerError`
1108/// with the specified error type and message, including the original error as the source.
1109///
1110/// # Examples
1111///
1112/// ```ignore
1113/// // Instead of:
1114/// .map_err(|e| ControllerError::new(ControllerErrorType::BadRequest, e.to_string(), Some(e.into())))?
1115///
1116/// // You can write:
1117/// .map_err(as_controller_error(ControllerErrorType::BadRequest, "Failed to process".to_string()))?
1118/// ```
1119pub fn as_controller_error<E>(
1120    error_type: ControllerErrorType,
1121    message: impl Into<String>,
1122) -> impl FnOnce(E) -> ControllerError
1123where
1124    E: Into<anyhow::Error>,
1125{
1126    let msg = message.into();
1127    move |e| ControllerError::new(error_type, msg, Some(e.into()))
1128}
1129
1130/// Helper function for `.ok_or_else()` to create ControllerError on None.
1131///
1132/// This function creates a closure that generates a `ControllerError` with the
1133/// specified error type and message when called.
1134///
1135/// # Examples
1136///
1137/// ```ignore
1138/// // Instead of:
1139/// .ok_or_else(|| ControllerError::new(ControllerErrorType::NotFound, "Item not found".to_string(), None))
1140///
1141/// // You can write:
1142/// .ok_or_else(missing_controller_error(ControllerErrorType::NotFound, "Item not found".to_string()))
1143/// ```
1144pub fn missing_controller_error(
1145    error_type: ControllerErrorType,
1146    message: impl Into<String>,
1147) -> impl FnOnce() -> ControllerError {
1148    let msg = message.into();
1149    move || ControllerError::new(error_type, msg, None)
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use actix_web::ResponseError;
1156    use futures_util::FutureExt;
1157
1158    #[test]
1159    fn test_controller_err_macro_without_source() {
1160        let err = controller_err!(BadRequest, "Test error message".to_string());
1161        assert_eq!(err.message(), "Test error message");
1162        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
1163    }
1164
1165    #[test]
1166    fn test_controller_err_macro_with_source() {
1167        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1168        let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
1169        assert_eq!(err.message(), "Wrapped error");
1170    }
1171
1172    #[test]
1173    fn test_controller_err_macro_tuple_variant_with_source() {
1174        let source_err = std::io::Error::other("source");
1175        let err = controller_err!(
1176            UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1177            "Wrapped error".to_string(),
1178            source_err
1179        );
1180        assert!(matches!(
1181            err.error_type(),
1182            ControllerErrorType::UnauthorizedWithReason(_)
1183        ));
1184    }
1185
1186    #[test]
1187    fn test_as_controller_error_helper() {
1188        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
1189            std::io::ErrorKind::NotFound,
1190            "test error",
1191        ));
1192        let controller_result = result.map_err(as_controller_error(
1193            ControllerErrorType::BadRequest,
1194            "Invalid input".to_string(),
1195        ));
1196
1197        assert!(controller_result.is_err());
1198        let err = controller_result.unwrap_err();
1199        assert_eq!(err.message(), "Invalid input");
1200        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
1201    }
1202
1203    #[test]
1204    fn test_missing_controller_error_helper() {
1205        let option: Option<String> = None;
1206        let result = option.ok_or_else(missing_controller_error(
1207            ControllerErrorType::NotFound,
1208            "Resource not found".to_string(),
1209        ));
1210
1211        assert!(result.is_err());
1212        let err = result.unwrap_err();
1213        assert_eq!(err.message(), "Resource not found");
1214        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1215    }
1216
1217    #[test]
1218    fn test_controller_err_with_format() {
1219        let user_id = 42;
1220        let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1221        assert_eq!(err.message(), "User 42 is not authorized");
1222    }
1223
1224    #[test]
1225    fn test_controller_err_all_variants() {
1226        // Test that macros work with all standard error type variants
1227        let _ = controller_err!(InternalServerError, "test".to_string());
1228        let _ = controller_err!(BadRequest, "test".to_string());
1229        let _ = controller_err!(NotFound, "test".to_string());
1230        let _ = controller_err!(Unauthorized, "test".to_string());
1231        let _ = controller_err!(
1232            UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1233            "test".to_string()
1234        );
1235        let _ = controller_err!(
1236            BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1237            "test".to_string()
1238        );
1239        let _ = controller_err!(Forbidden, "test".to_string());
1240    }
1241
1242    #[test]
1243    fn test_canonical_error_envelope_shape() {
1244        let err = controller_err!(BadRequest, "Validation failed".to_string());
1245        let response = err.error_response();
1246        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1247
1248        let bytes = actix_web::body::to_bytes(response.into_body())
1249            .now_or_never()
1250            .expect("response should resolve immediately")
1251            .expect("body bytes");
1252        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1253        assert_eq!(value["type"], "validation_error");
1254        assert_eq!(value["message_key"], "validation_error");
1255        assert_eq!(value["message"], "Validation failed");
1256        assert!(value.get("status").is_none());
1257        assert!(value.get("request_id").is_none());
1258    }
1259
1260    #[test]
1261    fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1262        let err = ControllerError::new(
1263            ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1264            MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1265            None,
1266        );
1267        let response = err.error_response();
1268        let bytes = actix_web::body::to_bytes(response.into_body())
1269            .now_or_never()
1270            .expect("response should resolve immediately")
1271            .expect("body bytes");
1272        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1273
1274        assert_eq!(value["type"], "validation_error");
1275        assert_eq!(value["message_key"], "validation_error_with_metadata");
1276        assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1277        assert_eq!(value["errors"][0]["path"], "exercise_type");
1278    }
1279
1280    #[test]
1281    fn test_chapter_not_open_uses_dedicated_message_key() {
1282        let err = ControllerError::new(
1283            ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1284            "Chapter is not open yet.".to_string(),
1285            None,
1286        );
1287        let response = err.error_response();
1288        let bytes = actix_web::body::to_bytes(response.into_body())
1289            .now_or_never()
1290            .expect("response should resolve immediately")
1291            .expect("body bytes");
1292        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1293
1294        assert_eq!(value["type"], "unauthorized");
1295        assert_eq!(value["message_key"], "chapter_not_open_yet");
1296        assert_eq!(value["message"], "Chapter is not open yet.");
1297    }
1298
1299    #[test]
1300    fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1301        let err = ControllerError::new(
1302            ControllerErrorType::UnauthorizedWithReason(
1303                UnauthorizedReason::AuthenticationRequiredForExamExercise,
1304            ),
1305            "User must be authenticated to view exam exercises".to_string(),
1306            None,
1307        );
1308        let response = err.error_response();
1309        let bytes = actix_web::body::to_bytes(response.into_body())
1310            .now_or_never()
1311            .expect("response should resolve immediately")
1312            .expect("body bytes");
1313        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1314
1315        assert_eq!(value["type"], "unauthorized");
1316        assert_eq!(
1317            value["message_key"],
1318            "authentication_required_for_exam_exercise"
1319        );
1320        assert_eq!(
1321            value["message"],
1322            "User must be authenticated to view exam exercises"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_not_enrolled_uses_dedicated_message_key_and_422() {
1328        let err = ControllerError::new(
1329            ControllerErrorType::BadRequestWithReason(BadRequestReason::NotEnrolled),
1330            "User is not enrolled to this exercise's course".to_string(),
1331            None,
1332        );
1333        let response = err.error_response();
1334        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1335        let bytes = actix_web::body::to_bytes(response.into_body())
1336            .now_or_never()
1337            .expect("response should resolve immediately")
1338            .expect("body bytes");
1339        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1340
1341        assert_eq!(value["type"], "validation_error");
1342        assert_eq!(value["message_key"], "not_enrolled");
1343    }
1344
1345    #[test]
1346    fn test_upgrade_required_uses_obsolete_client_key_and_426() {
1347        let err = ControllerError::new(
1348            ControllerErrorType::UpgradeRequired,
1349            "Client is too old".to_string(),
1350            None,
1351        );
1352        let response = err.error_response();
1353        assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
1354        let bytes = actix_web::body::to_bytes(response.into_body())
1355            .now_or_never()
1356            .expect("response should resolve immediately")
1357            .expect("body bytes");
1358        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1359
1360        assert_eq!(value["type"], "obsolete_client");
1361        assert_eq!(value["message_key"], "obsolete_client");
1362    }
1363
1364    #[test]
1365    fn test_generic_unauthorized_uses_unauthorized_message_key() {
1366        let err = ControllerError::new(
1367            ControllerErrorType::Unauthorized,
1368            "Unauthorized".to_string(),
1369            None,
1370        );
1371        let response = err.error_response();
1372        let bytes = actix_web::body::to_bytes(response.into_body())
1373            .now_or_never()
1374            .expect("response should resolve immediately")
1375            .expect("body bytes");
1376        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1377
1378        assert_eq!(value["type"], "unauthorized");
1379        assert_eq!(value["message_key"], "unauthorized");
1380        assert_eq!(value["message"], "Unauthorized");
1381    }
1382
1383    #[test]
1384    fn debug_renders_clean_format() {
1385        let err = controller_err!(InternalServerError, "database exploded".to_string());
1386        let debug = format!("{err:?}");
1387        assert!(
1388            debug.contains("ControllerError · InternalServerError: database exploded"),
1389            "got: {debug}"
1390        );
1391        // No error-infrastructure leakage in the raise line.
1392        assert!(!debug.contains("backend_error.rs"), "got: {debug}");
1393        assert!(!debug.contains("macros.rs"), "got: {debug}");
1394    }
1395
1396    #[test]
1397    fn debug_renders_wrapped_model_error_as_cause_node() {
1398        let model_error = ModelError::new(ModelErrorType::Generic, "row missing".to_string(), None);
1399        let err = ControllerError::from(model_error);
1400
1401        let debug = format!("{err:?}");
1402        assert!(debug.contains("ControllerError ·"), "got: {debug}");
1403        assert!(debug.contains("caused by:"), "got: {debug}");
1404        assert!(
1405            debug.contains("1. ModelError · Generic: row missing"),
1406            "got: {debug}"
1407        );
1408        assert!(!debug.contains("(external)"), "got: {debug}");
1409    }
1410}