File: /home/xedaptot/ai.naniguide.com/tests/Unit/SendingServers/Webhooks/AmazonSnsParseWebhookTest.php
<?php
use App\Model\SendingServer;
use App\SendingServers\Drivers\Vendors\Amazon\AmazonApiDriver;
use App\SendingServers\Webhooks\BounceReceived;
use App\SendingServers\Webhooks\BounceType;
use App\SendingServers\Webhooks\ComplaintReceived;
use App\SendingServers\Webhooks\IgnorableWebhookEvent;
use App\SendingServers\Webhooks\WebhookHandshake;
use Aws\Sns\Message as SnsMessage;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class);
/**
* AWS SNS bounce/feedback parity tests using REAL AWS-format payloads.
*
* Fixtures in tests/Fixtures/Webhooks/aws/ match the AWS SNS+SES message
* shape from the official docs:
* - https://docs.aws.amazon.com/sns/latest/dg/sns-message-and-json-formats.html
* (outer Notification / SubscriptionConfirmation envelope)
* - https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html
* (inner SES bounce/complaint Message — JSON-encoded string)
*
* Critical AWS-only quirks:
* - bounceType raw strings ('Permanent'/'Transient'/'Undetermined') stored
* verbatim on BounceLog::bounce_type — NOT normalized to BounceLog::HARD/SOFT.
* - Q4: when no tracking log AND Permanent, direct Blacklist write keyed by
* destination email extracted from SNS mail.destination[0].
* - Q2: try/catch around `complaintFeedbackType` index, fallback
* 'aws-unknown-feedback-type'.
* - Inner SES Message is JSON-encoded STRING — driver must json_decode it.
* - SubscriptionConfirmation envelope (Type=SubscriptionConfirmation) needs
* handshake reply — driver yields WebhookHandshake event with SubscribeURL.
*/
/**
* Test driver subclass that bypasses real SNS signature validation.
* Production verifyWebhook validates the AWS signature (SignatureVersion +
* Signature + SigningCertURL); fixtures are unsigned, so we override.
*/
class FakeAwsSnsDriver extends AmazonApiDriver
{
protected function buildAndValidateSnsMessage(array $data): SnsMessage
{
return new SnsMessage($data);
}
}
function makeAwsApiDriverWithFixture(): FakeAwsSnsDriver
{
$server = new SendingServer(['type' => 'amazon-api', 'name' => 'fixture']);
$server->uid = 'fixture-uid';
return new FakeAwsSnsDriver($server);
}
function awsFixtureRequest(string $fixtureName): Request
{
$body = file_get_contents(__DIR__ . '/../../../Fixtures/Webhooks/aws/' . $fixtureName);
return Request::create('/webhook/amazon-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], $body);
}
// ─── SubscriptionConfirmation handshake ──────────────────────────────────────
test('AWS SubscriptionConfirmation → WebhookHandshake event with SubscribeURL', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('subscription_confirmation.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(WebhookHandshake::class)
->and($events[0]->confirmUrl)->toContain('https://sns.us-east-1.amazonaws.com/')
->and($events[0]->confirmUrl)->toContain('Action=ConfirmSubscription')
->and($events[0]->confirmUrl)->toContain('Token=2336412f37fb687f5d51e6e2425fixture-token');
});
test('AWS AmazonSnsSubscriptionSucceeded → IgnorableWebhookEvent', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('sns_subscription_succeeded.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[0]->reason)->toContain('AmazonSnsSubscriptionSucceeded');
});
// ─── Bounce — three variants (Permanent / Transient / Undetermined) ─────────
test('AWS Bounce Permanent → BounceReceived (HARD, blacklist=true, raw=Permanent, directBlacklistEmail set)', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('bounce_permanent.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->runtimeMessageId)->toBe('01000168f0f9e3f4-fixture-msg-permanent')
->and($events[0]->type)->toBe(BounceType::HARD)
->and($events[0]->bounceTypeRaw)->toBe('Permanent')
->and($events[0]->shouldBlacklist)->toBeTrue()
->and($events[0]->directBlacklistEmail)->toBe('[email protected]');
});
test('AWS Bounce Transient → BounceReceived (SOFT, blacklist=false, raw=Transient, no directBlacklistEmail)', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('bounce_transient.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->type)->toBe(BounceType::SOFT)
->and($events[0]->bounceTypeRaw)->toBe('Transient')
->and($events[0]->shouldBlacklist)->toBeFalse()
->and($events[0]->directBlacklistEmail)->toBeNull();
});
test('AWS Bounce Undetermined → BounceReceived (SOFT, blacklist=false, raw=Undetermined)', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('bounce_undetermined.json')));
expect($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->bounceTypeRaw)->toBe('Undetermined')
->and($events[0]->shouldBlacklist)->toBeFalse();
});
test('AWS Bounce — rawForDb is the inner SES Message string (not the SNS envelope)', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('bounce_permanent.json')));
expect($events[0]->rawForDb)->toContain('"notificationType":"Bounce"')
->and($events[0]->rawForDb)->toContain('"bounceType":"Permanent"')
->and($events[0]->rawForDb)->not->toContain('"Type":"Notification"')
->and($events[0]->rawForDb)->not->toContain('SigningCertURL');
});
// ─── Complaint — with + without complaintFeedbackType ─────────────────────────
test('AWS Complaint with complaintFeedbackType=abuse → ComplaintReceived (raw=abuse, markSpam=true, blacklist=false)', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('complaint_abuse.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(ComplaintReceived::class)
->and($events[0]->runtimeMessageId)->toBe('01000168f0f9e3f4-fixture-msg-complaint-abuse')
->and($events[0]->feedbackTypeRaw)->toBe('abuse')
->and($events[0]->shouldMarkSpamReported)->toBeTrue()
->and($events[0]->shouldBlacklist)->toBeFalse();
});
test('AWS Complaint missing complaintFeedbackType → fallback "aws-unknown-feedback-type" (Q2 quirk)', function () {
$driver = makeAwsApiDriverWithFixture();
$events = iterator_to_array($driver->parseWebhook(awsFixtureRequest('complaint_no_feedback_type.json')));
expect($events[0])->toBeInstanceOf(ComplaintReceived::class)
->and($events[0]->feedbackTypeRaw)->toBe('aws-unknown-feedback-type');
});
// ─── Edge cases — malformed / missing fields / unknown types ─────────────────
test('AWS unknown notificationType (DeliveryDelay) → IgnorableWebhookEvent', function () {
$driver = makeAwsApiDriverWithFixture();
$envelope = [
'Type' => 'Notification',
'MessageId' => 'unknown-type-fixture',
'TopicArn' => 'arn:aws:sns:us-east-1:123:ACELLEHANDLER',
'Message' => json_encode([
'notificationType' => 'DeliveryDelay',
'mail' => ['messageId' => 'msg-delay'],
]),
'Timestamp' => '2026-04-27T10:00:00Z',
'SignatureVersion' => '1',
'Signature' => 'sig',
'SigningCertURL' => 'https://sns.example/cert.pem',
];
$req = Request::create('/webhook/amazon-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($envelope));
$events = iterator_to_array($driver->parseWebhook($req));
expect($events[0])->toBeInstanceOf(IgnorableWebhookEvent::class);
});
test('AWS Bounce missing mail.messageId → silent skip (parity)', function () {
$driver = makeAwsApiDriverWithFixture();
$envelope = [
'Type' => 'Notification',
'MessageId' => 'no-mid-fixture',
'TopicArn' => 'arn:aws:sns:us-east-1:123:ACELLEHANDLER',
'Message' => json_encode([
'notificationType' => 'Bounce',
'bounce' => ['bounceType' => 'Permanent'],
'mail' => [],
]),
'Timestamp' => '2026-04-27T10:00:00Z',
'SignatureVersion' => '1',
'Signature' => 'sig',
'SigningCertURL' => 'https://sns.example/cert.pem',
];
$req = Request::create('/webhook/amazon-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($envelope));
$events = iterator_to_array($driver->parseWebhook($req));
expect($events)->toBeEmpty();
});
test('AWS body is not JSON → log warning + yield nothing (legacy "pretend we are not here")', function () {
$driver = makeAwsApiDriverWithFixture();
$req = Request::create('/webhook/amazon-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], 'NOT JSON AT ALL — vendor sent garbage');
$events = iterator_to_array($driver->parseWebhook($req));
expect($events)->toBeEmpty();
});
test('AWS inner Message is not JSON-string → log warning + yield nothing', function () {
$driver = makeAwsApiDriverWithFixture();
$envelope = [
'Type' => 'Notification',
'MessageId' => 'malformed-fixture',
'TopicArn' => 'arn:aws:sns:us-east-1:123:ACELLEHANDLER',
'Message' => 'this is not valid JSON',
'Timestamp' => '2026-04-27T10:00:00Z',
'SignatureVersion' => '1',
'Signature' => 'sig',
'SigningCertURL' => 'https://sns.example/cert.pem',
];
$req = Request::create('/webhook/amazon-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($envelope));
$events = iterator_to_array($driver->parseWebhook($req));
expect($events)->toBeEmpty();
});
test('AWS UnsubscribeConfirmation envelope (non-Notification) → silent skip', function () {
$driver = makeAwsApiDriverWithFixture();
$envelope = [
'Type' => 'UnsubscribeConfirmation',
'MessageId' => 'unsub-fixture',
'TopicArn' => 'arn:aws:sns:us-east-1:123:ACELLEHANDLER',
'Message' => 'You have removed the subscription',
'Timestamp' => '2026-04-27T10:00:00Z',
'SignatureVersion' => '1',
'Signature' => 'sig',
'SigningCertURL' => 'https://sns.example/cert.pem',
];
$req = Request::create('/webhook/amazon-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($envelope));
$events = iterator_to_array($driver->parseWebhook($req));
expect($events)->toBeEmpty();
});