Receber documentos privados e locais de disponibilidade nas candidaturas
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user