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