"""Testes HTTP em uma instalação DESCARTÁVEL. Nunca aponte para produção.
Usa a biblioteca padrão Python. Configure APF_TEST_BASE e APF_TEST_ALLOW_INSTALL=yes.
"""
import os,re,json,secrets,sys
from pathlib import Path
from datetime import datetime,timedelta
from zoneinfo import ZoneInfo
import urllib.request,urllib.parse,urllib.error,http.cookiejar
class Response:
 def __init__(self,r):self.status_code=r.code;self.text=r.read().decode('utf-8');self.headers=r.headers;self.url=r.geturl()
 def json(self):return json.loads(self.text)
class Session:
 def __init__(self):self.opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
 def request(self,url,method='GET',data=None,headers=None,json_data=None,timeout=30):
  headers=dict(headers or {})
  if json_data is not None:data=json.dumps(json_data).encode();headers['Content-Type']='application/json'
  elif data is not None:data=urllib.parse.urlencode(data,doseq=True).encode();headers['Content-Type']='application/x-www-form-urlencoded'
  req=urllib.request.Request(url,data=data,headers=headers,method=method)
  try:return Response(self.opener.open(req,timeout=timeout))
  except urllib.error.HTTPError as e:return Response(e)
 def get(self,url,headers=None,timeout=30):return self.request(url,headers=headers,timeout=timeout)
 def post(self,url,data=None,headers=None,json=None,timeout=30):return self.request(url,'POST',data,headers,json,timeout)
from html.parser import HTMLParser
BASE=os.environ.get('APF_TEST_BASE','http://127.0.0.1:8898/').rstrip('/')+'/'
if os.environ.get('APF_TEST_ALLOW_INSTALL')!='yes':raise SystemExit('Defina APF_TEST_ALLOW_INSTALL=yes e use um banco descartável.')
if not BASE.startswith(('http://127.0.0.1:','http://localhost:')):raise SystemExit('Este teste é limitado a uma instância local descartável.')
checks=[]
def check(condition,name):
 if not condition:raise AssertionError(name)
 checks.append(name);print('PASS '+name,flush=True)
def session():
 return Session()
def csrf(html):
 m=re.search(r'name="csrf" value="([a-f0-9]+)"',html);assert m,'Token CSRF ausente';return m.group(1)
class Forms(HTMLParser):
 def __init__(self,html):super().__init__();self.forms=[];self.form=None;self.feed(html)
 def handle_starttag(self,tag,attrs):
  a=dict(attrs)
  if tag=='form':self.form={};self.forms.append(self.form)
  if tag=='input' and self.form is not None and a.get('name'):
   if a.get('type')=='checkbox' and 'checked' not in a:return
   self.form[a['name']]=a.get('value','on' if a.get('type')=='checkbox' else '')
 def handle_endtag(self,tag):
  if tag=='form':self.form=None
def find_form(html,op,name=None):
 return next(f for f in Forms(html).forms if f.get('do')==op and (name is None or f.get('name')==name))
def get(s,path):return s.get(BASE+path,timeout=20)
def post(s,path,data):return s.post(BASE+path,data=data,timeout=30)
def login(s,email,password,new=None):
 r=get(s,'index.php?p=login');r=post(s,'index.php?p=login',{'csrf':csrf(r.text),'email':email,'password':password})
 if new:
  assert 'Minha senha' in r.text and 'p=password' in r.url
  r=post(s,'index.php?p=password',{'csrf':csrf(r.text),'current':password,'password':new,'confirm':new})
 return r
def boot(s):
 r=get(s,'index.php?p=app');assert r.status_code==200 and 'window.AUPET_BOOT=' in r.text,r.text[:300]
 return json.loads(r.text.split('window.AUPET_BOOT=',1)[1].split(';</script>',1)[0])
def api(s,b,action=None,rev=None,store=None,token=True):
 h={'X-Store-ID':store or b['storeId']}
 if action is None:return s.get(BASE+'api.php',headers=h,timeout=20)
 if token:h['X-CSRF-Token']=b['csrf']
 return s.post(BASE+'api.php',headers=h,json={'revision':rev,'action':action},timeout=30)
