mod common;
use std::sync::Arc;
use common::Fixture;
use gunnar_core::{AuthTable, HashKind, NewRepo, RepoCatalog, RepoRef, Visibility, DEFAULT_BRANCH};
use gunnar_store::git_storage_trait::GitServe;
#[test]
fn a_repository_needs_a_namespace_that_exists() {
let fx = Fixture::new();
let path = RepoRef::new("acme", "widget").expect("legal");
let err = fx
.catalog
.create(NewRepo::new(path.clone()))
.expect_err("no namespace yet");
assert!(format!("{err}").contains("no such namespace"), "{err}");
fx.catalog.create_namespace("acme").expect("create");
let repo = fx
.catalog
.create(NewRepo::new(path.clone()))
.expect("create");
assert_eq!(repo.namespace, "acme");
assert_eq!(repo.name, "widget");
assert_eq!(repo.default_branch, DEFAULT_BRANCH);
assert_eq!(repo.visibility, Visibility::Private);
assert_eq!(repo.hash_kind, HashKind::Sha256, "sha256 is the default");
assert!(fx.catalog.create(NewRepo::new(path.clone())).is_err());
assert_eq!(fx.catalog.list(None).expect("list").len(), 1);
}
#[test]
fn both_hash_kinds_are_stored_and_kept_apart() {
let fx = Fixture::new();
let old = fx.repo_with("acme", "legacy", HashKind::Sha1, Visibility::Private);
let new = fx.repo_with("acme", "modern", HashKind::Sha256, Visibility::Private);
assert_eq!(old.hash_kind, HashKind::Sha1);
assert_eq!(new.hash_kind, HashKind::Sha256);
assert_eq!(
gix_hash::Kind::from(old.hash_kind),
gix_hash::Kind::Sha1,
"the conversion the object store is opened with must not collapse the two"
);
assert_eq!(gix_hash::Kind::from(new.hash_kind), gix_hash::Kind::Sha256);
assert_eq!(old.hash_kind.to_string(), "sha1");
assert_eq!(new.hash_kind.to_string(), "sha256");
assert_eq!(
fx.catalog.open(&old.path()).expect("open").hash_kind,
HashKind::Sha1
);
assert_eq!(
fx.catalog.open(&new.path()).expect("open").hash_kind,
HashKind::Sha256
);
}
#[test]
fn open_list_and_delete() {
let fx = Fixture::new();
fx.repo("acme", "one");
fx.repo("acme", "two");
fx.repo("other", "three");
assert_eq!(fx.catalog.list(None).expect("list").len(), 3);
assert_eq!(fx.catalog.list(Some("acme")).expect("list").len(), 2);
assert_eq!(fx.catalog.list(Some("nope")).expect("list").len(), 0);
assert_eq!(fx.catalog.counts().expect("counts"), (2, 3));
let path = RepoRef::new("acme", "one").expect("legal");
assert!(fx.catalog.contains(&path).expect("contains"));
let gone = fx.catalog.delete(&path).expect("delete");
assert_eq!(gone.name, "one");
assert!(!fx.catalog.contains(&path).expect("contains"));
assert!(fx.catalog.open(&path).is_err());
assert!(
fx.catalog.delete(&path).is_err(),
"deleting twice is an error"
);
assert_eq!(fx.catalog.list(None).expect("list").len(), 2);
}
#[test]
fn a_legal_path_that_is_not_in_the_catalog_is_not_a_repository() {
let fx = Fixture::new();
fx.repo("acme", "widget");
let path = gunnar_core::resolve_repo_path("acme/nothing.git").expect("legal path");
let err = fx.catalog.open(&path).expect_err("not catalogued");
assert!(format!("{err}").contains("no such repository"), "{err}");
assert!(!err.is_path_rejection(), "a miss is not a path rejection");
}
#[test]
fn a_namespace_is_not_deleted_out_from_under_its_repositories() {
let fx = Fixture::new();
fx.repo("acme", "one");
fx.repo("acme", "two");
let err = fx
.catalog
.delete_namespace("acme", false)
.expect_err("not empty");
assert!(format!("{err}").contains("still holds 2"), "{err}");
assert_eq!(fx.catalog.list(None).expect("list").len(), 2);
let taken = fx.catalog.delete_namespace("acme", true).expect("delete");
assert_eq!(taken.len(), 2);
assert!(fx.catalog.list(None).expect("list").is_empty());
assert!(fx.catalog.namespace("acme").expect("read").is_none());
assert!(fx.catalog.delete_namespace("acme", true).is_err());
}
#[test]
fn namespaces_are_validated_like_every_other_path_component() {
let fx = Fixture::new();
assert!(fx.catalog.create_namespace("acme").is_ok());
assert!(fx.catalog.create_namespace("team/sub").is_ok());
assert!(
fx.catalog.create_namespace("acme").is_err(),
"no duplicates"
);
for bad in [
"", "..", "../etc", "a/../b", ".hidden", "-flag", "ns\0", "café", "a/b/c/d",
] {
assert!(
fx.catalog.create_namespace(bad).is_err(),
"{bad:?} must not become a namespace"
);
}
assert_eq!(fx.catalog.namespaces().expect("list").len(), 2);
}
#[test]
fn the_default_branch_must_be_a_full_ref_name() {
let fx = Fixture::new();
fx.catalog.create_namespace("acme").expect("ns");
let path = RepoRef::new("acme", "widget").expect("legal");
for bad in [
"main",
"refs/heads/",
"refs/tags/v1",
"refs/heads/..",
"refs/heads/a b",
] {
assert!(
fx.catalog
.create(NewRepo::new(path.clone()).default_branch(bad))
.is_err(),
"{bad:?} must not be a default branch"
);
}
let repo = fx
.catalog
.create(NewRepo::new(path.clone()).default_branch("refs/heads/trunk"))
.expect("create");
assert_eq!(repo.default_branch, "refs/heads/trunk");
let moved = fx
.catalog
.set_default_branch(&path, "refs/heads/release")
.expect("set");
assert_eq!(moved.default_branch, "refs/heads/release");
assert!(fx.catalog.set_default_branch(&path, "release").is_err());
assert_eq!(
fx.catalog.open(&path).expect("open").default_branch,
"refs/heads/release"
);
}
#[test]
fn head_is_a_projection_of_the_catalogs_default_branch_and_says_when_it_moved() {
let git = gunnar_store::mem::MemGitServe::new(gix_hash::Kind::Sha256);
let head = |git: &gunnar_store::mem::MemGitServe| -> Option<String> {
git.head().expect("head").and_then(|row| row.symref_target)
};
assert_eq!(head(&git), None);
assert!(
gunnar_core::point_head_at(&git, "refs/heads/master").expect("point"),
"writing HEAD where there was none is a move and must be reported as one"
);
assert_eq!(head(&git).as_deref(), Some("refs/heads/master"));
assert!(
!gunnar_core::point_head_at(&git, "refs/heads/master").expect("point"),
"HEAD already named the default branch, so nothing moved and nothing may claim to"
);
assert!(gunnar_core::point_head_at(&git, "refs/heads/trunk").expect("point"));
assert_eq!(head(&git).as_deref(), Some("refs/heads/trunk"));
for bad in ["trunk", "refs/tags/v1", "refs/heads/..", ""] {
assert!(
gunnar_core::point_head_at(&git, bad).is_err(),
"{bad:?} must not be projectable onto HEAD"
);
}
assert_eq!(
head(&git).as_deref(),
Some("refs/heads/trunk"),
"a refused name must leave HEAD where it was"
);
}
#[test]
fn a_second_handle_over_the_same_stores_sees_the_same_catalog() {
let fx = Fixture::new();
fx.repo("acme", "widget");
let second = RepoCatalog::new(Arc::clone(&fx.store)).expect("open");
assert_eq!(second.list(None).expect("list").len(), 1);
second
.create(NewRepo::new(RepoRef::new("acme", "other").expect("legal")))
.expect("create through the second handle");
assert_eq!(fx.catalog.list(None).expect("list").len(), 2);
assert_ne!(fx.catalog.ref_name(), fx.auth.ref_name());
assert!(fx
.store
.read_doc(fx.catalog.ref_name())
.expect("read")
.is_some());
}
#[test]
fn the_catalog_survives_a_restart_of_the_control_database() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("control.redb");
{
let store = gunnar_core::open_control_store(&path).expect("open");
let catalog = RepoCatalog::new(Arc::clone(&store)).expect("catalog");
let auth = AuthTable::new(store).expect("auth");
catalog.create_namespace("acme").expect("ns");
catalog
.create(
NewRepo::new(RepoRef::new("acme", "widget").expect("legal"))
.hash_kind(HashKind::Sha1)
.visibility(Visibility::Public),
)
.expect("create");
auth.add_principal(
gunnar_core::Principal::new("ada", "Ada", gunnar_core::PrincipalKind::User)
.expect("id"),
)
.expect("add");
}
let store = gunnar_core::open_control_store(&path).expect("reopen");
let catalog = RepoCatalog::new(Arc::clone(&store)).expect("catalog");
let auth = AuthTable::new(Arc::clone(&store)).expect("auth");
let repos = catalog.list(None).expect("list");
assert_eq!(repos.len(), 1, "the catalog did not survive the restart");
assert_eq!(repos[0].name, "widget");
assert_eq!(repos[0].hash_kind, HashKind::Sha1);
assert_eq!(repos[0].visibility, Visibility::Public);
assert_eq!(catalog.namespaces().expect("list").len(), 1);
assert_eq!(auth.principals().expect("list").len(), 1);
let history = auth.history().expect("history");
assert_eq!(
history.len(),
1,
"one principal was added, so the identity table has exactly one version"
);
assert_eq!(
catalog.history().expect("history").len(),
2,
"a namespace and a repository are two changes to the catalog"
);
}
#[test]
fn a_corrupt_catalog_document_is_an_error_not_an_empty_catalog() {
let fx = Fixture::new();
fx.repo("acme", "widget");
let junk = fx.store.put_blob(b"not json at all").expect("write");
let current = fx.store.read_doc(fx.catalog.ref_name()).expect("read");
assert!(fx
.store
.commit_doc(fx.catalog.ref_name(), current, junk)
.expect("commit"));
let err = fx.catalog.list(None).expect_err("must not read as empty");
assert!(format!("{err}").contains("unusable"), "{err}");
}
#[test]
fn an_accounts_explode_quota_survives_a_reload_and_unset_is_not_unlimited() {
let fx = Fixture::new();
fx.catalog.create_namespace("acme").expect("create");
assert_eq!(
fx.catalog.namespace_explode_budget("acme").expect("read"),
None,
"a new account was born with a hard-coded quota instead of the server default"
);
let set = fx
.catalog
.set_namespace_explode_budget("acme", Some(64 << 30))
.expect("set");
assert_eq!(set.explode_budget_bytes, Some(64 << 30));
assert_eq!(
fx.catalog.namespace_explode_budget("acme").expect("read"),
Some(64 << 30),
"the quota did not survive the write"
);
let listed = fx
.catalog
.namespaces()
.expect("list")
.into_iter()
.find(|ns| ns.name == "acme")
.expect("acme is listed");
assert_eq!(listed.explode_budget_bytes, Some(64 << 30));
fx.catalog
.set_namespace_explode_budget("acme", Some(0))
.expect("set");
assert_eq!(
fx.catalog.namespace_explode_budget("acme").expect("read"),
Some(0),
"`unlimited` was stored as `unset`"
);
fx.catalog
.set_namespace_explode_budget("acme", None)
.expect("clear");
assert_eq!(
fx.catalog.namespace_explode_budget("acme").expect("read"),
None,
"clearing a quota left the account unlimited instead of on the default"
);
let err = fx
.catalog
.set_namespace_explode_budget("nobody", Some(1 << 30))
.expect_err("no such namespace");
assert!(format!("{err}").contains("no such namespace"), "{err}");
}