test(order): verify live import template directories without business writes

This commit is contained in:
2026-10-03 15:53:55 +08:00
parent bf8a985f22
commit 18935f1379
6 changed files with 279 additions and 5 deletions
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引
`client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=<load_workspace_dependencies返回的Python路径>`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-<run-id>/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。
`client-workbench-test-env.mjs` 是客户订单工作台只读 TEST 分页验收入口,默认 `--plan` 零网络。Firstleg/Order 新工作台接口部署后,以显式 `node --env-file=<用户指定目录的test-acceptance.env> scripts/client-workbench-test-env.mjs --execute --environment=TEST --run-id=<新批次>` 执行;独立客户登录及退出是唯一 POST,不创建订单、不写 SQL、不调用会补建预入库记录的旧草稿列表。要求稳定样本超过 200 条,遍历完整工作台及独立统一订单集合,核对完整正式单集合、费用/草稿待办、报关队列分页数量、21页切片、渠道筛选和伪造客户参数。报关业务资格、第二客户负向权限、页面及写后刷新仍需另验。报告独占 `reports/client-workbench-<run-id>/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。
`fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports/<C批次>/handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-<run-id>/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。
+193
View File
@@ -0,0 +1,193 @@
"""Read-only TEST template audit. Credentials and workbook bytes stay in memory."""
import hashlib
import io
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from openpyxl import load_workbook
def require(value, message):
if not value:
raise AssertionError(message)
def labels(values, labeler):
result = []
for item in values:
base = labeler(item)
label, n = base, 2
while label in result:
label = f"{base}(同名选项 {n})"
n += 1
result.append(label)
return result
def label(name, code):
return code if not name or name.lower() == code.lower() else f"{name}|{code}"
def compare_options(actual, expected):
return {"expectedCount": len(expected), "actualCount": len(actual),
"missingCount": len(set(expected) - set(actual)),
"extraCount": len(set(actual) - set(expected)),
"duplicateCount": len(actual) - len(set(actual)),
"passed": sorted(actual) == sorted(expected),
"actualSha256": hashlib.sha256(json.dumps(sorted(actual), ensure_ascii=False).encode()).hexdigest()}
def inspect_template(content, expected):
book = load_workbook(io.BytesIO(content), data_only=False)
try:
require(set(book.sheetnames) == {"订单导入", "选项数据", "填写说明"}, "Unexpected workbook sheets")
require(book["选项数据"].sheet_state == "hidden", "Options sheet must be hidden")
sheet = book["订单导入"]
headers = {str(cell.value).removesuffix(" *"): cell.column for cell in sheet[2] if cell.value}
checks = {}
for title, values in expected.items():
require(title in headers, "Missing expected header")
col = headers[title]
validations = [v for v in sheet.data_validations.dataValidation
if any(r.min_col <= col <= r.max_col and r.min_row <= 3 <= r.max_row for r in v.sqref.ranges)]
if not values:
require(not validations, "Empty customer directory unexpectedly has choices")
checks[title] = compare_options([], [])
continue
require(len(validations) == 1, "Missing or overlapping dropdown validation")
validation = validations[0]
require(validation.type == "list" and validation.showErrorMessage, "Dropdown must reject invalid choices")
require(any(r.min_row == 3 and r.max_row == 52 and r.min_col == col and r.max_col == col
for r in validation.sqref.ranges), "Dropdown must cover all 50 input rows")
name = book.defined_names.get(validation.formula1)
require(name is not None, "Dropdown name does not resolve")
destinations = list(name.destinations)
require(len(destinations) == 1 and destinations[0][0] == "选项数据", "Invalid dropdown destination")
actual = [cell.value for row in book[destinations[0][0]][destinations[0][1]] for cell in row]
require(all(isinstance(v, str) and v for v in actual), "Dropdown contains blank or invalid entries")
checks[title] = compare_options(actual, values)
return {"checks": checks, "passed": all(v["passed"] for v in checks.values()),
"workbookSha256": hashlib.sha256(content).hexdigest(), "headerCount": len(headers),
"dropdowns": len(sheet.data_validations.dataValidation)}
finally:
book.close()
def main(config):
require(config.get("environment") == "TEST" and re.fullmatch(r"[A-Za-z0-9_-]{1,60}", config.get("runId", "")), "Explicit TEST run required")
directory = Path(__file__).resolve().parent.parent / "reports" / ("client-template-" + config["runId"])
directory.mkdir() # Never overwrite an earlier run.
report_path = directory / "report.json"
report = {"startedAt": datetime.now(timezone.utc).isoformat(), "readOnlyBusiness": True,
"success": False, "sessionClosed": False, "requests": []}
token, refresh = "", ""
def save():
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
def request(method, path, body=None, binary=False):
require(method == "GET" or method == "POST" and path in ["/auth/login", "/auth/logout"], "Business writes forbidden")
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query)
record = {"method": method, "path": parsed.path, "outcome": "PENDING",
"page": query.get("page"), "size": query.get("size")}
report["requests"].append(record)
save()
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
if body is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(config["base"] + path, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
try:
with urllib.request.urlopen(req, timeout=30) as response:
data = response.read()
record.update(outcome="RECEIVED", http=response.status,
requestId=response.headers.get("X-Request-ID"), traceId=response.headers.get("X-Trace-ID"))
if binary:
require(data[:2] == b"PK", "Template response is not XLSX")
record["sha256"] = hashlib.sha256(data).hexdigest()
save()
return data
payload = json.loads(data)
record.update(code=payload.get("code"), requestId=payload.get("requestId") or record["requestId"],
traceId=payload.get("traceId") or record["traceId"])
save()
require(str(payload.get("code")) == "200", "API business response rejected")
return payload["data"]
except urllib.error.HTTPError as error:
record.update(outcome="RECEIVED", http=error.code)
save()
raise AssertionError("HTTP response rejected") from None
except Exception:
if record["outcome"] == "PENDING":
record["outcome"] = "UNKNOWN"
save()
raise
try:
auth = request("POST", "/auth/login", {"loginId": config["username"], "password": config["password"]})
token, refresh = auth.get("accessToken", ""), auth.get("refreshToken", "")
require(token and refresh, "Incomplete login")
warehouses, total, page_no = [], None, 1
while True:
page = request("GET", f"/merchant/v1/warehouses?status=ACTIVE&page={page_no}&size=200")
if total is None:
total = int(page["total"])
require(int(page["page"]) == page_no and int(page["size"]) == 200 and int(page["total"]) == total
and int(page["pages"]) == (total + 199) // 200, "Warehouse pagination changed")
require(len(page["records"]) == min(200, max(0, total - (page_no - 1) * 200)), "Warehouse page incomplete")
warehouses.extend(page["records"])
if page_no >= int(page["pages"]):
break
require(page_no < 1000, "Warehouse page limit exceeded")
page_no += 1
require(len({str(w["id"]) for w in warehouses}) == total and all(w["status"] == "ACTIVE" for w in warehouses), "Invalid warehouse set")
expected = {}
for kind, title in [("DOMESTIC", "国内收货仓"), ("FBA", "FBA目的仓"), ("OVERSEAS", "海外仓目的仓")]:
expected[title] = labels([w for w in warehouses if w["warehouseType"] == kind], lambda w: label(w["warehouseName"], w["warehouseCode"]))
channels = []
for warehouse in warehouses:
if warehouse["warehouseType"] != "DOMESTIC":
continue
values = request("GET", "/merchant/v1/first-leg/pricing/channels?originWarehouseId=" + str(warehouse["id"]))
channels.extend(label(c["channelName"], c["channelCode"]) for c in values)
expected["运输渠道"] = list(dict.fromkeys(channels))
for kind, title in [("PICKUP", "取货地址"), ("PRIVATE_DESTINATION", "私人目的仓地址")]:
addresses = request("GET", "/merchant/v1/orders/first-leg/addresses?addressType=" + kind)
expected[title] = labels(addresses, lambda a: "|".join(a[key] for key in ["addressName", "contactName", "city", "addressLine"]))
content = request("GET", "/merchant/v1/orders/first-leg/batch-imports/template", binary=True)
report["template"] = inspect_template(content, expected)
report["warehousePages"] = page_no
report["warehouseTotal"] = total
report["success"] = report["template"]["passed"]
report["limitations"] = ["No import submission or Excel desktop interaction performed",
"Second-customer address rejection requires separate evidence"]
except Exception as error:
report["failure"] = str(error) if isinstance(error, AssertionError) else type(error).__name__
finally:
if token:
try:
request("POST", "/auth/logout", {"refreshToken": refresh})
report["sessionClosed"] = True
except Exception:
report["logoutFailure"] = True
report["success"] = report["success"] and report["sessionClosed"]
report["completedAt"] = datetime.now(timezone.utc).isoformat()
save()
print(json.dumps({"success": report["success"], "sessionClosed": report["sessionClosed"], "report": str(report_path)}))
return 0 if report["success"] else 1
if __name__ == "__main__":
try:
sys.exit(main(json.load(sys.stdin)))
except Exception:
print("Template audit did not start; verify the new report directory and runtime.", file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,53 @@
import importlib.util
import io
import unittest
import zipfile
from pathlib import Path
from xml.sax.saxutils import escape
spec = importlib.util.spec_from_file_location("template_audit", Path(__file__).with_name("client-import-template-inspect.py"))
audit = importlib.util.module_from_spec(spec)
spec.loader.exec_module(audit)
def fixture(options=("测试仓|WH-A", "测试仓|WH-B"), rows="A3:A52", target="option_0", hidden=True):
ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
body = io.BytesIO()
with zipfile.ZipFile(body, "w") as z:
z.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>')
z.writestr("_rels/.rels", f'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{rel}/officeDocument" Target="xl/workbook.xml"/></Relationships>')
z.writestr("xl/workbook.xml", f'<workbook xmlns="{ns}" xmlns:r="{rel}"><sheets><sheet name="订单导入" sheetId="1" r:id="rId1"/><sheet name="选项数据" sheetId="2" state="{"hidden" if hidden else "visible"}" r:id="rId2"/><sheet name="填写说明" sheetId="3" r:id="rId3"/></sheets><definedNames><definedName name="option_0">选项数据!$A$1:$A${len(options)}</definedName></definedNames></workbook>')
z.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + ''.join(f'<Relationship Id="rId{i}" Type="{rel}/worksheet" Target="worksheets/sheet{i}.xml"/>' for i in (1, 2, 3)) + '</Relationships>')
z.writestr("xl/worksheets/sheet1.xml", f'<worksheet xmlns="{ns}"><sheetData><row r="2"><c r="A2" t="inlineStr"><is><t>国内收货仓 *</t></is></c></row></sheetData><dataValidations count="1"><dataValidation type="list" showErrorMessage="1" sqref="{rows}"><formula1>{target}</formula1></dataValidation></dataValidations></worksheet>')
data = ''.join(f'<row r="{i}"><c r="A{i}" t="inlineStr"><is><t>{escape(value)}</t></is></c></row>' for i, value in enumerate(options, 1))
z.writestr("xl/worksheets/sheet2.xml", f'<worksheet xmlns="{ns}"><sheetData>{data}</sheetData></worksheet>')
z.writestr("xl/worksheets/sheet3.xml", f'<worksheet xmlns="{ns}"><sheetData/></worksheet>')
return body.getvalue()
class TemplateAuditTest(unittest.TestCase):
def test_reads_actual_xlsx_names_validation_and_all_options(self):
result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]})
self.assertTrue(result["passed"])
self.assertEqual(result["checks"]["国内收货仓"]["actualCount"], 2)
def test_missing_extra_and_duplicate_options_are_reported_without_values(self):
result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "其他仓|WH-C"]})
self.assertFalse(result["passed"])
self.assertEqual(result["checks"]["国内收货仓"]["missingCount"], 1)
self.assertEqual(result["checks"]["国内收货仓"]["extraCount"], 1)
self.assertNotIn("WH-A", str(result))
self.assertFalse(audit.compare_options(["A", "A"], ["A"])["passed"])
def test_refuses_short_ranges_unresolved_names_and_exposed_options(self):
for options in ({"rows": "A3:A51"}, {"target": "missing"}, {"hidden": False}):
with self.assertRaises(AssertionError):
audit.inspect_template(fixture(**options), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]})
def test_duplicate_business_labels_remain_distinct_choices(self):
self.assertEqual(audit.labels(["A", "A", "A"], lambda x: x), ["A", "A(同名选项 2)", "A(同名选项 3)"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,25 @@
import { spawn } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { executionOptions } from './client-workbench-test-env.mjs'
export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) {
if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, businessWrites: false,
checks: ['full warehouse catalog', 'channel union', 'own customer addresses', 'named dropdown ranges', '50 input rows'],
required: 'Explicit TEST profile, new run-id, and --python=<bundled workspace Python>' }
if (args.length !== 4 || !args[3].startsWith('--python=')) throw new Error('Explicit bundled Python required')
const options = executionOptions(args.slice(0, 3), execArgs, env)
const python = resolve(args[3].slice(9))
if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw new Error('Use bundled workspace Python')
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const config = { environment: 'TEST', base: options.base, username: env.CHONGCHONG_MERCHANT_USERNAME,
password: env.CHONGCHONG_MERCHANT_PASSWORD, runId: options.runId }
const child = spawn(python, [resolve(root, 'scripts/client-import-template-inspect.py')], { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] })
child.stdin.end(JSON.stringify(config))
const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) })
return { success: code === 0, report: resolve(root, 'reports', `client-template-${options.runId}`, 'report.json') }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 })
.catch(() => { console.error('Template audit did not start. Check explicit TEST profile, runtime and new run-id.'); process.exitCode = 1 })
}
+2 -2
View File
@@ -23,8 +23,8 @@ export function executionOptions(args, execArgs, env) {
requireFact(Boolean(env[key]), `Missing ${key}`)
const base = new URL(env.CHONGCHONG_MERCHANT_API_BASE_URL)
requireFact(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password
&& base.pathname.replace(/\/$/, '') === '/api', 'Expected the configured TEST API base')
return { execute: true, runId: args[2].slice(9), base: base.href.replace(/\/$/, '') }
&& base.pathname === '/' && !base.search && !base.hash, 'Expected the configured TEST Gateway origin')
return { execute: true, runId: args[2].slice(9), base: base.origin + '/api' }
}
export function validatePage(page, number, size) {
+4 -3
View File
@@ -2,21 +2,22 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { executionOptions, validatePage, verifyWorkbench } from './client-workbench-test-env.mjs'
const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/api', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' }
const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' }
const args = ['--execute', '--environment=TEST', '--run-id=fixture']
const execArgs = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env']
test('plan has no credential or environment dependency', () => {
assert.deepEqual(executionOptions([], [], {}), { execute: false })
assert.deepEqual(executionOptions(['--plan'], [], {}), { execute: false })
})
test('execution requires explicit TEST profile, safe run id and API prefix', () => {
test('execution requires explicit TEST profile, safe run id and Gateway origin', () => {
assert.equal(executionOptions(args, execArgs, env).runId, 'fixture')
assert.equal(executionOptions(args, execArgs, env).base, 'https://test.invalid/api')
for (const [a, x, e] of [
[args, [], env], [args, execArgs, { ...env, CHONGCHONG_ENV: 'PROD' }],
[args, [execArgs[0].replace('test-acceptance', 'prod-acceptance')], env],
[['--execute', '--environment=TEST', '--run-id=../escape'], execArgs, env],
[[...args, '--resume'], execArgs, env],
[args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/' }],
[args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/other' }],
]) assert.throws(() => executionOptions(a, x, e))
})
function fixture() {