fix(web): make closed cases fully viewable (discovery-gate, not access-gate)
Follow-up to 6d1ca71: that commit made the recordings sub-page render for a
closed case by threading ?show_closed=1, but the page's own links and the
audio player still 404'd — playback was dead and the back link led nowhere.
Root cause: the `.closed` marker was implemented as an ACCESS gate (every
case-scoped read 404s a closed case unless the opt-in flag is threaded
through), while it is documented as a DISCOVERY gate ("hidden from the
default listing"). Threading the flag through every link/redirect is
whack-a-mole; the next new link forgets it.
Resolved in favour of discovery-gate semantics: the `.closed` marker only
filters the default case LIST (scan_user_cases). Direct/deep-link access to
a known, owned case and all its sub-resources is closed-agnostic. IDOR is
unchanged — it comes from the per-user root + existence check, orthogonal
to closed-ness.
Scope 1 — closed cases are fully viewable:
- handle_audio: drop the is_closed -> 404 gate (web.rs). Audio is a
capability URL behind the same per-user path + slug/UUID/filename
validation as an open case.
- handle_case_page / handle_case_recordings: always resolve via
locate_closed_case_or_404; show_closed is no longer an access gate, so
back links (recordings -> case, case -> list) reach live pages without
threading the flag.
- the "back to list" links carry ?show_closed=1 when the case is closed
(computed server-side from the marker) so the user returns to the
closed-inclusive list, not the default list that hides the case.
Scope 2 — closed means read-only:
- case_page.html withholds the analyze / reset / re-analyze / retry forms
on closed cases; only the reopen affordance remains.
- case_recordings.html withholds the per-recording delete form on closed
cases (CaseRecordingsTemplate gains is_closed).
- the mutating handlers (analyze/reset/delete/oneliner) keep rejecting
closed cases via locate_case_or_404 as defense-in-depth behind the
now-hidden buttons.
Tests (RED-first):
- web_test: audio serves a closed case's file (200, not 404).
- case_page_test: closed case page + recordings render without
?show_closed=1; closed case hides analyze/reset; open case still shows
delete; closed case hides delete.
- analyze_test: closed_case_returns_404_on_detail rewritten to
closed_case_detail_renders_read_only — the old test pinned the
now-superseded access-gate contract.
cargo test (440 passed), clippy, and fmt all clean.
closes #17
This commit is contained in:
@@ -14,7 +14,7 @@ use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::{
|
||||
ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig,
|
||||
ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, body_string,
|
||||
csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, test_admin,
|
||||
test_user, unique_tmpdir, write_document_for_test,
|
||||
};
|
||||
@@ -737,8 +737,13 @@ async fn close_writes_marker_and_hides_case() {
|
||||
);
|
||||
}
|
||||
|
||||
/// After closing, the case detail page still renders, read-only. The
|
||||
/// `.closed` marker is a discovery filter for the case LIST, not an access
|
||||
/// gate on a known, owned case (issue #17): a deep link or back link must
|
||||
/// resolve. Closing swaps the mutating actions for a reopen affordance
|
||||
/// rather than 404ing the page.
|
||||
#[tokio::test]
|
||||
async fn closed_case_returns_404_on_detail() {
|
||||
async fn closed_case_detail_renders_read_only() {
|
||||
let (cfg, settings) = cfg_llm("close-2");
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
@@ -763,7 +768,20 @@ async fn closed_case_returns_404_on_detail() {
|
||||
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"closed case detail must render read-only, not 404",
|
||||
);
|
||||
let body = body_string(resp).await;
|
||||
assert!(
|
||||
body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
|
||||
"closed case detail must offer the reopen action"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))),
|
||||
"closed case detail must not offer the analyze action"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -417,6 +417,180 @@ async fn case_recordings_back_link_targets_case_page() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: a closed case is reachable by direct navigation WITHOUT
|
||||
/// `?show_closed=1`. The `.closed` marker is a discovery filter for the case
|
||||
/// list, not an access gate — so deep-linking to a closed case, or following
|
||||
/// the recordings page's back link (which carries no flag), must render the
|
||||
/// case page, not 404. Regression for issue #17.
|
||||
#[tokio::test]
|
||||
async fn case_page_renders_for_closed_case_without_show_closed() {
|
||||
let cfg = TestConfig::new()
|
||||
.with_label("cp-closed-noflag")
|
||||
.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("ein Text"));
|
||||
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
|
||||
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_dr_a(&app).await;
|
||||
|
||||
// No ?show_closed=1 on the URL — the deep-link / back-link path.
|
||||
let resp = app
|
||||
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"closed case page must render on direct access, not 404",
|
||||
);
|
||||
let body = body_string(resp).await;
|
||||
assert!(
|
||||
body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
|
||||
"closed case page must show the reopen action (proves the closed view rendered)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: a closed case is read-only. Closing means "done", so the
|
||||
/// mutating affordances (analyze, reset) are withheld even though the case
|
||||
/// page still renders for viewing. Only reopen stays — it's how the closed
|
||||
/// state is left.
|
||||
#[tokio::test]
|
||||
async fn case_page_hides_mutation_actions_when_closed() {
|
||||
let cfg = TestConfig::new()
|
||||
.with_label("cp-closed-ro")
|
||||
.with_user(test_admin("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("ein Text"));
|
||||
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 resp = app
|
||||
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_string(resp).await;
|
||||
|
||||
assert!(
|
||||
!body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))),
|
||||
"closed case must not expose the analyze action"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(&format!(r#"action="{}""#, paths::case_reset(case_id))),
|
||||
"closed case must not expose the reset action"
|
||||
);
|
||||
assert!(
|
||||
body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
|
||||
"closed case must still offer reopen"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: the recordings sub-page is reachable for a closed case WITHOUT
|
||||
/// `?show_closed=1` (same discovery-gate reasoning as the case page).
|
||||
#[tokio::test]
|
||||
async fn case_recordings_renders_for_closed_case_without_show_closed() {
|
||||
let cfg = TestConfig::new()
|
||||
.with_label("rec-closed-noflag")
|
||||
.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"));
|
||||
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 resp = app
|
||||
.oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"closed case recordings must render on direct access, not 404",
|
||||
);
|
||||
let body = body_string(resp).await;
|
||||
assert!(
|
||||
body.contains("2026-04-15T10-00-00Z.m4a"),
|
||||
"recording on disk must be listed for the closed case"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: an OPEN case's recordings page exposes the per-recording delete
|
||||
/// form. Locks the positive side so the closed-case hiding below cannot
|
||||
/// silently regress into hiding delete everywhere.
|
||||
#[tokio::test]
|
||||
async fn case_recordings_shows_delete_when_open() {
|
||||
let cfg = TestConfig::new()
|
||||
.with_label("rec-del-open")
|
||||
.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("Text"));
|
||||
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_dr_a(&app).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body_string(resp).await;
|
||||
assert!(
|
||||
body.contains(&format!(
|
||||
r#"action="{}/delete""#,
|
||||
paths::case_recordings(case_id)
|
||||
)),
|
||||
"open recordings page must expose the delete action"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: a closed case is read-only — the per-recording delete form is
|
||||
/// withheld on the recordings sub-page, mirroring the case page hiding its
|
||||
/// mutating actions.
|
||||
#[tokio::test]
|
||||
async fn case_recordings_hides_delete_when_closed() {
|
||||
let cfg = TestConfig::new()
|
||||
.with_label("rec-del-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("Text"));
|
||||
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 resp = app
|
||||
.oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_string(resp).await;
|
||||
assert!(
|
||||
!body.contains(&format!(
|
||||
r#"action="{}/delete""#,
|
||||
paths::case_recordings(case_id)
|
||||
)),
|
||||
"closed recordings page must not expose the delete action"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_uses_oneliner_as_h1_when_present() {
|
||||
let cfg = TestConfig::new()
|
||||
|
||||
@@ -191,3 +191,38 @@ async fn web_audio_nonexistent_file_returns_404() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Property: a closed case still serves its audio. Closing only writes a
|
||||
/// `.closed` marker — the `.m4a` files stay on disk — and that marker is a
|
||||
/// discovery filter for the case LIST, not an access gate on the audio file
|
||||
/// server. So playback from a closed case's recordings page must work, not
|
||||
/// 404. Regression for issue #17 (audio playback broken on closed cases).
|
||||
#[tokio::test]
|
||||
async fn web_audio_serves_closed_case_file() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440099";
|
||||
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
|
||||
|
||||
let audio_bytes = b"fake audio content for a closed case";
|
||||
let filename = "2026-04-13T10-30-00Z.m4a";
|
||||
std::fs::write(case_dir.join(filename), audio_bytes).unwrap();
|
||||
common::write_closed_marker(&case_dir, "2026-04-13T12:00:00Z");
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(header_opt(&response, "content-type"), Some("audio/mp4"));
|
||||
let bytes = common::body_bytes(response).await;
|
||||
assert_eq!(&bytes[..], audio_bytes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user