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:
@@ -892,6 +892,7 @@ pub async fn handle_case_recordings(
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(vocab): State<Arc<Gazetteer>>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
Query(query): Query<CaseListQuery>,
|
||||
) -> Result<Html<String>, 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");
|
||||
|
||||
|
||||
@@ -744,7 +744,7 @@
|
||||
<div>
|
||||
<a
|
||||
class="recordings-link"
|
||||
href="/cases/{{ case_id }}/recordings"
|
||||
href="/cases/{{ case_id }}/recordings{% if is_closed %}?show_closed=1{% endif %}"
|
||||
>→ Aufnahmen anzeigen ({{ recordings_count }})</a
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user