Files
doctate/server/tests/common/http.rs
T
Brummel 2c6062a53e refactor: drop /web/ URL prefix from browser routes
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
2026-05-04 18:36:10 +02:00

161 lines
5.5 KiB
Rust

//! 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 /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 `/*` 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)
}