fix: restore shared map access and publish accepted administrative boundary projection
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user