test(order): verify rejected XLSX imports preserve scoped database facts
This commit is contained in:
+1
-1
@@ -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` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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': [
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user