Skip to main content

max / synckit

54.1 KB · 1367 lines History Blame Raw
1 //! The group directory: the server's answer about groups, written down locally.
2 //!
3 //! Groups, their members, the invitations outstanding on them, and the one
4 //! previewed invite code a person who is not in a group yet may be holding.
5 //!
6 //! # Why it exists
7 //!
8 //! A described screen's handler is a synchronous function. That is not a
9 //! limitation to work around; it is the property that lets one description serve
10 //! a webview, a terminal and an egui window, because nothing in a description
11 //! blocks. It does mean a screen cannot fetch anything, so any data a screen
12 //! needs has to be on this side of the network before the screen is drawn.
13 //!
14 //! Everything else a consuming app draws already is: rows come out of its own
15 //! tables. Groups were the exception. `list_groups` is an HTTP GET, membership
16 //! lives on the server, and the only local trace of a group was its id in
17 //! `sync_scope_cursor`, which is a cursor rather than a directory and carries no
18 //! name.
19 //!
20 //! # It costs no request
21 //!
22 //! The sync loop asks the server which groups to sync, once per cycle, and
23 //! [`SyncTransport::list_group_directory`](super::sync::SyncTransport::list_group_directory)
24 //! carries the name and the admin flag with each record. Writing that down is
25 //! the whole mechanism: the read already happens, on a schedule, outside any
26 //! request loop.
27 //!
28 //! Members and invitations are the parts that are a new request, made only for
29 //! groups this user administers, since the server refuses a non-admin. Worth
30 //! being plain about, because the "it costs no request" heading above is only
31 //! true of the group list itself: the rest is paid for, and it is worth paying
32 //! because a screen cannot ask for any of it.
33 //!
34 //! # Invitations carry one thing the server cannot give back
35 //!
36 //! `create_invitation` returns a token the server keeps only a hash of. So
37 //! `sync_invitations.token` is the single column here that is not a copy of the
38 //! server's answer: it is written by whoever issued the invitation
39 //! ([`record_issued`]) and it is the only copy that will ever exist. That is why
40 //! the refresh upserts instead of replacing, and why it drops the token the
41 //! moment the invitation stops being pending.
42 //!
43 //! A previewed code ([`write_preview`]) is not part of the directory proper and
44 //! the sync loop never touches it. The person holding one is not in the group,
45 //! so there is no scope to hang it off and nothing to refresh it against.
46 //!
47 //! # What it is not
48 //!
49 //! Not synced state. Both tables are absent from every
50 //! [`SyncSchema`](super::schema::SyncSchema), so they are never group-scoped,
51 //! never pushed, never on a shared changelog, and they **do not move the storage
52 //! version**: nothing here crosses the wire, which is exactly the local-only DDL
53 //! the gate's own rule exempts.
54 //!
55 //! The server is the authority and this is a copy of its answer, so there is
56 //! nothing to merge and no conflict to resolve. A refresh replaces.
57 //!
58 //! # Staleness is a fact, not a failure
59 //!
60 //! A screen that draws a list states how current that list is, and the three
61 //! tables here refresh on three schedules: the group list arrives every cycle,
62 //! while members and invitations are separate per-group fetches made only for
63 //! the groups this user administers. So there is a reader per list rather than
64 //! one for the directory -- [`refreshed_at`], [`members_refreshed_at`],
65 //! [`invitations_refreshed_at`], [`pending_confirmations_refreshed_at`] -- and
66 //! each is the freshness of exactly what its sibling read returns. One number
67 //! standing for all three would be a claim about a different list on the cycle
68 //! where it matters, which is the cycle where a fetch failed.
69 //!
70 //! A directory that has never been refreshed is empty, which is a true statement
71 //! about what this device knows rather than a claim that the user has no groups.
72
73 use rusqlite::{Connection, OptionalExtension, params};
74
75 pub use super::sync::{KnownGroup, KnownInvitation, KnownMember, KnownPreview};
76 use crate::GroupId;
77 use crate::error::Result;
78 use crate::ids::InvitationId;
79 use crate::types::InvitationState;
80
81 /// Replace the group directory with what the server just reported.
82 ///
83 /// A whole replace rather than an upsert: a group the user has been removed from
84 /// is absent from the answer, and leaving it behind would offer a scope they can
85 /// no longer reach. Members of a departed group go with it, which is why this
86 /// takes the transaction rather than leaving the caller to remember.
87 pub fn write_groups(conn: &mut Connection, groups: &[KnownGroup]) -> Result<()> {
88 let tx = conn.transaction()?;
89 tx.execute("DELETE FROM sync_group_members", [])?;
90 tx.execute("DELETE FROM sync_groups", [])?;
91 for group in groups {
92 tx.execute(
93 "INSERT INTO sync_groups (group_id, name, gck_version, is_admin) \
94 VALUES (?1, ?2, ?3, ?4)",
95 params![
96 group.id.to_string(),
97 group.name,
98 group.gck_version,
99 i32::from(group.is_admin),
100 ],
101 )?;
102 }
103 tx.commit()?;
104 Ok(())
105 }
106
107 /// Add or update one group, without touching the rest of the directory.
108 ///
109 /// For the moment a group is created: the server knows it, the sync loop has not
110 /// run since, and a screen that reads the directory would call the creator a
111 /// non-member of their own group. Additive on purpose, since the loop's whole
112 /// replace is the authoritative write and this is filling in ahead of it.
113 pub fn add_group(conn: &mut Connection, group: &KnownGroup) -> Result<()> {
114 conn.execute(
115 "INSERT INTO sync_groups (group_id, name, gck_version, is_admin) \
116 VALUES (?1, ?2, ?3, ?4) \
117 ON CONFLICT(group_id) DO UPDATE SET \
118 name = excluded.name, \
119 gck_version = excluded.gck_version, \
120 is_admin = excluded.is_admin, \
121 refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
122 params![
123 group.id.to_string(),
124 group.name,
125 group.gck_version,
126 i32::from(group.is_admin),
127 ],
128 )?;
129 Ok(())
130 }
131
132 /// Replace one group's member list.
133 ///
134 /// Scoped to the one group, unlike [`write_groups`]: a cycle where the member
135 /// fetch failed for one group should not blank the others, and a refusal is
136 /// "no answer this time" rather than "nobody is in it".
137 pub fn write_members(
138 conn: &mut Connection,
139 group_id: GroupId,
140 members: &[KnownMember],
141 ) -> Result<()> {
142 let tx = conn.transaction()?;
143 tx.execute(
144 "DELETE FROM sync_group_members WHERE group_id = ?1",
145 params![group_id.to_string()],
146 )?;
147 for member in members {
148 tx.execute(
149 "INSERT INTO sync_group_members (group_id, user_id, email, role, added_at) \
150 VALUES (?1, ?2, ?3, ?4, ?5)",
151 params![
152 group_id.to_string(),
153 member.user_id,
154 member.email,
155 member.role,
156 member.added_at,
157 ],
158 )?;
159 }
160 tx.commit()?;
161 Ok(())
162 }
163
164 /// Replace one group's invitation list, keeping the tokens this device issued.
165 ///
166 /// Upsert-and-prune rather than the delete-then-insert [`write_members`] uses,
167 /// and the difference is load-bearing: `token` is the one column the server
168 /// cannot send back, so deleting the row would throw away the only copy of a
169 /// live invite code on the device that issued it.
170 ///
171 /// The token is dropped the moment the invitation stops being `pending`. Once
172 /// somebody has accepted, the code has done its whole job and holding it is
173 /// exposure with no use; `revoked` and `expired` drop it for the same reason.
174 /// That happens here rather than at a call site because it is the answer to
175 /// "what clears the token", and a caller that forgot would leave a dead code on
176 /// screen.
177 ///
178 /// Scoped to the one group, like [`write_members`]: a cycle where the fetch
179 /// failed for one group should not blank the others.
180 pub fn write_invitations(
181 conn: &mut Connection,
182 group_id: GroupId,
183 invitations: &[KnownInvitation],
184 ) -> Result<()> {
185 let tx = conn.transaction()?;
186 for invitation in invitations {
187 tx.execute(
188 "INSERT INTO sync_invitations \
189 (invitation_id, group_id, state, invitee_email, invitee_fingerprint, \
190 expires_at, created_at) \
191 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
192 ON CONFLICT(invitation_id) DO UPDATE SET \
193 state = excluded.state, \
194 invitee_email = excluded.invitee_email, \
195 invitee_fingerprint = excluded.invitee_fingerprint, \
196 expires_at = excluded.expires_at, \
197 created_at = excluded.created_at, \
198 refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \
199 token = CASE WHEN excluded.state = 'pending' \
200 THEN sync_invitations.token ELSE NULL END",
201 params![
202 invitation.id.to_string(),
203 group_id.to_string(),
204 state_str(invitation.state),
205 invitation.invitee_email,
206 invitation.invitee_fingerprint,
207 invitation.expires_at,
208 invitation.created_at,
209 ],
210 )?;
211 }
212
213 // Prune what the server no longer reports. Built as a NOT IN rather than a
214 // delete-first so the upserts above keep their tokens; with no invitations
215 // at all it is an unconditional delete for the group, which is correct and
216 // is why the empty case is not special-cased away.
217 let ids: Vec<String> = invitations.iter().map(|i| i.id.to_string()).collect();
218 if ids.is_empty() {
219 tx.execute(
220 "DELETE FROM sync_invitations WHERE group_id = ?1",
221 params![group_id.to_string()],
222 )?;
223 } else {
224 let holes = std::iter::repeat_n("?", ids.len())
225 .collect::<Vec<_>>()
226 .join(",");
227 let mut args: Vec<String> = vec![group_id.to_string()];
228 args.extend(ids);
229 tx.execute(
230 &format!(
231 "DELETE FROM sync_invitations WHERE group_id = ?1 \
232 AND invitation_id NOT IN ({holes})"
233 ),
234 rusqlite::params_from_iter(args),
235 )?;
236 }
237 tx.commit()?;
238 Ok(())
239 }
240
241 /// Write down an invitation this device has just issued, with its token.
242 ///
243 /// The counterpart to [`add_group`], for the same reason and at the same moment:
244 /// the server knows about it, the sync loop has not run since, and a screen
245 /// reading the directory would show nothing landing. Here it is stronger than a
246 /// convenience. `create_invitation` returns the only copy of the token that will
247 /// ever exist, so a caller that does not write it down immediately has issued an
248 /// invitation nobody can use.
249 ///
250 /// Additive, and the refresh's upsert takes it from here.
251 pub fn record_issued(
252 conn: &Connection,
253 group_id: GroupId,
254 invitation: &crate::types::GroupInvitation,
255 ) -> Result<()> {
256 conn.execute(
257 "INSERT INTO sync_invitations \
258 (invitation_id, group_id, state, token, expires_at, created_at) \
259 VALUES (?1, ?2, 'pending', ?3, ?4, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) \
260 ON CONFLICT(invitation_id) DO UPDATE SET token = excluded.token",
261 params![
262 invitation.id.to_string(),
263 group_id.to_string(),
264 invitation.token,
265 invitation.expires_at.to_rfc3339(),
266 ],
267 )?;
268 Ok(())
269 }
270
271 /// A group's invitations, newest first.
272 ///
273 /// Empty for a group this user merely belongs to, because the server refuses a
274 /// non-admin the list, exactly as [`members`] is.
275 pub fn invitations(conn: &Connection, group_id: GroupId) -> Result<Vec<KnownInvitation>> {
276 read_invitations(
277 conn,
278 "SELECT invitation_id, group_id, state, invitee_email, invitee_fingerprint, \
279 token, expires_at, created_at FROM sync_invitations \
280 WHERE group_id = ?1 ORDER BY created_at DESC",
281 params![group_id.to_string()],
282 )
283 }
284
285 /// Every invitation waiting on this admin, across all groups, oldest first.
286 ///
287 /// `Accepted` is the state that wants a person: the invitee has posted a key and
288 /// nothing happens until the admin compares its fingerprint out of band and
289 /// confirms. That comparison is the security of the whole flow rather than a
290 /// formality, so it is worth one read that does not make the caller walk groups
291 /// to find it.
292 ///
293 /// Oldest first because the answer to "who has been waiting longest" is the one
294 /// an admin wants; the per-group list is newest first because there the question
295 /// is "what did I just issue".
296 pub fn pending_confirmations(conn: &Connection) -> Result<Vec<KnownInvitation>> {
297 read_invitations(
298 conn,
299 "SELECT invitation_id, group_id, state, invitee_email, invitee_fingerprint, \
300 token, expires_at, created_at FROM sync_invitations \
301 WHERE state = 'accepted' ORDER BY created_at",
302 params![],
303 )
304 }
305
306 /// Shared row mapping for the two invitation reads.
307 fn read_invitations<P: rusqlite::Params>(
308 conn: &Connection,
309 sql: &str,
310 args: P,
311 ) -> Result<Vec<KnownInvitation>> {
312 if !present(conn)? {
313 return Ok(Vec::new());
314 }
315 let mut stmt = conn.prepare(sql)?;
316 let rows = stmt
317 .query_map(args, |row| {
318 Ok((
319 row.get::<_, String>(0)?,
320 row.get::<_, String>(1)?,
321 row.get::<_, String>(2)?,
322 row.get::<_, Option<String>>(3)?,
323 row.get::<_, Option<String>>(4)?,
324 row.get::<_, Option<String>>(5)?,
325 row.get::<_, String>(6)?,
326 row.get::<_, String>(7)?,
327 ))
328 })?
329 .collect::<std::result::Result<Vec<_>, _>>()?;
330
331 Ok(rows
332 .into_iter()
333 // A row this build cannot address is skipped rather than failing the
334 // read, on the same reasoning as `groups`: it can only come from a build
335 // that wrote a shape this one does not know, and a screen that cannot
336 // list its invitations is worse than one missing a row it could not have
337 // acted on anyway.
338 .filter_map(
339 |(id, group, state, email, fingerprint, token, expires, created)| {
340 Some(KnownInvitation {
341 id: InvitationId::new(id.parse().ok()?),
342 group_id: GroupId::new(group.parse().ok()?),
343 state: state_from_str(&state)?,
344 invitee_email: email,
345 invitee_fingerprint: fingerprint,
346 token,
347 expires_at: expires,
348 created_at: created,
349 })
350 },
351 )
352 .collect())
353 }
354
355 /// Write down what a pasted invite code leads to.
356 ///
357 /// Replaces whatever was there: a person pastes one code at a time, and a
358 /// previous answer they did not act on is not something to keep beside a new one.
359 pub fn write_preview(conn: &mut Connection, preview: &KnownPreview) -> Result<()> {
360 let tx = conn.transaction()?;
361 tx.execute("DELETE FROM sync_invitation_previews", [])?;
362 tx.execute(
363 "INSERT INTO sync_invitation_previews \
364 (token, group_name, inviter_email, redeemable, state, expires_at) \
365 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
366 params![
367 preview.token,
368 preview.group_name,
369 preview.inviter_email,
370 i32::from(preview.redeemable),
371 state_str(preview.state),
372 preview.expires_at,
373 ],
374 )?;
375 tx.commit()?;
376 Ok(())
377 }
378
379 /// The code this device is currently holding an answer about, if any.
380 pub fn preview(conn: &Connection) -> Result<Option<KnownPreview>> {
381 if !present(conn)? {
382 return Ok(None);
383 }
384 let row = conn
385 .query_row(
386 "SELECT token, group_name, inviter_email, redeemable, state, expires_at \
387 FROM sync_invitation_previews LIMIT 1",
388 [],
389 |row| {
390 Ok((
391 row.get::<_, String>(0)?,
392 row.get::<_, String>(1)?,
393 row.get::<_, String>(2)?,
394 row.get::<_, i32>(3)?,
395 row.get::<_, String>(4)?,
396 row.get::<_, String>(5)?,
397 ))
398 },
399 )
400 .optional()?;
401
402 Ok(row.and_then(
403 |(token, group_name, inviter_email, redeemable, state, expires_at)| {
404 Some(KnownPreview {
405 token,
406 group_name,
407 inviter_email,
408 redeemable: redeemable != 0,
409 state: state_from_str(&state)?,
410 expires_at,
411 })
412 },
413 ))
414 }
415
416 /// Forget the previewed code, once it has been accepted or dismissed.
417 pub fn clear_preview(conn: &Connection) -> Result<()> {
418 if !present(conn)? {
419 return Ok(());
420 }
421 conn.execute("DELETE FROM sync_invitation_previews", [])?;
422 Ok(())
423 }
424
425 /// The stored spelling of a state.
426 ///
427 /// The serde `rename_all = "lowercase"` spelling, written by hand so the storage
428 /// format does not move if the wire format's attribute ever does. `write_invitations`
429 /// compares against the literal `'pending'` in SQL, so this is the one place the
430 /// two have to agree.
431 fn state_str(state: InvitationState) -> &'static str {
432 match state {
433 InvitationState::Pending => "pending",
434 InvitationState::Accepted => "accepted",
435 InvitationState::Redeemed => "redeemed",
436 InvitationState::Revoked => "revoked",
437 InvitationState::Expired => "expired",
438 // No catch-all arm: `#[non_exhaustive]` does not bind inside the crate
439 // that defines the enum, so a variant added later is a compile error
440 // here rather than a silent "unknown" written into the database. That is
441 // the outcome worth having, and it is why a consumer across the crate
442 // boundary (goingson's `invitation_state_str`) needs the arm this does
443 // not.
444 }
445 }
446
447 /// Parse a stored state, `None` for one this build does not know.
448 fn state_from_str(raw: &str) -> Option<InvitationState> {
449 match raw {
450 "pending" => Some(InvitationState::Pending),
451 "accepted" => Some(InvitationState::Accepted),
452 "redeemed" => Some(InvitationState::Redeemed),
453 "revoked" => Some(InvitationState::Revoked),
454 "expired" => Some(InvitationState::Expired),
455 _ => None,
456 }
457 }
458
459 /// The directory's own DDL, idempotent.
460 ///
461 /// The same `CREATE TABLE IF NOT EXISTS` statements
462 /// [`SyncSchema::migration_sql`](super::schema::SyncSchema::migration_sql) emits,
463 /// available on their own so an app can have a directory without building a
464 /// `SyncStore`. Nothing here is synced, so there is no manifest to be consistent
465 /// with and no ordering against the rest of the sync DDL to respect.
466 pub const DDL: &str = "\
467 -- The groups this user belongs to, and who is in them.
468 --
469 -- A DIRECTORY, not synced state. Every table here is the server's answer written
470 -- down: the sync loop already asks the server which groups to sync on every
471 -- cycle, and the answer carries the whole record.
472 --
473 -- WHY IT HAS TO BE LOCAL. A described screen's handler is a synchronous
474 -- function, so a screen that needs the group list cannot fetch it. That is not a
475 -- quasi limitation to work around but the property that lets one description
476 -- serve a webview, a terminal and an egui window: nothing in a description
477 -- blocks. The directory is how the answer gets to this side of the network
478 -- before the question is asked.
479 --
480 -- Local-only by construction, like `sync_conflict_stash`: absent from every
481 -- `SyncSchema`, so it is never group-scoped, never pushed, never on a shared
482 -- changelog, and it does NOT move the storage version. It is a cache of the
483 -- server's own state, so the server always wins and there is nothing to merge.
484 --
485 -- STALENESS IS A FACT, NOT A FAILURE. `refreshed_at` is what a screen states
486 -- when it says how current the list is. A directory that has never been
487 -- refreshed is empty rather than wrong.
488 CREATE TABLE IF NOT EXISTS sync_groups (
489 group_id TEXT PRIMARY KEY NOT NULL,
490 name TEXT NOT NULL,
491 gck_version INTEGER NOT NULL,
492 -- Whether this user administers the group, which decides whether the
493 -- member list and the invitation list below can be fetched at all.
494 is_admin INTEGER NOT NULL DEFAULT 0,
495 refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
496 );
497 -- Who is in each group, for the groups this user administers.
498 --
499 -- Only an admin may list members, so for a group this user merely belongs to
500 -- there are no rows and that is correct rather than missing. A screen reads
501 -- whether the user administers a group from `sync_groups.is_admin` rather than
502 -- by finding the member list empty, since those are different facts.
503 CREATE TABLE IF NOT EXISTS sync_group_members (
504 group_id TEXT NOT NULL,
505 user_id TEXT NOT NULL,
506 email TEXT NOT NULL,
507 role TEXT NOT NULL,
508 added_at TEXT NOT NULL,
509 refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
510 PRIMARY KEY (group_id, user_id)
511 ) WITHOUT ROWID;
512 -- Invitations outstanding on each group this user administers.
513 --
514 -- Fetched on the same terms as the member list, in the same breath, and gated on
515 -- the same `is_admin`. An invitation is a state machine rather than an act: it
516 -- persists across states, and two of the transitions happen without this device
517 -- doing anything (the invitee accepts, the deadline passes). So it is a list to
518 -- be refreshed, not a queued write that resolves.
519 CREATE TABLE IF NOT EXISTS sync_invitations (
520 invitation_id TEXT PRIMARY KEY NOT NULL,
521 group_id TEXT NOT NULL,
522 state TEXT NOT NULL,
523 invitee_email TEXT,
524 -- The fingerprint, never the key. Derived on the way in, so the raw key is
525 -- not in the directory at all and a screen cannot draw the wrong one.
526 invitee_fingerprint TEXT,
527 -- The one-use code, and the only column here the server did not send. It
528 -- keeps a hash and cannot re-issue it, so a code dropped between creating an
529 -- invitation and drawing it is gone. Held only while the invitation is
530 -- pending: `write_invitations` nulls it on any other state, because once
531 -- somebody has accepted, the code has done its whole job and showing it
532 -- again is exposure with no use.
533 token TEXT,
534 expires_at TEXT NOT NULL,
535 created_at TEXT NOT NULL,
536 refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
537 );
538 CREATE INDEX IF NOT EXISTS idx_sync_invitations_group ON sync_invitations(group_id);
539 CREATE TABLE IF NOT EXISTS sync_invitation_previews (
540 -- Keyed by the code, because that is all the holder of one has. A previewed
541 -- invitation has no group this device belongs to and no invitation id: the
542 -- server tells an outsider what a code leads to and nothing more, so this
543 -- shares no shape with `sync_invitations` and does not share its table.
544 token TEXT PRIMARY KEY NOT NULL,
545 group_name TEXT NOT NULL,
546 inviter_email TEXT NOT NULL,
547 redeemable INTEGER NOT NULL,
548 state TEXT NOT NULL,
549 expires_at TEXT NOT NULL,
550 refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
551 );
552 ";
553
554 /// Create the directory tables if they are not there.
555 ///
556 /// Safe to call on every start. An app that also builds a `SyncStore` gets these
557 /// from the sync DDL anyway; calling this as well costs two no-op statements and
558 /// means the reads below never depend on whether sync has been configured yet.
559 pub fn ensure_tables(conn: &Connection) -> Result<()> {
560 conn.execute_batch(DDL)?;
561 Ok(())
562 }
563
564 /// Whether the directory tables exist on this connection.
565 ///
566 /// A device that has never configured sync has no sync tables at all: the DDL
567 /// runs when a `SyncStore` is built, and an app that has never been signed in
568 /// never builds one. Every read below treats that as an empty directory, because
569 /// it is the same fact stated earlier: this device knows of no groups.
570 ///
571 /// Checked rather than inferred from an error string, so a genuine database
572 /// fault still surfaces as one instead of being swallowed as "no groups".
573 fn present(conn: &Connection) -> Result<bool> {
574 let found: Option<i64> = conn
575 .query_row(
576 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sync_groups'",
577 [],
578 |row| row.get(0),
579 )
580 .optional()?;
581 Ok(found.is_some())
582 }
583
584 /// The groups this device knows about, by name.
585 ///
586 /// Synchronous and cheap, which is the point: a described handler calls this
587 /// with a connection from the app's own pool.
588 pub fn groups(conn: &Connection) -> Result<Vec<KnownGroup>> {
589 if !present(conn)? {
590 return Ok(Vec::new());
591 }
592 let mut stmt = conn.prepare(
593 "SELECT group_id, name, gck_version, is_admin FROM sync_groups ORDER BY name COLLATE NOCASE",
594 )?;
595 let rows = stmt
596 .query_map([], |row| {
597 let raw: String = row.get(0)?;
598 Ok((
599 raw,
600 row.get::<_, String>(1)?,
601 row.get::<_, i32>(2)?,
602 row.get::<_, i32>(3)?,
603 ))
604 })?
605 .collect::<std::result::Result<Vec<_>, _>>()?;
606
607 Ok(rows
608 .into_iter()
609 // A row whose id will not parse is skipped rather than failing the read.
610 // It can only come from a build that wrote a different id shape, and a
611 // screen that cannot list its groups is worse than one missing a row it
612 // could not have addressed anyway.
613 .filter_map(|(raw, name, gck_version, is_admin)| {
614 Some(KnownGroup {
615 id: GroupId::new(raw.parse().ok()?),
616 name,
617 gck_version,
618 is_admin: is_admin != 0,
619 })
620 })
621 .collect())
622 }
623
624 /// Whether this device knows the user to be a member of `group_id`.
625 ///
626 /// Answered from the local directory, with no request. A
627 /// scope the sync engine holds no key for routes a whole subtree into a changelog
628 /// that goes nowhere, so the check is worth making; making it against the
629 /// directory means it can be made from a described handler.
630 pub fn is_member(conn: &Connection, group_id: GroupId) -> Result<bool> {
631 if !present(conn)? {
632 return Ok(false);
633 }
634 let found: Option<i64> = conn
635 .query_row(
636 "SELECT 1 FROM sync_groups WHERE group_id = ?1",
637 params![group_id.to_string()],
638 |row| row.get(0),
639 )
640 .optional()?;
641 Ok(found.is_some())
642 }
643
644 /// Who is in a group this user administers, earliest first.
645 ///
646 /// Empty for a group the user merely belongs to, because the server refuses a
647 /// non-admin the member list. A screen says "you administer this group" from
648 /// [`KnownGroup::is_admin`] rather than by finding this empty: those are
649 /// different facts and only one of them is about permission.
650 pub fn members(conn: &Connection, group_id: GroupId) -> Result<Vec<KnownMember>> {
651 if !present(conn)? {
652 return Ok(Vec::new());
653 }
654 let mut stmt = conn.prepare(
655 "SELECT user_id, email, role, added_at FROM sync_group_members \
656 WHERE group_id = ?1 ORDER BY added_at",
657 )?;
658 let rows = stmt
659 .query_map(params![group_id.to_string()], |row| {
660 Ok(KnownMember {
661 user_id: row.get(0)?,
662 email: row.get(1)?,
663 role: row.get(2)?,
664 added_at: row.get(3)?,
665 })
666 })?
667 .collect::<std::result::Result<Vec<_>, _>>()?;
668 Ok(rows)
669 }
670
671 /// When the group list was last written, RFC 3339, or `None` if it never has
672 /// been.
673 ///
674 /// What a screen drawing [`groups`] states when it says how current its list is.
675 /// The oldest row wins: a directory is only as fresh as its stalest entry, and
676 /// reporting the newest would call a list current on the strength of the one
677 /// group that refreshed.
678 ///
679 /// This is about the group list and nothing else. The member list and the
680 /// invitation list are separate fetches on separate schedules and come apart
681 /// from it on a bad cycle; [`members_refreshed_at`],
682 /// [`invitations_refreshed_at`] and [`pending_confirmations_refreshed_at`] are
683 /// what those lists state.
684 pub fn refreshed_at(conn: &Connection) -> Result<Option<String>> {
685 stalest(conn, "SELECT MIN(refreshed_at) FROM sync_groups", params![])
686 }
687
688 /// When a group's member list was last written, RFC 3339.
689 ///
690 /// The freshness of exactly what [`members`] returns for the same group, and it
691 /// is per-group because the failure being reported is per-group: `write_members`
692 /// is scoped to one group so a fetch that failed for one does not blank the
693 /// others, and that is the case where this and [`refreshed_at`] disagree.
694 ///
695 /// `None` for a list with no rows, which covers both "never fetched" and
696 /// "fetched, and the group has no members this device may see". Neither has a
697 /// date to state, so a screen states nothing rather than a number about a
698 /// different list.
699 pub fn members_refreshed_at(conn: &Connection, group_id: GroupId) -> Result<Option<String>> {
700 stalest(
701 conn,
702 "SELECT MIN(refreshed_at) FROM sync_group_members WHERE group_id = ?1",
703 params![group_id.to_string()],
704 )
705 }
706
707 /// When a group's invitation list was last written, RFC 3339.
708 ///
709 /// The freshness of exactly what [`invitations`] returns for the same group, on
710 /// the same per-group terms as [`members_refreshed_at`] and for the same reason.
711 pub fn invitations_refreshed_at(conn: &Connection, group_id: GroupId) -> Result<Option<String>> {
712 stalest(
713 conn,
714 "SELECT MIN(refreshed_at) FROM sync_invitations WHERE group_id = ?1",
715 params![group_id.to_string()],
716 )
717 }
718
719 /// When the invitations waiting on this admin were last written, RFC 3339.
720 ///
721 /// The freshness of exactly what [`pending_confirmations`] returns, so it spans
722 /// groups the way that read does and reports the stalest of them. That is the
723 /// honest number for a cross-group list: one group's invitation fetch can fail
724 /// while the rest succeed, and the section is only as current as the group that
725 /// missed.
726 ///
727 /// Worth its own reader rather than leaving the caller to walk groups, for the
728 /// reason [`pending_confirmations`] is: comparing a fingerprint and admitting
729 /// somebody is the security of the invite flow, and a section doing that should
730 /// be able to say how old its list is without assembling the answer itself.
731 pub fn pending_confirmations_refreshed_at(conn: &Connection) -> Result<Option<String>> {
732 stalest(
733 conn,
734 "SELECT MIN(refreshed_at) FROM sync_invitations WHERE state = 'accepted'",
735 params![],
736 )
737 }
738
739 /// Shared body for the staleness reads: the stalest row a query selects.
740 ///
741 /// `MIN` in every case, and the aggregate is why the `Option` is doubled: a
742 /// `MIN` over no rows is one row holding `NULL`, not no rows at all.
743 fn stalest<P: rusqlite::Params>(conn: &Connection, sql: &str, args: P) -> Result<Option<String>> {
744 if !present(conn)? {
745 return Ok(None);
746 }
747 Ok(conn
748 .query_row(sql, args, |row| row.get::<_, Option<String>>(0))
749 .optional()?
750 .flatten())
751 }
752
753 #[cfg(test)]
754 mod tests {
755 use super::*;
756 use crate::store::schema::{SyncSchema, SyncTable};
757
758 fn db() -> Connection {
759 let conn = Connection::open_in_memory().expect("a database");
760 let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
761 conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
762 .expect("the app table");
763 conn.execute_batch(&schema.migration_sql())
764 .expect("the sync tables");
765 conn
766 }
767
768 fn group(id: u128, name: &str, is_admin: bool) -> KnownGroup {
769 KnownGroup {
770 id: GroupId::new(uuid::Uuid::from_u128(id)),
771 name: name.to_owned(),
772 gck_version: 1,
773 is_admin,
774 }
775 }
776
777 fn invite(id: u128, group: u128, state: InvitationState) -> KnownInvitation {
778 KnownInvitation {
779 id: InvitationId::new(uuid::Uuid::from_u128(id)),
780 group_id: GroupId::new(uuid::Uuid::from_u128(group)),
781 state,
782 invitee_email: None,
783 invitee_fingerprint: None,
784 token: None,
785 expires_at: "2099-01-01T00:00:00+00:00".to_owned(),
786 created_at: format!("2026-08-{id:02}T00:00:00+00:00"),
787 }
788 }
789
790 fn issued(id: u128, token: &str) -> crate::types::GroupInvitation {
791 crate::types::GroupInvitation {
792 id: InvitationId::new(uuid::Uuid::from_u128(id)),
793 token: token.to_owned(),
794 expires_at: chrono::DateTime::parse_from_rfc3339("2099-01-01T00:00:00+00:00")
795 .unwrap()
796 .with_timezone(&chrono::Utc),
797 }
798 }
799
800 fn member(email: &str, added_at: &str) -> KnownMember {
801 KnownMember {
802 user_id: format!("user-{email}"),
803 email: email.to_owned(),
804 role: "member".to_owned(),
805 added_at: added_at.to_owned(),
806 }
807 }
808
809 #[test]
810 fn a_store_that_has_never_synced_knows_no_groups() {
811 let conn = db();
812 assert!(groups(&conn).unwrap().is_empty());
813 assert_eq!(refreshed_at(&conn).unwrap(), None);
814 }
815
816 #[test]
817 fn the_directory_comes_back_by_name() {
818 let mut conn = db();
819 write_groups(
820 &mut conn,
821 &[group(2, "Zebra", false), group(1, "Aardvark", true)],
822 )
823 .unwrap();
824
825 let listed = groups(&conn).unwrap();
826 assert_eq!(listed.len(), 2);
827 assert_eq!(listed[0].name, "Aardvark", "ordered by name, not by id");
828 assert_eq!(listed[1].name, "Zebra");
829 assert!(listed[0].is_admin);
830 assert!(!listed[1].is_admin);
831 }
832
833 /// A group the user has been removed from is absent from the server's
834 /// answer, and leaving it behind would offer a scope they cannot reach.
835 #[test]
836 fn a_refresh_replaces_rather_than_merges() {
837 let mut conn = db();
838 write_groups(
839 &mut conn,
840 &[group(1, "Kept", true), group(2, "Removed", true)],
841 )
842 .unwrap();
843 write_groups(&mut conn, &[group(1, "Kept", true)]).unwrap();
844
845 let listed = groups(&conn).unwrap();
846 assert_eq!(listed.len(), 1);
847 assert_eq!(listed[0].name, "Kept");
848 }
849
850 #[test]
851 fn losing_a_group_takes_its_members_with_it() {
852 let mut conn = db();
853 write_groups(&mut conn, &[group(1, "Gone", true)]).unwrap();
854 write_members(
855 &mut conn,
856 GroupId::new(uuid::Uuid::from_u128(1)),
857 &[member("a@localhost", "2026-01-01T00:00:00Z")],
858 )
859 .unwrap();
860
861 write_groups(&mut conn, &[]).unwrap();
862 assert!(
863 members(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
864 .unwrap()
865 .is_empty(),
866 "a member list outlived the group it belonged to"
867 );
868 }
869
870 /// Scoped, unlike the group write: a cycle where one group's member fetch
871 /// failed must not blank another group's list.
872 #[test]
873 fn writing_one_groups_members_leaves_another_groups_alone() {
874 let mut conn = db();
875 write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap();
876 let one = GroupId::new(uuid::Uuid::from_u128(1));
877 let two = GroupId::new(uuid::Uuid::from_u128(2));
878
879 write_members(
880 &mut conn,
881 one,
882 &[member("a@localhost", "2026-01-01T00:00:00Z")],
883 )
884 .unwrap();
885 write_members(
886 &mut conn,
887 two,
888 &[member("b@localhost", "2026-01-01T00:00:00Z")],
889 )
890 .unwrap();
891
892 assert_eq!(members(&conn, one).unwrap().len(), 1);
893 assert_eq!(members(&conn, two).unwrap()[0].email, "b@localhost");
894 }
895
896 #[test]
897 fn members_come_back_oldest_first() {
898 let mut conn = db();
899 write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
900 let one = GroupId::new(uuid::Uuid::from_u128(1));
901 write_members(
902 &mut conn,
903 one,
904 &[
905 member("late@localhost", "2026-06-01T00:00:00Z"),
906 member("early@localhost", "2026-01-01T00:00:00Z"),
907 ],
908 )
909 .unwrap();
910
911 let listed = members(&conn, one).unwrap();
912 assert_eq!(listed[0].email, "early@localhost");
913 assert_eq!(listed[1].email, "late@localhost");
914 }
915
916 /// Membership is answerable from the local directory, with no request.
917 #[test]
918 fn membership_is_answerable_without_a_request() {
919 let mut conn = db();
920 write_groups(&mut conn, &[group(1, "Mine", false)]).unwrap();
921
922 assert!(is_member(&conn, GroupId::new(uuid::Uuid::from_u128(1))).unwrap());
923 assert!(!is_member(&conn, GroupId::new(uuid::Uuid::from_u128(9))).unwrap());
924 }
925
926 /// Belonging to a group and administering it are different facts, and only
927 /// one of them is about permission. A screen must not read the empty member
928 /// list as "nobody is in it".
929 #[test]
930 fn a_group_you_only_belong_to_has_no_members_and_says_so_separately() {
931 let mut conn = db();
932 write_groups(&mut conn, &[group(1, "Someone else's", false)]).unwrap();
933
934 let listed = groups(&conn).unwrap();
935 assert!(!listed[0].is_admin, "the fact that gates the member list");
936 assert!(
937 members(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
938 .unwrap()
939 .is_empty(),
940 "and the list nobody asked the server for"
941 );
942 }
943
944 /// The moment a group is created: the server knows it, no cycle has run, and
945 /// a directory read must not call the creator a non-member of their own
946 /// group.
947 #[test]
948 fn a_group_added_on_creation_is_usable_before_the_next_sync() {
949 let mut conn = db();
950 write_groups(&mut conn, &[group(1, "Existing", false)]).unwrap();
951 add_group(&mut conn, &group(2, "Just made", true)).unwrap();
952
953 assert!(is_member(&conn, GroupId::new(uuid::Uuid::from_u128(2))).unwrap());
954 assert_eq!(
955 groups(&conn).unwrap().len(),
956 2,
957 "additive: it did not replace the directory"
958 );
959 }
960
961 #[test]
962 fn adding_a_group_that_is_already_known_updates_it() {
963 let mut conn = db();
964 add_group(&mut conn, &group(1, "Old name", false)).unwrap();
965 add_group(&mut conn, &group(1, "New name", true)).unwrap();
966
967 let listed = groups(&conn).unwrap();
968 assert_eq!(listed.len(), 1);
969 assert_eq!(listed[0].name, "New name");
970 assert!(listed[0].is_admin);
971 }
972
973 /// An app that has never been signed in never builds a `SyncStore`, so the
974 /// sync DDL has never run and the tables are absent. Every read answers as
975 /// an empty directory, because that is the same fact: this device knows of
976 /// no groups.
977 /// Both routes into the directory run [`DDL`] itself: `migration_sql`
978 /// concatenates it rather than restating it, so an app that builds a
979 /// `SyncStore` and one that only calls `ensure_tables` get the same tables by
980 /// construction. This test holds the concatenation in place.
981 #[test]
982 fn the_standalone_ddl_agrees_with_the_migration() {
983 let conn = Connection::open_in_memory().expect("a database");
984 ensure_tables(&conn).expect("the directory tables");
985 assert!(present(&conn).unwrap());
986
987 let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
988 for table in [
989 "sync_groups",
990 "sync_group_members",
991 "sync_invitations",
992 "sync_invitation_previews",
993 ] {
994 assert!(schema.migration_sql().contains(table), "{table}");
995 }
996
997 // Every statement, not merely the table names: a `migration_sql` that
998 // reworded the directory would pass the check above and still be a
999 // second definition.
1000 assert!(schema.migration_sql().contains(DDL));
1001 }
1002
1003 #[test]
1004 fn a_store_with_no_sync_tables_at_all_reads_as_an_empty_directory() {
1005 let conn = Connection::open_in_memory().expect("a database");
1006 conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
1007 .expect("the app table");
1008
1009 assert!(groups(&conn).unwrap().is_empty());
1010 assert!(!is_member(&conn, GroupId::new(uuid::Uuid::from_u128(1))).unwrap());
1011 assert!(
1012 members(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
1013 .unwrap()
1014 .is_empty()
1015 );
1016 assert_eq!(refreshed_at(&conn).unwrap(), None);
1017 }
1018
1019 #[test]
1020 fn a_written_directory_reports_when_it_was_written() {
1021 let mut conn = db();
1022 write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1023 assert!(refreshed_at(&conn).unwrap().is_some());
1024 }
1025
1026 /// The reason the per-list readers exist. `write_invitations` is scoped to
1027 /// one group so a failed fetch there does not blank the others, and this is
1028 /// what that looks like from a screen: the group list refreshed on the cycle
1029 /// the invitation list missed, and the two numbers disagree.
1030 #[test]
1031 fn the_invitation_list_can_be_staler_than_the_group_list() {
1032 let mut conn = db();
1033 let one = GroupId::new(uuid::Uuid::from_u128(1));
1034 write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1035 write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1036
1037 // The cycle where the invitation fetch failed: the group list is written
1038 // again, the invitation list is left standing.
1039 conn.execute(
1040 "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'",
1041 [],
1042 )
1043 .unwrap();
1044 write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1045
1046 let groups_at = refreshed_at(&conn).unwrap().expect("a group timestamp");
1047 let invites_at = invitations_refreshed_at(&conn, one)
1048 .unwrap()
1049 .expect("an invitation timestamp");
1050 assert_eq!(invites_at, "2026-01-01T00:00:00.000Z");
1051 assert!(
1052 invites_at < groups_at,
1053 "the invitation list is the stale one: {invites_at} vs {groups_at}"
1054 );
1055 }
1056
1057 /// Per-group, because the failure is per-group: one group refreshing tells a
1058 /// screen drawing another group's list nothing.
1059 #[test]
1060 fn a_groups_member_and_invitation_timestamps_are_its_own() {
1061 let mut conn = db();
1062 let one = GroupId::new(uuid::Uuid::from_u128(1));
1063 let two = GroupId::new(uuid::Uuid::from_u128(2));
1064 write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap();
1065 write_members(&mut conn, one, &[member("a@example.com", "2026-01-01")]).unwrap();
1066 write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1067
1068 assert!(members_refreshed_at(&conn, one).unwrap().is_some());
1069 assert!(invitations_refreshed_at(&conn, one).unwrap().is_some());
1070 assert_eq!(members_refreshed_at(&conn, two).unwrap(), None);
1071 assert_eq!(invitations_refreshed_at(&conn, two).unwrap(), None);
1072 }
1073
1074 /// The confirmations reader spans groups exactly as `pending_confirmations`
1075 /// does, and reports the stalest of them: a section is only as current as the
1076 /// group whose fetch missed.
1077 #[test]
1078 fn the_confirmations_timestamp_is_the_stalest_group_it_draws_from() {
1079 let mut conn = db();
1080 let one = GroupId::new(uuid::Uuid::from_u128(1));
1081 let two = GroupId::new(uuid::Uuid::from_u128(2));
1082 write_groups(&mut conn, &[group(1, "One", true), group(2, "Two", true)]).unwrap();
1083 write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Accepted)]).unwrap();
1084 conn.execute(
1085 "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'",
1086 [],
1087 )
1088 .unwrap();
1089 write_invitations(&mut conn, two, &[invite(22, 2, InvitationState::Accepted)]).unwrap();
1090
1091 assert_eq!(pending_confirmations(&conn).unwrap().len(), 2);
1092 assert_eq!(
1093 pending_confirmations_refreshed_at(&conn).unwrap(),
1094 Some("2026-01-01T00:00:00.000Z".to_owned()),
1095 );
1096 }
1097
1098 /// Only the invitations the section draws. A pending invitation is not
1099 /// waiting on the admin, so its age is not the confirmations list's age.
1100 #[test]
1101 fn the_confirmations_timestamp_ignores_invitations_the_section_does_not_draw() {
1102 let mut conn = db();
1103 let one = GroupId::new(uuid::Uuid::from_u128(1));
1104 write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1105 write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1106
1107 assert!(invitations_refreshed_at(&conn, one).unwrap().is_some());
1108 assert_eq!(pending_confirmations_refreshed_at(&conn).unwrap(), None);
1109 }
1110
1111 /// Both halves of an empty answer: never fetched, and fetched with nothing
1112 /// in it. Neither has a date to state, and a screen states nothing rather
1113 /// than a number about a different list.
1114 #[test]
1115 fn an_empty_list_has_no_timestamp_to_state() {
1116 let mut conn = db();
1117 let one = GroupId::new(uuid::Uuid::from_u128(1));
1118 write_groups(&mut conn, &[group(1, "One", true)]).unwrap();
1119 assert_eq!(members_refreshed_at(&conn, one).unwrap(), None);
1120
1121 write_invitations(&mut conn, one, &[invite(11, 1, InvitationState::Pending)]).unwrap();
1122 write_invitations(&mut conn, one, &[]).unwrap();
1123 assert_eq!(invitations_refreshed_at(&conn, one).unwrap(), None);
1124 }
1125
1126 #[test]
1127 fn a_store_with_no_directory_has_no_timestamp_for_any_list() {
1128 let conn = Connection::open_in_memory().expect("a database");
1129 let one = GroupId::new(uuid::Uuid::from_u128(1));
1130 assert_eq!(refreshed_at(&conn).unwrap(), None);
1131 assert_eq!(members_refreshed_at(&conn, one).unwrap(), None);
1132 assert_eq!(invitations_refreshed_at(&conn, one).unwrap(), None);
1133 assert_eq!(pending_confirmations_refreshed_at(&conn).unwrap(), None);
1134 }
1135
1136 /// Local-only DDL, so the gate must not see it. A table absent from every
1137 /// `SyncSchema` crosses no wire, and the storage version describes the wire.
1138 #[test]
1139 fn the_directory_is_absent_from_the_wire_manifest() {
1140 let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
1141 let wire = schema.wire_manifest();
1142 assert!(!wire.contains("sync_groups"), "{wire}");
1143 assert!(!wire.contains("sync_group_members"), "{wire}");
1144 }
1145
1146 #[test]
1147 fn a_store_that_has_never_synced_knows_no_invitations() {
1148 let conn = db();
1149 assert!(
1150 invitations(&conn, GroupId::new(uuid::Uuid::from_u128(1)))
1151 .unwrap()
1152 .is_empty()
1153 );
1154 assert!(pending_confirmations(&conn).unwrap().is_empty());
1155 assert!(preview(&conn).unwrap().is_none());
1156 }
1157
1158 /// The refresh must not throw away the one copy of a live invite code. The
1159 /// server keeps only a hash and cannot re-send it, so a delete-then-insert
1160 /// refresh would make every invitation unusable one cycle after issuing it.
1161 #[test]
1162 fn a_refresh_keeps_the_token_of_a_pending_invitation() {
1163 let mut conn = db();
1164 let group_id = GroupId::new(uuid::Uuid::from_u128(1));
1165 record_issued(&conn, group_id, &issued(7, "code-7")).unwrap();
1166
1167 write_invitations(
1168 &mut conn,
1169 group_id,
1170 &[invite(7, 1, InvitationState::Pending)],
1171 )
1172 .unwrap();
1173
1174 let listed = invitations(&conn, group_id).unwrap();
1175 assert_eq!(listed.len(), 1);
1176 assert_eq!(
1177 listed[0].token.as_deref(),
1178 Some("code-7"),
1179 "a pending invitation keeps the code this device issued"
1180 );
1181 }
1182
1183 /// Once somebody has accepted, the code has done its whole job. Holding it
1184 /// is exposure with no use, and the refresh that observes the new state is
1185 /// what clears it.
1186 #[test]
1187 fn leaving_pending_drops_the_token() {
1188 let mut conn = db();
1189 let group_id = GroupId::new(uuid::Uuid::from_u128(1));
1190 record_issued(&conn, group_id, &issued(7, "code-7")).unwrap();
1191
1192 for state in [
1193 InvitationState::Accepted,
1194 InvitationState::Redeemed,
1195 InvitationState::Revoked,
1196 InvitationState::Expired,
1197 ] {
1198 record_issued(&conn, group_id, &issued(7, "code-7")).unwrap();
1199 write_invitations(&mut conn, group_id, &[invite(7, 1, state)]).unwrap();
1200 assert_eq!(
1201 invitations(&conn, group_id).unwrap()[0].token,
1202 None,
1203 "{state:?} should not keep a token"
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn a_refresh_prunes_what_the_server_stopped_reporting() {
1210 let mut conn = db();
1211 let group_id = GroupId::new(uuid::Uuid::from_u128(1));
1212 write_invitations(
1213 &mut conn,
1214 group_id,
1215 &[
1216 invite(1, 1, InvitationState::Pending),
1217 invite(2, 1, InvitationState::Pending),
1218 ],
1219 )
1220 .unwrap();
1221 assert_eq!(invitations(&conn, group_id).unwrap().len(), 2);
1222
1223 write_invitations(
1224 &mut conn,
1225 group_id,
1226 &[invite(2, 1, InvitationState::Pending)],
1227 )
1228 .unwrap();
1229 let left = invitations(&conn, group_id).unwrap();
1230 assert_eq!(left.len(), 1);
1231 assert_eq!(left[0].id, InvitationId::new(uuid::Uuid::from_u128(2)));
1232
1233 write_invitations(&mut conn, group_id, &[]).unwrap();
1234 assert!(
1235 invitations(&conn, group_id).unwrap().is_empty(),
1236 "an empty answer empties the group"
1237 );
1238 }
1239
1240 /// A cycle where one group's fetch failed must not blank another's.
1241 #[test]
1242 fn a_refresh_is_scoped_to_its_group() {
1243 let mut conn = db();
1244 let one = GroupId::new(uuid::Uuid::from_u128(1));
1245 let two = GroupId::new(uuid::Uuid::from_u128(2));
1246 write_invitations(&mut conn, one, &[invite(1, 1, InvitationState::Pending)]).unwrap();
1247 write_invitations(&mut conn, two, &[invite(2, 2, InvitationState::Pending)]).unwrap();
1248
1249 write_invitations(&mut conn, two, &[]).unwrap();
1250 assert_eq!(invitations(&conn, one).unwrap().len(), 1);
1251 assert!(invitations(&conn, two).unwrap().is_empty());
1252 }
1253
1254 #[test]
1255 fn confirmations_are_every_accepted_invitation_oldest_first() {
1256 let mut conn = db();
1257 let one = GroupId::new(uuid::Uuid::from_u128(1));
1258 let two = GroupId::new(uuid::Uuid::from_u128(2));
1259 write_invitations(
1260 &mut conn,
1261 one,
1262 &[
1263 invite(9, 1, InvitationState::Accepted),
1264 invite(3, 1, InvitationState::Pending),
1265 ],
1266 )
1267 .unwrap();
1268 write_invitations(&mut conn, two, &[invite(4, 2, InvitationState::Accepted)]).unwrap();
1269
1270 let waiting = pending_confirmations(&conn).unwrap();
1271 assert_eq!(waiting.len(), 2, "across groups, accepted only");
1272 assert_eq!(
1273 waiting[0].id,
1274 InvitationId::new(uuid::Uuid::from_u128(4)),
1275 "oldest first: who has been waiting longest"
1276 );
1277 }
1278
1279 /// A deadline passes on its own, between two refreshes. Deriving it at read
1280 /// time is what stops a screen offering a dead code as a live one.
1281 #[test]
1282 fn a_passed_deadline_reads_as_expired_without_a_refresh() {
1283 let mut stale = invite(1, 1, InvitationState::Pending);
1284 stale.expires_at = "2020-01-01T00:00:00+00:00".to_owned();
1285 assert!(stale.is_past_deadline());
1286 assert_eq!(stale.effective_state(), InvitationState::Expired);
1287
1288 let live = invite(1, 1, InvitationState::Pending);
1289 assert!(!live.is_past_deadline());
1290 assert_eq!(live.effective_state(), InvitationState::Pending);
1291 }
1292
1293 /// An accepted invitation is waiting on the admin, not on the invitee. The
1294 /// deadline it carries was the deadline for redeeming the link, which
1295 /// somebody already did.
1296 #[test]
1297 fn only_pending_derives_to_expired() {
1298 let mut accepted = invite(1, 1, InvitationState::Accepted);
1299 accepted.expires_at = "2020-01-01T00:00:00+00:00".to_owned();
1300 assert!(accepted.is_past_deadline());
1301 assert_eq!(accepted.effective_state(), InvitationState::Accepted);
1302 }
1303
1304 #[test]
1305 fn an_unreadable_deadline_reads_as_live() {
1306 let mut odd = invite(1, 1, InvitationState::Pending);
1307 odd.expires_at = "whenever".to_owned();
1308 assert!(!odd.is_past_deadline(), "hiding a live invitation is worse");
1309 }
1310
1311 #[test]
1312 fn a_preview_replaces_rather_than_accumulates() {
1313 let mut conn = db();
1314 write_preview(
1315 &mut conn,
1316 &KnownPreview {
1317 token: "one".to_owned(),
1318 group_name: "First".to_owned(),
1319 inviter_email: "a@example.com".to_owned(),
1320 redeemable: true,
1321 state: InvitationState::Pending,
1322 expires_at: "2099-01-01T00:00:00+00:00".to_owned(),
1323 },
1324 )
1325 .unwrap();
1326 write_preview(
1327 &mut conn,
1328 &KnownPreview {
1329 token: "two".to_owned(),
1330 group_name: "Second".to_owned(),
1331 inviter_email: "b@example.com".to_owned(),
1332 redeemable: false,
1333 state: InvitationState::Revoked,
1334 expires_at: "2099-01-01T00:00:00+00:00".to_owned(),
1335 },
1336 )
1337 .unwrap();
1338
1339 let held = preview(&conn).unwrap().expect("the newer answer");
1340 assert_eq!(held.token, "two");
1341 assert_eq!(held.group_name, "Second");
1342 assert!(!held.redeemable);
1343
1344 clear_preview(&conn).unwrap();
1345 assert!(preview(&conn).unwrap().is_none());
1346 }
1347
1348 /// A row written by a build that knew a state this one does not is skipped,
1349 /// on the same reasoning an unparseable id is: a screen that cannot list its
1350 /// invitations is worse than one missing a row it could not have acted on.
1351 #[test]
1352 fn an_unknown_state_is_skipped_rather_than_failing_the_read() {
1353 let mut conn = db();
1354 let group_id = GroupId::new(uuid::Uuid::from_u128(1));
1355 write_invitations(
1356 &mut conn,
1357 group_id,
1358 &[invite(1, 1, InvitationState::Pending)],
1359 )
1360 .unwrap();
1361 conn.execute("UPDATE sync_invitations SET state = 'quarantined'", [])
1362 .unwrap();
1363
1364 assert!(invitations(&conn, group_id).unwrap().is_empty());
1365 }
1366 }
1367