Five test modules wrote the same db() over memory_conn, and portable.rs unwrapped it inline; local::test_db() is that. Four pull/push tests wrote the same label INSERT; local::seed_label(conn, id, name, dirty) is it. Both are cfg(test), beside memory_conn, as wire::sample_note is beside wire::Note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
594 lines
23 KiB
Rust
594 lines
23 KiB
Rust
//! The link record: which server this app is paired with, the device token that
|
|
//! authenticates to it, and how far it has consumed that server's change feed.
|
|
//!
|
|
//! One row, enforced by `CHECK (id = 1)` and seeded during migration, so every
|
|
//! operation here is an UPDATE — there is no create-or-missing case to handle.
|
|
//!
|
|
//! The token lives in the app-data SQLite file rather than an OS keyring on purpose:
|
|
//! the `keyring` crate needs libsecret/DBus on Linux, which adds a C dependency to a
|
|
//! binary that has to cross-compile, and fails outright on headless or minimal-WM
|
|
//! setups. Protecting the database file is the portable trade.
|
|
//!
|
|
//! A client that has somewhere better to keep a key passes a [`TokenSeal`], and the
|
|
//! token is stored sealed. Android does, with a key in the Keystore (family idea
|
|
//! #5105, practice 12); the desktop does not.
|
|
|
|
use rusqlite::{params, Connection};
|
|
use serde::Serialize;
|
|
|
|
/// The full link record, token included. Internal to the Rust side.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct SyncState {
|
|
pub server_url: Option<String>,
|
|
pub device_token: Option<String>,
|
|
pub last_cursor: i64,
|
|
pub last_sync_at: Option<String>,
|
|
/// The linked server's trash-retention window, as it last advertised it. `None`
|
|
/// until a probe or sync has learned it.
|
|
pub server_retention_days: Option<i64>,
|
|
}
|
|
|
|
impl SyncState {
|
|
/// Linked means BOTH a server and a credential for it. Either one alone is a
|
|
/// half-written link that nothing can act on, so it must not read as linked.
|
|
pub fn is_linked(&self) -> bool {
|
|
self.server_url.is_some() && self.device_token.is_some()
|
|
}
|
|
}
|
|
|
|
/// What the UI is allowed to see.
|
|
///
|
|
/// Deliberately has no `device_token` field: this crosses into the webview, and a
|
|
/// long-lived bearer token has no business being reachable from page scripts.
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
pub struct Status {
|
|
pub linked: bool,
|
|
pub server_url: Option<String>,
|
|
pub last_cursor: i64,
|
|
pub last_sync_at: Option<String>,
|
|
}
|
|
|
|
impl From<&SyncState> for Status {
|
|
fn from(s: &SyncState) -> Self {
|
|
Status {
|
|
linked: s.is_linked(),
|
|
server_url: s.server_url.clone(),
|
|
last_cursor: s.last_cursor,
|
|
last_sync_at: s.last_sync_at.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Treat a blank string as absent, so a half-cleared row can't masquerade as linked.
|
|
fn present(value: Option<String>) -> Option<String> {
|
|
value.filter(|s| !s.trim().is_empty())
|
|
}
|
|
|
|
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
|
conn.query_row(
|
|
"SELECT server_url, device_token, last_cursor, last_sync_at, server_retention_days
|
|
FROM sync_state WHERE id = 1",
|
|
[],
|
|
|row| {
|
|
let cursor: Option<String> = row.get(2)?;
|
|
Ok(SyncState {
|
|
last_sync_at: present(row.get(3)?),
|
|
server_retention_days: row.get(4)?,
|
|
server_url: present(row.get(0)?),
|
|
device_token: present(row.get(1)?),
|
|
// Stored TEXT (schema) but used as an integer watermark. Absent or
|
|
// unparseable means "start from the beginning" — always the safe
|
|
// reading, because a redundant full sync costs time, never data,
|
|
// whereas a too-high cursor silently skips changes.
|
|
last_cursor: cursor.and_then(|c| c.trim().parse().ok()).unwrap_or(0),
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Record a link.
|
|
///
|
|
/// Resets the change-feed cursor whenever the server differs from the one previously
|
|
/// linked. A cursor is only meaningful against the server that issued it; carrying
|
|
/// one across would silently skip every change on the new server below that
|
|
/// watermark — data loss that looks like a successful sync. Re-linking the SAME
|
|
/// server (after a token refresh, say) keeps the cursor, so a routine re-auth doesn't
|
|
/// force a full re-download.
|
|
pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusqlite::Result<()> {
|
|
let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url);
|
|
conn.execute(
|
|
"UPDATE sync_state
|
|
SET server_url = ?1,
|
|
device_token = ?2,
|
|
last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END,
|
|
shares_synced = CASE WHEN ?3 THEN shares_synced ELSE 0 END
|
|
WHERE id = 1",
|
|
params![server_url, device_token, keep_cursor],
|
|
)?;
|
|
if !keep_cursor {
|
|
forget_shared_notes(conn)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Seals the device token for storage, and opens it again.
|
|
///
|
|
/// Neither method fails loudly: a client's key store can be briefly unavailable, and
|
|
/// a token sealed on another device can never be opened here. `None` says so, and
|
|
/// [`set_sealed_link`] and [`open_token`] decide what happens next.
|
|
pub trait TokenSeal {
|
|
/// The token sealed for storage, or None when that isn't possible right now.
|
|
fn seal(&self, token: &str) -> Option<String>;
|
|
/// The sealed token opened, or None when this device can't open it.
|
|
fn open(&self, sealed: &str) -> Option<String>;
|
|
}
|
|
|
|
/// Marks a stored token as sealed. A device token is URL-safe base64, which has no
|
|
/// colon, so a plain one can't be mistaken for a sealed one.
|
|
const SEALED: &str = "sealed:";
|
|
|
|
/// [`set_link`] for a client that seals its token.
|
|
///
|
|
/// When sealing fails, the token is stored as it is and [`open_token`] seals it on
|
|
/// its next read. Refusing the link instead would leave someone unable to sync
|
|
/// over a passing key-store error.
|
|
pub fn set_sealed_link(
|
|
conn: &Connection,
|
|
server_url: &str,
|
|
device_token: &str,
|
|
seal: &dyn TokenSeal,
|
|
) -> rusqlite::Result<()> {
|
|
let stored = match seal.seal(device_token) {
|
|
Some(sealed) => format!("{SEALED}{sealed}"),
|
|
None => {
|
|
log::warn!("couldn't seal the device token; it is stored as is until the next read");
|
|
device_token.to_string()
|
|
}
|
|
};
|
|
set_link(conn, server_url, &stored)
|
|
}
|
|
|
|
/// The device token the server expects, from the `stored` one.
|
|
///
|
|
/// - A sealed token is opened. If it won't open, it is dropped, so the app reads as
|
|
/// unlinked and asks to sign in again. That is what happens when Android restores
|
|
/// the app's files onto another phone, whose Keystore never held the key. The
|
|
/// server address and cursor stay, for the sign-in form and the same server.
|
|
/// - A plain token, stored before tokens were sealed or when sealing failed, is
|
|
/// sealed in place and returned.
|
|
pub fn open_token(
|
|
conn: &Connection,
|
|
stored: &str,
|
|
seal: &dyn TokenSeal,
|
|
) -> rusqlite::Result<Option<String>> {
|
|
if let Some(sealed) = stored.strip_prefix(SEALED) {
|
|
let token = seal.open(sealed);
|
|
if token.is_none() {
|
|
log::warn!("the stored device token won't open on this device; sign in again");
|
|
store_token(conn, None)?;
|
|
}
|
|
return Ok(token);
|
|
}
|
|
if let Some(sealed) = seal.seal(stored) {
|
|
store_token(conn, Some(&format!("{SEALED}{sealed}")))?;
|
|
}
|
|
Ok(Some(stored.to_string()))
|
|
}
|
|
|
|
/// The server address and the token to send it, or None when this device isn't
|
|
/// linked. The ONE read of a link for a call to the server: every client goes
|
|
/// through here, so none can send a token as stored when it is sealed (#5381).
|
|
///
|
|
/// With a `seal`, the stored token is opened ([`open_token`]); a client without one
|
|
/// (the desktop keeps its token plain) passes None and gets it as stored.
|
|
pub fn credentials(
|
|
conn: &Connection,
|
|
seal: Option<&dyn TokenSeal>,
|
|
) -> rusqlite::Result<Option<(String, String)>> {
|
|
let link = read(conn)?;
|
|
let (Some(url), Some(stored)) = (link.server_url, link.device_token) else {
|
|
return Ok(None);
|
|
};
|
|
let token = match seal {
|
|
Some(seal) => open_token(conn, &stored, seal)?,
|
|
None => Some(stored),
|
|
};
|
|
Ok(token.map(|token| (url, token)))
|
|
}
|
|
|
|
/// Replace the stored token alone, leaving the server and cursor as they are.
|
|
fn store_token(conn: &Connection, token: Option<&str>) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state SET device_token = ?1 WHERE id = 1",
|
|
params![token],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Drop the notes other people shared with the account this device was linked to.
|
|
/// They were only ever here through that link: kept after it ends, they would sit
|
|
/// on the board as notes nobody here can edit and nothing would ever update.
|
|
fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> {
|
|
conn.execute("DELETE FROM notes WHERE permission <> 'owner'", [])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// How much of sharing this device's copy of the feed reflects, kept in
|
|
/// `sync_state.shares_synced`. Each level changed what the feed says about notes a
|
|
/// device may already hold, below the cursor it already has.
|
|
///
|
|
/// Shared notes arrive at all (#5175).
|
|
pub const SHARES: i64 = 1;
|
|
/// A shared note's pin, archive and position are this account's own (#5176). Before,
|
|
/// the feed sent the owner's, and a device holding those would keep showing them
|
|
/// until something next changed the note.
|
|
pub const SHARED_STATE: i64 = 2;
|
|
|
|
/// Start the change feed over the first time this device pulls at a sharing `level`
|
|
/// the server offers, so what it already holds is re-read in that level's terms.
|
|
///
|
|
/// Returns whether it restarted. Once per level per link: `set_link` to another
|
|
/// server and `clear_link` both go back to none.
|
|
pub fn begin_shares(conn: &Connection, level: i64) -> rusqlite::Result<bool> {
|
|
let restarted = conn.execute(
|
|
"UPDATE sync_state SET last_cursor = NULL, shares_synced = ?1
|
|
WHERE id = 1 AND shares_synced < ?1",
|
|
params![level],
|
|
)?;
|
|
Ok(restarted > 0)
|
|
}
|
|
|
|
/// Forget the server entirely.
|
|
///
|
|
/// Clears the cursor as well as the credentials: a cursor left behind would, on the
|
|
/// next link, be interpreted against a server that never issued it.
|
|
pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state
|
|
SET server_url = NULL, device_token = NULL, last_cursor = NULL,
|
|
last_sync_at = NULL, server_retention_days = NULL, shares_synced = 0
|
|
WHERE id = 1",
|
|
[],
|
|
)?;
|
|
forget_shared_notes(conn)
|
|
}
|
|
|
|
/// Remember the linked server's trash-retention window (0 = it never purges).
|
|
///
|
|
/// Refreshed on every sync rather than only at link time, so changing the setting on
|
|
/// the server reaches the desktop's Trash countdown on the next cycle instead of
|
|
/// waiting for someone to re-link.
|
|
pub fn set_server_retention(conn: &Connection, days: i64) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state SET server_retention_days = ?1 WHERE id = 1",
|
|
params![days],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The retention window in force on THIS device: the linked server's if we know it,
|
|
/// otherwise the caller's offline default. A linked device must never enforce or
|
|
/// advertise its own window over the server's.
|
|
pub fn effective_retention_days(conn: &Connection, offline_default: i64) -> rusqlite::Result<i64> {
|
|
let state = read(conn)?;
|
|
if !state.is_linked() {
|
|
return Ok(offline_default);
|
|
}
|
|
// Linked but the server hasn't told us yet (linked by an older build, or no sync
|
|
// has completed). Fall back to the default rather than claiming "kept forever".
|
|
Ok(state.server_retention_days.unwrap_or(offline_default))
|
|
}
|
|
|
|
/// Stamp a completed sync. The cursor can't stand in for this: it's a revision
|
|
/// watermark, and it doesn't move at all when a sync correctly finds nothing new —
|
|
/// so "synced a moment ago, no changes" would be indistinguishable from "never
|
|
/// synced" without it.
|
|
pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state SET last_sync_at = ?1 WHERE id = 1",
|
|
params![when],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
|
|
/// after a page has been fully applied.
|
|
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
"UPDATE sync_state SET last_cursor = ?1 WHERE id = 1",
|
|
params![cursor.to_string()],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn status(conn: &Connection) -> rusqlite::Result<Status> {
|
|
Ok(Status::from(&read(conn)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use crate::local::test_db as db;
|
|
|
|
#[test]
|
|
fn fresh_store_is_unlinked() {
|
|
let conn = db();
|
|
let state = read(&conn).expect("read");
|
|
assert_eq!(state, SyncState::default());
|
|
assert!(!state.is_linked());
|
|
assert_eq!(state.last_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn link_round_trips() {
|
|
let conn = db();
|
|
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
|
let state = read(&conn).expect("read");
|
|
assert!(state.is_linked());
|
|
assert_eq!(
|
|
state.server_url.as_deref(),
|
|
Some("https://notes.example.com")
|
|
);
|
|
assert_eq!(state.device_token.as_deref(), Some("tok-1"));
|
|
}
|
|
|
|
/// Reverses the token: enough to tell sealed from plain. `broken` stands in for a
|
|
/// key store that is unavailable, or a key this device never held.
|
|
struct Reverse {
|
|
broken: bool,
|
|
}
|
|
|
|
impl TokenSeal for Reverse {
|
|
fn seal(&self, token: &str) -> Option<String> {
|
|
(!self.broken).then(|| token.chars().rev().collect())
|
|
}
|
|
fn open(&self, sealed: &str) -> Option<String> {
|
|
(!self.broken).then(|| sealed.chars().rev().collect())
|
|
}
|
|
}
|
|
|
|
const WORKING: Reverse = Reverse { broken: false };
|
|
const BROKEN: Reverse = Reverse { broken: true };
|
|
|
|
fn stored_token(conn: &Connection) -> Option<String> {
|
|
read(conn).expect("read").device_token
|
|
}
|
|
|
|
#[test]
|
|
fn a_sealed_token_is_stored_sealed_and_opens() {
|
|
let conn = db();
|
|
set_sealed_link(&conn, "https://notes.example.com", "tok-1", &WORKING).expect("link");
|
|
assert_eq!(stored_token(&conn).as_deref(), Some("sealed:1-kot"));
|
|
|
|
let opened = open_token(&conn, "sealed:1-kot", &WORKING).expect("open");
|
|
assert_eq!(opened.as_deref(), Some("tok-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plain_token_is_sealed_on_its_next_read() {
|
|
let conn = db();
|
|
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
|
|
|
let opened = open_token(&conn, "tok-1", &WORKING).expect("open");
|
|
assert_eq!(opened.as_deref(), Some("tok-1"));
|
|
assert_eq!(stored_token(&conn).as_deref(), Some("sealed:1-kot"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_token_that_cant_be_sealed_is_kept_plain_and_still_works() {
|
|
let conn = db();
|
|
set_sealed_link(&conn, "https://notes.example.com", "tok-1", &BROKEN).expect("link");
|
|
assert_eq!(stored_token(&conn).as_deref(), Some("tok-1"));
|
|
let opened = open_token(&conn, "tok-1", &BROKEN).expect("open");
|
|
assert_eq!(opened.as_deref(), Some("tok-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_token_that_wont_open_here_is_dropped_but_the_server_is_kept() {
|
|
let conn = db();
|
|
set_sealed_link(&conn, "https://notes.example.com", "tok-1", &WORKING).expect("link");
|
|
|
|
let opened = open_token(&conn, "sealed:1-kot", &BROKEN).expect("open");
|
|
assert_eq!(opened, None);
|
|
let state = read(&conn).expect("read");
|
|
assert!(!state.is_linked());
|
|
assert_eq!(state.device_token, None);
|
|
assert_eq!(
|
|
state.server_url.as_deref(),
|
|
Some("https://notes.example.com")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unlinked_device_uses_its_own_retention_window() {
|
|
let conn = db();
|
|
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30);
|
|
}
|
|
|
|
#[test]
|
|
fn a_linked_device_adopts_the_servers_window() {
|
|
// Including 0 — a server that keeps trash forever must not have this device
|
|
// showing a 30-day countdown that will never fire.
|
|
let conn = db();
|
|
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
|
set_server_retention(&conn, 0).expect("retention");
|
|
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 0);
|
|
set_server_retention(&conn, 90).expect("retention");
|
|
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 90);
|
|
}
|
|
|
|
#[test]
|
|
fn a_linked_device_that_hasnt_heard_yet_falls_back() {
|
|
// Linked by an older build, or no cycle has completed. The default is a
|
|
// safer guess than "forever", which would promise a note is being kept.
|
|
let conn = db();
|
|
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
|
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30);
|
|
}
|
|
|
|
#[test]
|
|
fn unlinking_forgets_the_servers_window() {
|
|
let conn = db();
|
|
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
|
set_server_retention(&conn, 90).expect("retention");
|
|
clear_link(&conn).expect("unlink");
|
|
assert_eq!(read(&conn).expect("read").server_retention_days, None);
|
|
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30);
|
|
}
|
|
|
|
#[test]
|
|
fn relinking_the_same_server_keeps_the_cursor() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 4242).expect("cursor");
|
|
// e.g. the token was revoked and the user re-authenticated.
|
|
set_link(&conn, "https://a.example.com", "tok-2").expect("relink");
|
|
let state = read(&conn).expect("read");
|
|
assert_eq!(
|
|
state.last_cursor, 4242,
|
|
"a re-auth shouldn't force a full re-sync"
|
|
);
|
|
assert_eq!(state.device_token.as_deref(), Some("tok-2"));
|
|
}
|
|
|
|
#[test]
|
|
fn linking_a_different_server_resets_the_cursor() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 4242).expect("cursor");
|
|
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
|
assert_eq!(
|
|
read(&conn).expect("read").last_cursor,
|
|
0,
|
|
"a cursor from another server would skip everything below it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unlink_clears_the_cursor_too() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 99).expect("cursor");
|
|
clear_link(&conn).expect("unlink");
|
|
let state = read(&conn).expect("read");
|
|
assert!(!state.is_linked());
|
|
assert_eq!(state.last_cursor, 0);
|
|
assert!(state.server_url.is_none());
|
|
assert!(state.device_token.is_none());
|
|
}
|
|
|
|
fn seed(conn: &Connection, id: &str, permission: &str) {
|
|
conn.execute(
|
|
"INSERT INTO notes (id, body, created_at, updated_at, permission)
|
|
VALUES (?1, 'x', '2026-01-01', '2026-01-01', ?2)",
|
|
params![id, permission],
|
|
)
|
|
.expect("seed");
|
|
}
|
|
|
|
fn note_ids(conn: &Connection) -> Vec<String> {
|
|
let mut stmt = conn.prepare("SELECT id FROM notes ORDER BY id").unwrap();
|
|
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
|
|
rows.collect::<rusqlite::Result<_>>().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn unlinking_drops_the_notes_others_shared_and_keeps_our_own() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
seed(&conn, "mine", "owner");
|
|
seed(&conn, "theirs", "view");
|
|
seed(&conn, "editable", "edit");
|
|
// Re-linking the same server keeps everything.
|
|
set_link(&conn, "https://a.example.com", "tok-2").expect("relink");
|
|
assert_eq!(note_ids(&conn), ["editable", "mine", "theirs"]);
|
|
clear_link(&conn).expect("unlink");
|
|
assert_eq!(note_ids(&conn), ["mine"]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_first_pull_with_shares_starts_over_once_per_link() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
set_cursor(&conn, 500).expect("cursor");
|
|
assert!(begin_shares(&conn, SHARES).expect("begin"));
|
|
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
|
set_cursor(&conn, 600).expect("cursor");
|
|
assert!(
|
|
!begin_shares(&conn, SHARES).expect("again"),
|
|
"only the first time"
|
|
);
|
|
assert_eq!(read(&conn).expect("read").last_cursor, 600);
|
|
// A server offering more of sharing starts it over once more, and only once.
|
|
assert!(begin_shares(&conn, SHARED_STATE).expect("a newer server"));
|
|
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
|
set_cursor(&conn, 700).expect("cursor");
|
|
assert!(!begin_shares(&conn, SHARED_STATE).expect("again"));
|
|
assert!(!begin_shares(&conn, SHARES).expect("an older level"));
|
|
assert_eq!(read(&conn).expect("read").last_cursor, 700);
|
|
// Another server is a fresh start, shares included.
|
|
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
|
assert!(begin_shares(&conn, SHARES).expect("new server"));
|
|
}
|
|
|
|
#[test]
|
|
fn unlink_clears_the_last_sync_stamp() {
|
|
// Otherwise a freshly-linked server would claim it synced at a time that
|
|
// belonged to a different one.
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
|
mark_synced(&conn, "2026-07-26T04:00:00.000Z").expect("stamp");
|
|
assert!(read(&conn).expect("read").last_sync_at.is_some());
|
|
clear_link(&conn).expect("unlink");
|
|
assert!(read(&conn).expect("read").last_sync_at.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn half_written_link_is_not_linked() {
|
|
let conn = db();
|
|
conn.execute(
|
|
"UPDATE sync_state SET server_url = 'https://a.example.com' WHERE id = 1",
|
|
[],
|
|
)
|
|
.expect("partial write");
|
|
assert!(!read(&conn).expect("read").is_linked());
|
|
}
|
|
|
|
#[test]
|
|
fn blank_strings_count_as_absent() {
|
|
let conn = db();
|
|
conn.execute(
|
|
"UPDATE sync_state SET server_url = ' ', device_token = '' WHERE id = 1",
|
|
[],
|
|
)
|
|
.expect("blank write");
|
|
let state = read(&conn).expect("read");
|
|
assert!(!state.is_linked());
|
|
assert!(state.server_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unparseable_cursor_falls_back_to_a_full_sync() {
|
|
let conn = db();
|
|
conn.execute(
|
|
"UPDATE sync_state SET last_cursor = 'garbage' WHERE id = 1",
|
|
[],
|
|
)
|
|
.expect("bad cursor");
|
|
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn status_never_carries_the_token() {
|
|
let conn = db();
|
|
set_link(&conn, "https://a.example.com", "super-secret").expect("link");
|
|
let json = serde_json::to_string(&status(&conn).expect("status")).expect("serialize");
|
|
assert!(
|
|
!json.contains("super-secret"),
|
|
"token leaked to the webview: {json}"
|
|
);
|
|
assert!(json.contains("\"linked\":true"), "got {json}");
|
|
}
|
|
}
|