fix(web): serve closed-case recordings page instead of 404

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<CaseListQuery> 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
This commit is contained in:
2026-06-01 01:07:16 +02:00
parent 8d9fd22bdc
commit 6d1ca71d61
3 changed files with 64 additions and 9 deletions
+22 -7
View File
@@ -892,6 +892,7 @@ pub async fn handle_case_recordings(
State(http_client): State<reqwest::Client>, State(http_client): State<reqwest::Client>,
State(vocab): State<Arc<Gazetteer>>, State(vocab): State<Arc<Gazetteer>>,
CaseIdPath(case_id): CaseIdPath, CaseIdPath(case_id): CaseIdPath,
Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> { ) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug); let user_root = config.data_path.join(&user.slug);
pipeline pipeline
@@ -905,13 +906,27 @@ pub async fn handle_case_recordings(
) )
.await; .await;
let case_dir = locate_case_or_404( // Closing a case only writes a `.closed` marker; the `.m4a` recordings
&user_root, // stay on disk. A user who opted into viewing closed cases must still
&case_id, // reach this list, so branch on `show_closed=1` exactly like
&user.slug, // `handle_case_page` does.
"case recordings (possible IDOR probe)", let case_dir = if query.include_closed() {
) locate_closed_case_or_404(
.await?; &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(); let case_id_str = case_id.to_string();
info!(slug = %user.slug, case_id = %case_id, "case recordings viewed"); info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");
+1 -1
View File
@@ -744,7 +744,7 @@
<div> <div>
<a <a
class="recordings-link" class="recordings-link"
href="/cases/{{ case_id }}/recordings" href="/cases/{{ case_id }}/recordings{% if is_closed %}?show_closed=1{% endif %}"
>&rarr; Aufnahmen anzeigen ({{ recordings_count }})</a >&rarr; Aufnahmen anzeigen ({{ recordings_count }})</a
> >
</div> </div>
+41 -1
View File
@@ -7,7 +7,8 @@ use doctate_common::oneliners::OnelinerState;
use common::{ use common::{
TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, 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 /// 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")); 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] #[tokio::test]
async fn case_recordings_back_link_targets_case_page() { async fn case_recordings_back_link_targets_case_page() {
let cfg = TestConfig::new() let cfg = TestConfig::new()