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