//! Shared helpers for the end-to-end tests: a server on an ephemeral port and a ureq client. //! //! Accounts: `paired` bootstraps a user-owned vault from a fresh admin pairing code //! (a code issued for a vault id the server does not know creates the vault), //! `create_user` and `pair_user` drive the admin user endpoints, `make_vault` creates //! a vault owned by a device's user, and `invite` helpers share vaults. #![allow(dead_code)] // every helper is used by one of the test binaries use std::path::{Path, PathBuf}; use std::sync::Mutex; use notescraft_sync_server::{AppState, Store, router}; use serde_json::{Value, json}; pub const ADMIN: &str = "test-admin-token-0123456789abcdef"; /// A running server, a ureq client, and the secrets it must never leak. pub struct Server { pub base: String, pub agent: ureq::Agent, pub state: AppState, secrets: std::sync::Mutex>, } pub fn temp_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("nc-sync-{name}-{}", std::process::id())); std::fs::remove_dir_all(&dir).ok(); std::fs::create_dir_all(dir.join("blobs")).expect("temp dir"); dir } /// Starts a server with the default quota. pub fn start(data: &Path) -> Server { start_with_state(AppState::new(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN)) } /// Runs the state the test has built; the temp folder comes from its store and blobs. pub fn start_with_state(state: AppState) -> Server { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); listener.set_nonblocking(true).expect("nonblocking"); let port = listener.local_addr().expect("addr").port(); std::thread::spawn({ let state = state.clone(); move || { let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("runtime"); runtime.block_on(async { let listener = tokio::net::TcpListener::from_std(listener).expect("listener"); axum::serve(listener, router(state)).await.ok(); }); } }); let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); Server { state, base: format!("http://127.0.0.1:{port}"), agent, secrets: Mutex::new(Vec::new()) } } impl Server { /// Creates the default-state server over a fresh temp folder. pub fn start_temp(name: &str) -> Server { start(&temp_dir(name)) } /// Registers a secret so later checks can assert it never shows up in responses. pub fn leak(&self, secret: &str) { self.secrets.lock().expect("secrets").push(secret.to_owned()); } /// Asserts none of the registered secrets appear in `text`. pub fn assert_no_secrets(&self, text: &str) { for secret in self.secrets.lock().expect("secrets").iter() { assert!(!text.contains(secret.as_str()), "secret leaked: {secret}"); } } /// `(status, headers as (name, value) pairs, body)`. pub fn send_full( &self, method: &str, path: &str, token: Option<&str>, headers: &[(&str, &str)], body: Option<&[u8]>, ) -> (u16, Vec<(String, String)>, Vec) { let url = format!("{}{path}", self.base); let mut builder = ureq::http::Request::builder().method(method).uri(url); if let Some(token) = token { builder = builder.header("Authorization", format!("Bearer {token}")); } for (name, value) in headers { builder = builder.header(*name, *value); } let request = builder.body(body.unwrap_or_default().to_vec()).expect("request"); let mut response = self.agent.run(request).expect("transport"); let heads = response.headers().iter().map(|(name, value)| (name.as_str().to_owned(), String::from_utf8_lossy(value.as_bytes()).into_owned())).collect(); (response.status().as_u16(), heads, response.body_mut().read_to_vec().expect("body")) } pub fn send(&self, method: &str, path: &str, token: Option<&str>, headers: &[(&str, &str)], body: Option<&[u8]>) -> (u16, Vec) { let (status, _, body) = self.send_full(method, path, token, headers, body); (status, body) } pub fn json(&self, method: &str, path: &str, token: Option<&str>, headers: &[(&str, &str)], body: Option<&Value>) -> (u16, Value) { let bytes = body.map(|b| serde_json::to_vec(b).expect("json")); let mut all = vec![("Content-Type", "application/json")]; all.extend_from_slice(headers); let (status, reply) = self.send(method, path, token, &all, bytes.as_deref()); (status, serde_json::from_slice(&reply).unwrap_or(Value::Null)) } /// Creates a fresh vault id for admin pairing bootstraps (unique within a test run). pub fn fresh_vault_id(name: &str) -> String { use std::sync::atomic::{AtomicU32, Ordering}; static NEXT: AtomicU32 = AtomicU32::new(0); let serial = NEXT.fetch_add(1, Ordering::Relaxed); format!("v-{name}-{serial}-{}", std::process::id()) } /// Creates a vault owned by a new user in one bootstrap: a fresh vault id, an admin /// pairing code for it, one redemption. `(vault_id, token, user_id)`. pub fn owned_vault(&self, name: &str) -> (String, String, String) { let vault = Self::fresh_vault_id(name); let (status, code) = self.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(ADMIN), &[], None); assert_eq!(status, 200); let (status, device, token) = self.redeem(code["code"].as_str().expect("code"), name); assert_eq!(status, 200, "{device}"); (vault, token, device["user_id"].as_str().expect("user id").to_owned()) } /// An owner-only alias of [`Self::owned_vault`] for the earlier two-field callers. pub fn paired(&self, name: &str) -> (String, String) { let (vault, token, _) = self.owned_vault(name); (vault, token) } /// Creates a user (admin) and returns its id. pub fn create_user(&self, name: &str) -> String { let (status, user) = self.json("POST", "/v1/users", Some(ADMIN), &[], Some(&json!({"name": name}))); assert_eq!(status, 200); user["user_id"].as_str().expect("user id").to_owned() } /// Pairs a device under an existing user via a `?user=` bound pairing code. pub fn pair_user(&self, user: &str, vault: &str, name: &str) -> (u16, String) { let (status, code) = self.json("POST", &format!("/v1/vaults/{vault}/pairing?user={user}"), Some(ADMIN), &[], None); assert_eq!(status, 200); let (status, reply, token) = self.redeem(code["code"].as_str().expect("code"), name); let _ = reply; (status, token) } /// Creates a vault owned by `token`'s user: `(status, vault_id)`. pub fn make_vault(&self, token: &str, name: &str) -> (u16, String) { let (status, reply) = self.json("POST", "/v1/vaults", Some(token), &[], Some(&json!({"name": name}))); (status, reply["vault_id"].as_str().unwrap_or_default().to_owned()) } /// Mints an invite from `vault` with `role` (owner `token`); registers the code as /// secret. `(status, code)`. pub fn invite(&self, vault: &str, token: &str, role: &str) -> (u16, String) { let (status, minted) = self.json("POST", &format!("/v1/vaults/{vault}/invites"), Some(token), &[], Some(&json!({"role": role}))); let code = minted["code"].as_str().unwrap_or_default().to_owned(); self.leak(&code); (status, code) } /// The invite listing of a vault (owner `token`). pub fn invites(&self, vault: &str, token: &str) -> Vec { let (status, rows) = self.json("GET", &format!("/v1/vaults/{vault}/invites"), Some(token), &[], None); assert_eq!(status, 200); rows.as_array().expect("invite rows").clone() } /// Accepts `code` with `token`; `(status, body)`. pub fn accept(&self, token: &str, code: &str) -> (u16, Value) { self.json("POST", "/v1/invites/accept", Some(token), &[], Some(&json!({"code": code}))) } /// Issues a pairing code for the vault and redeems it; registers both as secrets. pub fn pair_to(&self, vault_id: &str, name: &str) -> String { let (status, code) = self.json("POST", &format!("/v1/vaults/{vault_id}/pairing"), Some(ADMIN), &[], None); assert_eq!(status, 200); let (status, device, token) = self.redeem(code["code"].as_str().expect("code"), name); assert_eq!(status, 200, "{device}"); self.leak(code["code"].as_str().expect("code")); token } /// Redeems a code without registering secrets; returns `(status, body, token)`. pub fn redeem(&self, code: &str, name: &str) -> (u16, Value, String) { let (status, device) = self.json("POST", "/v1/devices", None, &[], Some(&json!({"code": code, "name": name}))); let token = device["token"].as_str().unwrap_or_default().to_owned(); (status, device, token) } pub fn put(&self, vault: &str, token: &str, path: &str, body: &str, base_seq: u64) -> (u16, Value) { let (status, reply) = self.send("PUT", &format!("/v1/vaults/{vault}/files/{path}"), Some(token), &[("X-Base-Seq", &base_seq.to_string())], Some(body.as_bytes())); (status, serde_json::from_slice(&reply).unwrap_or(Value::Null)) } /// The audit rows (`/v1/audit`, admin), newest first. pub fn audit(&self) -> Vec { let (status, rows) = self.json("GET", "/v1/audit?limit=100", Some(ADMIN), &[], None); assert_eq!(status, 200); rows.as_array().expect("audit rows").clone() } }