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(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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}"
|
||||||
>→ Aufnahmen anzeigen ({{ recordings_count }})</a
|
>→ Aufnahmen anzeigen ({{ recordings_count }})</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user