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/be.naniguide.com/tests/Feature/SendingServerWebhooks/AwsSnsHttpFlowTest.php
<?php

use App\Model\Blacklist;
use App\Model\BounceLog;
use App\Model\Contact;
use App\Model\Customer;
use App\Model\FeedbackLog;
use App\Model\MailList;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\TrackingLog;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

uses(TestCase::class, DatabaseTransactions::class);

/**
 * End-to-end HTTP flow tests for AWS SNS webhooks using REAL AWS-format
 * payloads. Exercises:
 *
 *   POST /webhook/amazon-api/{uid}
 *     ↓
 *   SendingServerWebhookController::handle
 *     ↓
 *   $server->driver()->verifyWebhook($req)         (no-op for AWS — parity)
 *     ↓
 *   $server->driver()->parseWebhook($req)
 *     ↓
 *   yield BounceReceived | ComplaintReceived | WebhookHandshake | IgnorableWebhookEvent
 *     ↓
 *   event($event) → RecordBounce | RecordComplaint | ConfirmSnsSubscription
 *
 * Two test modes:
 *   - Event::fake() to assert events dispatched
 *   - Without fake to exercise listener DB writes
 *
 * Note: production verifyWebhook validates SNS signature against AWS cert.
 * Test fixtures are unsigned. We rebind the driver class via
 * DriverRegistry to a test subclass that bypasses validation.
 */

class HttpTestAwsDriver extends \App\SendingServers\Drivers\Vendors\Amazon\AmazonApiDriver
{
    protected function buildAndValidateSnsMessage(array $data): \Aws\Sns\Message
    {
        return new \Aws\Sns\Message($data);   // skip signature validate
    }
}

beforeEach(function () {
    \App\SendingServers\DriverRegistry::register('amazon-api', HttpTestAwsDriver::class);
});

afterEach(function () {
    \App\SendingServers\DriverRegistry::register(
        'amazon-api',
        \App\SendingServers\Drivers\Vendors\Amazon\AmazonApiDriver::class
    );
});

function awsFixturePayload(string $name): string
{
    return file_get_contents(__DIR__ . '/../../Fixtures/Webhooks/aws/' . $name);
}

function makeAwsServerHttp(): SendingServer
{
    $contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
    $customer = Customer::factory()->create([
        'contact_id' => $contact->id,
        'uid'        => 'awscust' . uniqid(),
    ]);
    return SendingServer::factory()->create([
        'type'        => 'amazon-api',
        'customer_id' => $customer->id,
    ]);
}

// ─── Event-bus dispatch verification (Event::fake) ───────────────────────────

test('POST /webhook/amazon-api/{uid} with Permanent bounce → 200 + BounceReceived dispatched', function () {
    Event::fake([\App\SendingServers\Webhooks\BounceReceived::class]);

    $server = makeAwsServerHttp();

    $response = $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('bounce_permanent.json'));

    $response->assertOk()->assertJson(['ok' => true]);
    Event::assertDispatched(
        \App\SendingServers\Webhooks\BounceReceived::class,
        function ($event) {
            return $event->bounceTypeRaw === 'Permanent'
                && $event->shouldBlacklist === true
                && $event->directBlacklistEmail === '[email protected]';
        }
    );
});

test('POST /webhook/amazon-api/{uid} with Transient bounce → BounceReceived (SOFT, no blacklist)', function () {
    Event::fake([\App\SendingServers\Webhooks\BounceReceived::class]);

    $server = makeAwsServerHttp();

    $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('bounce_transient.json'))->assertOk();

    Event::assertDispatched(
        \App\SendingServers\Webhooks\BounceReceived::class,
        function ($event) {
            return $event->type === \App\SendingServers\Webhooks\BounceType::SOFT
                && $event->bounceTypeRaw === 'Transient'
                && $event->shouldBlacklist === false;
        }
    );
});

test('POST /webhook/amazon-api/{uid} with Complaint abuse → ComplaintReceived (markSpam, no blacklist)', function () {
    Event::fake([\App\SendingServers\Webhooks\ComplaintReceived::class]);

    $server = makeAwsServerHttp();

    $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('complaint_abuse.json'))->assertOk();

    Event::assertDispatched(
        \App\SendingServers\Webhooks\ComplaintReceived::class,
        function ($event) {
            return $event->feedbackTypeRaw === 'abuse'
                && $event->shouldMarkSpamReported === true
                && $event->shouldBlacklist === false;
        }
    );
});

test('POST /webhook/amazon-api/{uid} with SubscriptionConfirmation → WebhookHandshake dispatched', function () {
    Event::fake([\App\SendingServers\Webhooks\WebhookHandshake::class]);

    $server = makeAwsServerHttp();

    $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('subscription_confirmation.json'))->assertOk();

    Event::assertDispatched(
        \App\SendingServers\Webhooks\WebhookHandshake::class,
        function ($event) {
            return str_contains($event->confirmUrl, 'Action=ConfirmSubscription')
                && str_contains($event->confirmUrl, 'Token=2336412f37fb687f5d51e6e2425fixture-token');
        }
    );
});

test('POST /webhook/amazon-api/{uid} with malformed body → 200 + no events (parity skip)', function () {
    Event::fake();

    $server = makeAwsServerHttp();

    $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], 'this is not json at all')->assertOk();

    // Legacy "pretend we are not here" — silent return 200, no events.
    Event::assertNotDispatched(\App\SendingServers\Webhooks\BounceReceived::class);
    Event::assertNotDispatched(\App\SendingServers\Webhooks\ComplaintReceived::class);
});

