| | @@ -8,11 +8,11 @@ use std::time::{Duration, SystemTime}; |
| 8 | 8 | use sqlx::{Row, SqlitePool, sqlite::SqliteRow}; |
| 9 | 9 | |
| 10 | 10 | use crate::domain::{ |
| 11 | | − Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, Role, Session, |
| 12 | | − SessionTokenHash, User, UserId, |
| 11 | + Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, RepoId, RepoName, |
| 12 | + Repository, Role, Session, SessionTokenHash, User, UserId, Visibility, |
| 13 | 13 | repository::{ |
| 14 | | − MembershipRepository, OrgRepository, RepositoryError, RepositoryResult, SessionRepository, |
| 15 | | − UserRepository, |
| 14 | + MembershipRepository, OrgRepository, RepoRepository, RepositoryError, RepositoryResult, |
| 15 | + SessionRepository, UserRepository, |
| 16 | 16 | }, |
| 17 | 17 | }; |
| 18 | 18 | |
| | @@ -220,6 +220,175 @@ impl MembershipRepository for SqliteMembershipRepo { |
| 220 | 220 | } |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | +#[derive(Debug, Clone)] |
| 224 | +pub struct SqliteSessionRepo { |
| 225 | + pool: SqlitePool, |
| 226 | +} |
| 227 | + |
| 228 | +impl SqliteSessionRepo { |
| 229 | + pub fn new(pool: SqlitePool) -> Self { |
| 230 | + Self { pool } |
| 231 | + } |
| 232 | +} |
| 233 | + |
| 234 | +/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in |
| 235 | +/// the future — so saturating at 0 is safe rather than lossy. |
| 236 | +fn to_unix(time: SystemTime) -> i64 { |
| 237 | + time.duration_since(SystemTime::UNIX_EPOCH) |
| 238 | + .map(|d| d.as_secs() as i64) |
| 239 | + .unwrap_or(0) |
| 240 | +} |
| 241 | + |
| 242 | +fn from_unix(seconds: i64) -> SystemTime { |
| 243 | + SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64) |
| 244 | +} |
| 245 | + |
| 246 | +impl SessionRepository for SqliteSessionRepo { |
| 247 | + async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> { |
| 248 | + let row = sqlx::query("select * from sessions where token_hash = ?") |
| 249 | + .bind(token_hash.as_str()) |
| 250 | + .fetch_optional(&self.pool) |
| 251 | + .await |
| 252 | + .map_err(backend)?; |
| 253 | + |
| 254 | + Ok(row.map(|row| { |
| 255 | + Session::new( |
| 256 | + SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")), |
| 257 | + UserId::from_trusted(row.get::<String, _>("user_id")), |
| 258 | + from_unix(row.get::<i64, _>("expires_at")), |
| 259 | + ) |
| 260 | + })) |
| 261 | + } |
| 262 | + |
| 263 | + async fn save(&self, session: &Session) -> RepositoryResult<()> { |
| 264 | + sqlx::query( |
| 265 | + "insert into sessions (token_hash, user_id, expires_at) |
| 266 | + values (?, ?, ?) |
| 267 | + on conflict (token_hash) do update set |
| 268 | + user_id = excluded.user_id, |
| 269 | + expires_at = excluded.expires_at", |
| 270 | + ) |
| 271 | + .bind(session.token_hash.as_str()) |
| 272 | + .bind(session.user_id.as_str()) |
| 273 | + .bind(to_unix(session.expires_at)) |
| 274 | + .execute(&self.pool) |
| 275 | + .await |
| 276 | + .map_err(backend)?; |
| 277 | + |
| 278 | + Ok(()) |
| 279 | + } |
| 280 | + |
| 281 | + async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> { |
| 282 | + sqlx::query("delete from sessions where token_hash = ?") |
| 283 | + .bind(token_hash.as_str()) |
| 284 | + .execute(&self.pool) |
| 285 | + .await |
| 286 | + .map_err(backend)?; |
| 287 | + |
| 288 | + Ok(()) |
| 289 | + } |
| 290 | + |
| 291 | + async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> { |
| 292 | + let result = sqlx::query("delete from sessions where expires_at <= ?") |
| 293 | + .bind(to_unix(now)) |
| 294 | + .execute(&self.pool) |
| 295 | + .await |
| 296 | + .map_err(backend)?; |
| 297 | + |
| 298 | + Ok(result.rows_affected()) |
| 299 | + } |
| 300 | +} |
| 301 | + |
| 302 | +#[derive(Debug, Clone)] |
| 303 | +pub struct SqliteRepoRepo { |
| 304 | + pool: SqlitePool, |
| 305 | +} |
| 306 | + |
| 307 | +impl SqliteRepoRepo { |
| 308 | + pub fn new(pool: SqlitePool) -> Self { |
| 309 | + Self { pool } |
| 310 | + } |
| 311 | + |
| 312 | + /// An unparseable visibility is a storage fault, not a default. |
| 313 | + /// |
| 314 | + /// Falling back to `Public` would publish a repository whose row we cannot read; |
| 315 | + /// falling back to `Private` would hide a public one. Neither is a guess worth |
| 316 | + /// making, so the row surfaces as an error. |
| 317 | + fn map(row: &SqliteRow) -> RepositoryResult<Repository> { |
| 318 | + let raw: String = row.get("visibility"); |
| 319 | + let visibility: Visibility = raw |
| 320 | + .parse() |
| 321 | + .map_err(|error| RepositoryError::backend(format!("{error}")))?; |
| 322 | + |
| 323 | + Ok(Repository::from_trusted( |
| 324 | + RepoId::from_trusted(row.get::<String, _>("id")), |
| 325 | + OrgId::from_trusted(row.get::<String, _>("org_id")), |
| 326 | + RepoName::from_trusted(row.get::<String, _>("name")), |
| 327 | + row.get::<Option<String>, _>("description"), |
| 328 | + visibility, |
| 329 | + )) |
| 330 | + } |
| 331 | +} |
| 332 | + |
| 333 | +impl RepoRepository for SqliteRepoRepo { |
| 334 | + async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> { |
| 335 | + let row = sqlx::query("select * from repositories where id = ?") |
| 336 | + .bind(id.as_str()) |
| 337 | + .fetch_optional(&self.pool) |
| 338 | + .await |
| 339 | + .map_err(backend)?; |
| 340 | + |
| 341 | + row.as_ref().map(Self::map).transpose() |
| 342 | + } |
| 343 | + |
| 344 | + async fn find_by_org_and_name( |
| 345 | + &self, |
| 346 | + org_id: &OrgId, |
| 347 | + name: &RepoName, |
| 348 | + ) -> RepositoryResult<Option<Repository>> { |
| 349 | + let row = sqlx::query("select * from repositories where org_id = ? and name = ?") |
| 350 | + .bind(org_id.as_str()) |
| 351 | + .bind(name.as_str()) |
| 352 | + .fetch_optional(&self.pool) |
| 353 | + .await |
| 354 | + .map_err(backend)?; |
| 355 | + |
| 356 | + row.as_ref().map(Self::map).transpose() |
| 357 | + } |
| 358 | + |
| 359 | + async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> { |
| 360 | + let rows = sqlx::query("select * from repositories where org_id = ? order by name") |
| 361 | + .bind(org_id.as_str()) |
| 362 | + .fetch_all(&self.pool) |
| 363 | + .await |
| 364 | + .map_err(backend)?; |
| 365 | + |
| 366 | + rows.iter().map(Self::map).collect() |
| 367 | + } |
| 368 | + |
| 369 | + async fn save(&self, repo: &Repository) -> RepositoryResult<()> { |
| 370 | + sqlx::query( |
| 371 | + "insert into repositories (id, org_id, name, description, visibility) |
| 372 | + values (?, ?, ?, ?, ?) |
| 373 | + on conflict (id) do update set |
| 374 | + org_id = excluded.org_id, |
| 375 | + name = excluded.name, |
| 376 | + description = excluded.description, |
| 377 | + visibility = excluded.visibility", |
| 378 | + ) |
| 379 | + .bind(repo.id.as_str()) |
| 380 | + .bind(repo.org_id.as_str()) |
| 381 | + .bind(repo.name.as_str()) |
| 382 | + .bind(repo.description.as_deref()) |
| 383 | + .bind(repo.visibility.as_str()) |
| 384 | + .execute(&self.pool) |
| 385 | + .await |
| 386 | + .map_err(backend)?; |
| 387 | + |
| 388 | + Ok(()) |
| 389 | + } |
| 390 | +} |
| 391 | + |
| 223 | 392 | #[cfg(test)] |
| 224 | 393 | mod tests { |
| 225 | 394 | use super::*; |
| | @@ -480,83 +649,185 @@ mod tests { |
| 480 | 649 | "unique(users.email) closes the other half" |
| 481 | 650 | ); |
| 482 | 651 | } |
| 483 | | −} |
| 484 | | − |
| 485 | | −#[derive(Debug, Clone)] |
| 486 | | −pub struct SqliteSessionRepo { |
| 487 | | − pool: SqlitePool, |
| 488 | | −} |
| 652 | + async fn org_with(orgs: &SqliteOrgRepo, name: &str) -> Organization { |
| 653 | + let org = Organization::new(OrgId::generate(), name, None).expect("valid org"); |
| 654 | + orgs.save(&org).await.expect("save org"); |
| 655 | + org |
| 656 | + } |
| 489 | 657 | |
| 490 | | −impl SqliteSessionRepo { |
| 491 | | − pub fn new(pool: SqlitePool) -> Self { |
| 492 | | − Self { pool } |
| 658 | + async fn saved_repo( |
| 659 | + repos: &SqliteRepoRepo, |
| 660 | + org: &Organization, |
| 661 | + name: &str, |
| 662 | + visibility: Visibility, |
| 663 | + ) -> Repository { |
| 664 | + let repo = Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility) |
| 665 | + .expect("valid repo"); |
| 666 | + repos.save(&repo).await.expect("save repo"); |
| 667 | + repo |
| 493 | 668 | } |
| 494 | | −} |
| 495 | 669 | |
| 496 | | −/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in |
| 497 | | −/// the future — so saturating at 0 is safe rather than lossy. |
| 498 | | −fn to_unix(time: SystemTime) -> i64 { |
| 499 | | − time.duration_since(SystemTime::UNIX_EPOCH) |
| 500 | | − .map(|d| d.as_secs() as i64) |
| 501 | | − .unwrap_or(0) |
| 502 | | −} |
| 670 | + #[tokio::test] |
| 671 | + async fn a_repository_round_trips() { |
| 672 | + let pool = test_pool().await; |
| 673 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 674 | + let repos = SqliteRepoRepo::new(pool); |
| 675 | + let org = org_with(&orgs, "acme").await; |
| 503 | 676 | |
| 504 | | −fn from_unix(seconds: i64) -> SystemTime { |
| 505 | | − SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64) |
| 506 | | −} |
| 677 | + let repo = Repository::new( |
| 678 | + RepoId::generate(), |
| 679 | + org.id.clone(), |
| 680 | + "steid", |
| 681 | + Some("A gitforge.".to_owned()), |
| 682 | + Visibility::Private, |
| 683 | + ) |
| 684 | + .expect("valid repo"); |
| 685 | + repos.save(&repo).await.expect("save"); |
| 507 | 686 | |
| 508 | | −impl SessionRepository for SqliteSessionRepo { |
| 509 | | − async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> { |
| 510 | | − let row = sqlx::query("select * from sessions where token_hash = ?") |
| 511 | | − .bind(token_hash.as_str()) |
| 512 | | − .fetch_optional(&self.pool) |
| 687 | + let found = repos |
| 688 | + .find_by_id(&repo.id) |
| 513 | 689 | .await |
| 514 | | − .map_err(backend)?; |
| 690 | + .expect("lookup") |
| 691 | + .expect("should exist"); |
| 515 | 692 | |
| 516 | | − Ok(row.map(|row| { |
| 517 | | − Session::new( |
| 518 | | − SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")), |
| 519 | | − UserId::from_trusted(row.get::<String, _>("user_id")), |
| 520 | | − from_unix(row.get::<i64, _>("expires_at")), |
| 521 | | − ) |
| 522 | | − })) |
| 693 | + assert_eq!(found, repo); |
| 694 | + assert_eq!(found.visibility, Visibility::Private); |
| 695 | + assert_eq!(found.description.as_deref(), Some("A gitforge.")); |
| 523 | 696 | } |
| 524 | 697 | |
| 525 | | − async fn save(&self, session: &Session) -> RepositoryResult<()> { |
| 698 | + #[tokio::test] |
| 699 | + async fn lookup_by_name_is_case_insensitive() { |
| 700 | + let pool = test_pool().await; |
| 701 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 702 | + let repos = SqliteRepoRepo::new(pool); |
| 703 | + let org = org_with(&orgs, "acme").await; |
| 704 | + saved_repo(&repos, &org, "steid", Visibility::Public).await; |
| 705 | + |
| 706 | + let found = repos |
| 707 | + .find_by_org_and_name(&org.id, &RepoName::from_trusted("STEID")) |
| 708 | + .await |
| 709 | + .expect("lookup"); |
| 710 | + |
| 711 | + assert!(found.is_some(), "collate nocase should make this match"); |
| 712 | + } |
| 713 | + |
| 714 | + #[tokio::test] |
| 715 | + async fn one_owner_cannot_have_two_repositories_with_the_same_name() { |
| 716 | + let pool = test_pool().await; |
| 717 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 718 | + let repos = SqliteRepoRepo::new(pool); |
| 719 | + let org = org_with(&orgs, "acme").await; |
| 720 | + saved_repo(&repos, &org, "steid", Visibility::Public).await; |
| 721 | + |
| 722 | + let clash = Repository::new( |
| 723 | + RepoId::generate(), |
| 724 | + org.id.clone(), |
| 725 | + "steid", |
| 726 | + None, |
| 727 | + Visibility::Public, |
| 728 | + ) |
| 729 | + .expect("valid repo"); |
| 730 | + |
| 731 | + assert!(repos.save(&clash).await.is_err(), "unique (org_id, name)"); |
| 732 | + } |
| 733 | + |
| 734 | + #[tokio::test] |
| 735 | + async fn two_owners_may_each_have_a_repository_of_the_same_name() { |
| 736 | + let pool = test_pool().await; |
| 737 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 738 | + let repos = SqliteRepoRepo::new(pool); |
| 739 | + let first = org_with(&orgs, "acme").await; |
| 740 | + let second = org_with(&orgs, "globex").await; |
| 741 | + |
| 742 | + saved_repo(&repos, &first, "steid", Visibility::Public).await; |
| 743 | + saved_repo(&repos, &second, "steid", Visibility::Public).await; |
| 744 | + |
| 745 | + // The constraint is a pair. On `name` alone, repository names would be globally |
| 746 | + // unique across the whole installation. |
| 747 | + assert_eq!(repos.list_by_org(&first.id).await.expect("list").len(), 1); |
| 748 | + assert_eq!(repos.list_by_org(&second.id).await.expect("list").len(), 1); |
| 749 | + } |
| 750 | + |
| 751 | + #[tokio::test] |
| 752 | + async fn a_repository_owned_by_a_missing_org_is_refused() { |
| 753 | + let pool = test_pool().await; |
| 754 | + let repos = SqliteRepoRepo::new(pool); |
| 755 | + let orphan = Repository::new( |
| 756 | + RepoId::generate(), |
| 757 | + OrgId::generate(), |
| 758 | + "steid", |
| 759 | + None, |
| 760 | + Visibility::Public, |
| 761 | + ) |
| 762 | + .expect("valid repo"); |
| 763 | + |
| 764 | + assert!(repos.save(&orphan).await.is_err(), "foreign key"); |
| 765 | + } |
| 766 | + |
| 767 | + #[tokio::test] |
| 768 | + async fn an_unreadable_visibility_surfaces_rather_than_defaulting() { |
| 769 | + let pool = test_pool().await; |
| 770 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 771 | + let repos = SqliteRepoRepo::new(pool.clone()); |
| 772 | + let org = org_with(&orgs, "acme").await; |
| 773 | + |
| 526 | 774 | sqlx::query( |
| 527 | | − "insert into sessions (token_hash, user_id, expires_at) |
| 528 | | − values (?, ?, ?) |
| 529 | | − on conflict (token_hash) do update set |
| 530 | | − user_id = excluded.user_id, |
| 531 | | − expires_at = excluded.expires_at", |
| 775 | + "insert into repositories (id, org_id, name, visibility) |
| 776 | + values ('r1', ?, 'secret', 'internal')", |
| 532 | 777 | ) |
| 533 | | − .bind(session.token_hash.as_str()) |
| 534 | | − .bind(session.user_id.as_str()) |
| 535 | | − .bind(to_unix(session.expires_at)) |
| 536 | | − .execute(&self.pool) |
| 778 | + .bind(org.id.as_str()) |
| 779 | + .execute(&pool) |
| 537 | 780 | .await |
| 538 | | − .map_err(backend)?; |
| 781 | + .expect("insert"); |
| 539 | 782 | |
| 540 | | − Ok(()) |
| 783 | + assert!( |
| 784 | + repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err(), |
| 785 | + "a visibility we cannot parse must not become a guess" |
| 786 | + ); |
| 541 | 787 | } |
| 542 | 788 | |
| 543 | | − async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> { |
| 544 | | − sqlx::query("delete from sessions where token_hash = ?") |
| 545 | | − .bind(token_hash.as_str()) |
| 546 | | − .execute(&self.pool) |
| 547 | | − .await |
| 548 | | − .map_err(backend)?; |
| 789 | + #[tokio::test] |
| 790 | + async fn listing_returns_only_that_org_ordered_by_name() { |
| 791 | + let pool = test_pool().await; |
| 792 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 793 | + let repos = SqliteRepoRepo::new(pool); |
| 794 | + let mine = org_with(&orgs, "acme").await; |
| 795 | + let theirs = org_with(&orgs, "globex").await; |
| 549 | 796 | |
| 550 | | − Ok(()) |
| 797 | + for name in ["zebra", "alpha", "middle"] { |
| 798 | + saved_repo(&repos, &mine, name, Visibility::Public).await; |
| 799 | + } |
| 800 | + saved_repo(&repos, &theirs, "not-mine", Visibility::Public).await; |
| 801 | + |
| 802 | + let listed = repos.list_by_org(&mine.id).await.expect("list"); |
| 803 | + |
| 804 | + let names: Vec<&str> = listed.iter().map(|repo| repo.name.as_str()).collect(); |
| 805 | + assert_eq!(names, vec!["alpha", "middle", "zebra"]); |
| 551 | 806 | } |
| 552 | 807 | |
| 553 | | − async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> { |
| 554 | | − let result = sqlx::query("delete from sessions where expires_at <= ?") |
| 555 | | − .bind(to_unix(now)) |
| 556 | | − .execute(&self.pool) |
| 808 | + #[tokio::test] |
| 809 | + async fn listing_includes_private_repositories() { |
| 810 | + let pool = test_pool().await; |
| 811 | + let orgs = SqliteOrgRepo::new(pool.clone()); |
| 812 | + let repos = SqliteRepoRepo::new(pool); |
| 813 | + let org = org_with(&orgs, "acme").await; |
| 814 | + saved_repo(&repos, &org, "secret", Visibility::Private).await; |
| 815 | + |
| 816 | + // The port returns everything; filtering by visibility is the use case's job, so |
| 817 | + // the page and /api cannot end up applying different rules. |
| 818 | + assert_eq!(repos.list_by_org(&org.id).await.expect("list").len(), 1); |
| 819 | + } |
| 820 | + |
| 821 | + #[tokio::test] |
| 822 | + async fn a_missing_repository_is_none_not_an_error() { |
| 823 | + let pool = test_pool().await; |
| 824 | + let repos = SqliteRepoRepo::new(pool); |
| 825 | + |
| 826 | + let found = repos |
| 827 | + .find_by_id(&RepoId::generate()) |
| 557 | 828 | .await |
| 558 | | − .map_err(backend)?; |
| 829 | + .expect("lookup should not error"); |
| 559 | 830 | |
| 560 | | − Ok(result.rows_affected()) |
| 831 | + assert_eq!(found, None); |
| 561 | 832 | } |
| 562 | 833 | } |