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