'smtp', 'app.url' => 'https://sgc.example.invalid']); Mail::fake(); $this->tenant = Tenant::query()->create(['name' => 'Empresa fictícia de acesso']); $admin = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($admin); app(CandidateProfiles::class)->configure($admin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso fictício para ensaio de recuperação e confirmação.', 'reason' => 'Abrir registo fictício para testes.']); $this->credentials = ['email' => 'contact@example.invalid', 'password' => 'Original-Test-123!', 'password_confirmation' => 'Original-Test-123!', 'portal_version' => 1, 'notice_acknowledged' => true]; $this->account = app(CandidateProfiles::class)->register($this->tenant->id, $this->credentials); }); function candidateAccessToken(object $test, string $purpose): string { app(CandidateAccess::class)->request($test->tenant->id, $test->account->email, $purpose); $message = Mail::sent(CandidateAccessLink::class)->last(); parse_str(parse_url($message->link, PHP_URL_FRAGMENT), $parts); return $parts['token']; } test('verification sends only a secret fragment stores its hash and is single use', function () { $token = candidateAccessToken($this, 'verify'); $message = Mail::sent(CandidateAccessLink::class)->sole(); expect($message->link)->toStartWith('https://sgc.example.invalid/')->and(parse_url($message->link, PHP_URL_QUERY))->toBeNull(); expect(DB::table('candidate_access_tokens')->value('token_hash'))->toBe(hash('sha256', $token))->not->toBe($token); app(CandidateAccess::class)->consume($this->tenant->id, 'verify', ['token' => $token]); expect($this->account->fresh()->email_verified_at)->not->toBeNull(); expect(fn () => app(CandidateAccess::class)->consume($this->tenant->id, 'verify', ['token' => $token]))->toThrow(ValidationException::class); }); test('password recovery revokes candidate sessions without authenticating anyone or changing staff', function () { $base = '/candidaturas/'.$this->tenant->id; $this->postJson($base.'/entrar', $this->credentials)->assertRedirect(); $token = candidateAccessToken($this, 'reset'); $new = ['token' => $token, 'password' => 'Replacement-Test-456!', 'password_confirmation' => 'Replacement-Test-456!']; $this->postJson($base.'/recuperar', $new)->assertRedirect(); $this->get($base.'/perfil')->assertRedirect($base); expect(Hash::check($new['password'], $this->account->fresh()->password))->toBeTrue()->and($this->account->fresh()->session_version)->toBe(2); $this->postJson($base.'/entrar', $this->credentials)->assertUnprocessable(); $this->postJson($base.'/entrar', [...$this->credentials, 'password' => $new['password']])->assertRedirect(); $this->postJson($base.'/recuperar', $new)->assertUnprocessable(); }); test('wrong company wrong purpose expired tokens and excessive requests cannot bypass access', function () { $token = candidateAccessToken($this, 'verify'); app(CandidateAccess::class)->request($this->tenant->id, $this->account->email, 'verify'); Mail::assertSentCount(1); expect(fn () => app(CandidateAccess::class)->consume($this->tenant->id + 100, 'verify', ['token' => $token]))->toThrow(ValidationException::class); expect(fn () => app(CandidateAccess::class)->consume($this->tenant->id, 'reset', ['token' => $token, 'password' => 'Replacement-Test-456!', 'password_confirmation' => 'Replacement-Test-456!']))->toThrow(ValidationException::class); $this->travel(61)->minutes(); expect(fn () => app(CandidateAccess::class)->consume($this->tenant->id, 'verify', ['token' => $token]))->toThrow(ValidationException::class); expect($this->account->fresh()->email_verified_at)->toBeNull(); }); test('missing accounts and disabled mail never emit tokens into log mailers', function () { app(CandidateAccess::class)->request($this->tenant->id, 'absent@example.invalid', 'reset'); config(['mail.default' => 'log']); app(CandidateAccess::class)->request($this->tenant->id, $this->account->email, 'reset'); Mail::assertNothingSent(); expect(DB::table('candidate_access_tokens')->count())->toBe(0); $this->postJson('/candidaturas/'.$this->tenant->id.'/pedir-recuperacao', ['email' => 'absent@example.invalid'])->assertRedirect()->assertSessionHas('candidate_access_status', 'requested'); }); test('audit failure rolls back verification and leaves the token unconsumed', function () { $token = candidateAccessToken($this, 'verify'); $this->partialMock(CandidateProfiles::class, fn ($mock) => $mock->shouldReceive('event')->once()->andThrow(new RuntimeException('Injected verification audit failure'))); expect(fn () => app(CandidateAccess::class)->consume($this->tenant->id, 'verify', ['token' => $token]))->toThrow(RuntimeException::class); expect($this->account->fresh()->email_verified_at)->toBeNull()->and(DB::table('candidate_access_tokens')->value('consumed_at'))->toBeNull(); });