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// Every 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 http_body_util::BodyExt;
15use server::db::Db;
16use server::error::AppState;
17use tower::ServiceExt;
18
19// ---------------------------------------------------------------------------
20// Environment
21// ---------------------------------------------------------------------------
22
23/// A server with a small fixture tree:
24///
25/// ```text
26/// root/
27/// docs/
28/// inner/
29/// hello.txt ("hello world")
30/// a.txt ("file a")
31/// src/
32/// main.rs
33/// config.json
34/// notes.md
35/// editme.txt ("v1")
36/// blob.bin (64 bytes)
37/// ```
38pub struct Env {
39 pub root: tempfile::TempDir,
40 pub state: Arc<AppState>,
41 pub app: Router,
42 /// Kept alive so the thumbnail cache folder outlives the server.
43 pub cache: Option<tempfile::TempDir>,
44}
45
46impl Env {
47 pub async fn new() -> Self {
48 Self::build(false, true).await
49 }
50
51 /// Same fixtures, plus a thumbnail cache in a temp folder.
52 pub async fn with_thumbs() -> Self {
53 Self::build(true, true).await
54 }
55
56 /// A thumbnail cache with ffmpeg reported as missing, so the no-video
57 /// branch can be exercised on a machine that has it.
58 pub async fn without_ffmpeg() -> Self {
59 Self::build(true, false).await
60 }
61
62 async fn build(thumbs: bool, ffmpeg: bool) -> Self {
63 let root = tempfile::tempdir().unwrap();
64 let p = root.path();
65 std::fs::create_dir_all(p.join("docs/inner")).unwrap();
66 std::fs::create_dir_all(p.join("src")).unwrap();
67 std::fs::write(p.join("docs/inner/hello.txt"), "hello world").unwrap();
68 std::fs::write(p.join("docs/a.txt"), "file a").unwrap();
69 std::fs::write(p.join("src/main.rs"), "fn main() {}").unwrap();
70 std::fs::write(p.join("config.json"), "{\"k\": 1}").unwrap();
71 std::fs::write(p.join("notes.md"), "# notes").unwrap();
72 std::fs::write(p.join("editme.txt"), "v1").unwrap();
73 std::fs::write(p.join("blob.bin"), (0..64u8).collect::<Vec<_>>()).unwrap();
74
75 // In-memory SQLite: no temp files, no WAL, faster than file-backed.
76 let db = Db::open(std::path::Path::new(":memory:")).await.unwrap();
77 let cache = match thumbs {
78 true => Some(tempfile::tempdir().unwrap()),
79 false => None,
80 };
81 let thumbs = match &cache {
82 Some(d) => Some(Arc::new(
83 server::thumb::Thumbs::with_ffmpeg(d.path().to_path_buf(), ffmpeg)
84 .await
85 .unwrap(),
86 )),
87 None => None,
88 };
89 let state = Arc::new(AppState {
90 db,
91 root: p.canonicalize().unwrap(),
92 root_name: server::root_file_name(p),
93 public_url: None,
94 thumbs,
95 });
96 let app = server::api::router(state.clone());
97 Self {
98 root,
99 state,
100 app,
101 cache,
102 }
103 }
104
105 /// Absolute path of a fixture file inside the root.
106 pub fn file(&self, rel: &str) -> std::path::PathBuf {
107 self.root.path().join(rel)
108 }
109
110 /// Create an admin account (first boot) and return a signed-in client.
111 pub async fn admin(&self) -> Client {
112 self.signed_in("/api/auth/setup", "admin", "admin1234")
113 .await
114 }
115
116 /// POST credentials to `path` and return a client holding the session.
117 async fn signed_in(&self, path: &str, name: &str, password: &str) -> Client {
118 let r = Client::new(self.app.clone())
119 .post_json(
120 path,
121 &serde_json::json!({ "name": name, "password": password }),
122 )
123 .await;
124 assert_eq!(r.status, StatusCode::OK, "{path} as {name}: {}", r.text());
125 let mut c = Client::new(self.app.clone());
126 c.set_cookie(&session_cookie(&r).expect("a session cookie"));
127 c
128 }
129}
130
131// ---------------------------------------------------------------------------
132// Client
133// ---------------------------------------------------------------------------
134
135#[derive(Clone)]
136pub struct Client {
137 app: Router,
138 pub cookie: Option<String>,
139}
140
141pub struct Resp {
142 pub status: StatusCode,
143 pub headers: HeaderMap,
144 pub body: Vec<u8>,
145}
146
147impl Resp {
148 pub fn json(&self) -> serde_json::Value {
149 serde_json::from_slice(&self.body).unwrap_or_else(|e| {
150 panic!(
151 "body is not JSON ({e}):\n{}",
152 String::from_utf8_lossy(&self.body)
153 )
154 })
155 }
156
157 pub fn text(&self) -> String {
158 String::from_utf8_lossy(&self.body).into_owned()
159 }
160
161 pub fn header(&self, name: &str) -> Option<String> {
162 self.headers
163 .get(name)
164 .and_then(|v| v.to_str().ok())
165 .map(str::to_string)
166 }
167}
168
169impl Client {
170 pub fn new(app: Router) -> Self {
171 Self { app, cookie: None }
172 }
173
174 pub fn set_cookie(&mut self, token: &str) {
175 self.cookie = Some(token.to_string());
176 }
177
178 pub async fn raw(
179 &self,
180 method: Method,
181 path: &str,
182 extra_headers: &[(&str, &str)],
183 body: Vec<u8>,
184 ) -> Resp {
185 // Every real client sends one (HTTP/1.1 requires it), and the
186 // passkey routes need it to know which domain they speak for.
187 // `oneshot` with a relative URI would otherwise send none. A caller
188 // that names its own host wins, so a test can pretend to be on one
189 // WebAuthn cannot use.
190 let mut b = axum::http::Request::builder().method(method).uri(path);
191 if !extra_headers
192 .iter()
193 .any(|(k, _)| k.eq_ignore_ascii_case("host"))
194 {
195 b = b.header(header::HOST, "files.example.com");
196 }
197 for (k, v) in extra_headers {
198 b = b.header(*k, *v);
199 }
200 if let Some(c) = &self.cookie {
201 b = b.header(header::COOKIE, format!("fbng_session={c}"));
202 }
203 let req = b
204 .body(Body::from(body))
205 .unwrap_or_else(|e| panic!("bad request: {e}"));
206 let res = self
207 .app
208 .clone()
209 .oneshot(req)
210 .await
211 .unwrap_or_else(|e| panic!("request failed: {e}"));
212 let status = res.status();
213 let headers = res.headers().clone();
214 let bytes = res.into_body().collect().await.unwrap().to_bytes();
215 Resp {
216 status,
217 headers,
218 body: bytes.to_vec(),
219 }
220 }
221
222 pub async fn get(&self, path: &str) -> Resp {
223 self.raw(Method::GET, path, &[], Vec::new()).await
224 }
225
226 pub async fn delete(&self, path: &str) -> Resp {
227 self.raw(Method::DELETE, path, &[], Vec::new()).await
228 }
229
230 pub async fn post_json(&self, path: &str, v: &serde_json::Value) -> Resp {
231 self.raw(
232 Method::POST,
233 path,
234 &[("content-type", "application/json")],
235 v.to_string().into_bytes(),
236 )
237 .await
238 }
239
240 pub async fn put_json(&self, path: &str, v: &serde_json::Value) -> Resp {
241 self.raw(
242 Method::PUT,
243 path,
244 &[("content-type", "application/json")],
245 v.to_string().into_bytes(),
246 )
247 .await
248 }
249
250 /// `PUT ...?action=content` (editor save).
251 pub async fn put_content(
252 &self,
253 path: &str,
254 content: &[u8],
255 expected_mtime: Option<i64>,
256 ) -> Resp {
257 let mtime = expected_mtime.map(|m| m.to_string());
258 let mut hdrs = vec![("content-type", "text/plain")];
259 if let Some(m) = &mtime {
260 hdrs.push(("x-expected-mtime", m));
261 }
262 self.raw(Method::PUT, path, &hdrs, content.to_vec()).await
263 }
264
265 /// POST a multipart upload with one file per part name.
266 pub async fn post_multipart(&self, path: &str, parts: &[(&str, &[u8])], query: &str) -> Resp {
267 let boundary = "testboundary123";
268 let (ct, body) = multipart_body(parts, boundary);
269 let full = if query.is_empty() {
270 path.to_string()
271 } else {
272 format!("{path}?{query}")
273 };
274 self.raw(Method::POST, &full, &[("content-type", &ct)], body)
275 .await
276 }
277}
278
279/// Extract the `fbng_session` cookie value from `Set-Cookie` headers.
280pub fn session_cookie(r: &Resp) -> Option<String> {
281 for v in r.headers.get_all(header::SET_COOKIE) {
282 let s = v.to_str().ok()?;
283 let first = s.split(';').next().unwrap_or("");
284 if let Some(tok) = first.strip_prefix("fbng_session=")
285 && !tok.is_empty()
286 {
287 return Some(tok.to_string());
288 }
289 }
290 None
291}
292
293/// An `Authorization: Basic` header value.
294pub fn basic(name: &str, password: &str) -> String {
295 use base64::Engine as _;
296 let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
297 format!("Basic {raw}")
298}
299
300/// POST /api/admin/users helper (used by several test files).
301pub async fn create_user(
302 admin: &Client,
303 name: &str,
304 password: &str,
305 roots: &[(&str, &str)],
306) -> serde_json::Value {
307 let roots_json: Vec<serde_json::Value> = roots
308 .iter()
309 .map(|(p, m)| serde_json::json!({ "path": p, "mode": m }))
310 .collect();
311 let r = admin
312 .post_json(
313 "/api/admin/users",
314 &serde_json::json!({
315 "name": name,
316 "password": password,
317 "is_admin": false,
318 "roots": roots_json,
319 }),
320 )
321 .await;
322 assert_eq!(r.status, StatusCode::OK, "create {name}: {}", r.text());
323 r.json()
324}
325
326/// Log in and return a signed-in client.
327pub async fn login(env: &Env, name: &str, password: &str) -> Client {
328 env.signed_in("/api/auth/login", name, password).await
329}
330
331/// POST /api/shares and return the created share.
332pub async fn create_share(who: &Client, body: serde_json::Value) -> serde_json::Value {
333 let r = who.post_json("/api/shares", &body).await;
334 assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
335 r.json()
336}
337
338/// Find a user id by name via the admin API.
339pub async fn user_id(admin: &Client, name: &str) -> i64 {
340 let r = admin.get("/api/admin/users").await;
341 assert_eq!(r.status, StatusCode::OK);
342 let j = r.json();
343 let users = j.as_array().unwrap();
344 users
345 .iter()
346 .find(|u| u["name"] == name)
347 .unwrap_or_else(|| panic!("user {name} not found"))["id"]
348 .as_i64()
349 .unwrap()
350}
351
352// ---------------------------------------------------------------------------
353// Helpers: multipart + archive readers
354// ---------------------------------------------------------------------------
355
356/// Build a minimal multipart/form-data body. Each part is a file whose
357/// `name` attribute is the (relative) target path — as the web client sends.
358pub fn multipart_body(parts: &[(&str, &[u8])], boundary: &str) -> (String, Vec<u8>) {
359 let mut buf = Vec::new();
360 for (name, content) in parts {
361 buf.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
362 buf.extend_from_slice(
363 format!("Content-Disposition: form-data; name=\"{name}\"\r\n").as_bytes(),
364 );
365 buf.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
366 buf.extend_from_slice(content);
367 buf.extend_from_slice(b"\r\n");
368 }
369 buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
370 (format!("multipart/form-data; boundary={boundary}"), buf)
371}
372
373/// Read a zip into a name → content map (files only).
374pub fn zip_map(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
375 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive");
376 let mut map = BTreeMap::new();
377 for i in 0..zip.len() {
378 let mut f = zip.by_index(i).unwrap();
379 let name = f.name().unwrap().to_string();
380 if name.ends_with('/') {
381 continue;
382 }
383 let mut buf = Vec::new();
384 std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
385 map.insert(name, buf);
386 }
387 map
388}
389
390/// Read a tar (optionally gz/zst compressed) into a name → content map.
391pub fn tar_map(raw: &[u8], compress: Compress) -> BTreeMap<String, Vec<u8>> {
392 let decompressed: Box<dyn std::io::Read> = match compress {
393 Compress::None => Box::new(std::io::Cursor::new(raw)),
394 Compress::Gz => Box::new(flate2::read::GzDecoder::new(std::io::Cursor::new(raw))),
395 Compress::Zst => {
396 Box::new(zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap())
397 }
398 };
399 let mut map = BTreeMap::new();
400 for entry in tar::Archive::new(decompressed).entries().unwrap() {
401 let mut e = entry.unwrap();
402 if !e.header().entry_type().is_file() {
403 continue;
404 }
405 let name = e.path().unwrap().to_string_lossy().into_owned();
406 let mut buf = Vec::new();
407 std::io::Read::read_to_end(&mut e, &mut buf).unwrap();
408 map.insert(name, buf);
409 }
410 map
411}
412
413pub enum Compress {
414 None,
415 Gz,
416 Zst,
417}
418