Files
doctate/server/tests/web_test.rs
T
Brummel 5e86cb59b0 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
2026-06-01 01:42:25 +02:00

229 lines
7.1 KiB
Rust

mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use common::{TestConfig, header_opt, test_user};
fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
TestConfig::new()
.with_label("web")
.with_user(test_user("dr_test"))
.build()
}
#[tokio::test]
async fn web_audio_serves_file() {
let config = test_config();
let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440000";
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
let audio_bytes = b"fake audio content for test";
let filename = "2026-04-13T10-30-00Z.m4a";
std::fs::write(case_dir.join(filename), audio_bytes).unwrap();
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);
}
#[tokio::test]
async fn web_audio_path_traversal_rejected() {
let app = doctate_server::create_router(test_config());
let response = app
.oneshot(
Request::builder()
.uri("/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_audio_invalid_case_id_rejected() {
let app = doctate_server::create_router(test_config());
let response = app
.oneshot(
Request::builder()
.uri("/audio/dr_test/not-a-uuid/foo.m4a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_audio_serves_range_as_206() {
let config = test_config();
let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440001";
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
let audio_bytes: Vec<u8> = (0u8..=200u8).collect();
let filename = "2026-04-13T10-30-00Z.m4a";
std::fs::write(case_dir.join(filename), &audio_bytes).unwrap();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
.header("Range", "bytes=10-19")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
header_opt(&response, "content-range"),
Some(format!("bytes 10-19/{}", audio_bytes.len()).as_str())
);
assert_eq!(header_opt(&response, "content-length"), Some("10"));
assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes"));
let bytes = common::body_bytes(response).await;
assert_eq!(&bytes[..], &audio_bytes[10..20]);
}
#[tokio::test]
async fn web_audio_invalid_range_returns_416() {
let config = test_config();
let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440002";
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
let audio_bytes = b"short";
let filename = "2026-04-13T10-30-00Z.m4a";
std::fs::write(case_dir.join(filename), audio_bytes).unwrap();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
.header("Range", "bytes=1000-2000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
header_opt(&response, "content-range"),
Some(format!("bytes */{}", audio_bytes.len()).as_str())
);
}
#[tokio::test]
async fn web_audio_no_range_header_advertises_accept_ranges() {
let config = test_config();
let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440003";
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
let audio_bytes = b"full file content";
let filename = "2026-04-13T10-30-00Z.m4a";
std::fs::write(case_dir.join(filename), audio_bytes).unwrap();
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, "accept-ranges"), Some("bytes"));
assert_eq!(
header_opt(&response, "content-length"),
Some(audio_bytes.len().to_string().as_str())
);
}
#[tokio::test]
async fn web_audio_nonexistent_file_returns_404() {
let app = doctate_server::create_router(test_config());
let response = app
.oneshot(
Request::builder()
.uri("/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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);
}