diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php index c5a9e57..97a67a0 100644 --- a/app/Http/Controllers/CandidatePortalController.php +++ b/app/Http/Controllers/CandidatePortalController.php @@ -7,6 +7,7 @@ use App\Models\PeopleProcess; use App\Models\Tenant; use App\Models\User; use App\Services\CandidateAccess; +use App\Services\CandidateDocuments; use App\Services\CandidateProfiles; use App\Services\CandidateSubmissions; use App\Services\CandidateTriage; @@ -200,7 +201,43 @@ class CandidatePortalController extends Controller abort_unless($row !== null, 404); app(CandidateProfiles::class)->event($account, 'submission.consulted', ['submission_id' => $row->id]); - return $this->page($request, 'candidate/receipt', ['companyId' => $company, 'decision' => app(CandidateTriage::class)->publicDecision($company, $submission), 'receipt' => ['reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR)]]); + return $this->page($request, 'candidate/receipt', ['companyId' => $company, 'decision' => app(CandidateTriage::class)->publicDecision($company, $submission), 'receipt' => ['id' => $row->id, 'reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR)]]); + } + + public function candidateDocument(Request $request, int $company, int $submission, string $document): Response + { + $account = $this->account($request, $company); + abort_unless($account !== null, 401); + $row = DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->where('id', $submission)->first(); + abort_unless($row !== null, 404); + $file = $this->submissionDocument($company, $row->people_process_id, $submission, $document); + app(CandidateProfiles::class)->event($account, 'submission.document_downloaded', ['submission_id' => $submission, 'document_key' => $document]); + + return app(CandidateDocuments::class)->download((array) $file); + } + + public function staffDocument(Request $request, int $process, int $submission, string $document): Response + { + return DB::transaction(function () use ($request, $process, $submission, $document) { + $actor = $this->staff($request); + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail(); + abort_unless(app(CandidateTriage::class)->canRead($actor, $record), 403); + $file = $this->submissionDocument($actor->tenant_id, $process, $submission, $document); + app(RequisitionWorkflow::class)->audit($actor, 'candidate_submission.document_downloaded', null, null, ['process_id' => $process, 'submission_id' => $submission, 'document_key' => $document]); + + return app(CandidateDocuments::class)->download((array) $file); + }); + } + + private function submissionDocument(int $company, int $process, int $submission, string $key): \stdClass + { + abort_unless(Schema::hasTable('candidate_submission_documents'), 404); + $row = DB::table('candidate_submission_documents')->where('tenant_id', $company)->where('people_process_id', $process)->where('candidate_submission_id', $submission)->where('document_key', $key)->first(); + abort_unless($row !== null, 404); + + return $row; } public function inbox(Request $request, int $process, CandidateSubmissions $submissions): Response diff --git a/app/Services/CandidateDocuments.php b/app/Services/CandidateDocuments.php new file mode 100644 index 0000000..878990c --- /dev/null +++ b/app/Services/CandidateDocuments.php @@ -0,0 +1,123 @@ + $files + * @return array> + */ + public function fingerprint(array $files): array + { + $result = []; + $total = 0; + foreach ($files as $key => $file) { + Validator::make(['file' => $file], ['file' => 'required|file|min:0.001|max:51200'])->validate(); + abort_unless($file instanceof UploadedFile, 422); + $total += $file->getSize(); + $name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180); + $result[$key] = ['name' => $name, 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getPathname()), 'mime' => $file->getMimeType()]; + } + abort_if($total > max(1, (int) config('candidate_documents.max_total_kb')) * 1024, 422, 'O conjunto de anexos excede o limite técnico de envio.'); + ksort($result); + + return $result; + } + + /** @param array $definition + * @param array $files + */ + public function validate(array $definition, array $files): void + { + $documents = $definition['documents']; + $keys = array_column($documents, 'key'); + $rules = ['documents' => $keys === [] ? 'present|array|max:0' : 'present|array:'.implode(',', $keys)]; + foreach ($documents as $document) { + $rules['documents.'.$document['key']] = ($document['required'] ? 'required' : 'sometimes').'|file|min:0.001|max:'.$document['max_kb'].'|mimes:'.implode(',', $document['formats']).'|extensions:'.implode(',', $document['formats']); + } + Validator::make(['documents' => $files], $rules)->validate(); + foreach ($files as $key => $file) { + abort_unless($file instanceof UploadedFile, 422); + $valid = match (strtolower($file->getClientOriginalExtension())) { + 'pdf' => str_starts_with((string) file_get_contents($file->getPathname(), false, null, 0, 5), '%PDF-'), + 'jpg', 'png' => @getimagesize($file->getPathname()) !== false, + 'docx' => $this->wordDocument($file), + default => false, + }; + Validator::make(['documents' => [$key => $valid]], ['documents.'.$key => 'accepted'], ['accepted' => 'O conteúdo do documento não corresponde ao formato indicado.'])->validate(); + } + } + + private function wordDocument(UploadedFile $file): bool + { + if (! class_exists(ZipArchive::class)) { + return false; + } + $zip = new ZipArchive; + if ($zip->open($file->getPathname()) !== true) { + return false; + } + try { + if ($zip->numFiles > 2000) { + return false; + } + $total = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->statIndex($i); + if ($entry === false || str_contains($entry['name'], '..') || str_starts_with($entry['name'], '/') || str_contains($entry['name'], '\\')) { + return false; + } + $total += $entry['size']; + if ($total > 100 * 1024 * 1024 || preg_match('/(?:vbaProject\.bin|\.(?:exe|dll|js|vbs))$/i', $entry['name'])) { + return false; + } + } + foreach (['[Content_Types].xml', 'word/document.xml'] as $part) { + $stat = $zip->statName($part); + if ($stat === false || $stat['size'] > 10 * 1024 * 1024) { + return false; + } + $xml = $zip->getFromName($part); + if ($xml === false || stripos($xml, 'loadXML($xml, LIBXML_NONET)) { + return false; + } + if ($part === 'word/document.xml' && ($document->documentElement?->localName !== 'document' || $document->documentElement->namespaceURI !== 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')) { + return false; + } + } + + return true; + } finally { + $zip->close(); + } + } + + /** @param array $document */ + public function download(array $document): StreamedResponse + { + $disk = Storage::disk('candidate_documents'); + abort_unless($disk->exists($document['path']), 404); + $stream = $disk->readStream($document['path']); + abort_unless(is_resource($stream), 404); + try { + $context = hash_init('sha256'); + hash_update_stream($context, $stream); + abort_unless(hash_equals($document['sha256'], hash_final($context)), 409, 'A integridade do documento exige conferência.'); + } finally { + fclose($stream); + } + + return $disk->download($document['path'], $document['original_name'], ['Content-Type' => 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store', 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Robots-Tag' => 'noindex, nofollow']); + } +} diff --git a/app/Services/CandidateProfiles.php b/app/Services/CandidateProfiles.php index f83d56b..b7671ff 100644 --- a/app/Services/CandidateProfiles.php +++ b/app/Services/CandidateProfiles.php @@ -79,13 +79,27 @@ class CandidateProfiles $data = Validator::make($input, ['version' => 'required|integer|min:0', 'name' => 'required|string|min:3|max:200', 'phone' => 'nullable|string|max:50', 'summary' => 'required|string|min:10|max:5000', 'availability' => 'required|array:province,district,locality', 'availability.province' => 'required|string|max:150', 'availability.district' => 'required|string|max:150', 'availability.locality' => 'required|string|max:150', + 'additional_availability' => 'sometimes|array|list|max:20', + 'additional_availability.*' => 'required|array:province,district,locality', + 'additional_availability.*.province' => 'required|string|max:150', + 'additional_availability.*.district' => 'required|string|max:150', + 'additional_availability.*.locality' => 'required|string|max:150', 'reason' => 'required|string|min:10|max:4000'])->validate(); $previous = $this->profile($account); + $data['additional_availability'] ??= $previous['data']['additional_availability'] ?? []; + $seen = []; + foreach ([$data['availability'], ...$data['additional_availability']] as $location) { + $key = json_encode(array_map(fn ($part) => mb_strtolower(trim($location[$part])), ['province', 'district', 'locality']), JSON_THROW_ON_ERROR); + if (isset($seen[$key])) { + throw ValidationException::withMessages(['additional_availability' => 'Indique cada local de disponibilidade apenas uma vez.']); + } + $seen[$key] = true; + } if ((int) $data['version'] !== $previous['version']) { throw ValidationException::withMessages(['version' => 'O perfil mudou. Actualize e confira antes de guardar.']); } $id = DB::table('candidate_profile_versions')->insertGetId(['tenant_id' => $account->tenant_id, 'candidate_account_id' => $account->id, - 'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability'])), JSON_THROW_ON_ERROR), + 'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability', 'additional_availability'])), JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'created_at' => now()]); $this->event($account, 'profile.version_created', ['profile_version_id' => $id, 'version' => $previous['version'] + 1]); }); diff --git a/app/Services/CandidateSubmissions.php b/app/Services/CandidateSubmissions.php index 0cb0af9..d450cf1 100644 --- a/app/Services/CandidateSubmissions.php +++ b/app/Services/CandidateSubmissions.php @@ -6,6 +6,8 @@ use App\Models\CandidateAccount; use App\Models\PeopleProcess; use App\Models\User; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -32,7 +34,7 @@ class CandidateSubmissions abort_unless($form !== null && $form['definition']['available'] && app(CandidateProfiles::class)->portal($actor->tenant_id) !== null, 422, 'Configure o portal e prepare um processo com formulário disponível.'); foreach ($form['definition']['documents'] as $document) { if ($document['required']) { - throw ValidationException::withMessages(['active' => 'Este formulário exige anexos. A recepção electrónica de anexos ainda está em desenvolvimento; não publique sem esse percurso.']); + abort_unless(Schema::hasTable('candidate_submission_documents'), 503, 'Instalação da recepção de documentos pendente.'); } } } @@ -62,36 +64,63 @@ class CandidateSubmissions /** @param array $input */ public function submit(CandidateAccount $actor, int $processId, array $input): int { - return DB::transaction(function () use ($actor, $processId, $input) { - DB::table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->firstOrFail(); - $sessionVersion = $actor->session_version; - $actor = CandidateAccount::query()->where('id', $actor->id)->lockForUpdate()->firstOrFail(); - abort_unless($actor->session_version === $sessionVersion, 401); - abort_unless($actor->email_verified_at !== null, 403, 'Confirme o email antes de submeter.'); - $data = Validator::make($input, ['operation_key' => 'required|uuid', 'profile_version' => 'required|integer|min:1', 'publication_version' => 'required|integer|min:1', 'answers' => 'present|array', 'documents' => 'sometimes|array|max:0'])->validate(); - $hash = hash('sha256', json_encode([$processId, $data], JSON_THROW_ON_ERROR)); - $previous = DB::table('candidate_submissions')->where('candidate_account_id', $actor->id)->where('operation_key', $data['operation_key'])->first(); - if ($previous !== null) { - abort_unless($previous->people_process_id === $processId && $previous->input_hash === $hash, 409, 'Esta operação já foi utilizada com outros dados.'); + $stored = []; + try { + return DB::transaction(function () use ($actor, $processId, $input, &$stored) { + DB::table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->firstOrFail(); + $sessionVersion = $actor->session_version; + $actor = CandidateAccount::query()->where('id', $actor->id)->lockForUpdate()->firstOrFail(); + abort_unless($actor->session_version === $sessionVersion, 401); + abort_unless($actor->email_verified_at !== null, 403, 'Confirme o email antes de submeter.'); + $data = Validator::make($input, ['operation_key' => 'required|uuid', 'profile_version' => 'required|integer|min:1', 'publication_version' => 'required|integer|min:1', 'answers' => 'present|array', 'documents' => 'sometimes|array|max:20'])->validate(); + $documents = app(CandidateDocuments::class); + $fingerprint = $documents->fingerprint($data['documents'] ?? []); + $hashData = $data; + if (array_key_exists('documents', $hashData)) { + $hashData['documents'] = $fingerprint; + } + $hash = hash('sha256', json_encode([$processId, $hashData], JSON_THROW_ON_ERROR)); + $previous = DB::table('candidate_submissions')->where('candidate_account_id', $actor->id)->where('operation_key', $data['operation_key'])->first(); + if ($previous !== null) { + abort_unless($previous->people_process_id === $processId && $previous->input_hash === $hash, 409, 'Esta operação já foi utilizada com outros dados.'); - return $previous->id; - } - $opportunity = $this->opportunity($actor->tenant_id, $processId); - $profile = app(CandidateProfiles::class)->profile($actor); - if ((int) $data['profile_version'] !== $profile['version'] || (int) $data['publication_version'] !== $opportunity['publication_version']) { - throw ValidationException::withMessages(['version' => 'O perfil ou a publicação mudou. Actualize e confira antes de submeter.']); - } - abort_if(DB::table('candidate_submissions')->where('people_process_id', $processId)->where('candidate_account_id', $actor->id)->exists(), 409, 'Já apresentou candidatura a esta oportunidade.'); - foreach ($opportunity['form']['definition']['documents'] as $document) { - abort_if($document['required'], 422, 'Faltam anexos obrigatórios.'); - } - $answers = app(SubmissionAnswers::class)->validate($opportunity['form']['definition'], $data['answers']); - $id = DB::table('candidate_submissions')->insertGetId(['tenant_id' => $actor->tenant_id, 'candidate_account_id' => $actor->id, 'people_process_id' => $processId, - 'operation_key' => $data['operation_key'], 'reference' => 'CAND-'.strtoupper((string) Str::uuid()), 'input_hash' => $hash, - 'snapshot' => json_encode(['opportunity' => $opportunity, 'profile' => $profile, 'email' => $actor->email, 'answers' => $answers, 'documents' => [], 'status' => 'received'], JSON_THROW_ON_ERROR), 'created_at' => now()]); - app(CandidateProfiles::class)->event($actor, 'submission.received', ['submission_id' => $id, 'process_id' => $processId, 'profile_version' => $profile['version']]); + return $previous->id; + } + $opportunity = $this->opportunity($actor->tenant_id, $processId); + $profile = app(CandidateProfiles::class)->profile($actor); + if ((int) $data['profile_version'] !== $profile['version'] || (int) $data['publication_version'] !== $opportunity['publication_version']) { + throw ValidationException::withMessages(['version' => 'O perfil ou a publicação mudou. Actualize e confira antes de submeter.']); + } + abort_if(DB::table('candidate_submissions')->where('people_process_id', $processId)->where('candidate_account_id', $actor->id)->exists(), 409, 'Já apresentou candidatura a esta oportunidade.'); + $documents->validate($opportunity['form']['definition'], $data['documents'] ?? []); + $answers = app(SubmissionAnswers::class)->validate($opportunity['form']['definition'], $data['answers']); + $metadata = []; + if ($fingerprint !== []) { + abort_unless(Schema::hasTable('candidate_submission_documents'), 503, 'Instalação da recepção de documentos pendente.'); + foreach ($fingerprint as $key => $details) { + $path = $data['documents'][$key]->store($actor->tenant_id.'/'.$actor->id, 'candidate_documents'); + abort_unless(is_string($path), 500); + $stored[$key] = $path; + $metadata[$key] = [...$details, 'key' => $key, 'verification' => 'format_checked']; + } + } + $id = DB::table('candidate_submissions')->insertGetId(['tenant_id' => $actor->tenant_id, 'candidate_account_id' => $actor->id, 'people_process_id' => $processId, + 'operation_key' => $data['operation_key'], 'reference' => 'CAND-'.strtoupper((string) Str::uuid()), 'input_hash' => $hash, + 'snapshot' => json_encode(['opportunity' => $opportunity, 'profile' => $profile, 'email' => $actor->email, 'answers' => $answers, 'documents' => $metadata, 'status' => 'received'], JSON_THROW_ON_ERROR), 'created_at' => now()]); + foreach ($stored as $key => $path) { + DB::table('candidate_submission_documents')->insert(['tenant_id' => $actor->tenant_id, 'people_process_id' => $processId, 'candidate_submission_id' => $id, + 'document_key' => $key, 'path' => $path, 'original_name' => $fingerprint[$key]['name'], 'mime_type' => $fingerprint[$key]['mime'], + 'size' => $fingerprint[$key]['size'], 'sha256' => $fingerprint[$key]['sha256'], 'created_at' => now()]); + } + app(CandidateProfiles::class)->event($actor, 'submission.received', ['submission_id' => $id, 'process_id' => $processId, 'profile_version' => $profile['version']]); - return $id; - }); + return $id; + }); + } catch (\Throwable $error) { + foreach ($stored as $path) { + Storage::disk('candidate_documents')->delete($path); + } + throw $error; + } } } diff --git a/config/candidate_documents.php b/config/candidate_documents.php new file mode 100644 index 0000000..b827174 --- /dev/null +++ b/config/candidate_documents.php @@ -0,0 +1,6 @@ + (int) env('CANDIDATE_DOCUMENTS_MAX_TOTAL_KB', 102400), +]; diff --git a/config/filesystems.php b/config/filesystems.php index ba2be01..10c00db 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -30,6 +30,14 @@ return [ 'disks' => [ + 'candidate_documents' => [ + 'driver' => 'local', + 'root' => storage_path('app/candidate-documents'), + 'visibility' => 'private', + 'serve' => false, + 'throw' => true, + ], + 'supplier_documents' => [ 'driver' => 'local', 'root' => storage_path('app/supplier-documents'), diff --git a/database/migrations/2026_10_30_000041_create_candidate_submission_documents.php b/database/migrations/2026_10_30_000041_create_candidate_submission_documents.php new file mode 100644 index 0000000..df3b1a0 --- /dev/null +++ b/database/migrations/2026_10_30_000041_create_candidate_submission_documents.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('people_process_id'); + $table->unsignedBigInteger('candidate_submission_id'); + $table->foreign(['tenant_id', 'people_process_id', 'candidate_submission_id'], 'candidate_document_submission_scope')->references(['tenant_id', 'people_process_id', 'id'])->on('candidate_submissions')->restrictOnDelete(); + $table->string('document_key', 50); + $table->string('path'); + $table->string('original_name', 180); + $table->string('mime_type', 100); + $table->unsignedBigInteger('size'); + $table->string('sha256', 64); + $table->timestamp('created_at'); + $table->unique(['candidate_submission_id', 'document_key']); + }); + if (DB::getDriverName() === 'sqlite') { + DB::unprepared("CREATE TRIGGER preserve_candidate_documents_update BEFORE UPDATE ON candidate_submission_documents BEGIN SELECT RAISE(ABORT, 'Preservar documentos recebidos'); END; + CREATE TRIGGER preserve_candidate_documents_delete BEFORE DELETE ON candidate_submission_documents BEGIN SELECT RAISE(ABORT, 'Preservar documentos recebidos'); END;"); + } elseif (DB::getDriverName() === 'pgsql') { + DB::unprepared('CREATE TRIGGER preserve_candidate_documents BEFORE UPDATE OR DELETE ON candidate_submission_documents FOR EACH ROW EXECUTE FUNCTION sgc_preserve_candidate_submissions();'); + } + } + + public function down(): void + { + throw new LogicException('Preservar documentos recebidos.'); + } +}; diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index 85e3259..05aacdc 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,29 @@ # Desenvolvimento do SGC +## CV e documentos na candidatura electrónica, 30 de Outubro de 2026 + +Recepção de documentos integrada no mesmo envio da candidatura. A obrigatoriedade, formatos (PDF, DOCX, JPG e PNG) e limite por ficheiro vêm do formulário preservado na preparação do processo, sem procurar a versão actual da empresa. Recusados documentos desconhecidos, obrigatórios em falta, tamanho excessivo e divergência de extensão/MIME/conteúdo. DOCX exige estrutura Word/XML válida, sem declarações de entidades/DOCTYPE, macros ou executáveis conhecidos, com limites de entradas e expansão do arquivo. A conferência de formato não constitui antivírus, prova de autenticidade, validação do mérito ou aprovação documental. + +Armazenamento privado separado, nomes físicos gerados e nome apresentado sanitizado. A candidatura conserva nome, tamanho, MIME e SHA-256, sem expor caminhos internos. Downloads separados para titular e equipa; conferem conta/sessão, empresa, processo e permissões actuais, registam consulta e verificam integridade dos bytes. Entrega como anexo, sem pré-visualização activa no portal. A tabela de documentos recebidos tem vínculo composto à submissão e protecção contra alteração/eliminação corrente. O comprovativo original não pode ser substituído por novo envio; mesmo identificador e mesmos ficheiros devolvem a submissão anterior, conteúdo diferente é recusado. Falha transaccional limpa os ficheiros gravados. + +Limite técnico do conjunto por pedido configurável por `CANDIDATE_DOCUMENTS_MAX_TOTAL_KB` (inicialmente 102400 KB); limites específicos continuam na parametrização da empresa. Não existe ainda quota acumulada por candidato/empresa nem integração antivírus. Na entrega ao domínio, conferir extensões PHP DOM/ZIP/fileinfo, limites PHP/proxy e armazenamento persistente privado com backup/restauro; não activar uploads numa pasta efémera da release. Complementos, substituições e reabertura precisam de um percurso versionado próprio, sem alterar estes originais. + +Migração aditiva `2026_10_30_000041_create_candidate_submission_documents` instalada apenas na avaliação local 8005. Backup fresco de 501935 bytes e restauro verificados; conteúdos anteriores das 104 tabelas preservados e nova tabela vazia. Sem alterar contas/grants, repetir outras migrações ou instalar no domínio. Prova `proofs/candidate-documents-install.json`. PostgreSQL descartável: 62 testes/1323 asserções aprovados, incluindo perfis e Pessoas; base removida. Suíte integral: 745 testes PHP, 743 aprovados, dois anteriormente ignorados e 7619 asserções. Módulo final SQLite: 24 testes/298 asserções; frontend integral: 83 testes aprovados. Tipos, PHPStan sem erros, Pint, lint dirigido, compilação e diff conferidos. A prova `proofs/candidate-documents.json` regista o âmbito e hashes. Os testes de navegação pública passam a usar uma fixture sintética versionada em tests/Fixtures, deixando de depender de um ficheiro privado ignorado pelo Git. A conferência visual dirigida mantém-se pendente. + +## Disponibilidade geográfica múltipla do candidato, 30 de Outubro de 2026 + +O perfil permite manter o local principal e adicionar ou retirar outros locais, cada um com província, distrito e localidade. A morada continua separada. Entradas incompletas e locais repetidos (sem distinguir maiúsculas ou espaços exteriores) são recusados no servidor. Existe um limite técnico inicial de 20 locais adicionais por pedido, sem constituir uma política territorial ou uma classificação de candidatos. O cadastro territorial oficial continua pendente. + +Cada gravação cria uma versão. Pedidos anteriores que omitem o novo campo preservam os locais adicionais já guardados; uma lista explicitamente vazia retira-os apenas da nova versão. No formulário de candidatura, o candidato pode escolher um local guardado para preencher o campo de disponibilidade, mantendo a possibilidade de o ajustar para aquela candidatura. A submissão preserva todos os locais da versão do perfil enviada. O comprovativo e a recepção interna mostram essa versão, sem consultar por arrasto o perfil actual. Não são criadas migrações, alteradas permissões ou executadas chamadas de IA. + +Validação dirigida: 18 testes PHP/247 asserções nos módulos de perfil e submissões, incluindo conservação das versões, pedidos anteriores, rejeição de duplicados/incompletos e conservação da candidatura após alteração do perfil. Oito testes frontend, TypeScript, PHPStan sem erros, Pint, lint dirigido, compilação e verificação do diff aprovados. O comando global de formatação identificou três ficheiros JSON de histórico anteriores a este incremento; estes não foram alterados para o fazer passar. Conferência visual e PostgreSQL deste incremento ainda pendentes. Alteração apenas local, sem publicação no domínio. Próximo incremento funcional: anexos verificados para candidaturas, com requisitos e versões preservados, sem retirar exigências documentais existentes. + +## Consulta pública confirmada em produção, 30 de Outubro de 2026 + +Commit `2134aa084be2d7be0a3bcaba74cc5eae604e6663` integrado em main, enviado e instalado pelo processo existente. Activação de `SGC_PUBLIC_NAVIGATION=true` no `.env` partilhado, com backup fresco da configuração e cache Laravel. A primeira tentativa foi revertida: PHP 8.5 CLI sem DOM/mbstring/XML. Activação concluída com PHP 8.4.27; restantes definições da cache conferidas como inalteradas. Sem migrações ou alterações de dados de negócio. + +Conferência HTTP anónima no domínio: raiz encaminha para dashboard, 21 ecrãs públicos com HTTP 200 e componentes esperados, utilizador nulo e âmbito de dados vazio; seis percursos privados encaminham para login. O proprietário confirmou que o acesso funciona. Isso confirma o acesso observado, sem equivaler à conferência visual completa de todos os percursos afectados. Prova técnica local em `C:/laragon/worktrees/compras-public-delivery/storage/app/private/compras-production-http.json`. + ## Triagem electrónica e aproveitamento na avaliação, 30 de Outubro de 2026 Recepção interna permite «Aceite para análise» ou «Não aceite para análise». Preparador com função e atribuição actuais, fora de manutenção, declara ausência de conflito e regista motivo interno e comunicação pública em campos separados. A decisão é preservada por inserção, com responsável/data/operação; repetição idêntica é idempotente e alteração posterior é recusada enquanto não existir percurso de revisão. Aceitar para análise não inclui a pessoa na base nem selecciona ou contrata. diff --git a/docs/proofs/candidate-documents-install.json b/docs/proofs/candidate-documents-install.json new file mode 100644 index 0000000..903f7fc --- /dev/null +++ b/docs/proofs/candidate-documents-install.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-30T18:48:58+00:00", + "migration": "2026_10_30_000041_create_candidate_submission_documents", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-candidate-documents-20261030-184854\/before.dump", + "backup_sha256": "e3bd53977bceecdbdc716a574caa14f1ee9cb2ae8c3e3c988a00700d2fe1822e", + "backup_bytes": 501935, + "restore_verified": true, + "existing_tables_preserved": 104, + "new_tables": { + "candidate_submission_documents": 0 + }, + "accounts_or_grants_changed": false, + "other_migrations_applied": false, + "domain_installed": false +} diff --git a/docs/proofs/candidate-documents-pg.json b/docs/proofs/candidate-documents-pg.json new file mode 100644 index 0000000..1f4b20c --- /dev/null +++ b/docs/proofs/candidate-documents-pg.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-30T18:48:38+00:00", + "database": "disposable PostgreSQL", + "application_role_reused_without_privilege_change": true, + "result": "passed", + "test_files": [ + "CandidateSubmissionsTest", + "PeopleHiringTest", + "CandidatePortalTest", + "CandidateDocumentFormatsTest" + ], + "original_evaluation_untouched": true, + "database_removed": true, + "tests": 62, + "assertions": 1323 +} diff --git a/docs/proofs/candidate-documents.json b/docs/proofs/candidate-documents.json new file mode 100644 index 0000000..08e5bc4 --- /dev/null +++ b/docs/proofs/candidate-documents.json @@ -0,0 +1,71 @@ +{ + "checked_at": "2026-10-30T18:51:58.536440+00:00", + "base_commit": "2134aa084be2d7be0a3bcaba74cc5eae604e6663", + "scope": [ + "multiple versioned candidate availability locations", + "reuse locations without re-entry", + "private immutable submission documents", + "versioned form document requirements", + "format MIME extension and size checks", + "bounded Word XML validation", + "same-operation retry and changed-content rejection", + "candidate and staff download authorisation", + "download SHA-256 verification", + "transaction failure cleanup", + "tracked anonymous public test fixtures" + ], + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 745, + "passed": 743, + "assertions": 7619, + "duration_ms": 174707, + "skipped": 2 + }, + "module_sqlite": { + "tests": 24, + "assertions": 298, + "passed": true + }, + "postgresql": { + "tests": 62, + "assertions": 1323, + "disposable_database_removed": true + }, + "frontend": { + "tests": 83, + "passed": true + }, + "typescript": true, + "phpstan_errors": 0, + "pint": true, + "targeted_lint": true, + "build": true, + "global_format_check": "blocked by three pre-existing ignored history JSON files; changed files checked separately", + "migration_installed": "local evaluation 8005 only", + "production_installed": false, + "visual_review": "pending", + "limitations": [ + "no antivirus integration or authentication of document contents", + "no accumulated tenant storage quota", + "no document replacement or complements yet", + "production persistent storage PHP extensions and request limits must be checked before installation" + ], + "source_sha256": { + "app/Services/CandidateProfiles.php": "287bec37f58fe2eb9f0fdcfb879a8e037d91b704d1de3af5a401364d2414eeae", + "app/Services/CandidateSubmissions.php": "3a89a181e288c7c1be5b417e943e0857c81058fb37118e8f86681d771c56860b", + "app/Services/CandidateDocuments.php": "898985829d6b1674baeadca9142e334c0b9624de00d71a7a3bcab727328a6604", + "app/Http/Controllers/CandidatePortalController.php": "9bad527126e939c95aa3b78f72deec4143bdd50ec77ddc5162d1dd33ee99639a", + "routes/web.php": "6400c72ebb90d796307af286b082ded8c8f019c0595b1a77f43a0eb059161c23", + "config/filesystems.php": "d627e636c65680051f12da58ff6cf0a8b1bd69ab054ae39824ca5a8d85110416", + "config/candidate_documents.php": "9ce54226b6701dd53df98613107e13c505ca9c42a13f9586a4015986a29fc7ae", + "database/migrations/2026_10_30_000041_create_candidate_submission_documents.php": "01e80309c5b193f262076d8f7f52a88a3ba75fb073bad369c22c2561c79663a9", + "resources/js/pages/candidate/profile.tsx": "ddf7f38aeff924bb5709d2c308c22cd726fad7eb904e09f6f31f52c51f275ce7", + "resources/js/pages/candidate/opportunity.tsx": "ffc86485fb488a62da6b25d6ca96bf208ac0cc6b9a480876aec28002a2a83008", + "resources/js/pages/candidate/receipt.tsx": "86a9ac93b5aa2b44aca8ee1a035a8bcce42c1482d5d50045770bc8c708028c78", + "resources/js/pages/people/submissions.tsx": "966f38a4a77e49fb3f66fde7def7ec1a617dcbe29516b41f2a8756d91fbb3acf", + "resources/js/components/candidate-documents.tsx": "6464f982bbd26cf2da714b517f758799974a6d953a18b27733f755ade07cc206", + "resources/js/components/candidate-availability.tsx": "4f44943790e0fac7b0edaf212912bc0264338cb70e7072acdfb5b1fde86d47d8" + } +} diff --git a/docs/reutilizacao-dados.md b/docs/reutilizacao-dados.md index d63940f..f7b8be1 100644 --- a/docs/reutilizacao-dados.md +++ b/docs/reutilizacao-dados.md @@ -16,6 +16,8 @@ Registos sem vínculo de cadastro continuam em compatibilidade; não foram ligad ## Matriz transversal de cobertura +Actualização de 30/10: o perfil externo admite vários locais de disponibilidade. A candidatura reutiliza um local guardado e conserva todos os locais da versão enviada. CV/documentos já podem acompanhar a submissão, com formato e limites do formulário preservado; o comprovativo e a recepção interna usam os mesmos anexos privados e hashes, sem novo carregamento pela equipa. Complementos e reutilização de um documento noutra candidatura ainda exigem um percurso próprio; não se associa um ficheiro de outra pessoa ou empresa por nome. Esta actualização substitui a indicação de anexos por desenvolver na linha de perfil externo abaixo. + | Percurso | Reutilização observada/implementada | Evolução ainda necessária | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Empresa, utilizadores, departamentos e centros | Identificadores seleccionados; contexto do actor usado nos pedidos; regras preservadas nos processos | Conferir reutilização de campos em todos os documentos e associações de funções/unidades | diff --git a/resources/js/components/candidate-availability.tsx b/resources/js/components/candidate-availability.tsx new file mode 100644 index 0000000..a46b1bb --- /dev/null +++ b/resources/js/components/candidate-availability.tsx @@ -0,0 +1,34 @@ +export type AvailabilityLocation = { + province: string; + district: string; + locality: string; +}; + +export function CandidateAvailability({ + data, +}: { + data?: { + availability?: AvailabilityLocation; + additional_availability?: AvailabilityLocation[]; + }; +}) { + if (!data?.availability) return null; + return ( +
+

