Skip to main content

headless_lms_server/controllers/exercise_services/
client.rs

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