83 lines
5.3 KiB
PHP
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();
|
|
});
|