//! Client-side helper: ask the server for a one-time magic-link token and //! build the URL the system browser should open. //! //! Kept separate from `app.rs` so the network call can be unit-tested //! against a wiremock server without involving `webbrowser::open` (which //! would launch a real browser during tests). //! //! # Behaviour on error //! //! Caller decides. The desktop app's `on_open_web` falls back to the //! plain case URL if this fails — the doctor lands on the login page and //! signs in manually rather than seeing a dead button. use doctate_common::{API_KEY_HEADER, join_url}; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Serialize)] struct CreateRequest<'a> { return_to: &'a str, } #[derive(Deserialize)] struct CreateResponse { token: String, } #[derive(Debug, Error)] pub enum MagicLinkError { #[error("http error: {0}")] Http(#[from] reqwest::Error), #[error("server returned status {0}")] Status(reqwest::StatusCode), } /// Request a magic-link token and build the full URL to open. The /// `return_to` path is what the browser lands on **after** the token /// is consumed; must start with `/web/` (server enforces this too). pub async fn build_magic_url( http: &reqwest::Client, server_url: &str, api_key: &str, return_to: &str, ) -> Result { let resp = http .post(join_url(server_url, "/api/auth/magic-link")) .header(API_KEY_HEADER, api_key) .json(&CreateRequest { return_to }) .send() .await?; if !resp.status().is_success() { return Err(MagicLinkError::Status(resp.status())); } let parsed: CreateResponse = resp.json().await?; Ok(join_url( server_url, &format!("/web/magic?token={}", parsed.token), )) } #[cfg(test)] mod tests { use super::*; use serde_json::json; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] async fn returns_full_url_with_token() { let server = MockServer::start().await; let token = "tok-1234567890"; Mock::given(method("POST")) .and(path("/api/auth/magic-link")) .and(header(API_KEY_HEADER, "test-key")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": token }))) .mount(&server) .await; let http = reqwest::Client::new(); let url = build_magic_url(&http, &server.uri(), "test-key", "/web/cases/abc") .await .expect("magic url"); assert_eq!(url, format!("{}/web/magic?token={token}", server.uri())); } #[tokio::test] async fn propagates_server_error() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/auth/magic-link")) .respond_with(ResponseTemplate::new(401)) .mount(&server) .await; let http = reqwest::Client::new(); let err = build_magic_url(&http, &server.uri(), "wrong-key", "/web/cases/abc") .await .expect_err("should error"); assert!(matches!(err, MagicLinkError::Status(s) if s.as_u16() == 401)); } #[tokio::test] async fn trims_trailing_slash_in_server_url() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/auth/magic-link")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": "abc" }))) .mount(&server) .await; let http = reqwest::Client::new(); let url_with_slash = format!("{}/", server.uri()); let url = build_magic_url(&http, &url_with_slash, "k", "/web/cases/abc") .await .expect("magic url"); assert!(!url.contains("//web/"), "double slash leaked: {url}"); } }