@@ -0,0 +1,82 @@
|
||||
<?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();
|
||||
});
|
||||
Reference in New Issue
Block a user