Files

341 lines
17 KiB
Python

import hashlib
import io
import json
import re
import secrets
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from urllib.parse import urlsplit
import openpyxl
import pytest
from flask import Flask
from sqlalchemy import text
from werkzeug.datastructures import MultiDict
from conftest import PASSWORD, login, post
def order(app, oid):
with app.Session() as db:
o = db.get(app.Order, oid)
return {'status': o.status, 'version': o.row_version, 'revision': o.revision,
'total': o.total_amount, 'supplier': o.supplier_name,
'items': [{'qty': i.qty, 'price': i.price, 'date': i.delivery_date} for i in o.items]}
def new(app, client, kind='purchase', key=None):
response = post(client, '/orders/new', {'doc_type': kind, 'request_key': key or secrets.token_hex(16)})
assert response.status_code == 302, response.get_data(as_text=True)
return int(re.search(r'/orders/(\d+)', response.location)[1])
def form(version, qty='3', price='12.5', n=1, **overrides):
values = MultiDict({'row_version': str(version), 'supplier_name': 'Audit supplier', 'our_company': '苏州博特维智能科技有限公司',
'payment_terms': '月结30天', 'delivery_place': '苏州工业园区', 'order_date': '2026-09-16'})
for i in range(n):
for k,v in {'code': 'TEST-%03d' % i, 'spec': '测试物料 A&B <规格>', 'unit': '件',
'qty': qty, 'price': price, 'date': '2026-10-01', 'remark': ''}.items():
values.add('item_' + k, v)
for key, value in overrides.items():
values[key] = value
return values
def save(app, client, oid, **kwargs):
return post(client, '/orders/%s/edit' % oid, form(order(app, oid)['version'], **kwargs))
def action(app, client, oid, name, **kwargs):
return post(client, '/orders/%s/%s' % (oid, name), {'row_version': str(order(app, oid)['version']), **kwargs})
def pending(app, clients, **kwargs):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid, intent='submit', **kwargs).status_code == 302
return oid
def approve(app, clients, oid):
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
@pytest.mark.parametrize('kind,n', [('purchase', 3), ('outsourcing', 30)])
def test_full_flow_and_frozen_pdf(app, clients, kind, n):
from models import OrderVersion
oid = new(app, clients['maker'], kind)
assert save(app, clients['maker'], oid, n=n, intent='submit').status_code == 302
assert order(app, oid)['status'] == 'pending_approval'
assert action(app, clients['maker'], oid, 'approve', stamped='1').status_code == 403
assert action(app, clients['approver'], oid, 'approve').status_code == 400
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
assert order(app, oid)['status'] == 'approved'
response = clients['maker'].get('/orders/%s/pdf' % oid)
assert response.data.startswith(b'%PDF') and response.data.count(b'%PDF-') == 1
assert clients['maker'].get('/orders/%s/pdf' % oid).data == response.data
import pymupdf
with pymupdf.open(stream=response.data, filetype='pdf') as doc:
# Older PDF readers may split adjacent Chinese glyphs into lines.
content = re.sub(r'\s+', '', ''.join(p.get_text() for p in doc))
assert content.count('采购专用章') == 1
assert '审核专用章' not in content and '批准专用章' not in content
with app.Session() as db:
v = db.query(OrderVersion).filter_by(order_id=oid, revision=1).one()
assert v.pdf_sha256 == hashlib.sha256(response.data).hexdigest()
assert len(json.loads(v.content_json)['items']) == n
assert v.template_version == 'po-v5'
assert save(app, clients['maker'], oid).status_code == 403
assert clients['maker'].get('/orders/%s/edit' % oid).status_code == 302
assert clients['maker'].get('/orders/%s/versions/1' % oid).status_code == 200
def test_cross_maker_access_all_paths(app, clients):
oid = pending(app, clients)
number = None
with app.Session() as db:
number = db.get(app.Order, oid).order_no
other = clients['other']
for suffix in ('view','edit','pdf','versions/1'):
assert other.get('/orders/%s/%s' % (oid,suffix)).status_code == 404
assert number not in other.get('/orders').get_data(as_text=True)
assert 'Audit supplier' not in other.get('/stats').get_data(as_text=True)
ledger = openpyxl.load_workbook(io.BytesIO(other.get('/export/ledger').data)).active
assert ledger.max_row == 1
assert action(app, other, oid, 'copy', request_key=secrets.token_hex(16)).status_code == 404
assert other.get('/exports/old.pdf').status_code == 404
def test_maker_separation_single_approval_and_rejection(app, clients):
for name in ('reviewer','approver'):
assert post(clients[name], '/orders/new', {'request_key': secrets.token_hex(16)}).status_code == 403
oid = new(app, clients['admin'])
assert save(app, clients['admin'], oid, intent='submit').status_code == 302
assert action(app, clients['admin'], oid, 'approve', stamped='1').status_code == 403
assert action(app, clients['admin'], oid, 'reject', comment='self').status_code == 403
assert action(app, clients['reviewer'], oid, 'reject', comment='需要修改').status_code == 302
assert order(app, oid)['status'] == 'rejected'
assert save(app, clients['admin'], oid, intent='submit').status_code == 302
assert order(app, oid)['revision'] == 2
assert action(app, clients['reviewer'], oid, 'approve', stamped='1').status_code == 302
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 403
assert action(app, clients['approver'], oid, 'reject', comment='already done').status_code == 403
oid2 = pending(app, clients)
assert action(app, clients['admin'], oid2, 'approve', stamped='1').status_code == 302
def test_old_review_form_cannot_finally_approve(app, clients):
oid = pending(app, clients)
before = order(app, oid)
assert action(app, clients['reviewer'], oid, 'review', stamped='1').status_code == 409
assert order(app, oid) == before
with app.Session() as db:
assert not db.get(app.Order, oid).approved_by
def test_pdf_failure_rolls_back_approval_and_notification(app, clients, monkeypatch):
import order_routes
from models import Notification, OrderVersion
from test_notifications import configure
configure(clients)
oid = pending(app, clients)
before = order(app, oid)
def fail(order):
raise ValueError('PDF generation failed')
monkeypatch.setattr(order_routes, 'pdf_bytes', fail)
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 400
assert order(app, oid) == before
with app.Session() as db:
assert not db.get(app.Order, oid).approved_by
assert db.query(Notification).count() == 1
assert db.query(OrderVersion).filter_by(order_id=oid).one().pdf_data is None
@pytest.mark.parametrize('qty,price', [('-2','10'),('abc','10'),('nan','10'),('Infinity','10'),('1','-1'),('1','0.00001')])
def test_bad_numbers_do_not_replace_saved_order(app, clients, qty, price):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid).status_code == 302
before = order(app, oid)
assert save(app, clients['maker'], oid, qty=qty, price=price).status_code == 400
assert order(app, oid) == before
@pytest.mark.parametrize('changes', [{'supplier_name':''},{'qty':'0'},{'price':'0'},{'order_date':'bad'}])
def test_submit_validation_atomic(app, clients, changes):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid).status_code == 302
before = order(app, oid)
assert save(app, clients['maker'], oid, intent='submit', **changes).status_code == 400
assert order(app, oid) == before
def test_money_and_save_submit_latest_values(app, clients):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid, qty='1',price='0.334',n=2).status_code == 302
assert order(app, oid)['total'] == Decimal('0.66')
assert save(app, clients['maker'], oid, qty='3',price='0.3333',intent='submit').status_code == 302
assert order(app, oid)['total'] == Decimal('1.00')
assert order(app, oid)['items'][0]['price'] == Decimal('0.3333')
with app.engine.connect() as con:
assert con.execute(text('SELECT total_cents, typeof(total_cents) FROM orders WHERE id=:id'), {'id': oid}).one() == (100,'integer')
from money import line_amount
assert line_amount('1','2.675') == Decimal('2.68')
def workbook(bad=False):
wb = openpyxl.Workbook()
wb.active.append(['物料代码','物料规格','单位','数量','含税单价','交期','备注'])
wb.active.append(['X','日期测试','件', 'oops' if bad else 3, 0.3333, datetime(2026,10,1),''])
out = io.BytesIO()
wb.save(out)
out.seek(0)
return out
def test_chinese_filename_excel_date_and_atomic_import(app, clients):
oid = new(app, clients['maker'])
response = action(app, clients['maker'], oid, 'import_excel', mode='replace', file=(workbook(), '采购明细.xlsx'))
assert response.status_code == 302
assert order(app, oid)['items'] == [{'qty': Decimal('3'), 'price': Decimal('0.3333'), 'date':'2026-10-01'}]
before = order(app, oid)
assert action(app, clients['maker'], oid, 'import_excel', mode='replace', file=(workbook(True), '采购明细.xlsx')).status_code == 400
assert order(app, oid) == before
def test_xls_native_date_and_masterdata_chinese_uploads(app, clients):
import xlwt
wb=xlwt.Workbook(); ws=wb.add_sheet('Sheet1')
for i,h in enumerate(['物料代码','物料规格','单位','数量','单价','交期','备注']): ws.write(0,i,h)
for i,v in enumerate(['XLS','旧版日期','件',2,3]): ws.write(1,i,v)
ws.write(1,5,datetime(2026,10,1),xlwt.easyxf(num_format_str='YYYY-MM-DD'))
out=io.BytesIO(); wb.save(out); out.seek(0)
oid=new(app,clients['maker'])
assert action(app,clients['maker'],oid,'import_excel',file=(out,'中文明细.xls')).status_code==302
assert order(app,oid)['items'][0]['date']=='2026-10-01'
for endpoint in ['materials','suppliers']:
template=clients['admin'].get('/%s/import_template.xlsx'%endpoint).data
response=post(clients['admin'],'/%s/import_excel'%endpoint,{'file':(io.BytesIO(template),'中文模板.xlsx')})
assert response.status_code==302
with app.Session() as db:
assert db.query(app.Material).count()==2
assert db.query(app.Supplier).count()==1
def test_old_secret_csrf_disabled_and_changed_password_sessions(app, clients):
shadow = Flask('old')
shadow.secret_key = 'botw-po-system-2026'
cookie = shadow.session_interface.get_signing_serializer(shadow).dumps({'user_id':1,'session_version':1})
forged = app.app.test_client()
forged.set_cookie('session', cookie)
assert urlsplit(forged.get('/users').location).path == '/login'
assert clients['maker'].post('/orders/new', data={'request_key':secrets.token_hex(16)}).status_code == 400
assert clients['maker'].post('/orders/new', data={'csrf_token':'无效标记'}).status_code == 400
with app.Session() as db:
u = db.query(app.User).filter_by(username='reviewer').one()
uid = u.id
oid = pending(app, clients)
assert post(clients['admin'], '/users', {'id':uid,'username':'reviewer','name':'reviewer','role':'approver','active':'0'}).status_code == 302
assert urlsplit(action(app, clients['reviewer'], oid, 'approve', stamped='1').location).path == '/login'
assert post(clients['admin'], '/users', {'id':uid,'username':'reviewer','name':'reviewer','role':'approver','active':'1','password':'Reset-Password-2026'}).status_code == 302
c = app.app.test_client()
c.get('/login')
assert urlsplit(post(c,'/login',{'username':'reviewer','password':'Reset-Password-2026'}).location).path == '/account/password'
c.get('/account/password')
assert post(c,'/account/password',{'old_password':'Reset-Password-2026','password':PASSWORD,'confirm_password':PASSWORD}).status_code == 302
c.get('/')
assert action(app, c, oid, 'approve', stamped='1').status_code == 302
def test_failed_db_write_isolated_and_last_admin_protected(app, clients):
result = post(clients['admin'], '/users', {'username':'maker','name':'duplicate','role':'maker','password':PASSWORD})
assert result.status_code == 409
assert clients['maker'].get('/orders').status_code == 200
with app.Session() as db:
uid = db.query(app.User).filter_by(username='admin').one().id
assert post(clients['admin'], '/users', {'id':uid,'username':'admin','name':'admin','role':'maker','active':'1'}).status_code == 400
def test_withdraw_revise_void_and_preserved_versions(app, clients):
from models import OrderVersion
oid = pending(app, clients)
assert action(app, clients['maker'], oid, 'withdraw', comment='修改价格').status_code == 302
assert save(app, clients['maker'], oid, price='20', intent='submit').status_code == 302
approve(app, clients, oid)
old_pdf = clients['maker'].get('/orders/%s/pdf' % oid).data
assert action(app, clients['maker'], oid, 'revise', comment='再次变更').status_code == 302
assert order(app, oid)['status'] == 'draft'
assert clients['maker'].get('/orders/%s/pdf' % oid).data != old_pdf
with app.Session() as db:
v = db.query(OrderVersion).filter_by(order_id=oid, revision=2).one()
assert v.pdf_data == old_pdf
assert json.loads(v.content_json)['items'][0]['price'] == '20.0000'
assert save(app, clients['maker'], oid, price='21', intent='submit').status_code == 302
approve(app, clients, oid)
assert order(app, oid)['revision'] == 3
assert action(app, clients['approver'], oid, 'void', comment='停止采购').status_code == 302
assert order(app, oid)['status'] == 'voided'
assert save(app, clients['maker'], oid).status_code == 403
assert clients['maker'].get('/orders/%s/pdf' % oid).data != old_pdf
def test_stale_forms_and_concurrent_number_allocation(app, clients):
oid = new(app, clients['maker'])
old = order(app,oid)['version']
assert save(app, clients['maker'], oid).status_code == 302
assert post(clients['maker'], '/orders/%s/edit'%oid, form(old)).status_code == 409
sessions = [login(app, 'maker') for _ in range(8)]
with ThreadPoolExecutor(max_workers=8) as pool:
ids = list(pool.map(lambda c: new(app,c), sessions))
assert len(set(ids)) == 8
with app.Session() as db:
numbers = [o.order_no for o in db.query(app.Order)]
assert len(set(numbers)) == len(numbers)
key = secrets.token_hex(16)
assert new(app, clients['maker'],key=key) == new(app,clients['maker'],key=key)
def test_simultaneous_approval_has_one_winner(app, clients):
oid = pending(app,clients)
revision = order(app,oid)['version']
reviewers = [login(app, name) for name in ('reviewer', 'approver')]
def approve_once(c):
return post(c, '/orders/%s/approve'%oid, {'row_version':str(revision),'stamped':'1'}).status_code
with ThreadPoolExecutor(max_workers=2) as pool:
statuses = list(pool.map(approve_once, reviewers))
assert sorted(statuses) == [302,409]
with app.Session() as db:
assert db.query(app.ApprovalLog).filter_by(order_id=oid,action='审批通过并归档正式 PDF').count() == 1
def test_pages_and_all_post_forms_have_csrf(app, clients):
from html.parser import HTMLParser
class Forms(HTMLParser):
def __init__(self):
super().__init__(); self.active=None; self.forms=[]
def handle_starttag(self,tag,attrs):
attrs=dict(attrs)
if tag=='form' and attrs.get('method','').lower()=='post': self.active=set()
if tag=='input' and self.active is not None: self.active.add(attrs.get('name'))
def handle_endtag(self,tag):
if tag=='form' and self.active is not None: self.forms.append(self.active); self.active=None
oid = new(app,clients['maker'])
for page in ['/','/orders','/orders/new','/orders/%s/edit'%oid,'/orders/%s/view'%oid,
'/users','/users?edit=2','/suppliers','/materials','/stats','/account/password','/settings/notifications']:
response=clients['admin'].get(page)
assert response.status_code==200,(page,response.status_code)
parser=Forms();parser.feed(response.get_data(as_text=True))
assert all('csrf_token' in fields for fields in parser.forms),page
def test_setup_requires_local_token(app):
with app.Session() as db:
db.query(app.User).delete(); db.commit()
c=app.app.test_client(); c.get('/setup')
body={'username':'owner','name':'Owner','password':PASSWORD,'setup_token':'wrong'}
assert post(c,'/setup',body).status_code==403
body['setup_token']=(Path(app.INSTANCE)/'setup.token').read_text().strip()
assert post(c,'/setup',body).status_code==302
assert c.get('/setup').location.endswith('/login')