import assert from 'node:assert/strict'; import { access, readFile } from 'node:fs/promises'; import test from 'node:test'; const repoRoot = new URL('../../', import.meta.url); const processNames = ['api', 'scheduler', 'worker', 'web']; const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/; async function readRepoFile(relativePath) { return readFile(new URL(relativePath, repoRoot), 'utf8'); } function parseEnvTemplate(content) { const entries = new Map(); for (const line of content.split(/\r?\n/)) { const normalizedLine = line.trim(); if (!normalizedLine || normalizedLine.startsWith('#')) continue; const separator = normalizedLine.indexOf('='); assert.ok(separator > 0, `无效环境变量行:${line}`); entries.set(normalizedLine.slice(0, separator), normalizedLine.slice(separator + 1)); } return entries; } test('开发和生产环境都提供四类进程模板且键集合一致', async () => { for (const processName of processNames) { const development = parseEnvTemplate( await readRepoFile(`deploy/env/development/${processName}.env.example`), ); const production = parseEnvTemplate( await readRepoFile(`deploy/env/production/${processName}.env.example`), ); assert.deepEqual([...development.keys()].sort(), [...production.keys()].sort()); } }); test('模板不包含敏感值且 Web 只暴露 VITE 变量', async () => { for (const environment of ['development', 'production']) { for (const processName of processNames) { const entries = parseEnvTemplate( await readRepoFile(`deploy/env/${environment}/${processName}.env.example`), ); for (const [key, value] of entries) { if (sensitiveKeyPattern.test(key)) assert.equal(value, '', `${key} 必须保持空值`); if (processName === 'web') assert.ok(key.startsWith('VITE_'), `Web 禁止暴露 ${key}`); } } } }); test('Web API 根地址不重复 OpenAPI 自带的 api 路径', async () => { const development = parseEnvTemplate( await readRepoFile('deploy/env/development/web.env.example'), ); const production = parseEnvTemplate( await readRepoFile('deploy/env/production/web.env.example'), ); assert.equal(development.get('VITE_API_BASE_URL'), ''); assert.equal(production.get('VITE_API_BASE_URL'), ''); }); test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契约', async () => { const rocketMqTemplates = new Map(); for (const environment of ['development', 'production']) { for (const processName of ['scheduler', 'worker']) { rocketMqTemplates.set( `${environment}/${processName}`, parseEnvTemplate( await readRepoFile(`deploy/env/${environment}/${processName}.env.example`), ), ); } } const developmentScheduler = rocketMqTemplates.get('development/scheduler'); const developmentWorker = rocketMqTemplates.get('development/worker'); const productionScheduler = rocketMqTemplates.get('production/scheduler'); const worker = parseEnvTemplate(await readRepoFile('deploy/env/production/worker.env.example')); assert.equal(worker.get('APP_ENV'), 'production'); assert.equal(worker.get('MARKET_DATA_MODE'), 'live'); assert.equal(worker.get('MYSQL_ENABLED'), 'false'); assert.equal(worker.get('MYSQL_CHARSET'), 'utf8mb4'); assert.equal(worker.get('MYSQL_COLLATION'), 'utf8mb4_general_ci'); assert.equal(worker.get('ROCKETMQ_ENABLED'), 'false'); assert.equal(worker.get('ROCKETMQ_ENDPOINT'), ''); assert.equal(worker.get('ROCKETMQ_TOPIC_PREFIX'), 'a-share-analysis'); assert.equal(worker.get('ROCKETMQ_CONSUMER_GROUP'), 'a-share-analysis-worker'); for (const [templateName, entries] of rocketMqTemplates) { assert.equal( [...entries.keys()].some((key) => key.startsWith('RABBITMQ_')), false, `${templateName} 禁止保留 RabbitMQ 变量`, ); assert.equal(entries.has('ROCKETMQ_PRODUCER_GROUP'), false); assert.equal(entries.has('ROCKETMQ_MAX_RECONSUME_TIMES'), false); } for (const entries of [developmentScheduler, developmentWorker]) { assert.equal(entries.get('ROCKETMQ_NAMESRV_ADDR'), '127.0.0.1:9876'); assert.equal(entries.get('ROCKETMQ_ENDPOINT'), '127.0.0.1:8081'); } for (const entries of [productionScheduler, worker]) { assert.equal(entries.get('ROCKETMQ_NAMESRV_ADDR'), ''); assert.equal(entries.get('ROCKETMQ_ENDPOINT'), ''); } }); test('活动仓库不再包含 Docker 运行构建或部署入口', async () => { for (const relativePath of [ '.dockerignore', 'web/Dockerfile', 'web/nginx.conf', 'server/Dockerfile', 'deploy/compose/development.yml', 'deploy/compose/production.yml.example', 'docs/deployment/docker-compose-production.md', 'tools/deployment/verify-docker.mjs', 'tools/deployment/verify-image-references.mjs', 'tools/deployment/verify-image-references.test.mjs', ]) { await assert.rejects(access(new URL(relativePath, repoRoot)), { code: 'ENOENT' }); } }); test('活动架构与部署文档只采用 RocketMQ 5.x', async () => { for (const relativePath of [ 'README.md', 'docs/architecture/system-architecture.md', 'docs/governance/code-development-standards.md', 'docs/deployment/local-development.md', 'docs/deployment/ubuntu-source-production.md', ]) { const content = await readRepoFile(relativePath); assert.match(content, /RocketMQ 5\.x|RocketMQ/); assert.doesNotMatch(content, /RabbitMQ/); } }); test('生产源码部署使用独立 EnvironmentFile 并提供 Nginx 代理', async () => { for (const processName of ['api', 'scheduler', 'worker']) { const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`); assert.match(unit, new RegExp(`EnvironmentFile=/etc/a-share-analysis/${processName}\\.env`)); assert.match(unit, /User=a-share/); assert.match(unit, /WorkingDirectory=\/opt\/a-share-analysis\/current/); } const nginx = await readRepoFile('deploy/nginx/a-share-analysis.conf.example'); assert.match(nginx, /location \/api\//); assert.match(nginx, /proxy_pass http:\/\/127\.0\.0\.1:3000/); assert.match(nginx, /\/opt\/a-share-analysis\/current\/web\/dist/); }); test('部署文档要求本机外部 Redis 和唯一 Ubuntu 源码生产路径', async () => { const local = await readRepoFile('docs/deployment/local-development.md'); const source = await readRepoFile('docs/deployment/ubuntu-source-production.md'); assert.match(local, /redis-cli -h 127\.0\.0\.1 -p 6379 ping/); assert.match(local, /外部服务|开发机/); assert.doesNotMatch(local, /docker compose/i); assert.match(source, /Ubuntu 22\.04 LTS/); assert.match(source, /Ubuntu 24\.04 LTS/); assert.match(source, /Node\.js 22\.x/); assert.match(source, /pnpm 10\.25\.0/); assert.match(source, /pnpm verify:release/); assert.match(source, /\/opt\/a-share-analysis\/releases\//); assert.match(source, /\/opt\/a-share-analysis\/current/); for (const processName of ['api', 'scheduler', 'worker']) { assert.match( source, new RegExp( `install -m 0644 deploy/systemd/a-share-${processName}\\.service\\.example /etc/systemd/system/a-share-${processName}\\.service`, ), ); assert.match(source, new RegExp(`systemctl restart a-share-${processName}\\.service`)); } assert.doesNotMatch(source, /systemctl (?:restart|status) a-share-\w+\.service\.example/); assert.match(source, /不负责安装/); for (const document of [local, source]) { assert.match(document, /MySQL 5\.7\.42/); } }); test('systemd 启动入口对应源码构建产物', async () => { for (const processName of ['api', 'scheduler', 'worker']) { const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`); const execStart = unit.match( /^ExecStart=\/usr\/bin\/node \/opt\/a-share-analysis\/current\/(.+)$/m, )?.[1]; assert.ok(execStart, `${processName} 缺少 Node 构建入口`); await access(new URL(execStart, repoRoot)); } }); test('根级发布命令只保留源码发布门禁', async () => { const rootPackage = JSON.parse(await readRepoFile('package.json')); assert.equal(rootPackage.scripts['verify:source'], 'node tools/deployment/verify-source-deployment.mjs'); assert.equal(rootPackage.scripts['verify:release'], 'pnpm verify && pnpm verify:source'); assert.equal(rootPackage.scripts['verify:docker'], undefined); assert.equal(rootPackage.scripts['check:images'], undefined); });