Files
Investidor-V2/tools/portal-onboarding-governance-qa.py
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

132 lines
11 KiB
Python

"""Build a separate QA source tree. Never replace application files served by Apache."""
from pathlib import Path
from datetime import datetime, timezone
import hashlib, json, secrets, shutil, subprocess
ROOT = Path(__file__).resolve().parent.parent
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
STAMP = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
STAGE = PRIVATE / ('onboarding-qa-' + STAMP)
assert not STAGE.exists() and STAGE.parent == PRIVATE
STAGE.mkdir()
for directory in ['app', 'config', 'database', 'routes', 'resources', 'tests']:
shutil.copytree(ROOT / directory, STAGE / directory)
for file in ['composer.json', 'composer.lock', 'artisan', 'public/index.php', 'bootstrap/app.php', 'bootstrap/providers.php']:
dest = STAGE / file
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / file, dest)
shutil.copytree(ROOT / 'public/build', STAGE / 'public/build')
for directory in ['bootstrap/cache', 'storage/logs', 'storage/app/private', 'storage/framework/cache/data', 'storage/framework/sessions', 'storage/framework/views']:
(STAGE / directory).mkdir(parents=True, exist_ok=True)
changed = {}
def replace(file, old, new):
dest = STAGE / file
current = dest.read_text(encoding='utf-8')
assert current.count(old) == 1, (file, old[:80])
dest.write_text(current.replace(old, new), encoding='utf-8')
changed[file] = hashlib.sha256((ROOT / file).read_bytes()).hexdigest() if (ROOT / file).exists() else None
replace('config/portal.php', " 'profile_access_enabled' => (bool) env('PORTAL_PROFILE_ACCESS_ENABLED', false),", " 'profile_access_enabled' => (bool) env('PORTAL_PROFILE_ACCESS_ENABLED', false),\n // Prepare and audit assignments without opening operational opportunity routes.\n 'profile_administration_enabled' => (bool) env('PORTAL_PROFILE_ADMINISTRATION_ENABLED', false),")
replace('app/Services/PortalProfileAccess.php', " return config('portal.profile_access_enabled') && Schema::hasTable('portal_role_assignments')", " return config('portal.profile_access_enabled') && $this->installed();\n }\n\n public function governanceAvailable(): bool\n {\n return (config('portal.profile_administration_enabled') || config('portal.profile_access_enabled')) && $this->installed();\n }\n\n public function installed(): bool\n {\n return Schema::hasTable('portal_role_assignments')")
replace('app/Services/PortalProfileAccess.php', "if (! $this->available() || ! $actor->hasVerifiedEmail()) {\n return [];", "if (! $this->governanceAvailable() || ! $actor->hasVerifiedEmail()) {\n return [];")
replace('app/Services/PortalProfileAccess.php', "return $this->available() && $actor->hasVerifiedEmail() && DB::table('portal_superuser_pair')", "return $this->governanceAvailable() && $actor->hasVerifiedEmail() && DB::table('portal_superuser_pair')")
replace('app/Services/PortalProfileAccess.php', " public function operational(User $actor, string $action, ?int $recordId = null): bool\n {", " public function operational(User $actor, string $action, ?int $recordId = null): bool\n {\n if (! $this->available()) {\n return false;\n }")
replace('app/Services/PortalProfileAccess.php', " public function canView(User $actor, Opportunity $record): bool\n {", " public function canView(User $actor, Opportunity $record): bool\n {\n if ($record->owner_user_id !== null && ! $this->available()) {\n return false;\n }")
replace('app/Services/PortalProfileAccess.php', "if (! $actor || ! $this->available() || ! $actor->hasVerifiedEmail())", "if (! $actor || ! $this->governanceAvailable() || ! $actor->hasVerifiedEmail())")
replace('app/Services/PortalProfileAccess.php', "'review' => $this->has($actor, 'validator') || $this->has($actor, 'publisher') || $this->has($actor, 'auditor') || $this->superuser($actor) ? '/minhas-oportunidades' : null", "'review' => $this->available() && ($this->has($actor, 'validator') || $this->has($actor, 'publisher') || $this->has($actor, 'auditor') || $this->superuser($actor)) ? '/minhas-oportunidades' : null")
replace('app/Services/PortalProfileAccess.php', "app_path('Http/Middleware/AuditProfileDecision.php'), app_path('Providers/AppServiceProvider.php'),", "app_path('Http/Middleware/AuditProfileDecision.php'), app_path('Http/Middleware/EnsurePortalProfileAdministration.php'), app_path('Providers/AppServiceProvider.php'),")
replace('app/Services/PortalProfileAdministration.php', "$this->access->available() && $actor->hasVerifiedEmail()", "$this->access->governanceAvailable() && $actor->hasVerifiedEmail()")
replace('app/Http/Middleware/PortalProfileSession.php', "$available = $access->available();", "$available = $access->governanceAvailable();")
replace('app/Http/Middleware/PortalProfileSession.php', "config('portal.profile_access_enabled') && ! $available", "(config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available")
for file in ['app/Http/Middleware/ProtectPrivilegedIdentity.php', 'app/Http/Middleware/AuditProfileDecision.php']:
replace(file, "$access->available()", "$access->governanceAvailable()")
replace('routes/web.php', "use App\\Http\\Middleware\\EnsurePortalProfiles;", "use App\\Http\\Middleware\\EnsurePortalProfiles;\nuse App\\Http\\Middleware\\EnsurePortalProfileAdministration;")
replace('routes/web.php', "Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {", "Route::middleware(['auth', 'verified', EnsurePortalProfileAdministration::class])->group(function () {")
replace('routes/web.php', " Route::get('/minhas-oportunidades', [$profiles, 'opportunities']);", "});\n\nRoute::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {\n $profiles = PortalProfileController::class;\n Route::get('/minhas-oportunidades', [$profiles, 'opportunities']);")
new_middleware = STAGE / 'app/Http/Middleware/EnsurePortalProfileAdministration.php'
new_middleware.write_text('''<?php
namespace App\\Http\\Middleware;
use App\\Services\\PortalProfileAccess;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class EnsurePortalProfileAdministration
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('portal.profile_administration_enabled') || config('portal.profile_access_enabled'), 404);
abort_unless(app(PortalProfileAccess::class)->governanceAvailable(), 503, 'Autorização por perfis ainda não instalada.');
return $next($request);
}
}
''', encoding='utf-8')
changed['app/Http/Middleware/EnsurePortalProfileAdministration.php'] = None
# Shared dependencies are read-only; PSR-4 application classes use only this staging tree.
task_stage = str(STAGE / 'vendor').replace("'", "''")
task_vendor = str(ROOT / 'vendor').replace("'", "''")
subprocess.run(['powershell.exe', '-NoProfile', '-Command', f"New-Item -ItemType Junction -Path '{task_stage}' -Target '{task_vendor}' | Out-Null"], check=True, capture_output=True)
(STAGE / 'qa-autoload.php').write_text('''<?php
$loader = require __DIR__.'/vendor/autoload.php';
$loader->setPsr4('App\\\\', __DIR__.'/app');
$loader->setPsr4('Tests\\\\', __DIR__.'/tests');
$loader->setPsr4('Database\\\\Factories\\\\', __DIR__.'/database/factories');
$loader->setPsr4('Database\\\\Seeders\\\\', __DIR__.'/database/seeders');
$loader->setClassMapAuthoritative(false);
return $loader;
''', encoding='utf-8')
# Composer's non-authoritative class map might include project classes: remove these mappings.
autoload = (STAGE / 'qa-autoload.php').read_text()
autoload = autoload.replace("$loader->setPsr4('App", "foreach ($loader->getClassMap() as $class => $path) {\n if (str_starts_with($class, 'App\\\\') || str_starts_with($class, 'Tests\\\\') || str_starts_with($class, 'Database\\\\')) {\n $loader->addClassMap([$class => null]);\n }\n}\n$loader->setPsr4('App", 1)
(STAGE / 'qa-autoload.php').write_text(autoload, encoding='utf-8')
index = (STAGE / 'public/index.php').read_text()
(STAGE / 'public/index.php').write_text(index.replace("require __DIR__.'/../vendor/autoload.php';", "require __DIR__.'/../qa-autoload.php';"), encoding='utf-8')
PHP = 'C:/laragon/bin/php/php-8.5.11-Win32-vs17-x64/php.exe'
prepared = subprocess.run([PHP, str(ROOT / 'tools/portal-onboarding-readiness.php'), 'prepare-tests'], cwd=ROOT, capture_output=True, text=True, check=True)
proof = json.loads(prepared.stdout)
database = proof['isolated_test_database']['database']
assert proof['isolated_test_database']['port'] == 55441 and database.endswith('_test')
key = __import__('base64').b64encode(secrets.token_bytes(32)).decode()
(STAGE / '.env').write_text(f'''APP_NAME="InvestMoz Onboarding QA"
APP_ENV=testing
APP_DEBUG=false
APP_KEY=base64:{key}
APP_URL=http://127.0.0.1:18117
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=55441
DB_DATABASE={database}
DB_USERNAME=investmoz_test
DB_PASSWORD=
DB_URL=
CACHE_STORE=file
SESSION_DRIVER=file
SESSION_COOKIE=investmoz_onboarding_qa_{STAMP}
MAIL_MAILER=array
QUEUE_CONNECTION=sync
INERTIA_SSR_ENABLED=false
PORTAL_PROFILE_ADMINISTRATION_ENABLED=true
PORTAL_PROFILE_ACCESS_ENABLED=false
PORTAL_OPPORTUNITY_DASHBOARD_ACCESS_ENABLED=false
PORTAL_OPPORTUNITY_SPECIALIZED_DRAFTS_ENABLED=false
PORTAL_ORGANIZATION_COUNTRY_ENABLED=true
PORTAL_NOTIFICATIONS_ENABLED=false
''', encoding='utf-8')
configuration = (PRIVATE / 'onboarding-qa-phpunit.xml').read_text()
configuration = configuration.replace(str(ROOT).replace('\\', '/') + '/', str(STAGE).replace('\\', '/') + '/')
configuration = configuration.replace('bootstrap="'+str(STAGE).replace('\\','/')+'/vendor/autoload.php"', 'bootstrap="'+str(STAGE).replace('\\','/')+'/qa-autoload.php"')
configuration = configuration.replace('<php>', '<php><env name="APP_BASE_PATH" value="'+str(STAGE).replace('\\','/')+'" force="true"/><env name="PORTAL_PROFILE_ADMINISTRATION_ENABLED" value="true" force="true"/>')
(STAGE / 'onboarding-phpunit.xml').write_text(configuration, encoding='utf-8')
manifest = [{'path': file, 'before_sha256': baseline, 'after_sha256': hashlib.sha256((STAGE/file).read_bytes()).hexdigest()} for file, baseline in sorted(changed.items())]
assert all(not row['before_sha256'] or hashlib.sha256((ROOT/row['path']).read_bytes()).hexdigest() == row['before_sha256'] for row in manifest)
revision = hashlib.sha256(json.dumps(manifest,sort_keys=True).encode()).hexdigest()
plan = {'at': datetime.now(timezone.utc).isoformat(), 'source': str(STAGE), 'target': proof['isolated_test_database'], 'delta_sha256': revision, 'files': manifest,
'base_candidate': '41caea0f900f', 'active_application_changes': 0, 'active_database_writes': 0, 'new_tables': 0, 'new_columns': 0, 'frontend_changes': False, 'production_build_required': False, 'QA_only': True}
(PRIVATE / 'onboarding-governance-plan.json').write_text(json.dumps(plan,indent=2), encoding='utf-8')
print(json.dumps({'result':'separate_QA_tree_prepared','source':str(STAGE),'target':plan['target'],'delta':revision[:12],'files':len(manifest),'active_changes':0}))