diff --git a/lib/exgit/cloudflare_artifacts.ex b/lib/exgit/cloudflare_artifacts.ex
new file mode 100644
index 0000000..38d1b29
--- /dev/null
+++ b/lib/exgit/cloudflare_artifacts.ex
@@ -0,0 +1,281 @@
+defmodule Exgit.CloudflareArtifacts do
+ @moduledoc """
+ Cloudflare Artifacts REST API client.
+
+ Wraps the control-plane endpoints documented at
+ —
+ repos (create / list / get / delete / fork / import) and repo
+ tokens (create / list / delete).
+
+ Function and field names mirror upstream verbatim: `create_repo`
+ not `mint_repo`, `default_branch` not `branch`, `plaintext` not
+ `secret`. The struct shapes (`Repo`, `Token`) match the TypeScript
+ interfaces in the upstream docs so the two can be cross-referenced
+ directly.
+
+ ## Setup
+
+ `new/1` returns a `%Req.Request{}` with `base_url`, bearer auth,
+ and a default user-agent already set:
+
+ client = Exgit.CloudflareArtifacts.new(
+ account_id: System.fetch_env!("CF_ACCOUNT_ID"),
+ namespace: "default",
+ api_token: System.fetch_env!("CF_API_TOKEN")
+ )
+
+ Any extra options (e.g. `:plug` for testing, `:retry`, `:receive_timeout`)
+ are forwarded to `Req.new/1`.
+
+ ## Full lifecycle
+
+ {:ok, %Repo{remote: remote}} =
+ Exgit.CloudflareArtifacts.create_repo(client,
+ name: "starter-repo",
+ default_branch: "main"
+ )
+
+ {:ok, %Token{plaintext: token}} =
+ Exgit.CloudflareArtifacts.create_token(client,
+ repo: "starter-repo",
+ scope: "write",
+ ttl: 86_400
+ )
+
+ transport = Exgit.Transport.HTTP.new(remote,
+ auth: Exgit.Credentials.Artifacts.auth(token)
+ )
+
+ # ... push, fetch, clone via the existing transport ...
+
+ {:ok, _} = Exgit.CloudflareArtifacts.delete_repo(client, "starter-repo")
+
+ ## Return shapes
+
+ Single-resource endpoints return `{:ok, struct}`. List endpoints
+ return `{:ok, items, result_info}` — `result_info` is the upstream
+ pagination map verbatim (cursor for repos, offset for tokens).
+
+ Errors:
+
+ * `{:error, %Req.Response{}}` — non-2xx, or 2xx with `success:
+ false`. The parsed v4 envelope is in `body`; the upstream error
+ list lives at `body["errors"]` (each entry has `code`,
+ `message`, optional `documentation_url`).
+ * `{:error, exception}` — Req-level transport failure (DNS, TLS,
+ connection refused, etc.).
+
+ Pattern-match on specific upstream codes directly:
+
+ case Exgit.CloudflareArtifacts.create_token(client, repo: r, ttl: 99_999_999) do
+ {:error, %Req.Response{body: %{"errors" => [%{"code" => 10103} | _]}}} ->
+ :ttl_out_of_range
+ ...
+ end
+ """
+
+ alias Exgit.CloudflareArtifacts.{Repo, Token}
+
+ @default_base_url "https://api.cloudflare.com/client/v4"
+
+ @doc """
+ Build a configured `%Req.Request{}` for the Cloudflare Artifacts API.
+
+ Required: `:account_id`, `:api_token`. Optional: `:namespace`
+ (defaults to `"default"`), `:base_url`. Any other keys (`:plug`,
+ `:retry`, `:receive_timeout`, …) are forwarded to `Req.new/1`.
+ """
+ @spec new(keyword()) :: Req.Request.t()
+ def new(opts) do
+ {own, rest} = Keyword.split(opts, [:account_id, :namespace, :api_token, :base_url])
+ account_id = Keyword.fetch!(own, :account_id)
+ api_token = Keyword.fetch!(own, :api_token)
+ namespace = Keyword.get(own, :namespace, "default")
+ base = own |> Keyword.get(:base_url, @default_base_url) |> String.trim_trailing("/")
+
+ Req.new(
+ [
+ base_url: "#{base}/accounts/#{account_id}/artifacts/namespaces/#{namespace}",
+ auth: {:bearer, api_token},
+ headers: [{"user-agent", "exgit/0.1.0"}]
+ ] ++ rest
+ )
+ end
+
+ # --- Repos ---
+
+ @doc """
+ Create a repo. `POST /repos`.
+
+ Required option: `:name`. Optional: `:description`,
+ `:default_branch`, `:read_only`. The returned `Repo` includes a
+ short-lived inline `:token` — use `create_token/2` for longer TTLs.
+ """
+ @spec create_repo(Req.Request.t(), keyword()) :: {:ok, Repo.t()} | {:error, term()}
+ def create_repo(req, opts) do
+ body = json_body(opts, [:name, :description, :default_branch, :read_only])
+
+ req
+ |> Req.post(url: "/repos", json: body)
+ |> handle(&Repo.from_map/1)
+ end
+
+ @doc """
+ List repos. `GET /repos`.
+
+ Optional: `:limit` (default 50, max 200), `:cursor`, `:search`,
+ `:sort` (`"created_at" | "updated_at" | "last_push_at" | "name"`),
+ `:direction` (`"asc" | "desc"`).
+ """
+ @spec list_repos(Req.Request.t(), keyword()) ::
+ {:ok, [Repo.t()], map()} | {:error, term()}
+ def list_repos(req, opts \\ []) do
+ params = Keyword.take(opts, [:limit, :cursor, :search, :sort, :direction])
+
+ req
+ |> Req.get(url: "/repos", params: params)
+ |> handle_list(&Repo.from_map/1)
+ end
+
+ @doc """
+ Get a repo by name. `GET /repos/:name`.
+ """
+ @spec get_repo(Req.Request.t(), String.t()) :: {:ok, Repo.t()} | {:error, term()}
+ def get_repo(req, name) when is_binary(name) do
+ req
+ |> Req.get(url: "/repos/:name", path_params: [name: name])
+ |> handle(&Repo.from_map/1)
+ end
+
+ @doc """
+ Delete a repo. `DELETE /repos/:name`. Returns 202 upstream; the
+ resulting `Repo` carries only `:id`.
+ """
+ @spec delete_repo(Req.Request.t(), String.t()) :: {:ok, Repo.t()} | {:error, term()}
+ def delete_repo(req, name) when is_binary(name) do
+ req
+ |> Req.delete(url: "/repos/:name", path_params: [name: name])
+ |> handle(&Repo.from_map/1)
+ end
+
+ @doc """
+ Fork a repo. `POST /repos/:name/fork`.
+
+ Required option: `:name` (the new repo). Optional: `:description`,
+ `:read_only`, `:default_branch_only`. The returned `Repo` adds an
+ `:objects` count.
+ """
+ @spec fork_repo(Req.Request.t(), String.t(), keyword()) ::
+ {:ok, Repo.t()} | {:error, term()}
+ def fork_repo(req, source_name, opts) when is_binary(source_name) do
+ body = json_body(opts, [:name, :description, :read_only, :default_branch_only])
+
+ req
+ |> Req.post(url: "/repos/:name/fork", path_params: [name: source_name], json: body)
+ |> handle(&Repo.from_map/1)
+ end
+
+ @doc """
+ Import a public HTTPS git remote. `POST /repos/:name/import`.
+
+ `name` is the destination repo name; `:url` in the body is the
+ HTTPS source remote. Optional body fields: `:branch`, `:depth`,
+ `:read_only`. May 409 while a previous import/fork is still in
+ progress.
+ """
+ @spec import_repo(Req.Request.t(), String.t(), keyword()) ::
+ {:ok, Repo.t()} | {:error, term()}
+ def import_repo(req, name, opts) when is_binary(name) do
+ body = json_body(opts, [:url, :branch, :depth, :read_only])
+
+ req
+ |> Req.post(url: "/repos/:name/import", path_params: [name: name], json: body)
+ |> handle(&Repo.from_map/1)
+ end
+
+ # --- Tokens ---
+
+ @doc """
+ Mint a repo-scoped token. `POST /tokens`.
+
+ Required option: `:repo`. Optional: `:scope` (`"read" | "write"` or
+ the matching atoms `:read | :write`; default `"write"`), `:ttl`
+ (seconds, default 86_400; capped at 31_536_000 — `code: 10103` if
+ exceeded). Returns a `Token` with `:plaintext` set.
+ """
+ @spec create_token(Req.Request.t(), keyword()) :: {:ok, Token.t()} | {:error, term()}
+ def create_token(req, opts) do
+ body = json_body(opts, [:repo, :scope, :ttl])
+
+ req
+ |> Req.post(url: "/tokens", json: body)
+ |> handle(&Token.from_map/1)
+ end
+
+ @doc """
+ List tokens for a repo. `GET /repos/:name/tokens`.
+
+ Optional: `:state` (`"active" | "expired" | "revoked" | "all"` or
+ the matching atoms `:active | :expired | :revoked | :all`; default
+ `"active"`), `:per_page` (default 30, max 100), `:page` (default
+ 1). Listed tokens have `:plaintext` set to `nil` — the API never
+ re-emits the secret.
+ """
+ @spec list_tokens(Req.Request.t(), String.t(), keyword()) ::
+ {:ok, [Token.t()], map()} | {:error, term()}
+ def list_tokens(req, repo_name, opts \\ []) when is_binary(repo_name) do
+ params =
+ opts
+ |> Keyword.take([:state, :per_page, :page])
+ |> Enum.map(fn {k, v} -> {k, stringify_atom(v)} end)
+
+ req
+ |> Req.get(url: "/repos/:name/tokens", path_params: [name: repo_name], params: params)
+ |> handle_list(&Token.from_map/1)
+ end
+
+ @doc """
+ Revoke a token. `DELETE /tokens/:id`. The returned `Token` carries
+ only `:id`.
+ """
+ @spec delete_token(Req.Request.t(), String.t()) :: {:ok, Token.t()} | {:error, term()}
+ def delete_token(req, token_id) when is_binary(token_id) do
+ req
+ |> Req.delete(url: "/tokens/:id", path_params: [id: token_id])
+ |> handle(&Token.from_map/1)
+ end
+
+ # --- Helpers ---
+
+ defp json_body(opts, keys) do
+ for k <- keys, {:ok, v} <- [Keyword.fetch(opts, k)], not is_nil(v), into: %{} do
+ {Atom.to_string(k), stringify_atom(v)}
+ end
+ end
+
+ # Coerce atom enum values (`:read`, `:active`, …) to their string
+ # form so callers can pass either. Booleans and non-atoms pass
+ # through unchanged so JSON booleans/numbers still encode correctly.
+ defp stringify_atom(v) when is_boolean(v), do: v
+ defp stringify_atom(v) when is_atom(v), do: Atom.to_string(v)
+ defp stringify_atom(v), do: v
+
+ defp handle({:ok, %Req.Response{status: s, body: %{"success" => true, "result" => r}}}, parser)
+ when s in 200..299 and is_map(r) do
+ {:ok, parser.(r)}
+ end
+
+ defp handle({:ok, %Req.Response{} = resp}, _parser), do: {:error, resp}
+ defp handle({:error, exception}, _parser), do: {:error, exception}
+
+ defp handle_list(
+ {:ok, %Req.Response{status: s, body: %{"success" => true, "result" => r} = env}},
+ parser
+ )
+ when s in 200..299 and is_list(r) do
+ {:ok, Enum.map(r, parser), Map.get(env, "result_info", %{})}
+ end
+
+ defp handle_list({:ok, %Req.Response{} = resp}, _parser), do: {:error, resp}
+ defp handle_list({:error, exception}, _parser), do: {:error, exception}
+end
diff --git a/lib/exgit/cloudflare_artifacts/repo.ex b/lib/exgit/cloudflare_artifacts/repo.ex
new file mode 100644
index 0000000..86187bb
--- /dev/null
+++ b/lib/exgit/cloudflare_artifacts/repo.ex
@@ -0,0 +1,80 @@
+defmodule Exgit.CloudflareArtifacts.Repo do
+ @moduledoc """
+ Cloudflare Artifacts repository.
+
+ Field names mirror the upstream `RepoInfo` / `RepoWithRemote` /
+ `CreateRepoResult` shapes verbatim — `default_branch`, `created_at`,
+ `last_push_at`, `read_only`, etc. are kept as-is rather than
+ Elixirified, so the struct can be cross-referenced against the
+ Cloudflare REST docs directly.
+
+ Not every field is populated by every endpoint:
+
+ * `POST /repos`, `POST /repos/:name/fork`, `POST /repos/:name/import`
+ return a `CreateRepoResult` — fills `id`, `name`, `description`,
+ `default_branch`, `remote`, `token`. The list/get-only fields
+ (`created_at`, `updated_at`, etc.) stay `nil`.
+ * `GET /repos`, `GET /repos/:name` return `RepoWithRemote` — fills
+ `id`, `name`, `description`, `default_branch`, `created_at`,
+ `updated_at`, `last_push_at`, `source`, `read_only`, `remote`.
+ `token` is `nil` (those routes don't mint one).
+ * Fork additionally returns `objects` (object count copied).
+ """
+
+ alias Exgit.CloudflareArtifacts.Repo
+
+ @type t :: %__MODULE__{
+ id: String.t() | nil,
+ name: String.t() | nil,
+ description: String.t() | nil,
+ default_branch: String.t() | nil,
+ remote: String.t() | nil,
+ token: String.t() | nil,
+ created_at: String.t() | nil,
+ updated_at: String.t() | nil,
+ last_push_at: String.t() | nil,
+ source: String.t() | nil,
+ read_only: boolean() | nil,
+ objects: integer() | nil
+ }
+
+ defstruct id: nil,
+ name: nil,
+ description: nil,
+ default_branch: nil,
+ remote: nil,
+ token: nil,
+ created_at: nil,
+ updated_at: nil,
+ last_push_at: nil,
+ source: nil,
+ read_only: nil,
+ objects: nil
+
+ @spec from_map(map()) :: t()
+ def from_map(map) when is_map(map) do
+ %Repo{
+ id: Map.get(map, "id"),
+ name: Map.get(map, "name"),
+ description: Map.get(map, "description"),
+ default_branch: Map.get(map, "default_branch"),
+ remote: Map.get(map, "remote"),
+ token: Map.get(map, "token"),
+ created_at: Map.get(map, "created_at"),
+ updated_at: Map.get(map, "updated_at"),
+ last_push_at: Map.get(map, "last_push_at"),
+ source: Map.get(map, "source"),
+ read_only: Map.get(map, "read_only"),
+ objects: Map.get(map, "objects")
+ }
+ end
+end
+
+defimpl Inspect, for: Exgit.CloudflareArtifacts.Repo do
+ # The `token` field on a Repo is a freshly-minted git auth token
+ # — same secrecy class as %Client.api_token. Always redact.
+ def inspect(%Exgit.CloudflareArtifacts.Repo{} = r, opts) do
+ redacted = if r.token, do: %{r | token: "***"}, else: r
+ Inspect.Any.inspect(redacted, opts)
+ end
+end
diff --git a/lib/exgit/cloudflare_artifacts/token.ex b/lib/exgit/cloudflare_artifacts/token.ex
new file mode 100644
index 0000000..73692e2
--- /dev/null
+++ b/lib/exgit/cloudflare_artifacts/token.ex
@@ -0,0 +1,73 @@
+defmodule Exgit.CloudflareArtifacts.Token do
+ @moduledoc """
+ Cloudflare Artifacts repo-scoped token.
+
+ This struct represents both shapes the API can return:
+
+ * `POST /tokens` (`CreateTokenResult`) — populates `id`, `plaintext`,
+ `scope`, `expires_at`. Listing fields (`state`, `created_at`)
+ stay `nil`.
+ * `GET /repos/:name/tokens` (`TokenInfo`) — populates `id`, `scope`,
+ `state`, `created_at`, `expires_at`. The actual token bytes are
+ not returned — `plaintext` stays `nil`.
+
+ Field names match upstream verbatim (`plaintext`, not `secret` or
+ `value`).
+
+ The `plaintext` value is opaque — pass it through to git wire auth
+ via `Exgit.Credentials.Artifacts.auth/1` (bearer header) without
+ modification. The expiration is already exposed as the `expires_at`
+ ISO timestamp on the struct, so callers don't need to parse the
+ embedded `?expires=` suffix.
+ """
+
+ alias Exgit.CloudflareArtifacts.Token
+
+ @type scope :: :read | :write
+ @type state :: :active | :expired | :revoked
+
+ @type t :: %__MODULE__{
+ id: String.t() | nil,
+ plaintext: String.t() | nil,
+ scope: scope() | nil,
+ state: state() | nil,
+ created_at: String.t() | nil,
+ expires_at: String.t() | nil
+ }
+
+ defstruct id: nil,
+ plaintext: nil,
+ scope: nil,
+ state: nil,
+ created_at: nil,
+ expires_at: nil
+
+ @spec from_map(map()) :: t()
+ def from_map(map) when is_map(map) do
+ %Token{
+ id: Map.get(map, "id"),
+ plaintext: Map.get(map, "plaintext"),
+ scope: parse_scope(Map.get(map, "scope")),
+ state: parse_state(Map.get(map, "state")),
+ created_at: Map.get(map, "created_at"),
+ expires_at: Map.get(map, "expires_at")
+ }
+ end
+
+ defp parse_scope("read"), do: :read
+ defp parse_scope("write"), do: :write
+ defp parse_scope(_), do: nil
+
+ defp parse_state("active"), do: :active
+ defp parse_state("expired"), do: :expired
+ defp parse_state("revoked"), do: :revoked
+ defp parse_state(_), do: nil
+end
+
+defimpl Inspect, for: Exgit.CloudflareArtifacts.Token do
+ # `plaintext` is the literal git-auth secret; never echo it.
+ def inspect(%Exgit.CloudflareArtifacts.Token{} = t, opts) do
+ redacted = if t.plaintext, do: %{t | plaintext: "***"}, else: t
+ Inspect.Any.inspect(redacted, opts)
+ end
+end
diff --git a/mix.exs b/mix.exs
index 7abf291..3347296 100644
--- a/mix.exs
+++ b/mix.exs
@@ -124,6 +124,9 @@ defmodule Exgit.MixProject do
optional: true,
runtime: false},
{:stream_data, "~> 1.0", only: [:test, :dev]},
+ # Test-only: localhost HTTP server for stubbing the Cloudflare
+ # Artifacts REST API in `test/exgit/cloudflare_artifacts_test.exs`.
+ {:bypass, "~> 2.1", only: :test},
# Optional dev-only OpenTelemetry bridge: auto-converts :telemetry
# events into OTel spans. Only loaded in dev/test; production users
# can wire their own handlers.
diff --git a/mix.lock b/mix.lock
index 9e06912..14f7f5c 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,7 +1,11 @@
%{
"acceptor_pool": {:hex, :acceptor_pool, "1.0.1", "d88c2e8a0be9216cf513fbcd3e5a4beb36bee3ff4168e85d6152c6f899359cdb", [:rebar3], [], "hexpm", "f172f3d74513e8edd445c257d596fc84dbdd56d2c6fa287434269648ae5a421e"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
+ "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
+ "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
+ "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
+ "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
@@ -21,6 +25,10 @@
"opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"},
"opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"},
"opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"},
+ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
+ "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
+ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+ "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
diff --git a/test/exgit/cloudflare_artifacts_lifecycle_test.exs b/test/exgit/cloudflare_artifacts_lifecycle_test.exs
new file mode 100644
index 0000000..4a4213b
--- /dev/null
+++ b/test/exgit/cloudflare_artifacts_lifecycle_test.exs
@@ -0,0 +1,169 @@
+defmodule Exgit.CloudflareArtifactsLifecycleTest do
+ @moduledoc """
+ End-to-end smoketest for the full Cloudflare Artifacts lifecycle
+ using a real Cloudflare account.
+
+ Walks the entire control + data plane:
+
+ 1. Create a fresh repo (unique name per run).
+ 2. Mint a write-scoped git token.
+ 3. Build a commit with exgit and push it via the git wire
+ protocol.
+ 4. Mint a read-scoped git token.
+ 5. Clone via `Exgit.clone/2` and verify byte-equality of the
+ pushed blob.
+ 6. List tokens, assert both are present and active.
+ 7. Revoke the write token; assert listing reflects the new state.
+ 8. Get the repo to confirm metadata.
+ 9. Delete the repo. Cleanup is also wrapped in `on_exit` so a
+ failure mid-test still releases the repo.
+
+ Requires `CF_ACCOUNT_ID` and `CF_API_TOKEN`. Optionally
+ `CF_ARTIFACT_NAMESPACE` (defaults to `"default"`).
+
+ Tagged `:cloudflare_api` (distinct from `:cloudflare`, which gates
+ the existing wire-protocol roundtrip test against a long-lived
+ repo). Run locally with `mix test --include cloudflare_api`.
+ `test_helper.exs` excludes this tag automatically when
+ `CF_ACCOUNT_ID` / `CF_API_TOKEN` aren't set.
+ """
+
+ use ExUnit.Case, async: false
+ @moduletag :cloudflare_api
+
+ alias Exgit.CloudflareArtifacts
+ alias Exgit.CloudflareArtifacts.{Repo, Token}
+ alias Exgit.Credentials.Artifacts, as: ArtifactsCreds
+ alias Exgit.Object.{Blob, Commit, Tree}
+ alias Exgit.{ObjectStore, RefStore, Repository, Transport}
+ alias Exgit.Test.CloudflareArtifacts, as: CFEnv
+
+ setup_all do
+ client =
+ CloudflareArtifacts.new(
+ account_id: CFEnv.account_id(),
+ namespace: CFEnv.namespace(),
+ api_token: CFEnv.api_token()
+ )
+
+ {:ok, client: client}
+ end
+
+ test "full lifecycle: create → tokens → push → fetch → list → revoke → delete", ctx do
+ repo_name = "exgit-lc-#{System.system_time(:millisecond)}-#{rand_hex(4)}"
+ branch = "refs/heads/main"
+ content = :crypto.strong_rand_bytes(2048)
+
+ # Best-effort cleanup even if the test bails midway.
+ on_exit(fn ->
+ _ = CloudflareArtifacts.delete_repo(ctx.client, repo_name)
+ end)
+
+ # 1. Create the repo.
+ assert {:ok, %Repo{name: ^repo_name, remote: remote, default_branch: "main"}} =
+ CloudflareArtifacts.create_repo(ctx.client,
+ name: repo_name,
+ default_branch: "main",
+ description: "exgit lifecycle smoketest"
+ )
+
+ assert is_binary(remote)
+ assert remote =~ "/git/#{CFEnv.namespace()}/#{repo_name}.git"
+
+ # 2. Mint a write token. Use a small TTL — well within the
+ # [60, 31_536_000] window from the API constraints memory.
+ assert {:ok, %Token{plaintext: write_token, scope: :write, id: write_token_id}} =
+ CloudflareArtifacts.create_token(ctx.client,
+ repo: repo_name,
+ scope: "write",
+ ttl: 600
+ )
+
+ assert is_binary(write_token)
+
+ # 3. Build a commit and push it to the new repo.
+ {repo, commit_sha} = build_single_file_commit(branch, "fixture.bin", content)
+ write_transport = Transport.HTTP.new(remote, auth: ArtifactsCreds.auth(write_token))
+
+ assert {:ok, %{ref_results: ref_results}} =
+ Exgit.push(repo, write_transport, refspecs: [branch])
+
+ assert Enum.any?(ref_results, &match?({^branch, :ok}, &1))
+
+ # 4. Mint a read token, then clone with it.
+ assert {:ok, %Token{plaintext: read_token, scope: :read, id: read_token_id}} =
+ CloudflareArtifacts.create_token(ctx.client,
+ repo: repo_name,
+ scope: :read,
+ ttl: 600
+ )
+
+ read_transport = Transport.HTTP.new(remote, auth: ArtifactsCreds.auth(read_token))
+
+ # 5. Clone via Exgit and verify the blob round-tripped.
+ {:ok, clone} = Exgit.clone(read_transport, 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
+
+ # 6. List tokens — both should appear active. Use the atom-input
+ # form for `state:` to exercise the scope/state coercion path.
+ assert {:ok, listed, _info} =
+ CloudflareArtifacts.list_tokens(ctx.client, repo_name, state: :all)
+
+ by_id = Map.new(listed, &{&1.id, &1})
+ assert %Token{state: :active, scope: :write} = by_id[write_token_id]
+ assert %Token{state: :active, scope: :read} = by_id[read_token_id]
+
+ # 7. Revoke the write token.
+ assert {:ok, %Token{id: ^write_token_id}} =
+ CloudflareArtifacts.delete_token(ctx.client, write_token_id)
+
+ assert {:ok, after_revoke, _info} =
+ CloudflareArtifacts.list_tokens(ctx.client, repo_name, state: "all")
+
+ revoked = Enum.find(after_revoke, &(&1.id == write_token_id))
+ assert revoked, "revoked token should still appear in state=all listing"
+ refute revoked.state == :active
+
+ # 8. Get the repo to confirm metadata is queryable.
+ assert {:ok, %Repo{name: ^repo_name, default_branch: "main"}} =
+ CloudflareArtifacts.get_repo(ctx.client, repo_name)
+
+ # 9. Delete the repo. The `on_exit` callback will also try to
+ # delete; the second DELETE will 404, which it ignores.
+ assert {:ok, %Repo{}} = CloudflareArtifacts.delete_repo(ctx.client, repo_name)
+ end
+
+ defp build_single_file_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: "exgit lifecycle 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 rand_hex(n), do: n |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
+end
diff --git a/test/exgit/cloudflare_artifacts_test.exs b/test/exgit/cloudflare_artifacts_test.exs
new file mode 100644
index 0000000..4cfc692
--- /dev/null
+++ b/test/exgit/cloudflare_artifacts_test.exs
@@ -0,0 +1,420 @@
+defmodule Exgit.CloudflareArtifactsTest do
+ @moduledoc """
+ Unit tests for the Cloudflare Artifacts REST client. Uses Bypass
+ to assert request shape (URL path, method, headers, body, query
+ params) and to feed scripted v4-envelope responses through the
+ client's parsers.
+
+ Live network coverage of the same surface lives in
+ `cloudflare_artifacts_roundtrip_test.exs` (tagged `:cloudflare`).
+ """
+
+ use ExUnit.Case, async: true
+
+ alias Exgit.CloudflareArtifacts
+ alias Exgit.CloudflareArtifacts.{Repo, Token}
+
+ @account "acct_test"
+ @namespace "default"
+ @api_token "TEST_API_TOKEN"
+
+ @ns_path "/client/v4/accounts/#{@account}/artifacts/namespaces/#{@namespace}"
+
+ setup do
+ bypass = Bypass.open()
+
+ client =
+ CloudflareArtifacts.new(
+ account_id: @account,
+ namespace: @namespace,
+ api_token: @api_token,
+ base_url: "http://localhost:#{bypass.port}/client/v4",
+ # Disable Req's retry-on-transport-error so the bypass-down
+ # test fails fast instead of retrying for ~7s.
+ retry: false
+ )
+
+ {:ok, bypass: bypass, client: client}
+ end
+
+ # --- Repos ---
+
+ describe "create_repo/2" do
+ test "POSTs the create body and parses CreateRepoResult", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/repos", fn conn ->
+ assert auth_header(conn) == "Bearer #{@api_token}"
+ assert read_json(conn) == %{"name" => "starter-repo", "default_branch" => "main"}
+
+ respond_ok(conn, %{
+ "id" => "repo_123",
+ "name" => "starter-repo",
+ "description" => nil,
+ "default_branch" => "main",
+ "remote" =>
+ "https://#{@account}.artifacts.cloudflare.net/git/#{@namespace}/starter-repo.git",
+ "token" => "art_v1_" <> String.duplicate("a", 40) <> "?expires=1760000000"
+ })
+ end)
+
+ assert {:ok, %Repo{} = repo} =
+ CloudflareArtifacts.create_repo(ctx.client,
+ name: "starter-repo",
+ default_branch: "main"
+ )
+
+ assert repo.id == "repo_123"
+ assert repo.name == "starter-repo"
+ assert repo.default_branch == "main"
+ assert repo.remote =~ "starter-repo.git"
+ assert repo.token =~ "art_v1_"
+ end
+
+ test "drops nil keys so we don't send {default_branch: null}", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/repos", fn conn ->
+ body = read_json(conn)
+ assert Map.keys(body) == ["name"]
+ respond_ok(conn, %{"id" => "repo_x", "name" => "x"})
+ end)
+
+ assert {:ok, %Repo{}} = CloudflareArtifacts.create_repo(ctx.client, name: "x")
+ end
+
+ test "non-2xx returns {:error, %Req.Response{}} with v4 errors intact", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/repos", fn conn ->
+ respond_error(conn, 400, [%{"code" => 10_100, "message" => "name in use"}])
+ end)
+
+ assert {:error, %Req.Response{status: 400, body: %{"errors" => errors}}} =
+ CloudflareArtifacts.create_repo(ctx.client, name: "dupe")
+
+ assert [%{"code" => 10_100, "message" => "name in use"}] = errors
+ end
+ end
+
+ describe "list_repos/2" do
+ test "expands path with no params and returns list + result_info", ctx do
+ Bypass.expect_once(ctx.bypass, "GET", "#{@ns_path}/repos", fn conn ->
+ assert conn.query_string == ""
+ respond_list(conn, [%{"id" => "r1", "name" => "r1"}], %{"cursor" => "next"})
+ end)
+
+ assert {:ok, [%Repo{id: "r1"}], %{"cursor" => "next"}} =
+ CloudflareArtifacts.list_repos(ctx.client)
+ end
+
+ test "forwards query params verbatim", ctx do
+ Bypass.expect_once(ctx.bypass, "GET", "#{@ns_path}/repos", fn conn ->
+ params = URI.decode_query(conn.query_string)
+ assert params["limit"] == "25"
+ assert params["sort"] == "updated_at"
+ assert params["direction"] == "desc"
+ respond_list(conn, [], %{})
+ end)
+
+ assert {:ok, [], _} =
+ CloudflareArtifacts.list_repos(ctx.client,
+ limit: 25,
+ sort: "updated_at",
+ direction: "desc"
+ )
+ end
+ end
+
+ describe "get_repo/2 + delete_repo/2" do
+ test "get_repo expands :name path param", ctx do
+ Bypass.expect_once(ctx.bypass, "GET", "#{@ns_path}/repos/my-repo", fn conn ->
+ respond_ok(conn, %{"id" => "repo_x", "name" => "my-repo", "default_branch" => "main"})
+ end)
+
+ assert {:ok, %Repo{name: "my-repo"}} = CloudflareArtifacts.get_repo(ctx.client, "my-repo")
+ end
+
+ test "delete_repo issues DELETE and returns the {id} envelope", ctx do
+ Bypass.expect_once(ctx.bypass, "DELETE", "#{@ns_path}/repos/my-repo", fn conn ->
+ respond_ok(conn, %{"id" => "repo_x"}, 202)
+ end)
+
+ assert {:ok, %Repo{id: "repo_x"}} = CloudflareArtifacts.delete_repo(ctx.client, "my-repo")
+ end
+
+ test "url-encodes path-param values that contain reserved characters", ctx do
+ # Pins the load-bearing assumption that Req's :path_params step
+ # URL-encodes `/`, ` `, `?` (→ %2F, %20, %3F) before joining
+ # into the path. Without this, repo names containing reserved
+ # chars would silently produce wrong-shaped URLs.
+ Bypass.expect_once(ctx.bypass, "GET", "#{@ns_path}/repos/weird%2Fname%20x", fn conn ->
+ respond_ok(conn, %{"id" => "x", "name" => "weird/name x"})
+ end)
+
+ assert {:ok, %Repo{name: "weird/name x"}} =
+ CloudflareArtifacts.get_repo(ctx.client, "weird/name x")
+ end
+ end
+
+ describe "fork_repo/3 + import_repo/3" do
+ test "fork POSTs to /repos/:name/fork and surfaces objects count", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/repos/source/fork", fn conn ->
+ body = read_json(conn)
+ assert body["name"] == "fork-target"
+ assert body["default_branch_only"] == true
+
+ respond_ok(conn, %{
+ "id" => "repo_fork",
+ "name" => "fork-target",
+ "default_branch" => "main",
+ "remote" => "https://example/git/default/fork-target.git",
+ "token" => "art_v1_token",
+ "objects" => 128
+ })
+ end)
+
+ assert {:ok, %Repo{id: "repo_fork", objects: 128}} =
+ CloudflareArtifacts.fork_repo(ctx.client, "source",
+ name: "fork-target",
+ default_branch_only: true
+ )
+ end
+
+ test "import POSTs to /repos/:name/import with the source url", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/repos/react-mirror/import", fn conn ->
+ body = read_json(conn)
+ assert body["url"] == "https://github.com/facebook/react"
+ assert body["depth"] == 100
+
+ respond_ok(conn, %{
+ "id" => "repo_import",
+ "name" => "react-mirror",
+ "default_branch" => "main",
+ "remote" => "https://example/git/default/react-mirror.git",
+ "token" => "art_v1_token"
+ })
+ end)
+
+ assert {:ok, %Repo{name: "react-mirror"}} =
+ CloudflareArtifacts.import_repo(ctx.client, "react-mirror",
+ url: "https://github.com/facebook/react",
+ depth: 100
+ )
+ end
+
+ test "import 409 still returns the v4 envelope for retry decisions", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/repos/x/import", fn conn ->
+ respond_error(conn, 409, [%{"code" => 10_200, "message" => "import in progress"}])
+ end)
+
+ assert {:error, %Req.Response{status: 409, body: %{"errors" => [%{"code" => 10_200} | _]}}} =
+ CloudflareArtifacts.import_repo(ctx.client, "x", url: "https://example/r.git")
+ end
+ end
+
+ # --- Tokens ---
+
+ describe "create_token/2" do
+ test "POSTs to /tokens (NOT /repos/:name/tokens) and returns plaintext", ctx do
+ # Memory note `reference_cf_artifacts_api`: create is /tokens,
+ # list is /repos/:name/tokens. Easy to mis-wire.
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/tokens", fn conn ->
+ assert read_json(conn) == %{"repo" => "starter-repo", "scope" => "read", "ttl" => 3600}
+
+ respond_ok(conn, %{
+ "id" => "tok_123",
+ "plaintext" => "art_v1_" <> String.duplicate("b", 40) <> "?expires=1760003600",
+ "scope" => "read",
+ "expires_at" => "2025-10-09T12:00:00Z"
+ })
+ end)
+
+ assert {:ok, %Token{} = token} =
+ CloudflareArtifacts.create_token(ctx.client,
+ repo: "starter-repo",
+ scope: "read",
+ ttl: 3600
+ )
+
+ assert token.id == "tok_123"
+ assert token.plaintext =~ "art_v1_"
+ assert token.scope == :read
+ assert token.expires_at == "2025-10-09T12:00:00Z"
+ end
+
+ test "accepts atom scope and serializes it as a string", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/tokens", fn conn ->
+ # Atom :read on input must hit the wire as the string "read".
+ assert read_json(conn) == %{"repo" => "r", "scope" => "read", "ttl" => 60}
+
+ respond_ok(conn, %{
+ "id" => "tok_a",
+ "plaintext" => "art_v1_x",
+ "scope" => "read",
+ "expires_at" => "2025-10-09T12:00:00Z"
+ })
+ end)
+
+ assert {:ok, %Token{scope: :read}} =
+ CloudflareArtifacts.create_token(ctx.client, repo: "r", scope: :read, ttl: 60)
+ end
+
+ test "ttl out of range surfaces upstream code 10_103", ctx do
+ Bypass.expect_once(ctx.bypass, "POST", "#{@ns_path}/tokens", fn conn ->
+ respond_error(conn, 400, [
+ %{"code" => 10_103, "message" => "ttl must be between 60 and 31536000 seconds"}
+ ])
+ end)
+
+ assert {:error, %Req.Response{body: %{"errors" => [%{"code" => 10_103} | _]}}} =
+ CloudflareArtifacts.create_token(ctx.client,
+ repo: "x",
+ ttl: 99_999_999
+ )
+ end
+ end
+
+ describe "list_tokens/3" do
+ test "accepts atom state and serializes it as a string in the query", ctx do
+ Bypass.expect_once(ctx.bypass, "GET", "#{@ns_path}/repos/r/tokens", fn conn ->
+ params = URI.decode_query(conn.query_string)
+ assert params["state"] == "active"
+ respond_list(conn, [], %{})
+ end)
+
+ assert {:ok, [], _} = CloudflareArtifacts.list_tokens(ctx.client, "r", state: :active)
+ end
+
+ test "GETs /repos/:name/tokens with offset-pagination params", ctx do
+ Bypass.expect_once(ctx.bypass, "GET", "#{@ns_path}/repos/starter-repo/tokens", fn conn ->
+ params = URI.decode_query(conn.query_string)
+ assert params["state"] == "all"
+ assert params["per_page"] == "30"
+
+ result_info = %{
+ "page" => 1,
+ "per_page" => 30,
+ "total_pages" => 1,
+ "count" => 1,
+ "total_count" => 1
+ }
+
+ respond_list(
+ conn,
+ [
+ %{
+ "id" => "tok_a",
+ "scope" => "write",
+ "state" => "active",
+ "created_at" => "2025-09-01T00:00:00Z",
+ "expires_at" => "2025-10-01T00:00:00Z"
+ }
+ ],
+ result_info
+ )
+ end)
+
+ assert {:ok, [%Token{id: "tok_a", scope: :write, state: :active}], %{"total_count" => 1}} =
+ CloudflareArtifacts.list_tokens(ctx.client, "starter-repo",
+ state: "all",
+ per_page: 30
+ )
+ end
+ end
+
+ describe "delete_token/2" do
+ test "issues DELETE /tokens/:id", ctx do
+ Bypass.expect_once(ctx.bypass, "DELETE", "#{@ns_path}/tokens/tok_to_kill", fn conn ->
+ respond_ok(conn, %{"id" => "tok_to_kill"})
+ end)
+
+ assert {:ok, %Token{id: "tok_to_kill"}} =
+ CloudflareArtifacts.delete_token(ctx.client, "tok_to_kill")
+ end
+ end
+
+ # --- Transport-level error path ---
+
+ describe "transport errors" do
+ test "bypass-down returns the Req exception, not %Req.Response{}", ctx do
+ Bypass.down(ctx.bypass)
+
+ assert {:error, exception} = CloudflareArtifacts.get_repo(ctx.client, "x")
+ refute is_struct(exception, Req.Response)
+ end
+ end
+
+ # --- Inspect redaction ---
+
+ describe "secret redaction" do
+ test "Repo with token field redacts in inspect" do
+ r = %Repo{id: "r1", name: "r1", token: "art_v1_secretsecret"}
+ str = inspect(r)
+ refute str =~ "secretsecret"
+ assert str =~ "***"
+ end
+
+ test "Repo without token field shows normally" do
+ r = %Repo{id: "r1", name: "r1"}
+ assert inspect(r) =~ "id: \"r1\""
+ end
+
+ test "Token with plaintext redacts in inspect" do
+ t = %Token{id: "tok_1", plaintext: "art_v1_secret_plaintext"}
+ str = inspect(t)
+ refute str =~ "secret_plaintext"
+ assert str =~ "***"
+ end
+ end
+
+ # --- Bypass / Plug helpers ---
+
+ defp auth_header(conn) do
+ Enum.find_value(conn.req_headers, fn
+ {"authorization", v} -> v
+ _ -> nil
+ end)
+ end
+
+ defp read_json(conn) do
+ {:ok, body, _conn} = Plug.Conn.read_body(conn)
+ Jason.decode!(body)
+ end
+
+ defp respond_ok(conn, result, status \\ 200) when is_map(result) do
+ body = Jason.encode!(envelope_body(result))
+
+ conn
+ |> Plug.Conn.put_resp_content_type("application/json")
+ |> Plug.Conn.resp(status, body)
+ end
+
+ defp respond_list(conn, items, result_info) do
+ body = Jason.encode!(envelope_body(items, %{"result_info" => result_info}))
+
+ conn
+ |> Plug.Conn.put_resp_content_type("application/json")
+ |> Plug.Conn.resp(200, body)
+ end
+
+ defp respond_error(conn, status, errors) do
+ body =
+ Jason.encode!(%{
+ "result" => nil,
+ "success" => false,
+ "errors" => errors,
+ "messages" => []
+ })
+
+ conn
+ |> Plug.Conn.put_resp_content_type("application/json")
+ |> Plug.Conn.resp(status, body)
+ end
+
+ defp envelope_body(result, extras \\ %{}) do
+ Map.merge(
+ %{
+ "result" => result,
+ "success" => true,
+ "errors" => [],
+ "messages" => []
+ },
+ extras
+ )
+ end
+end
diff --git a/test/support/cloudflare_artifacts.ex b/test/support/cloudflare_artifacts.ex
index 4693cbb..8facbbc 100644
--- a/test/support/cloudflare_artifacts.ex
+++ b/test/support/cloudflare_artifacts.ex
@@ -1,20 +1,33 @@
defmodule Exgit.Test.CloudflareArtifacts do
@moduledoc """
- Test-support shim for the Cloudflare Artifacts smoketest.
+ Test-support shim for the Cloudflare Artifacts smoketests.
- 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:
+ Two modes are supported via different env vars:
+
+ ## Long-lived repo mode (`cloudflare_artifacts_roundtrip_test.exs`)
+
+ Targets a pre-provisioned repo with an injected token; tests push
+ unique branches to it.
* `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.
- Because the repo is persistent across runs, tests must use unique
- branch names to avoid stomping each other.
+ ## Full-lifecycle mode (`cloudflare_artifacts_lifecycle_test.exs`)
+
+ Uses a Cloudflare API token to create a fresh repo, mint git
+ tokens, exercise the wire protocol, and tear everything down.
+
+ * `CF_ACCOUNT_ID` — Cloudflare account ID. Generic
+ Cloudflare credential, not artifact-specific.
+ * `CF_API_TOKEN` — Cloudflare API token with
+ `Artifacts > Edit` permission. Generic.
+ * `CF_ARTIFACT_NAMESPACE` — optional, defaults to `"default"`.
+ Artifact-specific.
"""
+ # --- Long-lived mode ---
+
def available? do
System.get_env("CF_ARTIFACT_REMOTE") not in [nil, ""] and
System.get_env("CF_ARTIFACT_TOKEN") not in [nil, ""]
@@ -27,4 +40,21 @@ defmodule Exgit.Test.CloudflareArtifacts do
def token do
System.get_env("CF_ARTIFACT_TOKEN") || raise "CF_ARTIFACT_TOKEN not set"
end
+
+ # --- Full-lifecycle mode ---
+
+ def api_available? do
+ System.get_env("CF_ACCOUNT_ID") not in [nil, ""] and
+ System.get_env("CF_API_TOKEN") not in [nil, ""]
+ end
+
+ def account_id do
+ System.get_env("CF_ACCOUNT_ID") || raise "CF_ACCOUNT_ID not set"
+ end
+
+ def api_token do
+ System.get_env("CF_API_TOKEN") || raise "CF_API_TOKEN not set"
+ end
+
+ def namespace, do: System.get_env("CF_ARTIFACT_NAMESPACE") || "default"
end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 8ed250d..c3f1f2b 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -34,6 +34,13 @@ exclude =
[{:cloudflare, true} | exclude]
end
+exclude =
+ if Exgit.Test.CloudflareArtifacts.api_available?() do
+ exclude
+ else
+ [{:cloudflare_api, true} | exclude]
+ end
+
# Live-network tiers are excluded by default; opt in via
# `mix test --include github_private` (or other tag names).
exclude =
@@ -42,6 +49,7 @@ exclude =
{:slow, true},
{:integration, true},
{:cloudflare, true},
+ {:cloudflare_api, true},
{:github_private, true},
{:github_private_write, true}
| exclude