torii_storage_postgres/
magic_link.rs1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use torii_core::{
4 UserId,
5 error::StorageError,
6 storage::{MagicLinkStorage, MagicToken},
7};
8
9use crate::PostgresStorage;
10
11#[derive(Debug, Clone, sqlx::FromRow)]
12pub struct PostgresMagicToken {
13 pub id: Option<String>,
14 pub user_id: String,
15 pub token: String,
16 pub used_at: Option<DateTime<Utc>>,
17 pub expires_at: DateTime<Utc>,
18 pub created_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20}
21
22impl From<PostgresMagicToken> for MagicToken {
23 fn from(row: PostgresMagicToken) -> Self {
24 MagicToken::new(
25 UserId::new(&row.user_id),
26 row.token.clone(),
27 row.used_at,
28 row.expires_at,
29 row.created_at,
30 row.updated_at,
31 )
32 }
33}
34
35impl From<&MagicToken> for PostgresMagicToken {
36 fn from(token: &MagicToken) -> Self {
37 PostgresMagicToken {
38 id: None,
39 user_id: token.user_id.as_str().to_string(),
40 token: token.token.clone(),
41 used_at: token.used_at,
42 expires_at: token.expires_at,
43 created_at: token.created_at,
44 updated_at: token.updated_at,
45 }
46 }
47}
48
49#[async_trait]
50impl MagicLinkStorage for PostgresStorage {
51 async fn save_magic_token(&self, token: &MagicToken) -> Result<(), Self::Error> {
52 let row = PostgresMagicToken::from(token);
53
54 sqlx::query("INSERT INTO magic_links (user_id, token, expires_at, created_at, updated_at) VALUES ($1::uuid, $2, $3, $4, $5)")
55 .bind(row.user_id)
56 .bind(row.token)
57 .bind(row.expires_at)
58 .bind(row.created_at)
59 .bind(row.updated_at)
60 .execute(&self.pool)
61 .await
62 .map_err(|e| StorageError::Database(e.to_string()))?;
63
64 Ok(())
65 }
66
67 async fn get_magic_token(&self, token: &str) -> Result<Option<MagicToken>, Self::Error> {
68 let row: Option<PostgresMagicToken> =
69 sqlx::query_as("SELECT id::text, user_id::text, token, used_at, expires_at, created_at, updated_at FROM magic_links WHERE token = $1 AND expires_at > $2 AND used_at IS NULL")
70 .bind(token)
71 .bind(Utc::now())
72 .fetch_optional(&self.pool)
73 .await
74 .map_err(|e| StorageError::Database(e.to_string()))?;
75
76 Ok(row.map(|row| row.into()))
77 }
78
79 async fn set_magic_token_used(&self, token: &str) -> Result<(), Self::Error> {
80 sqlx::query("UPDATE magic_links SET used_at = $1 WHERE token = $2")
81 .bind(Utc::now())
82 .bind(token)
83 .execute(&self.pool)
84 .await
85 .map_err(|e| StorageError::Database(e.to_string()))?;
86
87 Ok(())
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 use chrono::Duration;
96 use torii_core::{NewUser, User, UserStorage, storage::MagicLinkStorage};
97 use uuid::Uuid;
98
99 use crate::PostgresStorage;
100
101 async fn create_test_user(storage: &PostgresStorage) -> User {
102 let user = NewUser::builder()
103 .email("test@test.com".to_string())
104 .build()
105 .expect("Failed to build test user");
106 storage
107 .create_user(&user)
108 .await
109 .expect("Failed to create test user")
110 }
111
112 #[tokio::test]
113 async fn test_save_and_get_magic_token() {
114 let storage = crate::tests::setup_test_db().await;
115
116 let user = create_test_user(&storage).await;
118
119 let token = MagicToken::new(
120 UserId::new(&user.id.to_string()),
121 Uuid::new_v4().to_string(),
122 None,
123 Utc::now() + Duration::minutes(5),
124 Utc::now(),
125 Utc::now(),
126 );
127 storage
128 .save_magic_token(&token)
129 .await
130 .expect("Failed to save magic token");
131
132 let stored_token = storage
133 .get_magic_token(&token.token)
134 .await
135 .expect("Failed to get magic token");
136 assert!(stored_token.is_some());
137
138 let stored_token = stored_token.unwrap();
139 assert_eq!(stored_token, token);
140 }
141
142 #[tokio::test]
143 async fn test_get_nonexistent_magic_token() {
144 let storage = crate::tests::setup_test_db().await;
145
146 let token = Uuid::new_v4().to_string();
147 let result = storage
148 .get_magic_token(&token)
149 .await
150 .expect("Failed to query magic token");
151 assert!(result.is_none());
152 }
153}