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
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>
248 lines
16 KiB
Python
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()
|