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>
192 lines
9.4 KiB
Rust
192 lines
9.4 KiB
Rust
//! Accounts over real HTTP: legacy migration, pairing creates or reuses an account,
|
|
//! `GET /v1/vaults` lists only the caller's memberships, and the ledger cache cap
|
|
//! keeps every vault answering after eviction.
|
|
|
|
mod common;
|
|
|
|
use common::{ADMIN, Server, temp_dir};
|
|
use notescraft_sync::hash_hex;
|
|
use serde_json::json;
|
|
|
|
/// The legacy schema, before accounts existed (devices were bound to vaults directly).
|
|
const LEGACY: &str = "
|
|
CREATE TABLE vaults (id TEXT PRIMARY KEY, created_ms INTEGER NOT NULL);
|
|
CREATE TABLE pairings (code_hash TEXT PRIMARY KEY, vault TEXT NOT NULL, expires_ms INTEGER NOT NULL);
|
|
CREATE TABLE devices (id TEXT PRIMARY KEY, vault TEXT NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_ms INTEGER NOT NULL);
|
|
CREATE TABLE files (vault TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL,
|
|
mtime_ms INTEGER NOT NULL, seq INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY (vault, path));
|
|
";
|
|
|
|
#[test]
|
|
fn migration_keeps_an_old_token_working_and_creates_an_owner() {
|
|
let dir = temp_dir("accounts-legacy");
|
|
let path = dir.join("sync.db");
|
|
{
|
|
let conn = rusqlite::Connection::open(&path).expect("db");
|
|
conn.execute_batch(LEGACY).expect("legacy schema");
|
|
let token = "legacy-device-token";
|
|
conn.execute("INSERT INTO vaults (id, created_ms) VALUES ('v-old', 1)", []).expect("vault");
|
|
conn.execute("INSERT INTO devices (id, vault, name, token_hash, created_ms) VALUES ('d-old', 'v-old', 'keeper', ?1, 1)", [hash_hex(token.as_bytes())])
|
|
.expect("device");
|
|
}
|
|
let state = notescraft_sync_server::AppState::new(notescraft_sync_server::Store::open(&path).expect("open"), dir.join("blobs"), ADMIN);
|
|
let server = common::start_with_state(state);
|
|
|
|
// The old token now belongs to a fresh account that owns the old vault.
|
|
let (status, rows) = server.json("GET", "/v1/vaults", Some("legacy-device-token"), &[], None);
|
|
assert_eq!(status, 200, "the legacy token must keep working");
|
|
let rows = rows.as_array().expect("rows");
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0]["id"], "v-old");
|
|
assert_eq!(rows[0]["role"], "owner");
|
|
assert_eq!(rows[0]["name"], "v-old");
|
|
|
|
// The migrated user is named after the device and is the vault's owner.
|
|
let (status, members) = server.json("GET", "/v1/vaults/v-old/members", Some("legacy-device-token"), &[], None);
|
|
assert_eq!(status, 200);
|
|
let members = members.as_array().expect("members");
|
|
assert_eq!(members.len(), 1);
|
|
assert_eq!(members[0]["name"], "keeper");
|
|
assert_eq!(members[0]["role"], "owner");
|
|
assert!(members[0]["user_id"].as_str().expect("user id").starts_with("u-"));
|
|
|
|
// Reading and writing through the legacy token is unchanged.
|
|
assert_eq!(server.put("v-old", "legacy-device-token", "a.md", "x", 0).0, 201);
|
|
assert!(server.audit().iter().any(|row| row["kind"] == "member_added"));
|
|
}
|
|
|
|
#[test]
|
|
fn pair_twice_with_the_same_user_leads_to_one_account() {
|
|
let server = Server::start_temp("accounts-two-devices");
|
|
let user = server.create_user("marc");
|
|
let vault = Server::fresh_vault_id("two-devices");
|
|
|
|
let (status, phone) = server.pair_user(&user, &vault, "phone");
|
|
assert_eq!(status, 200);
|
|
let (status, laptop) = server.pair_user(&user, &vault, "laptop");
|
|
assert_eq!(status, 200);
|
|
assert_ne!(phone, laptop);
|
|
|
|
// Both devices belong to the same account and see exactly that account's vaults.
|
|
let (_, mine_phone) = server.json("GET", "/v1/vaults", Some(&phone), &[], None);
|
|
let (_, mine_laptop) = server.json("GET", "/v1/vaults", Some(&laptop), &[], None);
|
|
assert_eq!(mine_phone, mine_laptop);
|
|
assert_eq!(mine_phone.as_array().expect("rows").len(), 1);
|
|
assert_eq!(mine_phone[0]["id"], vault);
|
|
|
|
let (_, devices) = server.json("GET", &format!("/v1/vaults/{vault}/devices"), Some(&phone), &[], None);
|
|
assert_eq!(devices.as_array().expect("rows").len(), 2);
|
|
|
|
// A second pairing did not demote the existing membership: still one member row.
|
|
let (_, members) = server.json("GET", &format!("/v1/vaults/{vault}/members"), Some(&phone), &[], None);
|
|
assert_eq!(members.as_array().expect("members").len(), 1);
|
|
|
|
// `?user=` for a user that does not exist is refused.
|
|
let (status, _) = server.json("POST", &format!("/v1/vaults/{}/pairing?user=u-missing", Server::fresh_vault_id("none")), Some(ADMIN), &[], None);
|
|
assert_eq!(status, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn vault_listing_shows_only_my_memberships_sorted_by_name() {
|
|
let server = Server::start_temp("accounts-twelve");
|
|
let (bootstrap, token, _) = server.owned_vault("home");
|
|
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{bootstrap}"), Some(&token), &[], Some(&json!({"name": "00 home"}))).0, 204);
|
|
|
|
let wanted =
|
|
["20 work", "10 personal", "30 course", "40 game jam", "50 cooking", "60 music", "70 travel", "80 reading", "90 garden", "95 blog", "99 archive"];
|
|
let mut ids = vec![bootstrap.clone()];
|
|
for name in wanted {
|
|
let (status, vault) = server.make_vault(&token, name);
|
|
assert_eq!(status, 200, "{name}");
|
|
ids.push(vault);
|
|
}
|
|
assert_eq!(ids.len(), 12);
|
|
|
|
// Another account's vault never shows up.
|
|
let (intruder_vault, _) = server.paired("intruder");
|
|
let _ = intruder_vault;
|
|
|
|
let (_, mine) = server.json("GET", "/v1/vaults", Some(&token), &[], None);
|
|
let rows = mine.as_array().expect("rows");
|
|
assert_eq!(rows.len(), 12);
|
|
let names: Vec<&str> = rows.iter().map(|row| row["name"].as_str().expect("name")).collect();
|
|
let mut sorted = names.clone();
|
|
sorted.sort_unstable();
|
|
assert_eq!(names, sorted, "sorted by display name");
|
|
assert!(!names.contains(&"intruder"), "no other user's vault");
|
|
assert!(!names.contains(&"v-intruder"));
|
|
for row in rows {
|
|
assert_eq!(row["role"], "owner");
|
|
assert_eq!(row["members"], 1);
|
|
let _ = (row["bytes_used"].as_u64(), row["quota_bytes"].as_u64().unwrap_or(u64::MAX) > 0, row["head_seq"].as_u64(), row["last_change_ms"].as_u64());
|
|
assert!(ids.contains(&row["id"].as_str().expect("id").to_owned()), "row {} of mine", row["id"]);
|
|
}
|
|
assert_eq!(rows.iter().find(|row| row["id"] == bootstrap.as_str()).map(|row| row["name"].as_str()), Some(Some("00 home")));
|
|
|
|
// An unknown token lists nothing: 401.
|
|
assert_eq!(server.json("GET", "/v1/vaults", Some("nonsense"), &[], None).0, 401);
|
|
assert_eq!(server.json("GET", "/v1/vaults", None, &[], None).0, 401);
|
|
}
|
|
|
|
#[test]
|
|
fn vault_names_are_trimmed_validated_and_capped_per_user() {
|
|
let server = Server::start_temp("accounts-cap");
|
|
let (bootstrap, token, _) = server.owned_vault("cap");
|
|
assert_eq!(server.make_vault(&token, " ").0, 400, "empty names are refused");
|
|
assert_eq!(server.make_vault(&token, "x".repeat(81).as_str()).0, 400);
|
|
let (status, vault) = server.make_vault(&token, " padded ");
|
|
assert_eq!(status, 200);
|
|
let (_, mine) = server.json("GET", "/v1/vaults", Some(&token), &[], None);
|
|
let row = mine.as_array().expect("rows").iter().find(|row| row["id"] == vault.as_str()).expect("row");
|
|
assert_eq!(row["name"], "padded", "names are trimmed");
|
|
|
|
// The bootstrap vault plus 49 created reach the ownership cap; 51 is refused.
|
|
for index in 0..48 {
|
|
let (status, _) = server.make_vault(&token, &format!("vault {index:02}"));
|
|
assert_eq!(status, 200, "vault {index}");
|
|
}
|
|
assert_eq!(server.make_vault(&token, "one too many").0, 409);
|
|
let _ = bootstrap;
|
|
}
|
|
|
|
#[test]
|
|
fn seventy_vaults_stay_answerable_across_lru_evictions() {
|
|
let server = Server::start_temp("accounts-lru");
|
|
let (owner_vault, owner, _) = server.owned_vault("lru-owner");
|
|
let mut vaults = vec![owner_vault.clone()];
|
|
for index in 0..49 {
|
|
let (status, vault) = server.make_vault(&owner, &format!("owned {index:02}"));
|
|
assert_eq!(status, 200);
|
|
vaults.push(vault);
|
|
}
|
|
// A second account adds 20 more, so the cap of 64 is passed together.
|
|
let (other_vault, other) = server.paired("lru-other");
|
|
vaults.push(other_vault);
|
|
for index in 0..19 {
|
|
let (status, vault) = server.make_vault(&other, &format!("theirs {index:02}"));
|
|
assert_eq!(status, 200);
|
|
vaults.push(vault);
|
|
}
|
|
assert_eq!(vaults.len(), 70);
|
|
|
|
// Touch all 70: loading walks past the cache cap, so older ledgers are evicted.
|
|
for (index, vault) in vaults.iter().enumerate() {
|
|
let token = if index < 50 { owner.as_str() } else { other.as_str() };
|
|
let name = format!("note{index}.md");
|
|
assert_eq!(server.put(vault, token, &name, "written", 0).0, 201, "vault {index}");
|
|
let (status, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(token), &[], None);
|
|
assert_eq!((status, page["records"].as_array().map(Vec::len)), (200, Some(1)), "vault {index}");
|
|
}
|
|
|
|
// Every vault, including the earliest (long-evicted), still answers correctly.
|
|
for (index, vault) in vaults.iter().enumerate() {
|
|
let token = if index < 50 { owner.as_str() } else { other.as_str() };
|
|
let (status, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(token), &[], None);
|
|
assert_eq!((status, page["records"][0]["path"].as_str()), (200, Some(format!("note{index}.md").as_str())), "vault {index}");
|
|
}
|
|
let (_, mine) = server.json("GET", "/v1/vaults", Some(&owner), &[], None);
|
|
assert_eq!(mine.as_array().expect("rows").len(), 50);
|
|
assert_eq!(mine[0]["role"], "owner");
|
|
let _ = owner_vault;
|
|
}
|