test(order): verify foreign customer addresses rejected in XLSX imports
This commit is contained in:
@@ -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 桌面操作另验。
|
||||
|
||||
@@ -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)
|
||||
@@ -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('<&', xml)
|
||||
self.assertEqual(xml.count('<row r="3">'), 1)
|
||||
self.assertIn('<tail/>', xml)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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'], [], {}))
|
||||
})
|
||||
Reference in New Issue
Block a user