Skip to main content

headless_lms_server/controllers/exercise_services/
client.rs

1/*!
2Handlers for `/api/v0/exercise-services/client`.
3
4A generic native-client API for exercise services: over plain HTTP it plays the role an
5exercise service's in-browser IFrame plays on the web (download a stub, edit locally,
6submit, poll grading, review old submissions). Specs and answers stay opaque plugin-owned
7blobs the host only forwards.
8
9Which services this API serves is not hardcoded: an exercise service is served exactly when it
10declares `supports_native_client` in its service info. The course/exercise queries filter on that
11capability, and submit rejects a task whose service lacks it.
12*/
13use crate::controllers::helpers::file_uploading;
14use crate::domain::error::{BadRequestReason, bad_request_with_reason};
15use crate::domain::exercise_services::token::UserFromOAuthToken;
16use crate::domain::models_requests::{self, JwtKey};
17use crate::prelude::*;
18use actix_web::FromRequest;
19use exercise_services_api as api;
20use headless_lms_models::exercises::{ActivityProgress, GradingProgress};
21use headless_lms_models::user_exercise_states::UserExerciseState;
22use models::CourseOrExamId;
23use models::chapters::DatabaseChapter;
24use models::exercise_task_submissions::AnswerKind;
25use models::library::grading::{StudentExerciseSlideSubmission, StudentExerciseTaskSubmission};
26use std::collections::HashSet;
27use std::future::{Ready, ready};
28use std::path::Path;
29use utoipa::OpenApi;
30
31#[derive(OpenApi)]
32#[openapi(
33    paths(
34        get_courses,
35        get_course,
36        get_course_exercises,
37        get_course_progress,
38        get_exercise,
39        upload_exercise_files,
40        submit_exercise,
41        get_submission_grading,
42        get_exercise_submissions,
43        download_submission,
44        share_submission
45    ),
46    components(schemas(
47        api::ExerciseSlideSubmission,
48        api::ExerciseSlideSubmissionListItem,
49        api::AnswerFile,
50        api::AnswerKind,
51        api::UploadedFiles,
52        api::SubmissionFiles,
53        api::CourseProgress,
54        api::ExerciseProgress,
55        api::PasteResult,
56        crate::domain::error::ApiErrorResponse
57    ))
58)]
59pub(crate) struct ExerciseServicesClientRoutesApiDoc;
60
61/// Header a client sends to advertise its version, e.g. `0.39.4`.
62const CLIENT_VERSION_HEADER: &str = "X-Client-Version";
63
64/// Object-store namespace for files uploaded through this API. Not a service slug: at upload time
65/// no task is chosen yet, and the prefix carries no authorization meaning.
66const CLIENT_UPLOAD_PATH_PREFIX: &str = "exercise-services-client";
67
68/// Minimum client version the backend accepts. `None` disables the check; a
69/// `"major.minor.patch"` string rejects older clients with `426 Upgrade Required`.
70const MINIMUM_CLIENT_VERSION: Option<&str> = None;
71
72/// Parses a `major.minor.patch` version string into a comparable tuple. Missing
73/// minor/patch components default to `0`; a malformed string returns `None`.
74fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
75    let mut parts = version.trim().split('.');
76    let major = parts.next()?.parse().ok()?;
77    let minor = parts.next().unwrap_or("0").parse().ok()?;
78    let patch = parts.next().unwrap_or("0").parse().ok()?;
79    Some((major, minor, patch))
80}
81
82/// Rejects clients older than `minimum` with `426 Upgrade Required`; a `None` minimum
83/// passes everything. When a minimum is set, a missing or unparseable client version
84/// counts as obsolete.
85fn check_client_version(
86    client_version: Option<&str>,
87    minimum: Option<&str>,
88) -> Result<(), ControllerError> {
89    let Some(minimum) = minimum else {
90        return Ok(());
91    };
92    let minimum_parsed = parse_version(minimum);
93    if let (Some(client), Some(minimum_parsed)) =
94        (client_version.and_then(parse_version), minimum_parsed)
95        && client >= minimum_parsed
96    {
97        return Ok(());
98    }
99    Err(controller_err!(
100        UpgradeRequired,
101        format!("This client is obsolete; the minimum supported version is {minimum}.")
102    ))
103}
104
105/// Slugs of the exercise services this API can serve: exactly those declaring
106/// `supports_native_client`.
107///
108/// Reads the `exercise_service_info` cache, which `service-info-fetcher` refreshes about once a
109/// minute; fetching live would fan every request out to every exercise service. An empty set
110/// therefore usually means that fetcher is down or cold, which the client sees only as empty
111/// course and exercise lists — hence the warning.
112async fn native_client_capable_slugs(conn: &mut PgConnection) -> ModelResult<Vec<String>> {
113    let slugs = models::exercise_services::get_native_client_capable_slugs(conn).await?;
114    if slugs.is_empty() {
115        warn!(
116            "No exercise service declares supports_native_client, so the client API can serve nothing. Check that service-info-fetcher is running."
117        );
118    }
119    Ok(slugs)
120}
121
122/// Filters a slide's tasks down to the ones whose exercise service can serve this client and
123/// converts them to the client shape. Shared by the list and single-exercise views so their
124/// visibility rules cannot drift apart. Model solutions are stripped unless
125/// `reveal_model_solution`; the list view never reveals them.
126fn client_tasks_from_slide(
127    tasks: Vec<models::exercise_tasks::CourseMaterialExerciseTask>,
128    capable_slugs: &[String],
129    reveal_model_solution: bool,
130) -> Vec<api::ExerciseTask> {
131    tasks
132        .into_iter()
133        .filter(|et| capable_slugs.contains(&et.exercise_service_slug))
134        .map(|et| api::ExerciseTask {
135            task_id: et.id,
136            order_number: et.order_number,
137            assignment: et.assignment,
138            public_spec: et.public_spec,
139            model_solution_spec: if reveal_model_solution {
140                et.model_solution_spec
141            } else {
142                None
143            },
144            exercise_service_slug: et.exercise_service_slug,
145        })
146        .collect()
147}
148
149/// Ids of the course's currently open chapters. Shared by the exercise list and progress views
150/// so their visibility rules cannot drift apart.
151async fn open_chapter_ids(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<HashSet<Uuid>> {
152    Ok(models::chapters::get_course_chapters(conn, course_id)
153        .await?
154        .into_iter()
155        .filter(DatabaseChapter::has_opened)
156        .map(|c| c.id)
157        .collect())
158}
159
160/// Extractor guarding every client route: reads `X-Client-Version` and rejects obsolete
161/// clients before the handler runs. Yields no data; its presence applies the check.
162#[derive(Debug)]
163pub struct SupportedClient;
164
165impl FromRequest for SupportedClient {
166    type Error = ControllerError;
167    type Future = Ready<Result<Self, ControllerError>>;
168
169    fn from_request(req: &HttpRequest, _payload: &mut actix_http::Payload) -> Self::Future {
170        let client_version = req
171            .headers()
172            .get(CLIENT_VERSION_HEADER)
173            .and_then(|value| value.to_str().ok())
174            .map(str::to_string);
175        ready(
176            check_client_version(client_version.as_deref(), MINIMUM_CLIENT_VERSION).map(|()| Self),
177        )
178    }
179}
180
181/**
182 * GET /api/v0/exercise-services/client/courses
183 *
184 * Returns the courses that the user is currently enrolled on that contain exercises this
185 * client can be served.
186 */
187#[utoipa::path(
188    get,
189    path = "/courses",
190    operation_id = "getClientCourses",
191    tag = "exercise-services-client",
192    security(("bearer_auth" = [])),
193    params(
194        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
195    ),
196    responses(
197        (status = 200, description = "The courses the user is enrolled on that contain client-servable exercises", body = Vec<api::Course>),
198        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
199        (status = 403, description = "The token lacks the `exercise-services` scope", body = crate::domain::error::ApiErrorResponse),
200        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
201    )
202)]
203#[instrument(skip(pool))]
204async fn get_courses(
205    pool: web::Data<PgPool>,
206    user: UserFromOAuthToken,
207    _client: SupportedClient,
208) -> ControllerResult<web::Json<Vec<api::Course>>> {
209    let mut conn = pool.acquire().await?;
210
211    let capable_slugs = native_client_capable_slugs(&mut conn).await?;
212    let courses =
213        models::course_instances::get_enrolled_course_instances_for_user_with_exercise_types(
214            &mut conn,
215            user.id,
216            &capable_slugs,
217        )
218        .await?
219        .into_iter()
220        .map(|ci| api::Course {
221            id: ci.course_id,
222            slug: ci.course_slug,
223            name: ci.course_name,
224            description: ci.course_description,
225            organization_name: ci.organization_name,
226        })
227        .collect();
228
229    // enrolled users may view their courses regardless of role permissions
230    let token = skip_authorize();
231    token.authorized_ok(web::Json(courses))
232}
233
234/**
235 * GET /api/v0/exercise-services/client/courses/:id
236 *
237 * Returns the course with the given id.
238 */
239#[utoipa::path(
240    get,
241    path = "/courses/{id}",
242    operation_id = "getClientCourse",
243    tag = "exercise-services-client",
244    security(("bearer_auth" = [])),
245    params(
246        ("id" = Uuid, Path, description = "Course id"),
247        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
248    ),
249    responses(
250        (status = 200, description = "The requested course", body = api::Course),
251        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
252        (status = 403, description = "The token lacks the `exercise-services` scope, or the user may not view this course", body = crate::domain::error::ApiErrorResponse),
253        (status = 404, description = "No course with the given id exists", body = crate::domain::error::ApiErrorResponse),
254        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
255    )
256)]
257#[instrument(skip(pool))]
258async fn get_course(
259    pool: web::Data<PgPool>,
260    user: UserFromOAuthToken,
261    course: web::Path<Uuid>,
262    _client: SupportedClient,
263) -> ControllerResult<web::Json<api::Course>> {
264    let mut conn = pool.acquire().await?;
265    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Course(*course)).await?;
266
267    let course = models::courses::get_course(&mut conn, *course).await?;
268    let org = models::organizations::get_organization(&mut conn, course.organization_id).await?;
269    let course = api::Course {
270        id: course.id,
271        slug: course.slug,
272        name: course.name,
273        description: course.description,
274        organization_name: org.name,
275    };
276
277    token.authorized_ok(web::Json(course))
278}
279
280/**
281 * GET /api/v0/exercise-services/client/courses/:id/exercises
282 *
283 * Returns the user's exercise slides for the given course.
284 * Does not return anything for chapters which are not open yet.
285 * Selects slides for exercises with no slide selected yet.
286 * Only returns slides which have tasks whose exercise service can serve this client.
287 */
288#[utoipa::path(
289    get,
290    path = "/courses/{id}/exercises",
291    operation_id = "getClientCourseExercises",
292    tag = "exercise-services-client",
293    security(("bearer_auth" = [])),
294    params(
295        ("id" = Uuid, Path, description = "Course id"),
296        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
297    ),
298    responses(
299        (status = 200, description = "The user's client-servable exercise slides for open chapters", body = Vec<api::ExerciseSlide>),
300        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
301        (status = 403, description = "The token lacks the `exercise-services` scope, or the user may not view this course", body = crate::domain::error::ApiErrorResponse),
302        (status = 404, description = "No course with the given id exists", body = crate::domain::error::ApiErrorResponse),
303        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
304    )
305)]
306#[instrument(skip(pool, file_store, app_conf))]
307async fn get_course_exercises(
308    pool: web::Data<PgPool>,
309    user: UserFromOAuthToken,
310    course: web::Path<Uuid>,
311    file_store: web::Data<dyn FileStore>,
312    app_conf: web::Data<ApplicationConfiguration>,
313    _client: SupportedClient,
314) -> ControllerResult<web::Json<Vec<api::ExerciseSlide>>> {
315    let mut conn = pool.acquire().await?;
316    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Course(*course)).await?;
317
318    let capable_slugs = native_client_capable_slugs(&mut conn).await?;
319    let mut slides = Vec::new();
320    let open_chapter_ids = open_chapter_ids(&mut conn, *course).await?;
321
322    let course = models::courses::get_course(&mut conn, *course).await?;
323    let open_chapter_exercises =
324        models::exercises::get_exercises_by_course_id(&mut conn, course.id)
325            .await?
326            .into_iter()
327            .filter(|e| {
328                e.chapter_id
329                    .map(|ci| open_chapter_ids.contains(&ci))
330                    .unwrap_or_default()
331            });
332    for open_exercise in open_chapter_exercises {
333        let (slide, _) = models::exercises::get_or_select_exercise_slide(
334            &mut conn,
335            Some(user.id),
336            &open_exercise,
337            models_requests::fetch_service_info,
338            file_store.as_ref(),
339            app_conf.as_ref(),
340        )
341        .await?;
342        // The per-user "reveal once solved" gate lives in `get_exercise`, so this list view
343        // never reveals model solutions.
344        let tasks = client_tasks_from_slide(slide.exercise_tasks, &capable_slugs, false);
345        if !tasks.is_empty() {
346            slides.push(api::ExerciseSlide {
347                slide_id: slide.id,
348                exercise_id: open_exercise.id,
349                course_id: course.id,
350                exercise_name: open_exercise.name,
351                exercise_order_number: open_exercise.order_number,
352                deadline: open_exercise.deadline,
353                tasks,
354            });
355        }
356    }
357
358    token.authorized_ok(web::Json(slides))
359}
360
361/// Derives the client-facing per-exercise progress from an exercise's maximum score and
362/// the user's exercise state (absent when the user has never touched the exercise).
363fn derive_exercise_progress(
364    exercise_id: Uuid,
365    score_maximum: i32,
366    state: Option<&UserExerciseState>,
367) -> api::ExerciseProgress {
368    let score_given = state.and_then(|s| s.score_given).unwrap_or(0.0);
369    let activity_progress = state.map(|s| s.activity_progress).unwrap_or_default();
370    api::ExerciseProgress {
371        exercise_id,
372        score_given,
373        score_maximum,
374        completed: activity_progress == ActivityProgress::Completed,
375        attempted: activity_progress != ActivityProgress::Initialized,
376    }
377}
378
379/// Mirrors the project-wide "solved" reveal rule (`controllers/course_material/exercises.rs`,
380/// `domain/exercises.rs`): full points, or the per-slide attempt limit exhausted.
381fn model_solution_should_be_revealed(
382    exercise: &models::exercises::Exercise,
383    score_given: f32,
384    slide_submission_count: i64,
385) -> bool {
386    let has_received_full_points = score_given >= exercise.score_maximum as f32
387        || (score_given - exercise.score_maximum as f32).abs() < 0.0001;
388    let out_of_tries = exercise.limit_number_of_tries
389        && slide_submission_count >= exercise.max_tries_per_slide.unwrap_or(i32::MAX) as i64;
390    has_received_full_points || out_of_tries
391}
392
393/**
394 * GET /api/v0/exercise-services/client/courses/:id/progress
395 *
396 * Returns the current user's progress on every exercise of the course that lives in an
397 * open chapter (the same visibility as `courses/:id/exercises`): its awarded and maximum
398 * points and completed/attempted signals. One round-trip; course totals are derivable by
399 * summing the returned entries.
400 */
401#[utoipa::path(
402    get,
403    path = "/courses/{id}/progress",
404    operation_id = "getClientCourseProgress",
405    tag = "exercise-services-client",
406    security(("bearer_auth" = [])),
407    params(
408        ("id" = Uuid, Path, description = "Course id"),
409        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
410    ),
411    responses(
412        (status = 200, description = "The user's per-exercise progress for the course's open chapters", body = api::CourseProgress),
413        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
414        (status = 403, description = "The token lacks the `exercise-services` scope, or the user may not view this course", body = crate::domain::error::ApiErrorResponse),
415        (status = 404, description = "No course with the given id exists", body = crate::domain::error::ApiErrorResponse),
416        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
417    )
418)]
419#[instrument(skip(pool))]
420async fn get_course_progress(
421    pool: web::Data<PgPool>,
422    user: UserFromOAuthToken,
423    course: web::Path<Uuid>,
424    _client: SupportedClient,
425) -> ControllerResult<web::Json<api::CourseProgress>> {
426    let mut conn = pool.acquire().await?;
427    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Course(*course)).await?;
428
429    let course = models::courses::get_course(&mut conn, *course).await?;
430    let open_chapter_ids = open_chapter_ids(&mut conn, course.id).await?;
431
432    // One read for the whole course instead of a query per exercise.
433    let states = models::user_exercise_states::get_all_for_user_and_course_or_exam(
434        &mut conn,
435        user.id,
436        CourseOrExamId::Course(course.id),
437    )
438    .await?;
439    let mut state_by_exercise = std::collections::HashMap::new();
440    for state in &states {
441        state_by_exercise.insert(state.exercise_id, state);
442    }
443
444    let exercises = models::exercises::get_exercises_by_course_id(&mut conn, course.id)
445        .await?
446        .into_iter()
447        .filter(|e| {
448            e.chapter_id
449                .map(|ci| open_chapter_ids.contains(&ci))
450                .unwrap_or_default()
451        })
452        .map(|e| {
453            derive_exercise_progress(e.id, e.score_maximum, state_by_exercise.get(&e.id).copied())
454        })
455        .collect();
456
457    token.authorized_ok(web::Json(api::CourseProgress {
458        course_id: course.id,
459        exercises,
460    }))
461}
462
463/**
464 * GET /api/v0/exercise-services/client/exercises/:id
465 *
466 * Returns an exercise slide for the user for the given exercise.
467 */
468#[utoipa::path(
469    get,
470    path = "/exercises/{id}",
471    operation_id = "getClientExercise",
472    tag = "exercise-services-client",
473    security(("bearer_auth" = [])),
474    params(
475        ("id" = Uuid, Path, description = "Exercise id"),
476        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
477    ),
478    responses(
479        (status = 200, description = "An exercise slide for the user, carrying only the tasks whose exercise service can serve this client", body = api::ExerciseSlide),
480        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
481        (status = 403, description = "The token lacks the `exercise-services` scope, or the user may not view this exercise", body = crate::domain::error::ApiErrorResponse),
482        (status = 404, description = "No exercise with the given id exists, it belongs to an exam (not served by this API), or no task of it can serve this client", body = crate::domain::error::ApiErrorResponse),
483        (status = 422, description = "The user is not enrolled to this exercise's course (message_key `not_enrolled`)", body = crate::domain::error::ApiErrorResponse),
484        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
485    )
486)]
487#[instrument(skip(pool, file_store, app_conf))]
488async fn get_exercise(
489    pool: web::Data<PgPool>,
490    user: UserFromOAuthToken,
491    exercise_id: web::Path<Uuid>,
492    file_store: web::Data<dyn FileStore>,
493    app_conf: web::Data<ApplicationConfiguration>,
494    _client: SupportedClient,
495) -> ControllerResult<web::Json<api::ExerciseSlide>> {
496    let mut conn = pool.acquire().await?;
497    let token = authorize(
498        &mut conn,
499        Act::View,
500        Some(user.id),
501        Res::Exercise(*exercise_id),
502    )
503    .await?;
504
505    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
506    let (exercise_slide, course_or_exam_id) = models::exercises::get_or_select_exercise_slide(
507        &mut conn,
508        Some(user.id),
509        &exercise,
510        models_requests::fetch_service_info,
511        file_store.as_ref(),
512        app_conf.as_ref(),
513    )
514    .await?;
515    let course_id = match course_or_exam_id {
516        Some(CourseOrExamId::Course(course_id)) => course_id,
517        Some(CourseOrExamId::Exam(_)) => {
518            // Exam exercises are out of scope for this API; report not found rather than
519            // misdescribing it as an enrollment problem.
520            return Err(controller_err!(
521                NotFound,
522                "This exercise belongs to an exam, which the client API does not serve".to_string()
523            ));
524        }
525        None => {
526            return Err(bad_request_with_reason(
527                BadRequestReason::NotEnrolled,
528                "User is not enrolled to this exercise's course".to_string(),
529            ));
530        }
531    };
532
533    let user_exercise_state = models::user_exercise_states::get_user_exercise_state_if_exists(
534        &mut conn,
535        user.id,
536        exercise.id,
537        CourseOrExamId::Course(course_id),
538    )
539    .await?;
540    let score_given = user_exercise_state
541        .and_then(|s| s.score_given)
542        .unwrap_or(0.0);
543    let slide_submission_counts =
544        models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
545            &mut conn,
546            exercise.id,
547            CourseOrExamId::Course(course_id),
548            user.id,
549        )
550        .await?;
551    let slide_submission_count = slide_submission_counts
552        .get(&exercise_slide.id)
553        .copied()
554        .unwrap_or(0);
555    let reveal_model_solution =
556        model_solution_should_be_revealed(&exercise, score_given, slide_submission_count);
557
558    // Without the capability filter this endpoint would hand out task ids belonging to services
559    // that cannot serve a native client, which submit would then have to reject.
560    let capable_slugs = native_client_capable_slugs(&mut conn).await?;
561    let tasks = client_tasks_from_slide(
562        exercise_slide.exercise_tasks,
563        &capable_slugs,
564        reveal_model_solution,
565    );
566    if tasks.is_empty() {
567        return Err(controller_err!(
568            NotFound,
569            "No task of this exercise can be served to this client".to_string()
570        ));
571    }
572
573    token.authorized_ok(web::Json(api::ExerciseSlide {
574        slide_id: exercise_slide.id,
575        exercise_id: exercise.id,
576        course_id,
577        exercise_name: exercise.name,
578        exercise_order_number: exercise.order_number,
579        deadline: exercise.deadline,
580        tasks,
581    }))
582}
583
584/// Rejects a user who is not enrolled on the exercise's course.
585///
586/// The shared submit domain fn reports a not-enrolled user as 401 Unauthorized, but the editor
587/// client treats 401 as an invalid token and deletes the stored credentials, logging the student
588/// out on their first submit. 422 `not_enrolled` lets the client prompt for enrollment instead.
589async fn verify_enrolled(
590    conn: &mut PgConnection,
591    user_id: Uuid,
592    course_id: Uuid,
593) -> Result<(), ControllerError> {
594    if models::user_course_settings::get_user_course_settings_by_course_id(conn, user_id, course_id)
595        .await?
596        .is_some()
597    {
598        return Ok(());
599    }
600    Err(bad_request_with_reason(
601        BadRequestReason::NotEnrolled,
602        "User is not enrolled to this exercise's course".to_string(),
603    ))
604}
605
606/// The URL path authorizes `exercise_id`; the slide/task ids come from the request body,
607/// so without this check a caller could submit into an unrelated exercise's slide/task.
608fn verify_slide_and_task_belong(
609    exercise_id: Uuid,
610    slide_id: Uuid,
611    slide_exercise_id: Uuid,
612    task_id: Uuid,
613    task_slide_id: Uuid,
614) -> Result<(), ControllerError> {
615    if slide_exercise_id != exercise_id {
616        return Err(controller_err!(
617            BadRequest,
618            format!("Exercise slide {slide_id} does not belong to exercise {exercise_id}")
619        ));
620    }
621    if task_slide_id != slide_id {
622        return Err(controller_err!(
623            BadRequest,
624            format!("Exercise task {task_id} does not belong to exercise slide {slide_id}")
625        ));
626    }
627    Ok(())
628}
629
630/// Rejects access to a submission owned by a different user.
631fn verify_submission_owner(
632    submission_user_id: Uuid,
633    user_id: Uuid,
634    forbidden_message: String,
635) -> Result<(), ControllerError> {
636    if submission_user_id != user_id {
637        return Err(controller_err!(Forbidden, forbidden_message));
638    }
639    Ok(())
640}
641
642/// Rejects a submission to a task whose exercise service cannot serve a native client.
643///
644/// The slide/task ids come from the request body, so without this a caller holding any task id
645/// could persist an answer to a service that will never understand it, and grading would fail
646/// deep inside that service instead of at the edge.
647fn verify_task_is_client_capable(
648    task_id: Uuid,
649    exercise_type: &str,
650    capable_slugs: &[String],
651) -> Result<(), ControllerError> {
652    if capable_slugs.iter().any(|slug| slug == exercise_type) {
653        return Ok(());
654    }
655    Err(controller_err!(
656        BadRequest,
657        format!(
658            "Exercise task {task_id} belongs to the exercise service '{exercise_type}', which cannot be served to this client"
659        )
660    ))
661}
662
663/**
664 * POST /api/v0/exercise-services/client/exercises/:id/files
665 *
666 * Stores files for a later submission to this exercise. Every multipart field name must be a
667 * UUID the client picks; the host assigns the ids a submit request then names. Uploads are
668 * bound to this exercise and user, and unreferenced ones are reaped, so a client should upload
669 * immediately before submitting.
670 *
671 * Gated on the caller being able to answer the exercise at all, so stored objects cannot be
672 * accumulated past a deadline or a closed exam. The per-slide try limit is only checked across the
673 * whole exercise here, because this route is bound to an exercise rather than a slide.
674 */
675#[utoipa::path(
676    post,
677    path = "/exercises/{id}/files",
678    operation_id = "uploadClientExerciseFiles",
679    tag = "exercise-services-client",
680    security(("bearer_auth" = [])),
681    params(
682        ("id" = Uuid, Path, description = "Exercise id"),
683        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
684    ),
685    request_body(
686        content = String,
687        content_type = "multipart/form-data",
688        description = "One file part per file, each field name a distinct client-chosen UUID and each part carrying a file name"
689    ),
690    responses(
691        (status = 200, description = "The stored files, in the order the parts were sent", body = api::UploadedFiles),
692        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
693        (status = 403, description = "The token lacks the `exercise-services` scope, or the user may not view this exercise", body = crate::domain::error::ApiErrorResponse),
694        (status = 404, description = "No exercise with the given id exists", body = crate::domain::error::ApiErrorResponse),
695        (status = 422, description = "The user is not enrolled to this exercise's course (message_key `not_enrolled`), the exercise can no longer be answered because its deadline has passed or every slide is out of tries, or the multipart body violates the field-name, file-count or size rules", body = crate::domain::error::ApiErrorResponse),
696        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
697    )
698)]
699#[instrument(skip(pool, file_store, payload, app_conf))]
700async fn upload_exercise_files(
701    pool: web::Data<PgPool>,
702    file_store: web::Data<dyn FileStore>,
703    exercise_id: web::Path<Uuid>,
704    payload: Multipart,
705    user: UserFromOAuthToken,
706    app_conf: web::Data<ApplicationConfiguration>,
707    _client: SupportedClient,
708) -> ControllerResult<web::Json<api::UploadedFiles>> {
709    let mut conn = pool.acquire().await?;
710    let token = authorize(
711        &mut conn,
712        Act::View,
713        Some(user.id),
714        Res::Exercise(*exercise_id),
715    )
716    .await?;
717
718    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
719    let course_id = exercise
720        .course_id
721        .ok_or_else(|| anyhow::anyhow!("Cannot upload files for non-course exercises"))?;
722    verify_enrolled(&mut conn, user.id, course_id).await?;
723    domain::exercises::verify_user_can_answer_exercise(&mut conn, user.id, &exercise).await?;
724
725    let mut cleanup = file_uploading::UploadCleanup::new(file_store.clone());
726    let stored = store_client_uploads(
727        &mut conn,
728        exercise.id,
729        user.id,
730        payload,
731        file_store.as_ref(),
732        &mut cleanup.uploaded_paths,
733        &app_conf.base_url,
734    )
735    .await;
736    let uploads = match stored {
737        Ok(uploads) => uploads,
738        Err(error) => {
739            // A commit failure inside `store_client_uploads` surfaces here too: up to 100 MiB is
740            // already in the store with no `file_uploads` row, so nothing the reaper can find.
741            cleanup.clean_up().await;
742            return Err(error);
743        }
744    };
745    cleanup.disarm();
746
747    let data_files = uploads
748        .into_iter()
749        .map(|upload| api::AnswerFile {
750            id: upload.entry.id,
751            name: upload.name,
752            mime: upload.mime,
753            size_bytes: Some(upload.size_bytes),
754            // These files are not part of an answer yet; a submit decides their order.
755            order_number: None,
756            url: upload.entry.url,
757        })
758        .collect();
759    token.authorized_ok(web::Json(api::UploadedFiles { data_files }))
760}
761
762/// Stores the multipart parts and binds them to the exercise and user, so that a failure to
763/// record the binding cannot leave uploads the reaper is unable to find.
764///
765/// The transaction opens only after the last byte has been streamed: the multipart body has no
766/// time limit, so opening it first would pin a pool connection `idle in transaction` for the whole
767/// upload.
768async fn store_client_uploads(
769    conn: &mut PgConnection,
770    exercise_id: Uuid,
771    user_id: Uuid,
772    payload: Multipart,
773    file_store: &dyn FileStore,
774    uploaded_paths: &mut Vec<file_uploading::ExerciseServiceUploadCleanup>,
775    base_url: &str,
776) -> Result<Vec<file_uploading::ExerciseServiceUpload>, ControllerError> {
777    let streamed = file_uploading::stream_exercise_service_upload(
778        CLIENT_UPLOAD_PATH_PREFIX,
779        payload,
780        file_store,
781        uploaded_paths,
782        base_url,
783    )
784    .await?;
785
786    let mut tx = conn.begin().await?;
787    let uploads =
788        file_uploading::record_exercise_service_upload(&mut tx, streamed, Some(user_id)).await?;
789    let file_upload_ids: Vec<Uuid> = uploads.iter().map(|u| u.entry.id).collect();
790    models::exercise_answer_uploads::insert_many(
791        &mut tx,
792        exercise_id,
793        user_id,
794        &file_upload_ids,
795        models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
796    )
797    .await?;
798    tx.commit().await?;
799    Ok(uploads)
800}
801
802/**
803 * POST /api/v0/exercise-services/client/exercises/:id/submit
804 *
805 * Accepts an exercise submission from the user. A `file` answer names files previously stored
806 * through this exercise's `files` endpoint, in the order they are to be graded; those files are the
807 * answer. An answer that names no files is a JSON answer, carried in `data_json`.
808 */
809#[utoipa::path(
810    post,
811    path = "/exercises/{id}/submit",
812    operation_id = "submitClientExercise",
813    tag = "exercise-services-client",
814    security(("bearer_auth" = [])),
815    params(
816        ("id" = Uuid, Path, description = "Exercise id"),
817        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
818    ),
819    request_body(content = api::ExerciseSlideSubmission, description = "The slide and task being answered, and the answer: its JSON, the ids of the files it consists of, or both"),
820    responses(
821        (status = 200, description = "The created submission, identified by both its task and slide submission ids", body = api::ExerciseTaskSubmissionResult),
822        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
823        (status = 403, description = "The token lacks the `exercise-services` scope, or the user may not view this exercise", body = crate::domain::error::ApiErrorResponse),
824        (status = 404, description = "No exercise with the given id exists, or the referenced slide/task does not exist", body = crate::domain::error::ApiErrorResponse),
825        (status = 422, description = "The user is not enrolled to this exercise's course (message_key `not_enrolled`), the referenced slide/task belongs to another exercise, the task's exercise service cannot be served to this client, a `file` answer names no files or a `json` one names files, or a named upload was reaped (`upload_expired`), was never uploaded for this exercise by this user (`unknown_upload`) or was named more than once (`duplicate_upload`)", body = crate::domain::error::ApiErrorResponse),
826        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
827    )
828)]
829#[allow(clippy::too_many_arguments)]
830async fn submit_exercise(
831    pool: web::Data<PgPool>,
832    file_store: web::Data<dyn FileStore>,
833    jwt_key: web::Data<JwtKey>,
834    exercise_id: web::Path<Uuid>,
835    submission: web::Json<api::ExerciseSlideSubmission>,
836    user: UserFromOAuthToken,
837    app_conf: web::Data<ApplicationConfiguration>,
838    _client: SupportedClient,
839) -> ControllerResult<web::Json<api::ExerciseTaskSubmissionResult>> {
840    let mut conn = pool.acquire().await?;
841    let token = authorize(
842        &mut conn,
843        Act::View,
844        Some(user.id),
845        Res::Exercise(*exercise_id),
846    )
847    .await?;
848
849    let submission = submission.into_inner();
850    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
851    let course_id = exercise
852        .course_id
853        .ok_or_else(|| anyhow::anyhow!("Cannot answer non-course exercises"))?;
854    verify_enrolled(&mut conn, user.id, course_id).await?;
855    let exercise_slide =
856        models::exercise_slides::get_exercise_slide(&mut conn, submission.exercise_slide_id)
857            .await?;
858    let exercise_task =
859        models::exercise_tasks::get_exercise_task_by_id(&mut conn, submission.exercise_task_id)
860            .await?;
861
862    verify_slide_and_task_belong(
863        exercise.id,
864        exercise_slide.id,
865        exercise_slide.exercise_id,
866        exercise_task.id,
867        exercise_task.exercise_slide_id,
868    )?;
869    let capable_slugs = native_client_capable_slugs(&mut conn).await?;
870    verify_task_is_client_capable(
871        exercise_task.id,
872        &exercise_task.exercise_type,
873        &capable_slugs,
874    )?;
875
876    let result = domain::exercises::process_submission(
877        &mut conn,
878        user.id,
879        exercise,
880        &StudentExerciseSlideSubmission {
881            exercise_slide_id: submission.exercise_slide_id,
882            exercise_task_submissions: vec![StudentExerciseTaskSubmission {
883                exercise_task_id: submission.exercise_task_id,
884                answer_kind: submission.answer_kind.map(model_answer_kind),
885                data_json: submission.data_json,
886                data_files: submission.data_files,
887            }],
888        },
889        jwt_key.into_inner(),
890        file_store.as_ref(),
891        app_conf.as_ref(),
892    )
893    .await?;
894
895    // one task submission in, so exactly one result out
896    let task_submission = result
897        .exercise_task_submission_results
898        .into_iter()
899        .next()
900        .ok_or_else(|| {
901            controller_err!(
902                InternalServerError,
903                "Failed to find exercise task submission id".to_string()
904            )
905        })?;
906
907    token.authorized_ok(web::Json(api::ExerciseTaskSubmissionResult {
908        task_submission_id: task_submission.submission.id,
909        slide_submission_id: task_submission.submission.exercise_slide_submission_id,
910    }))
911}
912
913/**
914 * GET /api/v0/exercise-services/client/submissions/:id/grading
915 *
916 * Returns the grading status of the given submission.
917 */
918#[utoipa::path(
919    get,
920    path = "/submissions/{id}/grading",
921    operation_id = "getClientSubmissionGrading",
922    tag = "exercise-services-client",
923    security(("bearer_auth" = [])),
924    params(
925        ("id" = Uuid, Path, description = "Submission id"),
926        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
927    ),
928    responses(
929        (status = 200, description = "The grading status of the submission", body = api::ExerciseTaskSubmissionStatus),
930        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
931        (status = 403, description = "Cannot view another user's submission grading", body = crate::domain::error::ApiErrorResponse),
932        (status = 404, description = "No submission with the given id exists", body = crate::domain::error::ApiErrorResponse),
933        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
934    )
935)]
936#[instrument(skip(pool, file_store, app_conf))]
937async fn get_submission_grading(
938    pool: web::Data<PgPool>,
939    submission_id: web::Path<Uuid>,
940    user: UserFromOAuthToken,
941    file_store: web::Data<dyn FileStore>,
942    app_conf: web::Data<ApplicationConfiguration>,
943    _client: SupportedClient,
944) -> ControllerResult<web::Json<api::ExerciseTaskSubmissionStatus>> {
945    let mut conn = pool.acquire().await?;
946    let submission = models::exercise_task_submissions::get_by_id(
947        &mut conn,
948        *submission_id,
949        file_store.as_ref(),
950        app_conf.as_ref(),
951    )
952    .await?;
953    let slide_submission = models::exercise_slide_submissions::get_by_id(
954        &mut conn,
955        submission.exercise_slide_submission_id,
956    )
957    .await?;
958    verify_submission_owner(
959        slide_submission.user_id,
960        user.id,
961        "Cannot view another user's submission grading".to_string(),
962    )?;
963    let token = skip_authorize();
964
965    let grading = models::exercise_task_gradings::get_by_exercise_task_submission_id(
966        &mut conn,
967        *submission_id,
968    )
969    .await?;
970    let status = match grading {
971        Some(grading) => api::ExerciseTaskSubmissionStatus::Grading {
972            grading_progress: map_grading_progress(grading.grading_progress),
973            score_given: grading.score_given,
974            grading_started_at: grading.grading_started_at,
975            grading_completed_at: grading.grading_completed_at,
976            feedback_json: grading.feedback_json,
977            feedback_text: grading.feedback_text,
978        },
979        None => api::ExerciseTaskSubmissionStatus::NoGradingYet,
980    };
981    token.authorized_ok(web::Json(status))
982}
983
984/// Maps the internal grading-progress enum to the exercise-services-api one.
985fn map_grading_progress(progress: GradingProgress) -> api::GradingProgress {
986    match progress {
987        GradingProgress::Failed => api::GradingProgress::Failed,
988        GradingProgress::NotReady => api::GradingProgress::NotReady,
989        GradingProgress::PendingManual => api::GradingProgress::PendingManual,
990        GradingProgress::Pending => api::GradingProgress::Pending,
991        GradingProgress::FullyGraded => api::GradingProgress::FullyGraded,
992    }
993}
994
995/**
996 * GET /api/v0/exercise-services/client/exercises/:id/submissions
997 *
998 * Returns the current user's past submissions to the given exercise, newest
999 * first, each annotated with its grading score and progress if graded.
1000 */
1001#[utoipa::path(
1002    get,
1003    path = "/exercises/{id}/submissions",
1004    operation_id = "getClientExerciseSubmissions",
1005    tag = "exercise-services-client",
1006    security(("bearer_auth" = [])),
1007    params(
1008        ("id" = Uuid, Path, description = "Exercise id"),
1009        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
1010    ),
1011    responses(
1012        (status = 200, description = "The current user's submissions to the exercise, newest first", body = Vec<api::ExerciseSlideSubmissionListItem>),
1013        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
1014        (status = 403, description = "The token lacks the `exercise-services` scope", body = crate::domain::error::ApiErrorResponse),
1015        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
1016    )
1017)]
1018#[instrument(skip(pool))]
1019async fn get_exercise_submissions(
1020    pool: web::Data<PgPool>,
1021    exercise_id: web::Path<Uuid>,
1022    user: UserFromOAuthToken,
1023    _client: SupportedClient,
1024) -> ControllerResult<web::Json<Vec<api::ExerciseSlideSubmissionListItem>>> {
1025    let mut conn = pool.acquire().await?;
1026    // The query is scoped to the current user, so no further authorization is needed.
1027    let token = skip_authorize();
1028
1029    // One query for the whole listing, gradings joined in: an editor slide has a single task, so
1030    // its grading is the slide's grading.
1031    let submissions =
1032        models::exercise_slide_submissions::get_users_submissions_for_exercise_with_gradings(
1033            &mut conn,
1034            user.id,
1035            *exercise_id,
1036        )
1037        .await?;
1038
1039    let items = submissions
1040        .into_iter()
1041        .map(|submission| api::ExerciseSlideSubmissionListItem {
1042            id: submission.id,
1043            exercise_id: submission.exercise_id,
1044            created_at: submission.created_at,
1045            score_given: submission.score_given,
1046            grading_progress: submission.grading_progress.map(map_grading_progress),
1047        })
1048        .collect();
1049
1050    token.authorized_ok(web::Json(items))
1051}
1052
1053/**
1054 * GET /api/v0/exercise-services/client/submissions/:id/download
1055 *
1056 * Resolves an exercise-slide submission (by the id returned from the submissions list, or by
1057 * a submit's `slide_submission_id`) to the files it was made from, so the client can restore
1058 * an old submission.
1059 */
1060#[utoipa::path(
1061    get,
1062    path = "/submissions/{id}/download",
1063    operation_id = "downloadClientSubmission",
1064    tag = "exercise-services-client",
1065    security(("bearer_auth" = [])),
1066    params(
1067        ("id" = Uuid, Path, description = "Exercise-slide-submission id"),
1068        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
1069    ),
1070    responses(
1071        (status = 200, description = "The files the submission was made from, in the order they were recorded; the same shape whether the submission came from a native client or the service's IFrame", body = api::SubmissionFiles),
1072        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
1073        (status = 403, description = "Cannot download another user's submission", body = crate::domain::error::ApiErrorResponse),
1074        (status = 404, description = "No submission with the given id exists", body = crate::domain::error::ApiErrorResponse),
1075        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
1076    )
1077)]
1078#[instrument(skip(pool, file_store, app_conf))]
1079async fn download_submission(
1080    pool: web::Data<PgPool>,
1081    file_store: web::Data<dyn FileStore>,
1082    submission_id: web::Path<Uuid>,
1083    user: UserFromOAuthToken,
1084    app_conf: web::Data<ApplicationConfiguration>,
1085    _client: SupportedClient,
1086) -> ControllerResult<web::Json<api::SubmissionFiles>> {
1087    let mut conn = pool.acquire().await?;
1088    let slide_submission =
1089        models::exercise_slide_submissions::get_by_id(&mut conn, *submission_id).await?;
1090    verify_submission_owner(
1091        slide_submission.user_id,
1092        user.id,
1093        "Cannot download another user's submission".to_string(),
1094    )?;
1095    let token = skip_authorize();
1096
1097    let task_submissions = models::exercise_task_submissions::get_by_exercise_slide_submission_id(
1098        &mut conn,
1099        *submission_id,
1100        file_store.as_ref(),
1101        app_conf.as_ref(),
1102    )
1103    .await?;
1104    // Resolved from the host's own file records, never from the exercise service's answer: the
1105    // answer is an opaque plugin-owned blob and reading it would tie this endpoint to one plugin's
1106    // shape. Both native-client and IFrame-made file answers are recorded here at submit time, so
1107    // both origins are served by this one read.
1108    let task_submission_ids: Vec<Uuid> = task_submissions.iter().map(|ts| ts.id).collect();
1109    let files = models::exercise_task_submission_files::get_by_task_submission_ids(
1110        &mut conn,
1111        &task_submission_ids,
1112    )
1113    .await?;
1114
1115    token.authorized_ok(web::Json(submission_files_response(
1116        files,
1117        file_store.as_ref(),
1118        app_conf.as_ref(),
1119    )))
1120}
1121
1122/// The client API's answer kind as the model's. Two enums rather than one because the client crate
1123/// stays free of server-internal dependencies.
1124fn model_answer_kind(kind: api::AnswerKind) -> AnswerKind {
1125    match kind {
1126        api::AnswerKind::Json => AnswerKind::Json,
1127        api::AnswerKind::File => AnswerKind::File,
1128    }
1129}
1130
1131/// Turns the host's own file records into the download response. Every tracked file is reachable,
1132/// not just the first, so a multi-file submission is fully restorable.
1133fn submission_files_response(
1134    files: Vec<models::exercise_task_submission_files::SubmissionFile>,
1135    file_store: &dyn FileStore,
1136    app_conf: &ApplicationConfiguration,
1137) -> api::SubmissionFiles {
1138    api::SubmissionFiles {
1139        data_files: files
1140            .into_iter()
1141            .map(|file| api::AnswerFile {
1142                id: file.file_upload_id,
1143                name: file.name,
1144                mime: file.mime,
1145                size_bytes: file.size_bytes,
1146                order_number: Some(file.order_number),
1147                url: file_store.get_download_url(Path::new(&file.path), app_conf),
1148            })
1149            .collect(),
1150    }
1151}
1152
1153/**
1154 * POST /api/v0/exercise-services/client/submissions/:id/share
1155 *
1156 * Mints a shareable link to an existing submission of the current user and returns
1157 * its URL.
1158 */
1159#[utoipa::path(
1160    post,
1161    path = "/submissions/{id}/share",
1162    operation_id = "shareClientSubmission",
1163    tag = "exercise-services-client",
1164    security(("bearer_auth" = [])),
1165    params(
1166        ("id" = Uuid, Path, description = "Exercise-slide-submission id"),
1167        ("X-Client-Version" = Option<String>, Header, description = "Optional client version; obsolete clients get 426")
1168    ),
1169    responses(
1170        (status = 200, description = "The shareable URL for the submission", body = api::PasteResult),
1171        (status = 401, description = "The bearer token is missing or was rejected", body = crate::domain::error::ApiErrorResponse),
1172        (status = 403, description = "Cannot share another user's submission", body = crate::domain::error::ApiErrorResponse),
1173        (status = 404, description = "No submission with the given id exists", body = crate::domain::error::ApiErrorResponse),
1174        (status = 426, description = "The client is obsolete and must be upgraded", body = crate::domain::error::ApiErrorResponse)
1175    )
1176)]
1177#[instrument(skip(pool, app_conf))]
1178async fn share_submission(
1179    pool: web::Data<PgPool>,
1180    submission_id: web::Path<Uuid>,
1181    user: UserFromOAuthToken,
1182    app_conf: web::Data<ApplicationConfiguration>,
1183    _client: SupportedClient,
1184) -> ControllerResult<web::Json<api::PasteResult>> {
1185    let mut conn = pool.acquire().await?;
1186    let slide_submission =
1187        models::exercise_slide_submissions::get_by_id(&mut conn, *submission_id).await?;
1188    verify_submission_owner(
1189        slide_submission.user_id,
1190        user.id,
1191        "Cannot share another user's submission".to_string(),
1192    )?;
1193    let token = skip_authorize();
1194
1195    let share = domain::exercise_services::submission_sharing::share_submission(
1196        &mut conn,
1197        *submission_id,
1198        user.id,
1199    )
1200    .await?;
1201    let paste_url = format!(
1202        "{}/shared-submissions/{}",
1203        app_conf.base_url.trim_end_matches('/'),
1204        share.id
1205    );
1206
1207    token.authorized_ok(web::Json(api::PasteResult { paste_url }))
1208}
1209
1210pub fn _add_routes(cfg: &mut ServiceConfig) {
1211    cfg.route("/courses", web::get().to(get_courses))
1212        .route("/courses/{id}", web::get().to(get_course))
1213        .route(
1214            "/courses/{id}/exercises",
1215            web::get().to(get_course_exercises),
1216        )
1217        .route("/courses/{id}/progress", web::get().to(get_course_progress))
1218        .route("/exercises/{id}", web::get().to(get_exercise))
1219        .route(
1220            "/exercises/{id}/files",
1221            web::post().to(upload_exercise_files),
1222        )
1223        .route("/exercises/{id}/submit", web::post().to(submit_exercise))
1224        .route(
1225            "/exercises/{id}/submissions",
1226            web::get().to(get_exercise_submissions),
1227        )
1228        .route(
1229            "/submissions/{id}/grading",
1230            web::get().to(get_submission_grading),
1231        )
1232        .route(
1233            "/submissions/{id}/download",
1234            web::get().to(download_submission),
1235        )
1236        .route("/submissions/{id}/share", web::post().to(share_submission));
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    use chrono::Utc;
1244    use headless_lms_models::user_exercise_states::ReviewingStage;
1245
1246    fn state_with(
1247        score_given: Option<f32>,
1248        activity_progress: ActivityProgress,
1249    ) -> UserExerciseState {
1250        let now = Utc::now();
1251        UserExerciseState {
1252            id: Uuid::new_v4(),
1253            user_id: Uuid::new_v4(),
1254            exercise_id: Uuid::new_v4(),
1255            course_id: Some(Uuid::new_v4()),
1256            exam_id: None,
1257            created_at: now,
1258            updated_at: now,
1259            deleted_at: None,
1260            score_given,
1261            grading_progress: GradingProgress::FullyGraded,
1262            activity_progress,
1263            reviewing_stage: ReviewingStage::NotStarted,
1264            selected_exercise_slide_id: None,
1265        }
1266    }
1267
1268    #[test]
1269    fn progress_without_state_is_zero_and_untouched() {
1270        let p = derive_exercise_progress(Uuid::nil(), 5, None);
1271        assert_eq!(p.score_given, 0.0);
1272        assert_eq!(p.score_maximum, 5);
1273        assert!(!p.completed);
1274        assert!(!p.attempted);
1275    }
1276
1277    #[test]
1278    fn progress_started_is_attempted_not_completed() {
1279        let state = state_with(Some(0.0), ActivityProgress::Started);
1280        let p = derive_exercise_progress(Uuid::nil(), 5, Some(&state));
1281        assert!(p.attempted);
1282        assert!(!p.completed);
1283    }
1284
1285    #[test]
1286    fn progress_completed_reports_points_and_flags() {
1287        let state = state_with(Some(5.0), ActivityProgress::Completed);
1288        let p = derive_exercise_progress(Uuid::nil(), 5, Some(&state));
1289        assert_eq!(p.score_given, 5.0);
1290        assert!(p.completed);
1291        assert!(p.attempted);
1292    }
1293
1294    #[test]
1295    fn progress_initialized_state_is_not_attempted() {
1296        let state = state_with(None, ActivityProgress::Initialized);
1297        let p = derive_exercise_progress(Uuid::nil(), 5, Some(&state));
1298        assert_eq!(p.score_given, 0.0);
1299        assert!(!p.attempted);
1300        assert!(!p.completed);
1301    }
1302
1303    #[test]
1304    fn version_check_is_disabled_when_no_minimum() {
1305        assert!(check_client_version(None, None).is_ok());
1306        assert!(check_client_version(Some("0.1.0"), None).is_ok());
1307        assert!(check_client_version(Some("garbage"), None).is_ok());
1308    }
1309
1310    #[test]
1311    fn version_check_accepts_equal_and_newer_clients() {
1312        assert!(check_client_version(Some("0.39.4"), Some("0.39.4")).is_ok());
1313        assert!(check_client_version(Some("0.39.5"), Some("0.39.4")).is_ok());
1314        assert!(check_client_version(Some("1.0.0"), Some("0.39.4")).is_ok());
1315    }
1316
1317    #[test]
1318    fn version_check_rejects_older_missing_or_malformed_clients() {
1319        assert!(check_client_version(Some("0.39.3"), Some("0.39.4")).is_err());
1320        assert!(check_client_version(None, Some("0.39.4")).is_err());
1321        assert!(check_client_version(Some("not-a-version"), Some("0.39.4")).is_err());
1322    }
1323
1324    #[test]
1325    fn parse_version_defaults_missing_components_to_zero() {
1326        assert_eq!(parse_version("1"), Some((1, 0, 0)));
1327        assert_eq!(parse_version("1.2"), Some((1, 2, 0)));
1328        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
1329        assert_eq!(parse_version("x"), None);
1330    }
1331
1332    // submit_exercise builds this exact error for a not-enrolled user; assert it produces the
1333    // same 422 `not_enrolled` shape get_exercise returns, so the client never sees a 401 here.
1334    #[test]
1335    fn not_enrolled_submit_error_maps_to_422_not_enrolled() {
1336        use actix_web::ResponseError;
1337        use actix_web::http::StatusCode;
1338        use futures_util::FutureExt;
1339
1340        let err = controller_err!(
1341            BadRequestWithReason(BadRequestReason::NotEnrolled),
1342            "User is not enrolled to this exercise's course".to_string()
1343        );
1344        let response = err.error_response();
1345        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1346
1347        let bytes = actix_web::body::to_bytes(response.into_body())
1348            .now_or_never()
1349            .expect("response should resolve immediately")
1350            .expect("body bytes");
1351        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1352        assert_eq!(value["type"], "validation_error");
1353        assert_eq!(value["message_key"], "not_enrolled");
1354    }
1355
1356    fn exercise_with(
1357        score_maximum: i32,
1358        limit_number_of_tries: bool,
1359        max_tries_per_slide: Option<i32>,
1360    ) -> models::exercises::Exercise {
1361        let now = Utc::now();
1362        models::exercises::Exercise {
1363            id: Uuid::new_v4(),
1364            created_at: now,
1365            updated_at: now,
1366            name: "Test exercise".to_string(),
1367            course_id: Some(Uuid::new_v4()),
1368            exam_id: None,
1369            page_id: Uuid::new_v4(),
1370            chapter_id: None,
1371            deadline: None,
1372            deleted_at: None,
1373            score_maximum,
1374            order_number: 0,
1375            copied_from: None,
1376            max_tries_per_slide,
1377            limit_number_of_tries,
1378            needs_peer_review: false,
1379            needs_self_review: false,
1380            use_course_default_peer_or_self_review_config: false,
1381            exercise_language_group_id: None,
1382            teacher_reviews_answer_after_locking: false,
1383        }
1384    }
1385
1386    #[test]
1387    fn model_solution_hidden_until_full_points() {
1388        // Unlimited tries; the only route to "solved" is full points.
1389        let ex = exercise_with(5, false, None);
1390        assert!(!model_solution_should_be_revealed(&ex, 0.0, 3));
1391        assert!(!model_solution_should_be_revealed(&ex, 4.0, 99));
1392        // Full points -> reveal, even with tries remaining.
1393        assert!(model_solution_should_be_revealed(&ex, 5.0, 0));
1394        // Floating-point full points still count via the epsilon comparison.
1395        assert!(model_solution_should_be_revealed(&ex, 4.99995, 0));
1396    }
1397
1398    #[test]
1399    fn model_solution_revealed_when_out_of_tries() {
1400        // Limited to 3 tries per slide.
1401        let ex = exercise_with(5, true, Some(3));
1402        // Fewer submissions than the limit and no points -> still hidden.
1403        assert!(!model_solution_should_be_revealed(&ex, 0.0, 2));
1404        // Attempt limit reached with no points -> solved by exhaustion, reveal.
1405        assert!(model_solution_should_be_revealed(&ex, 0.0, 3));
1406        assert!(model_solution_should_be_revealed(&ex, 0.0, 4));
1407    }
1408
1409    #[test]
1410    fn out_of_tries_ignored_when_limit_disabled() {
1411        // limit_number_of_tries = false: the attempt count must never mark it solved.
1412        let ex = exercise_with(5, false, Some(3));
1413        assert!(!model_solution_should_be_revealed(&ex, 0.0, 100));
1414    }
1415
1416    /// The submit body is deserialized by `web::Json`, so a malformed one fails at the serde
1417    /// boundary before the handler runs. Pin that contract: both ids are required and must be
1418    /// UUIDs. The answer fields are all optional -- a body naming no files is a JSON answer, and a
1419    /// `file` answer naming none is rejected by the submit path, not by serde.
1420    #[test]
1421    fn malformed_submission_body_fails_to_deserialize() {
1422        serde_json::from_value::<api::ExerciseSlideSubmission>(serde_json::json!({
1423            "exercise_slide_id": Uuid::new_v4(),
1424            "exercise_task_id": Uuid::new_v4(),
1425            "answer_kind": "file",
1426            "data_files": [Uuid::new_v4()],
1427        }))
1428        .expect("a well-formed submission body deserializes");
1429
1430        let json_answer =
1431            serde_json::from_value::<api::ExerciseSlideSubmission>(serde_json::json!({
1432                "exercise_slide_id": Uuid::new_v4(),
1433                "exercise_task_id": Uuid::new_v4(),
1434            }))
1435            .expect("a body without answer fields deserializes as a json answer");
1436        assert!(json_answer.answer_kind.is_none());
1437        assert!(json_answer.data_files.is_none());
1438
1439        // Missing exercise_task_id.
1440        assert!(
1441            serde_json::from_value::<api::ExerciseSlideSubmission>(serde_json::json!({
1442                "exercise_slide_id": Uuid::new_v4(),
1443                "data_files": [],
1444            }))
1445            .is_err()
1446        );
1447        // Ids must be UUIDs, not arbitrary strings or numbers.
1448        assert!(
1449            serde_json::from_value::<api::ExerciseSlideSubmission>(serde_json::json!({
1450                "exercise_slide_id": "not-a-uuid",
1451                "exercise_task_id": Uuid::new_v4(),
1452                "data_files": [],
1453            }))
1454            .is_err()
1455        );
1456        assert!(
1457            serde_json::from_value::<api::ExerciseSlideSubmission>(serde_json::json!({
1458                "exercise_slide_id": Uuid::new_v4(),
1459                "exercise_task_id": Uuid::new_v4(),
1460                "data_files": ["not-a-uuid"],
1461            }))
1462            .is_err()
1463        );
1464        // A completely unrelated body.
1465        assert!(
1466            serde_json::from_value::<api::ExerciseSlideSubmission>(serde_json::json!("nonsense"))
1467                .is_err()
1468        );
1469    }
1470
1471    fn capable_slugs() -> Vec<String> {
1472        vec!["tmc".to_string(), "other-native".to_string()]
1473    }
1474
1475    #[test]
1476    fn submit_accepts_a_task_whose_service_is_capable() {
1477        let slugs = capable_slugs();
1478        assert!(verify_task_is_client_capable(Uuid::new_v4(), "tmc", &slugs).is_ok());
1479        // Genericity: capability is not the `tmc` slug, so any declaring service passes.
1480        assert!(verify_task_is_client_capable(Uuid::new_v4(), "other-native", &slugs).is_ok());
1481    }
1482
1483    /// Closes a real hole: before this check a caller holding any task id could submit to a
1484    /// service that would never understand the resulting answer, and the junk submission was
1485    /// persisted before grading failed inside that service.
1486    #[test]
1487    fn submit_rejects_a_task_whose_service_is_not_capable() {
1488        use actix_web::ResponseError;
1489        use actix_web::http::StatusCode;
1490        let task_id = Uuid::new_v4();
1491        let err = verify_task_is_client_capable(task_id, "quizzes", &capable_slugs())
1492            .expect_err("a non-capable exercise service must be rejected");
1493        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1494        assert!(err.to_string().contains("quizzes"), "{err}");
1495    }
1496
1497    /// A cold or failing `service-info-fetcher` leaves the capable set empty; nothing may be
1498    /// submittable then, rather than everything.
1499    #[test]
1500    fn submit_rejects_every_task_when_nothing_is_capable() {
1501        assert!(verify_task_is_client_capable(Uuid::new_v4(), "tmc", &[]).is_err());
1502    }
1503
1504    pub(super) fn task_with(slug: &str) -> models::exercise_tasks::CourseMaterialExerciseTask {
1505        models::exercise_tasks::CourseMaterialExerciseTask {
1506            id: Uuid::new_v4(),
1507            exercise_service_slug: slug.to_string(),
1508            exercise_slide_id: Uuid::new_v4(),
1509            exercise_iframe_url: None,
1510            pseudonumous_user_id: None,
1511            assignment: serde_json::json!([]),
1512            public_spec: Some(serde_json::json!({ "spec": slug })),
1513            model_solution_spec: Some(serde_json::json!({ "solution": slug })),
1514            previous_submission: None,
1515            previous_submission_grading: None,
1516            order_number: 0,
1517            deleted_at: None,
1518        }
1519    }
1520
1521    #[test]
1522    fn only_capable_tasks_are_visible_to_the_client() {
1523        let tasks = vec![
1524            task_with("tmc"),
1525            task_with("quizzes"),
1526            task_with("other-native"),
1527        ];
1528        let visible = client_tasks_from_slide(tasks, &capable_slugs(), false);
1529        let slugs: Vec<&str> = visible
1530            .iter()
1531            .map(|t| t.exercise_service_slug.as_str())
1532            .collect();
1533        assert_eq!(slugs, vec!["tmc", "other-native"]);
1534    }
1535
1536    #[test]
1537    fn no_task_is_visible_when_nothing_is_capable() {
1538        let visible = client_tasks_from_slide(vec![task_with("tmc")], &[], false);
1539        assert!(visible.is_empty());
1540    }
1541
1542    #[test]
1543    fn model_solutions_are_stripped_unless_revealed() {
1544        let hidden = client_tasks_from_slide(vec![task_with("tmc")], &capable_slugs(), false);
1545        assert!(hidden[0].model_solution_spec.is_none());
1546        let revealed = client_tasks_from_slide(vec![task_with("tmc")], &capable_slugs(), true);
1547        assert!(revealed[0].model_solution_spec.is_some());
1548        // The public spec is unaffected by the reveal gate.
1549        assert!(hidden[0].public_spec.is_some());
1550    }
1551
1552    #[test]
1553    fn verify_slide_and_task_belong_accepts_matching_ids() {
1554        let exercise_id = Uuid::new_v4();
1555        let slide_id = Uuid::new_v4();
1556        let task_id = Uuid::new_v4();
1557        assert!(
1558            verify_slide_and_task_belong(exercise_id, slide_id, exercise_id, task_id, slide_id)
1559                .is_ok()
1560        );
1561    }
1562
1563    #[test]
1564    fn verify_slide_and_task_belong_rejects_foreign_slide() {
1565        use actix_web::ResponseError;
1566        use actix_web::http::StatusCode;
1567        let exercise_id = Uuid::new_v4();
1568        let slide_id = Uuid::new_v4();
1569        let task_id = Uuid::new_v4();
1570        // Slide claims to belong to a different exercise than the one in the URL path.
1571        let other_exercise = Uuid::new_v4();
1572        let err =
1573            verify_slide_and_task_belong(exercise_id, slide_id, other_exercise, task_id, slide_id)
1574                .expect_err("a slide from another exercise must be rejected");
1575        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1576    }
1577
1578    #[test]
1579    fn verify_slide_and_task_belong_rejects_foreign_task() {
1580        let exercise_id = Uuid::new_v4();
1581        let slide_id = Uuid::new_v4();
1582        let task_id = Uuid::new_v4();
1583        // Task belongs to a slide other than the submitted one.
1584        let other_slide = Uuid::new_v4();
1585        assert!(
1586            verify_slide_and_task_belong(exercise_id, slide_id, exercise_id, task_id, other_slide)
1587                .is_err()
1588        );
1589    }
1590}
1591
1592#[cfg(test)]
1593mod upload_tests {
1594    use super::*;
1595    use crate::domain::exercise_services::answer_uploads;
1596    use crate::test_helper::*;
1597    use actix_web::http::header::{CONTENT_TYPE, HeaderMap};
1598    use headless_lms_base::config::{
1599        ApplicationConfiguration, OAuthServerConfiguration, SuotarConfiguration,
1600    };
1601    use models::exercise_slide_submissions::NewExerciseSlideSubmission;
1602    use models::exercise_task_gradings::UserPointsUpdateStrategy;
1603    use secrecy::SecretString;
1604
1605    const BOUNDARY: &str = "clientuploadboundary";
1606
1607    pub(super) fn app_conf() -> ApplicationConfiguration {
1608        ApplicationConfiguration {
1609            base_url: "http://project-331.local".to_string(),
1610            test_mode: true,
1611            test_chatbot: false,
1612            test_sisu: false,
1613            test_suotar: false,
1614            disable_embedding_vector_creation_when_seeding: false,
1615            suotar_configuration: SuotarConfiguration::mock_conf("http://project-331.local")
1616                .expect("Failed to build the mock Suotar configuration"),
1617            development_uuid_login: false,
1618            enable_admin_email_verification: false,
1619            enable_email_ownership_verification: false,
1620            azure_configuration: None,
1621            tmc_account_creation_origin: None,
1622            tmc_admin_access_token: SecretString::new("mock".to_string().into()),
1623            oauth_server_configuration: OAuthServerConfiguration {
1624                rsa_public_key: "unused".into(),
1625                rsa_private_key: SecretString::new("unused".into()),
1626                oauth_token_hmac_key: SecretString::new("pippuri".into()),
1627                dpop_nonce_key: std::sync::Arc::new(secrecy::SecretBox::new(Box::new(
1628                    "unused".into(),
1629                ))),
1630            },
1631        }
1632    }
1633
1634    /// A multipart body in the shape the client sends: one part per file, each field name a UUID.
1635    fn multipart(parts: &[(Uuid, &str, &str)]) -> Multipart {
1636        let mut body = String::new();
1637        for (field_name, file_name, contents) in parts {
1638            body.push_str(&format!("--{BOUNDARY}\r\n"));
1639            body.push_str(&format!(
1640                "Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{file_name}\"\r\n"
1641            ));
1642            body.push_str("Content-Type: application/octet-stream\r\n\r\n");
1643            body.push_str(contents);
1644            body.push_str("\r\n");
1645        }
1646        body.push_str(&format!("--{BOUNDARY}--\r\n"));
1647
1648        let mut headers = HeaderMap::new();
1649        headers.insert(
1650            CONTENT_TYPE,
1651            format!("multipart/form-data; boundary={BOUNDARY}")
1652                .parse()
1653                .expect("valid content type"),
1654        );
1655        Multipart::new(
1656            &headers,
1657            futures::stream::once(async move {
1658                Ok::<_, actix_web::error::PayloadError>(actix_web::web::Bytes::from(body))
1659            }),
1660        )
1661    }
1662
1663    async fn insert_task_submission(
1664        conn: &mut PgConnection,
1665        course_id: Uuid,
1666        user_id: Uuid,
1667        exercise_id: Uuid,
1668        slide_id: Uuid,
1669        task_id: Uuid,
1670    ) -> Uuid {
1671        let slide_submission =
1672            models::exercise_slide_submissions::insert_exercise_slide_submission(
1673                conn,
1674                NewExerciseSlideSubmission {
1675                    exercise_slide_id: slide_id,
1676                    course_id: Some(course_id),
1677                    exam_id: None,
1678                    user_id,
1679                    exercise_id,
1680                    user_points_update_strategy:
1681                        UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1682                },
1683            )
1684            .await
1685            .expect("slide submission");
1686        models::exercise_task_submissions::insert(
1687            conn,
1688            models::PKeyPolicy::Generate,
1689            slide_submission.id,
1690            slide_id,
1691            task_id,
1692            &models::library::grading::SubmittedAnswer::Json {
1693                data: serde_json::json!({ "opaque": "plugin owned" }),
1694            },
1695        )
1696        .await
1697        .expect("task submission")
1698    }
1699
1700    /// The upload route's core: parts land in the file store, get `file_uploads` rows, and are
1701    /// bound to the exercise and user so a later submit can name them.
1702    #[actix_web::test]
1703    async fn the_files_route_stores_and_binds_every_part() {
1704        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
1705        let store = temp_file_store();
1706        let first = Uuid::new_v4();
1707        let second = Uuid::new_v4();
1708        let mut uploaded_paths = Vec::new();
1709
1710        let uploads = store_client_uploads(
1711            tx.as_mut(),
1712            exercise,
1713            user,
1714            multipart(&[(first, "a.tar.zst", "first"), (second, "b.txt", "second")]),
1715            &store,
1716            &mut uploaded_paths,
1717            "http://project-331.local",
1718        )
1719        .await
1720        .expect("the upload succeeds");
1721
1722        assert_eq!(
1723            uploads.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(),
1724            vec!["a.tar.zst", "b.txt"]
1725        );
1726        // The ids the client submits with are the host's, not the field names it chose.
1727        assert!(
1728            uploads
1729                .iter()
1730                .all(|u| u.entry.id != first && u.entry.id != second)
1731        );
1732        assert!(
1733            uploads
1734                .iter()
1735                .all(|u| u.entry.url.contains(CLIENT_UPLOAD_PATH_PREFIX))
1736        );
1737
1738        let ids: Vec<Uuid> = uploads.iter().map(|u| u.entry.id).collect();
1739        assert_eq!(
1740            models::file_uploads::get_many(tx.as_mut(), &ids)
1741                .await
1742                .expect("file uploads")
1743                .len(),
1744            2
1745        );
1746        assert!(
1747            answer_uploads::verify_uploads_belong_to_exercise(tx.as_mut(), exercise, user, &ids)
1748                .await
1749                .is_ok()
1750        );
1751        tx.rollback().await;
1752    }
1753
1754    /// Every object that reached the store is recorded for cleanup, which is what the upload
1755    /// route's error arm — including a commit failure — deletes.
1756    #[actix_web::test]
1757    async fn every_stored_object_is_recorded_for_cleanup() {
1758        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
1759        let store = temp_file_store();
1760        let mut uploaded_paths = Vec::new();
1761
1762        let uploads = store_client_uploads(
1763            tx.as_mut(),
1764            exercise,
1765            user,
1766            multipart(&[
1767                (Uuid::new_v4(), "a.tar.zst", "first"),
1768                (Uuid::new_v4(), "b.txt", "second"),
1769            ]),
1770            &store,
1771            &mut uploaded_paths,
1772            "http://project-331.local",
1773        )
1774        .await
1775        .expect("the upload succeeds");
1776
1777        let recorded: Vec<&str> = uploaded_paths.iter().map(|p| p.path.as_str()).collect();
1778        assert_eq!(recorded.len(), uploads.len());
1779        let ids: Vec<Uuid> = uploads.iter().map(|u| u.entry.id).collect();
1780        for file in models::file_uploads::get_many(tx.as_mut(), &ids)
1781            .await
1782            .expect("file uploads")
1783        {
1784            assert!(
1785                recorded.contains(&file.path.as_str()),
1786                "the object at {} would be leaked on a cleanup",
1787                file.path
1788            );
1789        }
1790        tx.rollback().await;
1791    }
1792
1793    /// Both the upload and the submit route gate on enrolment before touching anything else.
1794    #[actix_web::test]
1795    async fn only_an_enrolled_user_may_upload_or_submit() {
1796        insert_data!(:tx, user: user, :org, course: course, instance: instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
1797
1798        let err = verify_enrolled(tx.as_mut(), user, course)
1799            .await
1800            .expect_err("a user who never enrolled must be refused");
1801        assert_eq!(message_key_of(&err), "not_enrolled");
1802
1803        models::course_instance_enrollments::insert_enrollment_and_set_as_current(
1804            tx.as_mut(),
1805            models::course_instance_enrollments::NewCourseInstanceEnrollment {
1806                course_id: course,
1807                user_id: user,
1808                course_instance_id: instance.id,
1809            },
1810        )
1811        .await
1812        .expect("enrollment");
1813
1814        verify_enrolled(tx.as_mut(), user, course)
1815            .await
1816            .expect("an enrolled user is let through");
1817        tx.rollback().await;
1818    }
1819
1820    /// The capability gate end to end, from the service-info column to the task filter the routes
1821    /// apply: only a service that declares native-client support is offered to a client.
1822    #[actix_web::test]
1823    async fn only_a_service_declaring_native_client_support_is_visible_to_the_client() {
1824        insert_data!(:tx);
1825        let mut slugs_of = Vec::new();
1826        for declares in [true, false] {
1827            let slug = format!("gate-test-{}", Uuid::new_v4());
1828            let service = models::exercise_services::insert_exercise_service(
1829                tx.as_mut(),
1830                &models::exercise_services::ExerciseServiceNewOrUpdate {
1831                    name: slug.clone(),
1832                    slug: slug.clone(),
1833                    public_url: "http://example.com/api/service".to_string(),
1834                    internal_url: None,
1835                    max_reprocessing_submissions_at_once: 1,
1836                },
1837            )
1838            .await
1839            .expect("exercise service");
1840            models::exercise_service_info::insert(
1841                tx.as_mut(),
1842                &models::exercise_service_info::PathInfo {
1843                    exercise_service_id: service.id,
1844                    user_interface_iframe_path: "/iframe".to_string(),
1845                    grade_endpoint_path: "/grade".to_string(),
1846                    public_spec_endpoint_path: "/public-spec".to_string(),
1847                    model_solution_spec_endpoint_path: "/model-solution".to_string(),
1848                    has_custom_view: false,
1849                    supports_native_client: declares,
1850                    produces_file_answers: false,
1851                    declares_spec_files: false,
1852                },
1853            )
1854            .await
1855            .expect("service info");
1856            slugs_of.push(slug);
1857        }
1858
1859        let capable = native_client_capable_slugs(tx.as_mut())
1860            .await
1861            .expect("capable slugs");
1862        let visible = client_tasks_from_slide(
1863            slugs_of.iter().map(|slug| tests::task_with(slug)).collect(),
1864            &capable,
1865            false,
1866        );
1867        assert_eq!(
1868            visible
1869                .iter()
1870                .map(|task| task.exercise_service_slug.as_str())
1871                .collect::<Vec<_>>(),
1872            vec![slugs_of[0].as_str()],
1873            "only the declaring service may be offered to a client"
1874        );
1875        tx.rollback().await;
1876    }
1877
1878    /// An upload bound to one exercise must not be usable by another, or a client could replay
1879    /// any of the user's uploads into any exercise's submission.
1880    #[actix_web::test]
1881    async fn a_submit_naming_another_exercises_upload_is_rejected() {
1882        insert_data!(:tx, user: user, :org, course: course, instance: _instance, :course_module, chapter: chapter, page: page, :exercise, :slide, task: _task);
1883        let other_exercise = models::exercises::insert(
1884            tx.as_mut(),
1885            models::PKeyPolicy::Generate,
1886            course,
1887            "Other",
1888            page,
1889            chapter,
1890            1,
1891        )
1892        .await
1893        .expect("other exercise");
1894        let file_id = models::file_uploads::insert(
1895            tx.as_mut(),
1896            "a.tar.zst",
1897            "exercise-services-client/a",
1898            "application/octet-stream",
1899            Some(user),
1900            None,
1901        )
1902        .await
1903        .expect("file upload");
1904        models::exercise_answer_uploads::insert_many(
1905            tx.as_mut(),
1906            exercise,
1907            user,
1908            &[file_id],
1909            models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
1910        )
1911        .await
1912        .expect("binding");
1913
1914        assert!(
1915            answer_uploads::verify_uploads_belong_to_exercise(
1916                tx.as_mut(),
1917                exercise,
1918                user,
1919                &[file_id]
1920            )
1921            .await
1922            .is_ok()
1923        );
1924        let error = answer_uploads::verify_uploads_belong_to_exercise(
1925            tx.as_mut(),
1926            other_exercise,
1927            user,
1928            &[file_id],
1929        )
1930        .await
1931        .expect_err("another exercise must not be able to name this upload");
1932        assert_eq!(message_key_of(&error), "unknown_upload");
1933        tx.rollback().await;
1934    }
1935
1936    #[actix_web::test]
1937    async fn a_submit_naming_an_unrecorded_id_is_rejected_as_unknown() {
1938        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
1939        let error = answer_uploads::verify_uploads_belong_to_exercise(
1940            tx.as_mut(),
1941            exercise,
1942            user,
1943            &[Uuid::new_v4()],
1944        )
1945        .await
1946        .expect_err("an id the host never issued must be rejected");
1947        assert_eq!(message_key_of(&error), "unknown_upload");
1948        tx.rollback().await;
1949    }
1950
1951    /// The reaper soft-deletes so that this stays distinguishable from `unknown_upload`. Assert
1952    /// the whole chain from the row's `deleted_at` to the wire key, not just the pure check.
1953    #[actix_web::test]
1954    async fn a_submit_naming_a_reaped_upload_is_rejected_as_expired() {
1955        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, task: _task);
1956        let file_id = models::file_uploads::insert(
1957            tx.as_mut(),
1958            "a.tar.zst",
1959            "exercise-services-client/a",
1960            "application/octet-stream",
1961            Some(user),
1962            None,
1963        )
1964        .await
1965        .expect("file upload");
1966        models::exercise_answer_uploads::insert_many(
1967            tx.as_mut(),
1968            exercise,
1969            user,
1970            &[file_id],
1971            models::exercise_answer_uploads::AnswerUploadOrigin::NativeClient,
1972        )
1973        .await
1974        .expect("binding");
1975        models::exercise_answer_uploads::delete_by_file_upload_id(tx.as_mut(), file_id)
1976            .await
1977            .expect("soft delete");
1978
1979        let error = answer_uploads::verify_uploads_belong_to_exercise(
1980            tx.as_mut(),
1981            exercise,
1982            user,
1983            &[file_id],
1984        )
1985        .await
1986        .expect_err("a reaped upload must be rejected");
1987        assert_eq!(message_key_of(&error), "upload_expired");
1988        tx.rollback().await;
1989    }
1990
1991    /// The old contract could only ever expose `files[0]`. Every file of a submission must be
1992    /// reachable, in the order the client uploaded them.
1993    #[actix_web::test]
1994    async fn download_serves_every_file_of_a_multi_file_submission() {
1995        insert_data!(:tx, user: user, :org, course: course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
1996        let submission_id =
1997            insert_task_submission(tx.as_mut(), course, user, exercise, slide, task).await;
1998        let mut ids = Vec::new();
1999        for name in ["first.txt", "second.txt", "third.txt"] {
2000            ids.push(
2001                models::file_uploads::insert(
2002                    tx.as_mut(),
2003                    name,
2004                    &format!("exercise-services-client/{name}"),
2005                    "application/octet-stream",
2006                    Some(user),
2007                    None,
2008                )
2009                .await
2010                .expect("file upload"),
2011            );
2012        }
2013        models::exercise_task_submission_files::insert_many(tx.as_mut(), submission_id, &ids)
2014            .await
2015            .expect("associations");
2016
2017        let store = temp_file_store();
2018        let files = models::exercise_task_submission_files::get_by_task_submission_ids(
2019            tx.as_mut(),
2020            &[submission_id],
2021        )
2022        .await
2023        .expect("submission files");
2024        let response = submission_files_response(files, &store, &app_conf());
2025
2026        assert_eq!(
2027            response
2028                .data_files
2029                .iter()
2030                .map(|f| (f.id, f.name.as_str(), f.url.as_str()))
2031                .collect::<Vec<_>>(),
2032            vec![
2033                (
2034                    ids[0],
2035                    "first.txt",
2036                    "http://project-331.local/api/v0/files/exercise-services-client/first.txt"
2037                ),
2038                (
2039                    ids[1],
2040                    "second.txt",
2041                    "http://project-331.local/api/v0/files/exercise-services-client/second.txt"
2042                ),
2043                (
2044                    ids[2],
2045                    "third.txt",
2046                    "http://project-331.local/api/v0/files/exercise-services-client/third.txt"
2047                ),
2048            ]
2049        );
2050        tx.rollback().await;
2051    }
2052
2053    /// A submission whose answer needed no files is legitimate; download must report an empty
2054    /// list rather than the 404 the single-archive contract had to return.
2055    #[test]
2056    fn download_reports_an_empty_list_rather_than_failing() {
2057        let store = temp_file_store();
2058        let response = submission_files_response(Vec::new(), &store, &app_conf());
2059        assert!(response.data_files.is_empty());
2060    }
2061
2062    /// A submission and the record of which files it was made from land in one transaction. This is
2063    /// the property that buys: if the association fails, no submission is left behind for
2064    /// `download_submission` to be unable to serve. (`process_submission` itself needs a live
2065    /// exercise service, so the transaction shape is exercised here rather than the handler.)
2066    #[actix_web::test]
2067    async fn a_failing_file_association_leaves_no_submission() {
2068        insert_data!(:tx, user: user, :org, course: course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
2069        let submission_id;
2070        {
2071            let mut submit_tx = tx.begin().await;
2072            submission_id =
2073                insert_task_submission(submit_tx.as_mut(), course, user, exercise, slide, task)
2074                    .await;
2075            models::exercise_task_submission_files::insert_many(
2076                submit_tx.as_mut(),
2077                submission_id,
2078                &[Uuid::new_v4()],
2079            )
2080            .await
2081            .expect_err("an id with no file_uploads row violates the foreign key");
2082            submit_tx.rollback().await;
2083        }
2084
2085        assert!(
2086            models::exercise_task_submissions::get_by_id(
2087                tx.as_mut(),
2088                submission_id,
2089                &crate::test_helper::init_file_store(),
2090                &crate::test_helper::init_app_conf().expect("app conf"),
2091            )
2092            .await
2093            .is_err(),
2094            "the submission must not survive a failed association"
2095        );
2096        tx.rollback().await;
2097    }
2098}
2099
2100/// Route-level tests: the two write routes driven through actix, so the extractors, the
2101/// authorization call, the multipart parsing and the error mapping are all in the picture. The
2102/// helper-level tests above cover the individual checks; these cover the wiring, which is where a
2103/// handler stops matching its own OpenAPI annotations.
2104///
2105/// Fixtures are committed, because the handlers acquire their own pool connections and cannot see
2106/// an open transaction. Nothing left behind is reapable (every client upload here is fresh, so it
2107/// is inside the retention window), which is what keeps the reaper's unfiltered tests unaffected.
2108#[cfg(test)]
2109mod route_tests {
2110    use super::*;
2111    use crate::test_helper::*;
2112    use actix_web::http::StatusCode;
2113    use actix_web::{App, test};
2114    use chrono::Duration as ChronoDuration;
2115    use chrono::Utc;
2116    use headless_lms_models::library::oauth::pkce::PkceMethod;
2117    use headless_lms_models::library::oauth::{
2118        EXERCISE_SERVICES_SCOPE, GrantTypeName, generate_access_token, token_digest_sha256,
2119    };
2120    use headless_lms_models::oauth_access_token::{
2121        NewAccessTokenParams, OAuthAccessToken, TokenType,
2122    };
2123    use headless_lms_models::oauth_client::{
2124        ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod,
2125    };
2126    use headless_lms_utils::cache::Cache;
2127    use headless_lms_utils::file_store::FileStore;
2128    use models::exercise_task_gradings::ExerciseTaskGradingResult;
2129    use sqlx::Connection;
2130    use std::sync::{Arc, Mutex};
2131
2132    const BOUNDARY: &str = "clientrouteboundary";
2133
2134    /// Everything a request needs: the ids the routes are called with, and a bearer the
2135    /// `UserFromOAuthToken` extractor accepts.
2136    struct Fixture {
2137        user: Uuid,
2138        course: Uuid,
2139        exercise: Uuid,
2140        slide: Uuid,
2141        task: Uuid,
2142        /// A task of the fixture exercise whose exercise service does not declare
2143        /// `supports_native_client`, so this API cannot serve it.
2144        unservable_task: Uuid,
2145        token: String,
2146    }
2147
2148    /// A bearer for `user`, taking the real `oauth_access_tokens` path rather than the test-token
2149    /// shortcut, which only maps to seeded users this crate's tests do not have.
2150    async fn issue_token(conn: &mut PgConnection, user: Uuid) -> String {
2151        let client = OAuthClient::insert(
2152            conn,
2153            NewClientParams {
2154                client_id: &format!("cli-{}", &generate_access_token()[..12]),
2155                client_name: "Client API route test client",
2156                application_type: ApplicationType::Native,
2157                token_endpoint_auth_method: TokenEndpointAuthMethod::None,
2158                client_secret: None,
2159                client_secret_expires_at: None,
2160                redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
2161                post_logout_redirect_uris: None,
2162                allowed_grant_types: &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
2163                scopes: &[EXERCISE_SERVICES_SCOPE.to_string()],
2164                require_pkce: true,
2165                pkce_methods_allowed: &[PkceMethod::S256],
2166                allowed_origins: None,
2167                bearer_allowed: true,
2168            },
2169        )
2170        .await
2171        .expect("oauth client");
2172        let plaintext = generate_access_token();
2173        let hmac_key = upload_tests::app_conf()
2174            .oauth_server_configuration
2175            .oauth_token_hmac_key
2176            .clone();
2177        OAuthAccessToken::insert(
2178            conn,
2179            NewAccessTokenParams {
2180                digest: &token_digest_sha256(&plaintext, &hmac_key),
2181                user_id: Some(user),
2182                client_id: client.id,
2183                scopes: &[EXERCISE_SERVICES_SCOPE.to_string()],
2184                audience: None,
2185                token_type: TokenType::Bearer,
2186                dpop_jkt: None,
2187                metadata: serde_json::Map::new(),
2188                expires_at: Utc::now() + ChronoDuration::hours(1),
2189            },
2190        )
2191        .await
2192        .expect("access token");
2193        plaintext
2194    }
2195
2196    /// Registers a client-capable exercise service under a slug of its own, and a task of that
2197    /// type. A unique slug keeps these committed rows from being mistaken for anyone else's: the
2198    /// capability query is global, and the other tests that read it assert by membership.
2199    async fn insert_client_capable_task(
2200        conn: &mut PgConnection,
2201        slide: Uuid,
2202        internal_url: Option<String>,
2203    ) -> Uuid {
2204        let slug = format!("client-route-test-{}", Uuid::new_v4());
2205        let service = models::exercise_services::insert_exercise_service(
2206            conn,
2207            &models::exercise_services::ExerciseServiceNewOrUpdate {
2208                name: slug.clone(),
2209                slug: slug.clone(),
2210                public_url: "http://example.com/api/service".to_string(),
2211                internal_url,
2212                max_reprocessing_submissions_at_once: 1,
2213            },
2214        )
2215        .await
2216        .expect("exercise service");
2217        models::exercise_service_info::insert(
2218            conn,
2219            &models::exercise_service_info::PathInfo {
2220                exercise_service_id: service.id,
2221                user_interface_iframe_path: "/iframe".to_string(),
2222                grade_endpoint_path: "/grade".to_string(),
2223                public_spec_endpoint_path: "/public-spec".to_string(),
2224                model_solution_spec_endpoint_path: "/model-solution".to_string(),
2225                has_custom_view: false,
2226                supports_native_client: true,
2227                produces_file_answers: false,
2228                declares_spec_files: false,
2229            },
2230        )
2231        .await
2232        .expect("service info");
2233        models::exercise_tasks::insert(
2234            conn,
2235            models::PKeyPolicy::Generate,
2236            models::exercise_tasks::NewExerciseTask {
2237                exercise_slide_id: slide,
2238                exercise_type: slug,
2239                assignment: vec![],
2240                public_spec: Some(serde_json::Value::Null),
2241                private_spec: Some(serde_json::Value::Null),
2242                model_solution_spec: Some(serde_json::Value::Null),
2243                order_number: 1,
2244            },
2245        )
2246        .await
2247        .expect("exercise task")
2248    }
2249
2250    /// A committed course with one exercise, and a user who is enrolled unless `enrolled` is false.
2251    /// The exercise carries two tasks: the fixture macro's, whose exercise service cannot serve
2252    /// this client, and `task`, whose service can.
2253    async fn committed_fixture(enrolled: bool) -> Fixture {
2254        committed_fixture_with_service(enrolled, None).await
2255    }
2256
2257    async fn committed_fixture_with_service(
2258        enrolled: bool,
2259        service_internal_url: Option<String>,
2260    ) -> Fixture {
2261        insert_data!(:tx, user: user, :org, course: course, instance: instance, :course_module, :chapter, :page, exercise: exercise, slide: slide, task: _unservable_task);
2262        let task = insert_client_capable_task(tx.as_mut(), slide, service_internal_url).await;
2263        if enrolled {
2264            models::course_instance_enrollments::insert_enrollment_and_set_as_current(
2265                tx.as_mut(),
2266                models::course_instance_enrollments::NewCourseInstanceEnrollment {
2267                    course_id: course,
2268                    user_id: user,
2269                    course_instance_id: instance.id,
2270                },
2271            )
2272            .await
2273            .expect("enrollment");
2274        }
2275        let token = issue_token(tx.as_mut(), user).await;
2276        tx.commit().await;
2277        Fixture {
2278            user,
2279            course,
2280            exercise,
2281            slide,
2282            task,
2283            unservable_task: _unservable_task,
2284            token,
2285        }
2286    }
2287
2288    /// The routes under a real actix app, with the app data every extractor and handler reads.
2289    macro_rules! client_api_app {
2290        () => {{
2291            let file_store: Arc<dyn FileStore> = Arc::new(temp_file_store());
2292            client_api_app!(file_store)
2293        }};
2294        ($file_store:expr) => {{
2295            let pool = PgPool::connect(&test_database_url()).await.expect("pool");
2296            let file_store: Arc<dyn FileStore> = $file_store;
2297            test::init_service(
2298                App::new()
2299                    .app_data(web::Data::new(pool))
2300                    .app_data(web::Data::from(file_store))
2301                    .app_data(web::Data::new(upload_tests::app_conf()))
2302                    .app_data(web::Data::new(
2303                        Cache::new("redis://127.0.0.1:1").expect("cache"),
2304                    ))
2305                    .app_data(web::Data::new(JwtKey::test_key()))
2306                    .configure(_add_routes),
2307            )
2308            .await
2309        }};
2310    }
2311
2312    fn multipart_body(parts: &[(Uuid, &str, &str)]) -> Vec<u8> {
2313        let mut body = String::new();
2314        for (field_name, file_name, contents) in parts {
2315            body.push_str(&format!("--{BOUNDARY}\r\n"));
2316            body.push_str(&format!(
2317                "Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{file_name}\"\r\n"
2318            ));
2319            body.push_str("Content-Type: application/octet-stream\r\n\r\n");
2320            body.push_str(contents);
2321            body.push_str("\r\n");
2322        }
2323        body.push_str(&format!("--{BOUNDARY}--\r\n"));
2324        body.into_bytes()
2325    }
2326
2327    fn upload_request(
2328        exercise: Uuid,
2329        token: &str,
2330        parts: &[(Uuid, &str, &str)],
2331    ) -> test::TestRequest {
2332        test::TestRequest::post()
2333            .uri(&format!("/exercises/{exercise}/files"))
2334            .insert_header(("Authorization", format!("Bearer {token}")))
2335            .insert_header((
2336                "Content-Type",
2337                format!("multipart/form-data; boundary={BOUNDARY}"),
2338            ))
2339            .set_payload(multipart_body(parts))
2340    }
2341
2342    /// A file answer's submit body, which is the only shape a native client sends today.
2343    fn file_submission(
2344        exercise_slide_id: Uuid,
2345        exercise_task_id: Uuid,
2346        data_files: Vec<Uuid>,
2347    ) -> api::ExerciseSlideSubmission {
2348        api::ExerciseSlideSubmission {
2349            exercise_slide_id,
2350            exercise_task_id,
2351            answer_kind: Some(api::AnswerKind::File),
2352            data_json: None,
2353            data_files: Some(data_files),
2354        }
2355    }
2356
2357    fn submit_request(
2358        exercise: Uuid,
2359        token: &str,
2360        body: &api::ExerciseSlideSubmission,
2361    ) -> test::TestRequest {
2362        test::TestRequest::post()
2363            .uri(&format!("/exercises/{exercise}/submit"))
2364            .insert_header(("Authorization", format!("Bearer {token}")))
2365            .set_json(body)
2366    }
2367
2368    /// The `message_key` a client keys its error handling on.
2369    fn message_key(body: &serde_json::Value) -> &str {
2370        body["message_key"].as_str().unwrap_or_default()
2371    }
2372
2373    /// Uploads two files through the route and returns their ids, in the order the client sent them.
2374    async fn upload_two(fixture: &Fixture) -> Vec<Uuid> {
2375        let app = client_api_app!();
2376        let request = upload_request(
2377            fixture.exercise,
2378            &fixture.token,
2379            &[
2380                (Uuid::new_v4(), "a.tar.zst", "first"),
2381                (Uuid::new_v4(), "b.txt", "second"),
2382            ],
2383        )
2384        .to_request();
2385        let response = test::call_service(&app, request).await;
2386        assert_eq!(response.status(), StatusCode::OK);
2387        let body: api::UploadedFiles = test::read_body_json(response).await;
2388        body.data_files.into_iter().map(|file| file.id).collect()
2389    }
2390
2391    #[actix_web::test]
2392    async fn uploading_files_returns_them_in_the_order_they_were_sent() {
2393        let fixture = committed_fixture(true).await;
2394        let app = client_api_app!();
2395        let request = upload_request(
2396            fixture.exercise,
2397            &fixture.token,
2398            &[
2399                (Uuid::new_v4(), "a.tar.zst", "first"),
2400                (Uuid::new_v4(), "b.txt", "second"),
2401            ],
2402        )
2403        .to_request();
2404
2405        let response = test::call_service(&app, request).await;
2406        assert_eq!(response.status(), StatusCode::OK);
2407        let body: api::UploadedFiles = test::read_body_json(response).await;
2408        let names: Vec<&str> = body
2409            .data_files
2410            .iter()
2411            .map(|file| file.name.as_str())
2412            .collect();
2413        assert_eq!(names, vec!["a.tar.zst", "b.txt"]);
2414        assert!(
2415            body.data_files
2416                .iter()
2417                .all(|file| file.url.contains(&file.id.to_string()) || !file.url.is_empty())
2418        );
2419
2420        // The bindings the later submit validates against must exist for this exercise and user.
2421        let mut conn = Conn::init().await;
2422        let mut tx = conn.begin().await;
2423        let ids: Vec<Uuid> = body.data_files.iter().map(|file| file.id).collect();
2424        let recorded = models::exercise_answer_uploads::get_for_exercise_and_user(
2425            tx.as_mut(),
2426            fixture.exercise,
2427            fixture.user,
2428            &ids,
2429        )
2430        .await
2431        .expect("bindings");
2432        assert_eq!(recorded.len(), 2);
2433        assert!(recorded.iter().all(|upload| !upload.deleted));
2434        tx.rollback().await;
2435    }
2436
2437    #[actix_web::test]
2438    async fn uploading_without_a_bearer_is_unauthorized() {
2439        let fixture = committed_fixture(true).await;
2440        let app = client_api_app!();
2441        let request = test::TestRequest::post()
2442            .uri(&format!("/exercises/{}/files", fixture.exercise))
2443            .insert_header((
2444                "Content-Type",
2445                format!("multipart/form-data; boundary={BOUNDARY}"),
2446            ))
2447            .set_payload(multipart_body(&[(Uuid::new_v4(), "a.tar.zst", "first")]))
2448            .to_request();
2449
2450        let response = test::call_service(&app, request).await;
2451        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2452    }
2453
2454    #[actix_web::test]
2455    async fn a_user_who_never_enrolled_cannot_upload() {
2456        let fixture = committed_fixture(false).await;
2457        let app = client_api_app!();
2458        let request = upload_request(
2459            fixture.exercise,
2460            &fixture.token,
2461            &[(Uuid::new_v4(), "a.tar.zst", "first")],
2462        )
2463        .to_request();
2464
2465        let response = test::call_service(&app, request).await;
2466        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2467        let body: serde_json::Value = test::read_body_json(response).await;
2468        assert_eq!(message_key(&body), "not_enrolled");
2469    }
2470
2471    /// The native route must refuse what the iframe route refuses: stashing files for a submission
2472    /// the student can no longer make. A passed deadline stands in for the whole gate.
2473    #[actix_web::test]
2474    async fn a_user_past_the_deadline_cannot_upload() {
2475        let fixture = committed_fixture(true).await;
2476        expire_deadline(fixture.exercise).await;
2477        let app = client_api_app!();
2478        let request = upload_request(
2479            fixture.exercise,
2480            &fixture.token,
2481            &[(Uuid::new_v4(), "a.tar.zst", "first")],
2482        )
2483        .to_request();
2484
2485        let response = test::call_service(&app, request).await;
2486        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2487    }
2488
2489    async fn expire_deadline(exercise: Uuid) {
2490        let mut conn = PgConnection::connect(&test_database_url())
2491            .await
2492            .expect("connection");
2493        models::exercises::set_deadline(
2494            &mut conn,
2495            exercise,
2496            Some(Utc::now() - ChronoDuration::days(1)),
2497        )
2498        .await
2499        .expect("deadline");
2500    }
2501
2502    #[actix_web::test]
2503    async fn uploading_to_an_unknown_exercise_is_not_found() {
2504        let fixture = committed_fixture(true).await;
2505        let app = client_api_app!();
2506        let request = upload_request(
2507            Uuid::new_v4(),
2508            &fixture.token,
2509            &[(Uuid::new_v4(), "a.tar.zst", "first")],
2510        )
2511        .to_request();
2512
2513        let response = test::call_service(&app, request).await;
2514        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2515    }
2516
2517    /// A part whose field name is not a UUID is refused, and — the point of the test — nothing is
2518    /// recorded and no object is left in the store, since the ids are the client's own handles.
2519    #[actix_web::test]
2520    async fn a_part_named_by_something_other_than_a_uuid_is_refused() {
2521        let fixture = committed_fixture(true).await;
2522        let app = client_api_app!();
2523        let mut body = String::new();
2524        body.push_str(&format!("--{BOUNDARY}\r\n"));
2525        body.push_str(
2526            "Content-Disposition: form-data; name=\"not-a-uuid\"; filename=\"a.tar.zst\"\r\n",
2527        );
2528        body.push_str("Content-Type: application/octet-stream\r\n\r\nfirst\r\n");
2529        body.push_str(&format!("--{BOUNDARY}--\r\n"));
2530        let request = test::TestRequest::post()
2531            .uri(&format!("/exercises/{}/files", fixture.exercise))
2532            .insert_header(("Authorization", format!("Bearer {}", fixture.token)))
2533            .insert_header((
2534                "Content-Type",
2535                format!("multipart/form-data; boundary={BOUNDARY}"),
2536            ))
2537            .set_payload(body.into_bytes())
2538            .to_request();
2539
2540        let response = test::call_service(&app, request).await;
2541        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2542    }
2543
2544    /// Parts are streamed to the object store one at a time, so a body whose *second* part is
2545    /// invalid has already put an object there. Nothing points at that object — no `file_uploads`
2546    /// row, so not even the reaper can find it — which is why the route deletes it itself.
2547    #[actix_web::test]
2548    async fn a_part_rejected_after_an_earlier_one_was_stored_leaves_no_object_behind() {
2549        let fixture = committed_fixture(true).await;
2550        let store_dir = tempfile::tempdir().expect("temp dir");
2551        let store_path = store_dir.path().to_path_buf();
2552        let app = client_api_app!(Arc::new(crate::test_helper::TempFileStore(store_dir)));
2553
2554        let mut body = String::new();
2555        body.push_str(&format!("--{BOUNDARY}\r\n"));
2556        body.push_str(&format!(
2557            "Content-Disposition: form-data; name=\"{}\"; filename=\"a.tar.zst\"\r\n",
2558            Uuid::new_v4()
2559        ));
2560        body.push_str("Content-Type: application/octet-stream\r\n\r\nfirst\r\n");
2561        body.push_str(&format!("--{BOUNDARY}\r\n"));
2562        body.push_str(
2563            "Content-Disposition: form-data; name=\"not-a-uuid\"; filename=\"b.txt\"\r\n",
2564        );
2565        body.push_str("Content-Type: application/octet-stream\r\n\r\nsecond\r\n");
2566        body.push_str(&format!("--{BOUNDARY}--\r\n"));
2567        let request = test::TestRequest::post()
2568            .uri(&format!("/exercises/{}/files", fixture.exercise))
2569            .insert_header(("Authorization", format!("Bearer {}", fixture.token)))
2570            .insert_header((
2571                "Content-Type",
2572                format!("multipart/form-data; boundary={BOUNDARY}"),
2573            ))
2574            .set_payload(body.into_bytes())
2575            .to_request();
2576
2577        let response = test::call_service(&app, request).await;
2578        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2579
2580        let namespace = store_path.join(CLIENT_UPLOAD_PATH_PREFIX);
2581        let leftovers: Vec<String> = std::fs::read_dir(&namespace)
2582            .map(|entries| {
2583                entries
2584                    .map(|entry| {
2585                        entry
2586                            .expect("dir entry")
2587                            .file_name()
2588                            .to_string_lossy()
2589                            .into_owned()
2590                    })
2591                    .collect()
2592            })
2593            .unwrap_or_default();
2594        assert!(
2595            leftovers.is_empty(),
2596            "objects left in the store: {leftovers:?}"
2597        );
2598    }
2599
2600    #[actix_web::test]
2601    async fn a_user_who_never_enrolled_cannot_submit() {
2602        let fixture = committed_fixture(false).await;
2603        let app = client_api_app!();
2604        let request = submit_request(
2605            fixture.exercise,
2606            &fixture.token,
2607            &file_submission(fixture.slide, fixture.task, vec![]),
2608        )
2609        .to_request();
2610
2611        let response = test::call_service(&app, request).await;
2612        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2613        let body: serde_json::Value = test::read_body_json(response).await;
2614        assert_eq!(message_key(&body), "not_enrolled");
2615    }
2616
2617    #[actix_web::test]
2618    async fn submitting_to_an_unknown_exercise_is_not_found() {
2619        let fixture = committed_fixture(true).await;
2620        let app = client_api_app!();
2621        let request = submit_request(
2622            Uuid::new_v4(),
2623            &fixture.token,
2624            &file_submission(fixture.slide, fixture.task, vec![]),
2625        )
2626        .to_request();
2627
2628        let response = test::call_service(&app, request).await;
2629        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2630    }
2631
2632    #[actix_web::test]
2633    async fn submitting_the_same_upload_twice_is_reported() {
2634        let fixture = committed_fixture(true).await;
2635        let ids = upload_two(&fixture).await;
2636        let app = client_api_app!();
2637        let request = submit_request(
2638            fixture.exercise,
2639            &fixture.token,
2640            &file_submission(fixture.slide, fixture.task, vec![ids[0], ids[0]]),
2641        )
2642        .to_request();
2643
2644        let response = test::call_service(&app, request).await;
2645        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2646        let body: serde_json::Value = test::read_body_json(response).await;
2647        assert_eq!(message_key(&body), "duplicate_upload");
2648    }
2649
2650    #[actix_web::test]
2651    async fn submitting_an_upload_that_was_never_recorded_is_reported() {
2652        let fixture = committed_fixture(true).await;
2653        let app = client_api_app!();
2654        let request = submit_request(
2655            fixture.exercise,
2656            &fixture.token,
2657            &file_submission(fixture.slide, fixture.task, vec![Uuid::new_v4()]),
2658        )
2659        .to_request();
2660
2661        let response = test::call_service(&app, request).await;
2662        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2663        let body: serde_json::Value = test::read_body_json(response).await;
2664        assert_eq!(message_key(&body), "unknown_upload");
2665    }
2666
2667    /// Another user's upload must read as unknown rather than as a permission error: the binding is
2668    /// keyed by user, so from this user's side the id simply does not exist.
2669    #[actix_web::test]
2670    async fn submitting_another_users_upload_is_reported_as_unknown() {
2671        let owner = committed_fixture(true).await;
2672        let ids = upload_two(&owner).await;
2673        let other = committed_fixture(true).await;
2674        let app = client_api_app!();
2675        let request = submit_request(
2676            other.exercise,
2677            &other.token,
2678            &file_submission(other.slide, other.task, vec![ids[0]]),
2679        )
2680        .to_request();
2681
2682        let response = test::call_service(&app, request).await;
2683        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2684        let body: serde_json::Value = test::read_body_json(response).await;
2685        assert_eq!(message_key(&body), "unknown_upload");
2686    }
2687
2688    /// A reaped upload is reported distinctly from an unrecognised one, because only this one is
2689    /// recoverable by uploading again.
2690    #[actix_web::test]
2691    async fn submitting_a_reaped_upload_reports_it_as_expired() {
2692        let fixture = committed_fixture(true).await;
2693        let ids = upload_two(&fixture).await;
2694
2695        let mut conn = Conn::init().await;
2696        let mut tx = conn.begin().await;
2697        models::exercise_answer_uploads::delete_by_file_upload_id(tx.as_mut(), ids[0])
2698            .await
2699            .expect("retire");
2700        tx.commit().await;
2701
2702        let app = client_api_app!();
2703        let request = submit_request(
2704            fixture.exercise,
2705            &fixture.token,
2706            &file_submission(fixture.slide, fixture.task, vec![ids[0]]),
2707        )
2708        .to_request();
2709
2710        let response = test::call_service(&app, request).await;
2711        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2712        let body: serde_json::Value = test::read_body_json(response).await;
2713        assert_eq!(message_key(&body), "upload_expired");
2714    }
2715
2716    /// The slide and task come from the request body while the URL authorizes the exercise, so a
2717    /// body naming another exercise's slide must be refused.
2718    #[actix_web::test]
2719    async fn submitting_another_exercises_slide_is_refused() {
2720        let fixture = committed_fixture(true).await;
2721        let other = committed_fixture(true).await;
2722        let app = client_api_app!();
2723        let request = submit_request(
2724            fixture.exercise,
2725            &fixture.token,
2726            &file_submission(other.slide, other.task, vec![]),
2727        )
2728        .to_request();
2729
2730        let response = test::call_service(&app, request).await;
2731        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2732        let body: serde_json::Value = test::read_body_json(response).await;
2733        assert_eq!(message_key(&body), "validation_error");
2734    }
2735
2736    /// The exercise service behind the fixture task does not declare `supports_native_client`, so
2737    /// the task is not client-servable and submit must refuse it at the edge rather than let
2738    /// grading fail deep inside a service that will never understand the answer.
2739    #[actix_web::test]
2740    async fn submitting_to_a_task_no_service_can_serve_is_refused() {
2741        let fixture = committed_fixture(true).await;
2742        let app = client_api_app!();
2743        let request = submit_request(
2744            fixture.exercise,
2745            &fixture.token,
2746            &file_submission(fixture.slide, fixture.unservable_task, vec![]),
2747        )
2748        .to_request();
2749
2750        let response = test::call_service(&app, request).await;
2751        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2752        let body: serde_json::Value = test::read_body_json(response).await;
2753        assert_eq!(message_key(&body), "validation_error");
2754    }
2755
2756    fn stub_grading() -> ExerciseTaskGradingResult {
2757        ExerciseTaskGradingResult {
2758            grading_progress: GradingProgress::FullyGraded,
2759            score_given: 1.0,
2760            score_maximum: 1,
2761            feedback_text: Some("graded by the stub".to_string()),
2762            feedback_json: None,
2763            set_user_variables: None,
2764        }
2765    }
2766
2767    /// How the stub answers a grading request.
2768    enum StubGrading {
2769        Graded(ExerciseTaskGradingResult),
2770        Unavailable,
2771    }
2772
2773    struct StubState {
2774        grade_requests: Mutex<Vec<serde_json::Value>>,
2775        /// Paths a submit asked the stub for that it is not supposed to need. Asserted empty, so a
2776        /// hop added to the submit path cannot pass unnoticed — notably a live service-info fetch,
2777        /// which the committed `exercise_service_info` row is there to make unnecessary.
2778        unexpected: Mutex<Vec<String>>,
2779        grading: StubGrading,
2780    }
2781
2782    impl StubState {
2783        fn new(grading: StubGrading) -> Self {
2784            Self {
2785                grade_requests: Mutex::new(Vec::new()),
2786                unexpected: Mutex::new(Vec::new()),
2787                grading,
2788            }
2789        }
2790
2791        fn calls(&self, requests: &Mutex<Vec<serde_json::Value>>) -> Vec<serde_json::Value> {
2792            requests.lock().expect("stub lock").clone()
2793        }
2794
2795        /// Asserts grading was the only thing a submit asked the exercise service for.
2796        fn assert_hops(&self, grade_calls: usize) {
2797            assert!(
2798                self.unexpected.lock().expect("stub lock").is_empty(),
2799                "submit called endpoints beyond grade: {:?}",
2800                self.unexpected.lock().expect("stub lock")
2801            );
2802            assert_eq!(self.calls(&self.grade_requests).len(), grade_calls);
2803        }
2804    }
2805
2806    async fn stub_grade(
2807        state: web::Data<StubState>,
2808        body: web::Json<serde_json::Value>,
2809    ) -> actix_web::HttpResponse {
2810        state
2811            .grade_requests
2812            .lock()
2813            .expect("stub lock")
2814            .push(body.into_inner());
2815        match &state.grading {
2816            StubGrading::Graded(result) => actix_web::HttpResponse::Ok().json(result),
2817            StubGrading::Unavailable => {
2818                actix_web::HttpResponse::InternalServerError().body("the grader is down")
2819            }
2820        }
2821    }
2822
2823    async fn stub_unexpected(
2824        request: actix_web::HttpRequest,
2825        state: web::Data<StubState>,
2826    ) -> actix_web::HttpResponse {
2827        state.unexpected.lock().expect("stub lock").push(format!(
2828            "{} {}",
2829            request.method(),
2830            request.path()
2831        ));
2832        actix_web::HttpResponse::NotFound().finish()
2833    }
2834
2835    /// Serves the grading endpoint a submit drives, on a real socket, and returns its base URL for
2836    /// the exercise service's `internal_url`. Anything else a submit asks for lands in
2837    /// `unexpected`.
2838    fn start_exercise_service_stub(state: Arc<StubState>) -> String {
2839        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2840        let port = listener.local_addr().expect("local addr").port();
2841        let server = actix_web::HttpServer::new(move || {
2842            App::new()
2843                .app_data(web::Data::from(state.clone()))
2844                .route("/grade", web::post().to(stub_grade))
2845                .default_service(web::to(stub_unexpected))
2846        })
2847        .workers(1)
2848        .disable_signals()
2849        .listen(listener)
2850        .expect("listen")
2851        .run();
2852        actix_web::rt::spawn(server);
2853        format!("http://127.0.0.1:{port}")
2854    }
2855
2856    /// The `user_exercise_states` row `process_submission` requires, with the slide the student is
2857    /// answering selected. Both are written when the student opens the exercise, which a native
2858    /// client does before it can submit.
2859    async fn open_exercise(fixture: &Fixture) {
2860        let mut conn = Conn::init().await;
2861        let mut tx = conn.begin().await;
2862        models::user_exercise_states::upsert_selected_exercise_slide_id(
2863            tx.as_mut(),
2864            fixture.user,
2865            fixture.exercise,
2866            Some(fixture.course),
2867            None,
2868            Some(fixture.slide),
2869        )
2870        .await
2871        .expect("exercise state");
2872        tx.commit().await;
2873    }
2874
2875    /// A fixture whose exercise service is the stub, with the state a submit needs, plus two
2876    /// uploads.
2877    async fn fixture_with_stub(state: Arc<StubState>) -> (Fixture, Vec<Uuid>) {
2878        let url = start_exercise_service_stub(state);
2879        let fixture = committed_fixture_with_service(true, Some(url)).await;
2880        open_exercise(&fixture).await;
2881        let ids = upload_two(&fixture).await;
2882        (fixture, ids)
2883    }
2884
2885    /// The names of the files a grading request carries, in the order the service sees them.
2886    fn graded_names(request: &serde_json::Value) -> Vec<String> {
2887        request["submission_files"]
2888            .as_array()
2889            .expect("submission_files")
2890            .iter()
2891            .map(|file| file["name"].as_str().expect("name").to_string())
2892            .collect()
2893    }
2894
2895    async fn slide_submission_count(exercise: Uuid, user: Uuid) -> u32 {
2896        let mut conn = Conn::init().await;
2897        let mut tx = conn.begin().await;
2898        let count = models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_and_user_ids(tx.as_mut(), exercise, user)
2899            .await
2900            .expect("count");
2901        tx.rollback().await;
2902        count
2903    }
2904
2905    async fn rejected_submission_count(slide: Uuid, user: Uuid) -> u32 {
2906        let mut conn = Conn::init().await;
2907        let mut tx = conn.begin().await;
2908        let count = models::rejected_exercise_slide_submissions::count_with_slide_and_user_ids(
2909            tx.as_mut(),
2910            slide,
2911            user,
2912        )
2913        .await
2914        .expect("count");
2915        tx.rollback().await;
2916        count
2917    }
2918
2919    /// The happy path, end to end: the files the client named are the answer, they are graded
2920    /// through the service in the order the client named them rather than the order it uploaded in,
2921    /// and no answer of the host's own invention is persisted alongside them.
2922    #[actix_web::test]
2923    async fn submitting_records_the_named_uploads_as_the_answer() {
2924        let state = Arc::new(StubState::new(StubGrading::Graded(stub_grading())));
2925        let (fixture, ids) = fixture_with_stub(state.clone()).await;
2926        let named = vec![ids[1], ids[0]];
2927
2928        let app = client_api_app!();
2929        let request = submit_request(
2930            fixture.exercise,
2931            &fixture.token,
2932            &file_submission(fixture.slide, fixture.task, named.clone()),
2933        )
2934        .to_request();
2935
2936        let response = test::call_service(&app, request).await;
2937        assert_eq!(response.status(), StatusCode::OK);
2938        let body: api::ExerciseTaskSubmissionResult = test::read_body_json(response).await;
2939
2940        state.assert_hops(1);
2941        let grade_request = state.calls(&state.grade_requests).remove(0);
2942        assert_eq!(graded_names(&grade_request), vec!["b.txt", "a.tar.zst"]);
2943
2944        let mut conn = Conn::init().await;
2945        let mut tx = conn.begin().await;
2946        let submission = models::exercise_task_submissions::get_by_id(
2947            tx.as_mut(),
2948            body.task_submission_id,
2949            &crate::test_helper::init_file_store(),
2950            &crate::test_helper::init_app_conf().expect("app conf"),
2951        )
2952        .await
2953        .expect("task submission");
2954        assert_eq!(
2955            submission.answer_kind,
2956            AnswerKind::File,
2957            "a client submission must be recorded as a file answer"
2958        );
2959        let files = submission.data_files.expect("a file answer names files");
2960        assert_eq!(
2961            files.iter().map(|file| file.id).collect::<Vec<_>>(),
2962            named,
2963            "the client's order is the answer, not ours to sort"
2964        );
2965        assert_eq!(
2966            submission.data_json, None,
2967            "a client names files only, so there is no metadata for the host to invent"
2968        );
2969        assert_eq!(
2970            submission.exercise_slide_submission_id,
2971            body.slide_submission_id
2972        );
2973
2974        let grading = models::exercise_task_gradings::get_by_id(
2975            tx.as_mut(),
2976            submission
2977                .exercise_task_grading_id
2978                .expect("submission was graded"),
2979        )
2980        .await
2981        .expect("grading");
2982        assert_eq!(grading.grading_progress, GradingProgress::FullyGraded);
2983        assert_eq!(grading.unscaled_score_given, Some(1.0));
2984        assert_eq!(grading.feedback_text.as_deref(), Some("graded by the stub"));
2985
2986        let files = models::exercise_task_submission_files::get_by_task_submission_ids(
2987            tx.as_mut(),
2988            &[body.task_submission_id],
2989        )
2990        .await
2991        .expect("submission files");
2992        let recorded: Vec<(Uuid, &str, i32)> = files
2993            .iter()
2994            .map(|file| (file.file_upload_id, file.name.as_str(), file.order_number))
2995            .collect();
2996        assert_eq!(
2997            recorded,
2998            vec![(named[0], "b.txt", 0), (named[1], "a.tar.zst", 1)]
2999        );
3000        tx.rollback().await;
3001    }
3002
3003    /// A submit naming no files is refused: the named files are the answer, so an empty list is a
3004    /// claim with no content rather than an answer that happens to need no files.
3005    #[actix_web::test]
3006    async fn submitting_no_files_is_refused() {
3007        let state = Arc::new(StubState::new(StubGrading::Graded(stub_grading())));
3008        let (fixture, _ids) = fixture_with_stub(state.clone()).await;
3009
3010        let app = client_api_app!();
3011        let request = submit_request(
3012            fixture.exercise,
3013            &fixture.token,
3014            &file_submission(fixture.slide, fixture.task, vec![]),
3015        )
3016        .to_request();
3017
3018        let response = test::call_service(&app, request).await;
3019        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
3020        state.assert_hops(0);
3021        assert_eq!(
3022            slide_submission_count(fixture.exercise, fixture.user).await,
3023            0
3024        );
3025    }
3026
3027    /// A grading hop that fails must still leave the rejected-submission audit row behind, which is
3028    /// what submit's commit-then-return-the-error branch exists for. No accepted submission may
3029    /// survive it. A client's answer carries no metadata, so this is also what keeps the rejected
3030    /// copy of a file answer storable at all.
3031    #[actix_web::test]
3032    async fn a_failed_grading_keeps_only_the_rejected_submission() {
3033        let state = Arc::new(StubState::new(StubGrading::Unavailable));
3034        let (fixture, ids) = fixture_with_stub(state.clone()).await;
3035
3036        let app = client_api_app!();
3037        let request = submit_request(
3038            fixture.exercise,
3039            &fixture.token,
3040            &file_submission(fixture.slide, fixture.task, vec![ids[0]]),
3041        )
3042        .to_request();
3043
3044        let response = test::call_service(&app, request).await;
3045        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
3046
3047        state.assert_hops(1);
3048        assert_eq!(
3049            slide_submission_count(fixture.exercise, fixture.user).await,
3050            0
3051        );
3052        assert_eq!(
3053            rejected_submission_count(fixture.slide, fixture.user).await,
3054            1
3055        );
3056    }
3057
3058    /// The two files a student's work consists of, used for both origins so that the download
3059    /// responses can be compared for equality rather than merely for similarity.
3060    const STUDENT_FILES: [(&str, &str); 2] = [("a.tar.zst", "first"), ("b.txt", "second")];
3061
3062    /// Stores `STUDENT_FILES` the way the course-material upload route does — objects in the file
3063    /// store, `file_uploads` rows, and an IFrame-origin binding to this exercise and user — and
3064    /// returns their ids in order.
3065    async fn upload_from_the_iframe(fixture: &Fixture, store: &dyn FileStore) -> Vec<Uuid> {
3066        let mut conn = PgConnection::connect(&test_database_url())
3067            .await
3068            .expect("connection");
3069        let mut ids = Vec::new();
3070        for (name, contents) in STUDENT_FILES {
3071            let path = format!("exercise-answer-uploads/{}", Uuid::new_v4());
3072            store
3073                .upload(
3074                    std::path::Path::new(&path),
3075                    contents.as_bytes().to_vec(),
3076                    "application/octet-stream",
3077                )
3078                .await
3079                .expect("stored object");
3080            ids.push(
3081                models::file_uploads::insert(
3082                    &mut conn,
3083                    name,
3084                    &path,
3085                    "application/octet-stream",
3086                    Some(fixture.user),
3087                    Some(contents.len() as i64),
3088                )
3089                .await
3090                .expect("file upload"),
3091            );
3092        }
3093        models::exercise_answer_uploads::insert_many(
3094            &mut conn,
3095            fixture.exercise,
3096            fixture.user,
3097            &ids,
3098            models::exercise_answer_uploads::AnswerUploadOrigin::Iframe,
3099        )
3100        .await
3101        .expect("binding");
3102        ids
3103    }
3104
3105    /// Submits the way the course-material IFrame does — through the same function that route's
3106    /// handler calls — and returns the slide submission's id.
3107    async fn submit_from_the_iframe(
3108        fixture: &Fixture,
3109        answer: StudentExerciseTaskSubmission,
3110        store: &dyn FileStore,
3111    ) -> Uuid {
3112        let mut conn = PgConnection::connect(&test_database_url())
3113            .await
3114            .expect("connection");
3115        let exercise = models::exercises::get_by_id(&mut conn, fixture.exercise)
3116            .await
3117            .expect("exercise");
3118        let result = domain::exercises::process_submission(
3119            &mut conn,
3120            fixture.user,
3121            exercise,
3122            &StudentExerciseSlideSubmission {
3123                exercise_slide_id: fixture.slide,
3124                exercise_task_submissions: vec![answer],
3125            },
3126            std::sync::Arc::new(JwtKey::test_key()),
3127            store,
3128            &crate::test_helper::init_app_conf().expect("app conf"),
3129        )
3130        .await
3131        .expect("iframe submission");
3132        result
3133            .exercise_task_submission_results
3134            .into_iter()
3135            .next()
3136            .expect("one task submission")
3137            .submission
3138            .exercise_slide_submission_id
3139    }
3140
3141    async fn download(
3142        app: &impl actix_web::dev::Service<
3143            actix_http::Request,
3144            Response = actix_web::dev::ServiceResponse,
3145            Error = actix_web::Error,
3146        >,
3147        token: &str,
3148        submission: Uuid,
3149    ) -> serde_json::Value {
3150        let request = test::TestRequest::get()
3151            .uri(&format!("/submissions/{submission}/download"))
3152            .insert_header(("Authorization", format!("Bearer {token}")))
3153            .to_request();
3154        let response = test::call_service(app, request).await;
3155        assert_eq!(response.status(), StatusCode::OK);
3156        test::read_body_json(response).await
3157    }
3158
3159    /// Path of the object a file's `url` names, for reading it back out of the file store.
3160    fn object_path(url: &str) -> &str {
3161        url.split_once("/api/v0/files/").expect("a files URL").1
3162    }
3163
3164    /// Replaces the members that name *which stored object* a file is — necessarily a different
3165    /// row and a different object for two different submissions — with placeholders, leaving
3166    /// everything a client can otherwise observe: the response's field set, each file's field set,
3167    /// the names, and their order. Two canonicalised bodies compare equal only if the client cannot
3168    /// tell the two submissions' downloads apart. The bytes behind the URLs are asserted separately.
3169    fn canonicalize_download(body: &serde_json::Value) -> serde_json::Value {
3170        let files = body["data_files"].as_array().expect("data_files");
3171        serde_json::json!({
3172            "data_files": files
3173                .iter()
3174                .map(|file| {
3175                    let object = file.as_object().expect("file object");
3176                    let mut canonical = object.clone();
3177                    canonical.insert("id".to_string(), serde_json::json!("<uuid>"));
3178                    let url = object["url"].as_str().expect("url");
3179                    let served_from = &url[..url.len() - object_path(url).len()];
3180                    canonical.insert(
3181                        "url".to_string(),
3182                        serde_json::json!(format!("{served_from}<object>")),
3183                    );
3184                    serde_json::Value::Object(canonical)
3185                })
3186                .collect::<Vec<_>>(),
3187        })
3188    }
3189
3190    /// What a client actually gets when it follows every file's `url`, in order.
3191    async fn served_files(
3192        store: &dyn FileStore,
3193        body: &serde_json::Value,
3194    ) -> Vec<(String, String)> {
3195        let mut served = Vec::new();
3196        for file in body["data_files"].as_array().expect("data_files") {
3197            let url = file["url"].as_str().expect("url");
3198            let bytes = store
3199                .download(std::path::Path::new(object_path(url)))
3200                .await
3201                .expect("stored object");
3202            served.push((
3203                file["name"].as_str().expect("name").to_string(),
3204                String::from_utf8(bytes).expect("utf-8 contents"),
3205            ));
3206        }
3207        served
3208    }
3209
3210    /// The requirement itself: the same work, submitted once from a native client and once from the
3211    /// exercise service's IFrame, downloads identically. Not "both are non-empty" — byte-equal
3212    /// after only the per-file stored-object identity is canonicalised away.
3213    #[actix_web::test]
3214    async fn an_iframe_submission_downloads_exactly_like_a_native_client_one() {
3215        let state = Arc::new(StubState::new(StubGrading::Graded(stub_grading())));
3216        let url = start_exercise_service_stub(state.clone());
3217        let fixture = committed_fixture_with_service(true, Some(url)).await;
3218        open_exercise(&fixture).await;
3219
3220        let store: Arc<dyn FileStore> = Arc::new(crate::test_helper::TempFileStore(
3221            tempfile::tempdir().expect("temp dir"),
3222        ));
3223        let app = client_api_app!(store.clone());
3224        let upload = upload_request(
3225            fixture.exercise,
3226            &fixture.token,
3227            &[
3228                (Uuid::new_v4(), STUDENT_FILES[0].0, STUDENT_FILES[0].1),
3229                (Uuid::new_v4(), STUDENT_FILES[1].0, STUDENT_FILES[1].1),
3230            ],
3231        )
3232        .to_request();
3233        let response = test::call_service(&app, upload).await;
3234        assert_eq!(response.status(), StatusCode::OK);
3235        let uploaded: api::UploadedFiles = test::read_body_json(response).await;
3236        let named: Vec<Uuid> = uploaded.data_files.iter().map(|file| file.id).collect();
3237
3238        let submit = submit_request(
3239            fixture.exercise,
3240            &fixture.token,
3241            &file_submission(fixture.slide, fixture.task, named),
3242        )
3243        .to_request();
3244        let response = test::call_service(&app, submit).await;
3245        assert_eq!(response.status(), StatusCode::OK);
3246        let native: api::ExerciseTaskSubmissionResult = test::read_body_json(response).await;
3247
3248        let from_iframe_ids = upload_from_the_iframe(&fixture, store.as_ref()).await;
3249        let from_iframe = submit_from_the_iframe(
3250            &fixture,
3251            StudentExerciseTaskSubmission::files(
3252                fixture.task,
3253                from_iframe_ids,
3254                Some(serde_json::json!({ "plugin": "said so" })),
3255            ),
3256            store.as_ref(),
3257        )
3258        .await;
3259
3260        let native_body = download(&app, &fixture.token, native.slide_submission_id).await;
3261        let iframe_body = download(&app, &fixture.token, from_iframe).await;
3262
3263        assert_eq!(
3264            serde_json::to_vec(&canonicalize_download(&iframe_body)).expect("json"),
3265            serde_json::to_vec(&canonicalize_download(&native_body)).expect("json"),
3266            "iframe {iframe_body} differs in shape from native client {native_body}"
3267        );
3268        // Following the URLs must yield the same work, not merely the same field names.
3269        let expected: Vec<(String, String)> = STUDENT_FILES
3270            .iter()
3271            .map(|(name, contents)| (name.to_string(), contents.to_string()))
3272            .collect();
3273        assert_eq!(served_files(store.as_ref(), &iframe_body).await, expected);
3274        assert_eq!(served_files(store.as_ref(), &native_body).await, expected);
3275    }
3276
3277    /// A JSON-typed answer names no files, so its download is empty rather than an error.
3278    #[actix_web::test]
3279    async fn a_json_typed_submission_downloads_empty() {
3280        let state = Arc::new(StubState::new(StubGrading::Graded(stub_grading())));
3281        let url = start_exercise_service_stub(state.clone());
3282        let fixture = committed_fixture_with_service(true, Some(url)).await;
3283        open_exercise(&fixture).await;
3284
3285        let from_iframe = submit_from_the_iframe(
3286            &fixture,
3287            StudentExerciseTaskSubmission::json(
3288                fixture.task,
3289                serde_json::json!({ "opaque": "plugin owned" }),
3290            ),
3291            &temp_file_store(),
3292        )
3293        .await;
3294
3295        let app = client_api_app!();
3296        let body = download(&app, &fixture.token, from_iframe).await;
3297        assert_eq!(body, serde_json::json!({ "data_files": [] }));
3298    }
3299}