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();
});