Receber documentos privados e locais de disponibilidade nas candidaturas
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 20:52:10 +02:00
parent 2134aa084b
commit bf84575d10
26 changed files with 2756 additions and 49 deletions
@@ -0,0 +1,35 @@
<?php
use App\Services\CandidateDocuments;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
function candidateWordFixture(bool $unsafe = false): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'sgc-word-');
$zip = new ZipArchive;
$zip->open($path, ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
$zip->addFromString('word/document.xml', '<?xml version="1.0"?>'.($unsafe ? '<!DOCTYPE document [<!ENTITY x SYSTEM "file:///nonexistent">]>' : '').'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>'.str_repeat('Ficticio ', 300).'</w:t></w:r></w:p></w:body></w:document>');
$zip->setCompressionName('word/document.xml', ZipArchive::CM_STORE);
$zip->close();
try {
return UploadedFile::fake()->createWithContent('curriculo.docx', file_get_contents($path));
} finally {
unlink($path);
}
}
test('word documents must contain valid bounded document XML without external entity declarations', function () {
$definition = ['documents' => [['key' => 'cv', 'required' => true, 'formats' => ['docx'], 'max_kb' => 1024]]];
app(CandidateDocuments::class)->validate($definition, ['cv' => candidateWordFixture()]);
expect(fn () => app(CandidateDocuments::class)->validate($definition, ['cv' => candidateWordFixture(true)]))->toThrow(ValidationException::class);
});
test('an image must match its allowed format and content while an optional absent file remains optional', function () {
$definition = ['documents' => [['key' => 'photo', 'required' => false, 'formats' => ['png'], 'max_kb' => 1024]]];
app(CandidateDocuments::class)->validate($definition, []);
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=');
app(CandidateDocuments::class)->validate($definition, ['photo' => UploadedFile::fake()->createWithContent('photo.png', $png)]);
expect(fn () => app(CandidateDocuments::class)->validate($definition, ['photo' => UploadedFile::fake()->createWithContent('photo.png', str_repeat('invalid', 200))]))->toThrow(ValidationException::class);
});
+23
View File
@@ -77,6 +77,29 @@ test('one candidate never reads another company profile or another account profi
expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $first->id)->count())->toBe(1);
});
test('multiple availability locations are versioned and legacy requests preserve them', function () {
app(CandidateProfiles::class)->configure($this->admin, $this->settings);
$this->postJson($this->base.'/registar', $this->registration)->assertRedirect();
$extra = ['province' => 'Outra província', 'district' => 'Outro distrito', 'locality' => 'Outra localidade'];
$this->postJson($this->base.'/perfil', [...$this->profile, 'additional_availability' => [$extra]])->assertRedirect();
$this->postJson($this->base.'/perfil', [...$this->profile, 'version' => 1])->assertRedirect();
$this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('profile.data.additional_availability', [$extra]));
$this->postJson($this->base.'/perfil', [...$this->profile, 'version' => 2, 'additional_availability' => []])->assertRedirect();
$versions = DB::table('candidate_profile_versions')->orderBy('version')->get();
expect(json_decode($versions[0]->data, true)['additional_availability'])->toBe([$extra])
->and(json_decode($versions[2]->data, true)['additional_availability'])->toBe([]);
});
test('additional locations reject incomplete duplicate and excessive entries without writing a version', function () {
app(CandidateProfiles::class)->configure($this->admin, $this->settings);
$this->postJson($this->base.'/registar', $this->registration)->assertRedirect();
$duplicate = array_map(fn ($value) => ' '.mb_strtoupper($value).' ', $this->profile['availability']);
foreach ([[$duplicate], [['province' => 'Parcial']], array_fill(0, 21, $this->profile['availability'])] as $locations) {
$this->postJson($this->base.'/perfil', [...$this->profile, 'additional_availability' => $locations])->assertUnprocessable();
}
expect(DB::table('candidate_profile_versions')->count())->toBe(0);
});
test('profile and event writes roll back together and missing geography is rejected', function () {
app(CandidateProfiles::class)->configure($this->admin, $this->settings);
$this->postJson($this->base.'/registar', $this->registration)->assertRedirect();
+96 -3
View File
@@ -10,11 +10,15 @@ use App\Services\PeopleAccess;
use App\Services\PeopleHiring;
use App\Services\RequisitionWorkflow;
use App\Services\SubmissionForms;
use Illuminate\Database\QueryException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
beforeEach(function () {
Storage::fake('candidate_documents');
$this->tenant = Tenant::query()->create(['name' => 'Empresa fictícia de submissões']);
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->reviewer = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]);
@@ -52,6 +56,17 @@ test('electronic submission returns a scoped receipt and preserves the actual pr
$this->postJson($url, [...$this->body, 'operation_key' => (string) Str::uuid(), 'profile_version' => 2])->assertConflict();
});
test('all availability locations travel with the submitted profile and remain unchanged after profile edits', function () {
$extra = ['province' => 'Outra província', 'district' => 'Outro distrito', 'locality' => 'Outra localidade'];
app(CandidateProfiles::class)->save($this->account, [...$this->profile, 'version' => 1, 'additional_availability' => [$extra]]);
$this->postJson($this->base.'/oportunidades/'.$this->process->id, [...$this->body, 'profile_version' => 2])->assertRedirect();
$row = DB::table('candidate_submissions')->sole();
app(CandidateProfiles::class)->save($this->account, [...$this->profile, 'version' => 2, 'additional_availability' => []]);
$this->get($this->base.'/submissoes/'.$row->id)->assertInertia(fn (Assert $p) => $p->where('receipt.snapshot.profile.data.additional_availability', [$extra]));
$this->get('/pessoas/'.$this->process->id.'/recebidas')->assertInertia(fn (Assert $p) => $p->where('submissions.data.0.snapshot.profile.data.additional_availability', [$extra]));
expect(DB::table('candidate_submissions')->sole()->snapshot)->toBe($row->snapshot);
});
test('withdrawn opportunities stale profiles and unverified contacts cannot submit', function () {
$url = $this->base.'/oportunidades/'.$this->process->id;
$this->postJson($url, [...$this->body, 'profile_version' => 99])->assertUnprocessable();
@@ -76,7 +91,7 @@ test('received submissions do not become internal evaluations and only the assig
$this->postJson('/pessoas/'.$this->process->id.'/publicacao', [...$this->publication, 'version' => 1, 'active' => false])->assertForbidden();
});
test('an opportunity requiring files cannot be published through the data only reception path', function () {
test('an opportunity requiring a CV can be published but cannot receive an incomplete submission', function () {
$this->actingAs($this->admin);
$definition = [...$this->definition, 'documents' => [['key' => 'cv', 'label' => 'CV', 'formats' => ['pdf'], 'max_kb' => 1024, 'required' => true]]];
app(SubmissionForms::class)->save($this->admin, ['audience' => 'service', 'modality' => 'competition', 'version' => 1, 'definition' => $definition, 'reason' => 'Exigir CV no novo formulário fictício.']);
@@ -84,8 +99,86 @@ test('an opportunity requiring files cannot be published through the data only r
app(PeopleAccess::class)->assign($this->admin, $record->id, ['version' => 1, 'user_id' => $this->reviewer->id, 'scopes' => ['review'], 'reason' => 'Conferência independente do novo processo.']);
$this->actingAs($this->reviewer);
app(PeopleHiring::class)->execute($this->reviewer, $record->id, 'open', ['version' => 1, 'reason' => 'Abertura fictícia para testar os anexos.', 'no_conflict' => true]);
$this->actingAs($this->admin)->postJson('/pessoas/'.$record->id.'/publicacao', $this->publication)->assertUnprocessable();
expect(DB::table('candidate_opportunity_publications')->where('people_process_id', $record->id)->count())->toBe(0);
$this->actingAs($this->admin)->postJson('/pessoas/'.$record->id.'/publicacao', $this->publication)->assertRedirect();
$this->postJson($this->base.'/oportunidades/'.$record->id, $this->body)->assertUnprocessable();
expect(DB::table('candidate_submissions')->where('people_process_id', $record->id)->count())->toBe(0);
});
function requireElectronicCv(object $test): void
{
$test->actingAs($test->admin);
$definition = [...$test->definition, 'documents' => [['key' => 'cv', 'label' => 'Currículo', 'formats' => ['pdf'], 'max_kb' => 1024, 'required' => true]]];
app(SubmissionForms::class)->save($test->admin, ['audience' => 'service', 'modality' => 'competition', 'version' => 1, 'definition' => $definition, 'reason' => 'Exigir currículo no formulário de teste.']);
$test->process = app(PeopleHiring::class)->execute($test->admin, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Processo com currículo', 'purpose' => 'Conferência fictícia da recepção de documentos.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
app(PeopleAccess::class)->assign($test->admin, $test->process->id, ['version' => 1, 'user_id' => $test->reviewer->id, 'scopes' => ['review'], 'reason' => 'Conferência independente do processo com CV.']);
$test->actingAs($test->reviewer);
app(PeopleHiring::class)->execute($test->reviewer, $test->process->id, 'open', ['version' => 1, 'reason' => 'Abrir processo fictício com documentos.', 'no_conflict' => true]);
$test->actingAs($test->admin);
app(CandidateSubmissions::class)->publish($test->admin, $test->process->id, $test->publication);
}
function electronicPdf(string $extra = ''): UploadedFile
{
return UploadedFile::fake()->createWithContent('curriculo.pdf', "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".str_repeat(' ', 1024).$extra."\n%%EOF\n");
}
test('private CV receipt is immutable idempotent and downloadable only by its owner and authorised staff', function () {
requireElectronicCv($this);
$url = $this->base.'/oportunidades/'.$this->process->id;
$payload = [...$this->body, 'documents' => ['cv' => electronicPdf()]];
$this->post($url, $payload)->assertRedirect()->assertSessionHasNoErrors();
$row = DB::table('candidate_submissions')->sole();
$document = DB::table('candidate_submission_documents')->sole();
Storage::disk('candidate_documents')->assertExists($document->path);
$receipt = $this->base.'/submissoes/'.$row->id;
$this->get($receipt)->assertInertia(fn (Assert $p) => $p->where('receipt.snapshot.documents.cv.sha256', $document->sha256)->missing('receipt.snapshot.documents.cv.path'));
$this->get($receipt.'/documentos/cv')->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff')->assertDownload('curriculo.pdf');
$foreign = Tenant::query()->create(['name' => 'Empresa estrangeira fictícia']);
$this->get('/candidaturas/'.$foreign->id.'/submissoes/'.$row->id.'/documentos/cv')->assertUnauthorized();
$staffUrl = '/pessoas/'.$this->process->id.'/recebidas/'.$row->id.'/documentos/cv';
$this->get($staffUrl)->assertOk()->assertDownload('curriculo.pdf');
$this->post($url, [...$this->body, 'documents' => ['cv' => electronicPdf()]])->assertRedirect();
expect(DB::table('candidate_submission_documents')->count())->toBe(1)->and(Storage::disk('candidate_documents')->allFiles())->toHaveCount(1);
$this->post($url, [...$this->body, 'documents' => ['cv' => electronicPdf('changed')]])->assertConflict();
$this->actingAs($this->reviewer)->get($staffUrl)->assertForbidden();
$this->post($this->base.'/sair')->assertRedirect();
$this->get($receipt.'/documentos/cv')->assertUnauthorized();
$this->postJson($this->base.'/registar', [...$this->credentials, 'email' => 'other@example.invalid'])->assertRedirect();
$this->get($receipt.'/documentos/cv')->assertNotFound();
expect(DB::table('candidate_submissions')->sole()->snapshot)->toBe($row->snapshot);
});
test('missing forged unknown oversized and globally excessive files are rejected without stored files', function () {
requireElectronicCv($this);
$url = $this->base.'/oportunidades/'.$this->process->id;
foreach ([[], ['cv' => UploadedFile::fake()->createWithContent('fake.pdf', 'not a pdf')], ['unknown' => electronicPdf()], ['cv' => UploadedFile::fake()->create('large.pdf', 1025, 'application/pdf')]] as $documents) {
$this->postJson($url, [...$this->body, 'documents' => $documents])->assertUnprocessable();
}
config(['candidate_documents.max_total_kb' => 1]);
$this->postJson($url, [...$this->body, 'documents' => ['cv' => electronicPdf(str_repeat('x', 2048))]])->assertUnprocessable();
expect(DB::table('candidate_submissions')->count())->toBe(0)->and(Storage::disk('candidate_documents')->allFiles())->toBe([]);
});
test('failed audit removes uploaded bytes and rolls back the submission and document metadata', function () {
requireElectronicCv($this);
$this->partialMock(CandidateProfiles::class, fn ($mock) => $mock->shouldReceive('event')->once()->andThrow(new RuntimeException('Injected upload audit failure')));
$this->withoutExceptionHandling();
try {
$this->post($this->base.'/oportunidades/'.$this->process->id, [...$this->body, 'documents' => ['cv' => electronicPdf()]]);
$this->fail('Expected audit failure.');
} catch (RuntimeException $error) {
expect($error->getMessage())->toBe('Injected upload audit failure');
}
expect(DB::table('candidate_submissions')->count())->toBe(0)->and(DB::table('candidate_submission_documents')->count())->toBe(0)->and(Storage::disk('candidate_documents')->allFiles())->toBe([]);
});
test('download refuses changed stored bytes and document rows cannot be rewritten', function () {
requireElectronicCv($this);
$this->post($this->base.'/oportunidades/'.$this->process->id, [...$this->body, 'documents' => ['cv' => electronicPdf()]])->assertRedirect();
$document = DB::table('candidate_submission_documents')->sole();
Storage::disk('candidate_documents')->put($document->path, 'changed bytes');
$this->get($this->base.'/submissoes/'.$document->candidate_submission_id.'/documentos/cv')->assertConflict();
expect(fn () => DB::table('candidate_submission_documents')->where('id', $document->id)->update(['original_name' => 'other.pdf']))->toThrow(QueryException::class);
});
function electronicTriagePayload(object $test, string $outcome = 'accepted_for_analysis'): array
File diff suppressed because it is too large Load Diff
+25
View File
@@ -4,6 +4,7 @@ import { renderToString } from 'react-dom/server';
import { expect, test } from 'vitest';
import Entry from '@/pages/candidate/entry';
import Profile from '@/pages/candidate/profile';
import { CandidateAvailability } from '@/components/candidate-availability';
const page = {
component: 'candidate/entry',
@@ -13,6 +14,30 @@ const page = {
clearHistory: false,
encryptHistory: true,
} satisfies Page;
test('received availability renders primary and additional locations without requiring a new profile format', () => {
const availability = {
province: 'Província A',
district: 'Distrito A',
locality: 'Localidade A',
};
const additional = {
province: 'Província B',
district: 'Distrito B',
locality: 'Localidade B',
};
const html = renderToString(
<CandidateAvailability
data={{ availability, additional_availability: [additional] }}
/>,
);
expect(html).toContain('Localidade A');
expect(html).toContain('Localidade B');
expect(
renderToString(<CandidateAvailability data={{ availability }} />),
).toContain('Localidade A');
expect(renderToString(<CandidateAvailability />)).toBe('');
});
test.each([true, false])(
'registration availability (%s) preserves existing login and does not promise submission',
(registration_open) => {
+48 -1
View File
@@ -5,6 +5,7 @@ import { expect, test } from 'vitest';
import Opportunity from '@/pages/candidate/opportunity';
import Receipt from '@/pages/candidate/receipt';
import Access from '@/pages/candidate/access';
import { CandidateDocumentList } from '@/components/candidate-documents';
const page = {
component: 'candidate/opportunity',
@@ -29,8 +30,42 @@ const definition = {
required: true,
},
],
documents: [],
documents: [
{
key: 'cv',
label: 'Currículo obrigatório',
required: true,
formats: ['pdf', 'docx'],
max_kb: 1024,
},
],
};
test('received files use guarded download links and retain their fingerprint without a storage URL', () => {
const html = renderToString(
<CandidateDocumentList
baseUrl="/candidaturas/1/submissoes/7"
documents={{
cv: {
key: 'cv',
name: 'curriculo.pdf',
size: 2048,
sha256: 'abc123',
verification: 'format_checked',
},
}}
/>,
);
expect(html).toContain('/candidaturas/1/submissoes/7/documentos/cv');
expect(html).toContain('abc123');
expect(html).toContain('autenticidade dependem da análise documental');
expect(html).not.toContain('/storage/');
expect(
renderToString(
<CandidateDocumentList baseUrl="/unused" documents={[]} />,
),
).toBe('');
});
test.each([true, false])(
'actual submission form reuses geographic profile and requires email confirmation (%s)',
(emailVerified) => {
@@ -54,6 +89,13 @@ test.each([true, false])(
data: {
name: 'Nome do perfil',
summary: 'Perfil fictício',
additional_availability: [
{
province: 'Outra província',
district: 'Outro distrito',
locality: 'Outra localidade',
},
],
availability: {
province: 'Província do perfil',
district: 'Distrito do perfil',
@@ -68,6 +110,11 @@ test.each([true, false])(
);
expect(html).toContain('value="Nome do perfil"');
expect(html).toContain('value="Distrito do perfil"');
expect(html).toContain('Usar local guardado no perfil');
expect(html).toContain('Outra localidade');
expect(html).toContain('type="file"');
expect(html).toContain('Currículo obrigatório');
expect(html).toContain('accept=".pdf,.docx"');
expect(html).toContain('Submeter candidatura e obter comprovativo');
expect(html.includes('disabled=""')).toBe(!emailVerified);
},
+1 -4
View File
@@ -9,10 +9,7 @@ import { procurementNavigation } from '@/lib/procurement-navigation';
const fixtures = JSON.parse(
readFileSync(
new URL(
'../../storage/app/private/public-navigation-fixtures.json',
import.meta.url,
),
new URL('../Fixtures/public-navigation.json', import.meta.url),
'utf8',
),
) as Record<string, Page>;