test(order): verify rejected XLSX imports preserve scoped database facts

This commit is contained in:
2026-10-03 17:12:49 +08:00
parent 14696fb1a1
commit 78a4d4a089
5 changed files with 122 additions and 4 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
`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-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 测试检查零网络计划、精准拒绝及模板其他部分不变。 `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` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表浏览器通过。可追加 `--database-profile=<同目录test-database.env>`:用现有 mysql CLI 的只读一致性事务,在导入前及每次拒绝后读取两客户 Order 的订单、货物、参与方、内部备注、状态历史、幂等和领域 Outbox 七表完整记录摘要(含逻辑删除),严格比较数量与完整摘要;不同、缺表、截断或查询失败即停止。DB 凭据只进入子进程环境,报告不含原始行;文件、审计及认证正常留存不属于这七表不变断言,不声称全环境零写入。未传此参数只验 HTTP。对应 Node/Python 测试检查零网络计划、环境边界、精准拒绝、模板其他部分不变及数据库快照完整性。
模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。
+71 -1
View File
@@ -2,7 +2,9 @@
import hashlib import hashlib
import io import io
import json import json
import os
import re import re
import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -74,6 +76,63 @@ def assert_rejection(body, title):
and row.get('errorMessage') == title + '不存在、已停用或不属于当前客户', 'Not the precise customer-address rejection') and row.get('errorMessage') == title + '不存在、已停用或不属于当前客户', 'Not the precise customer-address rejection')
SNAPSHOT_TABLES = ['ord_order', 'ord_order_cargo', 'ord_order_party', 'ord_order_internal_note',
'ord_order_status_history', 'ord_idempotency_record', 'ord_outbox_event']
def snapshot_sql(columns, identities):
require(len(identities) == 2 and len(set(identities)) == 2
and all(re.fullmatch(r'[1-9][0-9]{0,19}', x) for x in identities), 'Invalid customer scope')
queries = ['SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;',
'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;']
for table in SNAPSHOT_TABLES:
names = columns.get(table, [])
require(names and len(names) == len(set(names)) and 'id' in names and 'tenant_id' in names
and all(re.fullmatch(r'[a-z][a-z0-9_]*', n) for n in names), 'Incomplete snapshot schema')
fields = ','.join('CAST(`' + n + '` AS CHAR)' for n in names)
where = 'tenant_id IN (' + ','.join(identities) + ')'
queries.append(f"SELECT JSON_OBJECT('table','{table}','hash',SHA2(CAST(JSON_ARRAY({fields}) AS CHAR),256)) FROM `{table}` WHERE {where} ORDER BY id;")
queries.append(f"SELECT JSON_OBJECT('table','{table}','count',COUNT(*)) FROM `{table}` WHERE {where};")
return '\n'.join(queries + ['COMMIT;'])
def snapshot_summary(rows):
hashes = {t: [] for t in SNAPSHOT_TABLES}
counts = {}
for row in rows:
table = row.get('table')
require(table in hashes, 'Unexpected snapshot table')
if 'count' in row:
require(table not in counts and isinstance(row['count'], int) and row['count'] >= 0, 'Invalid snapshot count')
counts[table] = row['count']
else:
require(re.fullmatch(r'[0-9a-f]{64}', row.get('hash', '')), 'Invalid row digest')
hashes[table].append(row['hash'])
require(len(counts) == len(hashes) and all(len(hashes[t]) == counts[t] for t in hashes), 'Truncated snapshot')
return {t: {'count': counts[t], 'sha256': hashlib.sha256('\n'.join(hashes[t]).encode()).hexdigest()}
for t in SNAPSHOT_TABLES}
def database_snapshot(config, identities):
def query(sql):
# SQL is sent via stdin; credentials are inherited only by this child process.
result = subprocess.run(['mysql', '--protocol=TCP', '-h', config['host'], '-P', config['port'],
'-u', config['username'], '--batch', '--raw', '--skip-column-names',
'--default-character-set=utf8mb4', '--database=baiyue_order'],
input=sql, text=True, encoding='utf-8', capture_output=True, timeout=30,
env={**os.environ, 'MYSQL_PWD': config['password']},
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
require(result.returncode == 0, 'Read-only TEST database query failed')
return [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
selected = ','.join("'" + t + "'" for t in SNAPSHOT_TABLES)
metadata = query("SELECT JSON_OBJECT('table',TABLE_NAME,'column',COLUMN_NAME) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA='baiyue_order' AND TABLE_NAME IN (" + selected + ") ORDER BY TABLE_NAME,ORDINAL_POSITION;")
columns = {}
for row in metadata:
columns.setdefault(row['table'], []).append(row['column'])
return snapshot_summary(query(snapshot_sql(columns, identities)))
def main(config): 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') 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 = Path(__file__).resolve().parent.parent / 'reports' / ('client-xlsx-address-' + config['runId'])
@@ -155,6 +214,9 @@ def main(config):
shared = [x for x in options(source, title) if x in options(target, title)] shared = [x for x in options(source, title) if x in options(target, title)]
require(shared, 'No shared valid master-data option') require(shared, 'No shared valid master-data option')
common[title] = shared[0] common[title] = shared[0]
if config.get('database'):
report['databaseBefore'] = database_snapshot(config['database'], identities)
save()
for title in ['取货地址', '私人目的仓地址']: for title in ['取货地址', '私人目的仓地址']:
foreign = [x for x in options(source, title) if x not in options(target, title)] foreign = [x for x in options(source, title) if x not in options(target, title)]
require(foreign, 'No distinct foreign address option') require(foreign, 'No distinct foreign address option')
@@ -170,10 +232,18 @@ def main(config):
result = request('control', 'POST', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL', payload, result = request('control', 'POST', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL', payload,
key='XADDR-' + config['runId'] + '-' + str(len(report['checks']))) key='XADDR-' + config['runId'] + '-' + str(len(report['checks'])))
assert_rejection(result, title) assert_rejection(result, title)
if config.get('database'):
after = database_snapshot(config['database'], identities)
report.setdefault('databaseAfterEachImport', []).append({'field': title, 'tables': after})
save()
require(after == report['databaseBefore'], 'Order-domain database changed after rejected XLSX import')
report['checks'].append({'field': title, 'passed': True, 'foreignOptionSha256': hashlib.sha256(foreign[0].encode()).hexdigest()}) report['checks'].append({'field': title, 'passed': True, 'foreignOptionSha256': hashlib.sha256(foreign[0].encode()).hexdigest()})
save() save()
report['success'] = True report['success'] = True
report['limitation'] = 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim' report['databaseVerified'] = bool(config.get('database'))
report['limitation'] = ('Complete rows in seven Order tables for both customers, including logically deleted rows; '
'file/audit/auth writes are outside this unchanged-order-domain assertion; no browser claim'
if config.get('database') else 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim')
except Exception as error: except Exception as error:
report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry' report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry'
finally: finally:
+28
View File
@@ -11,6 +11,34 @@ spec.loader.exec_module(probe)
class AddressProbeTests(unittest.TestCase): class AddressProbeTests(unittest.TestCase):
def test_snapshot_requires_complete_tables_and_row_counts(self):
rows = [{'table': t, 'count': 0} for t in probe.SNAPSHOT_TABLES]
before = probe.snapshot_summary(rows)
for bad in [rows[:-1], rows + [rows[0]], rows + [{'table': probe.SNAPSHOT_TABLES[0], 'hash': 'a' * 64}]]:
with self.assertRaises(AssertionError):
probe.snapshot_summary(bad)
changed = copy.deepcopy(rows)
changed[0]['count'] = 1
changed.append({'table': probe.SNAPSHOT_TABLES[0], 'hash': 'a' * 64})
after = probe.snapshot_summary(changed)
self.assertNotEqual(before, after)
changed[-1]['hash'] = 'b' * 64
self.assertNotEqual(after, probe.snapshot_summary(changed))
def test_snapshot_sql_is_read_only_complete_and_customer_scoped(self):
columns = {t: ['id', 'tenant_id', 'deleted', 'version'] for t in probe.SNAPSHOT_TABLES}
sql = probe.snapshot_sql(columns, ['123', '456'])
self.assertIn('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY', sql)
self.assertEqual(sql.count('tenant_id IN (123,456)'), 14)
self.assertNotIn('deleted=0', sql)
self.assertIn('CAST(`version` AS CHAR)', sql)
for ids in [['123', '123'], ['123 OR 1=1', '456'], ['123']]:
with self.assertRaises(AssertionError):
probe.snapshot_sql(columns, ids)
columns['ord_order'] = ['id', 'unsafe`field']
with self.assertRaises(AssertionError):
probe.snapshot_sql(columns, ['123', '456'])
def response(self): def response(self):
return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e', return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e',
'result': {'total': 1, 'succeeded': 0, 'failed': 1, 'items': [ 'result': {'total': 1, 'succeeded': 0, 'failed': 1, 'items': [
+15 -1
View File
@@ -9,7 +9,7 @@ import { customerProfilePath } from './client-import-template-test-env.mjs'
export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { 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, 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' } 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') if (![5, 6].includes(args.length) || !args[4].startsWith('--python=')) throw Error('Explicit TEST, customer and bundled Python required')
const options = executionOptions(args.slice(0, 3), execArgs, env) const options = executionOptions(args.slice(0, 3), execArgs, env)
const credentials = parseEnv(await readFile(customerProfilePath(args[3], execArgs[0].slice(11)), 'utf8')) 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') if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw Error('Missing control credentials')
@@ -19,12 +19,26 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e
const config = { environment: 'TEST', base: options.base, runId: options.runId, const config = { environment: 'TEST', base: options.base, runId: options.runId,
source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }, source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD },
control: { loginId: credentials.MERCHANT_TEST_USERNAME, password: credentials.MERCHANT_TEST_PASSWORD } } control: { loginId: credentials.MERCHANT_TEST_USERNAME, password: credentials.MERCHANT_TEST_PASSWORD } }
if (args[5]) {
const profile = databaseProfilePath(args[5], execArgs[0].slice(11))
const database = parseEnv(await readFile(profile, 'utf8'))
if (database.CHONGCHONG_ENV?.toUpperCase() !== 'TEST') throw Error('TEST database required')
for (const key of ['HOST', 'PORT', 'USERNAME', 'PASSWORD'])
if (!database['CHONGCHONG_DB_' + key]) throw Error('Incomplete TEST database configuration')
config.database = Object.fromEntries(['HOST', 'PORT', 'USERNAME', 'PASSWORD'].map(key => [key.toLowerCase(), database['CHONGCHONG_DB_' + key]]))
}
const child = spawn(python, ['-B', resolve(root, 'scripts/client-xlsx-address-probe.py')], const child = spawn(python, ['-B', resolve(root, 'scripts/client-xlsx-address-probe.py')],
{ cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] })
child.stdin.end(JSON.stringify(config)) child.stdin.end(JSON.stringify(config))
const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) 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') } return { success: code === 0, report: resolve(root, 'reports', `client-xlsx-address-${options.runId}`, 'report.json') }
} }
export function databaseProfilePath(argument, environmentFile) {
if (!argument.startsWith('--database-profile=')) throw Error('Unknown argument')
const profile = resolve(argument.slice(19))
if (profile !== resolve(dirname(environmentFile), 'test-database.env')) throw Error('Use explicit test-database.env beside TEST acceptance profile')
return profile
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) 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 }) 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 }) .catch(() => { console.error('XLSX probe did not start; check explicit TEST inputs and unused run ID.'); process.exitCode = 1 })
@@ -1,7 +1,13 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { execute } from './client-xlsx-address-test-env.mjs' import { execute, databaseProfilePath } from './client-xlsx-address-test-env.mjs'
test('XLSX rejection plan performs no network or credential reads', async () => { test('XLSX rejection plan performs no network or credential reads', async () => {
assert.equal((await execute(['--plan'], [], {})).plan, true) assert.equal((await execute(['--plan'], [], {})).plan, true)
await assert.rejects(execute(['--execute'], [], {})) await assert.rejects(execute(['--execute'], [], {}))
}) })
test('database evidence accepts only the named TEST sibling profile', () => {
const base = 'C:/Users/test/.codex/env/chongchong-global/test-acceptance.env'
assert.match(databaseProfilePath('--database-profile=C:/Users/test/.codex/env/chongchong-global/test-database.env', base), /test-database.env$/)
for (const filename of ['prod-database.env', 'dev-database.env', '../other/test-database.env'])
assert.throws(() => databaseProfilePath('--database-profile=C:/Users/test/.codex/env/chongchong-global/' + filename, base))
})