Files
doctate/server/tests/magic_link_test.rs
T
Brummel 5effa7c96e feat: Add endpoint to delete recordings
This commit introduces a new API endpoint for deleting recordings. The
endpoint
handles the removal of the audio file and its associated sidecar files
(transcript,
duration). It also invalidates derived artifacts like oneliner.json,
document.md,
and analysis_input.json. Additionally, it includes checks for path
traversal,
correct file extensions, and case ownership to ensure security and data
integrity.
A new `RecordingDeleted` event is also added to the `CaseEventKind`
enum.
2026-04-20 15:48:34 +02:00

255 lines
7.4 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use serde_json::json;
use tower::util::ServiceExt;
use doctate_common::API_KEY_HEADER;
use doctate_server::config::{Config, User};
use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink};
use doctate_server::{AnalyzeBusy, AppState, TranscribeBusy, analyze, transcribe, web_session};
const API_KEY: &str = "key-dr_a";
fn make_user() -> User {
User {
slug: "dr_a".into(),
api_key: API_KEY.into(),
web_password: bcrypt::hash("unused", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
}
}
fn build_state() -> (AppState, Arc<Config>) {
let user = make_user();
let data_path = std::env::temp_dir().join(format!(
"doctate-magic-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let mut api_keys: HashMap<String, String> = HashMap::new();
api_keys.insert(user.api_key.clone(), user.slug.clone());
let config = Arc::new(Config {
data_path,
users: vec![user],
api_keys,
..Config::test_default()
});
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
let magic_link_store = doctate_server::magic_link::new_store();
let state = AppState {
config: config.clone(),
transcribe_tx,
analyze_tx,
session_store: web_session::new_store(),
magic_link_store,
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
events_tx: doctate_server::events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
};
(state, config)
}
fn create_request(body: &str, api_key: Option<&str>) -> Request<Body> {
let mut b = Request::builder()
.method("POST")
.uri("/api/auth/magic-link")
.header(header::CONTENT_TYPE, "application/json");
if let Some(k) = api_key {
b = b.header(API_KEY_HEADER, k);
}
b.body(Body::from(body.to_owned())).unwrap()
}
fn consume_request(token: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(format!("/web/magic?token={token}"))
.body(Body::empty())
.unwrap()
}
async fn body_json(response: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn create_without_api_key_returns_401() {
let (state, _) = build_state();
let app = doctate_server::create_router_with_state(state);
let resp = app.oneshot(create_request("{}", None)).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_with_valid_key_returns_token() {
let (state, _) = build_state();
let app = doctate_server::create_router_with_state(state);
let resp = app
.oneshot(create_request(
r#"{"return_to":"/web/cases/abc"}"#,
Some(API_KEY),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let token = body["token"].as_str().expect("token field");
assert!(token.len() >= 30, "token looks short: {token}");
}
#[tokio::test]
async fn consume_valid_token_sets_cookie_and_redirects() {
let (state, _) = build_state();
let app = doctate_server::create_router_with_state(state);
// Step 1: issue token via API.
let create_resp = app
.clone()
.oneshot(create_request(
r#"{"return_to":"/web/cases/abc"}"#,
Some(API_KEY),
))
.await
.unwrap();
let token = body_json(create_resp).await["token"]
.as_str()
.unwrap()
.to_owned();
// Step 2: consume.
let resp = app.oneshot(consume_request(&token)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases/abc"
);
assert_eq!(
resp.headers()
.get(header::REFERRER_POLICY)
.unwrap()
.to_str()
.unwrap(),
"no-referrer"
);
// Cookie must be set with HttpOnly and SameSite=Strict.
let cookie = resp
.headers()
.get(header::SET_COOKIE)
.expect("session cookie missing")
.to_str()
.unwrap();
assert!(cookie.starts_with("session="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
}
#[tokio::test]
async fn consume_token_is_one_time_use() {
let (state, _) = build_state();
let app = doctate_server::create_router_with_state(state);
let token = body_json(
app.clone()
.oneshot(create_request("{}", Some(API_KEY)))
.await
.unwrap(),
)
.await["token"]
.as_str()
.unwrap()
.to_owned();
let first = app.clone().oneshot(consume_request(&token)).await.unwrap();
assert_eq!(first.status(), StatusCode::SEE_OTHER);
assert_eq!(
first.headers().get(header::LOCATION).unwrap(),
"/web/cases" // default
);
let second = app.oneshot(consume_request(&token)).await.unwrap();
// Second use: token gone, redirect to login.
assert_eq!(second.status(), StatusCode::SEE_OTHER);
assert_eq!(
second.headers().get(header::LOCATION).unwrap(),
"/web/login"
);
}
#[tokio::test]
async fn create_rejects_open_redirect_return_to() {
let (state, _) = build_state();
let app = doctate_server::create_router_with_state(state);
for evil in [
"https://evil.com/",
"//evil.com/",
"/api/upload",
"javascript:alert(1)",
] {
let body = json!({ "return_to": evil }).to_string();
let resp = app
.clone()
.oneshot(create_request(&body, Some(API_KEY)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"should reject return_to={evil}"
);
}
}
#[tokio::test]
async fn consume_expired_token_redirects_to_login() {
let (state, _) = build_state();
let store: MagicLinkStore = state.magic_link_store.clone();
// Insert a token that expired 1s ago — bypassing the API to control time.
let token = "test-token-expired".to_owned();
{
let mut w = store.write().await;
w.insert(
token.clone(),
PendingMagicLink {
slug: "dr_a".into(),
role: "doctor".into(),
return_to: "/web/cases".into(),
expires_at: Instant::now() - Duration::from_secs(1),
},
);
}
let app = doctate_server::create_router_with_state(state);
let resp = app.oneshot(consume_request(&token)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/login");
// And the token must be removed from the store, even though it expired.
assert!(store.read().await.get(&token).is_none());
}