import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { access, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; const repoRoot = new URL('../../', import.meta.url); const processNames = ['api', 'scheduler', 'worker', 'web']; const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/; const execFileAsync = promisify(execFile); 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'), 'http://127.0.0.1:3000'); 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('本地 Compose 仅启动 Redis 并提供健康检查', async () => { const compose = await readRepoFile('deploy/compose/development.yml'); assert.match(compose, /services:\s*\n\s+redis:/); assert.match(compose, /healthcheck:/); assert.match(compose, /127\.0\.0\.1:/); assert.doesNotMatch(compose, /^\s{2}(mysql|rocketmq|api|worker|scheduler|web):/m); }); 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/docker-compose-production.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('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async () => { const local = await readRepoFile('docs/deployment/local-development.md'); const compose = await readRepoFile('docs/deployment/docker-compose-production.md'); const source = await readRepoFile('docs/deployment/ubuntu-source-production.md'); assert.match(local, /Redis/); assert.match(compose, /Docker Compose/); assert.match(source, /Ubuntu 22\.04 LTS/); assert.match(source, /Ubuntu 24\.04 LTS/); assert.match(source, /Node\.js 22 LTS/); assert.match(source, /pnpm 10/); assert.match(source, /不负责安装/); }); test('生产 Compose 只接受显式镜像并解析为真实健康检查', async () => { const composePath = new URL('deploy/compose/production.yml.example', repoRoot).pathname; const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'a-share-compose-')); const envFiles = {}; for (const processName of processNames) { const envPath = path.join(temporaryDirectory, `${processName}.env`); await writeFile(envPath, 'APP_ENV=production\n'); envFiles[processName] = envPath; } await assert.rejects( execFileAsync('docker', ['compose', '-f', composePath, 'config'], { env: { ...process.env }, }), /必须提供不可变 (?:Web|Server) 镜像引用/, ); const imageDigest = `sha256:${'a'.repeat(64)}`; const { stdout } = await execFileAsync( 'docker', ['compose', '-f', composePath, 'config', '--format', 'json'], { env: { ...process.env, A_SHARE_WEB_IMAGE: `registry.invalid/web@${imageDigest}`, A_SHARE_SERVER_IMAGE: `registry.invalid/server@${imageDigest}`, A_SHARE_WEB_ENV_FILE: envFiles.web, A_SHARE_API_ENV_FILE: envFiles.api, A_SHARE_SCHEDULER_ENV_FILE: envFiles.scheduler, A_SHARE_WORKER_ENV_FILE: envFiles.worker, }, }, ); const config = JSON.parse(stdout); assert.equal(config.services.web.image, `registry.invalid/web@${imageDigest}`); assert.equal(config.services.api.image, `registry.invalid/server@${imageDigest}`); assert.deepEqual(config.services.api.command, ['node', 'dist/apps/api/main.js']); assert.ok(config.services.web.healthcheck?.test); assert.ok(config.services.api.healthcheck?.test); }); test('容器构建文件存在且 systemd 启动入口对应真实构建产物', async () => { await access(new URL('web/Dockerfile', repoRoot)); await access(new URL('web/nginx.conf', repoRoot)); await access(new URL('server/Dockerfile', repoRoot)); await execFileAsync('pnpm', ['--filter', '@a-share-analysis/server', 'build'], { cwd: repoRoot.pathname, }); 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('Docker 构建上下文排除本地配置与派生产物', async () => { const dockerIgnore = await readRepoFile('.dockerignore'); for (const requiredPattern of [ 'deploy/env/**/*.local', '.git', '.gitnexus', 'node_modules', 'web/dist', 'server/dist', ]) { assert.ok( dockerIgnore.split(/\r?\n/).includes(requiredPattern), `.dockerignore 缺少 ${requiredPattern}`, ); } });