From 18935f1379d44225217183a536517a70b7d6f95d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 15:53:55 +0800 Subject: [PATCH] test(order): verify live import template directories without business writes --- scripts/README.md | 2 + scripts/client-import-template-inspect.py | 193 ++++++++++++++++++ .../client-import-template-inspect.test.py | 53 +++++ scripts/client-import-template-test-env.mjs | 25 +++ scripts/client-workbench-test-env.mjs | 4 +- scripts/client-workbench-test-env.test.mjs | 7 +- 6 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 scripts/client-import-template-inspect.py create mode 100644 scripts/client-import-template-inspect.test.py create mode 100644 scripts/client-import-template-test-env.mjs diff --git a/scripts/README.md b/scripts/README.md index 47f0477bd..42898ab08 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-/`,失败保留。对应 `.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-/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。 `fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports//handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。 diff --git a/scripts/client-import-template-inspect.py b/scripts/client-import-template-inspect.py new file mode 100644 index 000000000..bc86a6079 --- /dev/null +++ b/scripts/client-import-template-inspect.py @@ -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) diff --git a/scripts/client-import-template-inspect.test.py b/scripts/client-import-template-inspect.test.py new file mode 100644 index 000000000..2dda6fbed --- /dev/null +++ b/scripts/client-import-template-inspect.test.py @@ -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", '') + z.writestr("_rels/.rels", f'') + z.writestr("xl/workbook.xml", f'选项数据!$A$1:$A${len(options)}') + z.writestr("xl/_rels/workbook.xml.rels", '' + ''.join(f'' for i in (1, 2, 3)) + '') + z.writestr("xl/worksheets/sheet1.xml", f'国内收货仓 *{target}') + data = ''.join(f'{escape(value)}' for i, value in enumerate(options, 1)) + z.writestr("xl/worksheets/sheet2.xml", f'{data}') + z.writestr("xl/worksheets/sheet3.xml", f'') + 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() diff --git a/scripts/client-import-template-test-env.mjs b/scripts/client-import-template-test-env.mjs new file mode 100644 index 000000000..250747351 --- /dev/null +++ b/scripts/client-import-template-test-env.mjs @@ -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=' } + 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 }) +} diff --git a/scripts/client-workbench-test-env.mjs b/scripts/client-workbench-test-env.mjs index ea9b845a8..349452c5a 100644 --- a/scripts/client-workbench-test-env.mjs +++ b/scripts/client-workbench-test-env.mjs @@ -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) { diff --git a/scripts/client-workbench-test-env.test.mjs b/scripts/client-workbench-test-env.test.mjs index 6e488a1f1..4240a7bdd 100644 --- a/scripts/client-workbench-test-env.test.mjs +++ b/scripts/client-workbench-test-env.test.mjs @@ -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() {