steid

@jamesgill /

Rust69.0 KBCode·Blame·History·Raw
1use super::*;
2
3fn at(seconds: u64) -> SystemTime {
4 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
5}
6use crate::{
7 application::{OwnerSpec, claim_instance, port::PasswordHasher},
8 domain::{PrivateTargets, ResetSecret, SetupToken, TokenLifetime, TokenSecret},
9 infrastructure::{database::test_support::test_pool, password::StubHasher},
10};
11
12struct Repos {
13 users: SqliteUserRepo,
14 orgs: SqliteOrgRepo,
15 memberships: SqliteMembershipRepo,
16}
17
18async fn repos() -> Repos {
19 let pool = test_pool().await;
20 Repos {
21 users: SqliteUserRepo::new(pool.clone()),
22 orgs: SqliteOrgRepo::new(pool.clone()),
23 memberships: SqliteMembershipRepo::new(pool),
24 }
25}
26
27async fn saved_org(repos: &Repos, name: &str) -> Organization {
28 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
29 repos.orgs.save(&org).await.expect("save org");
30 org
31}
32
33async fn saved_user(repos: &Repos, email: &str, org: &Organization) -> User {
34 let user = User::new(
35 UserId::generate(),
36 Email::new(email).expect("valid email"),
37 PasswordHash::from_trusted("$argon2id$test"),
38 org.id.clone(),
39 );
40 repos.users.save(&user).await.expect("save user");
41 user
42}
43
44#[tokio::test]
45async fn a_saved_user_round_trips() {
46 let repos = repos().await;
47 let org = saved_org(&repos, "james").await;
48 let user = saved_user(&repos, "dev@example.com", &org).await;
49
50 let found = repos
51 .users
52 .find_by_id(&user.id)
53 .await
54 .expect("lookup")
55 .expect("user should exist");
56
57 assert_eq!(found, user);
58}
59
60#[tokio::test]
61async fn users_are_found_by_email_case_insensitively() {
62 let repos = repos().await;
63 let org = saved_org(&repos, "james").await;
64 saved_user(&repos, "dev@example.com", &org).await;
65
66 // Email lowercases on construction, but a row written before that rule would
67 // still need finding.
68 let found = repos
69 .users
70 .find_by_email(&Email::from_trusted("DEV@EXAMPLE.COM"))
71 .await
72 .expect("lookup");
73
74 assert!(found.is_some(), "collate nocase should make this match");
75}
76
77#[tokio::test]
78async fn an_org_round_trips_with_its_display_name() {
79 let repos = repos().await;
80 let org =
81 Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned())).expect("valid org");
82 repos.orgs.save(&org).await.expect("save");
83
84 let found = repos
85 .orgs
86 .find_by_name(&org.name)
87 .await
88 .expect("lookup")
89 .expect("org should exist");
90
91 assert_eq!(found, org);
92 assert_eq!(found.label(), "Acme");
93}
94
95#[tokio::test]
96async fn a_missing_org_is_none_not_an_error() {
97 let repos = repos().await;
98
99 let found = repos
100 .orgs
101 .find_by_name(&OrgName::new("nobody").unwrap())
102 .await
103 .expect("lookup");
104
105 assert_eq!(found, None);
106}
107
108#[tokio::test]
109async fn a_membership_round_trips_with_its_role() {
110 let repos = repos().await;
111 let org = saved_org(&repos, "james").await;
112 let user = saved_user(&repos, "dev@example.com", &org).await;
113 let membership = Membership::new(
114 MembershipId::generate(),
115 org.id.clone(),
116 user.id.clone(),
117 Role::Owner,
118 );
119 repos.memberships.save(&membership).await.expect("save");
120
121 let found = repos
122 .memberships
123 .find(&org.id, &user.id)
124 .await
125 .expect("lookup")
126 .expect("membership should exist");
127
128 assert_eq!(found, membership);
129 assert!(found.can_write());
130}
131
132#[tokio::test]
133async fn an_unreadable_role_surfaces_rather_than_downgrading_access() {
134 let repos = repos().await;
135 let pool = test_pool().await;
136 let memberships = SqliteMembershipRepo::new(pool.clone());
137 let org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
138 SqliteOrgRepo::new(pool.clone())
139 .save(&org)
140 .await
141 .expect("save org");
142 let user = User::new(
143 UserId::generate(),
144 Email::new("dev@example.com").expect("valid email"),
145 PasswordHash::from_trusted("$argon2id$test"),
146 org.id.clone(),
147 );
148 SqliteUserRepo::new(pool.clone())
149 .save(&user)
150 .await
151 .expect("save user");
152
153 sqlx::query("insert into memberships (id, org_id, user_id, role) values (?, ?, ?, 'wat')")
154 .bind(MembershipId::generate().as_str())
155 .bind(org.id.as_str())
156 .bind(user.id.as_str())
157 .execute(&pool)
158 .await
159 .expect("insert");
160
161 let result = memberships.find(&org.id, &user.id).await;
162
163 assert!(
164 result.is_err(),
165 "a role we can't parse must not read as no membership"
166 );
167 drop(repos);
168}
169
170#[tokio::test]
171async fn saving_a_user_before_its_org_is_refused() {
172 let repos = repos().await;
173 let orphan = User::new(
174 UserId::generate(),
175 Email::new("dev@example.com").expect("valid email"),
176 PasswordHash::from_trusted("$argon2id$test"),
177 OrgId::generate(),
178 );
179
180 let result = repos.users.save(&orphan).await;
181
182 assert!(
183 result.is_err(),
184 "the foreign key should reject a user whose org doesn't exist"
185 );
186}
187
188#[tokio::test]
189async fn a_duplicate_handle_is_refused() {
190 let repos = repos().await;
191 saved_org(&repos, "james").await;
192
193 let clash = Organization::new(OrgId::generate(), "james", None).expect("valid org");
194 let result = repos.orgs.save(&clash).await;
195
196 assert!(result.is_err(), "orgs.name is unique");
197}
198
199#[tokio::test]
200async fn a_differently_cased_handle_is_also_refused() {
201 let repos = repos().await;
202 saved_org(&repos, "james").await;
203
204 // OrgName lowercases, so this can only arrive via from_trusted -- but the
205 // constraint is what we're testing, not the value object.
206 let clash = Organization::from_trusted(
207 OrgId::generate(),
208 OrgName::from_trusted("JAMES"),
209 None,
210 None,
211 );
212 let result = repos.orgs.save(&clash).await;
213
214 assert!(result.is_err(), "collate nocase should catch this");
215}
216
217/// The claim use case checks `is_claimed` and then writes, which is TOCTOU. This
218/// asserts the database is what actually stops a second owner being created.
219#[tokio::test]
220async fn a_second_claim_is_stopped_by_the_database_not_the_check() {
221 let repos = repos().await;
222 let token = SetupToken::generate();
223 let hasher = StubHasher::new();
224 let spec = OwnerSpec {
225 handle: "james".to_owned(),
226 email: "dev@example.com".to_owned(),
227 password: "correcthorsebattery".to_owned(),
228 };
229
230 claim_instance(
231 token.reveal(),
232 &token,
233 &spec,
234 &repos.users,
235 &repos.orgs,
236 &repos.memberships,
237 &hasher,
238 )
239 .await
240 .expect("first claim");
241
242 // Simulate the race: the second claimant passed is_claimed before the first
243 // committed, so it proceeds straight to the writes.
244 let intruder_org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
245 let result = repos.orgs.save(&intruder_org).await;
246
247 assert!(
248 result.is_err(),
249 "unique(orgs.name) is what actually serialises concurrent claims"
250 );
251
252 let intruder_user = User::new(
253 UserId::generate(),
254 Email::new("dev@example.com").expect("valid email"),
255 hasher.hash("letmein").expect("hash"),
256 OrgId::generate(),
257 );
258 assert!(
259 repos.users.save(&intruder_user).await.is_err(),
260 "unique(users.email) closes the other half"
261 );
262}
263async fn org_with(orgs: &SqliteOrgRepo, name: &str) -> Organization {
264 let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
265 orgs.save(&org).await.expect("save org");
266 org
267}
268
269async fn saved_repo(
270 repos: &SqliteRepoRepo,
271 org: &Organization,
272 name: &str,
273 visibility: Visibility,
274) -> Repository {
275 let repo = Repository::new(
276 RepoId::generate(),
277 org.id.clone(),
278 name,
279 None,
280 visibility,
281 at(1_000),
282 )
283 .expect("valid repo");
284 repos.save(&repo).await.expect("save repo");
285 repo
286}
287
288#[tokio::test]
289async fn a_repository_round_trips() {
290 let pool = test_pool().await;
291 let orgs = SqliteOrgRepo::new(pool.clone());
292 let repos = SqliteRepoRepo::new(pool);
293 let org = org_with(&orgs, "acme").await;
294
295 let repo = Repository::new(
296 RepoId::generate(),
297 org.id.clone(),
298 "steid",
299 Some("A gitforge.".to_owned()),
300 Visibility::Private,
301 at(1_000),
302 )
303 .expect("valid repo");
304 repos.save(&repo).await.expect("save");
305
306 let found = repos
307 .find_by_id(&repo.id)
308 .await
309 .expect("lookup")
310 .expect("should exist");
311
312 assert_eq!(found, repo);
313 assert_eq!(found.visibility, Visibility::Private);
314 assert_eq!(found.description.as_deref(), Some("A gitforge."));
315}
316
317#[tokio::test]
318async fn lookup_by_name_is_case_insensitive() {
319 let pool = test_pool().await;
320 let orgs = SqliteOrgRepo::new(pool.clone());
321 let repos = SqliteRepoRepo::new(pool);
322 let org = org_with(&orgs, "acme").await;
323 saved_repo(&repos, &org, "steid", Visibility::Public).await;
324
325 let found = repos
326 .find_by_org_and_name(&org.id, &RepoName::from_trusted("STEID"))
327 .await
328 .expect("lookup");
329
330 assert!(found.is_some(), "collate nocase should make this match");
331}
332
333#[tokio::test]
334async fn one_owner_cannot_have_two_repositories_with_the_same_name() {
335 let pool = test_pool().await;
336 let orgs = SqliteOrgRepo::new(pool.clone());
337 let repos = SqliteRepoRepo::new(pool);
338 let org = org_with(&orgs, "acme").await;
339 saved_repo(&repos, &org, "steid", Visibility::Public).await;
340
341 let clash = Repository::new(
342 RepoId::generate(),
343 org.id.clone(),
344 "steid",
345 None,
346 Visibility::Public,
347 at(1_000),
348 )
349 .expect("valid repo");
350
351 assert!(repos.save(&clash).await.is_err(), "unique (org_id, name)");
352}
353
354#[tokio::test]
355async fn two_owners_may_each_have_a_repository_of_the_same_name() {
356 let pool = test_pool().await;
357 let orgs = SqliteOrgRepo::new(pool.clone());
358 let repos = SqliteRepoRepo::new(pool);
359 let first = org_with(&orgs, "acme").await;
360 let second = org_with(&orgs, "globex").await;
361
362 saved_repo(&repos, &first, "steid", Visibility::Public).await;
363 saved_repo(&repos, &second, "steid", Visibility::Public).await;
364
365 // The constraint is a pair. On `name` alone, repository names would be globally
366 // unique across the whole installation.
367 assert_eq!(repos.list_by_org(&first.id).await.expect("list").len(), 1);
368 assert_eq!(repos.list_by_org(&second.id).await.expect("list").len(), 1);
369}
370
371#[tokio::test]
372async fn a_repository_owned_by_a_missing_org_is_refused() {
373 let pool = test_pool().await;
374 let repos = SqliteRepoRepo::new(pool);
375 let orphan = Repository::new(
376 RepoId::generate(),
377 OrgId::generate(),
378 "steid",
379 None,
380 Visibility::Public,
381 at(1_000),
382 )
383 .expect("valid repo");
384
385 assert!(repos.save(&orphan).await.is_err(), "foreign key");
386}
387
388#[tokio::test]
389async fn repositories_list_newest_first_breaking_ties_by_name() {
390 // The order the profile renders, and the same order `InMemoryRepoRepo` produces
391 // — the two are asserted against the same expectation on purpose.
392 let pool = test_pool().await;
393 let orgs = SqliteOrgRepo::new(pool.clone());
394 let repos = SqliteRepoRepo::new(pool);
395 let org = org_with(&orgs, "acme").await;
396
397 for (name, seconds) in [
398 ("alpha", 3_000),
399 ("older", 1_000),
400 ("newer", 2_000),
401 ("also-older", 1_000),
402 ] {
403 let mut repo = saved_repo(&repos, &org, name, Visibility::Public).await;
404 repo.updated_at = at(seconds);
405 repos.save(&repo).await.expect("restamp");
406 }
407
408 let listed = repos.list_by_org(&org.id).await.expect("list");
409
410 assert_eq!(
411 listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
412 vec!["alpha", "newer", "also-older", "older"]
413 );
414}
415
416#[tokio::test]
417async fn touching_moves_a_repository_to_the_supplied_time() {
418 let pool = test_pool().await;
419 let orgs = SqliteOrgRepo::new(pool.clone());
420 let repos = SqliteRepoRepo::new(pool);
421 let org = org_with(&orgs, "acme").await;
422 let repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
423
424 repos.touch(&repo.id, at(5_000)).await.expect("touch");
425
426 let found = repos
427 .find_by_id(&repo.id)
428 .await
429 .expect("lookup")
430 .expect("still there");
431 assert_eq!(found.updated_at, at(5_000));
432 assert_eq!(found.name, repo.name, "only the timestamp should move");
433}
434
435#[tokio::test]
436async fn touching_a_repository_that_is_gone_succeeds() {
437 let pool = test_pool().await;
438 let repos = SqliteRepoRepo::new(pool);
439
440 assert!(repos.touch(&RepoId::generate(), at(5_000)).await.is_ok());
441}
442
443#[tokio::test]
444async fn pinning_round_trips_through_an_integer_column() {
445 // SQLite has no boolean; a column read as anything but 0/1 would silently unpin.
446 let pool = test_pool().await;
447 let orgs = SqliteOrgRepo::new(pool.clone());
448 let repos = SqliteRepoRepo::new(pool);
449 let org = org_with(&orgs, "acme").await;
450 let mut repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
451
452 assert!(!repo.pinned, "a repository is not pinned by default");
453
454 repo.pinned = true;
455 repos.save(&repo).await.expect("save");
456
457 assert!(
458 repos
459 .find_by_id(&repo.id)
460 .await
461 .expect("lookup")
462 .expect("still there")
463 .pinned
464 );
465}
466
467/// The column migrations, applied to a row that predates all of them.
468///
469/// The dev database and any deployed instance already hold repositories, and this is
470/// the only chance to check what they look like afterwards: the table is created
471/// with the new columns from scratch everywhere else. Every migration that adds a
472/// column to `repositories` belongs in the list below.
473#[tokio::test]
474async fn the_repository_migrations_leave_existing_rows_looking_current() {
475 let pool = SqlitePool::connect("sqlite::memory:")
476 .await
477 .expect("in-memory database should open");
478
479 // The table as it stood before this migration, with a repository already in it.
480 sqlx::query(
481 "create table repositories (
482 id text primary key,
483 org_id text not null,
484 name text not null collate nocase,
485 description text,
486 visibility text not null,
487 unique (org_id, name)
488 )",
489 )
490 .execute(&pool)
491 .await
492 .expect("old schema");
493 sqlx::query(
494 "insert into repositories (id, org_id, name, visibility)
495 values ('r1', 'o1', 'steid', 'public')",
496 )
497 .execute(&pool)
498 .await
499 .expect("an existing repository");
500
501 for migration in [
502 include_str!("../../../migrations/20260829090000_add_repository_recency.sql"),
503 include_str!("../../../migrations/20260906120100_add_repository_import.sql"),
504 include_str!("../../../migrations/20260906120400_add_repository_archived.sql"),
505 ] {
506 for statement in migration
507 .split(';')
508 .filter(|statement| !statement.trim().is_empty())
509 {
510 sqlx::query(statement)
511 .execute(&pool)
512 .await
513 .expect("the migration should apply");
514 }
515 }
516
517 let repo = SqliteRepoRepo::new(pool)
518 .find_by_id(&RepoId::from_trusted("r1"))
519 .await
520 .expect("lookup")
521 .expect("the row survives");
522
523 assert!(
524 !repo.pinned,
525 "nothing becomes the lead without being chosen"
526 );
527 assert!(
528 !repo.archived,
529 "nothing becomes archived by migrating a database"
530 );
531
532 // Stamped with the migration's own clock, not left at the epoch: an existing
533 // repository must not read "updated 56 years ago" on the page this column exists
534 // to order.
535 let age = SystemTime::now()
536 .duration_since(repo.updated_at)
537 .expect("stamped in the past");
538 assert!(
539 age < Duration::from_secs(60),
540 "expected a just-migrated timestamp, got one {age:?} old"
541 );
542
543 // The import migration's columns are nullable and default to null, so every
544 // repository that existed before importing did stays an ordinary repository.
545 assert_eq!(repo.import_state, None);
546 assert_eq!(repo.import_source, None);
547 assert!(!repo.is_importing());
548}
549
550/// The boot sweep, on a table holding one of each kind of row.
551#[tokio::test]
552async fn the_boot_sweep_fails_only_the_repositories_still_importing() {
553 let pool = test_pool().await;
554 let orgs = SqliteOrgRepo::new(pool.clone());
555 let repos = SqliteRepoRepo::new(pool.clone());
556 let org = org_with(&orgs, "acme").await;
557
558 let named = |name: &str| {
559 Repository::new(
560 RepoId::generate(),
561 org.id.clone(),
562 name,
563 None,
564 Visibility::Public,
565 at(1_000),
566 )
567 .expect("valid repo")
568 };
569
570 let mut ordinary = named("ordinary");
571 let mut running = named("running");
572 let mut done = named("done");
573
574 running.start_import(ImportSource::new("https://example.com/a.git").expect("valid"));
575 done.start_import(ImportSource::new("https://example.com/b.git").expect("valid"));
576 done.finish_import();
577
578 for repo in [&ordinary, &running, &done] {
579 repos.save(repo).await.expect("save");
580 }
581
582 let swept = repos
583 .fail_interrupted_imports("interrupted by a restart")
584 .await
585 .expect("sweep");
586
587 assert_eq!(swept, 1, "only the running import is interrupted");
588
589 for repo in [&mut ordinary, &mut running, &mut done] {
590 *repo = repos
591 .find_by_id(&repo.id)
592 .await
593 .expect("lookup")
594 .expect("still there");
595 }
596
597 assert_eq!(ordinary.import_state, None);
598 assert_eq!(done.import_state, None);
599 assert_eq!(running.import_state, Some(ImportState::Failed));
600 assert_eq!(
601 running.import_error.as_deref(),
602 Some("interrupted by a restart")
603 );
604 // The source survives the sweep: it is what a retry needs.
605 assert_eq!(
606 running.import_source.as_ref().map(ImportSource::as_str),
607 Some("https://example.com/a.git")
608 );
609}
610
611/// An unreadable import state is a fault for the same reason an unreadable
612/// visibility is: both defaults are wrong in a way nobody would notice.
613#[tokio::test]
614async fn an_unreadable_import_state_surfaces_rather_than_defaulting() {
615 let pool = test_pool().await;
616 let orgs = SqliteOrgRepo::new(pool.clone());
617 let repos = SqliteRepoRepo::new(pool.clone());
618 let org = org_with(&orgs, "acme").await;
619
620 sqlx::query(
621 "insert into repositories (id, org_id, name, visibility, updated_at, pinned, import_state)
622 values ('r1', ?, 'half', 'public', 0, 0, 'halfway')",
623 )
624 .bind(org.id.as_str())
625 .execute(&pool)
626 .await
627 .expect("a row written behind the repository");
628
629 assert!(repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err());
630}
631
632#[tokio::test]
633async fn an_unreadable_visibility_surfaces_rather_than_defaulting() {
634 let pool = test_pool().await;
635 let orgs = SqliteOrgRepo::new(pool.clone());
636 let repos = SqliteRepoRepo::new(pool.clone());
637 let org = org_with(&orgs, "acme").await;
638
639 sqlx::query(
640 "insert into repositories (id, org_id, name, visibility)
641 values ('r1', ?, 'secret', 'internal')",
642 )
643 .bind(org.id.as_str())
644 .execute(&pool)
645 .await
646 .expect("insert");
647
648 assert!(
649 repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err(),
650 "a visibility we cannot parse must not become a guess"
651 );
652}
653
654#[tokio::test]
655async fn listing_returns_only_that_org_ordered_by_name() {
656 let pool = test_pool().await;
657 let orgs = SqliteOrgRepo::new(pool.clone());
658 let repos = SqliteRepoRepo::new(pool);
659 let mine = org_with(&orgs, "acme").await;
660 let theirs = org_with(&orgs, "globex").await;
661
662 for name in ["zebra", "alpha", "middle"] {
663 saved_repo(&repos, &mine, name, Visibility::Public).await;
664 }
665 saved_repo(&repos, &theirs, "not-mine", Visibility::Public).await;
666
667 let listed = repos.list_by_org(&mine.id).await.expect("list");
668
669 let names: Vec<&str> = listed.iter().map(|repo| repo.name.as_str()).collect();
670 assert_eq!(names, vec!["alpha", "middle", "zebra"]);
671}
672
673#[tokio::test]
674async fn listing_includes_private_repositories() {
675 let pool = test_pool().await;
676 let orgs = SqliteOrgRepo::new(pool.clone());
677 let repos = SqliteRepoRepo::new(pool);
678 let org = org_with(&orgs, "acme").await;
679 saved_repo(&repos, &org, "secret", Visibility::Private).await;
680
681 // The port returns everything; filtering by visibility is the use case's job, so
682 // the page and /api cannot end up applying different rules.
683 assert_eq!(repos.list_by_org(&org.id).await.expect("list").len(), 1);
684}
685
686#[tokio::test]
687async fn a_missing_repository_is_none_not_an_error() {
688 let pool = test_pool().await;
689 let repos = SqliteRepoRepo::new(pool);
690
691 let found = repos
692 .find_by_id(&RepoId::generate())
693 .await
694 .expect("lookup should not error");
695
696 assert_eq!(found, None);
697}
698
699#[tokio::test]
700async fn a_deleted_repository_is_gone_and_frees_its_name() {
701 let pool = test_pool().await;
702 let orgs = SqliteOrgRepo::new(pool.clone());
703 let repos = SqliteRepoRepo::new(pool);
704 let org = org_with(&orgs, "acme").await;
705 let repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
706
707 repos.delete(&repo.id).await.expect("delete");
708
709 assert_eq!(repos.find_by_id(&repo.id).await.expect("lookup"), None);
710 // The unique (org_id, name) pair is what would fail if the row lingered.
711 saved_repo(&repos, &org, "steid", Visibility::Private).await;
712}
713
714#[tokio::test]
715async fn deleting_a_repository_that_is_not_there_succeeds() {
716 // Idempotent, matching the fake: a second click must not become an error.
717 let pool = test_pool().await;
718 let repos = SqliteRepoRepo::new(pool);
719
720 assert!(repos.delete(&RepoId::generate()).await.is_ok());
721}
722
723#[tokio::test]
724async fn deleting_one_repository_leaves_the_others() {
725 let pool = test_pool().await;
726 let orgs = SqliteOrgRepo::new(pool.clone());
727 let repos = SqliteRepoRepo::new(pool);
728 let org = org_with(&orgs, "acme").await;
729 let doomed = saved_repo(&repos, &org, "alpha", Visibility::Public).await;
730 saved_repo(&repos, &org, "zebra", Visibility::Public).await;
731
732 repos.delete(&doomed.id).await.expect("delete");
733
734 let listed = repos.list_by_org(&org.id).await.expect("list");
735 assert_eq!(
736 listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
737 vec!["zebra"]
738 );
739}
740
741// --- SqliteTokenRepo ---------------------------------------------------------
742
743/// A pool plus a user to hang tokens off, since the foreign key runs that way.
744async fn token_fixture() -> (SqliteTokenRepo, User, User) {
745 let pool = test_pool().await;
746 let repos = Repos {
747 users: SqliteUserRepo::new(pool.clone()),
748 orgs: SqliteOrgRepo::new(pool.clone()),
749 memberships: SqliteMembershipRepo::new(pool.clone()),
750 };
751
752 let org = saved_org(&repos, "acme").await;
753 let owner = saved_user(&repos, "owner@example.com", &org).await;
754 let other = saved_user(&repos, "other@example.com", &org).await;
755
756 (SqliteTokenRepo::new(pool), owner, other)
757}
758
759fn issued(user: &User, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
760 let secret = TokenSecret::generate();
761 let token = PersonalAccessToken::new(
762 TokenId::generate(),
763 user.id.clone(),
764 name,
765 &secret,
766 SystemTime::UNIX_EPOCH + Duration::from_secs(at),
767 TokenLifetime::default(),
768 )
769 .expect("valid token");
770
771 (token, secret)
772}
773
774#[tokio::test]
775async fn a_token_round_trips_through_its_hash() {
776 let (tokens, owner, _) = token_fixture().await;
777 let (token, secret) = issued(&owner, "laptop", 1_000);
778 tokens.save(&token).await.expect("save");
779
780 let found = tokens
781 .find_by_hash(&secret.hash())
782 .await
783 .expect("lookup")
784 .expect("should be found");
785
786 assert_eq!(found, token);
787 assert_eq!(found.name, "laptop");
788 assert_eq!(found.prefix, secret.display_prefix());
789}
790
791#[tokio::test]
792async fn an_unknown_hash_finds_nothing() {
793 let (tokens, owner, _) = token_fixture().await;
794 let (token, _) = issued(&owner, "laptop", 1_000);
795 tokens.save(&token).await.expect("save");
796
797 let found = tokens
798 .find_by_hash(&TokenSecret::generate().hash())
799 .await
800 .expect("lookup");
801
802 assert!(found.is_none());
803}
804
805#[tokio::test]
806async fn tokens_are_listed_newest_first_and_only_the_users_own() {
807 let (tokens, owner, other) = token_fixture().await;
808
809 for (user, name, at) in [
810 (&owner, "old", 1_000),
811 (&owner, "new", 3_000),
812 (&owner, "middle", 2_000),
813 (&other, "theirs", 4_000),
814 ] {
815 let (token, _) = issued(user, name, at);
816 tokens.save(&token).await.expect("save");
817 }
818
819 let listed = tokens.list_by_user(&owner.id).await.expect("list");
820
821 assert_eq!(
822 listed
823 .iter()
824 .map(|token| token.name.as_str())
825 .collect::<Vec<_>>(),
826 vec!["new", "middle", "old"]
827 );
828}
829
830#[tokio::test]
831async fn deleting_a_token_stops_it_authenticating() {
832 // Revocation is a delete, so the credential is gone rather than flagged.
833 let (tokens, owner, _) = token_fixture().await;
834 let (token, secret) = issued(&owner, "laptop", 1_000);
835 tokens.save(&token).await.expect("save");
836
837 tokens.delete(&token.id).await.expect("delete");
838
839 assert!(
840 tokens
841 .find_by_hash(&secret.hash())
842 .await
843 .expect("lookup")
844 .is_none()
845 );
846}
847
848#[tokio::test]
849async fn deleting_a_token_that_is_not_there_succeeds() {
850 let (tokens, _, _) = token_fixture().await;
851
852 tokens
853 .delete(&TokenId::generate())
854 .await
855 .expect("deleting nothing should not fail");
856}
857
858#[tokio::test]
859async fn a_second_token_with_the_same_hash_is_refused() {
860 // The unique constraint. Two rows answering one credential would make which
861 // user a token authenticates depend on row order.
862 let (tokens, owner, other) = token_fixture().await;
863 let (first, secret) = issued(&owner, "laptop", 1_000);
864 tokens.save(&first).await.expect("save");
865
866 let clash = PersonalAccessToken::new(
867 TokenId::generate(),
868 other.id.clone(),
869 "clash",
870 &secret,
871 SystemTime::UNIX_EPOCH,
872 TokenLifetime::default(),
873 )
874 .expect("valid token");
875
876 assert!(tokens.save(&clash).await.is_err());
877}
878
879#[tokio::test]
880async fn a_token_belonging_to_no_user_is_refused() {
881 // The foreign key. Without `foreign_keys(true)` per connection SQLite ignores it.
882 let (tokens, _, _) = token_fixture().await;
883 let secret = TokenSecret::generate();
884 let orphan = PersonalAccessToken::new(
885 TokenId::generate(),
886 UserId::generate(),
887 "orphan",
888 &secret,
889 SystemTime::UNIX_EPOCH,
890 TokenLifetime::default(),
891 )
892 .expect("valid token");
893
894 assert!(tokens.save(&orphan).await.is_err());
895}
896
897// --- sole_user ----------------------------------------------------------------
898
899#[tokio::test]
900async fn there_is_no_sole_user_before_the_instance_is_claimed() {
901 let repos = repos().await;
902
903 assert!(repos.users.sole_user().await.expect("lookup").is_none());
904}
905
906#[tokio::test]
907async fn the_only_user_is_the_sole_user() {
908 let repos = repos().await;
909 let org = saved_org(&repos, "acme").await;
910 let user = saved_user(&repos, "owner@example.com", &org).await;
911
912 let found = repos
913 .users
914 .sole_user()
915 .await
916 .expect("lookup")
917 .expect("one user");
918
919 assert_eq!(found.id, user.id);
920}
921
922#[tokio::test]
923async fn a_second_user_makes_the_owner_undefined_rather_than_arbitrary() {
924 // The root redirects an anonymous visitor to "the owner". With two users that
925 // question has no answer, and picking whichever row came back first would send
926 // strangers to a person chosen by storage order.
927 let repos = repos().await;
928 let first = saved_org(&repos, "acme").await;
929 let second = saved_org(&repos, "other").await;
930 saved_user(&repos, "one@example.com", &first).await;
931 saved_user(&repos, "two@example.com", &second).await;
932
933 assert!(repos.users.sole_user().await.expect("lookup").is_none());
934}
935
936// --- SqliteIssueRepo ------------------------------------------------------------
937
938struct IssueFixture {
939 issues: SqliteIssueRepo,
940 /// The number sequence lives on this port, not on `issues`.
941 repos: SqliteRepoRepo,
942 repo: Repository,
943 other: Repository,
944 author: User,
945}
946
947/// A pool with two repositories and a user, since every foreign key runs that way.
948async fn issue_fixture() -> IssueFixture {
949 let pool = test_pool().await;
950 let repos = Repos {
951 users: SqliteUserRepo::new(pool.clone()),
952 orgs: SqliteOrgRepo::new(pool.clone()),
953 memberships: SqliteMembershipRepo::new(pool.clone()),
954 };
955 let repo_repo = SqliteRepoRepo::new(pool.clone());
956
957 let org = saved_org(&repos, "acme").await;
958 let author = saved_user(&repos, "owner@example.com", &org).await;
959
960 IssueFixture {
961 issues: SqliteIssueRepo::new(pool),
962 repo: saved_repo(&repo_repo, &org, "steid", Visibility::Public).await,
963 other: saved_repo(&repo_repo, &org, "other", Visibility::Public).await,
964 repos: repo_repo,
965 author,
966 }
967}
968
969fn issue_of(f: &IssueFixture, repo: &Repository, number: u64, title: &str) -> Issue {
970 Issue::new(
971 IssueId::generate(),
972 repo.id.clone(),
973 IssueNumber::from_trusted(number),
974 title,
975 "body",
976 f.author.id.clone(),
977 at(1_000),
978 )
979 .expect("valid issue")
980}
981
982#[tokio::test]
983async fn numbering_starts_at_one_and_never_repeats() {
984 let f = issue_fixture().await;
985
986 let first = f.repos.next_number(&f.repo.id).await.expect("allocate");
987 let second = f.repos.next_number(&f.repo.id).await.expect("allocate");
988 let third = f.repos.next_number(&f.repo.id).await.expect("allocate");
989
990 assert_eq!(
991 [first.get(), second.get(), third.get()],
992 [1, 2, 3],
993 "the sequence starts at 1 and steps by one"
994 );
995}
996
997#[tokio::test]
998async fn each_repository_has_its_own_sequence() {
999 // The number is part of a URL, so `#1` on two repositories has to be two things.
1000 let f = issue_fixture().await;
1001
1002 f.repos.next_number(&f.repo.id).await.expect("allocate");
1003 f.repos.next_number(&f.repo.id).await.expect("allocate");
1004
1005 assert_eq!(
1006 f.repos
1007 .next_number(&f.other.id)
1008 .await
1009 .expect("allocate")
1010 .get(),
1011 1
1012 );
1013}
1014
1015#[tokio::test]
1016async fn an_issue_round_trips_with_every_field() {
1017 let f = issue_fixture().await;
1018 let mut issue = issue_of(&f, &f.repo, 1, "Blame is slow");
1019 issue.close(at(2_000));
1020 f.issues.save(&issue).await.expect("save");
1021
1022 let found = f
1023 .issues
1024 .find_by_number(&f.repo.id, IssueNumber::from_trusted(1))
1025 .await
1026 .expect("lookup")
1027 .expect("the issue is there");
1028
1029 assert_eq!(found, issue);
1030}
1031
1032#[tokio::test]
1033async fn an_issue_is_found_only_under_its_own_repository() {
1034 let f = issue_fixture().await;
1035 f.issues
1036 .save(&issue_of(&f, &f.repo, 1, "Blame is slow"))
1037 .await
1038 .expect("save");
1039
1040 assert!(
1041 f.issues
1042 .find_by_number(&f.other.id, IssueNumber::from_trusted(1))
1043 .await
1044 .expect("lookup")
1045 .is_none()
1046 );
1047}
1048
1049#[tokio::test]
1050async fn two_issues_cannot_share_a_number_on_one_repository() {
1051 // The constraint that makes the sequence load-bearing rather than decorative.
1052 let f = issue_fixture().await;
1053 f.issues
1054 .save(&issue_of(&f, &f.repo, 1, "first"))
1055 .await
1056 .expect("save");
1057
1058 assert!(
1059 f.issues
1060 .save(&issue_of(&f, &f.repo, 1, "second"))
1061 .await
1062 .is_err()
1063 );
1064}
1065
1066#[tokio::test]
1067async fn saving_an_existing_issue_updates_it_rather_than_duplicating_it() {
1068 let f = issue_fixture().await;
1069 let mut issue = issue_of(&f, &f.repo, 1, "Blame is slow");
1070 f.issues.save(&issue).await.expect("save");
1071
1072 issue
1073 .edit("Blame is very slow", "more", at(3_000))
1074 .expect("edit");
1075 issue.close(at(4_000));
1076 f.issues.save(&issue).await.expect("update");
1077
1078 let found = f
1079 .issues
1080 .find_by_number(&f.repo.id, IssueNumber::from_trusted(1))
1081 .await
1082 .expect("lookup")
1083 .expect("still one issue");
1084
1085 assert_eq!(found.title, "Blame is very slow");
1086 assert_eq!(found.state, IssueState::Closed);
1087 assert_eq!(found.closed_at, Some(at(4_000)));
1088}
1089
1090#[tokio::test]
1091async fn the_second_page_of_issues_starts_where_the_first_ended() {
1092 let f = issue_fixture().await;
1093
1094 for number in 1..=5 {
1095 f.issues
1096 .save(&issue_of(&f, &f.repo, number, &format!("issue {number}")))
1097 .await
1098 .expect("save");
1099 }
1100
1101 let first = f
1102 .issues
1103 .list_by_repo(&f.repo.id, IssueState::Open, 0, 2)
1104 .await
1105 .expect("list");
1106 let second = f
1107 .issues
1108 .list_by_repo(&f.repo.id, IssueState::Open, 2, 2)
1109 .await
1110 .expect("list");
1111
1112 assert_eq!(
1113 first.iter().map(|i| i.number.get()).collect::<Vec<_>>(),
1114 vec![5, 4]
1115 );
1116 assert_eq!(
1117 second.iter().map(|i| i.number.get()).collect::<Vec<_>>(),
1118 vec![3, 2]
1119 );
1120 assert!(
1121 f.issues
1122 .list_by_repo(&f.repo.id, IssueState::Open, 500, 2)
1123 .await
1124 .expect("list")
1125 .is_empty(),
1126 "an offset past the end is an empty page, not an error"
1127 );
1128}
1129
1130#[tokio::test]
1131async fn a_listing_is_filtered_by_state_and_newest_first() {
1132 let f = issue_fixture().await;
1133 let mut second = issue_of(&f, &f.repo, 2, "issue 2");
1134 for issue in [
1135 issue_of(&f, &f.repo, 1, "issue 1"),
1136 second.clone(),
1137 issue_of(&f, &f.repo, 3, "issue 3"),
1138 ] {
1139 f.issues.save(&issue).await.expect("save");
1140 }
1141
1142 second.close(at(2_000));
1143 f.issues.save(&second).await.expect("close");
1144
1145 let open = f
1146 .issues
1147 .list_by_repo(&f.repo.id, IssueState::Open, 0, 50)
1148 .await
1149 .expect("list");
1150 let shut = f
1151 .issues
1152 .list_by_repo(&f.repo.id, IssueState::Closed, 0, 50)
1153 .await
1154 .expect("list");
1155
1156 assert_eq!(
1157 open.iter().map(|i| i.number.get()).collect::<Vec<_>>(),
1158 vec![3, 1]
1159 );
1160 assert_eq!(
1161 shut.iter().map(|i| i.number.get()).collect::<Vec<_>>(),
1162 vec![2]
1163 );
1164}
1165
1166#[tokio::test]
1167async fn counts_cover_both_states_and_only_this_repository() {
1168 let f = issue_fixture().await;
1169 f.issues
1170 .save(&issue_of(&f, &f.repo, 1, "open one"))
1171 .await
1172 .expect("save");
1173 let mut closed = issue_of(&f, &f.repo, 2, "closed one");
1174 closed.close(at(2_000));
1175 f.issues.save(&closed).await.expect("save");
1176 f.issues
1177 .save(&issue_of(&f, &f.other, 1, "elsewhere"))
1178 .await
1179 .expect("save");
1180
1181 assert_eq!(
1182 f.issues.counts(&f.repo.id).await.expect("counts"),
1183 IssueCounts { open: 1, closed: 1 }
1184 );
1185}
1186
1187#[tokio::test]
1188async fn comments_read_forwards_and_are_counted_per_issue() {
1189 let f = issue_fixture().await;
1190 let issue = issue_of(&f, &f.repo, 1, "Blame is slow");
1191 let quiet = issue_of(&f, &f.repo, 2, "Nobody replied");
1192 f.issues.save(&issue).await.expect("save");
1193 f.issues.save(&quiet).await.expect("save");
1194
1195 for (seconds, body) in [(3_000, "second"), (2_000, "first")] {
1196 f.issues
1197 .add_comment(
1198 &IssueComment::new(
1199 IssueCommentId::generate(),
1200 issue.id.clone(),
1201 f.author.id.clone(),
1202 body,
1203 at(seconds),
1204 )
1205 .expect("valid comment"),
1206 )
1207 .await
1208 .expect("comment");
1209 }
1210
1211 let comments = f.issues.list_comments(&issue.id).await.expect("list");
1212 assert_eq!(
1213 comments.iter().map(|c| c.body.as_str()).collect::<Vec<_>>(),
1214 vec!["first", "second"],
1215 "a conversation reads forwards"
1216 );
1217
1218 let mut counts = f.issues.comment_counts(&f.repo.id).await.expect("counts");
1219 counts.sort_by_key(|(id, _)| id.as_str().to_owned());
1220 let mut expected = vec![(issue.id.clone(), 2), (quiet.id.clone(), 0)];
1221 expected.sort_by_key(|(id, _)| id.as_str().to_owned());
1222
1223 assert_eq!(
1224 counts, expected,
1225 "an issue with no comments still gets a row"
1226 );
1227}
1228
1229#[tokio::test]
1230async fn deleting_a_repository_takes_its_issues_and_their_comments() {
1231 // `on delete cascade`, which is the whole of a comment's lifecycle.
1232 let f = issue_fixture().await;
1233 let issue = issue_of(&f, &f.repo, 1, "Blame is slow");
1234 f.issues.save(&issue).await.expect("save");
1235 f.issues
1236 .add_comment(
1237 &IssueComment::new(
1238 IssueCommentId::generate(),
1239 issue.id.clone(),
1240 f.author.id.clone(),
1241 "looks fixed",
1242 at(2_000),
1243 )
1244 .expect("valid comment"),
1245 )
1246 .await
1247 .expect("comment");
1248
1249 SqliteRepoRepo::new(f.issues.pool.clone())
1250 .delete(&f.repo.id)
1251 .await
1252 .expect("delete");
1253
1254 assert!(
1255 f.issues
1256 .find_by_number(&f.repo.id, IssueNumber::from_trusted(1))
1257 .await
1258 .expect("lookup")
1259 .is_none()
1260 );
1261 assert!(
1262 f.issues
1263 .list_comments(&issue.id)
1264 .await
1265 .expect("list")
1266 .is_empty()
1267 );
1268}
1269
1270#[tokio::test]
1271async fn an_issue_cannot_hang_off_a_repository_that_is_not_there() {
1272 let f = issue_fixture().await;
1273 let orphan = Issue::new(
1274 IssueId::generate(),
1275 RepoId::generate(),
1276 IssueNumber::from_trusted(1),
1277 "nowhere",
1278 "",
1279 f.author.id.clone(),
1280 at(1_000),
1281 )
1282 .expect("valid issue");
1283
1284 assert!(f.issues.save(&orphan).await.is_err());
1285}
1286
1287// --- push events, webhooks and their deliveries ---------------------------------
1288
1289/// A repository with an owner, which is what every row below hangs off.
1290async fn hooked_repo() -> (sqlx::SqlitePool, Repository, User) {
1291 let pool = test_pool().await;
1292 let orgs = SqliteOrgRepo::new(pool.clone());
1293 let users = SqliteUserRepo::new(pool.clone());
1294 let repos = SqliteRepoRepo::new(pool.clone());
1295
1296 let org = org_with(&orgs, "acme").await;
1297 let user = User::new(
1298 UserId::generate(),
1299 Email::new("dev@example.com").expect("valid email"),
1300 PasswordHash::from_trusted("$argon2id$test"),
1301 org.id.clone(),
1302 );
1303 users.save(&user).await.expect("save user");
1304
1305 let repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
1306
1307 (pool, repo, user)
1308}
1309
1310fn oid(prefix: &str) -> ObjectId {
1311 ObjectId::new(format!("{prefix:0>40}")).expect("valid id")
1312}
1313
1314#[tokio::test]
1315async fn a_push_event_round_trips_with_its_pusher() {
1316 let (pool, repo, user) = hooked_repo().await;
1317 let events = SqlitePushEventRepo::new(pool);
1318
1319 let event = PushEvent::new(
1320 PushEventId::generate(),
1321 repo.id.clone(),
1322 RefName::from_trusted("refs/heads/main"),
1323 oid("a"),
1324 oid("b"),
1325 Some(user.id.clone()),
1326 at(2_000),
1327 )
1328 .expect("valid event");
1329 events.save(&event).await.expect("save");
1330
1331 assert_eq!(
1332 events.find(&event.id).await.expect("lookup"),
1333 Some(event.clone())
1334 );
1335 assert_eq!(
1336 events.list_by_repo(&repo.id, 10).await.expect("list"),
1337 vec![event]
1338 );
1339}
1340
1341#[tokio::test]
1342async fn push_events_come_back_newest_first_and_capped() {
1343 let (pool, repo, user) = hooked_repo().await;
1344 let events = SqlitePushEventRepo::new(pool);
1345
1346 for (seconds, name) in [(1_000, "one"), (3_000, "three"), (2_000, "two")] {
1347 let event = PushEvent::new(
1348 PushEventId::generate(),
1349 repo.id.clone(),
1350 RefName::from_trusted(format!("refs/heads/{name}")),
1351 oid("a"),
1352 oid("b"),
1353 Some(user.id.clone()),
1354 at(seconds),
1355 )
1356 .expect("valid event");
1357 events.save(&event).await.expect("save");
1358 }
1359
1360 let listed = events.list_by_repo(&repo.id, 2).await.expect("list");
1361
1362 assert_eq!(listed.len(), 2);
1363 assert_eq!(listed[0].short_ref(), "three");
1364 assert_eq!(listed[1].short_ref(), "two");
1365}
1366
1367#[tokio::test]
1368async fn recent_push_events_span_the_repositories_asked_for_and_no_others() {
1369 let (pool, first, user) = hooked_repo().await;
1370 let repos = SqliteRepoRepo::new(pool.clone());
1371 let orgs = SqliteOrgRepo::new(pool.clone());
1372 let org = orgs
1373 .find_by_id(&first.org_id)
1374 .await
1375 .expect("lookup")
1376 .expect("the fixture's org");
1377 let second = saved_repo(&repos, &org, "other", Visibility::Public).await;
1378 let unseen = saved_repo(&repos, &org, "private", Visibility::Private).await;
1379 let events = SqlitePushEventRepo::new(pool);
1380
1381 for (repo, seconds, name) in [
1382 (&first, 1_000, "one"),
1383 (&second, 3_000, "three"),
1384 (&first, 2_000, "two"),
1385 (&unseen, 4_000, "hidden"),
1386 ] {
1387 let event = PushEvent::new(
1388 PushEventId::generate(),
1389 repo.id.clone(),
1390 RefName::from_trusted(format!("refs/heads/{name}")),
1391 oid("a"),
1392 oid("b"),
1393 Some(user.id.clone()),
1394 at(seconds),
1395 )
1396 .expect("valid event");
1397 events.save(&event).await.expect("save");
1398 }
1399
1400 let listed = events
1401 .list_recent(&[first.id.clone(), second.id.clone()], 10)
1402 .await
1403 .expect("list");
1404
1405 assert_eq!(
1406 listed.iter().map(PushEvent::short_ref).collect::<Vec<_>>(),
1407 vec!["three", "two", "one"],
1408 "newest first, across both repositories, and never the one not asked for"
1409 );
1410
1411 assert_eq!(
1412 events
1413 .list_recent(&[first.id.clone(), second.id.clone()], 1)
1414 .await
1415 .expect("list")
1416 .len(),
1417 1,
1418 "the limit applies to the merged list, not to each repository"
1419 );
1420
1421 assert!(
1422 events.list_recent(&[], 10).await.expect("list").is_empty(),
1423 "no repositories means nothing to report, not everything"
1424 );
1425}
1426
1427#[tokio::test]
1428async fn a_webhook_round_trips_with_its_secret_and_deliveries_follow_it_out() {
1429 // The cascade is the point: a hook's deliveries are that hook's history and
1430 // mean nothing without it.
1431 let (pool, repo, user) = hooked_repo().await;
1432 let events = SqlitePushEventRepo::new(pool.clone());
1433 let webhooks = SqliteWebhookRepo::new(pool.clone());
1434 let deliveries = SqliteWebhookDeliveryRepo::new(pool);
1435
1436 let hook = Webhook::new(
1437 WebhookId::generate(),
1438 repo.id.clone(),
1439 "https://example.com/hook",
1440 WebhookSecret::generate(),
1441 at(1_000),
1442 PrivateTargets::Refused,
1443 )
1444 .expect("valid hook");
1445 webhooks.save(&hook).await.expect("save");
1446
1447 let found = webhooks
1448 .find(&hook.id)
1449 .await
1450 .expect("lookup")
1451 .expect("hook should exist");
1452 assert_eq!(found.secret.reveal(), hook.secret.reveal());
1453 assert_eq!(found, hook);
1454
1455 let event = PushEvent::new(
1456 PushEventId::generate(),
1457 repo.id.clone(),
1458 RefName::from_trusted("refs/heads/main"),
1459 oid("a"),
1460 oid("b"),
1461 Some(user.id),
1462 at(2_000),
1463 )
1464 .expect("valid event");
1465 events.save(&event).await.expect("save");
1466
1467 for attempt in 1..=2u32 {
1468 deliveries
1469 .save(&WebhookDelivery::record(
1470 WebhookDeliveryId::generate(),
1471 hook.id.clone(),
1472 event.id.clone(),
1473 attempt,
1474 if attempt == 2 { Some(200) } else { None },
1475 "boom",
1476 12,
1477 at(2_000 + u64::from(attempt)),
1478 ))
1479 .await
1480 .expect("save");
1481 }
1482
1483 let listed = deliveries
1484 .list_by_webhook(&hook.id, 0, 10)
1485 .await
1486 .expect("list");
1487 assert_eq!(listed.len(), 2);
1488 assert_eq!(listed[0].attempt, 2, "newest attempt first");
1489 assert!(listed[0].ok);
1490 assert_eq!(listed[1].status, None);
1491 assert_eq!(listed[1].response, "boom");
1492
1493 // Paging: the second page starts where the first ended, and the count is what
1494 // the deliveries page numbers itself from.
1495 let first = deliveries
1496 .list_by_webhook(&hook.id, 0, 1)
1497 .await
1498 .expect("list");
1499 let second = deliveries
1500 .list_by_webhook(&hook.id, 1, 1)
1501 .await
1502 .expect("list");
1503
1504 assert_eq!(first.len(), 1);
1505 assert_eq!(first[0].attempt, 2);
1506 assert_eq!(second.len(), 1);
1507 assert_eq!(second[0].attempt, 1);
1508 assert_eq!(
1509 deliveries.count_by_webhook(&hook.id).await.expect("count"),
1510 2
1511 );
1512 assert!(
1513 deliveries
1514 .list_by_webhook(&hook.id, 500, 1)
1515 .await
1516 .expect("list")
1517 .is_empty(),
1518 "an offset past the end is an empty page"
1519 );
1520
1521 assert_eq!(
1522 deliveries
1523 .latest_by_webhook(&hook.id)
1524 .await
1525 .expect("lookup")
1526 .map(|delivery| delivery.attempt),
1527 Some(2)
1528 );
1529
1530 webhooks.delete(&hook.id).await.expect("delete");
1531
1532 assert!(webhooks.find(&hook.id).await.expect("lookup").is_none());
1533 assert!(
1534 deliveries
1535 .list_by_webhook(&hook.id, 0, 10)
1536 .await
1537 .expect("list")
1538 .is_empty(),
1539 "deliveries should go with the hook"
1540 );
1541 assert!(
1542 events.find(&event.id).await.expect("lookup").is_some(),
1543 "the event outlives the hook that was told about it"
1544 );
1545}
1546
1547#[tokio::test]
1548async fn a_hook_can_be_turned_off_and_stays_listed() {
1549 let (pool, repo, _) = hooked_repo().await;
1550 let webhooks = SqliteWebhookRepo::new(pool);
1551
1552 let mut hook = Webhook::new(
1553 WebhookId::generate(),
1554 repo.id.clone(),
1555 "https://example.com/hook",
1556 WebhookSecret::generate(),
1557 at(1_000),
1558 PrivateTargets::Refused,
1559 )
1560 .expect("valid hook");
1561 webhooks.save(&hook).await.expect("save");
1562
1563 hook.active = false;
1564 webhooks.save(&hook).await.expect("save again");
1565
1566 let listed = webhooks.list_by_repo(&repo.id).await.expect("list");
1567
1568 assert_eq!(listed.len(), 1);
1569 assert!(!listed[0].active);
1570}
1571
1572// --- SqliteTokenRepo, lifecycle ------------------------------------------------
1573
1574#[tokio::test]
1575async fn an_expiry_survives_a_round_trip_through_the_table() {
1576 let (tokens, owner, _) = token_fixture().await;
1577 let (mut token, secret) = issued(&owner, "laptop", 1_000);
1578 token.expires_at = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(90_000));
1579 tokens.save(&token).await.expect("save");
1580
1581 let found = tokens
1582 .find_by_hash(&secret.hash())
1583 .await
1584 .expect("lookup")
1585 .expect("present");
1586
1587 assert_eq!(found.expires_at, token.expires_at);
1588 assert_eq!(found.last_used_at, None);
1589}
1590
1591#[tokio::test]
1592async fn a_token_with_no_expiry_reads_back_with_none() {
1593 // The column is nullable and null is the answer, not the epoch — every token
1594 // issued before the lifecycle migration is exactly this row.
1595 let (tokens, owner, _) = token_fixture().await;
1596 let (mut token, secret) = issued(&owner, "mirror", 1_000);
1597 token.expires_at = None;
1598 tokens.save(&token).await.expect("save");
1599
1600 assert_eq!(
1601 tokens
1602 .find_by_hash(&secret.hash())
1603 .await
1604 .expect("lookup")
1605 .expect("present")
1606 .expires_at,
1607 None
1608 );
1609}
1610
1611#[tokio::test]
1612async fn recording_a_use_moves_only_that_column() {
1613 let (tokens, owner, _) = token_fixture().await;
1614 let (token, secret) = issued(&owner, "laptop", 1_000);
1615 tokens.save(&token).await.expect("save");
1616
1617 let used_at = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000);
1618 tokens
1619 .touch_last_used(&token.id, used_at)
1620 .await
1621 .expect("touch");
1622
1623 let found = tokens
1624 .find_by_hash(&secret.hash())
1625 .await
1626 .expect("lookup")
1627 .expect("present");
1628
1629 assert_eq!(found.last_used_at, Some(used_at));
1630 assert_eq!(found.name, token.name);
1631 assert_eq!(found.created_at, token.created_at);
1632 assert_eq!(found.expires_at, token.expires_at);
1633}
1634
1635#[tokio::test]
1636async fn saving_a_stale_record_does_not_walk_the_last_use_backwards() {
1637 // `save` upserts, and the record a caller holds may predate a use recorded since.
1638 // Only `touch_last_used` owns that column.
1639 let (tokens, owner, _) = token_fixture().await;
1640 let (mut token, secret) = issued(&owner, "laptop", 1_000);
1641 tokens.save(&token).await.expect("save");
1642
1643 let used_at = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000);
1644 tokens
1645 .touch_last_used(&token.id, used_at)
1646 .await
1647 .expect("touch");
1648
1649 token.name = "renamed".to_owned();
1650 tokens.save(&token).await.expect("save again");
1651
1652 let found = tokens
1653 .find_by_hash(&secret.hash())
1654 .await
1655 .expect("lookup")
1656 .expect("present");
1657
1658 assert_eq!(found.name, "renamed");
1659 assert_eq!(found.last_used_at, Some(used_at));
1660}
1661
1662#[tokio::test]
1663async fn recording_a_use_of_a_revoked_token_succeeds() {
1664 let (tokens, _, _) = token_fixture().await;
1665
1666 assert!(
1667 tokens
1668 .touch_last_used(&TokenId::generate(), SystemTime::UNIX_EPOCH)
1669 .await
1670 .is_ok()
1671 );
1672}
1673
1674// --- SqliteLabelRepo --------------------------------------------------------------
1675
1676struct LabelFixture {
1677 pool: SqlitePool,
1678 labels: SqliteLabelRepo,
1679 issues: SqliteIssueRepo,
1680 repo: Repository,
1681 other: Repository,
1682 author: User,
1683}
1684
1685/// The issue fixture plus a label repository on the same pool — labels hang off
1686/// repositories and assignments off issues, so both foreign keys need somewhere to
1687/// land.
1688async fn label_fixture() -> LabelFixture {
1689 let pool = test_pool().await;
1690 let repos = Repos {
1691 users: SqliteUserRepo::new(pool.clone()),
1692 orgs: SqliteOrgRepo::new(pool.clone()),
1693 memberships: SqliteMembershipRepo::new(pool.clone()),
1694 };
1695 let repo_repo = SqliteRepoRepo::new(pool.clone());
1696
1697 let org = saved_org(&repos, "acme").await;
1698 let author = saved_user(&repos, "owner@example.com", &org).await;
1699
1700 LabelFixture {
1701 labels: SqliteLabelRepo::new(pool.clone()),
1702 issues: SqliteIssueRepo::new(pool.clone()),
1703 pool,
1704 repo: saved_repo(&repo_repo, &org, "steid", Visibility::Public).await,
1705 other: saved_repo(&repo_repo, &org, "other", Visibility::Public).await,
1706 author,
1707 }
1708}
1709
1710async fn saved_label(f: &LabelFixture, repo: &Repository, name: &str) -> Label {
1711 let label = Label::new(
1712 LabelId::generate(),
1713 repo.id.clone(),
1714 name,
1715 LabelColour::Blue,
1716 None,
1717 at(1_000),
1718 )
1719 .expect("valid label");
1720
1721 f.labels.save(&label).await.expect("save label");
1722
1723 label
1724}
1725
1726async fn saved_issue(f: &LabelFixture, number: u64, title: &str) -> Issue {
1727 let issue = Issue::new(
1728 IssueId::generate(),
1729 f.repo.id.clone(),
1730 IssueNumber::from_trusted(number),
1731 title,
1732 "body",
1733 f.author.id.clone(),
1734 at(1_000),
1735 )
1736 .expect("valid issue");
1737
1738 f.issues.save(&issue).await.expect("save issue");
1739
1740 issue
1741}
1742
1743#[tokio::test]
1744async fn labels_are_listed_by_name_and_scoped_to_their_repository() {
1745 let f = label_fixture().await;
1746
1747 for name in ["docs", "bug"] {
1748 saved_label(&f, &f.repo, name).await;
1749 }
1750 saved_label(&f, &f.other, "bug").await;
1751
1752 let names: Vec<String> = f
1753 .labels
1754 .list_by_repo(&f.repo.id)
1755 .await
1756 .expect("list")
1757 .into_iter()
1758 .map(|label| label.name)
1759 .collect();
1760
1761 assert_eq!(names, ["bug", "docs"]);
1762 assert_eq!(
1763 f.labels
1764 .list_by_repo(&f.other.id)
1765 .await
1766 .expect("list")
1767 .len(),
1768 1
1769 );
1770}
1771
1772#[tokio::test]
1773async fn a_name_is_unique_within_a_repository() {
1774 let f = label_fixture().await;
1775
1776 saved_label(&f, &f.repo, "bug").await;
1777
1778 // The index is the backstop under the use case's own check: a second row with
1779 // the same name on the same repository must not be storable at all.
1780 let duplicate = Label::new(
1781 LabelId::generate(),
1782 f.repo.id.clone(),
1783 "bug",
1784 LabelColour::Red,
1785 None,
1786 at(2_000),
1787 )
1788 .expect("valid label");
1789
1790 assert!(f.labels.save(&duplicate).await.is_err());
1791 // The same name on another repository is a different label.
1792 saved_label(&f, &f.other, "bug").await;
1793}
1794
1795#[tokio::test]
1796async fn saving_an_existing_label_renames_and_recolours_it() {
1797 let f = label_fixture().await;
1798 let mut label = saved_label(&f, &f.repo, "bug").await;
1799
1800 label
1801 .edit("defect", LabelColour::Purple, Some("broken".to_owned()))
1802 .expect("valid edit");
1803 f.labels.save(&label).await.expect("save again");
1804
1805 let stored = f
1806 .labels
1807 .find_by_id(&label.id)
1808 .await
1809 .expect("lookup")
1810 .expect("still there");
1811
1812 assert_eq!(stored.name, "defect");
1813 assert_eq!(stored.colour, LabelColour::Purple);
1814 assert_eq!(stored.description.as_deref(), Some("broken"));
1815 assert_eq!(
1816 stored.created_at,
1817 at(1_000),
1818 "creation is not moved by an edit"
1819 );
1820}
1821
1822#[tokio::test]
1823async fn a_label_is_found_by_its_name_on_its_own_repository_only() {
1824 let f = label_fixture().await;
1825 saved_label(&f, &f.other, "bug").await;
1826
1827 assert!(
1828 f.labels
1829 .find_by_name(&f.repo.id, "bug")
1830 .await
1831 .expect("lookup")
1832 .is_none()
1833 );
1834 assert!(
1835 f.labels
1836 .find_by_name(&f.other.id, "bug")
1837 .await
1838 .expect("lookup")
1839 .is_some()
1840 );
1841}
1842
1843#[tokio::test]
1844async fn setting_the_full_set_replaces_what_was_there() {
1845 let f = label_fixture().await;
1846 let issue = saved_issue(&f, 1, "Blame is slow").await;
1847
1848 let bug = saved_label(&f, &f.repo, "bug").await;
1849 let docs = saved_label(&f, &f.repo, "docs").await;
1850
1851 f.labels
1852 .set_issue_labels(&issue.id, &[bug.id.clone(), docs.id.clone()])
1853 .await
1854 .expect("assign");
1855 assert_eq!(carried(&f, &issue).await, ["bug", "docs"]);
1856
1857 // Applying the same set again is a no-op rather than a duplicate-key failure.
1858 f.labels
1859 .set_issue_labels(&issue.id, &[bug.id.clone(), docs.id.clone()])
1860 .await
1861 .expect("assign again");
1862 assert_eq!(carried(&f, &issue).await, ["bug", "docs"]);
1863
1864 f.labels
1865 .set_issue_labels(&issue.id, std::slice::from_ref(&docs.id))
1866 .await
1867 .expect("replace");
1868 assert_eq!(carried(&f, &issue).await, ["docs"]);
1869
1870 f.labels
1871 .set_issue_labels(&issue.id, &[])
1872 .await
1873 .expect("clear");
1874 assert!(carried(&f, &issue).await.is_empty());
1875}
1876
1877#[tokio::test]
1878async fn the_repository_wide_read_groups_every_pair_and_counts_them() {
1879 let f = label_fixture().await;
1880 let first = saved_issue(&f, 1, "Blame is slow").await;
1881 let second = saved_issue(&f, 2, "Docs are thin").await;
1882
1883 let bug = saved_label(&f, &f.repo, "bug").await;
1884 let docs = saved_label(&f, &f.repo, "docs").await;
1885 let unused = saved_label(&f, &f.repo, "chore").await;
1886
1887 f.labels
1888 .set_issue_labels(&first.id, &[bug.id.clone(), docs.id.clone()])
1889 .await
1890 .expect("assign");
1891 f.labels
1892 .set_issue_labels(&second.id, std::slice::from_ref(&bug.id))
1893 .await
1894 .expect("assign");
1895
1896 let pairs = f.labels.labels_for_repo(&f.repo.id).await.expect("pairs");
1897 assert_eq!(pairs.len(), 3);
1898 assert_eq!(
1899 pairs
1900 .iter()
1901 .filter(|(issue, _)| issue == &first.id)
1902 .map(|(_, label)| label.name.as_str())
1903 .collect::<Vec<_>>(),
1904 ["bug", "docs"]
1905 );
1906
1907 let mut counts = f.labels.issue_counts(&f.repo.id).await.expect("counts");
1908 counts.sort_by(|left, right| left.0.cmp(&right.0));
1909
1910 let count_of = |id: &LabelId| {
1911 counts
1912 .iter()
1913 .find(|(label, _)| label == id)
1914 .map(|(_, total)| *total)
1915 };
1916
1917 assert_eq!(count_of(&bug.id), Some(2));
1918 assert_eq!(count_of(&docs.id), Some(1));
1919 // An unused label still gets a row rather than being absent from the lookup.
1920 assert_eq!(count_of(&unused.id), Some(0));
1921}
1922
1923#[tokio::test]
1924async fn deleting_a_label_takes_it_off_every_issue_and_leaves_them_there() {
1925 let f = label_fixture().await;
1926 let issue = saved_issue(&f, 1, "Blame is slow").await;
1927
1928 let bug = saved_label(&f, &f.repo, "bug").await;
1929 let docs = saved_label(&f, &f.repo, "docs").await;
1930
1931 f.labels
1932 .set_issue_labels(&issue.id, &[bug.id.clone(), docs.id.clone()])
1933 .await
1934 .expect("assign");
1935
1936 f.labels.delete(&bug.id).await.expect("delete");
1937
1938 assert_eq!(carried(&f, &issue).await, ["docs"]);
1939 assert!(
1940 f.issues
1941 .find_by_number(&f.repo.id, IssueNumber::from_trusted(1))
1942 .await
1943 .expect("lookup")
1944 .is_some(),
1945 "the issue survives its label"
1946 );
1947}
1948
1949#[tokio::test]
1950async fn deleting_an_issue_takes_its_assignments_with_it() {
1951 let f = label_fixture().await;
1952 let issue = saved_issue(&f, 1, "Blame is slow").await;
1953 let bug = saved_label(&f, &f.repo, "bug").await;
1954
1955 f.labels
1956 .set_issue_labels(&issue.id, std::slice::from_ref(&bug.id))
1957 .await
1958 .expect("assign");
1959
1960 sqlx::query("delete from issues where id = ?")
1961 .bind(issue.id.as_str())
1962 .execute(&f.pool)
1963 .await
1964 .expect("delete issue");
1965
1966 assert!(
1967 f.labels
1968 .labels_for_repo(&f.repo.id)
1969 .await
1970 .expect("pairs")
1971 .is_empty()
1972 );
1973 assert!(
1974 f.labels
1975 .find_by_id(&bug.id)
1976 .await
1977 .expect("lookup")
1978 .is_some(),
1979 "the label survives the issue"
1980 );
1981}
1982
1983#[tokio::test]
1984async fn a_pull_request_wears_the_same_labels_and_is_counted_apart_from_the_issues() {
1985 let f = label_fixture().await;
1986 let issue = saved_issue(&f, 1, "Blame is slow").await;
1987 let pull = saved_pull(&f, 2, "Speed blame up").await;
1988
1989 let bug = saved_label(&f, &f.repo, "bug").await;
1990 let docs = saved_label(&f, &f.repo, "docs").await;
1991
1992 f.labels
1993 .set_issue_labels(&issue.id, std::slice::from_ref(&bug.id))
1994 .await
1995 .expect("assign issue");
1996 f.labels
1997 .set_pull_labels(&pull.id, &[bug.id.clone(), docs.id.clone()])
1998 .await
1999 .expect("assign pull");
2000
2001 assert_eq!(
2002 f.labels
2003 .labels_for_pull(&pull.id)
2004 .await
2005 .expect("labels")
2006 .into_iter()
2007 .map(|label| label.name)
2008 .collect::<Vec<_>>(),
2009 ["bug", "docs"]
2010 );
2011
2012 // The two join tables are two answers: the issue's assignment must not show up
2013 // in the pull request's pairs, nor in its counts.
2014 let pairs = f
2015 .labels
2016 .labels_for_repo_pulls(&f.repo.id)
2017 .await
2018 .expect("pairs");
2019 assert_eq!(pairs.len(), 2);
2020 assert!(pairs.iter().all(|(id, _)| id == &pull.id));
2021
2022 let counts = f.labels.pull_counts(&f.repo.id).await.expect("counts");
2023 let count_of = |id: &LabelId| {
2024 counts
2025 .iter()
2026 .find(|(label, _)| label == id)
2027 .map(|(_, total)| *total)
2028 };
2029
2030 assert_eq!(count_of(&bug.id), Some(1));
2031 assert_eq!(count_of(&docs.id), Some(1));
2032 assert_eq!(
2033 f.labels
2034 .issue_counts(&f.repo.id)
2035 .await
2036 .expect("counts")
2037 .iter()
2038 .find(|(label, _)| label == &docs.id)
2039 .map(|(_, total)| *total),
2040 Some(0),
2041 "an issue count must not pick up a pull request's assignment"
2042 );
2043}
2044
2045#[tokio::test]
2046async fn deleting_a_pull_request_takes_its_assignments_with_it() {
2047 let f = label_fixture().await;
2048 let pull = saved_pull(&f, 1, "Speed blame up").await;
2049 let bug = saved_label(&f, &f.repo, "bug").await;
2050
2051 f.labels
2052 .set_pull_labels(&pull.id, std::slice::from_ref(&bug.id))
2053 .await
2054 .expect("assign");
2055
2056 sqlx::query("delete from pull_requests where id = ?")
2057 .bind(pull.id.as_str())
2058 .execute(&f.pool)
2059 .await
2060 .expect("delete pull request");
2061
2062 assert!(
2063 f.labels
2064 .labels_for_repo_pulls(&f.repo.id)
2065 .await
2066 .expect("pairs")
2067 .is_empty()
2068 );
2069 assert!(
2070 f.labels
2071 .find_by_id(&bug.id)
2072 .await
2073 .expect("lookup")
2074 .is_some(),
2075 "the label survives the pull request"
2076 );
2077}
2078
2079async fn saved_pull(f: &LabelFixture, number: u64, title: &str) -> PullRequest {
2080 let pull = PullRequest::new(
2081 PullRequestId::generate(),
2082 f.repo.id.clone(),
2083 IssueNumber::from_trusted(number),
2084 title,
2085 "body",
2086 f.author.id.clone(),
2087 RefName::from_trusted("spike"),
2088 RefName::from_trusted("main"),
2089 at(1_000),
2090 )
2091 .expect("valid pull request");
2092
2093 SqlitePullRequestRepo::new(f.pool.clone())
2094 .save(&pull)
2095 .await
2096 .expect("save pull request");
2097
2098 pull
2099}
2100
2101/// The names of the labels on one issue.
2102async fn carried(f: &LabelFixture, issue: &Issue) -> Vec<String> {
2103 f.labels
2104 .labels_for_issue(&issue.id)
2105 .await
2106 .expect("labels")
2107 .into_iter()
2108 .map(|label| label.name)
2109 .collect()
2110}
2111
2112#[tokio::test]
2113async fn users_are_counted() {
2114 let repos = repos().await;
2115 assert_eq!(repos.users.count().await.expect("count"), 0);
2116
2117 let org = saved_org(&repos, "james").await;
2118 saved_user(&repos, "dev@example.com", &org).await;
2119 let second = saved_org(&repos, "ada").await;
2120 saved_user(&repos, "ada@example.com", &second).await;
2121
2122 assert_eq!(repos.users.count().await.expect("count"), 2);
2123}
2124
2125#[tokio::test]
2126async fn repositories_are_counted_across_every_organisation() {
2127 let pool = test_pool().await;
2128 let orgs = SqliteOrgRepo::new(pool.clone());
2129 let repos = SqliteRepoRepo::new(pool);
2130 assert_eq!(repos.count().await.expect("count"), 0);
2131
2132 let acme = org_with(&orgs, "acme").await;
2133 let ada = org_with(&orgs, "ada").await;
2134 // One private, so the count is plainly not filtered by visibility: the operator
2135 // surface reports the size of the machine, not what a visitor may see.
2136 saved_repo(&repos, &acme, "steid", Visibility::Public).await;
2137 saved_repo(&repos, &acme, "notes", Visibility::Private).await;
2138 saved_repo(&repos, &ada, "engine", Visibility::Public).await;
2139
2140 assert_eq!(repos.count().await.expect("count"), 3);
2141}
2142
2143#[tokio::test]
2144async fn the_instance_counts_are_what_a_manifest_reports() {
2145 let pool = test_pool().await;
2146 let users = SqliteUserRepo::new(pool.clone());
2147 let orgs = SqliteOrgRepo::new(pool.clone());
2148 let repos = SqliteRepoRepo::new(pool);
2149
2150 assert_eq!(users.count().await.expect("count"), 0);
2151 assert_eq!(orgs.count().await.expect("count"), 0);
2152 assert_eq!(repos.count().await.expect("count"), 0);
2153
2154 let org = org_with(&orgs, "james").await;
2155 users
2156 .save(&User::new(
2157 UserId::generate(),
2158 Email::new("dev@example.com").expect("valid email"),
2159 PasswordHash::from_trusted("$argon2id$test"),
2160 org.id.clone(),
2161 ))
2162 .await
2163 .expect("save user");
2164 org_with(&orgs, "acme").await;
2165 saved_repo(&repos, &org, "steid", Visibility::Public).await;
2166 saved_repo(&repos, &org, "notes", Visibility::Private).await;
2167
2168 assert_eq!(users.count().await.expect("count"), 1);
2169 assert_eq!(orgs.count().await.expect("count"), 2);
2170 // Across every organisation, not one: a backup holds the whole instance.
2171 assert_eq!(repos.count().await.expect("count"), 2);
2172}
2173
2174// --- SqlitePasswordResetRepo -------------------------------------------------
2175
2176/// A pool plus a user to hang resets off, since the foreign key runs that way.
2177async fn reset_fixture() -> (SqlitePool, User) {
2178 let pool = test_pool().await;
2179 let orgs = SqliteOrgRepo::new(pool.clone());
2180 let users = SqliteUserRepo::new(pool.clone());
2181
2182 let org = Organization::new(OrgId::generate(), "ada", None).expect("valid org");
2183 orgs.save(&org).await.expect("save org");
2184
2185 let user = User::new(
2186 UserId::generate(),
2187 Email::new("ada@example.com").expect("valid email"),
2188 PasswordHash::from_trusted("$argon2id$test"),
2189 org.id.clone(),
2190 );
2191 users.save(&user).await.expect("save user");
2192
2193 (pool, user)
2194}
2195
2196#[tokio::test]
2197async fn a_reset_round_trips_through_sqlite_by_its_hash() {
2198 let (pool, user) = reset_fixture().await;
2199 let resets = SqlitePasswordResetRepo::new(pool);
2200 let secret = ResetSecret::generate();
2201 let reset = PasswordReset::new(ResetId::generate(), user.id.clone(), &secret, at(1_000));
2202
2203 resets.save(&reset).await.expect("save");
2204
2205 let found = resets
2206 .find_by_hash(&secret.hash())
2207 .await
2208 .expect("read")
2209 .expect("the reset exists");
2210
2211 assert_eq!(found, reset);
2212 assert!(found.is_live(at(1_000)));
2213 assert!(
2214 resets
2215 .find_by_hash(&ResetSecret::generate().hash())
2216 .await
2217 .expect("read")
2218 .is_none()
2219 );
2220}
2221
2222#[tokio::test]
2223async fn marking_a_reset_used_is_idempotent_about_when() {
2224 // Two submissions of the same form must not move the moment it was spent.
2225 let (pool, user) = reset_fixture().await;
2226 let resets = SqlitePasswordResetRepo::new(pool);
2227 let secret = ResetSecret::generate();
2228 let reset = PasswordReset::new(ResetId::generate(), user.id.clone(), &secret, at(1_000));
2229 resets.save(&reset).await.expect("save");
2230
2231 resets.mark_used(&reset.id, at(1_100)).await.expect("mark");
2232 resets.mark_used(&reset.id, at(1_200)).await.expect("mark");
2233
2234 let found = resets
2235 .find_by_hash(&secret.hash())
2236 .await
2237 .expect("read")
2238 .expect("the reset exists");
2239
2240 assert_eq!(found.used_at, Some(at(1_100)));
2241 assert!(!found.is_live(at(1_150)));
2242}
2243
2244#[tokio::test]
2245async fn issuing_a_link_invalidates_only_this_users_live_ones() {
2246 let (pool, user) = reset_fixture().await;
2247 let orgs = SqliteOrgRepo::new(pool.clone());
2248 let users = SqliteUserRepo::new(pool.clone());
2249 let resets = SqlitePasswordResetRepo::new(pool.clone());
2250
2251 let other_org = Organization::new(OrgId::generate(), "grace", None).expect("valid org");
2252 orgs.save(&other_org).await.expect("save org");
2253 let other = User::new(
2254 UserId::generate(),
2255 Email::new("grace@example.com").expect("valid email"),
2256 PasswordHash::from_trusted("$argon2id$test"),
2257 other_org.id.clone(),
2258 );
2259 users.save(&other).await.expect("save user");
2260
2261 let mine = ResetSecret::generate();
2262 let theirs = ResetSecret::generate();
2263 resets
2264 .save(&PasswordReset::new(
2265 ResetId::generate(),
2266 user.id.clone(),
2267 &mine,
2268 at(1_000),
2269 ))
2270 .await
2271 .expect("save");
2272 resets
2273 .save(&PasswordReset::new(
2274 ResetId::generate(),
2275 other.id.clone(),
2276 &theirs,
2277 at(1_000),
2278 ))
2279 .await
2280 .expect("save");
2281
2282 assert_eq!(
2283 resets
2284 .invalidate_for_user(&user.id, at(1_100))
2285 .await
2286 .expect("invalidate"),
2287 1
2288 );
2289 // Already spent, so a second sweep counts nothing.
2290 assert_eq!(
2291 resets
2292 .invalidate_for_user(&user.id, at(1_200))
2293 .await
2294 .expect("invalidate"),
2295 0
2296 );
2297
2298 let live = |secret: ResetSecret| {
2299 let resets = resets.clone();
2300 async move {
2301 resets
2302 .find_by_hash(&secret.hash())
2303 .await
2304 .expect("read")
2305 .expect("the reset exists")
2306 .is_live(at(1_300))
2307 }
2308 };
2309
2310 assert!(!live(mine).await);
2311 assert!(live(theirs).await, "someone else's link is untouched");
2312}
2313
2314#[tokio::test]
2315async fn a_user_is_found_by_the_org_whose_name_is_their_handle() {
2316 // What `steid reset-password <handle>` walks.
2317 let repos = repos().await;
2318 let org = saved_org(&repos, "ada").await;
2319 let user = saved_user(&repos, "ada@example.com", &org).await;
2320
2321 assert_eq!(
2322 repos
2323 .users
2324 .find_by_personal_org(&org.id)
2325 .await
2326 .expect("read"),
2327 Some(user)
2328 );
2329 assert_eq!(
2330 repos
2331 .users
2332 .find_by_personal_org(&OrgId::generate())
2333 .await
2334 .expect("read"),
2335 None
2336 );
2337}
2338
2339#[tokio::test]
2340async fn signing_out_every_other_device_keeps_the_one_named() {
2341 let (pool, user) = reset_fixture().await;
2342 let sessions = SqliteSessionRepo::new(pool);
2343
2344 let here = SessionTokenHash::from_trusted("a".repeat(64));
2345 for hash in [here.clone(), SessionTokenHash::from_trusted("b".repeat(64))] {
2346 sessions
2347 .save(&Session::new(hash, user.id.clone(), at(9_000)))
2348 .await
2349 .expect("save");
2350 }
2351
2352 assert_eq!(
2353 sessions
2354 .delete_by_user(&user.id, Some(&here))
2355 .await
2356 .expect("delete"),
2357 1
2358 );
2359 assert!(sessions.find(&here).await.expect("read").is_some());
2360
2361 // And with nothing kept, the rest goes too.
2362 assert_eq!(
2363 sessions
2364 .delete_by_user(&user.id, None)
2365 .await
2366 .expect("delete"),
2367 1
2368 );
2369 assert!(sessions.find(&here).await.expect("read").is_none());
2370}