Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,14 @@ jobs:
GITHUB_PRIVATE_TOKEN: ${{ secrets.GITHUB_PRIVATE_TOKEN }}
run: mix test --warnings-as-errors --only github_private
continue-on-error: true

- name: Run Cloudflare Artifacts smoketest (live network, secrets)
# Push-to-main only — needs a long-lived repo-scoped artifact
# token that isn't available on fork-based PR runs. Exercises
# the full push → clone → fsck round-trip against a persistent
# `ci` repo on Cloudflare Artifacts.
if: matrix.primary && github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
CF_ARTIFACT_REMOTE: ${{ secrets.CF_ARTIFACT_REMOTE }}
CF_ARTIFACT_TOKEN: ${{ secrets.CF_ARTIFACT_TOKEN }}
run: mix test --warnings-as-errors --only cloudflare
135 changes: 135 additions & 0 deletions test/exgit/cloudflare_artifacts_roundtrip_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
defmodule Exgit.CloudflareArtifactsRoundtripTest do
@moduledoc """
Live smoketest against Cloudflare Artifacts.

Pushes an exgit-built commit over the git smart-HTTP endpoint to a
long-lived persistent repo (URL + token injected via
`CF_ARTIFACT_REMOTE` and `CF_ARTIFACT_TOKEN`), then verifies the data
two ways:

1. **exgit ↔ exgit** — lazy-clone via `Exgit.clone`, read the blob
back, assert byte-equality with the random content we pushed.
Catches push/parse/transport bugs in our own code.

2. **exgit ↔ real git** — `git clone` with the real binary, then
`git fsck`. Proves the bytes Cloudflare stored are valid git
(not just exgit-flavoured), and that a third-party client can
read them.

Each test uses a unique branch name; the underlying repo is shared
across runs.

Tagged `:cloudflare`. Run with `mix test --include cloudflare`.
Test 2 shells out to the `git` binary; opting into `:cloudflare`
implies `git` is on PATH.
"""

use ExUnit.Case, async: false
@moduletag :cloudflare

alias Exgit.Credentials.Artifacts, as: ArtifactsCreds
alias Exgit.Object.{Blob, Commit, Tree}
alias Exgit.{ObjectStore, RefStore, Repository, Transport}
alias Exgit.Test.{CloudflareArtifacts, RealGit}

defp transport do
Transport.HTTP.new(CloudflareArtifacts.remote(),
auth: ArtifactsCreds.auth(CloudflareArtifacts.token())
)
end

defp unique_branch do
suffix = :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
"refs/heads/exgit-rt-#{System.system_time(:millisecond)}-#{suffix}"
end

# Build a single-file commit. Returns {repo, commit_sha}.
defp build_commit(branch, filename, content) do
store = ObjectStore.Memory.new()
{:ok, blob_sha, store} = ObjectStore.put(store, Blob.new(content))
{:ok, tree_sha, store} = ObjectStore.put(store, Tree.new([{"100644", filename, blob_sha}]))

commit =
Commit.new(
tree: tree_sha,
parents: [],
author: "Exgit Smoketest <test@exgit> 1700000000 +0000",
committer: "Exgit Smoketest <test@exgit> 1700000000 +0000",
message: "cloudflare artifacts smoketest\n"
)

{:ok, commit_sha, store} = ObjectStore.put(store, commit)
{:ok, ref_store} = RefStore.write(RefStore.Memory.new(), branch, commit_sha, [])

repo = %Repository{
object_store: store,
ref_store: ref_store,
config: Exgit.Config.new(),
path: nil
}

{repo, commit_sha}
end

defp tmp_dir!(prefix) do
base = Path.join(System.tmp_dir!(), "#{prefix}_#{System.unique_integer([:positive])}")
File.rm_rf!(base)
base
end

test "exgit push → exgit clone roundtrips a random blob" do
branch = unique_branch()
content = :crypto.strong_rand_bytes(4096)
{repo, commit_sha} = build_commit(branch, "fixture.bin", content)

t = transport()

assert {:ok, _} = Exgit.push(repo, t, refspecs: [branch])

{:ok, clone} = Exgit.clone(t, lazy: true)
assert {:ok, ^commit_sha} = RefStore.resolve(clone.ref_store, branch)

assert {:ok, {_mode, fetched_blob}, _clone} =
Exgit.FS.read_path(clone, branch, "fixture.bin")

assert fetched_blob.data == content
end

test "exgit-pushed commit is readable by real git clone + fsck" do
branch = unique_branch()
branch_name = String.trim_leading(branch, "refs/heads/")
content = "Pushed by exgit at #{System.system_time(:millisecond)}\n"
{repo, _commit_sha} = build_commit(branch, "hello.txt", content)

assert {:ok, _} = Exgit.push(repo, transport(), refspecs: [branch])

clone_dir = tmp_dir!("exgit_cf_clone")

