1use std::sync::{Arc, Mutex};
12
13use pylon_auth::org::{Invite, Membership, Org, OrgBackend, OrgRole};
14use rusqlite::Connection;
15
16const ORGS_TABLE: &str = "_pylon_orgs";
17const MEMBERS_TABLE: &str = "_pylon_org_members";
18const INVITES_TABLE: &str = "_pylon_org_invites";
19
20pub struct SqliteOrgBackend {
25 conn: Arc<Mutex<Connection>>,
26}
27
28impl SqliteOrgBackend {
29 pub fn open(path: &str) -> Result<Self, String> {
30 let conn = Connection::open(path).map_err(|e| format!("open: {e}"))?;
31 Self::from_connection(conn)
32 }
33 pub fn in_memory() -> Result<Self, String> {
34 let conn = Connection::open_in_memory().map_err(|e| format!("open: {e}"))?;
35 Self::from_connection(conn)
36 }
37 fn from_connection(conn: Connection) -> Result<Self, String> {
38 conn.execute_batch(&format!(
39 "CREATE TABLE IF NOT EXISTS {ORGS_TABLE} (
40 id TEXT PRIMARY KEY,
41 name TEXT NOT NULL,
42 created_by TEXT NOT NULL,
43 created_at INTEGER NOT NULL
44 );
45 CREATE TABLE IF NOT EXISTS {MEMBERS_TABLE} (
46 org_id TEXT NOT NULL,
47 user_id TEXT NOT NULL,
48 role TEXT NOT NULL,
49 joined_at INTEGER NOT NULL,
50 PRIMARY KEY (org_id, user_id)
51 );
52 CREATE INDEX IF NOT EXISTS {MEMBERS_TABLE}_user_idx ON {MEMBERS_TABLE}(user_id);
53 CREATE TABLE IF NOT EXISTS {INVITES_TABLE} (
54 id TEXT PRIMARY KEY,
55 org_id TEXT NOT NULL,
56 email TEXT NOT NULL,
57 role TEXT NOT NULL,
58 invited_by TEXT NOT NULL,
59 token_hash TEXT NOT NULL,
60 token_prefix TEXT NOT NULL,
61 created_at INTEGER NOT NULL,
62 expires_at INTEGER NOT NULL,
63 accepted_at INTEGER
64 );
65 CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_prefix_idx ON {INVITES_TABLE}(token_prefix);
66 CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_org_idx ON {INVITES_TABLE}(org_id);"
67 ))
68 .map_err(|e| format!("init schema: {e}"))?;
69 Ok(Self {
70 conn: Arc::new(Mutex::new(conn)),
71 })
72 }
73}
74
75fn role_to_str(r: OrgRole) -> &'static str {
76 r.as_str()
77}
78
79fn role_from_str(s: &str) -> OrgRole {
80 OrgRole::from_str(s).unwrap_or(OrgRole::Member)
81}
82
83impl OrgBackend for SqliteOrgBackend {
84 fn put_org(&self, org: &Org) {
85 if let Ok(c) = self.conn.lock() {
86 let _ = c.execute(
87 &format!(
88 "INSERT INTO {ORGS_TABLE} (id, name, created_by, created_at)
89 VALUES (?1, ?2, ?3, ?4)
90 ON CONFLICT(id) DO UPDATE SET
91 name = excluded.name"
92 ),
93 rusqlite::params![org.id, org.name, org.created_by, org.created_at as i64],
94 );
95 }
96 }
97
98 fn get_org(&self, id: &str) -> Option<Org> {
99 let c = self.conn.lock().ok()?;
100 c.query_row(
101 &format!("SELECT id, name, created_by, created_at FROM {ORGS_TABLE} WHERE id = ?1"),
102 rusqlite::params![id],
103 |r| {
104 Ok(Org {
105 id: r.get(0)?,
106 name: r.get(1)?,
107 created_by: r.get(2)?,
108 created_at: r.get::<_, i64>(3)? as u64,
109 })
110 },
111 )
112 .ok()
113 }
114
115 fn delete_org(&self, id: &str) -> bool {
116 let Ok(c) = self.conn.lock() else {
117 return false;
118 };
119 let _ = c.execute(
122 &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = ?1"),
123 rusqlite::params![id],
124 );
125 let _ = c.execute(
126 &format!("DELETE FROM {INVITES_TABLE} WHERE org_id = ?1"),
127 rusqlite::params![id],
128 );
129 c.execute(
130 &format!("DELETE FROM {ORGS_TABLE} WHERE id = ?1"),
131 rusqlite::params![id],
132 )
133 .map(|n| n > 0)
134 .unwrap_or(false)
135 }
136
137 fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
138 let Ok(c) = self.conn.lock() else {
139 return vec![];
140 };
141 let mut stmt = match c.prepare(&format!(
142 "SELECT o.id, o.name, o.created_by, o.created_at, m.role
143 FROM {ORGS_TABLE} o JOIN {MEMBERS_TABLE} m ON o.id = m.org_id
144 WHERE m.user_id = ?1
145 ORDER BY o.created_at DESC"
146 )) {
147 Ok(s) => s,
148 Err(_) => return vec![],
149 };
150 let iter = match stmt.query_map(rusqlite::params![user_id], |r| {
151 let role: String = r.get(4)?;
152 Ok((
153 Org {
154 id: r.get(0)?,
155 name: r.get(1)?,
156 created_by: r.get(2)?,
157 created_at: r.get::<_, i64>(3)? as u64,
158 },
159 role_from_str(&role),
160 ))
161 }) {
162 Ok(it) => it,
163 Err(_) => return vec![],
164 };
165 iter.filter_map(|r| r.ok()).collect()
166 }
167
168 fn put_membership(&self, m: &Membership) {
169 if let Ok(c) = self.conn.lock() {
170 let _ = c.execute(
171 &format!(
172 "INSERT INTO {MEMBERS_TABLE} (org_id, user_id, role, joined_at)
173 VALUES (?1, ?2, ?3, ?4)
174 ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role"
175 ),
176 rusqlite::params![m.org_id, m.user_id, role_to_str(m.role), m.joined_at as i64],
177 );
178 }
179 }
180
181 fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
182 let c = self.conn.lock().ok()?;
183 c.query_row(
184 &format!(
185 "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
186 WHERE org_id = ?1 AND user_id = ?2"
187 ),
188 rusqlite::params![org_id, user_id],
189 |r| {
190 let role: String = r.get(2)?;
191 Ok(Membership {
192 org_id: r.get(0)?,
193 user_id: r.get(1)?,
194 role: role_from_str(&role),
195 joined_at: r.get::<_, i64>(3)? as u64,
196 })
197 },
198 )
199 .ok()
200 }
201
202 fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
203 let Ok(c) = self.conn.lock() else {
204 return false;
205 };
206 c.execute(
207 &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = ?1 AND user_id = ?2"),
208 rusqlite::params![org_id, user_id],
209 )
210 .map(|n| n > 0)
211 .unwrap_or(false)
212 }
213
214 fn list_members(&self, org_id: &str) -> Vec<Membership> {
215 let Ok(c) = self.conn.lock() else {
216 return vec![];
217 };
218 let mut stmt = match c.prepare(&format!(
219 "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
220 WHERE org_id = ?1 ORDER BY joined_at"
221 )) {
222 Ok(s) => s,
223 Err(_) => return vec![],
224 };
225 let iter = match stmt.query_map(rusqlite::params![org_id], |r| {
226 let role: String = r.get(2)?;
227 Ok(Membership {
228 org_id: r.get(0)?,
229 user_id: r.get(1)?,
230 role: role_from_str(&role),
231 joined_at: r.get::<_, i64>(3)? as u64,
232 })
233 }) {
234 Ok(it) => it,
235 Err(_) => return vec![],
236 };
237 iter.filter_map(|r| r.ok()).collect()
238 }
239
240 fn put_invite(&self, inv: &Invite) {
241 if let Ok(c) = self.conn.lock() {
242 let _ = c.execute(
243 &format!(
244 "INSERT INTO {INVITES_TABLE}
245 (id, org_id, email, role, invited_by, token_hash, token_prefix,
246 created_at, expires_at, accepted_at)
247 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
248 ON CONFLICT(id) DO UPDATE SET
249 accepted_at = excluded.accepted_at"
250 ),
251 rusqlite::params![
252 inv.id,
253 inv.org_id,
254 inv.email,
255 role_to_str(inv.role),
256 inv.invited_by,
257 inv.token_hash,
258 inv.token_prefix,
259 inv.created_at as i64,
260 inv.expires_at as i64,
261 inv.accepted_at.map(|v| v as i64),
262 ],
263 );
264 }
265 }
266
267 fn get_invite(&self, id: &str) -> Option<Invite> {
268 let c = self.conn.lock().ok()?;
269 c.query_row(
270 &format!(
271 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
272 created_at, expires_at, accepted_at
273 FROM {INVITES_TABLE} WHERE id = ?1"
274 ),
275 rusqlite::params![id],
276 row_to_invite,
277 )
278 .ok()
279 }
280
281 fn list_invites(&self, org_id: &str) -> Vec<Invite> {
282 let Ok(c) = self.conn.lock() else {
283 return vec![];
284 };
285 let mut stmt = match c.prepare(&format!(
286 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
287 created_at, expires_at, accepted_at
288 FROM {INVITES_TABLE}
289 WHERE org_id = ?1 AND accepted_at IS NULL
290 ORDER BY created_at DESC"
291 )) {
292 Ok(s) => s,
293 Err(_) => return vec![],
294 };
295 let iter = match stmt.query_map(rusqlite::params![org_id], row_to_invite) {
296 Ok(it) => it,
297 Err(_) => return vec![],
298 };
299 iter.filter_map(|r| r.ok()).collect()
300 }
301
302 fn delete_invite(&self, id: &str) -> bool {
303 let Ok(c) = self.conn.lock() else {
304 return false;
305 };
306 c.execute(
307 &format!("DELETE FROM {INVITES_TABLE} WHERE id = ?1"),
308 rusqlite::params![id],
309 )
310 .map(|n| n > 0)
311 .unwrap_or(false)
312 }
313
314 fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
315 let Ok(c) = self.conn.lock() else {
316 return vec![];
317 };
318 let mut stmt = match c.prepare(&format!(
321 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
322 created_at, expires_at, accepted_at
323 FROM {INVITES_TABLE} WHERE token_prefix = ?1"
324 )) {
325 Ok(s) => s,
326 Err(_) => return vec![],
327 };
328 let iter = match stmt.query_map(rusqlite::params![prefix], row_to_invite) {
329 Ok(it) => it,
330 Err(_) => return vec![],
331 };
332 iter.filter_map(|r| r.ok()).collect()
333 }
334}
335
336fn row_to_invite(row: &rusqlite::Row<'_>) -> rusqlite::Result<Invite> {
337 let role: String = row.get(3)?;
338 Ok(Invite {
339 id: row.get(0)?,
340 org_id: row.get(1)?,
341 email: row.get(2)?,
342 role: role_from_str(&role),
343 invited_by: row.get(4)?,
344 token_hash: row.get(5)?,
345 token_prefix: row.get(6)?,
346 created_at: row.get::<_, i64>(7)? as u64,
347 expires_at: row.get::<_, i64>(8)? as u64,
348 accepted_at: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
349 })
350}
351
352pub use pg::PostgresOrgBackend;
357
358mod pg {
359 use super::*;
360 use postgres::Client;
361
362 pub struct PostgresOrgBackend {
363 client: Mutex<Client>,
364 }
365
366 impl PostgresOrgBackend {
367 pub fn connect(url: &str) -> Result<Self, String> {
368 let mut client = pylon_storage::postgres::live::connect_pg(url)?;
369 client
370 .batch_execute(&format!(
371 "CREATE TABLE IF NOT EXISTS {ORGS_TABLE} (
372 id TEXT PRIMARY KEY,
373 name TEXT NOT NULL,
374 created_by TEXT NOT NULL,
375 created_at BIGINT NOT NULL
376 );
377 CREATE TABLE IF NOT EXISTS {MEMBERS_TABLE} (
378 org_id TEXT NOT NULL,
379 user_id TEXT NOT NULL,
380 role TEXT NOT NULL,
381 joined_at BIGINT NOT NULL,
382 PRIMARY KEY (org_id, user_id)
383 );
384 CREATE INDEX IF NOT EXISTS {MEMBERS_TABLE}_user_idx ON {MEMBERS_TABLE}(user_id);
385 CREATE TABLE IF NOT EXISTS {INVITES_TABLE} (
386 id TEXT PRIMARY KEY,
387 org_id TEXT NOT NULL,
388 email TEXT NOT NULL,
389 role TEXT NOT NULL,
390 invited_by TEXT NOT NULL,
391 token_hash TEXT NOT NULL,
392 token_prefix TEXT NOT NULL,
393 created_at BIGINT NOT NULL,
394 expires_at BIGINT NOT NULL,
395 accepted_at BIGINT
396 );
397 CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_prefix_idx ON {INVITES_TABLE}(token_prefix);
398 CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_org_idx ON {INVITES_TABLE}(org_id);"
399 ))
400 .map_err(|e| format!("PG init schema: {e}"))?;
401 Ok(Self {
402 client: Mutex::new(client),
403 })
404 }
405 }
406
407 impl OrgBackend for PostgresOrgBackend {
408 fn put_org(&self, org: &Org) {
409 if let Ok(mut c) = self.client.lock() {
410 let _ = c.execute(
411 &format!(
412 "INSERT INTO {ORGS_TABLE} (id, name, created_by, created_at)
413 VALUES ($1, $2, $3, $4)
414 ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"
415 ),
416 &[&org.id, &org.name, &org.created_by, &(org.created_at as i64)],
417 );
418 }
419 }
420
421 fn get_org(&self, id: &str) -> Option<Org> {
422 let mut c = self.client.lock().ok()?;
423 let row = c
424 .query_opt(
425 &format!(
426 "SELECT id, name, created_by, created_at FROM {ORGS_TABLE} WHERE id = $1"
427 ),
428 &[&id],
429 )
430 .ok()??;
431 Some(Org {
432 id: row.get(0),
433 name: row.get(1),
434 created_by: row.get(2),
435 created_at: row.get::<_, i64>(3) as u64,
436 })
437 }
438
439 fn delete_org(&self, id: &str) -> bool {
440 let Ok(mut c) = self.client.lock() else {
441 return false;
442 };
443 let _ = c.execute(
444 &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = $1"),
445 &[&id],
446 );
447 let _ = c.execute(
448 &format!("DELETE FROM {INVITES_TABLE} WHERE org_id = $1"),
449 &[&id],
450 );
451 c.execute(
452 &format!("DELETE FROM {ORGS_TABLE} WHERE id = $1"),
453 &[&id],
454 )
455 .map(|n| n > 0)
456 .unwrap_or(false)
457 }
458
459 fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
460 let Ok(mut c) = self.client.lock() else {
461 return vec![];
462 };
463 let rows = match c.query(
464 &format!(
465 "SELECT o.id, o.name, o.created_by, o.created_at, m.role
466 FROM {ORGS_TABLE} o JOIN {MEMBERS_TABLE} m ON o.id = m.org_id
467 WHERE m.user_id = $1
468 ORDER BY o.created_at DESC"
469 ),
470 &[&user_id],
471 ) {
472 Ok(r) => r,
473 Err(_) => return vec![],
474 };
475 rows.iter()
476 .map(|row| {
477 let role: String = row.get(4);
478 (
479 Org {
480 id: row.get(0),
481 name: row.get(1),
482 created_by: row.get(2),
483 created_at: row.get::<_, i64>(3) as u64,
484 },
485 role_from_str(&role),
486 )
487 })
488 .collect()
489 }
490
491 fn put_membership(&self, m: &Membership) {
492 if let Ok(mut c) = self.client.lock() {
493 let _ = c.execute(
494 &format!(
495 "INSERT INTO {MEMBERS_TABLE} (org_id, user_id, role, joined_at)
496 VALUES ($1, $2, $3, $4)
497 ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role"
498 ),
499 &[&m.org_id, &m.user_id, &role_to_str(m.role), &(m.joined_at as i64)],
500 );
501 }
502 }
503
504 fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
505 let mut c = self.client.lock().ok()?;
506 let row = c
507 .query_opt(
508 &format!(
509 "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
510 WHERE org_id = $1 AND user_id = $2"
511 ),
512 &[&org_id, &user_id],
513 )
514 .ok()??;
515 let role: String = row.get(2);
516 Some(Membership {
517 org_id: row.get(0),
518 user_id: row.get(1),
519 role: role_from_str(&role),
520 joined_at: row.get::<_, i64>(3) as u64,
521 })
522 }
523
524 fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
525 let Ok(mut c) = self.client.lock() else {
526 return false;
527 };
528 c.execute(
529 &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = $1 AND user_id = $2"),
530 &[&org_id, &user_id],
531 )
532 .map(|n| n > 0)
533 .unwrap_or(false)
534 }
535
536 fn list_members(&self, org_id: &str) -> Vec<Membership> {
537 let Ok(mut c) = self.client.lock() else {
538 return vec![];
539 };
540 let rows = match c.query(
541 &format!(
542 "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
543 WHERE org_id = $1 ORDER BY joined_at"
544 ),
545 &[&org_id],
546 ) {
547 Ok(r) => r,
548 Err(_) => return vec![],
549 };
550 rows.iter()
551 .map(|row| {
552 let role: String = row.get(2);
553 Membership {
554 org_id: row.get(0),
555 user_id: row.get(1),
556 role: role_from_str(&role),
557 joined_at: row.get::<_, i64>(3) as u64,
558 }
559 })
560 .collect()
561 }
562
563 fn put_invite(&self, inv: &Invite) {
564 if let Ok(mut c) = self.client.lock() {
565 let _ = c.execute(
566 &format!(
567 "INSERT INTO {INVITES_TABLE}
568 (id, org_id, email, role, invited_by, token_hash, token_prefix,
569 created_at, expires_at, accepted_at)
570 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
571 ON CONFLICT (id) DO UPDATE SET accepted_at = EXCLUDED.accepted_at"
572 ),
573 &[
574 &inv.id,
575 &inv.org_id,
576 &inv.email,
577 &role_to_str(inv.role),
578 &inv.invited_by,
579 &inv.token_hash,
580 &inv.token_prefix,
581 &(inv.created_at as i64),
582 &(inv.expires_at as i64),
583 &inv.accepted_at.map(|v| v as i64),
584 ],
585 );
586 }
587 }
588
589 fn get_invite(&self, id: &str) -> Option<Invite> {
590 let mut c = self.client.lock().ok()?;
591 let row = c
592 .query_opt(
593 &format!(
594 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
595 created_at, expires_at, accepted_at
596 FROM {INVITES_TABLE} WHERE id = $1"
597 ),
598 &[&id],
599 )
600 .ok()??;
601 Some(pg_row_to_invite(&row))
602 }
603
604 fn list_invites(&self, org_id: &str) -> Vec<Invite> {
605 let Ok(mut c) = self.client.lock() else {
606 return vec![];
607 };
608 let rows = match c.query(
609 &format!(
610 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
611 created_at, expires_at, accepted_at
612 FROM {INVITES_TABLE}
613 WHERE org_id = $1 AND accepted_at IS NULL
614 ORDER BY created_at DESC"
615 ),
616 &[&org_id],
617 ) {
618 Ok(r) => r,
619 Err(_) => return vec![],
620 };
621 rows.iter().map(pg_row_to_invite).collect()
622 }
623
624 fn delete_invite(&self, id: &str) -> bool {
625 let Ok(mut c) = self.client.lock() else {
626 return false;
627 };
628 c.execute(
629 &format!("DELETE FROM {INVITES_TABLE} WHERE id = $1"),
630 &[&id],
631 )
632 .map(|n| n > 0)
633 .unwrap_or(false)
634 }
635
636 fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
637 let Ok(mut c) = self.client.lock() else {
638 return vec![];
639 };
640 let rows = match c.query(
641 &format!(
642 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
643 created_at, expires_at, accepted_at
644 FROM {INVITES_TABLE} WHERE token_prefix = $1"
645 ),
646 &[&prefix],
647 ) {
648 Ok(r) => r,
649 Err(_) => return vec![],
650 };
651 rows.iter().map(pg_row_to_invite).collect()
652 }
653 }
654
655 fn pg_row_to_invite(row: &postgres::Row) -> Invite {
656 let role: String = row.get(3);
657 Invite {
658 id: row.get(0),
659 org_id: row.get(1),
660 email: row.get(2),
661 role: role_from_str(&role),
662 invited_by: row.get(4),
663 token_hash: row.get(5),
664 token_prefix: row.get(6),
665 created_at: row.get::<_, i64>(7) as u64,
666 expires_at: row.get::<_, i64>(8) as u64,
667 accepted_at: row.get::<_, Option<i64>>(9).map(|v| v as u64),
668 }
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use pylon_auth::org::{Invite, Membership, Org, OrgRole};
676
677 #[test]
678 fn sqlite_org_round_trip() {
679 let b = SqliteOrgBackend::in_memory().unwrap();
680 let org = Org {
681 id: "o1".into(),
682 name: "Acme".into(),
683 created_by: "u1".into(),
684 created_at: 100,
685 };
686 b.put_org(&org);
687 assert_eq!(b.get_org("o1").unwrap().name, "Acme");
688 assert!(b.delete_org("o1"));
689 assert!(b.get_org("o1").is_none());
690 }
691
692 #[test]
693 fn sqlite_membership_and_list_for_user() {
694 let b = SqliteOrgBackend::in_memory().unwrap();
695 b.put_org(&Org {
696 id: "o1".into(),
697 name: "A".into(),
698 created_by: "u1".into(),
699 created_at: 100,
700 });
701 b.put_org(&Org {
702 id: "o2".into(),
703 name: "B".into(),
704 created_by: "u2".into(),
705 created_at: 200,
706 });
707 b.put_membership(&Membership {
708 org_id: "o1".into(),
709 user_id: "u1".into(),
710 role: OrgRole::Owner,
711 joined_at: 100,
712 });
713 b.put_membership(&Membership {
714 org_id: "o2".into(),
715 user_id: "u1".into(),
716 role: OrgRole::Member,
717 joined_at: 200,
718 });
719 let list = b.list_orgs_for_user("u1");
720 assert_eq!(list.len(), 2);
721 assert_eq!(list[0].0.id, "o2");
723 assert_eq!(list[0].1, OrgRole::Member);
724 }
725
726 #[test]
727 fn sqlite_invite_prefix_index_used() {
728 let b = SqliteOrgBackend::in_memory().unwrap();
729 b.put_org(&Org {
730 id: "o1".into(),
731 name: "A".into(),
732 created_by: "u1".into(),
733 created_at: 100,
734 });
735 b.put_invite(&Invite {
736 id: "i1".into(),
737 org_id: "o1".into(),
738 email: "x@y.com".into(),
739 role: OrgRole::Member,
740 invited_by: "u1".into(),
741 token_hash: "h".into(),
742 token_prefix: "abcd1234".into(),
743 created_at: 100,
744 expires_at: 9_999_999_999,
745 accepted_at: None,
746 });
747 let hits = b.invites_by_prefix("abcd1234");
748 assert_eq!(hits.len(), 1);
749 let misses = b.invites_by_prefix("nomatch1");
750 assert_eq!(misses.len(), 0);
751 }
752
753 #[test]
754 fn sqlite_delete_org_cascades() {
755 let b = SqliteOrgBackend::in_memory().unwrap();
756 b.put_org(&Org {
757 id: "o1".into(),
758 name: "A".into(),
759 created_by: "u1".into(),
760 created_at: 100,
761 });
762 b.put_membership(&Membership {
763 org_id: "o1".into(),
764 user_id: "u1".into(),
765 role: OrgRole::Owner,
766 joined_at: 100,
767 });
768 b.put_invite(&Invite {
769 id: "i1".into(),
770 org_id: "o1".into(),
771 email: "x@y.com".into(),
772 role: OrgRole::Member,
773 invited_by: "u1".into(),
774 token_hash: "h".into(),
775 token_prefix: "p".into(),
776 created_at: 100,
777 expires_at: 9_999_999_999,
778 accepted_at: None,
779 });
780 assert!(b.delete_org("o1"));
781 assert!(b.list_members("o1").is_empty());
782 assert!(b.list_invites("o1").is_empty());
783 }
784}