2c6062a53e
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.
The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.
Pre-production: no transition redirects.
194 lines
5.7 KiB
Rust
194 lines
5.7 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);
|
|
}
|