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 fn mark_invite_accepted(&self, id: &str, now: u64) -> bool {
336 let Ok(c) = self.conn.lock() else {
337 return false;
338 };
339 c.execute(
342 &format!(
343 "UPDATE {INVITES_TABLE} SET accepted_at = ?2
344 WHERE id = ?1 AND accepted_at IS NULL"
345 ),
346 rusqlite::params![id, now as i64],
347 )
348 .map(|n| n > 0)
349 .unwrap_or(false)
350 }
351}
352
353fn row_to_invite(row: &rusqlite::Row<'_>) -> rusqlite::Result<Invite> {
354 let role: String = row.get(3)?;
355 Ok(Invite {
356 id: row.get(0)?,
357 org_id: row.get(1)?,
358 email: row.get(2)?,
359 role: role_from_str(&role),
360 invited_by: row.get(4)?,
361 token_hash: row.get(5)?,
362 token_prefix: row.get(6)?,
363 created_at: row.get::<_, i64>(7)? as u64,
364 expires_at: row.get::<_, i64>(8)? as u64,
365 accepted_at: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
366 })
367}
368
369pub use pg::PostgresOrgBackend;
374
375mod pg {
376 use super::*;
377 use postgres::Client;
378
379 pub struct PostgresOrgBackend {
380 client: Mutex<Client>,
381 }
382
383 impl PostgresOrgBackend {
384 pub fn connect(url: &str) -> Result<Self, String> {
385 let mut client = pylon_storage::postgres::live::connect_pg(url)?;
386 client
387 .batch_execute(&format!(
388 "CREATE TABLE IF NOT EXISTS {ORGS_TABLE} (
389 id TEXT PRIMARY KEY,
390 name TEXT NOT NULL,
391 created_by TEXT NOT NULL,
392 created_at BIGINT NOT NULL
393 );
394 CREATE TABLE IF NOT EXISTS {MEMBERS_TABLE} (
395 org_id TEXT NOT NULL,
396 user_id TEXT NOT NULL,
397 role TEXT NOT NULL,
398 joined_at BIGINT NOT NULL,
399 PRIMARY KEY (org_id, user_id)
400 );
401 CREATE INDEX IF NOT EXISTS {MEMBERS_TABLE}_user_idx ON {MEMBERS_TABLE}(user_id);
402 CREATE TABLE IF NOT EXISTS {INVITES_TABLE} (
403 id TEXT PRIMARY KEY,
404 org_id TEXT NOT NULL,
405 email TEXT NOT NULL,
406 role TEXT NOT NULL,
407 invited_by TEXT NOT NULL,
408 token_hash TEXT NOT NULL,
409 token_prefix TEXT NOT NULL,
410 created_at BIGINT NOT NULL,
411 expires_at BIGINT NOT NULL,
412 accepted_at BIGINT
413 );
414 CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_prefix_idx ON {INVITES_TABLE}(token_prefix);
415 CREATE INDEX IF NOT EXISTS {INVITES_TABLE}_org_idx ON {INVITES_TABLE}(org_id);"
416 ))
417 .map_err(|e| format!("PG init schema: {e}"))?;
418 Ok(Self {
419 client: Mutex::new(client),
420 })
421 }
422 }
423
424 impl OrgBackend for PostgresOrgBackend {
425 fn put_org(&self, org: &Org) {
426 if let Ok(mut c) = self.client.lock() {
427 let _ = c.execute(
428 &format!(
429 "INSERT INTO {ORGS_TABLE} (id, name, created_by, created_at)
430 VALUES ($1, $2, $3, $4)
431 ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"
432 ),
433 &[
434 &org.id,
435 &org.name,
436 &org.created_by,
437 &(org.created_at as i64),
438 ],
439 );
440 }
441 }
442
443 fn get_org(&self, id: &str) -> Option<Org> {
444 let mut c = self.client.lock().ok()?;
445 let row = c
446 .query_opt(
447 &format!(
448 "SELECT id, name, created_by, created_at FROM {ORGS_TABLE} WHERE id = $1"
449 ),
450 &[&id],
451 )
452 .ok()??;
453 Some(Org {
454 id: row.get(0),
455 name: row.get(1),
456 created_by: row.get(2),
457 created_at: row.get::<_, i64>(3) as u64,
458 })
459 }
460
461 fn delete_org(&self, id: &str) -> bool {
462 let Ok(mut c) = self.client.lock() else {
463 return false;
464 };
465 let _ = c.execute(
466 &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = $1"),
467 &[&id],
468 );
469 let _ = c.execute(
470 &format!("DELETE FROM {INVITES_TABLE} WHERE org_id = $1"),
471 &[&id],
472 );
473 c.execute(&format!("DELETE FROM {ORGS_TABLE} WHERE id = $1"), &[&id])
474 .map(|n| n > 0)
475 .unwrap_or(false)
476 }
477
478 fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
479 let Ok(mut c) = self.client.lock() else {
480 return vec![];
481 };
482 let rows = match c.query(
483 &format!(
484 "SELECT o.id, o.name, o.created_by, o.created_at, m.role
485 FROM {ORGS_TABLE} o JOIN {MEMBERS_TABLE} m ON o.id = m.org_id
486 WHERE m.user_id = $1
487 ORDER BY o.created_at DESC"
488 ),
489 &[&user_id],
490 ) {
491 Ok(r) => r,
492 Err(_) => return vec![],
493 };
494 rows.iter()
495 .map(|row| {
496 let role: String = row.get(4);
497 (
498 Org {
499 id: row.get(0),
500 name: row.get(1),
501 created_by: row.get(2),
502 created_at: row.get::<_, i64>(3) as u64,
503 },
504 role_from_str(&role),
505 )
506 })
507 .collect()
508 }
509
510 fn put_membership(&self, m: &Membership) {
511 if let Ok(mut c) = self.client.lock() {
512 let _ = c.execute(
513 &format!(
514 "INSERT INTO {MEMBERS_TABLE} (org_id, user_id, role, joined_at)
515 VALUES ($1, $2, $3, $4)
516 ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role"
517 ),
518 &[
519 &m.org_id,
520 &m.user_id,
521 &role_to_str(m.role),
522 &(m.joined_at as i64),
523 ],
524 );
525 }
526 }
527
528 fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
529 let mut c = self.client.lock().ok()?;
530 let row = c
531 .query_opt(
532 &format!(
533 "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
534 WHERE org_id = $1 AND user_id = $2"
535 ),
536 &[&org_id, &user_id],
537 )
538 .ok()??;
539 let role: String = row.get(2);
540 Some(Membership {
541 org_id: row.get(0),
542 user_id: row.get(1),
543 role: role_from_str(&role),
544 joined_at: row.get::<_, i64>(3) as u64,
545 })
546 }
547
548 fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
549 let Ok(mut c) = self.client.lock() else {
550 return false;
551 };
552 c.execute(
553 &format!("DELETE FROM {MEMBERS_TABLE} WHERE org_id = $1 AND user_id = $2"),
554 &[&org_id, &user_id],
555 )
556 .map(|n| n > 0)
557 .unwrap_or(false)
558 }
559
560 fn list_members(&self, org_id: &str) -> Vec<Membership> {
561 let Ok(mut c) = self.client.lock() else {
562 return vec![];
563 };
564 let rows = match c.query(
565 &format!(
566 "SELECT org_id, user_id, role, joined_at FROM {MEMBERS_TABLE}
567 WHERE org_id = $1 ORDER BY joined_at"
568 ),
569 &[&org_id],
570 ) {
571 Ok(r) => r,
572 Err(_) => return vec![],
573 };
574 rows.iter()
575 .map(|row| {
576 let role: String = row.get(2);
577 Membership {
578 org_id: row.get(0),
579 user_id: row.get(1),
580 role: role_from_str(&role),
581 joined_at: row.get::<_, i64>(3) as u64,
582 }
583 })
584 .collect()
585 }
586
587 fn put_invite(&self, inv: &Invite) {
588 if let Ok(mut c) = self.client.lock() {
589 let _ = c.execute(
590 &format!(
591 "INSERT INTO {INVITES_TABLE}
592 (id, org_id, email, role, invited_by, token_hash, token_prefix,
593 created_at, expires_at, accepted_at)
594 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
595 ON CONFLICT (id) DO UPDATE SET accepted_at = EXCLUDED.accepted_at"
596 ),
597 &[
598 &inv.id,
599 &inv.org_id,
600 &inv.email,
601 &role_to_str(inv.role),
602 &inv.invited_by,
603 &inv.token_hash,
604 &inv.token_prefix,
605 &(inv.created_at as i64),
606 &(inv.expires_at as i64),
607 &inv.accepted_at.map(|v| v as i64),
608 ],
609 );
610 }
611 }
612
613 fn get_invite(&self, id: &str) -> Option<Invite> {
614 let mut c = self.client.lock().ok()?;
615 let row = c
616 .query_opt(
617 &format!(
618 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
619 created_at, expires_at, accepted_at
620 FROM {INVITES_TABLE} WHERE id = $1"
621 ),
622 &[&id],
623 )
624 .ok()??;
625 Some(pg_row_to_invite(&row))
626 }
627
628 fn list_invites(&self, org_id: &str) -> Vec<Invite> {
629 let Ok(mut c) = self.client.lock() else {
630 return vec![];
631 };
632 let rows = match c.query(
633 &format!(
634 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
635 created_at, expires_at, accepted_at
636 FROM {INVITES_TABLE}
637 WHERE org_id = $1 AND accepted_at IS NULL
638 ORDER BY created_at DESC"
639 ),
640 &[&org_id],
641 ) {
642 Ok(r) => r,
643 Err(_) => return vec![],
644 };
645 rows.iter().map(pg_row_to_invite).collect()
646 }
647
648 fn delete_invite(&self, id: &str) -> bool {
649 let Ok(mut c) = self.client.lock() else {
650 return false;
651 };
652 c.execute(
653 &format!("DELETE FROM {INVITES_TABLE} WHERE id = $1"),
654 &[&id],
655 )
656 .map(|n| n > 0)
657 .unwrap_or(false)
658 }
659
660 fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
661 let Ok(mut c) = self.client.lock() else {
662 return vec![];
663 };
664 let rows = match c.query(
665 &format!(
666 "SELECT id, org_id, email, role, invited_by, token_hash, token_prefix,
667 created_at, expires_at, accepted_at
668 FROM {INVITES_TABLE} WHERE token_prefix = $1"
669 ),
670 &[&prefix],
671 ) {
672 Ok(r) => r,
673 Err(_) => return vec![],
674 };
675 rows.iter().map(pg_row_to_invite).collect()
676 }
677
678 fn mark_invite_accepted(&self, id: &str, now: u64) -> bool {
679 let Ok(mut c) = self.client.lock() else {
680 return false;
681 };
682 c.execute(
683 &format!(
684 "UPDATE {INVITES_TABLE} SET accepted_at = $2
685 WHERE id = $1 AND accepted_at IS NULL"
686 ),
687 &[&id, &(now as i64)],
688 )
689 .map(|n| n > 0)
690 .unwrap_or(false)
691 }
692 }
693
694 fn pg_row_to_invite(row: &postgres::Row) -> Invite {
695 let role: String = row.get(3);
696 Invite {
697 id: row.get(0),
698 org_id: row.get(1),
699 email: row.get(2),
700 role: role_from_str(&role),
701 invited_by: row.get(4),
702 token_hash: row.get(5),
703 token_prefix: row.get(6),
704 created_at: row.get::<_, i64>(7) as u64,
705 expires_at: row.get::<_, i64>(8) as u64,
706 accepted_at: row.get::<_, Option<i64>>(9).map(|v| v as u64),
707 }
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use pylon_auth::org::{Invite, Membership, Org, OrgRole};
715
716 #[test]
717 fn sqlite_org_round_trip() {
718 let b = SqliteOrgBackend::in_memory().unwrap();
719 let org = Org {
720 id: "o1".into(),
721 name: "Acme".into(),
722 created_by: "u1".into(),
723 created_at: 100,
724 };
725 b.put_org(&org);
726 assert_eq!(b.get_org("o1").unwrap().name, "Acme");
727 assert!(b.delete_org("o1"));
728 assert!(b.get_org("o1").is_none());
729 }
730
731 #[test]
732 fn sqlite_membership_and_list_for_user() {
733 let b = SqliteOrgBackend::in_memory().unwrap();
734 b.put_org(&Org {
735 id: "o1".into(),
736 name: "A".into(),
737 created_by: "u1".into(),
738 created_at: 100,
739 });
740 b.put_org(&Org {
741 id: "o2".into(),
742 name: "B".into(),
743 created_by: "u2".into(),
744 created_at: 200,
745 });
746 b.put_membership(&Membership {
747 org_id: "o1".into(),
748 user_id: "u1".into(),
749 role: OrgRole::Owner,
750 joined_at: 100,
751 });
752 b.put_membership(&Membership {
753 org_id: "o2".into(),
754 user_id: "u1".into(),
755 role: OrgRole::Member,
756 joined_at: 200,
757 });
758 let list = b.list_orgs_for_user("u1");
759 assert_eq!(list.len(), 2);
760 assert_eq!(list[0].0.id, "o2");
762 assert_eq!(list[0].1, OrgRole::Member);
763 }
764
765 #[test]
766 fn sqlite_invite_prefix_index_used() {
767 let b = SqliteOrgBackend::in_memory().unwrap();
768 b.put_org(&Org {
769 id: "o1".into(),
770 name: "A".into(),
771 created_by: "u1".into(),
772 created_at: 100,
773 });
774 b.put_invite(&Invite {
775 id: "i1".into(),
776 org_id: "o1".into(),
777 email: "x@y.com".into(),
778 role: OrgRole::Member,
779 invited_by: "u1".into(),
780 token_hash: "h".into(),
781 token_prefix: "abcd1234".into(),
782 created_at: 100,
783 expires_at: 9_999_999_999,
784 accepted_at: None,
785 });
786 let hits = b.invites_by_prefix("abcd1234");
787 assert_eq!(hits.len(), 1);
788 let misses = b.invites_by_prefix("nomatch1");
789 assert_eq!(misses.len(), 0);
790 }
791
792 #[test]
793 fn sqlite_delete_org_cascades() {
794 let b = SqliteOrgBackend::in_memory().unwrap();
795 b.put_org(&Org {
796 id: "o1".into(),
797 name: "A".into(),
798 created_by: "u1".into(),
799 created_at: 100,
800 });
801 b.put_membership(&Membership {
802 org_id: "o1".into(),
803 user_id: "u1".into(),
804 role: OrgRole::Owner,
805 joined_at: 100,
806 });
807 b.put_invite(&Invite {
808 id: "i1".into(),
809 org_id: "o1".into(),
810 email: "x@y.com".into(),
811 role: OrgRole::Member,
812 invited_by: "u1".into(),
813 token_hash: "h".into(),
814 token_prefix: "p".into(),
815 created_at: 100,
816 expires_at: 9_999_999_999,
817 accepted_at: None,
818 });
819 assert!(b.delete_org("o1"));
820 assert!(b.list_members("o1").is_empty());
821 assert!(b.list_invites("o1").is_empty());
822 }
823}