Files
opendance-web/web-server/test/init-env.spec.ts
T
zijunchiang 9f08d316d0 docs: 清除文档中的真实部署信息与易混淆 Unicode 字符
需求:公开仓库的说明文档不应暴露开发/部署期信息,且 Gitea 会对 README.txt
报"含模棱两可的 Unicode 字符"。

敏感信息(全部替换为占位符或文档示例值):
- 真实数据库实例:DB_HOST=192.168.150.10 / DB_USER=dpendance / DB_NAME=dpendance
  → <数据库主机> / opendance_app / opendance(web-server/.env.example、
  deploy/.env.example、contracts/database.md ×3 库、project.yaml、README.txt
  的建库 SQL、tasks/001、tasks/002)
- 内网网段 192.168.150.0/24 / 192.168.150.20 / 192.168.1.10
  → RFC 5737 文档网段 192.0.2.0/24 等(含测试夹具,行为不变)
- 真实 Gitea 域名 → https://<Gitea 地址>/<owner>/<repo>(systemd 单元、
  Windows/Android README 的交叉链接)
- 测试夹具里的真实口令字面量 → 虚构口令(仍覆盖特殊字符场景)
- 保留 /opt/opendance、/var/lib/opendance 等产品默认安装路径(非敏感)

README.txt 易混淆字符:
- 全角拉丁字母(I/K 等)与全角 ASCII 标点((),:;?)→ 半角 + 中文排版空格
- 正文里的乱码字节样例(模å…)→ 改为文字描述
- × → x,★ → *
- 注意:代码/测试/契约里的全角「IK」是 MMD 真实骨骼名,**必须保留**,
  已回滚误改(mmd-skeleton.ts、mmd-vmd.spec.ts、inspect-vmd.mjs、各契约)

验证:168 项单测通过(含 VMD 31 项,确认全角骨骼名未被破坏)。
2026-09-15 21:25:25 +08:00

