//! Per-recording delete endpoint: POST /cases/{case_id}/recordings/delete. //! //! Verifies the endpoint: //! - removes the audio file and its `.json` metadata sidecar //! - invalidates derived artefacts (oneliner.json, document.md, analysis_input.json) //! - leaves other recordings in the same case untouched //! - rejects path-traversal filenames with 400 //! - enforces case ownership (returns 404 on cross-user access) mod common; use axum::http::StatusCode; use doctate_common::oneliners::OnelinerState; use tower::util::ServiceExt; use common::{ DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post, login_with_csrf, paths, seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording_with_sidecars, test_user, write_document_for_test, }; fn delete_body(filename: &str) -> String { format!("filename={filename}") } #[tokio::test] async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let victim = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me"); let survivor = seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me"); // Derived artefacts the delete must also purge. The oneliner is // seeded as `Ready` (a real LLM-derived state) so the new sticky- // Manual wrapper sees a non-Manual variant and proceeds with the // delete — keeps the original contract „auto state must be wiped". seed_oneliner_ready(&case_dir, "knee, left, pain"); write_document_for_test(&case_dir, "# stale", "1970-01-01T00:00:00Z"); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie, &csrf, &delete_body(&victim), )) .await .unwrap(); assert_eq!( resp.status().as_u16() / 100, 3, "delete should redirect (3xx), got {}", resp.status() ); let expected_location = format!("/cases/{case_id}/recordings"); assert_eq!( resp.headers() .get(axum::http::header::LOCATION) .and_then(|v| v.to_str().ok()), Some(expected_location.as_str()), "delete should redirect back to the recording list, not the case list" ); // Victim + its sidecar are gone. assert!(!case_dir.join(&victim).exists(), "m4a should be deleted"); assert!( !case_dir.join("2026-04-19T10-00-00Z.json").exists(), "metadata sidecar should be deleted" ); // Survivor is intact. assert!(case_dir.join(&survivor).exists(), "other m4a must remain"); assert!( case_dir.join("2026-04-19T11-00-00Z.json").exists(), "other metadata sidecar must remain" ); // Derived artefacts are invalidated. assert!( !case_dir.join(ONELINER_FILENAME).exists(), "oneliner.json must be cleared" ); assert!( !case_dir.join(DOCUMENT_FILE).exists(), "document.md must be cleared" ); } #[tokio::test] async fn delete_recording_rejects_path_traversal() { let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "22222222-2222-2222-2222-222222222222"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "safe"); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie, &csrf, &delete_body("../../../etc/passwd.m4a"), )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "path traversal must be rejected" ); // Safe recording is untouched. assert!(case_dir.join("2026-04-19T10-00-00Z.m4a").exists()); } #[tokio::test] async fn delete_recording_rejects_wrong_extension() { let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "33333333-3333-3333-3333-333333333333"; seed_case(&cfg.data_path, "dr_a", case_id); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie, &csrf, &delete_body(DOCUMENT_FILE), )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "non-audio extension must be rejected" ); } #[tokio::test] async fn delete_recording_cross_user_returns_404() { // User B owns the case; User A tries to delete a recording in it. let cfg = TestConfig::new() .with_user(test_user("dr_a")) .with_user(test_user("dr_b")) .build(); let case_id = "44444444-4444-4444-4444-444444444444"; let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); let filename = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "bob's recording"); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie_a, csrf_a) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie_a, &csrf_a, &delete_body(&filename), )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "cross-user access must return 404, not 403 (IDOR hardening)" ); assert!( case_dir.join(&filename).exists(), "Bob's file must not have been touched" ); } #[tokio::test] async fn delete_last_recording_clears_case() { // Deleting the only recording must succeed, not error out. let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "55555555-5555-5555-5555-555555555555"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let only = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "lonely"); std::fs::write(case_dir.join(ONELINER_FILENAME), b"{}").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie, &csrf, &delete_body(&only), )) .await .unwrap(); assert_eq!( resp.status().as_u16() / 100, 3, "last-recording delete should still redirect, got {}", resp.status() ); let expected_location = format!("/cases/{case_id}/recordings"); assert_eq!( resp.headers() .get(axum::http::header::LOCATION) .and_then(|v| v.to_str().ok()), Some(expected_location.as_str()), "last-recording delete should still redirect to the recording list" ); assert!(!case_dir.join(&only).exists()); assert!(!case_dir.join(ONELINER_FILENAME).exists()); } #[tokio::test] async fn delete_recording_accepts_failed_suffix() { // Recordings whose transcription failed are renamed to .m4a.failed. // The delete button is shown for them too — they should be deletable. let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "66666666-6666-6666-6666-666666666666"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a.failed"), b"x").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie, &csrf, &delete_body("2026-04-19T10-00-00Z.m4a.failed"), )) .await .unwrap(); assert_eq!(resp.status().as_u16() / 100, 3); assert!(!case_dir.join("2026-04-19T10-00-00Z.m4a.failed").exists()); } #[tokio::test] async fn delete_recording_preserves_manual_oneliner_override() { // Bug regression: deleting a recording must NOT wipe a clinician's // manual oneliner override. The Manual variant is sticky for the // lifetime of the case — only `purge-closed` (whole-case delete) // is allowed to remove it. let cfg = TestConfig::new().with_user(test_user("dr_a")).build(); let case_id = "77777777-7777-7777-7777-777777777777"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let victim = seed_recording_with_sidecars(&case_dir, "2026-04-26T10-00-00Z", "raw text"); seed_recording_with_sidecars(&case_dir, "2026-04-26T11-00-00Z", "more raw text"); let manual_text = "55 J., Knieschmerz links — manuell vom Arzt"; seed_oneliner_state( &case_dir, &OnelinerState::Manual { text: manual_text.into(), set_at: "2026-04-26T12:00:00Z".into(), }, ); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; let resp = app .clone() .oneshot(csrf_form_post( paths::recording_delete(case_id), &cookie, &csrf, &delete_body(&victim), )) .await .unwrap(); assert_eq!( resp.status().as_u16() / 100, 3, "delete should still redirect on success" ); // Recording is gone… assert!(!case_dir.join(&victim).exists(), "victim m4a deleted"); // …but the Manual override is untouched. let bytes = std::fs::read(case_dir.join(ONELINER_FILENAME)) .expect("oneliner.json must still exist after recording delete"); let on_disk: OnelinerState = serde_json::from_slice(&bytes).expect("oneliner.json must still be valid JSON"); match on_disk { OnelinerState::Manual { text, .. } => { assert_eq!(text, manual_text, "manual text must be byte-identical"); } other => panic!("expected Manual after recording delete, got {other:?}"), } }