From 6d1ca71d616c902201905d01f186364d02417470 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 1 Jun 2026 01:07:16 +0200 Subject: [PATCH] fix(web): serve closed-case recordings page instead of 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following "Aufnahmen anzeigen" on a closed case returned HTTP 404 {"error":"Case not found"} — an apparently empty page. handle_case_recordings unconditionally resolved the case via locate_case_or_404, which rejects any case carrying the .closed marker, so the recordings (which stay on disk after a close — only a marker is written) became unreachable. Fix mirrors handle_case_page: the handler now takes Query and branches to locate_closed_case_or_404 when show_closed=1 is requested, falling back to the IDOR-guarded locate_case_or_404 otherwise. The in-app link in case_page.html now carries ?show_closed=1 for closed cases (mirroring my_cases.html), so navigation from a closed case's detail page no longer drops the flag and 404s. Reuses the existing CaseListQuery/include_closed() extractor; no parallel type. Minimal fix, no surrounding refactor. RED+GREEN combined: regression test case_recordings_honours_show_closed_for_closed_case in server/tests/case_page_test.rs seeds a closed case with a recording and asserts the route returns 200 with the recording listed, not 404. Full case_page_test suite green (18 passed); cargo clippy clean; cargo fmt clean. closes #17 --- server/src/routes/user_web.rs | 29 +++++++++++++++++------ server/templates/case_page.html | 2 +- server/tests/case_page_test.rs | 42 ++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 9 deletions(-) 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 @@ 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()