From fb9a363e9ec2ea6a4171f818f7066524001f0d2b Mon Sep 17 00:00:00 2001 From: nala Date: Thu, 29 Oct 2026 22:42:22 +0200 Subject: [PATCH] fix: validate dossier scope and accurate directory MFA status --- .../Controllers/PortalProfileController.php | 10 +-- app/Services/InvestmentConsumerScope.php | 2 +- .../consolidated-review-matrix-2026-10-03.md | 9 +++ .../local-validation-20261029.json | 67 +++++++++++++++++++ .../unification/traceability-and-manual.md | 9 +++ tests/Feature/OpportunityPromotionTest.php | 4 ++ tests/Unit/InvestmentConsumerScopeTest.php | 39 +++++++++++ 7 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/InvestmentConsumerScopeTest.php diff --git a/app/Http/Controllers/PortalProfileController.php b/app/Http/Controllers/PortalProfileController.php index 7cb1fc5b..38d4ea00 100644 --- a/app/Http/Controllers/PortalProfileController.php +++ b/app/Http/Controllers/PortalProfileController.php @@ -52,10 +52,12 @@ class PortalProfileController extends Controller return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(), 'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(), 'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(), - 'staffAccounts' => User::query()->orderBy('name')->get(['id', 'name', 'email', 'email_verified_at', 'two_factor_confirmed_at'])->map(fn (User $user) => [ - 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, - 'verified' => $user->email_verified_at !== null, 'mfa' => $user->two_factor_confirmed_at !== null, - ]), + 'staffAccounts' => User::query()->select(['id', 'name', 'email', 'email_verified_at']) + ->selectRaw('CASE WHEN two_factor_secret IS NOT NULL AND two_factor_confirmed_at IS NOT NULL THEN 1 ELSE 0 END AS mfa_enrolled') + ->withCasts(['mfa_enrolled' => 'boolean'])->orderBy('name')->get()->map(fn (User $user) => [ + 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, + 'verified' => $user->email_verified_at !== null, 'mfa' => $user->getAttribute('mfa_enrolled'), + ]), 'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES, 'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES, 'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']), diff --git a/app/Services/InvestmentConsumerScope.php b/app/Services/InvestmentConsumerScope.php index 14a0c270..297f4b0d 100644 --- a/app/Services/InvestmentConsumerScope.php +++ b/app/Services/InvestmentConsumerScope.php @@ -53,10 +53,10 @@ final class InvestmentConsumerScope public function context(array $context, ?string $key): array { $choices = $context['opportunity_choices'] ?? []; + $key = $this->key($context, $key); if (! $choices) { return $context; } - $key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null); $choice = $key ? collect(StructuredData::collectionItems($choices))->firstWhere('opportunity.key', $key) : null; abort_if($key && ! $choice, 422, 'Escolha uma oportunidade deste dossier.'); $specific = ['project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'special_zone_id', 'investment_regime']; diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index dd723f84..f3d738cd 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -2101,3 +2101,12 @@ Backup fresco consistente de `teste_investidor:5432`, criado em 29/10/2026 às 2 **IVA herdado conferido contra o instalado:** os quatro ficheiros de runtime instalados coincidem por SHA-256 com `main` e ainda não contêm o incremento `d1551dab`. O âmbito de entrega é a correspondência 05/06/07, pertença exclusiva das operações, contagem única no campo14 e reutilização actual dos campos01/02. Não cria tratamento positivo06, não infere estrangeiro=06 nem transforma desconhecido em zero. Provas IVA/P001 aceites reutilizadas, com preservação de histórico, snapshots e edições deliberadas. **Continua pendente a conferência visual dirigida da candidata**, pela equipa, com registo nesta matriz. O conector permanece sem browser acessível. Não houve avanço de main, troca de release, importações ou alteração de permissões. Após o resultado visual, reconferir que o destino e a base de preservação continuam actuais antes do avanço normal e do pipeline existente; preservar quaisquer alterações legítimas entretanto ocorridas. As provas automáticas de 798 testes PHP e1190 frontend mantêm-se aceites, sem repetição integral. Detalhes no campo `git_delivery.authenticated_preparation` do recibo existente. + + +### 29/10/2026 — revisão dirigida de erros e inconsistências após a preparação + +Corrigidos dois defeitos sobre `4f72751b`: uma oportunidade explícita fora de um dossier sem escolhas era descartada silenciosamente, passando a contexto geral; agora é recusada com HTTP422. O directório de contas assinalava MFA apenas pela data de confirmação; agora exige também segredo existente e projecta apenas o booleano, sem carregar nem divulgar o segredo. Não foram alteradas permissões nem dados de contas. + +Ambos reproduzidos antes da correcção. Passaram 3 testes Unit novos (5 asserções) e 20 testes Feature existentes dirigidos (461 asserções), incluindo os estados MFA incompletos. PHPStan e Pint passaram no âmbito registado no recibo. Reutilizadas as provas históricas de 798 PHP e 1190 frontend: não se afirma uma nova execução integral. Conferidos os 32 ficheiros de provas anteriores, sem divergências; contrato de migrações mantém-se compatível, sem novo delta de schema. + +Diagnóstico autenticado apenas de leitura às 20:40:29 UTC: destino continua em `fde73438`, nenhum erro Laravel datado de hoje no conjunto limitado examinado (até quatro logs recentes, últimos 2MiB por ficheiro), nenhum índice não sistémico inválido e nenhuma conta com confirmação MFA sem segredo em `teste_investidor:5432`. Não é uma auditoria histórica completa. Nenhuma instalação ou escrita na base activa. Conferência visual dirigida da candidata continua pendente; incluir estes dois comportamentos nos percursos já afectados. Evidências e hashes do novo delta no campo `targeted_error_consistency_review` do recibo existente. diff --git a/docs/investmoz/unification/local-validation-20261029.json b/docs/investmoz/unification/local-validation-20261029.json index 2c32a790..b8edb94f 100644 --- a/docs/investmoz/unification/local-validation-20261029.json +++ b/docs/investmoz/unification/local-validation-20261029.json @@ -1115,5 +1115,72 @@ "portal_release_switch": false, "visual_acceptance": "pending_team_execution_and_record" } + }, + "targeted_error_consistency_review": { + "recorded_at": "2026-10-29T20:42:11.713173+00:00", + "base_commit": "4f72751b8bd423c0240d776331d7561d2dcd154a", + "corrections": [ + "Reject an explicit opportunity outside an empty dossier with HTTP 422 instead of silently falling back to general context.", + "Staff directory MFA flag requires both an existing secret and confirmation; SQL returns only a boolean, never the secret." + ], + "new_unit_tests": { + "passed": 3, + "assertions": 5 + }, + "directed_existing_feature_tests": { + "passed": 20, + "assertions": 461 + }, + "regressions_reproduced_before_fixes": true, + "phpstan": "passed; full after context fix and both modified app files after MFA fix", + "pint": "passed; four changed PHP files", + "historical_baseline": "798 PHP and 1190 frontend accepted; not rerun in full; totals above retain their historical scope", + "previous_evidence_integrity": { + "files_checked": 32, + "mismatches": 0 + }, + "migration_contract_mismatches": 0, + "source_sha256": { + "app/Services/InvestmentConsumerScope.php": "b63dee74f0720c197e4a2cc8d1ab64b4ef99d6cea7b1af9b5a6b94aa3fc88719", + "app/Http/Controllers/PortalProfileController.php": "7a74c1f5ccda85dc704ea067fa4dbd810eb339d1b4a6bb9fd9ca6f73a79d55ab", + "tests/Unit/InvestmentConsumerScopeTest.php": "554f86f0b7995374d84b10221245aace8b6b8601a5973a998242c4a33ce488d7", + "tests/Feature/OpportunityPromotionTest.php": "1e6b60659b3fa098fcdf8fd8aa04f1b09b4388803e5ddccc0ac950caa0da4011" + }, + "evidence": { + "outputs/validation-20261029/context-scope-red.log": "546b28c44ad8fa9119fafb23947ebf760d704846633cc8cde73759dedee97a1b", + "outputs/validation-20261029/context-scope-green.log": "cb0dd1ffbe67bee19810d4625a8540636ccdbb137ce3ebbc9ddfea514d34cc9c", + "outputs/validation-20261029/context-scope-feature.log": "b17ceb050ea46618024c55982ec8dd02ea5f64412242a242f03fa6675c322dc3", + "outputs/validation-20261029/context-scope-phpstan.log": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2", + "outputs/validation-20261029/directory-mfa-red.log": "1032e758116c7e465ce122e0ad59772bd30671f9b559d750f382fef41222a8a8", + "outputs/validation-20261029/directory-mfa-green.log": "855c01c17bb4b28d54445f469aa5504e757fc9d24c8754771aa7bf554c55ee3f", + "outputs/validation-20261029/targeted-audit-phpstan.log": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2", + "outputs/validation-20261029/targeted-audit-pint.log": "cd1a94fc2cf6a965b86e1a4809d6c7fb9148b1ee374e1010ed2ac96ff4876ec2", + "outputs/validation-20261029/error-consistency-readonly-result.json": "e68d977ba6520e98f4e56ee75c2682c44691ca7e6cef60db48e52e4d95e0815c" + }, + "server_readonly_diagnostic": { + "checked_at": "2026-10-29T20:40:29.998407+00:00", + "installed_commit": "fde7343894964043fa633e3d4d07385a6cb7e039", + "log_scope": "At most the last 2 MiB of each of the four most recently modified Laravel logs; only error-level entries dated 2026-10-29. This is not a complete historical audit.", + "logs": [ + { + "file": "laravel.log", + "total_bytes": 166123, + "bytes_examined_at_most": 2097152, + "matching_day_error_entries": 0, + "last_matching_timestamp": null + } + ], + "error_categories": [], + "database_consistency": { + "database": "teste_investidor", + "port": 5432, + "accounts_with_confirmed_date_but_missing_MFA_secret": 0, + "invalid_non_system_indexes": 0 + }, + "personal_data_or_secret_values_returned": false, + "active_database_writes": 0, + "deployment_triggered": false + }, + "installation": "not triggered; directed candidate visual acceptance remains pending" } } diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 17fca804..1893743b 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -7638,3 +7638,12 @@ Backup fresco consistente de `teste_investidor:5432`, criado em 29/10/2026 às 2 **IVA herdado conferido contra o instalado:** os quatro ficheiros de runtime instalados coincidem por SHA-256 com `main` e ainda não contêm o incremento `d1551dab`. O âmbito de entrega é a correspondência 05/06/07, pertença exclusiva das operações, contagem única no campo14 e reutilização actual dos campos01/02. Não cria tratamento positivo06, não infere estrangeiro=06 nem transforma desconhecido em zero. Provas IVA/P001 aceites reutilizadas, com preservação de histórico, snapshots e edições deliberadas. **Continua pendente a conferência visual dirigida da candidata**, pela equipa, com registo nesta matriz. O conector permanece sem browser acessível. Não houve avanço de main, troca de release, importações ou alteração de permissões. Após o resultado visual, reconferir que o destino e a base de preservação continuam actuais antes do avanço normal e do pipeline existente; preservar quaisquer alterações legítimas entretanto ocorridas. As provas automáticas de 798 testes PHP e1190 frontend mantêm-se aceites, sem repetição integral. Detalhes no campo `git_delivery.authenticated_preparation` do recibo existente. + + +### 29/10/2026 — revisão dirigida de erros e inconsistências após a preparação + +Corrigidos dois defeitos sobre `4f72751b`: uma oportunidade explícita fora de um dossier sem escolhas era descartada silenciosamente, passando a contexto geral; agora é recusada com HTTP422. O directório de contas assinalava MFA apenas pela data de confirmação; agora exige também segredo existente e projecta apenas o booleano, sem carregar nem divulgar o segredo. Não foram alteradas permissões nem dados de contas. + +Ambos reproduzidos antes da correcção. Passaram 3 testes Unit novos (5 asserções) e 20 testes Feature existentes dirigidos (461 asserções), incluindo os estados MFA incompletos. PHPStan e Pint passaram no âmbito registado no recibo. Reutilizadas as provas históricas de 798 PHP e 1190 frontend: não se afirma uma nova execução integral. Conferidos os 32 ficheiros de provas anteriores, sem divergências; contrato de migrações mantém-se compatível, sem novo delta de schema. + +Diagnóstico autenticado apenas de leitura às 20:40:29 UTC: destino continua em `fde73438`, nenhum erro Laravel datado de hoje no conjunto limitado examinado (até quatro logs recentes, últimos 2MiB por ficheiro), nenhum índice não sistémico inválido e nenhuma conta com confirmação MFA sem segredo em `teste_investidor:5432`. Não é uma auditoria histórica completa. Nenhuma instalação ou escrita na base activa. Conferência visual dirigida da candidata continua pendente; incluir estes dois comportamentos nos percursos já afectados. Evidências e hashes do novo delta no campo `targeted_error_consistency_review` do recibo existente. diff --git a/tests/Feature/OpportunityPromotionTest.php b/tests/Feature/OpportunityPromotionTest.php index 8f9e5bd8..cc3e4681 100644 --- a/tests/Feature/OpportunityPromotionTest.php +++ b/tests/Feature/OpportunityPromotionTest.php @@ -190,9 +190,13 @@ final class OpportunityPromotionTest extends TestCase public function test_profile_directory_uses_existing_accounts_without_exposing_authentication_secrets(): void { $other = User::factory()->create(); + $missingSecret = User::factory()->create(['two_factor_secret' => null, 'two_factor_confirmed_at' => now()]); + $unconfirmed = User::factory()->create(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => null]); DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]); $this->get('/perfis/gestao')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page ->has('staffAccounts')->where('staffAccounts', fn ($rows) => collect($rows)->contains(fn ($row) => $row['id'] === $this->actor->id && $row['email'] === $this->actor->email && $row['verified'] && $row['mfa']) + && collect($rows)->contains(fn ($row) => $row['id'] === $missingSecret->id && $row['mfa'] === false) + && collect($rows)->contains(fn ($row) => $row['id'] === $unconfirmed->id && $row['mfa'] === false) && collect($rows)->every(fn ($row) => ! isset($row['password']) && ! isset($row['two_factor_secret'])))); } } diff --git a/tests/Unit/InvestmentConsumerScopeTest.php b/tests/Unit/InvestmentConsumerScopeTest.php new file mode 100644 index 00000000..df650561 --- /dev/null +++ b/tests/Unit/InvestmentConsumerScopeTest.php @@ -0,0 +1,39 @@ + ['project_title' => 'Synthetic QA dossier'], 'selection' => [], 'opportunity_choices' => []]; + + try { + app(InvestmentConsumerScope::class)->context($context, 'opportunity:99999999'); + $this->fail('An explicit opportunity outside the dossier must be rejected instead of becoming general context.'); + } catch (HttpException $error) { + $this->assertSame(422, $error->getStatusCode()); + $this->assertSame([], $context['opportunity_choices']); + $this->assertSame('Synthetic QA dossier', $context['fields']['project_title']); + } + } + + public function test_general_context_without_an_opportunity_preserves_an_empty_dossier(): void + { + $context = ['fields' => ['project_title' => 'Synthetic QA dossier'], 'selection' => []]; + + $this->assertSame($context, app(InvestmentConsumerScope::class)->context($context, null)); + } + + public function test_an_explicit_opportunity_outside_existing_choices_is_rejected(): void + { + $context = ['fields' => [], 'selection' => [], 'opportunity_choices' => [['opportunity' => ['key' => 'opportunity:1']]]]; + + $this->expectException(HttpException::class); + app(InvestmentConsumerScope::class)->context($context, 'opportunity:2'); + } +}