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