Skip to main content

headless_lms_server/domain/
exercises.rs

1use std::sync::Arc;
2
3use crate::{
4    domain::exercise_services::answer_uploads,
5    domain::models_requests::{self, JwtKey},
6    prelude::*,
7};
8use chrono::{Duration, Utc};
9use futures_util::future::OptionFuture;
10use models::{
11    exercises::Exercise,
12    library::grading::{
13        GradingPolicy, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult,
14        SubmittedAnswer,
15    },
16    user_exercise_states::ExerciseWithUserState,
17};
18
19/// Records and grades one slide submission, having established that its answers may claim the
20/// uploads they name.
21///
22/// Owns the transaction the ownership checks need: the unlocked check runs first, so a submit that
23/// may not name its uploads costs no grading hop, and the locked re-check runs inside the
24/// transaction that records the submission, before anything is written to it.
25pub async fn process_submission(
26    conn: &mut PgConnection,
27    user_id: Uuid,
28    exercise: Exercise,
29    submission: &StudentExerciseSlideSubmission,
30    jwt_key: Arc<JwtKey>,
31    file_store: &dyn FileStore,
32    app_conf: &ApplicationConfiguration,
33) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
34    verify_named_uploads(conn, exercise.id, user_id, submission).await?;
35
36    let mut tx = conn.begin().await?;
37    if let Err(error) =
38        lock_and_verify_named_uploads(&mut tx, exercise.id, user_id, submission).await
39    {
40        tx.rollback().await?;
41        return Err(error);
42    }
43    let result = grade_submission(
44        &mut tx, user_id, exercise, submission, jwt_key, file_store, app_conf,
45    )
46    .await;
47    let result = match result {
48        Ok(result) => result,
49        Err(error) => {
50            // A failed grading hop discards its own writes and records a rejected submission
51            // instead; committing is what keeps that audit row rather than rolling it back too.
52            if let Err(commit_error) = tx.commit().await {
53                error!("Failed to commit after a failed submission: {commit_error}");
54            }
55            return Err(error);
56        }
57    };
58    tx.commit().await?;
59    Ok(result)
60}
61
62/// Collects every uploaded file id named across all of a submission's file-typed answers.
63///
64/// One list for the whole submission, not one per task: a file named by two different task
65/// submissions is still a duplicate, and the callers below run their checks against this list in a
66/// single batched query rather than one query per task.
67fn named_upload_ids(submission: &StudentExerciseSlideSubmission) -> Vec<Uuid> {
68    submission
69        .exercise_task_submissions
70        .iter()
71        .flat_map(|task_submission| task_submission.named_file_ids().iter().copied())
72        .collect()
73}
74
75/// Rejects a file-typed answer naming uploads this user did not make for this exercise, naming none
76/// at all, or naming the same upload as another task submission in the same slide submission. Also
77/// rejects a JSON answer that names files, before anything is written.
78///
79/// Unlocked, so [`lock_and_verify_named_uploads`] must repeat it inside the submission transaction.
80async fn verify_named_uploads(
81    conn: &mut PgConnection,
82    exercise_id: Uuid,
83    user_id: Uuid,
84    submission: &StudentExerciseSlideSubmission,
85) -> Result<(), ControllerError> {
86    for task_submission in &submission.exercise_task_submissions {
87        if let SubmittedAnswer::File {
88            file_upload_ids, ..
89        } = task_submission.to_submitted_answer()?
90        {
91            answer_uploads::verify_answer_names_uploads(&file_upload_ids)?;
92        }
93    }
94    let file_upload_ids = named_upload_ids(submission);
95    if file_upload_ids.is_empty() {
96        return Ok(());
97    }
98    answer_uploads::verify_uploads_belong_to_exercise(conn, exercise_id, user_id, &file_upload_ids)
99        .await
100}
101
102/// Re-checks the named uploads under the reaper's row lock, inside the transaction that records the
103/// submission and before anything is written to it.
104async fn lock_and_verify_named_uploads(
105    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
106    exercise_id: Uuid,
107    user_id: Uuid,
108    submission: &StudentExerciseSlideSubmission,
109) -> Result<(), ControllerError> {
110    let file_upload_ids = named_upload_ids(submission);
111    if file_upload_ids.is_empty() {
112        return Ok(());
113    }
114    answer_uploads::lock_and_verify_uploads_are_usable(tx, exercise_id, user_id, &file_upload_ids)
115        .await
116}
117
118/// Grades one slide submission and trims the result down to what the submitter may see. Runs inside
119/// [`process_submission`]'s transaction.
120async fn grade_submission(
121    conn: &mut PgConnection,
122    user_id: Uuid,
123    exercise: Exercise,
124    submission: &StudentExerciseSlideSubmission,
125    jwt_key: Arc<JwtKey>,
126    file_store: &dyn FileStore,
127    app_conf: &ApplicationConfiguration,
128) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
129    enforce_deadline(conn, &exercise).await?;
130
131    let (course_or_exam_id, last_try) = resolve_course_or_exam_id_and_verify_that_user_can_submit(
132        conn,
133        user_id,
134        &exercise,
135        submission.exercise_slide_id,
136    )
137    .await?;
138
139    // TODO: Should this be an upsert?
140    let user_exercise_state = models::user_exercise_states::get_user_exercise_state_if_exists(
141        conn,
142        user_id,
143        exercise.id,
144        course_or_exam_id,
145    )
146    .await?
147    .ok_or_else(|| {
148        ControllerError::new(
149            ControllerErrorType::Unauthorized,
150            "Missing exercise state.".to_string(),
151            None,
152        )
153    })?;
154
155    let mut exercise_with_user_state = ExerciseWithUserState::new(exercise, user_exercise_state)?;
156    let mut result = models::library::grading::grade_user_submission(
157        conn,
158        &mut exercise_with_user_state,
159        submission,
160        GradingPolicy::Default,
161        models_requests::fetch_service_info,
162        models_requests::make_grading_request_sender(jwt_key, app_conf.base_url.clone()),
163        file_store,
164        app_conf,
165    )
166    .await?;
167
168    if exercise_with_user_state.is_exam_exercise() {
169        // If exam, we don't want to expose model any grading details.
170        result.clear_grading_information();
171    }
172
173    let score_given = if let Some(exercise_status) = &result.exercise_status {
174        exercise_status.score_given.unwrap_or(0.0)
175    } else {
176        0.0
177    };
178
179    // Model solution spec should only be shown when this is the last try for the current slide or they have gotten full points from the current slide.
180    // TODO: this uses points for the whole exercise, change this to slide points when slide grading finalized
181    let has_received_full_points = score_given
182        >= exercise_with_user_state.exercise().score_maximum as f32
183        || (score_given - exercise_with_user_state.exercise().score_maximum as f32).abs() < 0.0001;
184    if !has_received_full_points && !last_try {
185        result.clear_model_solution_specs();
186    }
187    Ok(result)
188}
189
190/// Rejects an attempt to answer an exercise that a submit would reject anyway: a passed deadline,
191/// no enrolment on the course, a closed exam, or no tries left on the slide.
192///
193/// Meant for work a student does *before* submitting, such as uploading an answer's files: those
194/// objects occupy the store for days, so they must not be accepted from someone who could never
195/// submit them. Submitting re-runs these checks, so this is a gate, not a guarantee.
196pub async fn verify_user_can_answer_exercise_slide(
197    conn: &mut PgConnection,
198    user_id: Uuid,
199    exercise: &Exercise,
200    slide_id: Uuid,
201) -> Result<(), ControllerError> {
202    enforce_deadline(conn, exercise).await?;
203    resolve_course_or_exam_id_and_verify_that_user_can_submit(conn, user_id, exercise, slide_id)
204        .await?;
205    Ok(())
206}
207
208/// The same gate as [`verify_user_can_answer_exercise_slide`] for a caller that has only an
209/// exercise id, such as the native client's upload route.
210///
211/// The try limit is per slide, so without a slide id this can only reject a user who has exhausted
212/// *every* slide of the exercise; one who is out of tries on the slide they actually mean to answer
213/// still gets through here and is rejected on submit. Everything else -- deadline, enrolment, exam
214/// window -- is checked in full.
215pub async fn verify_user_can_answer_exercise(
216    conn: &mut PgConnection,
217    user_id: Uuid,
218    exercise: &Exercise,
219) -> Result<(), ControllerError> {
220    enforce_deadline(conn, exercise).await?;
221    let course_or_exam_id =
222        resolve_course_or_exam_id_for_submitting(conn, user_id, exercise).await?;
223    verify_any_slide_has_tries_left(conn, user_id, exercise, course_or_exam_id).await
224}
225
226/// Rejects a user who has used up the try limit on every slide of the exercise.
227async fn verify_any_slide_has_tries_left(
228    conn: &mut PgConnection,
229    user_id: Uuid,
230    exercise: &Exercise,
231    course_or_exam_id: CourseOrExamId,
232) -> Result<(), ControllerError> {
233    if !is_out_of_tries(conn, user_id, exercise, course_or_exam_id).await? {
234        return Ok(());
235    }
236    tracing::error!(
237        user_id = %user_id,
238        exercise_id = %exercise.id,
239        course_or_exam_id = ?course_or_exam_id,
240        "User has run out of tries on every slide of the exercise"
241    );
242    Err(out_of_tries_error())
243}
244
245/// Whether the user has used up the try limit on every slide of the exercise, so no further
246/// submission is accepted. Always `false` for an exercise without a try limit or without slides;
247/// a slide nobody has submitted to has all its tries left.
248pub async fn is_out_of_tries(
249    conn: &mut PgConnection,
250    user_id: Uuid,
251    exercise: &Exercise,
252    course_or_exam_id: CourseOrExamId,
253) -> models::ModelResult<bool> {
254    let Some(max_tries_per_slide) = try_limit(exercise) else {
255        return Ok(false);
256    };
257    let submission_counts =
258        models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
259            conn,
260            exercise.id,
261            course_or_exam_id,
262            user_id,
263        )
264        .await?;
265    let slides =
266        models::exercise_slides::get_exercise_slides_by_exercise_id(conn, exercise.id).await?;
267    Ok(!slides.is_empty()
268        && slides
269            .iter()
270            .all(|slide| submission_counts.get(&slide.id).unwrap_or(&0) >= &max_tries_per_slide))
271}
272
273/// The rejection both try-limit checks report, kept in one place because the message reaches the
274/// student verbatim.
275fn out_of_tries_error() -> ControllerError {
276    controller_err!(BadRequest, "You've ran out of tries.".to_string())
277}
278
279/// The per-slide try limit, or `None` when the exercise does not limit tries.
280fn try_limit(exercise: &Exercise) -> Option<i64> {
281    exercise
282        .limit_number_of_tries
283        .then_some(exercise.max_tries_per_slide)
284        .flatten()
285        .map(i64::from)
286}
287
288/// Returns an error if the chapter's or exercise's deadline has passed.
289async fn enforce_deadline(
290    conn: &mut PgConnection,
291    exercise: &Exercise,
292) -> Result<(), ControllerError> {
293    let chapter_option_future: OptionFuture<_> = exercise
294        .chapter_id
295        .map(|id| models::chapters::get_chapter(conn, id))
296        .into();
297    let chapter = chapter_option_future.await.transpose()?;
298
299    // Exercise deadlines takes precedence to chapter deadlines
300    if let Some(deadline) = exercise
301        .deadline
302        .or_else(|| chapter.and_then(|c| c.deadline))
303        && Utc::now() + Duration::seconds(1) >= deadline
304    {
305        return Err(ControllerError::new(
306            ControllerErrorType::BadRequest,
307            "Exercise deadline passed.".to_string(),
308            None,
309        ));
310    }
311
312    Ok(())
313}
314
315/// Resolves the course instance or exam a submission would be recorded against, and rejects a
316/// submission the user may not make at all: not enrolled on the course, or past the exam's window.
317///
318/// Does not check the try limit, which is per slide; see the two callers for that.
319async fn resolve_course_or_exam_id_for_submitting(
320    conn: &mut PgConnection,
321    user_id: Uuid,
322    exercise: &Exercise,
323) -> Result<CourseOrExamId, ControllerError> {
324    let course_id_or_exam_id: CourseOrExamId = if let Some(course_id) = exercise.course_id {
325        // If submitting for a course, there should be existing course settings that dictate which
326        // instance the user is on.
327        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
328            conn, user_id, course_id,
329        )
330        .await?;
331        if let Some(settings) = settings {
332            let token = authorize(conn, Act::View, Some(user_id), Res::Course(course_id)).await?;
333            token.authorized_ok(CourseOrExamId::Course(settings.current_course_id))
334        } else {
335            Err(ControllerError::new(
336                ControllerErrorType::Unauthorized,
337                "User is not enrolled on this course.".to_string(),
338                None,
339            ))
340        }
341    } else if let Some(exam_id) = exercise.exam_id {
342        // If submitting for an exam, make sure that user's time is not up.
343        if models::exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await? {
344            let token = authorize(conn, Act::View, Some(user_id), Res::Exam(exam_id)).await?;
345            token.authorized_ok(CourseOrExamId::Exam(exam_id))
346        } else {
347            Err(ControllerError::new(
348                ControllerErrorType::Unauthorized,
349                "Submissions for this exam are no longer accepted.".to_string(),
350                None,
351            ))
352        }
353    } else {
354        // On database level this scenario is impossible.
355        Err(ControllerError::new(
356            ControllerErrorType::InternalServerError,
357            "Exam doesn't belong to either a course nor exam.".to_string(),
358            None,
359        ))
360    }?
361    .data;
362    Ok(course_id_or_exam_id)
363}
364
365/// The gate a submit runs: everything [`resolve_course_or_exam_id_for_submitting`] rejects, plus the
366/// per-slide try limit.
367///
368/// Also reports whether this would be the user's last try on the slide, which decides whether the
369/// model solution may be revealed.
370async fn resolve_course_or_exam_id_and_verify_that_user_can_submit(
371    conn: &mut PgConnection,
372    user_id: Uuid,
373    exercise: &Exercise,
374    slide_id: Uuid,
375) -> Result<(CourseOrExamId, bool), ControllerError> {
376    let mut last_try = false;
377    let course_id_or_exam_id =
378        resolve_course_or_exam_id_for_submitting(conn, user_id, exercise).await?;
379    if let Some(max_tries_per_slide) = try_limit(exercise) {
380        // check if the user has attempts remaining
381        let slide_id_to_submissions_count =
382                models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
383                    conn,
384                    exercise.id,
385                    course_id_or_exam_id,
386                    user_id,
387                )
388                .await?;
389
390        let count = slide_id_to_submissions_count.get(&slide_id).unwrap_or(&0);
391        if count >= &max_tries_per_slide {
392            tracing::error!(
393                user_id = %user_id,
394                exercise_id = %exercise.id,
395                slide_id = %slide_id,
396                course_or_exam_id = ?course_id_or_exam_id,
397                current_try_count = %count,
398                max_tries_per_slide = %max_tries_per_slide,
399                limit_number_of_tries = %exercise.limit_number_of_tries,
400                "User has run out of tries for exercise slide submission"
401            );
402            return Err(out_of_tries_error());
403        }
404        if count + 1 >= max_tries_per_slide {
405            last_try = true;
406        }
407    }
408    Ok((course_id_or_exam_id, last_try))
409}
410
411/// A submit with a file-typed answer: the checks that decide whether a student may claim the
412/// uploads they name, and the rows a claim that passes leaves behind.
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::test_helper::*;
417    use models::exercise_answer_uploads::AnswerUploadOrigin;
418    use models::exercise_task_gradings::ExerciseTaskGradingResult;
419    use models::exercise_task_submissions::{AnswerFile, AnswerKind};
420    use models::exercises::GradingProgress;
421    use models::library::grading::StudentExerciseTaskSubmission;
422    use sqlx::Connection;
423    use std::sync::{Arc, Mutex};
424
425    /// The ids one fixture course's submits are made against.
426    struct Fixture {
427        user: Uuid,
428        course: Uuid,
429        chapter: Uuid,
430        page: Uuid,
431        exercise: Uuid,
432        slide: Uuid,
433        task: Uuid,
434    }
435
436    /// Registers an exercise service under a slug of its own and a task of that type, so the
437    /// grading hop lands on `internal_url` rather than on whatever another test registered.
438    async fn insert_graded_task(
439        conn: &mut PgConnection,
440        slide: Uuid,
441        internal_url: String,
442        order_number: i32,
443    ) -> Uuid {
444        let slug = format!("submit-test-{}", Uuid::new_v4());
445        let service = models::exercise_services::insert_exercise_service(
446            conn,
447            &models::exercise_services::ExerciseServiceNewOrUpdate {
448                name: slug.clone(),
449                slug: slug.clone(),
450                public_url: "http://example.com/api/service".to_string(),
451                internal_url: Some(internal_url),
452                max_reprocessing_submissions_at_once: 1,
453            },
454        )
455        .await
456        .expect("exercise service");
457        models::exercise_service_info::insert(
458            conn,
459            &models::exercise_service_info::PathInfo {
460                exercise_service_id: service.id,
461                user_interface_iframe_path: "/iframe".to_string(),
462                grade_endpoint_path: "/grade".to_string(),
463                public_spec_endpoint_path: "/public-spec".to_string(),
464                model_solution_spec_endpoint_path: "/model-solution".to_string(),
465                has_custom_view: false,
466                supports_native_client: false,
467                produces_file_answers: false,
468                declares_spec_files: false,
469            },
470        )
471        .await
472        .expect("service info");
473        models::exercise_tasks::insert(
474            conn,
475            models::PKeyPolicy::Generate,
476            models::exercise_tasks::NewExerciseTask {
477                exercise_slide_id: slide,
478                exercise_type: slug,
479                assignment: vec![],
480                public_spec: Some(serde_json::Value::Null),
481                private_spec: Some(serde_json::Value::Null),
482                model_solution_spec: Some(serde_json::Value::Null),
483                order_number,
484            },
485        )
486        .await
487        .expect("exercise task")
488    }
489
490    /// Everything a submit needs beyond the ids: the enrollment and the `user_exercise_states` row
491    /// with the answered slide selected, both written when a student opens the exercise.
492    async fn enroll_and_open(
493        conn: &mut PgConnection,
494        user: Uuid,
495        course: Uuid,
496        instance: Uuid,
497        exercise: Uuid,
498        slide: Uuid,
499    ) {
500        models::course_instance_enrollments::insert_enrollment_and_set_as_current(
501            conn,
502            models::course_instance_enrollments::NewCourseInstanceEnrollment {
503                course_id: course,
504                user_id: user,
505                course_instance_id: instance,
506            },
507        )
508        .await
509        .expect("enrollment");
510        models::user_exercise_states::upsert_selected_exercise_slide_id(
511            conn,
512            user,
513            exercise,
514            Some(course),
515            None,
516            Some(slide),
517        )
518        .await
519        .expect("exercise state");
520    }
521
522    /// A file the student uploaded for `exercise`, bound to them the way the IFrame upload route
523    /// binds it.
524    async fn bind_upload(conn: &mut PgConnection, exercise: Uuid, user: Uuid, name: &str) -> Uuid {
525        let file_id = models::file_uploads::insert(
526            conn,
527            name,
528            &format!("exercise-answer-uploads/{}", Uuid::new_v4()),
529            "application/octet-stream",
530            Some(user),
531            Some(3),
532        )
533        .await
534        .expect("file upload");
535        models::exercise_answer_uploads::insert_many(
536            conn,
537            exercise,
538            user,
539            &[file_id],
540            AnswerUploadOrigin::Iframe,
541        )
542        .await
543        .expect("binding");
544        file_id
545    }
546
547    fn file_answer(exercise_task_id: Uuid, data_files: Vec<Uuid>) -> StudentExerciseTaskSubmission {
548        StudentExerciseTaskSubmission::files(
549            exercise_task_id,
550            data_files,
551            Some(serde_json::json!({ "plugin": "said so" })),
552        )
553    }
554
555    async fn submit(
556        conn: &mut PgConnection,
557        fixture: &Fixture,
558        answer: StudentExerciseTaskSubmission,
559        file_store: &dyn FileStore,
560    ) -> Result<models::library::grading::StudentExerciseSlideSubmissionResult, ControllerError>
561    {
562        let exercise = models::exercises::get_by_id(conn, fixture.exercise)
563            .await
564            .expect("exercise");
565        process_submission(
566            conn,
567            fixture.user,
568            exercise,
569            &StudentExerciseSlideSubmission {
570                exercise_slide_id: fixture.slide,
571                exercise_task_submissions: vec![answer],
572            },
573            Arc::new(crate::domain::models_requests::JwtKey::test_key()),
574            file_store,
575            &init_app_conf().expect("app conf"),
576        )
577        .await
578    }
579
580    async fn slide_submission_count(conn: &mut PgConnection, exercise: Uuid, user: Uuid) -> u32 {
581        models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_and_user_ids(conn, exercise, user)
582            .await
583            .expect("count")
584    }
585
586    async fn answer_kind_of(conn: &mut PgConnection, submission: Uuid) -> AnswerKind {
587        models::exercise_task_submissions::get_answer_kind(conn, submission)
588            .await
589            .expect("answer kind")
590    }
591
592    async fn recorded_files(conn: &mut PgConnection, submission: Uuid) -> Vec<(Uuid, i32)> {
593        models::exercise_task_submission_files::get_positions_by_task_submission_id(
594            conn, submission,
595        )
596        .await
597        .expect("submission files")
598    }
599
600    async fn binding_id_of(conn: &mut PgConnection, file_upload_id: Uuid) -> Uuid {
601        models::exercise_answer_uploads::get_id_by_file_upload_id(conn, file_upload_id)
602            .await
603            .expect("binding id")
604    }
605
606    async fn reap(conn: &mut PgConnection, file_upload_id: Uuid) {
607        models::exercise_answer_uploads::delete_by_file_upload_id(conn, file_upload_id)
608            .await
609            .expect("reap");
610    }
611
612    fn stub_grading() -> ExerciseTaskGradingResult {
613        ExerciseTaskGradingResult {
614            grading_progress: GradingProgress::FullyGraded,
615            score_given: 1.0,
616            score_maximum: 1,
617            feedback_text: Some("graded by the stub".to_string()),
618            feedback_json: None,
619            set_user_variables: None,
620        }
621    }
622
623    struct StubState {
624        grade_requests: Mutex<Vec<serde_json::Value>>,
625        /// When set, the grading hop waits for a permit before answering, holding the submission
626        /// transaction — and therefore the upload's row lock — open for as long as the test wants.
627        hold_grading: Option<Arc<tokio::sync::Semaphore>>,
628    }
629
630    async fn stub_grade(
631        state: web::Data<StubState>,
632        body: web::Json<serde_json::Value>,
633    ) -> HttpResponse {
634        state
635            .grade_requests
636            .lock()
637            .expect("stub lock")
638            .push(body.into_inner());
639        if let Some(hold) = &state.hold_grading {
640            hold.acquire().await.expect("hold permit").forget();
641        }
642        HttpResponse::Ok().json(stub_grading())
643    }
644
645    /// Serves the grading endpoint on a real socket and returns its base URL. HTTP rather than an
646    /// in-process shortcut because the hop happens inside the submission transaction, which is what
647    /// the reap race turns on.
648    fn start_exercise_service_stub(state: Arc<StubState>) -> String {
649        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
650        let port = listener.local_addr().expect("local addr").port();
651        let server = actix_web::HttpServer::new(move || {
652            actix_web::App::new()
653                .app_data(web::Data::from(state.clone()))
654                .route("/grade", web::post().to(stub_grade))
655        })
656        .workers(1)
657        .disable_signals()
658        .listen(listener)
659        .expect("listen")
660        .run();
661        actix_web::rt::spawn(server);
662        format!("http://127.0.0.1:{port}")
663    }
664
665    /// A rejected submit must leave nothing behind: a check that ran after the insert would pass a
666    /// status-only assertion while persisting the answer anyway.
667    async fn assert_rejected_without_a_submission(
668        conn: &mut PgConnection,
669        fixture: &Fixture,
670        answer: StudentExerciseTaskSubmission,
671        expected_message_key: &str,
672    ) {
673        let store = temp_file_store();
674        let error = submit(conn, fixture, answer, &store)
675            .await
676            .expect_err("the submit must be rejected");
677        assert_eq!(message_key_of(&error), expected_message_key);
678        assert_eq!(
679            slide_submission_count(conn, fixture.exercise, fixture.user).await,
680            0
681        );
682    }
683
684    macro_rules! fixture {
685        ($tx:ident, $fixture:ident) => {
686            fixture!($tx, $fixture, _state);
687        };
688        ($tx:ident, $fixture:ident, $state:ident) => {
689            let $state = Arc::new(StubState {
690                grade_requests: Mutex::new(Vec::new()),
691                hold_grading: None,
692            });
693            let url = start_exercise_service_stub($state.clone());
694            insert_data!(:$tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
695            let task = insert_graded_task($tx.as_mut(), slide, url, 1).await;
696            enroll_and_open($tx.as_mut(), user, course, instance.id, exercise, slide).await;
697            let $fixture = Fixture {
698                user,
699                course,
700                chapter,
701                page,
702                exercise,
703                slide,
704                task,
705            };
706        };
707    }
708
709    #[actix_web::test]
710    async fn naming_another_users_upload_is_rejected_and_creates_no_submission() {
711        fixture!(tx, fixture);
712        let stranger = models::users::insert(
713            tx.as_mut(),
714            models::PKeyPolicy::Generate,
715            &format!("{}@example.com", Uuid::new_v4()),
716            None,
717            None,
718        )
719        .await
720        .expect("stranger");
721        let theirs = bind_upload(tx.as_mut(), fixture.exercise, stranger, "theirs.txt").await;
722
723        assert_rejected_without_a_submission(
724            tx.as_mut(),
725            &fixture,
726            file_answer(fixture.task, vec![theirs]),
727            "unknown_upload",
728        )
729        .await;
730        tx.rollback().await;
731    }
732
733    #[actix_web::test]
734    async fn naming_another_exercises_upload_is_rejected_and_creates_no_submission() {
735        fixture!(tx, fixture);
736        let other_exercise = models::exercises::insert(
737            tx.as_mut(),
738            models::PKeyPolicy::Generate,
739            fixture.course,
740            "other",
741            fixture.page,
742            fixture.chapter,
743            1,
744        )
745        .await
746        .expect("second exercise");
747        let elsewhere =
748            bind_upload(tx.as_mut(), other_exercise, fixture.user, "elsewhere.txt").await;
749
750        assert_rejected_without_a_submission(
751            tx.as_mut(),
752            &fixture,
753            file_answer(fixture.task, vec![elsewhere]),
754            "unknown_upload",
755        )
756        .await;
757        tx.rollback().await;
758    }
759
760    #[actix_web::test]
761    async fn naming_the_same_upload_twice_is_rejected() {
762        fixture!(tx, fixture);
763        let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "once.txt").await;
764
765        assert_rejected_without_a_submission(
766            tx.as_mut(),
767            &fixture,
768            file_answer(fixture.task, vec![file, file]),
769            "duplicate_upload",
770        )
771        .await;
772        tx.rollback().await;
773    }
774
775    /// Distinctness is a property of the whole slide submission, not of one task's file list: two
776    /// task submissions in the same slide submission naming the same upload must be rejected the
777    /// same way as one task naming it twice.
778    #[actix_web::test]
779    async fn naming_the_same_upload_from_two_different_tasks_is_rejected() {
780        fixture!(tx, fixture, state);
781        let other_task = insert_graded_task(
782            tx.as_mut(),
783            fixture.slide,
784            start_exercise_service_stub(state.clone()),
785            2,
786        )
787        .await;
788        let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "shared.txt").await;
789        let store = temp_file_store();
790        let exercise = models::exercises::get_by_id(tx.as_mut(), fixture.exercise)
791            .await
792            .expect("exercise");
793
794        let error = process_submission(
795            tx.as_mut(),
796            fixture.user,
797            exercise,
798            &StudentExerciseSlideSubmission {
799                exercise_slide_id: fixture.slide,
800                exercise_task_submissions: vec![
801                    file_answer(fixture.task, vec![file]),
802                    file_answer(other_task, vec![file]),
803                ],
804            },
805            Arc::new(crate::domain::models_requests::JwtKey::test_key()),
806            &store,
807            &init_app_conf().expect("app conf"),
808        )
809        .await
810        .expect_err("naming the same upload from two tasks must be rejected");
811        assert_eq!(message_key_of(&error), "duplicate_upload");
812        assert_eq!(
813            slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
814            0
815        );
816        assert!(
817            state.grade_requests.lock().expect("stub lock").is_empty(),
818            "the answer must be refused at the edge, before the exercise service is asked anything"
819        );
820        tx.rollback().await;
821    }
822
823    /// A file answer naming nothing is malformed rather than empty: presence of the field is the
824    /// discriminator, so the degenerate case has to be an error and not an ambiguity.
825    #[actix_web::test]
826    async fn a_file_answer_naming_no_files_is_refused() {
827        use actix_web::ResponseError;
828        use actix_web::http::StatusCode;
829        fixture!(tx, fixture, state);
830        let store = temp_file_store();
831
832        let error = submit(
833            tx.as_mut(),
834            &fixture,
835            file_answer(fixture.task, vec![]),
836            &store,
837        )
838        .await
839        .expect_err("a file answer naming nothing must be refused");
840        assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
841        assert_eq!(
842            slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
843            0
844        );
845        assert!(
846            state.grade_requests.lock().expect("stub lock").is_empty(),
847            "the answer must be refused at the edge, before the exercise service is asked anything"
848        );
849        tx.rollback().await;
850    }
851
852    #[actix_web::test]
853    async fn naming_a_reaped_upload_is_rejected_as_expired() {
854        fixture!(tx, fixture);
855        let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "gone.txt").await;
856        reap(tx.as_mut(), file).await;
857
858        assert_rejected_without_a_submission(
859            tx.as_mut(),
860            &fixture,
861            file_answer(fixture.task, vec![file]),
862            "upload_expired",
863        )
864        .await;
865        tx.rollback().await;
866    }
867
868    /// The happy path: the answer lands file-typed, the files land in the order the plugin named
869    /// them rather than the order they were uploaded in, the plugin's metadata lands in `data_json`,
870    /// and the result hands all of it back as `AnswerData::File`.
871    #[actix_web::test]
872    async fn a_legitimate_file_answer_lands_ordered_with_its_metadata() {
873        fixture!(tx, fixture);
874        let first = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "first.txt").await;
875        let second = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "second.txt").await;
876        let named = vec![second, first];
877        let store = temp_file_store();
878
879        let result = submit(
880            tx.as_mut(),
881            &fixture,
882            file_answer(fixture.task, named.clone()),
883            &store,
884        )
885        .await
886        .expect("the submit must be accepted");
887
888        let submission = result
889            .exercise_task_submission_results
890            .into_iter()
891            .next()
892            .expect("one task submission")
893            .submission;
894        assert_eq!(
895            answer_kind_of(tx.as_mut(), submission.id).await,
896            AnswerKind::File
897        );
898        assert_eq!(
899            recorded_files(tx.as_mut(), submission.id).await,
900            vec![(second, 0), (first, 1)]
901        );
902        assert_eq!(submission.answer_kind, AnswerKind::File);
903        let files = submission
904            .data_files
905            .expect("a file answer comes back with its files");
906        assert_eq!(
907            files.iter().map(|file| file.id).collect::<Vec<_>>(),
908            named,
909            "the plugin's order is the answer, not ours to sort"
910        );
911        assert_eq!(
912            files
913                .iter()
914                .map(|file: &AnswerFile| file.name.as_str())
915                .collect::<Vec<_>>(),
916            vec!["second.txt", "first.txt"]
917        );
918        assert_eq!(
919            submission.data_json,
920            Some(serde_json::json!({ "plugin": "said so" }))
921        );
922        tx.rollback().await;
923    }
924
925    /// The reap-vs-submit race through this path, with two real connections. The reaper must block
926    /// on the row lock the submit takes rather than deciding without it, and must then observe the
927    /// association the submit committed while it waited.
928    ///
929    /// The grading hop is what holds the transaction open here: it runs inside it, so a stub that
930    /// answers only when told reproduces the window without any sleeping.
931    #[actix_web::test]
932    async fn a_concurrent_reaper_blocks_on_the_submit_lock_and_then_declines_to_reap() {
933        let hold = Arc::new(tokio::sync::Semaphore::new(0));
934        let state = Arc::new(StubState {
935            grade_requests: Mutex::new(Vec::new()),
936            hold_grading: Some(hold.clone()),
937        });
938        let url = start_exercise_service_stub(state.clone());
939
940        // Committed so the reaper's connection can see them. An IFrame upload's retention window is
941        // seven days, so nothing this leaves behind is visible to `get_reapable`.
942        insert_data!(:tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
943        let task = insert_graded_task(tx.as_mut(), slide, url, 1).await;
944        enroll_and_open(tx.as_mut(), user, course, instance.id, exercise, slide).await;
945        let file = bind_upload(tx.as_mut(), exercise, user, "raced.txt").await;
946        let binding = binding_id_of(tx.as_mut(), file).await;
947        tx.commit().await;
948
949        let fixture = Fixture {
950            user,
951            course,
952            chapter,
953            page,
954            exercise,
955            slide,
956            task,
957        };
958        let submitting = actix_web::rt::spawn(async move {
959            let mut conn = PgConnection::connect(&test_database_url())
960                .await
961                .expect("submit connection");
962            let store = temp_file_store();
963            submit(
964                &mut conn,
965                &fixture,
966                file_answer(fixture.task, vec![file]),
967                &store,
968            )
969            .await
970            .map(|result| {
971                result
972                    .exercise_task_submission_results
973                    .into_iter()
974                    .next()
975                    .expect("one task submission")
976                    .submission
977                    .id
978            })
979        });
980
981        // The grading request proves the submit is inside its transaction, past the lock.
982        for _ in 0..100 {
983            if !state.grade_requests.lock().expect("stub lock").is_empty() {
984                break;
985            }
986            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
987        }
988        assert_eq!(
989            state.grade_requests.lock().expect("stub lock").len(),
990            1,
991            "the submit must reach the grading hop, which is what holds its transaction open"
992        );
993
994        let mut reaper_conn = PgConnection::connect(&test_database_url())
995            .await
996            .expect("reaper connection");
997        // Scoped so the pinned future releases its borrow before the connection is dropped.
998        let outcome = {
999            let mut reaping = std::pin::pin!(models::exercise_answer_uploads::mark_reaped(
1000                &mut reaper_conn,
1001                binding
1002            ));
1003            assert!(
1004                tokio::time::timeout(std::time::Duration::from_millis(500), &mut reaping)
1005                    .await
1006                    .is_err(),
1007                "the reaper must block on the row lock the submit holds, not decide without it"
1008            );
1009
1010            hold.add_permits(1);
1011            let submission = submitting
1012                .await
1013                .expect("the submit task must not panic")
1014                .expect("the submit must be accepted");
1015
1016            let reaped = tokio::time::timeout(std::time::Duration::from_secs(10), &mut reaping)
1017                .await
1018                .expect("the reaper must unblock once the submit commits")
1019                .expect("mark_reaped");
1020            (reaped, submission)
1021        };
1022        let (reaped, submission) = outcome;
1023        assert!(
1024            !reaped,
1025            "the reaper must decline an upload the submit referenced while it waited"
1026        );
1027
1028        let mut check_conn = Conn::init().await;
1029        let mut check_tx = check_conn.begin().await;
1030        assert_eq!(
1031            recorded_files(check_tx.as_mut(), submission).await,
1032            vec![(file, 0)],
1033            "the submission must keep the file the reaper tried to take"
1034        );
1035        assert_eq!(
1036            models::exercise_answer_uploads::get_for_exercise_and_user(
1037                check_tx.as_mut(),
1038                exercise,
1039                user,
1040                &[file]
1041            )
1042            .await
1043            .expect("binding lookup"),
1044            vec![models::exercise_answer_uploads::AnswerUpload {
1045                file_upload_id: file,
1046                deleted: false
1047            }],
1048            "the upload must stay usable, so a download can still serve it"
1049        );
1050        check_tx.rollback().await;
1051    }
1052}