test(order): verify foreign customer addresses rejected in XLSX imports

This commit is contained in:
2026-10-03 17:08:15 +08:00
parent e49ad96cc4
commit 14696fb1a1
5 changed files with 291 additions and 0 deletions
+2
View File
@@ -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-<run-id>/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。
`client-xlsx-address-test-env.mjs` 补充真实 XLSX 地址隔离负向导入,默认计划零网络。执行用显式 TEST 配置,参数为 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env> --python=<bundled Python>`;内部 `client-xlsx-address-probe.py` 只在内存读取两个客户的真实模板,保留来源模板的隐藏选项/关系,仅填一行取货或私人地址后以对照客户上传。两次 PARTIAL 导入必须精准拒绝外客户地址,普通格式/必填错误不通过;导入源文件及错误报告会保留,意外成功/未知结果立即停止,不自动重试、删除或推进订单。凭据只经 stdin,报告 `reports/client-xlsx-address-<run-id>/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=<load_workspace_dependencies返回的Python路径>`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-<run-id>/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。
+195
View File
@@ -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'<row\b[^>]*\br="[12]"[^>]*>[\s\S]*?</row>', 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'<c r="{col}3" t="inlineStr"><is><t xml:space="preserve">{escape(str(value))}</t></is></c>')
xml, count = re.subn(r'<sheetData\b[^>]*>[\s\S]*?</sheetData>',
'<sheetData>' + ''.join(headers) + '<row r="3">' + ''.join(cells) + '</row></sheetData>', 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)
+57
View File
@@ -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 = '<worksheet><sheetData><row r="1"><c r="A1"/></row><row r="2"><c r="A2"/></row><row r="3"><c r="A3"/></row></sheetData><tail/></worksheet>'
with zipfile.ZipFile(raw, 'w') as z:
z.writestr('xl/worksheets/sheet1.xml', sheet)
z.writestr('xl/worksheets/sheet2.xml', '<foreign>source-options</foreign>')
z.writestr('xl/workbook.xml', '<names>source-names</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'<foreign>source-options</foreign>')
self.assertEqual(z.read('xl/workbook.xml'), b'<names>source-names</names>')
xml = z.read('xl/worksheets/sheet1.xml').decode()
self.assertIn('r="AE3"', xml)
self.assertIn('&lt;&amp;', xml)
self.assertEqual(xml.count('<row r="3">'), 1)
self.assertIn('<tail/>', xml)
if __name__ == '__main__':
unittest.main()
+30
View File
@@ -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 })
@@ -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'], [], {}))
})