//! The group directory: the server's answer about groups, written down locally. //! //! Groups, their members, the invitations outstanding on them, and the one //! previewed invite code a person who is not in a group yet may be holding. //! //! # Why it exists //! //! A described screen's handler is a synchronous function. That is not a //! limitation to work around; it is the property that lets one description serve //! a webview, a terminal and an egui window, because nothing in a description //! blocks. It does mean a screen cannot fetch anything, so any data a screen //! needs has to be on this side of the network before the screen is drawn. //! //! Everything else a consuming app draws already is: rows come out of its own //! tables. Groups were the exception. `list_groups` is an HTTP GET, membership //! lives on the server, and the only local trace of a group was its id in //! `sync_scope_cursor`, which is a cursor rather than a directory and carries no //! name. //! //! # It costs no request //! //! The sync loop asks the server which groups to sync, once per cycle, and //! [`SyncTransport::list_group_directory`](super::sync::SyncTransport::list_group_directory) //! carries the name and the admin flag with each record. Writing that down is //! the whole mechanism: the read already happens, on a schedule, outside any //! request loop. //! //! Members and invitations are the parts that are a new request, made only for //! groups this user administers, since the server refuses a non-admin. Worth //! being plain about, because the "it costs no request" heading above is only //! true of the group list itself: the rest is paid for, and it is worth paying //! because a screen cannot ask for any of it. //! //! # Invitations carry one thing the server cannot give back //! //! `create_invitation` returns a token the server keeps only a hash of. So //! `sync_invitations.token` is the single column here that is not a copy of the //! server's answer: it is written by whoever issued the invitation //! ([`record_issued`]) and it is the only copy that will ever exist. That is why //! the refresh upserts instead of replacing, and why it drops the token the //! moment the invitation stops being pending. //! //! A previewed code ([`write_preview`]) is not part of the directory proper and //! the sync loop never touches it. The person holding one is not in the group, //! so there is no scope to hang it off and nothing to refresh it against. //! //! # What it is not //! //! Not synced state. Both tables are absent from every //! [`SyncSchema`](super::schema::SyncSchema), so they are never group-scoped, //! never pushed, never on a shared changelog, and they **do not move the storage //! version**: nothing here crosses the wire, which is exactly the local-only DDL //! the gate's own rule exempts. //! //! The server is the authority and this is a copy of its answer, so there is //! nothing to merge and no conflict to resolve. A refresh replaces. //! //! # Staleness is a fact, not a failure //! //! A screen that draws a list states how current that list is, and the three //! tables here refresh on three schedules: the group list arrives every cycle, //! while members and invitations are separate per-group fetches made only for //! the groups this user administers. So there is a reader per list rather than //! one for the directory -- [`refreshed_at`], [`members_refreshed_at`], //! [`invitations_refreshed_at`], [`pending_confirmations_refreshed_at`] -- and //! each is the freshness of exactly what its sibling read returns. One number //! standing for all three would be a claim about a different list on the cycle //! where it matters, which is the cycle where a fetch failed. //! //! A directory that has never been refreshed is empty, which is a true statement //! about what this device knows rather than a claim that the user has no groups. use rusqlite::{Connection, OptionalExtension, params}; pub use super::sync::{KnownGroup, KnownInvitation, KnownMember, KnownPreview}; use crate::GroupId; use crate::error::Result; use crate::ids::InvitationId; use crate::types::InvitationState; /// Replace the group directory with what the server just reported. /// /// A whole replace rather than an upsert: a group the user has been removed from /// is absent from the answer, and leaving it behind would offer a scope they can /// no longer reach. Members of a departed group go with it, which is why this /// takes the transaction rather than leaving the caller to remember. pub fn write_groups(conn: &mut Connection, groups: &[KnownGroup]) -> Result<()> { let tx = conn.transaction()?; tx.execute("DELETE FROM sync_group_members", [])?; tx.execute("DELETE FROM sync_groups", [])?; for group in groups { tx.execute( "INSERT INTO sync_groups (group_id, name, gck_version, is_admin) \ VALUES (?1, ?2, ?3, ?4)", params![ group.id.to_string(), group.name, group.gck_version, i32::from(group.is_admin), ], )?; } tx.commit()?; Ok(()) } /// Add or update one group, without touching the rest of the directory. /// /// For the moment a group is created: the server knows it, the sync loop has not /// run since, and a screen that reads the directory would call the creator a /// non-member of their own group. Additive on purpose, since the loop's whole /// replace is the authoritative write and this is filling in ahead of it. pub fn add_group(conn: &mut Connection, group: &KnownGroup) -> Result<()> { conn.execute( "INSERT INTO sync_groups (group_id, name, gck_version, is_admin) \ VALUES (?1, ?2, ?3, ?4) \ ON CONFLICT(group_id) DO UPDATE SET \ name = excluded.name, \ gck_version = excluded.gck_version, \ is_admin = excluded.is_admin, \ refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", params![ group.id.to_string(), group.name, group.gck_version, i32::from(group.is_admin), ], )?; Ok(()) } /// Replace one group's member list. /// /// Scoped to the one group, unlike [`write_groups`]: a cycle where the member /// fetch failed for one group should not blank the others, and a refusal is /// "no answer this time" rather than "nobody is in it". pub fn write_members( conn: &mut Connection, group_id: GroupId, members: &[KnownMember], ) -> Result<()> { let tx = conn.transaction()?; tx.execute( "DELETE FROM sync_group_members WHERE group_id = ?1", params![group_id.to_string()], )?; for member in members { tx.execute( "INSERT INTO sync_group_members (group_id, user_id, email, role, added_at) \ VALUES (?1, ?2, ?3, ?4, ?5)", params![ group_id.to_string(), member.user_id, member.email, member.role, member.added_at, ], )?; } tx.commit()?; Ok(()) } /// Replace one group's invitation list, keeping the tokens this device issued. /// /// Upsert-and-prune rather than the delete-then-insert [`write_members`] uses, /// and the difference is load-bearing: `token` is the one column the server /// cannot send back, so deleting the row would throw away the only copy of a /// live invite code on the device that issued it. /// /// The token is dropped the moment the invitation stops being `pending`. Once /// somebody has accepted, the code has done its whole job and holding it is /// exposure with no use; `revoked` and `expired` drop it for the same reason. /// That happens here rather than at a call site because it is the answer to /// "what clears the token", and a caller that forgot would leave a dead code on /// screen. /// /// Scoped to the one group, like [`write_members`]: a cycle where the fetch /// failed for one group should not blank the others. pub fn write_invitations( conn: &mut Connection, group_id: GroupId, invitations: &[KnownInvitation], ) -> Result<()> { let tx = conn.transaction()?; for invitation in invitations { tx.execute( "INSERT INTO sync_invitations \ (invitation_id, group_id, state, invitee_email, invitee_fingerprint, \ expires_at, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ ON CONFLICT(invitation_id) DO UPDATE SET \ state = excluded.state, \ invitee_email = excluded.invitee_email, \ invitee_fingerprint = excluded.invitee_fingerprint, \ expires_at = excluded.expires_at, \ created_at = excluded.created_at, \ refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \ token = CASE WHEN excluded.state = 'pending' \ THEN sync_invitations.token ELSE NULL END", params![ invitation.id.to_string(), group_id.to_string(), state_str(invitation.state), invitation.invitee_email, invitation.invitee_fingerprint, invitation.expires_at, invitation.created_at, ], )?; } // Prune what the server no longer reports. Built as a NOT IN rather than a // delete-first so the upserts above keep their tokens; with no invitations // at all it is an unconditional delete for the group, which is correct and // is why the empty case is not special-cased away. let ids: Vec = invitations.iter().map(|i| i.id.to_string()).collect(); if ids.is_empty() { tx.execute( "DELETE FROM sync_invitations WHERE group_id = ?1", params![group_id.to_string()], )?; } else { let holes = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); let mut args: Vec = vec![group_id.to_string()]; args.extend(ids); tx.execute( &format!( "DELETE FROM sync_invitations WHERE group_id = ?1 \ AND invitation_id NOT IN ({holes})" ), rusqlite::params_from_iter(args), )?; } tx.commit()?; Ok(()) } /// Write down an invitation this device has just issued, with its token. /// /// The counterpart to [`add_group`], for the same reason and at the same moment: /// the server knows about it, the sync loop has not run since, and a screen /// reading the directory would show nothing landing. Here it is stronger than a /// convenience. `create_invitation` returns the only copy of the token that will /// ever exist, so a caller that does not write it down immediately has issued an /// invitation nobody can use. /// /// Additive, and the refresh's upsert takes it from here. pub fn record_issued( conn: &Connection, group_id: GroupId, invitation: &crate::types::GroupInvitation, ) -> Result<()> { conn.execute( "INSERT INTO sync_invitations \ (invitation_id, group_id, state, token, expires_at, created_at) \ VALUES (?1, ?2, 'pending', ?3, ?4, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) \ ON CONFLICT(invitation_id) DO UPDATE SET token = excluded.token", params![ invitation.id.to_string(), group_id.to_string(), invitation.token, invitation.expires_at.to_rfc3339(), ], )?; Ok(()) } /// A group's invitations, newest first. /// /// Empty for a group this user merely belongs to, because the server refuses a /// non-admin the list, exactly as [`members`] is. pub fn invitations(conn: &Connection, group_id: GroupId) -> Result> { read_invitations( conn, "SELECT invitation_id, group_id, state, invitee_email, invitee_fingerprint, \ token, expires_at, created_at FROM sync_invitations \ WHERE group_id = ?1 ORDER BY created_at DESC", params![group_id.to_string()], ) } /// Every invitation waiting on this admin, across all groups, oldest first. /// /// `Accepted` is the state that wants a person: the invitee has posted a key and /// nothing happens until the admin compares its fingerprint out of band and /// confirms. That comparison is the security of the whole flow rather than a /// formality, so it is worth one read that does not make the caller walk groups /// to find it. /// /// Oldest first because the answer to "who has been waiting longest" is the one /// an admin wants; the per-group list is newest first because there the question /// is "what did I just issue". pub fn pending_confirmations(conn: &Connection) -> Result> { read_invitations( conn, "SELECT invitation_id, group_id, state, invitee_email, invitee_fingerprint, \ token, expires_at, created_at FROM sync_invitations \ WHERE state = 'accepted' ORDER BY created_at", params![], ) } /// Shared row mapping for the two invitation reads. fn read_invitations( conn: &Connection, sql: &str, args: P, ) -> Result> { if !present(conn)? { return Ok(Vec::new()); } let mut stmt = conn.prepare(sql)?; let rows = stmt .query_map(args, |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?, row.get::<_, String>(6)?, row.get::<_, String>(7)?, )) })? .collect::, _>>()?; Ok(rows .into_iter() // A row this build cannot address is skipped rather than failing the // read, on the same reasoning as `groups`: it can only come from a build // that wrote a shape this one does not know, and a screen that cannot // list its invitations is worse than one missing a row it could not have // acted on anyway. .filter_map( |(id, group, state, email, fingerprint, token, expires, created)| { Some(KnownInvitation { id: InvitationId::new(id.parse().ok()?), group_id: GroupId::new(group.parse().ok()?), state: state_from_str(&state)?, invitee_email: email, invitee_fingerprint: fingerprint, token, expires_at: expires, created_at: created, }) }, ) .collect()) } /// Write down what a pasted invite code leads to. /// /// Replaces whatever was there: a person pastes one code at a time, and a /// previous answer they did not act on is not something to keep beside a new one. pub fn write_preview(conn: &mut Connection, preview: &KnownPreview) -> Result<()> { let tx = conn.transaction()?; tx.execute("DELETE FROM sync_invitation_previews", [])?; tx.execute( "INSERT INTO sync_invitation_previews \ (token, group_name, inviter_email, redeemable, state, expires_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ preview.token, preview.group_name, preview.inviter_email, i32::from(preview.redeemable), state_str(preview.state), preview.expires_at, ], )?; tx.commit()?; Ok(()) } /// The code this device is currently holding an answer about, if any. pub fn preview(conn: &Connection) -> Result> { if !present(conn)? { return Ok(None); } let row = conn .query_row( "SELECT token, group_name, inviter_email, redeemable, state, expires_at \ FROM sync_invitation_previews LIMIT 1", [], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, i32>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, )) }, ) .optional()?; Ok(row.and_then( |(token, group_name, inviter_email, redeemable, state, expires_at)| { Some(KnownPreview { token, group_name, inviter_email, redeemable: redeemable != 0, state: state_from_str(&state)?, expires_at, }) }, )) } /// Forget the previewed code, once it has been accepted or dismissed. pub fn clear_preview(conn: &Connection) -> Result<()> { if !present(conn)? { return Ok(()); } conn.execute("DELETE FROM sync_invitation_previews", [])?; Ok(()) } /// The stored spelling of a state. /// /// The serde `rename_all = "lowercase"` spelling, written by hand so the storage /// format does not move if the wire format's attribute ever does. `write_invitations` /// compares against the literal `'pending'` in SQL, so this is the one place the /// two have to agree. fn state_str(state: InvitationState) -> &'static str { match state { InvitationState::Pending => "pending", InvitationState::Accepted => "accepted", InvitationState::Redeemed => "redeemed", InvitationState::Revoked => "revoked", InvitationState::Expired => "expired", // No catch-all arm: `#[non_exhaustive]` does not bind inside the crate // that defines the enum, so a variant added later is a compile error // here rather than a silent "unknown" written into the database. That is // the outcome worth having, and it is why a consumer across the crate // boundary (goingson's `invitation_state_str`) needs the arm this does // not. } } /// Parse a stored state, `None` for one this build does not know. fn state_from_str(raw: &str) -> Option { match raw { "pending" => Some(InvitationState::Pending), "accepted" => Some(InvitationState::Accepted), "redeemed" => Some(InvitationState::Redeemed), "revoked" => Some(InvitationState::Revoked), "expired" => Some(InvitationState::Expired), _ => None, } } /// The directory's own DDL, idempotent. /// /// The same `CREATE TABLE IF NOT EXISTS` statements /// [`SyncSchema::migration_sql`](super::schema::SyncSchema::migration_sql) emits, /// available on their own so an app can have a directory without building a /// `SyncStore`. Nothing here is synced, so there is no manifest to be consistent /// with and no ordering against the rest of the sync DDL to respect. pub const DDL: &str = "\ -- The groups this user belongs to, and who is in them. -- -- A DIRECTORY, not synced state. Every table here is the server's answer written -- down: the sync loop already asks the server which groups to sync on every -- cycle, and the answer carries the whole record. -- -- WHY IT HAS TO BE LOCAL. A described screen's handler is a synchronous -- function, so a screen that needs the group list cannot fetch it. That is not a -- quasi limitation to work around but the property that lets one description -- serve a webview, a terminal and an egui window: nothing in a description -- blocks. The directory is how the answer gets to this side of the network -- before the question is asked. -- -- Local-only by construction, like `sync_conflict_stash`: absent from every -- `SyncSchema`, so it is never group-scoped, never pushed, never on a shared -- changelog, and it does NOT move the storage version. It is a cache of the -- server's own state, so the server always wins and there is nothing to merge. -- -- STALENESS IS A FACT, NOT A FAILURE. `refreshed_at` is what a screen states -- when it says how current the list is. A directory that has never been -- refreshed is empty rather than wrong. CREATE TABLE IF NOT EXISTS sync_groups ( group_id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, gck_version INTEGER NOT NULL, -- Whether this user administers the group, which decides whether the -- member list and the invitation list below can be fetched at all. is_admin INTEGER NOT NULL DEFAULT 0, refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -- Who is in each group, for the groups this user administers. -- -- Only an admin may list members, so for a group this user merely belongs to -- there are no rows and that is correct rather than missing. A screen reads -- whether the user administers a group from `sync_groups.is_admin` rather than -- by finding the member list empty, since those are different facts. CREATE TABLE IF NOT EXISTS sync_group_members ( group_id TEXT NOT NULL, user_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, added_at TEXT NOT NULL, refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), PRIMARY KEY (group_id, user_id) ) WITHOUT ROWID; -- Invitations outstanding on each group this user administers. -- -- Fetched on the same terms as the member list, in the same breath, and gated on -- the same `is_admin`. An invitation is a state machine rather than an act: it -- persists across states, and two of the transitions happen without this device -- doing anything (the invitee accepts, the deadline passes). So it is a list to -- be refreshed, not a queued write that resolves. CREATE TABLE IF NOT EXISTS sync_invitations ( invitation_id TEXT PRIMARY KEY NOT NULL, group_id TEXT NOT NULL, state TEXT NOT NULL, invitee_email TEXT, -- The fingerprint, never the key. Derived on the way in, so the raw key is -- not in the directory at all and a screen cannot draw the wrong one. invitee_fingerprint TEXT, -- The one-use code, and the only column here the server did not send. It -- keeps a hash and cannot re-issue it, so a code dropped between creating an -- invitation and drawing it is gone. Held only while the invitation is -- pending: `write_invitations` nulls it on any other state, because once -- somebody has accepted, the code has done its whole job and showing it -- again is exposure with no use. token TEXT, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_sync_invitations_group ON sync_invitations(group_id); CREATE TABLE IF NOT EXISTS sync_invitation_previews ( -- Keyed by the code, because that is all the holder of one has. A previewed -- invitation has no group this device belongs to and no invitation id: the -- server tells an outsider what a code leads to and nothing more, so this -- shares no shape with `sync_invitations` and does not share its table. token TEXT PRIMARY KEY NOT NULL, group_name TEXT NOT NULL, inviter_email TEXT NOT NULL, redeemable INTEGER NOT NULL, state TEXT NOT NULL, expires_at TEXT NOT NULL, refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); "; /// Create the directory tables if they are not there. /// /// Safe to call on every start. An app that also builds a `SyncStore` gets these /// from the sync DDL anyway; calling this as well costs two no-op statements and /// means the reads below never depend on whether sync has been configured yet. pub fn ensure_tables(conn: &Connection) -> Result<()> { conn.execute_batch(DDL)?; Ok(()) } /// Whether the directory tables exist on this connection. /// /// A device that has never configured sync has no sync tables at all: the DDL /// runs when a `SyncStore` is built, and an app that has never been signed in /// never builds one. Every read below treats that as an empty directory, because /// it is the same fact stated earlier: this device knows of no groups. /// /// Checked rather than inferred from an error string, so a genuine database /// fault still surfaces as one instead of being swallowed as "no groups". fn present(conn: &Connection) -> Result { let found: Option = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sync_groups'", [], |row| row.get(0), ) .optional()?; Ok(found.is_some()) } /// The groups this device knows about, by name. /// /// Synchronous and cheap, which is the point: a described handler calls this /// with a connection from the app's own pool. pub fn groups(conn: &Connection) -> Result> { if !present(conn)? { return Ok(Vec::new()); } let mut stmt = conn.prepare( "SELECT group_id, name, gck_version, is_admin FROM sync_groups ORDER BY name COLLATE NOCASE", )?; let rows = stmt .query_map([], |row| { let raw: String = row.get(0)?; Ok(( raw, row.get::<_, String>(1)?, row.get::<_, i32>(2)?, row.get::<_, i32>(3)?, )) })? .collect::, _>>()?; Ok(rows .into_iter() // A row whose id will not parse is skipped rather than failing the read. // It can only come from a build that wrote a different id shape, and a // screen that cannot list its groups is worse than one missing a row it // could not have addressed anyway. .filter_map(|(raw, name, gck_version, is_admin)| { Some(KnownGroup { id: GroupId::new(raw.parse().ok()?), name, gck_version, is_admin: is_admin != 0, }) }) .collect()) } /// Whether this device knows the user to be a member of `group_id`. /// /// Answered from the local directory, with no request. A /// scope the sync engine holds no key for routes a whole subtree into a changelog /// that goes nowhere, so the check is worth making; making it against the /// directory means it can be made from a described handler. pub fn is_member(conn: &Connection, group_id: GroupId) -> Result { if !present(conn)? { return Ok(false); } let found: Option = conn .query_row( "SELECT 1 FROM sync_groups WHERE group_id = ?1", params![group_id.to_string()], |row| row.get(0), ) .optional()?; Ok(found.is_some()) } /// Who is in a group this user administers, earliest first. /// /// Empty for a group the user merely belongs to, because the server refuses a /// non-admin the member list. A screen says "you administer this group" from /// [`KnownGroup::is_admin`] rather than by finding this empty: those are /// different facts and only one of them is about permission. pub fn members(conn: &Connection, group_id: GroupId) -> Result> { if !present(conn)? { return Ok(Vec::new()); } let mut stmt = conn.prepare( "SELECT user_id, email, role, added_at FROM sync_group_members \ WHERE group_id = ?1 ORDER BY added_at", )?; let rows = stmt .query_map(params![group_id.to_string()], |row| { Ok(KnownMember { user_id: row.get(0)?, email: row.get(1)?, role: row.get(2)?, added_at: row.get(3)?, }) })? .collect::, _>>()?; Ok(rows) } /// When the group list was last written, RFC 3339, or `None` if it never has /// been. /// /// What a screen drawing [`groups`] states when it says how current its list is. /// The oldest row wins: a directory is only as fresh as its stalest entry, and /// reporting the newest would call a list current on the strength of the one /// group that refreshed. /// /// This is about the group list and nothing else. The member list and the /// invitation list are separate fetches on separate schedules and come apart /// from it on a bad cycle; [`members_refreshed_at`], /// [`invitations_refreshed_at`] and [`pending_confirmations_refreshed_at`] are /// what those lists state. pub fn refreshed_at(conn: &Connection) -> Result> { stalest(conn, "SELECT MIN(refreshed_at) FROM sync_groups", params![]) } /// When a group's member list was last written, RFC 3339. /// /// The freshness of exactly what [`members`] returns for the same group, and it /// is per-group because the failure being reported is per-group: `write_members` /// is scoped to one group so a fetch that failed for one does not blank the /// others, and that is the case where this and [`refreshed_at`] disagree. /// /// `None` for a list with no rows, which covers both "never fetched" and /// "fetched, and the group has no members this device may see". Neither has a /// date to state, so a screen states nothing rather than a number about a /// different list. pub fn members_refreshed_at(conn: &Connection, group_id: GroupId) -> Result> { stalest( conn, "SELECT MIN(refreshed_at) FROM sync_group_members WHERE group_id = ?1", params![group_id.to_string()], ) } /// When a group's invitation list was last written, RFC 3339. /// /// The freshness of exactly what [`invitations`] returns for the same group, on /// the same per-group terms as [`members_refreshed_at`] and for the same reason. pub fn invitations_refreshed_at(conn: &Connection, group_id: GroupId) -> Result> { stalest( conn, "SELECT MIN(refreshed_at) FROM sync_invitations WHERE group_id = ?1", params![group_id.to_string()], ) } /// When the invitations waiting on this admin were last written, RFC 3339. /// /// The freshness of exactly what [`pending_confirmations`] returns, so it spans /// groups the way that read does and reports the stalest of them. That is the /// honest number for a cross-group list: one group's invitation fetch can fail /// while the rest succeed, and the section is only as current as the group that /// missed. /// /// Worth its own reader rather than leaving the caller to walk groups, for the /// reason [`pending_confirmations`] is: comparing a fingerprint and admitting /// somebody is the security of the invite flow, and a section doing that should /// be able to say how old its list is without assembling the answer itself. pub fn pending_confirmations_refreshed_at(conn: &Connection) -> Result> { stalest( conn, "SELECT MIN(refreshed_at) FROM sync_invitations WHERE state = 'accepted'", params![], ) } /// Shared body for the staleness reads: the stalest row a query selects. /// /// `MIN` in every case, and the aggregate is why the `Option` is doubled: a /// `MIN` over no rows is one row holding `NULL`, not no rows at all. fn stalest(conn: &Connection, sql: &str, args: P) -> Result> { if !present(conn)? { return Ok(None); } Ok(conn .query_row(sql, args, |row| row.get::<_, Option>(0)) .optional()? .flatten()) } #[cfg(test)] mod tests { use super::*; use crate::store::schema::{SyncSchema, SyncTable}; fn db() -> Connection { let conn = Connection::open_in_memory().expect("a database"); let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]); conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);") .expect("the app table"); conn.execute_batch(&schema.migration_sql()) .expect("the sync tables"); conn } fn group(id: u128, name: &str, is_admin: bool) -> KnownGroup { KnownGroup { id: GroupId::new(uuid::Uuid::from_u128(id)), name: name.to_owned(), gck_version: 1, is_admin, } } fn invite(id: u128, group: u128, state: InvitationState) -> KnownInvitation { KnownInvitation { id: InvitationId::new(uuid::Uuid::from_u128(id)), group_id: GroupId::new(uuid::Uuid::from_u128(group)), state, invitee_email: None, invitee_fingerprint: None, token: None, expires_at: "2099-01-01T00:00:00+00:00".to_owned(), created_at: format!("2026-08-{id:02}T00:00:00+00:00"), } } fn issued(id: u128, token: &str) -> crate::types::GroupInvitation { crate::types::GroupInvitation { id: InvitationId::new(uuid::Uuid::from_u128(id)), token: token.to_owned(), expires_at: chrono::DateTime::parse_from_rfc3339("2099-01-01T00:00:00+00:00") .unwrap() .with_timezone(&chrono::Utc), } } fn member(email: &str, added_at: &str) -> KnownMember { KnownMember { user_id: format!("user-{email}"), email: email.to_owned(), role: "member".to_owned(), added_at: added_at.to_owned(), } } #[test] fn a_store_that_has_never_synced_knows_no_groups() { let conn = db(); assert!(groups(&conn).unwrap().is_empty()); assert_eq!(refreshed_at(&conn).unwrap(), None); } #[test] fn the_directory_comes_back_by_name() { let mut conn = db(); write_groups( &mut conn, &[group(2, "Zebra", false), group(1, "Aardvark", true)], ) .unwrap(); let listed = groups(&conn).unwrap(); assert_eq!(listed.len(), 2); assert_eq!(listed[0].name, "Aardvark", "ordered by name, not by id"); assert_eq!(listed[1].name, "Zebra"); assert!(listed[0].is_admin); assert!(!listed[1].is_admin); } /// A group the user has been removed from is absent from the server's /// answer, and leaving it behind would offer a scope they cannot reach. #[test] fn a_refresh_replaces_rather_than_merges() { let mut conn = db(); write_groups( &mut conn, &[group(1, "Kept", true), group(2, "Removed", true)], ) .unwrap(); write_groups(&mut conn, &[group(1, "Kept", true)]).unwrap(); let listed = groups(&conn).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].name, "Kept"); } #[test] fn losing_a_group_takes_its_members_with_it() { let mut conn = db(); write_groups(&mut conn, &[group(1, "Gone", true)]).unwrap(); write_members( &mut conn, GroupId::new(uuid::Uuid::from_u128(1)), &[member("a@localhost", "2026-01-01T00:00:00Z")], ) .unwrap(); write_groups(&mut conn, &[]).unwrap(); assert!( members(&conn, GroupId::new(uuid::Uuid::from_u128(1))) .unwrap() .is_empty(), "a member list outlived the group it belonged to" ); } /// Scoped, unlike the group write: a cycle where one group's member fetch /// failed must not blank another group's list. #[test] fn writing_one_groups_members_leaves_another_groups_alone() { let mut conn = db(); write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap(); let one = GroupId::new(uuid::Uuid::from_u128(1)); let two = GroupId::new(uuid::Uuid::from_u128(2)); write_members( &mut conn, one, &[member("a@localhost", "2026-01-01T00:00:00Z")], ) .unwrap(); write_members( &mut conn, two, &[member("b@localhost", "2026-01-01T00:00:00Z")], ) .unwrap(); assert_eq!(members(&conn, one).unwrap().len(), 1); assert_eq!(members(&conn, two).unwrap()[0].email, "b@localhost"); } #[test] fn members_come_back_oldest_first() { let mut conn = db(); write_groups(&mut conn, &[group(1, "One", true)]).unwrap(); let one = GroupId::new(uuid::Uuid::from_u128(1)); write_members( &mut conn, one, &[ member("late@localhost", "2026-06-01T00:00:00Z"), member("early@localhost", "2026-01-01T00:00:00Z"), ], ) .unwrap(); let listed = members(&conn, one).unwrap(); assert_eq!(listed[0].email, "early@localhost"); assert_eq!(listed[1].email, "late@localhost"); } /// Membership is answerable from the local directory, with no request. #[test] fn membership_is_answerable_without_a_request() { let mut conn = db(); write_groups(&mut conn, &[group(1, "Mine", false)]).unwrap(); assert!(is_member(&conn, GroupId::new(uuid::Uuid::from_u128(1))).unwrap()); assert!(!is_member(&conn, GroupId::new(uuid::Uuid::from_u128(9))).unwrap()); } /// Belonging to a group and administering it are different facts, and only /// one of them is about permission. A screen must not read the empty member /// list as "nobody is in it". #[test] fn a_group_you_only_belong_to_has_no_members_and_says_so_separately() { let mut conn = db(); write_groups(&mut conn, &[group(1, "Someone else's", false)]).unwrap(); let listed = groups(&conn).unwrap(); assert!(!listed[0].is_admin, "the fact that gates the member list"); assert!( members(&conn, GroupId::new(uuid::Uuid::from_u128(1))) .unwrap() .is_empty(), "and the list nobody asked the server for" ); } /// The moment a group is created: the server knows it, no cycle has run, and /// a directory read must not call the creator a non-member of their own /// group. #[test] fn a_group_added_on_creation_is_usable_before_the_next_sync() { let mut conn = db(); write_groups(&mut conn, &[group(1, "Existing", false)]).unwrap(); add_group(&mut conn, &group(2, "Just made", true)).unwrap(); assert!(is_member(&conn, GroupId::new(uuid::Uuid::from_u128(2))).unwrap()); assert_eq!( groups(&conn).unwrap().len(), 2, "additive: it did not replace the directory" ); } #[test] fn adding_a_group_that_is_already_known_updates_it() { let mut conn = db(); add_group(&mut conn, &group(1, "Old name", false)).unwrap(); add_group(&mut conn, &group(1, "New name", true)).unwrap(); let listed = groups(&conn).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].name, "New name"); assert!(listed[0].is_admin); } /// An app that has never been signed in never builds a `SyncStore`, so the /// sync DDL has never run and the tables are absent. Every read answers as /// an empty directory, because that is the same fact: this device knows of /// no groups. /// Both routes into the directory run [`DDL`] itself: `migration_sql` /// concatenates it rather than restating it, so an app that builds a /// `SyncStore` and one that only calls `ensure_tables` get the same tables by /// construction. This test holds the concatenation in place. #[test] fn the_standalone_ddl_agrees_with_the_migration() { let conn = Connection::open_in_memory().expect("a database"); ensure_tables(&conn).expect("the directory tables"); assert!(present(&conn).unwrap()); let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]); for table in [ "sync_groups", "sync_group_members", "sync_invitations", "sync_invitation_previews", ] { assert!(schema.migration_sql().contains(table), "{table}"); } // Every statement, not merely the table names: a `migration_sql` that // reworded the directory would pass the check above and still be a // second definition. assert!(schema.migration_sql().contains(DDL)); } #[test] fn a_store_with_no_sync_tables_at_all_reads_as_an_empty_directory() { let conn = Connection::open_in_memory().expect("a database"); conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);") .expect("the app table"); assert!(groups(&conn).unwrap().is_empty()); assert!(!is_member(&conn, GroupId::new(uuid::Uuid::from_u128(1))).unwrap()); assert!( members(&conn, GroupId::new(uuid::Uuid::from_u128(1))) .unwrap() .is_empty() ); assert_eq!(refreshed_at(&conn).unwrap(), None); } #[test] fn a_written_directory_reports_when_it_was_written() { let mut conn = db(); write_groups(&mut conn, &[group(1, "One", true)]).unwrap(); assert!(refreshed_at(&conn).unwrap().is_some()); } /// The reason the per-list readers exist. `write_invitations` is scoped to /// one group so a failed fetch there does not blank the others, and this is /// what that looks like from a screen: the group list refreshed on the cycle /// the invitation list missed, and the two numbers disagree. #[test] fn the_invitation_list_can_be_staler_than_the_group_list() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); write_groups(&mut conn, &[group(1, "One", true)]).unwrap(); write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap(); // The cycle where the invitation fetch failed: the group list is written // again, the invitation list is left standing. conn.execute( "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'", [], ) .unwrap(); write_groups(&mut conn, &[group(1, "One", true)]).unwrap(); let groups_at = refreshed_at(&conn).unwrap().expect("a group timestamp"); let invites_at = invitations_refreshed_at(&conn, one) .unwrap() .expect("an invitation timestamp"); assert_eq!(invites_at, "2026-01-01T00:00:00.000Z"); assert!( invites_at < groups_at, "the invitation list is the stale one: {invites_at} vs {groups_at}" ); } /// Per-group, because the failure is per-group: one group refreshing tells a /// screen drawing another group's list nothing. #[test] fn a_groups_member_and_invitation_timestamps_are_its_own() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); let two = GroupId::new(uuid::Uuid::from_u128(2)); write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap(); write_members(&mut conn, one, &[member("a@example.com", "2026-01-01")]).unwrap(); write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap(); assert!(members_refreshed_at(&conn, one).unwrap().is_some()); assert!(invitations_refreshed_at(&conn, one).unwrap().is_some()); assert_eq!(members_refreshed_at(&conn, two).unwrap(), None); assert_eq!(invitations_refreshed_at(&conn, two).unwrap(), None); } /// The confirmations reader spans groups exactly as `pending_confirmations` /// does, and reports the stalest of them: a section is only as current as the /// group whose fetch missed. #[test] fn the_confirmations_timestamp_is_the_stalest_group_it_draws_from() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); let two = GroupId::new(uuid::Uuid::from_u128(2)); write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap(); write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Accepted)]).unwrap(); conn.execute( "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'", [], ) .unwrap(); write_invitations(&mut conn, two, &[invite(22, 2, InvitationState::Accepted)]).unwrap(); assert_eq!(pending_confirmations(&conn).unwrap().len(), 2); assert_eq!( pending_confirmations_refreshed_at(&conn).unwrap(), Some("2026-01-01T00:00:00.000Z".to_owned()), ); } /// Only the invitations the section draws. A pending invitation is not /// waiting on the admin, so its age is not the confirmations list's age. #[test] fn the_confirmations_timestamp_ignores_invitations_the_section_does_not_draw() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); write_groups(&mut conn, &[group(1, "One", true)]).unwrap(); write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap(); assert!(invitations_refreshed_at(&conn, one).unwrap().is_some()); assert_eq!(pending_confirmations_refreshed_at(&conn).unwrap(), None); } /// Both halves of an empty answer: never fetched, and fetched with nothing /// in it. Neither has a date to state, and a screen states nothing rather /// than a number about a different list. #[test] fn an_empty_list_has_no_timestamp_to_state() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); write_groups(&mut conn, &[group(1, "One", true)]).unwrap(); assert_eq!(members_refreshed_at(&conn, one).unwrap(), None); write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap(); write_invitations(&mut conn, one, &[]).unwrap(); assert_eq!(invitations_refreshed_at(&conn, one).unwrap(), None); } #[test] fn a_store_with_no_directory_has_no_timestamp_for_any_list() { let conn = Connection::open_in_memory().expect("a database"); let one = GroupId::new(uuid::Uuid::from_u128(1)); assert_eq!(refreshed_at(&conn).unwrap(), None); assert_eq!(members_refreshed_at(&conn, one).unwrap(), None); assert_eq!(invitations_refreshed_at(&conn, one).unwrap(), None); assert_eq!(pending_confirmations_refreshed_at(&conn).unwrap(), None); } /// Local-only DDL, so the gate must not see it. A table absent from every /// `SyncSchema` crosses no wire, and the storage version describes the wire. #[test] fn the_directory_is_absent_from_the_wire_manifest() { let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]); let wire = schema.wire_manifest(); assert!(!wire.contains("sync_groups"), "{wire}"); assert!(!wire.contains("sync_group_members"), "{wire}"); } #[test] fn a_store_that_has_never_synced_knows_no_invitations() { let conn = db(); assert!( invitations(&conn, GroupId::new(uuid::Uuid::from_u128(1))) .unwrap() .is_empty() ); assert!(pending_confirmations(&conn).unwrap().is_empty()); assert!(preview(&conn).unwrap().is_none()); } /// The refresh must not throw away the one copy of a live invite code. The /// server keeps only a hash and cannot re-send it, so a delete-then-insert /// refresh would make every invitation unusable one cycle after issuing it. #[test] fn a_refresh_keeps_the_token_of_a_pending_invitation() { let mut conn = db(); let group_id = GroupId::new(uuid::Uuid::from_u128(1)); record_issued(&conn, group_id, &issued(7, "code-7")).unwrap(); write_invitations( &mut conn, group_id, &[invite(7, 1, InvitationState::Pending)], ) .unwrap(); let listed = invitations(&conn, group_id).unwrap(); assert_eq!(listed.len(), 1); assert_eq!( listed[0].token.as_deref(), Some("code-7"), "a pending invitation keeps the code this device issued" ); } /// Once somebody has accepted, the code has done its whole job. Holding it /// is exposure with no use, and the refresh that observes the new state is /// what clears it. #[test] fn leaving_pending_drops_the_token() { let mut conn = db(); let group_id = GroupId::new(uuid::Uuid::from_u128(1)); record_issued(&conn, group_id, &issued(7, "code-7")).unwrap(); for state in [ InvitationState::Accepted, InvitationState::Redeemed, InvitationState::Revoked, InvitationState::Expired, ] { record_issued(&conn, group_id, &issued(7, "code-7")).unwrap(); write_invitations(&mut conn, group_id, &[invite(7, 1, state)]).unwrap(); assert_eq!( invitations(&conn, group_id).unwrap()[0].token, None, "{state:?} should not keep a token" ); } } #[test] fn a_refresh_prunes_what_the_server_stopped_reporting() { let mut conn = db(); let group_id = GroupId::new(uuid::Uuid::from_u128(1)); write_invitations( &mut conn, group_id, &[ invite(1, 1, InvitationState::Pending), invite(2, 1, InvitationState::Pending), ], ) .unwrap(); assert_eq!(invitations(&conn, group_id).unwrap().len(), 2); write_invitations( &mut conn, group_id, &[invite(2, 1, InvitationState::Pending)], ) .unwrap(); let left = invitations(&conn, group_id).unwrap(); assert_eq!(left.len(), 1); assert_eq!(left[0].id, InvitationId::new(uuid::Uuid::from_u128(2))); write_invitations(&mut conn, group_id, &[]).unwrap(); assert!( invitations(&conn, group_id).unwrap().is_empty(), "an empty answer empties the group" ); } /// A cycle where one group's fetch failed must not blank another's. #[test] fn a_refresh_is_scoped_to_its_group() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); let two = GroupId::new(uuid::Uuid::from_u128(2)); write_invitations(&mut conn, one, &[invite(1, 1, InvitationState::Pending)]).unwrap(); write_invitations(&mut conn, two, &[invite(2, 2, InvitationState::Pending)]).unwrap(); write_invitations(&mut conn, two, &[]).unwrap(); assert_eq!(invitations(&conn, one).unwrap().len(), 1); assert!(invitations(&conn, two).unwrap().is_empty()); } #[test] fn confirmations_are_every_accepted_invitation_oldest_first() { let mut conn = db(); let one = GroupId::new(uuid::Uuid::from_u128(1)); let two = GroupId::new(uuid::Uuid::from_u128(2)); write_invitations( &mut conn, one, &[ invite(9, 1, InvitationState::Accepted), invite(3, 1, InvitationState::Pending), ], ) .unwrap(); write_invitations(&mut conn, two, &[invite(4, 2, InvitationState::Accepted)]).unwrap(); let waiting = pending_confirmations(&conn).unwrap(); assert_eq!(waiting.len(), 2, "across groups, accepted only"); assert_eq!( waiting[0].id, InvitationId::new(uuid::Uuid::from_u128(4)), "oldest first: who has been waiting longest" ); } /// A deadline passes on its own, between two refreshes. Deriving it at read /// time is what stops a screen offering a dead code as a live one. #[test] fn a_passed_deadline_reads_as_expired_without_a_refresh() { let mut stale = invite(1, 1, InvitationState::Pending); stale.expires_at = "2020-01-01T00:00:00+00:00".to_owned(); assert!(stale.is_past_deadline()); assert_eq!(stale.effective_state(), InvitationState::Expired); let live = invite(1, 1, InvitationState::Pending); assert!(!live.is_past_deadline()); assert_eq!(live.effective_state(), InvitationState::Pending); } /// An accepted invitation is waiting on the admin, not on the invitee. The /// deadline it carries was the deadline for redeeming the link, which /// somebody already did. #[test] fn only_pending_derives_to_expired() { let mut accepted = invite(1, 1, InvitationState::Accepted); accepted.expires_at = "2020-01-01T00:00:00+00:00".to_owned(); assert!(accepted.is_past_deadline()); assert_eq!(accepted.effective_state(), InvitationState::Accepted); } #[test] fn an_unreadable_deadline_reads_as_live() { let mut odd = invite(1, 1, InvitationState::Pending); odd.expires_at = "whenever".to_owned(); assert!(!odd.is_past_deadline(), "hiding a live invitation is worse"); } #[test] fn a_preview_replaces_rather_than_accumulates() { let mut conn = db(); write_preview( &mut conn, &KnownPreview { token: "one".to_owned(), group_name: "First".to_owned(), inviter_email: "a@example.com".to_owned(), redeemable: true, state: InvitationState::Pending, expires_at: "2099-01-01T00:00:00+00:00".to_owned(), }, ) .unwrap(); write_preview( &mut conn, &KnownPreview { token: "two".to_owned(), group_name: "Second".to_owned(), inviter_email: "b@example.com".to_owned(), redeemable: false, state: InvitationState::Revoked, expires_at: "2099-01-01T00:00:00+00:00".to_owned(), }, ) .unwrap(); let held = preview(&conn).unwrap().expect("the newer answer"); assert_eq!(held.token, "two"); assert_eq!(held.group_name, "Second"); assert!(!held.redeemable); clear_preview(&conn).unwrap(); assert!(preview(&conn).unwrap().is_none()); } /// A row written by a build that knew a state this one does not is skipped, /// on the same reasoning an unparseable id is: a screen that cannot list its /// invitations is worse than one missing a row it could not have acted on. #[test] fn an_unknown_state_is_skipped_rather_than_failing_the_read() { let mut conn = db(); let group_id = GroupId::new(uuid::Uuid::from_u128(1)); write_invitations( &mut conn, group_id, &[invite(1, 1, InvitationState::Pending)], ) .unwrap(); conn.execute("UPDATE sync_invitations SET state = 'quarantined'", []) .unwrap(); assert!(invitations(&conn, group_id).unwrap().is_empty()); } }