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 = match self.error_type {
448 ControllerErrorType::InternalServerError => None,
449 _ => Some(self.message.clone()),
450 };
451
452 let error_response = ApiErrorResponse {
453 error_type: Some(error_type.to_string()),
454 message_key: Some(message_key.to_string()),
455 message,
456 errors,
457 metadata: metadata_json,
458 };
459
460 HttpResponseBuilder::new(status)
461 .append_header(ContentType::json())
462 .body(serde_json::to_string(&error_response).unwrap_or_else(|_| {
463 r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
464 }))
465 }
466
467 fn status_code(&self) -> StatusCode {
468 match self.error_type {
469 ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
470 ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
471 ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
472 ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
473 ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
474 ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
475 ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
476 ControllerErrorType::OAuthError(_) => StatusCode::OK,
477 }
478 }
479}
480
481impl ControllerError {
482 fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
483 match self.error_type {
484 ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
485 ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
486 ControllerErrorType::BadRequestWithData(_) => {
487 ("validation_error", "validation_error_with_metadata")
488 }
489 ControllerErrorType::NotFound => ("not_found", "not_found"),
490 ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
491 ControllerErrorType::UnauthorizedWithReason(reason) => {
492 ("unauthorized", reason.message_key())
493 }
494 ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
495 ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
496 }
497 }
498
499 fn validation_issues(&self) -> Vec<ApiErrorIssue> {
501 match &self.error_type {
502 ControllerErrorType::BadRequestWithData(_)
503 if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
504 {
505 vec![ApiErrorIssue {
506 path: Some("exercise_type".to_string()),
507 code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
508 message: self.message.clone(),
509 }]
510 }
511 _ => Vec::new(),
512 }
513 }
514}
515
516#[derive(Debug, Serialize, Deserialize, Clone)]
517#[serde(rename_all = "snake_case")]
518pub struct OAuthErrorData {
519 pub error: String,
520 pub error_description: String,
521 pub redirect_uri: Option<String>,
522 pub state: Option<String>,
523 pub nonce: Option<String>,
524}
525
526pub enum OAuthErrorCode {
527 InvalidGrant,
528 InvalidRequest,
529 InvalidClient,
530 InvalidToken,
531 InsufficientScope,
532 UnsupportedGrantType,
533 UnsupportedResponseType,
534 ServerError,
535 InvalidDpopProof,
536 UseDpopNonce,
537}
538
539impl OAuthErrorCode {
540 pub fn as_str(&self) -> &'static str {
541 match self {
542 Self::InvalidGrant => "invalid_grant",
543 Self::InvalidRequest => "invalid_request",
544 Self::InvalidClient => "invalid_client",
545 Self::InvalidToken => "invalid_token",
546 Self::InsufficientScope => "insufficient_scope",
547 Self::UnsupportedGrantType => "unsupported_grant_type",
548 Self::UnsupportedResponseType => "unsupported_response_type",
549 Self::ServerError => "server_error",
550 Self::InvalidDpopProof => "invalid_dpop_proof",
551 Self::UseDpopNonce => "use_dpop_nonce",
552 }
553 }
554}
555
556impl From<anyhow::Error> for ControllerError {
557 fn from(err: anyhow::Error) -> ControllerError {
558 if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
559 return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
560 }
561
562 Self::new(
563 ControllerErrorType::InternalServerError,
564 err.to_string(),
565 Some(err),
566 )
567 }
568}
569
570impl From<uuid::Error> for ControllerError {
571 fn from(err: uuid::Error) -> ControllerError {
572 Self::new(
573 ControllerErrorType::BadRequest,
574 err.to_string(),
575 Some(err.into()),
576 )
577 }
578}
579
580impl From<sqlx::Error> for ControllerError {
581 fn from(err: sqlx::Error) -> ControllerError {
582 Self::new(
583 ControllerErrorType::InternalServerError,
584 err.to_string(),
585 Some(err.into()),
586 )
587 }
588}
589
590impl From<git2::Error> for ControllerError {
591 fn from(err: git2::Error) -> ControllerError {
592 Self::new(
593 ControllerErrorType::InternalServerError,
594 err.to_string(),
595 Some(err.into()),
596 )
597 }
598}
599
600impl From<actix_web::Error> for ControllerError {
601 fn from(err: actix_web::Error) -> Self {
602 Self::new(
603 ControllerErrorType::InternalServerError,
604 err.to_string(),
605 None,
606 )
607 }
608}
609
610impl From<actix_multipart::MultipartError> for ControllerError {
611 fn from(err: actix_multipart::MultipartError) -> Self {
612 Self::new(
613 ControllerErrorType::InternalServerError,
614 err.to_string(),
615 None,
616 )
617 }
618}
619
620impl From<jsonwebtoken::errors::Error> for ControllerError {
621 fn from(err: jsonwebtoken::errors::Error) -> Self {
622 Self::new(
623 ControllerErrorType::InternalServerError,
624 err.to_string(),
625 None,
626 )
627 }
628}
629
630impl From<ModelError> for ControllerError {
631 fn from(err: ModelError) -> Self {
632 let backtrace: Backtrace =
633 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
634 Some(backtrace) => backtrace.clone(),
635 _ => Backtrace::new(),
636 };
637 let span_trace = err.span_trace().clone();
638 match err.error_type() {
639 ModelErrorType::RecordNotFound => Self::new_with_traces(
640 ControllerErrorType::NotFound,
641 err.to_string(),
642 Some(err.into()),
643 backtrace,
644 span_trace,
645 ),
646 ModelErrorType::NotFound => Self::new_with_traces(
647 ControllerErrorType::NotFound,
648 err.to_string(),
649 Some(err.into()),
650 backtrace,
651 span_trace,
652 ),
653 ModelErrorType::PreconditionFailed => Self::new_with_traces(
654 ControllerErrorType::BadRequest,
655 err.message().to_string(),
656 Some(err.into()),
657 backtrace,
658 span_trace,
659 ),
660 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
661 Self::new_with_traces(
662 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
663 description.to_string(),
664 Some(err.into()),
665 backtrace,
666 span_trace,
667 )
668 }
669 ModelErrorType::DatabaseConstraint { description, .. } => Self::new_with_traces(
670 ControllerErrorType::BadRequest,
671 description.to_string(),
672 Some(err.into()),
673 backtrace,
674 span_trace,
675 ),
676 ModelErrorType::InvalidRequest => Self::new_with_traces(
677 ControllerErrorType::BadRequest,
678 err.message().to_string(),
679 Some(err.into()),
680 backtrace,
681 span_trace,
682 ),
683 _ => Self::new_with_traces(
684 ControllerErrorType::InternalServerError,
685 err.to_string(),
686 Some(err.into()),
687 backtrace,
688 span_trace,
689 ),
690 }
691 }
692}
693
694impl From<UtilError> for ControllerError {
695 fn from(err: UtilError) -> Self {
696 let backtrace: Backtrace =
697 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
698 Some(backtrace) => backtrace.clone(),
699 _ => Backtrace::new(),
700 };
701 let span_trace = err.span_trace().clone();
702 Self::new_with_traces(
703 ControllerErrorType::InternalServerError,
704 err.to_string(),
705 Some(err.into()),
706 backtrace,
707 span_trace,
708 )
709 }
710}
711
712impl From<serde_json::Error> for ControllerError {
713 fn from(err: serde_json::Error) -> Self {
714 Self::new(
715 ControllerErrorType::InternalServerError,
716 err.to_string(),
717 Some(err.into()),
718 )
719 }
720}
721
722impl From<base64::DecodeError> for ControllerError {
723 fn from(err: base64::DecodeError) -> Self {
724 Self::new(
725 ControllerErrorType::InternalServerError,
726 err.to_string(),
727 Some(err.into()),
728 )
729 }
730}
731
732impl From<std::string::FromUtf8Error> for ControllerError {
733 fn from(err: std::string::FromUtf8Error) -> Self {
734 Self::new(
735 ControllerErrorType::InternalServerError,
736 err.to_string(),
737 Some(err.into()),
738 )
739 }
740}
741
742impl From<pkcs8::spki::Error> for ControllerError {
743 fn from(err: pkcs8::spki::Error) -> Self {
744 Self::new(
745 ControllerErrorType::InternalServerError,
746 err.to_string(),
747 Some(err.into()),
748 )
749 }
750}
751
752impl From<dpop_verifier::error::DpopError> for ControllerError {
753 fn from(err: DpopError) -> Self {
754 let oauth_error = match &err {
755 DpopError::MultipleDpopHeaders
756 | DpopError::InvalidDpopHeader
757 | DpopError::MissingDpopHeader
758 | DpopError::MalformedJws
759 | DpopError::InvalidAlg(_)
760 | DpopError::UnsupportedAlg(_)
761 | DpopError::InvalidSignature
762 | DpopError::BadJwk(_)
763 | DpopError::MissingClaim(_)
764 | DpopError::InvalidMethod
765 | DpopError::HtmMismatch
766 | DpopError::MalformedHtu
767 | DpopError::HtuMismatch
768 | DpopError::AthMalformed
769 | DpopError::MissingAth
770 | DpopError::AthMismatch
771 | DpopError::FutureSkew
772 | DpopError::Stale
773 | DpopError::Replay
774 | DpopError::JtiTooLong
775 | DpopError::NonceMismatch
776 | DpopError::NonceStale
777 | DpopError::InvalidHmacConfig
778 | DpopError::MissingNonce => OAuthErrorData {
779 error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
780 error_description: err.to_string(),
781 redirect_uri: None,
782 state: None,
783 nonce: None,
784 },
785
786 DpopError::Store(e) => OAuthErrorData {
787 error: OAuthErrorCode::ServerError.as_str().into(),
788 error_description: format!("DPoP storage error: {e}"),
789 redirect_uri: None,
790 state: None,
791 nonce: None,
792 },
793
794 DpopError::UseDpopNonce { nonce } => OAuthErrorData {
795 error: OAuthErrorCode::UseDpopNonce.as_str().into(), error_description: "Server requires DPoP nonce".into(),
797 redirect_uri: None,
798 state: None,
799 nonce: Some(nonce.clone()),
800 },
801 };
802
803 ControllerError::new(
804 ControllerErrorType::OAuthError(Box::new(oauth_error)),
805 err.to_string(),
806 Some(err.into()),
807 )
808 }
809}
810
811#[derive(Debug, thiserror::Error)]
812pub enum PkceFlowError {
813 #[error("{0}")]
815 InvalidRequest(&'static str),
816
817 #[error("{0}")]
819 InvalidGrant(&'static str),
820
821 #[error("{0}")]
823 ServerError(&'static str),
824}
825
826impl From<PkceFlowError> for ControllerError {
827 fn from(err: PkceFlowError) -> Self {
828 let data = match &err {
829 PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
830 error: OAuthErrorCode::InvalidRequest.as_str().into(),
831 error_description: (*msg).into(),
832 redirect_uri: None,
833 state: None,
834 nonce: None,
835 },
836 PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
837 error: OAuthErrorCode::InvalidGrant.as_str().into(),
838 error_description: (*msg).into(),
839 redirect_uri: None,
840 state: None,
841 nonce: None,
842 },
843 PkceFlowError::ServerError(msg) => OAuthErrorData {
844 error: OAuthErrorCode::ServerError.as_str().into(),
845 error_description: (*msg).into(),
846 redirect_uri: None,
847 state: None,
848 nonce: None,
849 },
850 };
851
852 ControllerError::new(
853 ControllerErrorType::OAuthError(Box::new(data)),
854 err.to_string(),
855 Some(anyhow::anyhow!(err)),
856 )
857 }
858}
859
860impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
861 fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
862 PkceFlowError::InvalidRequest("invalid code_verifier")
864 }
865}
866
867impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
868 fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
869 PkceFlowError::from(err).into()
870 }
871}
872
873impl From<ChatbotError> for ControllerError {
874 fn from(err: ChatbotError) -> Self {
875 ControllerError::new(
876 ControllerErrorType::InternalServerError,
877 err.message().to_string(),
878 Some(err.into()),
879 )
880 }
881}
882
883headless_lms_utils::define_err_macro!(
885 controller_err,
886 ControllerError,
887 ControllerErrorType,
888 ControllerErrorType,
889 "Create a ControllerError with less boilerplate."
890);
891
892pub fn as_controller_error<E>(
907 error_type: ControllerErrorType,
908 message: impl Into<String>,
909) -> impl FnOnce(E) -> ControllerError
910where
911 E: Into<anyhow::Error>,
912{
913 let msg = message.into();
914 move |e| ControllerError::new(error_type, msg, Some(e.into()))
915}
916
917pub fn missing_controller_error(
932 error_type: ControllerErrorType,
933 message: impl Into<String>,
934) -> impl FnOnce() -> ControllerError {
935 let msg = message.into();
936 move || ControllerError::new(error_type, msg, None)
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use actix_web::ResponseError;
943 use futures_util::FutureExt;
944
945 #[test]
946 fn test_controller_err_macro_without_source() {
947 let err = controller_err!(BadRequest, "Test error message".to_string());
948 assert_eq!(err.message(), "Test error message");
949 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
950 }
951
952 #[test]
953 fn test_controller_err_macro_with_source() {
954 let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
955 let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
956 assert_eq!(err.message(), "Wrapped error");
957 }
958
959 #[test]
960 fn test_controller_err_macro_tuple_variant_with_source() {
961 let source_err = std::io::Error::other("source");
962 let err = controller_err!(
963 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
964 "Wrapped error".to_string(),
965 source_err
966 );
967 assert!(matches!(
968 err.error_type(),
969 ControllerErrorType::UnauthorizedWithReason(_)
970 ));
971 }
972
973 #[test]
974 fn test_as_controller_error_helper() {
975 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
976 std::io::ErrorKind::NotFound,
977 "test error",
978 ));
979 let controller_result = result.map_err(as_controller_error(
980 ControllerErrorType::BadRequest,
981 "Invalid input".to_string(),
982 ));
983
984 assert!(controller_result.is_err());
985 let err = controller_result.unwrap_err();
986 assert_eq!(err.message(), "Invalid input");
987 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
988 }
989
990 #[test]
991 fn test_missing_controller_error_helper() {
992 let option: Option<String> = None;
993 let result = option.ok_or_else(missing_controller_error(
994 ControllerErrorType::NotFound,
995 "Resource not found".to_string(),
996 ));
997
998 assert!(result.is_err());
999 let err = result.unwrap_err();
1000 assert_eq!(err.message(), "Resource not found");
1001 assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1002 }
1003
1004 #[test]
1005 fn test_controller_err_with_format() {
1006 let user_id = 42;
1007 let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1008 assert_eq!(err.message(), "User 42 is not authorized");
1009 }
1010
1011 #[test]
1012 fn test_controller_err_all_variants() {
1013 let _ = controller_err!(InternalServerError, "test".to_string());
1015 let _ = controller_err!(BadRequest, "test".to_string());
1016 let _ = controller_err!(NotFound, "test".to_string());
1017 let _ = controller_err!(Unauthorized, "test".to_string());
1018 let _ = controller_err!(
1019 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1020 "test".to_string()
1021 );
1022 let _ = controller_err!(
1023 BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1024 "test".to_string()
1025 );
1026 let _ = controller_err!(Forbidden, "test".to_string());
1027 }
1028
1029 #[test]
1030 fn test_canonical_error_envelope_shape() {
1031 let err = controller_err!(BadRequest, "Validation failed".to_string());
1032 let response = err.error_response();
1033 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1034
1035 let bytes = actix_web::body::to_bytes(response.into_body())
1036 .now_or_never()
1037 .expect("response should resolve immediately")
1038 .expect("body bytes");
1039 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1040 assert_eq!(value["type"], "validation_error");
1041 assert_eq!(value["message_key"], "validation_error");
1042 assert_eq!(value["message"], "Validation failed");
1043 assert!(value.get("status").is_none());
1044 assert!(value.get("request_id").is_none());
1045 }
1046
1047 #[test]
1048 fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1049 let err = ControllerError::new(
1050 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1051 MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1052 None,
1053 );
1054 let response = err.error_response();
1055 let bytes = actix_web::body::to_bytes(response.into_body())
1056 .now_or_never()
1057 .expect("response should resolve immediately")
1058 .expect("body bytes");
1059 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1060
1061 assert_eq!(value["type"], "validation_error");
1062 assert_eq!(value["message_key"], "validation_error_with_metadata");
1063 assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1064 assert_eq!(value["errors"][0]["path"], "exercise_type");
1065 }
1066
1067 #[test]
1068 fn test_chapter_not_open_uses_dedicated_message_key() {
1069 let err = ControllerError::new(
1070 ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1071 "Chapter is not open yet.".to_string(),
1072 None,
1073 );
1074 let response = err.error_response();
1075 let bytes = actix_web::body::to_bytes(response.into_body())
1076 .now_or_never()
1077 .expect("response should resolve immediately")
1078 .expect("body bytes");
1079 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1080
1081 assert_eq!(value["type"], "unauthorized");
1082 assert_eq!(value["message_key"], "chapter_not_open_yet");
1083 assert_eq!(value["message"], "Chapter is not open yet.");
1084 }
1085
1086 #[test]
1087 fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1088 let err = ControllerError::new(
1089 ControllerErrorType::UnauthorizedWithReason(
1090 UnauthorizedReason::AuthenticationRequiredForExamExercise,
1091 ),
1092 "User must be authenticated to view exam exercises".to_string(),
1093 None,
1094 );
1095 let response = err.error_response();
1096 let bytes = actix_web::body::to_bytes(response.into_body())
1097 .now_or_never()
1098 .expect("response should resolve immediately")
1099 .expect("body bytes");
1100 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1101
1102 assert_eq!(value["type"], "unauthorized");
1103 assert_eq!(
1104 value["message_key"],
1105 "authentication_required_for_exam_exercise"
1106 );
1107 assert_eq!(
1108 value["message"],
1109 "User must be authenticated to view exam exercises"
1110 );
1111 }
1112
1113 #[test]
1114 fn test_generic_unauthorized_uses_unauthorized_message_key() {
1115 let err = ControllerError::new(
1116 ControllerErrorType::Unauthorized,
1117 "Unauthorized".to_string(),
1118 None,
1119 );
1120 let response = err.error_response();
1121 let bytes = actix_web::body::to_bytes(response.into_body())
1122 .now_or_never()
1123 .expect("response should resolve immediately")
1124 .expect("body bytes");
1125 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1126
1127 assert_eq!(value["type"], "unauthorized");
1128 assert_eq!(value["message_key"], "unauthorized");
1129 assert_eq!(value["message"], "Unauthorized");
1130 }
1131}