3218fc62bd
The /api/oneliners time window is now read per-user from users.toml (window_hours, default 72h). Clients no longer carry a window: client.toml oneliner_window_hours, SyncConfig.window_hours, the ?hours=N query param, and the render_case_list cutoff filter are gone. ETag suffix keeps the effective hours so an admin edit to users.toml invalidates client caches on the next request. OnelinersResponse.window_hours stays in the wire format, but now exists solely to anchor client reconciliation.
276 lines
7.4 KiB
Rust
276 lines
7.4 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use tower::util::ServiceExt;
|
|
|
|
use doctate_server::config::{Config, User};
|
|
|
|
fn test_config() -> Arc<Config> {
|
|
let data_path = std::env::temp_dir().join(format!(
|
|
"doctate-web-test-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
Arc::new(Config {
|
|
data_path,
|
|
users: vec![User {
|
|
slug: "dr_test".into(),
|
|
api_key: "test-key".into(),
|
|
web_password: "unused".into(),
|
|
role: "doctor".into(),
|
|
whisper: Default::default(),
|
|
retention: Default::default(),
|
|
window_hours: 72,
|
|
}],
|
|
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
|
|
..Config::test_default()
|
|
})
|
|
}
|
|
|
|
#[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 = data_path.join("dr_test").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
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!("/web/audio/dr_test/{case_id}/{filename}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("content-type")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"audio/mp4"
|
|
);
|
|
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(&bytes[..], audio_bytes);
|
|
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_audio_path_traversal_rejected() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/web/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 config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/web/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 = data_path.join("dr_test").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
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!("/web/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!(
|
|
response
|
|
.headers()
|
|
.get("content-range")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
format!("bytes 10-19/{}", audio_bytes.len())
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("content-length")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"10"
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("accept-ranges")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"bytes"
|
|
);
|
|
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(&bytes[..], &audio_bytes[10..20]);
|
|
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[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 = data_path.join("dr_test").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
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!("/web/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!(
|
|
response
|
|
.headers()
|
|
.get("content-range")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
format!("bytes */{}", audio_bytes.len())
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[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 = data_path.join("dr_test").join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
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!("/web/audio/dr_test/{case_id}/{filename}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("accept-ranges")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"bytes"
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("content-length")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
audio_bytes.len().to_string()
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_audio_nonexistent_file_returns_404() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/web/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|