master=session();password=secrets.token_urlsafe(20);owner_temp=secrets.token_urlsafe(20);owner_pass=secrets.token_urlsafe(20);staff_temp=secrets.token_urlsafe(20);staff_pass=secrets.token_urlsafe(20)
date=datetime.now(ZoneInfo('America/Sao_Paulo')).date();expires=str(date+timedelta(days=30))
r=get(master,'install.php');check(r.status_code==200 and 'Instale seu AupetFlow' in r.text,'instalador abre e mostra requisitos')
key=Path(__file__).resolve().parents[1]/'core/config/chave-instalacao.txt'
install={'csrf':csrf(r.text),'installation_key':'invalid','db_host':os.getenv('APF_TEST_DB_HOST','127.0.0.1'),'db_port':os.getenv('APF_TEST_DB_PORT','33077'),'db_name':os.getenv('APF_TEST_DB_NAME','aupetflow_test'),'db_user':os.getenv('APF_TEST_DB_USER','root'),'db_password':os.getenv('APF_TEST_DB_PASSWORD',''),'db_prefix':'apf_','master_name':'Master Teste','master_email':'master@example.test','master_password':password,'master_confirm':password,'company_name':'Empresa Inicial','store_name':'Matriz Inicial'}
r=post(master,'install.php',install);check('Chave de instalação inválida' in r.text,'instalação rejeita chave inválida sem criar tabelas')
install['installation_key']=key.read_text().strip();install['csrf']=csrf(r.text)
r=post(master,'install.php',install);check('Instalação concluída' in r.text and 'p=login' in r.url,'instalação cria banco, conta master e primeira loja')
r=get(master,'install.php');check('p=login' in r.url,'reinstalação fica bloqueada')
r=post(master,'index.php?p=login',{'csrf':csrf(r.text),'email':"' OR 1=1 --",'password':'invalid'});check('E-mail ou senha inválidos' in r.text,'login trata tentativa de SQL injection como texto')
r=login(master,'master@example.test',password);check(r.status_code==200 and 'Administração do SaaS' in r.text,'master entra no painel administrativo')
plans=get(master,'index.php?p=master&tab=plans').text
essential=find_form(plans,'save_plan','Essencial')['id'];pro=find_form(plans,'save_plan','Profissional')['id']
r=get(master,'index.php?p=master&tab=new')
new={'do':'create_account','name':'Beta Pet','store_name':'Beta Matriz','owner_name':'Responsável Beta','email':'beta@example.test','password':owner_temp,'plan_id':essential,'expires_at':expires}
bad=post(master,'index.php?p=master&tab=new',new);check('formulário expirou' in bad.text,'painel master exige CSRF válido')
new['csrf']=csrf(r.text);r=post(master,'index.php?p=master&tab=new',new);check('Empresa, primeira loja e usuário responsável criados' in r.text,'master cria empresa, loja e responsável')
accounts=get(master,'index.php?p=master').text;f=find_form(accounts,'save_account','Beta Pet');aid=f['account_id']
owner=session();r=login(owner,'beta@example.test',owner_temp,owner_pass);check(r.status_code==200 and 'window.AUPET_BOOT=' in r.text,'responsável troca senha temporária antes de operar')
b=boot(owner);sid=b['storeId'];r=api(owner,b);data=r.json();check(r.status_code==200 and len(data['state']['clients'])==0,'nova empresa começa com loja vazia e catálogo pronto')
r=api(owner,b,{'type':'saveClient','data':{'name':'Tutor Beta','phone':'61999999999','email':'','address':'','notes':''}},data['revision']);data=r.json();check(r.status_code==200 and len(data['state']['clients'])==1,'cadastro do tutor persiste no MySQL')
client_id=data['state']['clients'][0]['id']
other_forms=Forms(get(master,'index.php?p=stores').text).forms;other_sid=next(f['store_id'] for f in other_forms if f.get('store_id') and f['store_id']!=sid)
r=api(owner,b,store=other_sid);check(r.status_code==403,'usuário não consegue ler loja de outra empresa')
r=api(owner,b,{'type':'saveClient','data':{'name':'Intrusão'}},data['revision'],store=other_sid);check(r.status_code==403,'usuário não consegue escrever em outra empresa')
r=api(owner,b,{'type':'saveClient','data':{'name':'Sem token'}},data['revision'],token=False);check(r.status_code==419,'API rejeita escrita sem CSRF')
r=api(owner,b,{'type':'saveClient','data':{'name':'Revisão antiga'}},data['revision']-1);check(r.status_code==409 and len(r.json()['state']['clients'])==1,'controle de revisão impede perda de dados concorrentes')
r=get(owner,'index.php?p=master');check(r.status_code==403,'responsável da empresa não acessa master')
account_path='index.php?p=account&account_id='+aid
r=get(owner,account_path);token=csrf(r.text)
r=post(owner,account_path,{'csrf':token,'do':'create_store','account_id':aid,'name':'Acima do plano'});check('limite de lojas' in r.text,'limite de lojas é aplicado no servidor')
r=get(master,'index.php?p=master');edit=find_form(r.text,'save_account','Beta Pet');edit.update(plan_id=pro,status='active',expires_at=expires);r=post(master,'index.php?p=master',edit);check('Empresa atualizada' in r.text,'master altera plano e prazo de acesso')
r=get(owner,account_path);r=post(owner,account_path,{'csrf':csrf(r.text),'do':'create_store','account_id':aid,'name':'Beta Filial'});check('Loja criada com cadastros separados' in r.text,'responsável cria filial dentro do novo limite')
forms=Forms(get(owner,'index.php?p=stores').text).forms;sid2=next(f['store_id'] for f in forms if f.get('store_id') and f['store_id']!=sid)
r=get(owner,'index.php?p=stores');r=post(owner,'index.php?p=select',{'csrf':csrf(r.text),'store_id':sid2});b2=boot(owner)
r=api(owner,b2);check(len(r.json()['state']['clients'])==0,'filial não recebe fichas nem estoque da matriz')
r=api(owner,b);check(len(r.json()['state']['clients'])==1,'aba anterior continua vinculada à matriz após trocar de loja')
rev=r.json()['revision'];r=api(owner,b,{'type':'saveClient','data':{'name':'Outro tutor da matriz','phone':'','email':'','address':'','notes':''}},rev);check(r.status_code==200 and len(api(owner,b2).json()['state']['clients'])==0,'escrita em aba antiga não contamina a filial')
r=get(owner,account_path);r=post(owner,account_path,{'csrf':csrf(r.text),'do':'create_member','account_id':aid,'name':'Tosador Teste','email':'tosador@example.test','password':staff_temp,'role':'groomer','store_ids[]':[sid]});check('Usuário criado' in r.text,'responsável cria funcionário restrito a uma loja')
staff=session();r=login(staff,'tosador@example.test',staff_temp,staff_pass);bs=boot(staff);check(bs['role']=='groomer' and 'finance' not in bs['allowedViews'],'perfil da equipe é refletido na interface')
r=api(staff,bs);ds=r.json();check(ds['state']['transactions']==[] and ds['state']['vaccines']==[] and ds['state']['products']==[],'servidor remove dados fora das permissões do funcionário')
r=api(staff,bs,{'type':'addTransaction','data':{}},ds['revision']);check(r.status_code==403,'perfil não pode forçar ação financeira pela API')
r=api(staff,bs,store=sid2);check(r.status_code==403,'funcionário não herda acesso a outra filial')
# Exercício completo de um atendimento com pacote no banco real.
data=api(owner,b).json();pet={'clientId':client_id,'name':'Luna','species':'Cachorro','breed':'SRD','size':'Pequeno','sex':'Fêmea','birthdate':'2022-03-12','weight':5,'alerts':'Pele sensível'}
data=api(owner,b,{'type':'savePet','data':pet},data['revision']).json();pid=data['state']['pets'][0]['id']
data=api(owner,b,{'type':'subscribe','data':{'petId':pid,'templateId':'tpl-essencial','startsAt':str(date),'paid':True}},data['revision']).json();sub=data['state']['subscriptions'][0]['id']
apt={'petId':pid,'serviceId':'srv-banho','date':str(date),'time':'09:00','professional':'Equipe teste','notes':'Banho hidratante','addons':['Hidratação'],'packageId':sub,'price':0}
data=api(owner,b,{'type':'saveAppointment','data':apt},data['revision']).json();aptid=data['state']['appointments'][0]['id']
r=api(owner,b,{'type':'appointmentStatus','data':{'id':aptid,'status':'Concluído'}},data['revision']);data=r.json();check(r.status_code==200 and data['state']['subscriptions'][0]['used']==1 and len(data['state']['transactions'])==1 and data['state']['appointments'][0]['status']=='Concluído','fluxo real grava histórico e desconta pacote sem cobrança duplicada')
r=get(master,'index.php?p=master');edit=find_form(r.text,'save_account','Beta Pet');edit.update(plan_id=pro,status='suspended',expires_at=expires);post(master,'index.php?p=master',edit);check(api(owner,b).status_code==403 and api(staff,bs).status_code==403,'suspensão da empresa bloqueia usuários já conectados')
r=get(master,'index.php?p=master');edit=find_form(r.text,'save_account','Beta Pet');edit.update(plan_id=pro,status='active',expires_at=str(date-timedelta(days=1)));post(master,'index.php?p=master',edit);check(api(owner,b).status_code==403,'acesso vencido é bloqueado no servidor')
r=get(master,'index.php?p=master');edit=find_form(r.text,'save_account','Beta Pet');edit.update(plan_id=pro,status='active',expires_at=expires);post(master,'index.php?p=master',edit)
r=get(owner,account_path);member=find_form(r.text,'save_member','Tosador Teste');member.update(role='groomer',password='');member.pop('active',None);member['store_ids[]']=[sid];post(owner,account_path,member);check(api(staff,bs).status_code==401,'desativar usuário encerra sua sessão antiga')
check(get(owner,'core/config/config.php').status_code==404,'configuração privada está fora da raiz pública')
r=get(owner,'index.php?p=app');check("frame-ancestors 'none'" in r.headers.get('Content-Security-Policy','') and r.headers.get('X-Content-Type-Options')=='nosniff','resposta contém cabeçalhos de proteção')
# A lista de auditoria não mostra senhas.
r=get(master,'index.php?p=master&tab=audit');check('erp_appointmentStatus' in r.text and password not in r.text and owner_pass not in r.text,'auditoria registra operações sem mostrar senhas')
r=post(owner,'index.php?p=logout',{'csrf':b['csrf']});check('p=login' in r.url and api(owner,b).status_code==401,'logout remove a sessão com verificação CSRF')
print('\n'+str(len(checks))+' testes HTTP e MySQL aprovados.',flush=True)
report=Path(os.environ.get('APF_TEST_REPORT','http-test-report.json'));report.write_text(json.dumps({'passed':len(checks),'checks':checks,'engine':'PHP 8.3 / MariaDB 10.11'},ensure_ascii=False,indent=2))
