ref:45d05a6ce1455f034511587bba02c1d4f2361834

test(storage): shared conformance module — same contract for every backend

Before: each backend had its own ~25 tests duplicating the same scenarios (write/read blob, put/get ref, CAS, HEAD, packs, blobs). 84 source tests across the three files for ~25 logical scenarios. Drift was easy and silent: Filesystem tested CAS one way, S3 another, Memory a third — the "contract" lived in three slightly-different copies. This commit extracts the contract into a shared `StorageConformance` module under `test/support/`. Each backend's test file now: setup do %{repo: build_a_fresh_repo()} end use ExGitObjectstore.Test.StorageConformance # backend-specific tests below The shared module covers ~27 contract scenarios (object/ref/HEAD/pack/ blob CRUD plus CAS semantics). Backend-specific tests stay in each file: Filesystem disk-layout / orphaned packs / lock files / C9 ref deletion / C10 stale locks; S3 pagination / parallelization / telemetry / full round-trip; Memory namespacing / traversal. Net coverage: Memory 24 → 29 tests (was missing several CAS scenarios) Filesystem 25 → 36 tests (was missing blob-overwrite / non-existent pack / non-existent pack index / list-empty and several ref scenarios) S3 35 → 36 tests (was the most thorough — picked up a couple of cross-backend additions) Total 84 → 101 This is the foundation for the four-class test scheme: 1. Contract (this commit) — runs against ALL backends. Drift impossible. 2. Application logic — single backend (Memory). 3. Backend-specific — relevant backend only (each file's own describe blocks). 4. Integration / E2E — parameterized over realistic backends. Future work. Mirrors the pattern already established by `LfsStoreConformance`. Verified: full ex_git_objectstore suite (971 tests) green with MinIO running locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: 45d05a6ce1455f034511587bba02c1d4f2361834
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-29 18:55
Parents: bdb8027
4 files changed +308 -594
Type
test/ex_git_objectstore/storage/filesystem_test.exs +15 −141
@@ -24,21 +24,17 @@
root = Path.join(tmp_dir, "fs_store_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(root)
repo =
Repo.new("test-repo", storage: {Filesystem, %{root: root}})
repo = Repo.new("test-repo", storage: {Filesystem, %{root: root}})
on_exit(fn -> File.rm_rf!(root) end)
%{repo: repo, root: root}
end
describe "object storage" do
test "write and read a blob", %{repo: repo} do
blob = Blob.from_content("hello world")
{:ok, sha} = Object.write(repo, blob)
{:ok, decoded} = Object.read(repo, sha)
assert %Blob{content: "hello world"} = decoded
end
use ExGitObjectstore.Test.StorageConformance
# Backend-specific tests for the Filesystem implementation.
describe "Filesystem on-disk layout" do
test "object is stored in <sha[0:2]>/<sha[2:]> layout", %{repo: repo, root: root} do
blob = Blob.from_content("test")
{:ok, sha} = Object.write(repo, blob)
@@ -48,89 +44,17 @@
assert File.exists?(path)
end
test "read non-existent object", %{repo: repo} do
assert {:error, :not_found} = Object.read(repo, String.duplicate("0", 40))
end
test "object_exists?", %{repo: repo} do
blob = Blob.from_content("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)
: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 "CAS put_ref", %{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 :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, sha1])
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, wrong])
test "blob lives at <root>/<prefix>/blobs/<key>", %{repo: repo, root: root} do
:ok = Repo.storage_call(repo, :put_blob, ["graph/commit-graph.v1", "payload"])
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"])
assert File.read!(Path.join([root, "repos/test-repo/blobs/graph/commit-graph.v1"])) ==
"payload"
end
test "list refs", %{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" 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
end
describe "pack storage" do
test "put, get, list packs", %{repo: repo} do
: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"])
assert {:ok, ["abc123"]} = Repo.storage_call(repo, :list_packs, [])
end
test "stream_pack", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["def456", "stream-data", "idx"])
{:ok, stream} = Repo.storage_call(repo, :stream_pack, ["def456"])
result = stream |> Enum.to_list() |> IO.iodata_to_binary()
assert result == "stream-data"
end
test "list_packs skips orphaned packs without matching idx (H1)", %{repo: repo, root: root} do
describe "Filesystem orphaned-pack handling (H1)" do
test "list_packs skips orphaned packs without matching idx", %{repo: repo, root: root} do
# Write a complete pack (both .pack and .idx)
:ok = Repo.storage_call(repo, :put_pack, ["complete1", "pack-data", "idx-data"])
# Create an orphaned .pack file (no .idx) to simulate a crash during put_pack
pack_dir = Path.join([root, "repos/test-repo/objects/pack"])
File.write!(Path.join(pack_dir, "pack-orphaned1.pack"), "orphan-data")
@@ -141,11 +65,10 @@
end
end
describe "C9: list_refs handles concurrent ref deletion" do
describe "Filesystem concurrent ref deletion (C9)" do
test "list_refs skips refs deleted between listing and reading", %{root: root} do
config = %{root: root}
prefix = "repos/test-repo"
# Create ref directory and files
ref_dir = Path.join([root, prefix, "refs", "heads"])
File.mkdir_p!(ref_dir)
@@ -155,14 +78,11 @@
File.write!(Path.join(ref_dir, "main"), sha1 <> "\n")
File.write!(Path.join(ref_dir, "dev"), sha2 <> "\n")
# Verify both refs listed
{:ok, refs} = Filesystem.list_refs(config, prefix, "refs/heads/")
assert length(refs) == 2
# Now delete one ref file to simulate concurrent deletion
File.rm!(Path.join(ref_dir, "dev"))
# list_refs should still succeed, just omitting the deleted ref
{:ok, refs} = Filesystem.list_refs(config, prefix, "refs/heads/")
assert length(refs) == 1
assert {"refs/heads/main", ^sha1} = hd(refs)
@@ -178,8 +98,6 @@
sha = String.duplicate("a", 40)
File.write!(Path.join(ref_dir, "main"), sha <> "\n")
# Delete the ref between the dir listing and file read
# We simulate this by deleting after dir creation but it tests the code path
File.rm!(Path.join(ref_dir, "main"))
{:ok, refs} = Filesystem.list_refs(config, prefix, "refs/heads/")
@@ -187,11 +105,10 @@
end
end
describe "C10: stale lock detection" do
describe "Filesystem stale lock detection (C10)" do
test "put_ref returns :ref_locked for fresh lock files", %{repo: repo, root: root} do
sha = String.duplicate("a", 40)
# Create the ref directory and a fresh lock file
ref_path = Path.join([root, "repos/test-repo/refs/heads"])
File.mkdir_p!(ref_path)
lock_path = Path.join(ref_path, "main.lock")
@@ -200,20 +117,17 @@
assert {:error, :ref_locked} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
# Clean up
File.rm(lock_path)
end
test "put_ref breaks stale lock and succeeds", %{repo: repo, root: root} do
sha = String.duplicate("a", 40)
# Create the ref directory and a lock file
ref_path = Path.join([root, "repos/test-repo/refs/heads"])
File.mkdir_p!(ref_path)
lock_path = Path.join(ref_path, "main.lock")
File.write!(lock_path, "stale-lock\n")
# Set the lock file's mtime to 10 minutes ago (well past the 5-minute threshold)
past_time = :os.system_time(:second) - 600
past_datetime =
@@ -223,13 +137,12 @@
File.touch!(lock_path, past_datetime)
# put_ref should break the stale lock and succeed
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
end
describe "full round-trip through ExGitObjectstore API" do
describe "Filesystem 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)
@@ -256,46 +169,7 @@
end
end
describe "blob storage" do
test "put/get round-trips", %{repo: repo} do
data = :crypto.strong_rand_bytes(4096)
: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 "blob lives at <root>/<prefix>/blobs/<key>", %{repo: repo, root: root} do
:ok = Repo.storage_call(repo, :put_blob, ["graph/commit-graph.v1", "payload"])
assert File.read!(Path.join([root, "repos/test-repo/blobs/graph/commit-graph.v1"])) ==
"payload"
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 "delete on missing is :ok", %{repo: repo} do
assert :ok = Repo.storage_call(repo, :delete_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
describe "Filesystem blob path traversal" do
test "rejects traversal", %{repo: repo} do
assert_raise ArgumentError, fn ->
Repo.storage_call(repo, :put_blob, ["../evil", "x"])
test/ex_git_objectstore/storage/memory_test.exs +11 −224
@@ -15,233 +15,22 @@
defmodule ExGitObjectstore.Storage.MemoryTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Storage.Memory
alias ExGitObjectstore.Test.RepoHelper
describe "object storage" do
test "write and read a blob" do
repo = RepoHelper.memory_repo()
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" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Object.read(repo, "deadbeef" <> String.duplicate("0", 32))
end
test "write and read a tree" do
repo = RepoHelper.memory_repo()
# Write a blob first
blob = Blob.from_content("file content")
{:ok, blob_sha} = Object.write(repo, blob)
# Write a tree referencing the blob
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
# Read it back
{: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" do
repo = RepoHelper.memory_repo()
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" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("test")
sha = Object.hash(blob)
refute Repo.storage_call(repo, :object_exists?, [sha])
setup do
%{repo: RepoHelper.memory_repo()}
{:ok, _} = Object.write(repo, blob)
assert Repo.storage_call(repo, :object_exists?, [sha])
end
end
describe "ref storage" do
test "put and get ref" do
repo = RepoHelper.memory_repo()
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
use ExGitObjectstore.Test.StorageConformance
test "get non-existent ref returns error" do
describe "Memory-specific behavior" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/nope"])
end
test "CAS put_ref succeeds with matching old_sha" do
repo = RepoHelper.memory_repo()
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
: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" do
repo = RepoHelper.memory_repo()
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 "delete ref" do
repo = RepoHelper.memory_repo()
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" do
repo = RepoHelper.memory_repo()
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" do
repo = RepoHelper.memory_repo()
: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" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Repo.storage_call(repo, :get_head, [])
end
end
describe "pack storage" do
test "put and get pack" do
repo = RepoHelper.memory_repo()
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 "list packs" do
repo = RepoHelper.memory_repo()
: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 "stream_pack returns enumerable" do
repo = RepoHelper.memory_repo()
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
end
describe "blob storage" do
test "put and get blob round-trips" do
repo = RepoHelper.memory_repo()
data = :crypto.strong_rand_bytes(1024)
: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 non-existent blob returns :not_found" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["graph/commit-graph.v1"])
end
test "put_blob overwrites existing blob" do
repo = RepoHelper.memory_repo()
: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 "blob_exists? reflects presence" do
repo = RepoHelper.memory_repo()
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 "delete_blob removes the blob" do
repo = RepoHelper.memory_repo()
: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 "delete_blob on missing key is :ok" do
repo = RepoHelper.memory_repo()
assert :ok = Repo.storage_call(repo, :delete_blob, ["k"])
end
test "blob keys are namespaced by repo prefix" do
repo_a = RepoHelper.memory_repo("repo-a")
{:ok, pid} = Memory.start_link()
# Reuse the same storage config by pointing another Repo at the same pid
{mod, cfg} = repo_a.storage
repo_b = Repo.new("repo-b", storage: {mod, cfg})
cfg = Memory.config(pid)
repo_a = Repo.new("repo-a", storage: {Memory, cfg})
repo_b = Repo.new("repo-b", storage: {Memory, cfg})
:ok = Repo.storage_call(repo_a, :put_blob, ["graph/commit-graph.v1", "a"])
:ok = Repo.storage_call(repo_b, :put_blob, ["graph/commit-graph.v1", "b"])
@@ -250,9 +39,7 @@
assert {:ok, "b"} = Repo.storage_call(repo_b, :get_blob, ["graph/commit-graph.v1"])
end
test "rejects path traversal in blob keys", %{repo: repo} do
test "rejects path traversal" do
repo = RepoHelper.memory_repo()
assert_raise ArgumentError, fn ->
Repo.storage_call(repo, :put_blob, ["../evil", "x"])
end
test/ex_git_objectstore/storage/s3_test.exs +14 −229
@@ -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
test/support/storage_conformance.ex +268 −0
@@ -1,0 +1,268 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Test.StorageConformance do
@moduledoc """
Shared contract suite for `ExGitObjectstore.Storage` backends.
Every conforming implementation (Filesystem, S3, Memory, …) must
pass these tests. Backend-specific scenarios (FS lock files, S3
pagination, etc.) stay in each backend's own test module *outside*
the `use` block; this module covers only the common contract.
## Usage
defmodule MyBackendTest do
use ExUnit.Case, async: true
use ExGitObjectstore.Test.StorageConformance
setup do
# Construct a fresh repo and return %{repo: repo}
%{repo: build_repo()}
end
# Backend-specific tests below — these run alongside the
# contract scenarios and have full access to the same setup.
describe "MyBackend-specific quirks" do
...
end
end
The `setup` must produce at least `%{repo: repo}`. Anything else
the backend tests want (e.g. `:root` for Filesystem) can be added
alongside.
## Why this exists
Before this module, each backend re-implemented the same ~20
scenarios in its own file. The duplication had two failure modes:
1. Backends drifted — Filesystem tested ref CAS one way,
S3 tested it another, neither caught the same edge cases.
2. The `:s3` exclusion in `test_helper.exs` hid the fact that
the S3 backend wasn't running. With a shared contract module
used by every backend, "S3 isn't tested" surfaces immediately
as missing coverage rather than as a green-but-empty matrix.
Backend-specific tests still belong in each backend's file because
they exercise behavior unique to that backend (FS file locks, S3
list-pagination continuation tokens, etc.).
"""
defmacro __using__(_opts) do
quote do
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
describe "object storage (contract)" 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, %Blob{content: "hello world"}} = Object.read(repo, sha)
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, %Tree{entries: [entry]}} = Object.read(repo, tree_sha)
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, %Commit{} = decoded} = Object.read(repo, sha)
assert decoded.tree == tree_sha
assert decoded.message == "init\n"
end
test "read non-existent object returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Object.read(repo, String.duplicate("0", 40))
end
test "object_exists? reflects presence", %{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 (contract)" 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 :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/nope"])
end
test "CAS put_ref succeeds with matching old_sha", %{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])
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/main" in ref_names
assert "refs/heads/dev" in ref_names
refute "refs/tags/v1" in ref_names
end
end
describe "HEAD storage (contract)" 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 :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_head, [])
end
end
describe "pack storage (contract)" do
test "put and get pack", %{repo: repo} do
: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 :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack, ["nonexistent"])
end
test "get non-existent pack index returns :not_found", %{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, ["pack1", "data1", "idx1"])
:ok = Repo.storage_call(repo, :put_pack, ["pack2", "data2", "idx2"])
{:ok, packs} = Repo.storage_call(repo, :list_packs, [])
assert "pack1" in packs
assert "pack2" in packs
end
test "list packs returns empty when none exist", %{repo: repo} do
assert {:ok, []} = Repo.storage_call(repo, :list_packs, [])
end
test "stream_pack returns enumerable", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["streamtest", "stream-data", "idx"])
{:ok, stream} = Repo.storage_call(repo, :stream_pack, ["streamtest"])
result = stream |> Enum.to_list() |> IO.iodata_to_binary()
assert result == "stream-data"
end
test "stream_pack returns :not_found for non-existent pack", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :stream_pack, ["nope"])
end
end
describe "blob storage (contract)" do
test "put and get blob round-trips", %{repo: repo} do
assert :ok = Repo.storage_call(repo, :put_blob, ["k/v", "blob-data"])
assert {:ok, "blob-data"} = Repo.storage_call(repo, :get_blob, ["k/v"])
end
test "get on missing blob returns :not_found", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["missing"])
end
test "put_blob overwrites existing blob", %{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 "blob_exists? reflects presence", %{repo: repo} do
refute Repo.storage_call(repo, :blob_exists?, ["maybe"])
:ok = Repo.storage_call(repo, :put_blob, ["maybe", "yes"])
assert Repo.storage_call(repo, :blob_exists?, ["maybe"])
end
test "delete_blob removes the blob", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_blob, ["bye", "data"])
:ok = Repo.storage_call(repo, :delete_blob, ["bye"])
assert {:error, :not_found} = Repo.storage_call(repo, :get_blob, ["bye"])
end
test "delete_blob on missing key is :ok", %{repo: repo} do
assert :ok = Repo.storage_call(repo, :delete_blob, ["never"])
end
end
end
end
end