250 lines
9.7 KiB
JavaScript
250 lines
9.7 KiB
JavaScript
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'), '');
|
|
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, /不负责安装/);
|
|
for (const document of [local, compose, source]) {
|
|
assert.match(document, /MySQL 5\.7\.42/);
|
|
}
|
|
});
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
});
|