| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- <?php
- namespace Tests\Feature;
- use App\Enums\ApprovalStatusEnum;
- use App\Enums\IdentityVerificationStatusEnum;
- use App\Enums\UserTypeEnum;
- use App\Models\IdentityVerification;
- use App\Models\Provider;
- use App\Models\User;
- use App\Models\Webhook;
- use App\Services\PushNotificationService;
- use Illuminate\Foundation\Testing\RefreshDatabase;
- use Illuminate\Support\Facades\Mail;
- use Tests\TestCase;
- /**
- * Exercita o webhook do Didit ponta a ponta usando os payloads reais capturados
- * nas sessoes de homologacao (tests/Fixtures/didit).
- */
- class DiditWebhookTest extends TestCase
- {
- use RefreshDatabase;
- private const SECRET = 'segredo-de-teste-do-webhook';
- protected function setUp(): void
- {
- parent::setUp();
- Mail::fake();
- $this->mock(PushNotificationService::class)->shouldReceive('sendToUser')->andReturnNull();
- config([
- 'services.didit.webhook_secret' => self::SECRET,
- 'services.didit.max_attempts' => 3,
- 'services.didit.liveness_min_score' => 80,
- 'services.didit.face_match_min_score' => 85,
- ]);
- }
- public function test_assinatura_valida_e_aceita(): void
- {
- $this->postWebhook($this->payload('decision_approved_cin.json', $this->makeProvider()->user_id))
- ->assertOk();
- }
- public function test_assinatura_invalida_e_rejeitada(): void
- {
- $payload = $this->payload('decision_approved_cin.json', $this->makeProvider()->user_id);
- $this->postJson('/api/webhooks/didit', $payload, [
- 'X-Timestamp' => (string) time(),
- 'X-Signature-V2' => str_repeat('a', 64),
- ])->assertStatus(401);
- }
- public function test_timestamp_fora_da_janela_e_rejeitado(): void
- {
- $payload = $this->payload('decision_approved_cin.json', $this->makeProvider()->user_id);
- $this->postJson('/api/webhooks/didit', $payload, [
- 'X-Timestamp' => (string) (time() - 3600),
- 'X-Signature-V2' => $this->sign($payload),
- ])->assertStatus(401);
- }
- /** O secret pode conter varios valores (producao, dev, sandbox) separados por virgula. */
- public function test_aceita_qualquer_um_dos_secrets_configurados(): void
- {
- config(['services.didit.webhook_secret' => 'outro-secret,'.self::SECRET]);
- $this->postWebhook($this->payload('decision_approved_cin.json', $this->makeProvider()->user_id))
- ->assertOk();
- }
- public function test_evento_repetido_e_ignorado(): void
- {
- $provider = $this->makeProvider();
- $payload = $this->payload('decision_approved_cin.json', $provider->user_id);
- $this->postWebhook($payload)->assertOk();
- $this->postWebhook($payload)->assertOk();
- $this->assertSame(1, Webhook::where('provider', 'didit')->count());
- $this->assertSame(2, (int) Webhook::where('provider', 'didit')->first()->attempts_count);
- }
- /** Sessao limpa: OCR, liveness e face match aprovados, sem warning acionavel. */
- public function test_aprovado_sem_pendencia_aprova_o_prestador_automaticamente(): void
- {
- $provider = $this->makeProvider();
- $this->postWebhook($this->payload('decision_approved_cin.json', $provider->user_id))->assertOk();
- $provider->refresh();
- $this->assertSame(IdentityVerificationStatusEnum::APPROVED, $provider->identity_verification_status);
- $this->assertTrue($provider->document_verified);
- $this->assertNotNull($provider->identity_verified_at);
- $this->assertSame(ApprovalStatusEnum::ACCEPTED, $provider->approval_status);
- }
- /**
- * Warnings de duplicidade chegam com log_type = information e nao podem barrar
- * a aprovacao: eles aparecem em toda retentativa legitima.
- */
- public function test_warnings_informativos_nao_impedem_aprovacao(): void
- {
- $provider = $this->makeProvider();
- $this->postWebhook($this->payload('decision_approved_cnh.json', $provider->user_id))->assertOk();
- $verification = IdentityVerification::firstOrFail();
- $this->assertNotEmpty($verification->warnings);
- $this->assertEmpty($verification->actionableWarnings());
- $this->assertSame(
- IdentityVerificationStatusEnum::APPROVED,
- $provider->refresh()->identity_verification_status,
- );
- }
- /** CPF divergente: o Didit devolve In Review e o cadastro vai para a fila humana. */
- public function test_divergencia_de_cpf_vai_para_analise_manual(): void
- {
- $provider = $this->makeProvider();
- $this->postWebhook($this->payload('decision_in_review_cpf_mismatch.json', $provider->user_id))->assertOk();
- $provider->refresh();
- $this->assertSame(IdentityVerificationStatusEnum::IN_REVIEW, $provider->identity_verification_status);
- $this->assertFalse($provider->document_verified);
- $this->assertSame(ApprovalStatusEnum::PENDING, $provider->approval_status);
- $warnings = IdentityVerification::firstOrFail()->actionableWarnings();
- $this->assertContains(
- 'IDENTIFICATION_NUMBER_MISMATCH_WITH_PROVIDED',
- array_column($warnings, 'risk'),
- );
- }
- /** Tentativa e chance de ser avaliado: so a reprovacao do Didit consome uma. */
- public function test_reprovacao_consome_tentativa_e_criar_sessao_nao(): void
- {
- $provider = $this->makeProvider();
- $this->assertSame(0, (int) $provider->identity_verification_attempts);
- $this->postWebhook($this->declinedPayload($provider->user_id))->assertOk();
- $provider->refresh();
- $this->assertSame(1, (int) $provider->identity_verification_attempts);
- $this->assertSame(IdentityVerificationStatusEnum::DECLINED, $provider->identity_verification_status);
- }
- /** Esgotadas as tentativas, o caso deixa de ser "tente de novo" e vira analise humana. */
- public function test_ultima_reprovacao_manda_para_analise_humana(): void
- {
- $provider = $this->makeProvider();
- $provider->forceFill(['identity_verification_attempts' => 2])->save();
- $this->postWebhook($this->declinedPayload($provider->user_id))->assertOk();
- $provider->refresh();
- $this->assertSame(3, (int) $provider->identity_verification_attempts);
- $this->assertSame(IdentityVerificationStatusEnum::IN_REVIEW, $provider->identity_verification_status);
- }
- public function test_declined_marca_como_reprovado_sem_aprovar_cadastro(): void
- {
- $provider = $this->makeProvider();
- $payload = $this->payload('decision_approved_cin.json', $provider->user_id);
- $payload['status'] = 'Declined';
- $payload['decision']['status'] = 'Declined';
- $payload['decision']['id_verifications'][0]['status'] = 'Declined';
- $this->postWebhook($payload)->assertOk();
- $provider->refresh();
- $this->assertSame(IdentityVerificationStatusEnum::DECLINED, $provider->identity_verification_status);
- $this->assertSame(ApprovalStatusEnum::PENDING, $provider->approval_status);
- }
- /** Not Started e In Progress apenas acompanham o progresso, sem decidir nada. */
- public function test_status_intermediario_nao_decide_nada(): void
- {
- $provider = $this->makeProvider();
- $payload = [
- 'session_id' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
- 'webhook_type' => 'status.updated',
- 'status' => 'In Progress',
- 'vendor_data' => (string) $provider->user_id,
- 'timestamp' => time(),
- ];
- $this->postWebhook($payload)->assertOk();
- $this->assertSame(
- IdentityVerificationStatusEnum::NOT_STARTED,
- $provider->refresh()->identity_verification_status,
- );
- $this->assertSame('In Progress', IdentityVerification::firstOrFail()->didit_status);
- }
- public function test_score_baixo_de_face_match_barra_a_aprovacao(): void
- {
- config(['services.didit.face_match_min_score' => 99]);
- $provider = $this->makeProvider();
- $this->postWebhook($this->payload('decision_approved_cin.json', $provider->user_id))->assertOk();
- $this->assertSame(
- IdentityVerificationStatusEnum::IN_REVIEW,
- $provider->refresh()->identity_verification_status,
- );
- }
- //
- private function declinedPayload(int $userId): array
- {
- $payload = $this->payload('decision_approved_cin.json', $userId);
- $payload['event_id'] = 'evt-declined-'.$userId;
- $payload['status'] = 'Declined';
- $payload['decision']['status'] = 'Declined';
- $payload['decision']['id_verifications'][0]['status'] = 'Declined';
- return $payload;
- }
- private function postWebhook(array $payload)
- {
- return $this->postJson('/api/webhooks/didit', $payload, [
- 'X-Timestamp' => (string) time(),
- 'X-Signature-V2' => $this->sign($payload),
- ]);
- }
- private function sign(array $payload): string
- {
- return hash_hmac('sha256', $this->canonical($payload), self::SECRET);
- }
- /** Mesma canonicalizacao do Didit: chaves ordenadas, unicode e barras sem escape. */
- private function canonical(array $payload): string
- {
- $decoded = json_decode(json_encode($payload), false);
- $sort = function ($value) use (&$sort) {
- if (is_array($value)) {
- return array_map($sort, $value);
- }
- if ($value instanceof \stdClass) {
- $data = get_object_vars($value);
- ksort($data, SORT_STRING);
- return (object) array_map($sort, $data);
- }
- if (is_float($value) && floor($value) === $value) {
- return (int) $value;
- }
- return $value;
- };
- return json_encode($sort($decoded), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
- }
- private function payload(string $fixture, int $userId): array
- {
- $decision = json_decode(file_get_contents(base_path("tests/Fixtures/didit/{$fixture}")), true);
- return [
- 'event_id' => 'evt-'.$fixture,
- 'session_id' => $decision['session_id'] ?? '11111111-2222-3333-4444-555555555555',
- 'webhook_type' => 'status.updated',
- 'status' => $decision['status'],
- 'vendor_data' => (string) $userId,
- 'timestamp' => time(),
- 'decision' => $decision,
- ];
- }
- private function makeProvider(): Provider
- {
- $user = User::query()->create([
- 'name' => 'Prestador Teste',
- 'email' => 'prestador'.uniqid().'@teste.com',
- 'password' => 'secret',
- 'type' => UserTypeEnum::PROVIDER->value,
- ]);
- return Provider::query()->create([
- 'user_id' => $user->id,
- 'document' => '06767310905',
- 'birth_date' => '1990-01-01',
- 'approval_status' => ApprovalStatusEnum::PENDING->value,
- ]);
- }
- }
|