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