steid

@jamesgill /

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