mod.rs
⎇
Raw
1//! Shared integration-test harness: drives the real router with
2//! `tower::ServiceExt::oneshot` — no ports, no network, fully parallel.
3//
4// Each test binary compiles this module privately, so helpers unused by a
5// particular binary would warn as dead code.
6#![allow(dead_code)]
7
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11use axum::Router;
12use axum::body::Body;
13use axum::http::{HeaderMap, Method, StatusCode, header};
14use pimdav::xml::{self, DAV, Name};
15use server::db::Db;
16use server::error::AppState;
17use tower::ServiceExt;
18use xmltree::Element;
19
20// ---------------------------------------------------------------------------
21// Environment
22// ---------------------------------------------------------------------------
23
24/// A server with a small fixture tree:
25///
26/// ```text
27/// root/
28/// docs/
29/// inner/
30/// hello.txt ("hello world")
31/// a.txt ("file a")
32/// src/
33/// main.rs
34/// config.json
35/// notes.md
36/// editme.txt ("v1")
37/// blob.bin (64 bytes)
38/// ```
39pub struct Env {
40 pub root: tempfile::TempDir,
41 pub state: Arc<AppState>,
42 pub app: Router,
43 /// Kept alive so the thumbnail cache folder outlives the server.
44 pub cache: Option<tempfile::TempDir>,
45}
46
47impl Env {
48 pub async fn new() -> Self {
49 Self::build(false, true).await
50 }
51
52 /// Same fixtures, plus a thumbnail cache in a temp folder.
53 pub async fn with_thumbs() -> Self {
54 Self::build(true, true).await
55 }
56
57 /// A thumbnail cache with ffmpeg reported as missing, so the no-video
58 /// branch can be exercised on a machine that has it.
59 pub async fn without_ffmpeg() -> Self {
60 Self::build(true, false).await
61 }
62
63 async fn build(thumbs: bool, ffmpeg: bool) -> Self {
64 let root = tempfile::tempdir().unwrap();
65 let p = root.path();
66 std::fs::create_dir_all(p.join("docs/inner")).unwrap();
67 std::fs::create_dir_all(p.join("src")).unwrap();
68 std::fs::write(p.join("docs/inner/hello.txt"), "hello world").unwrap();
69 std::fs::write(p.join("docs/a.txt"), "file a").unwrap();
70 std::fs::write(p.join("src/main.rs"), "fn main() {}").unwrap();
71 std::fs::write(p.join("config.json"), "{\"k\": 1}").unwrap();
72 std::fs::write(p.join("notes.md"), "# notes").unwrap();
73 std::fs::write(p.join("editme.txt"), "v1").unwrap();
74 std::fs::write(p.join("blob.bin"), (0..64u8).collect::<Vec<_>>()).unwrap();
75
76 // In-memory SQLite: no temp files, no WAL, faster than file-backed.
77 let db = Db::open(std::path::Path::new(":memory:")).await.unwrap();
78 let cache = match thumbs {
79 true => Some(tempfile::tempdir().unwrap()),
80 false => None,
81 };
82 let thumbs = match &cache {
83 Some(d) => Some(Arc::new(
84 server::thumb::Thumbs::with_ffmpeg(d.path().to_path_buf(), ffmpeg)
85 .await
86 .unwrap(),
87 )),
88 None => None,
89 };
90 let state = Arc::new(AppState {
91 db,
92 root: p.canonicalize().unwrap(),
93 root_name: server::root_file_name(p),
94 public_url: None,
95 thumbs,
96 });
97 let app = server::api::router(state.clone());
98 Self {
99 root,
100 state,
101 app,
102 cache,
103 }
104 }
105
106 /// Absolute path of a fixture file inside the root.
107 pub fn file(&self, rel: &str) -> std::path::PathBuf {
108 self.root.path().join(rel)
109 }
110
111 /// Create an admin account (first boot) and return a signed-in client.
112 pub async fn admin(&self) -> Client {
113 self.signed_in("/api/auth/setup", "admin", "admin1234")
114 .await
115 }
116
117 /// POST credentials to `path` and return a client holding the session.
118 async fn signed_in(&self, path: &str, name: &str, password: &str) -> Client {
119 let r = Client::new(self.app.clone())
120 .post_json(
121 path,
122 &serde_json::json!({ "name": name, "password": password }),
123 )
124 .await;
125 assert_eq!(r.status, StatusCode::OK, "{path} as {name}: {}", r.text());
126 let mut c = Client::new(self.app.clone());
127 c.cookie = Some(session_cookie(&r).expect("a session cookie"));
128 c
129 }
130}
131
132// ---------------------------------------------------------------------------
133// Client
134// ---------------------------------------------------------------------------
135
136#[derive(Clone)]
137pub struct Client {
138 app: Router,
139 pub cookie: Option<String>,
140}
141
142pub struct Resp {
143 pub status: StatusCode,
144 pub headers: HeaderMap,
145 pub body: Vec<u8>,
146}
147
148impl Resp {
149 pub fn json(&self) -> serde_json::Value {
150 serde_json::from_slice(&self.body).unwrap_or_else(|e| {
151 panic!(
152 "body is not JSON ({e}):\n{}",
153 String::from_utf8_lossy(&self.body)
154 )
155 })
156 }
157
158 pub fn text(&self) -> String {
159 String::from_utf8_lossy(&self.body).into_owned()
160 }
161
162 pub fn header(&self, name: &str) -> Option<String> {
163 self.headers
164 .get(name)
165 .and_then(|v| v.to_str().ok())
166 .map(str::to_string)
167 }
168}
169
170impl Client {
171 pub fn new(app: Router) -> Self {
172 Self { app, cookie: None }
173 }
174
175 pub async fn raw(
176 &self,
177 method: Method,
178 path: &str,
179 extra_headers: &[(&str, &str)],
180 body: impl Into<Body>,
181 ) -> Resp {
182 // Every real client sends one (HTTP/1.1 requires it), and the
183 // passkey routes need it to know which domain they speak for.
184 // `oneshot` with a relative URI would otherwise send none. A caller
185 // that names its own host wins, so a test can pretend to be on one
186 // WebAuthn cannot use.
187 let mut b = axum::http::Request::builder().method(method).uri(path);
188 if !extra_headers
189 .iter()
190 .any(|(k, _)| k.eq_ignore_ascii_case("host"))
191 {
192 b = b.header(header::HOST, "files.example.com");
193 }
194 for (k, v) in extra_headers {
195 b = b.header(*k, *v);
196 }
197 if let Some(c) = &self.cookie {
198 b = b.header(header::COOKIE, format!("dovenest_session={c}"));
199 }
200 let req = b
201 .body(body.into())
202 .unwrap_or_else(|e| panic!("bad request: {e}"));
203 let res = self
204 .app
205 .clone()
206 .oneshot(req)
207 .await
208 .unwrap_or_else(|e| panic!("request failed: {e}"));
209 let status = res.status();
210 let headers = res.headers().clone();
211 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
212 .await
213 .unwrap();
214 Resp {
215 status,
216 headers,
217 body: bytes.to_vec(),
218 }
219 }
220
221 pub async fn get(&self, path: &str) -> Resp {
222 self.raw(Method::GET, path, &[], Vec::new()).await
223 }
224
225 pub async fn delete(&self, path: &str) -> Resp {
226 self.raw(Method::DELETE, path, &[], Vec::new()).await
227 }
228
229 pub async fn post_json(&self, path: &str, v: &serde_json::Value) -> Resp {
230 self.raw(
231 Method::POST,
232 path,
233 &[("content-type", "application/json")],
234 v.to_string().into_bytes(),
235 )
236 .await
237 }
238
239 pub async fn put_json(&self, path: &str, v: &serde_json::Value) -> Resp {
240 self.raw(
241 Method::PUT,
242 path,
243 &[("content-type", "application/json")],
244 v.to_string().into_bytes(),
245 )
246 .await
247 }
248
249 /// `PUT ...?action=content` (editor save).
250 pub async fn put_content(
251 &self,
252 path: &str,
253 content: &[u8],
254 expected_mtime: Option<i64>,
255 ) -> Resp {
256 let mtime = expected_mtime.map(|m| m.to_string());
257 let mut hdrs = vec![("content-type", "text/plain")];
258 if let Some(m) = &mtime {
259 hdrs.push(("x-expected-mtime", m));
260 }
261 self.raw(Method::PUT, path, &hdrs, content.to_vec()).await
262 }
263
264 /// POST a multipart upload with one file per part name.
265 pub async fn post_multipart(&self, path: &str, parts: &[(&str, &[u8])], query: &str) -> Resp {
266 let boundary = "testboundary123";
267 let (ct, body) = multipart_body(parts, boundary);
268 let full = if query.is_empty() {
269 path.to_string()
270 } else {
271 format!("{path}?{query}")
272 };
273 self.raw(Method::POST, &full, &[("content-type", &ct)], body)
274 .await
275 }
276}
277
278/// Extract the `dovenest_session` cookie value from `Set-Cookie` headers.
279pub fn session_cookie(r: &Resp) -> Option<String> {
280 for v in r.headers.get_all(header::SET_COOKIE) {
281 let s = v.to_str().ok()?;
282 let first = s.split(';').next().unwrap_or("");
283 if let Some(tok) = first.strip_prefix("dovenest_session=")
284 && !tok.is_empty()
285 {
286 return Some(tok.to_string());
287 }
288 }
289 None
290}
291
292/// An `Authorization: Basic` header value.
293pub fn basic(name: &str, password: &str) -> String {
294 use headers::authorization::Credentials;
295 let v = headers::Authorization::basic(name, password).0.encode();
296 v.to_str().unwrap().to_string()
297}
298
299/// A DAV request with Basic auth.
300pub async fn req(
301 env: &Env,
302 verb: &str,
303 path: &str,
304 auth: &str,
305 extra: &[(&str, &str)],
306 body: &str,
307) -> Resp {
308 let mut headers = vec![("authorization", auth)];
309 headers.extend_from_slice(extra);
310 Client::new(env.app.clone())
311 .raw(
312 Method::from_bytes(verb.as_bytes()).unwrap(),
313 path,
314 &headers,
315 body.to_string(),
316 )
317 .await
318}
319
320/// `href -> [(status, property element)]` of a multistatus.
321pub fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> {
322 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
323 let root = Element::parse(r.body.as_slice()).unwrap();
324 xml::elements(&root)
325 .map(|resp| {
326 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
327 let props = xml::elements(resp)
328 .filter(|e| Name::of(e).is(DAV, "propstat"))
329 .flat_map(|ps| {
330 let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
331 .split(' ')
332 .nth(1)
333 .unwrap()
334 .parse()
335 .unwrap();
336 let prop = xml::child(ps, DAV, "prop").unwrap();
337 xml::elements(prop)
338 .map(move |p| (code, p.clone()))
339 .collect::<Vec<_>>()
340 })
341 .collect();
342 (href, props)
343 })
344 .collect()
345}
346
347/// The precondition element of a `<d:error>` body.
348pub fn error_condition(r: &Resp) -> Name {
349 let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
350 assert!(Name::of(&root).is(DAV, "error"), "{}", r.text());
351 Name::of(xml::elements(&root).next().unwrap())
352}
353
354/// The hrefs PROPFIND lists below a collection.
355pub async fn members(env: &Env, auth: &str, collection: &str) -> Vec<String> {
356 let r = req(env, "PROPFIND", collection, auth, &[("depth", "1")], "").await;
357 parse_multistatus(&r)
358 .into_iter()
359 .map(|(href, _)| href)
360 .filter(|h| h != collection)
361 .collect()
362}
363
364/// The id of the collection with this CalDAV/CardDAV URL, from the JSON API.
365pub async fn collection_id(who: &Client, url: &str) -> i64 {
366 let r = who.get("/api/pim/collections").await;
367 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
368 let list = r.json();
369 list.as_array()
370 .unwrap()
371 .iter()
372 .find(|c| c["url"] == url)
373 .unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"]
374 .as_i64()
375 .unwrap()
376}
377
378pub fn ics(body: &str) -> String {
379 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
380}
381
382pub fn unfold(s: &str) -> String {
383 s.replace("\r\n ", "")
384}
385
386pub fn addr(user: &str) -> String {
387 format!("mailto:{user}@dovenest.invalid")
388}
389
390/// POST /api/admin/users helper (used by several test files).
391pub async fn create_user(
392 admin: &Client,
393 name: &str,
394 password: &str,
395 roots: &[(&str, &str)],
396) -> serde_json::Value {
397 let roots_json: Vec<serde_json::Value> = roots
398 .iter()
399 .map(|(p, m)| serde_json::json!({ "path": p, "mode": m }))
400 .collect();
401 let r = admin
402 .post_json(
403 "/api/admin/users",
404 &serde_json::json!({
405 "name": name,
406 "password": password,
407 "is_admin": false,
408 "roots": roots_json,
409 }),
410 )
411 .await;
412 assert_eq!(r.status, StatusCode::OK, "create {name}: {}", r.text());
413 r.json()
414}
415
416/// Log in and return a signed-in client.
417pub async fn login(env: &Env, name: &str, password: &str) -> Client {
418 env.signed_in("/api/auth/login", name, password).await
419}
420
421/// POST /api/shares and return the created share.
422pub async fn create_share(who: &Client, body: serde_json::Value) -> serde_json::Value {
423 let r = who.post_json("/api/shares", &body).await;
424 assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
425 r.json()
426}
427
428/// Find a user id by name via the admin API.
429pub async fn user_id(admin: &Client, name: &str) -> i64 {
430 let r = admin.get("/api/admin/users").await;
431 assert_eq!(r.status, StatusCode::OK);
432 let j = r.json();
433 let users = j.as_array().unwrap();
434 users
435 .iter()
436 .find(|u| u["name"] == name)
437 .unwrap_or_else(|| panic!("user {name} not found"))["id"]
438 .as_i64()
439 .unwrap()
440}
441
442// ---------------------------------------------------------------------------
443// Helpers: multipart + archive readers
444// ---------------------------------------------------------------------------
445
446/// Build a minimal multipart/form-data body. Each part is a file whose
447/// `name` attribute is the (relative) target path — as the web client sends.
448pub fn multipart_body(parts: &[(&str, &[u8])], boundary: &str) -> (String, Vec<u8>) {
449 let mut buf = Vec::new();
450 for (name, content) in parts {
451 buf.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
452 buf.extend_from_slice(
453 format!("Content-Disposition: form-data; name=\"{name}\"\r\n").as_bytes(),
454 );
455 buf.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
456 buf.extend_from_slice(content);
457 buf.extend_from_slice(b"\r\n");
458 }
459 buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
460 (format!("multipart/form-data; boundary={boundary}"), buf)
461}
462
463/// Read a zip into a name → content map (files only).
464pub fn zip_map(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
465 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive");
466 let mut map = BTreeMap::new();
467 for i in 0..zip.len() {
468 let mut f = zip.by_index(i).unwrap();
469 let name = f.name().unwrap().to_string();
470 if name.ends_with('/') {
471 continue;
472 }
473 let mut buf = Vec::new();
474 std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
475 map.insert(name, buf);
476 }
477 map
478}
479
480/// Read a tar (optionally gz/zst compressed) into a name → content map.
481pub fn tar_map(raw: &[u8], compress: Compress) -> BTreeMap<String, Vec<u8>> {
482 let decompressed: Box<dyn std::io::Read> = match compress {
483 Compress::None => Box::new(std::io::Cursor::new(raw)),
484 Compress::Gz => Box::new(flate2::read::GzDecoder::new(std::io::Cursor::new(raw))),
485 Compress::Zst => {
486 Box::new(zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap())
487 }
488 };
489 let mut map = BTreeMap::new();
490 for entry in tar::Archive::new(decompressed).entries().unwrap() {
491 let mut e = entry.unwrap();
492 if !e.header().entry_type().is_file() {
493 continue;
494 }
495 let name = e.path().unwrap().to_string_lossy().into_owned();
496 let mut buf = Vec::new();
497 std::io::Read::read_to_end(&mut e, &mut buf).unwrap();
498 map.insert(name, buf);
499 }
500 map
501}
502
503pub enum Compress {
504 None,
505 Gz,
506 Zst,
507}
508