180 lines
16 KiB
Python
180 lines
16 KiB
Python
"""Canonical contracts/workflow through QA HTTP with real Fortify MFA and ClamD. Sanitized output."""
|
|
from portal_profiles_http_session import Session, actors, PRIVATE, BASE, EXPECTED, REASON, events
|
|
import json
|
|
import uuid
|
|
import hashlib
|
|
import zlib
|
|
import struct
|
|
from datetime import datetime, timezone
|
|
|
|
fixtures = json.loads((PRIVATE / 'opportunity-workflow-fixtures-qa.json').read_text())
|
|
old = json.loads((PRIVATE / 'profiles-v1-http-receipts-qa.json').read_text())
|
|
path = PRIVATE / 'opportunity-workflow-http-qa.json'
|
|
proof = {'target': EXPECTED, 'url': BASE, 'started_at': datetime.now(timezone.utc).isoformat(), 'interface_demonstrated': False, 'active_writes': 0, 'cases': []}
|
|
sessions = {role: Session(role).login() for role in ['promoter', 'colleague', 'validator', 'publisher', 'outsider', 'multiple', 'super_first']}
|
|
promoter = sessions['promoter']; validator = sessions['validator']; publisher = sessions['publisher']; superuser = sessions['super_first']
|
|
types = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise', 'concession', 'financing', 'supply', 'other']
|
|
|
|
|
|
def persist():
|
|
proof['events'] = events
|
|
path.write_text(json.dumps(proof, indent=2), encoding='utf-8')
|
|
|
|
|
|
def prepare_roles():
|
|
assignments=superuser.call('GET','/perfis/gestao',inertia=True)['props']['assignments']
|
|
def available(role,access,responsibilities=()):
|
|
return any(g['user_id']==actors[role]['id'] and g['role']==access and g['revoked_at'] is None
|
|
and set(types).issubset(json.loads(g['opportunity_types'])) and set(responsibilities).issubset(json.loads(g['responsibilities'])) for g in assignments)
|
|
if available('promoter','promoter') and available('validator','validator',['validation_coordinator','technical_reviewer','legal_reviewer']) and available('publisher','publisher') and available('multiple','publisher') and available('colleague','validator',['appeal_reviewer']):
|
|
return
|
|
# Additional scope uses the normal request and independent approval, preserving earlier grants.
|
|
request = promoter.call('POST', '/perfil/promotor', {'creation_key': str(uuid.uuid4()), 'representation_type': 'company',
|
|
'organization_id': fixtures['organization_id'], 'opportunity_types': types, 'document_ids': [old['requests']['promoter']['document_id']], 'reason': REASON}, status=201)
|
|
superuser.call('POST', f"/perfis/pedidos/{request['id']}/decisao", {'version': 1, 'decision': 'approved', 'approved_types': types,
|
|
'verified_identity': True, 'verified_representation': True, 'verified_documents': True, 'reason': 'Aprovação de dados estritamente sintéticos QA; não verifica identidade real'})
|
|
for role, access, responsibilities in [('validator','validator',['validation_coordinator','technical_reviewer','legal_reviewer']), ('publisher','publisher',[]), ('multiple','publisher',[]), ('colleague','validator',['appeal_reviewer'])]:
|
|
superuser.call('POST', '/perfis/atribuicoes', {'user_id': actors[role]['id'], 'role': access, 'opportunity_types': types, 'responsibilities': responsibilities, 'reason': REASON}, status=201)
|
|
|
|
|
|
def page(s, id):
|
|
return s.call('GET', f'/minhas-oportunidades/{id}/view', inertia=True)['props']
|
|
|
|
|
|
def transition(s, id, action, status=200, **data):
|
|
p = page(s, id)
|
|
if action=='publish':
|
|
data={'confirm_publication':True,'preview_hash':p['profileContext'].get('approval',{}).get('hash','0'*64),**data}
|
|
return s.call('POST', f'/minhas-oportunidades/{id}/workflow', {'etag': p['etag'], 'action': action, 'reason': REASON, **data}, status=status)
|
|
|
|
|
|
def save(s, id, canonical, status=200):
|
|
p = page(s, id)
|
|
return s.call('PUT', f'/minhas-oportunidades/{id}/draft', {'etag': p['etag'], 'canonical': canonical, 'reason': REASON}, status=status)
|
|
|
|
|
|
def normalized(value):
|
|
if isinstance(value,str): return value.strip()
|
|
if isinstance(value,list): return [normalized(v) for v in value]
|
|
if isinstance(value,dict): return {k:normalized(v) for k,v in value.items()}
|
|
return value
|
|
|
|
|
|
def png(seed):
|
|
def chunk(t, raw):
|
|
return struct.pack('>I', len(raw)) + t + raw + struct.pack('>I', zlib.crc32(t + raw) & 0xffffffff)
|
|
pixels = b''.join(b'\x00' + bytes([20 + seed % 200, 90, 140]) * 512 for _ in range(512))
|
|
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 512, 512, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(pixels)) + chunk(b'IEND', b'')
|
|
|
|
|
|
def create(category):
|
|
draft = promoter.call('POST', '/minhas-oportunidades/drafts', {'creation_key': str(uuid.uuid4()), 'organization_id': fixtures['organization_id'],
|
|
'opportunity_category': category, 'title': 'Oportunidade sintética QA ' + category, 'canonical': {'opportunity_category': category}, 'reason': REASON}, status=201)
|
|
id = draft['id']; transition(promoter, id, 'submit', status=422)
|
|
first = page(promoter, id)
|
|
assert first['canonicalOptions']['version'] == 'opportunity-canonical-adapter-20261010-semantic1'
|
|
provinces = promoter.call('GET', '/minhas-oportunidades/territory-options?type=province')['data']
|
|
province = provinces[0]
|
|
districts = promoter.call('GET', '/minhas-oportunidades/territory-options?type=district&parent_id=' + province['id'])['data']
|
|
district = districts[0]
|
|
location = promoter.call('POST', f'/minhas-oportunidades/{id}/resources/locations', {'etag': page(promoter,id)['etag'], 'creation_key': str(uuid.uuid4()), 'reason': REASON,
|
|
'payload': {'country_code':'MZ','province_id':province['id'],'district_id':district['id'],'role':'headquarters','is_primary':True}}, status=201)
|
|
raw = png(id)
|
|
document = promoter.call('POST', f'/minhas-oportunidades/{id}/resources/documents', {'etag':page(promoter,id)['etag'], 'creation_key':str(uuid.uuid4()), 'reason':REASON,
|
|
'payload':json.dumps({'title':'Documento de ensaio sintético — sem validade real','category':'qa-synthetic','classification':'restricted','visibility':'internal'})}, multipart=raw, status=201)
|
|
resources = promoter.call('GET',f'/minhas-oportunidades/{id}/resources',inertia=True)['props']['resources']
|
|
doc = next(d for d in resources['documents'] if d['id']==document['id'])
|
|
assert doc['file']['malware_scan_status']=='clean' and doc['file']['sha256'].strip()==hashlib.sha256(raw).hexdigest()
|
|
payload = {'opportunity_category':category,'official_title':'Oportunidade sintética QA '+category, 'promoter_id':fixtures['organization_id'], 'promoter_type':'private',
|
|
'internal_contact_id':fixtures['contact_id'], 'sector_id':fixtures['sector_id'], 'subsector_id':fixtures['subsector_id'], 'economic_activity_id':fixtures['activity_id'],
|
|
'location_mode':'single','geographic_scope':'district','locations':[location['id']], 'executive_summary':('Cenário sintético exclusivamente QA, sem oferta ou decisão real. ' * 7).strip(),
|
|
'demand_statement':('Necessidade fictícia para verificar recuperação e decisões independentes. '*3).strip(),'documents':[document['id']],
|
|
'evidences':[{'title':'Comprovativo sintético','evidence_type':'qa-synthetic','document_id':document['id']}],
|
|
'publication_languages':[{'language_code':'pt-MZ','title':'Oportunidade sintética QA','summary':'Texto de demonstração QA'}], 'visibility':'internal','truth_declaration':True}
|
|
details = {'investment_project': {'opportunity_type':'greenfield','maturity_stage':'idea','investment_modalities':['equity'],'investor_profiles':['institutional'],
|
|
'products':[{'name':'Produto QA','category':'qa'}], 'assets':[{'name':'Ativo QA','asset_type':'qa'}], 'markets':[{'name':'Mercado QA','market_type':'domestic','country_code':'MZ'}],
|
|
'competitive_advantage':'Diferenciação fictícia exclusivamente para ensaio QA. '*4,'employment':{'employment_type':'permanent','direct_jobs':2},
|
|
'impact_indicators':[{'impact_type':'qa','description':'Impacto fictício QA'}],'total_investment':1000,'currency':'MZN',
|
|
'investment_breakdown':[{'label':'Rubrica QA','amount':1000,'currency':'MZN'}], 'secured_financing':[], 'funding_sought':1000,
|
|
'milestones':[{'title':'Preparação QA','due_date':'2027-01-01','status':'planned'},{'title':'Execução QA','due_date':'2027-02-01','status':'planned'}],
|
|
'licenses':[{'name':'Declaração QA de aplicabilidade','authority':'Entidade fictícia QA','status':'not_applicable'}],
|
|
'environmental_social':{'assessment_status':'qa','environmental_category':'qa'},'risks':[{'title':'Risco sintético QA','category':'qa','likelihood':1,'severity':1,'mitigation':'Medida de ensaio QA'}]},
|
|
'real_estate':{'real_estate':{'transaction_type':'sale','property_type':'warehouse','construction_status':'completed','property_condition':'good','land_area':1000,'built_area':500,'area_unit':'sqm',
|
|
'utilities':[{'type':'water','status':'existing'}], 'occupancy_status':'vacant','title_type':'qa-synthetic','registration_number':'QA-'+str(id),'encumbrances':[{'type':'none'}],
|
|
'valuation':{'value':1000,'date':'2026-10-10','method':'qa-only','appraiser':'Synthetic QA','document_id':document['id']},'price':1000,'use_license_document_id':document['id']}},
|
|
'land_rights':{'land_rights':{'operation_type':'investment_with_duat','duat_status':'application','holder_name':'Titular sintético QA','authorized_purpose':'Preparação sintética QA',
|
|
'parcels':[{'location_id':location['id'],'area':1000,'geometry':{'type':'MultiPolygon','coordinates':[[[[40,-13],[40.001,-13],[40.001,-12.999],[40,-12.999],[40,-13]]]]},'overlap_status':'qa-pending'}],
|
|
'overlaps_conflicts':[{'type':'none','status':'qa-only'}],'required_authorizations':[{'name':'Decisão externa ainda necessária','status':'pending'}]}},
|
|
}
|
|
if category in ['business_sale','business_partnership','franchise']:
|
|
transaction = {'business_sale':'full_sale','business_partnership':'partnership','franchise':'franchise'}[category]
|
|
details[category] = {'business_transaction':{'transaction_type':transaction,'legal_entity_id':fixtures['organization_id'],'legal_form':'Synthetic QA','asking_price':1000,
|
|
'valuation':{'value':1000,'date':'2026-10-10','method':'QA'},'financial_periods':[{'year':2025,'document_id':document['id']}], 'assets_liabilities':{'assets':'QA','liabilities':'QA'},
|
|
'employees':{'total':2}, 'contracts_licenses':[{'name':'QA','document_id':document['id']}],'litigation_contingencies':[], 'reason_for_transaction':'Operação fictícia para validar contratos, versões e permissões em QA. ',
|
|
'confidentiality_level':'restricted','due_diligence_status':'data_room_ready', **({'percentage_offered':100 if category=='business_sale' else 40,'governance_rights':{'voting':'QA'}} if category!='franchise' else {'franchise_terms':{'brand':'QA','territory':'QA','term':'QA'}}),
|
|
**({'partner_contribution':{'capital':1000,'calendar':'QA'}} if category=='business_partnership' else {})}}
|
|
payload.update(details.get(category,{'module_profile_version':'qa-unapproved'}))
|
|
payload=normalized(payload)
|
|
save(promoter,id,payload)
|
|
new = Session('promoter').login(); recovered = page(new,id)
|
|
assert recovered['values']['canonical']==payload and recovered['canonicalOptions']['resources']['locations'][0]['payload']['country_code']=='MZ'
|
|
proof['cases'].append({'category':category,'id':id,'draft_saved_incomplete':True,'recovered_in_new_mfa_session':True,'document_id':document['id'],'location_id':location['id'],'clamd_real_clean':True})
|
|
persist()
|
|
return id, payload, document['id']
|
|
|
|
|
|
def review(id, document):
|
|
gates = page(validator,id)['canonicalOptions']['gates']
|
|
for gate in gates:
|
|
transition(validator,id,'review_gate',gate=gate,gate_decision='approved',evidence_document_ids=[document])
|
|
transition(validator,id,'approve')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
prepare_roles()
|
|
# MFA session revisions may have changed when the new scope was granted.
|
|
sessions.update({r:Session(r).login() for r in ['promoter','validator','publisher','colleague','multiple']})
|
|
promoter=sessions['promoter'];validator=sessions['validator'];publisher=sessions['publisher']
|
|
nav=promoter.call('GET','/dashboard',inertia=True)['props']['profileAccess']
|
|
promoter.call('GET',nav['mine'],inertia=True);promoter.call('GET',nav['create'],inertia=True)
|
|
id,payload,doc=create('investment_project')
|
|
transition(promoter,id,'submit')
|
|
transition(validator,id,'assign_review',assignee_user_id=actors['validator']['id'])
|
|
transition(validator,id,'approve',status=422)
|
|
transition(validator,id,'request_correction',correction_items=['canonical.demand_statement'])
|
|
payload['demand_statement']+=' Informação sintética corrigida pelo Promotor QA.'
|
|
save(promoter,id,payload);transition(promoter,id,'submit');transition(validator,id,'start_review');review(id,doc)
|
|
transition(sessions['multiple'],id,'approve',status=403)
|
|
transition(publisher,id,'publish')
|
|
published=page(promoter,id)
|
|
assert published['workflowState']=='published' and published['profileContext']['publication']['visibility']=='internal'
|
|
transition(promoter,id,'request_revision');payload['official_title']+=' — alteração material';save(promoter,id,payload)
|
|
revised=page(promoter,id)
|
|
assert revised['record']['title']==published['record']['title'] and 'approval' not in revised['profileContext'] and revised['workflowState']=='needs_information'
|
|
transition(publisher,id,'publish',status=409);transition(publisher,id,'suspend')
|
|
assert page(promoter,id)['record']['status']=='suspended'
|
|
proof['positive_cycle']={'id':id,'promoter':actors['promoter']['id'],'validator':actors['validator']['id'],'publisher':actors['publisher']['id'],
|
|
'path':['create','save','new_session','recover','submit','assign_review','request_correction','correct','resubmit','review_gates','approve','publish','request_revision','material_edit','suspend'],
|
|
'publication_visibility':'internal','published_columns_preserved_during_revision':True,'approval_invalidated':True,'sla_unconfigured':True}
|
|
for category in ['real_estate','land_rights','business_sale','business_partnership','franchise','concession','financing','supply','other']:
|
|
other,p,document=create(category)
|
|
if category in ['concession','financing','supply','other']:
|
|
transition(promoter,other,'submit',status=422)
|
|
proof['cases'][-1]['submission']='blocked-unapproved-module-profile'
|
|
else:
|
|
transition(promoter,other,'submit');proof['cases'][-1]['submission']='passed'
|
|
if category=='real_estate':
|
|
transition(validator,other,'reject',status=422,reason='curto')
|
|
transition(validator,other,'reject');transition(promoter,other,'appeal')
|
|
transition(validator,other,'appeal_reopen',status=403)
|
|
# Same-organization colleague with another role is still conflicted, not an appeal instance.
|
|
transition(sessions['colleague'],other,'appeal_reopen',status=403)
|
|
proof['reasoned_rejection_and_appeal']={'id':other,'reason_persisted':True,'same_rejector_denied':True,'organization_conflict_denied':True,'independent_appeal_decision':'pending-authorized-independent-actor'}
|
|
sessions['outsider'].call('GET',f'/minhas-oportunidades/{other}/view',status=403,inertia=True)
|
|
persist()
|
|
proof['completed_at']=datetime.now(timezone.utc).isoformat();proof['result']='passed';persist()
|
|
print(json.dumps({'result':'passed','target':EXPECTED,'categories':len(proof['cases']),'positive_cycle_id':id,'promoter_qa_id':actors['promoter']['id'],'visual_pending':True,'active_writes':0}))
|
|
except Exception:
|
|
proof['result']='failed';persist();raise
|