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