// ─── Full pipeline (no Event::fake) — verify DB write through listener ──────

test('AWS Permanent bounce full pipeline writes BounceLog row + blacklists subscriber', function () {
    // Build a real customer + tracking log so listener can resolve customer
    // and find the subscriber. messageId in fixture is
    // "01000168f0f9e3f4-fixture-msg-permanent" — driver uses it as runtime_message_id
    // for the event. Listener looks up Customer::getCustomerFromKey(msgId).
    $contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
    $customer = Customer::factory()->create([
        'contact_id' => $contact->id,
        'uid'        => 'awspipe' . uniqid(),
    ]);
    $list = MailList::forceCreate([
        'uid'         => 'awsp_' . uniqid(),
        'customer_id' => $customer->id,
        'name'        => 'fixture',
        'from_name'   => 'Fixture',
        'from_email'  => '[email protected]',
    ]);
    $subscriber = Subscriber::factory()->create([
        'mail_list_id' => $list->id,
        'email'        => '[email protected]',
    ]);
    $server = SendingServer::factory()->create([
        'type'        => 'amazon-api',
        'customer_id' => $customer->id,
    ]);

    // The fixture's runtime_message_id is the AWS SES messageId. To make
    // Customer::getCustomerFromKey resolve to OUR test customer, override
    // the cache so getCustomerFromKey returns it.
    $awsMsgId = '01000168f0f9e3f4-fixture-msg-permanent';
    cache()->put("runtime_message_id_{$awsMsgId}", $customer->id);

    TrackingLog::forceCreate([
        'runtime_message_id' => $awsMsgId,
        'message_id'         => 'tracking-aws-fixture',
        'customer_id'        => $customer->id,
        'subscriber_id'      => $subscriber->id,
        'sending_server_id'  => $server->id,
        'status'             => 'sent',
    ]);

    $response = $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('bounce_permanent.json'));

    $response->assertOk();

    // Listener wrote the BounceLog row.
    $log = BounceLog::where('runtime_message_id', $awsMsgId)->firstOrFail();
    expect($log->bounce_type)->toBe('Permanent');                            // PARITY: raw AWS string
    expect($log->raw)->toContain('"notificationType":"Bounce"')
        ->and($log->raw)->not->toContain('"Type":"Notification"');           // inner Message only

    // Listener blacklisted via subscriber path (tracking log present).
    expect(Blacklist::where('email', $subscriber->email)->exists())->toBeTrue();
});

test('AWS Permanent bounce with NO tracking log → Q4 path: direct Blacklist by destination email', function () {
    $contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
    $customer = Customer::factory()->create([
        'contact_id' => $contact->id,
        'uid'        => 'awsq4' . uniqid(),
    ]);
    $server = SendingServer::factory()->create([
        'type'        => 'amazon-api',
        'customer_id' => $customer->id,
    ]);

    $awsMsgId = '01000168f0f9e3f4-fixture-msg-permanent';
    cache()->put("runtime_message_id_{$awsMsgId}", $customer->id);

    // NO TrackingLog created → Q4 path triggers.

    $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('bounce_permanent.json'))->assertOk();

    // Q4: direct Blacklist row keyed by mail.destination[0] = '[email protected]'.
    expect(Blacklist::where('email', '[email protected]')->exists())->toBeTrue();
});

test('AWS Complaint full pipeline writes FeedbackLog with raw=abuse + markAsSpamReported on subscriber', function () {
    $contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
    $customer = Customer::factory()->create([
        'contact_id' => $contact->id,
        'uid'        => 'awscpl' . uniqid(),
    ]);
    $list = MailList::forceCreate([
        'uid'         => 'awspc_' . uniqid(),
        'customer_id' => $customer->id,
        'name'        => 'fixture',
        'from_name'   => 'Fixture',
        'from_email'  => '[email protected]',
    ]);
    $subscriber = Subscriber::factory()->create([
        'mail_list_id' => $list->id,
        'email'        => '[email protected]',
    ]);
    $server = SendingServer::factory()->create([
        'type'        => 'amazon-api',
        'customer_id' => $customer->id,
    ]);

    $awsMsgId = '01000168f0f9e3f4-fixture-msg-complaint-abuse';
    cache()->put("runtime_message_id_{$awsMsgId}", $customer->id);

    TrackingLog::forceCreate([
        'runtime_message_id' => $awsMsgId,
        'message_id'         => 'tracking-cpl-fixture',
        'customer_id'        => $customer->id,
        'subscriber_id'      => $subscriber->id,
        'sending_server_id'  => $server->id,
        'status'             => 'sent',
    ]);

    $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
        'CONTENT_TYPE' => 'application/json',
    ], awsFixturePayload('complaint_abuse.json'))->assertOk();

    $log = FeedbackLog::where('runtime_message_id', $awsMsgId)->firstOrFail();
    expect($log->feedback_type)->toBe('abuse');                              // PARITY: raw vendor type, not 'spam'
    expect($log->raw_feedback_content)->toContain('"notificationType":"Complaint"')
        ->and($log->raw_feedback_content)->toContain('"complaintFeedbackType":"abuse"');

    // PARITY: AWS Complaint does NOT blacklist (different from Mailgun Q7).
    expect(Blacklist::where('email', $subscriber->email)->exists())->toBeFalse();
});