813fb896ff
Pulls three pattern groups into shared helpers so a rename or wire-format tweak edits one file instead of 10+: - BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset" string literals that were duplicated between the bulk handler and 3 attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion" error shape; the handler match is now exhaustive over the enum. - join_url helper in doctate-common absorbs 7 identical trim_end_matches+format! call sites across client-core, client-desktop, server/transcribe (ollama, whisper), and server/analyze (llm). - server/tests/common/artefacts.rs re-exports ONELINER_FILENAME (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so test files reference the canonical name instead of inlining literals. Also lifts client-desktop local duplication: - paths.rs: project_path helper collapses 4 identical ProjectDirs chains - main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks - app.rs: named RecordingContext struct replaces (Uuid, String) tuple at 3 sites around the ffmpeg-flush finalization path Verification: 397 tests pass (baseline was 389; +8 for new unit tests on BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
122 lines
3.8 KiB
Rust
122 lines
3.8 KiB
Rust
//! 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<String, MagicLinkError> {
|
|
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}");
|
|
}
|
|
}
|