From 14696fb1a120e358eec390457a50b3b5e8def883 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 17:08:15 +0800 Subject: [PATCH] test(order): verify foreign customer addresses rejected in XLSX imports --- scripts/README.md | 2 + scripts/client-xlsx-address-probe.py | 195 ++++++++++++++++++ scripts/client-xlsx-address-probe.test.py | 57 +++++ scripts/client-xlsx-address-test-env.mjs | 30 +++ scripts/client-xlsx-address-test-env.test.mjs | 7 + 5 files changed, 291 insertions(+) create mode 100644 scripts/client-xlsx-address-probe.py create mode 100644 scripts/client-xlsx-address-probe.test.py create mode 100644 scripts/client-xlsx-address-test-env.mjs create mode 100644 scripts/client-xlsx-address-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index d3d41c8af..342cfb664 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,8 @@ `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 +`client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-/report.json` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表 SQL 零漂移或浏览器通过。对应 Node/Python 测试检查零网络计划、精准拒绝及模板其他部分不变。 + 模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 `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 桌面操作另验。 diff --git a/scripts/client-xlsx-address-probe.py b/scripts/client-xlsx-address-probe.py new file mode 100644 index 000000000..078e75237 --- /dev/null +++ b/scripts/client-xlsx-address-probe.py @@ -0,0 +1,195 @@ +"""Two bounded negative imports; preserve evidence and never retry unknown writes.""" +import hashlib +import io +import json +import re +import sys +import urllib.error +import urllib.request +import uuid +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from xml.sax.saxutils import escape +from openpyxl import load_workbook + + +def require(ok, message): + if not ok: + raise AssertionError(message) + + +def options(content, title): + book = load_workbook(io.BytesIO(content)) + try: + sheet = book['订单导入'] + headers = {str(c.value).removesuffix(' *'): c.column for c in sheet[2] if c.value} + require(len(headers) == 31 and title in headers, 'Unexpected template headers') + 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 validations: + return [] + require(len(validations) == 1, 'Ambiguous option range') + defined = book.defined_names[validations[0].formula1] + return [c.value for name, span in defined.destinations for row in book[name][span] for c in row] + finally: + book.close() + + +def workbook(content, row): + require(len(row) == 31, 'Expected 31 cells') + output = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(content)) as source, zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as target: + for item in source.infolist(): + data = source.read(item.filename) + if item.filename == 'xl/worksheets/sheet1.xml': + xml = data.decode() + headers = re.findall(r']*\br="[12]"[^>]*>[\s\S]*?', xml) + require(len(headers) == 2, 'Both original header rows required') + cells = [] + for index, value in enumerate(row): + col = chr(65 + index) if index < 26 else 'A' + chr(65 + index - 26) + cells.append(f'{escape(str(value))}') + xml, count = re.subn(r']*>[\s\S]*?', + '' + ''.join(headers) + '' + ''.join(cells) + '', xml) + require(count == 1, 'One data sheet required') + data = xml.encode() + target.writestr(item, data) + return output.getvalue() + + +def assert_rejection(body, title): + require(str(body.get('code')) == '200', 'Import failed before row-level validation') + data = body.get('data') or {} + result = data.get('result') or {} + require(data.get('mode') == 'PARTIAL' and data.get('sourceFileId') and data.get('errorReportFileId'), 'Missing import file evidence') + require(result.get('total') == 1 and result.get('succeeded') == 0 and result.get('failed') == 1, 'Unexpected creation result') + items = result.get('items') or [] + require(len(items) == 1, 'Expected exactly one error row') + row = items[0] + require(row.get('success') is False and row.get('clientRowId') == 'excel-row-3' + and all(row.get(k) is None for k in ['orderId', 'orderNo', 'inboundCode']) + and row.get('errorCode') == 'A0422' + and row.get('errorMessage') == title + '不存在、已停用或不属于当前客户', 'Not the precise customer-address rejection') + + +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-xlsx-address-' + config['runId']) + directory.mkdir() + report = {'startedAt': datetime.now(timezone.utc).isoformat(), 'success': False, 'requests': [], 'checks': [], 'sessionsClosed': []} + sessions = {} + + def save(): + (directory / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + opener = urllib.request.build_opener(NoRedirect) + + def request(name, method, path, body=None, binary=False, key=None): + require(method == 'GET' or path in ['/auth/login', '/auth/logout', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL'], 'Unexpected write') + entry = {'session': name, 'method': method, 'path': path, 'outcome': 'PENDING'} + headers = {} + if sessions.get(name, {}).get('accessToken'): + headers['Authorization'] = 'Bearer ' + sessions[name]['accessToken'] + if isinstance(body, bytes): + boundary = 'probe-' + uuid.uuid4().hex + entry['workbookSha256'] = hashlib.sha256(body).hexdigest() + body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="address-probe.xlsx"\r\nContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n'.encode() + + body + f'\r\n--{boundary}--\r\n'.encode()) + headers['Content-Type'] = 'multipart/form-data; boundary=' + boundary + headers['Idempotency-Key'] = key + entry['key'] = key + elif body is not None: + body = json.dumps(body).encode() + headers['Content-Type'] = 'application/json' + report['requests'].append(entry) + save() + try: + response = opener.open(urllib.request.Request(config['base'] + path, body, headers, method=method), timeout=30) + with response: + data = response.read() + entry.update(outcome='RECEIVED', http=response.status, requestId=response.headers.get('x-request-id')) + if binary: + require('spreadsheetml' in response.headers.get('content-type', ''), 'Expected workbook content type') + save() + return data + result = json.loads(data) + entry['code'] = result.get('code') + if key: + imported = result.get('data') or {} + rows = (imported.get('result') or {}).get('items') or [] + entry.update(batchNo=imported.get('batchNo'), sourceFileId=imported.get('sourceFileId'), + errorReportFileId=imported.get('errorReportFileId'), + returnedOrderIds=[r['orderId'] for r in rows if r.get('orderId') is not None], + rowErrorCodes=[r.get('errorCode') for r in rows]) + save() + return result + except Exception: + entry['outcome'] = 'UNKNOWN_STOP' if entry['outcome'] == 'PENDING' else 'RECEIVED_INVALID' + save() + raise + + def success(name, method, path, body=None): + result = request(name, method, path, body) + require(str(result.get('code')) == '200', 'Setup/cleanup failed') + return result['data'] + + try: + identities, templates = [], {} + for name in ['source', 'control']: + sessions[name] = success(name, 'POST', '/auth/login', config[name]) + require(sessions[name].get('accessToken') and sessions[name].get('refreshToken'), 'Incomplete session') + profile = success(name, 'GET', '/merchant/v1/iam/tenant/profile') + require(profile.get('id') is not None, 'Missing customer identity') + identities.append(str(profile['id'])) + templates[name] = request(name, 'GET', '/merchant/v1/orders/first-leg/batch-imports/template', binary=True) + require(identities[0] != identities[1], 'Distinct customers required') + report['customerIdentitySha256'] = [hashlib.sha256(x.encode()).hexdigest() for x in identities] + source, target = templates['source'], templates['control'] + common = {} + for title in ['国内收货仓', '运输渠道', 'FBA目的仓']: + shared = [x for x in options(source, title) if x in options(target, title)] + require(shared, 'No shared valid master-data option') + common[title] = shared[0] + for title in ['取货地址', '私人目的仓地址']: + foreign = [x for x in options(source, title) if x not in options(target, title)] + require(foreign, 'No distinct foreign address option') + row = ['海运', '', '自行快递', common['国内收货仓'], common['运输渠道'], '亚马逊FBA', common['FBA目的仓'], '', '', '', + '地址归属测试', '纸箱', '1', '1', '0.001', '塑料', '否', '否', 'ADDRESS-PROBE', '', '', '', + '买单报关', '包税', '散货拼箱', '否', '', '', '普通头程', '', config['runId']] + if title == '取货地址': + row[2], row[9], row[29] = '平台上门提货', '2026-10-10 10:00:00', foreign[0] + else: + row[5], row[6], row[8] = '私人地址', '', foreign[0] + # Use the actual source template, including its foreign hidden option directory. + payload = workbook(source, row) + result = request('control', 'POST', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL', payload, + key='XADDR-' + config['runId'] + '-' + str(len(report['checks']))) + assert_rejection(result, title) + report['checks'].append({'field': title, 'passed': True, 'foreignOptionSha256': hashlib.sha256(foreign[0].encode()).hexdigest()}) + save() + report['success'] = True + report['limitation'] = 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim' + except Exception as error: + report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry' + finally: + for name, session in sessions.items(): + if session.get('accessToken'): + try: + success(name, 'POST', '/auth/logout', {'refreshToken': session['refreshToken']}) + report['sessionsClosed'].append(name) + except Exception: + report['success'] = False + report['success'] = report['success'] and len(report['sessionsClosed']) == 2 + report['completedAt'] = datetime.now(timezone.utc).isoformat() + save() + print(json.dumps({'success': report['success'], 'checks': len(report['checks'])})) + return report['success'] + + +if __name__ == '__main__': + sys.exit(0 if main(json.load(sys.stdin)) else 1) diff --git a/scripts/client-xlsx-address-probe.test.py b/scripts/client-xlsx-address-probe.test.py new file mode 100644 index 000000000..cfd5149e9 --- /dev/null +++ b/scripts/client-xlsx-address-probe.test.py @@ -0,0 +1,57 @@ +import copy +import importlib.util +import io +from pathlib import Path +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('client-xlsx-address-probe.py')) +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) + + +class AddressProbeTests(unittest.TestCase): + def response(self): + return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e', + 'result': {'total': 1, 'succeeded': 0, 'failed': 1, 'items': [ + {'success': False, 'clientRowId': 'excel-row-3', 'errorCode': 'A0422', + 'errorMessage': '取货地址不存在、已停用或不属于当前客户'}]}}} + + def test_precise_address_rejection(self): + probe.assert_rejection(self.response(), '取货地址') + for change in [{'success': True}, {'orderId': 'created'}, {'errorCode': 'A0404'}, + {'clientRowId': 'excel-row-4'}, {'errorMessage': '国内收货仓不能为空'}]: + changed = copy.deepcopy(self.response()) + changed['data']['result']['items'][0].update(change) + with self.assertRaises(AssertionError): + probe.assert_rejection(changed, '取货地址') + + def test_not_setup_or_partial_success(self): + for part, change in [('root', {'code': 'A0422'}), ('data', {'errorReportFileId': None}), + ('result', {'succeeded': 1}), ('result', {'total': 2})]: + changed = self.response() + value = changed if part == 'root' else changed['data'] if part == 'data' else changed['data']['result'] + value.update(change) + with self.assertRaises(AssertionError): + probe.assert_rejection(changed, '取货地址') + + def test_workbook_preserves_hidden_parts_and_replaces_only_data(self): + raw = io.BytesIO() + sheet = '' + with zipfile.ZipFile(raw, 'w') as z: + z.writestr('xl/worksheets/sheet1.xml', sheet) + z.writestr('xl/worksheets/sheet2.xml', 'source-options') + z.writestr('xl/workbook.xml', 'source-names') + output = probe.workbook(raw.getvalue(), ['<&'] + [''] * 29 + ['marker']) + with zipfile.ZipFile(io.BytesIO(output)) as z: + self.assertEqual(z.read('xl/worksheets/sheet2.xml'), b'source-options') + self.assertEqual(z.read('xl/workbook.xml'), b'source-names') + xml = z.read('xl/worksheets/sheet1.xml').decode() + self.assertIn('r="AE3"', xml) + self.assertIn('<&', xml) + self.assertEqual(xml.count(''), 1) + self.assertIn('', xml) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-xlsx-address-test-env.mjs b/scripts/client-xlsx-address-test-env.mjs new file mode 100644 index 000000000..f7e1012dd --- /dev/null +++ b/scripts/client-xlsx-address-test-env.mjs @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { parseEnv } from 'node:util' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { executionOptions } from './client-workbench-test-env.mjs' +import { customerProfilePath } from './client-import-template-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, + writes: 'Two single-row XLSX PARTIAL imports must reject foreign customer addresses; source/error files may be retained; no retry or deletion' } + if (args.length !== 5 || !args[4].startsWith('--python=')) throw Error('Explicit TEST, customer and bundled Python required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const credentials = parseEnv(await readFile(customerProfilePath(args[3], execArgs[0].slice(11)), 'utf8')) + if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw Error('Missing control credentials') + const python = resolve(args[4].slice(9)) + if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw Error('Use bundled Python') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const config = { environment: 'TEST', base: options.base, runId: options.runId, + source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }, + control: { loginId: credentials.MERCHANT_TEST_USERNAME, password: credentials.MERCHANT_TEST_PASSWORD } } + const child = spawn(python, ['-B', resolve(root, 'scripts/client-xlsx-address-probe.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-xlsx-address-${options.runId}`, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) + execute().then(r => { console.log(JSON.stringify(r)); if (r.success === false) process.exitCode = 1 }) + .catch(() => { console.error('XLSX probe did not start; check explicit TEST inputs and unused run ID.'); process.exitCode = 1 }) diff --git a/scripts/client-xlsx-address-test-env.test.mjs b/scripts/client-xlsx-address-test-env.test.mjs new file mode 100644 index 000000000..e99e33d2e --- /dev/null +++ b/scripts/client-xlsx-address-test-env.test.mjs @@ -0,0 +1,7 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execute } from './client-xlsx-address-test-env.mjs' +test('XLSX rejection plan performs no network or credential reads', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) + await assert.rejects(execute(['--execute'], [], {})) +})