需求:公开仓库的说明文档不应暴露开发/部署期信息,且 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 项,确认全角骨骼名未被破坏)。
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
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,
|
||
);
|
||
});
|