HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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();
});