Files
doctate/server/tests/upload_test.rs
T
Brummel d489716c9b Feat: Implement basic web authentication and UI
This commit introduces the foundation for web-based authentication and
user interface. It includes:

- **Session Management**: Securely handling user sessions using
  cryptographically generated tokens, cookies with appropriate security
  flags, and server-side storage.
- **Login/Logout Functionality**: Endpoints for users to log in with
  their credentials and log out, clearing their session.
- **User Interface**: Basic templates for the login page, a list of
  cases, and a detailed view of a single case, allowing users to
  navigate and view their data.
- **Authorization**: An `AuthenticatedWebUser` extractor to ensure only
  logged-in users can access protected web routes.
- **Configuration**: Added a `cookie_secure` option to the configuration
  to control the `Secure` flag on session cookies, useful for local
  development.
- **Dependencies**: Added necessary dependencies for password hashing,
  time handling, and TOML file manipulation.
- **Helper Binary**: Included a `hash-password` binary to simplify
  generating bcrypt hashes for user passwords.
2026-04-14 15:09:23 +02:00

349 lines
11 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-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
Arc::new(Config {
server_port: 3000,
data_path,
log_level: "info".into(),
log_path: "/tmp/doctate-test/logs".into(),
log_max_days: 90,
users: vec![User {
slug: "dr_test".into(),
api_key: "test-key-123".into(),
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
}],
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
retention_audio_days: 30,
retention_transcript_days: 30,
retention_document_days: 0,
whisper_url: "http://localhost:10300".into(),
whisper_timeout_seconds: 120,
ollama_url: "http://localhost:11434".into(),
ollama_model: "gemma3:4b".into(),
ollama_keep_alive: 0,
llm_url: String::new(),
llm_api_key: String::new(),
llm_model: String::new(),
llm_temperature: 0.0,
session_timeout_hours: 8,
cookie_secure: false,
})
}
/// Build a multipart body with the given fields.
fn multipart_body(
case_id: &str,
recorded_at: &str,
audio: &[u8],
) -> (String, Vec<u8>) {
let boundary = "----testboundary";
let mut body = Vec::new();
// case_id field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n");
body.extend_from_slice(case_id.as_bytes());
body.extend_from_slice(b"\r\n");
// recorded_at field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n");
body.extend_from_slice(recorded_at.as_bytes());
body.extend_from_slice(b"\r\n");
// audio field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n",
);
body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n");
body.extend_from_slice(audio);
body.extend_from_slice(b"\r\n");
// End boundary
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(boundary.to_owned(), body)
}
#[tokio::test]
async fn upload_creates_new_case() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
let case_id = "550e8400-e29b-41d4-a716-446655440000";
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["case_id"], case_id);
assert_eq!(json["status"], "received");
// Verify file on disk
let file = data_path
.join("dr_test/open")
.join(case_id)
.join("2026-04-13T10-30-00Z.m4a");
assert!(file.exists(), "Audio file should exist at {file:?}");
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
}
#[tokio::test]
async fn upload_invalid_case_id_returns_400() {
let config = test_config();
let app = doctate_server::create_router(config);
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn upload_without_auth_returns_401() {
let config = test_config();
let app = doctate_server::create_router(config);
let (boundary, body) = multipart_body(
"550e8400-e29b-41d4-a716-446655440000",
"2026-04-13T10:30:00Z",
b"audio",
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn upload_empty_audio_returns_400() {
let config = test_config();
let app = doctate_server::create_router(config);
let (boundary, body) = multipart_body(
"550e8400-e29b-41d4-a716-446655440000",
"2026-04-13T10:30:00Z",
b"", // empty audio
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn upload_second_recording_same_case() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
let case_id = "660e8400-e29b-41d4-a716-446655440000";
// First upload
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Second upload, same case, different timestamp
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Both files should exist
let case_dir = data_path.join("dr_test/open").join(case_id);
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
}
#[tokio::test]
async fn upload_late_recording_to_done_case() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
let case_id = "770e8400-e29b-41d4-a716-446655440000";
// Pre-create a done/ case directory (simulating a closed case).
let done_dir = data_path.join("dr_test/done").join(case_id);
std::fs::create_dir_all(&done_dir).unwrap();
let (boundary, body) = multipart_body(case_id, "2026-04-13T11:00:00Z", b"late recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// File should be in done/, not open/
assert!(done_dir.join("2026-04-13T11-00-00Z.m4a").exists());
assert!(!data_path.join("dr_test/open").join(case_id).exists());
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
}
#[tokio::test]
async fn upload_removes_remove_marker_on_late_upload() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
let case_id = "880e8400-e29b-41d4-a716-446655440000";
// Pre-create a done/ case with .remove marker.
let done_dir = data_path.join("dr_test/done").join(case_id);
std::fs::create_dir_all(&done_dir).unwrap();
std::fs::write(done_dir.join(".remove"), b"").unwrap();
assert!(done_dir.join(".remove").exists());
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"surprise recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// .remove marker should be gone, audio should be there
assert!(!done_dir.join(".remove").exists());
assert!(done_dir.join("2026-04-13T12-00-00Z.m4a").exists());
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
}