Skip to main content

pylon_runtime/
org_backend.rs

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