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-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` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。
+71 -1
View File
@@ -2,7 +2,9 @@
import hashlib
import io
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
@@ -74,6 +76,63 @@ def assert_rejection(body, title):
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):
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'])
@@ -155,6 +214,9 @@ def main(config):
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]
if config.get('database'):
report['databaseBefore'] = database_snapshot(config['database'], identities)
save()
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')
@@ -170,10 +232,18 @@ def main(config):
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)
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()})
save()
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:
report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry'
finally:
+28
View File
@@ -11,6 +11,34 @@ spec.loader.exec_module(probe)
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):
return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e',
'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) {
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')
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 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')
@@ -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,
source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_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')],
{ 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') }
}
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)
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 })
@@ -1,7 +1,13 @@
import test from 'node:test'
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 () => {
assert.equal((await execute(['--plan'], [], {})).plan, true)
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))
})