Files
leadmail-sdk/tests/Feature/LeadMailWebhookTest.php
netlas b2b61a26ed Add typed error handling, retries, and webhook receiver
- Typed exceptions: LeadMailException base with LeadMailRequestException
  (structured statusCode/errorCode/logId/validationErrors) and
  LeadMailConnectionException; sendEmail/getDomains now throw these instead
  of raw Guzzle exceptions, and a malformed body is no longer a silent null.
- Automatic retry with exponential backoff on idempotent calls
  (getDomains, verifyEmail); sends are never retried to avoid duplicates.
- Webhook receiver: auto-registered route + LeadMailWebhookController that
  verifies the HMAC signature, logs failures, and dispatches a
  LeadMailWebhookReceived event. WebhookSignature/WebhookEvent/LeadMailWebhook
  helpers for manual handling.
- Webhook self-registration client methods (registerWebhook/getWebhook/
  deleteWebhook) and a promptless `leadmail:install` command that registers
  the URL and writes LEADMAIL_WEBHOOK_SECRET to .env.
- Null-safe client binding when LEADMAIL_TOKEN is unset.
- Test suite (Pest + Testbench) covering all of the above.
2026-06-09 11:14:10 +03:00

129 lines
4.2 KiB
PHP

<?php
use Illuminate\Http\Request;
use LeadM\LeadMail\Exceptions\InvalidWebhookSignatureException;
use LeadM\LeadMail\Webhooks\LeadMailWebhook;
use LeadM\LeadMail\Webhooks\WebhookEvent;
use LeadM\LeadMail\Webhooks\WebhookSignature;
const WEBHOOK_SECRET = 'whsec_test_secret';
/**
* Build a request the exact way the leadMail service signs its webhooks:
* body = json_encode($payload, JSON_UNESCAPED_SLASHES);
* signature = 'sha256=' . hash_hmac('sha256', $body, $secret).
*
* @param array<string, mixed> $payload
*/
function signedWebhookRequest(array $payload, string $secret = WEBHOOK_SECRET): Request
{
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
$signature = 'sha256='.hash_hmac('sha256', $body, $secret);
return Request::create(
'/webhooks/leadmail',
'POST',
[],
[],
[],
['HTTP_X_LEADMAIL_SIGNATURE' => $signature, 'CONTENT_TYPE' => 'application/json'],
$body,
);
}
/**
* @return array<string, mixed>
*/
function failurePayload(): array
{
return [
'event' => 'email.failed',
'delivery_id' => 'wd_01hxyz',
'occurred_at' => '2026-03-06T00:26:24+00:00',
'log_id' => 85,
'tenant_id' => 'tenant-7',
'status' => 'failed',
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
'from' => 'noreply@leadmagnet.dev',
'to' => ['lead@example.com'],
'subject' => 'Welcome',
'metadata' => ['campaign' => 'spring'],
];
}
it('parses a genuinely signed webhook into a typed event', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$event = $webhook->parse(signedWebhookRequest(failurePayload()));
expect($event)->toBeInstanceOf(WebhookEvent::class)
->and($event->isFailure())->toBeTrue()
->and($event->logId)->toBe(85)
->and($event->tenantId)->toBe('tenant-7')
->and($event->errorCode)->toBe('TRANSPORT_ERROR')
->and($event->errorMessage)->toBe('Sender address not verified')
->and($event->from)->toBe('noreply@leadmagnet.dev')
->and($event->to)->toBe(['lead@example.com'])
->and($event->metadata)->toBe(['campaign' => 'spring']);
});
it('rejects a tampered body', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$request = signedWebhookRequest(failurePayload());
$tampered = Request::create(
'/webhooks/leadmail',
'POST',
[],
[],
[],
['HTTP_X_LEADMAIL_SIGNATURE' => $request->header(WebhookSignature::HEADER)],
json_encode(['event' => 'email.failed', 'log_id' => 999]),
);
expect($webhook->verify($tampered))->toBeFalse()
->and(fn () => $webhook->parse($tampered))
->toThrow(InvalidWebhookSignatureException::class);
});
it('rejects a request signed with the wrong secret', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$request = signedWebhookRequest(failurePayload(), 'the_wrong_secret');
expect($webhook->verify($request))->toBeFalse();
});
it('rejects a request with no signature header', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$request = Request::create('/webhooks/leadmail', 'POST', [], [], [], [], json_encode(failurePayload()));
expect($webhook->verify($request))->toBeFalse()
->and(fn () => $webhook->parse($request))
->toThrow(InvalidWebhookSignatureException::class);
});
it('treats an empty configured secret as never valid', function () {
$webhook = new LeadMailWebhook('');
expect($webhook->verify(signedWebhookRequest(failurePayload())))->toBeFalse();
});
it('resolves the webhook helper from the container with the configured secret', function () {
config()->set('leadmail.webhook_secret', WEBHOOK_SECRET);
$event = app(LeadMailWebhook::class)->parse(signedWebhookRequest(failurePayload()));
expect($event->logId)->toBe(85);
});
it('degrades gracefully when optional fields are missing', function () {
$event = WebhookEvent::fromArray(['event' => 'email.failed']);
expect($event->isFailure())->toBeTrue()
->and($event->logId)->toBeNull()
->and($event->to)->toBe([])
->and($event->metadata)->toBeNull();
});