Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
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>
2026-10-20 07:46:24 -03:00

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()
}
}