Commit bcbfeeba authored by wenwen.tang's avatar wenwen.tang 😕

test(contracts): 单测 20 项 + API 冒烟 24 项 + UI 回归 30 项;沙箱补 CONTRACT_DIR

- test_contracts.py(新,20 项):解码白名单 / 超限 / 非法 base64、存取与路径穿越、
  列值 parse/dump/describe 容错、AL 列登记、coerce、files_text、
  台账写读 + 导出 xlsx 是文件名、合同目录与备注图片目录互不干扰
- smoke_verify_contracts.py(新,24 项,经 runner 沙箱):上传 → 查阅 200 +
  application/pdf + 字节一致 → ?dl=1 是 attachment → 写 AL → 导出 xlsx 是文件名 →
  访客上传/删 403 但可查阅 → 非法类型 400 → 穿越 404 → 清空 AL → 删文件后 404
- ui_verify12.py(新,30 项,自带沙箱 + 真浏览器):表头「合同原件」在最右、
  只读无上传入口、编辑态「+ 合同」→ 浮层 → 真实文件选择器上传 → 三个操作齐全 →
  查阅链接带 token 且返回原字节 → 关浮层变「📎 1 份」且 pending → 保存落盘 →
  只读浮层只有查阅/下载 → 移除后 AL 清空但服务器文件仍在 → 无 console 错误
- 沙箱常量清单补 CONTRACT_DIR(runner + ui_verify7/8/9/10/11 + check_scheduler 共 7 处),
  漏了会像 DRAFT_DIR 那样把合同写进真实 data/
