Files
doctate/server/tests/magic_link_test.rs
T
Brummel a510c20e75 refactor(server): plumb Arc<Settings> through workers and routes
Config sheds its 14 Bucket-B fields (retention, whisper, ollama, llm) and
gains a sibling Arc<Settings> in AppState, loaded from settings.toml at
startup via Settings::load_or_default. Workers (transcribe, analyze,
recovery, auto_trigger) and routes (health, bulk, case_actions,
user_web) take settings as a separate parameter or State<> extractor;
Config::llm_configured moves to Settings::llm_configured.

TestConfig::build_pair() returns (Arc<Config>, Arc<Settings>); the new
create_router_with_settings / create_router_and_session_store_with_settings
helpers wire both into integration tests that exercise LLM/Whisper/Ollama.
The legacy single-Arc create_router falls back to Settings::default()
so the 17 non-LLM tests stay untouched.

Verified: cargo build clean, clippy --all-targets clean, 318 tests pass.
2026-04-27 11:48:41 +02:00

234 lines
6.9 KiB
Rust

mod common;
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::magic_link::{MagicLinkStore, PendingMagicLink};
use doctate_server::{
AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
};
use common::{TestConfig, body_json, test_user};
const API_KEY: &str = "key-dr_a";
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(common::paths::magic(token))
.body(Body::empty())
.unwrap()
}
/// Most tests only need a router. Only the expiry test keeps a handle
/// to the `MagicLinkStore` so it can insert a pre-expired token.
fn test_app() -> axum::Router {
let cfg = TestConfig::new()
.with_label("magic")
.with_user(test_user("dr_a"))
.build();
doctate_server::create_router(cfg)
}
/// For the expiry test: build the full `AppState` so we retain a
/// reference to `magic_link_store` after the router is constructed.
fn build_state_with_stores() -> AppState {
let cfg = TestConfig::new()
.with_label("magic")
.with_user(test_user("dr_a"))
.build();
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
AppState {
config: cfg,
settings: Arc::new(doctate_server::settings::Settings::default()),
transcribe_tx,
analyze_tx,
session_store: web_session::new_store(),
magic_link_store: doctate_server::magic_link::new_store(),
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
oneliner_locks: doctate_server::oneliner_locks::OnelinerLocks::new(),
events_tx: doctate_server::events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
}
}
#[tokio::test]
async fn create_without_api_key_returns_401() {
let app = test_app();
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 app = test_app();
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 app = test_app();
// 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 app = test_app();
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 app = test_app();
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_with_stores();
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());
}