//! Shared integration-test harness: drives the real router with //! `tower::ServiceExt::oneshot` — no ports, no network, fully parallel. // // Each test binary compiles this module privately, so helpers unused by a // particular binary would warn as dead code. #![allow(dead_code)] use std::collections::BTreeMap; use std::sync::Arc; use axum::Router; use axum::body::Body; use axum::http::{HeaderMap, Method, StatusCode, header}; use pimdav::xml::{self, DAV, Name}; use server::db::Db; use server::error::AppState; use tower::ServiceExt; use xmltree::Element; // --------------------------------------------------------------------------- // Environment // --------------------------------------------------------------------------- /// A server with a small fixture tree: /// /// ```text /// root/ /// docs/ /// inner/ /// hello.txt ("hello world") /// a.txt ("file a") /// src/ /// main.rs /// config.json /// notes.md /// editme.txt ("v1") /// blob.bin (64 bytes) /// ``` pub struct Env { pub root: tempfile::TempDir, pub state: Arc, pub app: Router, /// Kept alive so the thumbnail cache folder outlives the server. pub cache: Option, } impl Env { pub async fn new() -> Self { Self::build(false, true).await } /// Same fixtures, plus a thumbnail cache in a temp folder. pub async fn with_thumbs() -> Self { Self::build(true, true).await } /// A thumbnail cache with ffmpeg reported as missing, so the no-video /// branch can be exercised on a machine that has it. pub async fn without_ffmpeg() -> Self { Self::build(true, false).await } async fn build(thumbs: bool, ffmpeg: bool) -> Self { let root = tempfile::tempdir().unwrap(); let p = root.path(); std::fs::create_dir_all(p.join("docs/inner")).unwrap(); std::fs::create_dir_all(p.join("src")).unwrap(); std::fs::write(p.join("docs/inner/hello.txt"), "hello world").unwrap(); std::fs::write(p.join("docs/a.txt"), "file a").unwrap(); std::fs::write(p.join("src/main.rs"), "fn main() {}").unwrap(); std::fs::write(p.join("config.json"), "{\"k\": 1}").unwrap(); std::fs::write(p.join("notes.md"), "# notes").unwrap(); std::fs::write(p.join("editme.txt"), "v1").unwrap(); std::fs::write(p.join("blob.bin"), (0..64u8).collect::>()).unwrap(); // In-memory SQLite: no temp files, no WAL, faster than file-backed. let db = Db::open(std::path::Path::new(":memory:")).await.unwrap(); let cache = match thumbs { true => Some(tempfile::tempdir().unwrap()), false => None, }; let thumbs = match &cache { Some(d) => Some(Arc::new( server::thumb::Thumbs::with_ffmpeg(d.path().to_path_buf(), ffmpeg) .await .unwrap(), )), None => None, }; let state = Arc::new(AppState { db, root: p.canonicalize().unwrap(), root_name: server::root_file_name(p), public_url: None, thumbs, }); let app = server::api::router(state.clone()); Self { root, state, app, cache, } } /// Absolute path of a fixture file inside the root. pub fn file(&self, rel: &str) -> std::path::PathBuf { self.root.path().join(rel) } /// Create an admin account (first boot) and return a signed-in client. pub async fn admin(&self) -> Client { self.signed_in("/api/auth/setup", "admin", "admin1234") .await } /// POST credentials to `path` and return a client holding the session. async fn signed_in(&self, path: &str, name: &str, password: &str) -> Client { let r = Client::new(self.app.clone()) .post_json( path, &serde_json::json!({ "name": name, "password": password }), ) .await; assert_eq!(r.status, StatusCode::OK, "{path} as {name}: {}", r.text()); let mut c = Client::new(self.app.clone()); c.cookie = Some(session_cookie(&r).expect("a session cookie")); c } } // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- #[derive(Clone)] pub struct Client { app: Router, pub cookie: Option, } pub struct Resp { pub status: StatusCode, pub headers: HeaderMap, pub body: Vec, } impl Resp { pub fn json(&self) -> serde_json::Value { serde_json::from_slice(&self.body).unwrap_or_else(|e| { panic!( "body is not JSON ({e}):\n{}", String::from_utf8_lossy(&self.body) ) }) } pub fn text(&self) -> String { String::from_utf8_lossy(&self.body).into_owned() } pub fn header(&self, name: &str) -> Option { self.headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_string) } } impl Client { pub fn new(app: Router) -> Self { Self { app, cookie: None } } pub async fn raw( &self, method: Method, path: &str, extra_headers: &[(&str, &str)], body: impl Into, ) -> Resp { // Every real client sends one (HTTP/1.1 requires it), and the // passkey routes need it to know which domain they speak for. // `oneshot` with a relative URI would otherwise send none. A caller // that names its own host wins, so a test can pretend to be on one // WebAuthn cannot use. let mut b = axum::http::Request::builder().method(method).uri(path); if !extra_headers .iter() .any(|(k, _)| k.eq_ignore_ascii_case("host")) { b = b.header(header::HOST, "files.example.com"); } for (k, v) in extra_headers { b = b.header(*k, *v); } if let Some(c) = &self.cookie { b = b.header(header::COOKIE, format!("dovenest_session={c}")); } let req = b .body(body.into()) .unwrap_or_else(|e| panic!("bad request: {e}")); let res = self .app .clone() .oneshot(req) .await .unwrap_or_else(|e| panic!("request failed: {e}")); let status = res.status(); let headers = res.headers().clone(); let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); Resp { status, headers, body: bytes.to_vec(), } } pub async fn get(&self, path: &str) -> Resp { self.raw(Method::GET, path, &[], Vec::new()).await } pub async fn delete(&self, path: &str) -> Resp { self.raw(Method::DELETE, path, &[], Vec::new()).await } pub async fn post_json(&self, path: &str, v: &serde_json::Value) -> Resp { self.raw( Method::POST, path, &[("content-type", "application/json")], v.to_string().into_bytes(), ) .await } pub async fn put_json(&self, path: &str, v: &serde_json::Value) -> Resp { self.raw( Method::PUT, path, &[("content-type", "application/json")], v.to_string().into_bytes(), ) .await } /// `PUT ...?action=content` (editor save). pub async fn put_content( &self, path: &str, content: &[u8], expected_mtime: Option, ) -> Resp { let mtime = expected_mtime.map(|m| m.to_string()); let mut hdrs = vec![("content-type", "text/plain")]; if let Some(m) = &mtime { hdrs.push(("x-expected-mtime", m)); } self.raw(Method::PUT, path, &hdrs, content.to_vec()).await } /// POST a multipart upload with one file per part name. pub async fn post_multipart(&self, path: &str, parts: &[(&str, &[u8])], query: &str) -> Resp { let boundary = "testboundary123"; let (ct, body) = multipart_body(parts, boundary); let full = if query.is_empty() { path.to_string() } else { format!("{path}?{query}") }; self.raw(Method::POST, &full, &[("content-type", &ct)], body) .await } } /// Extract the `dovenest_session` cookie value from `Set-Cookie` headers. pub fn session_cookie(r: &Resp) -> Option { for v in r.headers.get_all(header::SET_COOKIE) { let s = v.to_str().ok()?; let first = s.split(';').next().unwrap_or(""); if let Some(tok) = first.strip_prefix("dovenest_session=") && !tok.is_empty() { return Some(tok.to_string()); } } None } /// An `Authorization: Basic` header value. pub fn basic(name: &str, password: &str) -> String { use headers::authorization::Credentials; let v = headers::Authorization::basic(name, password).0.encode(); v.to_str().unwrap().to_string() } /// A DAV request with Basic auth. pub async fn req( env: &Env, verb: &str, path: &str, auth: &str, extra: &[(&str, &str)], body: &str, ) -> Resp { let mut headers = vec![("authorization", auth)]; headers.extend_from_slice(extra); Client::new(env.app.clone()) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), path, &headers, body.to_string(), ) .await } /// `href -> [(status, property element)]` of a multistatus. pub fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> { assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); xml::elements(&root) .map(|resp| { let href = xml::text(xml::child(resp, DAV, "href").unwrap()); let props = xml::elements(resp) .filter(|e| Name::of(e).is(DAV, "propstat")) .flat_map(|ps| { let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap()) .split(' ') .nth(1) .unwrap() .parse() .unwrap(); let prop = xml::child(ps, DAV, "prop").unwrap(); xml::elements(prop) .map(move |p| (code, p.clone())) .collect::>() }) .collect(); (href, props) }) .collect() } /// The precondition element of a `` body. pub fn error_condition(r: &Resp) -> Name { let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text())); assert!(Name::of(&root).is(DAV, "error"), "{}", r.text()); Name::of(xml::elements(&root).next().unwrap()) } /// The hrefs PROPFIND lists below a collection. pub async fn members(env: &Env, auth: &str, collection: &str) -> Vec { let r = req(env, "PROPFIND", collection, auth, &[("depth", "1")], "").await; parse_multistatus(&r) .into_iter() .map(|(href, _)| href) .filter(|h| h != collection) .collect() } /// The id of the collection with this CalDAV/CardDAV URL, from the JSON API. pub async fn collection_id(who: &Client, url: &str) -> i64 { let r = who.get("/api/pim/collections").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let list = r.json(); list.as_array() .unwrap() .iter() .find(|c| c["url"] == url) .unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"] .as_i64() .unwrap() } pub fn ics(body: &str) -> String { format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n") } pub fn unfold(s: &str) -> String { s.replace("\r\n ", "") } pub fn addr(user: &str) -> String { format!("mailto:{user}@dovenest.invalid") } /// POST /api/admin/users helper (used by several test files). pub async fn create_user( admin: &Client, name: &str, password: &str, roots: &[(&str, &str)], ) -> serde_json::Value { let roots_json: Vec = roots .iter() .map(|(p, m)| serde_json::json!({ "path": p, "mode": m })) .collect(); let r = admin .post_json( "/api/admin/users", &serde_json::json!({ "name": name, "password": password, "is_admin": false, "roots": roots_json, }), ) .await; assert_eq!(r.status, StatusCode::OK, "create {name}: {}", r.text()); r.json() } /// Log in and return a signed-in client. pub async fn login(env: &Env, name: &str, password: &str) -> Client { env.signed_in("/api/auth/login", name, password).await } /// POST /api/shares and return the created share. pub async fn create_share(who: &Client, body: serde_json::Value) -> serde_json::Value { let r = who.post_json("/api/shares", &body).await; assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text()); r.json() } /// Find a user id by name via the admin API. pub async fn user_id(admin: &Client, name: &str) -> i64 { let r = admin.get("/api/admin/users").await; assert_eq!(r.status, StatusCode::OK); let j = r.json(); let users = j.as_array().unwrap(); users .iter() .find(|u| u["name"] == name) .unwrap_or_else(|| panic!("user {name} not found"))["id"] .as_i64() .unwrap() } // --------------------------------------------------------------------------- // Helpers: multipart + archive readers // --------------------------------------------------------------------------- /// Build a minimal multipart/form-data body. Each part is a file whose /// `name` attribute is the (relative) target path — as the web client sends. pub fn multipart_body(parts: &[(&str, &[u8])], boundary: &str) -> (String, Vec) { let mut buf = Vec::new(); for (name, content) in parts { buf.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); buf.extend_from_slice( format!("Content-Disposition: form-data; name=\"{name}\"\r\n").as_bytes(), ); buf.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); buf.extend_from_slice(content); buf.extend_from_slice(b"\r\n"); } buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); (format!("multipart/form-data; boundary={boundary}"), buf) } /// Read a zip into a name → content map (files only). pub fn zip_map(bytes: &[u8]) -> BTreeMap> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive"); let mut map = BTreeMap::new(); for i in 0..zip.len() { let mut f = zip.by_index(i).unwrap(); let name = f.name().unwrap().to_string(); if name.ends_with('/') { continue; } let mut buf = Vec::new(); std::io::Read::read_to_end(&mut f, &mut buf).unwrap(); map.insert(name, buf); } map } /// Read a tar (optionally gz/zst compressed) into a name → content map. pub fn tar_map(raw: &[u8], compress: Compress) -> BTreeMap> { let decompressed: Box = match compress { Compress::None => Box::new(std::io::Cursor::new(raw)), Compress::Gz => Box::new(flate2::read::GzDecoder::new(std::io::Cursor::new(raw))), Compress::Zst => { Box::new(zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap()) } }; let mut map = BTreeMap::new(); for entry in tar::Archive::new(decompressed).entries().unwrap() { let mut e = entry.unwrap(); if !e.header().entry_type().is_file() { continue; } let name = e.path().unwrap().to_string_lossy().into_owned(); let mut buf = Vec::new(); std::io::Read::read_to_end(&mut e, &mut buf).unwrap(); map.insert(name, buf); } map } pub enum Compress { None, Gz, Zst, }