From f5f5649cfc9f8c9d9c81c98fbac26170120da7dd Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 30 Oct 2026 21:18:53 +0200 Subject: [PATCH] Retomar a oportunidade escolhida apos a entrada do candidato --- .../Controllers/CandidatePortalController.php | 17 +++++++- docs/desenvolvimento.md | 6 +++ .../candidate-opportunity-login-pg.json | 16 +++++++ docs/proofs/candidate-opportunity-login.json | 43 +++++++++++++++++++ resources/js/pages/candidate/entry.tsx | 20 ++++++++- resources/js/pages/candidate/profile.tsx | 7 +++ tests/Feature/CandidateSubmissionsTest.php | 27 ++++++++++++ tests/frontend/candidate-portal.test.tsx | 2 + 8 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 docs/proofs/candidate-opportunity-login-pg.json create mode 100644 docs/proofs/candidate-opportunity-login.json diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php index 9fa91a6..309e684 100644 --- a/app/Http/Controllers/CandidatePortalController.php +++ b/app/Http/Controllers/CandidatePortalController.php @@ -16,6 +16,7 @@ use App\Services\CurrentActor; use App\Services\OrganizationConfiguration; use App\Services\RequisitionWorkflow; use App\Services\SubmissionForms; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -24,6 +25,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CandidatePortalController extends Controller { @@ -69,7 +71,8 @@ class CandidatePortalController extends Controller public function login(Request $request, int $company, CandidateProfiles $profiles): Response { abort_unless($profiles->portal($company) !== null, 404); - $data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128']); + $data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128', + 'opportunity_id' => 'nullable|integer|min:1|max:2147483647']); $account = CandidateAccount::query()->where('tenant_id', $company)->where('email', mb_strtolower(trim($data['email'])))->first(); $dummy = Hash::make('candidate-unavailable-account'); $valid = Hash::check($data['password'], $account->password ?? $dummy); @@ -82,6 +85,18 @@ class CandidatePortalController extends Controller $request->session()->regenerate(); Inertia::clearHistory(); + if (isset($data['opportunity_id'])) { + try { + // Recheck current publication, company and deadline after authentication. + app(CandidateSubmissions::class)->opportunity($company, (int) $data['opportunity_id']); + + return redirect('/candidaturas/'.$company.'/oportunidades/'.$data['opportunity_id']); + } catch (NotFoundHttpException|ModelNotFoundException) { + // A withdrawn, expired or foreign opportunity cannot become an authenticated destination. + return redirect('/candidaturas/'.$company.'/perfil')->with('candidate_access_status', 'opportunity_unavailable'); + } + } + return redirect('/candidaturas/'.$company.'/perfil'); } diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index 4cdfcad..372c11a 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,11 @@ # Desenvolvimento do SGC +## Oportunidade escolhida preservada na entrada, 30 de Outubro de 2026 + +O botão de entrada de cada oportunidade guarda a escolha no formulário de login e apresenta o título escolhido. Após autenticação válida, o servidor volta a conferir publicação, empresa e prazo e encaminha para o formulário dessa oportunidade. Não aceita URLs de retorno fornecidos pelo cliente. Um identificador inexistente, de outra empresa, retirado ou expirado encaminha para o perfil com mensagem genérica de indisponibilidade. A entrada não envia a candidatura, não modifica o perfil e não dispensa confirmação de email ou requisitos documentais. Criar uma nova conta mantém o percurso de preparação do perfil anteriormente existente. + +Verificação dirigida final SQLite: 26 testes/400 asserções. PostgreSQL descartável: 66 testes/1426 asserções sobre os percursos de candidatos e Pessoas, incluindo os novos guardas de destino; a mensagem final de indisponibilidade foi adicionalmente conferida em SQLite e no frontend. Dez testes frontend, PHPStan sem erros, tipos, Pint e lint dirigidos. Sem novas migrações ou instalação no domínio; conferência visual continua pendente. Provas integrais da release bf84575 reutilizadas, sem repetição de toda a suíte. A compilação e prova final constam em `proofs/candidate-opportunity-login.json`. + ## Consulta de oportunidades antes do registo, 30 de Outubro de 2026 A entrada da área do candidato mostra oportunidades explicitamente publicadas, abertas e dentro do prazo, com título, tipo, data e descrição pública. Não expõe finalidade interna, regras, candidaturas, avaliações ou documentos. A consulta é paginada por empresa; a última versão da publicação controla a visibilidade, incluindo retirada. O fecho de novos registos mantém a consulta pública e a entrada das contas existentes. Preparar e enviar candidaturas continuam a exigir autenticação própria, perfil e confirmação de email conforme as regras já existentes. diff --git a/docs/proofs/candidate-opportunity-login-pg.json b/docs/proofs/candidate-opportunity-login-pg.json new file mode 100644 index 0000000..1578e65 --- /dev/null +++ b/docs/proofs/candidate-opportunity-login-pg.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-30T19:18:12+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": 66, + "assertions": 1426 +} diff --git a/docs/proofs/candidate-opportunity-login.json b/docs/proofs/candidate-opportunity-login.json new file mode 100644 index 0000000..ae4381f --- /dev/null +++ b/docs/proofs/candidate-opportunity-login.json @@ -0,0 +1,43 @@ +{ + "checked_at": "2026-10-30T19:18:47.034145+00:00", + "base_commit": "c22846b", + "scope": [ + "selected public opportunity carried into candidate login", + "fixed server-generated redirect after authentication", + "company publication deadline and unknown-id recheck", + "profile fallback with generic unavailable notice", + "no automatic submission or registration flow changes" + ], + "module_sqlite": { + "tests": 26, + "assertions": 400, + "passed": true + }, + "postgresql": { + "tests": 66, + "assertions": 1426, + "passed": true, + "disposable_database_removed": true, + "final_notice_additionally_checked_in_sqlite_and_frontend": true + }, + "frontend": { + "tests": 10, + "status": "passed" + }, + "phpstan_errors": 0, + "typescript": true, + "pint": true, + "lint": true, + "build": { + "exit_code": 0, + "completed": true + }, + "new_migrations": false, + "production_installed": false, + "visual_review": "pending; unavailable connector", + "source_sha256": { + "app/Http/Controllers/CandidatePortalController.php": "7866cb1c29a85761906637e3caa15f334ee67de8c212ccf4c31f07d021c61b1b", + "resources/js/pages/candidate/entry.tsx": "3c5cae01a0ebd5c2fd65569be5d2e039cb8c3e92e784497bfe8df468bdf13bab", + "resources/js/pages/candidate/profile.tsx": "b13aea00c82e7e35a955d598a65b5b01b3725d50a39a4b38a0b10c4ac6ef6dd2" + } +} diff --git a/resources/js/pages/candidate/entry.tsx b/resources/js/pages/candidate/entry.tsx index f4b069e..66212f4 100644 --- a/resources/js/pages/candidate/entry.tsx +++ b/resources/js/pages/candidate/entry.tsx @@ -21,7 +21,11 @@ export default function Entry({ next_page_url: string | null; }; }) { - const login = useForm({ email: '', password: '' }); + const login = useForm({ + email: '', + password: '', + opportunity_id: null as number | null, + }); const register = useForm({ email: '', password: '', @@ -110,6 +114,9 @@ export default function Entry({ + login.setData('opportunity_id', item.id) + } > Entrar para preparar a candidatura @@ -138,6 +145,17 @@ export default function Entry({
+ {login.data.opportunity_id !== null && ( +

+ Oportunidade escolhida:{' '} + { + opportunities.data.find( + (item) => + item.id === login.data.opportunity_id, + )?.title + } +

+ )}

Entrar

)} + {accessStatus === 'opportunity_unavailable' && ( +

+ Entrou na sua conta, mas a oportunidade escolhida já não + está disponível. Consulte as oportunidades publicadas + acima. +

+ )} { diff --git a/tests/Feature/CandidateSubmissionsTest.php b/tests/Feature/CandidateSubmissionsTest.php index ec7a9b5..49aaaef 100644 --- a/tests/Feature/CandidateSubmissionsTest.php +++ b/tests/Feature/CandidateSubmissionsTest.php @@ -82,6 +82,33 @@ test('public opportunity listing is paginated within the company and remains vis $this->getJson($this->base.'?opportunities_page=invalid')->assertUnprocessable(); }); +test('candidate login opens the selected published opportunity without accepting an arbitrary redirect', function () { + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id, 'return_to' => 'https://example.invalid/untrusted']) + ->assertRedirect($this->base.'/oportunidades/'.$this->process->id); + $this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.id', $this->process->id)); + $this->postJson($this->base.'/entrar', [...$this->credentials, 'return_to' => 'https://example.invalid/untrusted'])->assertRedirect($this->base.'/perfil'); + $other = Tenant::query()->create(['name' => 'Outra empresa do ensaio de destino']); + $otherAdmin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]); + $foreignProcess = (array) DB::table('people_processes')->where('id', $this->process->id)->first(); + $foreignPublication = (array) DB::table('candidate_opportunity_publications')->where('people_process_id', $this->process->id)->first(); + unset($foreignProcess['id'], $foreignPublication['id']); + $foreignId = DB::table('people_processes')->insertGetId([...$foreignProcess, 'tenant_id' => $other->id, 'created_by' => $otherAdmin->id, 'operation_key' => (string) Str::uuid()]); + DB::table('candidate_opportunity_publications')->insert([...$foreignPublication, 'tenant_id' => $other->id, 'people_process_id' => $foreignId, 'created_by' => $otherAdmin->id]); + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $foreignId])->assertRedirect($this->base.'/perfil'); + expect(DB::table('candidate_submissions')->count())->toBe(0); +}); + +test('login rechecks selected publication and deadline and falls back to the profile for stale destinations', function () { + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => 'https://example.invalid/untrusted'])->assertUnprocessable(); + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => 2147483647])->assertRedirect($this->base.'/perfil')->assertSessionHas('candidate_access_status', 'opportunity_unavailable'); + app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]); + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil'); + app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 2]); + $this->travel(8)->days(); + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil'); + expect(DB::table('candidate_submissions')->count())->toBe(0); +}); + test('electronic submission returns a scoped receipt and preserves the actual profile and answers', function () { $this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.description', 'Descrição pública fictícia da oportunidade.')->missing('opportunity.purpose')->missing('opportunity.selection')); $url = $this->base.'/oportunidades/'.$this->process->id; diff --git a/tests/frontend/candidate-portal.test.tsx b/tests/frontend/candidate-portal.test.tsx index 7537d91..4b339ab 100644 --- a/tests/frontend/candidate-portal.test.tsx +++ b/tests/frontend/candidate-portal.test.tsx @@ -108,6 +108,7 @@ test('candidate profile renders geography and retained version without internal ...page.props, companyId: 1, email: 'test@example.invalid', + accessStatus: 'opportunity_unavailable', profile: { version: 3, data: { @@ -130,6 +131,7 @@ test('candidate profile renders geography and retained version without internal expect(html).toContain('value="Distrito fictício"'); expect(html).toContain('value="Localidade fictícia"'); expect(html).toContain('Guardar nova versão do perfil'); + expect(html).toContain('a oportunidade escolhida já não está disponível'); expect(html).not.toContain('Aprovar'); expect(html).not.toContain('/pessoas'); });