try do
{out, status} =
System.cmd(
"git",
[
"-c",
"http.extraheader=Authorization: Bearer #{CloudflareArtifacts.token()}",
"clone",
"--branch",
branch_name,
"--depth",
"1",
CloudflareArtifacts.remote(),
clone_dir
],
stderr_to_stdout: true
)

assert status == 0, "git clone failed:\n#{out}"
assert File.read!(Path.join(clone_dir, "hello.txt")) == content

{out, status} = RealGit.git!(clone_dir, ["fsck", "--full"], allow_error: true)
assert status == 0, "git fsck failed:\n#{out}"
after
File.rm_rf!(clone_dir)
end
end
end
123 changes: 16 additions & 107 deletions test/support/cloudflare_artifacts.ex
Original file line number Diff line number Diff line change
@@ -1,121 +1,30 @@
defmodule Exgit.Test.CloudflareArtifacts do
@moduledoc """
Minimal client for the Cloudflare Artifacts REST API, used by the
roundtrip smoketest to create an ephemeral repo, mint a write token,
and clean up afterwards.
Test-support shim for the Cloudflare Artifacts smoketest.

Secrets come from environment variables:
The smoketest targets a long-lived persistent repo and authenticates
git wire operations with a repo-scoped token (prefix `art_v1_`).
Both the remote URL and token are injected via env vars — locally
from `.env`, in CI from secrets:

* `CF_API_TOKEN` — a Cloudflare API token with Artifacts Edit.
* `CF_ACCOUNT_ID` — the account ID that owns the namespace.
* `CF_ARTIFACT_REMOTE` — full git wire URL for the test repo
(`https://<account>.artifacts.cloudflare.net/git/<namespace>/<repo>.git`).
* `CF_ARTIFACT_TOKEN` — long-lived repo-scoped token for that repo.

All functions raise on non-2xx responses so test failures surface
immediately.
Because the repo is persistent across runs, tests must use unique
branch names to avoid stomping each other.
"""

@namespace "default"

def available? do
System.get_env("CF_API_TOKEN") not in [nil, ""] and
System.get_env("CF_ACCOUNT_ID") not in [nil, ""]
end

def base_url do
"https://api.cloudflare.com/client/v4/accounts/#{account_id()}/artifacts/namespaces/#{@namespace}"
end

def git_url(repo_name) do
"https://#{account_id()}.artifacts.cloudflare.net/git/#{@namespace}/#{repo_name}.git"
end

@doc "Create a repo. Idempotent on 409 (already exists)."
def create_repo!(name) do
resp =
Req.request!(
method: :post,
url: base_url() <> "/repos",
headers: api_headers(),
json: %{name: name},
retry: false
)

case resp.status do
s when s in 200..299 -> :ok
409 -> :already_exists
_ -> raise "create_repo failed: status=#{resp.status} body=#{inspect(resp.body)}"
end
end

@doc "Delete the repo (irreversible)."
def delete_repo!(name) do
resp =
Req.request!(
method: :delete,
url: base_url() <> "/repos/#{name}",
headers: api_headers(),
retry: false
)

case resp.status do
s when s in 200..299 -> :ok
404 -> :gone
_ -> raise "delete_repo failed: status=#{resp.status} body=#{inspect(resp.body)}"
end
end

@doc "Mint a repo-scoped token. `scope` is :read or :write."
def mint_token!(repo_name, scope) when scope in [:read, :write] do
resp =
Req.request!(
method: :post,
url: base_url() <> "/repos/#{repo_name}/tokens",
headers: api_headers(),
json: %{scope: to_string(scope)},
retry: false
)

case resp.status do
s when s in 200..299 ->
token =
resp.body
|> Map.get("result", %{})
|> Map.get("token")

if is_binary(token) and token != "",
do: token,
else: raise("mint_token: missing token in response: #{inspect(resp.body)}")

_ ->
raise "mint_token failed: status=#{resp.status} body=#{inspect(resp.body)}"
end
end

@doc """
Generate a unique repo name for a test run. Combines a prefix with
the millisecond timestamp and a short random suffix.
"""
def unique_name(prefix \\ "exgit-test") do
suffix =
:crypto.strong_rand_bytes(4)
|> Base.encode16(case: :lower)

"#{prefix}-#{System.system_time(:millisecond)}-#{suffix}"
end

# --- Internal ---

defp account_id do
System.get_env("CF_ACCOUNT_ID") || raise "CF_ACCOUNT_ID not set"
System.get_env("CF_ARTIFACT_REMOTE") not in [nil, ""] and
System.get_env("CF_ARTIFACT_TOKEN") not in [nil, ""]
end

defp api_token do
System.get_env("CF_API_TOKEN") || raise "CF_API_TOKEN not set"
def remote do
System.get_env("CF_ARTIFACT_REMOTE") || raise "CF_ARTIFACT_REMOTE not set"
end

defp api_headers do
[
{"authorization", "Bearer " <> api_token()},
{"content-type", "application/json"}
]
def token do
System.get_env("CF_ARTIFACT_TOKEN") || raise "CF_ARTIFACT_TOKEN not set"
end
end
Loading