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.
This commit is contained in:
118
tests/Feature/InstallCommandTest.php
Normal file
118
tests/Feature/InstallCommandTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use LeadM\LeadMail\LeadMailClient;
|
||||
|
||||
/**
|
||||
* Bind a client backed by canned HTTP responses into the container so the
|
||||
* command resolves it instead of making real requests.
|
||||
*
|
||||
* @param array<int, mixed> $queue
|
||||
*/
|
||||
function bindMockedClient(array $queue): void
|
||||
{
|
||||
app()->instance(LeadMailClient::class, new LeadMailClient(
|
||||
baseUrl: 'https://mail.test',
|
||||
token: 'lm_test',
|
||||
retryDelayMs: 0,
|
||||
handlerStack: HandlerStack::create(new MockHandler($queue)),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the application at a throwaway .env file and return its path.
|
||||
*/
|
||||
function useTempEnv(string $contents = "APP_NAME=Test\n"): string
|
||||
{
|
||||
$dir = sys_get_temp_dir().'/leadmail-test-'.uniqid();
|
||||
mkdir($dir);
|
||||
file_put_contents($dir.'/.env', $contents);
|
||||
app()->useEnvironmentPath($dir);
|
||||
|
||||
return $dir.'/.env';
|
||||
}
|
||||
|
||||
function registrationResponse(?string $secret): Response
|
||||
{
|
||||
$data = ['url' => 'https://app.test/webhooks/leadmail', 'has_secret' => true];
|
||||
|
||||
if ($secret !== null) {
|
||||
$data['secret'] = $secret;
|
||||
}
|
||||
|
||||
return new Response(200, [], json_encode(['success' => true, 'data' => $data]));
|
||||
}
|
||||
|
||||
it('derives the webhook url from app.url and the configured route without prompting', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
config()->set('app.url', 'https://helios.test');
|
||||
config()->set('leadmail.webhook_route', '/webhooks/leadmail');
|
||||
useTempEnv();
|
||||
|
||||
$mock = new MockHandler([registrationResponse('whsec_x')]);
|
||||
app()->instance(LeadMailClient::class, new LeadMailClient(
|
||||
baseUrl: 'https://mail.test',
|
||||
token: 'lm_test',
|
||||
retryDelayMs: 0,
|
||||
handlerStack: HandlerStack::create($mock),
|
||||
));
|
||||
|
||||
$this->artisan('leadmail:install', ['--no-publish' => true])->assertSuccessful();
|
||||
|
||||
$sent = json_decode((string) $mock->getLastRequest()->getBody(), true);
|
||||
expect($sent['url'])->toBe('https://helios.test/webhooks/leadmail');
|
||||
});
|
||||
|
||||
it('registers the webhook and writes the secret to .env', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
$envPath = useTempEnv();
|
||||
bindMockedClient([registrationResponse('whsec_written')]);
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect(file_get_contents($envPath))->toContain('LEADMAIL_WEBHOOK_SECRET=whsec_written');
|
||||
});
|
||||
|
||||
it('replaces an existing secret line instead of duplicating it', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
$envPath = useTempEnv("APP_NAME=Test\nLEADMAIL_WEBHOOK_SECRET=whsec_old\n");
|
||||
bindMockedClient([registrationResponse('whsec_new')]);
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$contents = file_get_contents($envPath);
|
||||
expect($contents)->toContain('whsec_new')
|
||||
->and($contents)->not->toContain('whsec_old')
|
||||
->and(substr_count($contents, 'LEADMAIL_WEBHOOK_SECRET='))->toBe(1);
|
||||
});
|
||||
|
||||
it('does not touch .env when the url is updated without a new secret', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
$envPath = useTempEnv();
|
||||
bindMockedClient([registrationResponse(null)]);
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect(file_get_contents($envPath))->not->toContain('LEADMAIL_WEBHOOK_SECRET');
|
||||
});
|
||||
|
||||
it('fails when the api token is not configured', function () {
|
||||
config()->set('leadmail.token', null);
|
||||
useTempEnv();
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertFailed();
|
||||
});
|
||||
166
tests/Feature/LeadMailClientTest.php
Normal file
166
tests/Feature/LeadMailClientTest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use LeadM\LeadMail\Exceptions\LeadMailConnectionException;
|
||||
use LeadM\LeadMail\Exceptions\LeadMailException;
|
||||
use LeadM\LeadMail\Exceptions\LeadMailRequestException;
|
||||
use LeadM\LeadMail\LeadMailClient;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $queue
|
||||
*/
|
||||
function clientWithResponses(array $queue): LeadMailClient
|
||||
{
|
||||
$mock = new MockHandler($queue);
|
||||
|
||||
return new LeadMailClient(
|
||||
baseUrl: 'https://mail.test',
|
||||
token: 'lm_test',
|
||||
retries: 2,
|
||||
retryDelayMs: 0,
|
||||
handlerStack: HandlerStack::create($mock),
|
||||
);
|
||||
}
|
||||
|
||||
it('returns the decoded body on success', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(202, [], json_encode([
|
||||
'success' => true,
|
||||
'data' => ['log_id' => 42, 'status' => 'queued'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$result = $client->sendEmail(['to' => []]);
|
||||
|
||||
expect($result['data']['log_id'])->toBe(42)
|
||||
->and($result['success'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws a request exception with structured fields on a transport error', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(502, [], json_encode([
|
||||
'success' => false,
|
||||
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
|
||||
'data' => ['log_id' => 85, 'status' => 'failed'],
|
||||
])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->sendEmail(['to' => []]);
|
||||
$this->fail('Expected LeadMailRequestException');
|
||||
} catch (LeadMailRequestException $e) {
|
||||
expect($e->statusCode())->toBe(502)
|
||||
->and($e->errorCode())->toBe('TRANSPORT_ERROR')
|
||||
->and($e->logId())->toBe(85)
|
||||
->and($e->getMessage())->toBe('Sender address not verified');
|
||||
}
|
||||
});
|
||||
|
||||
it('never retries a send, even on a retryable 5xx', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(503, [], json_encode(['error' => ['code' => 'X', 'message' => 'down']])),
|
||||
// If sends were retried this 200 would be consumed and the call would succeed.
|
||||
new Response(200, [], json_encode(['success' => true, 'data' => ['log_id' => 1, 'status' => 'sent']])),
|
||||
]);
|
||||
|
||||
expect(fn () => $client->sendEmail(['to' => []]))->toThrow(LeadMailRequestException::class);
|
||||
});
|
||||
|
||||
it('parses laravel validation errors from a 422', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(422, [], json_encode([
|
||||
'message' => 'The given data was invalid.',
|
||||
'errors' => ['from.email' => ['The from.email field is required.']],
|
||||
])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->sendEmail(['to' => []]);
|
||||
$this->fail('Expected LeadMailRequestException');
|
||||
} catch (LeadMailRequestException $e) {
|
||||
expect($e->isValidationError())->toBeTrue()
|
||||
->and($e->validationErrors())->toHaveKey('from.email');
|
||||
}
|
||||
});
|
||||
|
||||
it('flags authentication errors from a string error envelope', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(401, [], json_encode(['error' => 'Invalid API token.'])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->getDomains();
|
||||
$this->fail('Expected LeadMailRequestException');
|
||||
} catch (LeadMailRequestException $e) {
|
||||
expect($e->isAuthenticationError())->toBeTrue()
|
||||
->and($e->getMessage())->toBe('Invalid API token.');
|
||||
}
|
||||
});
|
||||
|
||||
it('retries transient 5xx responses then succeeds', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(503, [], 'service unavailable'),
|
||||
new Response(200, [], json_encode([
|
||||
'success' => true,
|
||||
'data' => ['domains' => ['leadmagnet.dev']],
|
||||
])),
|
||||
]);
|
||||
|
||||
expect($client->getDomains())->toBe(['leadmagnet.dev']);
|
||||
});
|
||||
|
||||
it('gives up after exhausting retries on persistent 5xx', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(500, [], json_encode(['error' => ['code' => 'X', 'message' => 'boom']])),
|
||||
new Response(500, [], json_encode(['error' => ['code' => 'X', 'message' => 'boom']])),
|
||||
new Response(500, [], json_encode(['error' => ['code' => 'X', 'message' => 'boom']])),
|
||||
]);
|
||||
|
||||
expect(fn () => $client->getDomains())->toThrow(LeadMailRequestException::class);
|
||||
});
|
||||
|
||||
it('does not retry 4xx responses', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(422, [], json_encode(['message' => 'nope', 'errors' => ['a' => ['b']]])),
|
||||
new Response(200, [], json_encode(['success' => true, 'data' => ['domains' => []]])),
|
||||
]);
|
||||
|
||||
// If it retried, it would consume the 200 and succeed. It must throw instead.
|
||||
expect(fn () => $client->getDomains())->toThrow(LeadMailRequestException::class);
|
||||
});
|
||||
|
||||
it('wraps connection failures in a connection exception', function () {
|
||||
$client = clientWithResponses([
|
||||
new ConnectException('Connection refused', new Request('GET', 'domains')),
|
||||
new ConnectException('Connection refused', new Request('GET', 'domains')),
|
||||
new ConnectException('Connection refused', new Request('GET', 'domains')),
|
||||
]);
|
||||
|
||||
expect(fn () => $client->getDomains())->toThrow(LeadMailConnectionException::class);
|
||||
});
|
||||
|
||||
it('throws on a malformed json body', function () {
|
||||
$client = clientWithResponses([
|
||||
new Response(200, [], '<html>not json</html>'),
|
||||
]);
|
||||
|
||||
expect(fn () => $client->getDomains())->toThrow(LeadMailException::class, 'malformed');
|
||||
});
|
||||
|
||||
it('fails open when verification cannot be reached', function () {
|
||||
$client = clientWithResponses([
|
||||
new ConnectException('Connection refused', new Request('POST', 'emails/verify')),
|
||||
new ConnectException('Connection refused', new Request('POST', 'emails/verify')),
|
||||
new ConnectException('Connection refused', new Request('POST', 'emails/verify')),
|
||||
]);
|
||||
|
||||
$result = $client->verifyEmail('user@example.com');
|
||||
|
||||
expect($result['success'])->toBeTrue()
|
||||
->and($result['data']['valid'])->toBeTrue()
|
||||
->and($result['data']['status'])->toBe('unknown');
|
||||
});
|
||||
128
tests/Feature/LeadMailWebhookTest.php
Normal file
128
tests/Feature/LeadMailWebhookTest.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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();
|
||||
});
|
||||
68
tests/Feature/RegisterWebhookTest.php
Normal file
68
tests/Feature/RegisterWebhookTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use LeadM\LeadMail\LeadMailClient;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $queue
|
||||
*/
|
||||
function webhookClient(array $queue): LeadMailClient
|
||||
{
|
||||
return new LeadMailClient(
|
||||
baseUrl: 'https://mail.test',
|
||||
token: 'lm_test',
|
||||
retryDelayMs: 0,
|
||||
handlerStack: HandlerStack::create(new MockHandler($queue)),
|
||||
);
|
||||
}
|
||||
|
||||
it('registers a webhook and returns the generated secret', function () {
|
||||
$client = webhookClient([
|
||||
new Response(200, [], json_encode([
|
||||
'success' => true,
|
||||
'data' => ['url' => 'https://app.test/hook', 'has_secret' => true, 'secret' => 'whsec_abc'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$result = $client->registerWebhook('https://app.test/hook');
|
||||
|
||||
expect($result['url'])->toBe('https://app.test/hook')
|
||||
->and($result['has_secret'])->toBeTrue()
|
||||
->and($result['secret'])->toBe('whsec_abc');
|
||||
});
|
||||
|
||||
it('reads the current webhook configuration', function () {
|
||||
$client = webhookClient([
|
||||
new Response(200, [], json_encode([
|
||||
'success' => true,
|
||||
'data' => ['url' => 'https://app.test/hook', 'has_secret' => true],
|
||||
])),
|
||||
]);
|
||||
|
||||
$result = $client->getWebhook();
|
||||
|
||||
expect($result)->toBe(['url' => 'https://app.test/hook', 'has_secret' => true]);
|
||||
});
|
||||
|
||||
it('deletes the webhook configuration', function () {
|
||||
$client = webhookClient([
|
||||
new Response(200, [], json_encode([
|
||||
'success' => true,
|
||||
'data' => ['url' => null, 'has_secret' => false],
|
||||
])),
|
||||
]);
|
||||
|
||||
expect($client->deleteWebhook())->toBe(['url' => null, 'has_secret' => false]);
|
||||
});
|
||||
|
||||
it('does not retry webhook registration on a 5xx', function () {
|
||||
$client = webhookClient([
|
||||
new Response(503, [], json_encode(['error' => ['code' => 'X', 'message' => 'down']])),
|
||||
new Response(200, [], json_encode(['success' => true, 'data' => ['url' => 'x', 'has_secret' => true]])),
|
||||
]);
|
||||
|
||||
expect(fn () => $client->registerWebhook('https://app.test/hook'))
|
||||
->toThrow(\LeadM\LeadMail\Exceptions\LeadMailRequestException::class);
|
||||
});
|
||||
69
tests/Feature/WebhookRouteTest.php
Normal file
69
tests/Feature/WebhookRouteTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use LeadM\LeadMail\Events\LeadMailWebhookReceived;
|
||||
|
||||
const ROUTE_SECRET = 'whsec_route_secret';
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('leadmail.webhook_secret', ROUTE_SECRET);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string} [body, signature]
|
||||
*/
|
||||
function signedBody(array $payload, string $secret = ROUTE_SECRET): array
|
||||
{
|
||||
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return [$body, 'sha256='.hash_hmac('sha256', $body, $secret)];
|
||||
}
|
||||
|
||||
function postWebhook(string $body, ?string $signature)
|
||||
{
|
||||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||
|
||||
if ($signature !== null) {
|
||||
$server['HTTP_X_LEADMAIL_SIGNATURE'] = $signature;
|
||||
}
|
||||
|
||||
return test()->call('POST', '/webhooks/leadmail', [], [], [], $server, $body);
|
||||
}
|
||||
|
||||
it('auto-registers the webhook route', function () {
|
||||
expect(Route::has('leadmail.webhook'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts a validly signed webhook and dispatches the event', function () {
|
||||
Event::fake([LeadMailWebhookReceived::class]);
|
||||
|
||||
[$body, $signature] = signedBody([
|
||||
'event' => 'email.failed',
|
||||
'log_id' => 85,
|
||||
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
|
||||
'to' => ['lead@example.com'],
|
||||
]);
|
||||
|
||||
postWebhook($body, $signature)->assertNoContent();
|
||||
|
||||
Event::assertDispatched(LeadMailWebhookReceived::class, function ($e) {
|
||||
return $e->event->logId === 85 && $e->event->isFailure();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid signature with 403 and dispatches nothing', function () {
|
||||
Event::fake([LeadMailWebhookReceived::class]);
|
||||
|
||||
[$body] = signedBody(['event' => 'email.failed', 'log_id' => 1]);
|
||||
|
||||
postWebhook($body, 'sha256=deadbeef')->assertForbidden();
|
||||
|
||||
Event::assertNotDispatched(LeadMailWebhookReceived::class);
|
||||
});
|
||||
|
||||
it('rejects a request with no signature header', function () {
|
||||
[$body] = signedBody(['event' => 'email.failed', 'log_id' => 1]);
|
||||
|
||||
postWebhook($body, null)->assertForbidden();
|
||||
});
|
||||
Reference in New Issue
Block a user