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 = 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        // Newest first.
722        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}