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

217 lines
10 KiB
Rust

//! Hardening: pairing lockout, device lifecycle, audit log, request ids and readiness.
mod common;
use common::{ADMIN, Server, temp_dir};
use notescraft_sync_server::now_ms;
use serde_json::{Value, json};
const LOCKOUT_KIND: &str = "pairing_locked_out";
const FAILED_KIND: &str = "pairing_failed";
fn kinds_of(rows: &[Value]) -> Vec<String> {
rows.iter().map(|row| row["kind"].as_str().expect("kind").to_owned()).collect()
}
fn body_text(body: &[u8]) -> String {
String::from_utf8_lossy(body).into_owned()
}
#[test]
fn lockout_after_five_bad_codes_answers_429_even_with_a_good_code() {
let server = Server::start_temp("lockout");
let (vault, _) = server.paired("keeper");
for _ in 0..5 {
let (status, _) = server.json("POST", "/v1/devices", None, &[], Some(&json!({"code": "bad", "name": "ghost"})));
assert_eq!(status, 401);
}
// A correct code is refused, too, with Retry-After.
let (_, code) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(ADMIN), &[], None);
server.leak(code["code"].as_str().expect("code"));
let request = serde_json::to_vec(&json!({"code": code["code"], "name": "late"})).expect("body");
let (status, heads, body) = server.send_full("POST", "/v1/devices", None, &[("Content-Type", "application/json")], Some(&request));
assert_eq!(status, 429, "{}", body_text(&body));
let retry_after = heads.iter().find(|(name, _)| name == "retry-after").map(|(_, value)| value.as_str()).expect("retry-after header");
assert!(retry_after.parse::<u64>().is_ok_and(|seconds| (1..=600).contains(&seconds)), "retry-after: {retry_after}");
// The lockout is audited; failed redemptions are, and none leaks the code.
let kinds = kinds_of(&server.audit());
assert_eq!(kinds.iter().filter(|k| *k == FAILED_KIND).count(), 5);
assert_eq!(kinds.iter().filter(|k| k.as_str() == LOCKOUT_KIND).count(), 1);
}
#[test]
fn pairing_works_again_once_the_lockout_window_has_passed() {
let server = Server::start_temp("lockout-window");
let state = server.state.clone();
// Vault and code come first, then failure stamps a second before the code existed.
let vault = Server::fresh_vault_id("lockout-window");
let (status, code) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(ADMIN), &[], None);
assert_eq!(status, 200);
let good = code["code"].as_str().expect("code").to_owned();
let base = now_ms().saturating_sub(1000);
for _ in 0..notescraft_sync_server::PAIRING_MAX_FAILURES {
let error = match state.pair_device("garbage", "ghost", None, base) {
Err(error) => error,
Ok(paired) => panic!("redeemed without a code: {paired:?}"),
};
assert_eq!(error.status.as_u16(), 401);
}
let error = match state.pair_device(&good, "late", None, base) {
Err(error) => error,
Ok(paired) => panic!("redeemed while locked: {paired:?}"),
};
assert_eq!(error.status.as_u16(), 429);
assert!(error.retry_after.is_some());
// Nothing was consumed; with `now` past the window the same code redeems.
let paired = state.pair_device(&good, "late", None, base + notescraft_sync_server::PAIRING_WINDOW_MS + 1).expect("redeemed");
assert_eq!(paired.vault_id, vault);
}
#[test]
fn rotation_gives_a_new_token_and_the_old_one_stops_working() {
let server = Server::start_temp("rotate");
let (vault, token) = server.paired("phone");
let changes = format!("/v1/vaults/{vault}/changes?since=0");
assert_eq!(server.send("GET", &changes, Some(&token), &[], None).0, 200);
let (status, rotated) = server.json("POST", &format!("/v1/vaults/{vault}/devices/me/rotate"), Some(&token), &[], None);
assert_eq!(status, 200);
let fresh = rotated["token"].as_str().expect("token").to_owned();
server.leak(&fresh);
assert_ne!(fresh, token);
assert_ne!(rotated["device_id"].as_str().expect("device id"), "");
assert_eq!(server.send("GET", &changes, Some(&token), &[], None).0, 401);
assert_eq!(server.send("GET", &changes, Some(&fresh), &[], None).0, 200);
// The rotation is audited with the device id, never the token.
let kinds = kinds_of(&server.audit());
assert!(kinds.iter().any(|k| k == "token_rotated"));
server.assert_no_secrets(&serde_json::to_string(&server.audit()).expect("rows"));
}
#[test]
fn revocation_kills_the_device_token_and_the_list_hides_secrets() {
let server = Server::start_temp("revoke");
let (vault, phone) = server.paired("phone");
let laptop = server.pair_to(&vault, "laptop");
server.leak(&laptop);
let list_url = format!("/v1/vaults/{vault}/devices");
let (_, devices) = server.json("GET", &list_url, Some(&laptop), &[], None);
let rows = devices.as_array().expect("device rows");
assert_eq!(rows.len(), 2);
let laptop_row = rows.iter().find(|row| row["name"] == "laptop").expect("laptop row");
let phone_row = rows.iter().find(|row| row["name"] == "phone").expect("phone row");
assert_eq!(laptop_row["current"], true);
assert_eq!(phone_row["current"], false);
server.assert_no_secrets(&serde_json::to_string(rows).expect("rows text"));
for wanted in ["id", "name", "created_ms", "last_seen_ms", "current"] {
assert!(laptop_row.get(wanted).is_some(), "missing field {wanted}");
}
let laptop_id = laptop_row["id"].as_str().expect("id").to_owned();
let (status, _) = server.send("DELETE", &format!("{list_url}/{laptop_id}"), Some(&phone), &[], None);
assert_eq!(status, 204);
assert_eq!(server.send("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&laptop), &[], None).0, 401);
assert_eq!(server.send("DELETE", &format!("{list_url}/{laptop_id}"), Some(&phone), &[], None).0, 404);
// A device may revoke itself.
let phone_id = phone_row["id"].as_str().expect("id").to_owned();
assert_eq!(server.json("GET", &list_url, Some(&phone), &[], None).1.as_array().expect("rows").len(), 1);
let (status, _) = server.send("DELETE", &format!("{list_url}/{phone_id}"), Some(&phone), &[], None);
assert_eq!(status, 204);
assert_eq!(server.send("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&phone), &[], None).0, 401);
}
#[test]
fn audit_records_events_in_order_and_without_secrets() {
let server = Server::start_temp("audit");
let (vault, token) = server.paired("phone");
server.leak(&token);
let (status, rotated) = server.json("POST", &format!("/v1/vaults/{vault}/devices/me/rotate"), Some(&token), &[], None);
assert_eq!(status, 200);
server.leak(rotated["token"].as_str().expect("token"));
// One failed redemption, so `pairing_failed` is in the log too.
let (status, _) = server.json("POST", "/v1/devices", None, &[], Some(&json!({"code": "nope", "name": "ghost"})));
assert_eq!(status, 401);
assert_eq!(server.send("GET", "/v1/audit?limit=100", Some("not-admin"), &[], None).0, 401);
let rows = server.audit();
let kinds = kinds_of(&rows);
for wanted in ["vault_created", "pairing_created", "device_paired", FAILED_KIND, "token_rotated"] {
assert!(kinds.iter().any(|k| k == wanted), "no {wanted} row; kinds: {kinds:?}");
}
// Newest first.
let ids: Vec<i64> = rows.iter().map(|row| row["id"].as_i64().expect("id")).collect();
let sorted: Vec<i64> = ids.clone();
assert_eq!(ids, sorted, "ids in insertion order");
let mut descending = ids.clone();
descending.sort_unstable();
descending.reverse();
assert_eq!(ids, descending);
// The failed row names the reason, never the code.
let failed = rows.iter().find(|row| row["kind"] == FAILED_KIND).expect("failed row");
assert_eq!(failed["detail"], "unknown_or_expired");
server.assert_no_secrets(&serde_json::to_string(&rows).expect("rows text"));
}
#[test]
fn every_answer_carries_an_echoed_or_generated_request_id() {
let server = Server::start_temp("request-id");
let id_of =
|heads: &[(String, String)]| heads.iter().find(|(name, _)| name == "x-request-id").map(|(_, value)| value.clone()).expect("x-request-id header");
let hex16 = |id: String| id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit());
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[], None);
assert_eq!(status, 200);
assert!(hex16(id_of(&heads)), "generated id is 16 hex chars: {}", id_of(&heads));
// A valid incoming id (at most 64 chars of [A-Za-z0-9-]) is echoed back.
let valid = "Echo-Id-01234567890123456789012345678901234567890123456789";
assert_eq!(valid.len(), 58);
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[("X-Request-Id", valid)], None);
assert_eq!((status, id_of(&heads)), (200, valid.to_owned()));
// Boundary: exactly 64 characters is still valid.
let boundary = "a".repeat(64);
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[("X-Request-Id", boundary.as_str())], None);
assert_eq!((status, id_of(&heads)), (200, boundary));
// Invalid ids are replaced by a generated one.
for bad in ["", "no/slash", "no space", "a".repeat(65).as_str()] {
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[("X-Request-Id", bad)], None);
assert_eq!(status, 200);
let id = id_of(&heads);
assert_ne!(id, bad);
assert!(hex16(id.clone()), "generated replacement: {id}");
}
}
#[test]
fn readyz_checks_database_and_blob_folder() {
let data = temp_dir("readyz");
let server = common::start(&data);
let (status, reply) = server.json("GET", "/readyz", None, &[], None);
assert_eq!((status, reply["ok"].clone()), (200, Value::Bool(true)));
assert_eq!(reply.get("failing"), None);
// A read-only blob folder fails the readiness probe (unix only).
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let blobs = data.join("blobs");
std::fs::set_permissions(&blobs, std::fs::Permissions::from_mode(0o555)).expect("chmod blobs");
let (status, reply) = server.json("GET", "/readyz", None, &[], None);
assert_eq!(status, 503);
assert_eq!(reply["ok"], Value::Bool(false));
assert_eq!(reply["failing"], "blobs");
std::fs::set_permissions(&blobs, std::fs::Permissions::from_mode(0o755)).expect("chmod back");
let (status, _) = server.json("GET", "/readyz", None, &[], None);
assert_eq!(status, 200);
std::fs::remove_dir_all(&data).expect("cleanup");
}
}