refactor(tests): introduce shared tests/common/ support module
Lift duplicated test-harness code (User factories, TestConfig builder, login/CSRF flow, form/json helpers, case-directory seeding, URL path builders) into `server/tests/common/` so future API changes touch one file instead of every integration test. Migrated batches: csrf_attack, auth, login, magic_link, security_headers (28 tests); analyze, case_page, delete_recording, close_watermark, retention_sweep (67 tests). All 95 tests still green. Net line delta across these 10 files: +1113 / -1896 (~780 lines removed), plus ~500 lines of new shared infrastructure under tests/common/.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
//! `Config` construction for tests.
|
||||
//!
|
||||
//! Collapses the ~13 lookalike `test_config_*` / `build_config` helpers
|
||||
//! scattered across the integration tests into one fluent builder.
|
||||
//! Every builder call is a minimal override on top of
|
||||
//! `Config::test_default()`; the builder automatically derives the
|
||||
//! `api_key → slug` index map from the collected users.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
|
||||
/// Unique tempdir path for a given label. `process::id()` + UUID v4
|
||||
/// keeps parallel `cargo test` runs from colliding; the label lets
|
||||
/// humans identify which test owns which leftover directory when
|
||||
/// cleanup fails.
|
||||
pub fn unique_tmpdir(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"doctate-test-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
|
||||
/// Fluent builder for `Arc<Config>`. All methods take `self` by value
|
||||
/// and return `Self` so calls chain naturally:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let cfg = TestConfig::new()
|
||||
/// .with_user(test_user("dr_a"))
|
||||
/// .with_llm(mock.uri())
|
||||
/// .build();
|
||||
/// ```
|
||||
pub struct TestConfig {
|
||||
data_path: Option<PathBuf>,
|
||||
users: Vec<User>,
|
||||
llm_url: Option<String>,
|
||||
llm_api_key: Option<String>,
|
||||
llm_model: Option<String>,
|
||||
whisper_url: Option<String>,
|
||||
whisper_timeout_seconds: Option<u64>,
|
||||
ollama_url: Option<String>,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl TestConfig {
|
||||
/// Start a fresh builder. `label` is used only to name the tempdir
|
||||
/// when no explicit `data_path` is set via `with_data_path`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data_path: None,
|
||||
users: Vec::new(),
|
||||
llm_url: None,
|
||||
llm_api_key: None,
|
||||
llm_model: None,
|
||||
whisper_url: None,
|
||||
whisper_timeout_seconds: None,
|
||||
ollama_url: None,
|
||||
label: "common",
|
||||
}
|
||||
}
|
||||
|
||||
/// Label used when auto-generating the tempdir name.
|
||||
pub fn with_label(mut self, label: &'static str) -> Self {
|
||||
self.label = label;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the data path. By default `build()` generates a unique
|
||||
/// tempdir under `std::env::temp_dir()`.
|
||||
pub fn with_data_path(mut self, path: PathBuf) -> Self {
|
||||
self.data_path = Some(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a single user. Can be called repeatedly.
|
||||
pub fn with_user(mut self, user: User) -> Self {
|
||||
self.users.push(user);
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the user list outright.
|
||||
pub fn with_users(mut self, users: Vec<User>) -> Self {
|
||||
self.users = users;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure a hosted-provider LLM with the common test defaults
|
||||
/// (`api_key = "test-key"`, `model = "test-model"`). For Ollama-style
|
||||
/// endpoints that expect no `Authorization` header, use
|
||||
/// [`with_llm_explicit`] and pass an empty api_key.
|
||||
pub fn with_llm(mut self, url: impl Into<String>) -> Self {
|
||||
self.llm_url = Some(url.into());
|
||||
self.llm_api_key.get_or_insert_with(|| "test-key".into());
|
||||
self.llm_model.get_or_insert_with(|| "test-model".into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the LLM with explicit values. `api_key` may be empty
|
||||
/// to exercise the Ollama-style no-auth path.
|
||||
pub fn with_llm_explicit(
|
||||
mut self,
|
||||
url: impl Into<String>,
|
||||
api_key: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
) -> Self {
|
||||
self.llm_url = Some(url.into());
|
||||
self.llm_api_key = Some(api_key.into());
|
||||
self.llm_model = Some(model.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Whisper ASR mock URL + timeout.
|
||||
pub fn with_whisper(mut self, url: impl Into<String>, timeout_seconds: u64) -> Self {
|
||||
self.whisper_url = Some(url.into());
|
||||
self.whisper_timeout_seconds = Some(timeout_seconds);
|
||||
self
|
||||
}
|
||||
|
||||
/// Ollama (oneliner generator) mock URL.
|
||||
pub fn with_ollama(mut self, url: impl Into<String>) -> Self {
|
||||
self.ollama_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Materialize the config. Panics only if the workspace-defaults in
|
||||
/// `Config::test_default` themselves are broken (not reachable in
|
||||
/// practice).
|
||||
pub fn build(self) -> Arc<Config> {
|
||||
let data_path = self
|
||||
.data_path
|
||||
.unwrap_or_else(|| unique_tmpdir(self.label));
|
||||
let api_keys: HashMap<String, String> = self
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||
.collect();
|
||||
|
||||
let defaults = Config::test_default();
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users: self.users,
|
||||
api_keys,
|
||||
llm_url: self.llm_url.unwrap_or(defaults.llm_url),
|
||||
llm_api_key: self.llm_api_key.unwrap_or(defaults.llm_api_key),
|
||||
llm_model: self.llm_model.unwrap_or(defaults.llm_model),
|
||||
whisper_url: self.whisper_url.unwrap_or(defaults.whisper_url),
|
||||
whisper_timeout_seconds: self
|
||||
.whisper_timeout_seconds
|
||||
.unwrap_or(defaults.whisper_timeout_seconds),
|
||||
ollama_url: self.ollama_url.unwrap_or(defaults.ollama_url),
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TestConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! HTTP request builders and response extraction helpers.
|
||||
//!
|
||||
//! The stock axum `Request::builder()…oneshot()` chain is verbose;
|
||||
//! centralising the common shapes here turns ~8 repetitive lines per
|
||||
//! call site into one function call, and localises URL/header shape
|
||||
//! changes to this file.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, header};
|
||||
use axum::response::Response;
|
||||
|
||||
use doctate_common::{
|
||||
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
|
||||
};
|
||||
|
||||
/// Plain form-urlencoded POST with a session cookie. `body` is passed
|
||||
/// verbatim; CSRF-protected calls should use [`csrf_form_post`] to add
|
||||
/// the token automatically.
|
||||
pub fn form_post<U: AsRef<str>>(uri: U, cookie: &str, body: impl Into<String>) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri.as_ref())
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body.into()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically.
|
||||
/// Pass an empty `extra_body` when the form has no other fields
|
||||
/// (e.g. POST /web/cases/{id}/close).
|
||||
pub fn csrf_form_post<U: AsRef<str>>(
|
||||
uri: U,
|
||||
cookie: &str,
|
||||
csrf: &str,
|
||||
extra_body: &str,
|
||||
) -> Request<Body> {
|
||||
let body = if extra_body.is_empty() {
|
||||
format!("csrf_token={csrf}")
|
||||
} else {
|
||||
format!("{extra_body}&csrf_token={csrf}")
|
||||
};
|
||||
form_post(uri, cookie, body)
|
||||
}
|
||||
|
||||
/// GET with a session cookie — authenticated `/web/*` pages.
|
||||
pub fn get_with_cookie<U: AsRef<str>>(uri: U, cookie: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri.as_ref())
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// GET with `X-API-Key` — the public `/api/*` surface used by the
|
||||
/// watch/desktop clients.
|
||||
pub fn get_with_api_key<U: AsRef<str>>(uri: U, api_key: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri.as_ref())
|
||||
.header(API_KEY_HEADER, api_key)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// GET with `X-API-Key` and a conditional `If-None-Match` header. Used
|
||||
/// by the oneliners ETag tests and the upload watermark-invalidation
|
||||
/// tests.
|
||||
pub fn get_with_api_key_if_none_match<U: AsRef<str>>(
|
||||
uri: U,
|
||||
api_key: &str,
|
||||
if_none_match: &str,
|
||||
) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri.as_ref())
|
||||
.header(API_KEY_HEADER, api_key)
|
||||
.header(header::IF_NONE_MATCH, if_none_match)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Consume a response body into raw bytes.
|
||||
pub async fn body_bytes(response: Response) -> Vec<u8> {
|
||||
axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
/// Consume a response body into a UTF-8 string. Panics on non-UTF-8 —
|
||||
/// which is correct for the HTML/JSON responses these tests assert on.
|
||||
pub async fn body_string(response: Response) -> String {
|
||||
String::from_utf8(body_bytes(response).await).unwrap()
|
||||
}
|
||||
|
||||
/// Consume a response body into a `serde_json::Value`. Panics on
|
||||
/// malformed JSON, which the test should want to surface loudly.
|
||||
pub async fn body_json(response: Response) -> serde_json::Value {
|
||||
let bytes = body_bytes(response).await;
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// Borrow a header value as a `&str`. Returns `None` if the header is
|
||||
/// absent or not valid UTF-8.
|
||||
pub fn header_opt<'a>(response: &'a Response, name: &str) -> Option<&'a str> {
|
||||
response.headers().get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Header value as an owned `String`, `""` if missing. Matches the
|
||||
/// existing style in `oneliners_api_test.rs`'s `header_str` helper.
|
||||
pub fn header_str(response: &Response, name: &str) -> String {
|
||||
header_opt(response, name).unwrap_or("").to_owned()
|
||||
}
|
||||
|
||||
/// Build a multipart body for `POST /api/upload`. Returns
|
||||
/// `(content_type_header_value, body_bytes)` — pass the first as the
|
||||
/// `Content-Type` header verbatim.
|
||||
///
|
||||
/// Uses the server's canonical field names (`case_id`, `recorded_at`,
|
||||
/// `audio`) from `doctate_common::constants`, so a server-side rename
|
||||
/// propagates here for free.
|
||||
pub fn multipart_upload_body(
|
||||
case_id: &str,
|
||||
recorded_at: &str,
|
||||
audio: &[u8],
|
||||
) -> (String, Vec<u8>) {
|
||||
let boundary = "----testboundary";
|
||||
let mut body = Vec::new();
|
||||
|
||||
// case_id
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!("Content-Disposition: form-data; name=\"{FIELD_CASE_ID}\"\r\n\r\n").as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(case_id.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// recorded_at
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!("Content-Disposition: form-data; name=\"{FIELD_RECORDED_AT}\"\r\n\r\n").as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(recorded_at.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// audio
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"Content-Disposition: form-data; name=\"{FIELD_AUDIO}\"; filename=\"test.m4a\"\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(format!("Content-Type: {CONTENT_TYPE_AUDIO_MP4}\r\n\r\n").as_bytes());
|
||||
body.extend_from_slice(audio);
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// end
|
||||
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
(format!("multipart/form-data; boundary={boundary}"), body)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Shared test-support helpers for `server/tests/*.rs`.
|
||||
//!
|
||||
//! Every integration test file under `tests/*.rs` is a standalone crate.
|
||||
//! This module's special filename `mod.rs` prevents Cargo from
|
||||
//! compiling it as its own test target — it is only loaded by test
|
||||
//! files that opt in via `mod common;` at the top.
|
||||
//!
|
||||
//! `#[allow(dead_code)]` is applied because each test crate uses only
|
||||
//! a subset of the helpers; without the allow, rustc emits a forest of
|
||||
//! warnings for the unused ones in each crate.
|
||||
|
||||
#![allow(dead_code)]
|
||||
// Re-exports below are visible to every test crate, but each crate only
|
||||
// uses a subset — without the allow, rustc emits "unused import"
|
||||
// warnings per crate. The helpers themselves are exercised; the
|
||||
// warning is a false positive of the per-crate visibility model.
|
||||
#![allow(unused_imports)]
|
||||
|
||||
pub mod config;
|
||||
pub mod http;
|
||||
pub mod paths;
|
||||
pub mod seed;
|
||||
pub mod session;
|
||||
pub mod users;
|
||||
|
||||
// Re-exports for ergonomic `use common::*;` in most test files.
|
||||
pub use config::{TestConfig, unique_tmpdir};
|
||||
pub use http::{
|
||||
body_json, body_string, csrf_form_post, form_post, get_with_api_key,
|
||||
get_with_api_key_if_none_match, get_with_cookie, header_opt, header_str, multipart_upload_body,
|
||||
};
|
||||
pub use seed::{
|
||||
past_rfc3339, seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording,
|
||||
seed_recording_with_age, seed_recording_with_sidecars, write_closed_marker,
|
||||
};
|
||||
pub use session::{extract_session_cookie, login, login_request, login_with_csrf};
|
||||
pub use users::{
|
||||
TEST_BCRYPT_COST, TEST_PASSWORD, test_admin, test_user, test_user_with_password,
|
||||
test_user_with_retention, test_user_with_window_hours,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
//! URL path constants and builders.
|
||||
//!
|
||||
//! Centralising these catches routing refactors at compile time (a
|
||||
//! rename to the constant/fn shows up in every test file in one
|
||||
//! `cargo check`) rather than as a scatter of 404s.
|
||||
//!
|
||||
//! The public `/api/*` surface lives in `doctate_common` and is
|
||||
//! re-exported from there (see `ONELINERS_PATH`, `UPLOAD_PATH`).
|
||||
|
||||
pub use doctate_common::{ONELINERS_PATH, UPLOAD_PATH};
|
||||
|
||||
// -- /web/* --
|
||||
|
||||
/// `POST /web/login` — form login (slug, password).
|
||||
pub const LOGIN: &str = "/web/login";
|
||||
/// `POST /web/logout` — CSRF-protected logout.
|
||||
pub const LOGOUT: &str = "/web/logout";
|
||||
/// `GET /web/cases` — case list; also triggers the lazy retention sweep.
|
||||
pub const CASES: &str = "/web/cases";
|
||||
/// `POST /web/cases/bulk` — admin bulk action (close/analyze/reset).
|
||||
pub const BULK: &str = "/web/cases/bulk";
|
||||
/// `POST /web/cases/purge-closed` — admin hard-delete of closed cases.
|
||||
pub const PURGE_CLOSED: &str = "/web/cases/purge-closed";
|
||||
/// `GET /web/events` — SSE stream (per-user scoped).
|
||||
pub const EVENTS: &str = "/web/events";
|
||||
|
||||
// -- per-case builders --
|
||||
|
||||
/// `/web/cases/{case_id}`.
|
||||
pub fn case_detail(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}")
|
||||
}
|
||||
|
||||
/// `/web/cases/{case_id}/recordings`.
|
||||
pub fn case_recordings(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/recordings")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/close`.
|
||||
pub fn case_close(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/close")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/reopen`.
|
||||
pub fn case_reopen(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/reopen")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/analyze`.
|
||||
pub fn case_analyze(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/analyze")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/reset` — admin-only hard-reset.
|
||||
pub fn case_reset(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/reset")
|
||||
}
|
||||
|
||||
/// `POST /web/cases/{case_id}/recordings/delete` — per-recording delete.
|
||||
pub fn recording_delete(case_id: &str) -> String {
|
||||
format!("/web/cases/{case_id}/recordings/delete")
|
||||
}
|
||||
|
||||
/// `GET /web/magic?token={token}` — magic-link consumption.
|
||||
pub fn magic(token: &str) -> String {
|
||||
format!("/web/magic?token={token}")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Filesystem-fixture builders for case directories, recordings,
|
||||
//! oneliner state files, and close markers.
|
||||
//!
|
||||
//! All functions use synchronous `std::fs` (tests run inside
|
||||
//! `#[tokio::test]` but these writes are short and their output is
|
||||
//! consumed sequentially, so there is no win from async I/O here). A
|
||||
//! few of the oneliner_api tests use `tokio::fs` directly because they
|
||||
//! need to run inside the same async flow; that's fine — the two
|
||||
//! coexist.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use filetime::FileTime;
|
||||
|
||||
/// Create `<data_path>/<slug>/<case_id>/` and return its path. This is
|
||||
/// the only "has to be on disk for the route to see it" fixture —
|
||||
/// recordings and derived artefacts layer on top.
|
||||
pub fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
||||
let dir = data_path.join(slug).join(case_id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Write `<stem>.m4a` with placeholder bytes, plus optionally a
|
||||
/// `<stem>.transcript.txt`. `stem` must be the full RFC3339-like
|
||||
/// filename stem (e.g. `2026-04-15T10-00-00Z`); pass the same prefix
|
||||
/// you want to read back later. Returns the audio filename (no path)
|
||||
/// so the caller can feed it back to the delete endpoint.
|
||||
pub fn seed_recording(case_dir: &Path, stem: &str, transcript: Option<&str>) -> String {
|
||||
let filename = format!("{stem}.m4a");
|
||||
std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap();
|
||||
if let Some(text) = transcript {
|
||||
std::fs::write(case_dir.join(format!("{stem}.transcript.txt")), text).unwrap();
|
||||
}
|
||||
filename
|
||||
}
|
||||
|
||||
/// Like [`seed_recording`] but additionally writes the `.duration.txt`
|
||||
/// sidecar — used by the delete-recording tests that assert every
|
||||
/// sidecar is cleaned up.
|
||||
pub fn seed_recording_with_sidecars(
|
||||
case_dir: &Path,
|
||||
stem: &str,
|
||||
transcript: &str,
|
||||
) -> String {
|
||||
let filename = seed_recording(case_dir, stem, Some(transcript));
|
||||
std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap();
|
||||
filename
|
||||
}
|
||||
|
||||
/// Write an m4a with an artificial `mtime` in the past (relative to
|
||||
/// now). Used by the retention-sweep tests to simulate "recorded N days
|
||||
/// ago" without a clock abstraction.
|
||||
pub fn seed_recording_with_age(case_dir: &Path, stem: &str, age: Duration) {
|
||||
let filename = format!("{stem}.m4a");
|
||||
let path = case_dir.join(&filename);
|
||||
std::fs::write(&path, b"audio").unwrap();
|
||||
let target = SystemTime::now() - age;
|
||||
filetime::set_file_mtime(&path, FileTime::from_system_time(target)).unwrap();
|
||||
}
|
||||
|
||||
/// Persist `OnelinerState::Ready { text }` with a fixed `generated_at`.
|
||||
/// Used by UI-rendering tests that care about the displayed text, not
|
||||
/// the timestamp.
|
||||
pub fn seed_oneliner_ready(case_dir: &Path, text: &str) {
|
||||
let state = OnelinerState::Ready {
|
||||
text: text.to_owned(),
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
};
|
||||
seed_oneliner_state(case_dir, &state);
|
||||
}
|
||||
|
||||
/// Persist an arbitrary [`OnelinerState`] as `oneliner.json`.
|
||||
pub fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) {
|
||||
let bytes = serde_json::to_vec(state).unwrap();
|
||||
std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Write a `.closed` marker with a caller-chosen `closed_at` string.
|
||||
/// Tests that need a specific age pass a historical RFC3339 stamp; a
|
||||
/// helper for that is [`past_rfc3339`].
|
||||
pub fn write_closed_marker(case_dir: &Path, closed_at: &str) {
|
||||
let json = format!(r#"{{"closed_at":"{closed_at}"}}"#);
|
||||
std::fs::write(case_dir.join(".closed"), json).unwrap();
|
||||
}
|
||||
|
||||
/// Format "now - age" as RFC3339 UTC. Used to mint historical
|
||||
/// `closed_at` strings for retention-purge tests.
|
||||
pub fn past_rfc3339(age: Duration) -> String {
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
let t = time::OffsetDateTime::now_utc() - time::Duration::seconds(age.as_secs() as i64);
|
||||
t.format(&Rfc3339).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Session cookie extraction + login flows.
|
||||
//!
|
||||
//! These were previously copy-pasted 4–6 times across the integration
|
||||
//! tests. Centralising them here means: a change to the cookie format
|
||||
//! or the login form field names affects one file, not six.
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, header};
|
||||
use axum::response::Response;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::web_session::SessionStore;
|
||||
|
||||
use crate::common::paths;
|
||||
|
||||
/// Extract the freshly-minted `session=<value>` cookie from a login
|
||||
/// response's `Set-Cookie` headers. Returns the whole `name=value` pair
|
||||
/// (no attributes like `; Path=/`) so it can be sent back verbatim in a
|
||||
/// `Cookie` header.
|
||||
pub fn extract_session_cookie(resp: &Response) -> Option<String> {
|
||||
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
||||
let s = v.to_str().ok()?;
|
||||
if let Some(pair) = s.split(';').next()
|
||||
&& pair.starts_with("session=")
|
||||
{
|
||||
return Some(pair.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the `POST /web/login` request with a URL-encoded form body.
|
||||
/// Pulled out so tests that need to *inspect* the login response
|
||||
/// (e.g. session-fixation tests) can reuse the exact wire shape.
|
||||
pub fn login_request(slug: &str, password: &str) -> Request<Body> {
|
||||
let body = format!("slug={slug}&password={password}");
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(paths::LOGIN)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Log in, return the resulting `session=...` cookie string.
|
||||
///
|
||||
/// Panics if the server didn't set a session cookie — meaning the
|
||||
/// credentials were wrong or the route shape has changed. Tests that
|
||||
/// deliberately exercise the failure path should use [`login_request`]
|
||||
/// directly.
|
||||
pub async fn login(app: &Router, slug: &str, password: &str) -> String {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request(slug, password))
|
||||
.await
|
||||
.unwrap();
|
||||
extract_session_cookie(&resp).expect("login did not set a session cookie")
|
||||
}
|
||||
|
||||
/// Log in and additionally fetch the session's CSRF token directly from
|
||||
/// the session store. Pairs with
|
||||
/// `doctate_server::create_router_and_session_store` — the returned
|
||||
/// token is what the `CsrfForm<T>` extractor validates on every
|
||||
/// state-changing POST under `/web/`.
|
||||
///
|
||||
/// Returns `(cookie_header_value, csrf_token)`.
|
||||
pub async fn login_with_csrf(
|
||||
app: &Router,
|
||||
store: &SessionStore,
|
||||
slug: &str,
|
||||
password: &str,
|
||||
) -> (String, String) {
|
||||
let cookie = login(app, slug, password).await;
|
||||
let token = cookie.trim_start_matches("session=");
|
||||
let csrf = store
|
||||
.read()
|
||||
.await
|
||||
.get(token)
|
||||
.expect("session must be in the store right after login")
|
||||
.csrf_token
|
||||
.clone();
|
||||
(cookie, csrf)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Factory functions for the `User` struct from `doctate_server::config`.
|
||||
//!
|
||||
//! Tests previously copy-pasted a 10-line `make_user` in every file.
|
||||
//! These factories centralise the defaults (doctor role, bcrypt cost 4,
|
||||
//! window 72 h, password "s") so an API change to `User` touches one
|
||||
//! file, not twenty.
|
||||
|
||||
use doctate_server::config::{RetentionUserSettings, User};
|
||||
|
||||
/// Bcrypt cost factor used in all tests. The production default is 12;
|
||||
/// tests drop it to 4 because the hash is computed on every `test_user()`
|
||||
/// call and a cost-12 hash adds ~200 ms per test. Security is irrelevant
|
||||
/// for test-only passwords; speed is not.
|
||||
pub const TEST_BCRYPT_COST: u32 = 4;
|
||||
|
||||
/// Default plaintext password across tests. Individual tests that need
|
||||
/// a specific password (e.g. for a login-wrong-password assertion) use
|
||||
/// [`test_user_with_password`] instead.
|
||||
pub const TEST_PASSWORD: &str = "s";
|
||||
|
||||
/// Bcrypt-hash the test password. Extracted so the cost factor lives in
|
||||
/// one place.
|
||||
fn hash_password(plain: &str) -> String {
|
||||
bcrypt::hash(plain, TEST_BCRYPT_COST).unwrap()
|
||||
}
|
||||
|
||||
/// Doctor user. `api_key = "key-{slug}"` — kept deterministic so tests
|
||||
/// that send the X-API-Key header can compute it from the slug without
|
||||
/// keeping a separate constant.
|
||||
pub fn test_user(slug: &str) -> User {
|
||||
User {
|
||||
slug: slug.into(),
|
||||
api_key: format!("key-{slug}"),
|
||||
web_password: hash_password(TEST_PASSWORD),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin variant — used by bulk/reset/purge-closed tests where the
|
||||
/// handler enforces `role == "admin"`.
|
||||
pub fn test_admin(slug: &str) -> User {
|
||||
let mut u = test_user(slug);
|
||||
u.role = "admin".into();
|
||||
u
|
||||
}
|
||||
|
||||
/// Doctor user with an explicit plaintext password. Used by tests that
|
||||
/// log in with a specific password (e.g. wrong-password rejection).
|
||||
pub fn test_user_with_password(slug: &str, password: &str) -> User {
|
||||
let mut u = test_user(slug);
|
||||
u.web_password = hash_password(password);
|
||||
u
|
||||
}
|
||||
|
||||
/// Doctor user with custom retention settings — for the retention sweep
|
||||
/// tests that drive the lazy auto-close / auto-purge logic.
|
||||
pub fn test_user_with_retention(
|
||||
slug: &str,
|
||||
auto_close_days: u32,
|
||||
auto_delete_days: u32,
|
||||
) -> User {
|
||||
let mut u = test_user(slug);
|
||||
u.retention = RetentionUserSettings {
|
||||
auto_close_days,
|
||||
auto_delete_days,
|
||||
};
|
||||
u
|
||||
}
|
||||
|
||||
/// Doctor user with a custom `window_hours` — used by `/api/oneliners`
|
||||
/// tests that verify the visibility window is server-authoritative.
|
||||
pub fn test_user_with_window_hours(slug: &str, hours: u32) -> User {
|
||||
let mut u = test_user(slug);
|
||||
u.window_hours = hours;
|
||||
u
|
||||
}
|
||||
Reference in New Issue
Block a user