Files
doctate/server/tests/magic_link_test.rs
T
Brummel 1d702e2d85 Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI.
This enables features like automatic page reloads when case data changes
in the background, improving the user experience by keeping the UI
synchronized with the backend.

Key components:
- `events` module: Contains the `CaseEventKind` enum, `CaseEvent`
  struct, and `EventSender` type for managing the broadcast channel.
- SSE endpoint (`/web/events`): Streams events to connected browsers.
- Client-side JavaScript: Listens for events and triggers debounced page
  reloads.
- Integration points: Workers and route handlers now emit events when
  relevant state changes occur.
2026-04-19 23:33:53 +02:00

253 lines
7.3 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(),
};
(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());
}