1use crate::domain::authorization::AuthorizedResponse;
6use actix_web::{
7 HttpResponse, HttpResponseBuilder, error,
8 http::{StatusCode, header::ContentType},
9};
10use backtrace::Backtrace;
11use derive_more::Display;
12use dpop_verifier::error::DpopError;
13use headless_lms_base::error::{
14 backend_error::BackendError, backtrace_formatter::format_backtrace,
15};
16use headless_lms_chatbot::prelude::ChatbotError;
17use headless_lms_models::{ModelError, ModelErrorType};
18use headless_lms_utils::error::util_error::UtilError;
19use serde::{Deserialize, Serialize};
20use tracing_error::SpanTrace;
21
22use uuid::Uuid;
23
24const MISSING_EXERCISE_TYPE_DESCRIPTION: &str = "Missing exercise type for exercise task.";
25
26pub type ControllerResult<T, E = ControllerError> = std::result::Result<AuthorizedResponse<T>, E>;
33
34#[derive(Debug, Display, Serialize, Deserialize)]
36pub enum ControllerErrorType {
37 #[display("Internal server error")]
39 InternalServerError,
40
41 #[display("Bad request")]
43 BadRequest,
44
45 #[display("Bad request")]
47 BadRequestWithData(ErrorMetadata),
48
49 #[display("Not found")]
51 NotFound,
52
53 #[display("Unauthorized")]
55 Unauthorized,
56
57 #[display("Unauthorized")]
59 UnauthorizedWithReason(UnauthorizedReason),
60
61 #[display("Forbidden")]
63 Forbidden,
64
65 #[display("OAuthError")]
67 OAuthError(Box<OAuthErrorData>),
68}
69
70#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
71#[serde(rename_all = "snake_case")]
72pub enum UnauthorizedReason {
73 #[display("Chapter not open yet")]
74 ChapterNotOpenYet,
75 #[display("Authentication required for exam exercise")]
76 AuthenticationRequiredForExamExercise,
77}
78
79impl UnauthorizedReason {
80 fn message_key(self) -> &'static str {
82 match self {
83 Self::ChapterNotOpenYet => "chapter_not_open_yet",
84 Self::AuthenticationRequiredForExamExercise => {
85 "authentication_required_for_exam_exercise"
86 }
87 }
88 }
89}
90
91pub struct ControllerError {
155 error_type: <ControllerError as BackendError>::ErrorType,
156 message: String,
157 source: Option<anyhow::Error>,
159 span_trace: Box<SpanTrace>,
161 backtrace: Box<Backtrace>,
163}
164
165impl std::fmt::Debug for ControllerError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("ControllerError")
169 .field("error_type", &self.error_type)
170 .field("message", &self.message)
171 .field("source", &self.source)
172 .finish()?;
173
174 f.write_str("\n\nOperating system thread stack backtrace:\n")?;
175 format_backtrace(&self.backtrace, f)?;
176
177 f.write_str("\n\nTokio tracing span trace:\n")?;
178 f.write_fmt(format_args!("{}\n", &self.span_trace))?;
179
180 Ok(())
181 }
182}
183
184impl std::error::Error for ControllerError {
185 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
186 self.source.as_ref().and_then(|o| o.source())
187 }
188
189 fn cause(&self) -> Option<&dyn std::error::Error> {
190 self.source()
191 }
192}
193
194impl std::fmt::Display for ControllerError {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 write!(
197 f,
198 "ControllerError {:?} {:?}",
199 self.error_type, self.message
200 )
201 }
202}
203
204impl BackendError for ControllerError {
205 type ErrorType = ControllerErrorType;
206
207 fn new<M: Into<String>, S: Into<Option<anyhow::Error>>>(
208 error_type: Self::ErrorType,
209 message: M,
210 source_error: S,
211 ) -> Self {
212 Self::new_with_traces(
213 error_type,
214 message,
215 source_error,
216 Backtrace::new(),
217 SpanTrace::capture(),
218 )
219 }
220
221 fn backtrace(&self) -> Option<&Backtrace> {
222 Some(&self.backtrace)
223 }
224
225 fn error_type(&self) -> &Self::ErrorType {
226 &self.error_type
227 }
228
229 fn message(&self) -> &str {
230 &self.message
231 }
232
233 fn span_trace(&self) -> &SpanTrace {
234 &self.span_trace
235 }
236
237 fn new_with_traces<M: Into<String>, S: Into<Option<anyhow::Error>>>(
238 error_type: Self::ErrorType,
239 message: M,
240 source_error: S,
241 backtrace: Backtrace,
242 span_trace: SpanTrace,
243 ) -> Self {
244 Self {
245 error_type,
246 message: message.into(),
247 source: source_error.into(),
248 span_trace: Box::new(span_trace),
249 backtrace: Box::new(backtrace),
250 }
251 }
252}
253
254#[derive(Debug, Serialize, Deserialize, Clone)]
255#[serde(rename_all = "snake_case")]
256pub enum ErrorMetadata {
257 BlockId(Uuid),
258}
259
260#[derive(Debug, Serialize, Deserialize)]
261pub struct ApiErrorIssue {
262 pub path: Option<String>,
263 pub code: Option<String>,
264 pub message: String,
265}
266
267#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
268#[serde(rename_all = "snake_case")]
269pub enum ValidationIssueCode {
270 MissingExerciseType,
271}
272
273impl ValidationIssueCode {
274 fn as_api_code(self) -> String {
276 serde_json::to_value(self)
277 .ok()
278 .and_then(|v| v.as_str().map(|code| code.to_string()))
279 .unwrap_or_else(|| "unknown_validation_issue".to_string())
280 }
281}
282
283#[derive(Debug, Serialize, Deserialize)]
285pub struct ApiErrorResponse {
286 #[serde(rename = "type")]
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub error_type: Option<String>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub message_key: Option<String>,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub message: Option<String>,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub errors: Vec<ApiErrorIssue>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub metadata: Option<serde_json::Value>,
297}
298
299impl error::ResponseError for ControllerError {
300 fn error_response(&self) -> HttpResponse {
301 if let ControllerErrorType::InternalServerError = &self.error_type {
302 use std::fmt::Write as _;
303 let mut err_string = String::new();
304 let mut source = Some(self as &dyn std::error::Error);
305 while let Some(err) = source {
306 let _ = write!(err_string, "{}\n ", err);
307 source = err.source();
308 }
309 error!("Internal server error: {}", err_string);
310
311 if let Some(pool) = crate::domain::internal_error_reporting::error_reporting_pool() {
312 let pool = pool.clone();
313 let message = self.message.clone();
314 let stack_trace = format!("{:?}", self);
315 let details = serde_json::json!({
316 "kind": "controller_error",
317 "controller_error_type": self.error_type.to_string(),
318 });
319
320 actix_web::rt::spawn(async move {
322 let mut conn = match tokio::time::timeout(
323 std::time::Duration::from_millis(250),
324 pool.acquire(),
325 )
326 .await
327 {
328 Ok(Ok(conn)) => conn,
329 Ok(Err(err)) => {
330 warn!(
331 "internal error reporting skipped: failed to acquire pool connection: {err}"
332 );
333 return;
334 }
335 Err(_) => {
336 warn!(
337 "internal error reporting skipped: timed out acquiring pool connection"
338 );
339 return;
340 }
341 };
342 let report = headless_lms_models::errors::NewErrorReport {
343 service: "headless-lms".to_string(),
344 error_source: Some(headless_lms_models::errors::ErrorSource::Backend),
345 message,
346 stack_trace: Some(stack_trace),
347 path: None,
348 app_version: None,
349 details: Some(details),
350 };
351 if let Err(err) =
352 headless_lms_models::errors::insert(&mut conn, None, &report).await
353 {
354 debug!("internal error reporting insert failed: {err}");
355 }
356 });
357 }
358 }
359 if let ControllerErrorType::OAuthError(data) = &self.error_type {
360 if let Some(uri) = &data.redirect_uri
361 && let Ok(mut url) = url::Url::parse(uri)
362 {
363 {
364 let mut qp = url.query_pairs_mut();
365 qp.append_pair("error", &data.error);
366 qp.append_pair("error_description", &data.error_description);
367 if let Some(state) = &data.state {
368 qp.append_pair("state", state);
369 }
370 }
371 let loc = url.to_string();
372 return HttpResponse::Found()
373 .append_header(("Location", loc))
374 .finish();
375 }
376
377 let status = match data.error.as_str() {
378 "invalid_client" => StatusCode::UNAUTHORIZED, "invalid_token" => StatusCode::UNAUTHORIZED, "invalid_dpop_proof" => StatusCode::UNAUTHORIZED, "use_dpop_nonce" => StatusCode::UNAUTHORIZED, "insufficient_scope" => StatusCode::FORBIDDEN, _ => StatusCode::BAD_REQUEST,
384 };
385
386 let mut res = HttpResponse::build(status);
387 fn escape_auth_param(s: &str) -> String {
389 s.replace('\\', "\\\\").replace('"', "\\\"")
390 }
391
392 match data.error.as_str() {
393 "invalid_client" | "invalid_token" | "insufficient_scope" | "invalid_request" => {
395 let err = escape_auth_param(&data.error);
396 let desc = escape_auth_param(&data.error_description);
397 let hdr = format!(r#"Bearer error="{}", error_description="{}""#, err, desc);
398 res.append_header(("WWW-Authenticate", hdr));
399 }
400
401 "invalid_dpop_proof" => {
403 let err = escape_auth_param(&data.error);
404 let desc = escape_auth_param(&data.error_description);
405 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
406 res.append_header(("WWW-Authenticate", hdr));
407 }
408
409 "use_dpop_nonce" => {
410 let err = escape_auth_param(&data.error);
411 let desc = escape_auth_param(&data.error_description);
412 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
413 res.append_header(("WWW-Authenticate", hdr));
414
415 if let Some(nonce) = &data.nonce {
417 res.append_header(("DPoP-Nonce", nonce.clone()));
418 }
419 }
420
421 _ => {}
422 }
423
424 res.append_header(("Cache-Control", "no-store"))
426 .append_header(("Pragma", "no-cache"));
427
428 return res.json(serde_json::json!({
431 "error": data.error,
432 "error_description": data.error_description
433 }));
434 }
435
436 let status = self.status_code();
437
438 let metadata = match &self.error_type {
439 ControllerErrorType::BadRequestWithData(data) => Some(data.clone()),
440 _ => None,
441 };
442
443 let metadata_json =
444 metadata.map(|ErrorMetadata::BlockId(id)| serde_json::json!({ "block_id": id }));
445 let (error_type, message_key) = self.error_type_and_message_key();
446 let errors = self.validation_issues();
447 let message = Some(self.message.clone());
448
449 let error_response = ApiErrorResponse {
450 error_type: Some(error_type.to_string()),
451 message_key: Some(message_key.to_string()),
452 message,
453 errors,
454 metadata: metadata_json,
455 };
456
457 HttpResponseBuilder::new(status)
458 .append_header(ContentType::json())
459 .body(serde_json::to_string(&error_response).unwrap_or_else(|e| {
460 error!("Error while serialising error response: {e}");
461 r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
462 }))
463 }
464
465 fn status_code(&self) -> StatusCode {
466 match self.error_type {
467 ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
468 ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
469 ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
470 ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
471 ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
472 ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
473 ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
474 ControllerErrorType::OAuthError(_) => StatusCode::OK,
475 }
476 }
477}
478
479impl ControllerError {
480 fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
481 match self.error_type {
482 ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
483 ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
484 ControllerErrorType::BadRequestWithData(_) => {
485 ("validation_error", "validation_error_with_metadata")
486 }
487 ControllerErrorType::NotFound => ("not_found", "not_found"),
488 ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
489 ControllerErrorType::UnauthorizedWithReason(reason) => {
490 ("unauthorized", reason.message_key())
491 }
492 ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
493 ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
494 }
495 }
496
497 fn validation_issues(&self) -> Vec<ApiErrorIssue> {
499 match &self.error_type {
500 ControllerErrorType::BadRequestWithData(_)
501 if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
502 {
503 vec![ApiErrorIssue {
504 path: Some("exercise_type".to_string()),
505 code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
506 message: self.message.clone(),
507 }]
508 }
509 _ => Vec::new(),
510 }
511 }
512}
513
514#[derive(Debug, Serialize, Deserialize, Clone)]
515#[serde(rename_all = "snake_case")]
516pub struct OAuthErrorData {
517 pub error: String,
518 pub error_description: String,
519 pub redirect_uri: Option<String>,
520 pub state: Option<String>,
521 pub nonce: Option<String>,
522}
523
524pub enum OAuthErrorCode {
525 InvalidGrant,
526 InvalidRequest,
527 InvalidClient,
528 InvalidToken,
529 InsufficientScope,
530 UnsupportedGrantType,
531 UnsupportedResponseType,
532 ServerError,
533 InvalidDpopProof,
534 UseDpopNonce,
535}
536
537impl OAuthErrorCode {
538 pub fn as_str(&self) -> &'static str {
539 match self {
540 Self::InvalidGrant => "invalid_grant",
541 Self::InvalidRequest => "invalid_request",
542 Self::InvalidClient => "invalid_client",
543 Self::InvalidToken => "invalid_token",
544 Self::InsufficientScope => "insufficient_scope",
545 Self::UnsupportedGrantType => "unsupported_grant_type",
546 Self::UnsupportedResponseType => "unsupported_response_type",
547 Self::ServerError => "server_error",
548 Self::InvalidDpopProof => "invalid_dpop_proof",
549 Self::UseDpopNonce => "use_dpop_nonce",
550 }
551 }
552}
553
554impl From<anyhow::Error> for ControllerError {
555 fn from(err: anyhow::Error) -> ControllerError {
556 if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
557 return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
558 }
559
560 Self::new(
561 ControllerErrorType::InternalServerError,
562 err.to_string(),
563 Some(err),
564 )
565 }
566}
567
568impl From<uuid::Error> for ControllerError {
569 fn from(err: uuid::Error) -> ControllerError {
570 Self::new(
571 ControllerErrorType::BadRequest,
572 err.to_string(),
573 Some(err.into()),
574 )
575 }
576}
577
578impl From<sqlx::Error> for ControllerError {
579 fn from(err: sqlx::Error) -> ControllerError {
580 Self::new(
581 ControllerErrorType::InternalServerError,
582 err.to_string(),
583 Some(err.into()),
584 )
585 }
586}
587
588impl From<git2::Error> for ControllerError {
589 fn from(err: git2::Error) -> ControllerError {
590 Self::new(
591 ControllerErrorType::InternalServerError,
592 err.to_string(),
593 Some(err.into()),
594 )
595 }
596}
597
598impl From<actix_web::Error> for ControllerError {
599 fn from(err: actix_web::Error) -> Self {
600 Self::new(
601 ControllerErrorType::InternalServerError,
602 err.to_string(),
603 None,
604 )
605 }
606}
607
608impl From<actix_multipart::MultipartError> for ControllerError {
609 fn from(err: actix_multipart::MultipartError) -> Self {
610 Self::new(
611 ControllerErrorType::InternalServerError,
612 err.to_string(),
613 None,
614 )
615 }
616}
617
618impl From<jsonwebtoken::errors::Error> for ControllerError {
619 fn from(err: jsonwebtoken::errors::Error) -> Self {
620 Self::new(
621 ControllerErrorType::InternalServerError,
622 err.to_string(),
623 None,
624 )
625 }
626}
627
628impl From<ModelError> for ControllerError {
629 fn from(err: ModelError) -> Self {
630 let backtrace: Backtrace =
631 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
632 Some(backtrace) => backtrace.clone(),
633 _ => Backtrace::new(),
634 };
635 let span_trace = err.span_trace().clone();
636 match err.error_type() {
637 ModelErrorType::RecordNotFound => Self::new_with_traces(
638 ControllerErrorType::NotFound,
639 err.to_string(),
640 Some(err.into()),
641 backtrace,
642 span_trace,
643 ),
644 ModelErrorType::NotFound => Self::new_with_traces(
645 ControllerErrorType::NotFound,
646 err.to_string(),
647 Some(err.into()),
648 backtrace,
649 span_trace,
650 ),
651 ModelErrorType::PreconditionFailed => Self::new_with_traces(
652 ControllerErrorType::BadRequest,
653 err.message().to_string(),
654 Some(err.into()),
655 backtrace,
656 span_trace,
657 ),
658 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
659 Self::new_with_traces(
660 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
661 description.to_string(),
662 Some(err.into()),
663 backtrace,
664 span_trace,
665 )
666 }
667 ModelErrorType::DatabaseConstraint { description, .. } => Self::new_with_traces(
668 ControllerErrorType::BadRequest,
669 description.to_string(),
670 Some(err.into()),
671 backtrace,
672 span_trace,
673 ),
674 ModelErrorType::InvalidRequest => Self::new_with_traces(
675 ControllerErrorType::BadRequest,
676 err.message().to_string(),
677 Some(err.into()),
678 backtrace,
679 span_trace,
680 ),
681 _ => Self::new_with_traces(
682 ControllerErrorType::InternalServerError,
683 err.to_string(),
684 Some(err.into()),
685 backtrace,
686 span_trace,
687 ),
688 }
689 }
690}
691
692impl From<UtilError> for ControllerError {
693 fn from(err: UtilError) -> Self {
694 let backtrace: Backtrace =
695 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
696 Some(backtrace) => backtrace.clone(),
697 _ => Backtrace::new(),
698 };
699 let span_trace = err.span_trace().clone();
700 Self::new_with_traces(
701 ControllerErrorType::InternalServerError,
702 err.to_string(),
703 Some(err.into()),
704 backtrace,
705 span_trace,
706 )
707 }
708}
709
710impl From<serde_json::Error> for ControllerError {
711 fn from(err: serde_json::Error) -> Self {
712 Self::new(
713 ControllerErrorType::InternalServerError,
714 err.to_string(),
715 Some(err.into()),
716 )
717 }
718}
719
720impl From<base64::DecodeError> for ControllerError {
721 fn from(err: base64::DecodeError) -> Self {
722 Self::new(
723 ControllerErrorType::InternalServerError,
724 err.to_string(),
725 Some(err.into()),
726 )
727 }
728}
729
730impl From<std::string::FromUtf8Error> for ControllerError {
731 fn from(err: std::string::FromUtf8Error) -> Self {
732 Self::new(
733 ControllerErrorType::InternalServerError,
734 err.to_string(),
735 Some(err.into()),
736 )
737 }
738}
739
740impl From<pkcs8::spki::Error> for ControllerError {
741 fn from(err: pkcs8::spki::Error) -> Self {
742 Self::new(
743 ControllerErrorType::InternalServerError,
744 err.to_string(),
745 Some(err.into()),
746 )
747 }
748}
749
750impl From<dpop_verifier::error::DpopError> for ControllerError {
751 fn from(err: DpopError) -> Self {
752 let oauth_error = match &err {
753 DpopError::MultipleDpopHeaders
754 | DpopError::InvalidDpopHeader
755 | DpopError::MissingDpopHeader
756 | DpopError::MalformedJws
757 | DpopError::InvalidAlg(_)
758 | DpopError::UnsupportedAlg(_)
759 | DpopError::InvalidSignature
760 | DpopError::BadJwk(_)
761 | DpopError::MissingClaim(_)
762 | DpopError::InvalidMethod
763 | DpopError::HtmMismatch
764 | DpopError::MalformedHtu
765 | DpopError::HtuMismatch
766 | DpopError::AthMalformed
767 | DpopError::MissingAth
768 | DpopError::AthMismatch
769 | DpopError::FutureSkew
770 | DpopError::Stale
771 | DpopError::Replay
772 | DpopError::JtiTooLong
773 | DpopError::NonceMismatch
774 | DpopError::NonceStale
775 | DpopError::InvalidHmacConfig
776 | DpopError::MissingNonce => OAuthErrorData {
777 error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
778 error_description: err.to_string(),
779 redirect_uri: None,
780 state: None,
781 nonce: None,
782 },
783
784 DpopError::Store(e) => OAuthErrorData {
785 error: OAuthErrorCode::ServerError.as_str().into(),
786 error_description: format!("DPoP storage error: {e}"),
787 redirect_uri: None,
788 state: None,
789 nonce: None,
790 },
791
792 DpopError::UseDpopNonce { nonce } => OAuthErrorData {
793 error: OAuthErrorCode::UseDpopNonce.as_str().into(), error_description: "Server requires DPoP nonce".into(),
795 redirect_uri: None,
796 state: None,
797 nonce: Some(nonce.clone()),
798 },
799 };
800
801 ControllerError::new(
802 ControllerErrorType::OAuthError(Box::new(oauth_error)),
803 err.to_string(),
804 Some(err.into()),
805 )
806 }
807}
808
809#[derive(Debug, thiserror::Error)]
810pub enum PkceFlowError {
811 #[error("{0}")]
813 InvalidRequest(&'static str),
814
815 #[error("{0}")]
817 InvalidGrant(&'static str),
818
819 #[error("{0}")]
821 ServerError(&'static str),
822}
823
824impl From<PkceFlowError> for ControllerError {
825 fn from(err: PkceFlowError) -> Self {
826 let data = match &err {
827 PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
828 error: OAuthErrorCode::InvalidRequest.as_str().into(),
829 error_description: (*msg).into(),
830 redirect_uri: None,
831 state: None,
832 nonce: None,
833 },
834 PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
835 error: OAuthErrorCode::InvalidGrant.as_str().into(),
836 error_description: (*msg).into(),
837 redirect_uri: None,
838 state: None,
839 nonce: None,
840 },
841 PkceFlowError::ServerError(msg) => OAuthErrorData {
842 error: OAuthErrorCode::ServerError.as_str().into(),
843 error_description: (*msg).into(),
844 redirect_uri: None,
845 state: None,
846 nonce: None,
847 },
848 };
849
850 ControllerError::new(
851 ControllerErrorType::OAuthError(Box::new(data)),
852 err.to_string(),
853 Some(anyhow::anyhow!(err)),
854 )
855 }
856}
857
858impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
859 fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
860 PkceFlowError::InvalidRequest("invalid code_verifier")
862 }
863}
864
865impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
866 fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
867 PkceFlowError::from(err).into()
868 }
869}
870
871impl From<ChatbotError> for ControllerError {
872 fn from(err: ChatbotError) -> Self {
873 ControllerError::new(
874 ControllerErrorType::InternalServerError,
875 err.message().to_string(),
876 Some(err.into()),
877 )
878 }
879}
880
881headless_lms_utils::define_err_macro!(
883 controller_err,
884 ControllerError,
885 ControllerErrorType,
886 ControllerErrorType,
887 "Create a ControllerError with less boilerplate."
888);
889
890pub fn as_controller_error<E>(
905 error_type: ControllerErrorType,
906 message: impl Into<String>,
907) -> impl FnOnce(E) -> ControllerError
908where
909 E: Into<anyhow::Error>,
910{
911 let msg = message.into();
912 move |e| ControllerError::new(error_type, msg, Some(e.into()))
913}
914
915pub fn missing_controller_error(
930 error_type: ControllerErrorType,
931 message: impl Into<String>,
932) -> impl FnOnce() -> ControllerError {
933 let msg = message.into();
934 move || ControllerError::new(error_type, msg, None)
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940 use actix_web::ResponseError;
941 use futures_util::FutureExt;
942
943 #[test]
944 fn test_controller_err_macro_without_source() {
945 let err = controller_err!(BadRequest, "Test error message".to_string());
946 assert_eq!(err.message(), "Test error message");
947 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
948 }
949
950 #[test]
951 fn test_controller_err_macro_with_source() {
952 let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
953 let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
954 assert_eq!(err.message(), "Wrapped error");
955 }
956
957 #[test]
958 fn test_controller_err_macro_tuple_variant_with_source() {
959 let source_err = std::io::Error::other("source");
960 let err = controller_err!(
961 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
962 "Wrapped error".to_string(),
963 source_err
964 );
965 assert!(matches!(
966 err.error_type(),
967 ControllerErrorType::UnauthorizedWithReason(_)
968 ));
969 }
970
971 #[test]
972 fn test_as_controller_error_helper() {
973 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
974 std::io::ErrorKind::NotFound,
975 "test error",
976 ));
977 let controller_result = result.map_err(as_controller_error(
978 ControllerErrorType::BadRequest,
979 "Invalid input".to_string(),
980 ));
981
982 assert!(controller_result.is_err());
983 let err = controller_result.unwrap_err();
984 assert_eq!(err.message(), "Invalid input");
985 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
986 }
987
988 #[test]
989 fn test_missing_controller_error_helper() {
990 let option: Option<String> = None;
991 let result = option.ok_or_else(missing_controller_error(
992 ControllerErrorType::NotFound,
993 "Resource not found".to_string(),
994 ));
995
996 assert!(result.is_err());
997 let err = result.unwrap_err();
998 assert_eq!(err.message(), "Resource not found");
999 assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1000 }
1001
1002 #[test]
1003 fn test_controller_err_with_format() {
1004 let user_id = 42;
1005 let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1006 assert_eq!(err.message(), "User 42 is not authorized");
1007 }
1008
1009 #[test]
1010 fn test_controller_err_all_variants() {
1011 let _ = controller_err!(InternalServerError, "test".to_string());
1013 let _ = controller_err!(BadRequest, "test".to_string());
1014 let _ = controller_err!(NotFound, "test".to_string());
1015 let _ = controller_err!(Unauthorized, "test".to_string());
1016 let _ = controller_err!(
1017 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1018 "test".to_string()
1019 );
1020 let _ = controller_err!(
1021 BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1022 "test".to_string()
1023 );
1024 let _ = controller_err!(Forbidden, "test".to_string());
1025 }
1026
1027 #[test]
1028 fn test_canonical_error_envelope_shape() {
1029 let err = controller_err!(BadRequest, "Validation failed".to_string());
1030 let response = err.error_response();
1031 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1032
1033 let bytes = actix_web::body::to_bytes(response.into_body())
1034 .now_or_never()
1035 .expect("response should resolve immediately")
1036 .expect("body bytes");
1037 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1038 assert_eq!(value["type"], "validation_error");
1039 assert_eq!(value["message_key"], "validation_error");
1040 assert_eq!(value["message"], "Validation failed");
1041 assert!(value.get("status").is_none());
1042 assert!(value.get("request_id").is_none());
1043 }
1044
1045 #[test]
1046 fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1047 let err = ControllerError::new(
1048 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1049 MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1050 None,
1051 );
1052 let response = err.error_response();
1053 let bytes = actix_web::body::to_bytes(response.into_body())
1054 .now_or_never()
1055 .expect("response should resolve immediately")
1056 .expect("body bytes");
1057 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1058
1059 assert_eq!(value["type"], "validation_error");
1060 assert_eq!(value["message_key"], "validation_error_with_metadata");
1061 assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1062 assert_eq!(value["errors"][0]["path"], "exercise_type");
1063 }
1064
1065 #[test]
1066 fn test_chapter_not_open_uses_dedicated_message_key() {
1067 let err = ControllerError::new(
1068 ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1069 "Chapter is not open yet.".to_string(),
1070 None,
1071 );
1072 let response = err.error_response();
1073 let bytes = actix_web::body::to_bytes(response.into_body())
1074 .now_or_never()
1075 .expect("response should resolve immediately")
1076 .expect("body bytes");
1077 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1078
1079 assert_eq!(value["type"], "unauthorized");
1080 assert_eq!(value["message_key"], "chapter_not_open_yet");
1081 assert_eq!(value["message"], "Chapter is not open yet.");
1082 }
1083
1084 #[test]
1085 fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1086 let err = ControllerError::new(
1087 ControllerErrorType::UnauthorizedWithReason(
1088 UnauthorizedReason::AuthenticationRequiredForExamExercise,
1089 ),
1090 "User must be authenticated to view exam exercises".to_string(),
1091 None,
1092 );
1093 let response = err.error_response();
1094 let bytes = actix_web::body::to_bytes(response.into_body())
1095 .now_or_never()
1096 .expect("response should resolve immediately")
1097 .expect("body bytes");
1098 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1099
1100 assert_eq!(value["type"], "unauthorized");
1101 assert_eq!(
1102 value["message_key"],
1103 "authentication_required_for_exam_exercise"
1104 );
1105 assert_eq!(
1106 value["message"],
1107 "User must be authenticated to view exam exercises"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_generic_unauthorized_uses_unauthorized_message_key() {
1113 let err = ControllerError::new(
1114 ControllerErrorType::Unauthorized,
1115 "Unauthorized".to_string(),
1116 None,
1117 );
1118 let response = err.error_response();
1119 let bytes = actix_web::body::to_bytes(response.into_body())
1120 .now_or_never()
1121 .expect("response should resolve immediately")
1122 .expect("body bytes");
1123 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1124
1125 assert_eq!(value["type"], "unauthorized");
1126 assert_eq!(value["message_key"], "unauthorized");
1127 assert_eq!(value["message"], "Unauthorized");
1128 }
1129}