eaed8f0dcd
- Replace native confirm() in the recording list with an inline
two-step confirm row; hide the player via visibility:hidden
while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
instead of relying on the Referer header (which silently fell
back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
source page (case detail vs. case list) is preserved reliably,
with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
adjust analyze/reset redirect expectations to the new
case-detail fallback.
261 lines
8.6 KiB
Rust
261 lines
8.6 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::{
|
|
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, ONELINER_FILENAME, 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_FILENAME), b"{}").unwrap();
|
|
std::fs::write(case_dir.join(DOCUMENT_FILE), b"# stale").unwrap();
|
|
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), 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()
|
|
);
|
|
let expected_location = format!("/web/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 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_FILENAME).exists(),
|
|
"oneliner.json must be cleared"
|
|
);
|
|
assert!(
|
|
!case_dir.join(DOCUMENT_FILE).exists(),
|
|
"document.md must be cleared"
|
|
);
|
|
assert!(
|
|
!case_dir.join(ANALYSIS_INPUT_FILE).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_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!("/web/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 <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());
|
|
}
|