oko/db/
user.rs

1use axum_login::AuthUser;
2use serde::{Deserialize, Serialize};
3use sqlx::{Result, SqlitePool};
4use time::OffsetDateTime;
5
6use super::Model;
7
8#[derive(Clone, Serialize, Deserialize)]
9pub struct User {
10    pub user_id: i64,
11    pub username: String,
12    pub password_hash: String,
13    pub created_at: OffsetDateTime,
14}
15
16// Here we've implemented `Debug` manually to avoid accidentally logging the
17// password hash.
18impl std::fmt::Debug for User {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        f.debug_struct("User")
21            .field("user_id", &self.user_id)
22            .field("username", &self.username)
23            .field("password_hash", &"[redacted]")
24            .field("created_at", &self.created_at)
25            .finish()
26    }
27}
28
29impl AuthUser for User {
30    type Id = i64;
31
32    fn id(&self) -> Self::Id {
33        self.user_id
34    }
35
36    fn session_auth_hash(&self) -> &[u8] {
37        self.password_hash.as_bytes() // We use the password hash as the auth
38                                      // hash--what this means
39                                      // is when the user changes their password the
40                                      // auth session becomes invalid.
41    }
42}
43
44#[allow(dead_code)]
45pub struct Default {
46    pub user_id: i64,
47}
48
49impl Default {
50    #[allow(clippy::unused_self)]
51    pub fn created_at(&self) -> OffsetDateTime {
52        OffsetDateTime::now_utc()
53    }
54}
55
56impl Model for User {
57    type Default = Default;
58    const DEFAULT: Default = Default { user_id: -1 };
59
60    async fn create_using_self(&mut self, pool: &SqlitePool) -> Result<()> {
61        let result = sqlx::query!(
62            r#"
63            INSERT INTO users (username, password_hash, created_at)
64            VALUES (?, ?, ?)
65            RETURNING user_id
66            "#,
67            self.username,
68            self.password_hash,
69            self.created_at
70        )
71        .fetch_one(pool)
72        .await?;
73
74        self.user_id = result.user_id;
75
76        Ok(())
77    }
78
79    async fn get_using_id(pool: &SqlitePool, id: i64) -> Result<Self> {
80        sqlx::query_as!(
81            User,
82            r#"
83            SELECT *
84            FROM users
85            WHERE user_id = ?
86            "#,
87            id
88        )
89        .fetch_one(pool)
90        .await
91    }
92
93    async fn update_using_self(&self, pool: &SqlitePool) -> Result<()> {
94        sqlx::query!(
95            r#"
96            UPDATE users
97            SET username = ?, password_hash = ?, created_at = ?
98            WHERE user_id = ?
99            RETURNING user_id
100            "#,
101            self.username,
102            self.password_hash,
103            self.created_at,
104            self.user_id
105        )
106        .fetch_one(pool)
107        .await?;
108
109        Ok(())
110    }
111
112    async fn delete_using_id(pool: &SqlitePool, id: i64) -> Result<()> {
113        sqlx::query!(
114            r#"
115            DELETE
116            FROM users
117            WHERE user_id = ?
118            RETURNING user_id
119            "#,
120            id
121        )
122        .fetch_one(pool)
123        .await?;
124
125        Ok(())
126    }
127}
128
129impl User {
130    pub async fn get_using_username(pool: &SqlitePool, username: &str) -> Result<Self> {
131        sqlx::query_as!(
132            User,
133            r#"
134            SELECT *
135            FROM users
136            WHERE username = ?
137            "#,
138            username
139        )
140        .fetch_one(pool)
141        .await
142    }
143
144    pub async fn get_all(pool: &SqlitePool) -> Result<Vec<Self>> {
145        sqlx::query_as!(
146            User,
147            r#"
148            SELECT *
149            FROM users
150            "#,
151        )
152        .fetch_all(pool)
153        .await
154    }
155
156    #[must_use]
157    pub fn to_redacted_clone(&self) -> Self {
158        Self {
159            user_id: self.user_id,
160            username: self.username.clone(),
161            password_hash: "[redacted]".to_string(),
162            created_at: self.created_at,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
172    async fn create(pool: SqlitePool) -> Result<()> {
173        let mut user = User {
174            user_id: User::DEFAULT.user_id,
175            username: "test_user".to_string(),
176            password_hash: "test_hash".to_string(),
177            created_at: User::DEFAULT.created_at(),
178        };
179
180        user.create_using_self(&pool).await?;
181
182        assert_eq!(user.user_id, 5);
183
184        let returned_user = User::get_using_id(&pool, 5).await?;
185
186        assert_eq!(returned_user.username, user.username);
187        assert_eq!(returned_user.password_hash, user.password_hash);
188        assert_eq!(returned_user.created_at, user.created_at);
189
190        Ok(())
191    }
192
193    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
194    async fn create_existing(pool: SqlitePool) -> Result<()> {
195        let mut user = User {
196            user_id: User::DEFAULT.user_id,
197            username: "piotrpdev".to_string(),
198            password_hash: "test_hash".to_string(),
199            created_at: User::DEFAULT.created_at(),
200        };
201
202        let returned_user_result = user.create_using_self(&pool).await;
203
204        assert!(returned_user_result.is_err());
205
206        Ok(())
207    }
208
209    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
210    async fn get(pool: SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
211        let user_id = 2;
212        let returned_user = User::get_using_id(&pool, user_id).await?;
213
214        assert_eq!(returned_user.user_id, user_id);
215        assert_eq!(returned_user.username, "piotrpdev");
216        assert_eq!(returned_user.password_hash, "$argon2id$v=19$m=19456,t=2,p=1$VE0e3g7DalWHgDwou3nuRA$uC6TER156UQpk0lNQ5+jHM0l5poVjPA1he/Tyn9J4Zw");
217        assert_eq!(
218            returned_user.created_at,
219            OffsetDateTime::from_unix_timestamp(1_729_530_138)?
220        );
221
222        Ok(())
223    }
224
225    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
226    async fn get_using_username(pool: SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
227        let username = "piotrpdev";
228
229        let returned_user = User::get_using_username(&pool, username).await?;
230
231        assert_eq!(returned_user.user_id, 2);
232        assert_eq!(returned_user.username, username);
233        assert_eq!(returned_user.password_hash, "$argon2id$v=19$m=19456,t=2,p=1$VE0e3g7DalWHgDwou3nuRA$uC6TER156UQpk0lNQ5+jHM0l5poVjPA1he/Tyn9J4Zw");
234        assert_eq!(
235            returned_user.created_at,
236            OffsetDateTime::from_unix_timestamp(1_729_530_138)?
237        );
238
239        Ok(())
240    }
241
242    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
243    async fn get_all(pool: SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
244        let usernames = ["admin", "piotrpdev", "joedaly", "guest"];
245        let returned_users = User::get_all(&pool).await?;
246
247        assert_eq!(returned_users.len(), 4);
248
249        assert!(returned_users
250            .iter()
251            .all(|user| usernames.contains(&user.username.as_str())));
252
253        Ok(())
254    }
255
256    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
257    async fn update(pool: SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
258        let old_user = User::get_using_id(&pool, 2).await?;
259
260        let updated_user = User {
261            user_id: old_user.user_id,
262            username: "new_joedaly".to_string(),
263            password_hash: old_user.password_hash,
264            created_at: OffsetDateTime::from_unix_timestamp(1_729_530_138)?,
265        };
266
267        let updated = updated_user.update_using_self(&pool).await;
268
269        assert!(updated.is_ok());
270
271        let returned_user = User::get_using_id(&pool, old_user.user_id).await?;
272
273        assert_eq!(returned_user.user_id, updated_user.user_id);
274        assert_eq!(returned_user.username, updated_user.username);
275        assert_eq!(returned_user.password_hash, updated_user.password_hash);
276        assert_eq!(returned_user.created_at, updated_user.created_at);
277
278        Ok(())
279    }
280
281    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
282    async fn delete(pool: SqlitePool) -> Result<()> {
283        let user_id = 2;
284        let deleted = User::delete_using_id(&pool, user_id).await;
285
286        assert!(deleted.is_ok());
287
288        let returned_user = User::get_using_id(&pool, user_id).await;
289
290        assert!(returned_user.is_err());
291
292        let impossible_deleted = User::delete_using_id(&pool, user_id).await;
293
294        assert!(impossible_deleted.is_err());
295
296        Ok(())
297    }
298
299    #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
300    async fn to_redacted_clone(pool: SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
301        let user_id = 2;
302
303        let returned_user = User::get_using_id(&pool, user_id).await?;
304        let redacted_user = returned_user.to_redacted_clone();
305
306        assert_eq!(redacted_user.user_id, user_id);
307        assert_eq!(redacted_user.username, "piotrpdev");
308        assert_eq!(redacted_user.password_hash, "[redacted]");
309        assert_eq!(
310            redacted_user.created_at,
311            OffsetDateTime::from_unix_timestamp(1_729_530_138)?
312        );
313
314        Ok(())
315    }
316}