diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index e53ac44..c3b3d44 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -892,6 +892,7 @@ pub async fn handle_case_recordings( State(http_client): State, State(vocab): State>, CaseIdPath(case_id): CaseIdPath, + Query(query): Query, ) -> Result, AppError> { let user_root = config.data_path.join(&user.slug); pipeline @@ -905,13 +906,27 @@ pub async fn handle_case_recordings( ) .await; - let case_dir = locate_case_or_404( - &user_root, - &case_id, - &user.slug, - "case recordings (possible IDOR probe)", - ) - .await?; + // Closing a case only writes a `.closed` marker; the `.m4a` recordings + // stay on disk. A user who opted into viewing closed cases must still + // reach this list, so branch on `show_closed=1` exactly like + // `handle_case_page` does. + let case_dir = if query.include_closed() { + locate_closed_case_or_404( + &user_root, + &case_id, + &user.slug, + "case recordings (show_closed=1)", + ) + .await? + } else { + locate_case_or_404( + &user_root, + &case_id, + &user.slug, + "case recordings (possible IDOR probe)", + ) + .await? + }; let case_id_str = case_id.to_string(); info!(slug = %user.slug, case_id = %case_id, "case recordings viewed"); diff --git a/server/templates/case_page.html b/server/templates/case_page.html index da2c425..3ad2c7e 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -744,7 +744,7 @@
→ Aufnahmen anzeigen ({{ recordings_count }})
diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 29d5bde..9e3816b 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -7,7 +7,8 @@ use doctate_common::oneliners::OnelinerState; use common::{ TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, - seed_oneliner_state, seed_recording, test_admin, test_user, write_document_for_test, + seed_oneliner_state, seed_recording, test_admin, test_user, write_closed_marker, + write_document_for_test, }; /// Password "s" is the shared test default; wrap `login` so call sites @@ -351,6 +352,45 @@ async fn case_recordings_shows_all_recordings() { assert!(body.contains("Transkription ausstehend")); } +/// Property: the recordings sub-page must honour `?show_closed=1` exactly +/// like the case detail page does. Closing a case only sets a `.closed` +/// marker — the `.m4a` recordings stay on disk — so a user who opted into +/// viewing closed cases must still be able to open the recordings list. +/// +/// Regression for issue #17: `handle_case_recordings` resolves the case via +/// `locate_case_or_404`, which returns `None` for any case carrying the +/// `.closed` marker, yielding 404 `{"error":"Case not found"}` and an +/// apparently empty page — even when `show_closed=1` is requested. +#[tokio::test] +async fn case_recordings_honours_show_closed_for_closed_case() { + let cfg = TestConfig::new() + .with_label("rec-closed") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme")); + // Close the case: only a marker is written; the recording stays put. + write_closed_marker(&case_dir, "2026-04-15T12:00:00Z"); + + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; + + let url = format!("{}?show_closed=1", paths::case_recordings(case_id)); + let resp = app.oneshot(get_with_cookie(&url, &cookie)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "recordings page must render for a closed case under show_closed=1, not 404", + ); + let body = body_string(resp).await; + assert!( + body.contains("2026-04-15T10-00-00Z.m4a"), + "recording still on disk must be listed for the closed case" + ); +} + #[tokio::test] async fn case_recordings_back_link_targets_case_page() { let cfg = TestConfig::new()