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/AdsR10WebhooksTest.php
<?php

/**
 * Ads Phase R10 — Webhook signature + replay + lead processing test suite.
 *
 * Closes gates S-6 (HMAC verification) + S-7 (replay protection).
 *
 * Verifies:
 *   SIGNATURE VERIFICATION
 *   1. Valid HMAC (sha256=hex) → accepts
 *   2. Invalid HMAC + enforce=true → rejects
 *   3. Invalid HMAC + enforce=false → accepts (observation mode)
 *   4. Missing header + enforce=true → rejects
 *   5. Missing header + enforce=false → accepts
 *   6. Missing app_secret + enforce=true → rejects (can't verify)
 *
 *   REPLAY PROTECTION
 *   7. First call returns fresh=true; second within TTL returns fresh=false
 *   8. Different event ids both fresh
 *   9. Cross-platform event_id collision prevented by key scoping
 *
 *   PROCESS LEAD JOB
 *  10. Unknown form id → no-op (returns silently)
 *  11. Duplicate (form_id, platform_lead_id) → dedup (no second AdLead)
 *  12. Happy path: Subscriber upserted with status=SUBSCRIBED + email from fields
 *  13. No email in payload → AdLead recorded, no Subscriber created
 *  14. leads_count on AdLeadFormConfig incremented + last_synced_at set
 *
 *   WEBHOOK VERIFY ENDPOINT
 *  15. GET /ads/webhooks/meta/verify with matching token → echoes challenge
 *  16. GET with mismatch → 403
 */

use App\Dto\Ads\SyncAudienceDto;
use App\Http\Middleware\Ads\VerifyAdsWebhookSignature;
use App\Jobs\Ads\ProcessAdLead;
use App\Model\AdLead;
use App\Model\AdLeadFormConfig;
use App\Model\AdPlatform;
use App\Model\MailList;
use App\Model\Subscriber;
use App\Services\Ads\AdapterSession;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\Results\LeadFetchResult;
use App\Services\Ads\WebhookSignatureVerifier;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;

uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);

beforeEach(function () {
    Cache::flush();
    config([
        'ads.webhooks.enforce_signature' => false,
        'ads.webhooks.replay_protection_ttl_seconds' => 300,
        'ads.webhooks.meta_verify_token' => 'secret_token_123',
        'ads.platforms.meta.app_secret' => 'hmac_key_abc',
    ]);
});

// ============================================================
// Signature verification
// ============================================================

test('valid HMAC signature is accepted', function () {
    config(['ads.webhooks.enforce_signature' => true]);
    $body = '{"entry":[{"id":"123","time":1700000000,"changes":[]}]}';
    $secret = 'hmac_key_abc';
    $sig = 'sha256=' . hash_hmac('sha256', $body, $secret);

    $v = new WebhookSignatureVerifier();
    expect($v->verifyMeta($body, $sig, $secret))->toBeTrue();
});

test('invalid HMAC with enforce=true rejects', function () {
    config(['ads.webhooks.enforce_signature' => true]);

    $v = new WebhookSignatureVerifier();
    expect($v->verifyMeta('{"x":1}', 'sha256=deadbeef', 'hmac_key_abc'))->toBeFalse();
});

test('invalid HMAC with enforce=false accepts (observation mode)', function () {
    config(['ads.webhooks.enforce_signature' => false]);

    $v = new WebhookSignatureVerifier();
    expect($v->verifyMeta('{"x":1}', 'sha256=deadbeef', 'hmac_key_abc'))->toBeTrue();
});

test('missing header with enforce=true rejects', function () {
    config(['ads.webhooks.enforce_signature' => true]);

    $v = new WebhookSignatureVerifier();
    expect($v->verifyMeta('{"x":1}', null, 'hmac_key_abc'))->toBeFalse();
});

test('missing header with enforce=false accepts', function () {
    config(['ads.webhooks.enforce_signature' => false]);

    $v = new WebhookSignatureVerifier();
    expect($v->verifyMeta('{"x":1}', null, 'hmac_key_abc'))->toBeTrue();
});

test('missing app_secret with enforce=true rejects', function () {
    config(['ads.webhooks.enforce_signature' => true]);

    $v = new WebhookSignatureVerifier();
    expect($v->verifyMeta('{"x":1}', 'sha256=any', null))->toBeFalse();
});

// ============================================================
// Replay protection
// ============================================================

test('first event id is fresh; duplicate within TTL is stale', function () {
    $v = new WebhookSignatureVerifier();
    expect($v->recordAndCheckFresh('meta', 'evt_1'))->toBeTrue()
        ->and($v->recordAndCheckFresh('meta', 'evt_1'))->toBeFalse();
});

