feat(offers): implement phase four private details and confidentiality in QA

This commit is contained in:
2026-11-14 17:28:12 +02:00
parent 7691cab3bc
commit e431de8b42
29 changed files with 2356 additions and 12 deletions
@@ -6,15 +6,84 @@ use App\Models\Opportunity;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityResources;
use App\Services\PortalProfileAccess;
use App\Services\PrivateOfferConfidentiality;
use App\Services\PrivateOfferPreparation;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
final class PrivateOfferPreparationController extends Controller
{
public function resources(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
{
$record = $service->record($request->user(), $id);
$resources = [];
foreach (['images', 'documents'] as $kind) {
$resources[$kind] = array_map(fn ($row) => ['id' => $row->id, 'version' => $row->version, 'deleted' => $row->deleted_at !== null,
'caption' => $row->payload['caption'] ?? null, 'digitally_edited' => $row->payload['digitally_edited'] ?? null,
'edit_description' => $row->payload['edit_description'] ?? null,
'scan' => $row->file->malware_scan_status ?? 'pending'], app(OpportunityResources::class)->listing($id, $kind));
}
return response()->json(['resources' => $resources, 'etag' => app(OpportunityManagement::class)->etag($record),
'internalProofAccess' => app(PrivateOfferConfidentiality::class)->internal($request->user(), $record)]);
}
public function attachment(Request $request, PrivateOfferPreparation $service, int $id, string $kind, string $resourceId): \Symfony\Component\HttpFoundation\Response
{
$record = $service->record($request->user(), $id);
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$link = DB::table('opportunity_'.$kind)->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
abort_unless($link !== null, 404);
$document = DB::table('documents')->where('id', $link->document_id)->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function confidentiality(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse|Response
{
$overview = $privacy->overview($request->user(), $id);
return $request->expectsJson() ? response()->json($overview) : Inertia::render('offers/confidentiality', ['offerId' => $id]);
}
public function confidentialityAction(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
{
return response()->json($privacy->act($request->user(), $id, $request->all()));
}
public function confidentialRead(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
{
try {
$result = $privacy->read($request->user(), $id);
} catch (HttpExceptionInterface $error) {
app(PortalProfileAccess::class)->audit($request->user(), 'offer_confidential_denied', ['record_id' => $id, 'http_status' => $error->getStatusCode()]);
throw $error;
}
abort_if(isset($result['denied']), 403, 'É necessária aceitação do NDA e autorização expressa do vendedor para esta versão.');
return response()->json($result);
}
public function nda(Request $request, PrivateOfferConfidentiality $privacy, int $id): \Symfony\Component\HttpFoundation\Response
{
$overview = $privacy->overview($request->user(), $id);
abort_unless($overview['nda'], 409, 'O NDA aprovado ainda não foi disponibilizado.');
app(PortalProfileAccess::class)->audit($request->user(), 'offer_nda_access', ['record_id' => $id, 'version_hash' => $overview['version_hash'], 'nda' => $overview['nda']]);
$document = DB::table('documents')->where('id', $overview['nda']['document_id'])->firstOrFail();
abort_unless((int) $document->version_no === $overview['nda']['version'] && hash_equals(trim($document->sha256), $overview['nda']['sha256']), 409, 'O exemplar do NDA mudou; actualize antes de continuar.');
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function index(Request $request, PortalProfileAccess $access): Response
{
$filters = $request->validate(['state' => 'nullable|in:draft,submitted,in_review,needs_information,rejected,approved']);
@@ -40,7 +109,9 @@ final class PrivateOfferPreparationController extends Controller
$options = app(OpportunityCanonicalContract::class)->options($request->user(), $record);
return Inertia::render('offers/preparation', ['record' => $presentation, 'contexts' => $contexts,
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors']]);
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors'],
'sectors' => DB::table('sectors')->where('is_active', true)->orderBy('name')->get(['id', 'name']),
'propertyTypes' => array_values(array_filter($options['definitions']['realEstatePatch']['properties']['property_type']['enum'], fn ($type) => is_string($type)))]);
}
public function save(Request $request, PrivateOfferPreparation $service, ?int $id = null): JsonResponse
@@ -45,6 +45,7 @@ class OpportunityLibrarySupport
*/
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass
{
abort_if($opportunity->isPrivateOfferPreparation(), 422, 'Os comprovativos de Ofertas usam originais reservados à validação interna.');
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
$data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate();
+17 -2
View File
@@ -92,7 +92,7 @@ class OpportunityResources
}
abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.');
$active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at');
abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.');
abort_if(! $id && $active->count() >= $this->limit($opportunity, $kind), 422, 'Limite de recursos atingido.');
if ($kind === 'locations') {
$others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get();
foreach ($others as $other) {
@@ -172,7 +172,7 @@ class OpportunityResources
abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.');
$payload = json_decode($record->payload, true);
if ($data['action'] === 'restore') {
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= $this->limit($opportunity, $kind), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
$this->validatePayload($kind, $payload, $opportunityId);
// Restoration never silently reclaims a primary/cover/order occupied by another resource.
if ($kind === 'images') {
@@ -221,12 +221,22 @@ class OpportunityResources
*/
private function validatePayload(string $kind, array $payload, int $opportunityId): array
{
$offer = Opportunity::findOrFail($opportunityId);
if ($offer->isPrivateOfferPreparation()) {
Validator::make($payload, ['visibility' => 'required|in:internal',
...($kind === 'images' ? ['digitally_edited' => 'required|boolean', 'edit_description' => 'nullable|required_if:digitally_edited,true|string|max:1000'] : []),
...($kind === 'documents' ? ['category' => 'required|in:real_estate_proof', 'classification' => 'required|in:restricted'] : [])])->validate();
abort_if($kind === 'documents' && $offer->opportunity_category !== 'real_estate', 422, 'Este percurso aceita comprovativos imobiliários.');
}
$rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public'];
if ($kind === 'locations') {
$rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]];
$rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => ['nullable', Rule::in(self::AREA_UNITS)], 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000'];
} elseif ($kind === 'images') {
$rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0'];
if ($offer->isPrivateOfferPreparation()) {
$rules += ['digitally_edited' => 'boolean', 'edit_description' => 'nullable|string|max:1000'];
}
} else {
$rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'];
}
@@ -257,6 +267,11 @@ class OpportunityResources
return $data;
}
private function limit(Opportunity $record, string $kind): int
{
return $kind === 'locations' ? 50 : ($kind === 'images' ? ($record->isPrivateOfferPreparation() ? 6 : 20) : 100);
}
/** @return array{string,string} */
private function store(string $kind, UploadedFile $file): array
{
@@ -0,0 +1,216 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** The same opportunity owns consent, grants and history. No automatic publication. */
final class PrivateOfferConfidentiality
{
public function internal(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category);
}
public function seller(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return (int) $record->owner_user_id === $actor->id && $access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id);
}
/** @return array<string,mixed>|null */
public function nda(): ?array
{
$definition = config('private_offers.nda');
if (! is_array($definition) || empty($definition['approval_reference']) || empty($definition['document_id']) || empty($definition['version']) || empty($definition['sha256'])) {
return null;
}
$doc = DB::table('documents')->where('id', $definition['document_id'])->whereNull('deleted_at')->first();
if (! $doc || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean' || (int) $doc->version_no !== (int) $definition['version']
|| ! hash_equals(trim($doc->sha256), $definition['sha256']) || ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
return null;
}
try {
app(VerifiedDocumentBinary::class)->respond($doc);
} catch (HttpExceptionInterface $error) {
return null;
}
$result = ['document_id' => $doc->id, 'version' => (int) $doc->version_no, 'sha256' => trim($doc->sha256), 'approval_reference' => $definition['approval_reference']];
return [...$result, 'fingerprint' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR))];
}
private function company(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $record->opportunity_category === 'business_sale', 404);
return $record;
}
private function approvedHash(Opportunity $record): string
{
$hash = $record->workflow_context['approval']['hash'] ?? null;
abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.');
try {
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
} catch (ValidationException $error) {
abort(409, 'A versão ou os suportes exigem reconferência editorial.');
}
abort_unless(hash_equals($hash, $snapshot['hash']), 409, 'A versão mudou; é necessária reconferência editorial.');
return $hash;
}
/** PostgreSQL jsonb reorders object keys; identity is the version and checksum, not array order.
* @param array<string,mixed> $nda */
private function sameNda(mixed $accepted, array $nda): bool
{
if (! is_array($accepted)) {
return false;
}
foreach (['document_id', 'version', 'sha256', 'approval_reference', 'fingerprint'] as $key) {
if (($accepted[$key] ?? null) !== $nda[$key]) {
return false;
}
}
return true;
}
/** @return array<string,mixed> */
public function overview(User $actor, int $id): array
{
$record = $this->company($id);
// An interested party learns no private title, contacts, documents or financial values.
$seller = $this->seller($actor, $record);
$nda = $this->nda();
$context = $record->workflow_context['offer_confidentiality'] ?? [];
$currentHash = null;
try {
$currentHash = $this->approvedHash($record);
} catch (HttpExceptionInterface $error) {
// A previous acceptance remains withdrawable even if the offer was revised.
abort_unless($seller || isset($context['acceptances'][(string) $actor->id]), 404);
}
$history = array_values(array_filter($context['events'] ?? [], fn ($event) => $seller || $event['interested_user_id'] === $actor->id));
return ['offer_id' => $id, 'nda' => $nda, 'nda_pending' => $nda === null, 'version_hash' => $record->workflow_context['approval']['hash'] ?? null,
'etag' => $seller ? app(OpportunityManagement::class)->etag($record) : null,
'can_accept' => ! $seller && $nda !== null && $currentHash !== null && ! app(PortalProfileAccess::class)->conflict($actor, $record),
'can_authorize' => $seller && $nda !== null && $currentHash !== null,
'seller' => $seller, 'requests' => $seller ? array_values($context['acceptances'] ?? []) : [],
'grants' => $seller ? array_values($context['grants'] ?? []) : [],
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'version_hash', 'nda'])), $history),
'acceptance' => $context['acceptances'][(string) $actor->id] ?? null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function act(User $actor, int $id, array $input): array
{
$data = validator($input, ['action' => 'required|in:accept,authorize,revoke,withdraw', 'version_hash' => 'required|string|size:64',
'nda_sha256' => 'required_unless:action,revoke,withdraw|string|size:64', 'acceptance_id' => 'required_if:action,authorize|uuid',
'nda_fingerprint' => 'required_if:action,accept,authorize|string|size:64',
'interested_user_id' => 'required_if:action,authorize,revoke|integer|min:1', 'confirm' => 'required|accepted',
'reason' => ['nullable', 'required_if:action,authorize,revoke', 'string', 'max:1000', ...(in_array($input['action'] ?? null, ['authorize', 'revoke'], true) ? ['min:20'] : [])]])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->company($id, true);
$context = $record->workflow_context;
$privacy = $context['offer_confidentiality'] ?? ['acceptances' => [], 'grants' => [], 'events' => []];
$action = $data['action'];
$target = in_array($action, ['authorize', 'revoke'], true) ? (string) $data['interested_user_id'] : (string) $actor->id;
$before = ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null];
if (in_array($action, ['authorize', 'revoke'], true)) {
abort_unless($this->seller($actor, $record), 403);
} elseif ($action === 'accept') {
abort_if($this->seller($actor, $record) || app(PortalProfileAccess::class)->conflict($actor, $record), 403);
}
if (in_array($action, ['accept', 'authorize'], true)) {
$nda = $this->nda();
abort_unless($nda !== null, 409, 'O exemplar aprovado do NDA ainda não foi disponibilizado.');
abort_unless(hash_equals($this->approvedHash($record), $data['version_hash']) && hash_equals($nda['sha256'], $data['nda_sha256'])
&& hash_equals($nda['fingerprint'], $data['nda_fingerprint']), 409);
$subject = User::findOrFail((int) $target);
abort_unless($subject->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->conflict($subject, $record), 403);
if ($action === 'accept') {
$privacy['acceptances'][$target] = ['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'nda' => $nda, 'access_revision' => (int) $subject->portal_access_revision];
unset($privacy['grants'][$target]); // A fresh acceptance never reuses a previous seller decision.
} else {
$acceptance = $privacy['acceptances'][$target] ?? null;
abort_unless($acceptance && $acceptance['id'] === $data['acceptance_id'] && $acceptance['version_hash'] === $data['version_hash']
&& $this->sameNda($acceptance['nda'], $nda) && $acceptance['access_revision'] === (int) $subject->portal_access_revision, 409);
$privacy['grants'][$target] = ['interested_user_id' => (int) $target, 'seller_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'acceptance_id' => $acceptance['id'], 'nda' => $nda];
}
} else {
$previous = $privacy[$action === 'withdraw' ? 'acceptances' : 'grants'][$target] ?? null;
abort_unless($previous && $previous['version_hash'] === $data['version_hash'], 409);
unset($privacy['grants'][$target]);
if ($action === 'withdraw') {
unset($privacy['acceptances'][$target]);
}
}
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor->id, 'interested_user_id' => (int) $target,
'version_hash' => $data['version_hash'], 'at' => now()->toIso8601String(), 'nda_sha256' => $data['nda_sha256'] ?? null,
'nda' => $nda ?? ($before['acceptance']['nda'] ?? $before['grant']['nda'] ?? null)];
$privacy['events'][] = [...$event, 'reason' => Crypt::encryptString($data['reason'] ?? ''), 'before' => $before,
'after' => ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]];
$context['offer_confidentiality'] = $privacy;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
app(PortalProfileAccess::class)->audit($actor, 'offer_confidentiality_'.$action, ['record_id' => $id, ...$event]);
return ['recorded' => true, 'event_id' => $event['id']];
});
}
/** @return array<string,mixed> */
public function read(User $actor, int $id): array
{
return DB::transaction(function () use ($actor, $id) {
$record = $this->company($id, true);
$nda = $this->nda();
$privacy = $record->workflow_context['offer_confidentiality'] ?? [];
$accept = $privacy['acceptances'][(string) $actor->id] ?? null;
$grant = $privacy['grants'][(string) $actor->id] ?? null;
$hash = $this->approvedHash($record);
$seller = User::find($record->owner_user_id);
$subject = User::find($actor->id);
$allowed = $nda && $accept && $grant && $seller && $subject && $this->seller($seller, $record) && $subject->hasVerifiedEmail()
&& $accept['access_revision'] === (int) $subject->portal_access_revision && $grant['seller_id'] === $seller->id
&& $grant['acceptance_id'] === $accept['id'] && $grant['version_hash'] === $hash && $accept['version_hash'] === $hash
&& $this->sameNda($grant['nda'], $nda) && $this->sameNda($accept['nda'], $nda) && ! app(PortalProfileAccess::class)->conflict($actor, $record);
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_confidential_access' : 'offer_confidential_denied', ['record_id' => $id, 'version_hash' => $hash,
'nda' => $nda, 'acceptance_id' => $accept['id'] ?? null]);
// Return the refusal after transaction commit so the denied-access audit survives.
if (! $allowed) {
return ['denied' => true];
}
$business = $record->workflow_context['submission']['payload']['private_offer']['business'] ?? [];
foreach (['sector_id' => 'sector', 'secondary_sector_id' => 'secondary_sector'] as $field => $label) {
if (isset($business[$field])) {
$business[$label] = DB::table('sectors')->where('id', $business[$field])->value('name');
}
unset($business[$field]);
}
return ['offer_id' => $id, 'version_hash' => $hash, 'business' => $business];
});
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Optional commercial fields confirmed by Tempo for phase four; no title certification. */
final class PrivateOfferDetails
{
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function validate(array $input): array
{
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
if ($bytes === false) {
throw new \RuntimeException('Opportunity schema unavailable.');
}
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
$property = $schema['$defs']['realEstatePatch']['properties'];
return Validator::make($input, [
'business' => 'sometimes|nullable|array:sector_id,secondary_sector_id,revenue,cash_flow,currency,reason,availability',
'business.sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
'business.secondary_sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
'business.revenue' => ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'business.cash_flow' => ['nullable', 'string', 'regex:/^-?\d{1,16}(\.\d{1,2})?$/'],
'business.currency' => 'nullable|required_with:business.revenue,business.cash_flow|in:MZN,USD',
'business.reason' => 'nullable|string|max:1000', 'business.availability' => 'nullable|in:available,sold',
'property' => 'sometimes|nullable|array:transaction_type,property_type,land_area,built_area,area_unit,bedrooms,bathrooms,amenities,availability',
'property.transaction_type' => 'nullable|in:sale,lease',
'property.property_type' => ['nullable', Rule::in($property['property_type']['enum'])],
'property.land_area' => 'nullable|numeric|gt:0', 'property.built_area' => 'nullable|numeric|gt:0',
'property.area_unit' => ['nullable', Rule::in(OpportunityResources::AREA_UNITS)],
'property.bedrooms' => 'nullable|integer|min:0|max:10000', 'property.bathrooms' => 'nullable|integer|min:0|max:10000',
'property.amenities' => 'nullable|array|max:100', 'property.amenities.*' => 'string|max:250',
'property.availability' => 'nullable|in:available,sold,leased',
])->validate();
}
/** @param array<string,mixed> $offer */
public function assertCategory(array $offer, string $category): void
{
Validator::make($offer, [
'business' => $category === 'business_sale' ? 'nullable|array' : 'prohibited',
'property' => $category === 'real_estate' ? 'nullable|array' : 'prohibited',
])->validate();
}
/** Snapshot all active linked originals and metadata in the existing editorial version.
* @return array<string,array<string,mixed>> */
public function resources(Opportunity $record): array
{
$result = ['documents' => [], 'images' => [], 'locations' => []];
foreach (array_keys($result) as $kind) {
foreach (app(OpportunityResources::class)->listing($record->id, $kind) as $row) {
if ($row->deleted_at !== null) {
continue;
}
if ($kind !== 'locations') {
$file = $row->file;
$doc = DB::table('documents')->where('id', $row->document_id)->first();
if (! $doc || $doc->deleted_at !== null || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean'
|| ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
throw ValidationException::withMessages(['attachments' => 'Aguarde uma análise de segurança válida dos anexos.']);
}
$result[$kind][$row->id] = ['version' => $row->version, 'document_id' => $row->document_id, 'payload' => $row->payload, 'file' => $file];
} else {
$result[$kind][$row->id] = ['version' => $row->version, 'payload' => $row->payload];
}
}
ksort($result[$kind]);
}
if (count($result['images']) > 6) {
throw ValidationException::withMessages(['images' => 'A oferta admite no máximo seis fotografias.']);
}
return $result;
}
}
+18 -4
View File
@@ -15,13 +15,17 @@ final class PrivateOfferPreparation
/** @return array<string,mixed> */
public function validate(mixed $input, bool $complete = false): array
{
return Validator::make(['private_offer' => $input], [
'private_offer' => 'present|array:price_mode,amount,currency,contact_id',
$validated = Validator::make(['private_offer' => $input], [
'private_offer' => 'present|array:price_mode,amount,currency,contact_id,business,property',
'private_offer.price_mode' => 'nullable|in:amount,negotiable',
'private_offer.amount' => ['nullable', 'string', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'private_offer.currency' => ['nullable', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'in:MZN,USD'],
'private_offer.contact_id' => 'nullable|uuid',
'private_offer.business' => 'sometimes|nullable|array',
'private_offer.property' => 'sometimes|nullable|array',
])->validate()['private_offer'];
return array_replace($validated, app(PrivateOfferDetails::class)->validate(array_intersect_key($validated, array_flip(['business', 'property']))));
}
/** @param array<string,mixed> $input */
@@ -40,6 +44,8 @@ final class PrivateOfferPreparation
return DB::transaction(function () use ($actor, $input, $id) {
$record = app(OpportunityManagement::class)->save($actor, $input, $id, true);
$values = app(OpportunityManagement::class)->values($record);
app(PrivateOfferDetails::class)->assertCategory($values['private_offer'] ?? [], $values['opportunity_category']);
$contactId = app(OpportunityManagement::class)->values($record)['private_offer']['contact_id'] ?? null;
if ($contactId && ! app(OpportunityContacts::class)->related($record, $contactId)) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'Seleccione um contacto autorizado do promotor.']);
@@ -128,6 +134,8 @@ final class PrivateOfferPreparation
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$offer = $this->validate($values['private_offer'], true);
app(PrivateOfferDetails::class)->assertCategory($offer, $values['opportunity_category']);
$resources = app(PrivateOfferDetails::class)->resources($record);
if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
@@ -142,6 +150,10 @@ final class PrivateOfferPreparation
$payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'],
'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer,
'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path];
$extended = isset($offer['business']) || isset($offer['property']) || array_filter($resources) !== [];
if ($extended) {
$payload['resources'] = $resources;
}
$normalize = function (mixed $value) use (&$normalize): mixed {
if (is_object($value)) {
$value = get_object_vars($value);
@@ -157,8 +169,10 @@ final class PrivateOfferPreparation
};
$payload = $normalize($payload);
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => 'private-offer-editorial-v2', 'payload' => $payload], JSON_THROW_ON_ERROR)),
'record_version' => $record->management_version, 'contract_version' => 'private-offer-editorial-v2', 'resources' => ['documents' => [], 'images' => [], 'locations' => []]];
$contract = $extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2';
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)),
'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources];
}
/** @return array<string,mixed> */
+14 -1
View File
@@ -61,7 +61,11 @@ class VerifiedDocumentBinary
if ($table === 'opportunity_images') {
$query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]);
}
$links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload']));
$rows = $query->get(['opportunity_id', 'deleted_at', 'payload']);
foreach ($rows as $link) {
$link->resource_kind = $table;
}
$links = $links->concat($rows);
}
$privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true);
if (! $privateOrigin) {
@@ -72,6 +76,15 @@ class VerifiedDocumentBinary
}
$actor = request()->user();
abort_unless($actor instanceof User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.');
foreach ($links as $link) {
$offer = Opportunity::withTrashed()->whereKey($link->opportunity_id)->first();
if ($offer?->isPrivateOfferPreparation() && $link->resource_kind === 'opportunity_documents') {
$allowed = $link->deleted_at === null && ! $offer->trashed() && app(PrivateOfferConfidentiality::class)->internal($actor, $offer);
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_proof_access_authorized' : 'offer_proof_access_denied',
['record_id' => $offer->id, 'document_id' => $document->id, 'record_version' => $offer->management_version, 'sha256' => trim($document->sha256)]);
abort_unless($allowed, 403, 'Comprovativo reservado à validação interna autorizada.');
}
}
foreach ($links as $link) {
$opportunity = Opportunity::whereKey($link->opportunity_id)->first();
if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived'
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
// No legal text or operational NDA is supplied/approved. Never infer it from an upload.
'nda' => null,
];
@@ -10173,3 +10173,15 @@ A publicação efectiva permanece bloqueada, incluindo rotas alternativas. O cat
QA final: 10 testes/133 asserções aprovados; inclui acessos directos, estados inválidos, isolamento, preservação, hierarquia, decimais/moedas, mapa sem localização exacta, CSV, HTML compilado e regressão nativa autocontida. Passagem anterior: 36/41 aprovados; cinco casos nativos impedidos por referência Inhambane ausente na base sintética. Tipos, lint focado, Pint, PHPStan e build aprovados. Formatação global: os mesmos 61 ficheiros exteriores ao delta. Conferência visual/móvel pendente: `The browser access host is unavailable`; não substituída por aprovação técnica. Email real F2 mantém pendência.
Estado: implementado em QA com barreira fechada, não instalado; módulo integral e Procuras parciais. Limites representados: 1.000 candidatos e contexto de 30 territórios, assinalados quando truncados. Relatório: `docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md`. Recibo: `outputs/offers-public-phase3-20261114/qa-result.json`. Próximo passo: conferência desktop/móvel e apreciação da entrega QA; sem instalação ou avanço de fase por inferência.
## 14/11/2026 — Ofertas Fase 4, particularidades e confidencialidade em QA
Base F3 7691cab3 preservada, tal como F1/F2 e plano aceite. A Tempo confirmou os campos propostos como opcionais na preparação; confirmou que o NDA real continua pendente. Empresas: sectores do cadastro existente, receita/fluxo de caixa, moeda, motivo e disponibilidade declarada. Imóveis: modalidade, tipo existente, áreas/unidade, divisões, comodidades e disponibilidade. Reutiliza localização, rascunho, versões, submissão e revisão existentes.
Fotografias: máximo de seis activas, envio/alteração/retirada/restauro sob autorização e bloqueio do registo. Originais cifrados no disco privado; MIME, conteúdo, tamanho e quarentena no serviço existente. Declaração de edição digital conservada. Comprovativos imobiliários só podem ser lidos por validador interno com âmbito, MFA e sem conflito; dono/interessado/auditor/outro âmbito recusados. O controlo comum de binários impede atalhos; o envio não certifica titularidade.
Controlo empresarial exige aceitação do exemplar exacto do NDA e autorização expressa do vendedor para a mesma versão aprovada. Cada consulta revalida ambas, direitos e revisão de acesso. Retirada/revogação e alteração de versão fecham a consulta seguinte; nova aceitação exige nova autorização. História e auditoria preservam actores, instantes, hashes, versões e antes/depois; fundamentos cifrados. A leitura autorizada só entrega os campos empresariais conferidos, sem contactos, morada exacta ou comprovativos. NDA configurado nulo, sem texto inventado ou publicação. Fotografias originais continuam privadas.
Os cinco testes territoriais foram investigados: dependiam de Inhambane/turismo previamente importados numa base vazia. O fixture cria apenas os mínimos sintéticos quando faltam, com guarda testing/loopback/*_test:55441 e transacção de rollback. Os seis casos passaram; nenhum cadastro operacional foi alterado. Os snapshots F4 vinculam particularidades/recursos ao contrato v3; Ofertas comuns anteriores conservam v2.
Estado: implementado em QA, sem instalação, publicação efectiva, pagamentos, destaque comercial, migrações ou alterações em mne. Fase 5 não iniciada; módulo integral/Procuras parciais. Pendências: exemplar/versão/aprovação do NDA, identificação jurídica dos comprovativos admitidos, visual desktop/móvel e email real anteriores. GD ausente: derivadas do processador nativo continuam bloqueadas, sem simular processamento. Formatação global mantém 61 ficheiros exteriores ao delta. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase4-20261114/qa-result.json; contém resultados e hashes finais, sem somar execuções sobrepostas. Próximo passo: apreciação QA e disponibilização do NDA aprovado, mantendo o bloqueio de publicação.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
# Ofertas — Fase 4: particularidades e confidencialidade em QA
14/11/2026. Base aceite `7691cab3`, preservando F1 `1f2990e4`, F2 `91c18573` e o plano `6e302db6`. Desenvolvimento exclusivo em QA autorizado pela Tempo. Os campos propostos no documento original foram confirmados nesta sessão como opcionais durante a preparação. A Tempo confirmou também que o NDA permanece pendente.
**Implementado em QA, com dependência documental para o NDA real. Publicação efectiva bloqueada. Sem instalação, alterações de dados operacionais, migrações ou modificações em `mne`. Fase 5 não iniciada.**
## Percursos implementados
Na mesma preparação privada de Ofertas, o promotor guarda, recupera e altera:
- **Venda de empresa:** sectores principal/secundário do cadastro existente; receita, lucro/fluxo de caixa, moeda independente, motivo da venda e disponibilidade declarada. Decimais são conservados como texto, incluindo fluxo de caixa negativo; não há conversão cambial. Estes dados não entram na projecção pública.
- **Imóvel:** venda/arrendamento, tipo do catálogo existente, áreas e unidade, quartos/casas de banho, características/comodidades e disponibilidade declarada. Os campos de quartos e casas de banho aparecem nos tipos habitacionais aplicáveis. A localização continua no selector existente. As categorias de direitos de terra e restantes produtos conservam os seus contratos; não se fundiu terreno com imóvel nem se inventou uma regra de titularidade.
- **Fotografias:** envio, legenda, declaração e descrição de edição digital, alteração de metadados/original, retirada e restauro lógico. Máximo de **seis fotografias activas**, verificado no servidor sob bloqueio da mesma Oferta, tanto na criação como no restauro. Os recursos nativos dos outros módulos mantêm o limite anterior.
- **Comprovativos imobiliários:** envio privado, estado de análise e consulta pelo validador interno autorizado. O anunciante recebe o estado do envio, mas não recebe uma ligação de leitura do comprovativo no editor. O suporte enviado não é apresentado como título certificado nem aprova o negócio.
Os ficheiros usam `OpportunityResources`, `documents`, ligações e histórico existentes, sem outro armazenamento documental. Originais são cifrados no disco privado `local`, com identificadores gerados pelo servidor, checksum e quarentena. Reutilizam-se validação MIME/conteúdo, limites de 10 MB para fotografia/20 MB para documento, mínimo de 400 × 400 píxeis e rejeição de imagens animadas/executáveis. Os formatos documentais são limites técnicos do serviço existente; não constituem uma lista jurídica de títulos admitidos. O scanner existente decide a disponibilidade. Nenhum envio passa automaticamente a limpo ou a publicado.
## Comprovativos e caminhos alternativos
A autorização comum de binários (`VerifiedDocumentBinary`) verifica também os comprovativos de Ofertas. Exige papel existente de validador com âmbito no registo, acesso efectivo, MFA recente e ausência de conflito; o promotor, auditor, outra categoria ou sessão sem MFA não obtêm os bytes. A verificação ocorre também nos consumidores gerais que reutilizam esse serviço, antes de qualquer entrega. Regista documento, hash, actor e versão da Oferta, sem incluir o original na auditoria.
As rotas nativas de edição de Ofertas que já estavam bloqueadas mantêm a recusa. A associação de originais públicos da biblioteca a uma Oferta é recusada neste percurso: um comprovativo reservado não é convertido em documento público por essa associação. Fotografias e localizações desta preparação só admitem visibilidade interna. Os comprovativos só admitem classificação restrita.
## Duas condições independentes para dados da empresa
O novo adaptador usa a mesma Oportunidade e a autorização existente. Não cria cadastro de empresas, utilizadores, permissões ou dashboard. A configuração `private_offers.nda` está **nula**; não contém texto jurídico nem exemplar fictício. A sua futura referência exige um documento existente com versão, SHA256 e referência da aprovação da Tempo. Estado, scan, versão e integridade dos bytes são reconferidos pelo serviço comum. Não se reutilizou a designação jurídica da legislação como aprovação automática de NDA.
Com um exemplar efectivamente aprovado, o percurso técnico é:
1. Interessado autenticado e com email verificado consulta o exemplar exacto e aceita-o expressamente para o hash da versão aprovada da empresa. Não é exigida uma justificação para aceitar ou retirar a aceitação.
2. O vendedor registado (dono da Oferta), com atribuição de promotor e representação ainda efectivas, autoriza expressamente aquele interessado e **aquela aceitação**. Esta autorização no portal não certifica titularidade sobre a empresa. Um validador não substitui o vendedor. Autorizações e revogações conservam fundamento.
3. Em cada consulta, o servidor reconfirma as duas condições, o exemplar/versão do NDA, a aprovação editorial actual, o hash dos dados, a identidade do vendedor, os direitos efectivos e a revisão de acesso do interessado.
4. O interessado recebe apenas os campos empresariais da submissão aprovada. Não recebe a descrição privada integral, contactos, morada exacta, comprovativos, caminhos de armazenamento ou ficheiros originais. As fotografias continuam no percurso privado do promotor e da equipa; não se acrescentou partilha externa de originais.
5. Revogar a autorização ou retirar a aceitação fecha o acesso seguinte. A retirada de uma aceitação anterior continua disponível enquanto a Oferta está em revisão, sem abrir os dados dessa revisão. Uma aceitação nova elimina a autorização anterior: exige nova decisão do vendedor. Mudar o NDA, a Oferta, os seus suportes, as referências abrangidas ou os direitos efectivos impede a reutilização silenciosa do acesso anterior.
Aceitações, autorizações, retiradas e revogações ficam na história da Oportunidade, com antes/depois, actor, instante, hash da Oferta e identidade/versão/checksum do NDA. Os fundamentos ficam cifrados. A auditoria existente regista operações e consultas autorizadas/recusadas, sem valores financeiros. O vendedor consulta as suas decisões; o interessado vê apenas as ocorrências que lhe respeitam. As respostas privadas mantêm `no-store`. Uma revogação não apaga informação que o interessado já tenha legitimamente recebido; não foi prometida recolha retroactiva de bytes.
## Continuidade editorial e protecção pública
Os campos específicos e os suportes activos são incorporados no snapshot editorial `private-offer-editorial-v3`. Hashes incluem identidades e versões dos recursos, conteúdo/metadados e checksums; alterações exigem nova submissão/decisão. Ofertas anteriores sem extensão mantêm o contrato v2. Identificadores, originais, rejeições, aprovações e histórico não são substituídos. Um suporte que volte a quarentena sinaliza aprovação desactualizada, conservando a possibilidade de recuperar e preparar nova revisão.
A projecção pública da Fase 3 e os bloqueios do modelo/workflow não foram abertos. Não existe publicador, pagamentos ou destaque comercial activo. Não se acrescentaram campos sensíveis a pesquisas, APIs públicas, mapas, CSV, notificações ou email. As provas anteriores da Fase 2 para notificações sem conteúdo privado são reutilizadas; a entrega real de email permanece pendente.
## Cinco testes territoriais antes bloqueados
A causa estava em `OpportunitySearchFixture`: exigia uma referência pública de Inhambane e o sector turismo previamente importados, apesar de o ensaio usar uma base de testes vazia. O produto não perdeu os dados territoriais. O fixture passa a criar os mínimos sintéticos apenas quando ausentes, **depois da guarda de ambiente testing/loopback/base `_test`/porta 55441 e dentro de uma transacção de rollback**. Fora dessa combinação recusa a criação. Não foi executada importação, reposição ou alteração no cadastro operacional.
Os seis casos de `OpportunitySearchTest`, incluindo os cinco impedidos, passaram. Não se alteraram as suas asserções ou os filtros do produto para obter aprovação.
## Ficheiros alterados
| Grupo | Finalidade |
| --- | --- |
| `PrivateOfferDetails`, `PrivateOfferPreparation` | Campos específicos e snapshots/versionamento existentes |
| `PrivateOfferConfidentiality`, `config/private_offers.php` | Duas condições, revogação, versões e auditoria; NDA inactivo |
| `OpportunityResources`, `OpportunityLibrarySupport`, `VerifiedDocumentBinary` | Limite de seis, metadados, visibilidade e protecção central dos comprovativos |
| `PrivateOfferPreparationController`, `routes/web.php` | Operações privadas no percurso existente |
| `offer-particulars`, `offer-private-resources`, `offer-confidential-access`, páginas `offers/preparation` e `offers/confidentiality` | Preparação progressiva, recursos e controlo privado de acesso |
| `PrivateOfferConfidentialityTest`, `OpportunitySearchFixture` | Segurança/isolamento e independência das importações operacionais |
| Matriz RF-MN-003, manual e recibo existentes | Estado, testes e dependências concretas |
## Verificação e saldo
Resultados finais, comandos, base isolada e hashes do delta: `outputs/offers-private-phase4-20261114/qa-result.json`. Grupos de testes sobrepostos não devem ser somados. A passagem alargada intermédia teve 48/50 aprovados; uma falha expôs uma referência de sessão desactualizada no cenário de revogação e a outra resultou de reconstruir assets durante a prova HTML. Foram corrigidas as causas e as verificações afectadas foram repetidas com o build estabilizado. Não se enfraqueceram asserções, permissões, scanner ou guardas.
| Verificação | Resultado |
| --- | --- |
| Passagem F4 e HTML das fases afectadas | 13 testes / 174 asserções aprovados |
| Delta final: retirada durante revisão, versões NDA e revogação de sessão | 1 teste / 35 asserções aprovados |
| HTML privado, público e de confidencialidade com o último build estabilizado | 3 testes / 29 asserções aprovados |
| Regressão da preparação privada F1 | 14 testes / 146 asserções aprovados |
| Regressão editorial F2 | 11 testes / 161 asserções aprovados |
| Pesquisa territorial, incluindo os cinco casos antes impedidos | 6 testes / 112 asserções aprovados |
| Recursos nativos: binários, uploads, autorização e processamento condicionado | 5 testes / 69 asserções aprovados |
| Pint, PHPStan, TypeScript, lint/formatação focados e build QA | Aprovados; PHPStan sem erros |
| Formatação global | Os mesmos 61 ficheiros, fora do delta e com conteúdo Git inalterado |
As regressões das Fases 1/2 e da pesquisa foram extraídas da passagem alargada; os cinco casos de recursos constam da passagem focada de 16 testes/217 asserções. O complemento final cobre a alteração de retirada sem repetir os restantes casos aprovados. O caso adicional de suporte novamente em quarentena teve a expectativa ajustada ao contrato **já existente** de reabertura (`needs_information`, editável), preservando o produto.
**Pendências concretas:**
- Exemplar, versão e referência da aprovação do NDA da Tempo. A ausência foi confirmada pelo proprietário; sem estes elementos o acesso real continua fechado. Os testes usam bytes explicitamente marcados como sintéticos e não jurídicos, por injecção local ao teste.
- Identificação jurídica dos comprovativos imobiliários admitidos. Implementaram-se segurança e revisão interna, sem fabricar um tipo de título ou regra de propriedade.
- Conferência visual desktop/móvel das fases aceites, ainda não concluída. O conector registou `The browser access host is unavailable`; não foram alterados navegadores ou controlos de segurança.
- GD não está instalado neste PHP QA. Uploads, validação estrutural, armazenamento e autorização foram provados com PNGs válidos sem GD. O processador nativo mantém a geração de derivadas bloqueada quando GD/WebP não está disponível; não se declarou processamento de galerias públicas demonstrado.
- A formatação global mantém os 61 ficheiros preexistentes fora do delta. Email real F2 e a abertura efectiva de publicação continuam com o seu estado anterior.
Próxima tarefa: apreciação desta entrega QA e disponibilização do exemplar aprovado do NDA, mantendo a publicação fechada. Qualquer instalação ou avanço para a Fase 5 exige autorização própria.
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":13,"passed":13,"assertions":174,"duration_ms":131026}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="13" assertions="174" errors="0" failures="0" skipped="0" time="131.009743">
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="11" assertions="156" errors="0" failures="0" skipped="0" time="128.514918">
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="107" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="20.768857"/>
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="118" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="8.735438"/>
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="129" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="14.870475"/>
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="171" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="7.138552"/>
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="185" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="6.180531"/>
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="207" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.017397"/>
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="225" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="9.546498"/>
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="249" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="31" time="21.536494"/>
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="286" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="10.637805"/>
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="310" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="5.890419"/>
<testcase name="test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="324" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="16" time="14.192451"/>
</testsuite>
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="1" assertions="8" errors="0" failures="0" skipped="0" time="0.343878">
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="8" time="0.343878"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="1" assertions="10" errors="0" failures="0" skipped="0" time="2.150948">
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.150948"/>
</testsuite>
</testsuite>
</testsuites>
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":16,"passed":16,"assertions":217,"duration_ms":133539}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="16" assertions="217" errors="0" failures="0" skipped="0" time="133.498881">
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="11" assertions="148" errors="0" failures="0" skipped="0" time="130.710864">
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="107" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="37.301637"/>
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="118" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="5.927772"/>
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="129" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="12.326472"/>
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="171" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="7.960654"/>
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="185" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="6.277187"/>
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="207" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.438135"/>
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="224" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="10.122150"/>
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="248" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="31" time="19.336820"/>
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="285" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="11.095258"/>
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="309" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="7.443802"/>
<testcase name="test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="323" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="8" time="3.480976"/>
</testsuite>
<testsuite name="Tests\Feature\OpportunityResourcesTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" tests="5" assertions="69" errors="0" failures="0" skipped="0" time="2.788017">
<testcase name="test_encrypted_original_scan_pending_recovery_replacement_and_logical_lifecycle" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="47" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="24" time="0.726761"/>
<testcase name="test_multiple_locations_without_coordinates_and_invalid_hierarchy_and_geometry" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="82" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="14" time="0.436207"/>
<testcase name="test_foreign_country_rejects_mozambican_hierarchy_without_changing_the_saved_draft" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="100" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="20" time="0.596993"/>
<testcase name="test_invalid_upload_and_cross_opportunity_links_do_not_create_resources" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="132" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="7" time="0.496219"/>
<testcase name="test_image_processing_is_explicit_and_every_derivative_requires_its_own_scan" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="141" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="4" time="0.531838"/>
</testsuite>
</testsuite>
</testsuites>
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":3,"passed":3,"assertions":29,"duration_ms":15844}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="3" assertions="29" errors="0" failures="0" skipped="0" time="15.836319">
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="1" assertions="11" errors="0" failures="0" skipped="0" time="13.410248">
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="313" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="13.410248"/>
</testsuite>
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="1" assertions="8" errors="0" failures="0" skipped="0" time="0.201255">
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="8" time="0.201255"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="1" assertions="10" errors="0" failures="0" skipped="0" time="2.224816">
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.224816"/>
</testsuite>
</testsuite>
</testsuites>
@@ -0,0 +1,184 @@
{
"phase": 4,
"base": "7691cab3bc3ab8215c6feab1a2396119f07a55b5",
"state": "implemented in QA; real NDA and visual review pending; publication blocked",
"authorization": {
"qa_only": true,
"optional_proposed_fields_confirmed": true,
"real_nda_pending_confirmed": true
},
"database": {
"name": "offers_phase1_20261114_test",
"port": 55441,
"host": "127.0.0.1",
"test_data": "synthetic with rollback"
},
"verified_runs": {
"final.log": {
"tool": "phpunit",
"result": "passed",
"tests": 13,
"passed": 13,
"assertions": 174,
"duration_ms": 131026
},
"withdrawal-latest.log": {
"tool": "phpunit",
"result": "passed",
"tests": 1,
"passed": 1,
"assertions": 35,
"duration_ms": 27438
},
"html-latest.log": {
"tool": "phpunit",
"result": "passed",
"tests": 3,
"passed": 3,
"assertions": 29,
"duration_ms": 15844
},
"focused.log": {
"tool": "phpunit",
"result": "passed",
"tests": 16,
"passed": 16,
"assertions": 217,
"duration_ms": 133539
}
},
"accepted_regressions": {
"PrivateOfferPreparationTest": {
"tests": 14,
"assertions": 146,
"errors": 0,
"failures": 0,
"evidence": "regression.xml"
},
"PrivateOfferEditorialTest": {
"tests": 11,
"assertions": 161,
"errors": 0,
"failures": 0,
"evidence": "regression.xml"
},
"OpportunitySearchTest": {
"tests": 6,
"assertions": 112,
"errors": 0,
"failures": 0,
"evidence": "regression.xml"
},
"OpportunityResourcesTest": {
"tests": 5,
"assertions": 69,
"errors": 0,
"failures": 0,
"evidence": "focused.xml"
}
},
"intermediate_run": {
"tests": 50,
"passed": 48,
"assertions": 663,
"failures": 2,
"resolution": [
"fresh access revision read from database and fresh synthetic actor",
"HTML checked after build stabilized; no concurrent rebuild"
]
},
"checks": {
"pint": "passed",
"phpstan": "passed; zero errors",
"typescript": "passed",
"frontend_focused": "five files passed",
"build": "passed; build-latest.log",
"git_diff_check": "passed",
"global_format": {
"failed_files": 61,
"outside_delta": true,
"unchanged_git_content": true,
"same_list_as_accepted_baseline": true
}
},
"limits": {
"photos_active_per_offer": 6,
"original_images_kib": 10240,
"documents_kib": 20480,
"image_min_pixels": [
400,
400
]
},
"confidentiality": {
"nda_configuration": null,
"publication_enabled": false,
"read_requires": [
"current approved offer hash",
"verified exact NDA bytes, version and approval reference",
"interested acceptance",
"express decision by registered offer owner",
"current seller and interested permissions/access revision"
],
"proof_reader": "existing scoped internal validator with MFA and no conflict",
"external_disclosure": "approved business fields only, no originals/contacts/exact coordinates/proof",
"history": "offer context, versioned before/after; existing audit; encrypted reasons"
},
"territorial_tests": {
"cause": "fixture depended on pre-existing imports in empty isolated database",
"resolved_cases": 6,
"previously_blocked": 5,
"guard": "verified testing loopback *_test:55441 plus active rollback transaction",
"operational_data_changed": false
},
"pending": [
"real approved NDA exemplar/version/approval reference",
"legal identification of acceptable property evidence; no title certification",
"desktop/mobile visual acceptance: browser access host unavailable",
"GD/WebP image derivatives not demonstrated in this PHP environment",
"real email delivery from phase two",
"61 pre-existing formatting failures"
],
"installed": false,
"operational_data_modified": false,
"migrations_added": false,
"mne_modified": false,
"phase5_started": false,
"new_roles_or_authentication": false,
"payments_or_paid_highlights": false,
"source_hash_encoding": "SHA256 of UTF-8 files with Git LF normalization",
"source_sha256": {
"app/Http/Controllers/PrivateOfferPreparationController.php": "b684b14f85bd569c09dbd5f16d6e7f210c1045637d1e4c4c9229ffe124937e6c",
"app/Services/OpportunityLibrarySupport.php": "26a08e3a358fe97f2609e275af72dd7e70ba777d2ad9ea791a3be2c1a4c08853",
"app/Services/OpportunityResources.php": "e8bcd6f4f5b994b466005aace3601fcfc642550d6ebda227c6de7a498f95f1b5",
"app/Services/PrivateOfferConfidentiality.php": "95bc76b9beb72924a4efdcbea6e8d36670f1ff15dac02baf0f761f489837e10b",
"app/Services/PrivateOfferDetails.php": "1253c5d3cb9e3a2ae422f813afc4eb5ee8ed3859b07e15a3b32b678d3a97a015",
"app/Services/PrivateOfferPreparation.php": "7b277a049442f90fcb15e913b43c551f2ba3716d54cfcfdcaf0b621fbf367a18",
"app/Services/VerifiedDocumentBinary.php": "9a03b62643e767ef7d23da9faf235c491629d61201755ff8391b5cc7f534aaeb",
"config/private_offers.php": "8414d6ffb362d5590d075a9c4205cd700f60b99f0cb5bd7e7f925b8638c60531",
"docs/investmoz/unification/traceability-and-manual.md": "9cb4949a9279e3132a60069ff3cea21c12a4d5c90b8e44f0c1ed68428a07d656",
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv": "c4715f857e750a4d11438284ee2fe7592af3d16b72862ba947f4539b5cb3fd35",
"docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md": "b4a5a8f06cb650885eb5af4de7e80d322d94f51af43509f893740bf5830fda61",
"resources/js/components/offer-confidential-access.tsx": "5723e5e8eda7500fe24670e7676749a4f36ad294bd284224cf7b6919675bddc1",
"resources/js/components/offer-particulars.tsx": "ef78a2a493705b6f4828addd0227fc7e91f511a48b2c7b12fbbe35c610fe5de0",
"resources/js/components/offer-private-resources.tsx": "cb2070aeb4fd8ebe4eac28b41501223d2a87a2b1213538ab66c9520ab244d9c9",
"resources/js/pages/offers/confidentiality.tsx": "b31d2cd72b55eaa9d04a3e7ff03fcb0d4a18696c403f89f5b791f9dc050c4d68",
"resources/js/pages/offers/preparation.tsx": "238cf223eb88f2725b65c3700b977dd984e190a5e890f7b208e40fd34dcf1d8d",
"routes/web.php": "1291c0ef1d7cfdef16c83f6444df62b03eff28e3193bb47cf21c1aac1aa51b13",
"tests/Feature/PrivateOfferConfidentialityTest.php": "77eb38a9512914cea5e2554e00ef960abb3c861f6a3037a7504ca73db9c39161",
"tests/Support/OpportunitySearchFixture.php": "1bab1218a1122f6a7713d1a256f8576f32dc03a2eff08601a1e356b840681364"
},
"evidence_sha256": {
"final.log": "3a6a9976cabe1820641845e1546f626347636e999909e0a88e79798960a445f5",
"final.xml": "38f4a99af98fb595dce559bc4a9e9bc0c06a4b6f92ec7b687631b8284aeacc28",
"withdrawal-latest.log": "d01eff68d96bc8fe73d564e2aafc357a692676b4adeaf685a29c3221612b0a81",
"withdrawal-latest.xml": "a51078aafee220a4134b20d3e864b59d54c96e95508d5fa5c9ceb267104bfafd",
"html-latest.log": "99d084019996a9b8a3079e281c160e10a81afde635ba8e09011106579d955447",
"html-latest.xml": "923911794057826eb12f4e0074ec283a9062d49037065ac3059d6b225dfe5c3c",
"focused.log": "1e0789c1219d42f1d8f1952b64fdad6354261ec252826e79b8d35bd0db2c278d",
"focused.xml": "a497520ddc934ac650f08ceb9d20f84dc32a9da8d0ec40e8bb99b3605aba10eb",
"regression.xml": "2809cfa01d935ad0e332791dbc74be947ecf4c1ccfad03bbd52f633cdb8d8ce7"
},
"report": "docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md",
"overlapping_runs_not_summed": true
}
@@ -0,0 +1,333 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="50" assertions="663" errors="0" failures="2" skipped="0" time="312.052046">
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="9" assertions="116" errors="0" failures="1" skipped="0" time="99.765962">
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="103" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="15.914456"/>
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="114" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="7.085736"/>
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="125" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="12.561826"/>
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="167" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="8.641678"/>
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="181" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="7.040351"/>
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="203" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="12" time="10.011660"/>
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="219" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="11.019254"/>
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="243" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="20" time="17.736005">
<failure type="PHPUnit\Framework\ExpectationFailedException">Tests\Feature\PrivateOfferConfidentialityTest::test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access&#13;
Expected response status code [200] but received 409.
Failed asserting that 409 is identical to 200.
&#13;
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponseAssert.php:46
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:183
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\Concerns\AssertsStatusCodes.php:16
C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php:254
C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php:270</failure>
</testcase>
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="280" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.754996"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="14" assertions="146" errors="0" failures="0" skipped="0" time="54.146736">
<testcase name="test_private_creation_recovery_edit_conflict_and_idempotency_use_one_opportunity" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="60" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="24" time="6.934737"/>
<testcase name="test_incomplete_draft_is_saved_but_submission_requires_common_fields_and_authorized_contact" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="78" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="7" time="1.585480"/>
<testcase name="test_submit_is_private_exact_version_confirmed_and_recoverable_without_editorial_actions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="87" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="17" time="4.918074"/>
<testcase name="test_other_accounts_ungranted_unverified_and_revoked_actors_cannot_operate" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="110" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="18" time="4.184187"/>
<testcase name="test_contact_scope_and_malicious_fields_roll_back_without_parallel_records" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="127" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="9" time="2.707529"/>
<testcase name="test_price_contract_rejects_conversion_modes_precision_and_negotiable_amount" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="142" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="3.243489"/>
<testcase name="test_no_public_exposure_and_native_routes_cannot_edit_or_publish_offer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="152" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="15" time="3.322057"/>
<testcase name="test_existing_opportunity_cannot_be_converted_through_offer_adapter" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="175" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="2.185731"/>
<testcase name="test_existing_contacts_registration_still_requires_fresh_mfa_and_submission_rechecks_contact" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="191" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="8.805449"/>
<testcase name="test_model_cannot_publish_or_remove_private_phase_marker" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="210" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="4" time="2.115354"/>
<testcase name="test_location_uses_existing_hierarchy_and_invalid_selection_never_saves" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="225" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="3.387721"/>
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.839783"/>
<testcase name="test_organization_representation_is_rechecked_and_other_organizations_are_not_assignable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="255" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="5" time="3.416102"/>
<testcase name="test_incomplete_price_is_preserved_but_cannot_be_submitted_and_invalid_structures_return_validation_errors" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="270" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="9" time="4.501044"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferEditorialTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" tests="11" assertions="161" errors="0" failures="0" skipped="0" time="152.109179">
<testcase name="test_submitted_offer_enters_existing_dashboard_and_private_approval_has_exact_snapshot_and_audit" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="83" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="31" time="10.376558"/>
<testcase name="test_rejection_revision_correction_resubmission_preserve_identity_decisions_and_versions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="113" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="27" time="35.198830"/>
<testcase name="test_return_requires_reason_and_common_field_and_preserves_fields_on_conflict" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="151" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="11" time="11.573634"/>
<testcase name="test_author_contributor_wrong_scope_auditor_and_unassigned_reviewer_cannot_approve" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="170" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="14" time="14.591862"/>
<testcase name="test_reference_changes_block_approval_and_signal_reconfirmation_without_overwriting_decision" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="197" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="12" time="32.820792"/>
<testcase name="test_phase_one_submission_is_bound_only_on_explicit_first_review" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="216" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="11" time="13.044988"/>
<testcase name="test_notices_are_scoped_deduplicated_and_use_private_destination_email_rechecks_permission" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="237" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="10" time="5.295986"/>
<testcase name="test_publication_and_alternative_routes_remain_blocked_after_private_approval" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="263" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="12" time="7.286119"/>
<testcase name="test_rollback_preserves_draft_and_does_not_leave_tasks_notices_audit_or_send_mail" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="288" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="9" time="2.091156"/>
<testcase name="test_disabling_editorial_does_not_downgrade_a_phase_two_submission_to_phase_one" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="316" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="8" time="7.864950"/>
<testcase name="test_existing_coordinator_can_reassign_only_to_a_scoped_independent_validator" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="332" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="16" time="11.964305"/>
</testsuite>
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="10" assertions="128" errors="0" failures="1" skipped="0" time="2.983256">
<testcase name="test_effective_publication_remains_closed_even_with_a_stored_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="51" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="9" time="0.169435"/>
<testcase name="test_pages_api_csv_and_map_only_use_the_explicit_public_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="68" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="35" time="0.211112"/>
<testcase name="test_invalid_unapproved_unpublished_or_sensitive_snapshots_are_not_readable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="88" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="10" time="0.184474"/>
<testcase name="test_pending_private_edits_do_not_replace_the_previously_approved_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="101" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="6" time="0.163914"/>
<testcase name="test_prices_are_exact_and_currencies_are_never_converted" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="117" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="12" time="0.201038"/>
<testcase name="test_csv_formula_text_is_escaped_and_http_has_no_publication_operation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="132" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="5" time="0.182387"/>
<testcase name="test_location_category_and_pagination_use_only_the_published_snapshot" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="143" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="23" time="0.438636"/>
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="3" time="0.163556">
<failure type="PHPUnit\Framework\ExpectationFailedException">Tests\Feature\OfferPublicCatalogueTest::test_public_html_resolves_the_real_compiled_pages_without_private_fields&#13;
Failed asserting that '&lt;!DOCTYPE html&gt;\n
&lt;html lang="pt" class=""&gt;\n
&lt;head&gt;\n
&lt;meta charset="utf-8"&gt;\n
&lt;meta name="viewport" content="width=device-width, initial-scale=1"&gt;\n
\n
\n
&lt;script&gt;\n
(function() {\n
const appearance = 'system';\n
\n
if (appearance === 'system') {\n
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n
\n
if (prefersDark) {\n
document.documentElement.classList.add('dark');\n
}\n
}\n
})();\n
&lt;/script&gt;\n
\n
\n
&lt;style&gt;\n
html {\n
background-color: oklch(1 0 0);\n
}\n
\n
html.dark {\n
background-color: oklch(0.145 0 0);\n
}\n
&lt;/style&gt;\n
\n
&lt;link rel="icon" href="/favicon.ico" sizes="any"&gt;\n
&lt;link rel="icon" href="/favicon.svg" type="image/svg+xml"&gt;\n
&lt;link rel="apple-touch-icon" href="/apple-touch-icon.png"&gt;\n
\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/instrument-sans-400-normal-DRC__1Mx.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/instrument-sans-500-normal-Dk9ku72i.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/instrument-sans-600-normal-B7fBEWYG.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-400-normal-BTkUljjl.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-500-normal-DCA2Z9Cj.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-600-normal-Dkgu5AE5.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-700-normal-CrvTEWhy.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-serif-400-normal-BV_iYGc9.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-serif-600-normal-BiuDA9L7.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-serif-700-normal-CIw17UNa.woff2" type="font/woff2" crossorigin="anonymous" /&gt;\n
&lt;style&gt;\n
@font-face {\n
font-family: "Instrument Sans";\n
font-style: normal;\n
font-weight: 400;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/instrument-sans-400-normal-DRC__1Mx.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Instrument Sans";\n
font-style: normal;\n
font-weight: 400;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/instrument-sans-400-normal-D1W7dsQl.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Instrument Sans";\n
font-style: normal;\n
font-weight: 500;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/instrument-sans-500-normal-Dk9ku72i.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Instrument Sans";\n
font-style: normal;\n
font-weight: 500;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/instrument-sans-500-normal-Z6ESRlEs.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Instrument Sans";\n
font-style: normal;\n
font-weight: 600;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/instrument-sans-600-normal-B7fBEWYG.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Instrument Sans";\n
font-style: normal;\n
font-weight: 600;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/instrument-sans-600-normal-B9e8oLYv.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 400;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-400-normal-BTkUljjl.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 400;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-400-normal-DPpKfCgK.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 500;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-500-normal-DCA2Z9Cj.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 500;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-500-normal-CDPngY5C.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 600;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-600-normal-Dkgu5AE5.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 600;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-600-normal-DVJMj_t0.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 700;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-700-normal-CrvTEWhy.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Sans";\n
font-style: normal;\n
font-weight: 700;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-sans-700-normal-ZyrKABVF.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Serif";\n
font-style: normal;\n
font-weight: 400;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-serif-400-normal-BV_iYGc9.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Serif";\n
font-style: normal;\n
font-weight: 400;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-serif-400-normal-DT0cwSa_.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Serif";\n
font-style: normal;\n
font-weight: 600;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-serif-600-normal-BiuDA9L7.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Serif";\n
font-style: normal;\n
font-weight: 600;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-serif-600-normal-RZopCp-J.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Serif";\n
font-style: normal;\n
font-weight: 700;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-serif-700-normal-CIw17UNa.woff2") format("woff2");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
@font-face {\n
font-family: "Noto Serif";\n
font-style: normal;\n
font-weight: 700;\n
font-display: swap;\n
src: url("/build-qa-opportunities/assets/noto-serif-700-normal-mX-AlFMT.woff") format("woff");\n
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
}\n
\n
:root {\n
--font-instrument-sans: "Instrument Sans";\n
--font-noto-sans: "Noto Sans";\n
--font-noto-serif: "Noto Serif";\n
}\n
\n
.font-instrument-sans {\n
font-family: var(--font-instrument-sans);\n
}\n
\n
.font-noto-sans {\n
font-family: var(--font-noto-sans);\n
}\n
\n
.font-noto-serif {\n
font-family: var(--font-noto-serif);\n
}\n
&lt;/style&gt;\n
&lt;link rel="preload" as="style" href="http://localhost/build-qa-opportunities/assets/app-UznX-FqS.css" /&gt;&lt;link rel="preload" as="style" href="http://localhost/build-qa-opportunities/assets/interactive-investment-map-Bm3WjoQm.css" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/app-BPqa0WnF.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/rolldown-runtime-Dd_uD5pT.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-BRfsww6z.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/jsx-runtime-BpzPEenQ.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/compiler-runtime-sKsOXjDo.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-Ddn_hEUd.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-BobFCjoG.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-qw3FPjRO.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/Combination-BBDI-RbM.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/utils-CCDJ05Ll.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/createLucideIcon-oMeAlXbs.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/wayfinder-Dp7PqxvV.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/catalogue-dPWuPV9C.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/search-state-RHLy6yI7.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/shared-dS3P3an9.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/opportunity-search-filters-D6l6oeyA.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/search-BRvPeo47.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/sliders-horizontal-D-4FqAHA.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/investment-territory-Cnp2648A.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/territory-geometry-preview-BSCKCw5m.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/interactive-investment-map-soic1493.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/catalogue-investment-map-DSAPtwWw.js" /&gt;&lt;link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/public-offer-card-Byw0miCA.js" /&gt;&lt;link rel="stylesheet" href="http://localhost/build-qa-opportunities/assets/app-UznX-FqS.css" /&gt;&lt;link rel="stylesheet" href="http://localhost/build-qa-opportunities/assets/interactive-investment-map-Bm3WjoQm.css" /&gt;&lt;script type="module" src="http://localhost/build-qa-opportunities/assets/app-BPqa0WnF.js"&gt;&lt;/script&gt;&lt;script type="module" src="http://localhost/build-qa-opportunities/assets/catalogue-dPWuPV9C.js"&gt;&lt;/script&gt; &lt;title&gt;Laravel&lt;/title&gt;\n
&lt;/head&gt;\n
&lt;body class="font-sans antialiased"&gt;\n
&lt;script data-page="app" type="application/json"&gt;{"component":"offers\/catalogue","props":{"errors":{},"name":"Laravel","administrationNavigation":[],"dashboardNavigation":[],"procurementEnabled":false,"fundingEnabled":true,"homologationReserved":false,"profileAccess":null,"contentManagementHref":null,"contentManagementAccess":[],"portalOperator":"Tempo, Lda.","portalInstitutionalContactEmail":"comercial@tempo.co.mz","legacyContentEnabled":false,"educationEnabled":false,"aiEnabled":false,"existingBusinessEnabled":false,"businessReviewUrl":null,"investorPathEnabled":false,"opportunityManagement":null,"flash":{"success":null,"warning":null,"uploaded_document_id":null},"scopedAdministration":[],"auth":{"user":null,"canManagePortal":false,"legacyManagementAccess":false},"sidebarOpen":true,"results":{"current_page":1,"data":[{"id":51,"title":"Synthetic public offer","summary":"Approved public summary.","category":"business_sale","location":[],"price":{"mode":"amount","amount":"12345.67","currency":"MZN"},"status":"published","published_at":"2026-11-13T15:10:01+00:00","revision":1,"url":"\/ofertas\/51"}],"first_page_url":"http:\/\/localhost\/ofertas?page=1","from":1,"last_page":1,"last_page_url":"http:\/\/localhost\/ofertas?page=1","links":[{"url":null,"label":"&amp;laquo; Previous","page":null,"active":false},{"url":"http:\/\/localhost\/ofertas?page=1","label":"1","page":1,"active":true},{"url":null,"label":"Next &amp;raquo;","page":null,"active":false}],"next_page_url":null,"path":"http:\/\/localhost\/ofertas","per_page":12,"prev_page_url":null,"to":1,"total":1},"truncated":false,"filters":[],"mapData":null,"territorySelection":[],"references":{"categories":["investment_project","real_estate","land_rights","business_sale","business_partnership","franchise","concession","financing","supply","other"],"options":[],"clusters":[],"corridors":[],"projects":[],"locations":[],"sectors":[]}},"url":"\/ofertas","version":"da4443275cc023299b6be2756e75d511","sharedProps":["errors","name","administrationNavigation","dashboardNavigation","procurementEnabled","fundingEnabled","homologationReserved","profileAccess","contentManagementHref","contentManagementAccess","portalOperator","portalInstitutionalContactEmail","legacyContentEnabled","educationEnabled","aiEnabled","existingBusinessEnabled","businessReviewUrl","investorPathEnabled","opportunityManagement","flash","scopedAdministration","auth","sidebarOpen"]}&lt;/script&gt;&lt;div id="app"&gt;&lt;/div&gt;\n
&lt;/body&gt;\n
&lt;/html&gt;\n
' [ASCII](length: 16604) contains "assets/catalogue-QoLYHhHv.js" [ASCII](length: 28).
&#13;
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponseAssert.php:46
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:720
C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php:198</failure>
</testcase>
<testcase name="test_existing_catalogue_keeps_its_own_currency_and_price_validation_contract" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="202" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="3" time="0.120404"/>
<testcase name="test_native_combined_filters_and_export_with_self_contained_synthetic_references" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="210" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="22" time="1.148300"/>
</testsuite>
<testsuite name="Tests\Feature\OpportunitySearchTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" tests="6" assertions="112" errors="0" failures="0" skipped="0" time="3.046914">
<testcase name="test_one_park_selector_matches_public_associations_not_proximity_or_shared_territory" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="26" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="9" time="0.844338"/>
<testcase name="test_historical_and_current_preparation_with_equal_numbers_are_not_equivalent" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="38" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="6" time="0.376099"/>
<testcase name="test_amount_requires_currency_and_combines_with_sector_location_and_modality" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="47" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="10" time="0.536198"/>
<testcase name="test_zone_options_are_not_filtered_by_opportunity_text_or_modality" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="58" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="7" time="0.271733"/>
<testcase name="test_list_map_and_export_keep_the_same_public_park_results" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="67" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="16" time="0.454824"/>
<testcase name="test_public_catalogue_consumers_keep_their_own_result_collections" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="80" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="64" time="0.563721"/>
</testsuite>
</testsuite>
</testsuites>
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":1,"passed":1,"assertions":35,"duration_ms":27438}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="1" assertions="35" errors="0" failures="0" skipped="0" time="27.434490">
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="249" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="35" time="27.434490"/>
</testsuite>
</testsuites>
@@ -0,0 +1,279 @@
import { useEffect, useState } from 'react';
import { profileJson } from '@/lib/profile-json';
type Acceptance = { id: string; actor_id: number; version_hash: string };
type Grant = { interested_user_id: number; version_hash: string };
type Overview = {
seller: boolean;
nda_pending: boolean;
can_accept: boolean;
can_authorize: boolean;
version_hash: string | null;
nda: { sha256: string; version: number; fingerprint: string } | null;
requests: Acceptance[];
grants: Grant[];
acceptance: Acceptance | null;
etag: string | null;
history: { id: string; action: string; at: string; version_hash: string }[];
};
export default function OfferConfidentialAccess({
id,
onChanged,
}: {
id: number;
onChanged?: (etag: string) => void;
}) {
const [overview, setOverview] = useState<Overview | null>(null);
const [confirm, setConfirm] = useState(false);
const [reason, setReason] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [business, setBusiness] = useState<Record<
string,
string | number | null
> | null>(null);
const base = `/minhas-oportunidades/ofertas/${id}`;
useEffect(() => {
let active = true;
profileJson(`${base}/confidencialidade`, {}, 'GET')
.then((result) => {
if (active) setOverview(result);
})
.catch((error) => {
if (active) setMessage(error.message);
});
return () => {
active = false;
};
}, [base]);
async function act(action: string, target?: Acceptance | Grant) {
if (!overview || !confirm || busy) return;
setBusy(true);
setBusiness(null);
try {
await profileJson(`${base}/confidencialidade`, {
action,
confirm,
reason,
version_hash: target?.version_hash ?? overview.version_hash,
nda_sha256: overview.nda?.sha256,
nda_fingerprint: overview.nda?.fingerprint,
interested_user_id:
target &&
('actor_id' in target
? target.actor_id
: target.interested_user_id),
acceptance_id: target && 'id' in target ? target.id : undefined,
});
const next = await profileJson(
`${base}/confidencialidade`,
{},
'GET',
);
setOverview(next);
if (typeof next.etag === 'string') onChanged?.(next.etag);
setConfirm(false);
setMessage('Decisão registada para a versão indicada.');
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível registar.',
);
} finally {
setBusy(false);
}
}
async function read() {
setBusy(true);
setBusiness(null);
try {
const result = await profileJson(`${base}/confidencial`, {}, 'GET');
setBusiness(result.business);
setMessage('Acesso autorizado e registado para esta versão.');
} catch (error) {
setMessage(
error instanceof Error ? error.message : 'Acesso indisponível.',
);
} finally {
setBusy(false);
}
}
const labels: Record<string, string> = {
sector: 'Sector principal',
secondary_sector: 'Sector secundário',
revenue: 'Receita',
cash_flow: 'Lucro / fluxo de caixa',
currency: 'Moeda',
reason: 'Motivo da venda',
availability: 'Disponibilidade declarada',
};
return (
<section className="space-y-3 rounded-lg border p-4">
<h2 className="font-semibold">Acesso confidencial à empresa</h2>
<p>
O acesso depende da aceitação do NDA e da autorização expressa
do vendedor para a mesma versão.
</p>
{overview?.nda_pending && (
<p>
O exemplar aprovado do NDA ainda não foi disponibilizado. O
acesso confidencial mantém-se fechado.
</p>
)}
{overview?.nda && (
<a className="underline" href={`${base}/nda`}>
Consultar NDA — versão {overview.nda.version}
</a>
)}
{overview && (
<fieldset disabled={busy} className="space-y-3">
<label className="block">
{overview.seller
? 'Fundamento da decisão'
: 'Observação (opcional)'}
<textarea
className="block w-full rounded border p-2"
minLength={overview.seller ? 20 : undefined}
maxLength={1000}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
{overview.seller
? 'Confirmo a decisão expressa para o interessado e versão indicados.'
: 'Confirmo a operação para o exemplar e a versão indicados.'}
</label>
{!overview.seller && (
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded border p-2"
disabled={!confirm || !overview.can_accept}
onClick={() => void act('accept')}
>
Li e aceito — registar aceitação
</button>
{overview.acceptance && (
<button
type="button"
className="underline"
disabled={!confirm}
onClick={() =>
void act(
'withdraw',
overview.acceptance!,
)
}
>
Retirar aceitação
</button>
)}
<button
type="button"
className="underline"
disabled={overview.nda_pending}
onClick={() => void read()}
>
Consultar dados autorizados
</button>
</div>
)}
{overview.seller && (
<>
<ul className="space-y-2">
{overview.requests.map((item) => (
<li
key={item.id}
className="flex flex-wrap gap-3"
>
Interessado #{item.actor_id} · versão{' '}
{item.version_hash.slice(0, 12)}
<button
type="button"
className="underline"
disabled={
!confirm ||
reason.trim().length < 20 ||
!overview.can_authorize ||
overview.version_hash !==
item.version_hash
}
onClick={() =>
void act('authorize', item)
}
>
Autorizar esta aceitação
</button>
</li>
))}
</ul>
<ul>
{overview.grants.map((item) => (
<li key={item.interested_user_id}>
Autorização ao interessado #
{item.interested_user_id}
<button
type="button"
className="ml-3 underline"
disabled={
!confirm ||
reason.trim().length < 20
}
onClick={() =>
void act('revoke', item)
}
>
Revogar
</button>
</li>
))}
</ul>
</>
)}
</fieldset>
)}
{overview && overview.history.length > 0 && (
<details>
<summary>Histórico de acesso e decisões</summary>
<ul className="mt-3 space-y-2">
{overview.history.map((event) => (
<li key={event.id}>
{(
{
accept: 'Aceitação do NDA',
authorize: 'Autorização do vendedor',
revoke: 'Autorização revogada',
withdraw: 'Aceitação retirada',
} as Record<string, string>
)[event.action] ?? 'Decisão registada'}{' '}
· {new Date(event.at).toLocaleString('pt-MZ')} ·
versão {event.version_hash.slice(0, 12)}
</li>
))}
</ul>
</details>
)}
{business && (
<dl className="grid gap-2 sm:grid-cols-2">
{Object.entries(business).map(([key, value]) => (
<div key={key}>
<dt className="font-semibold">
{labels[key] ?? key}
</dt>
<dd className="break-words">
{value ?? 'Não indicado'}
</dd>
</div>
))}
</dl>
)}
<p role="status">{message}</p>
</section>
);
}
@@ -0,0 +1,193 @@
export type OfferParticulars = {
business?: Record<string, string | number | null> | null;
property?: Record<string, string | number | string[] | null> | null;
};
const control = 'w-full min-w-0 rounded-md border p-2';
const propertyLabels: Record<string, string> = {
house: 'Casa',
apartment: 'Apartamento',
office: 'Escritório',
retail: 'Espaço comercial',
warehouse: 'Armazém',
hospitality: 'Hotelaria',
industrial: 'Industrial',
mixed_use: 'Uso misto',
development: 'Empreendimento',
other: 'Outro',
};
export default function OfferParticularsFields({
value,
category,
sectors,
propertyTypes,
disabled,
onChange,
}: {
value: OfferParticulars;
category: string;
sectors: { id: number; name: string }[];
propertyTypes: string[];
disabled: boolean;
onChange: (next: OfferParticulars) => void;
}) {
if (!['business_sale', 'real_estate'].includes(category)) return null;
const business = category === 'business_sale';
const group = business ? 'business' : 'property';
const data = value[group] ?? {};
const update = (key: string, next: string | string[] | null) =>
onChange({ ...value, [group]: { ...data, [key]: next } });
const text = (key: string, label: string, numeric = false) => (
<label key={key} className="space-y-1">
{label}
<input
className={control}
value={String(data[key] ?? '')}
inputMode={numeric ? 'decimal' : 'text'}
maxLength={numeric ? 30 : 1000}
onChange={(e) => update(key, e.target.value || null)}
/>
</label>
);
const select = (
key: string,
label: string,
options: { value: string; label: string }[],
) => (
<label key={key} className="space-y-1">
{label}
<select
className={control}
value={String(data[key] ?? '')}
onChange={(e) => update(key, e.target.value || null)}
>
<option value="">Por indicar</option>
{options.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
);
return (
<fieldset
disabled={disabled}
className="space-y-4 rounded-lg border p-4"
>
<legend className="px-2 font-semibold">
{business
? 'Dados da empresa — acesso reservado'
: 'Características do imóvel'}
</legend>
<p className="text-sm">
Campos opcionais durante a preparação. A gravação não publica
estes dados.
</p>
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
{business ? (
<>
{['sector_id', 'secondary_sector_id'].map(
(key, index) =>
select(
key,
index
? 'Sector secundário'
: 'Sector principal',
sectors.map((sector) => ({
value: String(sector.id),
label: sector.name,
})),
),
)}
{text('revenue', 'Receita resumida', true)}
{text(
'cash_flow',
'Lucro ou fluxo de caixa resumido',
true,
)}
{select('currency', 'Moeda dos valores da empresa', [
{ value: 'MZN', label: 'MZN' },
{ value: 'USD', label: 'USD' },
])}
{text('reason', 'Motivo da venda')}
</>
) : (
<>
{select('transaction_type', 'Tipo de negócio', [
{ value: 'sale', label: 'Venda' },
{ value: 'lease', label: 'Arrendamento' },
])}
{select(
'property_type',
'Tipo de imóvel',
propertyTypes.map((type) => ({
value: type,
label: propertyLabels[type] ?? type,
})),
)}
{text('land_area', 'Área do terreno', true)}
{text('built_area', 'Área construída', true)}
{select('area_unit', 'Unidade da área', [
{ value: 'sqm', label: 'm²' },
{ value: 'ha', label: 'ha' },
{ value: 'sqkm', label: 'km²' },
])}
{[
'house',
'apartment',
'hospitality',
'mixed_use',
].includes(String(data.property_type)) && (
<>
{text('bedrooms', 'Quartos', true)}
{text('bathrooms', 'Casas de banho', true)}
</>
)}
<label>
Características e comodidades (uma por linha)
<textarea
className={control}
value={
Array.isArray(data.amenities)
? data.amenities.join('\n')
: ''
}
onChange={(e) =>
update(
'amenities',
e.target.value
.split('\n')
.filter(Boolean),
)
}
/>
</label>
</>
)}
{select('availability', 'Disponibilidade declarada', [
{ value: 'available', label: 'Disponível' },
{ value: 'sold', label: 'Vendido' },
...(!business
? [{ value: 'leased', label: 'Arrendado' }]
: []),
])}
</div>
{value[business ? 'property' : 'business'] && (
<button
type="button"
className="underline"
onClick={() =>
onChange({
...value,
[business ? 'property' : 'business']: null,
})
}
>
Retirar os dados do tipo anterior antes de guardar esta
categoria
</button>
)}
</fieldset>
);
}
@@ -0,0 +1,332 @@
import { useEffect, useState } from 'react';
import { profileJson } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
type Resource = {
id: string;
version: number;
deleted: boolean;
caption: string | null;
digitally_edited: boolean | null;
edit_description: string | null;
scan: string;
};
export default function OfferPrivateResources({
id,
etag,
editable,
property,
disabled,
onChanged,
}: {
id: number;
etag: string;
editable: boolean;
property: boolean;
disabled: boolean;
onChanged: (etag: string) => void;
}) {
const [resources, setResources] = useState<{
images: Resource[];
documents: Resource[];
}>({ images: [], documents: [] });
const [internal, setInternal] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState('images');
const [caption, setCaption] = useState('');
const [edited, setEdited] = useState(false);
const [editDescription, setEditDescription] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState<Resource | null>(null);
const base = `/minhas-oportunidades/ofertas/${id}/recursos`;
async function refresh() {
const result = await profileJson(base, {}, 'GET');
setResources(result.resources);
setInternal(result.internalProofAccess);
onChanged(result.etag);
}
useEffect(() => {
let active = true;
profileJson(base, {}, 'GET')
.then((result) => {
if (active) {
setResources(result.resources);
setInternal(result.internalProofAccess);
}
})
.catch((error) => {
if (active) setMessage(error.message);
});
return () => {
active = false;
};
}, [base]);
async function upload() {
if ((!file && !editing) || busy || disabled) return;
setBusy(true);
setMessage('');
const form = new FormData();
if (file) form.append('file', file);
form.append('etag', etag);
if (editing) form.append('version', String(editing.version));
else form.append('creation_key', createOperationKey());
form.append(
'reason',
'Envio privado de suporte da oferta pelo promotor.',
);
form.append(
'payload',
JSON.stringify(
kind === 'images'
? {
visibility: 'internal',
caption,
alt_text: caption,
digitally_edited: edited,
edit_description: edited ? editDescription : null,
}
: {
visibility: 'internal',
classification: 'restricted',
category: 'real_estate_proof',
},
),
);
try {
await profileJson(
editing
? `${base}/images/${editing.id}/guardar`
: `${base}/${kind}`,
form,
);
await refresh();
setFile(null);
setEditing(null);
setMessage(
'Original guardado em privado. A análise de segurança pode ainda estar pendente.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível guardar.',
);
} finally {
setBusy(false);
}
}
async function lifecycle(resource: Resource, resourceKind: string) {
setBusy(true);
try {
await profileJson(`${base}/${resourceKind}/${resource.id}`, {
etag,
version: resource.version,
action: resource.deleted ? 'restore' : 'delete',
confirm: true,
reason: 'Alteração explícita dos suportes privados da oferta.',
});
await refresh();
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível alterar.',
);
} finally {
setBusy(false);
}
}
return (
<section className="space-y-4 rounded-lg border p-4">
<h2 className="font-semibold">Fotografias e suportes privados</h2>
<p>
Até seis fotografias. JPEG, PNG ou WebP, até 10 MB e pelo menos
400 × 400 píxeis. Os comprovativos são reservados à validação
interna; o envio não certifica titularidade.
</p>
{disabled && <p>Guarde os dados antes de alterar os ficheiros.</p>}
{editable && (
<fieldset
disabled={busy || disabled}
className="grid gap-3 sm:grid-cols-2"
>
<label>
Tipo
<select
className="w-full rounded border p-2"
value={kind}
disabled={editing !== null}
onChange={(e) => setKind(e.target.value)}
>
<option value="images">Fotografia</option>
{property && (
<option value="documents">
Comprovativo imobiliário
</option>
)}
</select>
</label>
<label>
Ficheiro
<input
type="file"
className="block w-full min-w-0"
accept={
kind === 'images'
? 'image/jpeg,image/png,image/webp'
: '.pdf,.docx,.xlsx,.png,.jpg,.jpeg'
}
onChange={(e) =>
setFile(e.target.files?.[0] ?? null)
}
/>
</label>
{kind === 'images' && (
<>
<label>
Legenda / divisão
<input
className="w-full rounded border p-2"
maxLength={250}
value={caption}
onChange={(e) => setCaption(e.target.value)}
/>
</label>
<label>
<input
type="checkbox"
checked={edited}
onChange={(e) =>
setEdited(e.target.checked)
}
/>{' '}
Imagem editada digitalmente
</label>
{edited && (
<label>
Alterações efectuadas
<textarea
className="w-full rounded border p-2"
maxLength={1000}
value={editDescription}
onChange={(e) =>
setEditDescription(e.target.value)
}
/>
</label>
)}
</>
)}
<button
type="button"
className="rounded border p-2"
disabled={
(!file && !editing) ||
(!editing &&
kind === 'images' &&
resources.images.filter((item) => !item.deleted)
.length >= 6)
}
onClick={() => void upload()}
>
{editing
? 'Guardar alterações da fotografia'
: 'Guardar ficheiro privado'}
</button>
</fieldset>
)}
{editing && (
<button
type="button"
className="underline"
disabled={busy}
onClick={() => {
setEditing(null);
setFile(null);
}}
>
Cancelar alteração da fotografia
</button>
)}
{(['images', 'documents'] as const).map((resourceKind) => (
<ul key={resourceKind} className="space-y-2">
{resources[resourceKind].map((resource) => (
<li
key={resource.id}
className="flex flex-wrap gap-3 break-words"
>
<span>
{resource.caption ||
(resourceKind === 'images'
? 'Fotografia'
: 'Comprovativo')}{' '}
· versão {resource.version} ·{' '}
{resource.deleted
? 'Retirado'
: resource.scan === 'clean'
? 'Análise concluída'
: 'Análise de segurança pendente'}
</span>
{!resource.deleted &&
resource.scan === 'clean' &&
(resourceKind === 'images' || internal) && (
<a
className="underline"
href={`${base}/${resourceKind}/${resource.id}`}
>
Consultar original
</a>
)}
{editable &&
resourceKind === 'images' &&
!resource.deleted && (
<button
type="button"
className="underline"
disabled={busy || disabled}
onClick={() => {
setEditing(resource);
setKind('images');
setCaption(resource.caption ?? '');
setEdited(
resource.digitally_edited ??
false,
);
setEditDescription(
resource.edit_description ?? '',
);
setFile(null);
}}
>
Alterar fotografia
</button>
)}
{editable && (
<button
className="underline"
disabled={busy || disabled}
onClick={() =>
void lifecycle(resource, resourceKind)
}
>
{resource.deleted ? 'Restaurar' : 'Retirar'}
</button>
)}
</li>
))}
</ul>
))}
<button
type="button"
className="underline"
disabled={busy || disabled}
onClick={() =>
void refresh().catch((error) => setMessage(error.message))
}
>
Actualizar estado dos ficheiros
</button>
<p role="status">{message}</p>
</section>
);
}
@@ -0,0 +1,11 @@
import { Head } from '@inertiajs/react';
import OfferConfidentialAccess from '@/components/offer-confidential-access';
export default function OfferConfidentiality({ offerId }: { offerId: number }) {
return (
<main className="mx-auto w-full max-w-3xl space-y-4 p-4">
<Head title="Acesso confidencial" />
<h1 className="text-2xl font-semibold">Acesso confidencial</h1>
<OfferConfidentialAccess id={offerId} />
</main>
);
}
+45 -1
View File
@@ -10,8 +10,13 @@ import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import OfferEditorialReview from '@/components/offer-editorial-review';
import { type OfferEditorialRecord } from '@/lib/offer-editorial';
import OfferParticularsFields, {
type OfferParticulars,
} from '@/components/offer-particulars';
import OfferPrivateResources from '@/components/offer-private-resources';
import OfferConfidentialAccess from '@/components/offer-confidential-access';
type Offer = {
type Offer = OfferParticulars & {
price_mode?: string | null;
amount?: string | null;
currency?: string | null;
@@ -49,11 +54,15 @@ export default function OfferPreparation({
contexts,
contacts,
contactInvestors,
sectors,
propertyTypes,
}: {
record: RecordData | null;
contexts: Context[];
contacts: Contact[];
contactInvestors: { id: string; legal_name: string }[];
sectors: { id: number; name: string }[];
propertyTypes: string[];
}) {
const [saved, setSaved] = useState(record);
const [creationKey] = useState(createOperationKey);
@@ -417,6 +426,41 @@ export default function OfferPreparation({
</button>
)}
</form>
<OfferParticularsFields
value={offer}
category={context.type}
sectors={sectors}
propertyTypes={propertyTypes}
disabled={busy || readOnly}
onChange={(next) => updateOffer({ ...offer, ...next })}
/>
{saved && (
<OfferPrivateResources
id={saved.id}
etag={saved.etag}
editable={saved.canEdit}
property={context.type === 'real_estate'}
disabled={dirty || busy}
onChanged={(etag) => {
setSaved((previous) =>
previous ? { ...previous, etag } : previous,
);
setConfirmed(false);
}}
/>
)}
{saved && context.type === 'business_sale' && (
<OfferConfidentialAccess
key={`${saved.id}-${saved.state}`}
id={saved.id}
onChanged={(etag) => {
setSaved((previous) =>
previous ? { ...previous, etag } : previous,
);
setConfirmed(false);
}}
/>
)}
{saved?.canEdit && (
<details className="rounded-lg border p-4">
<summary className="cursor-pointer font-medium">
+9
View File
@@ -243,6 +243,15 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::post('/{id}/guardar', [$offers, 'save'])->whereNumber('id');
Route::post('/{id}/submeter', [$offers, 'submit'])->whereNumber('id');
Route::post('/{id}/revisao', [$offers, 'transition'])->whereNumber('id');
Route::get('/{id}/recursos', [$offers, 'resources'])->whereNumber('id');
Route::post('/{id}/recursos/{kind}', [OpportunityResourceController::class, 'save'])->whereNumber('id')->whereIn('kind', ['images', 'documents']);
Route::post('/{id}/recursos/{kind}/{resourceId}/guardar', [OpportunityResourceController::class, 'save'])->whereNumber('id')->whereIn('kind', ['images', 'documents'])->whereUuid('resourceId');
Route::post('/{id}/recursos/{kind}/{resourceId}', [OpportunityResourceController::class, 'lifecycle'])->whereNumber('id')->whereIn('kind', ['images', 'documents'])->whereUuid('resourceId');
Route::get('/{id}/recursos/{kind}/{resourceId}', [$offers, 'attachment'])->whereNumber('id')->whereIn('kind', ['images', 'documents'])->whereUuid('resourceId');
Route::get('/{id}/confidencialidade', [$offers, 'confidentiality'])->whereNumber('id');
Route::post('/{id}/confidencialidade', [$offers, 'confidentialityAction'])->whereNumber('id');
Route::get('/{id}/confidencial', [$offers, 'confidentialRead'])->whereNumber('id');
Route::get('/{id}/nda', [$offers, 'nda'])->whereNumber('id');
});
Route::get('/minhas-oportunidades/oferta-interna', [ProactiveSupplyController::class, 'index'])->middleware('throttle:60,1');
Route::get('/minhas-oportunidades/oferta-interna/revisao', [ProactiveSupplyController::class, 'review'])->middleware(['throttle:60,1', PrivateInvestmentResponse::class]);
@@ -0,0 +1,348 @@
<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAdministration;
use App\Services\PrivateOfferConfidentiality;
use App\Services\PrivateOfferPreparation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
final class PrivateOfferConfidentialityTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/minhas-oportunidades/ofertas';
private const REASON = 'Operação exclusivamente sintética na base QA isolada.';
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('local');
Queue::fake();
Mail::fake();
config(['portal.profile_access_enabled' => true, 'editorial_workflow.enabled' => true, 'private_offers.nda' => null]);
}
private function actor(?string $role = null, string $category = 'business_sale'): User
{
$actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()]);
if ($role) {
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
'user_id' => $actor->id, 'role' => $role, 'organization_id' => null, 'responsibilities' => [], 'opportunity_types' => [$category], 'source' => 'synthetic-test-fixture']);
}
return $actor;
}
private function login(User $actor): static
{
$actor = $actor->fresh();
return $this->actingAs($actor)->withSession(['login.id' => null, 'portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->fresh()->portal_access_revision]);
}
private function draft(User $owner, string $category = 'business_sale', array $details = []): Opportunity
{
$contact = (string) Str::uuid();
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'PRIVATE-CONTACT-CANARY', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
$result = $this->login($owner)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(), 'title' => 'Synthetic QA private offer',
'description' => 'PRIVATE-DESCRIPTION-CANARY', 'opportunity_category' => $category,
'private_offer' => ['price_mode' => 'amount', 'amount' => '100.01', 'currency' => 'MZN', 'contact_id' => $contact, ...$details], 'reason' => self::REASON])->assertCreated();
return Opportunity::findOrFail($result->json('record.id'));
}
private function etag(Opportunity $record): string
{
return app(OpportunityManagement::class)->etag($record->fresh());
}
private function approve(User $owner, Opportunity $record): void
{
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertOk();
$reviewer = $this->actor('validator', $record->opportunity_category);
foreach (['start_review', 'approve'] as $action) {
$this->login($reviewer)->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => $action, 'reason' => self::REASON])->assertOk();
}
}
private function nda(): array
{
// Deliberately non-legal bytes, never registered as an operational NDA.
$bytes = "%PDF-1.7\nSYNTHETIC QA - NOT A LEGAL AGREEMENT\n%%EOF";
$id = (string) Str::uuid();
$sha = hash('sha256', $bytes);
Storage::disk('local')->put('qa-nda/'.$id, Crypt::encryptString($bytes));
DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic-qa.pdf', 'media_type' => 'application/pdf', 'storage_key' => 'qa-nda/'.$id,
'storage_encryption' => 'laravel', 'sha256' => $sha, 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $sha,
'malware_engine' => 'QA-Test-Double', 'version_no' => 1, 'data_classification' => 'restricted']);
config(['private_offers.nda' => ['document_id' => $id, 'version' => 1, 'sha256' => $sha, 'approval_reference' => 'SYNTHETIC-QA-NOT-LEGAL']]);
return app(PrivateOfferConfidentiality::class)->nda();
}
private function command(Opportunity $offer, array $nda, string $action, array $extra = []): array
{
return ['action' => $action, 'version_hash' => $offer->fresh()->workflow_context['approval']['hash'], 'nda_sha256' => $nda['sha256'],
'nda_fingerprint' => $nda['fingerprint'], 'confirm' => true, 'reason' => self::REASON, ...$extra];
}
public function test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner, details: ['business' => ['revenue' => '10000000000000.01', 'cash_flow' => '-123.45', 'currency' => 'USD', 'reason' => 'PRIVATE-FINANCIAL-CANARY']]);
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.values.private_offer.business.cash_flow', '-123.45');
$this->approve($owner, $record);
$this->assertSame('private-offer-editorial-v3', $record->fresh()->workflow_context['submission']['contract_version']);
$this->assertSame('-123.45', $record->fresh()->workflow_context['submission']['payload']['private_offer']['business']['cash_flow']);
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/guardar', ['etag' => $this->etag($record), 'private_offer' => ['business' => ['reason' => 'Changed']], 'reason' => self::REASON])->assertForbidden();
}
public function test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced(): void
{
$owner = $this->actor('promoter', 'real_estate');
$record = $this->draft($owner, 'real_estate', ['property' => ['transaction_type' => 'lease', 'property_type' => 'house', 'built_area' => '120.50', 'area_unit' => 'sqm', 'bedrooms' => 3, 'bathrooms' => 2, 'amenities' => ['Água', 'Acesso']]]);
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.values.private_offer.property.bedrooms', 3);
foreach ([['business' => ['revenue' => '5', 'currency' => 'USD']], ['property' => ['title_certified' => true]], ['property' => ['property_type' => 'invented']]] as $invalid) {
$this->postJson(self::BASE.'/'.$record->id.'/guardar', ['etag' => $this->etag($record), 'private_offer' => $invalid, 'reason' => self::REASON])->assertUnprocessable();
}
$this->assertSame(3, app(OpportunityManagement::class)->values($record->fresh())['private_offer']['property']['bedrooms']);
}
public function test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$ids = [];
for ($index = 0; $index < 6; $index++) {
$response = $this->upload($owner, $record, 'images', $this->png('qa-'.$index.'.png', 400 + $index, 400));
$ids[] = $response->assertCreated()->json('id');
}
$this->upload($owner, $record, 'images', $this->png('seventh.png', 420, 420))->assertUnprocessable();
$this->assertDatabaseCount('opportunity_images', 6);
$this->assertCount(6, Storage::disk('local')->allFiles('opportunity-resources'));
$life = ['etag' => $this->etag($record), 'version' => 1, 'confirm' => true, 'action' => 'delete', 'reason' => self::REASON];
$this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$ids[0], $life)->assertOk();
$this->upload($owner, $record, 'images', $this->png('new.png', 430, 430))->assertCreated();
$restore = [...$life, 'etag' => $this->etag($record), 'version' => 2, 'action' => 'restore'];
$this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$ids[0], $restore)->assertUnprocessable();
// The accepted F1 guard closes the native promoter route for private offers.
$this->postJson('/minhas-oportunidades/'.$record->id.'/resources/images/'.$ids[0].'/lifecycle', $restore)->assertForbidden();
$this->assertSame(6, DB::table('opportunity_images')->whereNull('deleted_at')->count());
}
private function upload(User $owner, Opportunity $record, string $kind, UploadedFile $file, array $override = []): TestResponse
{
$payload = $kind === 'images' ? ['visibility' => 'internal', 'caption' => 'Synthetic QA', 'digitally_edited' => false] : ['visibility' => 'internal', 'classification' => 'restricted', 'category' => 'real_estate_proof'];
return $this->login($owner)->post(self::BASE.'/'.$record->id.'/recursos/'.$kind, ['etag' => $this->etag($record), 'creation_key' => (string) Str::uuid(), 'reason' => self::REASON,
'payload' => json_encode([...$payload, ...$override]), 'file' => $file], ['Accept' => 'application/json']);
}
/** Valid synthetic PNG bytes, independent of the optional GD extension. */
private function png(string $name, int $width, int $height): UploadedFile
{
$chunk = static fn (string $type, string $bytes): string => pack('N', strlen($bytes)).$type.$bytes.pack('N', crc32($type.$bytes));
$pixels = gzcompress(str_repeat("\0".str_repeat("\0", $width * 3), $height));
if ($pixels === false) {
throw new \RuntimeException('Synthetic PNG compression failed.');
}
return UploadedFile::fake()->createWithContent($name, "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 2, 0, 0, 0)).$chunk('IDAT', $pixels).$chunk('IEND', ''));
}
public function test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->upload($owner, $record, 'images', UploadedFile::fake()->createWithContent('fake.jpg', '<?php echo 1;'))->assertUnprocessable();
$this->upload($owner, $record, 'images', $this->png('small.png', 20, 20))->assertUnprocessable();
$this->upload($owner, $record, 'images', $this->png('edited.png', 400, 400), ['digitally_edited' => true])->assertUnprocessable();
$this->upload($owner, $record, 'images', $this->png('public.png', 400, 400), ['visibility' => 'public'])->assertUnprocessable();
$id = $this->upload($owner, $record, 'images', $this->png('good.png', 400, 400))->assertCreated()->json('id');
$this->getJson(self::BASE.'/'.$record->id.'/recursos/images/'.$id)->assertNotFound();
$this->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertUnprocessable();
$this->assertSame('draft', $record->fresh()->status);
}
public function test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them(): void
{
$owner = $this->actor('promoter', 'real_estate');
$record = $this->draft($owner, 'real_estate');
$bytes = "%PDF-1.7\nSYNTHETIC QA PROPERTY PROOF - NOT A TITLE\n%%EOF";
$resourceId = $this->upload($owner, $record, 'documents', UploadedFile::fake()->createWithContent('qa.pdf', $bytes))->assertCreated()->json('id');
$link = DB::table('opportunity_documents')->where('id', $resourceId)->first();
$doc = DB::table('documents')->where('id', $link->document_id)->first();
$this->assertNotSame($bytes, Storage::disk('local')->get($doc->storage_key));
(new ScanDocumentForMalware($doc->id))->handle(new CleanScannerFixture);
$url = self::BASE.'/'.$record->id.'/recursos/documents/'.$resourceId;
$this->login($owner)->get($url)->assertForbidden();
$this->get('/minhas-oportunidades/'.$record->id.'/resources/documents/'.$resourceId.'/download')->assertForbidden();
$reviewer = $this->actor('validator', 'real_estate');
$this->login($reviewer)->get($url)->assertOk()->assertContent($bytes)->assertHeader('Cache-Control', 'no-store, private');
$this->login($this->actor('validator', 'business_sale'))->get($url)->assertForbidden();
$this->login($this->actor('auditor', 'real_estate'))->get($url)->assertForbidden();
$this->login($reviewer)->withSession(['portal.mfa_at' => time() - 1000])->get($url)->assertForbidden();
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'offer_proof_access_authorized')->count());
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'offer_proof_access_denied')->count());
}
public function test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$interested = $this->actor();
$this->login($interested)->getJson(self::BASE.'/'.$record->id.'/confidencialidade')->assertOk()->assertJsonPath('nda_pending', true)->assertJsonPath('requests', []);
$this->getJson(self::BASE.'/'.$record->id.'/confidencial')->assertForbidden();
$this->get(self::BASE.'/'.$record->id.'/nda')->assertConflict();
$this->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $this->command($record, ['sha256' => str_repeat('0', 64), 'fingerprint' => str_repeat('0', 64)], 'accept'))->assertConflict();
$nda = $this->nda();
$command = $this->command($record, $nda, 'accept');
unset($command['reason']); // Accepting an identified NDA does not require invented justification.
$this->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $command)->assertOk();
$this->getJson(self::BASE.'/'.$record->id.'/confidencial')->assertForbidden();
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => (string) Str::uuid()]))->assertConflict();
}
public function test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner, details: ['business' => ['revenue' => '789.01', 'currency' => 'MZN', 'reason' => 'PRIVATE-FINANCIAL-CANARY']]);
$this->approve($owner, $record);
$nda = $this->nda();
$interested = $this->actor();
$url = self::BASE.'/'.$record->id;
$this->login($interested)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertOk();
$accept = $this->getJson($url.'/confidencialidade')->json('acceptance');
$grant = $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $accept['id']]);
$this->login($this->actor('validator'))->postJson($url.'/confidencialidade', $grant)->assertForbidden();
$this->login($owner)->postJson($url.'/confidencialidade', $grant)->assertOk();
$data = $this->login($interested)->getJson($url.'/confidencial')->assertOk()->assertJsonPath('business.revenue', '789.01')->getContent();
$this->assertStringNotContainsString('PRIVATE-CONTACT-CANARY', $data);
$this->assertStringNotContainsString('PRIVATE-DESCRIPTION-CANARY', $data);
$this->login($this->actor())->getJson($url.'/confidencial')->assertForbidden();
$this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'revoke', ['interested_user_id' => $interested->id]))->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertForbidden();
$this->assertSame(1, DB::table('audit_events')->where('action', 'offer_confidential_access')->count());
$this->assertSame(1, DB::table('audit_events')->where('action', 'offer_confidentiality_revoke')->count());
$this->assertNull($record->fresh()->published_at);
}
public function test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner, details: ['business' => ['revenue' => '900.00', 'currency' => 'USD', 'reason' => 'PRIVATE-VERSION-CANARY']]);
$this->approve($owner, $record);
$nda = $this->nda();
$interested = $this->actor();
$url = self::BASE.'/'.$record->id;
$authorize = function (array $currentNda) use ($owner, $record, $interested, $url) {
$this->login($interested)->postJson($url.'/confidencialidade', $this->command($record, $currentNda, 'accept'))->assertOk();
$acceptance = $this->getJson($url.'/confidencialidade')->json('acceptance');
$this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $currentNda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $acceptance['id']]))->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertOk();
};
$authorize($nda);
// Identical document bytes in another approved version do not reuse the acceptance.
DB::table('documents')->where('id', $nda['document_id'])->update(['version_no' => 2]);
config(['private_offers.nda.version' => 2]);
$this->getJson($url.'/confidencial')->assertForbidden();
$this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertConflict();
$nda = app(PrivateOfferConfidentiality::class)->nda();
$authorize($nda);
$this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'withdraw'))->assertOk();
$this->getJson($url.'/confidencial')->assertForbidden();
$authorize($nda);
DB::table('users')->where('id', $interested->id)->increment('portal_access_revision');
$this->login($interested->fresh())->getJson($url.'/confidencial')->assertForbidden();
$authorize($nda);
$withdraw = $this->command($record, $nda, 'withdraw');
$this->login($owner)->postJson($url.'/revisao', ['etag' => $this->etag($record), 'action' => 'request_revision', 'reason' => self::REASON])->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertConflict();
$this->getJson($url.'/confidencialidade')->assertOk()->assertJsonPath('can_accept', false)->assertJsonPath('etag', null);
$this->postJson($url.'/confidencialidade', $withdraw)->assertOk();
$this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0);
$this->getJson('/api/v1/offers/'.$record->id)->assertNotFound();
$this->assertStringNotContainsString('PRIVATE-VERSION-CANARY', $this->get('/api/v1/offers/export')->assertOk()->streamedContent());
$this->getJson('/api/v1/offers/map')->assertOk()->assertJsonPath('features', []);
$this->assertSame(2, DB::table('audit_events')->where('action', 'offer_confidentiality_withdraw')->count());
}
public function test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$this->approve($owner, $record);
$interested = $this->actor();
$nda = $this->nda();
$url = self::BASE.'/'.$record->id;
$this->login($interested)->get($url.'/nda')->assertOk()->assertSee('SYNTHETIC QA - NOT A LEGAL AGREEMENT');
$this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertOk();
$accept = $this->getJson($url.'/confidencialidade')->json('acceptance');
$this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $accept['id']]))->assertOk();
$this->login($interested)->getJson($url.'/confidencial')->assertOk();
DB::table('portal_role_assignments')->where('user_id', $owner->id)->where('role', 'promoter')->update(['revoked_at' => now()]);
$this->getJson($url.'/confidencial')->assertForbidden();
// Restore this synthetic fixture only, to isolate the byte-integrity check below.
DB::table('portal_role_assignments')->where('user_id', $owner->id)->where('role', 'promoter')->update(['revoked_at' => null]);
$doc = DB::table('documents')->where('id', $nda['document_id'])->first();
Storage::disk('local')->put($doc->storage_key, Crypt::encryptString('TAMPERED SYNTHETIC BYTES'));
$this->getJson($url.'/confidencial')->assertForbidden();
$this->getJson($url.'/confidencialidade')->assertOk()->assertJsonPath('nda_pending', true);
$this->assertNull($record->fresh()->published_at);
}
public function test_compiled_confidential_page_has_no_company_data_before_both_conditions(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner, details: ['business' => ['reason' => 'PRIVATE-HTML-CANARY']]);
$this->approve($owner, $record);
$directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build';
$manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
$this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot')));
$entry = 'resources/js/pages/offers/confidentiality.tsx';
$this->assertArrayHasKey($entry, $manifest);
$this->login($this->actor())->get(self::BASE.'/'.$record->id.'/confidencialidade', ['Accept' => 'text/html'])->assertOk()
->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$entry]['file'], false)->assertDontSee('PRIVATE-HTML-CANARY')->assertDontSee('PRIVATE-CONTACT-CANARY');
}
public function test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint(): void
{
$owner = $this->actor('promoter');
$record = $this->draft($owner);
$resourceId = $this->upload($owner, $record, 'images', $this->png('metadata.png', 400, 400))->assertCreated()->json('id');
$resource = DB::table('opportunity_images')->where('id', $resourceId)->first();
(new ScanDocumentForMalware($resource->document_id))->handle(new CleanScannerFixture);
$before = app(PrivateOfferPreparation::class)->snapshot($record->fresh());
$this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$resourceId.'/guardar', ['etag' => $this->etag($record), 'version' => 1,
'payload' => ['visibility' => 'internal', 'caption' => 'Changed synthetic caption', 'digitally_edited' => true, 'edit_description' => 'Synthetic QA declared edit'], 'reason' => self::REASON])->assertOk();
$after = app(PrivateOfferPreparation::class)->snapshot($record->fresh());
$this->assertNotSame($before['hash'], $after['hash']);
$this->assertSame(1, $before['resources']['images'][$resourceId]['version']);
$this->assertSame(2, $after['resources']['images'][$resourceId]['version']);
$this->assertSame($resource->document_id, $after['resources']['images'][$resourceId]['document_id']);
$this->assertTrue($after['resources']['images'][$resourceId]['payload']['digitally_edited']);
$this->approve($owner, $record);
DB::table('documents')->where('id', $resource->document_id)->update(['status' => 'quarantined', 'malware_scan_status' => 'pending']);
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.approvalFresh', false);
$this->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => 'request_revision', 'reason' => self::REASON])->assertOk()->assertJsonPath('record.state', 'needs_information')->assertJsonPath('record.canEdit', true);
}
}
+21 -2
View File
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\InvestmentGeography;
use App\Services\OpportunityMaturity;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class OpportunitySearchFixture
@@ -20,9 +21,27 @@ final class OpportunitySearchFixture
}
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('name', 'Inhambane')->first();
if (! $province) {
throw new \LogicException('QA requires the existing public Inhambane reference');
// Tests must not depend on an operational import. These placeholders exist
// only in the already isolated test database and caller's rollback transaction.
if (DB::transactionLevel() < 1) {
throw new \LogicException('Synthetic references require test rollback');
}
$country = (string) Str::uuid();
$id = (string) Str::uuid();
foreach ([[$country, 'country', null, 'Synthetic QA country'], [$id, 'province', $country, 'Inhambane']] as [$key, $type, $parent, $name]) {
DB::table('geo_locations')->insert(['id' => $key, 'code' => 'SYNTHETIC-QA-'.$key, 'name' => $name,
'location_type' => $type, 'parent_id' => $parent, 'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$key]);
}
$province = app(InvestmentGeography::class)->publicReferences()->where('id', $id)->first();
}
$sector = Sector::where('slug', 'turismo')->first();
if (! $sector) {
if (DB::transactionLevel() < 1) {
throw new \LogicException('Synthetic sector requires test rollback');
}
$sector = Sector::create(['slug' => 'turismo', 'name' => 'Synthetic QA tourism', 'is_active' => true]);
}
$sector = Sector::where('slug', 'turismo')->firstOrFail();
$editor = User::factory()->create();
$reviewer = User::factory()->create();
$zone = InvestmentZone::query()->create(['id' => (string) Str::uuid(), 'code' => 'SINT-QA-'.Str::random(8),