405 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
/**
* deploy/init-env.sh —— Web 端首次启动的 .env 引导流程。
*
* 这些用例全部在临时目录里驱动真实脚本:
* * 非交互场景必须“要么 0、要么非 0”,绝不允许挂住(spawnSync 带 timeout,
* 一旦超时 r.error 会被置为 ETIMEDOUT,用例据此断言)。
* * 不访问真实数据库:统一注入 OPENDANCE_SKIP_DB_CHECK=1。
*/
function findScript(): string {
const candidates = [
resolve(process.cwd(), '..', 'deploy', 'init-env.sh'), // 从 web-server/ 运行(vitest 默认)
resolve(process.cwd(), 'deploy', 'init-env.sh'), // 从仓库根运行
resolve(process.cwd(), '..', '..', 'deploy', 'init-env.sh'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(`找不到 deploy/init-env.sh,候选路径:\n${candidates.join('\n')}`);
}
const SCRIPT = findScript();
/** 测试模板:含注释与“其它字段”,用于验证写回时不丢注释。 */
const EXAMPLE = `# ============================================================
# 测试用模板:这些注释必须被保留
# ============================================================
NODE_ENV=production
PORT=3000
# 管理端端口注释
ADMIN_PORT=3001
HOST=0.0.0.0
# 数据库
DB_HOST=<existing-mysql-host>
DB_PORT=3306
DB_USER=opendance_app
DB_PASS=<数据库口令>
DB_NAME=opendance
DB_SYNCHRONIZE=false
UPLOAD_MAX_BYTES=536870912
`;
/** 一份“齐备”的 .env 内容。 */
const COMPLETE_ENV =
[
'DB_HOST=10.0.0.5',
'DB_PORT=3306',
'DB_USER=u1',
'DB_PASS=p1',
'DB_NAME=d1',
'PORT=3000',
'ADMIN_PORT=3001',
'HOST=0.0.0.0',
].join('\n') + '\n';
const tempDirs: string[] = [];
function makeDir(withExample = true): string {
const dir = mkdtempSync(join(tmpdir(), 'opendance-init-env-'));
tempDirs.push(dir);
if (withExample) writeFileSync(join(dir, '.env.example'), EXAMPLE, 'utf8');
return dir;
}
function envPath(dir: string): string {
return join(dir, '.env');
}
interface RunResult {
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error?: Error;
}
function run(
args: string[],
opts: { input?: string; env?: Record<string, string>; timeoutMs?: number } = {},
): RunResult {
const result = spawnSync('bash', [SCRIPT, ...args], {
input: opts.input ?? '',
encoding: 'utf8',
timeout: opts.timeoutMs ?? 8000,
env: { ...process.env, OPENDANCE_SKIP_DB_CHECK: '1', ...opts.env },
});
return {
status: result.status,
signal: result.signal,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error,
};
}
function outputOf(result: RunResult): string {
return `${result.stdout}${result.stderr}`;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
describe('init-env.sh:非交互模式绝不阻塞', () => {
it(
'缺必填 → 非 0 退出并列出字段名,且不阻塞',
() => {
const dir = makeDir();
// 人为缺 DB_USER / DB_PASS
writeFileSync(envPath(dir), 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n');
const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 });
expect(result.error).toBeUndefined(); // 超时被杀则 error=ETIMEDOUT
expect(result.status).not.toBe(0);
const out = outputOf(result);
expect(out).toContain('DB_USER');
expect(out).toContain('DB_PASS');
// 不修改文件
expect(readFileSync(envPath(dir), 'utf8')).toBe(
'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n',
);
},
15000,
);
it(
'OPENDANCE_NONINTERACTIVE=1 与 --yes 同样跳过提问',
() => {
const dir = makeDir();
writeFileSync(envPath(dir), 'DB_HOST=h\nDB_NAME=n\n'); // 缺 DB_USER / DB_PASS
const byEnv = run(['--dir', dir], {
input: 'should-not-be-read\n',
env: { OPENDANCE_NONINTERACTIVE: '1' },
timeoutMs: 5000,
});
expect(byEnv.error).toBeUndefined();
expect(byEnv.status).not.toBe(0);
expect(outputOf(byEnv)).not.toContain('请输入');
const byFlag = run(['--yes', '--dir', dir], { timeoutMs: 5000 });
expect(byFlag.error).toBeUndefined();
expect(byFlag.status).not.toBe(0);
expect(outputOf(byFlag)).not.toContain('请输入');
},
15000,
);
it(
'全部齐备 → 0 退出且完全不修改文件',
() => {
const dir = makeDir();
writeFileSync(envPath(dir), COMPLETE_ENV);
const before = readFileSync(envPath(dir), 'utf8');
const modeBefore = statSync(envPath(dir)).mode;
const result = run(['--non-interactive', '--dir', dir]);
expect(result.status).toBe(0);
expect(result.error).toBeUndefined();
expect(readFileSync(envPath(dir), 'utf8')).toBe(before);
expect(statSync(envPath(dir)).mode).toBe(modeBefore);
},
15000,
);
it(
'.env 不存在 → 从 .env.example 建立基础,但仍因占位符未填而报错',
() => {
const dir = makeDir();
const result = run(['--non-interactive', '--dir', dir]);
expect(result.status).not.toBe(0);
const out = outputOf(result);
expect(out).toContain('已从模板创建');
expect(out).toContain('DB_HOST'); // <existing-mysql-host>
expect(out).toContain('DB_PASS'); // <部署方提供>
// 文件确实被创建(作为用户后续填写的基础)
expect(existsSync(envPath(dir))).toBe(true);
},
15000,
);
});
describe('init-env.sh:交互式引导(printf ... | bash)', () => {
it(
'逐项回答后写回正确值,注释保留,ADMIN_PORT 默认 = PORT + 1',
() => {
const dir = makeDir();
// 顺序:DB_HOST DB_PORT DB_USER DB_PASS DB_NAME PORT ADMIN_PORT HOST
const input =
['db.example.com', '3307', 'dance_user', 'p@ss*word%', 'dance_db', '4100', '', ''].join(
'\n',
) + '\n';
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
const content = readFileSync(envPath(dir), 'utf8');
expect(content).toMatch(/^DB_HOST=db\.example\.com$/m);
expect(content).toMatch(/^DB_PORT=3307$/m);
expect(content).toMatch(/^DB_USER=dance_user$/m);
expect(content).toMatch(/^DB_PASS=p@ss\*word%$/m);
expect(content).toMatch(/^DB_NAME=dance_db$/m);
expect(content).toMatch(/^PORT=4100$/m);
expect(content).toMatch(/^ADMIN_PORT=4101$/m); // 默认 = PORT + 1
expect(content).toMatch(/^HOST=0\.0\.0\.0$/m);
// 原有注释与其它字段必须保留
expect(content).toContain('# 测试用模板:这些注释必须被保留');
expect(content).toContain('# 管理端端口注释');
expect(content).toContain('NODE_ENV=production');
expect(content).toContain('DB_SYNCHRONIZE=false');
expect(content).toContain('UPLOAD_MAX_BYTES=536870912');
// 口令落盘为 600
expect(statSync(envPath(dir)).mode & 0o777).toBe(0o600);
},
15000,
);
it(
'交互输入中途 EOF → 立即非 0 退出,不挂住',
() => {
const dir = makeDir();
const result = run(['--dir', dir], { input: 'only-a-host\n', timeoutMs: 5000 });
expect(result.error).toBeUndefined(); // 未被 timeout 杀掉
expect(result.status).not.toBe(0);
expect(outputOf(result)).toContain('stdin');
},
15000,
);
it(
'重复运行(--force + 同样输入)结果幂等',
() => {
const dir = makeDir();
const input =
['h1', '3306', 'u1', 'pw1', 'd1', '3000', '3001', '0.0.0.0'].join('\n') + '\n';
expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0);
const first = readFileSync(envPath(dir), 'utf8');
expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0);
const second = readFileSync(envPath(dir), 'utf8');
expect(second).toBe(first);
},
20000,
);
});
describe('init-env.sh:字段校验', () => {
it.each([
['DB_PORT', '70000'],
['PORT', 'abc'],
['ADMIN_PORT', '70000'],
])(
'端口非法 %s=%s → 非 0 退出并报出字段名',
(key, bad) => {
const dir = makeDir();
const lines = COMPLETE_ENV.trimEnd()
.split('\n')
.filter((line) => !line.startsWith(`${key}=`));
lines.push(`${key}=${bad}`);
writeFileSync(envPath(dir), lines.join('\n') + '\n');
const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 });
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(outputOf(result)).toContain(key);
},
15000,
);
it(
'交互下端口非法会重新询问,最终接受合法值',
() => {
const dir = makeDir();
// DB_PORT 连续给两个非法值,脚本应重新询问
const input =
['h', 'abc', '70000', '3306', 'u', 'p', 'd', '', '', ''].join('\n') + '\n';
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
const content = readFileSync(envPath(dir), 'utf8');
expect(content).toMatch(/^DB_PORT=3306$/m);
expect(outputOf(result)).toContain('必须');
},
15000,
);
it.each([['S3cr3t*Pwd%1'], ['a%b*c-d_e.f']])(
'口令含特殊字符 %s:不加引号写盘且 set -a; . .env 能读回同一个值',
(password) => {
const dir = makeDir();
const input = ['h', '3306', 'u', password, 'd', '', '', ''].join('\n') + '\n';
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
const content = readFileSync(envPath(dir), 'utf8');
expect(content).toContain(`DB_PASS=${password}`);
// 不得给值加引号(否则 shell 语义可能变化)
expect(content).not.toMatch(/^DB_PASS=["']/m);
// 真实地用 shell source 一次,确认读回同一个值
const sourced = spawnSync(
'bash',
['-c', 'set -a; . "$1"; printf "%s" "$DB_PASS"', '_', envPath(dir)],
{ encoding: 'utf8' },
);
expect(sourced.status).toBe(0);
expect(sourced.stdout).toBe(password);
},
15000,
);
});
describe('init-env.sh:--force / --check / 幂等', () => {
it(
'--force 在已有 .env 时重新生成,并提示 ADMIN_PORT === PORT 的单端口模式',
() => {
const dir = makeDir();
writeFileSync(envPath(dir), COMPLETE_ENV); // 旧配置
const input =
['newhost', '3406', 'newuser', 'newpass', 'newdb', '5100', '5100', '127.0.0.1'].join('\n') +
'\n';
const result = run(['--force', '--dir', dir], { input, timeoutMs: 10000 });
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
const content = readFileSync(envPath(dir), 'utf8');
expect(content).toMatch(/^DB_HOST=newhost$/m);
expect(content).toMatch(/^DB_PORT=3406$/m);
expect(content).toMatch(/^DB_USER=newuser$/m);
expect(content).toMatch(/^DB_PASS=newpass$/m);
expect(content).toMatch(/^DB_NAME=newdb$/m);
expect(content).toMatch(/^PORT=5100$/m);
expect(content).toMatch(/^ADMIN_PORT=5100$/m);
expect(content).toMatch(/^HOST=127\.0\.0\.1$/m);
expect(outputOf(result)).toContain('单端口');
},
15000,
);
it(
'已有完整 .env 且未加 --force:跳过询问、不修改文件(幂等)',
() => {
const dir = makeDir();
writeFileSync(envPath(dir), COMPLETE_ENV);
const before = readFileSync(envPath(dir), 'utf8');
// 即使管道里塞了数据也应当忽略(因为完整且未 --force)
const result = run(['--dir', dir], { input: '\n\n\n\n\n\n\n\n', timeoutMs: 8000 });
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(outputOf(result)).toContain('跳过询问');
expect(readFileSync(envPath(dir), 'utf8')).toBe(before);
},
15000,
);
it(
'--check:合法则 0 且不改文件;缺字段则非 0',
() => {
const okDir = makeDir();
writeFileSync(envPath(okDir), COMPLETE_ENV);
const before = readFileSync(envPath(okDir), 'utf8');
const okResult = run(['--check', '--dir', okDir], { timeoutMs: 5000 });
expect(okResult.error).toBeUndefined();
expect(okResult.status).toBe(0);
expect(readFileSync(envPath(okDir), 'utf8')).toBe(before);
const badDir = makeDir();
writeFileSync(envPath(badDir), 'DB_HOST=h\n');
const badResult = run(['--check', '--dir', badDir], { timeoutMs: 5000 });
expect(badResult.error).toBeUndefined();
expect(badResult.status).not.toBe(0);
expect(outputOf(badResult)).toContain('DB_USER');
expect(outputOf(badResult)).toContain('DB_PASS');
},
15000,
);
});