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
393#[derive(Debug, Clone)]
394pub struct SqliteTokenRepo {
395 pool: SqlitePool,
396}
397
398impl SqliteTokenRepo {
399 pub fn new(pool: SqlitePool) -> Self {
400 Self { pool }
401 }
402
403 /// `from_trusted`, because these values were validated on the way in. Revalidating
404 /// stored rows means a tightened rule turns old rows unreadable.
405 fn hydrate(row: &SqliteRow) -> PersonalAccessToken {
406 PersonalAccessToken::from_trusted(
407 TokenId::from_trusted(row.get::<String, _>("id")),
408 UserId::from_trusted(row.get::<String, _>("user_id")),
409 row.get::<String, _>("name"),
410 row.get::<String, _>("prefix"),
411 TokenHash::from_trusted(row.get::<String, _>("token_hash")),
412 from_unix(row.get::<i64, _>("created_at")),
413 )
414 }
415}
416
417impl TokenRepository for SqliteTokenRepo {
418 async fn find_by_hash(
419 &self,
420 hash: &TokenHash,
421 ) -> RepositoryResult<Option<PersonalAccessToken>> {
422 // Matched in SQL by the hash, which is safe to compare with `=`: it is a digest,
423 // not the secret. The constant-time comparison guards the value a client
424 // presents, and that never reaches the database.
425 let row = sqlx::query("select * from tokens where token_hash = ?")
426 .bind(hash.as_str())
427 .fetch_optional(&self.pool)
428 .await
429 .map_err(backend)?;
430
431 Ok(row.as_ref().map(Self::hydrate))
432 }
433
434 async fn list_by_user(&self, user_id: &UserId) -> RepositoryResult<Vec<PersonalAccessToken>> {
435 let rows =
436 sqlx::query("select * from tokens where user_id = ? order by created_at desc, id asc")
437 .bind(user_id.as_str())
438 .fetch_all(&self.pool)
439 .await
440 .map_err(backend)?;
441
442 Ok(rows.iter().map(Self::hydrate).collect())
443 }
444
445 async fn save(&self, token: &PersonalAccessToken) -> RepositoryResult<()> {
446 sqlx::query(
447 "insert into tokens (id, user_id, name, prefix, token_hash, created_at)
448 values (?, ?, ?, ?, ?, ?)
449 on conflict (id) do update set
450 name = excluded.name",
451 )
452 .bind(token.id.as_str())
453 .bind(token.user_id.as_str())
454 .bind(&token.name)
455 .bind(&token.prefix)
456 .bind(token.token_hash.as_str())
457 .bind(to_unix(token.created_at))
458 .execute(&self.pool)
459 .await
460 .map_err(backend)?;
461
462 Ok(())
463 }
464
465 async fn delete(&self, id: &TokenId) -> RepositoryResult<()> {
466 sqlx::query("delete from tokens where id = ?")
467 .bind(id.as_str())
468 .execute(&self.pool)
469 .await
470 .map_err(backend)?;
471
472 Ok(())
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::{
480 application::{OwnerSpec, claim_instance, port::PasswordHasher},
481 domain::{SetupToken, TokenSecret},
482 infrastructure::{database::test_support::test_pool, password::StubHasher},
483 };
484
485 struct Repos {
486 users: SqliteUserRepo,
487 orgs: SqliteOrgRepo,
488 memberships: SqliteMembershipRepo,
489 }
490
491 async fn repos() -> Repos {
492 let pool = test_pool().await;
493 Repos {
494 users: SqliteUserRepo::new(pool.clone()),
495 orgs: SqliteOrgRepo::new(pool.clone()),
496 memberships: SqliteMembershipRepo::new(pool),
497 }
498 }
499
500 async fn saved_org(repos: &Repos, name: &str) -> Organization {
501 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
502 repos.orgs.save(&org).await.expect("save org");
503 org
504 }
505
506 async fn saved_user(repos: &Repos, email: &str, org: &Organization) -> User {
507 let user = User::new(
508 UserId::generate(),
509 Email::new(email).expect("valid email"),
510 PasswordHash::from_trusted("$argon2id$test"),
511 org.id.clone(),
512 );
513 repos.users.save(&user).await.expect("save user");
514 user
515 }
516
517 #[tokio::test]
518 async fn a_saved_user_round_trips() {
519 let repos = repos().await;
520 let org = saved_org(&repos, "james").await;
521 let user = saved_user(&repos, "dev@example.com", &org).await;
522
523 let found = repos
524 .users
525 .find_by_id(&user.id)
526 .await
527 .expect("lookup")
528 .expect("user should exist");
529
530 assert_eq!(found, user);
531 }
532
533 #[tokio::test]
534 async fn users_are_found_by_email_case_insensitively() {
535 let repos = repos().await;
536 let org = saved_org(&repos, "james").await;
537 saved_user(&repos, "dev@example.com", &org).await;
538
539 // Email lowercases on construction, but a row written before that rule would
540 // still need finding.
541 let found = repos
542 .users
543 .find_by_email(&Email::from_trusted("DEV@EXAMPLE.COM"))
544 .await
545 .expect("lookup");
546
547 assert!(found.is_some(), "collate nocase should make this match");
548 }
549
550 #[tokio::test]
551 async fn an_org_round_trips_with_its_display_name() {
552 let repos = repos().await;
553 let org = Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned()))
554 .expect("valid org");
555 repos.orgs.save(&org).await.expect("save");
556
557 let found = repos
558 .orgs
559 .find_by_name(&org.name)
560 .await
561 .expect("lookup")
562 .expect("org should exist");
563
564 assert_eq!(found, org);
565 assert_eq!(found.label(), "Acme");
566 }
567
568 #[tokio::test]
569 async fn a_missing_org_is_none_not_an_error() {
570 let repos = repos().await;
571
572 let found = repos
573 .orgs
574 .find_by_name(&OrgName::new("nobody").unwrap())
575 .await
576 .expect("lookup");
577
578 assert_eq!(found, None);
579 }
580
581 #[tokio::test]
582 async fn a_membership_round_trips_with_its_role() {
583 let repos = repos().await;
584 let org = saved_org(&repos, "james").await;
585 let user = saved_user(&repos, "dev@example.com", &org).await;
586 let membership = Membership::new(
587 MembershipId::generate(),
588 org.id.clone(),
589 user.id.clone(),
590 Role::Owner,
591 );
592 repos.memberships.save(&membership).await.expect("save");
593
594 let found = repos
595 .memberships
596 .find(&org.id, &user.id)
597 .await
598 .expect("lookup")
599 .expect("membership should exist");
600
601 assert_eq!(found, membership);
602 assert!(found.can_write());
603 }
604
605 #[tokio::test]
606 async fn an_unreadable_role_surfaces_rather_than_downgrading_access() {
607 let repos = repos().await;
608 let pool = test_pool().await;
609 let memberships = SqliteMembershipRepo::new(pool.clone());
610 let org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
611 SqliteOrgRepo::new(pool.clone())
612 .save(&org)
613 .await
614 .expect("save org");
615 let user = User::new(
616 UserId::generate(),
617 Email::new("dev@example.com").expect("valid email"),
618 PasswordHash::from_trusted("$argon2id$test"),
619 org.id.clone(),
620 );
621 SqliteUserRepo::new(pool.clone())
622 .save(&user)
623 .await
624 .expect("save user");
625
626 sqlx::query("insert into memberships (id, org_id, user_id, role) values (?, ?, ?, 'wat')")
627 .bind(MembershipId::generate().as_str())
628 .bind(org.id.as_str())
629 .bind(user.id.as_str())
630 .execute(&pool)
631 .await
632 .expect("insert");
633
634 let result = memberships.find(&org.id, &user.id).await;
635
636 assert!(
637 result.is_err(),
638 "a role we can't parse must not read as no membership"
639 );
640 drop(repos);
641 }
642
643 #[tokio::test]
644 async fn saving_a_user_before_its_org_is_refused() {
645 let repos = repos().await;
646 let orphan = User::new(
647 UserId::generate(),
648 Email::new("dev@example.com").expect("valid email"),
649 PasswordHash::from_trusted("$argon2id$test"),
650 OrgId::generate(),
651 );
652
653 let result = repos.users.save(&orphan).await;
654
655 assert!(
656 result.is_err(),
657 "the foreign key should reject a user whose org doesn't exist"
658 );
659 }
660
661 #[tokio::test]
662 async fn a_duplicate_handle_is_refused() {
663 let repos = repos().await;
664 saved_org(&repos, "james").await;
665
666 let clash = Organization::new(OrgId::generate(), "james", None).expect("valid org");
667 let result = repos.orgs.save(&clash).await;
668
669 assert!(result.is_err(), "orgs.name is unique");
670 }
671
672 #[tokio::test]
673 async fn a_differently_cased_handle_is_also_refused() {
674 let repos = repos().await;
675 saved_org(&repos, "james").await;
676
677 // OrgName lowercases, so this can only arrive via from_trusted -- but the
678 // constraint is what we're testing, not the value object.
679 let clash = Organization::from_trusted(
680 OrgId::generate(),
681 OrgName::from_trusted("JAMES"),
682 None,
683 None,
684 );
685 let result = repos.orgs.save(&clash).await;
686
687 assert!(result.is_err(), "collate nocase should catch this");
688 }
689
690 /// The claim use case checks `is_claimed` and then writes, which is TOCTOU. This
691 /// asserts the database is what actually stops a second owner being created.
692 #[tokio::test]
693 async fn a_second_claim_is_stopped_by_the_database_not_the_check() {
694 let repos = repos().await;
695 let token = SetupToken::generate();
696 let hasher = StubHasher::new();
697 let spec = OwnerSpec {
698 handle: "james".to_owned(),
699 email: "dev@example.com".to_owned(),
700 password: "hunter2".to_owned(),
701 };
702
703 claim_instance(
704 token.reveal(),
705 &token,
706 &spec,
707 &repos.users,
708 &repos.orgs,
709 &repos.memberships,
710 &hasher,
711 )
712 .await
713 .expect("first claim");
714
715 // Simulate the race: the second claimant passed is_claimed before the first
716 // committed, so it proceeds straight to the writes.
717 let intruder_org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
718 let result = repos.orgs.save(&intruder_org).await;
719
720 assert!(
721 result.is_err(),
722 "unique(orgs.name) is what actually serialises concurrent claims"
723 );
724
725 let intruder_user = User::new(
726 UserId::generate(),
727 Email::new("dev@example.com").expect("valid email"),
728 hasher.hash("letmein").expect("hash"),
729 OrgId::generate(),
730 );
731 assert!(
732 repos.users.save(&intruder_user).await.is_err(),
733 "unique(users.email) closes the other half"
734 );
735 }
736 async fn org_with(orgs: &SqliteOrgRepo, name: &str) -> Organization {
737 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
738 orgs.save(&org).await.expect("save org");
739 org
740 }
741
742 async fn saved_repo(
743 repos: &SqliteRepoRepo,
744 org: &Organization,
745 name: &str,
746 visibility: Visibility,
747 ) -> Repository {
748 let repo = Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
749 .expect("valid repo");
750 repos.save(&repo).await.expect("save repo");
751 repo
752 }
753
754 #[tokio::test]
755 async fn a_repository_round_trips() {
756 let pool = test_pool().await;
757 let orgs = SqliteOrgRepo::new(pool.clone());
758 let repos = SqliteRepoRepo::new(pool);
759 let org = org_with(&orgs, "acme").await;
760
761 let repo = Repository::new(
762 RepoId::generate(),
763 org.id.clone(),
764 "steid",
765 Some("A gitforge.".to_owned()),
766 Visibility::Private,
767 )
768 .expect("valid repo");
769 repos.save(&repo).await.expect("save");
770
771 let found = repos
772 .find_by_id(&repo.id)
773 .await
774 .expect("lookup")
775 .expect("should exist");
776
777 assert_eq!(found, repo);
778 assert_eq!(found.visibility, Visibility::Private);
779 assert_eq!(found.description.as_deref(), Some("A gitforge."));
780 }
781
782 #[tokio::test]
783 async fn lookup_by_name_is_case_insensitive() {
784 let pool = test_pool().await;
785 let orgs = SqliteOrgRepo::new(pool.clone());
786 let repos = SqliteRepoRepo::new(pool);
787 let org = org_with(&orgs, "acme").await;
788 saved_repo(&repos, &org, "steid", Visibility::Public).await;
789
790 let found = repos
791 .find_by_org_and_name(&org.id, &RepoName::from_trusted("STEID"))
792 .await
793 .expect("lookup");
794
795 assert!(found.is_some(), "collate nocase should make this match");
796 }
797
798 #[tokio::test]
799 async fn one_owner_cannot_have_two_repositories_with_the_same_name() {
800 let pool = test_pool().await;
801 let orgs = SqliteOrgRepo::new(pool.clone());
802 let repos = SqliteRepoRepo::new(pool);
803 let org = org_with(&orgs, "acme").await;
804 saved_repo(&repos, &org, "steid", Visibility::Public).await;
805
806 let clash = Repository::new(
807 RepoId::generate(),
808 org.id.clone(),
809 "steid",
810 None,
811 Visibility::Public,
812 )
813 .expect("valid repo");
814
815 assert!(repos.save(&clash).await.is_err(), "unique (org_id, name)");
816 }
817
818 #[tokio::test]
819 async fn two_owners_may_each_have_a_repository_of_the_same_name() {
820 let pool = test_pool().await;
821 let orgs = SqliteOrgRepo::new(pool.clone());
822 let repos = SqliteRepoRepo::new(pool);
823 let first = org_with(&orgs, "acme").await;
824 let second = org_with(&orgs, "globex").await;
825
826 saved_repo(&repos, &first, "steid", Visibility::Public).await;
827 saved_repo(&repos, &second, "steid", Visibility::Public).await;
828
829 // The constraint is a pair. On `name` alone, repository names would be globally
830 // unique across the whole installation.
831 assert_eq!(repos.list_by_org(&first.id).await.expect("list").len(), 1);
832 assert_eq!(repos.list_by_org(&second.id).await.expect("list").len(), 1);
833 }
834
835 #[tokio::test]
836 async fn a_repository_owned_by_a_missing_org_is_refused() {
837 let pool = test_pool().await;
838 let repos = SqliteRepoRepo::new(pool);
839 let orphan = Repository::new(
840 RepoId::generate(),
841 OrgId::generate(),
842 "steid",
843 None,
844 Visibility::Public,
845 )
846 .expect("valid repo");
847
848 assert!(repos.save(&orphan).await.is_err(), "foreign key");
849 }
850
851 #[tokio::test]
852 async fn an_unreadable_visibility_surfaces_rather_than_defaulting() {
853 let pool = test_pool().await;
854 let orgs = SqliteOrgRepo::new(pool.clone());
855 let repos = SqliteRepoRepo::new(pool.clone());
856 let org = org_with(&orgs, "acme").await;
857
858 sqlx::query(
859 "insert into repositories (id, org_id, name, visibility)
860 values ('r1', ?, 'secret', 'internal')",
861 )
862 .bind(org.id.as_str())
863 .execute(&pool)
864 .await
865 .expect("insert");
866
867 assert!(
868 repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err(),
869 "a visibility we cannot parse must not become a guess"
870 );
871 }
872
873 #[tokio::test]
874 async fn listing_returns_only_that_org_ordered_by_name() {
875 let pool = test_pool().await;
876 let orgs = SqliteOrgRepo::new(pool.clone());
877 let repos = SqliteRepoRepo::new(pool);
878 let mine = org_with(&orgs, "acme").await;
879 let theirs = org_with(&orgs, "globex").await;
880
881 for name in ["zebra", "alpha", "middle"] {
882 saved_repo(&repos, &mine, name, Visibility::Public).await;
883 }
884 saved_repo(&repos, &theirs, "not-mine", Visibility::Public).await;
885
886 let listed = repos.list_by_org(&mine.id).await.expect("list");
887
888 let names: Vec<&str> = listed.iter().map(|repo| repo.name.as_str()).collect();
889 assert_eq!(names, vec!["alpha", "middle", "zebra"]);
890 }
891
892 #[tokio::test]
893 async fn listing_includes_private_repositories() {
894 let pool = test_pool().await;
895 let orgs = SqliteOrgRepo::new(pool.clone());
896 let repos = SqliteRepoRepo::new(pool);
897 let org = org_with(&orgs, "acme").await;
898 saved_repo(&repos, &org, "secret", Visibility::Private).await;
899
900 // The port returns everything; filtering by visibility is the use case's job, so
901 // the page and /api cannot end up applying different rules.
902 assert_eq!(repos.list_by_org(&org.id).await.expect("list").len(), 1);
903 }
904
905 #[tokio::test]
906 async fn a_missing_repository_is_none_not_an_error() {
907 let pool = test_pool().await;
908 let repos = SqliteRepoRepo::new(pool);
909
910 let found = repos
911 .find_by_id(&RepoId::generate())
912 .await
913 .expect("lookup should not error");
914
915 assert_eq!(found, None);
916 }
917
918 // --- SqliteTokenRepo ---------------------------------------------------------
919
920 /// A pool plus a user to hang tokens off, since the foreign key runs that way.
921 async fn token_fixture() -> (SqliteTokenRepo, User, User) {
922 let pool = test_pool().await;
923 let repos = Repos {
924 users: SqliteUserRepo::new(pool.clone()),
925 orgs: SqliteOrgRepo::new(pool.clone()),
926 memberships: SqliteMembershipRepo::new(pool.clone()),
927 };
928
929 let org = saved_org(&repos, "acme").await;
930 let owner = saved_user(&repos, "owner@example.com", &org).await;
931 let other = saved_user(&repos, "other@example.com", &org).await;
932
933 (SqliteTokenRepo::new(pool), owner, other)
934 }
935
936 fn issued(user: &User, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
937 let secret = TokenSecret::generate();
938 let token = PersonalAccessToken::new(
939 TokenId::generate(),
940 user.id.clone(),
941 name,
942 &secret,
943 SystemTime::UNIX_EPOCH + Duration::from_secs(at),
944 )
945 .expect("valid token");
946
947 (token, secret)
948 }
949
950 #[tokio::test]
951 async fn a_token_round_trips_through_its_hash() {
952 let (tokens, owner, _) = token_fixture().await;
953 let (token, secret) = issued(&owner, "laptop", 1_000);
954 tokens.save(&token).await.expect("save");
955
956 let found = tokens
957 .find_by_hash(&secret.hash())
958 .await
959 .expect("lookup")
960 .expect("should be found");
961
962 assert_eq!(found, token);
963 assert_eq!(found.name, "laptop");
964 assert_eq!(found.prefix, secret.display_prefix());
965 }
966
967 #[tokio::test]
968 async fn an_unknown_hash_finds_nothing() {
969 let (tokens, owner, _) = token_fixture().await;
970 let (token, _) = issued(&owner, "laptop", 1_000);
971 tokens.save(&token).await.expect("save");
972
973 let found = tokens
974 .find_by_hash(&TokenSecret::generate().hash())
975 .await
976 .expect("lookup");
977
978 assert!(found.is_none());
979 }
980
981 #[tokio::test]
982 async fn tokens_are_listed_newest_first_and_only_the_users_own() {
983 let (tokens, owner, other) = token_fixture().await;
984
985 for (user, name, at) in [
986 (&owner, "old", 1_000),
987 (&owner, "new", 3_000),
988 (&owner, "middle", 2_000),
989 (&other, "theirs", 4_000),
990 ] {
991 let (token, _) = issued(user, name, at);
992 tokens.save(&token).await.expect("save");
993 }
994
995 let listed = tokens.list_by_user(&owner.id).await.expect("list");
996
997 assert_eq!(
998 listed
999 .iter()
1000 .map(|token| token.name.as_str())
1001 .collect::<Vec<_>>(),
1002 vec!["new", "middle", "old"]
1003 );
1004 }
1005
1006 #[tokio::test]
1007 async fn deleting_a_token_stops_it_authenticating() {
1008 // Revocation is a delete, so the credential is gone rather than flagged.
1009 let (tokens, owner, _) = token_fixture().await;
1010 let (token, secret) = issued(&owner, "laptop", 1_000);
1011 tokens.save(&token).await.expect("save");
1012
1013 tokens.delete(&token.id).await.expect("delete");
1014
1015 assert!(
1016 tokens
1017 .find_by_hash(&secret.hash())
1018 .await
1019 .expect("lookup")
1020 .is_none()
1021 );
1022 }
1023
1024 #[tokio::test]
1025 async fn deleting_a_token_that_is_not_there_succeeds() {
1026 let (tokens, _, _) = token_fixture().await;
1027
1028 tokens
1029 .delete(&TokenId::generate())
1030 .await
1031 .expect("deleting nothing should not fail");
1032 }
1033
1034 #[tokio::test]
1035 async fn a_second_token_with_the_same_hash_is_refused() {
1036 // The unique constraint. Two rows answering one credential would make which
1037 // user a token authenticates depend on row order.
1038 let (tokens, owner, other) = token_fixture().await;
1039 let (first, secret) = issued(&owner, "laptop", 1_000);
1040 tokens.save(&first).await.expect("save");
1041
1042 let clash = PersonalAccessToken::new(
1043 TokenId::generate(),
1044 other.id.clone(),
1045 "clash",
1046 &secret,
1047 SystemTime::UNIX_EPOCH,
1048 )
1049 .expect("valid token");
1050
1051 assert!(tokens.save(&clash).await.is_err());
1052 }
1053
1054 #[tokio::test]
1055 async fn a_token_belonging_to_no_user_is_refused() {
1056 // The foreign key. Without `foreign_keys(true)` per connection SQLite ignores it.
1057 let (tokens, _, _) = token_fixture().await;
1058 let secret = TokenSecret::generate();
1059 let orphan = PersonalAccessToken::new(
1060 TokenId::generate(),
1061 UserId::generate(),
1062 "orphan",
1063 &secret,
1064 SystemTime::UNIX_EPOCH,
1065 )
1066 .expect("valid token");
1067
1068 assert!(tokens.save(&orphan).await.is_err());
1069 }
1070}