From d7561971281975bb9283250c91fad3310130de53 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Wed, 6 May 2026 16:45:56 -0400 Subject: [PATCH 1/2] feat: Cloudflare Artifacts REST API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the full control plane documented at https://developers.cloudflare.com/artifacts/api/rest-api/ — six repo endpoints (create / list / get / delete / fork / import) and three token endpoints (create / list / delete) — so repos and git tokens can be managed end-to-end alongside the existing wire-protocol support. Function and field names mirror upstream verbatim (`create_repo`, `default_branch`, `plaintext`, `expires_at`, …) so the client can be cross-referenced against the Cloudflare TypeScript types directly. `Exgit.CloudflareArtifacts.new/1` returns a `%Req.Request{}` configured with bearer auth and `base_url`; per-endpoint functions use Req's `:path_params` and `:json` steps with no manual URL building. Errors are surfaced as `{:error, %Req.Response{}}` so callers can pattern-match on upstream codes directly (e.g. `code: 10103` for `ttl out of range`). Transport-level failures pass through Req's exception unchanged. `%Repo{}` and `%Token{}` redact the `:token` and `:plaintext` fields in their `Inspect` impls. Tests: * Bypass-backed unit suite covering every endpoint's URL, body, query params, auth header, and v4 envelope parsing — including a pin on Req's path-segment URL-encoding behavior. * Live lifecycle smoketest (`:cloudflare_api`) that creates a fresh repo, mints write+read tokens, pushes via exgit, clones via exgit with byte-equality assertion, lists/revokes tokens, and deletes the repo. Gated on `CF_ARTIFACT_ACCOUNT_ID` + `CF_ARTIFACT_API_TOKEN`. The new `:cloudflare_api` tag is decoupled from `:cloudflare` so the existing wire-roundtrip CI step is unaffected; the lifecycle test opts in once new secrets are wired. Adds Bypass as a test-only dep. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/exgit/cloudflare_artifacts.ex | 281 ++++++++++++ lib/exgit/cloudflare_artifacts/repo.ex | 80 ++++ lib/exgit/cloudflare_artifacts/token.ex | 73 +++ mix.exs | 3 + mix.lock | 8 + .../cloudflare_artifacts_lifecycle_test.exs | 169 +++++++ test/exgit/cloudflare_artifacts_test.exs | 420 ++++++++++++++++++ test/support/cloudflare_artifacts.ex | 42 +- test/test_helper.exs | 8 + 9 files changed, 1077 insertions(+), 7 deletions(-) create mode 100644 lib/exgit/cloudflare_artifacts.ex create mode 100644 lib/exgit/cloudflare_artifacts/repo.ex create mode 100644 lib/exgit/cloudflare_artifacts/token.ex create mode 100644 test/exgit/cloudflare_artifacts_lifecycle_test.exs create mode 100644 test/exgit/cloudflare_artifacts_test.exs diff --git a/lib/exgit/cloudflare_artifacts.ex b/lib/exgit/cloudflare_artifacts.ex new file mode 100644 index 0000000..1025610 --- /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: "abc123", + namespace: "default", + api_token: System.fetch_env!("CF_ARTIFACT_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..e33d6a7 --- /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_ARTIFACT_ACCOUNT_ID` and `CF_ARTIFACT_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_ARTIFACT_ACCOUNT_ID` / `CF_ARTIFACT_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..9fde5f4 100644 --- a/test/support/cloudflare_artifacts.ex +++ b/test/support/cloudflare_artifacts.ex @@ -1,20 +1,31 @@ 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_ARTIFACT_ACCOUNT_ID` — Cloudflare account ID. + * `CF_ARTIFACT_API_TOKEN` — Cloudflare API token with + `Artifacts > Edit` permission. + * `CF_ARTIFACT_NAMESPACE` — optional, defaults to `"default"`. """ + # --- 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 +38,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_ARTIFACT_ACCOUNT_ID") not in [nil, ""] and + System.get_env("CF_ARTIFACT_API_TOKEN") not in [nil, ""] + end + + def account_id do + System.get_env("CF_ARTIFACT_ACCOUNT_ID") || raise "CF_ARTIFACT_ACCOUNT_ID not set" + end + + def api_token do + System.get_env("CF_ARTIFACT_API_TOKEN") || raise "CF_ARTIFACT_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 From 0940ab3ddabb48ee744bf238d4de1cb307d67063 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Wed, 6 May 2026 16:50:54 -0400 Subject: [PATCH 2/2] test: use generic CF_ACCOUNT_ID / CF_API_TOKEN env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass prefixed both with `CF_ARTIFACT_` for symmetry with the existing `CF_ARTIFACT_REMOTE` / `CF_ARTIFACT_TOKEN` (which are artifact-scoped — a wire URL and a repo-scoped git token). But the account ID and API token are *generic* Cloudflare credentials — reusable across any CF product — so the artifact prefix was wrong. `CF_ARTIFACT_NAMESPACE` keeps its prefix since it really is artifact-specific. Verified end-to-end against a real account: full lifecycle (create → write-token → push → read-token → clone with byte-equal verification → list → revoke → delete) completes in ~5s. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/exgit/cloudflare_artifacts.ex | 4 ++-- .../cloudflare_artifacts_lifecycle_test.exs | 6 +++--- test/support/cloudflare_artifacts.ex | 18 ++++++++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/exgit/cloudflare_artifacts.ex b/lib/exgit/cloudflare_artifacts.ex index 1025610..38d1b29 100644 --- a/lib/exgit/cloudflare_artifacts.ex +++ b/lib/exgit/cloudflare_artifacts.ex @@ -19,9 +19,9 @@ defmodule Exgit.CloudflareArtifacts do and a default user-agent already set: client = Exgit.CloudflareArtifacts.new( - account_id: "abc123", + account_id: System.fetch_env!("CF_ACCOUNT_ID"), namespace: "default", - api_token: System.fetch_env!("CF_ARTIFACT_API_TOKEN") + api_token: System.fetch_env!("CF_API_TOKEN") ) Any extra options (e.g. `:plug` for testing, `:retry`, `:receive_timeout`) diff --git a/test/exgit/cloudflare_artifacts_lifecycle_test.exs b/test/exgit/cloudflare_artifacts_lifecycle_test.exs index e33d6a7..4a4213b 100644 --- a/test/exgit/cloudflare_artifacts_lifecycle_test.exs +++ b/test/exgit/cloudflare_artifacts_lifecycle_test.exs @@ -18,14 +18,14 @@ defmodule Exgit.CloudflareArtifactsLifecycleTest do 9. Delete the repo. Cleanup is also wrapped in `on_exit` so a failure mid-test still releases the repo. - Requires `CF_ARTIFACT_ACCOUNT_ID` and `CF_ARTIFACT_API_TOKEN`. - Optionally `CF_ARTIFACT_NAMESPACE` (defaults to `"default"`). + 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_ARTIFACT_ACCOUNT_ID` / `CF_ARTIFACT_API_TOKEN` aren't set. + `CF_ACCOUNT_ID` / `CF_API_TOKEN` aren't set. """ use ExUnit.Case, async: false diff --git a/test/support/cloudflare_artifacts.ex b/test/support/cloudflare_artifacts.ex index 9fde5f4..8facbbc 100644 --- a/test/support/cloudflare_artifacts.ex +++ b/test/support/cloudflare_artifacts.ex @@ -18,10 +18,12 @@ defmodule Exgit.Test.CloudflareArtifacts do Uses a Cloudflare API token to create a fresh repo, mint git tokens, exercise the wire protocol, and tear everything down. - * `CF_ARTIFACT_ACCOUNT_ID` — Cloudflare account ID. - * `CF_ARTIFACT_API_TOKEN` — Cloudflare API token with - `Artifacts > Edit` permission. - * `CF_ARTIFACT_NAMESPACE` — optional, defaults to `"default"`. + * `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 --- @@ -42,16 +44,16 @@ defmodule Exgit.Test.CloudflareArtifacts do # --- Full-lifecycle mode --- def api_available? do - System.get_env("CF_ARTIFACT_ACCOUNT_ID") not in [nil, ""] and - System.get_env("CF_ARTIFACT_API_TOKEN") not in [nil, ""] + 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_ARTIFACT_ACCOUNT_ID") || raise "CF_ARTIFACT_ACCOUNT_ID not set" + System.get_env("CF_ACCOUNT_ID") || raise "CF_ACCOUNT_ID not set" end def api_token do - System.get_env("CF_ARTIFACT_API_TOKEN") || raise "CF_ARTIFACT_API_TOKEN not set" + System.get_env("CF_API_TOKEN") || raise "CF_API_TOKEN not set" end def namespace, do: System.get_env("CF_ARTIFACT_NAMESPACE") || "default"