371 lines
16 KiB
Python
371 lines
16 KiB
Python
"""边界输入与畸形请求测试(迭代深挖)。
|
|
|
|
针对外部输入面做模糊测试:负数/零/超大数/非法类型/超长串/注入串。
|
|
目标不是"功能正确",而是**不崩、不 500、不留脏数据**(返回 4xx 即合格)。
|
|
"""
|
|
|
|
import pytest
|
|
from decimal import Decimal
|
|
from model_bakery import baker
|
|
from rest_framework.test import APIClient
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
|
from apps.catalog.models import Product
|
|
from apps.inventory.models import Warehouse, Stock
|
|
from apps.partner.models import Customer
|
|
from apps.sales import services as sales_services
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_client(db, user, tenant):
|
|
c = APIClient()
|
|
refresh = RefreshToken.for_user(user)
|
|
c.credentials(
|
|
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
|
|
HTTP_X_TENANT_ID=tenant.code,
|
|
)
|
|
return c
|
|
|
|
|
|
@pytest.fixture
|
|
def warehouse(db, tenant):
|
|
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
|
|
|
|
|
|
@pytest.fixture
|
|
def product(db, tenant):
|
|
return baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("10"))
|
|
|
|
|
|
@pytest.fixture
|
|
def customer(db, tenant):
|
|
return baker.make(Customer, tenant=tenant, code="C001", name="张三",
|
|
credit_limit=Decimal("0"))
|
|
|
|
|
|
# ============================================================
|
|
# 建单接口:畸形 lines
|
|
# ============================================================
|
|
|
|
|
|
def _create_bill(client, customer, warehouse, lines):
|
|
return client.post("/api/v1/sales/bills/create-bill/", {
|
|
"customer": customer.id, "warehouse": warehouse.id, "lines": lines,
|
|
}, format="json")
|
|
|
|
|
|
@pytest.mark.parametrize("bad_qty", ["0", "-1", "-999", "abc", "", None])
|
|
def test_create_bill_rejects_bad_quantity(db, auth_client, customer, warehouse,
|
|
product, bad_qty):
|
|
"""非法数量必须 4xx(不能 500、不能落库)。"""
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": bad_qty,
|
|
"unit_price": "10"}])
|
|
assert 400 <= resp.status_code < 500, f"qty={bad_qty!r} → {resp.status_code}"
|
|
|
|
|
|
@pytest.mark.parametrize("bad_price", ["-1", "abc"])
|
|
def test_create_bill_rejects_bad_price(db, auth_client, customer, warehouse,
|
|
product, bad_price):
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": 1,
|
|
"unit_price": bad_price}])
|
|
assert 400 <= resp.status_code < 500, f"price={bad_price!r} → {resp.status_code}"
|
|
|
|
|
|
def test_create_bill_rejects_huge_quantity(db, auth_client, customer, warehouse, product):
|
|
"""超大数量不该导致 500(库存校验会拒绝,或数量格式非法)。"""
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": "999999999999999999999",
|
|
"unit_price": "10"}])
|
|
assert resp.status_code < 500, f"超大数量 → {resp.status_code}"
|
|
|
|
|
|
def test_create_bill_rejects_nonexistent_product(db, auth_client, customer, warehouse):
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": 999999, "quantity": 1, "unit_price": "10"}])
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_create_bill_rejects_nonexistent_customer(db, auth_client, warehouse, product):
|
|
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
|
"customer": 999999, "warehouse": warehouse.id,
|
|
"lines": [{"product": product.id, "quantity": 1, "unit_price": "10"}],
|
|
}, format="json")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_create_bill_rejects_empty_lines(db, auth_client, customer, warehouse):
|
|
resp = _create_bill(auth_client, customer, warehouse, [])
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_create_bill_rejects_malformed_lines(db, auth_client, customer, warehouse):
|
|
"""lines 传字符串/数字/非 dict 元素等畸形结构:一律 4xx,不能 500。"""
|
|
for bad in ["not-a-list", 123, {"a": 1}, [None], ["string"], [123], [{}]]:
|
|
resp = _create_bill(auth_client, customer, warehouse, bad)
|
|
assert 400 <= resp.status_code < 500, f"lines={bad!r} → {resp.status_code}"
|
|
|
|
|
|
def test_create_bill_rejects_invalid_round_to(db, auth_client, customer, warehouse, product):
|
|
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
|
"customer": customer.id, "warehouse": warehouse.id, "round_to": "abc",
|
|
"lines": [{"product": product.id, "quantity": 1, "unit_price": "10"}],
|
|
}, format="json")
|
|
assert resp.status_code < 500
|
|
|
|
|
|
# ============================================================
|
|
# 超长 / 注入串
|
|
# ============================================================
|
|
|
|
|
|
def test_create_bill_handles_long_remark(db, auth_client, customer, warehouse, product):
|
|
"""超长备注不该 500(数据库列是 Text,但要有合理处理)。"""
|
|
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
|
"customer": customer.id, "warehouse": warehouse.id,
|
|
"remark": "x" * 10000,
|
|
"lines": [{"product": product.id, "quantity": 1, "unit_price": "10"}],
|
|
}, format="json")
|
|
assert resp.status_code < 500
|
|
|
|
|
|
def test_product_code_with_sql_meta(db, auth_client, tenant):
|
|
"""SQL 元字符进商品编码:应被当普通文本(Django ORM 参数化)。"""
|
|
resp = auth_client.post("/api/v1/catalog/products/", {
|
|
"code": "P'; DROP TABLE catalog_product; --",
|
|
"name": "注入测试",
|
|
}, format="json")
|
|
assert resp.status_code in (201, 400)
|
|
# 表还在
|
|
assert Product.objects.count() >= 0
|
|
|
|
|
|
def test_product_name_with_html(db, auth_client):
|
|
"""HTML/脚本进商品名:存储原样,渲染时转义(打印已测)。"""
|
|
resp = auth_client.post("/api/v1/catalog/products/", {
|
|
"code": "XSS01", "name": "<script>alert(1)</script>",
|
|
}, format="json")
|
|
assert resp.status_code in (201, 400)
|
|
|
|
|
|
def test_search_with_special_chars(db, auth_client, product):
|
|
"""搜索框输入 SQL/正则元字符不应 500。"""
|
|
for q in ["%", "_", "'", '"', "\\", ".*", "(a+)+", "'; DROP TABLE--"]:
|
|
resp = auth_client.get("/api/v1/catalog/products/", {"search": q})
|
|
assert resp.status_code == 200, f"search={q!r} → {resp.status_code}"
|
|
|
|
|
|
# ============================================================
|
|
# AI 接口边界
|
|
# ============================================================
|
|
|
|
|
|
def test_ai_parse_order_empty_text(db, auth_client):
|
|
assert auth_client.post("/api/v1/ai/parse-order/", {"text": ""},
|
|
format="json").status_code == 400
|
|
assert auth_client.post("/api/v1/ai/parse-order/", {},
|
|
format="json").status_code == 400
|
|
|
|
|
|
def test_ai_parse_order_huge_text(db, auth_client, settings):
|
|
"""超长文本不该打爆服务(应有上限或降级)。"""
|
|
settings.AI_PROVIDER = ""
|
|
settings.AI_API_KEY = ""
|
|
resp = auth_client.post("/api/v1/ai/parse-order/",
|
|
{"text": "可乐 2 箱\n" * 5000}, format="json")
|
|
assert resp.status_code < 500
|
|
|
|
|
|
def test_ai_ask_empty_question(db, auth_client):
|
|
assert auth_client.post("/api/v1/ai/ask/", {"question": ""},
|
|
format="json").status_code == 400
|
|
|
|
|
|
def test_ai_ask_special_chars(db, auth_client, settings):
|
|
settings.AI_PROVIDER = ""
|
|
settings.AI_API_KEY = ""
|
|
resp = auth_client.post("/api/v1/ai/ask/",
|
|
{"question": "'; DROP TABLE ai_usage; -- 销售额"},
|
|
format="json")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================
|
|
# 分页与查询参数
|
|
# ============================================================
|
|
|
|
|
|
@pytest.mark.parametrize("params", [
|
|
{"page": "0"}, {"page": "-1"}, {"page": "abc"}, {"page": "999999999"},
|
|
{"page_size": "0"}, {"page_size": "-5"}, {"page_size": "99999"},
|
|
{"page_size": "abc"},
|
|
])
|
|
def test_pagination_handles_bad_params(db, auth_client, product, params):
|
|
"""异常分页参数不该 500。"""
|
|
resp = auth_client.get("/api/v1/catalog/products/", params)
|
|
assert resp.status_code == 200, f"params={params} → {resp.status_code}"
|
|
|
|
|
|
def test_pagination_page_size_capped(db, auth_client, tenant):
|
|
"""page_size 应有上限(防止一次拉全表)。"""
|
|
for i in range(5):
|
|
baker.make(Product, tenant=tenant, code=f"P{i:03d}", name=f"商品{i}")
|
|
resp = auth_client.get("/api/v1/catalog/products/", {"page_size": "99999"})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()["results"]) <= 200 # max_page_size
|
|
|
|
|
|
# ============================================================
|
|
# 商城下单边界
|
|
# ============================================================
|
|
|
|
|
|
def test_storefront_order_bad_payload(db, client, tenant):
|
|
"""商城未登录 + 畸形 payload:应是 401,不是 500。"""
|
|
for payload in [{}, {"lines": []}, {"lines": "x"}, {"lines": [{"product_id": -1}]}]:
|
|
resp = client.post("/api/v1/storefront/orders/", payload,
|
|
format="json", HTTP_X_TENANT_ID=tenant.code)
|
|
assert resp.status_code < 500, f"{payload} → {resp.status_code}"
|
|
|
|
|
|
def test_storefront_login_bad_payload(db, client, tenant):
|
|
for payload in [{}, {"phone": ""}, {"password": ""},
|
|
{"phone": "x" * 500, "password": "y"},
|
|
{"phone": 123, "password": 456}]:
|
|
resp = client.post("/api/v1/storefront/login/", payload,
|
|
format="json", HTTP_X_TENANT_ID=tenant.code)
|
|
assert resp.status_code < 500, f"{payload} → {resp.status_code}"
|
|
|
|
|
|
# ============================================================
|
|
# 极小金额单据(迭代第 2 轮发现)
|
|
# ============================================================
|
|
|
|
|
|
def test_create_bill_rejects_tiny_amount(db, auth_client, customer, warehouse, product):
|
|
"""0.0001 数量会产生 0.0016 元的荒谬单据——后端必须拒绝。
|
|
|
|
背景:前端 el-input-number 的 `:min` 会把越界的 0 自动「纠正」为 min,
|
|
用户看到的仍是 0 但提交的是 min 值。后端不能依赖前端校验。
|
|
"""
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": "0.0001",
|
|
"unit_price": "15.5"}])
|
|
assert resp.status_code == 400, f"0.0001 数量应被拒 → {resp.status_code}"
|
|
assert resp.json()["code"] in ("invalid_quantity", "invalid_line")
|
|
|
|
|
|
def test_create_bill_rejects_zero_amount(db, auth_client, customer, warehouse, product):
|
|
"""单价 0 + 数量正常 → 合计为 0,应拒绝(避免空金额单据)。"""
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": "5", "unit_price": "0"}])
|
|
assert resp.status_code == 400, f"0 金额应被拒 → {resp.status_code}"
|
|
|
|
|
|
def test_purchase_bill_rejects_zero_amount(db, auth_client, tenant, warehouse):
|
|
from apps.partner.models import Supplier
|
|
|
|
supplier = baker.make(Supplier, tenant=tenant, code="S900", name="供应商")
|
|
prod = baker.make(Product, tenant=tenant, code="PZ01", name="测试品")
|
|
resp = auth_client.post("/api/v1/purchase/bills/create-bill/", {
|
|
"supplier": supplier.id, "warehouse": warehouse.id,
|
|
"lines": [{"product": prod.id, "quantity": 5, "unit_price": "0"}],
|
|
}, format="json")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_quantity_upper_bound_enforced(db, auth_client, customer, warehouse, product):
|
|
"""数量上限 10 亿(防止天文数字撑爆计算)。"""
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": "1000000001",
|
|
"unit_price": "10"}])
|
|
assert resp.status_code == 400
|
|
assert resp.json()["code"] == "invalid_quantity"
|
|
|
|
|
|
def test_normal_bill_still_works(db, auth_client, customer, warehouse, product):
|
|
"""正常单据不受影响(回归保护)。"""
|
|
from apps.inventory import services as inv
|
|
|
|
inv.inbound(tenant=tenant_of(auth_client), warehouse=warehouse, product=product,
|
|
quantity=Decimal("100"), unit_cost=Decimal("5"))
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": "3", "unit_price": "10"}])
|
|
assert resp.status_code == 201, resp.content
|
|
|
|
|
|
def tenant_of(client):
|
|
from apps.core.models import Tenant
|
|
|
|
return Tenant.objects.get(code="test")
|
|
|
|
|
|
# ============================================================
|
|
# 0 价拦截(迭代第 4 轮)
|
|
# ============================================================
|
|
|
|
|
|
def test_zero_price_rejected_sales(db, auth_client, customer, warehouse, product):
|
|
"""0 元销售单必须被拒(白送应走专门流程,不该产生 0 金额单据)。"""
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": 5, "unit_price": "0"}])
|
|
assert resp.status_code == 400, resp.content
|
|
assert resp.json()["code"] == "invalid_price"
|
|
|
|
|
|
def test_zero_price_rejected_purchase(db, auth_client, tenant, warehouse):
|
|
"""0 元进货必须被拒:会污染加权平均成本(把真实成本拉低 → 毛利虚高)。"""
|
|
from apps.partner.models import Supplier
|
|
|
|
supplier = baker.make(Supplier, tenant=tenant, code="S-ZERO", name="供应商")
|
|
prod = baker.make(Product, tenant=tenant, code="PZERO", name="测试品")
|
|
resp = auth_client.post("/api/v1/purchase/bills/create-bill/", {
|
|
"supplier": supplier.id, "warehouse": warehouse.id,
|
|
"lines": [{"product": prod.id, "quantity": 10, "unit_price": "0"}],
|
|
}, format="json")
|
|
assert resp.status_code == 400, resp.content
|
|
assert resp.json()["code"] == "invalid_price"
|
|
|
|
|
|
def test_negative_price_rejected_sales(db, auth_client, customer, warehouse, product):
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": 5, "unit_price": "-10"}])
|
|
assert resp.status_code == 400
|
|
assert resp.json()["code"] == "invalid_price"
|
|
|
|
|
|
def test_positive_price_still_works(db, auth_client, customer, warehouse, product):
|
|
"""回归保护:正常价格不受影响。"""
|
|
from apps.inventory import services as inv
|
|
from apps.core.models import Tenant
|
|
|
|
t = Tenant.objects.get(code="test")
|
|
inv.inbound(tenant=t, warehouse=warehouse, product=product,
|
|
quantity=Decimal("100"), unit_cost=Decimal("5"))
|
|
resp = _create_bill(auth_client, customer, warehouse,
|
|
[{"product": product.id, "quantity": 3, "unit_price": "0.01"}])
|
|
assert resp.status_code == 201, resp.content
|
|
|
|
|
|
def test_weighted_cost_not_polluted_by_zero_cost(db, tenant, warehouse, product):
|
|
"""单元级:0 成本入库不改变加权平均成本(因为业务层已拒绝)。
|
|
|
|
这里直接验证 inbound 的行为:unit_cost=0 时不参与加权(保持原成本)。
|
|
"""
|
|
from apps.inventory import services as inv
|
|
from apps.inventory.models import Stock
|
|
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=product,
|
|
quantity=Decimal("10"), unit_cost=Decimal("5"))
|
|
st = Stock.objects.get(product=product)
|
|
assert st.avg_cost == Decimal("5.0000")
|
|
|
|
# 0 成本入库(理论上业务层已拦,这里测服务层兜底行为)
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=product,
|
|
quantity=Decimal("10"), unit_cost=Decimal("0"))
|
|
st.refresh_from_db()
|
|
# 成本不应被 0 拉低
|
|
assert st.avg_cost == Decimal("5.0000"), f"0 成本污染了加权成本:{st.avg_cost}"
|