import urllib.request,urllib.parse,http.cookiejar,re,json,io,zipfile,struct,zlib
BASE='http://127.0.0.1:8787/'
def client():return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def req(c,path,body=None,method=None,headers=None):
 try:
  r=c.open(urllib.request.Request(BASE+path,data=body,method=method,headers=headers or {}));return r.status,r.read(),r.headers
 except urllib.error.HTTPError as e:return e.code,e.read(),e.headers

def form(c,path,d):return req(c,path,urllib.parse.urlencode(d).encode(),headers={'Content-Type':'application/x-www-form-urlencoded'})
def csrfpage(html):return re.search(rb'name="csrf" value="([^"]+)"',html)[1].decode()
def api(c,path,d=None,method='GET',csrf=''):
 code,data,h=req(c,'index.php?route=api/'+path,None if d is None else json.dumps(d).encode(),method,{'Content-Type':'application/json','X-CSRF-Token':csrf})
 return code,json.loads(data) if h.get_content_type()=='application/json' else data
owner=client();code,page,headers= req(owner,'install.php');assert code==200
from pathlib import Path
setup=Path('../private/setup-key.txt').read_text().strip()
code,page,_=form(owner,'install.php',{'csrf':csrfpage(page),'setup_key':setup,'db_host':'127.0.0.1','db_port':'3307','db_name':'pt_test','db_user':'root','db_password':'','owner_name':'Test Owner','username':'testowner','password':'TestOwnerPassword123!','password_confirm':'TestOwnerPassword123!'})
assert b'Welcome back' in page,(code,re.findall(rb'role="alert">(.*?)</p>',page))
assert req(owner,'install.php')[0]==403
code,page,_=form(owner,'login.php',{'csrf':csrfpage(page),'username':'testowner','password':'TestOwnerPassword123!'});assert code==200 and b'PRESS_CSRF' in page
code,session=api(owner,'session');token=session['csrf'];assert session['authenticated']
assert api(owner,'stock',{'revision':1,'data':{'items':[],'history':[]}},'PUT','bad')[0]==403
item=lambda id,kind,cat,photo='':{'id':id,'name':id,'kind':kind,'category':'DTF transfers' if kind=='Transfers' else 'Shirts','transferCategoryId':cat,'size':'M','color':'Black','qty':3,'onOrder':2,'cost':4.5,'low':1,'supplier':'Supplier A','photo':photo,'notes':''}
stock={'items':[item('blank','Blanks',''),item('public','Transfers','public'),item('secret','Transfers','private'),dict(item('finished','Premade',''),sourceTransferIds=['secret'])],'history':[{'text':'private'}],'transferCategories':[{'id':'public','name':'Public'},{'id':'private','name':'Private'}]}
code,s=api(owner,'stock');rev=s['revision'];code,out=api(owner,'stock',{'revision':rev,'data':stock},'PUT',token);assert code==200,(code,out)
assert api(owner,'stock',{'revision':rev,'data':stock},'PUT',token)[0]==409
code,out=api(owner,'users',{'email':'viewer','name':'Viewer','password':'ViewerPassword123!','categoryIds':['public'],'disabled':0},'PUT',token);assert code==200,(code,out)
viewer=client();_,page,_=req(viewer,'login.php');form(viewer,'login.php',{'csrf':csrfpage(page),'username':'viewer','password':'ViewerPassword123!'});_,sess=api(viewer,'session');vt=sess['csrf']
code,s=api(viewer,'stock');assert [i['id'] for i in s['data']['items']]==['blank','public'],s
assert not s['data']['history'];assert api(viewer,'users')[0]==403;assert api(viewer,'stock',{'data':stock,'revision':1},'PUT',vt)[0]==403
# multipart uploads and replacement

def png(rgb):
 def chunk(t,b):return struct.pack('>I',len(b))+t+b+struct.pack('>I',zlib.crc32(t+b)&0xffffffff)
 return b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',1,1,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(b'\0'+bytes(rgb)))+chunk(b'IEND',b'')
