1use crate::config::server_runtime_config;
4use crate::prelude::*;
5use actix_http::Payload;
6use actix_web::{FromRequest, HttpRequest};
7use chrono::{Duration, Utc};
8use futures::{
9 FutureExt,
10 future::{BoxFuture, Ready, ready},
11};
12use headless_lms_models::{
13 HttpErrorType, ModelError, ModelErrorType, ModelResult,
14 exercise_service_info::ExerciseServiceInfoApi,
15 exercise_task_gradings::{
16 ExerciseTaskGradingRequest, ExerciseTaskGradingResult, GradingRequestFile,
17 },
18 exercise_task_submissions::{AnswerFile as SubmittedAnswerFile, ExerciseTaskSubmission},
19 exercise_tasks::ExerciseTask,
20};
21use secrecy::{ExposeSecret, SecretString};
22
23use headless_lms_base::error::backend_error::BackendError;
24use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
25use models::SpecFetcher;
26use std::collections::HashMap;
27use std::fmt::Debug;
28use std::sync::{Arc, Mutex};
29use url::Url;
30
31use super::error::{ControllerError, ControllerErrorType};
32
33const EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER: &str = "exercise-service-grading-update-claim";
35const EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER: &str = "exercise-service-upload-claim";
36pub const PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM: &str = "playground-grading-callback-claim";
37pub const DOWNLOAD_CLAIM_PARAM: &str = "download-claim";
39
40type SpecCache = HashMap<(String, String, Option<String>), serde_json::Value>;
42
43#[derive(Clone, Debug)]
44pub struct JwtKey(Vec<u8>);
45
46impl JwtKey {
47 pub fn try_from_env() -> anyhow::Result<Self> {
48 let jwt_password = server_runtime_config().jwt_password.clone();
49 let jwt_key = Self::new(&jwt_password)?;
50 Ok(jwt_key)
51 }
52
53 pub fn new(key: &SecretString) -> anyhow::Result<Self> {
54 Ok(Self(key.expose_secret().as_bytes().to_vec()))
55 }
56
57 #[cfg(test)]
58 pub fn test_key() -> Self {
59 let test_jwt_key = "sMG87WlKnNZoITzvL2+jczriTR7JRsCtGu/bSKaSIvw=asdfjklasd***FSDfsdASDFDS";
60 Self(test_jwt_key.as_bytes().to_vec())
61 }
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65pub struct UploadClaim {
66 exercise_service_slug: String,
67 exp: usize,
68 iat: usize,
69}
70
71impl UploadClaim {
72 pub fn exercise_service_slug(&self) -> &str {
73 self.exercise_service_slug.as_ref()
74 }
75
76 pub fn expiring_in_1_day(exercise_service_slug: impl Into<String>) -> Self {
77 let now = Utc::now().timestamp().max(0) as usize;
78 let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
79 Self {
80 exercise_service_slug: exercise_service_slug.into(),
81 exp,
82 iat: now,
83 }
84 }
85
86 pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
87 sign_hs256_claim(&self, key)
88 }
89
90 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
91 validate_claim(token, key)
92 }
93}
94
95impl FromRequest for UploadClaim {
96 type Error = ControllerError;
97 type Future = Ready<Result<Self, Self::Error>>;
98
99 fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
100 let try_from_request = move || {
101 let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
102 ControllerError::new(
103 ControllerErrorType::InternalServerError,
104 "Missing JwtKey in app data - server configuration error".to_string(),
105 None,
106 )
107 })?;
108 let header = req
109 .headers()
110 .get(EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER)
111 .ok_or_else(|| {
112 ControllerError::new(
113 ControllerErrorType::BadRequest,
114 format!("Missing header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER}",),
115 None,
116 )
117 })?;
118 let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
119 ControllerError::new(
120 ControllerErrorType::BadRequest,
121 format!(
122 "Invalid header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER} = {}",
123 String::from_utf8_lossy(header.as_bytes())
124 ),
125 Some(err.into()),
126 )
127 })?;
128 let claim = UploadClaim::validate(header, jwt_key)?;
129 Result::<_, Self::Error>::Ok(claim)
130 };
131 ready(try_from_request())
132 }
133}
134
135#[derive(Debug, Serialize, Deserialize)]
142pub struct DownloadClaim {
143 file_upload_id: Uuid,
144 exp: usize,
145 iat: usize,
146}
147
148impl DownloadClaim {
149 pub fn file_upload_id(&self) -> Uuid {
150 self.file_upload_id
151 }
152
153 pub fn expiring_in_1_day(file_upload_id: Uuid) -> Self {
156 let now = Utc::now().timestamp().max(0) as usize;
157 let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
158 Self {
159 file_upload_id,
160 exp,
161 iat: now,
162 }
163 }
164
165 pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
166 sign_hs256_claim(&self, key)
167 }
168
169 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
170 validate_claim(token, key)
171 }
172}
173
174#[derive(Debug, Serialize, Deserialize)]
175pub struct GradingUpdateClaim {
176 submission_id: Uuid,
177 exp: usize,
178 iat: usize,
179}
180
181impl GradingUpdateClaim {
182 pub fn submission_id(&self) -> Uuid {
183 self.submission_id
184 }
185
186 pub fn expiring_in_1_day(submission_id: Uuid) -> Self {
187 let now = Utc::now().timestamp().max(0) as usize;
188 let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
189 Self {
190 submission_id,
191 exp,
192 iat: now,
193 }
194 }
195
196 pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
197 sign_hs256_claim(&self, key)
198 }
199
200 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
201 validate_claim(token, key)
202 }
203}
204
205impl FromRequest for GradingUpdateClaim {
206 type Error = ControllerError;
207 type Future = Ready<Result<Self, Self::Error>>;
208
209 fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
210 let try_from_request = move || {
211 let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
212 ControllerError::new(
213 ControllerErrorType::InternalServerError,
214 "Missing JwtKey in app data - server configuration error".to_string(),
215 None,
216 )
217 })?;
218 let header = req
219 .headers()
220 .get(EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER)
221 .ok_or_else(|| {
222 ControllerError::new(
223 ControllerErrorType::BadRequest,
224 format!("Missing header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER}",),
225 None,
226 )
227 })?;
228 let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
229 ControllerError::new(
230 ControllerErrorType::BadRequest,
231 format!(
232 "Invalid header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER} = {}",
233 String::from_utf8_lossy(header.as_bytes())
234 ),
235 Some(err.into()),
236 )
237 })?;
238 let claim = GradingUpdateClaim::validate(header, jwt_key)?;
239 Result::<_, Self::Error>::Ok(claim)
240 };
241 ready(try_from_request())
242 }
243}
244
245#[derive(Debug, Serialize, Deserialize)]
246pub struct PlaygroundGradingCallbackClaim {
247 websocket_id: Uuid,
248 exp: usize,
249 iat: usize,
250}
251
252impl PlaygroundGradingCallbackClaim {
253 pub fn websocket_id(&self) -> Uuid {
254 self.websocket_id
255 }
256
257 pub fn expiring_in_1_day(websocket_id: Uuid) -> Self {
258 let now = Utc::now().timestamp().max(0) as usize;
259 let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
260 Self {
261 websocket_id,
262 exp,
263 iat: now,
264 }
265 }
266
267 pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
268 sign_hs256_claim(&self, key)
269 }
270
271 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
272 validate_hs256_claim::<Self>(token, key).map_err(|err| {
273 controller_err!(
274 BadRequest,
275 format!("Invalid playground grading callback claim: {}", err),
276 err
277 )
278 })
279 }
280}
281
282impl FromRequest for PlaygroundGradingCallbackClaim {
283 type Error = ControllerError;
284 type Future = Ready<Result<Self, Self::Error>>;
285
286 fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
287 let try_from_request = move || {
288 let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
289 controller_err!(
290 InternalServerError,
291 "Missing JwtKey in app data - server configuration error".to_string()
292 )
293 })?;
294 let query_claim = url::form_urlencoded::parse(req.query_string().as_bytes())
295 .find(|(key, _)| key == PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM)
296 .map(|(_, value)| value.into_owned());
297 let header_claim = req
298 .headers()
299 .get(PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM)
300 .and_then(|header| std::str::from_utf8(header.as_bytes()).ok())
301 .map(ToString::to_string);
302 let claim = header_claim.or(query_claim).ok_or_else(|| {
303 controller_err!(
304 BadRequest,
305 format!("Missing {PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM}")
306 )
307 })?;
308 PlaygroundGradingCallbackClaim::validate(&claim, jwt_key)
309 };
310 ready(try_from_request())
311 }
312}
313
314#[derive(Debug, Serialize)]
316
317pub struct SpecRequest<'a> {
318 request_id: Uuid,
319 private_spec: Option<&'a serde_json::Value>,
320 upload_url: Option<String>,
321}
322
323#[derive(Debug, Serialize)]
324pub struct ExerciseServiceCsvExportRequest<'a, T: Serialize> {
325 pub items: &'a [T],
326}
327
328#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
330pub struct ExerciseServiceCsvExportColumn {
331 pub key: String,
332 pub header: String,
333}
334
335#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
337pub struct ExerciseServiceCsvExportResult {
338 pub rows: Vec<HashMap<String, serde_json::Value>>,
339}
340
341#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
343pub struct ExerciseServiceCsvExportResponse {
344 pub columns: Vec<ExerciseServiceCsvExportColumn>,
345 pub results: Vec<ExerciseServiceCsvExportResult>,
346}
347
348pub fn make_spec_fetcher(
352 base_url: String,
353 request_id: Uuid,
354 jwt_key: Arc<JwtKey>,
355) -> impl SpecFetcher {
356 move |url, exercise_service_slug, private_spec| {
357 let client = reqwest::Client::new();
358 let upload_claim = UploadClaim::expiring_in_1_day(exercise_service_slug);
359 let upload_url = Some(format!("{base_url}/api/v0/files/{exercise_service_slug}"));
360 let signed_upload_claim = match upload_claim.sign(&jwt_key) {
361 Ok(claim) => claim,
362 Err(err) => {
363 return async move {
364 Err(ModelError::new(
365 ModelErrorType::Generic,
366 format!("Failed to sign upload claim: {err}"),
367 Some(err.into()),
368 ))
369 }
370 .boxed();
371 }
372 };
373 let req = client
374 .post(url.clone())
375 .header(EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER, signed_upload_claim)
376 .timeout(std::time::Duration::from_secs(120))
377 .json(&SpecRequest {
378 request_id,
379 private_spec,
380 upload_url,
381 })
382 .send();
383 async move {
384 let res = req.await.map_err(ModelError::from)?;
385 let status_code = res.status();
386 if !status_code.is_success() {
387 let error_text = res.text().await;
388 let error = error_text.as_deref().unwrap_or("(No text in response)");
389 error!(
390 ?url,
391 ?exercise_service_slug,
392 ?private_spec,
393 ?status_code,
394 "Exercise service returned an error while generating a spec: {}",
395 error
396 );
397 return Err(ModelError::new(
398 ModelErrorType::HttpRequest {
399 status_code: status_code.as_u16(),
400 response_body: error.to_string(),
401 },
402 format!(
403 "Failed to generate spec for exercise for {exercise_service_slug}: {error}."
404 ),
405 None,
406 ));
407 }
408 let json = parse_response_json(res).await?;
409 Ok(json)
410 }
411 .boxed()
412 }
413}
414
415pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
417 fetch_service_info_with_timeout(url, 1000 * 120)
418}
419
420pub fn fetch_service_info_fast(
422 url: Url,
423) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
424 fetch_service_info_with_timeout(url, 1000 * 5)
425}
426
427fn fetch_service_info_with_timeout(
428 url: Url,
429 timeout_ms: u64,
430) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
431 async move {
432 let client = reqwest::Client::new();
433 let res = client
434 .get(url) .timeout(std::time::Duration::from_millis(timeout_ms))
436 .send()
437 .await
438 .map_err(ModelError::from)?;
439 let status = res.status();
440 if !status.is_success() {
441 let response_url = res.url().to_string();
442 let body = res.text().await.map_err(ModelError::from)?;
443 warn!(url=?response_url, status=?status, body=?body, "Could not fetch service info.");
444 return Err(ModelError::new(
445 ModelErrorType::HttpRequest {
446 status_code: status.as_u16(),
447 response_body: body,
448 },
449 "Could not fetch service info.".to_string(),
450 None,
451 ));
452 }
453 let res = parse_response_json(res).await?;
454 Ok(res)
455 }
456 .boxed()
457}
458
459fn grading_request_files(
465 files: Option<&[SubmittedAnswerFile]>,
466 base_url: &str,
467 jwt_key: &JwtKey,
468) -> Result<Vec<GradingRequestFile>, jsonwebtoken::errors::Error> {
469 let Some(files) = files else {
470 return Ok(Vec::new());
471 };
472 let mut ordered: Vec<&SubmittedAnswerFile> = files.iter().collect();
473 ordered.sort_by_key(|file| file.order_number);
474 ordered
475 .into_iter()
476 .map(|file| {
477 let claim = DownloadClaim::expiring_in_1_day(file.id).sign(jwt_key)?;
478 Ok(GradingRequestFile {
479 id: file.id,
480 name: file.name.clone(),
481 mime: file.mime.clone(),
482 size_bytes: file.size_bytes,
483 download_url: format!(
484 "{base_url}/api/v0/files/claimed/{}?{DOWNLOAD_CLAIM_PARAM}={claim}",
485 file.id
486 ),
487 })
488 })
489 .collect()
490}
491
492pub fn make_grading_request_sender(
498 jwt_key: Arc<JwtKey>,
499 base_url: String,
500) -> impl Fn(
501 Url,
502 &ExerciseTask,
503 &ExerciseTaskSubmission,
504) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>> {
505 move |grade_url, exercise_task, submission| {
506 let client = reqwest::Client::new();
507 let grading_update_url = format!(
508 "{base_url}/api/v0/exercise-services/grading/grading-update/{}",
509 submission.id
510 );
511 let submission_files =
512 match grading_request_files(submission.data_files.as_deref(), &base_url, &jwt_key) {
513 Ok(files) => files,
514 Err(err) => {
515 return async move {
516 Err(ModelError::new(
517 ModelErrorType::Generic,
518 format!("Failed to sign download claim: {err}"),
519 Some(err.into()),
520 ))
521 }
522 .boxed();
523 }
524 };
525 let grading_update_claim = GradingUpdateClaim::expiring_in_1_day(submission.id);
526 let signed_grading_update_claim = match grading_update_claim.sign(&jwt_key) {
527 Ok(claim) => claim,
528 Err(err) => {
529 return async move {
530 Err(ModelError::new(
531 ModelErrorType::Generic,
532 format!("Failed to sign grading update claim: {err}"),
533 Some(err.into()),
534 ))
535 }
536 .boxed();
537 }
538 };
539 let req = client
540 .post(grade_url)
541 .header(
542 EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER,
543 signed_grading_update_claim,
544 )
545 .timeout(std::time::Duration::from_secs(120))
546 .json(&ExerciseTaskGradingRequest {
547 grading_update_url: &grading_update_url,
548 exercise_spec: &exercise_task.private_spec,
549 submission_data: submission.data_json.as_ref(),
550 submission_files: &submission_files,
551 });
552 async move {
553 let res = req.send().await.map_err(ModelError::from)?;
554 let status = res.status();
555 if !status.is_success() {
556 let status_code = status.as_u16();
557 let response_body = res.text().await.unwrap_or_default();
558 error!(
559 ?response_body,
560 status_code = %status_code,
561 "Grading request returned an unsuccesful status code"
562 );
563
564 return Err(ModelError::new(
565 ModelErrorType::HttpRequest {
566 status_code,
567 response_body: response_body.clone(),
568 },
569 format!(
570 "Grading failed with status: {} response: {}",
571 status_code, response_body
572 ),
573 None,
574 ));
575 }
576 let obj = parse_response_json(res).await?;
577 info!("Received a grading result: {:#?}", &obj);
578 Ok(obj)
579 }
580 .boxed()
581 }
582}
583
584pub async fn post_exercise_service_csv_export_request<T: Serialize>(
585 url: Url,
586 items: &[T],
587) -> ModelResult<ExerciseServiceCsvExportResponse> {
588 let client = reqwest::Client::new();
589 let response = client
590 .post(url.clone())
591 .timeout(std::time::Duration::from_secs(120))
592 .json(&ExerciseServiceCsvExportRequest { items })
593 .send()
594 .await
595 .map_err(ModelError::from)?;
596
597 let status = response.status();
598 if !status.is_success() {
599 let status_code = status.as_u16();
600 let response_body = response.text().await.unwrap_or_default();
601 error!(
602 ?response_body,
603 status_code = %status_code,
604 "Exercise service CSV export request returned an unsuccessful status code"
605 );
606
607 return Err(ModelError::new(
608 ModelErrorType::HttpRequest {
609 status_code,
610 response_body: response_body.clone(),
611 },
612 format!(
613 "CSV export request failed with status: {} response: {}",
614 status_code, response_body
615 ),
616 None,
617 ));
618 }
619
620 parse_response_json(response).await
621}
622
623#[derive(Debug, Serialize, Deserialize)]
624pub struct GivePeerReviewClaim {
625 pub exercise_slide_submission_id: Uuid,
626 pub peer_or_self_review_config_id: Uuid,
627 exp: usize,
628 iat: usize,
629}
630
631impl GivePeerReviewClaim {
632 pub fn expiring_in_1_day(
633 exercise_slide_submission_id: Uuid,
634 peer_or_self_review_config_id: Uuid,
635 ) -> Self {
636 let now = Utc::now().timestamp().max(0) as usize;
637 let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
638 Self {
639 exercise_slide_submission_id,
640 peer_or_self_review_config_id,
641 exp,
642 iat: now,
643 }
644 }
645
646 pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
647 sign_hs256_claim(&self, key)
648 }
649
650 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
651 validate_hs256_claim(token, key).map_err(|err| {
652 ControllerError::new(
653 ControllerErrorType::BadRequest,
654 format!("Invalid claim: {}", err),
655 Some(err.into()),
656 )
657 })
658 }
659}
660
661fn sign_hs256_claim<T: serde::Serialize>(
663 claim: &T,
664 key: &JwtKey,
665) -> Result<String, jsonwebtoken::errors::Error> {
666 encode(
667 &Header::new(Algorithm::HS256),
668 claim,
669 &EncodingKey::from_secret(&key.0),
670 )
671}
672
673fn validate_claim<T: serde::de::DeserializeOwned>(
678 token: &str,
679 key: &JwtKey,
680) -> Result<T, ControllerError> {
681 validate_hs256_claim(token, key)
682 .map_err(|err| controller_err!(BadRequest, format!("Invalid jwt key: {}", err), err))
683}
684
685fn validate_hs256_claim<T: serde::de::DeserializeOwned>(
687 token: &str,
688 key: &JwtKey,
689) -> Result<T, jsonwebtoken::errors::Error> {
690 let validation = Validation::new(Algorithm::HS256);
691 decode::<T>(token, &DecodingKey::from_secret(&key.0), &validation)
692 .map(|token_data| token_data.claims)
693}
694
695pub fn make_seed_spec_fetcher_with_cache(
699 base_url: String,
700 request_id: Uuid,
701 jwt_key: Arc<JwtKey>,
702) -> impl SpecFetcher {
703 let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
705
706 let base_fetcher = Arc::new(make_spec_fetcher(base_url, request_id, jwt_key));
708
709 move |url, exercise_service_slug, private_spec| {
710 let url_str = url.to_string();
711 let service_slug = exercise_service_slug.to_string();
712 let private_spec_str =
714 private_spec.map(|spec| serde_json::to_string(&spec).unwrap_or_default());
715 let key = (url_str.clone(), service_slug.clone(), private_spec_str);
716 let cache = Arc::clone(&cache);
717 let base_fetcher = Arc::clone(&base_fetcher);
718
719 async move {
720 let cached_spec = {
722 let cache_guard = cache.lock().map_err(|err| {
723 ModelError::new(
724 ModelErrorType::Generic,
725 format!("Seed spec fetcher cache lock poisoned: {err}"),
726 None::<anyhow::Error>,
727 )
728 })?;
729 cache_guard.get(&key).cloned()
730 };
731 if let Some(cached_spec) = cached_spec {
732 return Ok(cached_spec.clone());
733 }
734
735 let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
737
738 {
740 let mut cache_guard = cache.lock().map_err(|err| {
741 ModelError::new(
742 ModelErrorType::Generic,
743 format!("Seed spec fetcher cache lock poisoned: {err}"),
744 None::<anyhow::Error>,
745 )
746 })?;
747 cache_guard.insert(key, fetched_spec.clone());
748 }
749
750 Ok(fetched_spec)
751 }
752 .boxed()
753 }
754}
755
756async fn parse_response_json<T>(response: reqwest::Response) -> ModelResult<T>
758where
759 T: serde::de::DeserializeOwned,
760{
761 let status = response.status();
762 let response_text = response.text().await.map_err(ModelError::from)?;
763
764 serde_json::from_str(&response_text).map_err(|err| {
765 ModelError::new(
766 ModelErrorType::HttpError {
767 error_type: HttpErrorType::ResponseDecodeFailed,
768 reason: err.to_string(),
769 status_code: Some(status.as_u16()),
770 response_body: Some(response_text),
771 },
772 format!("Failed to decode JSON response: {}", err),
773 None,
774 )
775 })
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use actix_web::ResponseError;
782 use actix_web::http::StatusCode;
783 use actix_web::http::header::{HeaderName, HeaderValue};
784 use actix_web::test::TestRequest;
785 use base64::Engine;
786 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
787 use serde_json::json;
788
789 fn other_key() -> JwtKey {
790 JwtKey::new(&SecretString::new(
791 "a-completely-different-jwt-secret-0123456789"
792 .to_string()
793 .into(),
794 ))
795 .expect("test key")
796 }
797
798 fn sign_json(payload: serde_json::Value, key: &JwtKey) -> String {
801 sign_hs256_claim(&payload, key).expect("signing should succeed")
802 }
803
804 fn past_timestamp(seconds_ago: i64) -> i64 {
805 (Utc::now() - Duration::seconds(seconds_ago)).timestamp()
806 }
807
808 fn future_timestamp(seconds_ahead: i64) -> i64 {
809 (Utc::now() + Duration::seconds(seconds_ahead)).timestamp()
810 }
811
812 fn answer_file(
813 id: Uuid,
814 name: &str,
815 order_number: i32,
816 size_bytes: Option<i64>,
817 ) -> SubmittedAnswerFile {
818 SubmittedAnswerFile {
819 id,
820 name: name.to_string(),
821 mime: "application/octet-stream".to_string(),
822 size_bytes,
823 order_number,
824 url: format!("http://project-331.local/api/v0/files/tmc/{name}"),
825 }
826 }
827
828 #[test]
829 fn download_claim_round_trips() {
830 let key = JwtKey::test_key();
831 let file_upload_id = Uuid::new_v4();
832 let token = DownloadClaim::expiring_in_1_day(file_upload_id)
833 .sign(&key)
834 .expect("signing should succeed");
835 let claim = DownloadClaim::validate(&token, &key).expect("the claim should validate");
836 assert_eq!(claim.file_upload_id(), file_upload_id);
837 }
838
839 #[test]
841 fn download_claim_expires_in_a_day() {
842 let claim = DownloadClaim::expiring_in_1_day(Uuid::new_v4());
843 let lifetime = claim.exp as i64 - claim.iat as i64;
844 assert_eq!(lifetime, Duration::days(1).num_seconds());
845 }
846
847 #[test]
848 fn expired_download_claim_is_rejected() {
849 let key = JwtKey::test_key();
850 let token = sign_json(
851 json!({
852 "file_upload_id": Uuid::new_v4(),
853 "exp": past_timestamp(3600),
854 "iat": past_timestamp(7200),
855 }),
856 &key,
857 );
858 DownloadClaim::validate(&token, &key).expect_err("an expired claim must be rejected");
859 }
860
861 #[test]
862 fn download_claim_signed_with_another_key_is_rejected() {
863 let token = DownloadClaim::expiring_in_1_day(Uuid::new_v4())
864 .sign(&other_key())
865 .expect("signing should succeed");
866 DownloadClaim::validate(&token, &JwtKey::test_key())
867 .expect_err("a claim signed with another key must be rejected");
868 }
869
870 #[test]
873 fn grading_request_files_are_in_answer_order_with_a_claim_for_each_file() {
874 let key = JwtKey::test_key();
875 let first = Uuid::new_v4();
876 let second = Uuid::new_v4();
877 let answer_files = vec![
878 answer_file(second, "b.txt", 1, None),
879 answer_file(first, "a.tar.zst", 0, Some(12)),
880 ];
881
882 let files = grading_request_files(Some(&answer_files), "http://project-331.local", &key)
883 .expect("the files should be built");
884
885 assert_eq!(
886 files.iter().map(|file| file.id).collect::<Vec<_>>(),
887 vec![first, second]
888 );
889 assert_eq!(files[0].size_bytes, Some(12));
890 assert_eq!(
891 files[1].size_bytes, None,
892 "an unknown size must not become a zero"
893 );
894 for (file, id) in files.iter().zip([first, second]) {
895 let (path, query) = file
896 .download_url
897 .strip_prefix("http://project-331.local/api/v0/files/claimed/")
898 .expect("a claimed-file url")
899 .split_once('?')
900 .expect("a claim in the query string");
901 assert_eq!(path, id.to_string());
902 let token = query
903 .strip_prefix(&format!("{DOWNLOAD_CLAIM_PARAM}="))
904 .expect("the claim parameter");
905 let claim = DownloadClaim::validate(token, &key).expect("the claim should validate");
906 assert_eq!(claim.file_upload_id(), id);
907 }
908 }
909
910 #[test]
912 fn a_json_answer_has_no_grading_request_files() {
913 let key = JwtKey::test_key();
914
915 assert!(
916 grading_request_files(None, "http://project-331.local", &key)
917 .expect("the files should be built")
918 .is_empty()
919 );
920 }
921
922 #[test]
923 fn grading_update_claim_round_trips() {
924 let key = JwtKey::test_key();
925 let submission_id = Uuid::new_v4();
926 let token = GradingUpdateClaim::expiring_in_1_day(submission_id)
927 .sign(&key)
928 .expect("signing should succeed");
929 let claim = GradingUpdateClaim::validate(&token, &key).expect("the claim should validate");
930 assert_eq!(claim.submission_id(), submission_id);
931 }
932
933 #[test]
935 fn expired_grading_update_claim_is_rejected() {
936 let key = JwtKey::test_key();
937 let token = sign_json(
939 json!({
940 "submission_id": Uuid::new_v4(),
941 "exp": past_timestamp(3600),
942 "iat": past_timestamp(7200),
943 }),
944 &key,
945 );
946 let err = GradingUpdateClaim::validate(&token, &key)
947 .expect_err("an expired claim must be rejected");
948 assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
949 }
950
951 #[test]
954 fn grading_update_claim_signed_with_another_key_is_rejected() {
955 let token = GradingUpdateClaim::expiring_in_1_day(Uuid::new_v4())
956 .sign(&other_key())
957 .expect("signing should succeed");
958 GradingUpdateClaim::validate(&token, &JwtKey::test_key())
959 .expect_err("a claim signed with a foreign key must be rejected");
960 }
961
962 #[test]
965 fn tampered_grading_update_claim_is_rejected() {
966 let key = JwtKey::test_key();
967 let token = GradingUpdateClaim::expiring_in_1_day(Uuid::new_v4())
968 .sign(&key)
969 .expect("signing should succeed");
970 let mut parts = token.split('.');
971 let header = parts.next().expect("header");
972 let _original_payload = parts.next().expect("payload");
973 let signature = parts.next().expect("signature");
974 let forged_payload = URL_SAFE_NO_PAD.encode(
976 serde_json::to_vec(&json!({
977 "submission_id": Uuid::new_v4(),
978 "exp": future_timestamp(3600),
979 "iat": Utc::now().timestamp(),
980 }))
981 .expect("json"),
982 );
983 let tampered = format!("{header}.{forged_payload}.{signature}");
984 GradingUpdateClaim::validate(&tampered, &key)
985 .expect_err("a tampered claim must be rejected");
986 }
987
988 #[test]
990 fn unsigned_grading_update_claim_is_rejected() {
991 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
992 let payload = URL_SAFE_NO_PAD.encode(
993 serde_json::to_vec(&json!({
994 "submission_id": Uuid::new_v4(),
995 "exp": future_timestamp(3600),
996 "iat": Utc::now().timestamp(),
997 }))
998 .expect("json"),
999 );
1000 let token = format!("{header}.{payload}.");
1001 GradingUpdateClaim::validate(&token, &JwtKey::test_key())
1002 .expect_err("an unsigned (alg=none) claim must be rejected");
1003 }
1004
1005 #[test]
1009 fn claims_do_not_cross_validate_between_types() {
1010 let key = JwtKey::test_key();
1011 let upload_token = UploadClaim::expiring_in_1_day("tmc")
1012 .sign(&key)
1013 .expect("signing should succeed");
1014 GradingUpdateClaim::validate(&upload_token, &key)
1015 .expect_err("an upload claim must not validate as a grading update claim");
1016
1017 let grading_token = GradingUpdateClaim::expiring_in_1_day(Uuid::new_v4())
1018 .sign(&key)
1019 .expect("signing should succeed");
1020 UploadClaim::validate(&grading_token, &key)
1021 .expect_err("a grading update claim must not validate as an upload claim");
1022 }
1023
1024 #[test]
1028 fn legacy_grading_update_claim_shape_is_rejected() {
1029 let key = JwtKey::test_key();
1030 let submission_id = Uuid::new_v4();
1031
1032 let unexpired = sign_json(
1033 json!({
1034 "submission_id": submission_id,
1035 "expiration_time": Utc::now() + Duration::hours(1),
1036 }),
1037 &key,
1038 );
1039 let err = GradingUpdateClaim::validate(&unexpired, &key)
1040 .expect_err("a claim without `exp` must be rejected");
1041 assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1042
1043 let expired = sign_json(
1044 json!({
1045 "submission_id": submission_id,
1046 "expiration_time": Utc::now() - Duration::hours(1),
1047 }),
1048 &key,
1049 );
1050 GradingUpdateClaim::validate(&expired, &key)
1051 .expect_err("an expired legacy claim must be rejected");
1052 }
1053
1054 #[test]
1056 fn grading_update_claim_without_an_expiry_is_rejected() {
1057 let key = JwtKey::test_key();
1058 let token = sign_json(
1059 json!({ "submission_id": Uuid::new_v4(), "iat": Utc::now().timestamp() }),
1060 &key,
1061 );
1062 GradingUpdateClaim::validate(&token, &key)
1063 .expect_err("a claim without an expiry must be rejected");
1064 }
1065
1066 fn extract_grading_update_claim(
1067 req: actix_web::HttpRequest,
1068 mut payload: Payload,
1069 ) -> Result<GradingUpdateClaim, ControllerError> {
1070 GradingUpdateClaim::from_request(&req, &mut payload)
1071 .now_or_never()
1072 .expect("the extractor resolves immediately")
1073 }
1074
1075 #[test]
1076 fn extractor_accepts_a_valid_claim_header() {
1077 let key = JwtKey::test_key();
1078 let submission_id = Uuid::new_v4();
1079 let token = GradingUpdateClaim::expiring_in_1_day(submission_id)
1080 .sign(&key)
1081 .expect("signing should succeed");
1082 let (req, payload) = TestRequest::default()
1083 .app_data(web::Data::new(key))
1084 .insert_header((EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER, token.as_str()))
1085 .to_http_parts();
1086 let claim = extract_grading_update_claim(req, payload).expect("should extract");
1087 assert_eq!(claim.submission_id(), submission_id);
1088 }
1089
1090 #[test]
1091 fn extractor_rejects_a_missing_claim_header() {
1092 let (req, payload) = TestRequest::default()
1093 .app_data(web::Data::new(JwtKey::test_key()))
1094 .to_http_parts();
1095 let err = extract_grading_update_claim(req, payload)
1096 .expect_err("a request without the claim header must be rejected");
1097 assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1098 }
1099
1100 #[test]
1102 fn extractor_rejects_an_invalid_utf8_claim_header() {
1103 let (req, payload) = TestRequest::default()
1104 .app_data(web::Data::new(JwtKey::test_key()))
1105 .insert_header((
1106 HeaderName::from_static(EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER),
1107 HeaderValue::from_bytes(&[0xff, 0xfe, 0x80]).expect("header value"),
1108 ))
1109 .to_http_parts();
1110 let err = extract_grading_update_claim(req, payload)
1111 .expect_err("a non-UTF-8 claim header must be rejected");
1112 assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1113 }
1114
1115 #[test]
1118 fn extractor_reports_a_missing_jwt_key_as_a_server_error() {
1119 let token = GradingUpdateClaim::expiring_in_1_day(Uuid::new_v4())
1120 .sign(&JwtKey::test_key())
1121 .expect("signing should succeed");
1122 let (req, payload) = TestRequest::default()
1123 .insert_header((EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER, token.as_str()))
1124 .to_http_parts();
1125 let err = extract_grading_update_claim(req, payload)
1126 .expect_err("a missing JwtKey must not yield a claim");
1127 assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
1128 }
1129
1130 #[test]
1132 fn extractor_rejects_a_non_jwt_claim_header() {
1133 let (req, payload) = TestRequest::default()
1134 .app_data(web::Data::new(JwtKey::test_key()))
1135 .insert_header((EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER, "not-a-jwt"))
1136 .to_http_parts();
1137 let err = extract_grading_update_claim(req, payload)
1138 .expect_err("a non-JWT claim header must be rejected");
1139 assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1140 }
1141}