+ Locais de disponibilidade do perfil enviado +

+
    + {[ + data.availability, + ...(data.additional_availability ?? []), + ].map((location, index) => ( +
  • + {location.province} · {location.district} ·{' '} + {location.locality} +
  • + ))} +
+
+ ); +} diff --git a/resources/js/components/candidate-documents.tsx b/resources/js/components/candidate-documents.tsx new file mode 100644 index 0000000..95a6fb7 --- /dev/null +++ b/resources/js/components/candidate-documents.tsx @@ -0,0 +1,42 @@ +export type ReceivedDocument = { + key: string; + name: string; + size: number; + sha256: string; + verification: string; +}; + +export function CandidateDocumentList({ + documents, + baseUrl, +}: { + documents?: Record | []; + baseUrl: string; +}) { + if (!documents || Object.keys(documents).length === 0) return null; + return ( +
+

Documentos recebidos

+
    + {Object.values(documents).map((document) => ( +
  • + + {document.name} + {' '} + · {document.size} bytes +

    + SHA-256: {document.sha256} +

    +
  • + ))} +
+

+ Formato conferido na recepção. O conteúdo e a autenticidade + dependem da análise documental. +

+
+ ); +} diff --git a/resources/js/pages/candidate/opportunity.tsx b/resources/js/pages/candidate/opportunity.tsx index 8b2f9e6..ef46059 100644 --- a/resources/js/pages/candidate/opportunity.tsx +++ b/resources/js/pages/candidate/opportunity.tsx @@ -24,7 +24,13 @@ export default function Opportunity({ version: number; definition: { fields: Field[]; - documents: { key: string; label: string; required: boolean }[]; + documents: { + key: string; + label: string; + required: boolean; + formats: string[]; + max_kb: number; + }[]; }; }; }; @@ -35,11 +41,16 @@ export default function Opportunity({ phone?: string; summary: string; availability: Location; + additional_availability?: Location[]; }; }; email: string; emailVerified: boolean; }) { + const locations = [ + profile.data.availability, + ...(profile.data.additional_availability ?? []), + ]; const source: Record = { name: profile.data.name, full_name: profile.data.name, @@ -64,6 +75,7 @@ export default function Opportunity({ : '', ]), ) as Record, + documents: {} as Record, }); return ( <> @@ -110,6 +122,80 @@ export default function Opportunity({ {field.label} {field.required ? ' *' : ''} + {field.key === 'availability_location' && + locations.length > 1 && ( + + )} {( [ 'province', @@ -193,11 +279,33 @@ export default function Opportunity({ ), )} - {opportunity.form.definition.documents.length > 0 && ( -

- O envio de anexos ainda está em desenvolvimento. Não - é possível entregar documentos neste formulário. -

+ {opportunity.form.definition.documents.map((document) => ( + + ))} + {form.progress && ( +

Envio dos documentos: {form.progress.percentage}%

)} + + ), + )} +

Disponibilidade é distinta da morada. Actualizar o perfil cria uma versão para usos futuros, preservando as diff --git a/resources/js/pages/candidate/receipt.tsx b/resources/js/pages/candidate/receipt.tsx index a61b6d3..cdb06c7 100644 --- a/resources/js/pages/candidate/receipt.tsx +++ b/resources/js/pages/candidate/receipt.tsx @@ -1,4 +1,12 @@ import { Head, Link } from '@inertiajs/react'; +import { + CandidateDocumentList, + type ReceivedDocument, +} from '@/components/candidate-documents'; +import { + CandidateAvailability, + type AvailabilityLocation, +} from '@/components/candidate-availability'; export default function Receipt({ companyId, @@ -8,16 +16,24 @@ export default function Receipt({ companyId: number; decision?: null | { outcome: string; message: string; decided_at: string }; receipt: { + id?: number; reference: string; received_at: string; snapshot: { + documents?: Record | []; opportunity: { title: string; form?: { definition: { fields: { key: string; label: string }[] }; }; }; - profile: { version: number }; + profile: { + version: number; + data?: { + availability?: AvailabilityLocation; + additional_availability?: AvailabilityLocation[]; + }; + }; answers: Record< string, string | number | null | Record @@ -87,6 +103,13 @@ export default function Receipt({ ), )} + + {receipt.id && ( + + )} | []; opportunity: { form: { definition: { @@ -129,7 +138,14 @@ export default function Submissions({ }; }; }; - profile: { data: { name: string; summary: string } }; + profile: { + data: { + name: string; + summary: string; + availability?: AvailabilityLocation; + additional_availability?: AvailabilityLocation[]; + }; + }; answers: Record; }; }[]; @@ -234,6 +250,13 @@ export default function Submissions({ : 'Recebida, por triar'}

{row.snapshot.profile.data.summary}

+ +
{Object.entries(row.snapshot.answers).map( ([key, value]) => ( diff --git a/routes/web.php b/routes/web.php index 0293023..f715f44 100644 --- a/routes/web.php +++ b/routes/web.php @@ -60,6 +60,7 @@ require __DIR__.'/preview.php'; Route::get('candidaturas/{company}/oportunidades/{process}', [CandidatePortalController::class, 'opportunity'])->whereNumber(['company', 'process']); Route::post('candidaturas/{company}/oportunidades/{process}', [CandidatePortalController::class, 'submit'])->whereNumber(['company', 'process'])->middleware('throttle:12,1'); Route::get('candidaturas/{company}/submissoes/{submission}', [CandidatePortalController::class, 'receipt'])->whereNumber(['company', 'submission']); +Route::get('candidaturas/{company}/submissoes/{submission}/documentos/{document}', [CandidatePortalController::class, 'candidateDocument'])->whereNumber(['company', 'submission'])->where('document', '[a-z][a-z0-9_]{0,49}'); Route::get('candidaturas/{company}/{purpose}', [CandidatePortalController::class, 'accessPage'])->whereNumber('company')->whereIn('purpose', ['recuperar', 'confirmar-email']); Route::post('candidaturas/{company}/{purpose}', [CandidatePortalController::class, 'confirmAccess'])->whereNumber('company')->whereIn('purpose', ['recuperar', 'confirmar-email'])->middleware('throttle:6,1'); @@ -81,6 +82,7 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group( Route::middleware(EnsureCompanyIsConfigured::class)->group(function () { Route::post('pessoas/{process}/recebidas/{submission}/triagem', [CandidatePortalController::class, 'triage'])->whereNumber(['process', 'submission']); Route::get('pessoas/{process}/recebidas', [CandidatePortalController::class, 'inbox'])->whereNumber('process'); + Route::get('pessoas/{process}/recebidas/{submission}/documentos/{document}', [CandidatePortalController::class, 'staffDocument'])->whereNumber(['process', 'submission'])->where('document', '[a-z][a-z0-9_]{0,49}'); Route::post('pessoas/{process}/publicacao', [CandidatePortalController::class, 'publish'])->whereNumber('process'); Route::get('administracao/candidaturas/portal', [CandidatePortalController::class, 'settings']); Route::post('administracao/candidaturas/portal', [CandidatePortalController::class, 'configure']); diff --git a/tests/Feature/CandidateDocumentFormatsTest.php b/tests/Feature/CandidateDocumentFormatsTest.php new file mode 100644 index 0000000..23669dc --- /dev/null +++ b/tests/Feature/CandidateDocumentFormatsTest.php @@ -0,0 +1,35 @@ +open($path, ZipArchive::OVERWRITE); + $zip->addFromString('[Content_Types].xml', ''); + $zip->addFromString('word/document.xml', ''.($unsafe ? ']>' : '').''.str_repeat('Ficticio ', 300).''); + $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); +}); diff --git a/tests/Feature/CandidatePortalTest.php b/tests/Feature/CandidatePortalTest.php index c17fb76..407e87d 100644 --- a/tests/Feature/CandidatePortalTest.php +++ b/tests/Feature/CandidatePortalTest.php @@ -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(); diff --git a/tests/Feature/CandidateSubmissionsTest.php b/tests/Feature/CandidateSubmissionsTest.php index d880d0f..c66525e 100644 --- a/tests/Feature/CandidateSubmissionsTest.php +++ b/tests/Feature/CandidateSubmissionsTest.php @@ -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 diff --git a/tests/Fixtures/public-navigation.json b/tests/Fixtures/public-navigation.json new file mode 100644 index 0000000..2af2581 --- /dev/null +++ b/tests/Fixtures/public-navigation.json @@ -0,0 +1,1787 @@ +{ + "dashboard": { + "component": "dashboard", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "company": null, + "stats": { + "total": 0, + "pending": 0, + "approved": 0, + "approvedMinor": 0, + "awaitingMe": 0 + }, + "recent": [], + "approvals": [] + }, + "url": "/dashboard", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "requisitions/index": { + "component": "requisitions/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "requisitions": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/requisicoes?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/requisicoes?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/requisicoes?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/requisicoes", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "filters": [] + }, + "url": "/requisicoes", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "requisitions/form": { + "component": "requisitions/form", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "costCenters": [], + "requisition": null, + "catalogue": { + "ready": false, + "items": [], + "search": "", + "more": false + } + }, + "url": "/requisicoes/criar", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "suppliers/index": { + "component": "suppliers/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "suppliers": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/fornecedores?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/fornecedores?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/fornecedores?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/fornecedores", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "filters": [], + "canCreate": false, + "canReview": false + }, + "url": "/fornecedores", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "quotations/index": { + "component": "quotations/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "requisitions": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/cotacoes?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/cotacoes?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/cotacoes?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/cotacoes", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + } + }, + "url": "/cotacoes", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "purchases/index": { + "component": "purchases/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "orders": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/ordens?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/ordens?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/ordens?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/ordens", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "awards": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/ordens?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/ordens?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/ordens?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/ordens", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + } + }, + "url": "/ordens", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "receipts/index": { + "component": "receipts/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "orders": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/recepcoes?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/recepcoes?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/recepcoes?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/recepcoes", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "receipts": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/recepcoes?receipts_page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/recepcoes?receipts_page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/recepcoes?receipts_page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/recepcoes", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + } + }, + "url": "/recepcoes", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "stock/index": { + "component": "stock/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "items": [], + "warehouses": [], + "balances": [], + "movements": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/stock?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/stock?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/stock?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/stock", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "filters": [], + "canManage": false + }, + "url": "/stock", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "stock/catalogue": { + "component": "stock/catalogue", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "records": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/stock/catalogo?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/stock/catalogo?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/stock/catalogo?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/stock/catalogo", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "filters": [], + "ready": true, + "canManage": false + }, + "url": "/stock/catalogo", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "stock/operations": { + "component": "stock/operations", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "items": [], + "warehouses": [], + "balances": [], + "users": [], + "operations": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/stock/operacoes?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/stock/operacoes?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/stock/operacoes?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/stock/operacoes", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "counts": [], + "equipment": [], + "canOperate": false + }, + "url": "/stock/operacoes", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "equipment/index": { + "component": "equipment/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "equipment": [], + "parts": [], + "links": [], + "levels": [], + "items": [], + "departments": [], + "history": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/equipamentos?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/equipamentos?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/equipamentos?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/equipamentos", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "canManage": false, + "canConfigure": false + }, + "url": "/equipamentos", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "planning/index": { + "component": "planning/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "items": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/planeamento?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/planeamento?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/planeamento?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/planeamento", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "milestones": [], + "history": [], + "canManage": false, + "today": "2026-10-30", + "departments": [], + "users": [], + "requisitions": [] + }, + "url": "/planeamento", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "people/index": { + "component": "people/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "ready": true, + "kinds": ["staff", "service"], + "availableKinds": ["staff", "service"], + "filters": [], + "workdesk": null, + "insights": null, + "company": null, + "canPrepare": [], + "formFields": { + "staff": { + "department": "Departamento e reporte", + "location": "Local e modalidade de trabalho", + "responsibilities": "Responsabilidades e tarefas essenciais", + "methods": "Métodos de selecção e perguntas nucleares", + "accessibility": "Contacto e medidas de acessibilidade", + "integration": "Integração e formação previstas", + "financial": "Condições de emprego, remuneração e benefícios" + }, + "service": { + "scope": "Âmbito do serviço e exclusões", + "relationship": "Enquadramento da relação e validação prevista", + "methodology": "Proposta técnica e metodologia esperada", + "availability": "Disponibilidade e condições de execução", + "deliverables": "Entregáveis, marcos e prazos", + "acceptance": "Critérios de aceitação e responsável", + "financial": "Orçamento, honorários, despesas e condições de pagamento" + } + }, + "administration": [], + "processes": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/pessoas?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/pessoas?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/pessoas?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/pessoas", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + } + }, + "url": "/pessoas", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "budgets/index": { + "component": "budgets/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "centers": [], + "budgets": [], + "reinforcements": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/orcamentos?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/orcamentos?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/orcamentos?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/orcamentos", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "canManage": false + }, + "url": "/orcamentos", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "invoices/index": { + "component": "invoices/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "invoices": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/facturas?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/facturas?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/facturas?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/facturas", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "orders": [], + "canManage": false + }, + "url": "/facturas", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "payments/index": { + "component": "payments/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "invoices": [], + "payments": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/pagamentos?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/pagamentos?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/pagamentos?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/pagamentos", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "canManage": false + }, + "url": "/pagamentos", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "investment/index": { + "component": "investment/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "policies": [], + "reviews": [], + "currentPolicyId": null, + "canManage": false + }, + "url": "/regras-investimento", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "purchases/admission-policy": { + "component": "purchases/admission-policy", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "policies": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/politica-admissao?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/politica-admissao?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/politica-admissao?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/politica-admissao", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "latest": null + }, + "url": "/politica-admissao", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "purchases/recommendation-policy": { + "component": "purchases/recommendation-policy", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "policies": { + "current_page": 1, + "data": [], + "first_page_url": "http://localhost:8027/politica-recomendacao?page=1", + "from": null, + "last_page": 1, + "last_page_url": "http://localhost:8027/politica-recomendacao?page=1", + "links": [ + { + "url": null, + "label": "« Previous", + "page": null, + "active": false + }, + { + "url": "http://localhost:8027/politica-recomendacao?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "Next »", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "http://localhost:8027/politica-recomendacao", + "per_page": 15, + "prev_page_url": null, + "to": null, + "total": 0 + }, + "latest": null + }, + "url": "/politica-recomendacao", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "purchase-controls/index": { + "component": "purchase-controls/index", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + }, + "role": "", + "permissions": [], + "actorId": null, + "policies": [], + "reviews": [], + "awards": [], + "cancellations": [], + "sources": [] + }, + "url": "/revisao-compras", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + }, + "settings/appearance": { + "component": "settings/appearance", + "props": { + "errors": {}, + "name": "SGC", + "canRequest": false, + "auth": { + "user": null + }, + "organizationAccess": null, + "payablesAvailable": false, + "sidebarOpen": true, + "publicNavigation": { + "paths": [ + "/dashboard", + "/requisicoes", + "/requisicoes/criar", + "/fornecedores", + "/cotacoes", + "/ordens", + "/recepcoes", + "/stock", + "/stock/catalogo", + "/stock/operacoes", + "/equipamentos", + "/planeamento", + "/pessoas", + "/orcamentos", + "/facturas", + "/pagamentos", + "/regras-investimento", + "/politica-admissao", + "/politica-recomendacao", + "/revisao-compras", + "/settings/appearance" + ], + "dataScope": "none" + } + }, + "url": "/settings/appearance", + "version": "", + "sharedProps": [ + "errors", + "name", + "canRequest", + "auth", + "organizationAccess", + "payablesAvailable", + "sidebarOpen" + ] + } +} diff --git a/tests/frontend/candidate-portal.test.tsx b/tests/frontend/candidate-portal.test.tsx index 789e159..117f355 100644 --- a/tests/frontend/candidate-portal.test.tsx +++ b/tests/frontend/candidate-portal.test.tsx @@ -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( + , + ); + expect(html).toContain('Localidade A'); + expect(html).toContain('Localidade B'); + expect( + renderToString(), + ).toContain('Localidade A'); + expect(renderToString()).toBe(''); +}); test.each([true, false])( 'registration availability (%s) preserves existing login and does not promise submission', (registration_open) => { diff --git a/tests/frontend/candidate-submissions.test.tsx b/tests/frontend/candidate-submissions.test.tsx index 7f7dcfc..59b3670 100644 --- a/tests/frontend/candidate-submissions.test.tsx +++ b/tests/frontend/candidate-submissions.test.tsx @@ -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( + , + ); + 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( + , + ), + ).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); }, diff --git a/tests/frontend/public-navigation.test.tsx b/tests/frontend/public-navigation.test.tsx index f5092c0..ff42d21 100644 --- a/tests/frontend/public-navigation.test.tsx +++ b/tests/frontend/public-navigation.test.tsx @@ -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;