Skip to main content

headless_lms_models/
exercise_answer_uploads.rs

1//! Files uploaded to be named in an exercise answer, bound to the exercise and user they were
2//! uploaded for.
3//!
4//! The binding is what lets submit reject a file uploaded for a different exercise; ownership
5//! alone would let any exercise's submission name any of the user's uploads. It lives in its own
6//! table rather than as a nullable column on `file_uploads` because `file_uploads` is shared with
7//! CMS media, organization images and certificates, and the reaper's safety depends on the
8//! distinction being structural.
9
10use crate::prelude::*;
11use chrono::Duration;
12
13/// Cap on one reaper run's listing, bounding its object-store fan-out and its runtime under the
14/// CronJob deadline. A backlog is worked off over successive runs.
15const REAP_BATCH_LIMIT: i64 = 1000;
16
17/// The channel a file was uploaded through. Selects the reaper's retention window: a native client
18/// uploads seconds before submitting, while an iframe student may hold an upload for the length of
19/// an exam.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
21#[sqlx(type_name = "answer_upload_origin", rename_all = "snake_case")]
22#[serde(rename_all = "snake_case")]
23pub enum AnswerUploadOrigin {
24    NativeClient,
25    Iframe,
26}
27
28/// An answer upload as seen by submit validation. `deleted` distinguishes a reaped upload
29/// (answerable with `upload_expired`) from one that was never recorded.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AnswerUpload {
32    pub file_upload_id: Uuid,
33    pub deleted: bool,
34}
35
36/// Binds freshly uploaded files to the exercise and user they were uploaded for.
37pub async fn insert_many(
38    conn: &mut PgConnection,
39    exercise_id: Uuid,
40    user_id: Uuid,
41    file_upload_ids: &[Uuid],
42    origin: AnswerUploadOrigin,
43) -> ModelResult<()> {
44    sqlx::query!(
45        "
46INSERT INTO exercise_answer_uploads (file_upload_id, exercise_id, user_id, origin)
47SELECT file_upload_id,
48  $2,
49  $3,
50  $4
51FROM UNNEST($1::uuid []) AS t(file_upload_id)
52",
53        file_upload_ids,
54        exercise_id,
55        user_id,
56        origin
57    )
58    .execute(conn)
59    .await?;
60    Ok(())
61}
62
63/// The requested uploads that belong to this exercise and user, soft-deleted ones included.
64///
65/// Rows bound to another exercise or another user are deliberately not returned: to the caller
66/// they must be indistinguishable from ids that were never uploaded, so a foreign id leaks
67/// nothing beyond "not yours".
68pub async fn get_for_exercise_and_user(
69    conn: &mut PgConnection,
70    exercise_id: Uuid,
71    user_id: Uuid,
72    file_upload_ids: &[Uuid],
73) -> ModelResult<Vec<AnswerUpload>> {
74    let res = sqlx::query!(
75        "
76SELECT file_upload_id,
77  deleted_at
78FROM exercise_answer_uploads
79WHERE file_upload_id = ANY($1)
80  AND exercise_id = $2
81  AND user_id = $3
82",
83        file_upload_ids,
84        exercise_id,
85        user_id
86    )
87    .fetch_all(conn)
88    .await?;
89    Ok(res
90        .into_iter()
91        .map(|row| AnswerUpload {
92            file_upload_id: row.file_upload_id,
93            deleted: row.deleted_at.is_some(),
94        })
95        .collect())
96}
97
98/// Like [`get_for_exercise_and_user`], but takes a row lock on the bindings so the reaper cannot
99/// retire them between this check and the caller's `exercise_task_submission_files` insert.
100///
101/// Must be called inside the transaction that later records the association, or the lock is
102/// released before it protects anything. Without it, a reap that lands between validation and the
103/// association commits a submission whose files are already gone: `download_submission` then
104/// reports zero files forever and the student never sees `upload_expired`.
105pub async fn lock_for_exercise_and_user(
106    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
107    exercise_id: Uuid,
108    user_id: Uuid,
109    file_upload_ids: &[Uuid],
110) -> ModelResult<Vec<AnswerUpload>> {
111    let res = sqlx::query!(
112        "
113SELECT file_upload_id,
114  deleted_at
115FROM exercise_answer_uploads
116WHERE file_upload_id = ANY($1)
117  AND exercise_id = $2
118  AND user_id = $3
119FOR UPDATE
120",
121        file_upload_ids,
122        exercise_id,
123        user_id
124    )
125    .fetch_all(&mut **tx)
126    .await?;
127    Ok(res
128        .into_iter()
129        .map(|row| AnswerUpload {
130            file_upload_id: row.file_upload_id,
131            deleted: row.deleted_at.is_some(),
132        })
133        .collect())
134}
135
136/// An upload the reaper may remove: old enough, and named by no submission.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ReapableUpload {
139    pub id: Uuid,
140    pub file_upload_id: Uuid,
141    /// Object-store path of the file to remove.
142    pub path: String,
143}
144
145/// Answer uploads past their origin's retention window that no submission was ever made from,
146/// oldest first, at most [`REAP_BATCH_LIMIT`] of them.
147///
148/// `FROM exercise_answer_uploads` is the safety property of this whole feature, not an
149/// optimisation: `file_uploads` also holds CMS media, organization images and certificates, none
150/// of which are bound here, so the host cannot tell whether one is still needed. Widening this
151/// query to `file_uploads` would silently destroy course media. Never do it.
152///
153/// Files referenced only from a spec blob (e.g. ones a teacher attaches in the CMS editor) must be
154/// uploaded through the `POST /api/v0/files/{exercise_service_slug}` route instead: the host never
155/// inspects spec contents, so such a file never gets an `exercise_task_submission_files` row and a
156/// binding here would have it reaped a week later. Their lifecycle is
157/// [`crate::exercise_spec_uploads`]', which reclaims them against declarations rather than
158/// submissions.
159///
160/// Progress is tracked by `file_uploads.deleted_at`, not by the binding's: the binding is retired
161/// first and the object removed afterwards, so a row whose object delete failed still has a live
162/// `file_uploads` row and comes back on the next run. Filtering on `u.deleted_at IS NULL` instead
163/// would make every transient object-store error orphan its object permanently.
164pub async fn get_reapable(conn: &mut PgConnection) -> ModelResult<Vec<ReapableUpload>> {
165    let res = sqlx::query_as!(
166        ReapableUpload,
167        "
168SELECT u.id,
169  u.file_upload_id,
170  f.path
171FROM exercise_answer_uploads AS u
172  JOIN file_uploads AS f ON f.id = u.file_upload_id
173WHERE f.deleted_at IS NULL
174  AND u.created_at < now() - CASE
175    u.origin
176    WHEN 'native_client' THEN interval '1 hour'
177    ELSE interval '7 days'
178  END
179  AND NOT EXISTS (
180    SELECT 1
181    FROM exercise_task_submission_files AS s
182    WHERE s.file_upload_id = u.file_upload_id
183      AND s.deleted_at IS NULL
184  )
185ORDER BY u.created_at
186LIMIT $1
187",
188        REAP_BATCH_LIMIT
189    )
190    .fetch_all(conn)
191    .await?;
192    Ok(res)
193}
194
195/// Retires an upload, keeping the row so a submit naming it answers `upload_expired` instead of
196/// the misleading `unknown_upload`. Idempotent, so a run retrying a failed object delete can call
197/// it again.
198///
199/// Re-checks that no submission has come to reference the upload since `get_reapable` listed it,
200/// and reports `false` if one has. That re-check plus the row lock
201/// [`lock_for_exercise_and_user`] takes is what stops a reap from landing in the middle of a
202/// submit and destroying its files.
203///
204/// The lock is taken in a statement of its own, and that ordering is the whole point. Under READ
205/// COMMITTED a statement's snapshot is fixed when the statement starts, and Postgres refreshes only
206/// the row an `UPDATE` locks — never the rows its subqueries read. So a single locking `UPDATE`
207/// blocks on the submit's lock, unblocks, and then evaluates `NOT EXISTS` against a snapshot from
208/// *before* the submit committed: it finds no submission and reaps the files of a submission that
209/// already answered 200. Locking first makes the `UPDATE` a later statement, so its snapshot
210/// includes that commit.
211pub async fn mark_reaped(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
212    let mut tx = conn.begin().await?;
213    let locked = sqlx::query_scalar!(
214        "
215SELECT id
216FROM exercise_answer_uploads
217WHERE id = $1
218FOR UPDATE
219",
220        id
221    )
222    .fetch_optional(&mut *tx)
223    .await?;
224    if locked.is_none() {
225        tx.rollback().await?;
226        return Ok(false);
227    }
228    let retired = sqlx::query_scalar!(
229        "
230UPDATE exercise_answer_uploads AS u
231SET deleted_at = COALESCE(u.deleted_at, now())
232WHERE u.id = $1
233  AND NOT EXISTS (
234    SELECT 1
235    FROM exercise_task_submission_files AS s
236    WHERE s.file_upload_id = u.file_upload_id
237      AND s.deleted_at IS NULL
238  )
239RETURNING u.id
240",
241        id
242    )
243    .fetch_optional(&mut *tx)
244    .await?;
245    tx.commit().await?;
246    Ok(retired.is_some())
247}
248
249/// Soft-deletes the binding rows for an uploaded file, whatever their state.
250///
251/// Unconditional by design: [`mark_reaped`] is the reaper's form, which locks the row and declines
252/// while a live submission file still references it.
253pub async fn delete_by_file_upload_id(
254    conn: &mut PgConnection,
255    file_upload_id: Uuid,
256) -> ModelResult<()> {
257    sqlx::query!(
258        "
259UPDATE exercise_answer_uploads
260SET deleted_at = now()
261WHERE file_upload_id = $1
262  AND deleted_at IS NULL
263",
264        file_upload_id
265    )
266    .execute(conn)
267    .await?;
268    Ok(())
269}
270
271/// The binding row's own id for an uploaded file.
272///
273/// Errors when no binding exists; [`mark_reaped`] is what consumes this id.
274pub async fn get_id_by_file_upload_id(
275    conn: &mut PgConnection,
276    file_upload_id: Uuid,
277) -> ModelResult<Uuid> {
278    let id = sqlx::query_scalar!(
279        "SELECT id FROM exercise_answer_uploads WHERE file_upload_id = $1",
280        file_upload_id
281    )
282    .fetch_one(conn)
283    .await?;
284    Ok(id)
285}
286
287/// What an upload route recorded for one uploaded file.
288pub struct AnswerUploadBinding {
289    pub file_upload_id: Uuid,
290    pub exercise_id: Uuid,
291    pub user_id: Uuid,
292    pub origin: AnswerUploadOrigin,
293}
294
295/// The live bindings for the given uploaded files, in no particular order.
296///
297/// Unlike [`get_for_exercise_and_user`] this looks up by file rather than by owner, and reports the
298/// exercise and user each file was bound to.
299pub async fn get_by_file_upload_ids(
300    conn: &mut PgConnection,
301    file_upload_ids: &[Uuid],
302) -> ModelResult<Vec<AnswerUploadBinding>> {
303    let bindings = sqlx::query_as!(
304        AnswerUploadBinding,
305        "
306SELECT file_upload_id,
307  exercise_id,
308  user_id,
309  origin
310FROM exercise_answer_uploads
311WHERE file_upload_id = ANY($1)
312  AND deleted_at IS NULL
313",
314        file_upload_ids
315    )
316    .fetch_all(conn)
317    .await?;
318    Ok(bindings)
319}
320
321/// Moves a binding's creation time `age` into the past, to bring it within a retention window.
322///
323/// Shifts the row rather than the clock because [`get_reapable`] compares against Postgres `now()`,
324/// which no Rust-side clock reaches.
325pub async fn backdate(
326    conn: &mut PgConnection,
327    file_upload_id: Uuid,
328    age: Duration,
329) -> ModelResult<()> {
330    sqlx::query!(
331        "UPDATE exercise_answer_uploads SET created_at = now() - $2::interval WHERE file_upload_id = $1",
332        file_upload_id,
333        age as Duration
334    )
335    .execute(conn)
336    .await?;
337    Ok(())
338}
339
340#[cfg(test)]
341mod test {
342    use super::*;
343    use crate::library::grading::SubmittedAnswer;
344    use crate::test_helper::*;
345    use chrono::Duration;
346
347    async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
348        crate::file_uploads::insert(
349            tx,
350            name,
351            &format!("exercise-services-client/{name}"),
352            "application/octet-stream",
353            None,
354            None,
355        )
356        .await
357        .unwrap()
358    }
359
360    async fn soft_delete(tx: &mut PgConnection, file_upload_id: Uuid) {
361        crate::exercise_answer_uploads::delete_by_file_upload_id(tx, file_upload_id)
362            .await
363            .unwrap();
364    }
365
366    async fn insert_file_at(tx: &mut PgConnection, name: &str, path: &str) -> Uuid {
367        crate::file_uploads::insert(tx, name, path, "application/octet-stream", None, None)
368            .await
369            .unwrap()
370    }
371
372    /// Backdates both the binding and the file, so that a query widened to `file_uploads` cannot
373    /// pass the negative test by accidentally tripping the age filter.
374    async fn backdate_file(tx: &mut PgConnection, file_upload_id: Uuid, age: Duration) {
375        crate::file_uploads::backdate(tx, file_upload_id, age)
376            .await
377            .unwrap();
378    }
379
380    async fn backdate(tx: &mut PgConnection, file_upload_id: Uuid, age: Duration) {
381        backdate_file(&mut *tx, file_upload_id, age).await;
382        crate::exercise_answer_uploads::backdate(tx, file_upload_id, age)
383            .await
384            .unwrap();
385    }
386
387    /// Records a task submission made from the given uploads, the thing that makes an upload
388    /// permanently un-reapable.
389    async fn insert_task_submission_referencing(
390        tx: &mut PgConnection,
391        course_id: Uuid,
392        user_id: Uuid,
393        exercise_id: Uuid,
394        slide_id: Uuid,
395        task_id: Uuid,
396        file_upload_ids: &[Uuid],
397    ) -> Option<Uuid> {
398        let slide_submission = crate::exercise_slide_submissions::insert_exercise_slide_submission(
399            &mut *tx,
400            crate::exercise_slide_submissions::NewExerciseSlideSubmission {
401                exercise_slide_id: slide_id,
402                course_id: Some(course_id),
403                exam_id: None,
404                user_id,
405                exercise_id,
406                user_points_update_strategy:
407                    crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
408            },
409        )
410        .await
411        .unwrap();
412        let task_submission = crate::exercise_task_submissions::insert(
413            &mut *tx,
414            PKeyPolicy::Generate,
415            slide_submission.id,
416            slide_id,
417            task_id,
418            &SubmittedAnswer::Json {
419                data: serde_json::json!({ "opaque": "plugin owned" }),
420            },
421        )
422        .await
423        .unwrap();
424        crate::exercise_task_submission_files::insert_many(
425            &mut *tx,
426            task_submission,
427            file_upload_ids,
428        )
429        .await
430        .unwrap();
431        Some(task_submission)
432    }
433
434    /// Which of `of_interest` the reaper lists, in listing order. Scoped to the caller's own
435    /// fixtures because the shared test database also holds rows other tests committed.
436    async fn reapable_among(tx: &mut PgConnection, of_interest: &[Uuid]) -> Vec<Uuid> {
437        get_reapable(tx)
438            .await
439            .unwrap()
440            .into_iter()
441            .map(|upload| upload.file_upload_id)
442            .filter(|file_upload_id| of_interest.contains(file_upload_id))
443            .collect()
444    }
445
446    /// The binding the reaper listed for this file. Panics if it listed none, which is the
447    /// assertion every caller wants first.
448    async fn reapable_binding_of(tx: &mut PgConnection, file_upload_id: Uuid) -> Uuid {
449        get_reapable(tx)
450            .await
451            .unwrap()
452            .into_iter()
453            .find(|upload| upload.file_upload_id == file_upload_id)
454            .expect("the reaper must list this upload")
455            .id
456    }
457
458    #[tokio::test]
459    async fn finds_only_uploads_bound_to_the_same_exercise_and_user() {
460        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:chapter_id, page:page_id, exercise:exercise_id, slide:_slide, task:_task);
461        let other_exercise = crate::exercises::insert(
462            tx.as_mut(),
463            PKeyPolicy::Generate,
464            course_id,
465            "Other",
466            page_id,
467            chapter_id,
468            1,
469        )
470        .await
471        .unwrap();
472        let other_user = crate::users::insert(
473            tx.as_mut(),
474            PKeyPolicy::Generate,
475            "other@example.com",
476            None,
477            None,
478        )
479        .await
480        .unwrap();
481
482        let mine = insert_file(tx.as_mut(), "mine").await;
483        let for_other_exercise = insert_file(tx.as_mut(), "other-exercise").await;
484        let for_other_user = insert_file(tx.as_mut(), "other-user").await;
485        insert_many(
486            tx.as_mut(),
487            exercise_id,
488            user_id,
489            &[mine],
490            AnswerUploadOrigin::NativeClient,
491        )
492        .await
493        .unwrap();
494        insert_many(
495            tx.as_mut(),
496            other_exercise,
497            user_id,
498            &[for_other_exercise],
499            AnswerUploadOrigin::NativeClient,
500        )
501        .await
502        .unwrap();
503        insert_many(
504            tx.as_mut(),
505            exercise_id,
506            other_user,
507            &[for_other_user],
508            AnswerUploadOrigin::NativeClient,
509        )
510        .await
511        .unwrap();
512
513        let found = get_for_exercise_and_user(
514            tx.as_mut(),
515            exercise_id,
516            user_id,
517            &[mine, for_other_exercise, for_other_user],
518        )
519        .await
520        .unwrap();
521        assert_eq!(
522            found,
523            vec![AnswerUpload {
524                file_upload_id: mine,
525                deleted: false
526            }]
527        );
528        tx.rollback().await;
529    }
530
531    /// A reaped upload must still be found, or submit cannot tell "expired" from "never yours".
532    #[tokio::test]
533    async fn a_soft_deleted_upload_is_still_found_and_flagged() {
534        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
535        let file_id = insert_file(tx.as_mut(), "reaped").await;
536        insert_many(
537            tx.as_mut(),
538            exercise_id,
539            user_id,
540            &[file_id],
541            AnswerUploadOrigin::NativeClient,
542        )
543        .await
544        .unwrap();
545        soft_delete(tx.as_mut(), file_id).await;
546
547        let found = get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[file_id])
548            .await
549            .unwrap();
550        assert_eq!(
551            found,
552            vec![AnswerUpload {
553                file_upload_id: file_id,
554                deleted: true
555            }]
556        );
557        tx.rollback().await;
558    }
559
560    #[tokio::test]
561    async fn an_unrecorded_id_is_not_found() {
562        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
563        let found = get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[Uuid::new_v4()])
564            .await
565            .unwrap();
566        assert!(found.is_empty());
567        assert!(
568            get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[])
569                .await
570                .unwrap()
571                .is_empty()
572        );
573        tx.rollback().await;
574    }
575
576    #[tokio::test]
577    async fn inserting_an_empty_list_records_nothing() {
578        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
579        insert_many(
580            tx.as_mut(),
581            exercise_id,
582            user_id,
583            &[],
584            AnswerUploadOrigin::NativeClient,
585        )
586        .await
587        .unwrap();
588        tx.rollback().await;
589    }
590
591    #[tokio::test]
592    async fn reaps_only_uploads_past_the_retention_window() {
593        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
594        let just_under = insert_file(tx.as_mut(), "just-under").await;
595        let just_over = insert_file(tx.as_mut(), "just-over").await;
596        insert_many(
597            tx.as_mut(),
598            exercise_id,
599            user_id,
600            &[just_under, just_over],
601            AnswerUploadOrigin::NativeClient,
602        )
603        .await
604        .unwrap();
605        backdate(tx.as_mut(), just_under, Duration::minutes(59)).await;
606        backdate(tx.as_mut(), just_over, Duration::minutes(61)).await;
607
608        assert_eq!(
609            reapable_among(tx.as_mut(), &[just_under, just_over]).await,
610            vec![just_over]
611        );
612        tx.rollback().await;
613    }
614
615    /// A fully reaped upload drops out of the listing, and its binding survives soft-deleted so
616    /// submit can still answer `upload_expired`.
617    #[tokio::test]
618    async fn a_fully_reaped_upload_is_not_reaped_again() {
619        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
620        let file_id = insert_file(tx.as_mut(), "old").await;
621        insert_many(
622            tx.as_mut(),
623            exercise_id,
624            user_id,
625            &[file_id],
626            AnswerUploadOrigin::NativeClient,
627        )
628        .await
629        .unwrap();
630        backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
631        let id = reapable_binding_of(tx.as_mut(), file_id).await;
632        assert!(mark_reaped(tx.as_mut(), id).await.unwrap());
633        // Removing the file row is what records that the object is gone.
634        crate::file_uploads::delete_and_fetch_path(tx.as_mut(), file_id)
635            .await
636            .unwrap();
637
638        assert!(reapable_among(tx.as_mut(), &[file_id]).await.is_empty());
639        assert_eq!(
640            get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[file_id])
641                .await
642                .unwrap(),
643            vec![AnswerUpload {
644                file_upload_id: file_id,
645                deleted: true
646            }]
647        );
648        tx.rollback().await;
649    }
650
651    #[tokio::test]
652    async fn an_upload_a_submission_was_made_from_is_never_reaped() {
653        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
654        let submitted = insert_file(tx.as_mut(), "submitted").await;
655        let orphan = insert_file(tx.as_mut(), "orphan").await;
656        insert_many(
657            tx.as_mut(),
658            exercise_id,
659            user_id,
660            &[submitted, orphan],
661            AnswerUploadOrigin::NativeClient,
662        )
663        .await
664        .unwrap();
665        backdate(tx.as_mut(), submitted, Duration::hours(2)).await;
666        backdate(tx.as_mut(), orphan, Duration::hours(2)).await;
667
668        insert_task_submission_referencing(
669            tx.as_mut(),
670            course_id,
671            user_id,
672            exercise_id,
673            slide_id,
674            task_id,
675            &[submitted],
676        )
677        .await;
678
679        assert_eq!(
680            reapable_among(tx.as_mut(), &[submitted, orphan]).await,
681            vec![orphan]
682        );
683        tx.rollback().await;
684    }
685
686    /// The load-bearing negative test. `file_uploads` is shared with CMS media, organization
687    /// images and answer files with no binding here, none of which the host can tell are still
688    /// referenced. Anyone widening `get_reapable` to select from `file_uploads` must fail here.
689    #[tokio::test]
690    async fn unbound_uploads_are_never_reaped() {
691        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
692        let org_id = crate::organizations::all_organizations(tx.as_mut())
693            .await
694            .unwrap()[0]
695            .id;
696
697        let unbound_answer =
698            insert_file_at(tx.as_mut(), "answer.tar.zst", "tmc/AbCdEfGhIjKlMnOp").await;
699        let cms_media = insert_file_at(
700            tx.as_mut(),
701            "lecture.pdf",
702            &format!("course/{course_id}/files/qRsTuVwXyZ"),
703        )
704        .await;
705        let organization_image = insert_file_at(
706            tx.as_mut(),
707            "logo.png",
708            &format!("organization/{org_id}/images/aBcDeFgHiJ"),
709        )
710        .await;
711        let bound_upload = insert_file(tx.as_mut(), "orphan").await;
712        insert_many(
713            tx.as_mut(),
714            exercise_id,
715            user_id,
716            &[bound_upload],
717            AnswerUploadOrigin::NativeClient,
718        )
719        .await
720        .unwrap();
721        backdate(tx.as_mut(), bound_upload, Duration::hours(2)).await;
722        for foreign in [unbound_answer, cms_media, organization_image] {
723            backdate_file(tx.as_mut(), foreign, Duration::hours(2)).await;
724        }
725
726        assert_eq!(
727            reapable_among(
728                tx.as_mut(),
729                &[unbound_answer, cms_media, organization_image, bound_upload]
730            )
731            .await,
732            vec![bound_upload]
733        );
734        tx.rollback().await;
735    }
736
737    /// The submit side of the reap race. A reaper that listed this upload as orphaned before the
738    /// submission referenced it must not go through with the reap, or the submission commits with
739    /// its files already gone.
740    ///
741    /// This covers the interleaving logically, in one transaction. The row lock that makes the two
742    /// orderings the *only* possibilities needs two connections, and so committed fixtures, which
743    /// this harness cannot produce; that half lives in the server crate, as
744    /// `programs::exercise_answer_upload_reaper`'s
745    /// `a_concurrent_reaper_blocks_on_the_submit_lock_and_then_declines_to_reap`.
746    #[tokio::test]
747    async fn a_reap_cannot_retire_an_upload_a_submission_just_referenced() {
748        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
749        let file_id = insert_file(tx.as_mut(), "raced").await;
750        insert_many(
751            tx.as_mut(),
752            exercise_id,
753            user_id,
754            &[file_id],
755            AnswerUploadOrigin::NativeClient,
756        )
757        .await
758        .unwrap();
759        backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
760
761        // The reaper lists it while the submit is still validating.
762        let binding = reapable_binding_of(tx.as_mut(), file_id).await;
763
764        // The submit wins the race and records the association.
765        let task_submission = insert_task_submission_referencing(
766            tx.as_mut(),
767            course_id,
768            user_id,
769            exercise_id,
770            slide_id,
771            task_id,
772            &[file_id],
773        )
774        .await;
775        assert!(task_submission.is_some());
776
777        assert!(
778            !mark_reaped(tx.as_mut(), binding).await.unwrap(),
779            "the reaper must abandon an upload that became referenced after it was listed"
780        );
781        assert_eq!(
782            get_for_exercise_and_user(tx.as_mut(), exercise_id, user_id, &[file_id])
783                .await
784                .unwrap(),
785            vec![AnswerUpload {
786                file_upload_id: file_id,
787                deleted: false
788            }],
789            "the upload must stay usable, so download_submission can still serve it"
790        );
791        tx.rollback().await;
792    }
793
794    /// The other ordering: the reaper wins, and the locked re-check submit runs inside its own
795    /// transaction reports the upload as expired instead of letting a fileless submission commit.
796    #[tokio::test]
797    async fn a_locked_lookup_reports_an_upload_the_reaper_already_retired() {
798        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
799        let file_id = insert_file(tx.as_mut(), "reaped-first").await;
800        insert_many(
801            tx.as_mut(),
802            exercise_id,
803            user_id,
804            &[file_id],
805            AnswerUploadOrigin::NativeClient,
806        )
807        .await
808        .unwrap();
809        backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
810        let binding = reapable_binding_of(tx.as_mut(), file_id).await;
811        assert!(mark_reaped(tx.as_mut(), binding).await.unwrap());
812
813        let mut inner = tx.begin().await;
814        let locked = lock_for_exercise_and_user(inner.as_mut(), exercise_id, user_id, &[file_id])
815            .await
816            .unwrap();
817        assert_eq!(
818            locked,
819            vec![AnswerUpload {
820                file_upload_id: file_id,
821                deleted: true
822            }]
823        );
824        inner.rollback().await;
825        tx.rollback().await;
826    }
827
828    /// A run retrying an object delete that failed earlier calls `mark_reaped` again on a row it
829    /// already retired; that must succeed, not read as "someone referenced it".
830    #[tokio::test]
831    async fn mark_reaped_is_idempotent_so_a_failed_object_delete_can_be_retried() {
832        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
833        let file_id = insert_file(tx.as_mut(), "retry").await;
834        insert_many(
835            tx.as_mut(),
836            exercise_id,
837            user_id,
838            &[file_id],
839            AnswerUploadOrigin::NativeClient,
840        )
841        .await
842        .unwrap();
843        backdate(tx.as_mut(), file_id, Duration::hours(2)).await;
844        let id = reapable_binding_of(tx.as_mut(), file_id).await;
845
846        assert!(mark_reaped(tx.as_mut(), id).await.unwrap());
847        assert!(mark_reaped(tx.as_mut(), id).await.unwrap());
848        // Still listed, because the object delete has not been confirmed by removing the file row.
849        assert_eq!(reapable_among(tx.as_mut(), &[file_id]).await, vec![file_id]);
850        tx.rollback().await;
851    }
852
853    /// An iframe student may hold an upload for the length of an exam, so the window that a native
854    /// client's upload is already past must still spare theirs.
855    #[tokio::test]
856    async fn an_iframe_upload_outlives_the_native_client_window() {
857        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
858        let native = insert_file(tx.as_mut(), "native").await;
859        let iframe = insert_file(tx.as_mut(), "iframe").await;
860        insert_many(
861            tx.as_mut(),
862            exercise_id,
863            user_id,
864            &[native],
865            AnswerUploadOrigin::NativeClient,
866        )
867        .await
868        .unwrap();
869        insert_many(
870            tx.as_mut(),
871            exercise_id,
872            user_id,
873            &[iframe],
874            AnswerUploadOrigin::Iframe,
875        )
876        .await
877        .unwrap();
878        backdate(tx.as_mut(), native, Duration::hours(2)).await;
879        backdate(tx.as_mut(), iframe, Duration::hours(2)).await;
880
881        assert_eq!(
882            reapable_among(tx.as_mut(), &[native, iframe]).await,
883            vec![native]
884        );
885        tx.rollback().await;
886    }
887
888    #[tokio::test]
889    async fn an_iframe_upload_past_seven_days_is_reaped() {
890        insert_data!(:tx, user:user_id, :org, course:_course, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:_slide, task:_task);
891        let file_id = insert_file(tx.as_mut(), "stale-iframe").await;
892        insert_many(
893            tx.as_mut(),
894            exercise_id,
895            user_id,
896            &[file_id],
897            AnswerUploadOrigin::Iframe,
898        )
899        .await
900        .unwrap();
901        backdate(tx.as_mut(), file_id, Duration::days(8)).await;
902
903        assert_eq!(reapable_among(tx.as_mut(), &[file_id]).await, vec![file_id]);
904        tx.rollback().await;
905    }
906
907    /// The interlock does not depend on origin: a submission's files are spared however old the
908    /// binding is.
909    #[tokio::test]
910    async fn a_submitted_iframe_upload_is_spared_past_its_window() {
911        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
912        let submitted = insert_file(tx.as_mut(), "submitted-iframe").await;
913        let orphan = insert_file(tx.as_mut(), "orphan-iframe").await;
914        insert_many(
915            tx.as_mut(),
916            exercise_id,
917            user_id,
918            &[submitted, orphan],
919            AnswerUploadOrigin::Iframe,
920        )
921        .await
922        .unwrap();
923        backdate(tx.as_mut(), submitted, Duration::days(8)).await;
924        backdate(tx.as_mut(), orphan, Duration::days(8)).await;
925
926        insert_task_submission_referencing(
927            tx.as_mut(),
928            course_id,
929            user_id,
930            exercise_id,
931            slide_id,
932            task_id,
933            &[submitted],
934        )
935        .await;
936
937        assert_eq!(
938            reapable_among(tx.as_mut(), &[submitted, orphan]).await,
939            vec![orphan]
940        );
941        tx.rollback().await;
942    }
943}