Test your integration

Copy Markdown View Source

What you will build: Deterministic tests for an application that signs or verifies requests with RequestSeal: generated keys per test, a fixed clock, golden signature bytes, boundary checks without sleeping, and a Plug receiver driven without a server. Prerequisite: RequestSeal and ExUnit; the Plug section also needs :plug. Use real keys and the real library in your tests; nothing here replaces RequestSeal with a stand-in.

Generate keys per test

Create keys inside the test and release them when it ends. In an ExUnit setup block, call RequestSeal.Custody.Local.release/1 from on_exit/1. The examples below check results with pattern matches so they run anywhere; in your tests, write the same checks with assert:

{_public_bytes, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, key} = RequestSeal.Custody.public_key(handle)
components = ~s[("@method" "@scheme" "@authority" "@path" "content-digest")]

Fix the clock and the nonce

Signing accepts clock: and nonce:; the policy's freshness takes its own clock. With both fixed, Ed25519 signing is deterministic, so a test can compare exact header bytes. Use a fixed nonce only in tests: production signing must use fresh entropy for every request.

created = 1_790_000_000
nonce = :binary.copy(<<7>>, 32)

{:ok, message} =
  RequestSeal.Message.request(
    "POST",
    "https://inventory.example.com/restock",
    [{"content-type", "application/json"}],
    ~s({"sku":"LAMP-9","quantity":12}),
    digest: ["sha-256"]
  )

spec = %{label: "sig", algorithm: "ed25519", components: components, expires_in: 60, keyid: "test-key"}
sign = fn -> RequestSeal.sign(message, spec, handle, clock: fn -> created end, nonce: nonce) end

{:ok, first} = sign.()
{:ok, second} = sign.()
true = first.fields == second.fields

[signature_input] = for f <- first.fields, f.name == "Signature-Input", do: f.value
true = signature_input =~ ";created=1790000000;expires=1790000060;"

Store such a header as a golden value when you want to catch any change in what your application signs.

Test acceptance and freshness boundaries

Build the policy from a clock argument so each test chooses the verifier's time. The expiration is exclusive: a request is accepted before expires and rejected at it, after allowing for skew:

policy_at = fn now ->
  {:ok, policy} =
    RequestSeal.Policy.new(%{
      algorithms: ["ed25519"],
      components: components,
      key_resolver: fn
        %{keyid: "test-key"} -> {:ok, %{algorithm: "ed25519", key: key}}
        _ -> :error
      end,
      freshness: %{clock: fn -> now end, max_age: 60, skew: 0, require_expires: true},
      content: %{kind: :content, algorithms: ["sha-256"], section: :headers},
      replay: :not_required
    })

  policy
end

{:ok, verification} = RequestSeal.verify(first, policy_at.(created + 59), label: "sig")
:valid = verification.signature.crypto
["sha-256"] = verification.content.checked
:not_evaluated = verification.authorization

{:error, %RequestSeal.Error{reason: :expired, layer: :freshness}} =
  RequestSeal.verify(first, policy_at.(created + 60), label: "sig")

For every other rejection your application must handle, the errors guide has a table of changes and the exact reason and layer each produces; copy the rows you rely on into your own tests.

Drive a Plug receiver without a server

Plug.Test connections run through RequestSeal.Plug.Capture and RequestSeal.Plug.Verify in the test process. Put the signed headers on the connection with the same body:

deliver = fn request, body ->
  conn =
    Enum.reduce(request.fields, Plug.Test.conn(:post, "https://inventory.example.com/restock", body), fn field, conn ->
      Plug.Conn.put_req_header(conn, String.downcase(field.name), field.value)
    end)

  RequestSeal.Plug.Capture.call(conn, RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: 65_536, read_timeout: 5_000))
  |> RequestSeal.Plug.Verify.call(
    RequestSeal.Plug.Verify.init(policy: policy_at.(created + 1), label: "sig", on_reject: {:halt, 401})
  )
end

accepted = deliver.(first, ~s({"sku":"LAMP-9","quantity":12}))
false = accepted.halted
{:ok, %RequestSeal.Verification{}} = RequestSeal.Plug.verification(accepted)

rejected = deliver.(first, ~s({"sku":"LAMP-9","quantity":120}))
{true, 401} = {rejected.halted, rejected.status}

In your application, call your own endpoint or router module the same way so the test covers your plug order, including Plug.Parsers with Capture's body reader.

Test single use with a per-test store

Start an ETS replay store in the test and stop it at the end. A second verification of the same request must reject:

{:ok, store} = RequestSeal.Replay.ETS.start_link(max_entries: 100)

replay = %{
  identifier: :nonce,
  namespace: "inventory-tests",
  commitment: fn facts -> {:ok, facts.identifier} end,
  store: RequestSeal.Replay.ETS.store(store),
  timeout: 5_000
}

{:ok, once} = RequestSeal.Policy.new(%{Map.from_struct(policy_at.(created + 1)) | replay: replay})
{:ok, _} = RequestSeal.verify(first, once, label: "sig")
{:error, %RequestSeal.Error{reason: :replayed}} = RequestSeal.verify(first, once, label: "sig")

GenServer.stop(store)
:ok = RequestSeal.Custody.Local.release(handle)

These tests show your code and RequestSeal agree with each other. They do not show that a deployed peer accepts your requests; testing and evidence explains what each kind of evidence establishes.