def multipart(c,path,fields,files,csrf):
 boundary='----PressTrackerTestBoundary';parts=[]
 for key,val in fields.items():parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{val}\r\n'.encode())
 for key,name,mime,contents in files:parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"; filename="{name}"\r\nContent-Type: {mime}\r\n\r\n'.encode()+contents+b'\r\n')
 parts.append(f'--{boundary}--\r\n'.encode());return req(c,path,b''.join(parts),'POST',{'Content-Type':'multipart/form-data; boundary='+boundary,'X-CSRF-Token':csrf})
a=png((255,0,0));b=png((0,255,0))
code,out,_=multipart(owner,'index.php?route=api/photos',{},[('file','red.png','image/png',a)],token);assert code==200,(code,out);photo=json.loads(out)
code,out,_=multipart(owner,'index.php?route=api/photos/'+photo['id']+'&replace=1',{},[('file','green.png','image/png',b)],token);assert code==200,(code,out)
assert api(owner,'photos/'+photo['id'])[1]==b
_,s=api(owner,'stock');d=s['data'];d['items'][2]['photo']=photo['url'];d['items'][3]['photo']=photo['url'];code,out=api(owner,'stock',{'data':d,'revision':s['revision']},'PUT',token);assert code==200
assert api(viewer,'photos/'+photo['id'])[0]==404;assert api(viewer,'photos')[1]['photos']==[]
# in-use photo cannot be trashed
_,s=api(owner,'stock');d=s['data'];d['archivedPhotos']=[photo['id']];assert api(owner,'stock',{'data':d,'revision':s['revision']},'PUT',token)[0]==400
for i in d['items']:i['photo']=''
code,out=api(owner,'stock',{'data':d,'revision':s['revision']},'PUT',token);assert code==200
code,out=api(owner,'photos',{'revision':out['revision']},'DELETE',token);assert code==200 and out['pending']==0
assert api(owner,'photos/'+photo['id'])[0]==404
# Make a fresh linked photo for the backup/import roundtrip.
code,out,_=multipart(owner,'index.php?route=api/photos',{},[('file','backup.png','image/png',a)],token);assert code==200;newphoto=json.loads(out)
_,s=api(owner,'stock');d=s['data'];d['items'][0]['photo']=newphoto['url'];assert api(owner,'stock',{'data':d,'revision':s['revision']},'PUT',token)[0]==200
# owner backup ZIP is readable, viewer access denied
code,backup,_=form(owner,'backup.php',{'csrf':token,'action':'export'});assert code==200;z=zipfile.ZipFile(io.BytesIO(backup));assert json.loads(z.read('manifest.json'))['format']=='press-tracker-backup-v1'
assert req(viewer,'backup.php')[0]==403
code,out,_=multipart(owner,'backup.php',{'csrf':token,'action':'import','confirm':'IMPORT'},[('backup','backup.zip','application/zip',backup)],token);assert b'only allowed into an empty' in out
_,s=api(owner,'stock');d={'items':[],'history':[],'archivedPhotos':[newphoto['id']]};code,out=api(owner,'stock',{'data':d,'revision':s['revision']},'PUT',token);assert code==200
assert api(owner,'photos',{'revision':out['revision']},'DELETE',token)[0]==200
code,out,_=multipart(owner,'backup.php',{'csrf':token,'action':'import','confirm':'IMPORT'},[('backup','backup.zip','application/zip',backup)],token);assert b'Import complete.' in out,(code,out)
_,s=api(owner,'stock');assert len(s['data']['items'])==4;assert api(owner,'photos/'+newphoto['id'])[1]==a

# disabled user loses access even with an active session
assert api(owner,'users',{'email':'viewer','name':'Viewer','password':'','categoryIds':['public'],'disabled':1},'PUT',token)[0]==200
assert api(viewer,'stock')[0]==401
print('PASS: installer lock, login, CSRF, stock CAS, local users, category/finished/image restrictions, replacement, trash protection, backup/import roundtrip, non-empty import refusal, session revocation')