test('different event ids are each fresh', function () {
    $v = new WebhookSignatureVerifier();
    expect($v->recordAndCheckFresh('meta', 'evt_a'))->toBeTrue()
        ->and($v->recordAndCheckFresh('meta', 'evt_b'))->toBeTrue();
});

test('cross-platform event ids do not collide', function () {
    $v = new WebhookSignatureVerifier();
    expect($v->recordAndCheckFresh('meta', 'evt_1'))->toBeTrue()
        ->and($v->recordAndCheckFresh('google', 'evt_1'))->toBeTrue(); // same id, different platform → fresh
});

test('metaEventId extracts leadgen_id when present', function () {
    $v = new WebhookSignatureVerifier();
    $payload = [
        'entry' => [[
            'id' => 'page_1',
            'time' => 1700000000,
            'changes' => [[
                'field' => 'leadgen',
                'value' => ['leadgen_id' => 'lead_xyz_42'],
            ]],
        ]],
    ];
    expect($v->metaEventId($payload, '{raw}'))->toBe('lead_xyz_42');
});

// ============================================================
// ProcessAdLead job
// ============================================================

/**
 * @return array{0:int, 1:AdPlatform, 2:MailList, 3:AdLeadFormConfig}
 */
function seedR10Scenario(): array
{
    $customerId = (int) \DB::table('customers')->insertGetId([
        'uid' => 'r10cus_' . uniqid(),
        'timezone' => 'UTC',
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
    $platform = AdPlatform::create([
        'uid' => 'r10plat_' . uniqid(),
        'customer_id' => $customerId,
        'platform' => AdPlatform::PLATFORM_META,
        'name' => 'Meta',
        'access_token' => 'r10-tok',
        'refresh_token' => 'r10-refresh',
        'token_expires_at' => now()->addDays(30),
        'platform_user_id' => 'r10-user',
        'platform_user_name' => 'R10 Biz',
        'scopes' => ['ads_management'],
        'health_status' => AdPlatform::HEALTH_HEALTHY,
        'last_health_check_at' => now(),
    ]);
    // status is NOT in AdPlatform::$fillable (R5 discovery) — set directly.
    $platform->status = AdPlatform::STATUS_ACTIVE;
    $platform->save();

    // Minimal MailList via factory; bypass complex required relations
    $listId = (int) \DB::table('mail_lists')->insertGetId([
        'uid' => 'r10list_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'R10 list',
        'from_email' => '[email protected]',
        'from_name' => 'R10',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
    $list = MailList::find($listId);

    $config = AdLeadFormConfig::create([
        'uid' => 'r10cfg_' . uniqid(),
        'customer_id' => $customerId,
        'platform' => 'meta',
        'form_id' => 'form_r10_' . uniqid(),
        'verify_token' => 'tok',
        'mail_list_id' => $list->id,
        'field_mapping' => ['email' => 'email', 'full_name' => 'name'],
        'status' => AdLeadFormConfig::STATUS_ACTIVE,
    ]);

    return [$customerId, $platform, $list, $config];
}

/**
 * Bind a stub AdPlatformManager whose fetchLeads returns a fixed lead matching
 * the given platformLeadId + fields.  Returns the stub for inspection.
 */
function r10BindLeadFetcher(string $platformLeadId, array $fields): void
{
    $spy = new class extends MockAdPlatform {
        public string $targetLeadId = '';
        public array $targetFields = [];
        public function fetchLeads(string $accessToken, string $formId, ?\Carbon\Carbon $since, int $limit = 100, ?string $cursor = null): LeadFetchResult
        {
            return new LeadFetchResult(
                status: LeadFetchResult::STATUS_SUCCESS,
                leads: [[
                    'platform_lead_id' => $this->targetLeadId,
                    'fields' => $this->targetFields,
                    'created_at' => time(),
                ]],
            );
        }
    };
    $spy->targetLeadId = $platformLeadId;
    $spy->targetFields = $fields;

    $manager = new class($spy) extends AdPlatformManager {
        public function __construct(public MockAdPlatform $spy) {}
        public function session(AdPlatform $adPlatform): AdapterSession
        {
            return new AdapterSession($adPlatform, $this->spy);
        }
    };

    app()->instance(AdPlatformManager::class, $manager);
}

test('ProcessAdLead — unknown form id short-circuits', function () {
    r10BindLeadFetcher('anything', []);
    $beforeLeads = AdLead::count();

    (new ProcessAdLead('lead_x', 'unknown_form_9999', 'meta'))
        ->handle(app(AdPlatformManager::class));

    expect(AdLead::count())->toBe($beforeLeads);
});

test('ProcessAdLead — duplicate (form, platform_lead_id) is deduplicated', function () {
    [, , , $config] = seedR10Scenario();
    r10BindLeadFetcher('dupe_lead', ['email' => '[email protected]']);

    (new ProcessAdLead('dupe_lead', $config->form_id, 'meta'))->handle(app(AdPlatformManager::class));
    (new ProcessAdLead('dupe_lead', $config->form_id, 'meta'))->handle(app(AdPlatformManager::class));

    $count = AdLead::where('ad_lead_form_config_id', $config->id)->where('platform_lead_id', 'dupe_lead')->count();
    expect($count)->toBe(1);
});

test('ProcessAdLead — happy path: Subscriber upserted with SUBSCRIBED status', function () {
    [, , $list, $config] = seedR10Scenario();
    r10BindLeadFetcher('lead_ok', ['email' => '[email protected]', 'name' => 'New Lead']);

    // Sanity: verify stub manager returns leads as expected BEFORE running the job
    $mgr = app(AdPlatformManager::class);
    $platform = AdPlatform::where('customer_id', $config->customer_id)->where('platform', 'meta')->first();
    $session = $mgr->session($platform);
    $res = $session->chain->fetchLeads('t', $config->form_id, null);
    expect($res->leads)->toHaveCount(1)
        ->and($res->leads[0]['platform_lead_id'])->toBe('lead_ok')
        ->and($res->leads[0]['fields']['email'])->toBe('[email protected]');

    (new ProcessAdLead('lead_ok', $config->form_id, 'meta'))->handle(app(AdPlatformManager::class));

    // AdLead must exist with the email captured in data — proves
    // fetchLeadFields returned payload
    $adLead = AdLead::where('ad_lead_form_config_id', $config->id)->first();
    expect($adLead)->not->toBeNull()
        ->and($adLead->platform_lead_id)->toBe('lead_ok')
        ->and($adLead->data['email'] ?? null)->toBe('[email protected]');

    $sub = Subscriber::where('mail_list_id', $list->id)
        ->where('email', '[email protected]')->first();
    expect($sub)->not->toBeNull()
        ->and($sub->status)->toBe(Subscriber::STATUS_SUBSCRIBED);

    expect($adLead->subscriber_id)->toBe($sub->id);
});

test('ProcessAdLead — no email in payload: AdLead saved, no Subscriber', function () {
    [, , $list, $config] = seedR10Scenario();
    r10BindLeadFetcher('lead_noemail', ['name' => 'Anonymous']);

    (new ProcessAdLead('lead_noemail', $config->form_id, 'meta'))->handle(app(AdPlatformManager::class));

    expect(AdLead::where('platform_lead_id', 'lead_noemail')->exists())->toBeTrue()
        ->and(Subscriber::where('mail_list_id', $list->id)->count())->toBe(0);
});

test('ProcessAdLead — leads_count incremented + last_synced_at set', function () {
    [, , , $config] = seedR10Scenario();
    r10BindLeadFetcher('lead_cnt', ['email' => '[email protected]']);

    // leads_count column has no default in the migration; seeded null means
    // "never synced".  The job's `?? 0` fallback handles this.
    expect($config->leads_count)->toBeIn([null, 0]);
    (new ProcessAdLead('lead_cnt', $config->form_id, 'meta'))->handle(app(AdPlatformManager::class));

    $config->refresh();
    expect($config->leads_count)->toBe(1)
        ->and($config->last_synced_at)->not->toBeNull();
});

// ============================================================
// Middleware + controller integration (unit style — no HTTP client)
// ============================================================

test('middleware respects enforce_signature flag', function () {
    // Observation mode (default) — bad signature STILL passes through
    config(['ads.webhooks.enforce_signature' => false]);
    $body = '{"entry":[]}';
    $verifier = new WebhookSignatureVerifier();

    // Direct verifier call — observation mode with bad sig should accept
    expect($verifier->verifyMeta($body, 'sha256=bad', 'hmac_key_abc'))->toBeTrue();

    config(['ads.webhooks.enforce_signature' => true]);
    expect($verifier->verifyMeta($body, 'sha256=bad', 'hmac_key_abc'))->toBeFalse();
});

test('config defaults — enforce_signature=false in shipped config', function () {
    $default = require base_path('config/ads.php');
    expect($default['webhooks']['enforce_signature'] ?? true)->toBeFalse();
});