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::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
33// keep in sync with the shared-module constants
34const 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";
37/// Query parameter carrying a [`DownloadClaim`]; the claimed-file route repeats it in a rename.
38pub const DOWNLOAD_CLAIM_PARAM: &str = "download-claim";
39
40/// A type for caching the spec fetching (only for the seed)
41type 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/// Authorizes the bearer to read one specific host-stored file for a while.
136///
137/// Minted by the host when it hands a file-typed answer to an exercise service's grade endpoint, so
138/// the service fetches a URL the host chose rather than one a student supplied. The read-side mirror
139/// of [`UploadClaim`]; unlike it, this claim names a single file rather than a namespace, so a
140/// holder cannot reach any other file.
141#[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    /// A day, not the grading request's 120 s: a service may finish asynchronously through
154    /// `grading_update_url` long after the request returns.
155    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/// Accepted by the public-spec and model-solution endpoints of exercise services.
315#[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/// Column definition for exercise service CSV export; callers must use scalar-only cell values.
329#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
330pub struct ExerciseServiceCsvExportColumn {
331    pub key: String,
332    pub header: String,
333}
334
335/// One batch of CSV rows; each row's values must be scalar (null, bool, number, string). Objects/arrays are rejected by the controller.
336#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
337pub struct ExerciseServiceCsvExportResult {
338    pub rows: Vec<HashMap<String, serde_json::Value>>,
339}
340
341/// Full CSV export response; columns define headers, results align by index. All cell values must be scalar.
342#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
343pub struct ExerciseServiceCsvExportResponse {
344    pub columns: Vec<ExerciseServiceCsvExportColumn>,
345    pub results: Vec<ExerciseServiceCsvExportResult>,
346}
347
348/// Fetches a public/model spec based on the private spec from the given url.
349/// The slug and jwt key are used for an upload claim that allows the service
350/// to upload files as part of the spec.
351pub 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
415// see `fetch_service_info_fast` while handling HTTP requests
416pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
417    fetch_service_info_with_timeout(url, 1000 * 120)
418}
419
420// use this while handling HTTP requests, see `fetch_service_info`
421pub 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) // e.g. http://example-exercise.default.svc.cluster.local:3002/example-exercise/api/service-info
435            .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
459/// The grading request's file list for a submission's answer: its files in answer order, empty for
460/// an answer that has none.
461///
462/// Mints a single-file download claim per file, so the service fetches a URL the host chose rather
463/// than one a student supplied.
464fn 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
492/// Sends a submission to an exercise service for grading, with the claims the service needs to
493/// report back and to read a file-typed answer's files.
494///
495/// `base_url` is the host's own public base url, so both the callback and the file urls point at a
496/// host the service can reach.
497pub 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
661/// Signs any serializable claim payload as HS256 using the shared JWT secret.
662fn 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
673/// Decodes a claim, reporting a bad token as a request error rather than a JWT one.
674///
675/// [`validate_hs256_claim`] is the raw form that leaves the `jsonwebtoken` error unmapped, for the
676/// claims that report it differently.
677fn 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
685/// Decodes and verifies an HS256 token into the requested claim type.
686fn 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
695/// A caching spec fetcher ONLY FOR THE SEED that returns a cached spec if the same
696/// (url, exercise_service_slug, private_spec) is requested. Since this is only used during seeding,
697/// there is no cache eviction.
698pub fn make_seed_spec_fetcher_with_cache(
699    base_url: String,
700    request_id: Uuid,
701    jwt_key: Arc<JwtKey>,
702) -> impl SpecFetcher {
703    // Cache key: (url, exercise_service_slug, private_spec serialized)
704    let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
705
706    // Create the base non-caching spec fetcher and wrap it in Arc to make it clonable
707    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        // Convert private_spec to string for cache key if present
713        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            // Try to get from cache first
721            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            // Not in cache - fetch using base fetcher
736            let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
737
738            // Store in cache
739            {
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
756/// Safely parses a response body as JSON, capturing the actual response body in error cases
757async 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    /// Signs an arbitrary JSON payload with the same HS256 helper the production claims use, so
799    /// tests can produce claims (expired, wrong shape, legacy) the public constructors can't.
800    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    /// A grading request's claims outlive the request itself, but not by more than a day.
840    #[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    /// A downstream grader grades by position, so the request must list the files in the order the
871    /// answer records, whatever order they were resolved in.
872    #[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    /// A JSON answer names no files at all, which reaches the request builder as `None`.
911    #[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    /// An expired claim must not keep authorizing grading updates.
934    #[test]
935    fn expired_grading_update_claim_is_rejected() {
936        let key = JwtKey::test_key();
937        // Well past the default 60s leeway jsonwebtoken allows for clock skew.
938        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    /// A claim signed with some other secret must not validate: this is the only thing standing
952    /// between an unauthenticated caller and writing grading results.
953    #[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    /// Rewriting the payload of a validly signed claim (e.g. to point at another submission)
963    /// must invalidate the signature.
964    #[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        // Swap in a payload naming a different submission, keeping the original signature.
975        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    /// The classic JWT bypass: an unsigned token declaring `alg: none` must not be accepted.
989    #[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    /// The claim types share one signing key, so a token minted for one purpose must not be
1006    /// usable for another. An upload claim carries no `submission_id`, so it must not deserialize
1007    /// into a grading update claim (and vice versa).
1008    #[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    /// A legacy-shaped claim, carrying `expiration_time` instead of `exp`, is rejected whether or
1025    /// not that timestamp has passed. Pinned so that accepting the old shape again has to be
1026    /// deliberate.
1027    #[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    /// A claim missing `exp` entirely must never be treated as non-expiring.
1055    #[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    /// A non-UTF-8 header value must be a client error, not a panic.
1101    #[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    /// Without the signing key in app data the server cannot verify anything; that is a server
1116    /// misconfiguration (500), and must never be mistaken for a valid claim.
1117    #[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    /// A request carrying a garbage header value must be rejected before any DB work.
1131    #[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}