1use std::sync::Arc;
2
3use crate::{
4 domain::exercise_services::answer_uploads,
5 domain::models_requests::{self, JwtKey},
6 prelude::*,
7};
8use chrono::{Duration, Utc};
9use futures_util::future::OptionFuture;
10use models::{
11 exercises::Exercise,
12 library::grading::{
13 GradingPolicy, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult,
14 SubmittedAnswer,
15 },
16 user_exercise_states::ExerciseWithUserState,
17};
18
19pub async fn process_submission(
26 conn: &mut PgConnection,
27 user_id: Uuid,
28 exercise: Exercise,
29 submission: &StudentExerciseSlideSubmission,
30 jwt_key: Arc<JwtKey>,
31 file_store: &dyn FileStore,
32 app_conf: &ApplicationConfiguration,
33) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
34 verify_named_uploads(conn, exercise.id, user_id, submission).await?;
35
36 let mut tx = conn.begin().await?;
37 if let Err(error) =
38 lock_and_verify_named_uploads(&mut tx, exercise.id, user_id, submission).await
39 {
40 tx.rollback().await?;
41 return Err(error);
42 }
43 let result = grade_submission(
44 &mut tx, user_id, exercise, submission, jwt_key, file_store, app_conf,
45 )
46 .await;
47 let result = match result {
48 Ok(result) => result,
49 Err(error) => {
50 if let Err(commit_error) = tx.commit().await {
53 error!("Failed to commit after a failed submission: {commit_error}");
54 }
55 return Err(error);
56 }
57 };
58 tx.commit().await?;
59 Ok(result)
60}
61
62fn named_upload_ids(submission: &StudentExerciseSlideSubmission) -> Vec<Uuid> {
68 submission
69 .exercise_task_submissions
70 .iter()
71 .flat_map(|task_submission| task_submission.named_file_ids().iter().copied())
72 .collect()
73}
74
75async fn verify_named_uploads(
81 conn: &mut PgConnection,
82 exercise_id: Uuid,
83 user_id: Uuid,
84 submission: &StudentExerciseSlideSubmission,
85) -> Result<(), ControllerError> {
86 for task_submission in &submission.exercise_task_submissions {
87 if let SubmittedAnswer::File {
88 file_upload_ids, ..
89 } = task_submission.to_submitted_answer()?
90 {
91 answer_uploads::verify_answer_names_uploads(&file_upload_ids)?;
92 }
93 }
94 let file_upload_ids = named_upload_ids(submission);
95 if file_upload_ids.is_empty() {
96 return Ok(());
97 }
98 answer_uploads::verify_uploads_belong_to_exercise(conn, exercise_id, user_id, &file_upload_ids)
99 .await
100}
101
102async fn lock_and_verify_named_uploads(
105 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
106 exercise_id: Uuid,
107 user_id: Uuid,
108 submission: &StudentExerciseSlideSubmission,
109) -> Result<(), ControllerError> {
110 let file_upload_ids = named_upload_ids(submission);
111 if file_upload_ids.is_empty() {
112 return Ok(());
113 }
114 answer_uploads::lock_and_verify_uploads_are_usable(tx, exercise_id, user_id, &file_upload_ids)
115 .await
116}
117
118async fn grade_submission(
121 conn: &mut PgConnection,
122 user_id: Uuid,
123 exercise: Exercise,
124 submission: &StudentExerciseSlideSubmission,
125 jwt_key: Arc<JwtKey>,
126 file_store: &dyn FileStore,
127 app_conf: &ApplicationConfiguration,
128) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
129 enforce_deadline(conn, &exercise).await?;
130
131 let (course_or_exam_id, last_try) = resolve_course_or_exam_id_and_verify_that_user_can_submit(
132 conn,
133 user_id,
134 &exercise,
135 submission.exercise_slide_id,
136 )
137 .await?;
138
139 let user_exercise_state = models::user_exercise_states::get_user_exercise_state_if_exists(
141 conn,
142 user_id,
143 exercise.id,
144 course_or_exam_id,
145 )
146 .await?
147 .ok_or_else(|| {
148 ControllerError::new(
149 ControllerErrorType::Unauthorized,
150 "Missing exercise state.".to_string(),
151 None,
152 )
153 })?;
154
155 let mut exercise_with_user_state = ExerciseWithUserState::new(exercise, user_exercise_state)?;
156 let mut result = models::library::grading::grade_user_submission(
157 conn,
158 &mut exercise_with_user_state,
159 submission,
160 GradingPolicy::Default,
161 models_requests::fetch_service_info,
162 models_requests::make_grading_request_sender(jwt_key, app_conf.base_url.clone()),
163 file_store,
164 app_conf,
165 )
166 .await?;
167
168 if exercise_with_user_state.is_exam_exercise() {
169 result.clear_grading_information();
171 }
172
173 let score_given = if let Some(exercise_status) = &result.exercise_status {
174 exercise_status.score_given.unwrap_or(0.0)
175 } else {
176 0.0
177 };
178
179 let has_received_full_points = score_given
182 >= exercise_with_user_state.exercise().score_maximum as f32
183 || (score_given - exercise_with_user_state.exercise().score_maximum as f32).abs() < 0.0001;
184 if !has_received_full_points && !last_try {
185 result.clear_model_solution_specs();
186 }
187 Ok(result)
188}
189
190pub async fn verify_user_can_answer_exercise_slide(
197 conn: &mut PgConnection,
198 user_id: Uuid,
199 exercise: &Exercise,
200 slide_id: Uuid,
201) -> Result<(), ControllerError> {
202 enforce_deadline(conn, exercise).await?;
203 resolve_course_or_exam_id_and_verify_that_user_can_submit(conn, user_id, exercise, slide_id)
204 .await?;
205 Ok(())
206}
207
208pub async fn verify_user_can_answer_exercise(
216 conn: &mut PgConnection,
217 user_id: Uuid,
218 exercise: &Exercise,
219) -> Result<(), ControllerError> {
220 enforce_deadline(conn, exercise).await?;
221 let course_or_exam_id =
222 resolve_course_or_exam_id_for_submitting(conn, user_id, exercise).await?;
223 verify_any_slide_has_tries_left(conn, user_id, exercise, course_or_exam_id).await
224}
225
226async fn verify_any_slide_has_tries_left(
228 conn: &mut PgConnection,
229 user_id: Uuid,
230 exercise: &Exercise,
231 course_or_exam_id: CourseOrExamId,
232) -> Result<(), ControllerError> {
233 if !is_out_of_tries(conn, user_id, exercise, course_or_exam_id).await? {
234 return Ok(());
235 }
236 tracing::error!(
237 user_id = %user_id,
238 exercise_id = %exercise.id,
239 course_or_exam_id = ?course_or_exam_id,
240 "User has run out of tries on every slide of the exercise"
241 );
242 Err(out_of_tries_error())
243}
244
245pub async fn is_out_of_tries(
249 conn: &mut PgConnection,
250 user_id: Uuid,
251 exercise: &Exercise,
252 course_or_exam_id: CourseOrExamId,
253) -> models::ModelResult<bool> {
254 let Some(max_tries_per_slide) = try_limit(exercise) else {
255 return Ok(false);
256 };
257 let submission_counts =
258 models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
259 conn,
260 exercise.id,
261 course_or_exam_id,
262 user_id,
263 )
264 .await?;
265 let slides =
266 models::exercise_slides::get_exercise_slides_by_exercise_id(conn, exercise.id).await?;
267 Ok(!slides.is_empty()
268 && slides
269 .iter()
270 .all(|slide| submission_counts.get(&slide.id).unwrap_or(&0) >= &max_tries_per_slide))
271}
272
273fn out_of_tries_error() -> ControllerError {
276 controller_err!(BadRequest, "You've ran out of tries.".to_string())
277}
278
279fn try_limit(exercise: &Exercise) -> Option<i64> {
281 exercise
282 .limit_number_of_tries
283 .then_some(exercise.max_tries_per_slide)
284 .flatten()
285 .map(i64::from)
286}
287
288async fn enforce_deadline(
290 conn: &mut PgConnection,
291 exercise: &Exercise,
292) -> Result<(), ControllerError> {
293 let chapter_option_future: OptionFuture<_> = exercise
294 .chapter_id
295 .map(|id| models::chapters::get_chapter(conn, id))
296 .into();
297 let chapter = chapter_option_future.await.transpose()?;
298
299 if let Some(deadline) = exercise
301 .deadline
302 .or_else(|| chapter.and_then(|c| c.deadline))
303 && Utc::now() + Duration::seconds(1) >= deadline
304 {
305 return Err(ControllerError::new(
306 ControllerErrorType::BadRequest,
307 "Exercise deadline passed.".to_string(),
308 None,
309 ));
310 }
311
312 Ok(())
313}
314
315async fn resolve_course_or_exam_id_for_submitting(
320 conn: &mut PgConnection,
321 user_id: Uuid,
322 exercise: &Exercise,
323) -> Result<CourseOrExamId, ControllerError> {
324 let course_id_or_exam_id: CourseOrExamId = if let Some(course_id) = exercise.course_id {
325 let settings = models::user_course_settings::get_user_course_settings_by_course_id(
328 conn, user_id, course_id,
329 )
330 .await?;
331 if let Some(settings) = settings {
332 let token = authorize(conn, Act::View, Some(user_id), Res::Course(course_id)).await?;
333 token.authorized_ok(CourseOrExamId::Course(settings.current_course_id))
334 } else {
335 Err(ControllerError::new(
336 ControllerErrorType::Unauthorized,
337 "User is not enrolled on this course.".to_string(),
338 None,
339 ))
340 }
341 } else if let Some(exam_id) = exercise.exam_id {
342 if models::exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await? {
344 let token = authorize(conn, Act::View, Some(user_id), Res::Exam(exam_id)).await?;
345 token.authorized_ok(CourseOrExamId::Exam(exam_id))
346 } else {
347 Err(ControllerError::new(
348 ControllerErrorType::Unauthorized,
349 "Submissions for this exam are no longer accepted.".to_string(),
350 None,
351 ))
352 }
353 } else {
354 Err(ControllerError::new(
356 ControllerErrorType::InternalServerError,
357 "Exam doesn't belong to either a course nor exam.".to_string(),
358 None,
359 ))
360 }?
361 .data;
362 Ok(course_id_or_exam_id)
363}
364
365async fn resolve_course_or_exam_id_and_verify_that_user_can_submit(
371 conn: &mut PgConnection,
372 user_id: Uuid,
373 exercise: &Exercise,
374 slide_id: Uuid,
375) -> Result<(CourseOrExamId, bool), ControllerError> {
376 let mut last_try = false;
377 let course_id_or_exam_id =
378 resolve_course_or_exam_id_for_submitting(conn, user_id, exercise).await?;
379 if let Some(max_tries_per_slide) = try_limit(exercise) {
380 let slide_id_to_submissions_count =
382 models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
383 conn,
384 exercise.id,
385 course_id_or_exam_id,
386 user_id,
387 )
388 .await?;
389
390 let count = slide_id_to_submissions_count.get(&slide_id).unwrap_or(&0);
391 if count >= &max_tries_per_slide {
392 tracing::error!(
393 user_id = %user_id,
394 exercise_id = %exercise.id,
395 slide_id = %slide_id,
396 course_or_exam_id = ?course_id_or_exam_id,
397 current_try_count = %count,
398 max_tries_per_slide = %max_tries_per_slide,
399 limit_number_of_tries = %exercise.limit_number_of_tries,
400 "User has run out of tries for exercise slide submission"
401 );
402 return Err(out_of_tries_error());
403 }
404 if count + 1 >= max_tries_per_slide {
405 last_try = true;
406 }
407 }
408 Ok((course_id_or_exam_id, last_try))
409}
410
411#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::test_helper::*;
417 use models::exercise_answer_uploads::AnswerUploadOrigin;
418 use models::exercise_task_gradings::ExerciseTaskGradingResult;
419 use models::exercise_task_submissions::{AnswerFile, AnswerKind};
420 use models::exercises::GradingProgress;
421 use models::library::grading::StudentExerciseTaskSubmission;
422 use sqlx::Connection;
423 use std::sync::{Arc, Mutex};
424
425 struct Fixture {
427 user: Uuid,
428 course: Uuid,
429 chapter: Uuid,
430 page: Uuid,
431 exercise: Uuid,
432 slide: Uuid,
433 task: Uuid,
434 }
435
436 async fn insert_graded_task(
439 conn: &mut PgConnection,
440 slide: Uuid,
441 internal_url: String,
442 order_number: i32,
443 ) -> Uuid {
444 let slug = format!("submit-test-{}", Uuid::new_v4());
445 let service = models::exercise_services::insert_exercise_service(
446 conn,
447 &models::exercise_services::ExerciseServiceNewOrUpdate {
448 name: slug.clone(),
449 slug: slug.clone(),
450 public_url: "http://example.com/api/service".to_string(),
451 internal_url: Some(internal_url),
452 max_reprocessing_submissions_at_once: 1,
453 is_global_admin_only: false,
454 is_experimental: false,
455 },
456 )
457 .await
458 .expect("exercise service");
459 models::exercise_service_info::insert(
460 conn,
461 &models::exercise_service_info::PathInfo {
462 exercise_service_id: service.id,
463 user_interface_iframe_path: "/iframe".to_string(),
464 grade_endpoint_path: "/grade".to_string(),
465 public_spec_endpoint_path: "/public-spec".to_string(),
466 model_solution_spec_endpoint_path: "/model-solution".to_string(),
467 has_custom_view: false,
468 supports_native_client: false,
469 produces_file_answers: false,
470 declares_spec_files: false,
471 },
472 )
473 .await
474 .expect("service info");
475 models::exercise_tasks::insert(
476 conn,
477 models::PKeyPolicy::Generate,
478 models::exercise_tasks::NewExerciseTask {
479 exercise_slide_id: slide,
480 exercise_type: slug,
481 assignment: vec![],
482 public_spec: Some(serde_json::Value::Null),
483 private_spec: Some(serde_json::Value::Null),
484 model_solution_spec: Some(serde_json::Value::Null),
485 order_number,
486 },
487 )
488 .await
489 .expect("exercise task")
490 }
491
492 async fn enroll_and_open(
495 conn: &mut PgConnection,
496 user: Uuid,
497 course: Uuid,
498 instance: Uuid,
499 exercise: Uuid,
500 slide: Uuid,
501 ) {
502 models::course_instance_enrollments::insert_enrollment_and_set_as_current(
503 conn,
504 models::course_instance_enrollments::NewCourseInstanceEnrollment {
505 course_id: course,
506 user_id: user,
507 course_instance_id: instance,
508 },
509 )
510 .await
511 .expect("enrollment");
512 models::user_exercise_states::upsert_selected_exercise_slide_id(
513 conn,
514 user,
515 exercise,
516 Some(course),
517 None,
518 Some(slide),
519 )
520 .await
521 .expect("exercise state");
522 }
523
524 async fn bind_upload(conn: &mut PgConnection, exercise: Uuid, user: Uuid, name: &str) -> Uuid {
527 let file_id = models::file_uploads::insert(
528 conn,
529 name,
530 &format!("exercise-answer-uploads/{}", Uuid::new_v4()),
531 "application/octet-stream",
532 Some(user),
533 Some(3),
534 )
535 .await
536 .expect("file upload");
537 models::exercise_answer_uploads::insert_many(
538 conn,
539 exercise,
540 user,
541 &[file_id],
542 AnswerUploadOrigin::Iframe,
543 )
544 .await
545 .expect("binding");
546 file_id
547 }
548
549 fn file_answer(exercise_task_id: Uuid, data_files: Vec<Uuid>) -> StudentExerciseTaskSubmission {
550 StudentExerciseTaskSubmission::files(
551 exercise_task_id,
552 data_files,
553 Some(serde_json::json!({ "plugin": "said so" })),
554 )
555 }
556
557 async fn submit(
558 conn: &mut PgConnection,
559 fixture: &Fixture,
560 answer: StudentExerciseTaskSubmission,
561 file_store: &dyn FileStore,
562 ) -> Result<models::library::grading::StudentExerciseSlideSubmissionResult, ControllerError>
563 {
564 let exercise = models::exercises::get_by_id(conn, fixture.exercise)
565 .await
566 .expect("exercise");
567 process_submission(
568 conn,
569 fixture.user,
570 exercise,
571 &StudentExerciseSlideSubmission {
572 exercise_slide_id: fixture.slide,
573 exercise_task_submissions: vec![answer],
574 },
575 Arc::new(crate::domain::models_requests::JwtKey::test_key()),
576 file_store,
577 &init_app_conf().expect("app conf"),
578 )
579 .await
580 }
581
582 async fn slide_submission_count(conn: &mut PgConnection, exercise: Uuid, user: Uuid) -> u32 {
583 models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_and_user_ids(conn, exercise, user)
584 .await
585 .expect("count")
586 }
587
588 async fn answer_kind_of(conn: &mut PgConnection, submission: Uuid) -> AnswerKind {
589 models::exercise_task_submissions::get_answer_kind(conn, submission)
590 .await
591 .expect("answer kind")
592 }
593
594 async fn recorded_files(conn: &mut PgConnection, submission: Uuid) -> Vec<(Uuid, i32)> {
595 models::exercise_task_submission_files::get_positions_by_task_submission_id(
596 conn, submission,
597 )
598 .await
599 .expect("submission files")
600 }
601
602 async fn binding_id_of(conn: &mut PgConnection, file_upload_id: Uuid) -> Uuid {
603 models::exercise_answer_uploads::get_id_by_file_upload_id(conn, file_upload_id)
604 .await
605 .expect("binding id")
606 }
607
608 async fn reap(conn: &mut PgConnection, file_upload_id: Uuid) {
609 models::exercise_answer_uploads::delete_by_file_upload_id(conn, file_upload_id)
610 .await
611 .expect("reap");
612 }
613
614 fn stub_grading() -> ExerciseTaskGradingResult {
615 ExerciseTaskGradingResult {
616 grading_progress: GradingProgress::FullyGraded,
617 score_given: 1.0,
618 score_maximum: 1,
619 feedback_text: Some("graded by the stub".to_string()),
620 feedback_json: None,
621 set_user_variables: None,
622 }
623 }
624
625 struct StubState {
626 grade_requests: Mutex<Vec<serde_json::Value>>,
627 hold_grading: Option<Arc<tokio::sync::Semaphore>>,
630 }
631
632 async fn stub_grade(
633 state: web::Data<StubState>,
634 body: web::Json<serde_json::Value>,
635 ) -> HttpResponse {
636 state
637 .grade_requests
638 .lock()
639 .expect("stub lock")
640 .push(body.into_inner());
641 if let Some(hold) = &state.hold_grading {
642 hold.acquire().await.expect("hold permit").forget();
643 }
644 HttpResponse::Ok().json(stub_grading())
645 }
646
647 fn start_exercise_service_stub(state: Arc<StubState>) -> String {
651 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
652 let port = listener.local_addr().expect("local addr").port();
653 let server = actix_web::HttpServer::new(move || {
654 actix_web::App::new()
655 .app_data(web::Data::from(state.clone()))
656 .route("/grade", web::post().to(stub_grade))
657 })
658 .workers(1)
659 .disable_signals()
660 .listen(listener)
661 .expect("listen")
662 .run();
663 actix_web::rt::spawn(server);
664 format!("http://127.0.0.1:{port}")
665 }
666
667 async fn assert_rejected_without_a_submission(
670 conn: &mut PgConnection,
671 fixture: &Fixture,
672 answer: StudentExerciseTaskSubmission,
673 expected_message_key: &str,
674 ) {
675 let store = temp_file_store();
676 let error = submit(conn, fixture, answer, &store)
677 .await
678 .expect_err("the submit must be rejected");
679 assert_eq!(message_key_of(&error), expected_message_key);
680 assert_eq!(
681 slide_submission_count(conn, fixture.exercise, fixture.user).await,
682 0
683 );
684 }
685
686 macro_rules! fixture {
687 ($tx:ident, $fixture:ident) => {
688 fixture!($tx, $fixture, _state);
689 };
690 ($tx:ident, $fixture:ident, $state:ident) => {
691 let $state = Arc::new(StubState {
692 grade_requests: Mutex::new(Vec::new()),
693 hold_grading: None,
694 });
695 let url = start_exercise_service_stub($state.clone());
696 insert_data!(:$tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
697 let task = insert_graded_task($tx.as_mut(), slide, url, 1).await;
698 enroll_and_open($tx.as_mut(), user, course, instance.id, exercise, slide).await;
699 let $fixture = Fixture {
700 user,
701 course,
702 chapter,
703 page,
704 exercise,
705 slide,
706 task,
707 };
708 };
709 }
710
711 #[actix_web::test]
712 async fn naming_another_users_upload_is_rejected_and_creates_no_submission() {
713 fixture!(tx, fixture);
714 let stranger = models::users::insert(
715 tx.as_mut(),
716 models::PKeyPolicy::Generate,
717 &format!("{}@example.com", Uuid::new_v4()),
718 None,
719 None,
720 )
721 .await
722 .expect("stranger");
723 let theirs = bind_upload(tx.as_mut(), fixture.exercise, stranger, "theirs.txt").await;
724
725 assert_rejected_without_a_submission(
726 tx.as_mut(),
727 &fixture,
728 file_answer(fixture.task, vec![theirs]),
729 "unknown_upload",
730 )
731 .await;
732 tx.rollback().await;
733 }
734
735 #[actix_web::test]
736 async fn naming_another_exercises_upload_is_rejected_and_creates_no_submission() {
737 fixture!(tx, fixture);
738 let other_exercise = models::exercises::insert(
739 tx.as_mut(),
740 models::PKeyPolicy::Generate,
741 fixture.course,
742 "other",
743 fixture.page,
744 fixture.chapter,
745 1,
746 )
747 .await
748 .expect("second exercise");
749 let elsewhere =
750 bind_upload(tx.as_mut(), other_exercise, fixture.user, "elsewhere.txt").await;
751
752 assert_rejected_without_a_submission(
753 tx.as_mut(),
754 &fixture,
755 file_answer(fixture.task, vec![elsewhere]),
756 "unknown_upload",
757 )
758 .await;
759 tx.rollback().await;
760 }
761
762 #[actix_web::test]
763 async fn naming_the_same_upload_twice_is_rejected() {
764 fixture!(tx, fixture);
765 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "once.txt").await;
766
767 assert_rejected_without_a_submission(
768 tx.as_mut(),
769 &fixture,
770 file_answer(fixture.task, vec![file, file]),
771 "duplicate_upload",
772 )
773 .await;
774 tx.rollback().await;
775 }
776
777 #[actix_web::test]
781 async fn naming_the_same_upload_from_two_different_tasks_is_rejected() {
782 fixture!(tx, fixture, state);
783 let other_task = insert_graded_task(
784 tx.as_mut(),
785 fixture.slide,
786 start_exercise_service_stub(state.clone()),
787 2,
788 )
789 .await;
790 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "shared.txt").await;
791 let store = temp_file_store();
792 let exercise = models::exercises::get_by_id(tx.as_mut(), fixture.exercise)
793 .await
794 .expect("exercise");
795
796 let error = process_submission(
797 tx.as_mut(),
798 fixture.user,
799 exercise,
800 &StudentExerciseSlideSubmission {
801 exercise_slide_id: fixture.slide,
802 exercise_task_submissions: vec![
803 file_answer(fixture.task, vec![file]),
804 file_answer(other_task, vec![file]),
805 ],
806 },
807 Arc::new(crate::domain::models_requests::JwtKey::test_key()),
808 &store,
809 &init_app_conf().expect("app conf"),
810 )
811 .await
812 .expect_err("naming the same upload from two tasks must be rejected");
813 assert_eq!(message_key_of(&error), "duplicate_upload");
814 assert_eq!(
815 slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
816 0
817 );
818 assert!(
819 state.grade_requests.lock().expect("stub lock").is_empty(),
820 "the answer must be refused at the edge, before the exercise service is asked anything"
821 );
822 tx.rollback().await;
823 }
824
825 #[actix_web::test]
828 async fn a_file_answer_naming_no_files_is_refused() {
829 use actix_web::ResponseError;
830 use actix_web::http::StatusCode;
831 fixture!(tx, fixture, state);
832 let store = temp_file_store();
833
834 let error = submit(
835 tx.as_mut(),
836 &fixture,
837 file_answer(fixture.task, vec![]),
838 &store,
839 )
840 .await
841 .expect_err("a file answer naming nothing must be refused");
842 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
843 assert_eq!(
844 slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
845 0
846 );
847 assert!(
848 state.grade_requests.lock().expect("stub lock").is_empty(),
849 "the answer must be refused at the edge, before the exercise service is asked anything"
850 );
851 tx.rollback().await;
852 }
853
854 #[actix_web::test]
855 async fn naming_a_reaped_upload_is_rejected_as_expired() {
856 fixture!(tx, fixture);
857 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "gone.txt").await;
858 reap(tx.as_mut(), file).await;
859
860 assert_rejected_without_a_submission(
861 tx.as_mut(),
862 &fixture,
863 file_answer(fixture.task, vec![file]),
864 "upload_expired",
865 )
866 .await;
867 tx.rollback().await;
868 }
869
870 #[actix_web::test]
874 async fn a_legitimate_file_answer_lands_ordered_with_its_metadata() {
875 fixture!(tx, fixture);
876 let first = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "first.txt").await;
877 let second = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "second.txt").await;
878 let named = vec![second, first];
879 let store = temp_file_store();
880
881 let result = submit(
882 tx.as_mut(),
883 &fixture,
884 file_answer(fixture.task, named.clone()),
885 &store,
886 )
887 .await
888 .expect("the submit must be accepted");
889
890 let submission = result
891 .exercise_task_submission_results
892 .into_iter()
893 .next()
894 .expect("one task submission")
895 .submission;
896 assert_eq!(
897 answer_kind_of(tx.as_mut(), submission.id).await,
898 AnswerKind::File
899 );
900 assert_eq!(
901 recorded_files(tx.as_mut(), submission.id).await,
902 vec![(second, 0), (first, 1)]
903 );
904 assert_eq!(submission.answer_kind, AnswerKind::File);
905 let files = submission
906 .data_files
907 .expect("a file answer comes back with its files");
908 assert_eq!(
909 files.iter().map(|file| file.id).collect::<Vec<_>>(),
910 named,
911 "the plugin's order is the answer, not ours to sort"
912 );
913 assert_eq!(
914 files
915 .iter()
916 .map(|file: &AnswerFile| file.name.as_str())
917 .collect::<Vec<_>>(),
918 vec!["second.txt", "first.txt"]
919 );
920 assert_eq!(
921 submission.data_json,
922 Some(serde_json::json!({ "plugin": "said so" }))
923 );
924 tx.rollback().await;
925 }
926
927 #[actix_web::test]
934 async fn a_concurrent_reaper_blocks_on_the_submit_lock_and_then_declines_to_reap() {
935 let hold = Arc::new(tokio::sync::Semaphore::new(0));
936 let state = Arc::new(StubState {
937 grade_requests: Mutex::new(Vec::new()),
938 hold_grading: Some(hold.clone()),
939 });
940 let url = start_exercise_service_stub(state.clone());
941
942 insert_data!(:tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
945 let task = insert_graded_task(tx.as_mut(), slide, url, 1).await;
946 enroll_and_open(tx.as_mut(), user, course, instance.id, exercise, slide).await;
947 let file = bind_upload(tx.as_mut(), exercise, user, "raced.txt").await;
948 let binding = binding_id_of(tx.as_mut(), file).await;
949 tx.commit().await;
950
951 let fixture = Fixture {
952 user,
953 course,
954 chapter,
955 page,
956 exercise,
957 slide,
958 task,
959 };
960 let submitting = actix_web::rt::spawn(async move {
961 let mut conn = PgConnection::connect(&dev_database_url())
962 .await
963 .expect("submit connection");
964 let store = temp_file_store();
965 submit(
966 &mut conn,
967 &fixture,
968 file_answer(fixture.task, vec![file]),
969 &store,
970 )
971 .await
972 .map(|result| {
973 result
974 .exercise_task_submission_results
975 .into_iter()
976 .next()
977 .expect("one task submission")
978 .submission
979 .id
980 })
981 });
982
983 for _ in 0..100 {
985 if !state.grade_requests.lock().expect("stub lock").is_empty() {
986 break;
987 }
988 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
989 }
990 assert_eq!(
991 state.grade_requests.lock().expect("stub lock").len(),
992 1,
993 "the submit must reach the grading hop, which is what holds its transaction open"
994 );
995
996 let mut reaper_conn = PgConnection::connect(&dev_database_url())
997 .await
998 .expect("reaper connection");
999 let outcome = {
1001 let mut reaping = std::pin::pin!(models::exercise_answer_uploads::mark_reaped(
1002 &mut reaper_conn,
1003 binding
1004 ));
1005 assert!(
1006 tokio::time::timeout(std::time::Duration::from_millis(500), &mut reaping)
1007 .await
1008 .is_err(),
1009 "the reaper must block on the row lock the submit holds, not decide without it"
1010 );
1011
1012 hold.add_permits(1);
1013 let submission = submitting
1014 .await
1015 .expect("the submit task must not panic")
1016 .expect("the submit must be accepted");
1017
1018 let reaped = tokio::time::timeout(std::time::Duration::from_secs(10), &mut reaping)
1019 .await
1020 .expect("the reaper must unblock once the submit commits")
1021 .expect("mark_reaped");
1022 (reaped, submission)
1023 };
1024 let (reaped, submission) = outcome;
1025 assert!(
1026 !reaped,
1027 "the reaper must decline an upload the submit referenced while it waited"
1028 );
1029
1030 let mut check_conn = Conn::init().await;
1031 let mut check_tx = check_conn.begin().await;
1032 assert_eq!(
1033 recorded_files(check_tx.as_mut(), submission).await,
1034 vec![(file, 0)],
1035 "the submission must keep the file the reaper tried to take"
1036 );
1037 assert_eq!(
1038 models::exercise_answer_uploads::get_for_exercise_and_user(
1039 check_tx.as_mut(),
1040 exercise,
1041 user,
1042 &[file]
1043 )
1044 .await
1045 .expect("binding lookup"),
1046 vec![models::exercise_answer_uploads::AnswerUpload {
1047 file_upload_id: file,
1048 deleted: false
1049 }],
1050 "the upload must stay usable, so a download can still serve it"
1051 );
1052 check_tx.rollback().await;
1053 }
1054}