Skip to content

Testing

Testing

Once a route requires a valid signature, every test that posts to it has to produce one. There are two bad ways to handle that and one good one.

Computing the signature by hand in the test duplicates the implementation, so the test agrees with the code by construction and stops catching anything. Mocking the verifier away is worse: the route is then never verified in any test, which is exactly the property you most want covered.

The trait signs with the production signing code, through the real configuration.

use Cbox\WebhookSignature\Testing\InteractsWithWebhookSignatures;

uses(InteractsWithWebhookSignatures::class);

The package's own suite composes this same trait — if a helper is awkward here, it is awkward for you, and it gets fixed rather than worked around.

Declare the endpoint for the test

$this->fakeWebhookEndpoint('github', 'github', 'test-secret');

So the test does not depend on whatever the host application happens to have configured, and does not need a real secret in the environment. Pass a list or a label map to exercise rotation:

$this->fakeWebhookEndpoint('github', 'github', ['new' => 'secret-b', 'old' => 'secret-a']);

Post a signed request

$this->postSignedWebhook('/webhooks/github', 'github', ['action' => 'opened'])
    ->assertOk();

The body is encoded once and both signed and sent — signing one encoding and sending another is the exact bug these helpers exist to make impossible.

Assert the refusals too

it('refuses an unsigned delivery', function () {
    $this->fakeWebhookEndpoint('github', 'github', 'test-secret');

    $this->postUnsignedWebhook('/webhooks/github', ['action' => 'opened'])
        ->assertUnauthorized();
});

it('refuses a tampered body', function () {
    $this->fakeWebhookEndpoint('github', 'github', 'test-secret');

    $headers = $this->webhookHeaders('github', json_encode(['amount' => 100]));

    $this->call('POST', '/webhooks/github', [], [], [],
        $this->transformHeadersToServerVars($headers),
        json_encode(['amount' => 100_000]),
    )->assertUnauthorized();
});

Control the clock

Timestamp-bound schemes need a fixed instant to be deterministic, and the window is only testable if you can move past it:

$clock = $this->freezeWebhookClock(1_700_000_000);

$headers = $this->webhookHeaders('stripe', $body);

$clock->advance(301);   // now outside Stripe's default 300s window

An untested tolerance window is a replay defence you cannot honestly claim to have.

Exercise replay protection

$guard = $this->trackWebhookReplays();   // in-memory, single process

$this->postSignedWebhook('/webhooks/stripe', 'stripe', $payload)->assertOk();
$this->postSignedWebhook('/webhooks/stripe', 'stripe', $payload)->assertUnauthorized();

ArrayReplayGuard is deliberately not offered as a production driver — per-process memory cannot enforce single use across more than one node. In a test there is one process, so it is exactly right.

Testing outbound deliveries

Assert what was sent, by verifying it — not by checking that a header exists:

Http::fake();

Http::webhookSignature('outbound')->post('https://example.test/hook', ['event' => 'ping']);

Http::assertSent(function (Request $request) {
    Webhooks::verify('outbound', WebhookMessage::make($request->body(), $request->headers()));

    return true;
});

If the signing ever covered something other than what went on the wire, that verification throws. Asserting $request->hasHeader('X-Cbox-Signature') would pass in exactly the case you care about catching.

Helpers

Helper Purpose
fakeWebhookEndpoint($name, $scheme, $secrets, $tolerance) Register an endpoint for this test
freezeWebhookClock($timestamp) Pin the clock; returns a FrozenClock you can advance
trackWebhookReplays() Enable single-use enforcement with an in-memory guard
webhookHeaders($endpoint, $body, $timestamp, $url) The headers a genuine sender would send
postSignedWebhook($uri, $endpoint, $payload) POST a correctly signed JSON payload
postUnsignedWebhook($uri, $payload) POST with no signature, to assert the refusal

The trait composes onto a plain Illuminate\Foundation\Testing\TestCase and onto Testbench's alike — it resolves everything through the container rather than through inherited properties.