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::body::Body;
12use axum::http::{header, HeaderMap, Method, StatusCode};
13use axum::Router;
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 dbdir: tempfile::TempDir,
41 pub state: Arc<AppState>,
42 pub app: Router,
43}
44
45impl Env {
46 pub async fn new() -> Self {
47 let root = tempfile::tempdir().unwrap();
48 let p = root.path();
49 std::fs::create_dir_all(p.join("docs/inner")).unwrap();
50 std::fs::create_dir_all(p.join("src")).unwrap();
51 std::fs::write(p.join("docs/inner/hello.txt"), "hello world").unwrap();
52 std::fs::write(p.join("docs/a.txt"), "file a").unwrap();
53 std::fs::write(p.join("src/main.rs"), "fn main() {}").unwrap();
54 std::fs::write(p.join("config.json"), "{\"k\": 1}").unwrap();
55 std::fs::write(p.join("notes.md"), "# notes").unwrap();
56 std::fs::write(p.join("editme.txt"), "v1").unwrap();
57 std::fs::write(p.join("blob.bin"), (0..64u8).collect::<Vec<_>>()).unwrap();
58
59 let dbdir = tempfile::tempdir().unwrap();
60 let db = Db::open(&dbdir.path().join("db.sqlite")).await.unwrap();
61 let state = Arc::new(AppState {
62 db,
63 root: p.canonicalize().unwrap(),
64 https: false,
65 });
66 let app = server::api::router(state.clone());
67 Self {
68 root,
69 dbdir,
70 state,
71 app,
72 }
73 }
74
75 /// Absolute path of a fixture file inside the root.
76 pub fn file(&self, rel: &str) -> std::path::PathBuf {
77 self.root.path().join(rel)
78 }
79
80 /// Create an admin account (first boot) and return a signed-in client.
81 pub async fn admin(&self) -> Client {
82 let c = Client::new(self.app.clone());
83 let r = c
84 .post_json(
85 "/api/auth/setup",
86 &serde_json::json!({ "name": "admin", "password": "admin1234" }),
87 )
88 .await;
89 assert_eq!(r.status, StatusCode::OK, "setup failed: {}", r.text());
90 let token = session_cookie(&r).expect("setup must set a session cookie");
91 let mut c = Client::new(self.app.clone());
92 c.set_cookie(&token);
93 c
94 }
95}
96
97// ---------------------------------------------------------------------------
98// Client
99// ---------------------------------------------------------------------------
100
101pub struct Client {
102 app: Router,
103 pub cookie: Option<String>,
104}
105
106pub struct Resp {
107 pub status: StatusCode,
108 pub headers: HeaderMap,
109 pub body: Vec<u8>,
110}
111
112impl Resp {
113 pub fn json(&self) -> serde_json::Value {
114 serde_json::from_slice(&self.body).unwrap_or_else(|e| {
115 panic!(
116 "body is not JSON ({e}):\n{}",
117 String::from_utf8_lossy(&self.body)
118 )
119 })
120 }
121
122 pub fn text(&self) -> String {
123 String::from_utf8_lossy(&self.body).into_owned()
124 }
125
126 pub fn header(&self, name: &str) -> Option<String> {
127 self.headers
128 .get(name)
129 .and_then(|v| v.to_str().ok())
130 .map(str::to_string)
131 }
132}
133
134impl Client {
135 pub fn new(app: Router) -> Self {
136 Self { app, cookie: None }
137 }
138
139 pub fn set_cookie(&mut self, token: &str) {
140 self.cookie = Some(token.to_string());
141 }
142
143 pub async fn raw(
144 &self,
145 method: Method,
146 path: &str,
147 extra_headers: &[(&str, &str)],
148 body: Vec<u8>,
149 ) -> Resp {
150 let mut b = axum::http::Request::builder().method(method).uri(path);
151 for (k, v) in extra_headers {
152 b = b.header(*k, *v);
153 }
154 if let Some(c) = &self.cookie {
155 b = b.header(header::COOKIE, format!("fbng_session={c}"));
156 }
157 let req = b
158 .body(Body::from(body))
159 .unwrap_or_else(|e| panic!("bad request: {e}"));
160 let res = self
161 .app
162 .clone()
163 .oneshot(req)
164 .await
165 .unwrap_or_else(|e| panic!("request failed: {e}"));
166 let status = res.status();
167 let headers = res.headers().clone();
168 let bytes = res.into_body().collect().await.unwrap().to_bytes();
169 Resp {
170 status,
171 headers,
172 body: bytes.to_vec(),
173 }
174 }
175
176 pub async fn get(&self, path: &str) -> Resp {
177 self.raw(Method::GET, path, &[], Vec::new()).await
178 }
179
180 pub async fn delete(&self, path: &str) -> Resp {
181 self.raw(Method::DELETE, path, &[], Vec::new()).await
182 }
183
184 pub async fn post_json(&self, path: &str, v: &serde_json::Value) -> Resp {
185 self.raw(
186 Method::POST,
187 path,
188 &[("content-type", "application/json")],
189 v.to_string().into_bytes(),
190 )
191 .await
192 }
193
194 pub async fn put_json(&self, path: &str, v: &serde_json::Value) -> Resp {
195 self.raw(
196 Method::PUT,
197 path,
198 &[("content-type", "application/json")],
199 v.to_string().into_bytes(),
200 )
201 .await
202 }
203
204 /// `PUT ...?action=content` (editor save).
205 pub async fn put_content(
206 &self,
207 path: &str,
208 content: &[u8],
209 expected_mtime: Option<i64>,
210 ) -> Resp {
211 let mut extra: Vec<(&str, String)> = vec![("content-type", "text/plain".to_string())];
212 if let Some(m) = expected_mtime {
213 extra.push(("x-expected-mtime", format!("{m}")));
214 }
215 let owned: Vec<(String, String)> =
216 extra.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
217 let hdrs: Vec<(&str, &str)> = owned
218 .iter()
219 .map(|(k, v)| (k.as_str(), v.as_str()))
220 .collect();
221 self.raw(Method::PUT, path, &hdrs, content.to_vec()).await
222 }
223
224 /// POST a multipart upload with one file per part name.
225 pub async fn post_multipart(&self, path: &str, parts: &[(&str, &[u8])], query: &str) -> Resp {
226 let boundary = "testboundary123";
227 let (ct, body) = multipart_body(parts, boundary);
228 let full = if query.is_empty() {
229 path.to_string()
230 } else {
231 format!("{path}?{query}")
232 };
233 self.raw(Method::POST, &full, &[("content-type", &ct)], body)
234 .await
235 }
236}
237
238/// Extract the `fbng_session` cookie value from `Set-Cookie` headers.
239pub fn session_cookie(r: &Resp) -> Option<String> {
240 for v in r.headers.get_all(header::SET_COOKIE) {
241 let s = v.to_str().ok()?;
242 let first = s.split(';').next().unwrap_or("");
243 if let Some(tok) = first.strip_prefix("fbng_session=") {
244 if !tok.is_empty() {
245 return Some(tok.to_string());
246 }
247 }
248 }
249 None
250}
251
252/// POST /api/admin/users helper (used by several test files).
253pub async fn create_user(
254 admin: &Client,
255 name: &str,
256 password: &str,
257 roots: &[(&str, &str)],
258) -> serde_json::Value {
259 let roots_json: Vec<serde_json::Value> = roots
260 .iter()
261 .map(|(p, m)| serde_json::json!({ "path": p, "mode": m }))
262 .collect();
263 let r = admin
264 .post_json(
265 "/api/admin/users",
266 &serde_json::json!({
267 "name": name,
268 "password": password,
269 "is_admin": false,
270 "roots": roots_json,
271 }),
272 )
273 .await;
274 assert_eq!(r.status, StatusCode::OK, "create {name}: {}", r.text());
275 r.json()
276}
277
278/// Log in and return a signed-in client.
279pub async fn login(env: &Env, name: &str, password: &str) -> Client {
280 let c = Client::new(env.app.clone());
281 let r = c
282 .post_json(
283 "/api/auth/login",
284 &serde_json::json!({ "name": name, "password": password }),
285 )
286 .await;
287 assert_eq!(r.status, StatusCode::OK, "login {name}: {}", r.text());
288 let mut c = Client::new(env.app.clone());
289 c.set_cookie(&session_cookie(&r).unwrap());
290 c
291}
292
293/// Find a user id by name via the admin API.
294pub async fn user_id(admin: &Client, name: &str) -> i64 {
295 let r = admin.get("/api/admin/users").await;
296 assert_eq!(r.status, StatusCode::OK);
297 let j = r.json();
298 let users = j.as_array().unwrap();
299 users
300 .iter()
301 .find(|u| u["name"] == name)
302 .unwrap_or_else(|| panic!("user {name} not found"))
303 .as_object()
304 .unwrap()
305 .get("id")
306 .unwrap()
307 .as_i64()
308 .unwrap()
309}
310
311// ---------------------------------------------------------------------------
312// Helpers: multipart + archive readers
313// ---------------------------------------------------------------------------
314
315/// Build a minimal multipart/form-data body. Each part is a file whose
316/// `name` attribute is the (relative) target path — as the web client sends.
317pub fn multipart_body(parts: &[(&str, &[u8])], boundary: &str) -> (String, Vec<u8>) {
318 let mut buf = Vec::new();
319 for (name, content) in parts {
320 buf.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
321 buf.extend_from_slice(
322 format!("Content-Disposition: form-data; name=\"{name}\"\r\n").as_bytes(),
323 );
324 buf.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
325 buf.extend_from_slice(content);
326 buf.extend_from_slice(b"\r\n");
327 }
328 buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
329 (format!("multipart/form-data; boundary={boundary}"), buf)
330}
331
332/// Read a zip into a name → content map (files only).
333pub fn zip_map(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
334 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive");
335 let mut map = BTreeMap::new();
336 for i in 0..zip.len() {
337 let mut f = zip.by_index(i).unwrap();
338 let name = f.name().unwrap().to_string();
339 if name.ends_with('/') {
340 continue;
341 }
342 let mut buf = Vec::new();
343 std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
344 map.insert(name, buf);
345 }
346 map
347}
348
349/// Read a tar (optionally gz/zst compressed) into a name → content map.
350pub fn tar_map(raw: &[u8], compress: Compress) -> BTreeMap<String, Vec<u8>> {
351 let decompressed: Box<dyn std::io::Read> = match compress {
352 Compress::None => Box::new(std::io::Cursor::new(raw)),
353 Compress::Gz => Box::new(flate2::read::GzDecoder::new(std::io::Cursor::new(raw))),
354 Compress::Zst => {
355 Box::new(zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap())
356 }
357 };
358 let mut map = BTreeMap::new();
359 for entry in tar::Archive::new(decompressed).entries().unwrap() {
360 let mut e = entry.unwrap();
361 if !e.header().entry_type().is_file() {
362 continue;
363 }
364 let name = e.path().unwrap().to_string_lossy().into_owned();
365 let mut buf = Vec::new();
366 std::io::Read::read_to_end(&mut e, &mut buf).unwrap();
367 map.insert(name, buf);
368 }
369 map
370}
371
372pub enum Compress {
373 None,
374 Gz,
375 Zst,
376}
377