fix: validate dossier scope and accurate directory MFA status

This commit is contained in:
2026-10-29 22:42:22 +02:00
parent 4f72751b8b
commit fb9a363e9e
7 changed files with 135 additions and 5 deletions
@@ -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']),
+1 -1
View File
@@ -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'];
@@ -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.
@@ -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"
}
}
@@ -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.
@@ -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']))));
}
}
@@ -0,0 +1,39 @@
<?php
namespace Tests\Unit;
use App\Services\InvestmentConsumerScope;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class InvestmentConsumerScopeTest extends TestCase
{
public function test_an_explicit_opportunity_cannot_be_silently_discarded_when_the_dossier_has_no_choices(): void
{
$context = ['fields' => ['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');
}
}