341 lines
17 KiB
Python
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-v4'
|
|
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')
|