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