@@ -51,188 +51,10 @@
%{repo: repo, config: config, unique_id: unique_id}
end
describe "object storage" do
test "write and read a blob", %{repo: repo} do
blob = Blob.from_content("hello world")
assert {:ok, sha} = Object.write(repo, blob)
assert String.length(sha) == 40
assert {:ok, decoded} = Object.read(repo, sha)
assert %Blob{content: "hello world"} = decoded
end
test "read non-existent object returns error", %{repo: repo} do
assert {:error, :not_found} =
Object.read(repo, "deadbeef" <> String.duplicate("0", 32))
end
test "write and read a tree", %{repo: repo} do
blob = Blob.from_content("file content")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
{:ok, decoded} = Object.read(repo, tree_sha)
assert %Tree{entries: [entry]} = decoded
assert entry.name == "file.txt"
assert entry.sha == blob_sha
end
test "write and read a commit", %{repo: repo} do
tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1234567890 +0000",
committer: "Test <test@test.com> 1234567890 +0000",
message: "init\n"
}
{:ok, sha} = Object.write(repo, commit)
{:ok, decoded} = Object.read(repo, sha)
assert %Commit{} = decoded
assert decoded.tree == tree_sha
assert decoded.message == "init\n"
end
test "object_exists? returns correct values", %{repo: repo} do
blob = Blob.from_content("exists check")
sha = Object.hash(blob)
refute Repo.storage_call(repo, :object_exists?, [sha])
{:ok, _} = Object.write(repo, blob)
assert Repo.storage_call(repo, :object_exists?, [sha])
end
end
describe "ref storage" do
test "put and get ref", %{repo: repo} do
sha = String.duplicate("a", 40)
assert :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
assert {:ok, ^sha} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "get non-existent ref returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/nope"])
end
use ExGitObjectstore.Test.StorageConformance
test "CAS put_ref succeeds with matching old_sha", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
# Backend-specific tests for the S3 implementation.
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
assert :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, sha1])
assert {:ok, ^sha2} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "CAS put_ref fails with mismatched old_sha", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
wrong = String.duplicate("c", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, wrong])
end
test "CAS put_ref fails when ref does not exist", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, sha2])
end
test "delete ref", %{repo: repo} do
sha = String.duplicate("a", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
:ok = Repo.storage_call(repo, :delete_ref, ["refs/heads/main"])
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "list refs under prefix", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/dev", sha2, nil])
:ok = Repo.storage_call(repo, :put_ref, ["refs/tags/v1", sha1, nil])
{:ok, heads} = Repo.storage_call(repo, :list_refs, ["refs/heads/"])
assert length(heads) == 2
ref_names = Enum.map(heads, &elem(&1, 0))
assert "refs/heads/dev" in ref_names
assert "refs/heads/main" in ref_names
end
end
describe "HEAD storage" do
test "put and get HEAD", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_head, ["ref: refs/heads/main"])
assert {:ok, "ref: refs/heads/main"} = Repo.storage_call(repo, :get_head, [])
end
test "get HEAD when not set returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_head, [])
end
end
describe "pack storage" do
test "put and get pack", %{repo: repo} do
pack_data = "PACK" <> <<0, 0, 0, 2>> <> "fake pack data"
idx_data = "fake index data"
:ok = Repo.storage_call(repo, :put_pack, ["abc123", pack_data, idx_data])
assert {:ok, ^pack_data} = Repo.storage_call(repo, :get_pack, ["abc123"])
assert {:ok, ^idx_data} = Repo.storage_call(repo, :get_pack_index, ["abc123"])
end
test "get non-existent pack returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack, ["nonexistent"])
end
test "get non-existent pack index returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack_index, ["nonexistent"])
end
test "list packs", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["abc123", "pack1", "idx1"])
:ok = Repo.storage_call(repo, :put_pack, ["def456", "pack2", "idx2"])
{:ok, packs} = Repo.storage_call(repo, :list_packs, [])
assert length(packs) == 2
assert "abc123" in packs
assert "def456" in packs
end
test "list packs returns empty when none exist", %{repo: repo} do
{:ok, packs} = Repo.storage_call(repo, :list_packs, [])
assert packs == []
end
test "stream_pack returns enumerable", %{repo: repo} do
pack_data = "test pack content"
:ok = Repo.storage_call(repo, :put_pack, ["abc123", pack_data, "idx"])
{:ok, stream} = Repo.storage_call(repo, :stream_pack, ["abc123"])
assert IO.iodata_to_binary(Enum.to_list(stream)) == pack_data
end
test "stream_pack returns error for non-existent pack", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :stream_pack, ["nonexistent"])
end
end
describe "S3 list pagination" do
# S3 ListObjectsV2 returns at most 1000 keys by default.
# We test that our s3_list_all implementation correctly handles
@@ -257,7 +79,6 @@
assert length(listed_packs) == pack_count
# Verify all expected pack SHAs are present
listed_set = MapSet.new(listed_packs)
for sha <- pack_shas do
@@ -268,7 +89,6 @@
@tag timeout: 120_000
test "list_refs handles paginated results with many refs", %{repo: repo} do
# Write more than 1000 refs under refs/heads/ to trigger pagination
ref_count = 1001
expected_refs =
@@ -294,9 +114,7 @@
end
end
describe "parallelized operations" do
describe "S3 parallelized operations" do
test "list_refs returns all refs under concurrency: 1", %{unique_id: unique_id} do
# Smoke test that the concurrency tuning path works end-to-end.
# Sets max_concurrency: 1, effectively sequential, and verifies correctness.
config = Map.put(@minio_config, :list_refs_concurrency, 1)
repo = Repo.new(unique_id, storage: {S3, config})
@@ -318,7 +136,6 @@
end
test "put_pack round-trips both pack and idx concurrently", %{repo: repo} do
# Larger bytes than the basic put_pack test to make concurrent uploads realistic.
pack_data = "PACK" <> <<0, 0, 0, 2>> <> :crypto.strong_rand_bytes(128 * 1024)
idx_data = :crypto.strong_rand_bytes(32 * 1024)
pack_sha = :crypto.hash(:sha, pack_data) |> Base.encode16(case: :lower)
@@ -330,17 +147,15 @@
end
test "put_pack propagates error when bucket does not exist", %{unique_id: unique_id} do
# Use a nonexistent bucket to force a PUT failure on both tasks.
bad_config = %{@minio_config | bucket: "this-bucket-does-not-exist-#{unique_id}"}
bad_repo = Repo.new(unique_id, storage: {S3, bad_config})
result = Repo.storage_call(bad_repo, :put_pack, ["deadbeef", "pack", "idx"])
result =
Repo.storage_call(bad_repo, :put_pack, ["deadbeef", "pack", "idx"])
assert match?({:error, _}, result)
end
end
describe "storage telemetry events" do
describe "S3 storage telemetry events" do
setup %{unique_id: unique_id} = ctx do
test_pid = self()
@@ -393,7 +208,7 @@
end
end
describe "full round-trip through ExGitObjectstore API" do
describe "S3 full round-trip through ExGitObjectstore API" do
test "init, write objects, create branch, resolve", %{repo: repo} do
:ok = ExGitObjectstore.init(repo)
assert {:ok, "main"} = ExGitObjectstore.default_branch(repo)
@@ -420,6 +235,14 @@
end
end
describe "S3 blob path traversal" do
test "rejects traversal", %{repo: repo} do
assert_raise ArgumentError, fn ->
Repo.storage_call(repo, :put_blob, ["../evil", "x"])
end
end
end
# -- Helpers --
defp ensure_bucket do
@@ -442,13 +265,11 @@
end
defp cleanup_prefix(config, prefix) do
# List and delete all objects under this prefix
case list_all_keys(config, prefix, nil, []) do
{:ok, []} ->
:ok
{:ok, keys} ->
# Delete in batches of 1000 (S3 multi-delete limit)
keys
|> Enum.chunk_every(1000)
|> Enum.each(fn batch ->
@@ -458,41 +279,5 @@
{:error, _} ->
:ok
end
end
describe "blob storage" do
test "put/get round-trips", %{repo: repo} do
data = :crypto.strong_rand_bytes(2048)
:ok = Repo.storage_call(repo, :put_blob, ["graph/commit-graph.v1", data])
assert {:ok, ^data} = Repo.storage_call(repo, :get_blob, ["graph/commit-graph.v1"])
end
test "get on missing blob", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["graph/commit-graph.v1"])
end
test "put overwrites", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_blob, ["k", "v1"])
:ok = Repo.storage_call(repo, :put_blob, ["k", "v2"])
assert {:ok, "v2"} = Repo.storage_call(repo, :get_blob, ["k"])
end
test "delete removes", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_blob, ["k", "v"])
:ok = Repo.storage_call(repo, :delete_blob, ["k"])
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["k"])
end
test "blob_exists?", %{repo: repo} do
refute Repo.storage_call(repo, :blob_exists?, ["k"])
:ok = Repo.storage_call(repo, :put_blob, ["k", "v"])
assert Repo.storage_call(repo, :blob_exists?, ["k"])
end
test "rejects traversal", %{repo: repo} do
assert_raise ArgumentError, fn ->
Repo.storage_call(repo, :put_blob, ["../evil", "x"])
end
end
end