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 },
454 )
455 .await
456 .expect("exercise service");
457 models::exercise_service_info::insert(
458 conn,
459 &models::exercise_service_info::PathInfo {
460 exercise_service_id: service.id,
461 user_interface_iframe_path: "/iframe".to_string(),
462 grade_endpoint_path: "/grade".to_string(),
463 public_spec_endpoint_path: "/public-spec".to_string(),
464 model_solution_spec_endpoint_path: "/model-solution".to_string(),
465 has_custom_view: false,
466 supports_native_client: false,
467 produces_file_answers: false,
468 declares_spec_files: false,
469 },
470 )
471 .await
472 .expect("service info");
473 models::exercise_tasks::insert(
474 conn,
475 models::PKeyPolicy::Generate,
476 models::exercise_tasks::NewExerciseTask {
477 exercise_slide_id: slide,
478 exercise_type: slug,
479 assignment: vec![],
480 public_spec: Some(serde_json::Value::Null),
481 private_spec: Some(serde_json::Value::Null),
482 model_solution_spec: Some(serde_json::Value::Null),
483 order_number,
484 },
485 )
486 .await
487 .expect("exercise task")
488 }
489
490 async fn enroll_and_open(
493 conn: &mut PgConnection,
494 user: Uuid,
495 course: Uuid,
496 instance: Uuid,
497 exercise: Uuid,
498 slide: Uuid,
499 ) {
500 models::course_instance_enrollments::insert_enrollment_and_set_as_current(
501 conn,
502 models::course_instance_enrollments::NewCourseInstanceEnrollment {
503 course_id: course,
504 user_id: user,
505 course_instance_id: instance,
506 },
507 )
508 .await
509 .expect("enrollment");
510 models::user_exercise_states::upsert_selected_exercise_slide_id(
511 conn,
512 user,
513 exercise,
514 Some(course),
515 None,
516 Some(slide),
517 )
518 .await
519 .expect("exercise state");
520 }
521
522 async fn bind_upload(conn: &mut PgConnection, exercise: Uuid, user: Uuid, name: &str) -> Uuid {
525 let file_id = models::file_uploads::insert(
526 conn,
527 name,
528 &format!("exercise-answer-uploads/{}", Uuid::new_v4()),
529 "application/octet-stream",
530 Some(user),
531 Some(3),
532 )
533 .await
534 .expect("file upload");
535 models::exercise_answer_uploads::insert_many(
536 conn,
537 exercise,
538 user,
539 &[file_id],
540 AnswerUploadOrigin::Iframe,
541 )
542 .await
543 .expect("binding");
544 file_id
545 }
546
547 fn file_answer(exercise_task_id: Uuid, data_files: Vec<Uuid>) -> StudentExerciseTaskSubmission {
548 StudentExerciseTaskSubmission::files(
549 exercise_task_id,
550 data_files,
551 Some(serde_json::json!({ "plugin": "said so" })),
552 )
553 }
554
555 async fn submit(
556 conn: &mut PgConnection,
557 fixture: &Fixture,
558 answer: StudentExerciseTaskSubmission,
559 file_store: &dyn FileStore,
560 ) -> Result<models::library::grading::StudentExerciseSlideSubmissionResult, ControllerError>
561 {
562 let exercise = models::exercises::get_by_id(conn, fixture.exercise)
563 .await
564 .expect("exercise");
565 process_submission(
566 conn,
567 fixture.user,
568 exercise,
569 &StudentExerciseSlideSubmission {
570 exercise_slide_id: fixture.slide,
571 exercise_task_submissions: vec![answer],
572 },
573 Arc::new(crate::domain::models_requests::JwtKey::test_key()),
574 file_store,
575 &init_app_conf().expect("app conf"),
576 )
577 .await
578 }
579
580 async fn slide_submission_count(conn: &mut PgConnection, exercise: Uuid, user: Uuid) -> u32 {
581 models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_and_user_ids(conn, exercise, user)
582 .await
583 .expect("count")
584 }
585
586 async fn answer_kind_of(conn: &mut PgConnection, submission: Uuid) -> AnswerKind {
587 models::exercise_task_submissions::get_answer_kind(conn, submission)
588 .await
589 .expect("answer kind")
590 }
591
592 async fn recorded_files(conn: &mut PgConnection, submission: Uuid) -> Vec<(Uuid, i32)> {
593 models::exercise_task_submission_files::get_positions_by_task_submission_id(
594 conn, submission,
595 )
596 .await
597 .expect("submission files")
598 }
599
600 async fn binding_id_of(conn: &mut PgConnection, file_upload_id: Uuid) -> Uuid {
601 models::exercise_answer_uploads::get_id_by_file_upload_id(conn, file_upload_id)
602 .await
603 .expect("binding id")
604 }
605
606 async fn reap(conn: &mut PgConnection, file_upload_id: Uuid) {
607 models::exercise_answer_uploads::delete_by_file_upload_id(conn, file_upload_id)
608 .await
609 .expect("reap");
610 }
611
612 fn stub_grading() -> ExerciseTaskGradingResult {
613 ExerciseTaskGradingResult {
614 grading_progress: GradingProgress::FullyGraded,
615 score_given: 1.0,
616 score_maximum: 1,
617 feedback_text: Some("graded by the stub".to_string()),
618 feedback_json: None,
619 set_user_variables: None,
620 }
621 }
622
623 struct StubState {
624 grade_requests: Mutex<Vec<serde_json::Value>>,
625 hold_grading: Option<Arc<tokio::sync::Semaphore>>,
628 }
629
630 async fn stub_grade(
631 state: web::Data<StubState>,
632 body: web::Json<serde_json::Value>,
633 ) -> HttpResponse {
634 state
635 .grade_requests
636 .lock()
637 .expect("stub lock")
638 .push(body.into_inner());
639 if let Some(hold) = &state.hold_grading {
640 hold.acquire().await.expect("hold permit").forget();
641 }
642 HttpResponse::Ok().json(stub_grading())
643 }
644
645 fn start_exercise_service_stub(state: Arc<StubState>) -> String {
649 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
650 let port = listener.local_addr().expect("local addr").port();
651 let server = actix_web::HttpServer::new(move || {
652 actix_web::App::new()
653 .app_data(web::Data::from(state.clone()))
654 .route("/grade", web::post().to(stub_grade))
655 })
656 .workers(1)
657 .disable_signals()
658 .listen(listener)
659 .expect("listen")
660 .run();
661 actix_web::rt::spawn(server);
662 format!("http://127.0.0.1:{port}")
663 }
664
665 async fn assert_rejected_without_a_submission(
668 conn: &mut PgConnection,
669 fixture: &Fixture,
670 answer: StudentExerciseTaskSubmission,
671 expected_message_key: &str,
672 ) {
673 let store = temp_file_store();
674 let error = submit(conn, fixture, answer, &store)
675 .await
676 .expect_err("the submit must be rejected");
677 assert_eq!(message_key_of(&error), expected_message_key);
678 assert_eq!(
679 slide_submission_count(conn, fixture.exercise, fixture.user).await,
680 0
681 );
682 }
683
684 macro_rules! fixture {
685 ($tx:ident, $fixture:ident) => {
686 fixture!($tx, $fixture, _state);
687 };
688 ($tx:ident, $fixture:ident, $state:ident) => {
689 let $state = Arc::new(StubState {
690 grade_requests: Mutex::new(Vec::new()),
691 hold_grading: None,
692 });
693 let url = start_exercise_service_stub($state.clone());
694 insert_data!(:$tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
695 let task = insert_graded_task($tx.as_mut(), slide, url, 1).await;
696 enroll_and_open($tx.as_mut(), user, course, instance.id, exercise, slide).await;
697 let $fixture = Fixture {
698 user,
699 course,
700 chapter,
701 page,
702 exercise,
703 slide,
704 task,
705 };
706 };
707 }
708
709 #[actix_web::test]
710 async fn naming_another_users_upload_is_rejected_and_creates_no_submission() {
711 fixture!(tx, fixture);
712 let stranger = models::users::insert(
713 tx.as_mut(),
714 models::PKeyPolicy::Generate,
715 &format!("{}@example.com", Uuid::new_v4()),
716 None,
717 None,
718 )
719 .await
720 .expect("stranger");
721 let theirs = bind_upload(tx.as_mut(), fixture.exercise, stranger, "theirs.txt").await;
722
723 assert_rejected_without_a_submission(
724 tx.as_mut(),
725 &fixture,
726 file_answer(fixture.task, vec![theirs]),
727 "unknown_upload",
728 )
729 .await;
730 tx.rollback().await;
731 }
732
733 #[actix_web::test]
734 async fn naming_another_exercises_upload_is_rejected_and_creates_no_submission() {
735 fixture!(tx, fixture);
736 let other_exercise = models::exercises::insert(
737 tx.as_mut(),
738 models::PKeyPolicy::Generate,
739 fixture.course,
740 "other",
741 fixture.page,
742 fixture.chapter,
743 1,
744 )
745 .await
746 .expect("second exercise");
747 let elsewhere =
748 bind_upload(tx.as_mut(), other_exercise, fixture.user, "elsewhere.txt").await;
749
750 assert_rejected_without_a_submission(
751 tx.as_mut(),
752 &fixture,
753 file_answer(fixture.task, vec![elsewhere]),
754 "unknown_upload",
755 )
756 .await;
757 tx.rollback().await;
758 }
759
760 #[actix_web::test]
761 async fn naming_the_same_upload_twice_is_rejected() {
762 fixture!(tx, fixture);
763 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "once.txt").await;
764
765 assert_rejected_without_a_submission(
766 tx.as_mut(),
767 &fixture,
768 file_answer(fixture.task, vec![file, file]),
769 "duplicate_upload",
770 )
771 .await;
772 tx.rollback().await;
773 }
774
775 #[actix_web::test]
779 async fn naming_the_same_upload_from_two_different_tasks_is_rejected() {
780 fixture!(tx, fixture, state);
781 let other_task = insert_graded_task(
782 tx.as_mut(),
783 fixture.slide,
784 start_exercise_service_stub(state.clone()),
785 2,
786 )
787 .await;
788 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "shared.txt").await;
789 let store = temp_file_store();
790 let exercise = models::exercises::get_by_id(tx.as_mut(), fixture.exercise)
791 .await
792 .expect("exercise");
793
794 let error = process_submission(
795 tx.as_mut(),
796 fixture.user,
797 exercise,
798 &StudentExerciseSlideSubmission {
799 exercise_slide_id: fixture.slide,
800 exercise_task_submissions: vec![
801 file_answer(fixture.task, vec![file]),
802 file_answer(other_task, vec![file]),
803 ],
804 },
805 Arc::new(crate::domain::models_requests::JwtKey::test_key()),
806 &store,
807 &init_app_conf().expect("app conf"),
808 )
809 .await
810 .expect_err("naming the same upload from two tasks must be rejected");
811 assert_eq!(message_key_of(&error), "duplicate_upload");
812 assert_eq!(
813 slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
814 0
815 );
816 assert!(
817 state.grade_requests.lock().expect("stub lock").is_empty(),
818 "the answer must be refused at the edge, before the exercise service is asked anything"
819 );
820 tx.rollback().await;
821 }
822
823 #[actix_web::test]
826 async fn a_file_answer_naming_no_files_is_refused() {
827 use actix_web::ResponseError;
828 use actix_web::http::StatusCode;
829 fixture!(tx, fixture, state);
830 let store = temp_file_store();
831
832 let error = submit(
833 tx.as_mut(),
834 &fixture,
835 file_answer(fixture.task, vec![]),
836 &store,
837 )
838 .await
839 .expect_err("a file answer naming nothing must be refused");
840 assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
841 assert_eq!(
842 slide_submission_count(tx.as_mut(), fixture.exercise, fixture.user).await,
843 0
844 );
845 assert!(
846 state.grade_requests.lock().expect("stub lock").is_empty(),
847 "the answer must be refused at the edge, before the exercise service is asked anything"
848 );
849 tx.rollback().await;
850 }
851
852 #[actix_web::test]
853 async fn naming_a_reaped_upload_is_rejected_as_expired() {
854 fixture!(tx, fixture);
855 let file = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "gone.txt").await;
856 reap(tx.as_mut(), file).await;
857
858 assert_rejected_without_a_submission(
859 tx.as_mut(),
860 &fixture,
861 file_answer(fixture.task, vec![file]),
862 "upload_expired",
863 )
864 .await;
865 tx.rollback().await;
866 }
867
868 #[actix_web::test]
872 async fn a_legitimate_file_answer_lands_ordered_with_its_metadata() {
873 fixture!(tx, fixture);
874 let first = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "first.txt").await;
875 let second = bind_upload(tx.as_mut(), fixture.exercise, fixture.user, "second.txt").await;
876 let named = vec![second, first];
877 let store = temp_file_store();
878
879 let result = submit(
880 tx.as_mut(),
881 &fixture,
882 file_answer(fixture.task, named.clone()),
883 &store,
884 )
885 .await
886 .expect("the submit must be accepted");
887
888 let submission = result
889 .exercise_task_submission_results
890 .into_iter()
891 .next()
892 .expect("one task submission")
893 .submission;
894 assert_eq!(
895 answer_kind_of(tx.as_mut(), submission.id).await,
896 AnswerKind::File
897 );
898 assert_eq!(
899 recorded_files(tx.as_mut(), submission.id).await,
900 vec![(second, 0), (first, 1)]
901 );
902 assert_eq!(submission.answer_kind, AnswerKind::File);
903 let files = submission
904 .data_files
905 .expect("a file answer comes back with its files");
906 assert_eq!(
907 files.iter().map(|file| file.id).collect::<Vec<_>>(),
908 named,
909 "the plugin's order is the answer, not ours to sort"
910 );
911 assert_eq!(
912 files
913 .iter()
914 .map(|file: &AnswerFile| file.name.as_str())
915 .collect::<Vec<_>>(),
916 vec!["second.txt", "first.txt"]
917 );
918 assert_eq!(
919 submission.data_json,
920 Some(serde_json::json!({ "plugin": "said so" }))
921 );
922 tx.rollback().await;
923 }
924
925 #[actix_web::test]
932 async fn a_concurrent_reaper_blocks_on_the_submit_lock_and_then_declines_to_reap() {
933 let hold = Arc::new(tokio::sync::Semaphore::new(0));
934 let state = Arc::new(StubState {
935 grade_requests: Mutex::new(Vec::new()),
936 hold_grading: Some(hold.clone()),
937 });
938 let url = start_exercise_service_stub(state.clone());
939
940 insert_data!(:tx, user: user, :org, course: course, instance: instance, :course_module, chapter: chapter, page: page, exercise: exercise, slide: slide, task: _unservable);
943 let task = insert_graded_task(tx.as_mut(), slide, url, 1).await;
944 enroll_and_open(tx.as_mut(), user, course, instance.id, exercise, slide).await;
945 let file = bind_upload(tx.as_mut(), exercise, user, "raced.txt").await;
946 let binding = binding_id_of(tx.as_mut(), file).await;
947 tx.commit().await;
948
949 let fixture = Fixture {
950 user,
951 course,
952 chapter,
953 page,
954 exercise,
955 slide,
956 task,
957 };
958 let submitting = actix_web::rt::spawn(async move {
959 let mut conn = PgConnection::connect(&test_database_url())
960 .await
961 .expect("submit connection");
962 let store = temp_file_store();
963 submit(
964 &mut conn,
965 &fixture,
966 file_answer(fixture.task, vec![file]),
967 &store,
968 )
969 .await
970 .map(|result| {
971 result
972 .exercise_task_submission_results
973 .into_iter()
974 .next()
975 .expect("one task submission")
976 .submission
977 .id
978 })
979 });
980
981 for _ in 0..100 {
983 if !state.grade_requests.lock().expect("stub lock").is_empty() {
984 break;
985 }
986 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
987 }
988 assert_eq!(
989 state.grade_requests.lock().expect("stub lock").len(),
990 1,
991 "the submit must reach the grading hop, which is what holds its transaction open"
992 );
993
994 let mut reaper_conn = PgConnection::connect(&test_database_url())
995 .await
996 .expect("reaper connection");
997 let outcome = {
999 let mut reaping = std::pin::pin!(models::exercise_answer_uploads::mark_reaped(
1000 &mut reaper_conn,
1001 binding
1002 ));
1003 assert!(
1004 tokio::time::timeout(std::time::Duration::from_millis(500), &mut reaping)
1005 .await
1006 .is_err(),
1007 "the reaper must block on the row lock the submit holds, not decide without it"
1008 );
1009
1010 hold.add_permits(1);
1011 let submission = submitting
1012 .await
1013 .expect("the submit task must not panic")
1014 .expect("the submit must be accepted");
1015
1016 let reaped = tokio::time::timeout(std::time::Duration::from_secs(10), &mut reaping)
1017 .await
1018 .expect("the reaper must unblock once the submit commits")
1019 .expect("mark_reaped");
1020 (reaped, submission)
1021 };
1022 let (reaped, submission) = outcome;
1023 assert!(
1024 !reaped,
1025 "the reaper must decline an upload the submit referenced while it waited"
1026 );
1027
1028 let mut check_conn = Conn::init().await;
1029 let mut check_tx = check_conn.begin().await;
1030 assert_eq!(
1031 recorded_files(check_tx.as_mut(), submission).await,
1032 vec![(file, 0)],
1033 "the submission must keep the file the reaper tried to take"
1034 );
1035 assert_eq!(
1036 models::exercise_answer_uploads::get_for_exercise_and_user(
1037 check_tx.as_mut(),
1038 exercise,
1039 user,
1040 &[file]
1041 )
1042 .await
1043 .expect("binding lookup"),
1044 vec![models::exercise_answer_uploads::AnswerUpload {
1045 file_upload_id: file,
1046 deleted: false
1047 }],
1048 "the upload must stay usable, so a download can still serve it"
1049 );
1050 check_tx.rollback().await;
1051 }
1052}