//! Per-recording delete endpoint: POST /web/cases/{case_id}/recordings/delete. //! //! Verifies the endpoint: //! - removes the audio file and its transcript / duration sidecars //! - 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) use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use doctate_server::config::{Config, User}; use tower::util::ServiceExt; fn make_user(slug: &str, password_plain: &str) -> User { User { slug: slug.into(), api_key: format!("key-{slug}"), web_password: bcrypt::hash(password_plain, 4).unwrap(), role: "doctor".into(), whisper: Default::default(), retention: Default::default(), window_hours: 72, preview_lines: 2, } } fn test_config(users: Vec) -> Arc { let data_path = std::env::temp_dir().join(format!( "doctate-delete-rec-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); let api_keys: HashMap = users .iter() .map(|u| (u.api_key.clone(), u.slug.clone())) .collect(); Arc::new(Config { data_path, users, api_keys, ..Config::test_default() }) } fn extract_session_cookie(resp: &axum::response::Response) -> Option { for v in resp.headers().get_all(header::SET_COOKIE).iter() { let s = v.to_str().ok()?; if let Some(pair) = s.split(';').next() && pair.starts_with("session=") { return Some(pair.to_string()); } } None } fn login_request(slug: &str, password: &str) -> Request { let body = format!("slug={slug}&password={password}"); Request::builder() .method("POST") .uri("/web/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap() } async fn login(app: &axum::Router, slug: &str, password: &str) -> String { let resp = app .clone() .oneshot(login_request(slug, password)) .await .unwrap(); extract_session_cookie(&resp).expect("login should set session cookie") } fn seed_case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { let case_dir = data_path.join(slug).join(case_id); std::fs::create_dir_all(&case_dir).unwrap(); case_dir } /// Write `.m4a` + matching `.transcript.txt` + `.duration.txt`, all /// with placeholder content. Returns the audio filename (no path). fn seed_recording(case_dir: &Path, ts_stem: &str, transcript: &str) -> String { let filename = format!("{ts_stem}.m4a"); std::fs::write(case_dir.join(&filename), b"fake audio").unwrap(); std::fs::write( case_dir.join(format!("{ts_stem}.transcript.txt")), transcript, ) .unwrap(); std::fs::write(case_dir.join(format!("{ts_stem}.duration.txt")), "42").unwrap(); filename } fn delete_request(case_id: &str, filename: &str, cookie: &str) -> Request { let body = format!("filename={filename}"); Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/recordings/delete")) .header(header::COOKIE, cookie) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap() } #[tokio::test] async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { let config = test_config(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case_dir(&data_path, "dr_a", case_id); let victim = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "delete me"); let survivor = seed_recording(&case_dir, "2026-04-19T11-00-00Z", "keep me"); // Derived artefacts the delete must also purge. std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); std::fs::write(case_dir.join("document.md"), b"# stale").unwrap(); std::fs::write(case_dir.join("analysis_input.json"), b"{}").unwrap(); let app = doctate_server::create_router(config); let cookie = login(&app, "dr_a", "s").await; let resp = app .clone() .oneshot(delete_request(case_id, &victim, &cookie)) .await .unwrap(); assert_eq!( resp.status().as_u16() / 100, 3, "delete should redirect (3xx), got {}", resp.status() ); // Victim + its sidecars are gone. assert!(!case_dir.join(&victim).exists(), "m4a should be deleted"); assert!( !case_dir .join("2026-04-19T10-00-00Z.transcript.txt") .exists(), "transcript sidecar should be deleted" ); assert!( !case_dir.join("2026-04-19T10-00-00Z.duration.txt").exists(), "duration 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.transcript.txt") .exists(), "other transcript must remain" ); // Derived artefacts are invalidated. assert!( !case_dir.join("oneliner.json").exists(), "oneliner.json must be cleared" ); assert!( !case_dir.join("document.md").exists(), "document.md must be cleared" ); assert!( !case_dir.join("analysis_input.json").exists(), "analysis_input.json must be cleared" ); } #[tokio::test] async fn delete_recording_rejects_path_traversal() { let config = test_config(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "22222222-2222-2222-2222-222222222222"; let case_dir = seed_case_dir(&data_path, "dr_a", case_id); seed_recording(&case_dir, "2026-04-19T10-00-00Z", "safe"); let app = doctate_server::create_router(config); let cookie = login(&app, "dr_a", "s").await; let resp = app .clone() .oneshot(delete_request(case_id, "../../../etc/passwd.m4a", &cookie)) .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 config = test_config(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "33333333-3333-3333-3333-333333333333"; seed_case_dir(&data_path, "dr_a", case_id); let app = doctate_server::create_router(config); let cookie = login(&app, "dr_a", "s").await; let resp = app .clone() .oneshot(delete_request(case_id, "document.md", &cookie)) .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 config = test_config(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]); let data_path = config.data_path.clone(); let case_id = "44444444-4444-4444-4444-444444444444"; let case_dir = seed_case_dir(&data_path, "dr_b", case_id); let filename = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "bob's recording"); let app = doctate_server::create_router(config); let cookie_a = login(&app, "dr_a", "s").await; let resp = app .clone() .oneshot(delete_request(case_id, &filename, &cookie_a)) .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 config = test_config(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "55555555-5555-5555-5555-555555555555"; let case_dir = seed_case_dir(&data_path, "dr_a", case_id); let only = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "lonely"); std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); let app = doctate_server::create_router(config); let cookie = login(&app, "dr_a", "s").await; let resp = app .clone() .oneshot(delete_request(case_id, &only, &cookie)) .await .unwrap(); assert_eq!( resp.status().as_u16() / 100, 3, "last-recording delete should still redirect, got {}", resp.status() ); assert!(!case_dir.join(&only).exists()); assert!(!case_dir.join("oneliner.json").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 config = test_config(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "66666666-6666-6666-6666-666666666666"; let case_dir = seed_case_dir(&data_path, "dr_a", case_id); std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a.failed"), b"x").unwrap(); let app = doctate_server::create_router(config); let cookie = login(&app, "dr_a", "s").await; let resp = app .clone() .oneshot(delete_request( case_id, "2026-04-19T10-00-00Z.m4a.failed", &cookie, )) .await .unwrap(); assert_eq!(resp.status().as_u16() / 100, 3); assert!(!case_dir.join("2026-04-19T10-00-00Z.m4a.failed").exists()); }