fix: restore shared map access and publish accepted administrative boundary projection
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-24 10:47:13 +02:00
parent c1f4a71963
commit 066707ece6
7 changed files with 3883 additions and 9 deletions
@@ -19,12 +19,21 @@ class InvestmentTerritoryController extends Controller
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
$profile = $request->is('minhas-oportunidades/*');
$management = $request->is('management/*') || $profile;
$actor = $request->user();
$access = app(PortalProfileAccess::class);
// The general consumers use this same endpoint while authenticated.
// Reuse the read access already granted by the private cadastral routes.
$profileAccess = $actor && $actor->hasVerifiedEmail()
&& ($access->grants($actor) !== [] || $access->superuser($actor));
$internal = $profileAccess || ($actor?->hasVerifiedEmail() && $actor->can('manage-portal'));
if ($management) {
$access = app(PortalProfileAccess::class);
abort_unless($profile ? ($access->grants($request->user()) !== [] || $access->superuser($request->user())) : $request->user()?->can('manage-portal'), 403);
abort_unless($profile ? $profileAccess : $internal, 403);
}
return response()->json(app(TerritoryGeometry::class)->context($data['territory_id'], $management));
return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store');
}
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
@@ -14,12 +14,13 @@ final class EnsureScopedPortalAdministration
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
abort_unless($actor !== null, 403);
$scope = app(ScopedPortalAdministration::class);
$path = $request->path();
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
$catalogue = $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
$geometryRead = $request->isMethod('GET') && $path === 'management/territory-geometries';
$catalogue = $geometryRead || $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use RuntimeException;
/** Public read projection authorized by Tempo; source rows and originals stay private. */
class ApprovedAdministrativeBoundaries
{
public const HASH = '06ab905fcf9b19115c3521bf81de03f37873880f7765f229b9e1f79d99df7adc';
/** @var array<string,array<string,string>>|null */
private ?array $items = null;
/** @return array<string,array<string,string>> */
public function snapshots(): array
{
if ($this->items !== null) {
return $this->items;
}
$contents = file_get_contents(base_path('docs/investmoz/unification/approved-administrative-boundaries-20261024.json'));
if ($contents === false || ! hash_equals(self::HASH, hash('sha256', str_replace("\r\n", "\n", $contents)))) {
throw new RuntimeException('Approved administrative boundary manifest changed.');
}
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
if (count($manifest['items']) !== 408) {
throw new RuntimeException('Exact 408-boundary scope required.');
}
$this->items = array_column($manifest['items'], null, 'id');
return $this->items;
}
public function allows(string $id, string $territoryId, string $geometryHash, string $propertiesHash): bool
{
$item = $this->snapshots()[$id] ?? null;
return $item !== null && $item['territory_id'] === $territoryId
&& hash_equals($item['geometry_sha256'], $geometryHash)
&& hash_equals($item['properties_sha256'], $propertiesHash);
}
}
+19 -4
View File
@@ -14,16 +14,31 @@ final class TerritoryGeometry
abort_unless($geography->references($management)->where('id', $id)->exists(), 404);
$path = $geography->path($id, $management);
$ids = array_map(fn ($node) => $node->id, $path);
$approved = app(ApprovedAdministrativeBoundaries::class);
$rows = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id')
->whereIn('g.geo_location_id', $ids)->whereNull('g.deleted_at')->whereNull('l.deleted_at')
->whereIn('g.data_classification', $management ? ['internal', 'public'] : ['public'])
->where(function ($query) use ($management, $approved) {
$query->where('g.data_classification', 'public');
$query->orWhere(function ($internal) use ($management, $approved) {
$internal->where('g.data_classification', 'internal');
if (! $management) {
$internal->whereIn('g.id', array_keys($approved->snapshots()));
}
});
})
->whereRaw("g.properties->>'purpose' = 'administrative_boundary'")
->whereIn('g.geometry_type', ['polygon', 'multipolygon'])
->whereRaw('ST_IsValid(g.geom) AND NOT ST_IsEmpty(g.geom) AND ST_SRID(g.geom)=4326')
->orderBy('g.geo_location_id')->orderBy('g.id')->limit(101)
->get(['g.id', 'g.geo_location_id', 'l.name', 'l.location_type', 'g.properties', 'g.data_classification',
DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) as geometry')]);
$areas = $rows->take(100)->map(function ($row) use ($id) {
DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) as geometry'),
DB::raw("encode(sha256(ST_AsEWKB(g.geom)),'hex') as geometry_sha256"),
DB::raw("encode(sha256(convert_to(g.properties::text,'UTF8')),'hex') as properties_sha256")]);
if (! $management) {
$rows = $rows->filter(fn ($row) => $row->data_classification === 'public'
|| $approved->allows($row->id, $row->geo_location_id, $row->geometry_sha256, $row->properties_sha256))->values();
}
$areas = $rows->take(100)->map(function ($row) use ($id, $management) {
$source = json_decode($row->properties, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'territory_id' => $row->geo_location_id, 'location_type' => $row->location_type,
@@ -31,7 +46,7 @@ final class TerritoryGeometry
'title' => 'Limite administrativo: '.$row->name, 'purpose' => 'administrative_boundary',
'source_reference' => $source['authority'] ?? null, 'source_date' => $source['source_version'] ?? null,
'source_url' => $source['source_url'] ?? null, 'source_sha256' => $source['source_sha256'] ?? null,
'data_classification' => $row->data_classification, 'legal_currentness' => $source['legal_currentness'] ?? 'not_determined', 'url' => ''];
'data_classification' => $management ? $row->data_classification : 'public', 'legal_currentness' => $source['legal_currentness'] ?? 'not_determined', 'url' => ''];
})->all();
return ['territory_id' => $id, 'territory_registered' => true,
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
# Rastreabilidade, utilização e formação
## Mapas — estabilização comum e divulgação delimitada, 24/10/2026
Diagnóstico por leitura autenticada na homologação `cf2ddf21`: **29 fichas, 28 relações territoriais e 408 geometrias válidas**, sem eliminação. Os 408 limites INE/2019 conservam classificação interna, propósito administrativo e SRID 4326. O fingerprint integral das linhas é `7d8b71d3e65d89ffdc43c748b0572dbc`. Não há RLS nas tabelas comparadas. O serviço comum não mudou desde `30aed7cd`: os consumidores gerais pediam exclusivamente geometrias públicas; o cadastro usava ainda `manage-portal`, ausente nos dois superutilizadores aprovados. A prova anterior demonstrava o cadastro privado, não a divulgação pública. O mapa base, tiles e assets carregam; a resposta territorial vazia explica a ausência de contornos e de enquadramento do território escolhido.
**Decisão expressa da Tempo nesta passagem:** «Sim, disponibilizar os contornos administrativos aceites». Implementada uma projecção pública de leitura dos [408 snapshots exactos](approved-administrative-boundaries-20261024.json), conferindo identidade e hashes da geometria original e metadados. Não há reimportação, reclassificação de linhas, publicação de parcelas, acesso aos ficheiros originais ou abertura de outras camadas internas. Alterações posteriores não recebem divulgação automática. Os perfis autorizados reutilizam o acesso privado já aprovado; a leitura administrativa conserva MFA. Respostas privadas não são guardadas em cache partilhada.
QA isolado `simulator_fiscal_delta_20261021_test:55441`: seis testes dirigidos, 64 asserções; selecção, gravação/recuperação no dossier e prefill do simulador com a mesma identidade, leitura pública autorizada, bloqueio de camadas não autorizadas e de snapshots alterados, permissões/MFA e fingerprint original preservado. PHPStan dos quatro ficheiros funcionais passou. Backup fresco: 174 tabelas, 369 ficheiros conferidos; restauro isolado sem rede reproduziu as 174 tabelas. A integração e conferência do delta instalado são registadas no recibo desta passagem; este parágrafo não antecipa a entrega.
Preservados os fechos `d8224ba8`, `55136b44`, `8036b9b0` e `cf2ddf21`/`c1f4a719`, sem repetição das provas aceites. Localidades sem polígono próprio conservam apenas o contexto ancestral explicitamente identificado; não são inventadas coordenadas ou parcelas. Estatísticas e IA permanecem adiadas. Esta estabilização não declara concluída a aplicação transversal nem substitui a avaliação de navegação e apresentação pela Tempo.
## Projectos âncora — entregues na homologação em cf2ddf21, 23/10/2026
Reconciliado o saldo de `6985f82e` com as provas territoriais de 7 de Outubro e os recibos de reposição. A leitura autenticada da homologação, com o papel da aplicação e transacção apenas de leitura, confirmou **29 fichas publicadas, 13 relações de clusters e zero ligações em `project_locations`**. As cinco tabelas comparadas não usam RLS. Os projectos transportados têm identificadores diferentes dos originais e perderam as 28 ligações territoriais e os respectivos metadados de citação; não desapareceram as 29 fichas. A reposição de visibilidade anterior não abrangia estas relações. A comparação delimita a omissão no transporte; não identifica por si só o instante em que ocorreu.
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\ApprovedAdministrativeBoundaries;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class TerritoryGeometryConsumerAccessTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private string $territory;
protected function setUp(): void
{
parent::setUp();
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->withoutVite();
$this->withoutMiddleware(ThrottleRequests::class);
config(['portal.profile_access_enabled' => true, 'portal.managers' => [], 'investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()]);
$other = User::factory()->create();
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
$this->territory = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id')
->where('g.source_system', 'ine-cod-ab')->where('g.data_classification', 'internal')->where('l.location_type', 'province')->value('l.id');
$this->assertNotEmpty($this->territory);
}
public function test_general_consumer_uses_existing_authenticated_read_access_without_the_legacy_gate(): void
{
$this->assertFalse($this->actor->can('manage-portal'));
$before = $this->fingerprint();
$this->actingAs($this->actor)->getJson('/api/v1/investment/territory-geometries?territory_id='.$this->territory)
->assertOk()->assertJsonPath('selected_geometry_available', true)->assertJsonPath('geometry_scope', 'authorized_internal')
->assertJsonPath('areas.0.territory_id', $this->territory)->assertHeader('Cache-Control', 'no-store, private');
$this->assertSame($before, $this->fingerprint());
}
public function test_existing_administration_reaches_the_same_geometry_with_its_completed_mfa_session(): void
{
$this->actingAs($this->actor)->withSession(['portal.mfa_actor' => $this->actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $this->actor->portal_access_revision]);
$this->getJson('/management/territory-geometries?territory_id='.$this->territory)->assertOk()->assertJsonPath('selected_geometry_available', true);
session()->forget(['portal.mfa_actor', 'portal.mfa_at']);
$this->getJson('/management/territory-geometries?territory_id='.$this->territory)->assertForbidden();
}
public function test_anonymous_and_ordinary_accounts_do_not_receive_or_publish_internal_layers(): void
{
$before = $this->fingerprint();
$url = '/api/v1/investment/territory-geometries?territory_id='.$this->territory;
$this->getJson($url)->assertOk()->assertJsonPath('geometry_scope', 'public')->assertJsonPath('selected_geometry_available', false)->assertJsonCount(0, 'areas');
$this->actingAs(User::factory()->create())->getJson($url)->assertOk()->assertJsonPath('geometry_scope', 'public')->assertJsonCount(0, 'areas');
$this->getJson('/management/territory-geometries?territory_id='.$this->territory)->assertForbidden();
$this->assertSame($before, $this->fingerprint());
}
public function test_saved_territory_is_recovered_in_dossier_and_simulator_and_the_map_uses_that_identity(): void
{
$this->actingAs($this->actor);
$id = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA - mapa recuperado', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$body = ['version' => $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'), 'selection' => ['territory' => 'territory:'.$this->territory]];
$preview = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $preview['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->assertSame($this->territory, $view['context']['selection']['territory']['id']);
$this->assertSame($this->territory, $view['prefill']['simulator']['province_id']);
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$view['prefill']['simulator']['province_id'])
->assertOk()->assertJsonPath('selected_geometry_available', true)->assertJsonPath('areas.0.selected_territory', true);
$this->assertSame($this->territory, $this->getJson('/investimentos/'.$id.'/contexto')->json('context.selection.territory.id'));
}
public function test_only_the_approved_unchanged_snapshot_is_public_without_reclassifying_the_original(): void
{
$before = $this->fingerprint();
$row = DB::table('geo_geometries as g')->where('geo_location_id', $this->territory)->first([
'g.id', 'g.geo_location_id', DB::raw("encode(sha256(ST_AsEWKB(g.geom)),'hex') as geometry_sha256"),
DB::raw("encode(sha256(convert_to(g.properties::text,'UTF8')),'hex') as properties_sha256"),
]);
// The QA import has its own identities; never copy or replace production rows.
$approved = $this->partialMock(ApprovedAdministrativeBoundaries::class);
$approved->shouldReceive('snapshots')->andReturn([$row->id => [
'territory_id' => $row->geo_location_id, 'geometry_sha256' => $row->geometry_sha256,
'properties_sha256' => $row->properties_sha256,
]]);
$url = '/api/v1/investment/territory-geometries?territory_id='.$this->territory;
$this->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', true)
->assertJsonPath('areas.0.data_classification', 'public')->assertJsonPath('investment_site', false);
$this->actingAs(User::factory()->create())->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', true);
$this->assertSame($before, $this->fingerprint());
$this->assertSame('internal', DB::table('geo_geometries')->where('id', $row->id)->value('data_classification'));
DB::table('geo_geometries')->where('id', $row->id)->update(['properties' => DB::raw("properties || '{\"qa_changed\":true}'::jsonb")]);
$this->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', false)->assertJsonCount(0, 'areas');
$this->actingAs($this->actor)->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', true);
}
public function test_public_manifest_has_the_exact_approved_scope_and_immutable_hash(): void
{
$items = app(ApprovedAdministrativeBoundaries::class)->snapshots();
$this->assertCount(408, $items);
$this->assertCount(408, array_unique(array_column($items, 'territory_id')));
$this->assertSame(['2019-06-07'], array_values(array_unique(array_column($items, 'source_version'))));
}
private function fingerprint(): string
{
return DB::selectOne("select md5(string_agg(row_to_json(g)::text,E'\\n' order by id)) as hash from geo_geometries g")->hash;
}
}