Skip to main content

headless_lms_server/domain/exercise_services/
answer_uploads.rs

1//! Deciding whether a submit may name the host-stored uploads it names.
2//!
3//! Two submit paths reach here: a native client names its uploads in the request body, and a
4//! course-material IFrame answer names them inside its `current-state`. Both are equally
5//! student-controlled, so the checks live here rather than in either caller — a second copy would
6//! be a second thing to keep in step.
7
8use crate::domain::error::{BadRequestReason, bad_request_with_reason};
9use crate::prelude::*;
10use models::exercise_answer_uploads::AnswerUpload;
11use std::collections::HashSet;
12
13/// Rejects a file-typed answer that names no files at all.
14///
15/// The named files are the answer, so an empty list is a claim with no content rather than an
16/// answer that happens to need no files.
17pub fn verify_answer_names_uploads(requested: &[Uuid]) -> Result<(), ControllerError> {
18    if requested.is_empty() {
19        return Err(controller_err!(
20            BadRequest,
21            "A file answer must name at least one uploaded file.".to_string()
22        ));
23    }
24    Ok(())
25}
26
27/// Rejects a submit naming a file the host has no usable upload record of.
28///
29/// Ownership alone would not be enough: without the exercise binding, any of the user's uploads
30/// could be replayed into any other exercise's submission. A reaped upload is reported distinctly
31/// from an unrecognised one, because only the former is a race a client can recover from by
32/// uploading again.
33pub fn verify_uploads_are_usable(
34    requested: &[Uuid],
35    found: &[AnswerUpload],
36) -> Result<(), ControllerError> {
37    for id in requested {
38        match found.iter().find(|upload| &upload.file_upload_id == id) {
39            Some(upload) if upload.deleted => {
40                return Err(bad_request_with_reason(
41                    BadRequestReason::UploadExpired,
42                    format!("Uploaded file {id} is no longer available; upload it again"),
43                ));
44            }
45            Some(_) => {}
46            None => {
47                return Err(bad_request_with_reason(
48                    BadRequestReason::UnknownUpload,
49                    format!("Uploaded file {id} was not uploaded for this exercise by this user"),
50                ));
51            }
52        }
53    }
54    Ok(())
55}
56
57/// Rejects a submit naming the same upload twice.
58///
59/// Deduplicating instead would record the file twice under one submission and list it twice in a
60/// download, and would hide a client defect while doing so. There is no answer a duplicate could
61/// sensibly mean, so it is reported rather than repaired.
62pub fn verify_uploads_are_distinct(requested: &[Uuid]) -> Result<(), ControllerError> {
63    let mut seen = HashSet::with_capacity(requested.len());
64    for id in requested {
65        if !seen.insert(id) {
66            return Err(bad_request_with_reason(
67                BadRequestReason::DuplicateUpload,
68                format!("Uploaded file {id} was named more than once"),
69            ));
70        }
71    }
72    Ok(())
73}
74
75/// Checks every id a submit names against the uploads recorded for this exercise and user.
76///
77/// Unlocked, so a caller that goes on to record the association must follow this with
78/// [`lock_and_verify_uploads_are_usable`] inside that transaction.
79pub async fn verify_uploads_belong_to_exercise(
80    conn: &mut PgConnection,
81    exercise_id: Uuid,
82    user_id: Uuid,
83    requested: &[Uuid],
84) -> Result<(), ControllerError> {
85    verify_uploads_are_distinct(requested)?;
86    let recorded = models::exercise_answer_uploads::get_for_exercise_and_user(
87        conn,
88        exercise_id,
89        user_id,
90        requested,
91    )
92    .await?;
93    verify_uploads_are_usable(requested, &recorded)
94}
95
96/// Re-checks the named uploads under a row lock the reaper honours.
97///
98/// Must be called inside the transaction that records the association, and before anything that
99/// depends on the uploads still being live. Repeating the unlocked check is the point: real time
100/// passes between it and the commit, so only a locked re-check can stop a reap from landing in
101/// between and returning 200 for a submission whose files are gone.
102pub async fn lock_and_verify_uploads_are_usable(
103    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
104    exercise_id: Uuid,
105    user_id: Uuid,
106    requested: &[Uuid],
107) -> Result<(), ControllerError> {
108    let locked = models::exercise_answer_uploads::lock_for_exercise_and_user(
109        tx,
110        exercise_id,
111        user_id,
112        requested,
113    )
114    .await?;
115    verify_uploads_are_usable(requested, &locked)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::test_helper::message_key_of;
122
123    #[test]
124    fn a_submission_naming_no_files_is_accepted() {
125        assert!(verify_uploads_are_usable(&[], &[]).is_ok());
126    }
127
128    #[test]
129    fn a_file_answer_must_name_at_least_one_upload() {
130        assert!(verify_answer_names_uploads(&[Uuid::new_v4()]).is_ok());
131        use actix_web::ResponseError;
132        use actix_web::http::StatusCode;
133        let error = verify_answer_names_uploads(&[])
134            .expect_err("a file answer naming nothing must be rejected");
135        assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
136    }
137
138    #[test]
139    fn a_submission_naming_its_own_uploads_is_accepted() {
140        let first = Uuid::new_v4();
141        let second = Uuid::new_v4();
142        let found = vec![
143            AnswerUpload {
144                file_upload_id: second,
145                deleted: false,
146            },
147            AnswerUpload {
148                file_upload_id: first,
149                deleted: false,
150            },
151        ];
152        // Lookup order must not matter; the client's order is what the caller preserves.
153        assert!(verify_uploads_are_usable(&[first, second], &found).is_ok());
154    }
155
156    /// An upload bound to another exercise, or to another user, is not returned by the lookup at
157    /// all, so it must be indistinguishable from an id that was never uploaded.
158    #[test]
159    fn a_submission_naming_a_foreign_upload_is_rejected_as_unknown() {
160        use actix_web::ResponseError;
161        use actix_web::http::StatusCode;
162        let foreign = Uuid::new_v4();
163        let error = verify_uploads_are_usable(&[foreign], &[])
164            .expect_err("an upload not bound to this exercise and user must be rejected");
165        assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
166        assert_eq!(message_key_of(&error), "unknown_upload");
167    }
168
169    /// The reaper soft-deletes precisely so this stays distinguishable from `unknown_upload`: only
170    /// this case is a race a client can recover from by uploading again.
171    #[test]
172    fn a_submission_naming_a_reaped_upload_is_rejected_as_expired() {
173        use actix_web::ResponseError;
174        use actix_web::http::StatusCode;
175        let reaped = Uuid::new_v4();
176        let error = verify_uploads_are_usable(
177            &[reaped],
178            &[AnswerUpload {
179                file_upload_id: reaped,
180                deleted: true,
181            }],
182        )
183        .expect_err("a reaped upload must be rejected");
184        assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
185        assert_eq!(message_key_of(&error), "upload_expired");
186    }
187
188    /// One bad id among good ones must fail the whole submit rather than being dropped, or the
189    /// exercise service would silently grade a partial answer.
190    #[test]
191    fn one_unusable_upload_rejects_the_whole_submission() {
192        let good = Uuid::new_v4();
193        let reaped = Uuid::new_v4();
194        let found = vec![
195            AnswerUpload {
196                file_upload_id: good,
197                deleted: false,
198            },
199            AnswerUpload {
200                file_upload_id: reaped,
201                deleted: true,
202            },
203        ];
204        let error = verify_uploads_are_usable(&[good, reaped], &found).expect_err("must reject");
205        assert_eq!(message_key_of(&error), "upload_expired");
206    }
207
208    #[test]
209    fn a_submission_naming_distinct_uploads_is_accepted() {
210        assert!(verify_uploads_are_distinct(&[]).is_ok());
211        assert!(verify_uploads_are_distinct(&[Uuid::new_v4(), Uuid::new_v4()]).is_ok());
212    }
213
214    /// Deduplicating would record one file twice under a submission and list it twice in a
215    /// download, so a duplicate is reported as the client bug it is.
216    #[test]
217    fn a_submission_naming_the_same_upload_twice_is_rejected() {
218        use actix_web::ResponseError;
219        use actix_web::http::StatusCode;
220        let repeated = Uuid::new_v4();
221        let error = verify_uploads_are_distinct(&[repeated, Uuid::new_v4(), repeated])
222            .expect_err("a repeated upload id must be rejected");
223        assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
224        assert_eq!(message_key_of(&error), "duplicate_upload");
225    }
226}