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