diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dd662d..de17234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/test/exgit/cloudflare_artifacts_roundtrip_test.exs b/test/exgit/cloudflare_artifacts_roundtrip_test.exs new file mode 100644 index 0000000..9c0b2bd --- /dev/null +++ b/test/exgit/cloudflare_artifacts_roundtrip_test.exs @@ -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 1700000000 +0000", + committer: "Exgit Smoketest 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 diff --git a/test/support/cloudflare_artifacts.ex b/test/support/cloudflare_artifacts.ex index e2651db..4693cbb 100644 --- a/test/support/cloudflare_artifacts.ex +++ b/test/support/cloudflare_artifacts.ex @@ -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://.artifacts.cloudflare.net/git//.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