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