Skip to main content

lib/coffrify/testing.ex

defmodule Coffrify.Testing do
  @moduledoc """
  Test helpers for apps consuming the Coffrify Elixir SDK.

  * `Coffrify.Testing.Fixtures` — minimal but realistic response shapes for
    transfers, webhooks, API keys, events.
  * `Coffrify.Testing.sign_payload_test/3` — build a `{body, headers}` pair
    that passes `Coffrify.Webhook.Verification.verify/4`, so your handler
    tests don't need to hit the live API.
  """

  alias Coffrify.Webhook.Verification

  @signature_version "v1"

  @doc """
  Sign a payload using the same scheme as Coffrify's server. Returns
  `{body, headers_list}` ready to plug into a `Plug.Test.conn/3` call.

  ## Options

    * `:event_id` — explicit `webhook-id` (default `evt_<rand>`)
    * `:timestamp` — explicit `webhook-timestamp` (default `now`)
    * `:extra_headers` — extra headers merged into the result

  ## Example

      {body, headers} =
        Coffrify.Testing.sign_payload_test("whsec_abc...",
          Jason.encode!(%{type: "ping"}))

      conn =
        :post
        |> Plug.Test.conn("/webhook", body)
        |> Map.update!(:req_headers, &(&1 ++ headers))
  """
  @spec sign_payload_test(String.t(), iodata(), keyword()) :: {binary(), [{String.t(), String.t()}]}
  def sign_payload_test(secret, body, opts \\ []) do
    body_bin = IO.iodata_to_binary(body)
    event_id = Keyword.get(opts, :event_id, "evt_" <> random_id())
    ts = Keyword.get(opts, :timestamp, System.system_time(:second))
    payload = "#{event_id}.#{ts}.#{body_bin}"

    sig =
      :crypto.mac(:hmac, :sha256, key_bytes(secret), payload)
      |> Base.encode64()

    headers =
      [
        {"webhook-id", event_id},
        {"webhook-timestamp", to_string(ts)},
        {"webhook-signature", "#{@signature_version},#{sig}"},
        {"content-type", "application/json"}
      ] ++ Keyword.get(opts, :extra_headers, [])

    {body_bin, headers}
  end

  @doc """
  Convenience: assert that a self-signed payload verifies. Returns the
  decoded event on success, raises with the rejection reason otherwise.
  """
  @spec verify_test_payload!(String.t(), iodata(), keyword()) :: map()
  def verify_test_payload!(secret, body, opts \\ []) do
    {bin, headers} = sign_payload_test(secret, body, opts)

    case Verification.verify(secret, bin, headers) do
      {:ok, event} -> event
      {:error, reason} -> raise "Coffrify.Testing.verify_test_payload! rejected: #{reason}"
    end
  end

  defp key_bytes("whsec_" <> rest = secret) do
    cond do
      String.match?(rest, ~r/^[0-9a-fA-F]+$/) and rem(byte_size(rest), 2) == 0 ->
        Base.decode16!(rest, case: :mixed)

      String.match?(rest, ~r/^[A-Za-z0-9_-]+={0,2}$/) ->
        Base.url_decode64!(rest, padding: false)

      true ->
        secret
    end
  end

  defp key_bytes(secret), do: secret

  defp random_id, do: :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false)
end