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