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, ]); } }