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 ControllerErrorType::OAuthError(data) = &self.error_type {
312 if let Some(uri) = &data.redirect_uri
313 && let Ok(mut url) = url::Url::parse(uri)
314 {
315 {
316 let mut qp = url.query_pairs_mut();
317 qp.append_pair("error", &data.error);
318 qp.append_pair("error_description", &data.error_description);
319 if let Some(state) = &data.state {
320 qp.append_pair("state", state);
321 }
322 }
323 let loc = url.to_string();
324 return HttpResponse::Found()
325 .append_header(("Location", loc))
326 .finish();
327 }
328
329 let status = match data.error.as_str() {
330 "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,
336 };
337
338 let mut res = HttpResponse::build(status);
339 fn escape_auth_param(s: &str) -> String {
341 s.replace('\\', "\\\\").replace('"', "\\\"")
342 }
343
344 match data.error.as_str() {
345 "invalid_client" | "invalid_token" | "insufficient_scope" | "invalid_request" => {
347 let err = escape_auth_param(&data.error);
348 let desc = escape_auth_param(&data.error_description);
349 let hdr = format!(r#"Bearer error="{}", error_description="{}""#, err, desc);
350 res.append_header(("WWW-Authenticate", hdr));
351 }
352
353 "invalid_dpop_proof" => {
355 let err = escape_auth_param(&data.error);
356 let desc = escape_auth_param(&data.error_description);
357 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
358 res.append_header(("WWW-Authenticate", hdr));
359 }
360
361 "use_dpop_nonce" => {
362 let err = escape_auth_param(&data.error);
363 let desc = escape_auth_param(&data.error_description);
364 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
365 res.append_header(("WWW-Authenticate", hdr));
366
367 if let Some(nonce) = &data.nonce {
369 res.append_header(("DPoP-Nonce", nonce.clone()));
370 }
371 }
372
373 _ => {}
374 }
375
376 res.append_header(("Cache-Control", "no-store"))
378 .append_header(("Pragma", "no-cache"));
379
380 return res.json(serde_json::json!({
383 "error": data.error,
384 "error_description": data.error_description
385 }));
386 }
387
388 let status = self.status_code();
389
390 let metadata = match &self.error_type {
391 ControllerErrorType::BadRequestWithData(data) => Some(data.clone()),
392 _ => None,
393 };
394
395 let metadata_json =
396 metadata.map(|ErrorMetadata::BlockId(id)| serde_json::json!({ "block_id": id }));
397 let (error_type, message_key) = self.error_type_and_message_key();
398 let errors = self.validation_issues();
399 let message = match self.error_type {
400 ControllerErrorType::InternalServerError => None,
401 _ => Some(self.message.clone()),
402 };
403
404 let error_response = ApiErrorResponse {
405 error_type: Some(error_type.to_string()),
406 message_key: Some(message_key.to_string()),
407 message,
408 errors,
409 metadata: metadata_json,
410 };
411
412 HttpResponseBuilder::new(status)
413 .append_header(ContentType::json())
414 .body(serde_json::to_string(&error_response).unwrap_or_else(|_| {
415 r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
416 }))
417 }
418
419 fn status_code(&self) -> StatusCode {
420 match self.error_type {
421 ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
422 ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
423 ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
424 ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
425 ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
426 ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
427 ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
428 ControllerErrorType::OAuthError(_) => StatusCode::OK,
429 }
430 }
431}
432
433impl ControllerError {
434 fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
435 match self.error_type {
436 ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
437 ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
438 ControllerErrorType::BadRequestWithData(_) => {
439 ("validation_error", "validation_error_with_metadata")
440 }
441 ControllerErrorType::NotFound => ("not_found", "not_found"),
442 ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
443 ControllerErrorType::UnauthorizedWithReason(reason) => {
444 ("unauthorized", reason.message_key())
445 }
446 ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
447 ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
448 }
449 }
450
451 fn validation_issues(&self) -> Vec<ApiErrorIssue> {
453 match &self.error_type {
454 ControllerErrorType::BadRequestWithData(_)
455 if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
456 {
457 vec![ApiErrorIssue {
458 path: Some("exercise_type".to_string()),
459 code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
460 message: self.message.clone(),
461 }]
462 }
463 _ => Vec::new(),
464 }
465 }
466}
467
468#[derive(Debug, Serialize, Deserialize, Clone)]
469#[serde(rename_all = "snake_case")]
470pub struct OAuthErrorData {
471 pub error: String,
472 pub error_description: String,
473 pub redirect_uri: Option<String>,
474 pub state: Option<String>,
475 pub nonce: Option<String>,
476}
477
478pub enum OAuthErrorCode {
479 InvalidGrant,
480 InvalidRequest,
481 InvalidClient,
482 InvalidToken,
483 InsufficientScope,
484 UnsupportedGrantType,
485 UnsupportedResponseType,
486 ServerError,
487 InvalidDpopProof,
488 UseDpopNonce,
489}
490
491impl OAuthErrorCode {
492 pub fn as_str(&self) -> &'static str {
493 match self {
494 Self::InvalidGrant => "invalid_grant",
495 Self::InvalidRequest => "invalid_request",
496 Self::InvalidClient => "invalid_client",
497 Self::InvalidToken => "invalid_token",
498 Self::InsufficientScope => "insufficient_scope",
499 Self::UnsupportedGrantType => "unsupported_grant_type",
500 Self::UnsupportedResponseType => "unsupported_response_type",
501 Self::ServerError => "server_error",
502 Self::InvalidDpopProof => "invalid_dpop_proof",
503 Self::UseDpopNonce => "use_dpop_nonce",
504 }
505 }
506}
507
508impl From<anyhow::Error> for ControllerError {
509 fn from(err: anyhow::Error) -> ControllerError {
510 if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
511 return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
512 }
513
514 Self::new(
515 ControllerErrorType::InternalServerError,
516 err.to_string(),
517 Some(err),
518 )
519 }
520}
521
522impl From<uuid::Error> for ControllerError {
523 fn from(err: uuid::Error) -> ControllerError {
524 Self::new(
525 ControllerErrorType::BadRequest,
526 err.to_string(),
527 Some(err.into()),
528 )
529 }
530}
531
532impl From<sqlx::Error> for ControllerError {
533 fn from(err: sqlx::Error) -> ControllerError {
534 Self::new(
535 ControllerErrorType::InternalServerError,
536 err.to_string(),
537 Some(err.into()),
538 )
539 }
540}
541
542impl From<git2::Error> for ControllerError {
543 fn from(err: git2::Error) -> ControllerError {
544 Self::new(
545 ControllerErrorType::InternalServerError,
546 err.to_string(),
547 Some(err.into()),
548 )
549 }
550}
551
552impl From<actix_web::Error> for ControllerError {
553 fn from(err: actix_web::Error) -> Self {
554 Self::new(
555 ControllerErrorType::InternalServerError,
556 err.to_string(),
557 None,
558 )
559 }
560}
561
562impl From<actix_multipart::MultipartError> for ControllerError {
563 fn from(err: actix_multipart::MultipartError) -> Self {
564 Self::new(
565 ControllerErrorType::InternalServerError,
566 err.to_string(),
567 None,
568 )
569 }
570}
571
572impl From<jsonwebtoken::errors::Error> for ControllerError {
573 fn from(err: jsonwebtoken::errors::Error) -> Self {
574 Self::new(
575 ControllerErrorType::InternalServerError,
576 err.to_string(),
577 None,
578 )
579 }
580}
581
582impl From<ModelError> for ControllerError {
583 fn from(err: ModelError) -> Self {
584 let backtrace: Backtrace =
585 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
586 Some(backtrace) => backtrace.clone(),
587 _ => Backtrace::new(),
588 };
589 let span_trace = err.span_trace().clone();
590 match err.error_type() {
591 ModelErrorType::RecordNotFound => Self::new_with_traces(
592 ControllerErrorType::NotFound,
593 err.to_string(),
594 Some(err.into()),
595 backtrace,
596 span_trace,
597 ),
598 ModelErrorType::NotFound => Self::new_with_traces(
599 ControllerErrorType::NotFound,
600 err.to_string(),
601 Some(err.into()),
602 backtrace,
603 span_trace,
604 ),
605 ModelErrorType::PreconditionFailed => Self::new_with_traces(
606 ControllerErrorType::BadRequest,
607 err.message().to_string(),
608 Some(err.into()),
609 backtrace,
610 span_trace,
611 ),
612 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
613 Self::new_with_traces(
614 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
615 description.to_string(),
616 Some(err.into()),
617 backtrace,
618 span_trace,
619 )
620 }
621 ModelErrorType::DatabaseConstraint { description, .. } => Self::new_with_traces(
622 ControllerErrorType::BadRequest,
623 description.to_string(),
624 Some(err.into()),
625 backtrace,
626 span_trace,
627 ),
628 ModelErrorType::InvalidRequest => Self::new_with_traces(
629 ControllerErrorType::BadRequest,
630 err.message().to_string(),
631 Some(err.into()),
632 backtrace,
633 span_trace,
634 ),
635 _ => Self::new_with_traces(
636 ControllerErrorType::InternalServerError,
637 err.to_string(),
638 Some(err.into()),
639 backtrace,
640 span_trace,
641 ),
642 }
643 }
644}
645
646impl From<UtilError> for ControllerError {
647 fn from(err: UtilError) -> Self {
648 let backtrace: Backtrace =
649 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
650 Some(backtrace) => backtrace.clone(),
651 _ => Backtrace::new(),
652 };
653 let span_trace = err.span_trace().clone();
654 Self::new_with_traces(
655 ControllerErrorType::InternalServerError,
656 err.to_string(),
657 Some(err.into()),
658 backtrace,
659 span_trace,
660 )
661 }
662}
663
664impl From<serde_json::Error> for ControllerError {
665 fn from(err: serde_json::Error) -> Self {
666 Self::new(
667 ControllerErrorType::InternalServerError,
668 err.to_string(),
669 Some(err.into()),
670 )
671 }
672}
673
674impl From<base64::DecodeError> for ControllerError {
675 fn from(err: base64::DecodeError) -> Self {
676 Self::new(
677 ControllerErrorType::InternalServerError,
678 err.to_string(),
679 Some(err.into()),
680 )
681 }
682}
683
684impl From<std::string::FromUtf8Error> for ControllerError {
685 fn from(err: std::string::FromUtf8Error) -> Self {
686 Self::new(
687 ControllerErrorType::InternalServerError,
688 err.to_string(),
689 Some(err.into()),
690 )
691 }
692}
693
694impl From<pkcs8::spki::Error> for ControllerError {
695 fn from(err: pkcs8::spki::Error) -> Self {
696 Self::new(
697 ControllerErrorType::InternalServerError,
698 err.to_string(),
699 Some(err.into()),
700 )
701 }
702}
703
704impl From<dpop_verifier::error::DpopError> for ControllerError {
705 fn from(err: DpopError) -> Self {
706 let oauth_error = match &err {
707 DpopError::MultipleDpopHeaders
708 | DpopError::InvalidDpopHeader
709 | DpopError::MissingDpopHeader
710 | DpopError::MalformedJws
711 | DpopError::InvalidAlg(_)
712 | DpopError::UnsupportedAlg(_)
713 | DpopError::InvalidSignature
714 | DpopError::BadJwk(_)
715 | DpopError::MissingClaim(_)
716 | DpopError::InvalidMethod
717 | DpopError::HtmMismatch
718 | DpopError::MalformedHtu
719 | DpopError::HtuMismatch
720 | DpopError::AthMalformed
721 | DpopError::MissingAth
722 | DpopError::AthMismatch
723 | DpopError::FutureSkew
724 | DpopError::Stale
725 | DpopError::Replay
726 | DpopError::JtiTooLong
727 | DpopError::NonceMismatch
728 | DpopError::NonceStale
729 | DpopError::InvalidHmacConfig
730 | DpopError::MissingNonce => OAuthErrorData {
731 error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
732 error_description: err.to_string(),
733 redirect_uri: None,
734 state: None,
735 nonce: None,
736 },
737
738 DpopError::Store(e) => OAuthErrorData {
739 error: OAuthErrorCode::ServerError.as_str().into(),
740 error_description: format!("DPoP storage error: {e}"),
741 redirect_uri: None,
742 state: None,
743 nonce: None,
744 },
745
746 DpopError::UseDpopNonce { nonce } => OAuthErrorData {
747 error: OAuthErrorCode::UseDpopNonce.as_str().into(), error_description: "Server requires DPoP nonce".into(),
749 redirect_uri: None,
750 state: None,
751 nonce: Some(nonce.clone()),
752 },
753 };
754
755 ControllerError::new(
756 ControllerErrorType::OAuthError(Box::new(oauth_error)),
757 err.to_string(),
758 Some(err.into()),
759 )
760 }
761}
762
763#[derive(Debug, thiserror::Error)]
764pub enum PkceFlowError {
765 #[error("{0}")]
767 InvalidRequest(&'static str),
768
769 #[error("{0}")]
771 InvalidGrant(&'static str),
772
773 #[error("{0}")]
775 ServerError(&'static str),
776}
777
778impl From<PkceFlowError> for ControllerError {
779 fn from(err: PkceFlowError) -> Self {
780 let data = match &err {
781 PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
782 error: OAuthErrorCode::InvalidRequest.as_str().into(),
783 error_description: (*msg).into(),
784 redirect_uri: None,
785 state: None,
786 nonce: None,
787 },
788 PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
789 error: OAuthErrorCode::InvalidGrant.as_str().into(),
790 error_description: (*msg).into(),
791 redirect_uri: None,
792 state: None,
793 nonce: None,
794 },
795 PkceFlowError::ServerError(msg) => OAuthErrorData {
796 error: OAuthErrorCode::ServerError.as_str().into(),
797 error_description: (*msg).into(),
798 redirect_uri: None,
799 state: None,
800 nonce: None,
801 },
802 };
803
804 ControllerError::new(
805 ControllerErrorType::OAuthError(Box::new(data)),
806 err.to_string(),
807 Some(anyhow::anyhow!(err)),
808 )
809 }
810}
811
812impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
813 fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
814 PkceFlowError::InvalidRequest("invalid code_verifier")
816 }
817}
818
819impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
820 fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
821 PkceFlowError::from(err).into()
822 }
823}
824
825impl From<ChatbotError> for ControllerError {
826 fn from(err: ChatbotError) -> Self {
827 ControllerError::new(
828 ControllerErrorType::InternalServerError,
829 err.message().to_string(),
830 Some(err.into()),
831 )
832 }
833}
834
835headless_lms_utils::define_err_macro!(
837 controller_err,
838 ControllerError,
839 ControllerErrorType,
840 "Create a ControllerError with less boilerplate."
841);
842
843pub fn as_controller_error<E>(
858 error_type: ControllerErrorType,
859 message: impl Into<String>,
860) -> impl FnOnce(E) -> ControllerError
861where
862 E: Into<anyhow::Error>,
863{
864 let msg = message.into();
865 move |e| ControllerError::new(error_type, msg, Some(e.into()))
866}
867
868pub fn missing_controller_error(
883 error_type: ControllerErrorType,
884 message: impl Into<String>,
885) -> impl FnOnce() -> ControllerError {
886 let msg = message.into();
887 move || ControllerError::new(error_type, msg, None)
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893 use actix_web::ResponseError;
894 use futures_util::FutureExt;
895
896 #[test]
897 fn test_controller_err_macro_without_source() {
898 let err = controller_err!(BadRequest, "Test error message".to_string());
899 assert_eq!(err.message(), "Test error message");
900 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
901 }
902
903 #[test]
904 fn test_controller_err_macro_with_source() {
905 let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
906 let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
907 assert_eq!(err.message(), "Wrapped error");
908 }
909
910 #[test]
911 fn test_as_controller_error_helper() {
912 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
913 std::io::ErrorKind::NotFound,
914 "test error",
915 ));
916 let controller_result = result.map_err(as_controller_error(
917 ControllerErrorType::BadRequest,
918 "Invalid input".to_string(),
919 ));
920
921 assert!(controller_result.is_err());
922 let err = controller_result.unwrap_err();
923 assert_eq!(err.message(), "Invalid input");
924 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
925 }
926
927 #[test]
928 fn test_missing_controller_error_helper() {
929 let option: Option<String> = None;
930 let result = option.ok_or_else(missing_controller_error(
931 ControllerErrorType::NotFound,
932 "Resource not found".to_string(),
933 ));
934
935 assert!(result.is_err());
936 let err = result.unwrap_err();
937 assert_eq!(err.message(), "Resource not found");
938 assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
939 }
940
941 #[test]
942 fn test_controller_err_with_format() {
943 let user_id = 42;
944 let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
945 assert_eq!(err.message(), "User 42 is not authorized");
946 }
947
948 #[test]
949 fn test_controller_err_all_variants() {
950 let _ = controller_err!(InternalServerError, "test".to_string());
952 let _ = controller_err!(BadRequest, "test".to_string());
953 let _ = controller_err!(NotFound, "test".to_string());
954 let _ = controller_err!(Unauthorized, "test".to_string());
955 let _ = ControllerError::new(
956 ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
957 "test".to_string(),
958 None,
959 );
960 let _ = controller_err!(Forbidden, "test".to_string());
961 }
962
963 #[test]
964 fn test_canonical_error_envelope_shape() {
965 let err = controller_err!(BadRequest, "Validation failed".to_string());
966 let response = err.error_response();
967 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
968
969 let bytes = actix_web::body::to_bytes(response.into_body())
970 .now_or_never()
971 .expect("response should resolve immediately")
972 .expect("body bytes");
973 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
974 assert_eq!(value["type"], "validation_error");
975 assert_eq!(value["message_key"], "validation_error");
976 assert_eq!(value["message"], "Validation failed");
977 assert!(value.get("status").is_none());
978 assert!(value.get("request_id").is_none());
979 }
980
981 #[test]
982 fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
983 let err = ControllerError::new(
984 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
985 MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
986 None,
987 );
988 let response = err.error_response();
989 let bytes = actix_web::body::to_bytes(response.into_body())
990 .now_or_never()
991 .expect("response should resolve immediately")
992 .expect("body bytes");
993 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
994
995 assert_eq!(value["type"], "validation_error");
996 assert_eq!(value["message_key"], "validation_error_with_metadata");
997 assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
998 assert_eq!(value["errors"][0]["path"], "exercise_type");
999 }
1000
1001 #[test]
1002 fn test_chapter_not_open_uses_dedicated_message_key() {
1003 let err = ControllerError::new(
1004 ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1005 "Chapter is not open yet.".to_string(),
1006 None,
1007 );
1008 let response = err.error_response();
1009 let bytes = actix_web::body::to_bytes(response.into_body())
1010 .now_or_never()
1011 .expect("response should resolve immediately")
1012 .expect("body bytes");
1013 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1014
1015 assert_eq!(value["type"], "unauthorized");
1016 assert_eq!(value["message_key"], "chapter_not_open_yet");
1017 assert_eq!(value["message"], "Chapter is not open yet.");
1018 }
1019
1020 #[test]
1021 fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1022 let err = ControllerError::new(
1023 ControllerErrorType::UnauthorizedWithReason(
1024 UnauthorizedReason::AuthenticationRequiredForExamExercise,
1025 ),
1026 "User must be authenticated to view exam exercises".to_string(),
1027 None,
1028 );
1029 let response = err.error_response();
1030 let bytes = actix_web::body::to_bytes(response.into_body())
1031 .now_or_never()
1032 .expect("response should resolve immediately")
1033 .expect("body bytes");
1034 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1035
1036 assert_eq!(value["type"], "unauthorized");
1037 assert_eq!(
1038 value["message_key"],
1039 "authentication_required_for_exam_exercise"
1040 );
1041 assert_eq!(
1042 value["message"],
1043 "User must be authenticated to view exam exercises"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_generic_unauthorized_uses_unauthorized_message_key() {
1049 let err = ControllerError::new(
1050 ControllerErrorType::Unauthorized,
1051 "Unauthorized".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"], "unauthorized");
1062 assert_eq!(value["message_key"], "unauthorized");
1063 assert_eq!(value["message"], "Unauthorized");
1064 }
1065}