From 78a4d4a0893a898a9c9543cfe64681103f4b0ea9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 17:12:49 +0800 Subject: [PATCH] test(order): verify rejected XLSX imports preserve scoped database facts --- scripts/README.md | 2 +- scripts/client-xlsx-address-probe.py | 72 ++++++++++++++++++- scripts/client-xlsx-address-probe.test.py | 28 ++++++++ scripts/client-xlsx-address-test-env.mjs | 16 ++++- scripts/client-xlsx-address-test-env.test.mjs | 8 ++- 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 342cfb664..a8859e8dd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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-/`,不记录凭据、地址明文或账号。该项不等于 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 测试检查零网络计划、精准拒绝及模板其他部分不变。 +`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` 只保存摘要、响应码和返回资源标识,会话结束退出;不保存原工作簿/地址明文,不代表浏览器通过。可追加 `--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` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。 diff --git a/scripts/client-xlsx-address-probe.py b/scripts/client-xlsx-address-probe.py index 078e75237..4a5622508 100644 --- a/scripts/client-xlsx-address-probe.py +++ b/scripts/client-xlsx-address-probe.py @@ -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: diff --git a/scripts/client-xlsx-address-probe.test.py b/scripts/client-xlsx-address-probe.test.py index cfd5149e9..58009517a 100644 --- a/scripts/client-xlsx-address-probe.test.py +++ b/scripts/client-xlsx-address-probe.test.py @@ -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': [ diff --git a/scripts/client-xlsx-address-test-env.mjs b/scripts/client-xlsx-address-test-env.mjs index f7e1012dd..b218d1fbe 100644 --- a/scripts/client-xlsx-address-test-env.mjs +++ b/scripts/client-xlsx-address-test-env.mjs @@ -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 }) diff --git a/scripts/client-xlsx-address-test-env.test.mjs b/scripts/client-xlsx-address-test-env.test.mjs index e99e33d2e..6daa22d9e 100644 --- a/scripts/client-xlsx-address-test-env.test.mjs +++ b/scripts/client-xlsx-address-test-env.test.mjs @@ -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)) +})