fabrica

hanna/fabrica

53559 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Store tests. The portable behaviour runs against SQLite both in-memory and
6//! file-backed; the same round-trips run against Postgres when the
7//! `postgres-tests` feature is enabled and `FABRICA_TEST_POSTGRES_URL` is set.
8
9#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
10
11use model::KeyKind;
12use tempfile::TempDir;
13
14use super::{Backend, NewKey, NewRepo, NewToken, NewUser, Store, StoreError};
15
16/// A migrated store plus any temp directory that must outlive it.
17struct Fixture {
18 store: Store,
19 _dir: Option<TempDir>,
20}
21
22/// A migrated in-memory SQLite store. A single connection keeps the database
23/// alive for the life of the pool.
24async fn memory_store() -> Fixture {
25 let store = Store::connect("sqlite::memory:", 1).await.unwrap();
26 store.migrate().await.unwrap();
27 Fixture { store, _dir: None }
28}
29
30/// A migrated file-backed SQLite store in a fresh temp directory.
31async fn file_store() -> Fixture {
32 let dir = tempfile::tempdir().unwrap();
33 let url = format!("sqlite://{}", dir.path().join("fabrica.db").display());
34 let store = Store::connect(&url, 5).await.unwrap();
35 store.migrate().await.unwrap();
36 Fixture {
37 store,
38 _dir: Some(dir),
39 }
40}
41
42/// Both SQLite flavours the portable tests must pass against.
43async fn sqlite_fixtures() -> Vec<Fixture> {
44 vec![memory_store().await, file_store().await]
45}
46
47fn sample_user(username: &str, email: &str) -> NewUser {
48 NewUser {
49 username: username.to_string(),
50 email: email.to_string(),
51 display_name: Some("Sample".to_string()),
52 password_hash: None,
53 is_admin: false,
54 must_change_password: false,
55 }
56}
57
58#[tokio::test]
59async fn migrations_are_idempotent() {
60 for fx in sqlite_fixtures().await {
61 // A second run over the already-migrated database is a no-op.
62 fx.store.migrate().await.unwrap();
63 }
64}
65
66#[tokio::test]
67async fn create_and_fetch_user() {
68 for fx in sqlite_fixtures().await {
69 let created = fx
70 .store
71 .create_user(sample_user("hanna", "hanna@example.com"))
72 .await
73 .unwrap();
74 assert_eq!(created.id.len(), 26, "id should be a 26-char ULID");
75 assert_eq!(created.created_at, created.updated_at);
76
77 let by_id = fx.store.user_by_id(&created.id).await.unwrap().unwrap();
78 assert_eq!(by_id, created);
79
80 // Username lookup is case-insensitive.
81 let by_name = fx.store.user_by_username("HANNA").await.unwrap().unwrap();
82 assert_eq!(by_name, created);
83
84 assert!(fx.store.user_by_id("nope").await.unwrap().is_none());
85 assert!(fx.store.user_by_username("ghost").await.unwrap().is_none());
86 }
87}
88
89#[tokio::test]
90async fn update_profile_and_avatar_round_trip() {
91 for fx in sqlite_fixtures().await {
92 let user = fx
93 .store
94 .create_user(sample_user("prof", "prof@example.com"))
95 .await
96 .unwrap();
97
98 let ok = fx
99 .store
100 .update_profile(
101 &user.id,
102 crate::ProfileUpdate {
103 display_name: Some("Professor".to_string()),
104 pronouns: Some("they/them".to_string()),
105 bio: Some("Hello there.".to_string()),
106 location: Some("Everywhere".to_string()),
107 links: vec![
108 "https://example.com".to_string(),
109 "https://github.com/prof".to_string(),
110 ],
111 },
112 )
113 .await
114 .unwrap();
115 assert!(ok);
116
117 let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
118 assert_eq!(got.pronouns.as_deref(), Some("they/them"));
119 assert_eq!(got.bio.as_deref(), Some("Hello there."));
120 assert_eq!(got.links.len(), 2, "two links stored");
121 assert_eq!(got.links[1], "https://github.com/prof");
122 assert!(got.avatar_mime.is_none(), "no avatar yet");
123
124 // Setting then clearing the avatar content type round-trips.
125 assert!(
126 fx.store
127 .set_avatar_mime(&user.id, Some("image/png"))
128 .await
129 .unwrap()
130 );
131 let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
132 assert_eq!(got.avatar_mime.as_deref(), Some("image/png"));
133
134 assert!(fx.store.set_avatar_mime(&user.id, None).await.unwrap());
135 let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
136 assert!(got.avatar_mime.is_none(), "avatar cleared");
137
138 // A missing user reports no update.
139 assert!(
140 !fx.store
141 .update_profile("nope", crate::ProfileUpdate::default())
142 .await
143 .unwrap()
144 );
145 }
146}
147
148#[tokio::test]
149async fn labels_create_list_delete() {
150 for fx in sqlite_fixtures().await {
151 let owner = fx
152 .store
153 .create_user(sample_user("lo", "lo@example.com"))
154 .await
155 .unwrap();
156 let repo = fx
157 .store
158 .create_repo(NewRepo {
159 owner_id: owner.id,
160 group_id: None,
161 name: "r".to_string(),
162 path: "r".to_string(),
163 description: None,
164 visibility: model::Visibility::Private,
165 default_branch: "main".to_string(),
166 })
167 .await
168 .unwrap();
169 // New repos default to issues/PRs enabled.
170 assert!(repo.issues_enabled && repo.pulls_enabled);
171
172 let label = fx
173 .store
174 .create_label(&repo.id, "type: backend", "#ff0000", Some("server code"))
175 .await
176 .unwrap();
177 assert_eq!(label.scope(), Some("type"));
178
179 // Duplicate name (case-insensitive) conflicts.
180 let dup = fx
181 .store
182 .create_label(&repo.id, "Type: Backend", "#00ff00", None)
183 .await;
184 assert!(matches!(dup, Err(StoreError::Conflict { .. })));
185
186 let list = fx.store.list_labels(&repo.id).await.unwrap();
187 assert_eq!(list.len(), 1);
188
189 assert!(fx.store.delete_label(&label.id).await.unwrap());
190 assert!(fx.store.list_labels(&repo.id).await.unwrap().is_empty());
191
192 // Feature toggles round-trip.
193 assert!(
194 fx.store
195 .set_feature_enabled(&repo.id, "issues", false)
196 .await
197 .unwrap()
198 );
199 let repo = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
200 assert!(!repo.issues_enabled);
201 }
202}
203
204#[tokio::test]
205async fn collaborators_add_update_list_remove() {
206 for fx in sqlite_fixtures().await {
207 let owner = fx
208 .store
209 .create_user(sample_user("owner", "owner@example.com"))
210 .await
211 .unwrap();
212 let friend = fx
213 .store
214 .create_user(sample_user("friend", "friend@example.com"))
215 .await
216 .unwrap();
217 let repo = fx
218 .store
219 .create_repo(NewRepo {
220 owner_id: owner.id.clone(),
221 group_id: None,
222 name: "proj".to_string(),
223 path: "proj".to_string(),
224 description: None,
225 visibility: model::Visibility::Private,
226 default_branch: "main".to_string(),
227 })
228 .await
229 .unwrap();
230
231 fx.store
232 .set_collaborator(&repo.id, &friend.id, "read")
233 .await
234 .unwrap();
235 let list = fx.store.list_collaborators(&repo.id).await.unwrap();
236 assert_eq!(list.len(), 1);
237 assert_eq!(list[0].username, "friend");
238 assert_eq!(list[0].permission, "read");
239
240 // Upsert changes the permission in place.
241 fx.store
242 .set_collaborator(&repo.id, &friend.id, "write")
243 .await
244 .unwrap();
245 let list = fx.store.list_collaborators(&repo.id).await.unwrap();
246 assert_eq!(list.len(), 1, "upsert, not a second row");
247 assert_eq!(list[0].permission, "write");
248 assert_eq!(
249 fx.store
250 .collaborator_permission(&repo.id, &friend.id)
251 .await
252 .unwrap()
253 .as_deref(),
254 Some("write")
255 );
256
257 assert!(
258 fx.store
259 .remove_collaborator(&repo.id, &friend.id)
260 .await
261 .unwrap()
262 );
263 assert!(
264 fx.store
265 .list_collaborators(&repo.id)
266 .await
267 .unwrap()
268 .is_empty()
269 );
270 assert!(
271 !fx.store
272 .remove_collaborator(&repo.id, &friend.id)
273 .await
274 .unwrap()
275 );
276 }
277}
278
279#[tokio::test]
280async fn duplicate_username_conflicts_case_insensitively() {
281 for fx in sqlite_fixtures().await {
282 fx.store
283 .create_user(sample_user("dup", "a@example.com"))
284 .await
285 .unwrap();
286 let err = fx
287 .store
288 .create_user(sample_user("DUP", "b@example.com"))
289 .await
290 .unwrap_err();
291 assert!(
292 matches!(
293 err,
294 StoreError::Conflict {
295 entity: "user",
296 field: "username"
297 }
298 ),
299 "got {err:?}"
300 );
301 }
302}
303
304#[tokio::test]
305async fn duplicate_email_conflicts_case_insensitively() {
306 for fx in sqlite_fixtures().await {
307 fx.store
308 .create_user(sample_user("one", "same@example.com"))
309 .await
310 .unwrap();
311 let err = fx
312 .store
313 .create_user(sample_user("two", "SAME@example.com"))
314 .await
315 .unwrap_err();
316 assert!(
317 matches!(
318 err,
319 StoreError::Conflict {
320 entity: "user",
321 field: "email"
322 }
323 ),
324 "got {err:?}"
325 );
326 }
327}
328
329#[tokio::test]
330async fn invalid_username_is_rejected_before_insert() {
331 for fx in sqlite_fixtures().await {
332 let err = fx
333 .store
334 .create_user(sample_user("bad name", "x@example.com"))
335 .await
336 .unwrap_err();
337 assert!(matches!(err, StoreError::Name(_)), "got {err:?}");
338 }
339}
340
341#[tokio::test]
342async fn booleans_round_trip() {
343 for fx in sqlite_fixtures().await {
344 let boss = fx
345 .store
346 .create_user(NewUser {
347 is_admin: true,
348 must_change_password: true,
349 ..sample_user("boss", "boss@example.com")
350 })
351 .await
352 .unwrap();
353 assert!(boss.is_admin);
354 assert!(boss.must_change_password);
355
356 let fetched = fx.store.user_by_id(&boss.id).await.unwrap().unwrap();
357 assert!(fetched.is_admin, "is_admin must survive the round-trip");
358 assert!(fetched.must_change_password);
359
360 // And the default-false path.
361 let plain = fx.store.user_by_username("boss").await.unwrap();
362 assert!(plain.is_some());
363 let normal = fx
364 .store
365 .create_user(sample_user("normal", "normal@example.com"))
366 .await
367 .unwrap();
368 let normal = fx.store.user_by_id(&normal.id).await.unwrap().unwrap();
369 assert!(!normal.is_admin);
370 assert!(!normal.must_change_password);
371 }
372}
373
374/// Insert a bare session row for `user_id` so the session-deletion side effects
375/// of `set_password`/`set_disabled` can be observed. Returns nothing; the count
376/// is read back with [`session_count`].
377async fn insert_session(store: &Store, user_id: &str) {
378 sqlx::query(
379 "INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at) \
380 VALUES ($1, $2, 0, 0, 0)",
381 )
382 .bind(super::new_id())
383 .bind(user_id)
384 .execute(store.pool())
385 .await
386 .unwrap();
387}
388
389async fn session_count(store: &Store, user_id: &str) -> i64 {
390 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sessions WHERE user_id = $1")
391 .bind(user_id)
392 .fetch_one(store.pool())
393 .await
394 .unwrap()
395}
396
397#[tokio::test]
398async fn set_password_sets_clears_and_kills_sessions() {
399 for fx in sqlite_fixtures().await {
400 let user = fx
401 .store
402 .create_user(sample_user("pw", "pw@example.com"))
403 .await
404 .unwrap();
405 assert!(user.password_hash.is_none());
406 insert_session(&fx.store, &user.id).await;
407 assert_eq!(session_count(&fx.store, &user.id).await, 1);
408
409 // Setting a hash records it, clears must_change_password, and wipes
410 // sessions.
411 assert!(
412 fx.store
413 .set_password(&user.id, Some("$argon2id$fake"))
414 .await
415 .unwrap()
416 );
417 let after = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
418 assert_eq!(after.password_hash.as_deref(), Some("$argon2id$fake"));
419 assert!(!after.must_change_password);
420 assert!(after.updated_at >= user.updated_at);
421 assert_eq!(session_count(&fx.store, &user.id).await, 0);
422
423 // Clearing it returns to the invite-pending state.
424 assert!(fx.store.set_password(&user.id, None).await.unwrap());
425 let cleared = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
426 assert!(cleared.password_hash.is_none());
427
428 // A missing user reports no update.
429 assert!(!fx.store.set_password("ghost", Some("x")).await.unwrap());
430 }
431}
432
433#[tokio::test]
434async fn set_disabled_toggles_and_kills_sessions() {
435 for fx in sqlite_fixtures().await {
436 let user = fx
437 .store
438 .create_user(sample_user("dis", "dis@example.com"))
439 .await
440 .unwrap();
441 insert_session(&fx.store, &user.id).await;
442
443 assert!(fx.store.set_disabled(&user.id, true).await.unwrap());
444 let disabled = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
445 assert!(disabled.disabled_at.is_some());
446 assert_eq!(session_count(&fx.store, &user.id).await, 0);
447
448 assert!(fx.store.set_disabled(&user.id, false).await.unwrap());
449 let enabled = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
450 assert!(enabled.disabled_at.is_none());
451
452 assert!(!fx.store.set_disabled("ghost", true).await.unwrap());
453 }
454}
455
456#[tokio::test]
457async fn list_users_is_sorted_case_insensitively() {
458 for fx in sqlite_fixtures().await {
459 for name in ["Charlie", "alice", "Bob"] {
460 fx.store
461 .create_user(sample_user(name, &format!("{name}@example.com")))
462 .await
463 .unwrap();
464 }
465 let names: Vec<String> = fx
466 .store
467 .list_users()
468 .await
469 .unwrap()
470 .into_iter()
471 .map(|u| u.username)
472 .collect();
473 assert_eq!(names, ["alice", "Bob", "Charlie"]);
474 }
475}
476
477#[tokio::test]
478async fn list_users_paged_slices_by_limit_and_offset() {
479 for fx in sqlite_fixtures().await {
480 for name in ["alice", "Bob", "Charlie", "dave"] {
481 fx.store
482 .create_user(sample_user(name, &format!("{name}@example.com")))
483 .await
484 .unwrap();
485 }
486 let page1: Vec<String> = fx
487 .store
488 .list_users_paged(2, 0)
489 .await
490 .unwrap()
491 .into_iter()
492 .map(|u| u.username)
493 .collect();
494 assert_eq!(page1, ["alice", "Bob"]);
495 let page2: Vec<String> = fx
496 .store
497 .list_users_paged(2, 2)
498 .await
499 .unwrap()
500 .into_iter()
501 .map(|u| u.username)
502 .collect();
503 assert_eq!(page2, ["Charlie", "dave"]);
504 // Past the end yields nothing.
505 assert!(fx.store.list_users_paged(2, 4).await.unwrap().is_empty());
506 }
507}
508
509#[tokio::test]
510async fn delete_user_cascades_to_repos() {
511 for fx in sqlite_fixtures().await {
512 let user = fx
513 .store
514 .create_user(sample_user("gone", "gone@example.com"))
515 .await
516 .unwrap();
517 fx.store
518 .create_repo(NewRepo {
519 owner_id: user.id.clone(),
520 group_id: None,
521 name: "r".to_string(),
522 path: "r".to_string(),
523 description: None,
524 visibility: model::Visibility::Private,
525 default_branch: "main".to_string(),
526 })
527 .await
528 .unwrap();
529
530 assert!(fx.store.delete_user(&user.id).await.unwrap());
531 assert!(fx.store.user_by_id(&user.id).await.unwrap().is_none());
532 assert!(
533 fx.store.repos_by_owner(&user.id).await.unwrap().is_empty(),
534 "the repo should cascade away with its owner"
535 );
536 assert!(!fx.store.delete_user(&user.id).await.unwrap());
537 }
538}
539
540#[tokio::test]
541async fn repos_by_owner_lists_in_path_order() {
542 for fx in sqlite_fixtures().await {
543 let owner = fx
544 .store
545 .create_user(sample_user("multi", "multi@example.com"))
546 .await
547 .unwrap();
548 for name in ["zeta", "alpha", "mu"] {
549 fx.store
550 .create_repo(NewRepo {
551 owner_id: owner.id.clone(),
552 group_id: None,
553 name: name.to_string(),
554 path: name.to_string(),
555 description: None,
556 visibility: model::Visibility::Private,
557 default_branch: "main".to_string(),
558 })
559 .await
560 .unwrap();
561 }
562 let paths: Vec<String> = fx
563 .store
564 .repos_by_owner(&owner.id)
565 .await
566 .unwrap()
567 .into_iter()
568 .map(|r| r.path)
569 .collect();
570 assert_eq!(paths, ["alpha", "mu", "zeta"]);
571 }
572}
573
574#[tokio::test]
575async fn create_and_fetch_repo() {
576 for fx in sqlite_fixtures().await {
577 let owner = fx
578 .store
579 .create_user(sample_user("owner", "owner@example.com"))
580 .await
581 .unwrap();
582
583 let repo = fx
584 .store
585 .create_repo(NewRepo {
586 owner_id: owner.id.clone(),
587 group_id: None,
588 name: "project".to_string(),
589 path: "project".to_string(),
590 description: Some("a repo".to_string()),
591 visibility: model::Visibility::Public,
592 default_branch: "main".to_string(),
593 })
594 .await
595 .unwrap();
596 assert_eq!(repo.size_bytes, 0);
597 assert!(repo.pushed_at.is_none());
598 assert_eq!(repo.visibility, model::Visibility::Public);
599
600 let fetched = fx
601 .store
602 .repo_by_owner_path(&owner.id, "project")
603 .await
604 .unwrap()
605 .unwrap();
606 assert_eq!(fetched, repo);
607
608 // Same owner, same path collides.
609 let err = fx
610 .store
611 .create_repo(NewRepo {
612 owner_id: owner.id.clone(),
613 group_id: None,
614 name: "project".to_string(),
615 path: "project".to_string(),
616 description: None,
617 visibility: model::Visibility::Private,
618 default_branch: "main".to_string(),
619 })
620 .await
621 .unwrap_err();
622 assert!(
623 matches!(
624 err,
625 StoreError::Conflict {
626 entity: "repo",
627 field: "path"
628 }
629 ),
630 "got {err:?}"
631 );
632 }
633}
634
635#[tokio::test]
636async fn foreign_keys_are_enforced() {
637 // A repo whose owner does not exist must be rejected — proof the SQLite
638 // `foreign_keys` pragma took effect on the pooled connection.
639 for fx in sqlite_fixtures().await {
640 let err = fx
641 .store
642 .create_repo(NewRepo {
643 owner_id: "no-such-owner".to_string(),
644 group_id: None,
645 name: "orphan".to_string(),
646 path: "orphan".to_string(),
647 description: None,
648 visibility: model::Visibility::Private,
649 default_branch: "main".to_string(),
650 })
651 .await
652 .unwrap_err();
653 assert!(
654 matches!(err, StoreError::Query(_)),
655 "expected a database (FK) error, got {err:?}"
656 );
657 }
658}
659
660#[test]
661fn unsupported_url_is_rejected() {
662 let err = Backend::detect("mysql://localhost/db").unwrap_err();
663 assert!(
664 matches!(err, StoreError::UnsupportedUrl { .. }),
665 "got {err:?}"
666 );
667 assert_eq!(Backend::detect("sqlite::memory:").unwrap(), Backend::Sqlite);
668 assert_eq!(
669 Backend::detect("postgres://localhost/db").unwrap(),
670 Backend::Postgres
671 );
672 assert_eq!(
673 Backend::detect("postgresql://localhost/db").unwrap(),
674 Backend::Postgres
675 );
676}
677
678/// The two dialects' initial migrations must define the same tables with the
679/// same columns in the same order — a hermetic check that needs no database.
680/// The only sanctioned differences are the boolean column type and dialect
681/// punctuation.
682#[test]
683fn schema_files_are_equivalent() {
684 let sqlite = include_str!("../../../migrations/sqlite/0001_init.sql");
685 let postgres = include_str!("../../../migrations/postgres/0001_init.sql");
686
687 assert_eq!(
688 columns_by_table(sqlite),
689 columns_by_table(postgres),
690 "SQLite and Postgres schemas must define identical tables and columns"
691 );
692
693 // The boolean columns must use the dialect-appropriate storage type.
694 for column in ["must_change_password", "is_admin", "is_private"] {
695 assert!(
696 column_line(sqlite, column).contains("INTEGER"),
697 "SQLite {column} should be INTEGER"
698 );
699 assert!(
700 column_line(postgres, column).contains("BOOLEAN"),
701 "Postgres {column} should be BOOLEAN"
702 );
703 }
704}
705
706/// Extract, per table, the ordered list of column names from a migration file.
707/// Relies on the house style: each column on its own line beginning with the
708/// column name, table-level constraints beginning with an uppercase keyword.
709fn columns_by_table(sql: &str) -> std::collections::BTreeMap<String, Vec<String>> {
710 const CONSTRAINT_KEYWORDS: &[&str] = &["UNIQUE", "PRIMARY", "FOREIGN", "CHECK", "CONSTRAINT"];
711 let mut tables = std::collections::BTreeMap::new();
712 let mut current: Option<(String, Vec<String>)> = None;
713
714 for raw in sql.lines() {
715 let line = raw.trim();
716 if let Some(rest) = line.strip_prefix("CREATE TABLE ") {
717 let name = rest.trim_end_matches(" (").trim_end_matches('(').trim();
718 current = Some((name.to_string(), Vec::new()));
719 continue;
720 }
721 let Some((name, cols)) = current.as_mut() else {
722 continue;
723 };
724 if line.starts_with(')') {
725 let (name, cols) = current.take().expect("current table");
726 tables.insert(name, cols);
727 continue;
728 }
729 // A column line starts with an identifier; skip blanks, comments, and
730 // table-level constraints.
731 let first = line.split([' ', '\t']).next().unwrap_or("");
732 if first.is_empty()
733 || line.starts_with("--")
734 || CONSTRAINT_KEYWORDS.contains(&first.to_ascii_uppercase().as_str())
735 {
736 let _ = name;
737 continue;
738 }
739 cols.push(first.to_string());
740 }
741 tables
742}
743
744/// Return the first line defining `column` (for type assertions).
745fn column_line<'a>(sql: &'a str, column: &str) -> &'a str {
746 sql.lines()
747 .map(str::trim)
748 .find(|line| line.split([' ', '\t']).next() == Some(column))
749 .unwrap_or("")
750}
751
752#[tokio::test]
753async fn ensure_group_path_creates_intermediates_and_reuses() {
754 for fx in sqlite_fixtures().await {
755 let owner = fx
756 .store
757 .create_user(sample_user("g", "g@example.com"))
758 .await
759 .unwrap();
760
761 let leaf = fx
762 .store
763 .ensure_group_path(&owner.id, "backend/service/v1")
764 .await
765 .unwrap();
766 assert_eq!(leaf.name, "v1");
767 assert_eq!(leaf.path, "backend/service/v1");
768
769 // Every intermediate was created, parents linked.
770 let all = fx.store.groups_by_owner(&owner.id).await.unwrap();
771 let paths: Vec<&str> = all.iter().map(|g| g.path.as_str()).collect();
772 assert_eq!(paths, ["backend", "backend/service", "backend/service/v1"]);
773 let api = all.iter().find(|g| g.path == "backend/service").unwrap();
774 let backend = all.iter().find(|g| g.path == "backend").unwrap();
775 assert_eq!(api.parent_id.as_deref(), Some(backend.id.as_str()));
776 assert!(backend.parent_id.is_none());
777
778 // Re-ensuring reuses existing groups (no duplicates, same leaf id).
779 let again = fx
780 .store
781 .ensure_group_path(&owner.id, "backend/service/v1")
782 .await
783 .unwrap();
784 assert_eq!(again.id, leaf.id);
785 assert_eq!(fx.store.groups_by_owner(&owner.id).await.unwrap().len(), 3);
786
787 assert!(fx.store.delete_group(&backend.id).await.unwrap());
788 // Deleting the top group cascades to its descendants.
789 assert!(
790 fx.store
791 .groups_by_owner(&owner.id)
792 .await
793 .unwrap()
794 .is_empty()
795 );
796 }
797}
798
799#[tokio::test]
800async fn ensure_group_path_rejects_excessive_nesting() {
801 for fx in sqlite_fixtures().await {
802 let owner = fx
803 .store
804 .create_user(sample_user("deep", "deep@example.com"))
805 .await
806 .unwrap();
807 // Exactly MAX_GROUP_DEPTH levels is allowed.
808 let at_limit = (1..=model::MAX_GROUP_DEPTH)
809 .map(|n| format!("g{n}"))
810 .collect::<Vec<_>>()
811 .join("/");
812 assert!(
813 fx.store
814 .ensure_group_path(&owner.id, &at_limit)
815 .await
816 .is_ok()
817 );
818 // One level deeper is rejected.
819 let too_deep = format!("{at_limit}/extra");
820 assert!(matches!(
821 fx.store.ensure_group_path(&owner.id, &too_deep).await,
822 Err(StoreError::GroupTooDeep { max }) if max == model::MAX_GROUP_DEPTH
823 ));
824 }
825}
826
827#[tokio::test]
828async fn fork_parent_and_count_round_trip() {
829 for fx in sqlite_fixtures().await {
830 let owner = fx
831 .store
832 .create_user(sample_user("fk", "fk@example.com"))
833 .await
834 .unwrap();
835 let mk = |name: &str| NewRepo {
836 owner_id: owner.id.clone(),
837 group_id: None,
838 name: name.to_string(),
839 path: name.to_string(),
840 description: None,
841 visibility: model::Visibility::Public,
842 default_branch: "main".to_string(),
843 };
844 let parent = fx.store.create_repo(mk("upstream")).await.unwrap();
845 let fork = fx.store.create_repo(mk("myfork")).await.unwrap();
846 assert!(fork.fork_parent_id.is_none());
847 assert_eq!(fx.store.count_forks(&parent.id).await.unwrap(), 0);
848
849 fx.store
850 .set_fork_parent(&fork.id, &parent.id)
851 .await
852 .unwrap();
853 let reloaded = fx.store.repo_by_id(&fork.id).await.unwrap().unwrap();
854 assert_eq!(reloaded.fork_parent_id.as_deref(), Some(parent.id.as_str()));
855 assert_eq!(fx.store.count_forks(&parent.id).await.unwrap(), 1);
856 }
857}
858
859#[tokio::test]
860async fn releases_and_assets_round_trip() {
861 for fx in sqlite_fixtures().await {
862 let owner = fx
863 .store
864 .create_user(sample_user("rel", "rel@example.com"))
865 .await
866 .unwrap();
867 let repo = fx
868 .store
869 .create_repo(NewRepo {
870 owner_id: owner.id.clone(),
871 group_id: None,
872 name: "r".to_string(),
873 path: "r".to_string(),
874 description: None,
875 visibility: model::Visibility::Public,
876 default_branch: "main".to_string(),
877 })
878 .await
879 .unwrap();
880 let rel = fx
881 .store
882 .create_release(crate::NewRelease {
883 repo_id: repo.id.clone(),
884 tag: "v1.0".to_string(),
885 name: "First".to_string(),
886 body: Some("notes".to_string()),
887 is_prerelease: false,
888 is_draft: false,
889 author_id: owner.id.clone(),
890 })
891 .await
892 .unwrap();
893 // Duplicate tag conflicts.
894 assert!(matches!(
895 fx.store
896 .create_release(crate::NewRelease {
897 repo_id: repo.id.clone(),
898 tag: "v1.0".to_string(),
899 name: "dup".to_string(),
900 body: None,
901 is_prerelease: false,
902 is_draft: false,
903 author_id: owner.id.clone(),
904 })
905 .await,
906 Err(StoreError::Conflict { .. })
907 ));
908 assert_eq!(fx.store.list_releases(&repo.id).await.unwrap().len(), 1);
909 assert_eq!(
910 fx.store
911 .release_by_tag(&repo.id, "v1.0")
912 .await
913 .unwrap()
914 .unwrap()
915 .id,
916 rel.id
917 );
918
919 let asset = fx
920 .store
921 .add_release_asset(&rel.id, "bin.tar.gz", 4096, "application/gzip")
922 .await
923 .unwrap();
924 assert_eq!(
925 fx.store.list_release_assets(&rel.id).await.unwrap().len(),
926 1
927 );
928 // Deleting the release cascades to its assets.
929 assert!(fx.store.delete_release(&rel.id).await.unwrap());
930 assert!(
931 fx.store
932 .release_asset_by_id(&asset.id)
933 .await
934 .unwrap()
935 .is_none()
936 );
937 }
938}
939
940#[tokio::test]
941async fn mirrors_create_list_due_and_delete() {
942 for fx in sqlite_fixtures().await {
943 let owner = fx
944 .store
945 .create_user(sample_user("mir", "mir@example.com"))
946 .await
947 .unwrap();
948 let repo = fx
949 .store
950 .create_repo(NewRepo {
951 owner_id: owner.id,
952 group_id: None,
953 name: "r".to_string(),
954 path: "r".to_string(),
955 description: None,
956 visibility: model::Visibility::Private,
957 default_branch: "main".to_string(),
958 })
959 .await
960 .unwrap();
961 let m = fx
962 .store
963 .create_mirror(crate::NewMirror {
964 repo_id: repo.id.clone(),
965 direction: crate::MirrorDirection::Push,
966 remote_url: "https://example.com/r.git".to_string(),
967 username: Some("u".to_string()),
968 secret: Some("t".to_string()),
969 branch_filter: None,
970 interval_secs: 600,
971 sync_on_push: true,
972 })
973 .await
974 .unwrap();
975 assert_eq!(fx.store.mirrors_for_repo(&repo.id).await.unwrap().len(), 1);
976 // Never synced -> due now.
977 let due = fx.store.due_mirrors(super::now_ms()).await.unwrap();
978 assert_eq!(due.len(), 1);
979 // sync-on-push mirrors are returned for the repo.
980 assert_eq!(
981 fx.store.push_mirrors_on_push(&repo.id).await.unwrap().len(),
982 1
983 );
984 // After a successful sync, it is no longer due until the interval elapses.
985 fx.store.mirror_synced(&m.id, None).await.unwrap();
986 assert!(
987 fx.store
988 .due_mirrors(super::now_ms())
989 .await
990 .unwrap()
991 .is_empty()
992 );
993 let stored = fx.store.mirror_by_id(&m.id).await.unwrap().unwrap();
994 assert!(stored.last_sync_at.is_some() && stored.last_error.is_none());
995
996 assert!(fx.store.delete_mirror(&m.id).await.unwrap());
997 assert!(
998 fx.store
999 .mirrors_for_repo(&repo.id)
1000 .await
1001 .unwrap()
1002 .is_empty()
1003 );
1004 }
1005}
1006
1007#[tokio::test]
1008async fn lfs_objects_track_per_repo() {
1009 for fx in sqlite_fixtures().await {
1010 let owner = fx
1011 .store
1012 .create_user(sample_user("lfs", "lfs@example.com"))
1013 .await
1014 .unwrap();
1015 let repo = fx
1016 .store
1017 .create_repo(NewRepo {
1018 owner_id: owner.id,
1019 group_id: None,
1020 name: "r".to_string(),
1021 path: "r".to_string(),
1022 description: None,
1023 visibility: model::Visibility::Private,
1024 default_branch: "main".to_string(),
1025 })
1026 .await
1027 .unwrap();
1028 let oid = "a".repeat(64);
1029 assert!(!fx.store.lfs_object_exists(&repo.id, &oid).await.unwrap());
1030 fx.store.lfs_object_add(&repo.id, &oid, 1234).await.unwrap();
1031 // A repeat add is a no-op (idempotent), not a conflict.
1032 fx.store.lfs_object_add(&repo.id, &oid, 1234).await.unwrap();
1033 assert!(fx.store.lfs_object_exists(&repo.id, &oid).await.unwrap());
1034 assert_eq!(
1035 fx.store.lfs_object_size(&repo.id, &oid).await.unwrap(),
1036 Some(1234)
1037 );
1038 assert_eq!(fx.store.lfs_repo_bytes(&repo.id).await.unwrap(), 1234);
1039 }
1040}
1041
1042#[tokio::test]
1043async fn keys_get_stable_per_user_ordinals() {
1044 for fx in sqlite_fixtures().await {
1045 let user = fx
1046 .store
1047 .create_user(sample_user("k", "k@example.com"))
1048 .await
1049 .unwrap();
1050
1051 let new_key = |fp: &str, kind| NewKey {
1052 user_id: user.id.clone(),
1053 kind,
1054 name: None,
1055 fingerprint: fp.to_string(),
1056 public_key: format!("material-{fp}"),
1057 };
1058
1059 let k1 = fx
1060 .store
1061 .create_key(new_key("SHA256:a", KeyKind::Ssh))
1062 .await
1063 .unwrap();
1064 let k2 = fx
1065 .store
1066 .create_key(new_key("fp-b", KeyKind::Gpg))
1067 .await
1068 .unwrap();
1069 // Ordinals are unique per user across kinds, so `key del <index>` needs no
1070 // type.
1071 assert_eq!(k1.ordinal, 1);
1072 assert_eq!(k2.ordinal, 2);
1073
1074 // Duplicate fingerprint is rejected.
1075 let err = fx
1076 .store
1077 .create_key(new_key("SHA256:a", KeyKind::Ssh))
1078 .await
1079 .unwrap_err();
1080 assert!(
1081 matches!(err, StoreError::Conflict { entity: "key", .. }),
1082 "got {err:?}"
1083 );
1084
1085 // Delete the first; the second keeps its ordinal (no renumbering).
1086 assert!(fx.store.delete_key(&k1.id).await.unwrap());
1087 let remaining = fx.store.keys_by_user(&user.id).await.unwrap();
1088 assert_eq!(remaining.len(), 1);
1089 assert_eq!(remaining[0].ordinal, 2);
1090
1091 // A new key takes max(ordinal)+1, not a filled gap.
1092 let k3 = fx
1093 .store
1094 .create_key(new_key("SHA256:c", KeyKind::Ssh))
1095 .await
1096 .unwrap();
1097 assert_eq!(k3.ordinal, 3);
1098
1099 let by_ord = fx.store.key_by_ordinal(&user.id, 2).await.unwrap().unwrap();
1100 assert_eq!(by_ord.id, k2.id);
1101 let by_fp = fx
1102 .store
1103 .key_by_fingerprint(KeyKind::Gpg, "fp-b")
1104 .await
1105 .unwrap()
1106 .unwrap();
1107 assert_eq!(by_fp.id, k2.id);
1108 }
1109}
1110
1111#[tokio::test]
1112async fn tokens_create_list_and_revoke() {
1113 for fx in sqlite_fixtures().await {
1114 let user = fx
1115 .store
1116 .create_user(sample_user("t", "t@example.com"))
1117 .await
1118 .unwrap();
1119
1120 let first = fx
1121 .store
1122 .create_token(NewToken {
1123 user_id: user.id.clone(),
1124 name: "ci".to_string(),
1125 scopes: "repo:read".to_string(),
1126 expires_at: None,
1127 })
1128 .await
1129 .unwrap();
1130 fx.store
1131 .create_token(NewToken {
1132 user_id: user.id.clone(),
1133 name: "deploy".to_string(),
1134 scopes: "repo:read,repo:write".to_string(),
1135 expires_at: Some(now_plus_a_day()),
1136 })
1137 .await
1138 .unwrap();
1139
1140 let listed = fx.store.tokens_by_user(&user.id).await.unwrap();
1141 assert_eq!(listed.len(), 2);
1142 assert_eq!(listed[0].name, "ci", "ordered by creation");
1143
1144 // Revocation is what makes a token invalid to the API.
1145 assert!(fx.store.revoke_token(&first.id).await.unwrap());
1146 let revoked = fx.store.token_by_id(&first.id).await.unwrap().unwrap();
1147 assert!(revoked.revoked_at.is_some());
1148 // Revoking again is a no-op (already revoked).
1149 assert!(!fx.store.revoke_token(&first.id).await.unwrap());
1150
1151 assert!(fx.store.delete_token(&first.id).await.unwrap());
1152 assert!(fx.store.token_by_id(&first.id).await.unwrap().is_none());
1153 }
1154}
1155
1156fn now_plus_a_day() -> i64 {
1157 super::now_ms() + 86_400_000
1158}
1159
1160#[tokio::test]
1161async fn rename_and_delete_repo() {
1162 for fx in sqlite_fixtures().await {
1163 let owner = fx
1164 .store
1165 .create_user(sample_user("r", "r@example.com"))
1166 .await
1167 .unwrap();
1168 let repo = fx
1169 .store
1170 .create_repo(NewRepo {
1171 owner_id: owner.id.clone(),
1172 group_id: None,
1173 name: "old".to_string(),
1174 path: "old".to_string(),
1175 description: None,
1176 visibility: model::Visibility::Private,
1177 default_branch: "main".to_string(),
1178 })
1179 .await
1180 .unwrap();
1181
1182 assert!(fx.store.rename_repo(&repo.id, "new", "new").await.unwrap());
1183 assert!(
1184 fx.store
1185 .repo_by_owner_path(&owner.id, "old")
1186 .await
1187 .unwrap()
1188 .is_none()
1189 );
1190 let renamed = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
1191 assert_eq!(renamed.name, "new");
1192 assert_eq!(renamed.path, "new");
1193
1194 assert!(fx.store.delete_repo(&repo.id).await.unwrap());
1195 assert!(fx.store.repo_by_id(&repo.id).await.unwrap().is_none());
1196 assert!(!fx.store.delete_repo(&repo.id).await.unwrap());
1197 }
1198}
1199
1200#[tokio::test]
1201async fn sessions_resolve_the_user_and_respect_expiry() {
1202 use super::NewSession;
1203
1204 for fx in sqlite_fixtures().await {
1205 let user = fx
1206 .store
1207 .create_user(sample_user("s", "s@example.com"))
1208 .await
1209 .unwrap();
1210
1211 fx.store
1212 .create_session(NewSession {
1213 id: "sess-live".to_string(),
1214 user_id: user.id.clone(),
1215 user_agent: Some("curl".to_string()),
1216 ip: None,
1217 expires_at: now_plus_a_day(),
1218 })
1219 .await
1220 .unwrap();
1221 let resolved = fx.store.user_for_session("sess-live").await.unwrap();
1222 assert_eq!(resolved.map(|u| u.id), Some(user.id.clone()));
1223
1224 // An expired session resolves to nobody and is reaped.
1225 fx.store
1226 .create_session(NewSession {
1227 id: "sess-dead".to_string(),
1228 user_id: user.id.clone(),
1229 user_agent: None,
1230 ip: None,
1231 expires_at: 1,
1232 })
1233 .await
1234 .unwrap();
1235 assert!(
1236 fx.store
1237 .user_for_session("sess-dead")
1238 .await
1239 .unwrap()
1240 .is_none()
1241 );
1242 assert_eq!(fx.store.delete_expired_sessions().await.unwrap(), 1);
1243
1244 // Logout deletes the live session.
1245 assert!(fx.store.delete_session("sess-live").await.unwrap());
1246 assert!(
1247 fx.store
1248 .user_for_session("sess-live")
1249 .await
1250 .unwrap()
1251 .is_none()
1252 );
1253 }
1254}
1255
1256#[tokio::test]
1257async fn invites_round_trip_and_are_single_use() {
1258 use super::NewInvite;
1259
1260 for fx in sqlite_fixtures().await {
1261 let user = fx
1262 .store
1263 .create_user(sample_user("i", "i@example.com"))
1264 .await
1265 .unwrap();
1266
1267 let invite = fx
1268 .store
1269 .create_invite(NewInvite {
1270 user_id: user.id.clone(),
1271 token_hash: "hash-abc".to_string(),
1272 purpose: "activate".to_string(),
1273 expires_at: now_plus_a_day(),
1274 })
1275 .await
1276 .unwrap();
1277 assert!(invite.used_at.is_none());
1278
1279 let found = fx
1280 .store
1281 .invite_by_token_hash("hash-abc")
1282 .await
1283 .unwrap()
1284 .unwrap();
1285 assert_eq!(found.user_id, user.id);
1286 assert_eq!(found.purpose, "activate");
1287
1288 // Redemption is single-use: the first mark succeeds, the second does not.
1289 assert!(fx.store.mark_invite_used(&invite.id).await.unwrap());
1290 assert!(!fx.store.mark_invite_used(&invite.id).await.unwrap());
1291 let redeemed = fx
1292 .store
1293 .invite_by_token_hash("hash-abc")
1294 .await
1295 .unwrap()
1296 .unwrap();
1297 assert!(redeemed.used_at.is_some());
1298
1299 assert!(
1300 fx.store
1301 .invite_by_token_hash("nope")
1302 .await
1303 .unwrap()
1304 .is_none()
1305 );
1306 }
1307}
1308
1309/// Postgres round-trips, mirroring the SQLite ones. Ignored unless the
1310/// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a
1311/// reachable database; the test cleans up the rows it creates so it can rerun.
1312#[tokio::test]
1313#[cfg_attr(
1314 not(feature = "postgres-tests"),
1315 ignore = "enable the postgres-tests feature and set FABRICA_TEST_POSTGRES_URL"
1316)]
1317async fn postgres_round_trip() {
1318 let Ok(url) = std::env::var("FABRICA_TEST_POSTGRES_URL") else {
1319 eprintln!("skipping postgres_round_trip: FABRICA_TEST_POSTGRES_URL not set");
1320 return;
1321 };
1322
1323 let store = Store::connect(&url, 5).await.unwrap();
1324 assert_eq!(store.backend(), Backend::Postgres);
1325 store.migrate().await.unwrap();
1326
1327 // Unique names so repeated runs against a shared database don't collide.
1328 let suffix = super::new_id();
1329 let username = format!("pg{suffix}");
1330 let email = format!("{suffix}@example.com");
1331
1332 let admin = store
1333 .create_user(NewUser {
1334 is_admin: true,
1335 must_change_password: true,
1336 ..sample_user(&username, &email)
1337 })
1338 .await
1339 .unwrap();
1340
1341 let fetched = store.user_by_id(&admin.id).await.unwrap().unwrap();
1342 assert_eq!(fetched, admin);
1343 assert!(fetched.is_admin, "native BOOLEAN must decode as true");
1344 assert!(fetched.must_change_password);
1345
1346 let repo = store
1347 .create_repo(NewRepo {
1348 owner_id: admin.id.clone(),
1349 group_id: None,
1350 name: "pgproj".to_string(),
1351 path: "pgproj".to_string(),
1352 description: None,
1353 visibility: model::Visibility::Public,
1354 default_branch: "main".to_string(),
1355 })
1356 .await
1357 .unwrap();
1358 let fetched_repo = store
1359 .repo_by_owner_path(&admin.id, "pgproj")
1360 .await
1361 .unwrap()
1362 .unwrap();
1363 assert_eq!(fetched_repo, repo);
1364 assert_eq!(fetched_repo.visibility, model::Visibility::Public);
1365
1366 // Clean up (repo cascades with the user).
1367 sqlx::query("DELETE FROM users WHERE id = $1")
1368 .bind(&admin.id)
1369 .execute(store.pool())
1370 .await
1371 .unwrap();
1372}
1373
1374/// Helper: create a repo at `path`, auto-creating its group chain, and return it.
1375async fn repo_in_group(store: &Store, owner_id: &str, path: &str) -> model::Repo {
1376 let (group, leaf) = match path.rsplit_once('/') {
1377 Some((g, l)) => (Some(g), l),
1378 None => (None, path),
1379 };
1380 let group_id = match group {
1381 Some(g) => Some(store.ensure_group_path(owner_id, g).await.unwrap().id),
1382 None => None,
1383 };
1384 store
1385 .create_repo(NewRepo {
1386 owner_id: owner_id.to_string(),
1387 group_id,
1388 name: leaf.to_string(),
1389 path: path.to_string(),
1390 description: None,
1391 visibility: model::Visibility::Private,
1392 default_branch: "main".to_string(),
1393 })
1394 .await
1395 .unwrap()
1396}
1397
1398#[tokio::test]
1399async fn deleting_last_repo_gcs_empty_auto_groups() {
1400 for fx in sqlite_fixtures().await {
1401 let owner = fx
1402 .store
1403 .create_user(sample_user("gc", "gc@example.com"))
1404 .await
1405 .unwrap();
1406 // what/the/fuck where `fuck` is a repo; siblings keep their branch alive.
1407 let repo = repo_in_group(&fx.store, &owner.id, "what/the/fuck").await;
1408 let sibling = repo_in_group(&fx.store, &owner.id, "what/keep").await;
1409
1410 assert!(
1411 fx.store
1412 .group_by_owner_path(&owner.id, "what/the")
1413 .await
1414 .unwrap()
1415 .is_some()
1416 );
1417
1418 // Deleting `fuck` empties `the` (deleted), but `what` still holds `keep`.
1419 assert!(fx.store.delete_repo(&repo.id).await.unwrap());
1420 assert!(
1421 fx.store
1422 .group_by_owner_path(&owner.id, "what/the")
1423 .await
1424 .unwrap()
1425 .is_none(),
1426 "empty auto group `what/the` should be collected"
1427 );
1428 assert!(
1429 fx.store
1430 .group_by_owner_path(&owner.id, "what")
1431 .await
1432 .unwrap()
1433 .is_some(),
1434 "`what` still holds the `keep` repo"
1435 );
1436
1437 // Removing the last repo collects the whole chain up to the root.
1438 assert!(fx.store.delete_repo(&sibling.id).await.unwrap());
1439 assert!(
1440 fx.store
1441 .group_by_owner_path(&owner.id, "what")
1442 .await
1443 .unwrap()
1444 .is_none(),
1445 "the now-empty root auto group is collected too"
1446 );
1447 }
1448}
1449
1450#[tokio::test]
1451async fn manual_groups_survive_gc() {
1452 for fx in sqlite_fixtures().await {
1453 let owner = fx
1454 .store
1455 .create_user(sample_user("man", "man@example.com"))
1456 .await
1457 .unwrap();
1458 // Manually create `team`, then a repo under an auto subgroup.
1459 let team = fx.store.create_group(&owner.id, "team").await.unwrap();
1460 assert!(team.manual);
1461 let repo = repo_in_group(&fx.store, &owner.id, "team/auto/proj").await;
1462
1463 assert!(fx.store.delete_repo(&repo.id).await.unwrap());
1464 assert!(
1465 fx.store
1466 .group_by_owner_path(&owner.id, "team/auto")
1467 .await
1468 .unwrap()
1469 .is_none(),
1470 "auto subgroup collected"
1471 );
1472 assert!(
1473 fx.store
1474 .group_by_owner_path(&owner.id, "team")
1475 .await
1476 .unwrap()
1477 .is_some(),
1478 "manual group is never collected"
1479 );
1480 }
1481}
1482
1483#[tokio::test]
1484async fn group_collaborator_grants_effective_permission() {
1485 for fx in sqlite_fixtures().await {
1486 let owner = fx
1487 .store
1488 .create_user(sample_user("own", "own@example.com"))
1489 .await
1490 .unwrap();
1491 let collab = fx
1492 .store
1493 .create_user(sample_user("col", "col@example.com"))
1494 .await
1495 .unwrap();
1496 let repo = repo_in_group(&fx.store, &owner.id, "acme/deep/svc").await;
1497
1498 // No access before any grant.
1499 assert!(
1500 fx.store
1501 .effective_permission(&repo.id, &collab.id)
1502 .await
1503 .unwrap()
1504 .is_none()
1505 );
1506
1507 // Grant write on the top group; it cascades down to the nested repo.
1508 let acme = fx
1509 .store
1510 .group_by_owner_path(&owner.id, "acme")
1511 .await
1512 .unwrap()
1513 .unwrap();
1514 fx.store
1515 .add_group_collaborator(&acme.id, &collab.id, "write")
1516 .await
1517 .unwrap();
1518 assert_eq!(
1519 fx.store
1520 .effective_permission(&repo.id, &collab.id)
1521 .await
1522 .unwrap()
1523 .as_deref(),
1524 Some("write")
1525 );
1526
1527 // A stronger repo-level grant wins over the weaker group grant.
1528 fx.store
1529 .set_collaborator(&repo.id, &collab.id, "admin")
1530 .await
1531 .unwrap();
1532 assert_eq!(
1533 fx.store
1534 .effective_permission(&repo.id, &collab.id)
1535 .await
1536 .unwrap()
1537 .as_deref(),
1538 Some("admin")
1539 );
1540
1541 // The group-level effective permission also resolves for the group itself.
1542 let svc_group = fx
1543 .store
1544 .group_by_owner_path(&owner.id, "acme/deep")
1545 .await
1546 .unwrap()
1547 .unwrap();
1548 assert_eq!(
1549 fx.store
1550 .effective_group_permission(&svc_group.id, &collab.id)
1551 .await
1552 .unwrap()
1553 .as_deref(),
1554 Some("write"),
1555 "ancestor group grant is inherited"
1556 );
1557 }
1558}
1559
1560#[tokio::test]
1561async fn rename_group_moves_subtree() {
1562 for fx in sqlite_fixtures().await {
1563 let owner = fx
1564 .store
1565 .create_user(sample_user("rn", "rn@example.com"))
1566 .await
1567 .unwrap();
1568 let repo = repo_in_group(&fx.store, &owner.id, "old/sub/proj").await;
1569 let top = fx
1570 .store
1571 .group_by_owner_path(&owner.id, "old")
1572 .await
1573 .unwrap()
1574 .unwrap();
1575
1576 fx.store.rename_group(&top.id, "new").await.unwrap();
1577
1578 // The repo and descendant group paths are rewritten under the new prefix.
1579 let moved = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
1580 assert_eq!(moved.path, "new/sub/proj");
1581 assert!(
1582 fx.store
1583 .group_by_owner_path(&owner.id, "new/sub")
1584 .await
1585 .unwrap()
1586 .is_some()
1587 );
1588 assert!(
1589 fx.store
1590 .group_by_owner_path(&owner.id, "old/sub")
1591 .await
1592 .unwrap()
1593 .is_none()
1594 );
1595 }
1596}
1597
1598#[tokio::test]
1599async fn group_visibility_ceiling_clamps_subtree() {
1600 use model::Visibility;
1601 for fx in sqlite_fixtures().await {
1602 let owner = fx
1603 .store
1604 .create_user(sample_user("vis", "vis@example.com"))
1605 .await
1606 .unwrap();
1607 let org = fx.store.create_group(&owner.id, "org").await.unwrap();
1608 let repo = repo_in_group(&fx.store, &owner.id, "org/team/app").await;
1609 // Start everything fully public.
1610 fx.store
1611 .set_visibility(&repo.id, Visibility::Public)
1612 .await
1613 .unwrap();
1614 let team = fx
1615 .store
1616 .group_by_owner_path(&owner.id, "org/team")
1617 .await
1618 .unwrap()
1619 .unwrap();
1620 fx.store
1621 .set_group_visibility(&team.id, Visibility::Public)
1622 .await
1623 .unwrap();
1624 assert_eq!(
1625 fx.store.group_visibility_ceiling(&team.id).await.unwrap(),
1626 Visibility::Public
1627 );
1628
1629 // Lower the top group to internal and cascade the ceiling downward.
1630 fx.store
1631 .set_group_visibility(&org.id, Visibility::Internal)
1632 .await
1633 .unwrap();
1634 let org = fx.store.group_by_id(&org.id).await.unwrap().unwrap();
1635 fx.store.clamp_subtree_visibility(&org).await.unwrap();
1636
1637 let team = fx
1638 .store
1639 .group_by_owner_path(&owner.id, "org/team")
1640 .await
1641 .unwrap()
1642 .unwrap();
1643 assert_eq!(
1644 team.visibility,
1645 Visibility::Internal,
1646 "subgroup clamped to the new ceiling"
1647 );
1648 let repo = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
1649 assert_eq!(
1650 repo.visibility,
1651 Visibility::Internal,
1652 "repo clamped to the new ceiling"
1653 );
1654 assert_eq!(
1655 fx.store.group_visibility_ceiling(&team.id).await.unwrap(),
1656 Visibility::Internal
1657 );
1658 }
1659}