Files
dtf-system/tests/smoke_test.py
Cauê Faleiros 5f2be7ea20
Some checks failed
Build and deploy / Validate source (push) Successful in 12s
Build and deploy / Integration suite on a real stack (push) Failing after 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Has been skipped
feat: accept sheets of up to 5 GB end to end
Sheets of several GB are the normal order. The upload limit is now 5 GB.
ClamAV scans files up to 2 GB; a larger file is released only when its
first bytes match the format its name claims, and a disguised file is
refused. The Site grades a sheet over 150 MB from the pixel size in its
PNG, JPEG or WebP header without decoding it, and reads large PDFs in
ranges. The worker never opens a source over 300 MB: a finished sheet
placed whole becomes its own print file, which the Kanban offers to approve
as the final, and anything else goes to hand preparation. Files start
uploading as they enter the cart, with progress in the summary, and each
part renews the reservation so slow uploads do not expire. Quotas grow to
50 GB per customer and 500 GB in total; the Swarm config for ClamAV is
renamed because a deployed config cannot change in place.

Verified locally with a 386 MB and a 1.8 GB PNG (scanned, paid, original
as print file), a 2.3 GB PNG (format check) and a disguised 2.3 GB file
(refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:18:18 -03:00

248 lines
16 KiB
Python

"""Local stack integration checks; creates and retains clearly named test orders.
Run: python3 -m tests.smoke_test. Standard library only. Honors .env/environment.
"""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import time
from urllib.error import HTTPError
from urllib.request import build_opener, HTTPCookieProcessor, Request, urlopen
from uuid import uuid4
if Path('.env').exists():
for line in Path('.env').read_text().splitlines():
if line.strip() and not line.startswith('#') and '=' in line:
key,value=line.split('=',1)
os.environ.setdefault(key,value)
# CI runs these from a container on the stack's own network, because a runner
# container cannot reach ports published on the host's loopback. SITE_BASE_URL
# points at the gateway by service name; SITE_HOST_HEADER keeps the Host the
# gateway and TrustedHostMiddleware expect, so the security configuration under
# test stays identical to a developer's localhost run.
BASE=os.environ.get('SITE_BASE_URL') or 'http://localhost:'+os.environ.get('SITE_PORT','8080')
HOST_HEADER=os.environ.get('SITE_HOST_HEADER')
def with_host(headers=None):
headers=dict(headers or {})
if HOST_HEADER and not any(k.lower()=='host' for k in headers):
headers['Host']=HOST_HEADER
return headers
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar()
self.opener=build_opener(HTTPCookieProcessor(self.jar))
self.operator_client=None
def call(self,path,body=None,operator=False,expected=200,method=None):
headers=with_host({'Content-Type':'application/json'})
if operator:
if self.operator_client is None:
self.operator_client=Client()
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
return self.operator_client.call(path,body,expected=expected,method=method)
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),
headers=headers,method=method)
try:
with self.opener.open(request,timeout=30) as response:
assert response.status==expected,(path,response.status,expected)
return json.load(response)
except HTTPError as exc:
if exc.code!=expected:raise AssertionError((path,exc.code,exc.read().decode())) from exc
return json.load(exc)
def wait_scan(client, uid, operator=False, expected='clean'):
prefix='/operator/uploads' if operator else '/uploads'
deadline=time.monotonic()+150
while time.monotonic()<deadline:
state=client.call(prefix+'/'+uid,operator=operator)
if state['scan_state'] in ('clean','rejected','error'):
assert state['scan_state']==expected,state
return state
time.sleep(0.5)
raise AssertionError('Malware scan did not finish')
def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected_scan='clean'):
operator=order_id is not None
prefix='/operator/uploads' if operator else '/uploads'
start='/operator/orders/'+order_id+'/uploads' if operator else prefix
data=client.call(start,{'name':name,'size':len(content)},operator=operator)
uid=data['id'];size=data['part_bytes']
for offset in range(0,len(content),size):
url=client.call(prefix+'/'+uid+'/parts/'+str(offset//size+1),{},operator=operator)['url']
with urlopen(Request(url,data=content[offset:offset+size],method='PUT'),timeout=30) as response:
assert response.status==200
client.call(prefix+'/'+uid+'/complete',{},operator=operator)
wait_scan(client,uid,operator,expected_scan)
return uid
def item_spec(mode, metres, grade, uid):
film_width=28.5 if mode in ('uvfile','uv') else 57
length_cm=float(metres)*100
return {'mode':mode,'metres':str(metres),'grade':grade,'uploads':[uid],
'production':{'version':2,'film_width_cm':film_width,'height_cm':length_cm,
'sources':[{'upload_id':uid,
'kind':'sheet' if mode in ('file','uvfile') else 'artwork',
'width_cm':film_width,'length_cm':length_cm,'copies':1,
'rotation_degrees':0,'mirrored':False,'measurement':'customer'}],
'placements':[{'source_index':0,'copy_index':0,'x_cm':0,'y_cm':0,
'width_cm':film_width,'length_cm':length_cm,
'rotation_degrees':0,'mirrored':False}]},
'quality_status':'unverified' if grade==0 else 'ok',
'quality_acknowledged':False}
def approved_quote(client, quote, items):
"""The approval a customer pays against: automatic, or by the operator."""
if quote['status']=='approved':
return client.call('/quotes/'+quote['id'])['approved']
return client.call('/operator/quotes/'+quote['id']+'/approve',{'items':items},operator=True)
def run():
client=Client();other=Client()
config=client.call('/session');other.call('/session')
assert client.call('/health')['integrations']=='fake'
# Sheets of several GB are the normal order: 5 GB per file.
assert config['max_upload_bytes'] == 5 * 1024 ** 3
client.call('/uploads',{'name':'too-large.cdr',
'size':config['max_upload_bytes']+1},expected=413)
cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id']
other.call('/uploads/'+cancelled,expected=404,method='DELETE')
assert client.call('/uploads/'+cancelled,method='DELETE')['cancelled']
client.call('/uploads/'+cancelled,expected=410)
client.call('/operator/board',expected=401)
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
other.call('/uploads/'+uid,expected=404)
other.call('/uploads/'+uid+'/parts/1',{},expected=404)
signed=client.call('/uploads/'+uid+'/parts/1',{})['url']
with urlopen(Request(signed,data=content[:block],method='PUT'),timeout=30) as response:assert response.status==200
assert client.call('/uploads/'+uid)['parts']==[1]
client.call('/uploads/'+uid+'/complete',{},expected=409)
signed=client.call('/uploads/'+uid+'/parts/2',{})['url']
with urlopen(Request(signed,data=content[block:],method='PUT'),timeout=30) as response:assert response.status==200
client.call('/uploads/'+uid+'/complete',{})
client.call('/uploads/'+uid+'/complete',{})
wait_scan(client,uid)
client.call('/uploads/'+uid+'/parts/1',{},expected=409)
download=client.call('/operator/uploads/'+uid+'/download',operator=True)
with urlopen(download['url'],timeout=30) as response:assert hashlib.sha256(response.read()).digest()==hashlib.sha256(content).digest()
unsigned=download['url'].split('?')[0]
try:urlopen(unsigned,timeout=10);raise AssertionError('Bucket must be private')
except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
# Above QUOTE_AUTO_MAX_METRES (50 m by default), so a person reviews it.
items=[item_spec(m,'13',90,uid) for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422)
# Freight quoted by CEP alone cannot ship: the address is required, must be
# the quoted CEP, and a pickup order takes none.
client.call('/quotes',{**draft,'destination':None},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'postal_code':'01001000'}},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'state':'XX'}},expected=422)
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=422)
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
outside={**items[0],'production':{**items[0]['production'],
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
client.call('/quotes',{**draft,'items':[outside]},expected=422)
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft)
assert quote['status']=='pending_review'
assert client.call('/quotes/'+quote['id'])['review_reason']=='Pedido acima de 50 m'
assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
qid=quote['id']
other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
altered={**items[0],'production':{**items[0]['production'],
'sources':[{**items[0]['production']['sources'][0],'measurement':'file'}]}}
client.call('/operator/quotes/'+qid+'/approve',{'items':[altered,*items[1:]]},operator=True,expected=422)
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+32370+90870+109070+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert approved['destination']=={**draft['destination'],'complement':''}
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
assert not client.call('/quotes/'+qid)['auto_approved']
# A cart the Site priced is approved at once and can be paid straight away,
# at the server's own prices; no operator can then change it.
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
auto=client.call('/quotes',small)
assert auto['status']=='approved'
seen=client.call('/quotes/'+auto['id'])
assert seen['auto_approved'] and seen['review_reason'] is None
assert seen['approved']['total_cents']==6972+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert client.call('/quotes',small)['status']=='approved'
client.call('/operator/quotes/'+auto['id']+'/approve',{'items':small['items']},operator=True,expected=409)
# The Site grades only art it analysed; a discount on unanalysed art waits for a person.
claimed={**item_spec('avulsa','2.75',0,uid),'grade':90}
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
assert held['status']=='pending_review'
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
# Concurrent retries must produce precisely one payment/order/outbox pair.
with ThreadPoolExecutor(max_workers=4) as executor:
paid=list(executor.map(lambda _:client.call('/orders/dev-paid',{'quote_id':qid}),range(4)))
assert len({p['id'] for p in paid})==1
order=paid[0];oid=order['id']
assert order['payment']['status']=='paid' and order['snapshot']==approved
board=client.call('/operator/board',operator=True)
assert len([o for o in board['orders'] if o['quote_id']==qid])==1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':0},operator=True,expected=409)
client.call('/operator/orders/'+oid+'/move',{'state':'cor','version':0},operator=True,expected=422)
version=0
for state in ('cor','rec','tra','fil','imp','fin'):
if state=='fil':
client.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
files=[{'item_index':i,'upload_id':upload_bytes(client,b'LOCAL FINAL FIXTURE '+str(i).encode(),order_id=oid)} for i in range(4)]
result=client.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':files,'note':'Local test manual final-file approval'},operator=True)
version=result['version']
moved=client.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Local test correction' if state=='cor' else ''},operator=True)
version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
# A mistaken move can be undone one stage at a time, with an internal
# reason. The customer is not told again: going back and forward once more
# adds no messages (the outbox count below stays 8).
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
assert back['state']=='imp';version+=1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
history=client.call('/operator/orders/'+oid+'/history',operator=True)
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
# The customer sees the stages and the correction's reason, never the
# internal reason for going back.
seen=client.call('/customer/orders/'+oid)['history']
assert len(seen)==7 and all(h['reason']=='' or h['to_state']=='cor' for h in seen)
assert any(h['to_state']=='cor' and h['reason']=='Local test correction' for h in seen)
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30
while time.monotonic()<deadline:
events=[e for e in client.call('/operator/events?order='+str(order['number']),operator=True)['events'] if e['payload']['order_id']==oid]
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1)
else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8
# Tiny sets the pickup situação from the order it is sent; the fake sale
# has no Tiny id, so the real adapter would search for it.
ready={e['provider']:e['payload'] for e in events if e['payload']['event']=='ready'}
assert ready['tiny']['order']['freight']==order['snapshot']['freight'] and 'tiny_id' in ready['tiny']
assert 'order' not in ready['whatsapp']
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid
if __name__=='__main__':run()