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.