feat: Add endpoint to delete recordings
This commit introduces a new API endpoint for deleting recordings. The endpoint handles the removal of the audio file and its associated sidecar files (transcript, duration). It also invalidates derived artifacts like oneliner.json, document.md, and analysis_input.json. Additionally, it includes checks for path traversal, correct file extensions, and case ownership to ensure security and data integrity. A new `RecordingDeleted` event is also added to the `CaseEventKind` enum.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(users: Vec<User>) -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-delete-rec-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let api_keys: HashMap<String, String> = 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<String> {
|
||||
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<Body> {
|
||||
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 `<ts>.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<Body> {
|
||||
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 <ts>.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());
|
||||
}
|
||||
@@ -55,6 +55,8 @@ fn build_state() -> (AppState, Arc<Config>) {
|
||||
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
||||
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
||||
events_tx: doctate_server::events::channel(),
|
||||
http_client: reqwest::Client::new(),
|
||||
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
||||
};
|
||||
|
||||
(state, config)
|
||||
|
||||
Reference in New Issue
Block a user