- test_rules:列数 31→32,并断言「合同原件」排最后、AJ 紧随其后
parent 7a75747e
......@@ -50,6 +50,7 @@ def sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONTRACT_DIR": os.path.join(data, "contracts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
# -*- coding: utf-8 -*-
"""合同原件(台账「合同原件」列)端到端冒烟 —— **必须经 ui_verify_runner 沙箱跑**。
用法:python tools/ui_verify_runner.py tools/smoke_verify_contracts.py
覆盖(2026-10-15 新增功能):
1. 列模型:/api/tables/rent_parking 有 AL「合同原件」,type=files,在最后一列
2. 上传:POST /api/contracts(PDF data URL)→ 拿到 id / name / size
3. 查阅:GET /api/contracts/{id}?token= → 200 + application/pdf + 字节与原文件一致
4. 下载:GET /api/contracts/{id}?dl=1 → Content-Disposition: attachment
5. 落库:PUT /api/tables/rent_parking/cells 写 AL → 重读台账值正确
6. 导出:GET /api/tables/rent_parking/export → xlsx 里 AL 单元格是文件名(不是 JSON)
7. 清空:AL 写空串 → 存成 None
8. 权限:合同接口仅管理员(guest 上传 / 删 一律 403),但 guest 能只读台账看到合同
9. 拒绝:.exe 之类不在白名单 → 400
10. 安全:路径穿越 id → 404;删除后查阅 → 404
"""
import base64
import io
import json
import os
import sys
import urllib.error
import urllib.request
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
BASE = os.environ["RENTOS_BASE"]
TOKEN = ""
FAILED = []
PDF = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n%%EOF\n"
class ApiError(Exception):
def __init__(self, code, payload):
super().__init__("HTTP %s %s" % (code, payload))
self.code = code
self.payload = payload
def call(path, method="GET", body=None, token=None, raw=False):
req = urllib.request.Request(BASE + path, method=method)
req.add_header("Authorization", "Bearer " + (token or TOKEN))
data = None
if body is not None:
req.add_header("Content-Type", "application/json")
data = json.dumps(body).encode("utf-8")
try:
with urllib.request.urlopen(req, data, timeout=60) as r:
blob = r.read()
if raw:
return r.status, dict(r.headers), blob
return json.loads(blob.decode("utf-8"))
except urllib.error.HTTPError as e:
if raw:
return e.code, dict(e.headers), e.read()
raise ApiError(e.code, e.read().decode("utf-8")) from None
def check(name, cond, detail=""):
print((" PASS " if cond else " FAIL ") + name + (" " + str(detail) if detail else ""))
if not cond:
FAILED.append(name)
def login(username="admin", password="1989@rent05"):
req = urllib.request.Request(BASE + "/api/login", method="POST")
req.add_header("Content-Type", "application/json")
d = json.dumps({"username": username, "password": password}).encode("utf-8")
with urllib.request.urlopen(req, d, timeout=20) as r:
return json.loads(r.read().decode("utf-8"))["token"]
def main():
global TOKEN
TOKEN = login()
print("\n-- 1. 列模型 --")
t = call("/api/tables/rent_parking")
cols = t["table"]["columns"]
al = [c for c in cols if c["id"] == "AL"]
check("AL 列存在", len(al) == 1)
check("标签=合同原件 / type=files", al and al[0]["label"] == "合同原件" and al[0]["type"] == "files", al)
check("合同列排最后", cols[-1]["id"] == "AL", [c["id"] for c in cols][-3:])
rows = t["table"]["rows"]
check("行数 > 0", len(rows) > 0, len(rows))
row_id = rows[1]["_id"] # 用第 2 行(302室)测
check("该行原本没有合同", not rows[1].get("AL"), rows[1].get("AL"))
print("\n-- 2. 上传合同 --")
up = call("/api/contracts", "POST", {
"filename": "302租赁合同.pdf",
"data": "data:application/pdf;base64," + base64.b64encode(PDF).decode(),
})
cid = up["contract"]["id"]
check("上传成功并返回 id", bool(cid) and len(cid) == 32, cid)
check("保留原始文件名", up["contract"]["name"] == "302租赁合同.pdf", up["contract"]["name"])
check("大小正确", up["contract"]["size"] == len(PDF), up["contract"]["size"])
print("\n-- 3. 查阅 / 下载 --")
st, hd, blob = call("/api/contracts/%s?token=%s" % (cid, TOKEN), raw=True)
check("查阅 200", st == 200, st)
check("Content-Type=application/pdf", "application/pdf" in (hd.get("Content-Type") or ""),
hd.get("Content-Type"))
check("字节与上传一致", blob == PDF, len(blob))
check("内联预览(非 attachment)",
"attachment" not in (hd.get("Content-Disposition") or ""), hd.get("Content-Disposition"))
st2, hd2, _ = call("/api/contracts/%s?dl=1&token=%s" % (cid, TOKEN), raw=True)
check("下载 200", st2 == 200, st2)
check("下载是 attachment",
"attachment" in (hd2.get("Content-Disposition") or ""), hd2.get("Content-Disposition"))
print("\n-- 4. 挂到台账 AL 列 --")
val = json.dumps([{"id": cid, "name": "302租赁合同.pdf"}], ensure_ascii=False)
r = call("/api/tables/rent_parking/cells", "PUT",
{"changes": [{"row_id": row_id, "col": "AL", "value": val}],
"confirm": True, "who": "冒烟测试"})
check("写入返回 200", r.get("ok"), r)
t2 = call("/api/tables/rent_parking")
row2 = [x for x in t2["table"]["rows"] if x["_id"] == row_id][0]
got = json.loads(row2["AL"])
check("台账读回 id 正确", got[0]["id"] == cid, got)
check("台账读回 name 正确", got[0]["name"] == "302租赁合同.pdf", got)
print("\n-- 5. 导出 xlsx 里是文件名 --")
st3, _, xb = call("/api/tables/rent_parking/export", raw=True)
check("导出 200", st3 == 200, st3)
try:
import openpyxl
ws = openpyxl.load_workbook(io.BytesIO(xb)).active
col_i = [c["id"] for c in cols if not c.get("hide")].index("AL") + 1
r_i = [x["_id"] for x in t2["table"]["rows"]].index(row_id) + 5
v = ws.cell(r_i, col_i).value
check("AL 单元格是文件名", v == "302租赁合同.pdf", v)
except ImportError:
check("openpyxl 可用(跳过 xlsx 校验)", False, "venv 里应有 openpyxl")
print("\n-- 6. 权限:合同仅管理员 --")
gtoken = login("guest", "guest@2026")
try:
call("/api/contracts", "POST",
{"filename": "x.pdf", "data": base64.b64encode(PDF).decode()}, token=gtoken)
check("访客上传被拒 403", False, "竟然成功了")
except ApiError as e:
check("访客上传被拒 403", e.code == 403, e.code)
try:
call("/api/contracts/" + cid, "DELETE", token=gtoken)
check("访客删合同被拒 403", False, "竟然成功了")
except ApiError as e:
check("访客删合同被拒 403", e.code == 403, e.code)
gt = call("/api/tables/rent_parking", token=gtoken)
grow = [x for x in gt["table"]["rows"] if x["_id"] == row_id][0]
check("访客只读也能看到合同", json.loads(grow["AL"])[0]["id"] == cid, grow.get("AL"))
st4, _, _ = call("/api/contracts/%s?token=%s" % (cid, gtoken), raw=True)
check("访客能查阅合同", st4 == 200, st4)
print("\n-- 7. 拒绝非法类型 / 路径穿越 --")
try:
call("/api/contracts", "POST",
{"filename": "virus.exe", "data": base64.b64encode(b"MZ\x90\x00").decode()})
check("不支持的类型被拒", False, "竟然成功了")
except ApiError as e:
check("不支持的类型被拒 400", e.code == 400, e.code)
st5, _, _ = call("/api/contracts/../../etc/passwd?token=%s" % TOKEN, raw=True)
check("路径穿越 404", st5 == 404, st5)
print("\n-- 8. 清空 AL / 删除合同 --")
r2 = call("/api/tables/rent_parking/cells", "PUT",
{"changes": [{"row_id": row_id, "col": "AL", "value": ""}],
"confirm": True, "who": "冒烟测试"})
check("清空写入 200", r2.get("ok"), r2)
t3 = call("/api/tables/rent_parking")
row3 = [x for x in t3["table"]["rows"] if x["_id"] == row_id][0]
check("清空后为 None", row3.get("AL") is None, row3.get("AL"))
d = call("/api/contracts/" + cid, "DELETE")
check("删除合同 ok", d.get("ok"), d)
st6, _, _ = call("/api/contracts/%s?token=%s" % (cid, TOKEN), raw=True)
check("删除后查阅 404", st6 == 404, st6)
print("\n" + "=" * 60)
if FAILED:
print("失败 %d 项:%s" % (len(FAILED), "; ".join(FAILED)))
return 1
print("全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
This diff is collapsed.
......@@ -403,12 +403,12 @@ class TestRentParkingTable(unittest.TestCase):
spec = tables.list_tables()[0]
self.assertEqual(spec["name"], "房租车位费")
self.assertTrue(spec["ready"])
self.assertEqual(spec["col_count"], 31) # 29 出单列 + 1 免租类型 + 1 推送排期
self.assertEqual(spec["col_count"], 32) # 29 出单列 + 1 免租类型 + 1 推送排期 + 1 合同原件
def test_no_hidden_column(self):
t = self._t()
self.assertEqual([c["id"] for c in t["columns"] if c.get("hide")], [])
self.assertEqual(len(t["columns"]), 31)
self.assertEqual(len(t["columns"]), 32)
self.assertNotIn("序号", [c["label"] for c in t["columns"]])
def test_column_layout(self):
......@@ -472,7 +472,8 @@ class TestRentParkingTable(unittest.TestCase):
self.assertEqual(cmap["AJ"]["type"], "date")
self.assertEqual(cmap["AJ"]["group"], "推送")
self.assertEqual(tables.BILL_COLS["next_bill"], "AJ")
self.assertEqual([c["id"] for c in t["columns"]][-1], "AJ", "推送列应是最后一列")
self.assertEqual([c["id"] for c in t["columns"]][-2], "AJ", "推送列紧邻最右的合同列")
self.assertEqual([c["id"] for c in t["columns"]][-1], "AL", "合同原件列排最后")
def test_retired_cols_not_used_by_billing(self):
"""已删除的房产/房产税列不得再被出单逻辑(BILL_COLS)引用。"""
......
......@@ -63,6 +63,7 @@ def start_sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONTRACT_DIR": os.path.join(data, "contracts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -57,6 +57,7 @@ def start_sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONTRACT_DIR": os.path.join(data, "contracts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
This diff is collapsed.
......@@ -57,6 +57,7 @@ def start_sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONTRACT_DIR": os.path.join(data, "contracts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -59,6 +59,7 @@ def start_sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONTRACT_DIR": os.path.join(data, "contracts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -55,6 +55,7 @@ def start_sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONTRACT_DIR": os.path.join(data, "contracts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -46,6 +46,7 @@ def start_sandbox():
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"), # 电费录入草稿(临时保存)
"CONTRACT_DIR": os.path.join(data, "contracts"), # 台账「合同原件」
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment