完善采购审批流程、企业微信通知和采购专用章
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'po_app'))
|
||||
PASSWORD = 'Test-only-Password-2026'
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def module(tmp_path_factory):
|
||||
os.environ['PO_DATA_DIR'] = str(tmp_path_factory.mktemp('po-data'))
|
||||
os.environ.pop('PO_SECRET_KEY', None)
|
||||
result = importlib.import_module('app')
|
||||
result.app.config['TESTING'] = True
|
||||
yield result
|
||||
result.engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(module):
|
||||
from models import Base
|
||||
Base.metadata.drop_all(module.engine)
|
||||
Base.metadata.create_all(module.engine)
|
||||
hashed = generate_password_hash(PASSWORD)
|
||||
with module.Session() as db:
|
||||
for name, role in [('admin', 'admin'), ('maker', 'maker'), ('other', 'maker'),
|
||||
('reviewer', 'approver'), ('approver', 'approver')]:
|
||||
db.add(module.User(username=name, name=name, role=role, active=True,
|
||||
session_version=1, must_change_password=False, password_hash=hashed))
|
||||
db.commit()
|
||||
return module
|
||||
|
||||
|
||||
def post(client, path, data=None, **kwargs):
|
||||
with client.session_transaction() as sess:
|
||||
token = sess.get('csrf_token')
|
||||
if not token:
|
||||
client.get('/login')
|
||||
with client.session_transaction() as sess:
|
||||
token = sess['csrf_token']
|
||||
if data is None:
|
||||
data = {}
|
||||
data = data.copy()
|
||||
data['csrf_token'] = token
|
||||
return client.post(path, data=data, **kwargs)
|
||||
|
||||
|
||||
def login(app, name):
|
||||
client = app.app.test_client()
|
||||
client.get('/login')
|
||||
response = post(client, '/login', {'username': name, 'password': PASSWORD})
|
||||
assert response.status_code == 302
|
||||
client.get(response.location)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clients(app):
|
||||
return {name: login(app, name) for name in ('admin', 'maker', 'other', 'reviewer', 'approver')}
|
||||
Vendored
+109
@@ -0,0 +1,109 @@
|
||||
|
||||
CREATE TABLE materials (
|
||||
id INTEGER NOT NULL,
|
||||
code VARCHAR(80) NOT NULL,
|
||||
spec VARCHAR(300),
|
||||
unit VARCHAR(20),
|
||||
price FLOAT,
|
||||
active BOOLEAN,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
|
||||
;
|
||||
|
||||
CREATE TABLE suppliers (
|
||||
id INTEGER NOT NULL,
|
||||
code VARCHAR(50),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(50),
|
||||
phone VARCHAR(50),
|
||||
tax_no VARCHAR(80),
|
||||
bank_name VARCHAR(120),
|
||||
bank_account VARCHAR(80),
|
||||
payment_terms VARCHAR(120),
|
||||
active BOOLEAN,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
|
||||
;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER NOT NULL,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
password_hash VARCHAR(200) NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
active BOOLEAN,
|
||||
created_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (username)
|
||||
)
|
||||
|
||||
;
|
||||
|
||||
CREATE TABLE orders (
|
||||
id INTEGER NOT NULL,
|
||||
order_no VARCHAR(50) NOT NULL,
|
||||
doc_type VARCHAR(20) NOT NULL,
|
||||
supplier_name VARCHAR(200),
|
||||
supplier_contact VARCHAR(50),
|
||||
supplier_phone VARCHAR(50),
|
||||
supplier_tax_no VARCHAR(80),
|
||||
supplier_bank_name VARCHAR(120),
|
||||
supplier_bank_account VARCHAR(80),
|
||||
payment_terms VARCHAR(120),
|
||||
our_company VARCHAR(200),
|
||||
our_contact VARCHAR(50),
|
||||
our_phone VARCHAR(50),
|
||||
our_tax_no VARCHAR(80),
|
||||
tax_rate VARCHAR(30),
|
||||
order_date VARCHAR(20),
|
||||
delivery_place VARCHAR(300),
|
||||
note TEXT,
|
||||
total_amount FLOAT,
|
||||
status VARCHAR(20),
|
||||
created_by INTEGER,
|
||||
created_by_name VARCHAR(50),
|
||||
created_at DATETIME,
|
||||
submitted_at DATETIME,
|
||||
reviewed_by VARCHAR(50),
|
||||
reviewed_at DATETIME,
|
||||
approved_by VARCHAR(50),
|
||||
approved_at DATETIME,
|
||||
reject_reason TEXT,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY(created_by) REFERENCES users (id)
|
||||
)
|
||||
|
||||
;
|
||||
|
||||
CREATE TABLE approval_logs (
|
||||
id INTEGER NOT NULL,
|
||||
order_id INTEGER,
|
||||
action VARCHAR(50),
|
||||
user_name VARCHAR(50),
|
||||
comment TEXT,
|
||||
created_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY(order_id) REFERENCES orders (id)
|
||||
)
|
||||
|
||||
;
|
||||
|
||||
CREATE TABLE order_items (
|
||||
id INTEGER NOT NULL,
|
||||
order_id INTEGER,
|
||||
seq INTEGER,
|
||||
material_code VARCHAR(80),
|
||||
material_spec VARCHAR(300),
|
||||
unit VARCHAR(20),
|
||||
qty FLOAT,
|
||||
price FLOAT,
|
||||
amount FLOAT,
|
||||
delivery_date VARCHAR(30),
|
||||
remark VARCHAR(200),
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY(order_id) REFERENCES orders (id)
|
||||
)
|
||||
|
||||
;
|
||||
@@ -0,0 +1,25 @@
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_backup_and_restore_snapshot(tmp_path):
|
||||
source = Path(__file__).resolve().parents[1] / 'tools' / 'backup_db.py'
|
||||
spec = importlib.util.spec_from_file_location('backup_tool', source)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
instance = tmp_path / 'instance'
|
||||
instance.mkdir()
|
||||
path = instance / 'po.db'
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute('CREATE TABLE orders(id INTEGER PRIMARY KEY, amount INTEGER)')
|
||||
db.execute('INSERT INTO orders(amount) VALUES(66)')
|
||||
snapshot = module.backup_database(tmp_path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute('UPDATE orders SET amount=100')
|
||||
with sqlite3.connect(snapshot) as db:
|
||||
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
|
||||
assert db.execute('SELECT amount FROM orders').fetchone()[0] == 66
|
||||
with sqlite3.connect(tmp_path / 'restored.db') as restored:
|
||||
db.backup(restored)
|
||||
assert restored.execute('SELECT amount FROM orders').fetchone()[0] == 66
|
||||
@@ -0,0 +1,125 @@
|
||||
import sqlite3
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCHEMA = Path(__file__).parent / 'fixtures' / 'legacy_schema.sql'
|
||||
|
||||
|
||||
def legacy(path, duplicate=False):
|
||||
with sqlite3.connect(path) as con:
|
||||
con.executescript(SCHEMA.read_text(encoding='utf-8'))
|
||||
con.execute("INSERT INTO users(id,username,password_hash,name,role,active) VALUES(1,'owner','old-hash','Owner','maker',1)")
|
||||
con.execute("INSERT INTO orders(id,order_no,doc_type,status,total_amount,created_by) VALUES(1,'PO202609100001','purchase','approved',1,1)")
|
||||
con.execute("INSERT INTO order_items(order_id,seq,qty,price,amount) VALUES(1,1,3,0.3333,1)")
|
||||
con.execute("INSERT INTO materials(code,price) VALUES('MAT',12.3456)")
|
||||
con.execute("INSERT INTO approval_logs(order_id,action,user_name) VALUES(1,'approved','Previous Manager')")
|
||||
if duplicate:
|
||||
con.execute("INSERT INTO orders(order_no,doc_type) VALUES('PO202609100001','purchase')")
|
||||
|
||||
|
||||
def test_legacy_upgrade_preserves_history_and_fixed_amounts(tmp_path):
|
||||
from models import Material, Order, User, init_db
|
||||
path = tmp_path / 'po.db'
|
||||
legacy(path)
|
||||
engine, factory = init_db(str(path))
|
||||
with factory() as db:
|
||||
order = db.get(Order,1)
|
||||
assert order.status == 'approved' and order.order_no == 'PO202609100001'
|
||||
assert order.total_amount == Decimal('1')
|
||||
assert order.items[0].price == Decimal('0.3333')
|
||||
assert order.logs[0].user_name == 'Previous Manager'
|
||||
assert db.get(Material,1).price == Decimal('12.3456')
|
||||
assert db.get(User,1).must_change_password
|
||||
engine.dispose()
|
||||
backups = list(tmp_path.glob('*.bak'))
|
||||
assert len(backups) == 1
|
||||
with sqlite3.connect(backups[0]) as con:
|
||||
assert con.execute('SELECT price FROM order_items').fetchone()[0] == 0.3333
|
||||
assert 'price_units' not in {x[1] for x in con.execute('PRAGMA table_info(order_items)')}
|
||||
again,_ = init_db(str(path))
|
||||
again.dispose()
|
||||
assert len(list(tmp_path.glob('*.bak'))) == 1
|
||||
|
||||
|
||||
def test_duplicates_stop_migration_without_renumbering(tmp_path):
|
||||
from models import init_db
|
||||
path=tmp_path/'po.db'
|
||||
legacy(path,duplicate=True)
|
||||
with pytest.raises(RuntimeError,match='重复单号'):
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as con:
|
||||
assert con.execute('SELECT COUNT(*) FROM orders').fetchone()[0] == 2
|
||||
assert 'row_version' not in {x[1] for x in con.execute('PRAGMA table_info(orders)')}
|
||||
|
||||
|
||||
def test_invalid_amount_rolls_back_whole_migration(tmp_path):
|
||||
from models import init_db
|
||||
path=tmp_path/'po.db'
|
||||
legacy(path)
|
||||
with sqlite3.connect(path) as con:
|
||||
con.execute("UPDATE order_items SET price='Infinity'")
|
||||
with pytest.raises(RuntimeError,match='无法迁移'):
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as con:
|
||||
assert 'row_version' not in {x[1] for x in con.execute('PRAGMA table_info(orders)')}
|
||||
assert con.execute('SELECT price FROM order_items').fetchone()[0] == 'Infinity'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('manager_hook', ['', 'manager-hook'])
|
||||
def test_v2_merge_preserves_archives_and_migrates_pending_work(tmp_path, manager_hook):
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from models import (ApprovalLog, Notification, NotificationSettings, Order,
|
||||
OrderVersion, User, init_db)
|
||||
path = tmp_path / 'po.db'
|
||||
engine, factory = init_db(str(path))
|
||||
old_pdf = b'%PDF-1.4 old archived two-stamp file'
|
||||
digest = hashlib.sha256(old_pdf).hexdigest()
|
||||
with factory() as db:
|
||||
db.add(User(id=1, username='r', password_hash='hash', name='Old Reviewer', role='reviewer',
|
||||
session_version=4, must_change_password=False))
|
||||
db.add(NotificationSettings(id=1, enabled=True, base_url='http://192.168.1.2:8080',
|
||||
review_webhook='review-hook', approval_webhook=manager_hook))
|
||||
for oid, status in [(1, 'pending_review'), (2, 'pending_approval'), (3, 'approved')]:
|
||||
db.add(Order(id=oid, order_no='LEGACY-%s' % oid, status=status, revision=1,
|
||||
reviewed_by='Old Reviewer' if oid > 1 else None,
|
||||
reviewed_at=datetime(2026, 9, 16) if oid > 1 else None))
|
||||
db.flush()
|
||||
db.add(ApprovalLog(order_id=2, action='主管审核通过', user_name='Old Reviewer', revision=1,
|
||||
from_status='pending_review', to_status='pending_approval'))
|
||||
db.add(OrderVersion(order_id=3, revision=1, content_json='{}', pdf_data=old_pdf,
|
||||
pdf_sha256=digest, template_version='po-v2'))
|
||||
for oid, channel, status in [(1, 'review', 'pending_review'), (2, 'review', 'pending_review'),
|
||||
(2, 'approval', 'pending_approval'), (3, 'approval', 'pending_approval')]:
|
||||
db.add(Notification(order_id=oid, revision=1, channel=channel, event='old', expected_status=status))
|
||||
db.add(Notification(order_id=3, revision=1, channel='review', event='old', status='sent'))
|
||||
db.commit()
|
||||
with engine.begin() as con:
|
||||
con.exec_driver_sql('PRAGMA user_version=2')
|
||||
engine.dispose()
|
||||
engine, factory = init_db(str(path))
|
||||
with factory() as db:
|
||||
user = db.get(User, 1)
|
||||
assert (user.role, user.session_version, user.must_change_password) == ('approver', 5, False)
|
||||
assert db.get(Order, 1).status == db.get(Order, 2).status == 'pending_approval'
|
||||
assert db.get(Order, 1).row_version == db.get(Order, 2).row_version == 2
|
||||
assert db.get(Order, 2).reviewed_by == 'Old Reviewer'
|
||||
assert db.query(ApprovalLog).one().action == '主管审核通过'
|
||||
archive = db.query(OrderVersion).one()
|
||||
assert (archive.pdf_data, archive.pdf_sha256, archive.template_version) == (old_pdf, digest, 'po-v2')
|
||||
rows = db.query(Notification).order_by(Notification.id).all()
|
||||
assert [n.status for n in rows] == ['pending', 'cancelled', 'pending', 'cancelled', 'sent']
|
||||
assert rows[0].channel == 'approval' and rows[0].expected_status == 'pending_approval'
|
||||
assert rows[4].channel == 'review' # Historical delivery remains untouched.
|
||||
config = db.get(NotificationSettings, 1)
|
||||
assert config.approval_webhook == (manager_hook or 'review-hook') and config.review_webhook == ''
|
||||
engine.dispose()
|
||||
assert len(list(tmp_path.glob('*.bak'))) == 1
|
||||
again, factory = init_db(str(path))
|
||||
with factory() as db:
|
||||
assert db.get(User, 1).session_version == 5
|
||||
assert db.get(Order, 1).row_version == 2
|
||||
again.dispose()
|
||||
assert len(list(tmp_path.glob('*.bak'))) == 1
|
||||
@@ -0,0 +1,7 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const money = require('../po_app/static/money.js');
|
||||
assert.equal(money.format(money.lineCents('1','0.334') + money.lineCents('1','0.334')), '0.66');
|
||||
assert.equal(money.format(money.lineCents('1','2.675')), '2.68');
|
||||
assert.equal(money.format(money.lineCents('3','0.3333')), '1.00');
|
||||
for (const bad of ['-1','NaN','Infinity','abc','0.00001']) assert.throws(()=>money.lineCents(bad,'1'));
|
||||
console.log('Decimal preview checks passed');
|
||||
@@ -0,0 +1,238 @@
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from conftest import PASSWORD, post
|
||||
from test_workflow import action, approve, form, new, order, pending, save
|
||||
|
||||
HOOK = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=TEST-ONLY-NOT-A-REAL-KEY'
|
||||
BASE = 'http://192.168.1.10:8080'
|
||||
|
||||
|
||||
def configure(clients, **overrides):
|
||||
data = dict(enabled='1', base_url=BASE,
|
||||
approval_webhook=HOOK + '-approval', result_webhook=HOOK + '-result')
|
||||
data.update(overrides)
|
||||
return post(clients['admin'], '/settings/notifications', data)
|
||||
|
||||
|
||||
def notices(app):
|
||||
from models import Notification
|
||||
with app.Session() as db:
|
||||
return [dict(id=n.id, status=n.status, channel=n.channel, attempts=n.attempts, error=n.last_error)
|
||||
for n in db.query(Notification).order_by(Notification.id)]
|
||||
|
||||
|
||||
def test_workflow_queues_only_committed_transitions(app, clients, monkeypatch):
|
||||
import notifications
|
||||
from models import Notification, NotificationSettings
|
||||
monkeypatch.setattr(notifications, 'send_wecom', lambda *args: pytest.fail('HTTP request must not send'))
|
||||
oid = pending(app, clients) # Disabled by default; no historical replay.
|
||||
assert not notices(app)
|
||||
assert configure(clients).status_code == 302
|
||||
assert action(app, clients['maker'], oid, 'withdraw', comment='fix').status_code == 302
|
||||
assert action(app, clients['maker'], oid, 'submit').status_code == 302
|
||||
stale = order(app, oid)['version']
|
||||
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
|
||||
assert post(clients['reviewer'], f'/orders/{oid}/approve', {'row_version': str(stale), 'stamped': '1'}).status_code == 409
|
||||
assert [n['channel'] for n in notices(app)] == ['result', 'approval', 'result']
|
||||
fresh = new(app, clients['maker'])
|
||||
assert save(app, clients['maker'], fresh, intent='submit', qty='-1').status_code == 400
|
||||
assert len(notices(app)) == 3
|
||||
# Even a failure after enqueue rolls back both order and notification.
|
||||
original = notifications.enqueue_transition
|
||||
def fail_after_enqueue(*args):
|
||||
original(*args)
|
||||
raise ValueError('simulated persistence failure')
|
||||
monkeypatch.setattr(notifications, 'enqueue_transition', fail_after_enqueue)
|
||||
assert save(app, clients['maker'], fresh, intent='submit').status_code == 400
|
||||
assert order(app, fresh)['status'] == 'draft'
|
||||
assert len(notices(app)) == 3
|
||||
|
||||
|
||||
def test_worker_cancels_old_tasks_and_sends_minimal_lan_links(app, clients):
|
||||
from notifications import dispatch_once
|
||||
configure(clients, result_webhook='')
|
||||
oid = pending(app, clients)
|
||||
assert action(app, clients['maker'], oid, 'withdraw', comment='修改').status_code == 302
|
||||
assert save(app, clients['maker'], oid, intent='submit').status_code == 302
|
||||
sent = []
|
||||
def sender(hook, payload):
|
||||
# Sending must not hold SQLite's write lock.
|
||||
with app.Session() as db:
|
||||
db.execute(text('BEGIN IMMEDIATE'))
|
||||
sent.append((hook, payload))
|
||||
now = datetime.utcnow() + timedelta(seconds=1)
|
||||
assert dispatch_once(app.Session, sender, now)
|
||||
assert notices(app)[0]['status'] == 'cancelled'
|
||||
assert not sent
|
||||
assert dispatch_once(app.Session, sender, now)
|
||||
assert notices(app)[1]['status'] == 'sent'
|
||||
assert sent[0][0] == HOOK + '-approval'
|
||||
content = sent[0][1]['markdown']['content']
|
||||
assert f'{BASE}/orders/{oid}/view' in content
|
||||
assert 'Audit supplier' not in content and 'key=' not in content
|
||||
assert sent[0][1]['msgtype'] == 'markdown'
|
||||
configure(clients)
|
||||
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
|
||||
assert not dispatch_once(app.Session, sender, now) # Shared rate limit.
|
||||
assert dispatch_once(app.Session, sender, now + timedelta(seconds=5))
|
||||
assert sent[-1][0] == HOOK + '-result'
|
||||
assert clients['maker'].get(f'/orders/{oid}/pdf').data.startswith(b'%PDF')
|
||||
|
||||
|
||||
def test_retry_recovery_pause_and_no_credentials_in_errors(app, clients):
|
||||
from models import Notification, NotificationSettings
|
||||
from notifications import dispatch_once
|
||||
configure(clients)
|
||||
oid = pending(app, clients)
|
||||
now = datetime.utcnow() + timedelta(seconds=1)
|
||||
def fail(*args):
|
||||
raise RuntimeError(HOOK)
|
||||
for attempt in range(5):
|
||||
assert dispatch_once(app.Session, fail, now + timedelta(hours=attempt))
|
||||
row = notices(app)[0]
|
||||
assert row['status'] == 'failed' and row['attempts'] == 5
|
||||
assert HOOK not in row['error']
|
||||
assert post(clients['admin'], f"/settings/notifications/{row['id']}/retry").status_code == 302
|
||||
configure(clients, enabled='0')
|
||||
assert not dispatch_once(app.Session, fail, now + timedelta(hours=6))
|
||||
configure(clients)
|
||||
# Recover a crashed sender after its lease expires.
|
||||
with app.Session() as db:
|
||||
notice = db.get(Notification, row['id'])
|
||||
notice.status = 'sending'
|
||||
notice.lease_token = 'expired'
|
||||
notice.lease_until = now
|
||||
db.commit()
|
||||
sent = []
|
||||
assert dispatch_once(app.Session, lambda *a: sent.append(a), now + timedelta(hours=6))
|
||||
assert len(sent) == 1 and notices(app)[0]['status'] == 'sent'
|
||||
assert not dispatch_once(app.Session, fail, now + timedelta(hours=7))
|
||||
assert post(clients['admin'], f"/settings/notifications/{row['id']}/retry").status_code == 409
|
||||
html = clients['admin'].get('/settings/notifications').get_data(as_text=True)
|
||||
assert HOOK not in html and '已发送' in html
|
||||
|
||||
|
||||
def test_parallel_workers_claim_once(app, clients):
|
||||
from notifications import dispatch_once
|
||||
configure(clients)
|
||||
pending(app, clients)
|
||||
sent = []
|
||||
now = datetime.utcnow() + timedelta(seconds=1)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(lambda _: dispatch_once(app.Session, lambda *a: sent.append(a), now), range(4)))
|
||||
assert results.count(True) == 1 and len(sent) == 1
|
||||
|
||||
|
||||
def test_settings_permissions_secret_preservation_and_testing(app, clients):
|
||||
from models import NotificationSettings
|
||||
for name in ('maker', 'reviewer', 'approver'):
|
||||
assert clients[name].get('/settings/notifications').status_code == 403
|
||||
for path in ('/settings/notifications', '/settings/notifications/test', '/settings/notifications/1/retry'):
|
||||
assert post(clients[name], path).status_code == 403
|
||||
assert clients['admin'].post('/settings/notifications', data={'enabled': '1'}).status_code == 400
|
||||
assert configure(clients).status_code == 302
|
||||
assert configure(clients, approval_webhook='', result_webhook='').status_code == 302
|
||||
with app.Session() as db:
|
||||
assert db.get(NotificationSettings, 1).approval_webhook == HOOK + '-approval'
|
||||
html = clients['admin'].get('/settings/notifications')
|
||||
assert html.headers['Cache-Control'] == 'no-store'
|
||||
assert HOOK not in html.get_data(as_text=True)
|
||||
for _ in range(2):
|
||||
assert post(clients['admin'], '/settings/notifications/test', {'channel': 'approval'}).status_code == 302
|
||||
assert len(notices(app)) == 1
|
||||
assert configure(clients, clear_approval='1').status_code == 400
|
||||
with app.Session() as db:
|
||||
assert db.get(NotificationSettings, 1).approval_webhook == HOOK + '-approval'
|
||||
assert configure(clients, clear_result='1').status_code == 302
|
||||
assert post(clients['admin'], '/settings/notifications/test', {'channel': 'result'}).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['http://127.0.0.1:8080', 'http://localhost:8080', 'http://0.0.0.0',
|
||||
'https://example.org/orders/1/view', 'https://user:pass@192.168.1.2', 'http://192.168.1.2/?x=1',
|
||||
'http://192.168.1.2/#x', 'javascript:alert(1)', 'http://8.8.8.8', 'http://192.168.1.2:99999'])
|
||||
def test_invalid_lan_addresses(app, clients, url):
|
||||
assert configure(clients, base_url=url).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['http://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x',
|
||||
'https://qyapi.weixin.qq.com.evil.invalid/cgi-bin/webhook/send?key=x',
|
||||
HOOK + '&redirect=evil', HOOK + '#secret', 'http://192.168.1.1'])
|
||||
def test_webhook_rejects_untrusted_destinations(app, clients, url):
|
||||
assert configure(clients, approval_webhook=url).status_code == 400
|
||||
assert 'value="' + url + '"' not in clients['admin'].get('/settings/notifications').get_data(as_text=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reply,expected', [(b'{"errcode":0,"errmsg":"ok"}', None),
|
||||
(b'{"errcode":45009,"errmsg":"secret url"}', '45009'), (b'{}', '格式'),
|
||||
(b'{"errcode":false}', '格式'), (b'not json', '格式'), (b'x'*16385, '异常')])
|
||||
def test_wecom_protocol(app, monkeypatch, reply, expected):
|
||||
import notifications
|
||||
class Response:
|
||||
status = 200
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def read(self, count): return reply[:count]
|
||||
class Opener:
|
||||
def open(self, req, timeout):
|
||||
assert req.full_url == HOOK and req.method == 'POST' and timeout == 5
|
||||
assert json.loads(req.data)['msgtype'] == 'markdown'
|
||||
return Response()
|
||||
monkeypatch.setattr(notifications, 'build_opener', lambda *args: Opener())
|
||||
if expected:
|
||||
with pytest.raises(notifications.DeliveryError, match=expected) as error:
|
||||
notifications.send_wecom(HOOK, {'msgtype': 'markdown'})
|
||||
assert 'secret url' not in str(error.value)
|
||||
else:
|
||||
notifications.send_wecom(HOOK, {'msgtype': 'markdown'})
|
||||
|
||||
|
||||
def test_wecom_redirect_is_not_followed(app, monkeypatch):
|
||||
import notifications
|
||||
class Opener:
|
||||
def open(self, req, timeout):
|
||||
raise HTTPError(HOOK, 302, 'redirect', {}, None)
|
||||
def opener(handler):
|
||||
assert handler.redirect_request(None, None, 302, '', {}, 'https://evil.invalid') is None
|
||||
return Opener()
|
||||
monkeypatch.setattr(notifications, 'build_opener', opener)
|
||||
with pytest.raises(notifications.DeliveryError, match='302'):
|
||||
notifications.send_wecom(HOOK, {})
|
||||
|
||||
|
||||
def test_notice_login_password_return_and_scope(app, clients):
|
||||
oid = pending(app, clients)
|
||||
destination = f'/orders/{oid}/view'
|
||||
with app.Session() as db:
|
||||
db.query(app.User).filter_by(username='reviewer').one().must_change_password = True
|
||||
db.commit()
|
||||
c = app.app.test_client()
|
||||
response = c.get(destination)
|
||||
assert parse_qs(urlsplit(response.location).query)['next'] == [destination]
|
||||
c.get(response.location)
|
||||
response = post(c, '/login', {'username': 'reviewer', 'password': PASSWORD, 'next': destination})
|
||||
assert urlsplit(response.location).path == '/account/password'
|
||||
assert parse_qs(urlsplit(response.location).query)['next'] == [destination]
|
||||
c.get(response.location)
|
||||
new_password = PASSWORD + '-personal'
|
||||
response = post(c, '/account/password', {'old_password': PASSWORD, 'password': new_password,
|
||||
'confirm_password': new_password, 'next': destination})
|
||||
assert response.location == destination
|
||||
assert c.get(destination).status_code == 200
|
||||
assert order(app, oid)['status'] == 'pending_approval' # Viewing cannot approve.
|
||||
assert clients['other'].get(destination).status_code == 404
|
||||
assert c.get(f'/orders/{oid}/review').status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize('destination', ['https://evil.invalid', '//evil.invalid', '/\\evil.invalid',
|
||||
'/orders/1/approve', '/settings/notifications'])
|
||||
def test_login_next_cannot_escape_or_execute(app, destination):
|
||||
c = app.app.test_client()
|
||||
c.get('/login')
|
||||
assert post(c, '/login', {'username': 'maker', 'password': PASSWORD, 'next': destination}).location == '/'
|
||||
@@ -0,0 +1,339 @@
|
||||
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:
|
||||
content = ''.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-v3'
|
||||
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')
|
||||
Reference in New Issue
Block a user