181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
208 lines
9.5 KiB
Rust
208 lines
9.5 KiB
Rust
//! 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<Vec<String>>,
|
|
}
|
|
|
|
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<u8>) {
|
|
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<u8>) {
|
|
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<Value> {
|
|
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<Value> {
|
|
let (status, rows) = self.json("GET", "/v1/audit?limit=100", Some(ADMIN), &[], None);
|
|
assert_eq!(status, 200);
|
|
rows.as_array().expect("audit rows").clone()
|
|
}
|
|
}
|