Binary file not shown.
@@ -0,0 +1,164 @@
|
||||
"""Verify the actual QA browser response protocol; never claim visual acceptance."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
diagnosis = json.loads((private / 'mfa-key-diagnosis-qa.json').read_text(encoding='utf-8-sig'))
|
||||
target = diagnosis['target']
|
||||
assert re.fullmatch(r'desk_restore_\d{8}_\d{6}_test', target['database']) and target['port'] == 55441
|
||||
BASE = 'http://127.0.0.1:18097'
|
||||
events = []
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class BrowserProtocol:
|
||||
def __init__(self, label):
|
||||
self.label = label
|
||||
self.jar = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar), NoRedirect())
|
||||
self.version = None
|
||||
self.page('/login', initial=True)
|
||||
|
||||
def call(self, method, path, data=None, status=200, initial=False):
|
||||
headers = {'Accept': 'text/html', 'Content-Type': 'application/json'}
|
||||
if not initial:
|
||||
headers.update({'X-Inertia': 'true', 'X-Inertia-Version': self.version})
|
||||
for cookie in self.jar:
|
||||
if cookie.name == 'XSRF-TOKEN':
|
||||
headers['X-XSRF-TOKEN'] = urllib.parse.unquote(cookie.value)
|
||||
req = urllib.request.Request(BASE + path, data=None if data is None else json.dumps(data).encode(), headers=headers, method=method)
|
||||
try:
|
||||
response = self.opener.open(req)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
assert response.headers.get('X-Investmoz-QA-Target') == target['database'] + ':55441', 'QA target mismatch; aborted'
|
||||
assert response.headers.get('X-Investmoz-QA-Key-Fingerprint') == diagnosis['qa_key_fingerprint'], 'QA key mismatch; aborted'
|
||||
assert response.status == status, f'{method} {path}: expected {status}, observed {response.status}; body withheld'
|
||||
location = response.headers.get('Location')
|
||||
if location:
|
||||
parsed = urllib.parse.urlsplit(urllib.parse.urljoin(BASE + '/', location))
|
||||
expected_origin = urllib.parse.urlsplit(BASE)
|
||||
assert (parsed.scheme, parsed.netloc) == (expected_origin.scheme, expected_origin.netloc), 'Redirect leaves the QA origin; aborted'
|
||||
location = parsed.path or '/'
|
||||
events.append({'session_label': self.label, 'method': method, 'path': path, 'status': response.status,
|
||||
'redirect_path': location, 'trace': response.headers.get('X-Investmoz-QA-Auth-Trace')})
|
||||
return response.read(), location
|
||||
|
||||
def page(self, path, initial=False):
|
||||
raw, _ = self.call('GET', path, initial=initial)
|
||||
if initial:
|
||||
m = re.search(rb'<script[^>]*data-page="app"[^>]*>(.*?)</script>', raw, re.S)
|
||||
assert m, 'Inertia page missing; body withheld'
|
||||
page = json.loads(m.group(1))
|
||||
else:
|
||||
page = json.loads(raw)
|
||||
self.version = page['version']
|
||||
return page
|
||||
|
||||
def identity(self):
|
||||
values = [c.value for c in self.jar if c.name == 'opportunity_resources_qa']
|
||||
assert len(values) == 1
|
||||
return hashlib.sha256(values[0].encode()).digest() # In-memory comparison only.
|
||||
|
||||
|
||||
def otp(secret):
|
||||
digest = hmac.new(base64.b32decode(secret), struct.pack('>Q', int(time.time()) // 30), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 15
|
||||
return f'{(struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7fffffff) % 1000000:06d}'
|
||||
|
||||
|
||||
owner = json.loads((private / 'investor-mfa-qa-private.json').read_text(encoding='utf-8-sig'))
|
||||
assert owner['actor_id'] == 18
|
||||
selector = subprocess.run(['php', '-r', r"require 'tools/portal-qa-environment.php'; $target=portalQaEnvironment(); require 'vendor/autoload.php'; $app=require 'bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); portalQaAssertTarget($target); echo json_encode(\Illuminate\Support\Facades\DB::table('users')->where('id',13)->first(['id','email']),JSON_THROW_ON_ERROR);"], cwd=root, capture_output=True)
|
||||
assert selector.returncode == 0, 'Guarded selector read failed; output withheld'
|
||||
restored = json.loads(selector.stdout)
|
||||
assert restored['id'] == 13 and not restored['email'].endswith('@example.invalid')
|
||||
bad = BrowserProtocol('restored-account-selection-negative')
|
||||
_, redirect = bad.call('POST', '/login', {'email': restored['email'], 'password': 'synthetic-placeholder'}, status=302)
|
||||
assert redirect == '/login'
|
||||
page = bad.page('/login')
|
||||
assert page['props']['qaAccount']['id'] == 18 and not page['props']['auth']['user']
|
||||
assert 'contas sintéticas' in page['props']['errors']['email']
|
||||
|
||||
normal = BrowserProtocol('synthetic-investor18-first-session')
|
||||
assert normal.call('GET', '/investimentos', status=302)[1] == '/login'
|
||||
normal.page('/login')
|
||||
assert normal.call('POST', '/login', {k: owner[k] for k in ['email', 'password']}, status=302)[1] == '/two-factor-challenge'
|
||||
challenge = normal.page('/two-factor-challenge')
|
||||
assert challenge['props']['qaAccount']['id'] == 18 and not challenge['props']['auth']['user']
|
||||
assert normal.call('POST', '/two-factor-challenge', {'code': 'invalid'}, status=302)[1] == '/two-factor-challenge'
|
||||
challenge = normal.page('/two-factor-challenge')
|
||||
assert challenge['props']['errors']['code'] and not challenge['props']['auth']['user']
|
||||
before = normal.identity()
|
||||
slot = int(time.time()) // 30
|
||||
assert normal.call('POST', '/two-factor-challenge', {'code': otp(owner['totp_secret'])}, status=302)[1] == '/investimentos'
|
||||
assert normal.identity() != before
|
||||
index = normal.page('/investimentos')
|
||||
assert index['component'] == 'investor-path/index' and index['props']['auth']['user']['id'] == 18
|
||||
ids = [case['id'] for case in index['props']['contexts']]
|
||||
case = '413ba1f6-bdf6-402a-a561-c4e2d4098d1a'
|
||||
assert case in ids
|
||||
assert '5935737d-9e91-44d6-87fa-05444a37c117' not in ids and '3026f22a-d3d9-42ed-9660-529f29dc5b90' not in ids
|
||||
assert normal.page('/investimentos')['props']['auth']['user']['id'] == 18
|
||||
assert normal.page('/investimentos/' + case)['props']['case']['id'] == case
|
||||
normal.call('GET', '/investimentos/5935737d-9e91-44d6-87fa-05444a37c117', status=403)
|
||||
assert normal.call('POST', '/logout', {}, status=302)[1] == '/'
|
||||
assert normal.call('GET', '/investimentos', status=302)[1] == '/login'
|
||||
|
||||
new = BrowserProtocol('synthetic-investor18-new-cookie-session')
|
||||
assert new.call('GET', '/investimentos', status=302)[1] == '/login'
|
||||
new.page('/login')
|
||||
assert new.call('POST', '/login', {k: owner[k] for k in ['email', 'password']}, status=302)[1] == '/two-factor-challenge'
|
||||
new.page('/two-factor-challenge')
|
||||
while int(time.time()) // 30 == slot:
|
||||
time.sleep(0.5) # Use a fresh TOTP without disabling replay controls; at most30s.
|
||||
assert new.call('POST', '/two-factor-challenge', {'code': otp(owner['totp_secret'])}, status=302)[1] == '/investimentos'
|
||||
assert new.page('/investimentos')['props']['auth']['user']['id'] == 18
|
||||
assert new.page('/investimentos/' + case)['props']['case']['id'] == case
|
||||
new.call('POST', '/logout', {}, status=302)
|
||||
|
||||
negative = json.loads((private / 'mfa-negative-qa-private.json').read_text(encoding='utf-8-sig'))
|
||||
failed = BrowserProtocol('synthetic-wrong-key19-negative')
|
||||
assert failed.call('POST', '/login', {k: negative[k] for k in ['email', 'password']}, status=302)[1] == '/two-factor-challenge'
|
||||
failed.page('/two-factor-challenge')
|
||||
assert failed.call('POST', '/two-factor-challenge', {'code': '123456'}, status=302)[1] == '/login'
|
||||
page = failed.page('/login')
|
||||
assert not page['props']['auth']['user'] and 'Não foi possível verificar' in page['props']['errors']['email']
|
||||
assert failed.call('GET', '/investimentos', status=302)[1] == '/login'
|
||||
|
||||
traces = {row['trace']: row for row in [json.loads(line) for line in (private / 'auth-session-trace-qa.jsonl').read_text().splitlines()]}
|
||||
successful_mfa = [traces[event['trace']] for event in events if event['path'] == '/two-factor-challenge' and event['method'] == 'POST'
|
||||
and traces[event['trace']]['after']['authenticated_user'] == 18]
|
||||
assert len(successful_mfa) == 2
|
||||
assert all(row['persisted']['stored_web_user'] == 18 for row in successful_mfa)
|
||||
assert all(row['loaded']['session'] != row['after']['session'] for row in successful_mfa)
|
||||
proof = {'case': 'C37', 'captured_at_utc': datetime.now(timezone.utc).isoformat(), 'target': target, 'actor_id': 18,
|
||||
'restored_accounts_stopped_before_mfa': True, 'qa_identity_on_login_and_challenge': True,
|
||||
'restored_account13_selector_explicitly_tested': True,
|
||||
'invalid_mfa_preserves_challenge_without_auth': True, 'decryption_failure_refuses_auth_and_returns_message': True,
|
||||
'mfa_browser_redirect_protocol_passed': True, 'session_regenerated_and_user18_persisted': True,
|
||||
'investimentos_get_and_reload_passed': True, 'logout_revokes_access': True, 'fresh_session_recovers_same_case': True,
|
||||
'other_actor_case_forbidden': True, 'requests': events,
|
||||
'headers_bodies_cookies_codes_exported': False, 'visual_acceptance_passed': False, 'active_writes': 0,
|
||||
'source_hashes': {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in [
|
||||
'tools/portal-qa-account-selection.php', 'tools/opportunity-resources-qa.php',
|
||||
'resources/js/pages/auth/login.tsx', 'resources/js/pages/auth/two-factor-challenge.tsx']}}
|
||||
(private / 'c37-auth-redirect-qa.json').write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({k: v for k, v in proof.items() if k not in ['requests', 'source_hashes']}, ensure_ascii=True))
|
||||
@@ -65,6 +65,7 @@ foreach ($tables as $t) {
|
||||
$counts[$name] = count($a);
|
||||
$hashes[$name] = hash('sha256', implode("\n", $a));
|
||||
}
|
||||
$auditEventIds = $active->query('SELECT id FROM audit_events ORDER BY id')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$active->exec('ROLLBACK');
|
||||
// Preserve exactly the private files referenced by restored document records, plus the runtime key.
|
||||
$files = $directory.'/private-files.zip';
|
||||
@@ -96,6 +97,6 @@ $read->close();
|
||||
$proof = ['restore_verified' => true, 'verified_at' => gmdate('c'), 'target' => $target, 'restored_target' => $restored,
|
||||
'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump), 'table_counts' => $counts, 'table_hashes' => $hashes,
|
||||
'files_verified' => true, 'files_path' => $files, 'files_sha256' => hash_file('sha256', $files), 'file_hashes' => $manifest,
|
||||
'audit_event_ids' => $active->query('SELECT id FROM audit_events ORDER BY id')->fetchAll(PDO::FETCH_COLUMN)];
|
||||
'audit_event_ids' => $auditEventIds];
|
||||
file_put_contents(storage_path('app/private/virtual-desk/backup-proof.json'), json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['restore_verified' => true, 'tables' => count($tables), 'files' => count($manifest), 'restore_target' => $restored], JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -1,25 +1,162 @@
|
||||
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs=require('node:fs'),assert=require('node:assert/strict');
|
||||
const base='http://127.0.0.1:18095',dir='storage/app/private/investmoz-v2/';
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true,executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
|
||||
const errors=[];
|
||||
async function session(viewport){const context=await browser.newContext({viewport});await context.route('**/*',r=>r.request().url().startsWith(base)||r.request().url().startsWith('data:')?r.continue():r.abort());const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(base+'/login');await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');await page.getByRole('button',{name:'Log in',exact:true}).click();await page.waitForURL('**/dashboard');return {context,page};}
|
||||
const desktop=await session({width:1280,height:900}),page=desktop.page;
|
||||
await page.getByRole('link',{name:'Gestão de Oportunidades',exact:true}).click();await page.getByRole('link',{name:'Nova oportunidade',exact:true}).click();
|
||||
await page.getByLabel('Motivo da alteração').fill('Criação incompleta autorizada somente em QA');
|
||||
await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.waitForURL(/\/management\/opportunities\/\d+\/edit$/);
|
||||
const id=Number(page.url().match(/(\d+)\/edit$/)[1]);assert(id>6);
|
||||
await page.getByLabel('Título oficial').fill('QA browser opportunity recovered from server');await page.getByLabel('Categoria',{exact:true}).selectOption('investment_project');
|
||||
await page.getByLabel('Investimento estimado').fill('-1');await page.getByLabel('Motivo da alteração').fill('Edição financeira inválida para verificar erros QA');
|
||||
await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.getByText('The estimated investment amount field format is invalid.').waitFor().catch(async()=>{await page.locator('[role="alert"]').first().waitFor();});
|
||||
assert.equal(await page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');
|
||||
await page.getByLabel('Investimento estimado').fill('1000.25');await page.getByLabel('Moeda',{exact:true}).selectOption('MZN');await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.getByText('Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.',{exact:true}).waitFor();
|
||||
await page.screenshot({path:dir+'opportunity-management-desktop.png',fullPage:true});await desktop.context.close();
|
||||
const mobile=await session({width:390,height:844});await mobile.page.goto(base+`/management/opportunities/${id}/edit`);
|
||||
await mobile.page.evaluate(()=>localStorage.clear());await mobile.page.reload();assert.equal(await mobile.page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');assert.equal(await mobile.page.getByLabel('Investimento estimado').inputValue(),'1000.25');
|
||||
await mobile.page.screenshot({path:dir+'opportunity-management-mobile.png',fullPage:true});assert(await mobile.page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
|
||||
await mobile.page.getByLabel('Motivo da alteração').fill('Exclusão lógica do rascunho de demonstração QA');await mobile.page.getByRole('checkbox',{name:/Confirmo a exclusão/}).check();await mobile.page.getByRole('button',{name:'Excluir rascunho',exact:true}).click();await mobile.page.getByRole('button',{name:'Restaurar',exact:true}).waitFor();
|
||||
await mobile.page.getByLabel('Motivo da alteração').fill('Restauração autorizada do rascunho somente QA');await mobile.page.getByRole('checkbox',{name:/Confirmo a restauração/}).check();await mobile.page.getByRole('button',{name:'Restaurar',exact:true}).click();await mobile.page.getByRole('button',{name:'Excluir rascunho',exact:true}).waitFor();assert.equal(await mobile.page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');
|
||||
assert.deepEqual(errors,[]);const proof={recorded_at:new Date().toISOString(),id,desktop:true,mobile:true,new_session_recovery:true,field_errors:true,soft_delete_restore:true,errors,active_writes:0};fs.writeFileSync(dir+'opportunity-management-browser.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));await browser.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs = require('node:fs'),
|
||||
assert = require('node:assert/strict');
|
||||
const base = 'http://127.0.0.1:18095',
|
||||
dir = 'storage/app/private/investmoz-v2/';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
const errors = [];
|
||||
async function session(viewport) {
|
||||
const context = await browser.newContext({ viewport });
|
||||
await context.route('**/*', (r) =>
|
||||
r.request().url().startsWith(base) ||
|
||||
r.request().url().startsWith('data:')
|
||||
? r.continue()
|
||||
: r.abort(),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(base + '/login');
|
||||
await page
|
||||
.getByLabel('Email address')
|
||||
.fill('opportunity-manager@example.invalid');
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('Opportunity-QA-2026!');
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
return { context, page };
|
||||
}
|
||||
const desktop = await session({ width: 1280, height: 900 }),
|
||||
page = desktop.page;
|
||||
await page
|
||||
.getByRole('link', { name: 'Gestão de Oportunidades', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Nova oportunidade', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByLabel('Motivo da alteração')
|
||||
.fill('Criação incompleta autorizada somente em QA');
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar rascunho', exact: true })
|
||||
.click();
|
||||
await page.waitForURL(/\/management\/opportunities\/\d+\/edit$/);
|
||||
const id = Number(page.url().match(/(\d+)\/edit$/)[1]);
|
||||
assert(id > 6);
|
||||
await page
|
||||
.getByLabel('Título oficial')
|
||||
.fill('QA browser opportunity recovered from server');
|
||||
await page
|
||||
.getByLabel('Categoria', { exact: true })
|
||||
.selectOption('investment_project');
|
||||
await page.getByLabel('Investimento estimado').fill('-1');
|
||||
await page
|
||||
.getByLabel('Motivo da alteração')
|
||||
.fill('Edição financeira inválida para verificar erros QA');
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar rascunho', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText('The estimated investment amount field format is invalid.')
|
||||
.waitFor()
|
||||
.catch(async () => {
|
||||
await page.locator('[role="alert"]').first().waitFor();
|
||||
});
|
||||
assert.equal(
|
||||
await page.getByLabel('Título oficial').inputValue(),
|
||||
'QA browser opportunity recovered from server',
|
||||
);
|
||||
await page.getByLabel('Investimento estimado').fill('1000.25');
|
||||
await page.getByLabel('Moeda', { exact: true }).selectOption('MZN');
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar rascunho', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText(
|
||||
'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.',
|
||||
{ exact: true },
|
||||
)
|
||||
.waitFor();
|
||||
await page.screenshot({
|
||||
path: dir + 'opportunity-management-desktop.png',
|
||||
fullPage: true,
|
||||
});
|
||||
await desktop.context.close();
|
||||
const mobile = await session({ width: 390, height: 844 });
|
||||
await mobile.page.goto(base + `/management/opportunities/${id}/edit`);
|
||||
await mobile.page.evaluate(() => localStorage.clear());
|
||||
await mobile.page.reload();
|
||||
assert.equal(
|
||||
await mobile.page.getByLabel('Título oficial').inputValue(),
|
||||
'QA browser opportunity recovered from server',
|
||||
);
|
||||
assert.equal(
|
||||
await mobile.page.getByLabel('Investimento estimado').inputValue(),
|
||||
'1000.25',
|
||||
);
|
||||
await mobile.page.screenshot({
|
||||
path: dir + 'opportunity-management-mobile.png',
|
||||
fullPage: true,
|
||||
});
|
||||
assert(
|
||||
await mobile.page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||
),
|
||||
);
|
||||
await mobile.page
|
||||
.getByLabel('Motivo da alteração')
|
||||
.fill('Exclusão lógica do rascunho de demonstração QA');
|
||||
await mobile.page
|
||||
.getByRole('checkbox', { name: /Confirmo a exclusão/ })
|
||||
.check();
|
||||
await mobile.page
|
||||
.getByRole('button', { name: 'Excluir rascunho', exact: true })
|
||||
.click();
|
||||
await mobile.page
|
||||
.getByRole('button', { name: 'Restaurar', exact: true })
|
||||
.waitFor();
|
||||
await mobile.page
|
||||
.getByLabel('Motivo da alteração')
|
||||
.fill('Restauração autorizada do rascunho somente QA');
|
||||
await mobile.page
|
||||
.getByRole('checkbox', { name: /Confirmo a restauração/ })
|
||||
.check();
|
||||
await mobile.page
|
||||
.getByRole('button', { name: 'Restaurar', exact: true })
|
||||
.click();
|
||||
await mobile.page
|
||||
.getByRole('button', { name: 'Excluir rascunho', exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await mobile.page.getByLabel('Título oficial').inputValue(),
|
||||
'QA browser opportunity recovered from server',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
const proof = {
|
||||
recorded_at: new Date().toISOString(),
|
||||
id,
|
||||
desktop: true,
|
||||
mobile: true,
|
||||
new_session_recovery: true,
|
||||
field_errors: true,
|
||||
soft_delete_restore: true,
|
||||
errors,
|
||||
active_writes: 0,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
dir + 'opportunity-management-browser.json',
|
||||
JSON.stringify(proof, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify(proof));
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ foreach ($oldEvents as $id => $row) {
|
||||
}
|
||||
}
|
||||
$baseline->exec('ROLLBACK');
|
||||
foreach (['opportunities','opportunity_projects','opportunity_clusters','opportunity_favourites','interest_requests','interest_submission_receipts','investment_messages','tasks','project_locations','cluster_projects'] as $table) {
|
||||
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects'] as $table) {
|
||||
$key = '"public"."'.$table.'"';
|
||||
$rows = $pdo->query('SELECT row_to_json(t)::text AS row FROM '.$key.' t ORDER BY row_to_json(t)::text')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$hash = hash('sha256', implode("\n", $rows));
|
||||
|
||||
@@ -1,12 +1,272 @@
|
||||
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs=require('node:fs'),assert=require('node:assert/strict'),zlib=require('node:zlib');
|
||||
const base='http://127.0.0.1:18096',dir='storage/app/private/investmoz-v2/';
|
||||
function png(){function crc(bytes){let c=0xffffffff;for(const b of bytes){c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}return (c^0xffffffff)>>>0;}function chunk(name,bytes){const n=Buffer.from(name),body=Buffer.concat([n,bytes]),head=Buffer.alloc(4),tail=Buffer.alloc(4);head.writeUInt32BE(bytes.length);tail.writeUInt32BE(crc(body));return Buffer.concat([head,body,tail]);}const hdr=Buffer.alloc(13);hdr.writeUInt32BE(500,0);hdr.writeUInt32BE(500,4);hdr[8]=8;hdr[9]=2;return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',hdr),chunk('IDAT',zlib.deflateSync(Buffer.alloc(500*1501))),chunk('IEND',Buffer.alloc(0))]);}
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});const errors=[];
|
||||
async function session(viewport){const context=await browser.newContext({viewport});await context.route('**/*',r=>r.request().url().startsWith(base)||r.request().url().startsWith('data:')?r.continue():r.abort());const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(base+'/login');await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');await page.getByRole('button',{name:'Log in',exact:true}).click();await page.waitForURL('**/dashboard');return {context,page};}
|
||||
const desktop=await session({width:1280,height:900}),page=desktop.page;await page.getByRole('link',{name:'Gestão de Oportunidades',exact:true}).click();await page.getByRole('link',{name:'Nova oportunidade',exact:true}).click();await page.getByLabel('Motivo da alteração').fill('Criação de recursos somente na cópia QA');await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.waitForURL(/\/opportunities\/\d+\/edit$/);const id=Number(page.url().match(/(\d+)\/edit$/)[1]);assert(id>6);await page.getByRole('link',{name:'Imagens, documentos e localizações',exact:true}).click();await page.waitForURL('**/resources');
|
||||
await page.getByLabel('Título',{exact:true}).fill('QA production location');await page.getByLabel('Papel da localização',{exact:true}).selectOption('production');const provinceSelect=page.getByLabel('Província',{exact:true});await provinceSelect.locator('option').nth(1).waitFor({state:'attached'});const province=await provinceSelect.locator('option').nth(1).getAttribute('value');await provinceSelect.selectOption(province);const districtSelect=page.getByLabel('Distrito',{exact:true});await districtSelect.locator('option').nth(1).waitFor({state:'attached'});const district=await districtSelect.locator('option').nth(1).getAttribute('value');await districtSelect.selectOption(district);await page.getByLabel('Distribuição (%)',{exact:true}).fill('60');await page.getByLabel('Localização principal',{exact:true}).check();await page.getByLabel('Motivo (20–1000 caracteres)').fill('Localização e distribuição declaradas em QA');await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA production location'}).waitFor();
|
||||
await page.getByLabel('Título',{exact:true}).fill('QA market location');await page.getByLabel('Papel da localização',{exact:true}).selectOption('market');await provinceSelect.selectOption(province);await districtSelect.locator('option').nth(1).waitFor({state:'attached'});await districtSelect.selectOption(district);await page.getByLabel('Distribuição (%)',{exact:true}).fill('40');await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA market location'}).waitFor();assert.equal(await page.locator('article').count(),2);
|
||||
await page.getByRole('button',{name:'Documentos',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA source document');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-1.7\nSynthetic QA document\n%%EOF')});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA source document'}).waitFor();
|
||||
await page.getByRole('button',{name:'Imagens',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA synthetic image');await page.getByLabel('Legenda',{exact:true}).fill('Synthetic image only used in isolated QA');await page.getByLabel('Texto alternativo',{exact:true}).fill('Black square generated for isolated QA');await page.getByLabel('Direitos de utilização',{exact:true}).selectOption('owned');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.png',mimeType:'image/png',buffer:png()});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();const imageArticle=page.locator('article').filter({hasText:'QA synthetic image'});await imageArticle.waitFor();await imageArticle.getByRole('button',{name:'Ver / editar / restaurar'}).click();const prepareButton=page.getByRole('button',{name:'Preparar WebP e miniaturas'});if(await prepareButton.isDisabled()){await page.getByText(/Processamento de imagens indisponível/).waitFor();}else{await prepareButton.click();await page.getByText(/Scan do original pendente ou inseguro/).waitFor();}assert.equal(await page.getByLabel('Título',{exact:true}).inputValue(),'QA synthetic image');await page.screenshot({path:dir+'opportunity-resources-desktop.png',fullPage:true});await desktop.context.close();
|
||||
const mobile=await session({width:390,height:844}),mp=mobile.page;await mp.goto(base+`/management/opportunities/${id}/resources`);await mp.evaluate(()=>localStorage.clear());await mp.reload();assert.equal(await mp.locator('article').count(),2);await mp.getByRole('button',{name:'Imagens',exact:true}).click();const mi=mp.locator('article').filter({hasText:'QA synthetic image'});await mi.getByRole('button',{name:'Ver / editar / restaurar'}).click();assert.equal(await mp.getByLabel('Texto alternativo',{exact:true}).inputValue(),'Black square generated for isolated QA');await mp.getByLabel('Motivo (20–1000 caracteres)').fill('Exclusão lógica de imagem somente no ensaio QA');await mp.getByLabel('Confirmar exclusão lógica / restauração').check();await mp.getByRole('button',{name:'Excluir recurso logicamente'}).click();await mp.getByText('eliminado logicamente',{exact:false}).first().waitFor();await mi.getByRole('button',{name:'Ver / editar / restaurar'}).click();await mp.getByLabel('Confirmar exclusão lógica / restauração').check();await mp.getByRole('button',{name:'Restaurar recurso'}).click();await mi.getByText(/rascunho/).waitFor();await mp.screenshot({path:dir+'opportunity-resources-mobile.png',fullPage:true});assert(await mp.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));assert.deepEqual(errors,[]);const proof={recorded_at:new Date().toISOString(),id,desktop:true,mobile:true,new_session_recovery:true,multiple_locations:true,documents:true,images:true,scan_failure_visible:true,soft_delete_restore:true,errors,active_writes:0};fs.writeFileSync(dir+'opportunity-resources-browser.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));await browser.close();})().catch(e=>{console.error(e);process.exit(1)});
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs = require('node:fs'),
|
||||
assert = require('node:assert/strict'),
|
||||
zlib = require('node:zlib');
|
||||
const base = 'http://127.0.0.1:18096',
|
||||
dir = 'storage/app/private/investmoz-v2/';
|
||||
function png() {
|
||||
function crc(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of bytes) {
|
||||
c ^= b;
|
||||
for (let i = 0; i < 8; i++)
|
||||
c = (c >>> 1) ^ (c & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(name, bytes) {
|
||||
const n = Buffer.from(name),
|
||||
body = Buffer.concat([n, bytes]),
|
||||
head = Buffer.alloc(4),
|
||||
tail = Buffer.alloc(4);
|
||||
head.writeUInt32BE(bytes.length);
|
||||
tail.writeUInt32BE(crc(body));
|
||||
return Buffer.concat([head, body, tail]);
|
||||
}
|
||||
const hdr = Buffer.alloc(13);
|
||||
hdr.writeUInt32BE(500, 0);
|
||||
hdr.writeUInt32BE(500, 4);
|
||||
hdr[8] = 8;
|
||||
hdr[9] = 2;
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk('IHDR', hdr),
|
||||
chunk('IDAT', zlib.deflateSync(Buffer.alloc(500 * 1501))),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
const errors = [];
|
||||
async function session(viewport) {
|
||||
const context = await browser.newContext({ viewport });
|
||||
await context.route('**/*', (r) =>
|
||||
r.request().url().startsWith(base) ||
|
||||
r.request().url().startsWith('data:')
|
||||
? r.continue()
|
||||
: r.abort(),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(base + '/login');
|
||||
await page
|
||||
.getByLabel('Email address')
|
||||
.fill('opportunity-manager@example.invalid');
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('Opportunity-QA-2026!');
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
return { context, page };
|
||||
}
|
||||
const desktop = await session({ width: 1280, height: 900 }),
|
||||
page = desktop.page;
|
||||
await page
|
||||
.getByRole('link', { name: 'Gestão de Oportunidades', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Nova oportunidade', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByLabel('Motivo da alteração')
|
||||
.fill('Criação de recursos somente na cópia QA');
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar rascunho', exact: true })
|
||||
.click();
|
||||
await page.waitForURL(/\/opportunities\/\d+\/edit$/);
|
||||
const id = Number(page.url().match(/(\d+)\/edit$/)[1]);
|
||||
assert(id > 6);
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: 'Imagens, documentos e localizações',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page.waitForURL('**/resources');
|
||||
await page
|
||||
.getByLabel('Título', { exact: true })
|
||||
.fill('QA production location');
|
||||
await page
|
||||
.getByLabel('Papel da localização', { exact: true })
|
||||
.selectOption('production');
|
||||
const provinceSelect = page.getByLabel('Província', { exact: true });
|
||||
await provinceSelect
|
||||
.locator('option')
|
||||
.nth(1)
|
||||
.waitFor({ state: 'attached' });
|
||||
const province = await provinceSelect
|
||||
.locator('option')
|
||||
.nth(1)
|
||||
.getAttribute('value');
|
||||
await provinceSelect.selectOption(province);
|
||||
const districtSelect = page.getByLabel('Distrito', { exact: true });
|
||||
await districtSelect
|
||||
.locator('option')
|
||||
.nth(1)
|
||||
.waitFor({ state: 'attached' });
|
||||
const district = await districtSelect
|
||||
.locator('option')
|
||||
.nth(1)
|
||||
.getAttribute('value');
|
||||
await districtSelect.selectOption(district);
|
||||
await page.getByLabel('Distribuição (%)', { exact: true }).fill('60');
|
||||
await page.getByLabel('Localização principal', { exact: true }).check();
|
||||
await page
|
||||
.getByLabel('Motivo (20–1000 caracteres)')
|
||||
.fill('Localização e distribuição declaradas em QA');
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar recurso', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'QA production location' })
|
||||
.waitFor();
|
||||
await page.getByLabel('Título', { exact: true }).fill('QA market location');
|
||||
await page
|
||||
.getByLabel('Papel da localização', { exact: true })
|
||||
.selectOption('market');
|
||||
await provinceSelect.selectOption(province);
|
||||
await districtSelect
|
||||
.locator('option')
|
||||
.nth(1)
|
||||
.waitFor({ state: 'attached' });
|
||||
await districtSelect.selectOption(district);
|
||||
await page.getByLabel('Distribuição (%)', { exact: true }).fill('40');
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar recurso', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'QA market location' })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator('article').count(), 2);
|
||||
await page.getByRole('button', { name: 'Documentos', exact: true }).click();
|
||||
await page.getByLabel('Título', { exact: true }).fill('QA source document');
|
||||
await page
|
||||
.getByLabel('Original / substituir por nova versão')
|
||||
.setInputFiles({
|
||||
name: 'qa.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('%PDF-1.7\nSynthetic QA document\n%%EOF'),
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar recurso', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'QA source document' })
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Imagens', exact: true }).click();
|
||||
await page.getByLabel('Título', { exact: true }).fill('QA synthetic image');
|
||||
await page
|
||||
.getByLabel('Legenda', { exact: true })
|
||||
.fill('Synthetic image only used in isolated QA');
|
||||
await page
|
||||
.getByLabel('Texto alternativo', { exact: true })
|
||||
.fill('Black square generated for isolated QA');
|
||||
await page
|
||||
.getByLabel('Direitos de utilização', { exact: true })
|
||||
.selectOption('owned');
|
||||
await page
|
||||
.getByLabel('Original / substituir por nova versão')
|
||||
.setInputFiles({
|
||||
name: 'qa.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png(),
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: 'Guardar recurso', exact: true })
|
||||
.click();
|
||||
const imageArticle = page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'QA synthetic image' });
|
||||
await imageArticle.waitFor();
|
||||
await imageArticle
|
||||
.getByRole('button', { name: 'Ver / editar / restaurar' })
|
||||
.click();
|
||||
const prepareButton = page.getByRole('button', {
|
||||
name: 'Preparar WebP e miniaturas',
|
||||
});
|
||||
if (await prepareButton.isDisabled()) {
|
||||
await page.getByText(/Processamento de imagens indisponível/).waitFor();
|
||||
} else {
|
||||
await prepareButton.click();
|
||||
await page.getByText(/Scan do original pendente ou inseguro/).waitFor();
|
||||
}
|
||||
assert.equal(
|
||||
await page.getByLabel('Título', { exact: true }).inputValue(),
|
||||
'QA synthetic image',
|
||||
);
|
||||
await page.screenshot({
|
||||
path: dir + 'opportunity-resources-desktop.png',
|
||||
fullPage: true,
|
||||
});
|
||||
await desktop.context.close();
|
||||
const mobile = await session({ width: 390, height: 844 }),
|
||||
mp = mobile.page;
|
||||
await mp.goto(base + `/management/opportunities/${id}/resources`);
|
||||
await mp.evaluate(() => localStorage.clear());
|
||||
await mp.reload();
|
||||
assert.equal(await mp.locator('article').count(), 2);
|
||||
await mp.getByRole('button', { name: 'Imagens', exact: true }).click();
|
||||
const mi = mp.locator('article').filter({ hasText: 'QA synthetic image' });
|
||||
await mi.getByRole('button', { name: 'Ver / editar / restaurar' }).click();
|
||||
assert.equal(
|
||||
await mp.getByLabel('Texto alternativo', { exact: true }).inputValue(),
|
||||
'Black square generated for isolated QA',
|
||||
);
|
||||
await mp
|
||||
.getByLabel('Motivo (20–1000 caracteres)')
|
||||
.fill('Exclusão lógica de imagem somente no ensaio QA');
|
||||
await mp.getByLabel('Confirmar exclusão lógica / restauração').check();
|
||||
await mp
|
||||
.getByRole('button', { name: 'Excluir recurso logicamente' })
|
||||
.click();
|
||||
await mp
|
||||
.getByText('eliminado logicamente', { exact: false })
|
||||
.first()
|
||||
.waitFor();
|
||||
await mi.getByRole('button', { name: 'Ver / editar / restaurar' }).click();
|
||||
await mp.getByLabel('Confirmar exclusão lógica / restauração').check();
|
||||
await mp.getByRole('button', { name: 'Restaurar recurso' }).click();
|
||||
await mi.getByText(/rascunho/).waitFor();
|
||||
await mp.screenshot({
|
||||
path: dir + 'opportunity-resources-mobile.png',
|
||||
fullPage: true,
|
||||
});
|
||||
assert(
|
||||
await mp.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
const proof = {
|
||||
recorded_at: new Date().toISOString(),
|
||||
id,
|
||||
desktop: true,
|
||||
mobile: true,
|
||||
new_session_recovery: true,
|
||||
multiple_locations: true,
|
||||
documents: true,
|
||||
images: true,
|
||||
scan_failure_visible: true,
|
||||
soft_delete_restore: true,
|
||||
errors,
|
||||
active_writes: 0,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
dir + 'opportunity-resources-browser.json',
|
||||
JSON.stringify(proof, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify(proof));
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
// Read-only installation readiness; never applies migrations or authenticates an actor.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$db = $app->make('db')->connection();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
$preview = $app->make(\App\Services\OpportunityResourceInstallation::class)->preview();
|
||||
$preview = $app->make(OpportunityResourceInstallation::class)->preview();
|
||||
$proofPath = storage_path('app/private/investmoz-v2/opportunity-resources-activation-proof.json');
|
||||
$proof = is_file($proofPath) ? json_decode(file_get_contents($proofPath), true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$result = [
|
||||
|
||||
@@ -10,24 +10,34 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once $root.'/tools/portal-qa-environment.php';
|
||||
$dir = $root.'/storage/app/private/investmoz-v2';
|
||||
$backup = json_decode(file_get_contents($dir.'/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $backup['restored_target'];
|
||||
if (! $backup['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('QA target required');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'true', 'APP_URL' => 'http://127.0.0.1:18096', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'opportunity_resources_qa', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
$target = portalQaEnvironment('file');
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Refusing non-QA target');
|
||||
}
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
config(['investor-path.enabled' => true]);
|
||||
config(['geometry-import.enabled' => true]);
|
||||
$identity = portalQaAssertTarget($target);
|
||||
config(['portal.qa_synthetic_accounts_only' => true]);
|
||||
require_once $root.'/tools/portal-qa-account-selection.php';
|
||||
// Only expose the non-secret identity; passwords, TOTP secrets and recovery codes remain private.
|
||||
\Laravel\Fortify\Fortify::loginView(fn (Request $request) => \Inertia\Inertia::render('auth/login', [
|
||||
'canResetPassword' => \Laravel\Fortify\Features::enabled(\Laravel\Fortify\Features::resetPasswords()),
|
||||
'status' => $request->session()->get('status'),
|
||||
'qaAccount' => ['id' => 18, 'email' => User::findOrFail(18)->email],
|
||||
]));
|
||||
\Laravel\Fortify\Fortify::twoFactorChallengeView(function (Request $request) {
|
||||
$actor = User::findOrFail($request->session()->get('login.id'));
|
||||
|
||||
return \Inertia\Inertia::render('auth/two-factor-challenge', ['qaAccount' => ['id' => $actor->id, 'email' => $actor->email]]);
|
||||
});
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid', 'geo-manager-qa@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
$sessions = $dir.'/qa-opportunity-resources-sessions';
|
||||
if (! is_dir($sessions)) {
|
||||
mkdir($sessions, 0700, true);
|
||||
@@ -99,4 +109,13 @@ if ($path !== '/' && $asset && $public && str_starts_with($asset, $public.DIRECT
|
||||
return false;
|
||||
}
|
||||
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
||||
header('X-Investmoz-QA-Key-Fingerprint: '.hash('sha256', app('encrypter')->getKey()));
|
||||
header('X-Investmoz-QA-Config-Cache: bypassed');
|
||||
require_once $root.'/tools/portal-qa-auth-trace.php';
|
||||
$httpKernel = $app->make(\Illuminate\Contracts\Http\Kernel::class);
|
||||
$httpKernel->prependMiddleware(PortalQaAuthTrace::class);
|
||||
$httpKernel->appendMiddlewareToGroup('web', PortalQaSessionTrace::class);
|
||||
$httpKernel->addToMiddlewarePriorityAfter(\Illuminate\Session\Middleware\StartSession::class, PortalQaSessionTrace::class);
|
||||
$httpKernel->appendMiddlewareToGroup('web', PortalQaAccountSelection::class);
|
||||
$httpKernel->addToMiddlewarePriorityAfter(PortalQaSessionTrace::class, PortalQaAccountSelection::class);
|
||||
$app->handleRequest(Request::capture());
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
||||
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
|
||||
|
||||
// Metadata, counts and hashes only; no active writes or personal records exported.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
(new LoadEnvironmentVariables)->bootstrap($app);
|
||||
(new LoadConfiguration)->bootstrap($app);
|
||||
$c = $app->make('config')->get('database.connections.'.$app->make('config')->get('database.default'));
|
||||
if (($c['driver'] ?? '') !== 'pgsql' || ! in_array($c['url'] ?? null, [null, ''], true)) {
|
||||
throw new RuntimeException('Explicit PostgreSQL target required');
|
||||
}
|
||||
$pdo = new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$c['database'], $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$pdo->exec('BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
try {
|
||||
$result = ['captured_at' => gmdate('c'), 'identity' => $pdo->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC), 'tables' => [], 'active_writes' => 0];
|
||||
$tables = $pdo->query("SELECT table_schema,table_name FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('information_schema','pg_catalog') AND table_schema NOT LIKE 'pg_%' ORDER BY table_schema,table_name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($tables as $t) {
|
||||
$qualified = '"'.str_replace('"', '""', $t['table_schema']).'"."'.str_replace('"', '""', $t['table_name']).'"';
|
||||
$q = $pdo->prepare('SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=? AND table_name=? ORDER BY ordinal_position');
|
||||
$q->execute([$t['table_schema'], $t['table_name']]);
|
||||
$columns = $q->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$h = hash_init('sha256');
|
||||
$count = 0;
|
||||
foreach ($pdo->query('SELECT row_to_json(t)::text AS row FROM '.$qualified.' t ORDER BY row_to_json(t)::text') as $r) {
|
||||
if ($count++) {
|
||||
hash_update($h, "\n");
|
||||
}
|
||||
hash_update($h, $r['row']);
|
||||
}
|
||||
$entry = ['rows' => $count, 'sha256' => hash_final($h), 'columns' => $columns];
|
||||
foreach (['status', 'data_classification', 'malware_scan_status', 'location_type'] as $field) {
|
||||
if (isset($columns[$field])) {
|
||||
$entry['groups'][$field] = $pdo->query('SELECT "'.$field.'", count(*) AS count FROM '.$qualified.' GROUP BY "'.$field.'" ORDER BY "'.$field.'"')->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
$result['tables'][$t['table_schema'].'.'.$t['table_name']] = $entry;
|
||||
}
|
||||
$result['migrations'] = $pdo->query('SELECT migration,batch FROM public.migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$result['installation_events'] = $pdo->query("SELECT action,count(*) AS count,max(occurred_at) AS last_at FROM public.audit_events WHERE action LIKE '%installed%' GROUP BY action ORDER BY action")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$result['opportunity_classifications'] = $pdo->query("SELECT DISTINCT ON(after_data->>'opportunity_id') after_data->>'opportunity_id' AS id,after_data->>'classification' AS classification FROM public.audit_events WHERE source_system='opportunity-origin-review' ORDER BY after_data->>'opportunity_id',occurred_at DESC,id DESC")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$result['geo_with_geometry'] = $result['tables']['public.geo_geometries']['rows'] ?? null;
|
||||
$result['origin_counts'] = $pdo->query("SELECT metadata->>'declared_origin_country_code' AS code,count(*) AS count FROM public.interest_requests WHERE deleted_at IS NULL GROUP BY metadata->>'declared_origin_country_code'")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$result['features'] = ['education' => $app->make('config')->get('education.enabled'), 'suitecrm' => $app->make('config')->get('suitecrm.enabled'), 'ai' => $app->make('config')->get('investmoz_ai.enabled')];
|
||||
} finally {
|
||||
$pdo->exec('ROLLBACK');
|
||||
}
|
||||
$label = $argv[1] ?? 'before';
|
||||
if (! in_array($label, ['before', 'after'], true)) {
|
||||
throw new RuntimeException('before/after label required');
|
||||
}
|
||||
$dir = dirname(__DIR__).'/storage/app/private/investmoz-v2/portal-audit';
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
$destination = $dir.'/estado-ativo-'.$label.'.json';
|
||||
if (is_file($destination)) {
|
||||
if ($label === 'before') {
|
||||
throw new RuntimeException('Initial audit snapshot already exists; preserve it and use after');
|
||||
}
|
||||
$archive = $dir.'/estado-ativo-after-'.gmdate('Ymd_His').'-'.bin2hex(random_bytes(3)).'.json';
|
||||
if (! copy($destination, $archive)) {
|
||||
throw new RuntimeException('Cannot preserve previous audit snapshot');
|
||||
}
|
||||
}
|
||||
file_put_contents($destination, json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['captured_at' => $result['captured_at'], 'identity' => $result['identity'], 'tables' => count($tables), 'geo_with_geometry' => $result['geo_with_geometry'], 'installation_events' => $result['installation_events'], 'features' => $result['features'], 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,112 @@
|
||||
// Consolidates the existing register; keeps every source row and its divergent criteria.
|
||||
const crypto = require('node:crypto');
|
||||
const normalize = (s) => String(s).normalize('NFC').replace(/\s+/g, ' ').trim();
|
||||
module.exports = function consolidate(sourceRows) {
|
||||
const groups = new Map();
|
||||
for (const row of sourceRows) {
|
||||
const field = /^OP-FIELD-([^-]+)-(.+)$/.exec(row.id);
|
||||
const definition = field?.[1].replace(/(?:Create|Patch|Response)$/, '');
|
||||
const key = field
|
||||
? `field:${definition}.${field[2]}`
|
||||
: `clause:${row.modulo}:${normalize(row.pedido)}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(row);
|
||||
}
|
||||
const rank = [
|
||||
'concluído e demonstrado',
|
||||
'parcial',
|
||||
'implementado mas não ativado',
|
||||
'dependente de decisão concreta',
|
||||
'não implementado',
|
||||
'com falha',
|
||||
];
|
||||
const unique = (values) => [...new Set(values)];
|
||||
const result = [];
|
||||
for (const [key, members] of groups) {
|
||||
const field = key.startsWith('field:');
|
||||
const canonical = field
|
||||
? 'OP-FIELD-' + key.slice(6).replace('.', '-')
|
||||
: members[0].id;
|
||||
const selected = members.find((r) => r.id === canonical) || members[0];
|
||||
const states = unique(members.map((r) => r.estado));
|
||||
const stages = Object.fromEntries(
|
||||
['interface', 'api', 'gravacao', 'recuperacao', 'resultado'].map(
|
||||
(stage) => [
|
||||
stage,
|
||||
unique(
|
||||
members.map(
|
||||
(r) =>
|
||||
r.percurso?.[stage] ||
|
||||
'Não demonstrado individualmente; localizar critério e executar em QA.',
|
||||
),
|
||||
).join(' | '),
|
||||
],
|
||||
),
|
||||
);
|
||||
result.push({
|
||||
...selected,
|
||||
id: canonical,
|
||||
pedido: field
|
||||
? `Persistir, validar e recuperar ${key.slice(6)}; critérios de Create/Patch/Response discriminados por origem`
|
||||
: selected.pedido,
|
||||
fontes: unique(members.flatMap((r) => r.fontes)),
|
||||
rotas: unique(members.flatMap((r) => r.rotas)),
|
||||
estado: states.sort((a, b) => rank.indexOf(b) - rank.indexOf(a))[0],
|
||||
prioridade: members.some((r) => r.prioridade === 'P0')
|
||||
? 'P0'
|
||||
: members.some((r) => r.prioridade === 'P1')
|
||||
? 'P1'
|
||||
: 'P2',
|
||||
aceitacao: unique(
|
||||
members.map((r) => `${r.id}: ${r.aceitacao}`),
|
||||
).join(' | '),
|
||||
variantes: unique(
|
||||
members.map((r) => `${r.id}: ${r.variantes}`),
|
||||
).join(' | '),
|
||||
aliases: members.map((r) => r.id),
|
||||
linhas_origem: members.length,
|
||||
origens: members.map((r) => ({
|
||||
id: r.id,
|
||||
pedido: r.pedido,
|
||||
fontes: r.fontes,
|
||||
aceitacao: r.aceitacao,
|
||||
estado: r.estado,
|
||||
variantes: r.variantes,
|
||||
modulo: r.modulo,
|
||||
})),
|
||||
percurso: stages,
|
||||
consolidacao: field
|
||||
? 'Mesmo campo canónico; variantes de operação conservadas'
|
||||
: 'Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada',
|
||||
});
|
||||
}
|
||||
const ids = new Set(result.map((r) => r.id));
|
||||
const aliases = result.flatMap((r) => r.aliases);
|
||||
if (
|
||||
ids.size !== result.length ||
|
||||
new Set(aliases).size !== sourceRows.length ||
|
||||
aliases.length !== sourceRows.length
|
||||
)
|
||||
throw Error('Consolidation lost or duplicated source identities');
|
||||
const byState = {};
|
||||
for (const row of result) {
|
||||
byState[row.estado] ??= { requisitos: 0, linhas_origem: 0 };
|
||||
byState[row.estado].requisitos++;
|
||||
byState[row.estado].linhas_origem += row.linhas_origem;
|
||||
}
|
||||
return {
|
||||
rows: result,
|
||||
summary: {
|
||||
requisitos: result.length,
|
||||
linhas_matriz: result.length,
|
||||
linhas_origem: sourceRows.length,
|
||||
por_estado: byState,
|
||||
conservation_sha256: crypto
|
||||
.createHash('sha256')
|
||||
.update(aliases.slice().sort().join('\n'))
|
||||
.digest('hex'),
|
||||
metodo: 'Deduplicação exata por módulo e campo canónico entre operações; diferenças preservadas, sem considerar catálogo/API como aceitação de UI.',
|
||||
browser_current_version_verified: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
// Evidence inventory, not a second requirements matrix. Static bindings are not acceptance.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const rows = [];
|
||||
const controls = [];
|
||||
function walk(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (entry.name.endsWith('.tsx')) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const tree = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
function visit(node) {
|
||||
const opening = ts.isJsxElement(node)
|
||||
? node.openingElement
|
||||
: ts.isJsxSelfClosingElement(node)
|
||||
? node
|
||||
: null;
|
||||
if (opening) {
|
||||
const tag = opening.tagName.getText(tree);
|
||||
const attributes = opening.attributes.properties;
|
||||
const names = attributes.map((a) => a.name?.getText(tree));
|
||||
const base = {
|
||||
file: path.relative(root, file).replace(/\\/g, '/'),
|
||||
line:
|
||||
tree.getLineAndCharacterOfPosition(
|
||||
node.getStart(tree),
|
||||
).line + 1,
|
||||
component: tag,
|
||||
};
|
||||
if (tag === 'button' || tag === 'Button') {
|
||||
let form = null;
|
||||
for (
|
||||
let parent = node.parent;
|
||||
parent;
|
||||
parent = parent.parent
|
||||
) {
|
||||
if (
|
||||
ts.isJsxElement(parent) &&
|
||||
['form', 'Form'].includes(
|
||||
parent.openingElement.tagName.getText(tree),
|
||||
)
|
||||
) {
|
||||
form = parent.openingElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const formNames =
|
||||
form?.attributes.properties.map((a) =>
|
||||
a.name?.getText(tree),
|
||||
) ?? [];
|
||||
rows.push({
|
||||
...base,
|
||||
label: ts.isJsxElement(node)
|
||||
? node.children
|
||||
.map((c) => c.getText(tree))
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 180)
|
||||
: '',
|
||||
binding: names.some((n) =>
|
||||
['onClick', 'onSelect'].includes(n),
|
||||
)
|
||||
? 'direct_handler'
|
||||
: form && form.tagName.getText(tree) === 'Form'
|
||||
? 'inertia_form'
|
||||
: formNames.includes('onSubmit')
|
||||
? 'form_handler'
|
||||
: form &&
|
||||
(formNames.includes('action') ||
|
||||
formNames.includes('method'))
|
||||
? 'native_form_action'
|
||||
: names.includes('asChild')
|
||||
? 'delegated_child'
|
||||
: 'context_or_handler_review',
|
||||
functional_demonstration:
|
||||
'Not inferred from static binding; use per-journey QA evidence and visual walkthrough',
|
||||
});
|
||||
}
|
||||
if (
|
||||
tag === 'select' ||
|
||||
/CountrySelect|ProvinceSelect|TerritoryFields|TerritoryLevel|TerritoryField|MapPanel|InvestmentMap|Geography/.test(
|
||||
tag,
|
||||
)
|
||||
) {
|
||||
controls.push({
|
||||
...base,
|
||||
attributes: attributes
|
||||
.map((a) => a.getText(tree))
|
||||
.join(' ')
|
||||
.slice(0, 700),
|
||||
visual_verified: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(tree);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(path.join(root, 'resources/js/pages'));
|
||||
walk(path.join(root, 'resources/js/components'));
|
||||
const file = path.join(
|
||||
root,
|
||||
'storage/app/private/investmoz-v2/portal-audit/consumer-actions-lote2.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify(
|
||||
{
|
||||
captured_at: new Date().toISOString(),
|
||||
scope: 'All TSX pages/components; inventory of source controls and action bindings, not rendered DOM or functional completion',
|
||||
buttons: rows,
|
||||
selectors_and_maps: controls,
|
||||
counts: {
|
||||
buttons: rows.length,
|
||||
selectors_and_maps: controls.length,
|
||||
binding_review: rows.filter(
|
||||
(r) => r.binding === 'context_or_handler_review',
|
||||
).length,
|
||||
},
|
||||
visual_verified: false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
buttons: rows.length,
|
||||
selectors_and_maps: controls.length,
|
||||
review: rows
|
||||
.filter((r) => r.binding === 'context_or_handler_review')
|
||||
.map(({ file, line, label }) => ({ file, line, label })),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Update the existing audit documents; retain previous accounts as history."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
proof = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
|
||||
report = '''# InvesteMoz — relatório executivo do lote 2
|
||||
|
||||
09/10/2026. Continuação da **mesma matriz**. Código preparado, API/persistência demonstradas em QA e funcionalidade ativa são estados separados. **Nenhuma escrita de dados na base ativa por esta auditoria.** Leitura ativa: 17:20:47 UTC. Navegador indisponível: a tentativa de abrir QA voltou a falhar; não há demonstração visual desta versão.
|
||||
|
||||
## Resultado e prioridades
|
||||
|
||||
As três falhas anteriormente classificadas foram corrigidas no âmbito ensaiado: rejeição 422 da maturidade 7/8, indisponibilidade do ClamD e checks globais de formato. Isto não encerra os requisitos mais amplos de maturidade/publicação, binários/derivadas ou interface. Foram ainda encontrados e corrigidos a rejeição da hierarquia territorial fundiária, a perda da origem ao solicitar apoio e o botão de pesquisa sem ação.
|
||||
|
||||
| Requisito / caso | Correção e evidência | QA / ativo |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-05 / C15 | Oito etapas versionadas; 7/8 recebem 201 e GET recupera etapa/posição/versão. Legado de seis níveis mantém significado; discrepância 422, conflito 409, outro utilizador 403. | API/persistência QA; visual/publicação pendentes. Sem conversão ativa. |
|
||||
| AUD-COUNTRY-06/07; AUD-STATS-01 / C17–19 | País e origem separados em negócios/fundiário; gravação cifrada, recuperação, filtros autorizados e agregados. Texto legado conservado. | QA demonstrada por formulário; visual e versão servida ativa não demonstradas. |
|
||||
| AUD-GEO-02/03 / C17 | Fundiário aceita país→província→distrito→posto→localidade. 361 geometrias oficiais inequívocas importadas privadamente e recuperadas, sem duplicar na segunda execução. | QA: 9 províncias, 120 distritos, 232 postos. Ativo: zero geometrias. |
|
||||
| AUD-SCAN-01 / C07 | ClamD real 1.5.4, assinaturas atualizadas, EICAR detetado e seis integrações reais passaram. | Serviço local disponível; arranque após reinício, manutenção e derivadas web pendentes. |
|
||||
| AUD-STYLE-01 / C10 | Formato global passou; lint sem erros, 11 avisos existentes. | Critério concluído; não certifica interface. |
|
||||
| AUD-UI-01 / C20 | Ícone de pesquisa ligado à rota existente de oportunidades. | Código preparado/rota ensaiada; clique visual pendente. |
|
||||
|
||||
O próximo lote prioriza **percursos existentes de guardar/recuperar, filtros, seletores, mapas e ações por perfil**. Rascunhos e novas jornadas educativas ficam depois destas correções, sem desaparecer do backlog.
|
||||
|
||||
## Contagens reconciliadas
|
||||
|
||||
A [matriz existente](matriz-requisitos.csv) contém **3.354 requisitos canónicos e 3.354 linhas de dados**, mais cabeçalho. Preserva **5.830 linhas de origem**, com IDs/aliases, fontes, trechos, hashes, versões divergentes e critérios por operação. Não foi criada outra matriz.
|
||||
|
||||
| Estado | Requisitos | Linhas de origem |
|
||||
| --- | ---: | ---: |
|
||||
| Concluído e demonstrado | 5 | 5 |
|
||||
| Parcial | 3.198 | 5.380 |
|
||||
| Não implementado | 146 | 440 |
|
||||
| Implementado mas não ativado | 1 | 1 |
|
||||
| Com falha no critério atualmente registado | 0 | 0 |
|
||||
| Dependente de decisão concreta | 4 | 4 |
|
||||
| Total | 3.354 | 5.830 |
|
||||
|
||||
Zero linhas no estado «com falha» **não significa zero falhas no portal**: requisitos com componentes corrigidos e componentes ainda ausentes permanecem parciais. Continuam sem implementação o workflow completo, contratos especializados e rascunho educativo incompleto. Continuam não demonstrados todos os consumidores/mapas e a interface renderizada.
|
||||
|
||||
Concluídos apenas nos critérios estreitos: AUD-OPS-02 (guarda QA), AUD-OPS-03 (leitura/preservação), AUD-COUNTRY-05 (API ISO), AUD-GEO-04 (recuperação do catálogo administrativo) e AUD-STYLE-01 (checks de formato). Catálogo API não encerra formulário/mapa. Contagens não são percentagem de aceitação; decomposição semântica de cláusulas compostas permanece AUD-DOC-01. As cinco etapas de prova estão separadas por requisito: interface → API → gravação → recuperação → resultado apresentado.
|
||||
|
||||
## Países, origem e estatísticas por percurso
|
||||
|
||||
**Negócios:** CountrySelect e 249 países nas props → POST/PUT autorizados → country=PT/origem=ZA no payload cifrado existente → GET recupera os dois → lista filtrada e estatísticas de negócio submetido ZA=1/PT=0. Draft fica fora do agregado; outra conta recebe 403/lista vazia. ZZ recebe 422 sem nova versão. Texto histórico Portugal conserva-se no draft até escolha explícita de PT; submissão completa exige ISO conhecido.
|
||||
|
||||
**Fundiário:** CountrySelect país/origem → POST/PUT do caso → payload cifrado separado → GET recupera país/origem e caminho dos quatro níveis → solicitar apoio conserva origem na solicitação/CRM; gestor filtra país PT/origem ZA. Agregado de interesses conta o pedido uma vez. Texto legado Moçambique permanece exato; inválido conserva versão; outra conta 404/403.
|
||||
|
||||
**Percursos anteriores preservados:** manifestação pública, quatro tipos de apoio, interesse em negócio, jornada e Balcão têm provas específicas de origem; simulador guarda países em snapshots cifrados, sem gerar cálculo por isso. Os conjuntos interests, desk e businesses são apresentados separadamente. São solicitações/negócios, não investidores únicos. Ausência permanece «não informado»: não foi inferida origem para os quatro interesses históricos ativos. [Cobertura por consumidor](cobertura-consumidores-lote2.md) identifica o que falta por formulário.
|
||||
|
||||
## Território, fontes e mapas
|
||||
|
||||
O inventário passa a **524 ficheiros e 60 anexos embebidos: 584 registos**, incluindo 11 originais novos de serviço/metadados/datasets GIS. Binários ClamAV e logs são artefactos de execução, não acervo documental. A aceitação das fontes oficiais mantém-se; metadados servem para identificar edição/âmbito, não recertificar credibilidade.
|
||||
|
||||
Fonte selecionada: serviço Mozambique/FeatureServer, origem **INE**, distribuição UNOCHA, edição **07/06/2019**, disponível no alojamento ArcGIS do Banco Mundial. Originais GeoJSON preservados por hash: ADM1=11, ADM2=159, ADM3=411. Importação exige origem, contagem integral, scan real do original, geometria válida/SRID 4326 e correspondência única de nome normalizado + pai. Códigos de fontes diferentes não foram equiparados automaticamente.
|
||||
|
||||
QA `desk_restore_20261009_171209_test:55441`: **9 + 120 + 232 = 361** correspondências importadas em `geo_geometries` existente; segundo import reutiliza todas e cria zero. Todas válidas e recuperáveis. **Lacunas: 2 províncias, 39 distritos, 179 postos sem correspondência inequívoca; nenhuma geometria de localidade nessa fonte.** As duas designações de Maputo diferem entre versões; sem remapeamento silencioso. Edição de 2019 não é declaração de atualidade jurídica.
|
||||
|
||||
Geometrias estão **internas, privadas, QA**. Não viram locais de investimento, polígonos publicados ou pontos inventados. Os 1.920 territórios recuperados em 597 pedidos API continuam prova de catálogo, não de campos/mapas. Mapas de oportunidades, catálogo/detalhe/portefólio, parcerias e zonas precisam de provas próprias de camada, filtro, geometria/identificador e resultado. Ativo mantém zero geometrias; 28 locais de projetos e 13 relações cluster–projeto foram preservados.
|
||||
|
||||
## QA, ações e limites
|
||||
|
||||
**379 testes passaram, 4.922 asserções, zero saltados; 156 testes frontend/reconciliação passaram.** TypeScript, PHPStan, build e formato global passaram; build conserva aviso de tamanho de chunks, lint 11 avisos existentes. PortalAuditNextLotTest demonstra maturidade, negócios e fundiário com persistência, recuperação, isolamento, inválidos e conflitos. Seis testes de ClamD real passaram, sem substituto fictício.
|
||||
|
||||
Inventário estático de todas as páginas/componentes TSX: **363 ocorrências de botões e 270 seletores/mapas**. Cinco dependem de contexto de diálogo/menu e exigem confirmação por percurso; não são classificados como falha só por falta de handler direto. Binding estático e os 352 GET anteriores por quatro perfis não demonstram todos os cliques/transições/anexos. O navegador continua indisponível; não há capturas apresentadas como prova atual.
|
||||
|
||||
Base ativa consultada READ ONLY: 288 tabelas; seis oportunidades reais IDs 1–6, 1.990 territórios, zero geometrias, dois negócios/duas revisões, quatro interesses sem origem, um dossier Balcão/zero pedidos. Cadastros vazios educativos/CRM/Bolsa não foram preenchidos artificialmente. Dos 209 hashes temporais comparáveis, 207 coincidem; cache/sessions diferem sem causa atribuída. Backup/restauro atual compara todas as 288 tabelas e cinco ficheiros.
|
||||
|
||||
## Instalação e próximo lote
|
||||
|
||||
Recursos continuam **installed=false; ready=true** no preview READ ONLY de 17:24:56 UTC. Backup/restauro verificado às **17:21:00 UTC**, QA `desk_restore_20261009_172050_test:55441`; reversão compatível nas 288 tabelas. Fingerprint `bb413e306779656699072f91235856bec626c1c6571ff2267a3d64417c7576ce`; validade até 10/10 17:21 UTC, sujeita às guardas.
|
||||
|
||||
Países/origem/estatísticas/maturidade do draft não exigem migration nem ClamD. Extensão privada de imagens/documentos/multilocalização exige a ação autenticada existente e aplica só `2026_10_09_030000_extend_opportunity_resources`, uma vez. Não repetir Balcão/Bolsa/gestão básica. ClamD limita scans/libertação dos binários afetados; GD/WebP do PHP web e scans de derivadas continuam prova separada. Serviço ClamD atual não comprova arranque após reinício.
|
||||
|
||||
Próximo lote: fechar API/persistência dos restantes seletores/filtros e ações existentes por consumidor/perfil; integrar em QA as camadas privadas já correspondidas e demonstrar resultados; completar workflow e consumidores da maturidade versionada sem reinterpretar histórico. Verificação visual retoma assim que houver navegador. Instalação não transforma preparação em aceitação integral.
|
||||
|
||||
Decisões humanas mantidas: pares oportunidade–projeto/cluster por ID/papel/citação; entidade operadora/contactos/termos; contactos/links/responsável por serviço; pesos/limiares/baseline dos pilotos. Nenhuma reabertura da aceitação oficial.
|
||||
|
||||
[Backlog](backlog.md) · [Matriz](matriz-requisitos.csv) · [Casos C01–C20](casos-reproducao.md) · [Cobertura e evidências](cobertura-consumidores-lote2.md) · [Plano de ativação](plano-ativacao.md) · [Inventário](inventario-fontes.csv). Dumps, ZIP, configuração, chaves e dados pessoais não acompanham os anexos.
|
||||
'''
|
||||
|
||||
backlog = '''# InvesteMoz — backlog priorizado do lote 2
|
||||
|
||||
09/10/2026. Mesma [matriz](matriz-requisitos.csv): **3.354 requisitos, 5.830 linhas de origem**. Estados: 5 concluídos, 3.198 parciais, 146 não implementados, 1 implementado não ativo, 0 com falha no critério registado, 4 decisões concretas. Parcial pode conter funcionalidade ainda em falta. Código preparado ≠ QA demonstrada ≠ funcionalidade ativa.
|
||||
|
||||
## P0 — guardar, recuperar e preservar
|
||||
|
||||
**AUD-OPS-02/03 — concluídos no critério operacional.** Guarda host/porta/base e lock antes da limpeza; ensaios só em QA; consulta ativa READ ONLY. Aceitação cumprida: runner concorrente recusado, suite sequencial passa, snapshots preservados. Backup/restauro atual compara 288 tabelas/cinco ficheiros; 207/209 hashes temporais coincidem, divergência cache/sessions não atribuída.
|
||||
|
||||
**AUD-GEO-02 / C17 — falha fundiária corrigida em QA, requisito global parcial.** Remover condição errada province.parent_id=null; reutilizar geo_locations/InvestmentGeography, mantendo validação de descendentes e classificação. Aceitação QA: país→província→distrito→posto→localidade guarda/recupera e apresenta caminho nas props; IDs inválidos/outra conta negados sem alterar versão. Falta UI. Sem migration/ClamD.
|
||||
|
||||
**AUD-COUNTRY-07 / C18 — perda da origem corrigida em QA.** Conservar origem explícita no payload/hash/metadata ao solicitar apoio; reutilizar InterestSubmission e CRM interno. Aceitação QA: country PT/origem ZA continuam distintos no caso, recibo, pedido e filtro; uma contagem, replay sem duplicação, sem inferência histórica. Falta clique/resultado visual.
|
||||
|
||||
Toda nova falha de escrita/recuperação nos percursos seguintes sobe a P0, com reprodução, esperado/observado, causa e prova de conservação. Não instalar recursos ativos para diagnosticar.
|
||||
|
||||
## P1 — lote já corrigido, ainda sem aceitação visual
|
||||
|
||||
**AUD-OP-05 / C15 — 422 para 7/8 resolvido.** OpportunityMaturity/OpportunityManagement e seletor reutilizam management_draft. Oito códigos spec-v2 coexistem com seis valores legacy-v1. Aceitação demonstrada: oito criações/GET, 7/8=201, etapa/posição/versão recuperadas, código/posição incompatíveis 422, conflito 409, perfil indevido 403, replay único. Mudança só de versão sem etapa é recusada; legado não é reinterpretado. Próximo: filtros/fichas e publicação conscientes da versão, após workflow; não expor draft privado no catálogo. Sem migration/ClamD.
|
||||
|
||||
**AUD-COUNTRY-06 / C19 — negócios.** CountryCatalogue/CountrySelect + BusinessFormSchema/ExistingBusinessIntake/CanonicalBusinessRepository existentes. País/origem separados, ISO desconhecido 422, texto Portugal preservado no draft até escolha explícita, payload cifrado, cinco revisões conservadas. Filtros operam depois do escopo autorizado; drafts não entram no agregado. Falta visual e versão web ativa. Dependência: catálogo 249 existente, sem dados novos/tabela.
|
||||
|
||||
**AUD-COUNTRY-07 / C17–19 — fundiário.** País/origem separados; GET recupera nomes/códigos/caminho; submissão integra interesses/CRM uma vez; inválido conserva versão e legado exato. Falta visual. Sem ClamD ou extensão de recursos.
|
||||
|
||||
**AUD-STATS-01 — filtros/agregados.** DeclaredOriginStatistics mantém interests, desk e businesses separados; negócio só submetido, fundiário contabilizado como interesse. Aceitação QA: filtro ZA contém pedido, PT não; unknown permanece não informado; país não substitui origem; outra conta negada. Próximo: URL/seleção/nova sessão/tabela no navegador. Não contar investidores únicos.
|
||||
|
||||
**AUD-SCAN-01 / C07 — serviço real disponível.** ClamD 1.5.4 com bases oficiais, EICAR em memória detetado e seis integrações reais. Próximo: arranque após reinício, atualizações regulares e prova de GD/WebP web/scan de cada derivada. Critério: benigno/ameaça por hash, assinatura/data/versão, restrição só na operação afetada. Limite geral de 20 MB mantido; ADM3 GIS teve scan CLI separado, não alargamento do limite do portal.
|
||||
|
||||
**AUD-STYLE-01 / C10 — concluído.** Pint e formato global passaram; lint 0 erros/11 avisos. **AUD-UI-01 / C20 — pesquisa ligada à rota existente**, ainda precisa de clique/feedback no browser.
|
||||
|
||||
## P1 — próximo lote executável, por ordem
|
||||
|
||||
1. **AUD-UI-01; AUD-DASH-01; AUD-DESK-01/02; AUD-CRM-01; AUD-BIZ-01; AUD-LAND-01.** Percorrer ações existentes antes de novas jornadas: criar/editar/guardar/recuperar/pesquisar/filtrar, transições, exclusão lógica/restauro e anexos aplicáveis, por dono/revisor/gestor/consumidor. Reutilizar controllers/services/formulários existentes e inventário de 363 botões. Aceitação por ação: resposta/erro visível, versão/evento, GET seguinte e isolamento; GET de rota não fecha botão. API e persistência avançam sem browser; visual continua pendente. ClamD só nos anexos/scans afetados; recursos pendentes só nos percursos que usam a extensão.
|
||||
2. **AUD-COUNTRY-01–04/06–07; AUD-STATS-01; AUD-SIM-01.** Fechar consumidores restantes por formulário, incluindo campos condicionais de serviços/Balcão e origem na recuperação. Reutilizar catálogo ISO/componentes/payloads; não inferir nacionalidade/residência/origem entre si. Aceitação: seleção/pesquisa → pedido válido → valor cifrado/canónico → nova sessão → nome/código e filtros/agregado reconciliados; ZZ 422 sem escrita, legado exato, unknown/todos, contas isoladas. Não depende de ClamD.
|
||||
3. **AUD-GEO-01–03; AUD-FIELD-* — todos os seletores.** Inventário de 270 seletores/mapas é evidência de consumidores, não cobertura funcional. Reutilizar TerritoryFields/ProvinceSelect/InvestmentGeography e os 1.920 IDs existentes. Aceitação individual: pai limpa filhos, pesquisa paginada sem omissões, ID válido guarda/recupera, privado/descendente errado 422 conserva versão, nomes e caminho apresentados. Múltiplos locais de oportunidades dependem da extensão instalada em QA, não de repetir módulos.
|
||||
4. **AUD-GEO-03 — camadas e lacunas GIS.** Reutilizar geo_geometries e importador guardado. Já há 361 correspondências internas em QA, scan original real, SRID/validade e replay zero novos. Próximo: conferir camadas em cada mapa, filtro/ID/geometria/legenda/resultado e política existente de acesso. Lacunas concretas: 2/39/179 sem correspondência por nível, nenhuma localidade; Maputo exige reconciliação explícita de versão/designação. Não usar centroides administrativos como investimento. Aceitação: fonte/edição/hash, correspondência inequívoca e resultado da camada; sem publicação ou vigência presumida. Procura de outras geometrias oficiais não reabre aceitação da fonte identificada.
|
||||
5. **AUD-OP-03/04/05 — workflow e contratos especializados.** Reutilizar draft/versionamento/audit_events/categorias existentes. Implementar revisão→devolução→aprovação→publicação com perfis distintos e critérios por categoria; levar catálogo versionado até filtros/ficha pública sem vazar propostas. Aceitação: cada transição autorizada/negada, motivo/ator/versão, recuperação, publicação só do conteúdo aprovado; campos/regras por categoria, legado conservado. Pessoas reais necessárias para atos ativos, não para desenvolver/testar QA.
|
||||
|
||||
Depois de cada incremento, atualizar a mesma linha canónica, referências/versões, caso e cinco etapas de prova. Não esperar por decisões independentes nem criar tabelas/cadastros paralelos.
|
||||
|
||||
## P1/P2 — restantes pedidos preservados
|
||||
|
||||
**AUD-EDU-02/03/01:** rascunho incompleto de procura/capacidade e jornadas/painéis por ator continuam ausentes/incompletos. Curso/proposta em draft não os substitui. Entram depois dos percursos existentes acima. Reutilizar LabourDemand/TrainingCapacity/schemas, sem datas/zeros fictícios. Aceitação: draft privado incompleto guarda/recupera, submissão incompleta 422 conserva draft, versão/conflito/isolamento, exclusão dos agregados; completo segue revisão por ator distinto.
|
||||
|
||||
**AUD-LIB-01/02:** inventário 584 registos, disponibilidade/jurídico/cálculo independentes; preview/hash/dedup por bytes/conflito de identidade/scan/escrita privada/replay, nunca publicação automática. Original e autorização por ficheiro; associação a artigo/módulo não condiciona cadastro/consulta. Fonte fiscal ausente/parâmetro ambíguo indeterminado, nunca zero/taxa recente.
|
||||
|
||||
**AUD-SIM-02; AUD-PART-01; AUD-GUIDE-01:** regras/versionamento/cálculos explicáveis; candidatura/sala/marcos/retirada por papel; orientação com fonte/âmbito/período e recuperação. Preservar cadastros vazios sem fabricar população. Critério individual na matriz, não fecho por existência de menu.
|
||||
|
||||
**AUD-DOC-01:** separar semântica das cláusulas compostas preservando fonte/ID/versões; histórico não substitui estado atual. Original upgrade v4 e relatório outputs indicado não localizados; mensagem preservada como fonte sem hash inventado.
|
||||
|
||||
## Decisões humanas — perguntas exatas
|
||||
|
||||
- **AUD-OP-02:** quais pares de IDs oportunidade–projeto/cluster, papel e citação devem ser associados?
|
||||
- **AUD-HUMAN-01:** qual entidade operadora, contactos e versões de termos/política estão autorizados para divulgação?
|
||||
- **AUD-HUMAN-02:** quais contactos/links específicos e responsável real devem constar por serviço?
|
||||
- **AUD-HUMAN-03:** que pesos, limiares, baseline/período e responsáveis devem ser usados nos pilotos?
|
||||
|
||||
Nenhuma pergunta é uma segunda validação da credibilidade oficial já aceite.
|
||||
|
||||
## Ativação e prova de fecho
|
||||
|
||||
379 testes/4.922 asserções/zero skips; 156 frontend/reconciliação; checks técnicos passaram. [Casos C01–C20](casos-reproducao.md), [cobertura por percurso](cobertura-consumidores-lote2.md), [plano autenticado](plano-ativacao.md). Browser indisponível; nenhuma demonstração visual atual.
|
||||
|
||||
Backup/restauro 09/10 17:21 UTC, 288 tabelas/cinco ficheiros, hash bb413e30…; ready=true, installed=false. Países/origem/estatísticas/maturidade privada não exigem migration; recursos privados exigem só extensão pendente autenticada; geometria QA é independente e privada. Não repetir Balcão/Bolsa/gestão básica. Instalação não fecha workflow/mapas/visual nem ativa cálculo fiscal.
|
||||
'''
|
||||
|
||||
plan = '''# Plano de ativação autenticada — lote 2
|
||||
|
||||
09/10/2026. Preview READ ONLY 17:24:56 UTC: **installed=false, ready=true**. Preparação não é instalação ou conclusão funcional. Nenhum ato ativo executado nesta auditoria.
|
||||
|
||||
## Ordem e dependências reconciliadas
|
||||
|
||||
1. **Código/assets, sem migration:** maturidade versionada no draft, países/origem de negócios/fundiário, filtros/estatísticas, hierarquia fundiária e pesquisa. Reutilizam JSON/payloads cifrados/catálogos existentes; não convertem valores históricos. Verificar versão web/manifesto e percursos QA. ClamD e recursos não são dependências deste grupo.
|
||||
2. **Preview com conta autorizada existente:** `/management/opportunity-resources-install`; destino `investemoz:5432`. Backup/restauro verificado em **09/10/2026 17:21:00 UTC**, válido até **10/10/2026 17:21:00 UTC** sujeito a código/destino/guardas. QA de compatibilidade `desk_restore_20261009_172050_test:55441`, 288 tabelas/cinco ficheiros; up/down preservou baseline. Fingerprint **bb413e306779656699072f91235856bec626c1c6571ff2267a3d64417c7576ce**. Mudança/expiração exige renovar provas pertinentes; não modificar prova para simular aceitação.
|
||||
3. **Só extensão pendente, uma vez:** ação autenticada aplica `2026_10_09_030000_extend_opportunity_resources`. Imagens/documentos/multilocalização privados ficam disponíveis; não publica, não atribui relação/classificação e não ativa cálculo. Confirmar por leitura migration/evento/autor/hash e conservação dos IDs 1–6, relações 28/13, ficheiros/histórico. Não repetir Balcão/Bolsa/gestão básica: cada um tem um evento de instalação.
|
||||
4. **Binários/derivadas afetados:** ClamD real 1.5.4 está disponível nesta sessão e integrações QA passaram. Arranque após reinício/atualização periódica, GD/WebP no PHP web e scan de cada derivada são provas específicas ainda pendentes. Não impedir metadados/países/origem/pesquisas por esta dependência; manter restrição concreta nos binários não verificados.
|
||||
5. **Geometria, independente:** QA `desk_restore_20261009_171209_test:55441` contém 361 geometrias internas de 2019, originals/hash/scan e correspondências inequívocas; replay zero novos. Esta cópia não é a cópia de compatibilidade de recursos. Não copiar fixtures, publicar camadas ou declarar atualidade jurídica por importar. Ativo continua zero; reconciliar camadas/consumidores/lacunas antes do ato autenticado de uso ativo pertinente.
|
||||
6. **Conteúdo/workflow real:** publicação/aprovação/contratos especializados e designações institucionais não surgem da extensão. QA avança sem pessoas inventadas; atos ativos usam contas/designações reais. Revisão visual retoma assim que o browser existir, sem declarar frontend observado agora.
|
||||
|
||||
QA global: 379 testes, 4.922 asserções, zero skips; 156 frontend/reconciliação; TypeScript/PHPStan/build/formato passaram. Escopo é preparação técnica/API/persistência, não aceitação integral do portal.
|
||||
|
||||
Reversão direta demonstrada **antes de novas escritas**. Depois de uso, preservar novos dados/eventos e reconciliar antes de down/restauro. Dumps, ZIP, .env/chaves permanecem privados. Provas: backup-proof, compatibility, activation-proof e resources-lote2-preview; não contornar autenticação ou impersonar conta ativa via CLI.
|
||||
'''
|
||||
|
||||
coverage = '''# Evidências por consumidor — lote 2
|
||||
|
||||
09/10/2026. Inventário de evidências ligado aos IDs da matriz existente; **não é outra matriz de requisitos**. “Props” é resultado de GET/Inertia, não observação visual. Todos os ensaios com escrita são QA; ativo apenas leitura.
|
||||
|
||||
| Consumidor / requisito | Interface preparada | API → gravação → recuperação → resultado | Lacuna |
|
||||
| --- | --- | --- | --- |
|
||||
| Manifestação pública / COUNTRY-01–04 | CountrySelect país/origem | InterestOriginTest/PortalAuditContinuationTest: PT/ZA separados, metadata persistido, GET gestor/estatística, inválido sem escrita/replay único | Browser, restantes variações por consumidor |
|
||||
| Apoio: quatro tipos / COUNTRY-04 | Campo país pertinente e origem opcional | PortalAuditContinuationTest: guarda ambos nos quatro tipos, recupera detalhe/origem, another actor denied | Browser/serviços condicionais restantes |
|
||||
| Interesse em negócio público / COUNTRY-04 | Dois campos distintos | Provas anteriores de InterestSubmission/recuperação; não confundir com wizard privado | Visual e ensaio por cada variação |
|
||||
| Jornada / COUNTRY-04 | Seleção e props country/origin | InvestmentPortfolioTest conserva recuperação; pedidos integram interesses | Visual/nova sessão renderizada |
|
||||
| Wizard de negócios / COUNTRY-06 | CountrySelect país/origem, 249 países | PortalAuditNextLotTest: POST/PUT encrypted payload → GET → filtros country/origin; draft aggregate 0, submitted ZA=1/PT=0; legado Portugal, ZZ 422 preserva versão, other actor 403/list empty | Visual/perfis adicionais/revisão completa |
|
||||
| Assistência fundiária / COUNTRY-07; GEO-02 | País/origem e quatro níveis | Mesmo teste: POST/PUT/GET cifra e recupera nomes/territoryPaths; request-support preserva ZA; gestor filtra PT/ZA, agregado interests uma vez; legado e versão conservados | Visual/mapas/anexos/transições restantes |
|
||||
| Balcão / DESK-01; COUNTRY-04; GEO-02 | Origem e TerritoryFields | VirtualDeskTest/PortalAuditContinuationTest: payload cifrado, localidade/path recuperados; origem agregada desk separada | Todos os campos condicionais por serviço, transições/visual |
|
||||
| Simulador / SIM-01; COUNTRY-05 | País ISO/fundos e território separados | SimulationPrivateDraftTest/Continuation: PT/ZA cifrados GET, legado, ZZ 422/other actor 404; nenhum cálculo por guardar | Demais campos/cálculos/visual |
|
||||
| Gestor/CRM / STATS-01 | Filtros country/origin/unknown e 3 agregados | GET authorized scope/filtered rows/counts; interests/desk/businesses separados, business drafts excluídos | URL/seleção/tabela no navegador; escala/performance das listas cifradas |
|
||||
| Draft de oportunidade / OP-05 | Catálogo server-side 8; opção histórica | NextLotTest: 8 POST/GET, 7/8=201, canonical code/position/version stored private JSON; legacy raw unchanged; mismatch/version-only 422, conflict409/actor403 | Publicação/filtros públicos versionados/visual |
|
||||
|
||||
## Território por consumidor
|
||||
|
||||
Catálogo: 597 pedidos/1920 IDs na cópia ativa restaurada; não se generaliza aos campos. Fundiário e Balcão têm prova própria de hierarquia/persistência; demais controles mantêm prova individual pendente.
|
||||
|
||||
- Seletores de submissão/perfil/oportunidades, negócios, parceria, simuladores e administração: conferência de tipo/pai/paginação/seleção/limpeza de filhos/recuperação/erro/visual por componente. Reutilizar IDs existentes, sem transformar cadastro em preenchimento automático.
|
||||
- Mapas do catálogo/detalhe/portefólio: `/api/v1/investment/map`; mapas de zonas: `/api/v2/investment/zone-map`; mapas de parcerias e respectivas áreas/IDs têm critérios próprios. Campos cartográficos não fecham por responder a API territorial.
|
||||
- Geometrias administrativas INE/UNOCHA 2019: originais 11/159/411, privadas QA 9/120/232. Correspondência única nome+pai; nenhum centro administrativo vira ponto de oportunidade. Sem locality geometry; unmatched 2/39/179. Maputo diverge por designação/versão.
|
||||
|
||||
## Ações e perfil
|
||||
|
||||
consumer-actions-lote2.json inventaria todas as páginas/componentes: 363 botões e 270 seletores/mapas. Cinco bindings requerem contexto (menu/avatar/diálogo de exclusão/passkey/cancelamento). Não se infere falha ou sucesso só pelo AST. C20 encontrou pesquisa sem ação e liga-a à rota existente.
|
||||
|
||||
Ensaios já presentes demonstram negativa entre contas e gravação/recuperação dos percursos específicos; não se afirma cobertura de cada botão. Próximo lote executa dono/revisor/gestor/público, com retorno visível e evento/versão quando aplicável. No browser indisponível, API/persistência continuam; visual fica separado.
|
||||
|
||||
## Manifesto das provas exportáveis
|
||||
|
||||
- phpunit-final.json; phpunit-lote2-final.xml: 379 pass, 4922 assertions, zero skipped.
|
||||
- consumer-actions-lote2.json: inventário estático, não DOM observado.
|
||||
- geometry-qa-adm1/2/3-import.json: destino QA, counts/reuse/SRID/validity/hash; geometry-source-scans.json: scans reais dos originais.
|
||||
- resources-lote2-preview.json; activation-evidence.json: destino ativo somente leitura, hash/provas, ready=true/installed=false.
|
||||
- clamd-lote2.json: saúde/versão/assinatura/EICAR e hashes; sem afirmar scans de derivadas não ensaiadas.
|
||||
- contagens-requisitos.json: 3354 canónicos/5830 origens conservadas; matriz CSV mantém aliases/versões.
|
||||
|
||||
Logs de execução e snapshots pessoais/dumps/configuração permanecem privados. Provas de geometria e compatibilidade usam cópias QA distintas; não misturar seus estados.
|
||||
'''
|
||||
|
||||
activation = json.loads((proof / 'activation-evidence.json').read_text(encoding='utf-8'))
|
||||
preview = json.loads((proof / 'resources-lote2-preview.json').read_text(encoding='utf-16').lstrip('\ufeff'))
|
||||
backup_time = activation['verified_at'][11:19]
|
||||
backup_short = backup_time[:5]
|
||||
compatibility = json.loads((proof.parent / 'opportunity-resources-compatibility.json').read_text(encoding='utf-8'))
|
||||
|
||||
for name, text in [('relatorio-executivo.md', report), ('backlog.md', backlog), ('plano-ativacao.md', plan)]:
|
||||
text = text.replace('bb413e306779656699072f91235856bec626c1c6571ff2267a3d64417c7576ce', activation['code_hash'])
|
||||
text = text.replace('bb413e30…', activation['code_hash'][:8] + '…')
|
||||
text = text.replace('17:21:00', backup_time).replace('17:21', backup_short)
|
||||
text = text.replace('17:24:56', preview['captured_at'][11:19])
|
||||
text = text.replace('desk_restore_20261009_172050_test', compatibility['target']['database'])
|
||||
file = docs / name
|
||||
previous = file.read_text(encoding='utf-8')
|
||||
marker = '## Histórico da continuação anterior — não representa o estado atual'
|
||||
if marker in previous:
|
||||
previous = previous.split(marker, 1)[1].lstrip()
|
||||
file.write_text(text + '\n' + marker + '\n\n' + previous, encoding='utf-8')
|
||||
(docs / 'cobertura-consumidores-lote2.md').write_text(coverage, encoding='utf-8')
|
||||
|
||||
cases = docs / 'casos-reproducao.md'
|
||||
append = '''
|
||||
## Atualização C07/C10/C15 — lote 2, observações antigas preservadas
|
||||
|
||||
C07: indisponibilidade corrigida; ClamD real 1.5.4/assinaturas, EICAR detetado em memória e seis testes reais passaram. Restam arranque após reinício/atualizações e GD web/derivadas. C10: formato global corrigido e checks passaram; lint 11 avisos existentes. C15: POST maturity_level=7/8 agora 201 em QA, GET recupera versão/etapa/posição. Oito etapas spec-v2 explícitas, seis legacy-v1 preservadas. Código/posição incoerentes ou versão sem etapa 422; conflito409/perfil403/replay único. AUD-OP-05 parcial por workflow/publicação/visual, não pela rejeição antiga. PortalAuditNextLotTest/phpunit-lote2-final.xml. Nenhuma escrita ativa.
|
||||
|
||||
## C17 — província com país pai recusada no fundiário — P0/P1
|
||||
|
||||
Reprodução: criar caso privado QA, escolher os IDs válidos país→província→distrito→posto→localidade do cadastro. Esperado: guardar e recuperar hierarquia pública válida. Observado antes: 422 province_id apesar de cadeia correta. Causa identificada: StoreLandAssistanceRequest exigia parent_id=null para província, enquanto o catálogo tem país pai. Correção remove essa suposição; conserva tipo/classificação/descendência/ciclos. QA NextLotTest POST/PUT/GET recupera quatro níveis e territoryPaths; inválido/outra conta recusados, versão preservada. Interface preparada, visual pendente. Ativo não migrado/reescrito.
|
||||
|
||||
## C18 — origem fundiária omitida ao pedir apoio — P0/P1
|
||||
|
||||
Reprodução: caso QA com country PT e declared_origin_country_code ZA; executar request-support e GET gestor/filtrar origem. Esperado: ZA continua origem, PT país. Observado por código anterior: campo não constava do payload enviado a InterestSubmission, perdendo rastreabilidade. Correção inclui origem no payload/hash/metadata existentes. QA conserva ZA no pedido/CRM/recuperação, filtro PT/ZA e agregado interests uma vez; conta indevida 404/403. Não inferir valores históricos. Visual pendente.
|
||||
|
||||
## C19 — negócios/fundiário sem catálogo completo e percurso de origem — P1
|
||||
|
||||
Reprodução por código/API: wizard country texto livre, sem origem separada/filtros/agregado de negócios; fundiário sem seleção ISO. Esperado: países pertinentes selecionáveis, legado conservado, origem explícita independente e recuperável. Correção CountryCatalogue/CountrySelect/validação e payloads cifrados existentes. QA negócios: draft PT/ZA, ZZ422 conserva versão, texto Portugal recuperado exato, escolha explícita PT, submissão válida/agregado ZA1, filtros e outro ator negado; cinco revisões. QA fundiário: PT/ZA/legado Moçambique recuperados, apoio preserva origem. Não reivindicar visual ou cobertura dos outros formulários por estas provas.
|
||||
|
||||
## C20 — botão de pesquisa do cabeçalho sem efeito — P1
|
||||
|
||||
Reprodução por inspeção: app-header.tsx apresentava botão Search sem href/onClick ou formulário/contexto de ação. Esperado: abrir pesquisa existente com destino visível. Causa: ícone decorativo embrulhado em Button sem binding. Correção usa Link /oportunidades via asChild, com nome acessível. Rota/compilação verificadas; clique/URL/feedback visual ainda pendentes por browser indisponível. Não é ação de persistência. Inventário dos demais 363 botões não é aceitação funcional.
|
||||
'''
|
||||
if '## Atualização C07/C10/C15 — lote 2' not in cases.read_text(encoding='utf-8'):
|
||||
cases.write_text(cases.read_text(encoding='utf-8') + append, encoding='utf-8')
|
||||
|
||||
notice = '''
|
||||
## Estado de auditoria — lote 2, 09/10/2026
|
||||
|
||||
Prevalece o [relatório atualizado](auditoria-portal/relatorio-executivo.md) sobre estados históricos: maturidade 7/8 já grava/recupera em QA com catálogo versionado, negócios/fundiário têm países/origem separados e provas por percurso, 361 geometrias oficiais internas estão em QA, ClamD real está disponível. Workflow/publicação, cobertura transversal/visual/mapas e recursos ativos continuam pendentes. 379 testes/4922 asserções/zero skips; recursos ready=true, installed=false. A [mesma matriz](auditoria-portal/matriz-requisitos.csv) conserva 3354 requisitos/5830 origens; próximo lote no [backlog](auditoria-portal/backlog.md) prioriza falhas de percursos existentes antes de novas jornadas educativas.
|
||||
'''
|
||||
for name in ['gestao-oportunidades-dashboard.md', 'backlog-mestre.md']:
|
||||
file = docs.parent / name
|
||||
if file.is_file() and '## Estado de auditoria — lote 2' not in file.read_text(encoding='utf-8'):
|
||||
file.write_text(file.read_text(encoding='utf-8') + notice, encoding='utf-8')
|
||||
|
||||
print(json.dumps({'updated_existing_matrix_documents': True, 'parallel_matrix_created': False}, ensure_ascii=False))
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Reconcile current evidence into the existing documents, preserving history."""
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import Counter
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
d = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
tests = json.loads((d / 'phpunit-final.json').read_text(encoding='utf-8'))
|
||||
activation = json.loads((d / 'activation-evidence.json').read_text(encoding='utf-8'))
|
||||
compatibility = json.loads((d.parent / 'opportunity-resources-compatibility.json').read_text(encoding='utf-8'))
|
||||
preview = json.loads((d / 'resources-lote3-preview.json').read_text(encoding='utf-16').lstrip('\ufeff'))
|
||||
cases = list(ET.parse(d / 'phpunit-lote3-final.xml').iter('testcase'))
|
||||
selected = ['CrmIntakeTest', 'InvestmentTerritoryTest', 'LandAssistanceTest', 'OpportunityResourcesTest', 'PartnershipExchangeTest', 'PortalAuditConsumerLotTest', 'PortalAuditContinuationTest', 'PortalAuditNextLotTest', 'TerritorialRegistryTest']
|
||||
with (docs / 'acoes-perfis-lote3.csv').open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(['Classe', 'Percurso/caso automatizado', 'Asserções', 'Resultado QA', 'UI observada', 'Disponibilidade ativa demonstrada'])
|
||||
for case in cases:
|
||||
name = case.attrib.get('class', case.attrib.get('classname', ''))
|
||||
if name.rsplit('\\', 1)[-1] in selected:
|
||||
writer.writerow([name, case.attrib['name'], case.attrib.get('assertions', ''), 'passou' if len(case) == 0 else 'ver XML', 'não', 'não'])
|
||||
|
||||
report = '''# InvesteMoz — relatório executivo do lote 3
|
||||
|
||||
09/10/2026. Continuação da mesma matriz, sem novas jornadas educativas. Código preparado e API/persistência demonstradas em QA; nenhuma escrita ativa pela auditoria. Resultados visuais e anexação direta de PDFs continuam pendentes.
|
||||
|
||||
## Lote executado
|
||||
|
||||
- **AUD-COUNTRY-04 / AUD-DESK-01, C21:** os três serviços existentes do Balcão têm agora país do requerente separado da origem da solicitação. Ambos guardam no payload cifrado existente e recuperam via GET/Inertia; catálogo de249 países. ZZ recebe422 sem nova versão. Submissão recuperável; dono não analisa o próprio pedido, outra conta não consulta, gestor devolve os campos explicitamente permitidos. Corrigida a rejeição da origem na lista de campos devolvidos para correção. Correção permitida guarda versão5; alteração de título não autorizado422; versão antiga409. Definições/histórico dos serviços preservados, sem reinstalação.
|
||||
- **AUD-GEO-02/03, C22:** consulta de contexto administrativo ligada ao seletor territorial partilhado, gestão territorial, draft/recursos de oportunidades, catálogo e painel de mapas. TerritoryGeometry usa geo_locations/geo_geometries existentes; não cria local de investimento, perímetro de zona ou ponto fictício. GeoJSON simplificado, origem/edição/hash e legenda administrativa independentes dos marcadores de investimento.
|
||||
- **Geometrias reais:** na QA171209,361/361 limites importados recuperados pelo novo serviço; zero geometrias internas divulgadas publicamente. Teste HTTP separado demonstra autorização de gestor403 para outra conta, UUID inválido422, geometria arquivada retirada, edição2019 preservada. Localidade sem polígono próprio continua sem geometria própria mesmo tendo contexto ancestral. Nenhuma classificação/publicação ativa alterada.
|
||||
- **AUD-UI-01:** ações de CRM, Bolsa, recursos, território, fundiário e Balcão passaram nos casos automatizados ligados em acoes-perfis-lote3.csv. Resultado de resposta/GET/Inertia/evento é prova de servidor, não de clique observado. Inventário atual363 botões/272 seletores e mapas não fecha todos os consumidores.
|
||||
|
||||
## Cobertura por formulário
|
||||
|
||||
| Formulário | País/origem | API, gravação e recuperação | Interface atual |
|
||||
| --- | --- | --- | --- |
|
||||
| Manifestação pública | Campos distintos | Provas de InterestOrigin/Continuation/Compatibility: payload, metadata, inválido e replay | Preparada; visual pendente |
|
||||
| Apoio:4 tipos | País pertinente/origem opcional | Quatro percursos já ensaiados, recuperação/agregado | Preparada; visual pendente |
|
||||
| Interesse público em negócio | País/origem distintos | Provas anteriores próprias, não confundidas com wizard | Visual/variações pendentes |
|
||||
| Jornada | País/origem separados | Recuperação de jornada/solicitação | Visual pendente |
|
||||
| Wizard de negócios | Catálogo249 e legado conservado | Cifra, GET, filtros autorizados, submetido no agregado; draft excluído | Visual/revisão completa pendentes |
|
||||
| Assistência fundiária | Catálogo249/legado/origem | Grava/recupera, apoio mantém origem, filtros/agregado e hierarquia4 níveis | Visual/mapa/anexos pendentes |
|
||||
| Balcão preparação | País/origem separados | Novo percurso completo servidor, incluindo devolução/correção autorizada | Visual pendente |
|
||||
| Balcão alteração | País/origem separados | Mesmo ensaio individual com campos próprios do serviço | Visual pendente |
|
||||
| Balcão acompanhamento | País/origem separados | Mesmo ensaio individual com campos próprios do serviço | Visual pendente |
|
||||
| Simulador | Residência fiscal/países dos fundos; sem inferir origem de solicitação | Snapshots cifrados/GET, legado e inválido, sem cálculo por guardar | Demais campos/cálculos/visual pendentes |
|
||||
| Gestor/CRM | Filtros de país/origem/unknown | Interests/desk/businesses separados; solicitações, não investidores únicos | URL/seleção/tabela visuais pendentes |
|
||||
|
||||
Campo opcional ausente permanece não informado. Não preencheu cadastros vazios nem converteu textos/valores históricos silenciosamente. Cobertura global continua parcial: alterações em componentes comuns não demonstram individualmente todos os formulários.
|
||||
|
||||
## Três estados territoriais independentes
|
||||
|
||||
1. **Território cadastrado:**1920 IDs administrativos públicos recuperados pela API, além do país; fonte workbook existente. Não representa campo preenchido.
|
||||
2. **Geometria disponível:**361 limites internos QA,9 províncias/120 distritos/232 postos. Fonte INE/UNOCHA,edição07/06/2019, originais/hash/scan preservados e aceitação oficial mantida. Lacunas nominais continuam2 províncias/39 distritos/179 postos; sem polígonos de localidade nesse exemplar. Ativo:zero geometrias.
|
||||
3. **Apresentação funcional:** endpoints e consumidores preparados, recuperação real361/361, sem divulgação pública. Mapa renderizado/zoom/seleção/filtro/feedback ainda não observados. Não fechar requisito por GeoJSON disponível. Zonas/perímetros e locais documentados de projetos/parcerias têm regras próprias; limite administrativo não os substitui.
|
||||
|
||||
## Navegador e documentação
|
||||
|
||||
C23: servidor QA18096 não escutava; foi iniciado e respondeu HTTP200 com header de destino _test:55441. O controlo de UI devolveu apps[]/browsers[] antes e depois de reset; Chrome e Edge responderam Browser is not available. Diagnóstico concreto: serviço QA recuperado, fornecedor de UI não registado nesta sessão. Causa subjacente do registo não foi demonstrada. Ligação de navegador solicitada, sem parar API/persistência. Não foram usadas capturas antigas como prova atual.
|
||||
|
||||
Não existe ferramenta de anexação nativa de PDF nesta sessão. Os links Adobe anteriores não funcionaram para o utilizador; logo os PDFs **não foram entregues como anexos diretos**, nem considerados revisão documental concluída. Este relatório/backlog mantêm-se no workspace e o conteúdo necessário à revisão é apresentado na conversa, sem novos links Adobe. PDFs do lote2 permanecem históricos.
|
||||
|
||||
## Provas, matriz e ativo
|
||||
|
||||
TESTS testes passaram, ASSERTIONS asserções, zero skips;156 frontend/reconciliação. TypeScript, PHPStan, build e formato passaram. Lint0 erros/11 avisos; npm check termina1 por avisos, não aprovação limpa. Novos2 testes/124 asserções mais regressão integral381. 360 GET por4 perfis, sem falhas registadas; não demonstra todos os botões/transições.
|
||||
|
||||
Mesma matriz:3354 requisitos/3354 linhas de dados+header;5830 linhas de origem preservadas, com referências adicionais ao pedido deste lote. Estados:5 concluídos no critério estreito,3198 parciais,146 não implementados,1 implementado não ativo,0 com falha no critério atualmente registado,4 decisões concretas. Parcial pode conter ações/campos ainda ausentes; zero com falha não significa portal sem falhas. Concluídos:OPS02/03,COUNTRY05,GEO04,STYLE01. Workflow,contratos especializados e decomposição semântica de cláusulas continuam no backlog.
|
||||
|
||||
Leitura ativa17:47:24UTC:288 tabelas; zero geometrias; Balcão/Bolsa/gestão básica com um evento de instalação cada. Recursos installed=false,ready=true; não repetidos. Fonte oficial aceite, importação não publica nem ativa cálculo.
|
||||
|
||||
Plano atual: código sem migration → recursos autenticados uma vez → importação cartográfica separada/privada → divulgação/camadas por ato explícito e prova por consumidor. Backup/restauro verificado BACKUPDATE, cópia COMPATDB:55441,288 tabelas/cinco ficheiros e reversão comparados. Hash HASH. Validade24h sujeita às guardas. ClamD só afeta scans/libertação binária; manutenção após reinício e GD/WebP web/derivadas pendentes.
|
||||
|
||||
Evidências: matriz-requisitos.csv, acoes-perfis-lote3.csv, casos C01–C23, phpunit-lote3-final.xml, geometry-consumers-lote3.json, resources-lote3-preview.json, activation-evidence.json. Dumps/configuração/chaves não são anexos.
|
||||
'''
|
||||
backlog = '''# InvesteMoz — backlog do lote 3
|
||||
|
||||
09/10/2026. Mesma matriz3354 requisitos/5830 origens; código/QA/ativo separados. Nenhuma nova jornada educativa iniciada.
|
||||
|
||||
## P0/P1 corrigidos em QA
|
||||
|
||||
**COUNTRY04/DESK01,C21:** país separado no Balcão; origem devolvida para correção já não recebe422 por whitelist incompleta. Três serviços: guardar cifrado → recuperar → submeter → gestor devolver campos permitidos → dono corrigir → versão5/GET; autorrevisão e outra conta403, campo não autorizado422, conflito409. Sem migração/reinstalação. Falta visual.
|
||||
|
||||
**GEO02/03,C22:** consulta/preview de limites administrativos em consumidores existentes, incluindo oportunidade draft/recursos e catálogo/painel.361 limites reais QA recuperados; zero divulgação interna pública; contexto ancestral não vira geometria própria da localidade. Sem novas relações/pontos/perímetros. Fonte/edição/hash preservados. Falta renderização e cobertura individual restante.
|
||||
|
||||
**UI01,C23:** serviço QA recuperado; HTTP200/_test:55441. Provider de UI continua ausente após reset/Chrome/Edge. Retomar cliques/capturas assim que a sessão de navegador for ligada. Causa de configuração não confirmada; não declarar recuperação visual concluída.
|
||||
|
||||
## Próximo lote executável
|
||||
|
||||
1. **Ações por perfil/consumidor: UI01,DASH01,DESK01/02,CRM01,BIZ01,LAND01,PART01.** Reutilizar controllers/services/versionamento/audit_events. Usar acoes-perfis-lote3.csv como índice de casos passados, não substituto da inspeção de todos os botões. Aceitação por ação: perfil autorizado/negado → resposta/erro visível → versão/evento/cifra quando aplicável → GET/nova sessão → resultado apresentado; conflito/inválido conserva dados. Escopo atual já inclui CRM6,território13,fundiário6,recursos4,Bolsa9 casos, mas falta cada consumidor/transição visual. Ensaios API avançam independentemente do browser. Anexos têm dependência ClamD/GD concreta.
|
||||
2. **Países/origem/estatísticas: COUNTRY01–04/06–07,STATS01,SIM01.** Revisar variações condicionais/nova sessão/URL/unknown/todos e filtros por cada formulário da tabela do relatório. CountryCatalogue/CountrySelect/payloads cifrados existentes; sem inferir residência/nacionalidade/origem. Aceitação:249 opções/pesquisa, código conhecido, recuperação nome/código, texto histórico conservado, sem nova versão após inválido, conjuntos interests/desk/businesses reconciliados. Falta interface atual e comprovação por cada variação, sem ClamD.
|
||||
3. **Seletores/mapas: GEO01–03/AUD-FIELD.** Em TerritoryFields, gestão territorial, drafts/recursos, catálogo/painel, testar filhos limpos ao trocar pai, paginação, seleção/recuperação, disponibilidade geométrica por perfil, edição/camada/legenda/zoom e ausência visível. Integrar consumidores restantes de estruturas, parcerias e zonas conforme seu contrato: contexto administrativo nunca autoriza perímetro ou localização de investimento. Usar geo_locations/geo_geometries existentes. Sem criar módulos/tabelas paralelos.
|
||||
4. **Lacunas GIS:** resolver explicitamente2/39/179 correspondências não inequívocas e procurar exemplar oficial de localidades. Reconciliar Maputo/edições/nome/pai; não fuzzy-remapear ou assumir equivalência de códigos. Aceitação:original/hash/scan/correspondência única/validezSRID4326; replay zero duplicados, classificação e histórico preservados. Aceitação oficial já dada não é reaberta. Atividade/divulgação exige ato explícito, não importação.
|
||||
5. **Ativação:** seguir plano consolidado separado. Código sem migration, extensão de recursos uma vez autenticada, cartografia em lote separado privado. Contagens previstas361=9+120+232 somente se baseline ativo ainda0 e sem conflitos; zero novos geo_locations, zero relações/pontos de oportunidade inferidos. Prévia/cartografia ativa ainda precisa do mecanismo autenticado correspondente: não adaptar o importador QA para apontar ao ativo. Backup/restauro/contagens/hash/rollback verificados antes do ato. Não repetir Balcão/Bolsa/gestão básica.
|
||||
|
||||
## Backlog preservado
|
||||
|
||||
**OP03/04/05:** workflow completo e contratos especializados; filtros/ficha pública conscientes da maturidade versionada, sem vazar draft nem reinterpretar legado. Mantidos abertos; não iniciados como jornadas novas neste lote.
|
||||
|
||||
**EDU02/03/01:** rascunho incompleto, jornadas/painéis educativos continuam depois das falhas prioritárias acima. Sem novas jornadas até seu fecho.
|
||||
|
||||
**LIB01/02,SIM02,GUIDE01:** acervo, regras/cálculos e orientação conforme critérios existentes; estados consulta/jurídico/cálculo independentes. Sem preenchimentos artificiais, parâmetros ausentes nunca zero/taxa recente.
|
||||
|
||||
**SCAN01:** ClamD real funciona e testes reais passaram; arranque após reinício/manutenção/GD web/scan de derivadas ainda pendentes por operação. Não bloquear países/estatísticas/seletores por isso.
|
||||
|
||||
**DOC01:** estados reconciliados, fontes/versões preservadas; revisão semântica de cláusulas ainda pendente. PDFs diretos não entregues por falta de ferramenta nativa; links Adobe falhados não contam como entrega.
|
||||
|
||||
Decisões humanas mantidas: pares de IDs oportunidade–projeto/cluster e citação/papel; entidade operadora/contactos/termos; responsável/links por serviço; pesos/limiares/baseline dos pilotos. Nenhuma revalidação genérica da fonte oficial aceite.
|
||||
|
||||
QA atual TESTS/ASSERTIONS,zero skips;156 frontend/reconciliação,TypeScript/PHPStan/build/formato passam,lint11 avisos. UI não observada. Installed=false,ready=true emPREVIEWDATE;backup BACKUPDATE/COMPATDB:55441/hashHASH. A preparação técnica não fecha ações/mapas/workflow nem é instalação.
|
||||
'''
|
||||
plan = '''# Plano consolidado de ativação — lote 3
|
||||
|
||||
09/10/2026. Três grupos separados, nenhuma instalação/importação ativa executada. Preview PREVIEWDATE: installed=false,ready=true. QA global TESTS testes/ASSERTIONS asserções/zero skips; visual pendente.
|
||||
|
||||
| Grupo | Ordem/efeito | Dependência e contagem prevista | Reversão |
|
||||
| --- | --- | --- | --- |
|
||||
| Correções de código/assets | Primeiro: país/origem/correção Balcão, consulta/preview de limites, maturidade draft/filtros já preparados | Sem migration; zero cadastros/relações/classificações reescritos; confirmar versão servida/assets e percursos | Reverter código compatível preservando novos payloads e versões, sem apagar campos novos |
|
||||
| Recursos privados | Depois: ação autenticada existente /management/opportunity-resources-install, migration2026_10_09_030000_extend_opportunity_resources uma vez | Backup/prova/fingerprint; tabelas/recurso privado da extensão, zero publicação automática. Não repetir instalações básicas. ClamD só para binários; GD web/derivadas específicos | Up/down demonstrado antes de novos dados. Depois de uso, preservar/consolidar novas escritas; não executar down que apague versões/ficheiros |
|
||||
| Importação cartográfica | Lote independente, preview/original/hash/scan/dedup/conflitos, escrita privada por mecanismo autenticado | Baseline ativo0:361=9+120+232 correspondências previstas; zero geo_locations novos, zero locais/pontos/perímetros de investimento.2/39/179 não importados. Fonte2019 não presume vigência atual | Identificar lote/origem/edição/IDs/eventos; arquivar/reverter apenas o lote novo não usado. Se houver novas relações/escritas, reconciliar antes de restaurar snapshot |
|
||||
| Camadas/divulgação | Ato separado de importação, com âmbito/edição e acesso por consumidor | Publicar só geometrias autorizadas; fonte aceite não implica publicidade automática. Contexto administrativo não autoriza zona/implantação. UI atual ainda pendente | Retirar camada/autorização sem apagar original/hash/histórico; preservar relações legítimas |
|
||||
|
||||
Backup/restauro **BACKUPDATE**, destino ativo investemoz:5432, cópia compatível **COMPATDB:55441**.288 tabelas/cinco ficheiros iguais; reversão demonstrada. Fingerprint **HASH**. Validade24h; mudanças/expiração/destino exigem renovar provas pertinentes. Recursos ready=true não indica instalação nem aceitação funcional.
|
||||
|
||||
QA cartográfica preservada:desk_restore_20261009_171209_test:55441,361 limites internos, replay zero novos e consulta361/361 READ ONLY. Não é a cópia de compatibilidade dos recursos, não importar fixtures. O importador existente é deliberadamente QA-only. **Execução cartográfica ativa ainda precisa de preview/aplicação autenticados no mecanismo existente**; não há autorização para contornar essa condição com CLI ou converter o script QA em instalador ativo.
|
||||
|
||||
Antes do ato: verificar identidade/hash/validade/estado instalado e schema; preservar classificações IDs1–6, relações28/13, originais/histórico; obter contagens frescas para recusar duplicados/conflitos. Após cada ato, consultar migration/evento/autor/versão e comparar baseline. Dumps/.env/chaves privados. Nenhuma importação/publicação ativa cálculo fiscal.
|
||||
|
||||
Browser:QA18096 responde HTTP200/_test, mas provider de UI ausente; Chrome/Edge indisponíveis após reset. Não certificar resultado visual. Fluxos ativos de revisão/publicação e contratos especializados continuam no backlog; não criados pela instalação de recursos.
|
||||
'''
|
||||
def resolve(text):
|
||||
replacements = {
|
||||
'de249': 'de 249', 'recebe422': 'recebe 422', 'versão5': 'versão 5',
|
||||
'autorizado422': 'autorizado 422', 'antiga409': 'antiga 409',
|
||||
'QA171209': 'QA 171209', 'inválido422': 'inválido 422',
|
||||
'gestor403': 'gestor 403', 'edição2019': 'edição 2019',
|
||||
'atual363': 'atual 363', 'Apoio:4': 'Apoio: 4', 'Catálogo249': 'Catálogo 249',
|
||||
'catálogo249': 'catálogo 249', 'hierarquia4': 'hierarquia 4',
|
||||
':1920': ': 1920', ':361': ': 361', ',9 províncias': ', 9 províncias',
|
||||
'Fonte INE/UNOCHA,edição07': 'Fonte INE/UNOCHA, edição 07',
|
||||
'continuam2': 'continuam 2', 'ativo17:47:24UTC:288': 'ativo 17:47:24 UTC: 288',
|
||||
'QA18096': 'QA 18096', 'servidor QA18096': 'servidor QA 18096',
|
||||
'matriz:3354': 'matriz: 3354', 'matriz3354': 'matriz 3354',
|
||||
';5830': '; 5830', 'Estados:5': 'Estados: 5', ',3198': ', 3198',
|
||||
',146': ', 146', ',1 implementado': ', 1 implementado',
|
||||
',0 com falha': ', 0 com falha', ',4 decisões': ', 4 decisões',
|
||||
'TESTS testes': 'TESTS testes', ';156': '; 156', 'Novos2': 'Novos 2',
|
||||
'Lint0': 'Lint 0', '/11 avisos': '/11 avisos', 'por4': 'por 4',
|
||||
'.288': '. 288', 'Validade24h': 'Validade 24h',
|
||||
'emPREVIEWDATE': 'em PREVIEWDATE', 'hashHASH': 'hash HASH',
|
||||
'ativos0': 'ativos 0', 'ativo0': 'ativo 0', 'Fonte2019': 'Fonte 2019',
|
||||
'investment.2': 'investment. 2', 'investimento.2': 'investimento. 2',
|
||||
'CRM6,território13,fundiário6,recursos4,Bolsa9': 'CRM 6, território 13, fundiário 6, recursos 4, Bolsa 9',
|
||||
'explicitamente2': 'explicitamente 2', 'validadeSRID4326': 'validade SRID 4326',
|
||||
'validezSRID4326': 'validade SRID 4326', 'IDs1–6': 'IDs 1–6',
|
||||
'relações28/13': 'relações 28/13', 'Balcão:país': 'Balcão: país',
|
||||
'nos3': 'nos 3', 'ensaia3': 'ensaia 3', ':HTTP200': ': HTTP 200',
|
||||
}
|
||||
for before, after in replacements.items():
|
||||
text = text.replace(before, after)
|
||||
return text.replace('TESTS', str(tests['tests'])).replace('ASSERTIONS', str(tests['assertions'])).replace('BACKUPDATE', activation['verified_at']).replace('COMPATDB', compatibility['target']['database']).replace('HASH', activation['code_hash']).replace('PREVIEWDATE', preview['captured_at'])
|
||||
|
||||
for name, text in [('relatorio-executivo.md', report), ('backlog.md', backlog), ('plano-ativacao.md', plan)]:
|
||||
file = docs / name
|
||||
previous = file.read_text(encoding='utf-8')
|
||||
marker = '## Histórico do lote 2 e anteriores — não representa o estado atual'
|
||||
if marker in previous:
|
||||
previous = previous.split(marker, 1)[1].lstrip()
|
||||
file.write_text(resolve(text) + '\n' + marker + '\n\n' + previous, encoding='utf-8')
|
||||
|
||||
extra = '''
|
||||
## C21 — Balcão sem país e sem devolução da origem para correção — P1
|
||||
|
||||
Reprodução anterior por código: country não autorizado em validateFields; declared_origin_country_code disponível no formulário, mas não admitido em editable_fields da devolução. Esperado: país e origem distintos, persistidos e correção explícita permitida por gestor. Observado: country sem campo/percurso e devolução da origem422. Causa: campos comuns não incluídos na whitelist de correção/país. Correção: campo separado e duas whitelists coerentes, sem alterar definição histórica do serviço. PortalAuditConsumerLotTest ensaia3 serviços, POST/PUT/GET, cifra, ZZ422, submissão, dono autorrevisão403, gestor devolve, título não autorizado422, country/origin corrigidos versão5, conflito409 e outra conta403. API/props/persistência demonstradas, visual pendente, ativo sem escrita.
|
||||
|
||||
## C22 — geometria importada sem percurso de consulta nos campos/mapas — P1
|
||||
|
||||
Reprodução por código/dados QA: geo_geometries tinha361 limites internos, consumidores partilhados só recuperavam nomes/IDs; mapas públicos exigiam geometrias publicadas/locais e não havia preview administrativo por perfil. Esperado: cadastro/geom disponível/contexto/apresentação separados, sem publicar para resolver UI. Correção: TerritoryGeometry, endpoints público/gestor e preview contextual com edição/legenda, preservando classificação. Prova real READ ONLY361/361 e zero divulgação pública; HTTP sintético testa gestor/outra conta/arquivamento/fallbacklocalidade. Integrações frontend preparadas, mapa renderizado ainda não observado.
|
||||
|
||||
## C23 — servidor QA parado e provider de UI indisponível — P1
|
||||
|
||||
Passos: verificar18096; getState, abrirChrome, reset, getState, abrirEdge. Antes:sem listener18096. Após iniciar servidor QA guardado:HTTP200/header _test:55441. Ainda observado:apps[]/browsers[];Browser is not available:chrome/edge após reset. Causa do servidor:processo QA não em execução; causa subjacente do provider não confirmada, apenas ausência de registo demonstrada. Ligação de navegador pedida ao utilizador. Interface permanece pendente; API/persistência continuam. Nenhum bypass de autenticação/controlo ou captura antiga usada.
|
||||
'''
|
||||
file = docs / 'casos-reproducao.md'
|
||||
if '## C21 —' not in file.read_text(encoding='utf-8'):
|
||||
file.write_text(file.read_text(encoding='utf-8') + extra, encoding='utf-8')
|
||||
|
||||
diagnosis = {'captured_at': preview['captured_at'], 'before': {'qa_18096_listening': False, 'apps': [], 'browsers': []},
|
||||
'recovery': {'qa_http_status': 200, 'qa_target_guard': '_test:55441', 'ui_reset_attempted': True},
|
||||
'after': {'chrome_error': 'Browser is not available: chrome', 'edge_error': 'Browser is not available: edge', 'apps': [], 'browsers': []},
|
||||
'ui_available': False, 'root_cause_of_provider_registration_confirmed': False, 'direct_pdf_attachment_tool_available': False,
|
||||
'new_adobe_links_supplied': False, 'visual_acceptance': False, 'active_writes': 0}
|
||||
(d / 'browser-diagnosis-lote3.json').write_text(json.dumps(diagnosis, indent=2), encoding='utf-8')
|
||||
|
||||
notice = '\n## Estado de auditoria — lote 3, 09/10/2026\n\nBalcão:país/origem e devolução para correção demonstrados nos3 serviços; contexto administrativo ligado aos consumidores existentes e361 limites QA recuperados sem publicação.381 testes/5046 asserções/zero skips. QA HTTP recuperada; provider de UI indisponível e visual pendente. Recursos prontos,não instalados; cartografia ativa requer ato autenticado separado. Workflow/contratos mantidos no backlog; nenhuma jornada educativa nova. Prevalecem [relatório](auditoria-portal/relatorio-executivo.md),[backlog](auditoria-portal/backlog.md) e [plano](auditoria-portal/plano-ativacao.md) atuais sobre estados anteriores. PDFs diretos não anexados nesta sessão.\n'
|
||||
for name in ['gestao-oportunidades-dashboard.md', 'backlog-mestre.md']:
|
||||
file = docs.parent / name
|
||||
if '## Estado de auditoria — lote 3' not in file.read_text(encoding='utf-8'):
|
||||
file.write_text(file.read_text(encoding='utf-8') + notice, encoding='utf-8')
|
||||
print(json.dumps({'current_requirements':3354, 'source_lines':5830, 'parallel_matrix':False, 'direct_pdf_attached':False}))
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Export evidence, never active/private data or configuration."""
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
import hashlib
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
d = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
out = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
gaps = []
|
||||
for level in [1, 2, 3]:
|
||||
data = json.loads((d / f'geometry-qa-adm{level}-import.json').read_text(encoding='utf-8'))
|
||||
for mapping in data['mappings']:
|
||||
if mapping['matched_id'] is None:
|
||||
gaps.append([level, mapping['pcode'], mapping['source_name'], mapping['source_parent_code'], mapping['match_basis'], len(mapping['candidates']), data['source_sha256']])
|
||||
with (out / 'lacunas-geometrias-lote2.csv').open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(['ADM', 'PCODE da fonte', 'Nome da fonte', 'Pai na fonte', 'Motivo', 'Candidatos inequívocos', 'SHA256 do exemplar'])
|
||||
writer.writerows(gaps)
|
||||
|
||||
proof_names = ['phpunit-final.json', 'phpunit-lote2-final.xml', 'consumer-actions-lote2.json',
|
||||
'geometry-qa-adm1-import.json', 'geometry-qa-adm2-import.json', 'geometry-qa-adm3-import.json',
|
||||
'geometry-source-scans.json', 'clamd-lote2.json', 'contagens-requisitos.json',
|
||||
'territory-catalogue-coverage.json']
|
||||
with zipfile.ZipFile(out / 'evidencias-lote2.zip', 'w', compression=zipfile.ZIP_DEFLATED) as z:
|
||||
for name in proof_names:
|
||||
z.write(d / name, name)
|
||||
activation = json.loads((d / 'activation-evidence.json').read_text(encoding='utf-8'))
|
||||
activation.pop('dump_path', None)
|
||||
z.writestr('activation-evidence.json', json.dumps(activation, ensure_ascii=False, indent=2))
|
||||
preview = json.loads((d / 'resources-lote2-preview.json').read_text(encoding='utf-16').lstrip('\ufeff'))
|
||||
z.writestr('resources-lote2-preview.json', json.dumps(preview, ensure_ascii=False, indent=2))
|
||||
for name in ['cobertura-consumidores-lote2.md', 'casos-reproducao.md', 'plano-ativacao.md', 'lacunas-geometrias-lote2.csv']:
|
||||
z.write(out / name, name)
|
||||
z.writestr('LEIA-ME.txt', '09/10/2026. API/persistência em QA; visual indisponível. Importação GIS interna, privada, sem publicação. Recursos ready=true/installed=false. Não contém dump, configuração, chaves ou dados pessoais. Provas históricas nos documentos são explicitamente separadas do estado atual. Checks de formato passam; npm check termina com código 1 devido aos 11 avisos lint existentes, sem erros. Fontes GIS: INE/UNOCHA, edição 2019-06-07, https://services.arcgis.com/iQ1dY19aHwbSDYIF/arcgis/rest/services/Mozambique/FeatureServer; originais privados preservados por hash, scans reais anexados.\n')
|
||||
|
||||
# Validate row widths and conserved source identities before delivery.
|
||||
for name, expected in [('matriz-requisitos.csv', 3354), ('inventario-fontes.csv', 584)]:
|
||||
with (out / name).open(encoding='utf-8-sig', newline='') as f:
|
||||
rows = list(csv.reader(f))
|
||||
if len(rows) - 1 != expected or any(len(row) != len(rows[0]) for row in rows):
|
||||
raise RuntimeError(f'Invalid exported CSV: {name}')
|
||||
print(json.dumps({'requirements': 3354, 'source_lines': 5830, 'inventory': 584, 'geometry_gaps': len(gaps), 'evidence_zip_sha256': hashlib.sha256((out / 'evidencias-lote2.zip').read_bytes()).hexdigest()}))
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Services\TerritoryGeometry;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// Read the preserved GIS QA only; no publication and no active connection.
|
||||
$root = dirname(__DIR__);
|
||||
$dir = $root.'/storage/app/private/investmoz-v2/portal-audit';
|
||||
$pathQa = ($argv[1] ?? '') === 'path-qa';
|
||||
$proof = json_decode(file_get_contents($pathQa ? $dir.'/../opportunity-resources-backup-proof.json' : $dir.'/resources-backup-proof-171209.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $proof['restored_target'];
|
||||
if (! ($proof['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (! $pathQa && $target['database'] !== 'desk_restore_20261009_171209_test') || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Named preserved geometry QA required.');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => ''] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Refusing unexpected connection.');
|
||||
}
|
||||
DB::beginTransaction();
|
||||
DB::statement('SET TRANSACTION READ ONLY');
|
||||
$rows = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->whereNull('deleted_at')->orderBy('id')->get(['id', 'geo_location_id']);
|
||||
$results = [];
|
||||
$service = app(TerritoryGeometry::class);
|
||||
foreach ($rows as $row) {
|
||||
$private = $service->context($row->geo_location_id, true);
|
||||
$public = $service->context($row->geo_location_id, false);
|
||||
if (! $private['selected_geometry_available'] || $public['context_geometry_count'] !== 0) {
|
||||
throw new RuntimeException('Geometry recovery or privacy mismatch.');
|
||||
}
|
||||
$results[] = ['geometry_id' => $row->id, 'territory_id' => $row->geo_location_id, 'selected_available_manager' => true,
|
||||
'context_geometry_count_manager' => $private['context_geometry_count'], 'public_context_count' => $public['context_geometry_count'],
|
||||
'editions' => array_values(array_unique(array_column($private['areas'], 'source_date')))];
|
||||
}
|
||||
$examples = [];
|
||||
foreach (['province', 'district', 'admin_post'] as $type) {
|
||||
$example = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id')
|
||||
->where('g.source_system', 'ine-cod-ab')->whereNull('g.deleted_at')->where('l.location_type', $type)
|
||||
->orderBy('l.name')->first(['l.id', 'l.name', 'l.location_type']);
|
||||
if ($example) {
|
||||
$examples[] = $example;
|
||||
}
|
||||
}
|
||||
DB::rollBack();
|
||||
$result = ['captured_at' => gmdate('c'), 'target' => $target, 'read_only' => true, 'real_geometries_checked' => count($rows),
|
||||
'results' => $results, 'active_writes' => 0, 'qa_writes' => 0, 'public_geometries_promoted' => 0, 'visual_verified' => false,
|
||||
'selector_examples' => $examples,
|
||||
'scope' => 'Service recovery of every retained real QA administrative boundary; synthetic HTTP profile authorization is demonstrated separately by PortalAuditConsumerLotTest'];
|
||||
file_put_contents($dir.($pathQa ? '/geometry-consumers-path-qa.json' : '/geometry-consumers-lote3.json'), json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['checked' => count($rows), 'private_recovery' => 'passed', 'public_leak' => 0, 'active_writes' => 0, 'visual_verified' => false]).PHP_EOL;
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Name every unmatched official feature; supplement the existing audit, not its matrix."""
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
levels = {}
|
||||
for level in [1, 2, 3]:
|
||||
source = json.loads((private / f'moz-adm{level}-ine-20190607.geojson').read_text(encoding='utf-8-sig'))
|
||||
rehearsal = json.loads((private / f'geometry-qa-adm{level}-import.json').read_text(encoding='utf-8-sig'))
|
||||
levels[level] = {r['pcode']: r for r in rehearsal['mappings']}
|
||||
for feature in source['features']:
|
||||
p = feature['properties']
|
||||
r = levels[level][p[f'ADM{level}_PCODE']]
|
||||
r['parent_name'] = p.get(f'ADM{level - 1}_PT')
|
||||
r['province_name'] = p['ADM1_PT']
|
||||
|
||||
names = {1: 'Províncias', 2: 'Distritos', 3: 'Postos administrativos'}
|
||||
text = ['# Correspondências cartográficas — lacunas por nome', '', 'Complemento de AUD-GEO-02/03 da mesma matriz. Exemplar INE/UNOCHA 07/06/2019 aceite; esperado significa total deste exemplar, não um total administrativo atual certificado. Não converte limites administrativos em disponibilidade de terra.', '', '| Nível | Esperado no exemplar | Resolvido em QA | Pendente |', '| --- | ---: | ---: | ---: |']
|
||||
gaps = []
|
||||
for level in [1, 2, 3]:
|
||||
pending = [r for r in levels[level].values() if not r['matched_id']]
|
||||
assert len(pending) == {1: 2, 2: 39, 3: 179}[level]
|
||||
text.append(f'| {names[level]} | {len(levels[level])} | {len(levels[level]) - len(pending)} | {len(pending)} |')
|
||||
for r in pending:
|
||||
reason = 'Nome não tem correspondência única no nível' if level == 1 else ('Pai sem correspondência; não associa por nome isolado' if not levels[level - 1][r['source_parent_code']]['matched_id'] else 'Nome sem correspondência única sob o pai resolvido')
|
||||
gaps.append({'level': level, 'name': r['source_name'], 'pcode': r['pcode'], 'parent_name': r['parent_name'], 'parent_pcode': r['source_parent_code'], 'province': r['province_name'], 'reason': reason})
|
||||
text += ['', '2/39/179 são pendentes, não importados. Foram resolvidos 361 de 581 elementos: 9+120+232. A repetição do ensaio CLI reutilizou361. Aplicação autenticada e interface têm provas independentes.', '', 'A normalização permite somente caixa e diacríticos. Não decide aliases, divisões recentes, fusões, homónimos ou mudança de pai. Maputo e Maputo City permanecem distintos; não renomear os cadastros existentes para forçar correspondência. Para fechar um alias é necessária a correspondência explícita entre código do exemplar e ID existente, com fundamento e âmbito/edição.', '']
|
||||
for level in [1, 2, 3]:
|
||||
items = [r for r in gaps if r['level'] == level]
|
||||
text += [f'## {names[level]} — {len(items)} pendentes', '', '| Código do exemplar | Nome exato | Pai | Causa |', '| --- | --- | --- | --- |']
|
||||
for r in items:
|
||||
text.append(f"| {r['pcode']} | {r['name']} | {r['parent_name'] or '—'} | {r['reason']} |")
|
||||
text.append('')
|
||||
text += ['## Lacunas de cobertura além das correspondências', '', '- O exemplar identificado não contém polígonos próprios de localidades; não inventar nem duplicar territórios.', '- Os 1.990 territórios cadastrados e estes581 elementos têm níveis/âmbitos distintos; um não é o denominador do outro.', '- Geometria disponível, acesso autorizado e desenho funcional em cada consumidor são critérios independentes. As361 geometrias permanecem internas em QA; a importação não as publica.', '- O total previsto no ativo é361 somente se a prévia renovada conservar estas correspondências e zero conflitos. Não repetir instalações nem considerar `ready=true` uma importação.', '']
|
||||
(docs / 'correspondencias-cartograficas.md').write_text('\n'.join(text), encoding='utf-8')
|
||||
(private / 'geometry-named-gaps.json').write_text(json.dumps(gaps, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'expected': 581, 'resolved': 361, 'pending': len(gaps), 'named_gaps': 'correspondencias-cartograficas.md'}))
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
// Private, idempotent geometry rehearsal. This tool cannot target an active database.
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$directory = $root.'/storage/app/private/investmoz-v2/portal-audit';
|
||||
$proof = json_decode(file_get_contents($directory.'/../opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $proof['restored_target'];
|
||||
if (! ($proof['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Verified isolated QA restore required.');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if (! app()->environment('testing') || $identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Non-QA target rejected before writes.');
|
||||
}
|
||||
$action = $argv[1] ?? 'preview';
|
||||
$level = (int) ($argv[2] ?? 1);
|
||||
if (! in_array($level, [1, 2, 3], true)) {
|
||||
throw new RuntimeException('Administrative levels 1-3 only.');
|
||||
}
|
||||
if (! in_array($action, ['preview', 'import'], true)) {
|
||||
throw new RuntimeException('Only preview/import are supported.');
|
||||
}
|
||||
$file = $directory.'/moz-adm'.$level.'-ine-20190607.geojson';
|
||||
$metadataFile = $directory.'/moz-adm'.$level.'-ine-metadata.html';
|
||||
$sourceHash = hash_file('sha256', $file);
|
||||
$sourceScan = null;
|
||||
if ($action === 'import') {
|
||||
$proofs = json_decode(file_get_contents($directory.'/geometry-source-scans.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach ($proofs['files'] as $scan) {
|
||||
if ($scan['file'] === basename($file) && $scan['sha256'] === $sourceHash) {
|
||||
$sourceScan = ($scan['scan']['result'] ?? null) === 'clean' ? $scan['scan'] : ($scan['gis_scan'] ?? null);
|
||||
}
|
||||
}
|
||||
if (($sourceScan['result'] ?? null) !== 'clean' || strtotime($proofs['captured_at']) < time() - 86400) {
|
||||
throw new RuntimeException('Current clean scan tied to the retained original hash required before QA import.');
|
||||
}
|
||||
}
|
||||
$metadata = file_get_contents($metadataFile);
|
||||
if (! str_contains($metadata, 'Mozambique INE - Instituto Nacional de Estatistica') || ! str_contains($metadata, '2019-06-07')) {
|
||||
throw new RuntimeException('Source identity/version absent from retained metadata.');
|
||||
}
|
||||
$source = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (($source['type'] ?? '') !== 'FeatureCollection' || count($source['features'] ?? []) !== [1 => 11, 2 => 159, 3 => 411][$level] || ($source['exceededTransferLimit'] ?? false)) {
|
||||
throw new RuntimeException('Incomplete/unexpected provincial dataset.');
|
||||
}
|
||||
$normalize = fn (string $value) => strtolower(trim(Str::ascii($value)));
|
||||
$locations = DB::table('geo_locations')->where('location_type', [1 => 'province', 2 => 'district', 3 => 'admin_post'][$level])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'code', 'source_code']);
|
||||
$results = [];
|
||||
foreach ($source['features'] as $feature) {
|
||||
$properties = $feature['properties'];
|
||||
$name = $properties['ADM'.$level.'_PT'];
|
||||
$parent = $level > 1 ? DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', '2019-06-07:adm'.($level - 1).':'.$properties['ADM'.($level - 1).'_PCODE'])->whereNull('deleted_at')->value('geo_location_id') : null;
|
||||
$matches = $locations->filter(fn ($location) => $normalize($location->name) === $normalize($name) && ($level === 1 || ($parent !== null && $location->parent_id === $parent)))->values();
|
||||
$results[] = ['pcode' => $properties['ADM'.$level.'_PCODE'], 'source_name' => $name, 'source_parent_code' => $level > 1 ? $properties['ADM'.($level - 1).'_PCODE'] : null,
|
||||
'candidates' => $matches->all(), 'matched_id' => count($matches) === 1 ? $matches[0]->id : null,
|
||||
'match_basis' => count($matches) === 1 ? 'unique_exact_name_and_matched_parent_after_case_and_diacritics' : 'unresolved_no_implicit_translation_or_parent_mapping',
|
||||
'geometry_type' => $feature['geometry']['type'], 'feature' => $feature];
|
||||
}
|
||||
$inserted = 0;
|
||||
$reused = 0;
|
||||
if ($action === 'import') {
|
||||
DB::transaction(function () use (&$results, &$inserted, &$reused, $sourceHash, $metadataFile, $level, $sourceScan) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(91026, 20190607)');
|
||||
foreach ($results as &$row) {
|
||||
if ($row['matched_id'] === null) {
|
||||
continue;
|
||||
}
|
||||
$geometry = json_encode($row['feature']['geometry'], JSON_THROW_ON_ERROR);
|
||||
$type = strtolower($row['geometry_type']);
|
||||
$valid = DB::selectOne('SELECT ST_IsValid(g) AS valid, ST_IsEmpty(g) AS empty, GeometryType(g) AS type FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) AS g) x', [$geometry]);
|
||||
if (! $valid->valid || $valid->empty || ! in_array($type, ['polygon', 'multipolygon'], true)) {
|
||||
$row['matched_id'] = null;
|
||||
$row['match_basis'] = 'invalid_geometry_no_automatic_repair';
|
||||
|
||||
continue;
|
||||
}
|
||||
$external = '2019-06-07:adm'.$level.':'.$row['pcode'];
|
||||
$existing = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', $external)->first();
|
||||
if ($existing) {
|
||||
$saved = json_decode($existing->properties, true, 512, JSON_THROW_ON_ERROR);
|
||||
if ($existing->geo_location_id !== $row['matched_id'] || ($saved['source_sha256'] ?? null) !== $sourceHash || $existing->deleted_at !== null) {
|
||||
throw new RuntimeException('Identity/version conflict; nothing overwritten.');
|
||||
}
|
||||
$reused++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$trace = ['authority' => 'Mozambique INE - Instituto Nacional de Estatistica', 'provider' => 'UNOCHA; exemplar hosted by World Bank', 'source_version' => '2019-06-07',
|
||||
'source_url' => 'https://services.arcgis.com/iQ1dY19aHwbSDYIF/arcgis/rest/services/Mozambique/FeatureServer/'.($level + 2),
|
||||
'source_sha256' => $sourceHash, 'metadata_sha256' => hash_file('sha256', $metadataFile), 'source_properties' => $row['feature']['properties'],
|
||||
'match_basis' => $row['match_basis'], 'qa_only' => true, 'purpose' => 'administrative_boundary', 'legal_currentness' => 'not_determined', 'investment_site' => false, 'source_scan' => $sourceScan];
|
||||
DB::insert('INSERT INTO geo_geometries(id,source_system,external_id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326),?::jsonb,?)', [(string) Str::uuid(), 'ine-cod-ab', $external, $row['matched_id'], $type, $geometry, json_encode($trace, JSON_THROW_ON_ERROR), 'internal']);
|
||||
$inserted++;
|
||||
}
|
||||
if ($inserted > 0) {
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-audit-qa', 'entity_type' => 'geo_geometries', 'action' => 'official_geometry_qa_import', 'data_classification' => 'internal', 'after_data' => json_encode(['level' => $level, 'source_sha256' => $sourceHash, 'inserted' => $inserted, 'published' => false], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
foreach ($results as &$row) {
|
||||
unset($row['feature']);
|
||||
}
|
||||
$saved = DB::select("SELECT external_id,geo_location_id,data_classification,ST_IsValid(geom) AS valid,ST_SRID(geom) AS srid,ST_NPoints(geom) AS vertices,properties->>'source_sha256' AS source_sha256 FROM geo_geometries WHERE source_system='ine-cod-ab' ORDER BY external_id");
|
||||
$result = ['captured_at' => gmdate('c'), 'action' => $action, 'level' => $level, 'target' => $target, 'source_sha256' => $sourceHash, 'metadata_sha256' => hash_file('sha256', $metadataFile), 'source_features' => count($source['features']),
|
||||
'mappings' => $results, 'inserted' => $inserted, 'reused' => $reused, 'recovered_geometries' => $saved, 'active_writes' => 0, 'published' => false, 'visual_verified' => false,
|
||||
'remaining' => ['Unmatched names/parents listed per feature; no fuzzy mapping', 'No locality polygons in the identified official exemplar', 'Private QA import does not populate public map layers or investment locations']];
|
||||
$destination = $directory.'/geometry-qa-adm'.$level.'-'.$action.'.json';
|
||||
if (is_file($destination)) {
|
||||
copy($destination, $directory.'/geometry-qa-adm'.$level.'-'.$action.'-'.gmdate('Ymd_His').'-'.bin2hex(random_bytes(3)).'.json');
|
||||
}
|
||||
file_put_contents($destination, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['action' => $action, 'level' => $level, 'features' => count($results), 'matched' => count(array_filter($results, fn ($row) => $row['matched_id'] !== null)), 'inserted' => $inserted, 'reused' => $reused, 'persisted_total' => count($saved), 'target' => $target, 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
// Reproduce reported gaps on the verified restored QA, never on the active connection.
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$proof = json_decode(file_get_contents($root.'/storage/app/private/investmoz-v2/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $proof['restored_target'];
|
||||
if (! ($proof['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Verified QA target required.');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$kernel = $app->make(Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441 || ! app()->environment('testing')) {
|
||||
throw new RuntimeException('Non-QA connection rejected.');
|
||||
}
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid'], 'education.enabled' => true, 'portal.notifications_enabled' => false, 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false]);
|
||||
Http::fake();
|
||||
$actor = User::where('email', 'opportunity-manager@example.invalid')->firstOrFail();
|
||||
$snapshot = function (): array {
|
||||
$result = [];
|
||||
foreach (['opportunities', 'labour_demands', 'training_capacities', 'audit_events'] as $table) {
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
|
||||
$result[$table] = ['rows' => count($rows), 'hash' => hash('sha256', implode("\n", array_column($rows, 'row')))];
|
||||
}
|
||||
|
||||
return $result;
|
||||
};
|
||||
$before = $snapshot();
|
||||
$cases = [];
|
||||
foreach ([
|
||||
['maturity_7', '/management/opportunities/drafts', ['maturity_level' => 7, 'creation_key' => (string) Str::uuid(), 'reason' => 'Ensaio exclusivamente sintético de maturidade na QA']],
|
||||
['maturity_8', '/management/opportunities/drafts', ['maturity_level' => 8, 'creation_key' => (string) Str::uuid(), 'reason' => 'Ensaio exclusivamente sintético de maturidade na QA']],
|
||||
['incomplete_demand', '/management/education/demands', ['qualification' => 'Rascunho incompleto exclusivamente QA']],
|
||||
['incomplete_capacity', '/management/education/capacities', ['qualification' => 'Rascunho incompleto exclusivamente QA']],
|
||||
] as [$case,$url,$payload]) {
|
||||
$request = Request::create($url, 'POST', [], [], [], ['HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$app->instance('request', $request);
|
||||
Auth::guard('web')->setUser($actor);
|
||||
$response = $kernel->handle($request);
|
||||
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
$cases[] = ['case' => $case, 'url' => $url, 'observed_http' => $response->getStatusCode(), 'errors' => array_keys($body['errors'] ?? []), 'requirement_satisfied' => false];
|
||||
if ($response->getStatusCode() !== 422 || ($body['errors'] ?? []) === []) {
|
||||
throw new RuntimeException('Reported gap reproduction changed: '.$case);
|
||||
}
|
||||
$kernel->terminate($request, $response);
|
||||
}
|
||||
$after = $snapshot();
|
||||
if ($before !== $after) {
|
||||
throw new RuntimeException('Diagnostic requests unexpectedly changed business/history data.');
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
$result = ['captured_at' => gmdate('c'), 'target' => $target, 'cases' => $cases, 'business_history_unchanged' => true, 'active_writes' => 0, 'visual_verified' => false, 'scope' => 'Diagnostic failures; these observations are not passing acceptance tests'];
|
||||
file_put_contents($root.'/storage/app/private/investmoz-v2/portal-audit/pending-actions.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($result, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
// Publishes evidence files only. Installation still requires an authenticated HTTP action.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$dir = storage_path('app/private/investmoz-v2');
|
||||
$read = function (string $file): array {
|
||||
$text = file_get_contents($file);
|
||||
if ($text === false) {
|
||||
throw new RuntimeException('Missing evidence: '.basename($file));
|
||||
}
|
||||
|
||||
return json_decode(preg_replace('/^\xEF\xBB\xBF/', '', $text), true, 512, JSON_THROW_ON_ERROR);
|
||||
};
|
||||
$backup = $read($dir.'/opportunity-resources-backup-proof.json');
|
||||
$compatibility = $read($dir.'/opportunity-resources-compatibility.json');
|
||||
$tests = $read($dir.'/portal-audit/phpunit-final.json');
|
||||
$routeChecks = $read($dir.'/portal-audit/ensaios-rotas.json');
|
||||
$hash = $app->make(OpportunityResourceInstallation::class)->fingerprint();
|
||||
if (! ($backup['restore_verified'] ?? false) || ! ($backup['files_verified'] ?? false)
|
||||
|| ($backup['target']['database'] ?? '') !== 'investemoz' || (int) ($backup['target']['port'] ?? 0) !== 5432
|
||||
|| ! str_ends_with($backup['restored_target']['database'] ?? '', '_test') || (int) ($backup['restored_target']['port'] ?? 0) !== 55441
|
||||
|| ($compatibility['target'] ?? []) !== $backup['restored_target']
|
||||
|| ! ($compatibility['compatibility_verified'] ?? false) || ! ($compatibility['reversal_verified'] ?? false)
|
||||
|| ($compatibility['code_hash'] ?? '') !== $hash
|
||||
|| ($tests['result'] ?? '') !== 'passed' || (int) ($tests['tests'] ?? 0) < 1 || ($tests['passed'] ?? 0) !== ($tests['tests'] ?? 0)
|
||||
|| ($tests['skipped'] ?? 0) !== 0 || ($routeChecks['failures'] ?? ['missing']) !== []
|
||||
|| strtotime($backup['verified_at']) < time() - 86400
|
||||
|| hash_file('sha256', $backup['dump_path']) !== $backup['sha256']
|
||||
|| hash_file('sha256', $backup['files_path']) !== $backup['files_sha256']) {
|
||||
throw new RuntimeException('Current backup, isolated reversal and QA evidence required; no activation proof written.');
|
||||
}
|
||||
$proof = [
|
||||
'restore_verified' => true, 'compatibility_verified' => true, 'reversal_verified' => true,
|
||||
'qa_passed' => true, 'code_hash' => $hash, 'target' => $backup['target'], 'verified_at' => $backup['verified_at'],
|
||||
'dump_path' => $backup['dump_path'], 'sha256' => $backup['sha256'],
|
||||
'qa_scope' => 'Schema compatibility/reversal, automated server QA and frontend build/type checks; not full visual or legal acceptance.',
|
||||
'tests' => ['total' => $tests['tests'], 'passed' => $tests['passed'], 'assertions' => $tests['assertions'], 'skipped_real_clamd' => 0,
|
||||
'source' => 'phpunit-final.json', 'scope' => 'Regression inventory only; not individual requirement or visual acceptance'],
|
||||
'browser_current_version_verified' => false, 'full_portal_acceptance' => false,
|
||||
'outstanding' => ['Current UI walkthroughs: browser unavailable', 'Portable ClamD healthy now; reboot startup and periodic updates require configuration', 'GD/WebP in active web PHP and each real derivative scan not verified', 'Official geometry QA import remains private; public map layers incomplete', 'Final review/publication and specialized opportunity contracts remain open'],
|
||||
'active_writes' => 0,
|
||||
];
|
||||
$destination = $dir.'/opportunity-resources-activation-proof.json';
|
||||
if (is_file($destination)) {
|
||||
$archive = $dir.'/portal-audit/resources-activation-proof-'.gmdate('Ymd_His').'-'.bin2hex(random_bytes(3)).'.json';
|
||||
if (! copy($destination, $archive)) {
|
||||
throw new RuntimeException('Cannot preserve previous activation evidence.');
|
||||
}
|
||||
}
|
||||
file_put_contents($destination, json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
file_put_contents($dir.'/portal-audit/activation-evidence.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['prepared' => true, 'code_hash' => $hash, 'backup_date' => $backup['verified_at'], 'installed_by_this_tool' => false, 'authenticated_action_required' => true, 'browser_verified' => false], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,553 @@
|
||||
// Source inventory and conservative initial matrix. Does not connect to a database.
|
||||
const fs = require('node:fs'),
|
||||
path = require('node:path'),
|
||||
crypto = require('node:crypto');
|
||||
const JSZip = require('jszip');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const priv = path.join(root, 'storage/app/private/investmoz-v2/portal-audit');
|
||||
const out = path.join(root, 'docs/investmoz/v2/auditoria-portal');
|
||||
fs.mkdirSync(priv, { recursive: true });
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const hash = (b) => crypto.createHash('sha256').update(b).digest('hex');
|
||||
const read = (f) => {
|
||||
const b = fs.readFileSync(path.join(root, f));
|
||||
return b
|
||||
.toString(b[0] === 255 && b[1] === 254 ? 'utf16le' : 'utf8')
|
||||
.replace(/^\uFEFF/, '');
|
||||
};
|
||||
function csv(text) {
|
||||
const rows = [];
|
||||
let row = [],
|
||||
cell = '',
|
||||
quote = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '"') {
|
||||
if (quote && text[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else quote = !quote;
|
||||
} else if (c === ',' && !quote) {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
} else if (c === '\n' && !quote) {
|
||||
row.push(cell.replace(/\r$/, ''));
|
||||
if (row.some(Boolean)) rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
} else cell += c;
|
||||
}
|
||||
if (cell || row.length) {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows
|
||||
.slice(1)
|
||||
.map((r) => Object.fromEntries(rows[0].map((h, i) => [h, r[i] || ''])));
|
||||
}
|
||||
function walk(dir) {
|
||||
let all = [];
|
||||
if (!fs.existsSync(dir)) return all;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const f = path.join(dir, e.name);
|
||||
if (e.isDirectory()) all.push(...walk(f));
|
||||
else if (e.isFile()) all.push(f);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
const decode = (s) =>
|
||||
s
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
function paragraphs(xml) {
|
||||
return [...xml.matchAll(/<w:p(?:\s[^>]*?)?(?:\/>|>[\s\S]*?<\/w:p>)/g)].map(
|
||||
(m, i) => ({
|
||||
p: i + 1,
|
||||
text: decode(
|
||||
[...m[0].matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g)]
|
||||
.map((x) => x[1])
|
||||
.join(''),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const routes = JSON.parse(
|
||||
read('storage/app/private/investmoz-v2/portal-audit-routes.json'),
|
||||
);
|
||||
const domains = [
|
||||
[
|
||||
'oportunidades',
|
||||
'oportunidad|investment_project|imobili|duat|fundiári|franquia|RF-AGR|RF-OPP',
|
||||
'OpportunityManagement|OpportunityResource|PublicBusiness|LandAssistance',
|
||||
[
|
||||
'app/Services/OpportunityManagement.php',
|
||||
'app/Services/OpportunityResources.php',
|
||||
'resources/js/pages/management/opportunity-draft.tsx',
|
||||
],
|
||||
],
|
||||
[
|
||||
'cartografia/relações',
|
||||
'territ|cartograf|geometri|coordenad|cluster|corredor|RF-CLU|RF-COR|RF-ZON',
|
||||
'Territor|InvestmentStructure|InvestmentDiscovery|Zone|Cluster',
|
||||
[
|
||||
'resources/js/components/investment-territory.tsx',
|
||||
'app/Http/Controllers/InvestmentTerritoryController.php',
|
||||
],
|
||||
],
|
||||
[
|
||||
'Balcão/orientação',
|
||||
'balcão|balcao|dossier|orienta|jornada|encaminh|institucion|recomend',
|
||||
'VirtualDesk|InvestorGuidance',
|
||||
[
|
||||
'app/Services/VirtualDesk.php',
|
||||
'app/Services/Ai/InvestorGuidance.php',
|
||||
],
|
||||
],
|
||||
[
|
||||
'CRM/solicitações',
|
||||
'crm|lead|solicita|manifesta|contacto|responsável|RF-CRM',
|
||||
'Crm|InvestmentSubmission|PortalServices|InvestorWorkspace',
|
||||
[
|
||||
'app/Http/Controllers/CrmController.php',
|
||||
'app/Services/DeclaredOriginStatistics.php',
|
||||
],
|
||||
],
|
||||
[
|
||||
'simuladores',
|
||||
'simula|fisc|tribut|fluxo de caixa|cálculo|calculador',
|
||||
'Simulation',
|
||||
[
|
||||
'app/Services/SimulationTaxParameterResolver.php',
|
||||
'resources/js/components/simulation-form.tsx',
|
||||
],
|
||||
],
|
||||
[
|
||||
'biblioteca',
|
||||
'biblioteca|document|acervo|anexo|scan|malware|formulário',
|
||||
'PortalServices|Document|Legal',
|
||||
[
|
||||
'app/Http/Controllers/PortalServicesController.php',
|
||||
'app/Jobs/ScanDocumentForMalware.php',
|
||||
],
|
||||
],
|
||||
[
|
||||
'educação',
|
||||
'educa|competência|capacitação|RF-EDU|RNF-EDU',
|
||||
'Education',
|
||||
['app/Http/Controllers/EducationController.php'],
|
||||
],
|
||||
[
|
||||
'Bolsa/parcerias',
|
||||
'parcer|bolsa|candidatur|acordo',
|
||||
'Partnership',
|
||||
['app/Services/PartnershipExchange.php'],
|
||||
],
|
||||
[
|
||||
'negócios',
|
||||
'negócio|empresa|RF-MNE|RF-MKT',
|
||||
'Business',
|
||||
['app/Http/Controllers/ExistingBusinessController.php'],
|
||||
],
|
||||
[
|
||||
'dashboard/perfis',
|
||||
'dashboard|menu|perfil|país|origem|estatístic|residên|RF-INV',
|
||||
'Dashboard|InvestmentWorkspace',
|
||||
[
|
||||
'app/Http/Controllers/DashboardController.php',
|
||||
'app/Services/CountryCatalogue.php',
|
||||
],
|
||||
],
|
||||
['governação/outros', '.*', 'Management', ['AGENTS.md']],
|
||||
];
|
||||
const rows = [];
|
||||
const keys = new Map();
|
||||
function add(id, request, source, criterion = '', variant = '') {
|
||||
request = request.trim();
|
||||
if (!request) return;
|
||||
const key = id + '\0' + request;
|
||||
if (keys.has(key)) {
|
||||
const existing = keys.get(key);
|
||||
if (!existing.fontes.includes(source)) existing.fontes.push(source);
|
||||
return;
|
||||
}
|
||||
const d = domains.find((d) =>
|
||||
new RegExp(d[1], 'i').test(id + ' ' + request),
|
||||
);
|
||||
const found = routes
|
||||
.filter((r) => new RegExp(d[2], 'i').test(r.action))
|
||||
.map((r) => '/' + r.uri)
|
||||
.filter((v, i, a) => a.indexOf(v) === i);
|
||||
const code = d[3].filter((f) => fs.existsSync(path.join(root, f)));
|
||||
const item = {
|
||||
id,
|
||||
pedido: request,
|
||||
fontes: [source],
|
||||
modulo: d[0],
|
||||
rotas: found.slice(0, 12),
|
||||
implementacao: code.length
|
||||
? code.join('; ') +
|
||||
' — componentes do domínio encontrados; correspondência integral deste requisito não presumida'
|
||||
: 'Correspondência concreta ainda não localizada',
|
||||
ativo: 'Consultar estado-ativo.json; existência de tabela não certifica este requisito',
|
||||
evidencia:
|
||||
'Sem ensaio individual nesta matriz inicial; cruzar ensaios-rotas.json e suites por domínio',
|
||||
estado: 'parcial',
|
||||
lacuna: 'Aceitação integral por requisito ainda não demonstrada; verificar campos, ações e recuperação',
|
||||
dependencia:
|
||||
'Revisão individual e ensaio QA; nenhuma nova validação de credibilidade oficial',
|
||||
aceitacao: criterion || request,
|
||||
prioridade: 'P2',
|
||||
variantes: variant,
|
||||
};
|
||||
rows.push(item);
|
||||
keys.set(key, item);
|
||||
}
|
||||
(async () => {
|
||||
const previous = JSON.parse(
|
||||
read('storage/app/private/investmoz-v2/legal-journey-inventory.json'),
|
||||
);
|
||||
const sourceRoot =
|
||||
'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo';
|
||||
const sourceRoots = [
|
||||
...new Set([
|
||||
sourceRoot,
|
||||
...previous.roots,
|
||||
...[
|
||||
'portal-documents',
|
||||
'ai-documents',
|
||||
'document-import-staging',
|
||||
'geo-ine-source-20260928',
|
||||
'opportunity-resources',
|
||||
].map((directory) =>
|
||||
path.join(root, 'storage/app/private', directory),
|
||||
),
|
||||
]),
|
||||
];
|
||||
const external = [...new Set(sourceRoots.flatMap(walk))],
|
||||
local = walk(path.join(root, 'docs/investmoz')).filter(
|
||||
(f) => !f.includes(path.sep + 'auditoria-portal' + path.sep),
|
||||
);
|
||||
const inventory = [];
|
||||
const masters = [];
|
||||
for (const f of [...external, ...local]) {
|
||||
const bytes = fs.readFileSync(f);
|
||||
const entry = {
|
||||
path: f,
|
||||
bytes: bytes.length,
|
||||
sha256: hash(bytes),
|
||||
attachments: [],
|
||||
consultation: external.includes(f)
|
||||
? 'Acervo inventariado; consulta/publicação individual não inferida'
|
||||
: 'Documentação do repositório',
|
||||
rule_use: 'Nenhum cálculo ativado pela auditoria',
|
||||
};
|
||||
if (/\.(docx|xlsx|pptx|zip)$/i.test(f)) {
|
||||
try {
|
||||
const z = await JSZip.loadAsync(bytes);
|
||||
for (const [name, e] of Object.entries(z.files)) {
|
||||
if (
|
||||
!e.dir &&
|
||||
/^(word\/embeddings\/|word\/media\/|xl\/embeddings\/|ppt\/embeddings\/)/.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const b = await e.async('nodebuffer');
|
||||
entry.attachments.push({
|
||||
path: name,
|
||||
bytes: b.length,
|
||||
sha256: hash(b),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
/\.docx$/i.test(f) &&
|
||||
/upgrade do portal|melhorar e transformar|Reformulacao do portal|Prompt master|Especificação do Formulário de Cadastro de Oportunidades|Competências e Oportunidades Educativas|prompt de gestao de educação/i.test(
|
||||
path.basename(f),
|
||||
)
|
||||
) {
|
||||
const x = z.file('word/document.xml');
|
||||
if (x) {
|
||||
const ps = paragraphs(await x.async('string'));
|
||||
masters.push({
|
||||
source: f,
|
||||
hash: entry.sha256,
|
||||
paragraphs: ps,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
entry.read_error = e.message;
|
||||
}
|
||||
}
|
||||
inventory.push(entry);
|
||||
if (/\.txt$/i.test(f) && external.includes(f)) {
|
||||
const text = bytes
|
||||
.toString(
|
||||
bytes[0] === 255 && bytes[1] === 254 ? 'utf16le' : 'utf8',
|
||||
)
|
||||
.replace(/^\uFEFF/, '');
|
||||
masters.push({
|
||||
source: f,
|
||||
hash: entry.sha256,
|
||||
paragraphs: text
|
||||
.split(/\r?\n/)
|
||||
.map((text, i) => ({ p: i + 1, text })),
|
||||
line_numbered: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// All available source files inventoried before selecting requirements. Preserve earlier inaccessible inventory too.
|
||||
fs.writeFileSync(
|
||||
path.join(priv, 'inventario-fontes.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
captured_at: new Date().toISOString(),
|
||||
source_roots: sourceRoots,
|
||||
source_root_accessible: fs.existsSync(sourceRoot),
|
||||
files: inventory,
|
||||
previous_inventory: previous.entries.map((e) => ({
|
||||
path: e.path,
|
||||
sha256: e.sha256,
|
||||
})),
|
||||
masters: masters.map((m) => ({
|
||||
source: m.source,
|
||||
sha256: m.hash,
|
||||
paragraphs: m.paragraphs.length,
|
||||
line_numbered: m.line_numbered || false,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
for (const r of csv(read('docs/investmoz/requirements-audit.csv')))
|
||||
add(
|
||||
r.ID,
|
||||
r.Requisito,
|
||||
'upgrade v4 SHA f0b9815…; p. ' +
|
||||
r.Paragrafo +
|
||||
'; requirements-audit.csv',
|
||||
r.Criterio,
|
||||
'Relatório 25/09 conservado; estado histórico não promove conclusão',
|
||||
);
|
||||
for (const r of csv(read('docs/investmoz/education/requirements.csv')))
|
||||
add(
|
||||
r.id,
|
||||
r.descricao,
|
||||
'education/requirements.csv; ' + r.artefacto,
|
||||
r.criterio_aceitacao,
|
||||
'Proposta e revisões em education/implementation-status.md',
|
||||
);
|
||||
for (const r of csv(read('docs/investmoz/education/nonfunctional.csv')))
|
||||
add(
|
||||
r.id,
|
||||
r.area + ' — ' + r.metrica,
|
||||
'education/nonfunctional.csv',
|
||||
r.limiar_proposto,
|
||||
'Limiar proposto não é resultado medido',
|
||||
);
|
||||
for (const r of csv(
|
||||
read('docs/investmoz/existing-business/traceability.csv'),
|
||||
))
|
||||
add(
|
||||
r.rf_local,
|
||||
r.objecto + '; ' + r.regra_juridica,
|
||||
'existing-business/traceability.csv',
|
||||
r.teste_planeado,
|
||||
'Escopo local RF-MNE; conservar homónimos do mestre',
|
||||
);
|
||||
for (const line of read('docs/investmoz/v2/backlog-mestre.md').split(
|
||||
'\n',
|
||||
)) {
|
||||
const cells = line.split('|').map((s) => s.trim());
|
||||
if (/^V2-\d{2}$/.test(cells[1] || ''))
|
||||
add(
|
||||
cells[1],
|
||||
cells[3],
|
||||
`backlog-mestre.md; ${cells[2]}`,
|
||||
cells[4],
|
||||
'Estado da primeira entrega; confirmações posteriores permanecem separadas',
|
||||
);
|
||||
}
|
||||
let obligationCount = 0;
|
||||
const obligation =
|
||||
/\b(deve[mr]?|obrigatóri[oa]s?|exigir|permitir|impedir|guardar|preservar|validar|registar|disponibilizar|reutilizar|implementar|assegurar|garantir|não inferir|não inventar|recuperar|publicar|rejeitar|seleccionar|selecionar)\b/i;
|
||||
for (const m of masters) {
|
||||
let counter = 0;
|
||||
const seen = new Map();
|
||||
for (const p of m.paragraphs) {
|
||||
const text = p.text.trim();
|
||||
if (
|
||||
text.length < 24 ||
|
||||
text.length > 4000 ||
|
||||
/^(CREATE |ALTER |INSERT |SELECT |DROP |--|\{|\}|\[|\]|"\w+"\s*:)/i.test(
|
||||
text,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (/oportunidades/i.test(m.source) && p.p > 2107 && p.p < 14682)
|
||||
continue;
|
||||
if (!obligation.test(text) && !/^(REQ\d+|DT\d+|RF[-–])/i.test(text))
|
||||
continue;
|
||||
const normal = text.replace(/\s+/g, ' '),
|
||||
label = /oportunidades/i.test(m.source)
|
||||
? 'OP'
|
||||
: /melhorar e transformar/i.test(m.source)
|
||||
? 'M2'
|
||||
: 'MASTER';
|
||||
const identifier =
|
||||
seen.get(normal) ||
|
||||
`${label}-${m.hash.slice(0, 8)}-${String(++counter).padStart(4, '0')}`;
|
||||
seen.set(normal, identifier);
|
||||
add(
|
||||
identifier,
|
||||
normal,
|
||||
`${path.relative(sourceRoot, m.source)}; SHA ${m.hash}; ${m.line_numbered ? 'linha' : 'w:p'} ${p.p}`,
|
||||
'',
|
||||
'Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas',
|
||||
);
|
||||
obligationCount++;
|
||||
}
|
||||
}
|
||||
const fields = JSON.parse(
|
||||
read('storage/app/private/investmoz-v2/opportunity-schema-index.json'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(priv, 'schema-spec-index.json'),
|
||||
JSON.stringify(fields, null, 2),
|
||||
);
|
||||
for (const definition of fields) {
|
||||
for (const field of (definition.fields || '')
|
||||
.split(',')
|
||||
.filter(Boolean)) {
|
||||
const required = (definition.required || '')
|
||||
.split(',')
|
||||
.includes(field);
|
||||
add(
|
||||
`OP-FIELD-${definition.name}-${field}`,
|
||||
`Campo ${definition.name}.${field}; ${required ? 'obrigatório na validação final quando aplicável' : 'opcional/condicional; conferir contrato'}; rascunho incompleto permitido`,
|
||||
'Especificação de oportunidades SHA 9f173ed3…; §26; opportunity-schema-index.json',
|
||||
`Persistir, recuperar e validar ${definition.name}.${field} segundo o schema da categoria; nunca bloquear rascunho apenas por exigência final`,
|
||||
'Campo do contrato técnico; IDs bigint atuais prevalecem sobre exemplos UUID',
|
||||
);
|
||||
}
|
||||
}
|
||||
const consumerRows = [];
|
||||
for (const file of walk(path.join(root, 'resources/js')).filter((f) =>
|
||||
/\.(tsx|ts)$/.test(f),
|
||||
)) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (
|
||||
/<(ProvinceSelect|TerritoryFields|TerritoryLevel|CountrySelect)|<.*Map\b|name=["'](?:province|district|locality|territory)/.test(
|
||||
lines[i],
|
||||
) ||
|
||||
(/<select/.test(lines[i]) &&
|
||||
/territor|location|provinc|district|locality/.test(
|
||||
lines.slice(Math.max(0, i - 5), i + 12).join('\n'),
|
||||
))
|
||||
)
|
||||
consumerRows.push({
|
||||
file: path.relative(root, file).replace(/\\/g, '/'),
|
||||
line: i + 1,
|
||||
component: lines[i].trim(),
|
||||
coverage:
|
||||
'Inventariado; teste individual de UI ainda pendente',
|
||||
});
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(priv, 'consumidores-campos.json'),
|
||||
JSON.stringify(consumerRows, null, 2),
|
||||
);
|
||||
for (const [i, c] of consumerRows.entries())
|
||||
add(
|
||||
`AUD-FIELD-${String(i + 1).padStart(3, '0')}`,
|
||||
`Pesquisar, selecionar, guardar e recuperar no consumidor ${c.file}:${c.line}; ${c.component}`,
|
||||
'Pedido direto 09/10/2026, §1 cartografia em todos os campos; inventário de consumidores',
|
||||
`Demonstração individual em 1440 e 390 px; selecção respeita pai e ID guardado; ausência de geometria mantém indeterminação`,
|
||||
'Consumidores independentes; componente comum não demonstra todos os usos',
|
||||
);
|
||||
const headers = [
|
||||
'ID',
|
||||
'Pedido/trecho',
|
||||
'Fonte/ocorrências',
|
||||
'Módulo',
|
||||
'Rotas',
|
||||
'Implementação encontrada',
|
||||
'Disponibilidade ativa',
|
||||
'Evidência funcional',
|
||||
'Estado',
|
||||
'Lacuna',
|
||||
'Dependência',
|
||||
'Critério de aceitação',
|
||||
'Prioridade',
|
||||
'Versões divergentes',
|
||||
];
|
||||
function save() {
|
||||
fs.writeFileSync(
|
||||
path.join(priv, 'matriz.json'),
|
||||
JSON.stringify(rows, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(out, 'matriz-requisitos.csv'),
|
||||
'\uFEFF' +
|
||||
[
|
||||
headers,
|
||||
...rows.map((r) => [
|
||||
r.id,
|
||||
r.pedido,
|
||||
r.fontes.join(' | '),
|
||||
r.modulo,
|
||||
r.rotas.join('; '),
|
||||
r.implementacao,
|
||||
r.ativo,
|
||||
r.evidencia,
|
||||
r.estado,
|
||||
r.lacuna,
|
||||
r.dependencia,
|
||||
r.aceitacao,
|
||||
r.prioridade,
|
||||
r.variantes,
|
||||
]),
|
||||
]
|
||||
.map((row) =>
|
||||
row
|
||||
.map(
|
||||
(v) =>
|
||||
'"' + String(v).replace(/"/g, '""') + '"',
|
||||
)
|
||||
.join(','),
|
||||
)
|
||||
.join('\n') +
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
save();
|
||||
const summary = {
|
||||
files: inventory.length,
|
||||
external_files: external.length,
|
||||
attachments: inventory.reduce((n, e) => n + e.attachments.length, 0),
|
||||
masters: masters.map((m) => ({
|
||||
file: path.basename(m.source),
|
||||
sha256: m.hash,
|
||||
paragraphs: m.paragraphs.length,
|
||||
})),
|
||||
requirements: rows.length,
|
||||
obligation_occurrences: obligationCount,
|
||||
route_count: routes.length,
|
||||
status: 'Matriz inicial conservadora; não é certificação de aceitação',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(priv, 'inventario-resumo.json'),
|
||||
JSON.stringify(summary, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify(summary));
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Keep the existing audit and historical MAC evidence; reopen browser authentication."""
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
heading = '## C37 reaberto — ciclo de login da conta QA 18 — P0'
|
||||
old_heading = heading
|
||||
body = '''C37 continua aberto e bloqueia a demonstração visual A. O utilizador reporta ciclo login→MFA→redirecionamento na QA18097 com a conta18. A correção anterior do MAC inválido da conta13 e as provas HTTP são históricas específicas; não demonstram a continuidade da sessão neste navegador. Nenhum login foi repetido nesta investigação.
|
||||
|
||||
GET/login QA confirmou alvo desk_restore_20261009_174726_test:55441 antes de instrumentar. Configuração efetiva: cookie opportunity_resources_qa, domínio vazio (host-only), caminho/, Secure=false, HttpOnly=true, SameSite=lax; XSRF-TOKEN no mesmo domínio/caminho, HttpOnly=false. Guard padrão e Fortify=web, sessão file120min, cache de configuração não carregado. Os atributos observados não identificam uma causa. Portas partilham o âmbito do cookie no mesmo host; o router antigo18096 usa o mesmo nome/diretório por código, mas seu GET atual devolve erro PHP sem Set-Cookie. Não está demonstrado que sobrescreveu o cookie na tentativa reportada.
|
||||
|
||||
O router QA, depois da guarda real de destino, ativa tools/portal-qa-auth-trace.php. auth-session-trace-qa.jsonl guarda método/caminho sem query, horaUTC, estado antes/depois da rota, pseudónimo HMAC da sessão e do cookie aceite, utilizador pendente/autenticado, persistência efetiva no handler, atributos Set-Cookie e destino Location/X-Inertia-Location. Não guarda Cookie, X-XSRF-TOKEN, credenciais, códigos MFA, corpos ou valores de cookies. Identificador não secreto X-Investmoz-QA-Auth-Trace permite correlacionar resposta manual. Instrumentação temporária exclusivamente na QA18097; não altera login, MFA, chave ou dados ativos. auth-trace-readiness-qa.json demonstra continuidade convidada e captura antes da autorização, persistência legível e diretório efetivo correto; não é prova de login. Os bundles servidos usam /login e /two-factor-challenge relativos. A configuração de origem declara APP_NAME=Laravel e não SESSION_COOKIE (default laravel_session), distinta do nomeQA; isto é leitura documental de configuração, não consulta ao servidor ativo. XSRF-TOKEN genérico pode partilhar o âmbito entre portas; nenhum conflito concreto foi observado. tools/portal-qa-auth-diagnosis.py lê as provas sem fazer pedidos de login e destaca a primeira divergência da conta18; divergência não é automaticamente causa.
|
||||
|
||||
Primeiro diagnóstico manual: usar o navegador afetado e cookies atuais; abrir http://127.0.0.1:18097/investimentos, entrar com a conta18 e confirmar MFA uma vez. Parar no resultado e comunicar hora aproximada/URL final. Não limpar cookies nem trocar de janela antes desta captura. A anterior instrução de janela privada aplica-se ao ensaio posterior, não à localização desta perda. Comparar primeiro: pending18 após POST/login→mesma sessão GET/MFA→POST/MFA autentica/regenera e grava18→GET destino recebe a nova sessão e carrega18. O primeiro desvio identifica o ponto da perda; sem essa sequência observada, a causa fica indeterminada.
|
||||
|
||||
Aceitação ainda pendente: /investimentos visível e autorizado após MFA, reload mantém18, logout retira acesso, novo login+MFA recupera o dossier, sem mistura entre projetos/perfis. Depois retomar A/B completos. Correção somente após causa concreta; não fechar por HTTP/unitários. MFA preservado, sem key:generate; zero escritas ativas, candidate_revision_fixed=false e activation_ready=false.
|
||||
|
||||
Ativação: C37 precede revisão candidata, ensaio visual A/B e renovação de backup/restauro. O trace QA não integra instalação ativa, recursos ou importação cartográfica. Reversão da instrumentação: remover o carregamento e os três registos de middleware no router QA; guardar provas sanitizadas privadas, não eliminar histórico. Não repetir instalações existentes nem ativar flags.'''
|
||||
|
||||
confirmed = (private / 'c37-manual-2122-2124-qa.json').exists() and (private / 'c37-auth-redirect-qa.json').exists()
|
||||
if confirmed:
|
||||
manual = json.loads((private / 'c37-manual-2122-2124-qa.json').read_text(encoding='utf-8'))
|
||||
http = json.loads((private / 'c37-auth-redirect-qa.json').read_text(encoding='utf-8'))
|
||||
runtime = json.loads((private / 'investor-path-qa-runtime.json').read_text(encoding='utf-8-sig'))
|
||||
assert manual['cause_confirmed'] and http['mfa_browser_redirect_protocol_passed'] and not http['visual_acceptance_passed']
|
||||
heading = '## C37 — causa confirmada; seleção de conta QA corrigida — P0, visual pendente'
|
||||
body = f'''O pedido de reabertura referia a conta esperada18. A captura manual21:22–21:24UTC demonstra que foi selecionada a conta13 restaurada. O MFA foi alcançado, mas a autenticação nunca se completou. Causa concreta: credenciais MFA da conta13 cifradas pela chave de origem, incompatíveis com a chave estávelQA; a conta18 sintética não foi desafiada nessa tentativa. Nenhuma chave, MFA ou conta13 foi alterada para contornar o problema.
|
||||
|
||||
| UTC09/10 | Pedido | Resultado observado |
|
||||
|---|---|---|
|
||||
|21:22:10|GET/investimentos|302/login; sessão convidada guardada, destino pretendido /investimentos.|
|
||||
|21:22:11|GET/login|200; mesmo cookie/sessão, sem autenticação.|
|
||||
|21:22:44|POST/login|302/two-factor-challenge; login.id=13, não18.|
|
||||
|21:22:44|GET/two-factor-challenge|200; desafio13 recuperado da mesma sessão; ecrã confirmado pelo utilizador.|
|
||||
|21:23:04|POST/two-factor-challenge|Falha de desencriptação; warning correlacionado conta13;302/login, invalidação intencional e nova sessão convidada.|
|
||||
|21:23:04|GET/login|200; cookie aceite corresponde à sessão regenerada; sem utilizador autenticado.|
|
||||
|
||||
Primeiro desvio do ensaio: POST/login desafia13. Primeira falha técnica: POST/two-factor-challenge às21:23:04 (trace ec68d040a3291cb8, referência26e15b04-07b8-4211-a2a0-7f70509e03f6). Não se tratou de código MFA rejeitado pelo verificador nem de perda após autenticação. Os dois campos cifrados (secret/recovery) são incompatíveis; como nenhum corpo foi retido, não se afirma qual modalidade de entrada foi usada nesta tentativa. Não foram observados conflitos de cookies, mudança de chave/guard ou redirecionamento para outra origem.
|
||||
|
||||
CorreçãoQA, após guarda real desk_restore_20261009_174726_test:55441: login identifica/preenche o email não secreto da conta18; o ecrã MFA identifica a conta realmente desafiada. PortalQaAccountSelection, carregado exclusivamente pelo routerQA, recusa endereços não sintéticos antes do desafio e cancela desafios antigos de contas restauradas, com erro controlado. Os perfis sintéticosQA mantêm o login Fortify normal e respetivo MFA. Contas restauradas e dados ativos preservados. A conta13 continua cifrada como no original; não foi reencryptada nem usa chave ativa como fallback.
|
||||
|
||||
Lacuna de feedback encontrada no código: o Form recém-criado no login começa com erros locais vazios e não lia o errors.email devolvido pela página após redirecionamento do MFA. Login agora apresenta também esse erro da página. A mensagem de falha de desencriptação continua controlada e não autentica; sua apresentação visual ainda exige confirmação.
|
||||
|
||||
Evidência atual{http['captured_at_utc']} em c37-auth-redirect-qa.json, pelo servidor18097 e protocolo Inertia/HTML com redirects reais: seletor13 recusado antes do MFA; desafio18 identificado; código inválido mantém desafio sem autenticar; MFA válido regenera/grava18 e redireciona /investimentos; GET e reload mantêm18; logout revoga acesso; novo cookie jar+login+MFA recupera o mesmo dossier413ba1f6-bdf6-402a-a561-c4e2d4098d1a; outra conta403. Negativo sintético19 demonstra falha de desencriptação→login com mensagem, sem autenticação. Nenhuma criação extra de dossier. Estes são resultados de resposta/persistência, não um ensaio visual bem-sucedido.
|
||||
|
||||
Evidências: c37-manual-2122-2124-qa.json conserva os seis pedidos e warning sanitizados; auth-session-trace-qa.jsonl mantém histórico append-only e pseudónimos HMAC; c37-qa-account-auth.xml verifica políticaQA, preservação, MFA válido/inválido e falhas secret/recovery; c37-installation-auth-fingerprint.xml verifica guardas/importação/reversão. TypeScript e build passaram. mfa-persistence-qa.json reconfirma conta13 e .env byte a byte, MFA18 confirmado/oito códigos, chave não gerada/rodada e zero escritas ativas. Cookies, passwords, OTP e corpos não são exportados.
|
||||
|
||||
Estado: causa identificada, preparaçãoQA corrigida e protocolo/persistência demonstrados; C37 permanece aberto para /investimentos visível→reload→logout→novo login da conta18 no navegador. A captura manual demonstrou apenas o MFA da conta13 e retorno ao login; não certifica o percursoA/B nem o feedback atualizado. Próximo teste deve confirmar que o ecrã diz contaQA18 antes de usar o autenticador dessa conta. URL exato http://127.0.0.1:18097/investimentos. Credenciais/segredo permanecem somente no ficheiro privado local investor-mfa-qa-private.json, sem exportação. Workflow/contratos especializados continuam no backlog, sem jornadas educativas novas.
|
||||
|
||||
Ativação não autorizada e revisão candidata não fixada. Fingerprints agora incluem também login.tsx e two-factor-challenge.tsx: percurso{runtime['code_hash']}; cartografia{runtime['geometry_code_hash']}; recursos{runtime['resource_code_hash']}. Ready/provas de versões anteriores não demonstram esta revisão. Política/seletor/rastreioQA não integram instalação ativa. Após aceitação visualA/B e correções, renovar backup/restauro/compatibilidade e manter a ordem já documentada de recursos→percurso→flag→importação cartográfica→publicação separada, sem repetir o que estiver instalado. ReversãoQA: retirar registo/configuração de PortalQaAccountSelection e overrides Fortify do router; retirar os registos de rastreio; preservar provas. A correção do feedback é código compatível sem migration, alteração de chave ou modificação de dados.'''
|
||||
|
||||
for name in ['relatorio-executivo.md', 'backlog.md', 'casos-reproducao.md', 'roteiro-manual-qa-i0-i3.md', 'plano-ativacao.md', 'execucao-percurso-i0-i3.md']:
|
||||
path = docs / name
|
||||
if not path.exists():
|
||||
continue
|
||||
text = path.read_text(encoding='utf-8-sig')
|
||||
start = text.find(heading + '\n')
|
||||
if start < 0 and confirmed:
|
||||
start = text.find(old_heading + '\n')
|
||||
if start >= 0:
|
||||
end = text.find('\n## ', start + 3)
|
||||
text = text[:start] + heading + '\n\n' + body + '\n\n' + (text[end+1:] if end >= 0 else '')
|
||||
else:
|
||||
first = text.find('\n')
|
||||
text = text[:first+1] + '\n' + heading + '\n\n' + body + '\n\n' + text[first+1:].lstrip('\n')
|
||||
path.write_text(text, encoding='utf-8')
|
||||
|
||||
matrix = docs / 'matriz-requisitos.csv'
|
||||
with matrix.open(encoding='utf-8-sig', newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
fields = reader.fieldnames
|
||||
rows = list(reader)
|
||||
ids = ['AUD-UI-01', 'AUD-DASH-01', 'AUD-SIM-01', 'AUD-DESK-02', 'AUD-GUIDE-01', 'AUD-CRM-01']
|
||||
assert len(rows) == 3354
|
||||
assert sum(int(row['Quantidade de linhas de origem']) for row in rows) == 5830
|
||||
suffix = ' | C37 REABERTO/P0: ciclo login da conta18 no navegador18097. auth-cookie-config-qa.json confirma atributos/guard QA; auth-session-trace-qa.jsonl preparado para primeira perda antes/depois MFA. Causa e continuidade visual não demonstradas; provas HTTP anteriores não fecham este caso.'
|
||||
if confirmed:
|
||||
suffix = ' | C37 DIAGNÓSTICO CONFIRMADO/P0: manual21:22–21:24UTC desafia conta13, não18; POST/MFA21:23:04 falha na desencriptação e invalida intencionalmente; GET/login aceita nova sessão. Sem autenticação concluída/perda posterior/conflito de cookies. c37-manual-2122-2124-qa.json e c37-auth-redirect-qa.json: QA seleciona18, bloqueia restauradas antesMFA, apresenta erro da página; MFA18→/investimentos→reload→logout→nova sessão/dossier HTTP passou; visual18 pendente.'
|
||||
for row in rows:
|
||||
if row['ID'] not in ids:
|
||||
continue
|
||||
row['Prioridade'] = 'P0'
|
||||
if 'C37 REABERTO/P0:' not in row['Evidência funcional']:
|
||||
if not confirmed:
|
||||
row['Evidência funcional'] += suffix
|
||||
row['Lacuna'] += ' | C37 reaberto: localizar perda; corrigir causa e demonstrar /investimentos→reload→logout→novo login/MFA no navegador.'
|
||||
if confirmed:
|
||||
row['Evidência funcional'] = row['Evidência funcional'].replace('C37 REABERTO/P0:', 'Histórico C37 REABERTO/P0, antes da captura21:22–21:24UTC:')
|
||||
row['Lacuna'] = row['Lacuna'].replace('C37 reaberto: localizar perda;', 'Histórico da reaberturaC37, antes da captura: localizar perda;')
|
||||
if 'C37 DIAGNÓSTICO CONFIRMADO/P0:' not in row['Evidência funcional']:
|
||||
row['Evidência funcional'] += suffix
|
||||
row['Lacuna'] += ' | C37 após captura: causa conhecida e seleção/feedbackQA corrigidos; falta entrada visual bem-sucedida da conta18, reload/logout/nova sessão e retomaA/B.'
|
||||
row['Interface'] = 'Manual: MFA13 alcançado e retorno/login observado21:22–21:24UTC; seleção/feedbackQA preparados. Sucesso visual da conta18/reload/logout/nova sessão pendente.'
|
||||
row['API'] += '' if 'c37-auth-redirect-qa.json' in row['API'] else ' | c37-auth-redirect-qa.json: redirects Inertia/HTML no servidor18097, MFA18 e negativas diferenciadas.'
|
||||
row['Recuperação'] += '' if 'C37 nova sessão18' in row['Recuperação'] else ' | C37 nova sessão18 por HTTP recupera o dossier próprio apóslogout; outra conta403. Interface pendente.'
|
||||
else:
|
||||
row['Interface'] = 'Pendente/com falha reportada: C37 ciclo de login conta18 QA18097; trace manual preparado, causa por localizar.'
|
||||
with matrix.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
file = private / 'investor-path-evidence.json'
|
||||
proof = json.loads(file.read_text(encoding='utf-8-sig'))
|
||||
proof.update({'mfa_blocking_case': 'C37', 'c37_reopened_browser_login_loop': True,
|
||||
'c37_browser_session_loss_identified': False, 'c37_browser_acceptance_passed': False,
|
||||
'integrated_ui_ab_passed': False, 'candidate_revision_fixed': False, 'activation_ready': False})
|
||||
if confirmed:
|
||||
proof.update({'c37_cause_identified': True, 'c37_manual_observed_actor': 13,
|
||||
'c37_session_loss_after_auth_ruled_out_in_manual_attempt': True,
|
||||
'c37_qa_account_selection_corrected': True, 'c37_auth_redirect_qa_evidence': 'c37-auth-redirect-qa.json',
|
||||
'c37_manual_interval_evidence': 'c37-manual-2122-2124-qa.json', 'c37_browser_acceptance_passed': False})
|
||||
file.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'case': 'C37 P0 visual pending', 'cause_confirmed': confirmed, 'requirements': len(rows), 'origins': 5830, 'active_writes': 0}))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
// No database connection or data writes; scan retained private QA source files only.
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$scanner = app(MalwareScanner::class);
|
||||
$health = $scanner->health();
|
||||
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
|
||||
$files = [];
|
||||
if ($health['available']) {
|
||||
foreach ([1, 2, 3] as $level) {
|
||||
$path = $root.'/storage/app/private/investmoz-v2/portal-audit/moz-adm'.$level.'-ine-20190607.geojson';
|
||||
$scan = $scanner->scan($path, filesize($path), $root.'/storage/app/private');
|
||||
$scan['signatures_at'] = $scan['signatures_at']?->format(DATE_ATOM);
|
||||
$files[] = ['file' => basename($path), 'sha256' => hash_file('sha256', $path), 'scan' => $scan];
|
||||
}
|
||||
}
|
||||
// Standard harmless antivirus test string is streamed from memory, never written to disk.
|
||||
$probe = null;
|
||||
if ($health['available']) {
|
||||
$socket = stream_socket_client('tcp://127.0.0.1:3310', $number, $message, 5);
|
||||
if ($socket === false) {
|
||||
throw new RuntimeException('ClamD probe connection failed.');
|
||||
}
|
||||
stream_set_timeout($socket, 10);
|
||||
$content = base64_decode('WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=');
|
||||
fwrite($socket, "zINSTREAM\0".pack('N', strlen($content)).$content.pack('N', 0));
|
||||
$reply = stream_get_contents($socket);
|
||||
fclose($socket);
|
||||
$probe = ['mode' => 'INSTREAM_memory', 'detected' => str_contains($reply, 'FOUND') && str_contains($reply, 'Eicar'), 'reply' => trim($reply, "\0\r\n")];
|
||||
// Names are engine dependent and may contain EICAR in upper case.
|
||||
$probe['detected'] = str_contains($reply, 'FOUND') && str_contains(strtolower($reply), 'eicar');
|
||||
}
|
||||
$result = ['captured_at' => gmdate('c'), 'health' => $health, 'files' => $files, 'standard_test_probe' => $probe, 'active_database_writes' => 0,
|
||||
'binary_url' => 'https://www.clamav.net/downloads/production/clamav-1.5.4.win.x64.zip', 'binary_sha256' => hash_file('sha256', $root.'/storage/app/private/investmoz-v2/portal-audit/clamav/clamav-1.5.4.win.x64.zip'),
|
||||
'runtime_scope' => 'Local portable daemon on loopback; startup after reboot and periodic signature maintenance still require configuration'];
|
||||
file_put_contents($root.'/storage/app/private/investmoz-v2/portal-audit/clamd-lote2.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($result, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
// Exercise the real public API over the restored active catalogue. No migration or active connection.
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$proof = json_decode(file_get_contents($root.'/storage/app/private/investmoz-v2/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $proof['restored_target'];
|
||||
if (! ($proof['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Verified restored QA target required');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'false', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'],
|
||||
'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$kernel = $app->make(Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Non-QA database rejected');
|
||||
}
|
||||
// Enumerating the whole catalogue is a QA coverage run, independent of rate-limit acceptance.
|
||||
RateLimiter::for('api', fn () => Limit::none());
|
||||
$expected = app(InvestmentGeography::class)->publicReferences()->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
|
||||
->get(['id', 'parent_id', 'location_type']);
|
||||
$counts = [];
|
||||
$results = [];
|
||||
$seen = [];
|
||||
foreach (['province' => null, 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'] as $type => $parentType) {
|
||||
$parents = $parentType === null ? [null] : $expected->where('location_type', $parentType)->pluck('id')->all();
|
||||
foreach ($parents as $parent) {
|
||||
$page = 1;
|
||||
$actual = [];
|
||||
do {
|
||||
$uri = '/api/v1/investment/territory-options?'.http_build_query(['type' => $type, 'parent_id' => $parent, 'page' => $page]);
|
||||
$request = Request::create($uri, 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']);
|
||||
$response = $kernel->handle($request);
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new RuntimeException('Catalogue API failed: '.$uri.' HTTP '.$response->getStatusCode());
|
||||
}
|
||||
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach ($body['data'] as $item) {
|
||||
if ($item['location_type'] !== $type || ($parent !== null && $item['parent_id'] !== $parent)) {
|
||||
throw new RuntimeException('Hierarchy mismatch');
|
||||
}
|
||||
$actual[] = $item['id'];
|
||||
$seen[$item['id']] = true;
|
||||
}
|
||||
$kernel->terminate($request, $response);
|
||||
$results[] = ['type' => $type, 'parent_id' => $parent, 'page' => $page, 'http' => 200, 'rows' => count($body['data'])];
|
||||
$page++;
|
||||
} while ($body['next_page_url'] !== null);
|
||||
$wanted = $expected->where('location_type', $type);
|
||||
if ($parent !== null) {
|
||||
$wanted = $wanted->where('parent_id', $parent);
|
||||
}
|
||||
$wanted = $wanted->pluck('id')->sort()->values()->all();
|
||||
sort($actual);
|
||||
if ($wanted !== $actual) {
|
||||
throw new RuntimeException('API omissions/duplicates at '.$type.' '.$parent);
|
||||
}
|
||||
}
|
||||
$counts[$type] = $expected->where('location_type', $type)->count();
|
||||
}
|
||||
if (count($seen) !== $expected->count()) {
|
||||
throw new RuntimeException('Unreachable catalogue references');
|
||||
}
|
||||
$result = ['captured_at' => gmdate('c'), 'target' => $identity, 'source' => 'Verified active backup restored in QA', 'counts' => $counts,
|
||||
'references_expected' => $expected->count(), 'references_recovered' => count($seen), 'api_requests' => count($results), 'checks' => $results,
|
||||
'visual_verified' => false, 'all_consumers_accepted' => false, 'geometry_rows' => DB::table('geo_geometries')->count(),
|
||||
'rate_limit_scope' => 'Excluded only in QA bulk catalogue enumeration; access/rate behavior covered separately', 'active_writes' => 0];
|
||||
file_put_contents($root.'/storage/app/private/investmoz-v2/portal-audit/territory-catalogue-coverage.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
unset($result['checks']);
|
||||
echo json_encode($result, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Reconcile current QA evidence into existing reports; no database or new matrix."""
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
runtime = json.loads((private / 'investor-path-qa-runtime.json').read_text(encoding='utf-8-sig'))
|
||||
http = json.loads((private / 'investor-path-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert http['target'] == runtime['target'] and http['api_persistence_passed']
|
||||
assert not http['integrated_ui_ab_passed'] and http['active_writes'] == 0
|
||||
proof_file = private / 'investor-path-evidence.json'
|
||||
proof = json.loads(proof_file.read_text(encoding='utf-8-sig'))
|
||||
calculation = json.loads((private / 'investor-path-recovered-calculations.json').read_text(encoding='utf-8-sig'))
|
||||
assert calculation['passed'] and calculation['target'] == runtime['target'] and not calculation['interface_observed']
|
||||
old = private / 'investor-path-evidence-before-continuation.json'
|
||||
if not old.exists():
|
||||
old.write_text(json.dumps(proof, indent=2), encoding='utf-8')
|
||||
proof['previous_full_suite'] = proof.get('previous_full_suite', {**proof['full_suite'], 'code_hash': proof['code_hash'], 'scope': 'Previous full regression; no per-requirement or current UI acceptance'})
|
||||
proof.update(runtime)
|
||||
selected = []
|
||||
for name in ['continuation-consumer-qa.xml', 'continuation-profile-qa.xml', 'continuation-install-qa.xml', 'continuation-business-journey-qa.xml']:
|
||||
tree = ET.parse(private / name)
|
||||
cases = list(tree.iter('testcase'))
|
||||
assert cases and all(not list(case) for case in cases), f'Failure/skip in {name}'
|
||||
selected.append({'junit': name, 'tests': len(cases), 'assertions': sum(int(c.get('assertions', 0)) for c in cases), 'result': 'passed',
|
||||
'cases': [{'class': c.get('class'), 'method': c.get('name'), 'assertions': int(c.get('assertions', 0))} for c in cases]})
|
||||
proof.update({'current_selected_suites': selected, 'real_http_ab_evidence': 'investor-path-http-qa.json',
|
||||
'new_authenticated_http_session_recovered': True, 'integrated_ui_ab_passed': False,
|
||||
'cartography_current_qa_evidence': 'geometry-consumers-path-qa.json', 'current_frontend_directed_tests': 7,
|
||||
'manual_script': 'roteiro-manual-qa-i0-i3.md', 'activation_ready': False,
|
||||
'actual_recovered_calculations': 'investor-path-recovered-calculations.json',
|
||||
'qa_demonstrated': 'Real authenticated HTTP/new cookie sessions A/B plus API/persistence/profile criteria; UI pending',
|
||||
'remaining': ['A/B browser interface and new browser session', 'Visual country/origin/statistics per form',
|
||||
'Each selector/map consumer rendering, including C27',
|
||||
'Workflow and specialized contracts', 'Visual authenticated cartography import/reversal',
|
||||
'Final backup/restore/compatibility renewal after visual demonstration'],
|
||||
'browser': {'apps': [], 'browsers': [], 'reset_rechecked': True, 'failed_providers': ['iab', 'chrome', 'edge'],
|
||||
'qa_login_http': 200, 'cause': 'No browser provider exposed; connection/registration root cause not established', 'visual_verified': False}})
|
||||
proof_file.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def insert(filename, heading, body):
|
||||
file = docs / filename
|
||||
text = file.read_text(encoding='utf-8-sig')
|
||||
block = heading + '\n\n' + body.strip() + '\n\n'
|
||||
start = text.find(heading + '\n')
|
||||
if start >= 0:
|
||||
end = text.find('\n## ', start + len(heading))
|
||||
text = text[:start] + block + (text[end + 1:] if end >= 0 else '')
|
||||
else:
|
||||
first = text.find('\n')
|
||||
text = text[:first + 1] + '\n' + block + text[first + 1:].lstrip('\n')
|
||||
file.write_text(text, encoding='utf-8')
|
||||
|
||||
|
||||
insert('relatorio-executivo.md', '## Continuação atual — prova HTTP, cobertura por formulário e interface pendente', '''
|
||||
**I0–I3 continuam parciais; nenhuma escrita ativa.** A/B passaram no servidor QA real18097: login → criação/gravação → logout → nova sessão/cookies/login → recuperação → aplicabilidade → ferramenta/entradas congeladas → resultado no dossier → rascunho Balcão → acompanhamento interno. Outra conta403/404; troca de contexto403; gestor não lê draft/resultado financeiro privado. Tarefa de mercado concluída enquanto terra aguarda. QA web{runtime['qa_path_journeys']} instâncias/{runtime['qa_path_steps']} etapas; inclui os dossiers sintéticos preservados e a continuação com MFA quando demonstrada. Não importar fixtures no ativo.
|
||||
|
||||
País/origem/filtros/estatísticas: provas específicas dos quatro tipos de apoio e três serviços Balcão reforçadas; wizard, fundiário, perfil/empresa/submissão e CRM conservam provas próprias. Complemento continuation-business-journey-qa.xml demonstra interesse em negócio público PT/ZA, recibo/replay, recuperação/filtro/estatística e jornada PT=1/MZ=0, sem inferir país como origem. Ver [cobertura por formulário/consumidor](cobertura-percursos-i0-i3.md), ligada aos IDs da matriz. Apresentação visual continua pendente.
|
||||
|
||||
C27 preparou a ligação do território principal ao preview na ficha da oportunidade e do filtro aplicado ao mapa da Bolsa; TypeScript/build passaram.361/361 limites reais recuperados READ ONLY na mesma QA A/B; perfis HTTP gestor/público/conta comum ensaiados em província/distrito/posto. Públicos0, internos361; faltam2/39/179 correspondências e polígonos de localidades. **Nenhum mapa ou seletor foi visualmente aceite.**
|
||||
|
||||
C29: a fixture HTTP inicial tinha listas orçamentais vazias incompatíveis. Corrigida por nova versão, mantendo os resultados congelados anteriores e o erro explícito. A função efetiva do dossier verifica agora os snapshots realmente recuperados: A20unidades/0.00 e B20/70000.00; é cálculo demonstrado, sem observação visual.
|
||||
|
||||
Browser: inventário vazio antes/depois de reset, IAB/Chrome/Edge indisponíveis; login QA HTTP200. Causa observada é fornecedor não exposto nesta sessão; causa de conexão/registo não determinada. [Roteiro manual executável](roteiro-manual-qa-i0-i3.md) com URLs, contas QA, ações/esperados e negativas preparado. HTML/props/HTTP não substituem essa execução.
|
||||
|
||||
Matriz única mantém3.354 requisitos/linhas canónicas e5.830 ocorrências;5 concluídos anteriores,3.198 parciais,146 não implementados,1 implementado mas não ativado,4 decisões concretas. **Nenhum novo requisito encerrado.** Evidências complementares por método nos JUnit continuation-consumer/profile/install, sessão real em investor-path-http-qa.json e mapa de cobertura acima. Regressão global384/5215 é anterior a C27; não serve como prova atual de interface. C28 remove número381 fixo no preparador de evidências, sem eliminar guardas/hashes/autenticação.
|
||||
|
||||
Backup/restauro final não renovado antes da demonstração visual. Fingerprints mudaram; ready=true histórico não vale para a versão atual. Instalação de recursos, extensão do percurso, disponibilização por configuração e importação/divulgação cartográfica são ações separadas, fora da autorização ativa. Workflow e contratos especializados permanecem no backlog; nenhuma jornada educativa nova.
|
||||
''')
|
||||
insert('backlog.md', '## Próximo lote atual — critérios abertos após as provas HTTP', '''
|
||||
1. **P0/P1 AUD-UI-01, DASH-01, SIM-01, DESK-02, GUIDE-01, CRM-01:** executar [roteiro A/B](roteiro-manual-qa-i0-i3.md), criar projetos novos pela interface, recuperar em outra sessão do navegador, alternar sem mistura, ferramenta/resultado, rascunho de apoio e tarefa paralela. Provas HTTP já passaram; navegador continua sem fornecedor. Fecho exige capturas/resultados/rede/persistência por passo.
|
||||
2. **P1 AUD-COUNTRY-01–04/06–07 e STATS-01:** ensaiar visualmente cada formulário da [cobertura](cobertura-percursos-i0-i3.md), filtros/URL/tabela e agregados separados. Interesse em negócio público e estatística específica da jornada agora têm provas API/persistência próprias; a apresentação e variações condicionais continuam abertas. Perfil/empresa e residência fiscal/fundos preservam significados próprios.
|
||||
3. **P1 AUD-GEO-02/03:** observar cada seletor/mapa, incluindo C27 já preparado; guardar/reabrir campo, limpar descendentes, respeitar acesso e edição.361 disponíveis na QA não são361 públicos nem cobertura visual. Correspondências2/39/179 e localidades permanecem; reutilizar cadastro/originais sem aproximação ou dados inventados. Edição de zonas exige conta QA com MFA; não desativar a permissão.
|
||||
4. **P1 AUD-OPS-01:** após demonstração, renovar backup/restauro/hashes/compatibilidade da versão final; repetir somente ensaios pertinentes e gerar provas verdadeiras. Recursos030000 e percurso190000 são instalações distintas; flag habilita navegação separadamente. Mecanismo cartográfico C30 preparado e demonstrado em QA; verificar interface e preparar prova específica da revisão/alvo após backup final. Importador CLI QA não pode ser apontado ao ativo. C28 preparador corrigido para não exigir contagem381 fixa.
|
||||
5. **Workflow e contratos especializados:** continuam pendentes; sem novas jornadas educativas enquanto falhas prioritárias abertas. ClamD real e GD/scan das três derivadas de oportunidades têm provas QA atuais; arranque permanente/manutenção/PHP ativo e operações ainda não ensaiadas são pendências específicas, sem bloquear países/preparação.
|
||||
|
||||
Todas as linhas permanecem na matriz existente; não reabrir aceitação de fontes oficiais nem recriar estados/dados paralelos. Nenhuma instalação ativa autorizada.
|
||||
''')
|
||||
insert('execucao-percurso-i1-i3.md', '## Continuação atual — sessões reais e evidência por consumidor', '''
|
||||
Prova posterior ao lote inicial abaixo: investor-path-http-qa.json executa login/logout e novo cookie jar/login no servidor QA18097, recupera A/B por ID, aplicabilidade, draft próprio, resultado congelado, pedido e tarefa concluída. Outra conta403/draft404 e contexto cruzado403. A mostra necessidade esperada0; B70000; ambas20unidades nas fórmulas. Na QA web existem4instâncias/76etapas, incluindo2fixtures de setup e2HTTP; os38steps dos testes abaixo pertencem a outra base isolada.
|
||||
|
||||
País/origem/filtros/estatísticas foram reforçados por formulário; interesse em negócio público e jornada têm agora provas específicas. C27 liga consumidores de mapas; build/TypeScript passaram. C28 corrige a contagem fixa no preparador. Ver cobertura-percursos-i0-i3.md e roteiro-manual-qa-i0-i3.md.
|
||||
|
||||
Interface ainda não observada; I0–I3 permanecem parciais. Provas anteriores de backup e hashes são históricas; renovar depois da demonstração visual. Nenhuma escrita/ativação ativa autorizada; instalação de schema, flag e importação/divulgação cartográfica são atos separados.
|
||||
''')
|
||||
insert('plano-ativacao.md', '## Sequência atual — após demonstração e sem autorização ativa', f'''
|
||||
Plano preparado, **não executado**. A/B HTTP/nova sessão real passaram; interface ainda pendente. Por isso não renovar o backup final como se a demonstração visual estivesse concluída nem considerar pronta a ativação. A revisão atual alterou fingerprints: percurso `{runtime['code_hash']}`; recursos `{runtime['resource_code_hash']}`. Provas anteriores preservadas e insuficientes para esses hashes. `activation_ready=false`.
|
||||
|
||||
| Ordem | Ação exata futura | Dependência/efeito esperado | Verificação e reversão |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | Concluir roteiro QA A/B e consumidores; fixar revisão/assets/hashes | Interface com nova sessão/perfis/resultados; nenhuma mistura. Não usar contagem global como substituto. | Registar cada critério na mesma matriz; se mudar código relevante, repetir respetiva demonstração. |
|
||||
| 1 | Renovar dump + ficheiros privados e restaurar numa nova base loopback*_test:55441; ensaiar compatibilidade/reversão sobre essa cópia | Origem ativa apenas READ ONLY; destino QA com guarda. Usar mecanismos de backup existentes, incluindo tools/desk-activation-backup.php; preservar prova antiga antes de substituir. Comparar schema, contagens, hashes por tabela, ficheiros/chave privada, órfãos/relações/RLS.288tabelas/5ficheiros são baseline histórico, não contagem a forçar. | Repetir ensaio vazio e recusa depois de uso; gerar provas para ambos os fingerprints sem fabricar true nem copiar flags visuais de outra versão. Não renovar/aplicar no ativo agora. |
|
||||
| 2 | Disponibilizar correções/assets desta revisão, mantendo INVESTOR_PATH_ENABLED=false | Países/origem/filtros/maturidade/C27; sem reclassificar valores legados, sem novo dado por deploy. | Confirmar assets servidos; rollback de código compatível preserva novos payloads/histórico. |
|
||||
| 3 | Conta ativa autorizada: GET `/management/opportunity-resources-install`, confirmar prévia atual, depois POST nessa rota **somente se030000 estiver ausente** | `2026_10_09_030000_extend_opportunity_resources`:3tabelas de associação existentes no desenho,1coluna em documents,1função/3triggers,1migration/evento;0recursos/documentos/localizações novos automaticamente. Backup/restauro/compatibilidade/hash/permissões. | Ler ledger/evento/autor/hash e comparar dados/classificações/relações/originais. Não repetir instalação como ensaio. Down só sem recursos/originais cifrados; após uso preservação/exportação antes de recuperação. |
|
||||
| 4 | GET `/management/balcao-install`, conferir apenas migration190000 pendente; POST autenticado pela mesma rota |2colunas nas entidades atuais +1definição publicada imutável +ledger/evento.0conversões de journeys antigos,0dossiers/etapas/tarefas criados. `pathQaVerified` exige API e A/B visual dessa revisão. Não repetir migrations básicas do Balcão/Bolsa/gestão já instaladas. | Schema/definição/ledger/evento/hash; instalação não habilita percurso. Down recusa após uso; preservar definição/história/RLS. |
|
||||
| 5 | Em ação de configuração/deploy separada, habilitar INVESTOR_PATH_ENABLED e atualizar configuração servida | Só depois de schema instalado e QA visual da versão. Mostra `/investimentos`/painel; instancia19etapas apenas ao criar novo dossier, sem renumerar roteiros. | Confirmar rotas/layout/perfis/recuperação. Reversão imediata: desligar flag preservando dossiers, drafts, resultados, documentos, consentimentos, tarefas e eventos; ferramentas diretas mantidas. |
|
||||
| 6 | Prévia/aplicação **cartográfica autenticada**, lote oficial privado, em geo_geometries existentes | C30: GET/POST `/management/territory-geometry-import`, POST `/{{batch}}/reverse`; mecanismo preparado e demonstrado em QA. **Não existe prova ativa válida desta revisão agora**. Depende de prévia/hash/original/scan/dedup/conflitos. Se base continuar0 e todas as correspondências permanecerem inequívocas:361=9+120+232novos;0geo_locations/pontos/perímetros de investimento.2/39/179ficam de fora. | Guardar lote/origem/edição/IDs/autor/eventos; replay0novos. Não apontar portal-audit-geometry-qa.php ao ativo. Reverter acesso/arquivar apenas lote novo sem uso; preservar originais/relações/história. |
|
||||
| 7 | Disponibilizar camadas por consumidor/âmbito autorizado, ato separado de importar | Fonte aceite não é divulgação automática; edição2019/atualidade jurídica/administrativo vs implantação explícitos. Limites internos não podem aparecer publicamente para tornar o teste verde. | Conferir cada mapa/selector/perfil. Retirar camada/autorização sem apagar geometria ou histórico. |
|
||||
|
||||
ClamD é requisito de scan/reabertura/download/publicação dos binários afetados; arranque/assinaturas e cada derivada têm provas próprias. GD/WebP depende do PHP web. Não bloquear metadados, países, estatísticas ou preparação independente por estes itens. Nenhum cálculo fiscal ativado por importação/publicação.
|
||||
|
||||
Restauro integral ativo continua fora da autorização; antes dele reconciliar todas as escritas posteriores ao backup. A renovação após interface pode envolver leitura ativa/restoreQA, mas nenhuma instalação, importação, publicação ou sobrescrita ativa está autorizada.
|
||||
''')
|
||||
with (docs / 'casos-reproducao.md').open('a', encoding='utf-8') as f:
|
||||
if '## C27 —' not in (docs / 'casos-reproducao.md').read_text(encoding='utf-8'):
|
||||
f.write('''\n## C27 — consumidores sem contexto cartográfico\n\nRequisitos AUD-GEO-02/03. Reprodução de código: ficha de oportunidade chama InvestmentMapPanel apenas com opportunity_id; preview procura IDs territoriais exclusivamente nos filtros e recebe vazio. Bolsa desenha pontos e omite consulta de limites do território aplicado. Esperado: contexto administrativo do território conhecido, respeitando classificação, sem mudar resultado económico nem inventar implantação. Observado: chamada ao preview ausente nestes consumidores. Causa identificada: ligação de props/componente incompleta. Corrigido em código QA: territoryId separado a partir do primary_territory_path, e TerritoryGeometryPreview da Bolsa a partir de p.filters. TypeScript/build passam; GET real/serviço QA verificam acessos. Reprodução/renderização no navegador ainda pendentes; não confundir causa de código com prova visual.\n\n## C28 — preparador de prova preso à contagem381\n\nRequisito AUD-OPS-01. Fonte tools/portal-audit-prepare-activation.php exigia tests=passed=381 e emitia381 fixo; a regressão posterior384/5215, apesar de passar, seria rejeitada por esta condição. Esperado: total positivo, todos passados, sem skips, mantendo hash/backup/restauro/compatibilidade/autenticação independentes. Corrigida condição/serialização para usar total efetivo; a contagem continua apenas inventário de regressão, sem provar critérios ou UI. Sintaxe/Pint verificados. Preparador não executado contra ativo nem prova de ativação emitida; demais dependências continuam obrigatórias.\n''')
|
||||
if '## C29 —' not in (docs / 'casos-reproducao.md').read_text(encoding='utf-8'):
|
||||
f.write('''\n## C29 — fixture HTTP com orçamento incompatível\n\nRequisito AUD-SIM-02; falha na preparação da fixture, não demonstrada como regressão do portal. Reprodução QA: snapshot financeirov1 com incomes=[]/expenses=[], gravar draft e devolver evidência; executar recoverPathResult sobre o snapshot recuperado. Esperado para demonstração:20unidades/gap0(A) ou70000(B). Observado: Entradas incompatíveis. Causa: fixture criada pelo ensaio HTTP não respeitava o contrato de pelo menos uma linha orçamental. Correção no ensaio: novas linhas sintéticas explicitamente identificadas, PUT com ETag/contexto gera draftv2 e novo resultado; originais congeladosv1 preservados. Não relaxada a validação nem atribuído zero ao erro. investor-path-recovered-calculations.json demonstra novos cálculos e recusa dos resultados históricos incompatíveis; HTTP recupera ambos sem sobrescrita. Roteiro visual cria projetos novos e informa o histórico das fixtures de inspeção. Interface permanece pendente.\n''')
|
||||
historical = {
|
||||
'relatorio-executivo.md': {'## Estado atual — lote I0/I1–I3 executado exclusivamente em QA': '## Histórico do lote inicial I0/I1–I3 — antes da continuação HTTP/C27'},
|
||||
'backlog.md': {'## Próximo lote — após execução QA aprovada de I1–I3': '## Histórico do próximo lote então definido — antes da continuação HTTP/C27'},
|
||||
'plano-ativacao.md': {'## Ordem final — execução aprovada QA, ativo não autorizado': '## Histórico da ordem anterior — fingerprints substituídos por C27',
|
||||
'## Versão atual — separar preparação, instalação e disponibilização': '## Histórico da versão anterior — separar preparação, instalação e disponibilização'},
|
||||
'execucao-percurso-i1-i3.md': {'## Lote executado e limites da evidência': '## Histórico do lote inicial — limites da evidência então obtida'},
|
||||
}
|
||||
for filename, replacements in historical.items():
|
||||
file = docs / filename
|
||||
content = file.read_text(encoding='utf-8')
|
||||
for old_heading, new_heading in replacements.items():
|
||||
content = content.replace(old_heading, new_heading)
|
||||
file.write_text(content, encoding='utf-8')
|
||||
print(json.dumps({'evidence_updated': True, 'requirements_closed': 0, 'active_writes': 0, 'qa_instances': runtime['qa_path_journeys'], 'qa_steps': runtime['qa_path_steps'], 'visual': False}))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Supplement the existing audit with concrete I0 evidence; never grants active authorization."""
|
||||
import csv
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
geometry = json.loads((private / 'geometry-auth-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
resources = json.loads((private / 'resources-real-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
persistence = json.loads((private / 'document-real-persistence-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert geometry['api_persistence_passed'] and resources['api_persistence_passed'] and persistence['passed']
|
||||
assert geometry['target'] == resources['target'] == persistence['target']
|
||||
assert all(not p['interface_observed'] and p['active_writes'] == 0 for p in [geometry, resources, persistence])
|
||||
tests = list(ET.parse(private / 'import-document-real-qa.xml').iter('testcase'))
|
||||
assert len(tests) == 15 and all(not list(t) for t in tests)
|
||||
consumer_tests = list(ET.parse(private / 'document-consumers-real-qa.xml').iter('testcase'))
|
||||
assert consumer_tests and all(not list(t) for t in consumer_tests)
|
||||
summary = json.loads((private / 'contagens-requisitos.json').read_text(encoding='utf-8-sig'))
|
||||
assert summary['requisitos'] == summary['linhas_matriz'] == 3354 and summary['linhas_origem'] == 5830
|
||||
assert summary['conservation_sha256'] == '35f4af4af5447faad115f4761954557d4458df658ccedbc0be781cf65a5536c6'
|
||||
proof = json.loads((private / 'investor-path-evidence.json').read_text(encoding='utf-8-sig'))
|
||||
proof.update({'geometry_authenticated_http': 'geometry-auth-http-qa.json', 'document_operations_real_http': 'resources-real-http-qa.json',
|
||||
'document_operations_real_persistence': 'document-real-persistence-qa.json', 'qa_url': 'http://127.0.0.1:18097', 'candidate_revision_fixed': False,
|
||||
'i0_selected_suite': {'junit': 'import-document-real-qa.xml', 'tests': len(tests), 'assertions': sum(int(t.get('assertions', 0)) for t in tests),
|
||||
'cases': [{'class': t.get('class'), 'method': t.get('name'), 'assertions': t.get('assertions'), 'scanner_scope': 'mock fixture' if t.get('class') == 'Tests\\Feature\\OpportunityResourcesTest' else 'real ClamD or explicit negative control'} for t in tests]},
|
||||
'integrated_ui_ab_passed': False, 'activation_ready': False})
|
||||
proof['document_consumer_suite'] = {'junit': 'document-consumers-real-qa.xml', 'tests': len(consumer_tests), 'assertions': sum(int(t.get('assertions', 0)) for t in consumer_tests),
|
||||
'cases': [{'class': t.get('class'), 'method': t.get('name'), 'scanner_scope': 'real ClamD consumer/worker' if t.get('class') in ['Tests\\Feature\\PortalRealDocumentConsumersTest', 'Tests\\Feature\\ClamDQueueWorkerIntegrationTest'] else 'mock/manual fixture or schema compatibility; not real ClamD proof'} for t in consumer_tests]}
|
||||
(private / 'investor-path-evidence.json').write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def insert(filename, heading, body):
|
||||
p = docs / filename
|
||||
text = p.read_text(encoding='utf-8-sig')
|
||||
block = heading + '\n\n' + body.strip() + '\n\n'
|
||||
start = text.find(heading + '\n')
|
||||
if start >= 0:
|
||||
end = text.find('\n## ', start + len(heading))
|
||||
text = text[:start] + block + (text[end + 1:] if end >= 0 else '')
|
||||
else:
|
||||
first = text.find('\n')
|
||||
text = text[:first + 1] + '\n' + block + text[first + 1:].lstrip('\n')
|
||||
p.write_text(text, encoding='utf-8')
|
||||
|
||||
|
||||
states = '; '.join(f"{k}: {v['requisitos']} requisitos/{v['linhas_origem']} ocorrências" for k, v in summary['por_estado'].items())
|
||||
body = f'''**QA atual: http://127.0.0.1:18097/investimentos.** Porta18096 anterior não usar; erro PHP/Symfony no arranque com GD, antes do cabeçalho QA. Processo isolado recuperado com GD e OPcache desligado, sem mudança do PHP ativo. Base `desk_restore_20261009_174726_test:55441`, sessões próprias; SuiteCRM/AI externos desativados. CUA permanece sem apps/browsers. Nenhuma observação visual nem escrita ativa.
|
||||
|
||||
**Mesma matriz:**3354 requisitos/linhas canónicas,5830 ocorrências preservadas; {states}. Nenhum requisito amplo fechado por este lote. `candidate_revision_fixed=false`, `integrated_ui_ab_passed=false`, `activation_ready=false`; não fixar candidata antes dos resultados manuais e correções.
|
||||
|
||||
| Requisito | Correção/prova concreta deste lote | Estado |
|
||||
| --- | --- | --- |
|
||||
| AUD-GEO-02/03, OPS-01 | C30: GET/POST `/management/territory-geometry-import`, POST `/{{batch}}/reverse`. Fontes retidas/edição/hash/scan; nomes exatos+pai+país, conflitos, preview vinculado ao ator/estado, MFA, `geo_write_receipts`, `audit_events` append-only; nada publicado. HTTP real com login+TOTP, três níveis,361 reutilizadas/0 novas; replay e reversão de lote de reutilização mantêm361. QA de testes demonstra criação e reversão de lote novo, preservação de histórico e recusas de alteração/publicação/FK/scan expirado/MFA/alvo não QA. | Código preparado + API/persistência QA; interface e ativo pendentes |
|
||||
| AUD-SCAN-01 | C31: nome da rota assinada corrigido para `management.opportunity-image-preview`, mantendo URL. Servidor web QA gera3WebP320/640/1280; PDF+PNG+3derivadas têm cinco scans reais ClamAV1.5.4, clean/hash próprios, cifragem Laravel e classificação restricted; downloads reais comparados, assinaturas ausentes403 e outra conta403. QA de testes prova quarentena423, scanner inconclusivo e retry real, substituição sem apagar original, versão409, exclusão/restauro e adulteração409. | Operações de oportunidades demonstradas por API/persistência; apresentação visual pendente |
|
||||
| AUD-LIB-01/SCAN-01/DESK-02 | C35 corrigiu erro500 ao omitir data/emissor opcionais. C36 corrigiu recuperação404 de originais cifrados: os consumidores comparavam o hash do ciphertext com o original. VerifiedDocumentBinary reutiliza decifragem/integridade sem cópia pública nem alteração de permissões. PortalRealDocumentConsumersTest usa ClamD real: biblioteca privada→scan→classificação→publicação separada→download; versão409/dedup/404hash. Balcão testa upload/confidential→scan→download, dedup/403estranho. Original canónico cifrado é reutilizado sem duplicar documents; downloads na gestão/Balcão/biblioteca e view PDF devolvem os bytes originais; antes de publicação/adulteração404. | API/persistência QA; bytes de preview PDF verificados, renderização visual pendente; sem publicação ativa nem fecho global |
|
||||
| AUD-UI-01/DASH-01/SIM-01/DESK-02/GUIDE-01/CRM-01 | A/B repetidos por HTTP no servidor recuperado; nova sessão real, IDs/drafts próprios, recuperação, aplicabilidade, resultado20/0 e20/70000, Balcão e tarefa concluída enquanto terra aguarda; outra conta/contexto recusados. | API/persistência QA; cenários completos na interface pendentes |
|
||||
|
||||
**Correspondências:** províncias11→9/2; distritos159→120/39; postos411→232/179. Esperado refere o exemplar2019, não um cadastro atual certificado. Maputo e Maputo City são as duas pendências provinciais. Distritos:9 por pai não resolvido/30 por nome; postos:86 por pai/93 por nome. [Todas220 lacunas por nome/código/pai/causa](correspondencias-cartograficas.md). Sem polígonos de localidades no exemplar; nenhum limite equivale a implantação/DUAT. Aceitação da fonte oficial preservada.
|
||||
|
||||
**Evidências:** `geometry-auth-http-qa.json`, `resources-real-http-qa.json`, `document-real-persistence-qa.json`, `import-document-real-qa.xml`, `investor-path-http-qa.json` e `investor-path-recovered-calculations.json`, no acervo privado. As15 verificações dirigidas são inventário:4 importação,2 operações documentais reais,5 scanner/negativas e4 recursos com fixture. Não substituem prova por requisito ou interface.
|
||||
|
||||
**Ainda não verificado:** interface de A/B, cada formulário/estatística, cada seletor/mapa e operações documentais visuais. Biblioteca/Balcão têm agora os ensaios reais específicos acima; PDF view tem prova de bytes/MIME/CSP, mas renderização, importação em lote, todos os tipos/documentos/perfis e partilhas/imutabilidade por consumidor continuam pendentes. Os ensaios de compatibilidade/schema e fixtures não constituem prova ClamD real. ClamD real demonstrado nesta sessão é distinto de arranque após reboot/manutenção de assinaturas e GD no PHP ativo, ainda pendentes. Workflow e contratos especializados permanecem abertos; sem jornadas educativas novas.
|
||||
'''
|
||||
for filename in ['relatorio-executivo.md', 'execucao-percurso-i1-i3.md']:
|
||||
insert(filename, '## Lote I0 atual — importação autenticada e operações documentais reais', body)
|
||||
insert('backlog.md', '## Próximo lote I0–I3 — após importação e scans reais', '''
|
||||
1. **P0/P1 interface:** executar A primeiro em http://127.0.0.1:18097/investimentos; depois B e perfis. Registar URL/ação/esperado/observado/IDs/versão e prova visual; incorporar os resultados manuais na mesma matriz e corrigir falhas antes de fixar candidata. API/HTTP já passaram, não antecipar UI.
|
||||
2. **P1 países/origem/estatísticas:** percorrer cada formulário de cobertura-percursos-i0-i3.md, incluindo negócios/fundiário, serviços Balcão, filtros e agregados separados. Não confundir residência/constituição/origem de fundos com origem declarada de solicitações.
|
||||
3. **P1 territorial:** observar cada seletor/mapa/consumidor; testar nova página `/management/territory-geometry-import?level=1`, depois2/3, confirmando correspondências, resultados e histórico com gestor QA/MFA. A importação não publica. Resolver somente correspondências explícitas fundamentadas, listadas por nome em correspondencias-cartograficas.md; conservar casos2/39/179 e ausência de localidades.
|
||||
4. **P1 documental:** testar visualmente upload/scan/download/substituição/exclusão/restauro/previews; C31/C35/C36 corrigidos em QA. Biblioteca e Balcão têm provas reais específicas, incluindo reutilização do original cifrado sem duplicação e resposta PDF inline verificada. Renderização PDF, lote, tipos/perfis/partilhas/imutabilidade continuam abertos por consumidor. Configuração permanente ClamD/assinaturas/GD ativo é pendência operacional específica.
|
||||
5. **OPS-01:** após demonstração e correções, fixar hashes e renovar backup/restauro/compatibilidade. Preparar provas vinculadas à revisão/alvo, incluindo a prova específica cartográfica. Recursos030000, percurso190000, flag e importação/publicação separados. Nenhuma ativação autorizada agora; fonte oficial aceite não se reabre.
|
||||
''')
|
||||
insert('plano-ativacao.md', '## Importação C30 — ações exatas e dependências', '''
|
||||
Esta sequência é preparação, fora da autorização ativa atual. Antes de cada ação reconfirmar alvo/ledger/contagens; números abaixo são previsão do último retrato, não valores a forçar.
|
||||
|
||||
1. Incorporar A/B e consumidores manuais, corrigir falhas, repetir provas pertinentes e fixar revisão/manifest/fingerprints. Hoje continuam pendentes.
|
||||
2. Renovar backup de DB/ficheiros privados/chaves e originais cartográficos/metadados; restaurar em nova QA com guarda, comparar schema/contagens/hashes/IDs/relações/classificações/RLS. Preservar prova anterior. Validar reversão vazia e recusa após uso. Preparar prova específica cartográfica a partir dessas evidências e resultados manuais; não fabricar `ui_passed=true`.
|
||||
3. Disponibilizar código/assets, mantendo `INVESTOR_PATH_ENABLED=false` e `TERRITORY_GEOMETRY_IMPORT_ENABLED=false`.
|
||||
4. Gestor autenticado: GET/POST `/management/opportunity-resources-install`, somente se030000 ausente. Previsto3tabelas de associação+1coluna de documents+função/3triggers;0recursos novos automáticos. Não repetir gestão/Balcão/Bolsa já instalados.
|
||||
5. GET/POST `/management/balcao-install`, somente migrations realmente pendentes, prevista190000.2colunas+1definição imutável;0conversões/dossiers/steps automáticos. Prova visual da revisão é pré-condição; instalar não disponibiliza percurso.
|
||||
6. Habilitar separadamente `INVESTOR_PATH_ENABLED` após schema/provas; novos dossiers instanciam19etapas nas5fases sem renumerar roteiros. Rollback imediato: flagfalse preservando dados e ferramentas diretas.
|
||||
7. Preparar `geometry-import-activation-proof.json`, vinculada ao fingerprint cartográfico, alvo real, QA/UI e backup restaurado/hash/data<24h; configuração específica habilita rota, não equivale a aplicar. Este artefacto final não foi emitido. Gestor com MFA: GET `/management/territory-geometry-import?level=1`, rever linhas/hash; POST `/management/territory-geometry-import` com level/hash/idempotency_key/reason/confirm. Repetir2 só após pais1, depois3. Se prévia conservar todas as correspondências e ativo0:9+120+232=361 geometrias privadas novas;0territórios/locais/pontos novos. Hoje QA reutilizou361 e inseriu0. Fonte/scan expirado exige atualização da prova por bytes, não revalidação de credibilidade oficial.
|
||||
8. Publicação de camadas por consumidor/âmbito é ato separado. O importador não tem comando de publicação. Não alterar para public nem usar publicação de zonas de investimento para publicar limites administrativos; mecanismo de divulgação correspondente permanece no backlog quando necessário.
|
||||
|
||||
Reversão do lote: POST `/management/territory-geometry-import/{batch}/reverse`, com MFA, idempotency_key/reason/confirm. Só faz exclusão lógica das geometrias criadas por esse lote, intactas e sem FK consumidor; preserva361 reutilizadas, originais, receipts e auditoria. Se houver uso/alteração/publicação, recusa e exige reconciliação, sem apagar relações. Rollback de migrations030000/190000 apenas vazio; após utilização desligar disponibilização e preservar dados. Restauro integral nunca sobrescreve escritas posteriores sem reconciliação/autorização.
|
||||
''')
|
||||
insert('cobertura-percursos-i0-i3.md', '## Complemento atual C30/C31 — critérios distintos', body)
|
||||
|
||||
cases = docs / 'casos-reproducao.md'
|
||||
text = cases.read_text(encoding='utf-8')
|
||||
additions = {
|
||||
'C30': '## C30 — importação oficial dependia de CLI QA sem ato autenticado — P1\n\nAUD-GEO-02/03, OPS-01. Reprodução anterior: existiam361 limites privados aplicados por tools/portal-audit-geometry-qa.php, sem rota de prévia/aplicação/reversão por gestor. Esperado: gestor/MFA, prévia/hash/correspondências, receipts idempotentes, auditoria e reversão preservando adotadas. Corrigido: serviço/controlador/página nas entidades existentes, flag desativada fora de QA e guarda de backup/provas no alvo ativo. HTTP real nos3níveis e ensaio de criação/reversão/FK na QA; apresentação visual pendente.\n',
|
||||
'C31': '## C31 — previews de derivadas lançavam RouteNotFoundException — P0\n\nAUD-SCAN-01. Reprodução: gerar e limpar uma derivada; abrir recursos da oportunidade. Esperado: URL assinada com preview WebP. Observado: Route [opportunity-image-preview] not defined; GET da página falha após existir derivada limpa. Causa: grupo management prefixa nome da rota, mas controlador usava nome sem prefixo; teste antigo saía no ramo GD ausente. Corrigido: management.opportunity-image-preview, sem mudar URL. PHP QA com GD e ClamD1.5.4:3derivadas320/640/1280, scans separados, página/bytes recuperados, assinatura/ator indevidos403 e adulteração409. Visual pendente.\n',
|
||||
'C32': '## C32 — arranque PHP QA com GD falhava antes do cabeçalho de isolamento — P1\n\nReprodução: processo18096 reiniciado com extensão GD. Observado: Fatal error Cannot instantiate interface RecursiveIterator no arranque Symfony/Finder, resposta200 sem cabeçalho QA; ensaio abortou antes de escrever. Causa exata do PHP/OPcache não comprovada; funcionamento restabelecido num processo QA18097 com GD e OPcache desligado apenas nele, sem editar vendor/PHP ativo. Tentativa agrupada de reinício foi rejeitada pela revisão automática sem razão detalhada; alternativa isolada permitida. Login/cabeçalho/HTTP A/B/importação/uploads/derivadas verificados; CUA ainda sem provider.\n',
|
||||
'C33': '## C33 — fixture MFA incompatível com serialização Fortify — QA\n\nReprodução: primeira fixture de gestor cartográfico usava Crypt::encryptString no secret; POST /two-factor-challenge dava unserialize() offset error. Corrigida somente a fixture QA para Crypt::encrypt, compatível com Fortify. Login TOTP real passou; nenhuma credencial ativa ou controlo MFA alterado.\n',
|
||||
'C34': '## C34 — prévia cartográfica recalculava hash do pai por elemento — P1 QA\n\nAo acrescentar verificação da edição/hash do pai, o hash era recalculado por feature. Ensaio de nível3 excedeu limite PHP e devolveu conteúdo fatal em resposta200, recusado pelo consumidor JSON. Corrigido para calcular hash uma vez por nível; transação preserva estado em falha. Repetir prova HTTP integral na revisão final e manter resultado visual pendente.\n',
|
||||
'C35': '## C35 — cadastro documental falha500 quando campos opcionais são omitidos — P0\n\nAUD-LIB-01/SCAN-01. Reprodução: upload privado, depois POST /management/portal-services/resources com campos obrigatórios, omitindo document_date/issuer permitidos como nullable; o cadastro tenta aceder diretamente a document_date na atualização do documents. Observado: Undefined array key document_date em PortalServicesController:444 e transação revertida. Esperado: cadastro com data desconhecida/sem invenção, preservar emissor/data existentes quando ausentes. Corrigido acesso com ??null tanto na criação como na edição; teste mantém opcionais omitidos e cobre gravação/recuperação/scans reais/classificação/publicação separada, conflitos/dedup/hash/permissões. Sem alteração ativa nem aceitação fictícia de fonte oficial.\n',
|
||||
'C36': '## C36 — original canónico cifrado devolve404 em consumidores — P0\n\nAUD-LIB-01/SCAN-01/DESK-02. Reprodução QA: documents com storage_encryption=laravel, bytes originais/hash, scan ClamD real clean; GET /management/documents/{id}/download. Esperado200 com original íntegro; observado404 (encrypted-consumer-before-qa.xml). Causa: gestão/biblioteca/Balcão comparavam hash do ficheiro cifrado ao hash original. Corrigido VerifiedDocumentBinary partilhado: mantém guardas do consumidor, scan/hash, path privado e cifra; decifra só em memória e verifica plaintext, sem copiar para disco público. Teste real verifica gestão, associação ao Balcão sem novo documents, download e view PDF após classificação/publicação explícitas, bloqueio antes de publicação, bytes/MIME/CSP e adulteração404 em todos os consumidores. document-consumers-real-qa.xml distingue os três ensaios ClamD de consumidores/worker das fixtures de outros testes. Sem observação visual nem escrita ativa.\n',
|
||||
}
|
||||
for id, block in additions.items():
|
||||
if f'## {id} —' not in text:
|
||||
text += '\n' + block
|
||||
cases.write_text(text, encoding='utf-8')
|
||||
print(json.dumps({'requirements': summary['requisitos'], 'source_lines': summary['linhas_origem'], 'requirements_closed': 0, 'visual': False, 'candidate_fixed': False, 'active_writes': 0}))
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Record C37 in the existing audit, without closing visual/active criteria."""
|
||||
import json
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
http = json.loads((private / 'mfa-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
diagnosis = json.loads((private / 'mfa-key-diagnosis-qa.json').read_text(encoding='utf-8-sig'))
|
||||
invalidated = json.loads((private / 'mfa-session-invalidation-qa.json').read_text(encoding='utf-8-sig'))
|
||||
persistence = json.loads((private / 'mfa-persistence-qa.json').read_text(encoding='utf-8-sig'))
|
||||
runtime = json.loads((private / 'investor-path-qa-runtime.json').read_text(encoding='utf-8-sig'))
|
||||
cases = list(ET.parse(private / 'mfa-auth-qa.xml').iter('testcase'))
|
||||
assert len(cases) == 17 and all(not list(c) for c in cases)
|
||||
assert http['target'] == diagnosis['target'] == invalidated['target'] == persistence['target'] == runtime['target']
|
||||
assert http['login_mfa_logout_new_session_passed']
|
||||
current_mfa_revision = http['code_hash'] == runtime['code_hash']
|
||||
assert http['stable_key_matches_cli_web'] and not http['interface_observed'] and http['active_writes'] == 0
|
||||
assert invalidated['qa_sessions_invalidated'] == 2 and invalidated['account_row_unchanged']
|
||||
assert persistence['original_account_13_unchanged'] and persistence['source_env_unchanged'] and not persistence['key_generated_or_rotated']
|
||||
assert diagnosis['encrypted_field_checks']['two_factor_secret'] == {'qa': False, 'source_env': True}
|
||||
|
||||
# Retain every frame, omitting all arguments and request data.
|
||||
lines = list(deque((root / 'storage/logs/laravel.log').open(encoding='utf-8', errors='replace'), maxlen=5000))
|
||||
stacks = []
|
||||
for i, line in enumerate(lines):
|
||||
if 'testing.ERROR: The MAC is invalid.' not in line:
|
||||
continue
|
||||
frames = []
|
||||
for frame in lines[i+1:]:
|
||||
if re.match(r'^\[\d{4}-', frame):
|
||||
break
|
||||
if frame.startswith('#'):
|
||||
location, sep, call = frame.partition('): ')
|
||||
if sep:
|
||||
frames.append(location + '): ' + call.split('(')[0] + '(arguments omitted)')
|
||||
elif '{main}' in frame:
|
||||
frames.append(frame.strip())
|
||||
if frames and 'TwoFactorLoginRequest.php' in frames[0]:
|
||||
stacks.append({'observed': line.split(' {')[0].strip(), 'frames': frames, 'arguments_exported': False})
|
||||
assert len(stacks) >= 2 and all(len(s['frames']) == 68 for s in stacks[-2:])
|
||||
(private / 'mfa-original-stack-redacted.json').write_text(json.dumps(stacks[-2:], ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def insert(filename, heading, body):
|
||||
path = docs / filename
|
||||
text = path.read_text(encoding='utf-8-sig')
|
||||
block = heading + '\n\n' + body.strip() + '\n\n'
|
||||
start = text.find(heading + '\n')
|
||||
if start >= 0:
|
||||
end = text.find('\n## ', start + len(heading))
|
||||
text = text[:start] + block + (text[end+1:] if end >= 0 else '')
|
||||
else:
|
||||
first = text.find('\n')
|
||||
text = text[:first+1] + '\n' + block + text[first+1:].lstrip('\n')
|
||||
path.write_text(text, encoding='utf-8')
|
||||
|
||||
|
||||
body = f'''**C37/P0 — histórico da correção do tratamento do MAC inválido e ensaio HTTP em {http['captured_at']}, revisão {http['code_hash']}.** Correspondência com a revisão atual: {current_mfa_revision}. Esta evidência não fecha o ciclo manual reaberto nem a interface; consultar o diagnóstico C37 mais recente no início deste documento. URL: http://127.0.0.1:18097/investimentos. Alvo real confirmado antes de qualquer escrita: `{runtime['target']['database']}:55441`.
|
||||
|
||||
Stack completa dos erros20:39:16/20:41:52 (68frames por ocorrência) retida sem argumentos em mfa-original-stack-redacted.json. Falha em TwoFactorLoginRequest::hasValidCode() ao desencriptar users.two_factor_secret da conta13; PreserveFormFeedback apenas chama o próximo middleware. A conta restaurada não é sintética: secret/recovery abrem com a chave do .env de origem, mas não com a chaveQA. Cache de configuração não carregado; source cache também ausente. A ferramenta de preparação do percurso antes não fixava APP_KEY e podia herdar origem. portal-qa-environment.php centraliza a mesma chaveQA já existente entre preparação/web, força alvo/restauro, configura caminho QA de cache separado e recusa cache não reconciliado. Não gera chave, não usa chaves ativas como fallback nem modifica .env/cache ativo.
|
||||
|
||||
Duas sessões pendentes da conta13 removidas exclusivamente do diretório de sessões QA, após guarda do destino e paths. Original conta13 preservado byte a byte pelo hash da linha; nenhuma chave/MFA ativa ou da conta13 alterada. Novo investidor sintético QA: actor{http['actor_id']}, inscrição normal pelos endpoints Fortify (confirmar password→enable→secret-key→confirm), oito recovery codes, sem injeção direta do segredo nem desativação de MFA. Dados de acesso/segredo somente em investor-mfa-qa-private.json local privado; não exportados aqui.
|
||||
|
||||
POST /two-factor-challenge com DecryptException agora invalida desafio/sessão e retorna422 com mensagem controlada em JSON ou redireciona ao login com erro visível; nenhuma autenticação. Negativas reais de secret/recovery verificam401 após erro e remoção do desafio. A resposta HTML/Inertia contém a mensagem, sem ser prova de renderização visual. Logs controlados incluem apenas referência e ID pendente; as provas não exportam Cookie, X-XSRF-TOKEN, OTP ou corpos.
|
||||
|
||||
No mesmo servidor18097: login/MFA/logout/nova sessão passaram. A retomado por HTTP em `/investimentos/{http['scenario_A']['id']}`: criação→logout→nova sessãoMFA→recuperação→aplicabilidade semterra/semfinanciamento→draft próprio→resultado→Balcão draft→tarefa concluída pela gestão→nova sessãoMFA recupera tudo. Gestão não recebe resultado financeiro privado. Função efetiva do dossier recalcula20unidades/0.00 a partir do resultado recuperado. QA atual{runtime['qa_path_journeys']}instâncias/{runtime['qa_path_steps']}etapas; originals anteriores preservados. Esta conta não vê os A/B da outra contaQA.
|
||||
|
||||
Evidências: mfa-key-diagnosis-qa.json, mfa-session-invalidation-qa.json, mfa-persistence-qa.json, mfa-http-qa.json e mfa-auth-qa.xml. Os17 ensaios autenticam ou recusam os percursos concretos; não substituem interface. Fingerprints passam a incluir bootstrap/Fortify, invalidando ready/provas anteriores para ativação. `integrated_ui_ab_passed=false`, `candidate_revision_fixed=false`, `activation_ready=false`; nenhuma escrita ativa. Mesma matriz3354requisitos/5830origens, sem fecho amplo novo.
|
||||
'''
|
||||
for filename in ['relatorio-executivo.md', 'execucao-percurso-i1-i3.md', 'cobertura-percursos-i0-i3.md']:
|
||||
insert(filename, '## C37 — MFA QA recuperado; cenário A visual por retomar', body)
|
||||
insert('backlog.md', '## Próximo passo após C37 — retomar A com MFA', '''
|
||||
1. Retomar A primeiro na interface18097 com o investidor sintético MFA; iniciar janela privada nova. Preservar o500 reportado como falha bloqueante histórica; conta13 restaurada não deve servir de conta sintéticaQA. Nova criação manual continua exigida, não apenas abrir o dossier HTTP.
|
||||
2. Registar login/desafio/mensagem/recuperação, depois ferramenta→resultado→Balcão→acompanhamento e negativas; omitir Cookie/X-XSRF-TOKEN/corpo do código das capturas. Corrigir outras falhas antes de fixar candidata.
|
||||
3. B, países/estatísticas e seletores/mapas/documentos continuam no mesmo backlog. API/persistência das operações não aguardam browser; ClamD mantém âmbito específico.
|
||||
4. Após resultados manuais: renovar backup/restauro/compatibilidade sobre a revisão final. Bootstrap/Fortify agora entram nos fingerprints; não reutilizar ready/provas de revisão anterior. Nenhuma ativação ativa autorizada.
|
||||
''')
|
||||
insert('plano-ativacao.md', '## C37 — revisão ainda sem candidatura ou ativação', '''
|
||||
Correção MFA é código, não migration/importação/publicação. Aplicação e preparação QA usam chave estável existente; não executar key:generate nem rekey dos dados. Conta13 ativa/copiada preservada; contas sintéticas e invalidação de sessões exclusivamenteQA. Fingerprints incluem bootstrap/app.php e configuração/provider Fortify; provas antigas devem ser renovadas depois da retoma visual A/B e correções. Ordem de recursos030000, percurso190000, flag e cartografia/divulgação continua no plano abaixo, sem repetir instalações. Ação ativa não autorizada.
|
||||
''')
|
||||
insert('roteiro-manual-qa-i0-i3.md', '## Retoma obrigatória após C37 — sessão e investidor MFA', '''
|
||||
Endereço exato: http://127.0.0.1:18097/investimentos; login /login. Fechar a sessão anterior e abrir janela privada nova; as duas sessõesQA da conta13 foram invalidadas. Confirmar X-Investmoz-QA-Target=desk_restore_20261009_174726_test:55441 antes de escrever. Não usar a conta13 restaurada como fixtureQA.
|
||||
|
||||
Usar o investidor sintético MFA preparado (actor18). As credenciais e segredo para configurar o autenticador ficam somente no ficheiro local privado storage/app/private/investmoz-v2/portal-audit/investor-mfa-qa-private.json; não colar conteúdo/código/cookies nesta conversa. MFA foi ativado/confirmado normalmente pelo Fortify e é obrigatório no login. Gestão e outra contaQA conservadas para os restantes passos/permissões.
|
||||
|
||||
1. Entrar com a contaQA, confirmar desafioMFA antes do acesso ao investimento, usar o autenticador e abrir /investimentos. Esperado: entrada depois do código; nenhum500.
|
||||
2. Criar um A visual novo, guardar; logout, fechar janela e nova sessão com password+MFA. Esperado: mesmoID/título recuperado.
|
||||
3. Continuar a sequência A/B deste roteiro: aplicabilidade→ferramenta→resultado→Balcão→acompanhamento; registar cada prova. O dossier A HTTP atual é apenas inspeção complementar, não prova de criação na interface.
|
||||
4. Para falha de desencriptação, esperado422JSON/mensagem controlada ou retorno ao login; nunca autenticação. Esse negativo foi ensaiado com fixture sintética separada, sem alterar conta13. Não adulterar manualmente a conta do investidor para o reproduzir.
|
||||
|
||||
Capturas/requisições exportadas devem omitir Cookie, X-XSRF-TOKEN, passwords e corpo com code/recovery_code. As provas exportadas pela ferramenta registam somente método/URL/status/alvo, não cabeçalhos/corpos. Retoma visual permanece pendente até resultados observados pelo utilizador.
|
||||
''')
|
||||
insert('casos-reproducao.md', '## C37 — MAC inválido no desafio MFA bloqueia cenário A — P0', body)
|
||||
proof_file = private / 'investor-path-evidence.json'
|
||||
proof = json.loads(proof_file.read_text(encoding='utf-8-sig'))
|
||||
proof.update({'mfa_http_qa_evidence': 'mfa-http-qa.json', 'mfa_persistence_qa': 'mfa-persistence-qa.json',
|
||||
'mfa_blocking_case': 'C37', 'manual_scenario_A_failed_before_fix': True, 'manual_scenario_A_retested_after_fix': False,
|
||||
'integrated_ui_ab_passed': False, 'candidate_revision_fixed': False, 'activation_ready': False})
|
||||
proof_file.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
if proof.get('c37_reopened_browser_login_loop'):
|
||||
# Historical HTTP evidence must never undo the user's reopened browser blocker.
|
||||
import runpy
|
||||
runpy.run_path(str(root / 'tools/portal-audit-reopen-c37.py'), run_name='__main__')
|
||||
else:
|
||||
print(json.dumps({'case': 'C37', 'qa_login_mfa_new_session': 'passed', 'sessions_invalidated': 2, 'scenario_A_http_resumed': True,
|
||||
'visual_retest': 'pending', 'requirements_closed': 0, 'active_writes': 0}))
|
||||
@@ -1,51 +1,165 @@
|
||||
const {chromium} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs = require('node:fs'), assert = require('node:assert/strict');
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs = require('node:fs'),
|
||||
assert = require('node:assert/strict');
|
||||
const base = 'http://127.0.0.1:18096';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true, executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
|
||||
const errors = [], results = [];
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
const errors = [],
|
||||
results = [];
|
||||
async function login(width) {
|
||||
const context = await browser.newContext({viewport:{width,height:900}});
|
||||
await context.route('**/*', route => route.request().url().startsWith(base) || route.request().url().startsWith('data:') ? route.continue() : route.abort());
|
||||
const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(base+'/login');
|
||||
assert.match((await page.request.get(base+'/login')).headers()['x-investmoz-qa-target'], /_test:55441$/);
|
||||
await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');
|
||||
await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');
|
||||
await page.getByRole('button',{name:'Log in',exact:true}).click(); await page.waitForURL('**/dashboard');
|
||||
return {page,context};
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
});
|
||||
await context.route('**/*', (route) =>
|
||||
route.request().url().startsWith(base) ||
|
||||
route.request().url().startsWith('data:')
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(base + '/login');
|
||||
assert.match(
|
||||
(await page.request.get(base + '/login')).headers()[
|
||||
'x-investmoz-qa-target'
|
||||
],
|
||||
/_test:55441$/,
|
||||
);
|
||||
await page
|
||||
.getByLabel('Email address')
|
||||
.fill('opportunity-manager@example.invalid');
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('Opportunity-QA-2026!');
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
return { page, context };
|
||||
}
|
||||
const desktop = await login(1280), p = desktop.page;
|
||||
await p.goto(base+'/investidor/profile');
|
||||
const desktop = await login(1280),
|
||||
p = desktop.page;
|
||||
await p.goto(base + '/investidor/profile');
|
||||
await p.locator('#residence_country_codeaccount').selectOption('PT');
|
||||
const saved = p.waitForResponse(r => r.request().method() === 'PUT' && r.url().endsWith('/investidor/profile'));
|
||||
await p.getByRole('button',{name:'Guardar perfil de investimento',exact:true}).click();
|
||||
const saved = p.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' &&
|
||||
r.url().endsWith('/investidor/profile'),
|
||||
);
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Guardar perfil de investimento',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const response = await saved;
|
||||
assert([200, 302, 303].includes(response.status()), `Guardar perfil: HTTP ${response.status()}`);
|
||||
await p.getByText('Perfil de investimento guardado.', {exact:true}).waitFor();
|
||||
await p.reload(); assert.equal(await p.locator('#residence_country_codeaccount').inputValue(),'PT');
|
||||
results.push({route:'/investidor/profile',action:'guardar residência',http:response.status(),visibleResult:true,recovered:true});
|
||||
assert(
|
||||
[200, 302, 303].includes(response.status()),
|
||||
`Guardar perfil: HTTP ${response.status()}`,
|
||||
);
|
||||
await p
|
||||
.getByText('Perfil de investimento guardado.', { exact: true })
|
||||
.waitFor();
|
||||
await p.reload();
|
||||
assert.equal(
|
||||
await p.locator('#residence_country_codeaccount').inputValue(),
|
||||
'PT',
|
||||
);
|
||||
results.push({
|
||||
route: '/investidor/profile',
|
||||
action: 'guardar residência',
|
||||
http: response.status(),
|
||||
visibleResult: true,
|
||||
recovered: true,
|
||||
});
|
||||
await desktop.context.close();
|
||||
const mobile = await login(390), m = mobile.page;
|
||||
await m.goto(base+'/investidor/profile'); await m.evaluate(()=>localStorage.clear()); await m.reload();
|
||||
assert.equal(await m.locator('#residence_country_codeaccount').inputValue(),'PT');
|
||||
const search = m.getByRole('textbox',{name:'Pesquisar país'}).first(); await search.fill('portugal');
|
||||
assert.equal(await m.locator('#residence_country_codeaccount option').count(),2);
|
||||
assert(await m.evaluate(()=>document.documentElement.scrollWidth <= innerWidth+1));
|
||||
await m.screenshot({path:'storage/app/private/investmoz-v2/functional-country-mobile.png',fullPage:true});
|
||||
results.push({route:'/investidor/profile',action:'nova sessão móvel e pesquisa',serverRecovery:true,localStorage:false});
|
||||
await m.goto(base+'/oportunidades?dimension=cluster');
|
||||
assert.equal(await m.getByText('ZEE',{exact:true}).count(),0);
|
||||
await m.getByRole('textbox',{name:'Pesquisar Província'}).fill('Maputo');
|
||||
await m.getByLabel('Província',{exact:true}).locator('option').nth(1).waitFor({state:'attached'});
|
||||
assert(await m.getByLabel('Província',{exact:true}).locator('option').count()>1);
|
||||
results.push({route:'/oportunidades?dimension=cluster',action:'filtros contextuais e pesquisa provincial',visible:true});
|
||||
await m.goto(base+'/oportunidades?view=map');
|
||||
await m.getByRole('region', {name:'Estruturas territoriais consultáveis'}).waitFor();
|
||||
assert(await m.getByRole('region', {name:'Estruturas territoriais consultáveis'}).getByRole('link').count() > 0);
|
||||
results.push({route:'/oportunidades?view=map',action:'consultar estruturas sem inventar coordenadas',visible:true});
|
||||
assert.deepEqual(errors,[]);
|
||||
const proof={captured_at:new Date().toISOString(),results,errors,active_writes:0,scope:'country profile and catalogue filters only; not full portal acceptance'};
|
||||
fs.writeFileSync('storage/app/private/investmoz-v2/functional-country-browser.json',JSON.stringify(proof,null,2));
|
||||
console.log(JSON.stringify(proof)); await browser.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
const mobile = await login(390),
|
||||
m = mobile.page;
|
||||
await m.goto(base + '/investidor/profile');
|
||||
await m.evaluate(() => localStorage.clear());
|
||||
await m.reload();
|
||||
assert.equal(
|
||||
await m.locator('#residence_country_codeaccount').inputValue(),
|
||||
'PT',
|
||||
);
|
||||
const search = m.getByRole('textbox', { name: 'Pesquisar país' }).first();
|
||||
await search.fill('portugal');
|
||||
assert.equal(
|
||||
await m.locator('#residence_country_codeaccount option').count(),
|
||||
2,
|
||||
);
|
||||
assert(
|
||||
await m.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||
),
|
||||
);
|
||||
await m.screenshot({
|
||||
path: 'storage/app/private/investmoz-v2/functional-country-mobile.png',
|
||||
fullPage: true,
|
||||
});
|
||||
results.push({
|
||||
route: '/investidor/profile',
|
||||
action: 'nova sessão móvel e pesquisa',
|
||||
serverRecovery: true,
|
||||
localStorage: false,
|
||||
});
|
||||
await m.goto(base + '/oportunidades?dimension=cluster');
|
||||
assert.equal(await m.getByText('ZEE', { exact: true }).count(), 0);
|
||||
await m
|
||||
.getByRole('textbox', { name: 'Pesquisar Província' })
|
||||
.fill('Maputo');
|
||||
await m
|
||||
.getByLabel('Província', { exact: true })
|
||||
.locator('option')
|
||||
.nth(1)
|
||||
.waitFor({ state: 'attached' });
|
||||
assert(
|
||||
(await m
|
||||
.getByLabel('Província', { exact: true })
|
||||
.locator('option')
|
||||
.count()) > 1,
|
||||
);
|
||||
results.push({
|
||||
route: '/oportunidades?dimension=cluster',
|
||||
action: 'filtros contextuais e pesquisa provincial',
|
||||
visible: true,
|
||||
});
|
||||
await m.goto(base + '/oportunidades?view=map');
|
||||
await m
|
||||
.getByRole('region', { name: 'Estruturas territoriais consultáveis' })
|
||||
.waitFor();
|
||||
assert(
|
||||
(await m
|
||||
.getByRole('region', {
|
||||
name: 'Estruturas territoriais consultáveis',
|
||||
})
|
||||
.getByRole('link')
|
||||
.count()) > 0,
|
||||
);
|
||||
results.push({
|
||||
route: '/oportunidades?view=map',
|
||||
action: 'consultar estruturas sem inventar coordenadas',
|
||||
visible: true,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
const proof = {
|
||||
captured_at: new Date().toISOString(),
|
||||
results,
|
||||
errors,
|
||||
active_writes: 0,
|
||||
scope: 'country profile and catalogue filters only; not full portal acceptance',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
'storage/app/private/investmoz-v2/functional-country-browser.json',
|
||||
JSON.stringify(proof, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify(proof));
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Authenticated requests to the guarded QA server; no UI acceptance or active writes."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
backup = json.loads((private.parent / 'opportunity-resources-backup-proof.json').read_text(encoding='utf-8-sig'))
|
||||
target = backup['restored_target']
|
||||
assert backup['restore_verified'] and re.fullmatch(r'desk_restore_\d{8}_\d{6}_test', target['database']) and int(target['port']) == 55441
|
||||
expected = target['database'] + ':55441'
|
||||
events = []
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def call(method, path, data=None, status=200, html=False):
|
||||
assert path.startswith('/') and not path.startswith('//')
|
||||
headers = {'Accept': 'text/html' if html else 'application/json', 'Content-Type': 'application/json'}
|
||||
for cookie in jar:
|
||||
if cookie.name == 'XSRF-TOKEN':
|
||||
headers['X-XSRF-TOKEN'] = urllib.parse.unquote(cookie.value)
|
||||
request = urllib.request.Request('http://127.0.0.1:18097' + path, headers=headers, data=None if data is None else json.dumps(data).encode(), method=method)
|
||||
try:
|
||||
response = opener.open(request)
|
||||
except urllib.error.HTTPError as e:
|
||||
response = e
|
||||
assert response.headers.get('X-Investmoz-QA-Target') == expected
|
||||
content = response.read()
|
||||
assert response.status == status, f'{method} {path}: expected {status}, observed {response.status}; response body withheld'
|
||||
events.append({'method': method, 'url': path, 'status': response.status, 'qa_target_verified': True})
|
||||
return None if html or not content else json.loads(content)
|
||||
|
||||
|
||||
call('GET', '/login', html=True)
|
||||
credentials = json.loads((private / 'i0-qa-actor-private.json').read_text(encoding='utf-8-sig'))
|
||||
login = call('POST', '/login', {k: credentials[k] for k in ['email', 'password']})
|
||||
if login.get('two_factor'):
|
||||
secret = base64.b32decode(credentials['totp_secret'])
|
||||
digest = hmac.new(secret, struct.pack('>Q', int(time.time()) // 30), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 15
|
||||
code = f'{(struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7fffffff) % 1000000:06d}'
|
||||
call('POST', '/two-factor-challenge', {'code': code}, status=204)
|
||||
plans = []
|
||||
results = []
|
||||
for level in [1, 2, 3]:
|
||||
plan = call('GET', f'/management/territory-geometry-import?level={level}')
|
||||
assert [plan['expected'], plan['resolved'], plan['pending']] == {1: [11, 9, 2], 2: [159, 120, 39], 3: [411, 232, 179]}[level]
|
||||
plans.append(plan)
|
||||
data = {'level': level, 'hash': plan['hash'], 'idempotency_key': str(uuid.uuid4()), 'reason': 'Reutilização autenticada do exemplar oficial existente em QA', 'confirm': True}
|
||||
result = call('POST', '/management/territory-geometry-import', data)
|
||||
assert result['inserted'] == 0 and result['reused'] == plan['resolved'] and not result['published']
|
||||
assert call('POST', '/management/territory-geometry-import', data) == result
|
||||
reverse = {'idempotency_key': str(uuid.uuid4()), 'reason': 'Reversão do lote sem remover geometrias anteriormente existentes', 'confirm': True}
|
||||
r = call('POST', '/management/territory-geometry-import/' + result['batch_id'] + '/reverse', reverse)
|
||||
assert r['reversed'] == 0 and r['reused_preserved'] == plan['resolved']
|
||||
results.append({'apply': result, 'reverse': r})
|
||||
call('GET', '/management/territory-geometry-import?level=1', html=True)
|
||||
call('POST', '/logout', status=204)
|
||||
jar.clear()
|
||||
call('GET', '/login', html=True)
|
||||
call('POST', '/login', {'email': 'investor-path-outsider-qa@example.invalid', 'password': 'Outsider-Path-QA-2026!'})
|
||||
call('GET', '/management/territory-geometry-import?level=1', status=403)
|
||||
proof = {'captured_at': datetime.now(timezone.utc).isoformat(), 'target': target, 'mfa_login_completed': bool(login.get('two_factor')), 'plans': plans, 'results': results, 'requests': events, 'api_persistence_passed': True, 'interface_observed': False, 'active_writes': 0, 'published': False}
|
||||
destination = private / 'geometry-auth-http-qa.json'
|
||||
if destination.exists():
|
||||
destination.rename(private / ('geometry-auth-http-qa-' + datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + '.json'))
|
||||
destination.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'target': target, 'mfa_login_completed': proof['mfa_login_completed'], 'levels': [{'level': p['level'], 'expected': p['expected'], 'resolved': p['resolved'], 'pending': p['pending']} for p in plans], 'inserted': 0, 'reused': 361, 'reversed': 0, 'published': False, 'interface_observed': False, 'active_writes': 0}))
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
// Synthetic actor for authenticated cartography, on the existing isolated restore only.
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once $root.'/tools/portal-qa-environment.php';
|
||||
$directory = $root.'/storage/app/private/investmoz-v2/portal-audit';
|
||||
$backup = json_decode(file_get_contents(dirname($directory).'/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $backup['restored_target'];
|
||||
if (! $backup['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Verified QA restore only.');
|
||||
}
|
||||
$target = portalQaEnvironment();
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = portalQaAssertTarget($target);
|
||||
$file = $directory.'/i0-qa-actor-private.json';
|
||||
$credentials = is_file($file) ? json_decode(file_get_contents($file), true) : ['email' => 'geo-manager-qa@example.invalid', 'password' => 'QA-'.Str::uuid(), 'totp_secret' => 'JBSWY3DPEHPK3PXP'];
|
||||
$actor = User::firstOrCreate(['email' => $credentials['email']], ['name' => 'Synthetic cartography manager QA', 'password' => Hash::make($credentials['password'])]);
|
||||
$actor->forceFill(['email_verified_at' => now(), 'two_factor_confirmed_at' => now(), 'two_factor_secret' => Crypt::encrypt($credentials['totp_secret']), 'two_factor_recovery_codes' => Crypt::encrypt('[]')])->save();
|
||||
file_put_contents($file, json_encode($credentials, JSON_THROW_ON_ERROR));
|
||||
chmod($file, 0600);
|
||||
echo json_encode(['qa_target' => $identity, 'actor_id' => $actor->id, 'mfa_configured' => true, 'credentials' => 'retained privately, not exported', 'active_writes' => 0]).PHP_EOL;
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Supplement the existing audit documents and a capability map, without a second audit matrix."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
docs = root / 'docs/investmoz/v2/auditoria-portal'
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
source = json.loads((private / 'assistant-source-full.json').read_text(encoding='utf-8'))
|
||||
runtime = json.loads((private / 'investor-consolidation-runtime.json').read_text(encoding='utf-8'))
|
||||
proposal = docs / 'proposta-consolidacao-percurso.md'
|
||||
text = proposal.read_text(encoding='utf-8').replace('Emóvel', 'Em móvel')
|
||||
for number in range(22, 41):
|
||||
text = text.replace('§' + str(number) + ' /', 'P' + str(number) + ' /')
|
||||
proposal.write_text(text, encoding='utf-8')
|
||||
paragraphs = {p['paragraph']: p['text'] for p in source['parts'][0]['paragraphs']}
|
||||
stages = []
|
||||
for line in text.splitlines():
|
||||
if not line.startswith('|'):
|
||||
continue
|
||||
cells = [x.strip() for x in line.strip().strip('|').split('|')]
|
||||
if len(cells) != 5:
|
||||
continue
|
||||
code = re.search(r'\bS(\d{2})\b', cells[0])
|
||||
paragraph = re.search(r'P(\d+)\s*/', cells[1])
|
||||
if not code or not paragraph:
|
||||
continue
|
||||
number = int(code.group(1))
|
||||
stages.append({'stage_id': 'S' + code.group(1), 'phase': 1 if number <= 4 else 2 if number <= 7 else 3 if number <= 11 else 4 if number <= 15 else 5,
|
||||
'source': {'sha256': source['sha256'], 'part': 'word/document.xml', 'paragraph': int(paragraph.group(1)), 'text': paragraphs[int(paragraph.group(1))]},
|
||||
'diagram_mapping': cells[1].split('/', 1)[1].strip(), 'capabilities': cells[2], 'applicability_dependencies_result': cells[3],
|
||||
'audit_requirement_refs': cells[4], 'proposed': True, 'runtime_definition_prepared': True, 'integrated_ui_demonstrated': False})
|
||||
assert [s['stage_id'] for s in stages] == ['S%02d' % i for i in range(1, 20)]
|
||||
assert [s['source']['paragraph'] for s in stages] == list(range(22, 41))
|
||||
mapping = {'kind': 'capability_map_not_a_parallel_requirement_matrix', 'source_sha256': source['sha256'], 'runtime_read_only_capture': runtime['captured_at'],
|
||||
'stage_count': 19, 'phase_count': 5, 'structural_implementation_approved': True, 'active_writes': 0, 'stages': stages,
|
||||
'qa_scenarios': {'A': {'land_needed': False, 'external_funding_needed': False, 'status': 'api_persistence_demonstrated_integrated_ui_pending'},
|
||||
'B': {'land_needed': True, 'external_funding_needed': True, 'status': 'api_persistence_demonstrated_integrated_ui_pending'}}}
|
||||
(docs / 'proposta-percurso-19-etapas.json').write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
|
||||
updates = {
|
||||
'relatorio-executivo.md': '''## Consolidação do percurso — aprovação QA e execução parcial
|
||||
|
||||
O estado atual inclui a [proposta fundamentada](proposta-consolidacao-percurso.md), baseada na leitura integral do DOCX solicitado e do seu fluxograma: cinco fases e 19 etapas, vinculadas aos IDs da mesma matriz. O texto tem 19 etapas; a imagem tem 17 blocos numerados e decisões separadas. Os roteiros E0–E11, sete grupos de orientação e outro roteiro com 14 atividades permanecem identificados por versão.
|
||||
|
||||
PostgreSQL/PostGIS e CRM externo desativado foram confirmados por leitura efetiva. O complemento não reinicia inventário: acrescenta um original e um anexo, ficando 525 ficheiros + 61 anexos = 586 registos. A matriz conserva 3.354 requisitos/linhas e 5.830 linhas de origem; estados não mudam por proposta. Os 381 testes/5.046 asserções do lote 3 abaixo são históricos desse lote; a nova regressão fica em consolidation-regression.log/XML.
|
||||
|
||||
C24: corrigido o encaminhamento do dossier para o draft específico do simulador, sem partilha de dados pessoais ou permissão do gestor. Regressão red→green: 16 testes de orientação/285 asserções; UI pendente. Execução I1–I3 aprovada em QA. Código/modelo/motor preparados e API/persistência A/B demonstradas; aceitação na interface ainda pendente. A extensão foi instalada apenas na cópia QA restaurada, sem conversão de roteiros anteriores. Ver execucao-percurso-i1-i3.md; a aprovação não autoriza a base ativa. Nenhuma escrita ativa.
|
||||
|
||||
''',
|
||||
'backlog.md': '''## Prioridades organizadas em torno do percurso do investidor
|
||||
|
||||
Consultar a [proposta de consolidação](proposta-consolidacao-percurso.md) e a correspondência das 19 etapas aos IDs da matriz. Execução QA aprovada; código e API/persistência preparados, aceitação visual pendente.
|
||||
|
||||
1. **I0/P0–P1, já autorizado:** falhas de ações/recuperação, países/origem por formulário, cartografia por consumidor e contexto correto; C24 corrigido em QA, visual pendente. Nenhuma dependência de aprovação da consolidação para estes reparos.
|
||||
2. **I1, QA autorizada e parcial:** painel composto com projeto atual, etapas, pendências/documentos/resultados e próxima ação. Preservar URLs e funções da gestão. Aceitação: UI completa e autorização por origem.
|
||||
3. **I2, QA autorizada e parcial:** dados mestres e prefill consentido, resultados/contexto por ID/versão, nova sessão e alternância A/B sem contaminação. Aceitação inclui conflitos e ausência de sobrescrita; C25 permite rascunhos independentes por dossier; API/persistência demonstradas, interface pendente.
|
||||
4. **I3, QA autorizada e parcial:** definição configurável/versionada das 19 etapas, aplicabilidade triestado, dependências por ação e estados separados. Reutilizar QuestionnaireVersion, investor_journeys/journey_steps, tasks e eventos. A/B completos na interface são condição de fecho; migration nova executada exclusivamente na cópia QA; nenhuma migration ativa.
|
||||
5. **I4:** licenças/terra/financiamento/execução e monitoria conforme evidências e âmbito, sem integrações externas obrigatórias. Workflow e contratos especializados mantidos. Educação permanece depois das falhas prioritárias, sem nova jornada iniciada.
|
||||
|
||||
Ativação de recursos/cartografia continua separada; não inclui automaticamente o futuro motor. Backup/compatibilidade/rollback deverão ser renovados após alterações estruturais. As quatro decisões humanas anteriores e a aceitação oficial permanecem preservadas.
|
||||
|
||||
''',
|
||||
'plano-ativacao.md': '''## Limite do plano face à consolidação proposta
|
||||
|
||||
Este plano cobre os recursos e importação identificados na auditoria; **não autoriza a ativação do painel/modelo/motor das 19 etapas na base ativa**. A [proposta](proposta-consolidacao-percurso.md) recebeu aprovação expressa de I1–I3 somente em QA. Código/API/persistência e interface devem manter estados separados. I0, correções existentes, continua autorizado.
|
||||
|
||||
A alteração pontual C24 reutiliza uma URL de draft já existente e não instala schema. Cenários A/B não foram demonstrados. Depois de qualquer mudança estrutural aprovada, preparar preview de migrations/contagens/órfãos, preservar snapshots e renovar backup/restauro/compatibilidade antes do ato autenticado. Reversão pós-uso nunca apaga decisões, vínculos, consentimentos, documentos ou auditoria.
|
||||
|
||||
'''
|
||||
}
|
||||
for name, addition in updates.items():
|
||||
file = docs / name
|
||||
content = file.read_text(encoding='utf-8')
|
||||
marker = '## Consolidação do percurso — aprovação QA e execução parcial' if name == 'relatorio-executivo.md' else '## Prioridades organizadas em torno do percurso do investidor' if name == 'backlog.md' else '## Limite do plano face à consolidação proposta'
|
||||
if marker not in content:
|
||||
title, rest = content.split('\n', 1)
|
||||
file.write_text(title + '\n\n' + addition + rest.lstrip(), encoding='utf-8')
|
||||
|
||||
case = '''
|
||||
## C24 — orientação abre o simulador sem identificar o draft ligado — P1
|
||||
|
||||
Passos anteriores: criar dossier ligado a um draft do dono; consultar orientação; abrir ação financeira/plano quando a conta tem outro draft corrente. Esperado: recuperar exatamente a origem associada, preservando dono/versão. Observado na reprodução de servidor: href genérico /simulador-fiscal, apesar de existir simulation_id autorizado; destino pode escolher outro draft corrente. Causa: InvestorGuidance::view descartava o identificador na ação; SimulationForm já suporta ?draft=ID e API por ID.
|
||||
|
||||
Correção pontual: actions.simulator, actions.plan e recomendação financeira usam ?draft=ID somente se context confirmou disponibilidade/autorização; gestor permanece sem leitura do draft privado. QA: teste primeiro falhou no href genérico; depois 16 testes de orientação/285 asserções passaram, incluindo duas origens do mesmo dono, recuperação específica e gestor 404. Provas: consolidation-context-red.log, consolidation-context-green.log/XML. Link preparado e API/persistência demonstrados; clique/resultado visual pendentes. Nenhuma mudança de navegação/modelo/motor estrutural ou escrita ativa.
|
||||
'''
|
||||
file = docs / 'casos-reproducao.md'
|
||||
if '## C24 ' not in file.read_text(encoding='utf-8'):
|
||||
file.write_text(file.read_text(encoding='utf-8') + case, encoding='utf-8')
|
||||
notice = '''
|
||||
## Execução I1–I3 autorizada em QA
|
||||
|
||||
Foi lido integralmente o documento de assistente por etapas. A [proposta](auditoria-portal/proposta-consolidacao-percurso.md) organiza cinco fases/19 etapas e cenários A/B, com dados mestres, estados/permissões, reutilização, compatibilidade e reversão. I1–I3 têm execução QA aprovada; modelo/motor/rotas preparados atrás de flag, API/persistência A/B demonstradas e aceitação visual pendente. Nenhuma escrita ativa. Correções anteriores de países/cartografia permanecem em I0. C24 corrige somente o link para o draft associado, demonstrado em API/QA e ainda sem clique visual. A proposta complementa a mesma matriz, não a substitui.
|
||||
'''
|
||||
for name in ['gestao-oportunidades-dashboard.md', 'backlog-mestre.md']:
|
||||
file = docs.parent / name
|
||||
if '## Execução I1–I3 autorizada em QA' not in file.read_text(encoding='utf-8'):
|
||||
file.write_text(file.read_text(encoding='utf-8') + notice, encoding='utf-8')
|
||||
print(json.dumps({'stages': len(stages), 'phases': 5, 'parallel_matrix': False, 'structural_changes_qa': True, 'qa_scenarios_demonstrated': False}))
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
// Read-only complement to the existing audit; never boot mutation services or export credentials/payloads.
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
||||
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
(new LoadEnvironmentVariables)->bootstrap($app);
|
||||
(new LoadConfiguration)->bootstrap($app);
|
||||
$config = $app->make('config');
|
||||
$connection = $config->get('database.connections.'.$config->get('database.default'));
|
||||
if (($connection['driver'] ?? '') !== 'pgsql' || ! in_array($connection['url'] ?? null, [null, ''], true)) {
|
||||
throw new RuntimeException('Explicit PostgreSQL connection required for read-only evidence.');
|
||||
}
|
||||
$pdo = new PDO('pgsql:host='.$connection['host'].';port='.$connection['port'].';dbname='.$connection['database'], $connection['username'], $connection['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$pdo->exec('BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
try {
|
||||
$identity = $pdo->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only,version() AS version")->fetch(PDO::FETCH_ASSOC);
|
||||
if ($identity['read_only'] !== 'on') {
|
||||
throw new RuntimeException('Read-only transaction required.');
|
||||
}
|
||||
$result = ['captured_at' => gmdate('c'), 'identity' => $identity, 'postgis_version' => $pdo->query('SELECT postgis_full_version()')->fetchColumn(),
|
||||
'php_version' => PHP_VERSION, 'laravel_version' => Application::VERSION,
|
||||
'configuration' => ['suitecrm_enabled' => (bool) $config->get('suitecrm.enabled'), 'ai_enabled' => (bool) $config->get('investmoz_ai.enabled'),
|
||||
'notifications_enabled' => (bool) $config->get('portal.notifications_enabled'), 'queue_default' => $config->get('queue.default'),
|
||||
'session_driver' => $config->get('session.driver'), 'cache_default' => $config->get('cache.default'), 'business_repository' => $config->get('mne.repository')],
|
||||
'tables' => [], 'active_writes' => 0];
|
||||
$tables = ['users', 'investors', 'investor_accounts', 'investment_accounts', 'contacts', 'consents', 'organizations', 'memberships',
|
||||
'projects', 'investment_dossiers', 'investor_journeys', 'journey_steps', 'submissions', 'desk_cases', 'desk_requests', 'desk_service_versions', 'desk_events', 'desk_documents',
|
||||
'tasks', 'leads', 'interest_requests', 'investment_submissions', 'investment_company_declarations', 'existing_businesses', 'land_assistance_cases',
|
||||
'simulation_form_drafts', 'simulation_draft_business_links', 'simulations', 'simulation_results', 'documents', 'questionnaire_versions', 'reference_catalog_versions', 'investment_legal_sources', 'portal_resources',
|
||||
'aftercare_plans', 'constraints', 'escalations', 'geo_locations', 'geo_geometries', 'audit_events'];
|
||||
foreach ($tables as $table) {
|
||||
$q = $pdo->prepare("SELECT column_name,data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema='public' AND table_name=? ORDER BY ordinal_position");
|
||||
$q->execute([$table]);
|
||||
$columns = $q->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($columns === []) {
|
||||
$result['tables'][$table] = ['exists' => false];
|
||||
|
||||
continue;
|
||||
}
|
||||
$q = $pdo->prepare('SELECT conname,contype,pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid=to_regclass(?) ORDER BY conname');
|
||||
$q->execute(['public.'.$table]);
|
||||
$constraints = $q->fetchAll(PDO::FETCH_ASSOC);
|
||||
$q = $pdo->prepare("SELECT indexname,indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=? ORDER BY indexname");
|
||||
$q->execute([$table]);
|
||||
$result['tables'][$table] = ['exists' => true, 'rows' => (int) $pdo->query('SELECT count(*) FROM public."'.$table.'"')->fetchColumn(),
|
||||
'columns' => $columns, 'constraints' => $constraints, 'indexes' => $q->fetchAll(PDO::FETCH_ASSOC)];
|
||||
}
|
||||
} finally {
|
||||
$pdo->exec('ROLLBACK');
|
||||
}
|
||||
$dir = dirname(__DIR__).'/storage/app/private/investmoz-v2/portal-audit';
|
||||
$file = $dir.'/investor-consolidation-runtime.json';
|
||||
if (is_file($file)) {
|
||||
copy($file, $dir.'/investor-consolidation-runtime-'.gmdate('Ymd_His').'-'.bin2hex(random_bytes(3)).'.json');
|
||||
}
|
||||
file_put_contents($file, json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['captured_at' => $result['captured_at'], 'identity' => $identity, 'configuration' => $result['configuration'],
|
||||
'table_counts' => array_map(fn ($row) => $row['rows'] ?? null, $result['tables']), 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,257 @@
|
||||
// Supplement existing requirement rows, without adding identities or changing acceptance states.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
module.exports = function supplement(rows, inventory) {
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const priv = path.join(
|
||||
root,
|
||||
'storage/app/private/investmoz-v2/portal-audit',
|
||||
);
|
||||
const sourceFile = path.join(priv, 'assistant-source-full.json');
|
||||
if (!fs.existsSync(sourceFile)) return;
|
||||
const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
|
||||
const runtime = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(priv, 'investor-consolidation-runtime.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const original = fs.readFileSync(source.source_path);
|
||||
const digest = (bytes) =>
|
||||
crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
if (digest(original) !== source.sha256)
|
||||
throw new Error(
|
||||
'Original changed: reconcile the document version before reuse.',
|
||||
);
|
||||
if (!inventory.files.some((file) => file.path === source.source_path)) {
|
||||
const file = path.join(priv, 'inventario-fontes.json');
|
||||
const archive = path.join(
|
||||
priv,
|
||||
'inventario-fontes-before-consolidation.json',
|
||||
);
|
||||
if (!fs.existsSync(archive)) fs.copyFileSync(file, archive);
|
||||
const image = fs.readFileSync(
|
||||
path.join(priv, 'assistant-source-image1.png'),
|
||||
);
|
||||
inventory.files.push({
|
||||
path: source.source_path,
|
||||
bytes: original.length,
|
||||
sha256: source.sha256,
|
||||
attachments: [
|
||||
{
|
||||
path: 'word/media/image1.png',
|
||||
bytes: image.length,
|
||||
sha256: digest(image),
|
||||
},
|
||||
],
|
||||
consultation:
|
||||
'Exemplar integral lido para proposta; publicação não inferida',
|
||||
rule_use:
|
||||
'Especificação funcional; nenhum cálculo ou regra jurídica ativados',
|
||||
document_status:
|
||||
'Documento de programação do responsável, não diploma oficial',
|
||||
source_version:
|
||||
'Exemplar identificado por SHA256; data/edição interna não identificada',
|
||||
});
|
||||
inventory.consolidation_source_added = {
|
||||
sha256: source.sha256,
|
||||
original_count: 1,
|
||||
embedded_count: 1,
|
||||
inventory_restart: false,
|
||||
};
|
||||
fs.writeFileSync(file, JSON.stringify(inventory, null, 2));
|
||||
}
|
||||
const anchors = {
|
||||
'AUD-DASH-01':
|
||||
'Painel atual não oferece ainda a composição das19 etapas; DashboardController devolve summary=null ao investidor. Proposta de cinco fases/painel no documento de consolidação; não implementada nem aceita.',
|
||||
'AUD-DESK-02':
|
||||
'desk_cases já liga investor/project/journey/submission/simulation; ficha editorial não equivale a dossier privado. Prefill completo, versão mestre/resultado e seleção multi-projetos ainda parciais.',
|
||||
'AUD-GUIDE-01':
|
||||
'Texto tem19 etapas; imagem17 blocos mais decisões; Balcão E0–E11, orientação7 grupos e outro roteiro14 atividades são versões diferentes. Proposta conserva mapeamentos e distingue preparação/submissão/apreciação interna/ato externo.',
|
||||
'AUD-SIM-01':
|
||||
'C24: orientação abriu URL genérica sem o draft ligado. Corrigido plano/simulador/recomendação por ?draft=ID autorizado; teste red→green,16 testes/285 asserções em QA. API recupera draft correto e gestor não ganha acesso; clique não observado. Serviço de criação ainda limita draft corrente por conta; multi-projetos depende de mudança aprovada.',
|
||||
'AUD-SIM-02':
|
||||
'Snapshot financeiro atual guarda entradas; resultado local recalculado não é resultado imutável no servidor. Reutilizar formulas, simulation_results e motor fiscal com seus âmbitos; sem score/aprovação automática.',
|
||||
'AUD-CRM-01':
|
||||
'CRM interno/tasks reutilizáveis; SuiteCRM externo desativado na configuração efetiva. Fonte própria/captação/consentimento e resposta externa separados. Cenários A/B preparados, não demonstrados na interface.',
|
||||
'AUD-LAND-01':
|
||||
'S09–S11 propostos usam assistência fundiária e evidências atuais. Ausência declarada de terra dispensa preparação do ramo, não inventa dispensa legal. Vínculo ao contexto e ato jurídico ainda incompletos.',
|
||||
'AUD-PART-01':
|
||||
'S12–S15 propostos reutilizam parceiros/sala/marcos/documentos; aprovação, condições e desembolso são atos distintos. Não criar financiamento paralelo ou integração bancária fictícia.',
|
||||
'AUD-LIB-01':
|
||||
'Acrescentado somente o DOCX solicitado e seu fluxograma à inventariação existente; leitura integral e SHA256. Consulta/estado documental/cálculo independentes; aceitação oficial preservada.',
|
||||
'AUD-GEO-02':
|
||||
'Uso transversal no percurso S01/S02/S10 proposto; catálogo, geometria, acesso, perímetro e apresentação separados. Não há aceitação visual integrada A/B.',
|
||||
'AUD-GEO-03':
|
||||
'361 limites internos QA continuam separados de zero geometria ativa. Demonstração integrada B requer camada autorizada na QA; fonte2019 não prova DUAT/disponibilidade.',
|
||||
'AUD-UI-01':
|
||||
'S01–S19 e cenários A/B requerem criação→nova sessão→adaptação→ferramenta→resultado→Balcão→tarefa paralela na interface. Cenários preparados, não aceites por testes unitários.',
|
||||
'AUD-DOC-01':
|
||||
'Proposta de consolidação fundamentada no DOCX integral (459 parágrafos com texto+fluxograma); cinco fases/19 etapas com fontes/módulos/IDs. Navegação/modelo/motor aguardam aprovação explícita conforme fonte §16/P326 e §19.1/P497. Mesma matriz, sem reiniciar inventário.',
|
||||
};
|
||||
const executionFile = path.join(priv, 'investor-path-evidence.json');
|
||||
if (fs.existsSync(executionFile)) {
|
||||
const execution = JSON.parse(fs.readFileSync(executionFile, 'utf8'));
|
||||
const qaRuntimeFile = path.join(priv, 'investor-path-qa-runtime.json');
|
||||
const qaCurrent = fs.existsSync(qaRuntimeFile) ? JSON.parse(fs.readFileSync(qaRuntimeFile, 'utf8')) : execution;
|
||||
const historical = execution.previous_full_suite ?? execution.full_suite;
|
||||
const proof = `Execução QA aprovada I1–I3. Revisão QA atual: ${qaCurrent.code_hash}, com provas dirigidas/HTTP identificadas abaixo. Regressão histórica: ${historical?.tests ?? 'pendentes'} ensaios globais, hash ${historical?.code_hash ?? 'da execução inicial preservada'}; path-focused.xml (32/541) e path-followup.xml (26/551) são anteriores. Cenários A/B API/persistência, UI pendente; execucao-percurso-i1-i3.md.`;
|
||||
anchors['AUD-DASH-01'] =
|
||||
'Painel composto e /investimentos preparados atrás de flag desativada no ativo; projeto recente, pendências, documentos, resultados/tarefas e próxima ação. Gestão preservada. ' +
|
||||
proof;
|
||||
anchors['AUD-SIM-01'] =
|
||||
'C25: criação/recuperação por dossier, bloqueio de contaminação de contexto, armazenamento local por contexto/draft e acesso privado independente; dois investimentos da mesma conta em QA. Rascunho anónimo e URLs diretas preservados. ' +
|
||||
proof;
|
||||
anchors['AUD-DESK-02'] =
|
||||
'Entidades existentes reutilizadas; dados do simulador por ID/versão e descrição prefills no Balcão com evento e hash. Resultados financeiros privados com entradas congeladas, recuperados pelas fórmulas existentes. Prefill de todos os campos/consumidores ainda incompleto. ' +
|
||||
proof;
|
||||
anchors['AUD-GUIDE-01'] =
|
||||
'Definição imutável v1/19 etapas/5 fases, pin na instância, triestado e dependências por ação; fases de outros roteiros preservadas. Informação/legalidade externa desconhecida não vira dispensa nem score. ' +
|
||||
proof;
|
||||
anchors['AUD-SIM-02'] =
|
||||
'C26: reversão recusa segunda definição publicada antes de alterar RLS. Resultados locais reutilizam fórmulas existentes com snapshot/versionamento; não são simulação fiscal nem score/decisão externa. ' +
|
||||
proof;
|
||||
anchors['AUD-CRM-01'] =
|
||||
'Tarefas existentes aceites, atribuídas e concluídas por responsável distinto; tarefa independente avança enquanto terra aguarda. Nenhuma transmissão externa. ' +
|
||||
proof;
|
||||
anchors['AUD-GEO-03'] =
|
||||
'361 limites oficiais privados importados também em desk_restore_20261009_174726_test (9/120/232), repetição zero novos. API/cadastro não equivalem a mapa público/DUAT. Zero geometria ativa; visual por consumidor pendente.';
|
||||
anchors['AUD-UI-01'] =
|
||||
'Interface integrada preparada, props Inertia e build verificados; nenhum provider de navegador disponível. Criação e nova sessão A/B na interface não demonstradas. ' +
|
||||
proof;
|
||||
anchors['AUD-DOC-01'] =
|
||||
'Aprovação expressa da execução QA recebida; fontes/IDs e versões históricas preservados, sem fecho automático de requisitos. ' +
|
||||
proof;
|
||||
}
|
||||
const reference = `Proposta consolidação 09/10/2026; DOCX ${source.sha256}; texto P22–P40, estados P76–P88, aprovação P326/P497; proposta-consolidacao-percurso.md`;
|
||||
for (const [id, addition] of Object.entries(anchors)) {
|
||||
const row = rows.find((entry) => entry.id === id);
|
||||
if (!row) throw new Error(`Missing existing requirement ${id}`);
|
||||
row.fontes.push(reference);
|
||||
row.evidencia += ` | Complemento de leitura: investor-consolidation-runtime.json (${runtime.captured_at}, READ ONLY), assistant-source-full.json; ${addition}`;
|
||||
if (fs.existsSync(executionFile) && ['AUD-DASH-01', 'AUD-DESK-02', 'AUD-SIM-01', 'AUD-SIM-02', 'AUD-GUIDE-01', 'AUD-CRM-01'].includes(id)) {
|
||||
row.percurso ??= { interface: '', api: '', gravacao: '', recuperacao: '', resultado: '' };
|
||||
row.fontes.push('Aprovação expressa da execução I1–I3 exclusivamente QA; execução 09/10/2026, execucao-percurso-i1-i3.md, C25/C26.');
|
||||
row.implementacao += ' | InvestorPath/InvestorPathDefinition, InvestorPathController, /investimentos e painel; entidades existentes, flag e definição versionada. Sem conversão dos roteiros antigos.';
|
||||
const qaRuntimeFile = path.join(priv, 'investor-path-qa-runtime.json');
|
||||
const qaExecution = JSON.parse(fs.readFileSync(fs.existsSync(qaRuntimeFile) ? qaRuntimeFile : executionFile, 'utf8'));
|
||||
row.ativo += ` | Runtime ativo READ ONLY 19:03:32UTC: extensão do percurso ausente, journey_steps=0; QA restaurada tem${qaExecution.qa_path_journeys} instâncias/${qaExecution.qa_path_steps} etapas. Nenhuma ativação ativa autorizada.`;
|
||||
row.percurso.interface += ' | Interface integrada preparada /investimentos; props Inertia/build não demonstram clique/renderização A/B.';
|
||||
row.percurso.api += ' | POST /investimentos, aplicabilidade/etapas/resultados/tarefas e drafts por context; GET por ID/conta; permissões e versões ensaiadas.';
|
||||
row.percurso.gravacao += ' | QA loopback55441: dossier/instância/19 etapas, payloads cifrados, resultados versionados, tasks/eventos; sem escrita ativa.';
|
||||
row.percurso.recuperacao += ' | A/B mesma conta e sessão HTTP limpa; GET recupera contexto próprio. Sessão nova no navegador ainda pendente.';
|
||||
row.percurso.resultado += ' | Evidência congelada recuperada, 20 unidades/70.000,00 pelas fórmulas existentes em teste frontend; decisão externa nunca inferida. Apresentação visual pendente.';
|
||||
}
|
||||
row.lacuna +=
|
||||
' | Execução estrutural QA aprovada e preparada; instalação ativa, cenários A/B na interface e cobertura de todos os consumidores permanecem pendentes.';
|
||||
row.dependencia +=
|
||||
' | I1–I3 autorizados exclusivamente em QA: concluir demonstração A/B na interface, cobertura de contexto/prefill e provas da versão. Ativação ativa não autorizada. I0 continua independente.';
|
||||
row.aceitacao +=
|
||||
' | Para consolidação: critérios individuais S01–S19 e cenários A/B na proposta, com UI→API→gravação→nova sessão→resultado e ação independente.';
|
||||
}
|
||||
const simulator = rows.find((entry) => entry.id === 'AUD-SIM-01');
|
||||
simulator.percurso.api +=
|
||||
' | C24: orientação fornece draft ID autorizado; recuperação por ID e conta indevida negada.';
|
||||
simulator.percurso.recuperacao +=
|
||||
' | Dois drafts sintéticos do dono não mudam o draft associado; GET conserva a ideia ligada.';
|
||||
simulator.percurso.resultado +=
|
||||
' | Link preparado para o draft correto; clique/renderização pendentes.';
|
||||
const httpFile = path.join(priv, 'investor-path-http-qa.json');
|
||||
if (fs.existsSync(httpFile)) {
|
||||
const http = JSON.parse(fs.readFileSync(httpFile, 'utf8'));
|
||||
if (!http.api_persistence_passed || http.integrated_ui_ab_passed || http.active_writes !== 0)
|
||||
throw new Error('HTTP proof must not imply visual or active acceptance.');
|
||||
for (const id of ['AUD-SIM-01', 'AUD-DESK-02', 'AUD-GUIDE-01', 'AUD-CRM-01', 'AUD-DASH-01', 'AUD-UI-01']) {
|
||||
const row = rows.find(entry => entry.id === id);
|
||||
row.evidencia += ` | Complemento ${http.captured_at}: investor-path-http-qa.json, login/logout e novo cookie jar autenticado; A/B próprios, pedidos/tarefas recuperados; troca de contexto403, outra conta403/draft404. Roteiro manual: roteiro-manual-qa-i0-i3.md. Sem UI observada.`;
|
||||
if (row.percurso) row.percurso.recuperacao += ' | Recuperação no servidor QA real após logout401 e login com sessão/cookies novos, não apenas actingAs/flushSession. A: necessidade0; B:70000; ambos20 unidades previstas pelas fórmulas existentes, desenho visual pendente.';
|
||||
}
|
||||
for (const id of ['AUD-COUNTRY-01', 'AUD-COUNTRY-02', 'AUD-COUNTRY-03', 'AUD-COUNTRY-04', 'AUD-COUNTRY-06', 'AUD-COUNTRY-07', 'AUD-STATS-01']) {
|
||||
const row = rows.find(entry => entry.id === id);
|
||||
row.evidencia += ' | continuation-consumer-qa.xml: provas individuais país/origem/filtro/estatística, incluindo quatro tipos de apoio e três serviços Balcão após correção; continuation-profile-qa.xml: perfil/empresa/submissão/CRM; continuation-business-journey-qa.xml: formulário de interesse em negócio público PT/ZA/recibo/replay/GET/filtro/estatística e jornada PT=1/MZ=0 sem inferir país. Ver cobertura-percursos-i0-i3.md, sem promover API/props a UI.';
|
||||
}
|
||||
for (const id of ['AUD-GEO-02', 'AUD-GEO-03']) {
|
||||
const row = rows.find(entry => entry.id === id);
|
||||
row.evidencia += ' | geometry-consumers-path-qa.json:361/361 na mesma QA A/B, leitura privada/pública; HTTP real gestor/conta comum para província/distrito/posto. C27: ficha de oportunidade usa território principal explícito; Bolsa usa filtro aplicado. TypeScript/build preparados; visual por consumidor pendente.';
|
||||
row.lacuna += ' | C27 preparado, sem fecho de renderização. Cartografia pública não divulgada; instalação/importação ativa fora da autorização.';
|
||||
}
|
||||
const activation = rows.find(entry => entry.id === 'AUD-OPS-01');
|
||||
activation.evidencia += ' | C28: preparador sem contagem381 fixa; provas/autenticação/hashes/compatibilidade conservados. Preview HTTP da QA: pathInstalled=true, pathQaVerified=false; fingerprint inclui consumidores C27. Backup final após UI ainda não renovado; nenhuma instalação ativa.';
|
||||
activation.lacuna = 'Recursos030000 e percurso190000 não instalados no ativo; configuração de disponibilização separada. C27 mudou fingerprints: backup/restauro/compatibilidade finais ainda não renovados, aguardando demonstração visual. Ready histórico não certifica versão atual. Cartografia necessita mecanismo autenticado, importadorQA não pode ser apontado ao ativo. Não repetir instalações básicas.';
|
||||
activation.dependencia = 'A/B e consumidores na interface → backup/restauro/compatibilidade da revisão final → preview e ações autenticadas de instalações ausentes → flag separada; cartografia privada/divulgação separadas. Ativo fora da autorização atual.';
|
||||
rows.find(entry => entry.id === 'AUD-COUNTRY-04').lacuna = 'API/persistência/filtros/estatísticas por formulário demonstrados nas provas identificadas; visual e variações condicionais restantes pendentes. Negócios/fundiário têm provas próprias em AUD-COUNTRY-06/07, não são encerrados por origem pública.';
|
||||
rows.find(entry => entry.id === 'AUD-SIM-02').evidencia += ' | investor-path-recovered-calculations.json: cálculo pela função efetiva do dossier a partir dos snapshots realmente recuperados na nova sessão HTTP; A20unidades/0.00, B20/70000.00. C29: fixture HTTP inicial tinha orçamento vazio incompatível; criada revisão válida, resultados anteriores preservados e incompatibilidade não vira zero. Interface pendente.';
|
||||
}
|
||||
const geometryFile = path.join(priv, 'geometry-auth-http-qa.json');
|
||||
if (fs.existsSync(geometryFile)) {
|
||||
const geometry = JSON.parse(fs.readFileSync(geometryFile, 'utf8'));
|
||||
if (!geometry.api_persistence_passed || geometry.interface_observed || geometry.active_writes !== 0 || geometry.published)
|
||||
throw new Error('Cartography evidence cannot imply visual acceptance or active publication.');
|
||||
for (const id of ['AUD-GEO-02', 'AUD-GEO-03']) {
|
||||
const row = rows.find(entry => entry.id === id);
|
||||
row.implementacao += ' | C30: /management/territory-geometry-import, fontes privadas retidas, prévia vinculada ao ator/estado/hash, MFA, receipts existentes, auditoria append-only e reversão apenas do lote criado, sem publicação.';
|
||||
row.evidencia += ' | geometry-auth-http-qa.json: login MFA real; esperado/resolvido/pendente 11/9/2,159/120/39,411/232/179; aplicação/replay reutilizam361 e criam0, reversão dos lotes de adoção preserva361. TerritoryGeometryImportTest: inserção real numa QA isolada, replay, alterações/conflitos/scan expirado/MFA/alvo não QA/consumidor FK e reversão atómica. correspondencias-cartograficas.md lista220 lacunas por nome/código/pai/causa.';
|
||||
row.lacuna = '2 províncias,39 distritos,179 postos ainda sem correspondência inequívoca; fonte sem polígonos de localidades. Prévia/botões/camadas por consumidor ainda não observados na interface. Zero importação ou publicação ativa; correspondência não confirma disponibilidade fundiária.';
|
||||
if (row.percurso) {
|
||||
row.percurso.api += ' | Prévia/aplicação/replay/reversão autenticados nos três níveis, incluindo login MFA e conta comum403.';
|
||||
row.percurso.gravacao += ' | Mesmo geo_geometries/geo_write_receipts/audit_events; lote de reutilização0novos na QA web e criação/reversão com histórico na QA de testes.';
|
||||
row.percurso.recuperacao += ' | Lotes e geometrias reutilizados recuperados; reversão não remove dados preexistentes nem consumidores.';
|
||||
}
|
||||
}
|
||||
const activation = rows.find(entry => entry.id === 'AUD-OPS-01');
|
||||
activation.lacuna = 'Recursos030000 e percurso190000 ausentes no último retrato ativo; prévia ativa futura deve reconfirmar ledger. Mecanismo cartográfico C30 demonstrado em QA; disponível somente aí, e no ativo exige prova específica vinculada ao alvo/revisão/backup/restauro/UI. Revisão candidata não fixada: resultados manuais A/B e consumidores pendentes; backup final não renovado. Publicação é ação separada, não fornecida pelo importador.';
|
||||
activation.evidencia += ' | C30 mecanismos autenticados demonstrados em QA; plano-ativacao.md distingue código, instalações ausentes, flag e cartografia. Ready anterior não significa instalação/conclusão.';
|
||||
}
|
||||
const resourcesFile = path.join(priv, 'resources-real-http-qa.json');
|
||||
if (fs.existsSync(resourcesFile)) {
|
||||
const resources = JSON.parse(fs.readFileSync(resourcesFile, 'utf8'));
|
||||
const persisted = JSON.parse(fs.readFileSync(path.join(priv, 'document-real-persistence-qa.json'), 'utf8'));
|
||||
if (!resources.api_persistence_passed || resources.interface_observed || resources.active_writes !== 0 || !persisted.passed)
|
||||
throw new Error('Documentary proof must remain scoped to real QA operations.');
|
||||
const row = rows.find(entry => entry.id === 'AUD-SCAN-01');
|
||||
row.evidencia += ' | C31 corrigiu RouteNotFoundException nos previews de derivadas (management.opportunity-image-preview), sem alterar URLs. resources-real-http-qa.json/document-real-persistence-qa.json: PHP web QA com GD; PDF/PNG e3WebP cifrados/restricted, cinco scans ClamAV1.5.4 clean/hash próprios; três downloads assinados reais, assinatura ausente403 e conta comum403. OpportunityRealDocumentOperationsTest: quarentena423, indisponível/inconclusivo, retry real, substituição preserva original,409versão, exclusão/restauro, adulteração409. OpportunityResourcesTest usa scanner fixture: não é prova real.';
|
||||
row.lacuna = 'UI documental e derivadas ainda não observadas; operações de biblioteca/Balcão não são encerradas pelos ensaios de oportunidades. Há ensaios reais específicos suplementares, sem fecho de PDF preview/lotes/tipos/perfis/partilhas. Arranque após reboot/manutenção de assinaturas e GD/WebP no PHP ativo não verificados. Nenhuma publicação ativa nem cálculo.';
|
||||
row.dependencia = 'Daemon/assinaturas afetam scans/libertação de binários; GD/WebP afeta geração de derivadas. Não bloqueiam metadados, países, estatísticas nem preparação paralela. Fonte aceite não dispensa scan por bytes.';
|
||||
if (row.percurso) {
|
||||
row.percurso.api += ' | Upload, geração, recuperação de previews e downloads pelo servidor QA18097; resultados concretos por ficheiro.';
|
||||
row.percurso.gravacao += ' | Cinco documents cifrados e respectivos document_malware_scans reais, hashes próprios e classificação restricted.';
|
||||
row.percurso.recuperacao += ' | Bytes originais e3WebP recuperados por HTTP, comparados com hashes; original anterior preservado na substituição e após exclusão lógica.';
|
||||
}
|
||||
}
|
||||
const consumersProof = path.join(priv, 'document-consumers-real-qa.xml');
|
||||
if (fs.existsSync(consumersProof)) {
|
||||
for (const id of ['AUD-LIB-01', 'AUD-SCAN-01', 'AUD-DESK-02']) {
|
||||
const row = rows.find(entry => entry.id === id);
|
||||
row.evidencia += ' | C35: campos data/emissor opcionais omitidos causavam500; corrigidos sem inventar dados. C36: original cifrado clean devolvia404 em consumidores por comparar ciphertext ao hash original; VerifiedDocumentBinary partilhado recupera bytes verificados, mantendo permissões/cifra. document-consumers-real-qa.xml: PortalRealDocumentConsumersTest (ClamD real) verifica biblioteca privada→scan→classificação→publicação separada→download,409versão/dedup/404hash/403gestão, Balcão upload→scan→download/dedup/confidential/403estranho, reutilização do original cifrado sem duplicar documents, downloads gestão/Balcão/biblioteca e view PDF bytes/MIME/CSP, publicação separada e adulteração404. ClamDQueueWorkerIntegrationTest verifica worker database. Outros testes usam fixtures/mock ou schema compatibility, identificados separadamente. Estes ensaios não são sessões novas do navegador nem renderização visual.';
|
||||
}
|
||||
}
|
||||
const mfaFile = path.join(priv, 'mfa-http-qa.json');
|
||||
if (fs.existsSync(mfaFile)) {
|
||||
const mfa = JSON.parse(fs.readFileSync(mfaFile, 'utf8'));
|
||||
if (!mfa.login_mfa_logout_new_session_passed || mfa.interface_observed || mfa.active_writes !== 0)
|
||||
throw new Error('MFA proof must remain QA HTTP/persistence, never visual/active acceptance.');
|
||||
for (const id of ['AUD-UI-01', 'AUD-DASH-01', 'AUD-SIM-01', 'AUD-DESK-02', 'AUD-GUIDE-01', 'AUD-CRM-01']) {
|
||||
const row = rows.find(entry => entry.id === id);
|
||||
row.fontes.push('Pedido direto 09/10/2026: corrigir HTTP500 POST /two-factor-challenge na QA18097 após conta13; preservar conta/chave/MFA, invalidar sessão exposta e retomar cenárioA.');
|
||||
row.evidencia += ` | C37 bloqueou cenário A na tentativa manual reportada: stack completa aponta users.two_factor_secret em TwoFactorLoginRequest::hasValidCode, não PreserveFormFeedback. mfa-key-diagnosis-qa.json demonstra segredo/recovery da conta13 legada incompatíveis com chaveQA; conta13 preservada, duas sessões QA invalidadas. Preparação/web agora partilham configuração estável/cache próprio, sem key:generate/rekey. mfa-auth-qa.xml e mfa-http-qa.json (${mfa.captured_at}): novo investidor sintético com inscrição MFA normal Fortify, login/MFA/logout/nova sessão; falhas segredo/recovery controladas sem autenticação, redirect login com mensagem. CenárioA ${mfa.scenario_A.id} criado, recuperado por nova sessão MFA, adaptado, ferramenta/resultado próprios, Balcão e tarefa interna concluída. Cookies/XSRF/OTP/corpos não exportados. Retoma visual continua pendente.`;
|
||||
row.lacuna += ' | C37: MAC inválido corrigido por código/HTTP/persistência QA; reaberto P0 por ciclo de login da conta18 no navegador18097. Localizar a primeira perda de sessão com auth-session-trace-qa.jsonl antes de repetir ou alterar o login. Acesso, reload, logout e nova sessão visual continuam pendentes.';
|
||||
row.evidencia += ' | Reabertura C37 pelo utilizador: QA18097 cookie opportunity_resources_qa host-only, path/, Secure=false, HttpOnly=true, SameSite=lax; guard web/Fortify web, file120min, cache não carregado. Registo QA sanitizado correlaciona sessão carregada/regenerada/gravação/redirect e pedido seguinte sem cookies/códigos. Causa do ciclo ainda não demonstrada; HTTP anterior não fecha a falha.';
|
||||
const manualFile = path.join(priv, 'c37-manual-2122-2124-qa.json');
|
||||
const correctedFile = path.join(priv, 'c37-auth-redirect-qa.json');
|
||||
if (fs.existsSync(manualFile) && fs.existsSync(correctedFile)) {
|
||||
const manual = JSON.parse(fs.readFileSync(manualFile, 'utf8'));
|
||||
const corrected = JSON.parse(fs.readFileSync(correctedFile, 'utf8'));
|
||||
if (!manual.cause_confirmed || !corrected.mfa_browser_redirect_protocol_passed || corrected.visual_acceptance_passed)
|
||||
throw new Error('C37 diagnosis/QA protocol must not become visual acceptance.');
|
||||
row.evidencia += ' | C37 diagnóstico posterior confirmado: manual21:22–21:24UTC desafia13, não18. POST/MFA21:23:04 falha na desencriptação,302/login e invalidação intencional; GET seguinte recebe a nova sessão. Sem autenticação completada nem perda posterior. QA agora identifica18/login e ator/MFA, recusa restauradas antesMFA e apresenta erro da página. c37-auth-redirect-qa.json demonstra MFA18→investimentos→reload→logout→nova sessão/dossier, inválido permanece no desafio, negativo cifrado recusa autenticação e outra conta403. Conta13/.env/chave/MFA preservados; sucesso visual18 continua pendente.';
|
||||
row.lacuna += ' | Estado posterior C37: causa identificada e seleção/feedbackQA corrigidos; aceitação visual18 e retomaA/B pendentes.';
|
||||
}
|
||||
if (row.percurso) {
|
||||
row.percurso.interface += ' | Utilizador reportou500 no MFA, bloqueando A; correção preparada, retoma visual não observada.';
|
||||
row.percurso.api += ' | Fortify enable/confirm/login/MFA/logout e negativas de desencriptação verificadas no mesmo servidor18097.';
|
||||
row.percurso.recuperacao += ' | CenárioA próprio recuperado após nova sessão HTTP com MFA confirmado.';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Real HTTP/session evidence in guarded loopback QA; never visual acceptance."""
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
BASE = 'http://127.0.0.1:18097'
|
||||
backup = json.loads((OUT.parent / 'opportunity-resources-backup-proof.json').read_text(encoding='utf-8-sig'))
|
||||
target = backup['restored_target']
|
||||
assert backup['restore_verified'] and re.fullmatch(r'desk_restore_\d{8}_\d{6}_test', target['database'])
|
||||
assert int(target['port']) == 55441
|
||||
EXPECTED = target['database'] + ':55441'
|
||||
events = []
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, label):
|
||||
self.label = label
|
||||
self.jar = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
|
||||
self.call('GET', '/login', status=200, html=True)
|
||||
|
||||
def identity(self):
|
||||
values = [c.value for c in self.jar if c.name == 'opportunity_resources_qa']
|
||||
assert len(values) == 1, 'QA session cookie missing'
|
||||
return hashlib.sha256(values[0].encode()).hexdigest()
|
||||
|
||||
def call(self, method, path, data=None, status=200, html=False, extra=None):
|
||||
assert path.startswith('/') and not path.startswith('//')
|
||||
headers = {'Accept': 'text/html' if html else 'application/json', 'Content-Type': 'application/json'}
|
||||
for cookie in self.jar:
|
||||
if cookie.name == 'XSRF-TOKEN':
|
||||
headers['X-XSRF-TOKEN'] = urllib.parse.unquote(cookie.value)
|
||||
headers.update(extra or {})
|
||||
request = urllib.request.Request(BASE + path, data=None if data is None else json.dumps(data).encode(), headers=headers, method=method)
|
||||
try:
|
||||
response = self.opener.open(request)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
assert response.headers.get('X-Investmoz-QA-Target') == EXPECTED, 'QA identity absent or changed'
|
||||
raw = response.read()
|
||||
assert response.status == status, f'{self.label} {method} {path}: expected {status}, observed {response.status}; response body withheld'
|
||||
events.append({'actor': self.label, 'method': method, 'url': path, 'status': response.status, 'qa_target_verified': True})
|
||||
return None if html or not raw else json.loads(raw)
|
||||
|
||||
def login(self, email, password):
|
||||
self.call('POST', '/login', {'email': email, 'password': password})
|
||||
|
||||
|
||||
# Verify isolation before constructing any mutation.
|
||||
owner = Session('investor')
|
||||
owner.login('investor-path-qa@example.invalid', 'Investor-Path-QA-2026!')
|
||||
manager = Session('manager')
|
||||
manager.login('opportunity-manager@example.invalid', 'Opportunity-QA-2026!')
|
||||
installation = manager.call('GET', '/management/balcao-install')
|
||||
assert installation['pathInstalled'] and not installation['pathQaVerified']
|
||||
scenarios = []
|
||||
for index, name in enumerate(['A', 'B']):
|
||||
view_id = owner.call('POST', '/investimentos', {
|
||||
'title': f'Cenário {name} HTTP QA — ' + ('terra e financiamento' if index else 'sem terra nem financiamento externo'),
|
||||
'client_key': f'61000919-0000-4000-8000-00000000000{index + 3}', 'reuse_consent': True,
|
||||
}, status=201)['id']
|
||||
url = '/investimentos/' + view_id
|
||||
view = owner.call('GET', url)
|
||||
assert len(view['steps']) == 19 and len(view['phases']) == 5
|
||||
if view['facts']['land_needed'] == 'unknown':
|
||||
owner.call('POST', url + '/aplicabilidade', {'version': view['case']['version'], 'land_needed': 'yes' if index else 'no',
|
||||
'external_funding': 'yes' if index else 'no', 'viability': 'proceed', 'reason': 'Declaração sintética QA; nenhuma decisão externa.'})
|
||||
draft = owner.call('GET', '/api/v1/simulation-drafts/current?context=' + view_id)['data']
|
||||
if not draft:
|
||||
snapshot = {'version': 1, 'data': {'budgetPeriod': 'Mensal', 'loan': {'principal': '100000', 'rate': '0', 'months': '24', 'rateType': 'nominal', 'periodMonths': '1', 'gracePeriods': '0', 'upfrontFee': '0', 'recurringFee': '0'},
|
||||
'breakEven': {'fixed': '1000', 'price': '100', 'variable': '50'}, 'funding': {'total': '100000', 'secured': '30000' if index else '100000'},
|
||||
'incomes': [{'label': 'Receita sintética QA', 'amount': '100'}], 'expenses': [{'label': 'Custo sintético QA', 'amount': '50'}]}}
|
||||
draft = owner.call('POST', '/api/v1/simulation-drafts', {'context': view_id, 'personal_snapshot': {'fiscal_residency_country': 'PT'},
|
||||
'economic_snapshot': {'purpose': 'simulate_only', 'project_title': name + ' HTTP QA', 'project_summary': 'Descrição ' + name + ' HTTP QA', 'currency': 'MZN',
|
||||
'funds_origin_countries': ['PT', 'ZA'], 'financial_tools_snapshot': json.dumps(snapshot)}}, status=201, extra={'Idempotency-Key': str(uuid.uuid4())})['data']
|
||||
stored_snapshot = json.loads(draft['economic_snapshot']['financial_tools_snapshot'])
|
||||
if stored_snapshot['data']['incomes'] == [] and stored_snapshot['data']['expenses'] == []:
|
||||
# Correct only this tool's synthetic incomplete fixture by a new draft/result version.
|
||||
# Old frozen results remain immutable and must report incompatibility, not invented zero.
|
||||
assert draft['economic_snapshot']['project_title'] == name + ' HTTP QA'
|
||||
stored_snapshot['data']['incomes'] = [{'label': 'Receita sintética QA', 'amount': '100'}]
|
||||
stored_snapshot['data']['expenses'] = [{'label': 'Custo sintético QA', 'amount': '50'}]
|
||||
draft = owner.call('PUT', '/api/v1/simulation-drafts/' + draft['id'], {'context': view_id, 'personal_snapshot': draft['personal_snapshot'],
|
||||
'economic_snapshot': {**draft['economic_snapshot'], 'financial_tools_snapshot': json.dumps(stored_snapshot)}},
|
||||
extra={'If-Match': '"sfd-' + draft['id'] + '-v' + str(draft['version_no']) + '"'})['data']
|
||||
view = owner.call('GET', url)
|
||||
result = owner.call('POST', url + '/resultados', {'version': view['case']['version'], 'draft_id': draft['id'], 'draft_version': draft['version_no']})['result']
|
||||
view = owner.call('GET', url)
|
||||
if not view['requests']:
|
||||
assert view['services'], 'Published QA support service required'
|
||||
owner.call('POST', '/balcao-virtual/dossiers/' + view_id + '/pedidos', {'service_version_id': view['services'][0]['id'], 'client_key': str(uuid.uuid4())}, status=201)
|
||||
view = owner.call('GET', url)
|
||||
if index and next(s for s in view['steps'] if s['code'] == 'S10')['saved_state'] != 'waiting_external':
|
||||
owner.call('POST', url + '/etapas/S10', {'version': view['case']['version'], 'state': 'waiting_external', 'action': 'prepare', 'reason': 'Aguarda informação do terreno; mercado pode avançar.'})
|
||||
view = owner.call('GET', url)
|
||||
if not view['tasks']:
|
||||
owner.call('POST', url + '/tarefas', {'version': view['case']['version'], 'code': 'S02', 'title': 'Mercado independente ' + name + ' HTTP QA',
|
||||
'due_at': (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(), 'consent': True}, status=201)
|
||||
task = owner.call('GET', url)['tasks'][0]
|
||||
managed = manager.call('GET', url)
|
||||
assert managed['canReview'] and not managed['canWrite'] and managed['results'] == []
|
||||
manager.call('GET', '/api/v1/simulation-drafts/' + draft['id'], status=404)
|
||||
for state in ['in_progress', 'done']:
|
||||
task = next(t for t in manager.call('GET', url)['tasks'] if t['id'] == task['id'])
|
||||
if task['status'] != 'done':
|
||||
manager.call('POST', '/balcao-virtual/dossiers/' + view_id + '/tarefas/' + task['id'], {'version': task['version_no'], 'status': state,
|
||||
'reason': 'Acompanhamento interno sintético; tarefa independente.'})
|
||||
scenarios.append({'scenario': name, 'id': view_id, 'url': url, 'draft_id': draft['id'], 'result_id': result['id'], 'ui_passed': False})
|
||||
|
||||
prior_session = owner.identity()
|
||||
owner.call('POST', '/logout', status=204)
|
||||
owner.call('GET', scenarios[0]['url'], status=401)
|
||||
owner = Session('investor-new-session') # fresh cookie jar; not merely a new request
|
||||
owner.login('investor-path-qa@example.invalid', 'Investor-Path-QA-2026!')
|
||||
assert owner.identity() != prior_session
|
||||
for entry in scenarios:
|
||||
view = owner.call('GET', entry['url'])
|
||||
recovered = next(r for r in view['results'] if r['id'] == entry['result_id'])
|
||||
assert recovered['draft_id'] == entry['draft_id']
|
||||
assert view['tasks'][0]['status'] == 'done' and view['requests'][0]['status'] == 'draft'
|
||||
request = owner.call('GET', '/balcao-virtual/pedidos/' + view['requests'][0]['id'])['request']
|
||||
assert request['payload']['description'] == 'Descrição ' + entry['scenario'] + ' HTTP QA'
|
||||
draft = owner.call('GET', '/api/v1/simulation-drafts/current?context=' + entry['id'])['data']
|
||||
assert draft['id'] == entry['draft_id'] and draft['personal_snapshot']['fiscal_residency_country'] == 'PT'
|
||||
assert draft['economic_snapshot']['funds_origin_countries'] == ['PT', 'ZA']
|
||||
steps = {s['code']: s for s in view['steps']}
|
||||
assert steps['S10']['state'] == ('dispensed' if entry['scenario'] == 'A' else 'waiting_external')
|
||||
assert steps['S13']['applicability'] == ('not_needed_for_preparation' if entry['scenario'] == 'A' else 'applicable')
|
||||
entry['new_authenticated_session_recovered'] = True
|
||||
entry['independent_task_done'] = True
|
||||
entry['expected_break_even_units'] = 20
|
||||
entry['expected_funding_gap'] = 0 if entry['scenario'] == 'A' else 70000
|
||||
entry['recovered_financial_result'] = {key: recovered[key] for key in ['id', 'engine', 'financial_snapshot', 'draft_id', 'draft_version']}
|
||||
entry['prior_result_ids_preserved'] = [r['id'] for r in view['results'] if r['id'] != entry['result_id']]
|
||||
owner.call('GET', '/api/v1/simulation-drafts/' + scenarios[0]['draft_id'] + '?context=' + scenarios[1]['id'], status=403)
|
||||
owner.call('POST', scenarios[1]['url'] + '/resultados', {'version': owner.call('GET', scenarios[1]['url'])['case']['version'],
|
||||
'draft_id': scenarios[0]['draft_id'], 'draft_version': 1}, status=403)
|
||||
stranger = Session('outsider')
|
||||
stranger.login('investor-path-outsider-qa@example.invalid', 'Outsider-Path-QA-2026!')
|
||||
for entry in scenarios:
|
||||
stranger.call('GET', entry['url'], status=403)
|
||||
stranger.call('GET', '/api/v1/simulation-drafts/' + entry['draft_id'], status=404)
|
||||
assert stranger.call('GET', '/investimentos')['contexts'] == []
|
||||
geometry_proof = json.loads((OUT / 'geometry-consumers-path-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert geometry_proof['target'] == target and geometry_proof['real_geometries_checked'] == 361
|
||||
geometry_checks = []
|
||||
for selected in geometry_proof['selector_examples']:
|
||||
query = urllib.parse.urlencode({'territory_id': selected['id']})
|
||||
private = manager.call('GET', '/management/territory-geometries?' + query)
|
||||
public = owner.call('GET', '/api/v1/investment/territory-geometries?' + query)
|
||||
stranger.call('GET', '/management/territory-geometries?' + query, status=403)
|
||||
assert private['selected_geometry_available'] and private['investment_site'] is False
|
||||
assert public['territory_registered'] and public['context_geometry_count'] == 0
|
||||
geometry_checks.append({'territory': selected, 'manager_selected_geometry': True, 'public_context_count': 0, 'ui_passed': False})
|
||||
proof = {'captured_at': datetime.now(timezone.utc).isoformat(), 'target': target, 'base_url': BASE, 'real_http_sessions': True,
|
||||
'session_cookie_changed': True, 'scenarios': scenarios, 'geometry_http_profiles': geometry_checks, 'requests': events, 'api_persistence_passed': True,
|
||||
'integrated_ui_ab_passed': False, 'active_writes': 0, 'activation_ready': False,
|
||||
'installation_preview_qa': {key: installation[key] for key in ['code_hash', 'baseDeskInstalled', 'pathInstalled', 'pathEnabled', 'pathQaVerified', 'pendingMigrations']}}
|
||||
proof_path = OUT / 'investor-path-http-qa.json'
|
||||
if proof_path.exists():
|
||||
shutil.copy2(proof_path, OUT / ('investor-path-http-qa-' + datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f') + '.json'))
|
||||
proof_path.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'target': target, 'scenarios': [{key: value for key, value in scenario.items() if key != 'recovered_financial_result'} for scenario in scenarios],
|
||||
'requests': len(events), 'ui_passed': False, 'active_writes': 0}, ensure_ascii=False))
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
// Rehearse only the new extension against the existing verified restore. Never connect to the active target.
|
||||
use App\Models\User;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\InvestorPathDefinition;
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use App\Services\SimulationFormDrafts;
|
||||
use App\Services\TerritoryGeometryImport;
|
||||
use App\Services\VirtualDesk;
|
||||
use App\Services\VirtualDeskInstallation;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once $root.'/tools/portal-qa-environment.php';
|
||||
$directory = $root.'/storage/app/private/investmoz-v2/portal-audit';
|
||||
$backup = json_decode(file_get_contents($directory.'/../opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $backup['restored_target'];
|
||||
if (! ($backup['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Verified restored QA required.');
|
||||
}
|
||||
$target = portalQaEnvironment();
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = portalQaAssertTarget($target);
|
||||
if (($argv[1] ?? '') === 'evidence') {
|
||||
DB::beginTransaction();
|
||||
DB::statement('SET TRANSACTION READ ONLY');
|
||||
$data = ['captured_at' => gmdate('c'), 'target' => $target, 'code_hash' => app(VirtualDeskInstallation::class)->fingerprint(),
|
||||
'geometry_code_hash' => app(TerritoryGeometryImport::class)->fingerprint(),
|
||||
'resource_code_hash' => app(OpportunityResourceInstallation::class)->fingerprint(),
|
||||
'qa_path_journeys' => DB::table('investor_journeys')->whereNotNull('path_definition_id')->count(),
|
||||
'qa_path_steps' => DB::table('journey_steps')->where('source_system', 'investor-path')->count(),
|
||||
'qa_private_geometries' => DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('data_classification', 'internal')->count(),
|
||||
'active_writes' => 0];
|
||||
$resourceProof = $directory.'/resources-real-http-qa.json';
|
||||
if (is_file($resourceProof)) {
|
||||
$resource = json_decode(file_get_contents($resourceProof), true, 512, JSON_THROW_ON_ERROR);
|
||||
if ($resource['target'] !== $target || ! $resource['api_persistence_passed']) {
|
||||
throw new RuntimeException('Resource evidence from another target refused.');
|
||||
}
|
||||
$ids = [$resource['original_image_document_id'], ...array_column($resource['derivatives'], 'document_id'), DB::table('opportunity_documents')->where('id', $resource['document_resource_id'])->value('document_id')];
|
||||
$documents = DB::table('documents')->whereIn('id', $ids)->get(['id', 'sha256', 'malware_scanned_sha256', 'status', 'storage_encryption', 'data_classification', 'malware_scan_status', 'malware_engine', 'malware_engine_version', 'malware_signatures_at', 'malware_scanned_at', 'malware_scan_attempts']);
|
||||
foreach ($documents as $document) {
|
||||
if ($document->malware_scan_status !== 'clean' || $document->malware_engine !== 'ClamAV' || trim($document->sha256) !== $document->malware_scanned_sha256 || $document->storage_encryption !== 'laravel' || $document->data_classification !== 'restricted') {
|
||||
throw new RuntimeException('Real independent clean/encrypted/restricted evidence absent.');
|
||||
}
|
||||
}
|
||||
if (count($documents) !== 5) {
|
||||
throw new RuntimeException('Five original/derivative documents expected in this QA operation.');
|
||||
}
|
||||
$persistence = ['captured_at' => gmdate('c'), 'target' => $target, 'documents' => $documents, 'scans' => DB::table('document_malware_scans')->whereIn('document_id', $ids)->get(), 'passed' => true, 'active_writes' => 0, 'interface_observed' => false];
|
||||
file_put_contents($directory.'/document-real-persistence-qa.json', json_encode($persistence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
DB::rollBack();
|
||||
file_put_contents($directory.'/investor-path-qa-runtime.json', json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($data).PHP_EOL;
|
||||
exit;
|
||||
}
|
||||
if (($argv[1] ?? '') === 'fixtures') {
|
||||
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
$actor = User::firstOrCreate(['email' => 'investor-path-qa@example.invalid'], [
|
||||
'name' => 'Investidor sintético A/B QA', 'password' => Hash::make('Investor-Path-QA-2026!'), 'email_verified_at' => now(),
|
||||
]);
|
||||
if (! $actor->hasVerifiedEmail()) {
|
||||
$actor->forceFill(['email_verified_at' => now()])->save();
|
||||
}
|
||||
$outsider = User::firstOrCreate(['email' => 'investor-path-outsider-qa@example.invalid'], [
|
||||
'name' => 'Outra conta sintética QA', 'password' => Hash::make('Outsider-Path-QA-2026!'),
|
||||
]);
|
||||
if (! $outsider->hasVerifiedEmail()) {
|
||||
$outsider->forceFill(['email_verified_at' => now()])->save();
|
||||
}
|
||||
$path = app(InvestorPath::class);
|
||||
$desk = app(VirtualDesk::class);
|
||||
$drafts = app(SimulationFormDrafts::class);
|
||||
$fixtures = [];
|
||||
foreach (['A', 'B'] as $index => $scenario) {
|
||||
$id = $path->create($actor, ['title' => 'Cenário '.$scenario.' sintético QA — '.($index ? 'terra e financiamento' : 'sem terra nem financiamento externo'),
|
||||
'client_key' => '61000919-0000-4000-8000-00000000000'.($index + 1), 'reuse_consent' => true]);
|
||||
$view = $path->view($actor, $id);
|
||||
if ($view['facts']['land_needed'] === 'unknown' && $view['facts']['external_funding'] === 'unknown') {
|
||||
$path->facts($actor, $id, ['version' => $view['case']['version'], 'land_needed' => $index ? 'yes' : 'no', 'external_funding' => $index ? 'yes' : 'no',
|
||||
'viability' => 'proceed', 'reason' => 'Fixture sintética para demonstração; decisão declarada de preparação, sem aprovação externa.']);
|
||||
}
|
||||
$request = Request::create('/', 'POST', ['context' => $id]);
|
||||
$request->setUserResolver(fn () => $actor);
|
||||
$case = $desk->case($actor, $id);
|
||||
if (! $case->simulation_id) {
|
||||
$snapshot = ['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
|
||||
'loan' => ['principal' => '100000', 'rate' => '0', 'months' => '24', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '0', 'upfrontFee' => '0', 'recurringFee' => '0'],
|
||||
'breakEven' => ['fixed' => '1000', 'price' => '100', 'variable' => '50'], 'funding' => ['total' => '100000', 'secured' => $index ? '30000' : '100000'],
|
||||
'incomes' => [['label' => 'Receita sintética QA', 'amount' => '100']], 'expenses' => [['label' => 'Custo sintético QA', 'amount' => '50']]]];
|
||||
$created = $drafts->create(['personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only',
|
||||
'project_title' => 'Ideia '.$scenario.' sintética QA', 'project_summary' => 'Dados fictícios para revisão do percurso integrado.', 'currency' => 'MZN',
|
||||
'financial_tools_snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR)]], $request, 'qa-fixture-path-'.$scenario.'-2026');
|
||||
$path->returnTool($request, $id, ['version' => $path->view($actor, $id)['case']['version'], 'draft_id' => $created['draft']->id, 'draft_version' => $created['draft']->version_no]);
|
||||
}
|
||||
$fixtures[] = ['scenario' => $scenario, 'id' => $id, 'url' => '/investimentos/'.$id, 'steps' => count($path->view($actor, $id)['steps']), 'ui_demonstrated' => false];
|
||||
}
|
||||
$downRefused = false;
|
||||
try {
|
||||
(require $root.'/database/migrations/2026_10_09_190000_prepare_investor_path.php')->down();
|
||||
} catch (RuntimeException $exception) {
|
||||
$downRefused = str_contains($exception->getMessage(), 'Preserve investor path history');
|
||||
}
|
||||
if (! $downRefused) {
|
||||
throw new RuntimeException('Used-data rollback guard was not demonstrated.');
|
||||
}
|
||||
$proof = ['target' => $target, 'fixtures' => $fixtures, 'account' => $actor->email, 'outsider_account' => $outsider->email, 'fixture_setup_only' => true,
|
||||
'used_down_refused' => true, 'active_writes' => 0, 'visual_verified' => false];
|
||||
file_put_contents($directory.'/investor-path-fixtures.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof).PHP_EOL;
|
||||
exit;
|
||||
}
|
||||
$name = '2026_10_09_190000_prepare_investor_path';
|
||||
$quote = fn (string $name): string => '"'.str_replace('"', '""', $name).'"';
|
||||
$columns = [];
|
||||
foreach (DB::select("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,ordinal_position") as $row) {
|
||||
$columns[$row->table_name][] = $row->column_name;
|
||||
}
|
||||
$snapshot = function () use ($columns, $quote): array {
|
||||
$hashes = [];
|
||||
foreach ($columns as $table => $fields) {
|
||||
if (! Schema::hasTable($table) || ! DB::selectOne("SELECT EXISTS(SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename=?) AS present", [$table])->present) {
|
||||
continue;
|
||||
}
|
||||
$projection = implode(',', array_map($quote, $fields));
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM (SELECT '.$projection.' FROM '.$quote($table).') t ORDER BY row_to_json(t)::text');
|
||||
$hashes[$table] = hash('sha256', implode("\n", array_column($rows, 'row')));
|
||||
}
|
||||
|
||||
return $hashes;
|
||||
};
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::select('select pg_advisory_xact_lock(261009, 1919)');
|
||||
$before = $snapshot();
|
||||
if (Schema::hasColumn('investor_journeys', 'path_definition_id')) {
|
||||
// Idempotent repeat: validate the published snapshot, do not repeat installation or rollback used data.
|
||||
$record = DB::table('questionnaire_versions')->where('id', config('investor-path.definition_id'))->firstOrFail();
|
||||
InvestorPathDefinition::validate(json_decode($record->schema_snapshot, true, 512, JSON_THROW_ON_ERROR));
|
||||
$unused = ! DB::table('investor_journeys')->whereNotNull('path_definition_id')->exists();
|
||||
if ($unused) {
|
||||
$migration = require $root.'/database/migrations/'.$name.'.php';
|
||||
$migration->down();
|
||||
$migration->up();
|
||||
}
|
||||
DB::rollBack();
|
||||
if ($snapshot() !== $before) {
|
||||
throw new RuntimeException('QA replay changed records.');
|
||||
}
|
||||
$proofFile = $directory.'/investor-path-compatibility.json';
|
||||
$proof = json_decode(file_get_contents($proofFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
$proof['code_hash'] = app(VirtualDeskInstallation::class)->fingerprint();
|
||||
$proof['rechecked_at'] = gmdate('c');
|
||||
$proof['replay_preserved'] = true;
|
||||
$proof['final_unused_down_rechecked'] = $unused;
|
||||
file_put_contents($proofFile, json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['target' => $target, 'already_installed_qa' => true, 'installation_repeated' => false, 'active_writes' => 0]).PHP_EOL;
|
||||
exit;
|
||||
}
|
||||
$migration = require $root.'/database/migrations/'.$name.'.php';
|
||||
$migration->up();
|
||||
$migration->down();
|
||||
// Immutable unused definition retained by down; remove the entire rehearsal through transaction rollback.
|
||||
DB::rollBack();
|
||||
$after = $snapshot();
|
||||
if ($before !== $after || Schema::hasColumn('investor_journeys', 'path_definition_id')) {
|
||||
throw new RuntimeException('Rehearsal rollback changed original data/schema.');
|
||||
}
|
||||
DB::transaction(function () use ($migration, $name): void {
|
||||
$migration->up();
|
||||
DB::table('migrations')->insert(['migration' => $name, 'batch' => (DB::table('migrations')->max('batch') ?? 0) + 1]);
|
||||
});
|
||||
$after = $snapshot();
|
||||
$exceptions = ['questionnaire_versions', 'migrations'];
|
||||
$changed = array_keys(array_filter($before, fn ($hash, $table) => ! in_array($table, $exceptions, true) && $hash !== ($after[$table] ?? null), ARRAY_FILTER_USE_BOTH));
|
||||
if ($changed) {
|
||||
throw new RuntimeException('Original records changed during installation: '.implode(',', $changed));
|
||||
}
|
||||
$proof = ['target' => $target, 'code_hash' => app(VirtualDeskInstallation::class)->fingerprint(), 'captured_at' => gmdate('c'),
|
||||
'backup_dump_sha256' => $backup['sha256'], 'original_tables_compared' => count($before), 'changed_original_tables' => $changed,
|
||||
'unused_down_and_transaction_restore_verified' => true, 'qa_installed' => true, 'migration' => $name,
|
||||
'added_columns' => 2, 'definition_rows_added' => 1, 'existing_journeys_converted' => 0, 'new_journeys' => 0, 'new_steps' => 0,
|
||||
'active_writes' => 0, 'integrated_ui_ab_passed' => false, 'activation_ready' => false];
|
||||
file_put_contents($directory.'/investor-path-compatibility.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof).PHP_EOL;
|
||||
} catch (Throwable $exception) {
|
||||
if (DB::transactionLevel()) {
|
||||
DB::rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Recalculate actual frozen evidence recovered by HTTP, without UI or database writes.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { recoverPathResult } from '../resources/js/lib/investor-path.ts';
|
||||
const directory = new URL('../storage/app/private/investmoz-v2/portal-audit/', import.meta.url);
|
||||
const http = JSON.parse(fs.readFileSync(new URL('investor-path-http-qa.json', directory), 'utf8'));
|
||||
assert.match(http.target.database, /^desk_restore_\d{8}_\d{6}_test$/);
|
||||
assert.equal(http.target.port, 55441);
|
||||
assert.equal(http.api_persistence_passed, true);
|
||||
const results = http.scenarios.map(scenario => {
|
||||
const result = recoverPathResult(scenario.recovered_financial_result);
|
||||
assert.equal(result.breakEven.value.breakEvenUnits, String(scenario.expected_break_even_units));
|
||||
assert.equal(result.funding.value.gap, scenario.expected_funding_gap.toFixed(2));
|
||||
return {scenario: scenario.scenario, case_id: scenario.id, result_id: scenario.result_id,
|
||||
units: result.breakEven.value.breakEvenUnits, funding_gap: result.funding.value.gap, ui_verified: false};
|
||||
});
|
||||
const incompatible = [];
|
||||
for (const filename of fs.readdirSync(directory).filter(name => /^investor-path-http-qa-\d.*\.json$/.test(name))) {
|
||||
const previous = JSON.parse(fs.readFileSync(new URL(filename, directory), 'utf8'));
|
||||
for (const scenario of previous.scenarios ?? []) {
|
||||
const result = scenario.recovered_financial_result;
|
||||
const current = http.scenarios.find(item => item.id === scenario.id);
|
||||
if (!result || !current?.prior_result_ids_preserved.includes(result.id) || incompatible.includes(result.id)) continue;
|
||||
const snapshot = JSON.parse(result.financial_snapshot);
|
||||
if (snapshot.data.incomes.length || snapshot.data.expenses.length) continue;
|
||||
assert.throws(() => recoverPathResult(result), /Entradas incompatíveis/);
|
||||
incompatible.push(result.id);
|
||||
}
|
||||
}
|
||||
const proof = {captured_at: new Date().toISOString(), target: http.target, results, passed: true,
|
||||
source: 'Actual frozen financial result recovered after a new authenticated HTTP session',
|
||||
preserved_incompatible_prior_results: incompatible,
|
||||
engine: 'financial-calculators:v1', interface_observed: false, active_writes: 0};
|
||||
const mfaFile = new URL('mfa-http-qa.json', directory);
|
||||
if (fs.existsSync(mfaFile)) {
|
||||
const mfa = JSON.parse(fs.readFileSync(mfaFile, 'utf8'));
|
||||
assert.deepEqual(mfa.target, http.target);
|
||||
assert.equal(mfa.login_mfa_logout_new_session_passed, true);
|
||||
const result = recoverPathResult(mfa.scenario_A.recovered_financial_result);
|
||||
assert.equal(result.breakEven.value.breakEvenUnits, '20');
|
||||
assert.equal(result.funding.value.gap, '0.00');
|
||||
proof.mfa_scenario_A = {case_id: mfa.scenario_A.id, result_id: mfa.scenario_A.result_id,
|
||||
units: '20', funding_gap: '0.00', source: 'Recovered after a new MFA-authenticated HTTP session', ui_verified: false};
|
||||
}
|
||||
fs.writeFileSync(new URL('investor-path-recovered-calculations.json', directory), JSON.stringify(proof, null, 2));
|
||||
console.log(JSON.stringify(proof));
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
// Registered only by the router after its actual isolated-database guard.
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PortalQaAccountSelection
|
||||
{
|
||||
public const MESSAGE = 'Este ambiente QA aceita apenas contas sintéticas de ensaio. Para os cenários A/B, use a conta QA 18 e o respetivo MFA. As contas restauradas são preservadas e não devem ser usadas neste ensaio.';
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('portal.qa_synthetic_accounts_only', false) || ! app()->environment('testing')) {
|
||||
return $next($request);
|
||||
}
|
||||
$email = null;
|
||||
if ($request->isMethod('POST') && $request->is('login')) {
|
||||
$email = $request->input('email');
|
||||
} elseif ($request->is('two-factor-challenge') && $request->session()->has('login.id')) {
|
||||
$email = User::find($request->session()->get('login.id'))?->email;
|
||||
}
|
||||
if (is_string($email) && ! str_ends_with(strtolower($email), '@example.invalid')) {
|
||||
$request->session()->forget(['login.id', 'login.remember']);
|
||||
$request->attributes->set('qa_restored_account_rejected', true);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => self::MESSAGE, 'errors' => ['email' => [self::MESSAGE]]], 422)
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => self::MESSAGE])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Analyze the user's bounded manual attempt; never send authentication requests."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
file = private / 'auth-session-trace-qa.jsonl'
|
||||
all_rows = [json.loads(line) for line in file.read_text(encoding='utf-8').splitlines()]
|
||||
rows = [row for row in all_rows if '2026-10-09T21:22:00' <= row['at_utc'] <= '2026-10-09T21:24:59']
|
||||
assert len(rows) == 6, 'Manual interval changed: inspect before classifying.'
|
||||
assert [(r['method'], r['path']) for r in rows] == [
|
||||
('GET', '/investimentos'), ('GET', '/login'), ('POST', '/login'),
|
||||
('GET', '/two-factor-challenge'), ('POST', '/two-factor-challenge'), ('GET', '/login')]
|
||||
assert all(row['qa_target'] == 'desk_restore_20261009_174726_test:55441' for row in rows)
|
||||
login, challenge, mfa, next_request = rows[2:]
|
||||
assert login['after']['pending_mfa_user'] == challenge['loaded']['pending_mfa_user'] == mfa['loaded']['pending_mfa_user'] == 13
|
||||
assert login['after']['session'] == challenge['loaded']['session'] == mfa['loaded']['session']
|
||||
assert mfa['status'] == 302 and mfa['redirect']['path'] == '/login'
|
||||
assert all(not row['after']['authenticated_user'] for row in rows)
|
||||
assert next_request['loaded']['session'] == mfa['after']['session'] == next_request['loaded']['accepted_cookie_session']
|
||||
|
||||
# Export only the correlated structured warning; no arguments, headers or bodies.
|
||||
log = root / 'storage/logs/laravel.log'
|
||||
with log.open('rb') as f:
|
||||
f.seek(max(0, log.stat().st_size - 2 * 1024 * 1024))
|
||||
lines = f.read().decode('utf-8', errors='replace').splitlines()
|
||||
warnings = []
|
||||
for line in lines:
|
||||
match = re.match(r'^\[2026-10-09 21:23:04\] testing\.WARNING: Two-factor credential could not be decrypted; authentication refused\. (\{.*\})\s*$', line)
|
||||
if match:
|
||||
data = json.loads(match.group(1))
|
||||
warnings.append({'at_utc': '2026-10-09T21:23:04+00:00', 'reference': data['reference'], 'pending_user_id': data['pending_user_id'], 'category': 'decryption_failure'})
|
||||
snapshot_hash = hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()
|
||||
archive = private / 'c37-manual-2122-2124-qa.json'
|
||||
if not warnings and archive.exists():
|
||||
retained = json.loads(archive.read_text(encoding='utf-8'))
|
||||
assert retained['manual_trace_snapshot_sha256'] == snapshot_hash, 'Archived proof must match the same six requests.'
|
||||
warnings = [retained['warning']]
|
||||
assert len(warnings) == 1 and warnings[0]['pending_user_id'] == 13
|
||||
diagnosis = json.loads((private / 'mfa-key-diagnosis-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert diagnosis['encrypted_field_checks']['two_factor_secret'] == {'qa': False, 'source_env': True}
|
||||
assert diagnosis['encrypted_field_checks']['two_factor_recovery_codes'] == {'qa': False, 'source_env': True}
|
||||
|
||||
result = {'case': 'C37', 'manual_interval_utc': '2026-10-09 21:22:00–21:24:59',
|
||||
'reported_expected_actor': 18, 'observed_challenged_actor': 13,
|
||||
'mfa_screen_reached': True, 'manual_mfa_screen_observed_by_user': True,
|
||||
'timeline': [{key: row[key] for key in ['at_utc', 'trace', 'method', 'path', 'status', 'redirect', 'loaded', 'after', 'persisted']} for row in rows],
|
||||
'first_failure': {'at_utc': mfa['at_utc'], 'trace': mfa['trace'], 'path': mfa['path'], 'category': 'decryption_failure',
|
||||
'result': 'Authentication refused before completion; controlled302 to /login and intentional session invalidation.'},
|
||||
'setup_mismatch_first_seen_at': login['at_utc'], 'warning': warnings[0],
|
||||
'mfa_invalid_code_rejection': False, 'authentication_completed': False,
|
||||
'session_lost_after_authentication': False, 'next_request_accepts_regenerated_session': True,
|
||||
'cookie_conflict_observed': False, 'server_key_or_guard_changed': False,
|
||||
'request_code_or_recovery_value_recorded': False,
|
||||
'exact_mfa_input_mode_in_manual_trace': 'not recorded; no request bodies retained',
|
||||
'cause_confirmed': True, 'cause': 'Restored non-synthetic account13 selected; its MFA credentials use the source key, incompatible with the stable isolated QA key. Account18 was not challenged in this attempt.',
|
||||
'browser_acceptance_passed': False, 'active_writes': 0,
|
||||
'manual_trace_snapshot_sha256': snapshot_hash}
|
||||
(private / 'c37-manual-2122-2124-qa.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
(private / 'auth-session-diagnosis-qa.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({key: value for key, value in result.items() if key != 'timeline'}, ensure_ascii=True, indent=2))
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
// Loaded exclusively by the guarded QA router. Never log request bodies or cookie values.
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PortalQaAuthTrace
|
||||
{
|
||||
public static function pseudonym(?string $value): ?string
|
||||
{
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
$file = storage_path('app/private/investmoz-v2/portal-audit/auth-trace-salt-private');
|
||||
if (! is_file($file)) {
|
||||
$handle = fopen($file, 'x');
|
||||
if ($handle) {
|
||||
fwrite($handle, random_bytes(32));
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
return substr(hash_hmac('sha256', $value, file_get_contents($file)), 0, 24);
|
||||
}
|
||||
|
||||
public static function destination(?string $url): ?array
|
||||
{
|
||||
if (! $url) {
|
||||
return null;
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
|
||||
return ['origin' => isset($parts['host']) ? ($parts['scheme'] ?? 'http').'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '') : null,
|
||||
'path' => $parts['path'] ?? '/']; // Query strings may contain tokens: excluded.
|
||||
}
|
||||
|
||||
public static function state(Request $request): ?array
|
||||
{
|
||||
if (! $request->hasSession()) {
|
||||
return null;
|
||||
}
|
||||
$session = $request->session();
|
||||
$guard = Auth::guard(config('fortify.guard'));
|
||||
|
||||
return ['session' => self::pseudonym($session->getId()),
|
||||
'accepted_cookie_session' => self::pseudonym($request->cookies->get(config('session.cookie'))),
|
||||
'pending_mfa_user' => $session->get('login.id'),
|
||||
'stored_web_user' => $session->get($guard->getName()), 'authenticated_user' => $guard->id(),
|
||||
'intended' => self::destination($session->get('url.intended')),
|
||||
'handler' => get_class($session->getHandler())];
|
||||
}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('testing') || (int) $request->getPort() !== 18097) {
|
||||
return $next($request);
|
||||
}
|
||||
$trace = bin2hex(random_bytes(8));
|
||||
$request->attributes->set('qa_auth_trace', $trace);
|
||||
$names = [];
|
||||
foreach (explode(';', $request->headers->get('Cookie', '')) as $cookie) {
|
||||
$name = trim(explode('=', $cookie, 2)[0]);
|
||||
if ($name === 'XSRF-TOKEN' || str_ends_with($name, '_qa') || str_starts_with($name, 'investmoz_qa_')) {
|
||||
$names[$name] = ($names[$name] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
$response = $next($request);
|
||||
$state = self::state($request);
|
||||
$persisted = null;
|
||||
if ($request->hasSession()) {
|
||||
$raw = $request->session()->getHandler()->read($request->session()->getId());
|
||||
$data = $raw ? json_decode($raw, true) : null;
|
||||
$guardKey = Auth::guard(config('fortify.guard'))->getName();
|
||||
$persisted = ['readable' => is_array($data), 'stored_web_user' => is_array($data) ? ($data[$guardKey] ?? null) : null];
|
||||
}
|
||||
$cookies = [];
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
$cookies[] = ['name' => $cookie->getName(), 'domain' => $cookie->getDomain(), 'path' => $cookie->getPath(),
|
||||
'secure' => $cookie->isSecure(), 'http_only' => $cookie->isHttpOnly(), 'same_site' => $cookie->getSameSite()];
|
||||
}
|
||||
$middleware = $request->route() ? app('router')->gatherRouteMiddleware($request->route()) : [];
|
||||
$handlerDirectory = null;
|
||||
if ($request->hasSession() && $request->session()->getHandler() instanceof \Illuminate\Session\FileSessionHandler) {
|
||||
$property = new \ReflectionProperty(\Illuminate\Session\FileSessionHandler::class, 'path');
|
||||
$handlerDirectory = $property->getValue($request->session()->getHandler());
|
||||
}
|
||||
$entry = ['at_utc' => gmdate('c'), 'trace' => $trace, 'method' => $request->method(), 'path' => '/'.$request->path(),
|
||||
'origin' => $request->getSchemeAndHttpHost(), 'cookie_name_counts' => $names,
|
||||
'inertia' => $request->headers->has('X-Inertia'), 'json_expected' => $request->expectsJson(),
|
||||
'qa_restored_account_rejected' => $request->attributes->get('qa_restored_account_rejected', false),
|
||||
'loaded' => $request->attributes->get('qa_auth_loaded'), 'after' => $state, 'persisted' => $persisted,
|
||||
'status' => $response->getStatusCode(), 'redirect' => self::destination($response->headers->get('Location') ?? $response->headers->get('X-Inertia-Location')),
|
||||
'set_cookie_attributes' => $cookies, 'default_guard' => config('auth.defaults.guard'), 'fortify_guard' => config('fortify.guard'),
|
||||
'session_driver' => config('session.driver'), 'lifetime_minutes' => config('session.lifetime'),
|
||||
'handler_directory_matches_config' => $handlerDirectory === config('session.files'),
|
||||
'middleware' => $middleware,
|
||||
'key_fingerprint' => hash('sha256', app('encrypter')->getKey()),
|
||||
'qa_target' => config('database.connections.pgsql.database').':'.config('database.connections.pgsql.port'),
|
||||
'config_cached' => app()->configurationIsCached()];
|
||||
$file = storage_path('app/private/investmoz-v2/portal-audit/auth-session-trace-qa.jsonl');
|
||||
if (! is_file($file) || filesize($file) < 5 * 1024 * 1024) {
|
||||
file_put_contents($file, json_encode($entry, JSON_THROW_ON_ERROR).PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
$response->headers->set('X-Investmoz-QA-Auth-Trace', $trace);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
class PortalQaSessionTrace
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->attributes->has('qa_auth_trace')) {
|
||||
$request->attributes->set('qa_auth_loaded', PortalQaAuthTrace::state($request));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// One stable key for the already encrypted isolated restore. Never generate/rotate it here.
|
||||
function portalQaEnvironment(string $sessionDriver = 'array'): array
|
||||
{
|
||||
$root = dirname(__DIR__);
|
||||
$backup = json_decode(file_get_contents($root.'/storage/app/private/investmoz-v2/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $backup['restored_target'];
|
||||
if (! ($backup['restore_verified'] ?? false) || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/D', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Verified isolated QA required.');
|
||||
}
|
||||
$cache = $root.'/storage/framework/cache/portal-investor-qa-config.php';
|
||||
if (is_file($cache)) {
|
||||
throw new RuntimeException('QA config cache must be reconciled before use; active cache is untouched.');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_URL' => 'http://127.0.0.1:18097', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
'APP_PREVIOUS_KEYS' => '', 'APP_CONFIG_CACHE' => $cache, 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
|
||||
'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => $sessionDriver,
|
||||
'SESSION_COOKIE' => 'opportunity_resources_qa', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
function portalQaAssertTarget(array $target): object
|
||||
{
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if (! app()->environment('testing') || $identity->database !== $target['database'] || (int) $identity->port !== 55441
|
||||
|| config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url')
|
||||
|| app()->configurationIsCached() || config('app.key') !== getenv('APP_KEY')) {
|
||||
throw new RuntimeException('QA configuration/actual target mismatch; refused before writes.');
|
||||
}
|
||||
|
||||
return $identity;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Real QA MFA enrollment/login and scenario A. Never export credentials or request bodies."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
diagnosis = json.loads((private / 'mfa-key-diagnosis-qa.json').read_text(encoding='utf-8-sig'))
|
||||
target = diagnosis['target']
|
||||
assert re.fullmatch(r'desk_restore_\d{8}_\d{6}_test', target['database']) and target['port'] == 55441
|
||||
BASE = 'http://127.0.0.1:18097'
|
||||
events = []
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, label):
|
||||
self.label = label
|
||||
self.jar = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
|
||||
self.call('GET', '/login', browser=True)
|
||||
|
||||
def identity(self):
|
||||
values = [c.value for c in self.jar if c.name == 'opportunity_resources_qa']
|
||||
assert len(values) == 1
|
||||
return hashlib.sha256(values[0].encode()).digest() # Only compared in memory.
|
||||
|
||||
def call(self, method, path, data=None, status=200, browser=False, extra=None):
|
||||
assert path.startswith('/') and not path.startswith('//')
|
||||
headers = {'Accept': 'text/html' if browser else 'application/json', 'Content-Type': 'application/json'}
|
||||
for cookie in self.jar:
|
||||
if cookie.name == 'XSRF-TOKEN':
|
||||
headers['X-XSRF-TOKEN'] = urllib.parse.unquote(cookie.value)
|
||||
headers.update(extra or {})
|
||||
req = urllib.request.Request(BASE + path, headers=headers, data=None if data is None else json.dumps(data).encode(), method=method)
|
||||
try:
|
||||
response = self.opener.open(req)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
assert response.headers.get('X-Investmoz-QA-Target') == target['database'] + ':55441', 'QA target changed: abort'
|
||||
assert response.headers.get('X-Investmoz-QA-Key-Fingerprint') == diagnosis['qa_key_fingerprint'], 'QA web/CLI key mismatch: abort'
|
||||
assert response.headers.get('X-Investmoz-QA-Config-Cache') == 'bypassed', 'QA config cache mismatch: abort'
|
||||
raw = response.read()
|
||||
assert response.status == status, f'{method} {path}: expected {status}, observed {response.status}; response body withheld'
|
||||
events.append({'actor': self.label, 'method': method, 'url': path, 'status': response.status, 'qa_target_and_key_verified': True})
|
||||
return raw if browser else (json.loads(raw) if raw else None)
|
||||
|
||||
def login(self, credentials, mfa=False):
|
||||
result = self.call('POST', '/login', {k: credentials[k] for k in ['email', 'password']})
|
||||
if mfa:
|
||||
assert result['two_factor'] is True
|
||||
self.call('GET', '/investimentos', status=401)
|
||||
self.call('POST', '/two-factor-challenge', {'code': otp(credentials['totp_secret'])}, status=204)
|
||||
return result
|
||||
|
||||
|
||||
def otp(secret):
|
||||
digest = hmac.new(base64.b32decode(secret), struct.pack('>Q', int(time.time()) // 30), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 15
|
||||
return f'{(struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7fffffff) % 1000000:06d}'
|
||||
|
||||
|
||||
actor_file = private / 'investor-mfa-qa-private.json'
|
||||
credentials = json.loads(actor_file.read_text(encoding='utf-8-sig'))
|
||||
owner = Session('synthetic-investor-MFA') # GET verifies isolation before writes.
|
||||
if 'totp_secret' not in credentials:
|
||||
owner.login(credentials)
|
||||
owner.call('POST', '/user/confirm-password', {'password': credentials['password']}, status=201)
|
||||
owner.call('POST', '/user/two-factor-authentication')
|
||||
credentials['totp_secret'] = owner.call('GET', '/user/two-factor-secret-key')['secretKey']
|
||||
actor_file.write_text(json.dumps(credentials, indent=2), encoding='utf-8')
|
||||
owner.call('POST', '/user/confirmed-two-factor-authentication', {'code': otp(credentials['totp_secret'])})
|
||||
recovery = owner.call('GET', '/user/two-factor-recovery-codes')
|
||||
assert len(recovery) == 8
|
||||
del recovery
|
||||
owner.call('POST', '/logout', status=204)
|
||||
owner = Session('synthetic-investor-MFA-enrolled')
|
||||
owner.login(credentials, mfa=True)
|
||||
owner.call('GET', '/investimentos')
|
||||
|
||||
case = owner.call('POST', '/investimentos', {'title': 'Cenário A MFA QA — sem terra nem financiamento externo',
|
||||
'client_key': '61000919-0000-4000-8000-000000000018', 'reuse_consent': True}, status=201)['id']
|
||||
url = '/investimentos/' + case
|
||||
before = owner.call('GET', url)
|
||||
assert len(before['steps']) == 19 and len(before['phases']) == 5
|
||||
prior_session = owner.identity()
|
||||
owner.call('POST', '/logout', status=204)
|
||||
owner.call('GET', url, status=401)
|
||||
owner = Session('synthetic-investor-MFA-new-session')
|
||||
owner.login(credentials, mfa=True)
|
||||
assert owner.identity() != prior_session
|
||||
view = owner.call('GET', url)
|
||||
assert view['case']['id'] == case and view['case']['title'] == before['case']['title']
|
||||
if view['facts']['land_needed'] == 'unknown':
|
||||
owner.call('POST', url + '/aplicabilidade', {'version': view['case']['version'], 'land_needed': 'no', 'external_funding': 'no',
|
||||
'viability': 'proceed', 'reason': 'Cenário A sintético QA com MFA; sem decisão externa.'})
|
||||
draft = owner.call('GET', '/api/v1/simulation-drafts/current?context=' + case)['data']
|
||||
if not draft:
|
||||
financial = {'version': 1, 'data': {'budgetPeriod': 'Mensal', 'loan': {'principal': '100000', 'rate': '0', 'months': '24', 'rateType': 'nominal', 'periodMonths': '1', 'gracePeriods': '0', 'upfrontFee': '0', 'recurringFee': '0'},
|
||||
'breakEven': {'fixed': '1000', 'price': '100', 'variable': '50'}, 'funding': {'total': '100000', 'secured': '100000'},
|
||||
'incomes': [{'label': 'Receita sintética QA', 'amount': '100'}], 'expenses': [{'label': 'Despesa sintética QA', 'amount': '50'}]}}
|
||||
draft = owner.call('POST', '/api/v1/simulation-drafts', {'context': case, 'personal_snapshot': {'fiscal_residency_country': 'PT'},
|
||||
'economic_snapshot': {'purpose': 'simulate_only', 'project_title': 'A MFA QA', 'project_summary': 'Descrição sintética A MFA QA', 'currency': 'MZN',
|
||||
'funds_origin_countries': ['PT', 'ZA'], 'financial_tools_snapshot': json.dumps(financial)}}, status=201, extra={'Idempotency-Key': str(uuid.uuid4())})['data']
|
||||
view = owner.call('GET', url)
|
||||
result = owner.call('POST', url + '/resultados', {'version': view['case']['version'], 'draft_id': draft['id'], 'draft_version': draft['version_no']})['result']
|
||||
view = owner.call('GET', url)
|
||||
if not view['requests']:
|
||||
owner.call('POST', '/balcao-virtual/dossiers/' + case + '/pedidos', {'service_version_id': view['services'][0]['id'], 'client_key': str(uuid.uuid4())}, status=201)
|
||||
view = owner.call('GET', url)
|
||||
if not view['tasks']:
|
||||
owner.call('POST', url + '/tarefas', {'version': view['case']['version'], 'code': 'S02', 'title': 'Mercado independente A MFA QA',
|
||||
'due_at': (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(), 'consent': True}, status=201)
|
||||
task = owner.call('GET', url)['tasks'][0]
|
||||
manager = Session('manager-QA')
|
||||
manager.login({'email': 'opportunity-manager@example.invalid', 'password': 'Opportunity-QA-2026!'})
|
||||
installation = manager.call('GET', '/management/balcao-install')
|
||||
assert manager.call('GET', url)['results'] == []
|
||||
manager.call('GET', '/api/v1/simulation-drafts/' + draft['id'], status=404)
|
||||
for state in ['in_progress', 'done']:
|
||||
task = next(t for t in manager.call('GET', url)['tasks'] if t['id'] == task['id'])
|
||||
if task['status'] != 'done':
|
||||
manager.call('POST', '/balcao-virtual/dossiers/' + case + '/tarefas/' + task['id'], {'version': task['version_no'], 'status': state,
|
||||
'reason': 'Acompanhamento interno sintético A com MFA.'})
|
||||
owner.call('POST', '/logout', status=204)
|
||||
owner = Session('synthetic-investor-MFA-final-session')
|
||||
owner.login(credentials, mfa=True)
|
||||
view = owner.call('GET', url)
|
||||
recovered = next(r for r in view['results'] if r['id'] == result['id'])
|
||||
assert recovered['draft_id'] == draft['id'] and view['tasks'][0]['status'] == 'done' and view['requests'][0]['status'] == 'draft'
|
||||
steps = {s['code']: s for s in view['steps']}
|
||||
assert steps['S10']['state'] == 'dispensed' and steps['S13']['applicability'] == 'not_needed_for_preparation'
|
||||
old_ab = json.loads((private / 'investor-path-http-qa.json').read_text(encoding='utf-8-sig'))['scenarios']
|
||||
contexts = owner.call('GET', '/investimentos')['contexts']
|
||||
assert all(c['id'] not in {entry['id'] for entry in old_ab} for c in contexts)
|
||||
for entry in old_ab:
|
||||
owner.call('GET', entry['url'], status=403)
|
||||
owner.call('GET', '/api/v1/simulation-drafts/' + entry['draft_id'], status=404)
|
||||
owner.call('POST', '/logout', status=204) # No completed test session left logged in.
|
||||
|
||||
negative = json.loads((private / 'mfa-negative-qa-private.json').read_text(encoding='utf-8-sig'))
|
||||
message = 'Não foi possível verificar a autenticação desta conta. Inicie sessão novamente ou contacte o suporte.'
|
||||
for field, value in [('code', '123456'), ('recovery_code', 'synthetic-only')]:
|
||||
rejected = Session('negative-wrong-key-' + field)
|
||||
assert rejected.login(negative)['two_factor'] is True
|
||||
old = rejected.identity()
|
||||
response = rejected.call('POST', '/two-factor-challenge', {field: value}, status=422)
|
||||
assert response['message'] == message and field in response['errors']
|
||||
assert rejected.identity() != old
|
||||
rejected.call('GET', '/investimentos', status=401)
|
||||
rejected.call('POST', '/two-factor-challenge', {field: value}, status=422) # Pending challenge was removed.
|
||||
rejected = Session('negative-browser-response')
|
||||
assert rejected.login(negative)['two_factor'] is True
|
||||
page = rejected.call('POST', '/two-factor-challenge', {'code': '123456'}, browser=True)
|
||||
match = re.search(rb'<script[^>]*data-page="[^"]+"[^>]*>(.*?)</script>', page, re.DOTALL)
|
||||
assert match
|
||||
props = json.loads(match.group(1).decode())['props']
|
||||
assert props['errors']['email'] == message
|
||||
rejected.call('GET', '/investimentos', status=401)
|
||||
proof = {'captured_at': datetime.now(timezone.utc).isoformat(), 'target': target, 'base_url': BASE, 'actor_id': credentials['actor_id'],
|
||||
'code_hash': installation['code_hash'], 'auth_code_sha256': hashlib.sha256((root / 'bootstrap/app.php').read_bytes()).hexdigest(),
|
||||
'qa_environment_sha256': hashlib.sha256((root / 'tools/portal-qa-environment.php').read_bytes()).hexdigest(),
|
||||
'enrollment': 'Fortify confirm-password, enable, secret-key, confirm; eight recovery codes; no direct secret injection',
|
||||
'stable_key_matches_cli_web': True, 'config_cache_bypassed': True, 'login_mfa_logout_new_session_passed': True,
|
||||
'other_accounts_AB_and_drafts_denied': True,
|
||||
'decryption_failures_controlled_without_authentication': ['secret', 'recovery_codes', 'browser_redirect_message'],
|
||||
'scenario_A': {'id': case, 'url': url, 'draft_id': draft['id'], 'result_id': result['id'], 'new_mfa_session_recovered': True,
|
||||
'tool_balcao_internal_followup_passed': True, 'recovered_financial_result': {k: recovered[k] for k in ['id', 'engine', 'financial_snapshot', 'draft_id', 'draft_version']}},
|
||||
'requests': events, 'request_headers_and_bodies_exported': False, 'interface_observed': False, 'active_writes': 0, 'candidate_fixed': False}
|
||||
path = private / 'mfa-http-qa.json'
|
||||
if path.exists():
|
||||
path.rename(private / ('mfa-http-qa-' + datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + '.json'))
|
||||
path.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'target': target, 'actor_id': credentials['actor_id'], 'login_mfa_logout_new_session_passed': True, 'controlled_failure_without_authentication': True,
|
||||
'scenario_A_url': BASE + url, 'interface_observed': False, 'active_writes': 0}))
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
// QA diagnostics and synthetic actor only. Never print keys, secrets, cookies or OTPs.
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once $root.'/tools/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = portalQaAssertTarget($target); // Before any write or session invalidation.
|
||||
$directory = $root.'/storage/app/private/investmoz-v2/portal-audit';
|
||||
$original = DB::table('users')->where('id', 13)->first();
|
||||
$originalHash = hash('sha256', json_encode($original, JSON_THROW_ON_ERROR));
|
||||
$mode = $argv[1] ?? 'diagnose';
|
||||
if ($mode === 'diagnose') {
|
||||
$env = Dotenv\Dotenv::parse(file_get_contents($root.'/.env'));
|
||||
$sourceKey = $env['APP_KEY'] ?? '';
|
||||
$sourceKey = str_starts_with($sourceKey, 'base64:') ? base64_decode(substr($sourceKey, 7)) : $sourceKey;
|
||||
$checks = [];
|
||||
foreach (['two_factor_secret', 'two_factor_recovery_codes'] as $field) {
|
||||
foreach (['qa' => app('encrypter'), 'source_env' => new Encrypter($sourceKey, config('app.cipher'))] as $label => $encrypter) {
|
||||
try {
|
||||
$value = $encrypter->decrypt($original->$field);
|
||||
$checks[$field][$label] = is_string($value);
|
||||
unset($value);
|
||||
} catch (DecryptException $exception) {
|
||||
$checks[$field][$label] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
$proof = ['target' => $identity, 'account_id' => 13, 'synthetic_account' => str_ends_with($original->email, '@example.invalid'),
|
||||
'encrypted_field_checks' => $checks, 'qa_key_fingerprint' => hash('sha256', app('encrypter')->getKey()),
|
||||
'source_key_differs' => ! hash_equals($sourceKey, app('encrypter')->getKey()), 'config_cache_loaded' => $app->configurationIsCached(),
|
||||
'active_config_cache_touched' => false, 'source_env_file_sha256' => hash_file('sha256', $root.'/.env'),
|
||||
'cache_store' => config('cache.default'), 'session_driver_web' => 'file', 'account_13_row_sha256' => $originalHash,
|
||||
'active_writes' => 0, 'captured_at' => gmdate('c')];
|
||||
unset($env, $sourceKey);
|
||||
file_put_contents($directory.'/mfa-key-diagnosis-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
} elseif ($mode === 'invalidate') {
|
||||
$sessions = realpath($root.'/storage/app/private/investmoz-v2/qa-opportunity-resources-sessions');
|
||||
if (! $sessions || ! str_starts_with($sessions, realpath($root.'/storage/app/private').DIRECTORY_SEPARATOR)) {
|
||||
throw new RuntimeException('Private QA session root required.');
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (new DirectoryIterator($sessions) as $file) {
|
||||
if (! $file->isFile() || $file->isLink() || ! preg_match('/^[a-zA-Z0-9]{40}$/D', $file->getFilename())) {
|
||||
continue;
|
||||
}
|
||||
$data = json_decode(file_get_contents($file->getPathname()), true, 512, JSON_THROW_ON_ERROR);
|
||||
$belongs = is_array($data) && (int) ($data['login']['id'] ?? $data['login.id'] ?? 0) === 13;
|
||||
foreach (is_array($data) ? $data : [] as $key => $value) {
|
||||
$belongs = $belongs || (str_starts_with((string) $key, 'login_web_') && (int) $value === 13);
|
||||
}
|
||||
if ($belongs) {
|
||||
$path = realpath($file->getPathname());
|
||||
if (! $path || ! str_starts_with($path, $sessions.DIRECTORY_SEPARATOR)) {
|
||||
throw new RuntimeException('Session path outside QA refused.');
|
||||
}
|
||||
if (! unlink($path)) {
|
||||
throw new RuntimeException('QA session invalidation failed.');
|
||||
}
|
||||
$removed++;
|
||||
}
|
||||
}
|
||||
$proof = ['target' => $identity, 'account_id' => 13, 'qa_sessions_invalidated' => $removed, 'account_row_unchanged' => hash_equals($originalHash, hash('sha256', json_encode(DB::table('users')->where('id', 13)->first(), JSON_THROW_ON_ERROR))),
|
||||
'session_ids_exported' => false, 'active_writes' => 0, 'captured_at' => gmdate('c')];
|
||||
file_put_contents($directory.'/mfa-session-invalidation-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof).PHP_EOL;
|
||||
} elseif ($mode === 'prepare') {
|
||||
$file = $directory.'/investor-mfa-qa-private.json';
|
||||
$credentials = is_file($file) ? json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR)
|
||||
: ['email' => 'investor-mfa-qa@example.invalid', 'password' => 'QA-'.bin2hex(random_bytes(24))];
|
||||
$actor = User::firstOrCreate(['email' => $credentials['email']], ['name' => 'Synthetic investor MFA QA', 'password' => Hash::make($credentials['password'])]);
|
||||
$actor->forceFill(['email_verified_at' => $actor->email_verified_at ?? now()])->save();
|
||||
$credentials['actor_id'] = $actor->id;
|
||||
file_put_contents($file, json_encode($credentials, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
chmod($file, 0600);
|
||||
$negativeFile = $directory.'/mfa-negative-qa-private.json';
|
||||
$negative = is_file($negativeFile) ? json_decode(file_get_contents($negativeFile), true, 512, JSON_THROW_ON_ERROR)
|
||||
: ['email' => 'mfa-negative-qa@example.invalid', 'password' => 'QA-'.bin2hex(random_bytes(24))];
|
||||
$failedActor = User::firstOrCreate(['email' => $negative['email']], ['name' => 'Synthetic wrong-key MFA negative control QA', 'password' => Hash::make($negative['password'])]);
|
||||
if ($failedActor->two_factor_secret === null) {
|
||||
$foreign = new Encrypter(random_bytes(32), 'AES-256-CBC');
|
||||
$failedActor->forceFill(['email_verified_at' => now(), 'two_factor_secret' => $foreign->encrypt('SYNTHETICQAONLY'),
|
||||
'two_factor_recovery_codes' => $foreign->encrypt('["synthetic-only"]'), 'two_factor_confirmed_at' => now()])->save();
|
||||
}
|
||||
$negative['actor_id'] = $failedActor->id;
|
||||
file_put_contents($negativeFile, json_encode($negative, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
chmod($negativeFile, 0600);
|
||||
echo json_encode(['target' => $identity, 'actor_id' => $actor->id, 'synthetic' => true, 'mfa_enrollment' => 'must use normal authenticated Fortify endpoints',
|
||||
'negative_control_actor_id' => $failedActor->id, 'original_account_13_preserved' => hash_equals($originalHash, hash('sha256', json_encode(DB::table('users')->where('id', 13)->first(), JSON_THROW_ON_ERROR))), 'active_writes' => 0]).PHP_EOL;
|
||||
} elseif ($mode === 'verify') {
|
||||
$diagnosis = json_decode(file_get_contents($directory.'/mfa-key-diagnosis-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$credentials = json_decode(file_get_contents($directory.'/investor-mfa-qa-private.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$actor = User::findOrFail($credentials['actor_id']);
|
||||
$secret = app('encrypter')->decrypt($actor->two_factor_secret);
|
||||
$codes = json_decode(app('encrypter')->decrypt($actor->two_factor_recovery_codes), true, 512, JSON_THROW_ON_ERROR);
|
||||
$proof = ['target' => $identity, 'actor_id' => $actor->id, 'mfa_confirmed' => $actor->two_factor_confirmed_at !== null,
|
||||
'normal_web_secret_decrypts_in_cli' => hash_equals($credentials['totp_secret'], $secret), 'recovery_code_count' => count($codes),
|
||||
'key_fingerprint_unchanged' => hash_equals($diagnosis['qa_key_fingerprint'], hash('sha256', app('encrypter')->getKey())),
|
||||
'source_env_unchanged' => hash_equals($diagnosis['source_env_file_sha256'], hash_file('sha256', $root.'/.env')),
|
||||
'original_account_13_unchanged' => hash_equals($diagnosis['account_13_row_sha256'], $originalHash),
|
||||
'key_generated_or_rotated' => false, 'mfa_disabled' => false, 'active_writes' => 0, 'captured_at' => gmdate('c')];
|
||||
unset($secret, $codes, $credentials);
|
||||
if (! $proof['mfa_confirmed'] || ! $proof['normal_web_secret_decrypts_in_cli'] || ! $proof['key_fingerprint_unchanged']
|
||||
|| ! $proof['source_env_unchanged'] || ! $proof['original_account_13_unchanged'] || $proof['recovery_code_count'] !== 8) {
|
||||
throw new RuntimeException('QA MFA persistence/config/preservation failed.');
|
||||
}
|
||||
file_put_contents($directory.'/mfa-persistence-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof).PHP_EOL;
|
||||
} else {
|
||||
throw new RuntimeException('Unknown QA operation.');
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Real upload/scans/derivatives/download through the QA web PHP process; no visual claim."""
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
private = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
backup = json.loads((private.parent / 'opportunity-resources-backup-proof.json').read_text(encoding='utf-8-sig'))
|
||||
target = backup['restored_target']
|
||||
assert backup['restore_verified'] and re.fullmatch(r'desk_restore_\d{8}_\d{6}_test', target['database']) and int(target['port']) == 55441
|
||||
expected = target['database'] + ':55441'
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
events = []
|
||||
asset_version = subprocess.check_output(['php', '-r', "echo hash_file('xxh128','public/build/manifest.json');"], cwd=root, text=True).strip()
|
||||
|
||||
|
||||
def call(method, path, data=None, status=200, binary=False, upload=None, inertia=False, html=False):
|
||||
assert path.startswith('/') and not path.startswith('//')
|
||||
headers = {'Accept': 'text/html' if html else 'application/json', 'Content-Type': 'application/json'}
|
||||
for cookie in jar:
|
||||
if cookie.name == 'XSRF-TOKEN':
|
||||
headers['X-XSRF-TOKEN'] = urllib.parse.unquote(cookie.value)
|
||||
if inertia:
|
||||
headers['X-Inertia'] = 'true'
|
||||
headers['X-Inertia-Version'] = asset_version
|
||||
body = None if data is None else json.dumps(data).encode()
|
||||
if upload:
|
||||
boundary = 'QA-' + uuid.uuid4().hex
|
||||
pieces = []
|
||||
for key, value in data.items():
|
||||
pieces.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n'.encode() + (json.dumps(value).encode() if isinstance(value, dict) else str(value).encode()) + b'\r\n')
|
||||
name, mime, content = upload
|
||||
pieces.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\nContent-Type: {mime}\r\n\r\n'.encode() + content + b'\r\n')
|
||||
body = b''.join(pieces) + f'--{boundary}--\r\n'.encode()
|
||||
headers['Content-Type'] = 'multipart/form-data; boundary=' + boundary
|
||||
req = urllib.request.Request('http://127.0.0.1:18097' + path, headers=headers, data=body, method=method)
|
||||
try:
|
||||
response = opener.open(req)
|
||||
except urllib.error.HTTPError as e:
|
||||
response = e
|
||||
assert response.headers.get('X-Investmoz-QA-Target') == expected
|
||||
raw = response.read()
|
||||
assert response.status == status, f'{method} {path}: expected {status}, observed {response.status}; response body withheld'
|
||||
events.append({'method': method, 'url': path.split('?')[0], 'status': response.status, 'qa_target_verified': True})
|
||||
return (raw, response.headers) if binary else None if html or not raw else json.loads(raw)
|
||||
|
||||
|
||||
call('GET', '/login', html=True)
|
||||
call('POST', '/login', {'email': 'opportunity-manager@example.invalid', 'password': 'Opportunity-QA-2026!'})
|
||||
installation = call('GET', '/management/opportunity-resources-install')
|
||||
assert installation['installed'] and installation['imageProcessingAvailable']
|
||||
draft = call('POST', '/management/opportunities/drafts', {'creation_key': str(uuid.uuid4()), 'reason': 'Ensaio de operações documentais web com ClamD real em QA'}, status=201)
|
||||
id = draft['id']
|
||||
base = f'/management/opportunities/{id}/resources'
|
||||
|
||||
|
||||
def props():
|
||||
return call('GET', base, inertia=True)['props']
|
||||
|
||||
|
||||
def input(payload):
|
||||
return {'etag': props()['etag'], 'creation_key': str(uuid.uuid4()), 'payload': payload, 'reason': 'Ensaio real de gravação e recuperação de recursos privados QA'}
|
||||
|
||||
|
||||
document_bytes = b'%PDF-1.7\nSynthetic web QA only\n%%EOF'
|
||||
document = call('POST', base + '/documents', input({'title': 'Synthetic web QA document'}), upload=('qa.pdf', 'application/pdf', document_bytes), status=201)
|
||||
chunk = lambda name, content: struct.pack('>I', len(content)) + name + content + struct.pack('>I', zlib.crc32(name + content))
|
||||
png = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 1600, 1600, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(bytes(1600 * 4801))) + chunk(b'IEND', b'')
|
||||
image = call('POST', base + '/images', input({'title': 'Synthetic web QA image', 'caption': 'Synthetic square no active data', 'alt_text': 'Synthetic square for QA only', 'rights_status': 'owned'}), upload=('qa.png', 'image/png', png), status=201)
|
||||
original = next(r for r in props()['resources']['images'] if r['id'] == image['id'])
|
||||
assert original['file']['malware_scan_status'] == 'clean' and original['file']['malware_engine'] == 'ClamAV'
|
||||
call('POST', base + '/images/' + image['id'] + '/prepare', {'etag': props()['etag'], 'version': original['version'], 'reason': 'Preparar três derivadas na execução PHP web QA'})
|
||||
p = props()
|
||||
prepared = next(r for r in p['resources']['images'] if r['id'] == image['id'])
|
||||
assert len(prepared['derivativePreviews']) == 3
|
||||
assert [r['width'] for r in prepared['derivativePreviews']] == [320, 640, 1280]
|
||||
derivatives = []
|
||||
for derived in prepared['derivativePreviews']:
|
||||
url = urllib.parse.urlsplit(derived['url'])
|
||||
assert url.hostname == '127.0.0.1' and url.port == 18097
|
||||
webp, headers = call('GET', url.path + '?' + url.query, binary=True)
|
||||
assert headers['Content-Type'] == 'image/webp' and webp[:4] == b'RIFF' and webp[8:12] == b'WEBP' and b'EXIF' not in webp
|
||||
call('GET', url.path, status=403)
|
||||
derivatives.append({'document_id': derived['document_id'], 'width': derived['width'], 'sha256': hashlib.sha256(webp).hexdigest(), 'clean_signed_preview': True})
|
||||
for kind, resource, expected_bytes in [('documents', document, document_bytes), ('images', image, png)]:
|
||||
downloaded, _ = call('GET', base + '/' + kind + '/' + resource['id'] + '/download', binary=True)
|
||||
assert downloaded == expected_bytes
|
||||
call('POST', '/logout', status=204)
|
||||
jar.clear()
|
||||
call('GET', '/login', html=True)
|
||||
call('POST', '/login', {'email': 'investor-path-outsider-qa@example.invalid', 'password': 'Outsider-Path-QA-2026!'})
|
||||
call('GET', base + '/documents/' + document['id'] + '/download', status=403)
|
||||
call('GET', url.path + '?' + url.query, status=403)
|
||||
call('POST', '/logout', status=204)
|
||||
jar.clear()
|
||||
call('GET', '/login', html=True)
|
||||
call('POST', '/login', {'email': 'opportunity-manager@example.invalid', 'password': 'Opportunity-QA-2026!'})
|
||||
recovered = next(r for r in props()['resources']['images'] if r['id'] == image['id'])
|
||||
assert len(recovered['derivativePreviews']) == 3 and recovered['document_id'] == original['document_id']
|
||||
recovered_bytes, _ = call('GET', base + '/documents/' + document['id'] + '/download', binary=True)
|
||||
assert recovered_bytes == document_bytes
|
||||
proof = {'captured_at': datetime.now(timezone.utc).isoformat(), 'target': target, 'code_hash': installation['code_hash'], 'asset_version': asset_version, 'opportunity_id': id, 'document_resource_id': document['id'], 'image_resource_id': image['id'], 'original_image_document_id': original['document_id'], 'original_image_sha256': hashlib.sha256(png).hexdigest(), 'web_gd_available': True, 'original_image_engine': original['file']['malware_engine'], 'derivatives': derivatives, 'requests': events, 'new_authenticated_session_recovered': True, 'api_persistence_passed': True, 'interface_observed': False, 'published': False, 'active_writes': 0}
|
||||
destination = private / 'resources-real-http-qa.json'
|
||||
if destination.exists():
|
||||
destination.rename(private / ('resources-real-http-qa-' + datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + '.json'))
|
||||
destination.write_text(json.dumps(proof, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'target': target, 'opportunity_id': id, 'web_gd_available': True, 'real_original_scanned': True, 'signed_derivatives_downloaded': len(derivatives), 'foreign_actor_denied': True, 'interface_observed': False, 'published': False, 'active_writes': 0}))
|
||||
Reference in New Issue
Block a user