Skip to main content

headless_lms_server/domain/
models_requests.rs

1//! Contains helper functions that are passed to headless-lms-models where it needs to make requests to exercise services.
2
3use 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
32// keep in sync with the shared-module constants
33const 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
37/// A type for caching the spec fetching (only for the seed)
38type 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/// Accepted by the public-spec and model-solution endpoints of exercise services.
252#[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/// Column definition for exercise service CSV export; callers must use scalar-only cell values.
266#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
267pub struct ExerciseServiceCsvExportColumn {
268    pub key: String,
269    pub header: String,
270}
271
272/// One batch of CSV rows; each row's values must be scalar (null, bool, number, string). Objects/arrays are rejected by the controller.
273#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
274pub struct ExerciseServiceCsvExportResult {
275    pub rows: Vec<HashMap<String, serde_json::Value>>,
276}
277
278/// Full CSV export response; columns define headers, results align by index. All cell values must be scalar.
279#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
280pub struct ExerciseServiceCsvExportResponse {
281    pub columns: Vec<ExerciseServiceCsvExportColumn>,
282    pub results: Vec<ExerciseServiceCsvExportResult>,
283}
284
285/// Fetches a public/model spec based on the private spec from the given url.
286/// The slug and jwt key are used for an upload claim that allows the service
287/// to upload files as part of the spec.
288pub 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
352// see `fetch_service_info_fast` while handling HTTP requests
353pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
354    fetch_service_info_with_timeout(url, 1000 * 120)
355}
356
357// use this while handling HTTP requests, see `fetch_service_info`
358pub 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) // e.g. http://example-exercise.default.svc.cluster.local:3002/example-exercise/api/service-info
372            .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
396/// The grading request's file list for a submission's answer: its files in answer order, empty for
397/// an answer that has none.
398///
399/// Mints a single-file download claim per file, so the service fetches a URL the host chose rather
400/// than one a student supplied.
401fn 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
429/// Sends a submission to an exercise service for grading, with the claims the service needs to
430/// report back and to read a file-typed answer's files.
431///
432/// `base_url` is the host's own public base url, so both the callback and the file urls point at a
433/// host the service can reach. The sender's last argument is the submitter's pseudonymous id for
434/// the service.
435pub 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
601/// Decodes a claim, reporting a bad token as a request error rather than a JWT one.
602///
603/// [`validate_hs256_claim`] is the raw form that leaves the `jsonwebtoken` error unmapped, for the
604/// claims that report it differently.
605fn 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
613/// A caching spec fetcher ONLY FOR THE SEED that returns a cached spec if the same
614/// (url, exercise_service_slug, private_spec) is requested. Since this is only used during seeding,
615/// there is no cache eviction.
616pub fn make_seed_spec_fetcher_with_cache(
617    base_url: String,
618    request_id: Uuid,
619    jwt_key: Arc<JwtKey>,
620) -> impl SpecFetcher {
621    // Cache key: (url, exercise_service_slug, private_spec serialized)
622    let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
623
624    // Create the base non-caching spec fetcher and wrap it in Arc to make it clonable
625    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        // Convert private_spec to string for cache key if present
631        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            // Try to get from cache first
639            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            // Not in cache - fetch using base fetcher
654            let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
655
656            // Store in cache
657            {
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
674/// Safely parses a response body as JSON, capturing the actual response body in error cases
675async 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    /// Signs an arbitrary JSON payload with the same HS256 helper the production claims use, so
718    /// tests can produce claims (expired, wrong shape, legacy) the public constructors can't.
719    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    /// A downstream grader grades by position, so the request must list the files in the order the
748    /// answer records, whatever order they were resolved in.
749    #[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    /// A JSON answer names no files at all, which reaches the request builder as `None`.
788    #[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    /// An expired claim must not keep authorizing grading updates.
811    #[test]
812    fn expired_grading_update_claim_is_rejected() {
813        let key = JwtKey::test_key();
814        // Well past the default 60s leeway jsonwebtoken allows for clock skew.
815        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    /// A claim signed with some other secret must not validate: this is the only thing standing
829    /// between an unauthenticated caller and writing grading results.
830    #[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    /// Rewriting the payload of a validly signed claim (e.g. to point at another submission)
840    /// must invalidate the signature.
841    #[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        // Swap in a payload naming a different submission, keeping the original signature.
852        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    /// The classic JWT bypass: an unsigned token declaring `alg: none` must not be accepted.
866    #[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    /// The claim types share one signing key, so a token minted for one purpose must not be
883    /// usable for another. An upload claim carries no `submission_id`, so it must not deserialize
884    /// into a grading update claim (and vice versa).
885    #[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    /// A legacy-shaped claim, carrying `expiration_time` instead of `exp`, is rejected whether or
902    /// not that timestamp has passed. Pinned so that accepting the old shape again has to be
903    /// deliberate.
904    #[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    /// A claim missing `exp` entirely must never be treated as non-expiring.
932    #[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    /// A non-UTF-8 header value must be a client error, not a panic.
978    #[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    /// Without the signing key in app data the server cannot verify anything; that is a server
993    /// misconfiguration (500), and must never be mistaken for a valid claim.
994    #[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    /// A request carrying a garbage header value must be rejected before any DB work.
1008    #[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}