Files
compras/tests/Feature/CandidateAccessTest.php
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

83 lines
5.3 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Mail\CandidateAccessLink;
use App\Models\Tenant;
use App\Models\User;
use App\Services\CandidateAccess;
use App\Services\CandidateProfiles;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
beforeEach(function () {
config(['mail.default' => '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();
});