Files
doctate/server/tests/delete_recording_test.rs
T
Brummel af3377a6bc refactor(tests): migrate remaining batches to tests/common/
Finishes the lift of shared helpers into `tests/common/`. Covered:
- health, web, oneliners_api, upload (31 tests; upload exercises the
  new `multipart_upload_body` helper driven by doctate_common field
  constants)
- sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled,
  silent_case_empty, failed_only_case_empty_oneliner,
  transient_failure_retries (24 tests)

Also applied cargo fmt across the test tree and fixed one clippy
needless_borrows_for_generic_args warning in analyze_test.

All 387 tests pass; 3 ignored (as before).

Side effect: health_test previously used a hardcoded `/tmp/doctate-test`
data path, which parallel `cargo test` runs could collide on. The
migration replaces it with the common unique-tmpdir pattern, removing
a latent flake.
2026-04-22 10:51:33 +02:00

245 lines
7.9 KiB
Rust

//! 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)
mod common;
use axum::http::StatusCode;
use tower::util::ServiceExt;
use common::{
TestConfig, csrf_form_post, login_with_csrf, paths, seed_case, seed_recording_with_sidecars,
test_user,
};
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.
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, 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()
);
// 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 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.md"),
))
.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.json"), 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()
);
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 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());
}