steid

@jamesgill /

1//! SQLite repository implementations.
2//!
3//! Rows are reassembled with `from_trusted`: they were validated on the way in, and
4//! re-validating them would make a tightened rule turn old rows unreadable.
5
6use std::time::{Duration, SystemTime};
7
8use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
9
10use crate::domain::{
11 Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash,
12 PersonalAccessToken, RepoId, RepoName, Repository, Role, Session, SessionTokenHash, TokenHash,
13 TokenId, User, UserId, Visibility,
14 repository::{
15 MembershipRepository, OrgRepository, RepoRepository, RepositoryError, RepositoryResult,
16 SessionRepository, TokenRepository, UserRepository,
17 },
18};
19
20fn backend(error: sqlx::Error) -> RepositoryError {
21 RepositoryError::backend(error)
22}
23
24#[derive(Debug, Clone)]
25pub struct SqliteUserRepo {
26 pool: SqlitePool,
27}
28
29impl SqliteUserRepo {
30 pub fn new(pool: SqlitePool) -> Self {
31 Self { pool }
32 }
33
34 fn map(row: &SqliteRow) -> User {
35 User::new(
36 UserId::from_trusted(row.get::<String, _>("id")),
37 Email::from_trusted(row.get::<String, _>("email")),
38 PasswordHash::from_trusted(row.get::<String, _>("password_hash")),
39 OrgId::from_trusted(row.get::<String, _>("personal_org_id")),
40 )
41 }
42}
43
44impl UserRepository for SqliteUserRepo {
45 async fn find_by_id(&self, id: &UserId) -> RepositoryResult<Option<User>> {
46 let row = sqlx::query("select * from users where id = ?")
47 .bind(id.as_str())
48 .fetch_optional(&self.pool)
49 .await
50 .map_err(backend)?;
51
52 Ok(row.as_ref().map(Self::map))
53 }
54
55 async fn find_by_email(&self, email: &Email) -> RepositoryResult<Option<User>> {
56 let row = sqlx::query("select * from users where email = ?")
57 .bind(email.as_str())
58 .fetch_optional(&self.pool)
59 .await
60 .map_err(backend)?;
61
62 Ok(row.as_ref().map(Self::map))
63 }
64
65 async fn save(&self, user: &User) -> RepositoryResult<()> {
66 sqlx::query(
67 "insert into users (id, email, password_hash, personal_org_id)
68 values (?, ?, ?, ?)
69 on conflict (id) do update set
70 email = excluded.email,
71 password_hash = excluded.password_hash,
72 personal_org_id = excluded.personal_org_id",
73 )
74 .bind(user.id.as_str())
75 .bind(user.email.as_str())
76 .bind(user.password_hash.as_str())
77 .bind(user.personal_org_id.as_str())
78 .execute(&self.pool)
79 .await
80 .map_err(backend)?;
81
82 Ok(())
83 }
84
85 async fn any_exist(&self) -> RepositoryResult<bool> {
86 let count: i64 = sqlx::query_scalar("select exists (select 1 from users)")
87 .fetch_one(&self.pool)
88 .await
89 .map_err(backend)?;
90
91 Ok(count != 0)
92 }
93}
94
95#[derive(Debug, Clone)]
96pub struct SqliteOrgRepo {
97 pool: SqlitePool,
98}
99
100impl SqliteOrgRepo {
101 pub fn new(pool: SqlitePool) -> Self {
102 Self { pool }
103 }
104
105 fn map(row: &SqliteRow) -> Organization {
106 Organization::from_trusted(
107 OrgId::from_trusted(row.get::<String, _>("id")),
108 OrgName::from_trusted(row.get::<String, _>("name")),
109 row.get::<Option<String>, _>("display_name"),
110 row.get::<Option<String>, _>("bio"),
111 )
112 }
113}
114
115impl OrgRepository for SqliteOrgRepo {
116 async fn find_by_id(&self, id: &OrgId) -> RepositoryResult<Option<Organization>> {
117 let row = sqlx::query("select * from orgs where id = ?")
118 .bind(id.as_str())
119 .fetch_optional(&self.pool)
120 .await
121 .map_err(backend)?;
122
123 Ok(row.as_ref().map(Self::map))
124 }
125
126 async fn find_by_name(&self, name: &OrgName) -> RepositoryResult<Option<Organization>> {
127 let row = sqlx::query("select * from orgs where name = ?")
128 .bind(name.as_str())
129 .fetch_optional(&self.pool)
130 .await
131 .map_err(backend)?;
132
133 Ok(row.as_ref().map(Self::map))
134 }
135
136 async fn save(&self, org: &Organization) -> RepositoryResult<()> {
137 sqlx::query(
138 "insert into orgs (id, name, display_name, bio)
139 values (?, ?, ?, ?)
140 on conflict (id) do update set
141 name = excluded.name,
142 display_name = excluded.display_name,
143 bio = excluded.bio",
144 )
145 .bind(org.id.as_str())
146 .bind(org.name.as_str())
147 .bind(org.display_name.as_deref())
148 .bind(org.bio.as_deref())
149 .execute(&self.pool)
150 .await
151 .map_err(backend)?;
152
153 Ok(())
154 }
155}
156
157#[derive(Debug, Clone)]
158pub struct SqliteMembershipRepo {
159 pool: SqlitePool,
160}
161
162impl SqliteMembershipRepo {
163 pub fn new(pool: SqlitePool) -> Self {
164 Self { pool }
165 }
166
167 /// An unparseable role is a storage fault, not a missing membership, so it
168 /// surfaces rather than silently downgrading the member's access.
169 fn map(row: &SqliteRow) -> RepositoryResult<Membership> {
170 let raw: String = row.get("role");
171 let role: Role = raw
172 .parse()
173 .map_err(|error| RepositoryError::backend(format!("{error}")))?;
174
175 Ok(Membership::new(
176 MembershipId::from_trusted(row.get::<String, _>("id")),
177 OrgId::from_trusted(row.get::<String, _>("org_id")),
178 UserId::from_trusted(row.get::<String, _>("user_id")),
179 role,
180 ))
181 }
182}
183
184impl MembershipRepository for SqliteMembershipRepo {
185 async fn find(&self, org_id: &OrgId, user_id: &UserId) -> RepositoryResult<Option<Membership>> {
186 let row = sqlx::query("select * from memberships where org_id = ? and user_id = ?")
187 .bind(org_id.as_str())
188 .bind(user_id.as_str())
189 .fetch_optional(&self.pool)
190 .await
191 .map_err(backend)?;
192
193 row.as_ref().map(Self::map).transpose()
194 }
195
196 async fn list_for_user(&self, user_id: &UserId) -> RepositoryResult<Vec<Membership>> {
197 let rows = sqlx::query("select * from memberships where user_id = ?")
198 .bind(user_id.as_str())
199 .fetch_all(&self.pool)
200 .await
201 .map_err(backend)?;
202
203 rows.iter().map(Self::map).collect()
204 }
205
206 async fn save(&self, membership: &Membership) -> RepositoryResult<()> {
207 sqlx::query(
208 "insert into memberships (id, org_id, user_id, role)
209 values (?, ?, ?, ?)
210 on conflict (id) do update set role = excluded.role",
211 )
212 .bind(membership.id.as_str())
213 .bind(membership.org_id.as_str())
214 .bind(membership.user_id.as_str())
215 .bind(membership.role.as_str())
216 .execute(&self.pool)
217 .await
218 .map_err(backend)?;
219
220 Ok(())
221 }
222}
223
224#[derive(Debug, Clone)]
225pub struct SqliteSessionRepo {
226 pool: SqlitePool,
227}
228
229impl SqliteSessionRepo {
230 pub fn new(pool: SqlitePool) -> Self {
231 Self { pool }
232 }
233}
234
235/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in
236/// the future — so saturating at 0 is safe rather than lossy.
237fn to_unix(time: SystemTime) -> i64 {
238 time.duration_since(SystemTime::UNIX_EPOCH)
239 .map(|d| d.as_secs() as i64)
240 .unwrap_or(0)
241}
242
243fn from_unix(seconds: i64) -> SystemTime {
244 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
245}
246
247impl SessionRepository for SqliteSessionRepo {
248 async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
249 let row = sqlx::query("select * from sessions where token_hash = ?")
250 .bind(token_hash.as_str())
251 .fetch_optional(&self.pool)
252 .await
253 .map_err(backend)?;
254
255 Ok(row.map(|row| {
256 Session::new(
257 SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")),
258 UserId::from_trusted(row.get::<String, _>("user_id")),
259 from_unix(row.get::<i64, _>("expires_at")),
260 )
261 }))
262 }
263
264 async fn save(&self, session: &Session) -> RepositoryResult<()> {
265 sqlx::query(
266 "insert into sessions (token_hash, user_id, expires_at)
267 values (?, ?, ?)
268 on conflict (token_hash) do update set
269 user_id = excluded.user_id,
270 expires_at = excluded.expires_at",
271 )
272 .bind(session.token_hash.as_str())
273 .bind(session.user_id.as_str())
274 .bind(to_unix(session.expires_at))
275 .execute(&self.pool)
276 .await
277 .map_err(backend)?;
278
279 Ok(())
280 }
281
282 async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
283 sqlx::query("delete from sessions where token_hash = ?")
284 .bind(token_hash.as_str())
285 .execute(&self.pool)
286 .await
287 .map_err(backend)?;
288
289 Ok(())
290 }
291
292 async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
293 let result = sqlx::query("delete from sessions where expires_at <= ?")
294 .bind(to_unix(now))
295 .execute(&self.pool)
296 .await
297 .map_err(backend)?;
298
299 Ok(result.rows_affected())
300 }
301}
302
303#[derive(Debug, Clone)]
304pub struct SqliteRepoRepo {
305 pool: SqlitePool,
306}
307
308impl SqliteRepoRepo {
309 pub fn new(pool: SqlitePool) -> Self {
310 Self { pool }
311 }
312
313 /// An unparseable visibility is a storage fault, not a default.
314 ///
315 /// Falling back to `Public` would publish a repository whose row we cannot read;
316 /// falling back to `Private` would hide a public one. Neither is a guess worth
317 /// making, so the row surfaces as an error.
318 fn map(row: &SqliteRow) -> RepositoryResult<Repository> {
319 let raw: String = row.get("visibility");
320 let visibility: Visibility = raw
321 .parse()
322 .map_err(|error| RepositoryError::backend(format!("{error}")))?;
323
324 Ok(Repository::from_trusted(
325 RepoId::from_trusted(row.get::<String, _>("id")),
326 OrgId::from_trusted(row.get::<String, _>("org_id")),
327 RepoName::from_trusted(row.get::<String, _>("name")),
328 row.get::<Option<String>, _>("description"),
329 visibility,
330 ))
331 }
332}
333
334impl RepoRepository for SqliteRepoRepo {
335 async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
336 let row = sqlx::query("select * from repositories where id = ?")
337 .bind(id.as_str())
338 .fetch_optional(&self.pool)
339 .await
340 .map_err(backend)?;
341
342 row.as_ref().map(Self::map).transpose()
343 }
344
345 async fn find_by_org_and_name(
346 &self,
347 org_id: &OrgId,
348 name: &RepoName,
349 ) -> RepositoryResult<Option<Repository>> {
350 let row = sqlx::query("select * from repositories where org_id = ? and name = ?")
351 .bind(org_id.as_str())
352 .bind(name.as_str())
353 .fetch_optional(&self.pool)
354 .await
355 .map_err(backend)?;
356
357 row.as_ref().map(Self::map).transpose()
358 }
359
360 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
361 let rows = sqlx::query("select * from repositories where org_id = ? order by name")
362 .bind(org_id.as_str())
363 .fetch_all(&self.pool)
364 .await
365 .map_err(backend)?;
366
367 rows.iter().map(Self::map).collect()
368 }
369
370 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
371 sqlx::query(
372 "insert into repositories (id, org_id, name, description, visibility)
373 values (?, ?, ?, ?, ?)
374 on conflict (id) do update set
375 org_id = excluded.org_id,
376 name = excluded.name,
377 description = excluded.description,
378 visibility = excluded.visibility",
379 )
380 .bind(repo.id.as_str())
381 .bind(repo.org_id.as_str())
382 .bind(repo.name.as_str())
383 .bind(repo.description.as_deref())
384 .bind(repo.visibility.as_str())
385 .execute(&self.pool)
386 .await
387 .map_err(backend)?;
388
389 Ok(())
390 }
391
392 async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
393 sqlx::query("delete from repositories where id = ?")
394 .bind(id.as_str())
395 .execute(&self.pool)
396 .await
397 .map_err(backend)?;
398
399 Ok(())
400 }
401}
402
403#[derive(Debug, Clone)]
404pub struct SqliteTokenRepo {
405 pool: SqlitePool,
406}
407
408impl SqliteTokenRepo {
409 pub fn new(pool: SqlitePool) -> Self {
410 Self { pool }
411 }
412
413 /// `from_trusted`, because these values were validated on the way in. Revalidating
414 /// stored rows means a tightened rule turns old rows unreadable.
415 fn hydrate(row: &SqliteRow) -> PersonalAccessToken {
416 PersonalAccessToken::from_trusted(
417 TokenId::from_trusted(row.get::<String, _>("id")),
418 UserId::from_trusted(row.get::<String, _>("user_id")),
419 row.get::<String, _>("name"),
420 row.get::<String, _>("prefix"),
421 TokenHash::from_trusted(row.get::<String, _>("token_hash")),
422 from_unix(row.get::<i64, _>("created_at")),
423 )
424 }
425}
426
427impl TokenRepository for SqliteTokenRepo {
428 async fn find_by_hash(
429 &self,
430 hash: &TokenHash,
431 ) -> RepositoryResult<Option<PersonalAccessToken>> {
432 // Matched in SQL by the hash, which is safe to compare with `=`: it is a digest,
433 // not the secret. The constant-time comparison guards the value a client
434 // presents, and that never reaches the database.
435 let row = sqlx::query("select * from tokens where token_hash = ?")
436 .bind(hash.as_str())
437 .fetch_optional(&self.pool)
438 .await
439 .map_err(backend)?;
440
441 Ok(row.as_ref().map(Self::hydrate))
442 }
443
444 async fn list_by_user(&self, user_id: &UserId) -> RepositoryResult<Vec<PersonalAccessToken>> {
445 let rows =
446 sqlx::query("select * from tokens where user_id = ? order by created_at desc, id asc")
447 .bind(user_id.as_str())
448 .fetch_all(&self.pool)
449 .await
450 .map_err(backend)?;
451
452 Ok(rows.iter().map(Self::hydrate).collect())
453 }
454
455 async fn save(&self, token: &PersonalAccessToken) -> RepositoryResult<()> {
456 sqlx::query(
457 "insert into tokens (id, user_id, name, prefix, token_hash, created_at)
458 values (?, ?, ?, ?, ?, ?)
459 on conflict (id) do update set
460 name = excluded.name",
461 )
462 .bind(token.id.as_str())
463 .bind(token.user_id.as_str())
464 .bind(&token.name)
465 .bind(&token.prefix)
466 .bind(token.token_hash.as_str())
467 .bind(to_unix(token.created_at))
468 .execute(&self.pool)
469 .await
470 .map_err(backend)?;
471
472 Ok(())
473 }
474
475 async fn delete(&self, id: &TokenId) -> RepositoryResult<()> {
476 sqlx::query("delete from tokens where id = ?")
477 .bind(id.as_str())
478 .execute(&self.pool)
479 .await
480 .map_err(backend)?;
481
482 Ok(())
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::{
490 application::{OwnerSpec, claim_instance, port::PasswordHasher},
491 domain::{SetupToken, TokenSecret},
492 infrastructure::{database::test_support::test_pool, password::StubHasher},
493 };
494
495 struct Repos {
496 users: SqliteUserRepo,
497 orgs: SqliteOrgRepo,
498 memberships: SqliteMembershipRepo,
499 }
500
501 async fn repos() -> Repos {
502 let pool = test_pool().await;
503 Repos {
504 users: SqliteUserRepo::new(pool.clone()),
505 orgs: SqliteOrgRepo::new(pool.clone()),
506 memberships: SqliteMembershipRepo::new(pool),
507 }
508 }
509
510 async fn saved_org(repos: &Repos, name: &str) -> Organization {
511 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
512 repos.orgs.save(&org).await.expect("save org");
513 org
514 }
515
516 async fn saved_user(repos: &Repos, email: &str, org: &Organization) -> User {
517 let user = User::new(
518 UserId::generate(),
519 Email::new(email).expect("valid email"),
520 PasswordHash::from_trusted("$argon2id$test"),
521 org.id.clone(),
522 );
523 repos.users.save(&user).await.expect("save user");
524 user
525 }
526
527 #[tokio::test]
528 async fn a_saved_user_round_trips() {
529 let repos = repos().await;
530 let org = saved_org(&repos, "james").await;
531 let user = saved_user(&repos, "dev@example.com", &org).await;
532
533 let found = repos
534 .users
535 .find_by_id(&user.id)
536 .await
537 .expect("lookup")
538 .expect("user should exist");
539
540 assert_eq!(found, user);
541 }
542
543 #[tokio::test]
544 async fn users_are_found_by_email_case_insensitively() {
545 let repos = repos().await;
546 let org = saved_org(&repos, "james").await;
547 saved_user(&repos, "dev@example.com", &org).await;
548
549 // Email lowercases on construction, but a row written before that rule would
550 // still need finding.
551 let found = repos
552 .users
553 .find_by_email(&Email::from_trusted("DEV@EXAMPLE.COM"))
554 .await
555 .expect("lookup");
556
557 assert!(found.is_some(), "collate nocase should make this match");
558 }
559
560 #[tokio::test]
561 async fn an_org_round_trips_with_its_display_name() {
562 let repos = repos().await;
563 let org = Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned()))
564 .expect("valid org");
565 repos.orgs.save(&org).await.expect("save");
566
567 let found = repos
568 .orgs
569 .find_by_name(&org.name)
570 .await
571 .expect("lookup")
572 .expect("org should exist");
573
574 assert_eq!(found, org);
575 assert_eq!(found.label(), "Acme");
576 }
577
578 #[tokio::test]
579 async fn a_missing_org_is_none_not_an_error() {
580 let repos = repos().await;
581
582 let found = repos
583 .orgs
584 .find_by_name(&OrgName::new("nobody").unwrap())
585 .await
586 .expect("lookup");
587
588 assert_eq!(found, None);
589 }
590
591 #[tokio::test]
592 async fn a_membership_round_trips_with_its_role() {
593 let repos = repos().await;
594 let org = saved_org(&repos, "james").await;
595 let user = saved_user(&repos, "dev@example.com", &org).await;
596 let membership = Membership::new(
597 MembershipId::generate(),
598 org.id.clone(),
599 user.id.clone(),
600 Role::Owner,
601 );
602 repos.memberships.save(&membership).await.expect("save");
603
604 let found = repos
605 .memberships
606 .find(&org.id, &user.id)
607 .await
608 .expect("lookup")
609 .expect("membership should exist");
610
611 assert_eq!(found, membership);
612 assert!(found.can_write());
613 }
614
615 #[tokio::test]
616 async fn an_unreadable_role_surfaces_rather_than_downgrading_access() {
617 let repos = repos().await;
618 let pool = test_pool().await;
619 let memberships = SqliteMembershipRepo::new(pool.clone());
620 let org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
621 SqliteOrgRepo::new(pool.clone())
622 .save(&org)
623 .await
624 .expect("save org");
625 let user = User::new(
626 UserId::generate(),
627 Email::new("dev@example.com").expect("valid email"),
628 PasswordHash::from_trusted("$argon2id$test"),
629 org.id.clone(),
630 );
631 SqliteUserRepo::new(pool.clone())
632 .save(&user)
633 .await
634 .expect("save user");
635
636 sqlx::query("insert into memberships (id, org_id, user_id, role) values (?, ?, ?, 'wat')")
637 .bind(MembershipId::generate().as_str())
638 .bind(org.id.as_str())
639 .bind(user.id.as_str())
640 .execute(&pool)
641 .await
642 .expect("insert");
643
644 let result = memberships.find(&org.id, &user.id).await;
645
646 assert!(
647 result.is_err(),
648 "a role we can't parse must not read as no membership"
649 );
650 drop(repos);
651 }
652
653 #[tokio::test]
654 async fn saving_a_user_before_its_org_is_refused() {
655 let repos = repos().await;
656 let orphan = User::new(
657 UserId::generate(),
658 Email::new("dev@example.com").expect("valid email"),
659 PasswordHash::from_trusted("$argon2id$test"),
660 OrgId::generate(),
661 );
662
663 let result = repos.users.save(&orphan).await;
664
665 assert!(
666 result.is_err(),
667 "the foreign key should reject a user whose org doesn't exist"
668 );
669 }
670
671 #[tokio::test]
672 async fn a_duplicate_handle_is_refused() {
673 let repos = repos().await;
674 saved_org(&repos, "james").await;
675
676 let clash = Organization::new(OrgId::generate(), "james", None).expect("valid org");
677 let result = repos.orgs.save(&clash).await;
678
679 assert!(result.is_err(), "orgs.name is unique");
680 }
681
682 #[tokio::test]
683 async fn a_differently_cased_handle_is_also_refused() {
684 let repos = repos().await;
685 saved_org(&repos, "james").await;
686
687 // OrgName lowercases, so this can only arrive via from_trusted -- but the
688 // constraint is what we're testing, not the value object.
689 let clash = Organization::from_trusted(
690 OrgId::generate(),
691 OrgName::from_trusted("JAMES"),
692 None,
693 None,
694 );
695 let result = repos.orgs.save(&clash).await;
696
697 assert!(result.is_err(), "collate nocase should catch this");
698 }
699
700 /// The claim use case checks `is_claimed` and then writes, which is TOCTOU. This
701 /// asserts the database is what actually stops a second owner being created.
702 #[tokio::test]
703 async fn a_second_claim_is_stopped_by_the_database_not_the_check() {
704 let repos = repos().await;
705 let token = SetupToken::generate();
706 let hasher = StubHasher::new();
707 let spec = OwnerSpec {
708 handle: "james".to_owned(),
709 email: "dev@example.com".to_owned(),
710 password: "hunter2".to_owned(),
711 };
712
713 claim_instance(
714 token.reveal(),
715 &token,
716 &spec,
717 &repos.users,
718 &repos.orgs,
719 &repos.memberships,
720 &hasher,
721 )
722 .await
723 .expect("first claim");
724
725 // Simulate the race: the second claimant passed is_claimed before the first
726 // committed, so it proceeds straight to the writes.
727 let intruder_org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
728 let result = repos.orgs.save(&intruder_org).await;
729
730 assert!(
731 result.is_err(),
732 "unique(orgs.name) is what actually serialises concurrent claims"
733 );
734
735 let intruder_user = User::new(
736 UserId::generate(),
737 Email::new("dev@example.com").expect("valid email"),
738 hasher.hash("letmein").expect("hash"),
739 OrgId::generate(),
740 );
741 assert!(
742 repos.users.save(&intruder_user).await.is_err(),
743 "unique(users.email) closes the other half"
744 );
745 }
746 async fn org_with(orgs: &SqliteOrgRepo, name: &str) -> Organization {
747 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
748 orgs.save(&org).await.expect("save org");
749 org
750 }
751
752 async fn saved_repo(
753 repos: &SqliteRepoRepo,
754 org: &Organization,
755 name: &str,
756 visibility: Visibility,
757 ) -> Repository {
758 let repo = Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
759 .expect("valid repo");
760 repos.save(&repo).await.expect("save repo");
761 repo
762 }
763
764 #[tokio::test]
765 async fn a_repository_round_trips() {
766 let pool = test_pool().await;
767 let orgs = SqliteOrgRepo::new(pool.clone());
768 let repos = SqliteRepoRepo::new(pool);
769 let org = org_with(&orgs, "acme").await;
770
771 let repo = Repository::new(
772 RepoId::generate(),
773 org.id.clone(),
774 "steid",
775 Some("A gitforge.".to_owned()),
776 Visibility::Private,
777 )
778 .expect("valid repo");
779 repos.save(&repo).await.expect("save");
780
781 let found = repos
782 .find_by_id(&repo.id)
783 .await
784 .expect("lookup")
785 .expect("should exist");
786
787 assert_eq!(found, repo);
788 assert_eq!(found.visibility, Visibility::Private);
789 assert_eq!(found.description.as_deref(), Some("A gitforge."));
790 }
791
792 #[tokio::test]
793 async fn lookup_by_name_is_case_insensitive() {
794 let pool = test_pool().await;
795 let orgs = SqliteOrgRepo::new(pool.clone());
796 let repos = SqliteRepoRepo::new(pool);
797 let org = org_with(&orgs, "acme").await;
798 saved_repo(&repos, &org, "steid", Visibility::Public).await;
799
800 let found = repos
801 .find_by_org_and_name(&org.id, &RepoName::from_trusted("STEID"))
802 .await
803 .expect("lookup");
804
805 assert!(found.is_some(), "collate nocase should make this match");
806 }
807
808 #[tokio::test]
809 async fn one_owner_cannot_have_two_repositories_with_the_same_name() {
810 let pool = test_pool().await;
811 let orgs = SqliteOrgRepo::new(pool.clone());
812 let repos = SqliteRepoRepo::new(pool);
813 let org = org_with(&orgs, "acme").await;
814 saved_repo(&repos, &org, "steid", Visibility::Public).await;
815
816 let clash = Repository::new(
817 RepoId::generate(),
818 org.id.clone(),
819 "steid",
820 None,
821 Visibility::Public,
822 )
823 .expect("valid repo");
824
825 assert!(repos.save(&clash).await.is_err(), "unique (org_id, name)");
826 }
827
828 #[tokio::test]
829 async fn two_owners_may_each_have_a_repository_of_the_same_name() {
830 let pool = test_pool().await;
831 let orgs = SqliteOrgRepo::new(pool.clone());
832 let repos = SqliteRepoRepo::new(pool);
833 let first = org_with(&orgs, "acme").await;
834 let second = org_with(&orgs, "globex").await;
835
836 saved_repo(&repos, &first, "steid", Visibility::Public).await;
837 saved_repo(&repos, &second, "steid", Visibility::Public).await;
838
839 // The constraint is a pair. On `name` alone, repository names would be globally
840 // unique across the whole installation.
841 assert_eq!(repos.list_by_org(&first.id).await.expect("list").len(), 1);
842 assert_eq!(repos.list_by_org(&second.id).await.expect("list").len(), 1);
843 }
844
845 #[tokio::test]
846 async fn a_repository_owned_by_a_missing_org_is_refused() {
847 let pool = test_pool().await;
848 let repos = SqliteRepoRepo::new(pool);
849 let orphan = Repository::new(
850 RepoId::generate(),
851 OrgId::generate(),
852 "steid",
853 None,
854 Visibility::Public,
855 )
856 .expect("valid repo");
857
858 assert!(repos.save(&orphan).await.is_err(), "foreign key");
859 }
860
861 #[tokio::test]
862 async fn an_unreadable_visibility_surfaces_rather_than_defaulting() {
863 let pool = test_pool().await;
864 let orgs = SqliteOrgRepo::new(pool.clone());
865 let repos = SqliteRepoRepo::new(pool.clone());
866 let org = org_with(&orgs, "acme").await;
867
868 sqlx::query(
869 "insert into repositories (id, org_id, name, visibility)
870 values ('r1', ?, 'secret', 'internal')",
871 )
872 .bind(org.id.as_str())
873 .execute(&pool)
874 .await
875 .expect("insert");
876
877 assert!(
878 repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err(),
879 "a visibility we cannot parse must not become a guess"
880 );
881 }
882
883 #[tokio::test]
884 async fn listing_returns_only_that_org_ordered_by_name() {
885 let pool = test_pool().await;
886 let orgs = SqliteOrgRepo::new(pool.clone());
887 let repos = SqliteRepoRepo::new(pool);
888 let mine = org_with(&orgs, "acme").await;
889 let theirs = org_with(&orgs, "globex").await;
890
891 for name in ["zebra", "alpha", "middle"] {
892 saved_repo(&repos, &mine, name, Visibility::Public).await;
893 }
894 saved_repo(&repos, &theirs, "not-mine", Visibility::Public).await;
895
896 let listed = repos.list_by_org(&mine.id).await.expect("list");
897
898 let names: Vec<&str> = listed.iter().map(|repo| repo.name.as_str()).collect();
899 assert_eq!(names, vec!["alpha", "middle", "zebra"]);
900 }
901
902 #[tokio::test]
903 async fn listing_includes_private_repositories() {
904 let pool = test_pool().await;
905 let orgs = SqliteOrgRepo::new(pool.clone());
906 let repos = SqliteRepoRepo::new(pool);
907 let org = org_with(&orgs, "acme").await;
908 saved_repo(&repos, &org, "secret", Visibility::Private).await;
909
910 // The port returns everything; filtering by visibility is the use case's job, so
911 // the page and /api cannot end up applying different rules.
912 assert_eq!(repos.list_by_org(&org.id).await.expect("list").len(), 1);
913 }
914
915 #[tokio::test]
916 async fn a_missing_repository_is_none_not_an_error() {
917 let pool = test_pool().await;
918 let repos = SqliteRepoRepo::new(pool);
919
920 let found = repos
921 .find_by_id(&RepoId::generate())
922 .await
923 .expect("lookup should not error");
924
925 assert_eq!(found, None);
926 }
927
928 #[tokio::test]
929 async fn a_deleted_repository_is_gone_and_frees_its_name() {
930 let pool = test_pool().await;
931 let orgs = SqliteOrgRepo::new(pool.clone());
932 let repos = SqliteRepoRepo::new(pool);
933 let org = org_with(&orgs, "acme").await;
934 let repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
935
936 repos.delete(&repo.id).await.expect("delete");
937
938 assert_eq!(repos.find_by_id(&repo.id).await.expect("lookup"), None);
939 // The unique (org_id, name) pair is what would fail if the row lingered.
940 saved_repo(&repos, &org, "steid", Visibility::Private).await;
941 }
942
943 #[tokio::test]
944 async fn deleting_a_repository_that_is_not_there_succeeds() {
945 // Idempotent, matching the fake: a second click must not become an error.
946 let pool = test_pool().await;
947 let repos = SqliteRepoRepo::new(pool);
948
949 assert!(repos.delete(&RepoId::generate()).await.is_ok());
950 }
951
952 #[tokio::test]
953 async fn deleting_one_repository_leaves_the_others() {
954 let pool = test_pool().await;
955 let orgs = SqliteOrgRepo::new(pool.clone());
956 let repos = SqliteRepoRepo::new(pool);
957 let org = org_with(&orgs, "acme").await;
958 let doomed = saved_repo(&repos, &org, "alpha", Visibility::Public).await;
959 saved_repo(&repos, &org, "zebra", Visibility::Public).await;
960
961 repos.delete(&doomed.id).await.expect("delete");
962
963 let listed = repos.list_by_org(&org.id).await.expect("list");
964 assert_eq!(
965 listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
966 vec!["zebra"]
967 );
968 }
969
970 // --- SqliteTokenRepo ---------------------------------------------------------
971
972 /// A pool plus a user to hang tokens off, since the foreign key runs that way.
973 async fn token_fixture() -> (SqliteTokenRepo, User, User) {
974 let pool = test_pool().await;
975 let repos = Repos {
976 users: SqliteUserRepo::new(pool.clone()),
977 orgs: SqliteOrgRepo::new(pool.clone()),
978 memberships: SqliteMembershipRepo::new(pool.clone()),
979 };
980
981 let org = saved_org(&repos, "acme").await;
982 let owner = saved_user(&repos, "owner@example.com", &org).await;
983 let other = saved_user(&repos, "other@example.com", &org).await;
984
985 (SqliteTokenRepo::new(pool), owner, other)
986 }
987
988 fn issued(user: &User, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
989 let secret = TokenSecret::generate();
990 let token = PersonalAccessToken::new(
991 TokenId::generate(),
992 user.id.clone(),
993 name,
994 &secret,
995 SystemTime::UNIX_EPOCH + Duration::from_secs(at),
996 )
997 .expect("valid token");
998
999 (token, secret)
1000 }
1001
1002 #[tokio::test]
1003 async fn a_token_round_trips_through_its_hash() {
1004 let (tokens, owner, _) = token_fixture().await;
1005 let (token, secret) = issued(&owner, "laptop", 1_000);
1006 tokens.save(&token).await.expect("save");
1007
1008 let found = tokens
1009 .find_by_hash(&secret.hash())
1010 .await
1011 .expect("lookup")
1012 .expect("should be found");
1013
1014 assert_eq!(found, token);
1015 assert_eq!(found.name, "laptop");
1016 assert_eq!(found.prefix, secret.display_prefix());
1017 }
1018
1019 #[tokio::test]
1020 async fn an_unknown_hash_finds_nothing() {
1021 let (tokens, owner, _) = token_fixture().await;
1022 let (token, _) = issued(&owner, "laptop", 1_000);
1023 tokens.save(&token).await.expect("save");
1024
1025 let found = tokens
1026 .find_by_hash(&TokenSecret::generate().hash())
1027 .await
1028 .expect("lookup");
1029
1030 assert!(found.is_none());
1031 }
1032
1033 #[tokio::test]
1034 async fn tokens_are_listed_newest_first_and_only_the_users_own() {
1035 let (tokens, owner, other) = token_fixture().await;
1036
1037 for (user, name, at) in [
1038 (&owner, "old", 1_000),
1039 (&owner, "new", 3_000),
1040 (&owner, "middle", 2_000),
1041 (&other, "theirs", 4_000),
1042 ] {
1043 let (token, _) = issued(user, name, at);
1044 tokens.save(&token).await.expect("save");
1045 }
1046
1047 let listed = tokens.list_by_user(&owner.id).await.expect("list");
1048
1049 assert_eq!(
1050 listed
1051 .iter()
1052 .map(|token| token.name.as_str())
1053 .collect::<Vec<_>>(),
1054 vec!["new", "middle", "old"]
1055 );
1056 }
1057
1058 #[tokio::test]
1059 async fn deleting_a_token_stops_it_authenticating() {
1060 // Revocation is a delete, so the credential is gone rather than flagged.
1061 let (tokens, owner, _) = token_fixture().await;
1062 let (token, secret) = issued(&owner, "laptop", 1_000);
1063 tokens.save(&token).await.expect("save");
1064
1065 tokens.delete(&token.id).await.expect("delete");
1066
1067 assert!(
1068 tokens
1069 .find_by_hash(&secret.hash())
1070 .await
1071 .expect("lookup")
1072 .is_none()
1073 );
1074 }
1075
1076 #[tokio::test]
1077 async fn deleting_a_token_that_is_not_there_succeeds() {
1078 let (tokens, _, _) = token_fixture().await;
1079
1080 tokens
1081 .delete(&TokenId::generate())
1082 .await
1083 .expect("deleting nothing should not fail");
1084 }
1085
1086 #[tokio::test]
1087 async fn a_second_token_with_the_same_hash_is_refused() {
1088 // The unique constraint. Two rows answering one credential would make which
1089 // user a token authenticates depend on row order.
1090 let (tokens, owner, other) = token_fixture().await;
1091 let (first, secret) = issued(&owner, "laptop", 1_000);
1092 tokens.save(&first).await.expect("save");
1093
1094 let clash = PersonalAccessToken::new(
1095 TokenId::generate(),
1096 other.id.clone(),
1097 "clash",
1098 &secret,
1099 SystemTime::UNIX_EPOCH,
1100 )
1101 .expect("valid token");
1102
1103 assert!(tokens.save(&clash).await.is_err());
1104 }
1105
1106 #[tokio::test]
1107 async fn a_token_belonging_to_no_user_is_refused() {
1108 // The foreign key. Without `foreign_keys(true)` per connection SQLite ignores it.
1109 let (tokens, _, _) = token_fixture().await;
1110 let secret = TokenSecret::generate();
1111 let orphan = PersonalAccessToken::new(
1112 TokenId::generate(),
1113 UserId::generate(),
1114 "orphan",
1115 &secret,
1116 SystemTime::UNIX_EPOCH,
1117 )
1118 .expect("valid token");
1119
1120 assert!(tokens.save(&orphan).await.is_err());
1121 }
1122}