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