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, RepoId, RepoName,
12 Repository, Role, Session, SessionTokenHash, User, UserId, Visibility,
13 repository::{
14 MembershipRepository, OrgRepository, RepoRepository, RepositoryError, RepositoryResult,
15 SessionRepository, UserRepository,
16 },
17};
18
19fn backend(error: sqlx::Error) -> RepositoryError {
20 RepositoryError::backend(error)
21}
22
23#[derive(Debug, Clone)]
24pub struct SqliteUserRepo {
25 pool: SqlitePool,
26}
27
28impl SqliteUserRepo {
29 pub fn new(pool: SqlitePool) -> Self {
30 Self { pool }
31 }
32
33 fn map(row: &SqliteRow) -> User {
34 User::new(
35 UserId::from_trusted(row.get::<String, _>("id")),
36 Email::from_trusted(row.get::<String, _>("email")),
37 PasswordHash::from_trusted(row.get::<String, _>("password_hash")),
38 OrgId::from_trusted(row.get::<String, _>("personal_org_id")),
39 )
40 }
41}
42
43impl UserRepository for SqliteUserRepo {
44 async fn find_by_id(&self, id: &UserId) -> RepositoryResult<Option<User>> {
45 let row = sqlx::query("select * from users where id = ?")
46 .bind(id.as_str())
47 .fetch_optional(&self.pool)
48 .await
49 .map_err(backend)?;
50
51 Ok(row.as_ref().map(Self::map))
52 }
53
54 async fn find_by_email(&self, email: &Email) -> RepositoryResult<Option<User>> {
55 let row = sqlx::query("select * from users where email = ?")
56 .bind(email.as_str())
57 .fetch_optional(&self.pool)
58 .await
59 .map_err(backend)?;
60
61 Ok(row.as_ref().map(Self::map))
62 }
63
64 async fn save(&self, user: &User) -> RepositoryResult<()> {
65 sqlx::query(
66 "insert into users (id, email, password_hash, personal_org_id)
67 values (?, ?, ?, ?)
68 on conflict (id) do update set
69 email = excluded.email,
70 password_hash = excluded.password_hash,
71 personal_org_id = excluded.personal_org_id",
72 )
73 .bind(user.id.as_str())
74 .bind(user.email.as_str())
75 .bind(user.password_hash.as_str())
76 .bind(user.personal_org_id.as_str())
77 .execute(&self.pool)
78 .await
79 .map_err(backend)?;
80
81 Ok(())
82 }
83
84 async fn any_exist(&self) -> RepositoryResult<bool> {
85 let count: i64 = sqlx::query_scalar("select exists (select 1 from users)")
86 .fetch_one(&self.pool)
87 .await
88 .map_err(backend)?;
89
90 Ok(count != 0)
91 }
92}
93
94#[derive(Debug, Clone)]
95pub struct SqliteOrgRepo {
96 pool: SqlitePool,
97}
98
99impl SqliteOrgRepo {
100 pub fn new(pool: SqlitePool) -> Self {
101 Self { pool }
102 }
103
104 fn map(row: &SqliteRow) -> Organization {
105 Organization::from_trusted(
106 OrgId::from_trusted(row.get::<String, _>("id")),
107 OrgName::from_trusted(row.get::<String, _>("name")),
108 row.get::<Option<String>, _>("display_name"),
109 row.get::<Option<String>, _>("bio"),
110 )
111 }
112}
113
114impl OrgRepository for SqliteOrgRepo {
115 async fn find_by_id(&self, id: &OrgId) -> RepositoryResult<Option<Organization>> {
116 let row = sqlx::query("select * from orgs where id = ?")
117 .bind(id.as_str())
118 .fetch_optional(&self.pool)
119 .await
120 .map_err(backend)?;
121
122 Ok(row.as_ref().map(Self::map))
123 }
124
125 async fn find_by_name(&self, name: &OrgName) -> RepositoryResult<Option<Organization>> {
126 let row = sqlx::query("select * from orgs where name = ?")
127 .bind(name.as_str())
128 .fetch_optional(&self.pool)
129 .await
130 .map_err(backend)?;
131
132 Ok(row.as_ref().map(Self::map))
133 }
134
135 async fn save(&self, org: &Organization) -> RepositoryResult<()> {
136 sqlx::query(
137 "insert into orgs (id, name, display_name, bio)
138 values (?, ?, ?, ?)
139 on conflict (id) do update set
140 name = excluded.name,
141 display_name = excluded.display_name,
142 bio = excluded.bio",
143 )
144 .bind(org.id.as_str())
145 .bind(org.name.as_str())
146 .bind(org.display_name.as_deref())
147 .bind(org.bio.as_deref())
148 .execute(&self.pool)
149 .await
150 .map_err(backend)?;
151
152 Ok(())
153 }
154}
155
156#[derive(Debug, Clone)]
157pub struct SqliteMembershipRepo {
158 pool: SqlitePool,
159}
160
161impl SqliteMembershipRepo {
162 pub fn new(pool: SqlitePool) -> Self {
163 Self { pool }
164 }
165
166 /// An unparseable role is a storage fault, not a missing membership, so it
167 /// surfaces rather than silently downgrading the member's access.
168 fn map(row: &SqliteRow) -> RepositoryResult<Membership> {
169 let raw: String = row.get("role");
170 let role: Role = raw
171 .parse()
172 .map_err(|error| RepositoryError::backend(format!("{error}")))?;
173
174 Ok(Membership::new(
175 MembershipId::from_trusted(row.get::<String, _>("id")),
176 OrgId::from_trusted(row.get::<String, _>("org_id")),
177 UserId::from_trusted(row.get::<String, _>("user_id")),
178 role,
179 ))
180 }
181}
182
183impl MembershipRepository for SqliteMembershipRepo {
184 async fn find(&self, org_id: &OrgId, user_id: &UserId) -> RepositoryResult<Option<Membership>> {
185 let row = sqlx::query("select * from memberships where org_id = ? and user_id = ?")
186 .bind(org_id.as_str())
187 .bind(user_id.as_str())
188 .fetch_optional(&self.pool)
189 .await
190 .map_err(backend)?;
191
192 row.as_ref().map(Self::map).transpose()
193 }
194
195 async fn list_for_user(&self, user_id: &UserId) -> RepositoryResult<Vec<Membership>> {
196 let rows = sqlx::query("select * from memberships where user_id = ?")
197 .bind(user_id.as_str())
198 .fetch_all(&self.pool)
199 .await
200 .map_err(backend)?;
201
202 rows.iter().map(Self::map).collect()
203 }
204
205 async fn save(&self, membership: &Membership) -> RepositoryResult<()> {
206 sqlx::query(
207 "insert into memberships (id, org_id, user_id, role)
208 values (?, ?, ?, ?)
209 on conflict (id) do update set role = excluded.role",
210 )
211 .bind(membership.id.as_str())
212 .bind(membership.org_id.as_str())
213 .bind(membership.user_id.as_str())
214 .bind(membership.role.as_str())
215 .execute(&self.pool)
216 .await
217 .map_err(backend)?;
218
219 Ok(())
220 }
221}
222
223#[derive(Debug, Clone)]
224pub struct SqliteSessionRepo {
225 pool: SqlitePool,
226}
227
228impl SqliteSessionRepo {
229 pub fn new(pool: SqlitePool) -> Self {
230 Self { pool }
231 }
232}
233
234/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in
235/// the future — so saturating at 0 is safe rather than lossy.
236fn to_unix(time: SystemTime) -> i64 {
237 time.duration_since(SystemTime::UNIX_EPOCH)
238 .map(|d| d.as_secs() as i64)
239 .unwrap_or(0)
240}
241
242fn from_unix(seconds: i64) -> SystemTime {
243 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
244}
245
246impl SessionRepository for SqliteSessionRepo {
247 async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
248 let row = sqlx::query("select * from sessions where token_hash = ?")
249 .bind(token_hash.as_str())
250 .fetch_optional(&self.pool)
251 .await
252 .map_err(backend)?;
253
254 Ok(row.map(|row| {
255 Session::new(
256 SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")),
257 UserId::from_trusted(row.get::<String, _>("user_id")),
258 from_unix(row.get::<i64, _>("expires_at")),
259 )
260 }))
261 }
262
263 async fn save(&self, session: &Session) -> RepositoryResult<()> {
264 sqlx::query(
265 "insert into sessions (token_hash, user_id, expires_at)
266 values (?, ?, ?)
267 on conflict (token_hash) do update set
268 user_id = excluded.user_id,
269 expires_at = excluded.expires_at",
270 )
271 .bind(session.token_hash.as_str())
272 .bind(session.user_id.as_str())
273 .bind(to_unix(session.expires_at))
274 .execute(&self.pool)
275 .await
276 .map_err(backend)?;
277
278 Ok(())
279 }
280
281 async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
282 sqlx::query("delete from sessions where token_hash = ?")
283 .bind(token_hash.as_str())
284 .execute(&self.pool)
285 .await
286 .map_err(backend)?;
287
288 Ok(())
289 }
290
291 async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
292 let result = sqlx::query("delete from sessions where expires_at <= ?")
293 .bind(to_unix(now))
294 .execute(&self.pool)
295 .await
296 .map_err(backend)?;
297
298 Ok(result.rows_affected())
299 }
300}
301
302#[derive(Debug, Clone)]
303pub struct SqliteRepoRepo {
304 pool: SqlitePool,
305}
306
307impl SqliteRepoRepo {
308 pub fn new(pool: SqlitePool) -> Self {
309 Self { pool }
310 }
311
312 /// An unparseable visibility is a storage fault, not a default.
313 ///
314 /// Falling back to `Public` would publish a repository whose row we cannot read;
315 /// falling back to `Private` would hide a public one. Neither is a guess worth
316 /// making, so the row surfaces as an error.
317 fn map(row: &SqliteRow) -> RepositoryResult<Repository> {
318 let raw: String = row.get("visibility");
319 let visibility: Visibility = raw
320 .parse()
321 .map_err(|error| RepositoryError::backend(format!("{error}")))?;
322
323 Ok(Repository::from_trusted(
324 RepoId::from_trusted(row.get::<String, _>("id")),
325 OrgId::from_trusted(row.get::<String, _>("org_id")),
326 RepoName::from_trusted(row.get::<String, _>("name")),
327 row.get::<Option<String>, _>("description"),
328 visibility,
329 ))
330 }
331}
332
333impl RepoRepository for SqliteRepoRepo {
334 async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
335 let row = sqlx::query("select * from repositories where id = ?")
336 .bind(id.as_str())
337 .fetch_optional(&self.pool)
338 .await
339 .map_err(backend)?;
340
341 row.as_ref().map(Self::map).transpose()
342 }
343
344 async fn find_by_org_and_name(
345 &self,
346 org_id: &OrgId,
347 name: &RepoName,
348 ) -> RepositoryResult<Option<Repository>> {
349 let row = sqlx::query("select * from repositories where org_id = ? and name = ?")
350 .bind(org_id.as_str())
351 .bind(name.as_str())
352 .fetch_optional(&self.pool)
353 .await
354 .map_err(backend)?;
355
356 row.as_ref().map(Self::map).transpose()
357 }
358
359 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
360 let rows = sqlx::query("select * from repositories where org_id = ? order by name")
361 .bind(org_id.as_str())
362 .fetch_all(&self.pool)
363 .await
364 .map_err(backend)?;
365
366 rows.iter().map(Self::map).collect()
367 }
368
369 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
370 sqlx::query(
371 "insert into repositories (id, org_id, name, description, visibility)
372 values (?, ?, ?, ?, ?)
373 on conflict (id) do update set
374 org_id = excluded.org_id,
375 name = excluded.name,
376 description = excluded.description,
377 visibility = excluded.visibility",
378 )
379 .bind(repo.id.as_str())
380 .bind(repo.org_id.as_str())
381 .bind(repo.name.as_str())
382 .bind(repo.description.as_deref())
383 .bind(repo.visibility.as_str())
384 .execute(&self.pool)
385 .await
386 .map_err(backend)?;
387
388 Ok(())
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::{
396 application::{OwnerSpec, claim_instance, port::PasswordHasher},
397 domain::SetupToken,
398 infrastructure::{database::test_support::test_pool, password::StubHasher},
399 };
400
401 struct Repos {
402 users: SqliteUserRepo,
403 orgs: SqliteOrgRepo,
404 memberships: SqliteMembershipRepo,
405 }
406
407 async fn repos() -> Repos {
408 let pool = test_pool().await;
409 Repos {
410 users: SqliteUserRepo::new(pool.clone()),
411 orgs: SqliteOrgRepo::new(pool.clone()),
412 memberships: SqliteMembershipRepo::new(pool),
413 }
414 }
415
416 async fn saved_org(repos: &Repos, name: &str) -> Organization {
417 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
418 repos.orgs.save(&org).await.expect("save org");
419 org
420 }
421
422 async fn saved_user(repos: &Repos, email: &str, org: &Organization) -> User {
423 let user = User::new(
424 UserId::generate(),
425 Email::new(email).expect("valid email"),
426 PasswordHash::from_trusted("$argon2id$test"),
427 org.id.clone(),
428 );
429 repos.users.save(&user).await.expect("save user");
430 user
431 }
432
433 #[tokio::test]
434 async fn a_saved_user_round_trips() {
435 let repos = repos().await;
436 let org = saved_org(&repos, "james").await;
437 let user = saved_user(&repos, "dev@example.com", &org).await;
438
439 let found = repos
440 .users
441 .find_by_id(&user.id)
442 .await
443 .expect("lookup")
444 .expect("user should exist");
445
446 assert_eq!(found, user);
447 }
448
449 #[tokio::test]
450 async fn users_are_found_by_email_case_insensitively() {
451 let repos = repos().await;
452 let org = saved_org(&repos, "james").await;
453 saved_user(&repos, "dev@example.com", &org).await;
454
455 // Email lowercases on construction, but a row written before that rule would
456 // still need finding.
457 let found = repos
458 .users
459 .find_by_email(&Email::from_trusted("DEV@EXAMPLE.COM"))
460 .await
461 .expect("lookup");
462
463 assert!(found.is_some(), "collate nocase should make this match");
464 }
465
466 #[tokio::test]
467 async fn an_org_round_trips_with_its_display_name() {
468 let repos = repos().await;
469 let org = Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned()))
470 .expect("valid org");
471 repos.orgs.save(&org).await.expect("save");
472
473 let found = repos
474 .orgs
475 .find_by_name(&org.name)
476 .await
477 .expect("lookup")
478 .expect("org should exist");
479
480 assert_eq!(found, org);
481 assert_eq!(found.label(), "Acme");
482 }
483
484 #[tokio::test]
485 async fn a_missing_org_is_none_not_an_error() {
486 let repos = repos().await;
487
488 let found = repos
489 .orgs
490 .find_by_name(&OrgName::new("nobody").unwrap())
491 .await
492 .expect("lookup");
493
494 assert_eq!(found, None);
495 }
496
497 #[tokio::test]
498 async fn a_membership_round_trips_with_its_role() {
499 let repos = repos().await;
500 let org = saved_org(&repos, "james").await;
501 let user = saved_user(&repos, "dev@example.com", &org).await;
502 let membership = Membership::new(
503 MembershipId::generate(),
504 org.id.clone(),
505 user.id.clone(),
506 Role::Owner,
507 );
508 repos.memberships.save(&membership).await.expect("save");
509
510 let found = repos
511 .memberships
512 .find(&org.id, &user.id)
513 .await
514 .expect("lookup")
515 .expect("membership should exist");
516
517 assert_eq!(found, membership);
518 assert!(found.can_write());
519 }
520
521 #[tokio::test]
522 async fn an_unreadable_role_surfaces_rather_than_downgrading_access() {
523 let repos = repos().await;
524 let pool = test_pool().await;
525 let memberships = SqliteMembershipRepo::new(pool.clone());
526 let org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
527 SqliteOrgRepo::new(pool.clone())
528 .save(&org)
529 .await
530 .expect("save org");
531 let user = User::new(
532 UserId::generate(),
533 Email::new("dev@example.com").expect("valid email"),
534 PasswordHash::from_trusted("$argon2id$test"),
535 org.id.clone(),
536 );
537 SqliteUserRepo::new(pool.clone())
538 .save(&user)
539 .await
540 .expect("save user");
541
542 sqlx::query("insert into memberships (id, org_id, user_id, role) values (?, ?, ?, 'wat')")
543 .bind(MembershipId::generate().as_str())
544 .bind(org.id.as_str())
545 .bind(user.id.as_str())
546 .execute(&pool)
547 .await
548 .expect("insert");
549
550 let result = memberships.find(&org.id, &user.id).await;
551
552 assert!(
553 result.is_err(),
554 "a role we can't parse must not read as no membership"
555 );
556 drop(repos);
557 }
558
559 #[tokio::test]
560 async fn saving_a_user_before_its_org_is_refused() {
561 let repos = repos().await;
562 let orphan = User::new(
563 UserId::generate(),
564 Email::new("dev@example.com").expect("valid email"),
565 PasswordHash::from_trusted("$argon2id$test"),
566 OrgId::generate(),
567 );
568
569 let result = repos.users.save(&orphan).await;
570
571 assert!(
572 result.is_err(),
573 "the foreign key should reject a user whose org doesn't exist"
574 );
575 }
576
577 #[tokio::test]
578 async fn a_duplicate_handle_is_refused() {
579 let repos = repos().await;
580 saved_org(&repos, "james").await;
581
582 let clash = Organization::new(OrgId::generate(), "james", None).expect("valid org");
583 let result = repos.orgs.save(&clash).await;
584
585 assert!(result.is_err(), "orgs.name is unique");
586 }
587
588 #[tokio::test]
589 async fn a_differently_cased_handle_is_also_refused() {
590 let repos = repos().await;
591 saved_org(&repos, "james").await;
592
593 // OrgName lowercases, so this can only arrive via from_trusted -- but the
594 // constraint is what we're testing, not the value object.
595 let clash = Organization::from_trusted(
596 OrgId::generate(),
597 OrgName::from_trusted("JAMES"),
598 None,
599 None,
600 );
601 let result = repos.orgs.save(&clash).await;
602
603 assert!(result.is_err(), "collate nocase should catch this");
604 }
605
606 /// The claim use case checks `is_claimed` and then writes, which is TOCTOU. This
607 /// asserts the database is what actually stops a second owner being created.
608 #[tokio::test]
609 async fn a_second_claim_is_stopped_by_the_database_not_the_check() {
610 let repos = repos().await;
611 let token = SetupToken::generate();
612 let hasher = StubHasher::new();
613 let spec = OwnerSpec {
614 handle: "james".to_owned(),
615 email: "dev@example.com".to_owned(),
616 password: "hunter2".to_owned(),
617 };
618
619 claim_instance(
620 token.reveal(),
621 &token,
622 &spec,
623 &repos.users,
624 &repos.orgs,
625 &repos.memberships,
626 &hasher,
627 )
628 .await
629 .expect("first claim");
630
631 // Simulate the race: the second claimant passed is_claimed before the first
632 // committed, so it proceeds straight to the writes.
633 let intruder_org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
634 let result = repos.orgs.save(&intruder_org).await;
635
636 assert!(
637 result.is_err(),
638 "unique(orgs.name) is what actually serialises concurrent claims"
639 );
640
641 let intruder_user = User::new(
642 UserId::generate(),
643 Email::new("dev@example.com").expect("valid email"),
644 hasher.hash("letmein").expect("hash"),
645 OrgId::generate(),
646 );
647 assert!(
648 repos.users.save(&intruder_user).await.is_err(),
649 "unique(users.email) closes the other half"
650 );
651 }
652 async fn org_with(orgs: &SqliteOrgRepo, name: &str) -> Organization {
653 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
654 orgs.save(&org).await.expect("save org");
655 org
656 }
657
658 async fn saved_repo(
659 repos: &SqliteRepoRepo,
660 org: &Organization,
661 name: &str,
662 visibility: Visibility,
663 ) -> Repository {
664 let repo = Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
665 .expect("valid repo");
666 repos.save(&repo).await.expect("save repo");
667 repo
668 }
669
670 #[tokio::test]
671 async fn a_repository_round_trips() {
672 let pool = test_pool().await;
673 let orgs = SqliteOrgRepo::new(pool.clone());
674 let repos = SqliteRepoRepo::new(pool);
675 let org = org_with(&orgs, "acme").await;
676
677 let repo = Repository::new(
678 RepoId::generate(),
679 org.id.clone(),
680 "steid",
681 Some("A gitforge.".to_owned()),
682 Visibility::Private,
683 )
684 .expect("valid repo");
685 repos.save(&repo).await.expect("save");
686
687 let found = repos
688 .find_by_id(&repo.id)
689 .await
690 .expect("lookup")
691 .expect("should exist");
692
693 assert_eq!(found, repo);
694 assert_eq!(found.visibility, Visibility::Private);
695 assert_eq!(found.description.as_deref(), Some("A gitforge."));
696 }
697
698 #[tokio::test]
699 async fn lookup_by_name_is_case_insensitive() {
700 let pool = test_pool().await;
701 let orgs = SqliteOrgRepo::new(pool.clone());
702 let repos = SqliteRepoRepo::new(pool);
703 let org = org_with(&orgs, "acme").await;
704 saved_repo(&repos, &org, "steid", Visibility::Public).await;
705
706 let found = repos
707 .find_by_org_and_name(&org.id, &RepoName::from_trusted("STEID"))
708 .await
709 .expect("lookup");
710
711 assert!(found.is_some(), "collate nocase should make this match");
712 }
713
714 #[tokio::test]
715 async fn one_owner_cannot_have_two_repositories_with_the_same_name() {
716 let pool = test_pool().await;
717 let orgs = SqliteOrgRepo::new(pool.clone());
718 let repos = SqliteRepoRepo::new(pool);
719 let org = org_with(&orgs, "acme").await;
720 saved_repo(&repos, &org, "steid", Visibility::Public).await;
721
722 let clash = Repository::new(
723 RepoId::generate(),
724 org.id.clone(),
725 "steid",
726 None,
727 Visibility::Public,
728 )
729 .expect("valid repo");
730
731 assert!(repos.save(&clash).await.is_err(), "unique (org_id, name)");
732 }
733
734 #[tokio::test]
735 async fn two_owners_may_each_have_a_repository_of_the_same_name() {
736 let pool = test_pool().await;
737 let orgs = SqliteOrgRepo::new(pool.clone());
738 let repos = SqliteRepoRepo::new(pool);
739 let first = org_with(&orgs, "acme").await;
740 let second = org_with(&orgs, "globex").await;
741
742 saved_repo(&repos, &first, "steid", Visibility::Public).await;
743 saved_repo(&repos, &second, "steid", Visibility::Public).await;
744
745 // The constraint is a pair. On `name` alone, repository names would be globally
746 // unique across the whole installation.
747 assert_eq!(repos.list_by_org(&first.id).await.expect("list").len(), 1);
748 assert_eq!(repos.list_by_org(&second.id).await.expect("list").len(), 1);
749 }
750
751 #[tokio::test]
752 async fn a_repository_owned_by_a_missing_org_is_refused() {
753 let pool = test_pool().await;
754 let repos = SqliteRepoRepo::new(pool);
755 let orphan = Repository::new(
756 RepoId::generate(),
757 OrgId::generate(),
758 "steid",
759 None,
760 Visibility::Public,
761 )
762 .expect("valid repo");
763
764 assert!(repos.save(&orphan).await.is_err(), "foreign key");
765 }
766
767 #[tokio::test]
768 async fn an_unreadable_visibility_surfaces_rather_than_defaulting() {
769 let pool = test_pool().await;
770 let orgs = SqliteOrgRepo::new(pool.clone());
771 let repos = SqliteRepoRepo::new(pool.clone());
772 let org = org_with(&orgs, "acme").await;
773
774 sqlx::query(
775 "insert into repositories (id, org_id, name, visibility)
776 values ('r1', ?, 'secret', 'internal')",
777 )
778 .bind(org.id.as_str())
779 .execute(&pool)
780 .await
781 .expect("insert");
782
783 assert!(
784 repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err(),
785 "a visibility we cannot parse must not become a guess"
786 );
787 }
788
789 #[tokio::test]
790 async fn listing_returns_only_that_org_ordered_by_name() {
791 let pool = test_pool().await;
792 let orgs = SqliteOrgRepo::new(pool.clone());
793 let repos = SqliteRepoRepo::new(pool);
794 let mine = org_with(&orgs, "acme").await;
795 let theirs = org_with(&orgs, "globex").await;
796
797 for name in ["zebra", "alpha", "middle"] {
798 saved_repo(&repos, &mine, name, Visibility::Public).await;
799 }
800 saved_repo(&repos, &theirs, "not-mine", Visibility::Public).await;
801
802 let listed = repos.list_by_org(&mine.id).await.expect("list");
803
804 let names: Vec<&str> = listed.iter().map(|repo| repo.name.as_str()).collect();
805 assert_eq!(names, vec!["alpha", "middle", "zebra"]);
806 }
807
808 #[tokio::test]
809 async fn listing_includes_private_repositories() {
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, "secret", Visibility::Private).await;
815
816 // The port returns everything; filtering by visibility is the use case's job, so
817 // the page and /api cannot end up applying different rules.
818 assert_eq!(repos.list_by_org(&org.id).await.expect("list").len(), 1);
819 }
820
821 #[tokio::test]
822 async fn a_missing_repository_is_none_not_an_error() {
823 let pool = test_pool().await;
824 let repos = SqliteRepoRepo::new(pool);
825
826 let found = repos
827 .find_by_id(&RepoId::generate())
828 .await
829 .expect("lookup should not error");
830
831 assert_eq!(found, None);
832 }
833}