76bf65be1a
This commit replaces the disk-based `analysis_input.json` marker for in-progress analysis jobs with an in-memory `Arc<RwLock<HashSet<PathBuf>>>`. Previously, the existence of `analysis_input.json` was used as a signal that a case was queued or being analyzed. This led to stale "Queued" states after server restarts, as the disk file would persist while the server process tracking it had vanished. The new `InFlight` struct, held in `AppState`, provides a process-local marker. This ensures that: - Server restarts correctly reset the state, as the `HashSet` is cleared. - Race conditions for triggering analysis are handled by the `try_claim` method, replacing the previous reliance on file system `O_EXCL` flags. - The `AnalyzeJob` payload now travels with the job over the channel, eliminating the need for workers to re-read `analysis_input.json` from disk. This change simplifies state management and improves the reliability of analysis job tracking.
309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
//! Per-recording delete endpoint: POST /cases/{case_id}/recordings/delete.
|
|
//!
|
|
//! Verifies the endpoint:
|
|
//! - removes the audio file and its `<stem>.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 <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());
|
|
}
|
|
|
|
#[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:?}"),
|
|
}
|
|
}
|