文件
hyb-oa/server/tests/unit/scripts/deployment-db-lifecycle.test.js
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

291 行
16 KiB
JavaScript

const { execFile } = require('node:child_process');
const { mkdtemp, mkdir, writeFile, readFile, chmod, symlink, rm } = require('node:fs/promises');
const { promisify } = require('node:util');
const os = require('node:os');
const path = require('node:path');
const YAML = require('yaml');
const { parseCompose, lifecycleServices, startCommandServer } = require('../fixtures/deployment-command-fake.cjs');
const execute = promisify(execFile);
const ROOT = path.resolve(__dirname, '../../../..');
const DEPLOY = path.join(ROOT, 'deploy/trialrun/deploy.sh');
const COMPOSE = path.join(ROOT, 'deploy/trialrun/docker-compose.yml');
const FAKE = path.join(__dirname, '../fixtures/deployment-command-client.py');
const ORIGINAL_PATH = process.env.PATH;
function remoteSection(source) {
const sections = [...source.matchAll(/<<'REMOTE'\n([\s\S]*?)\nREMOTE\n/g)];
expect(sections).toHaveLength(1);
return sections[0][1];
}
function replaceOnce(source, before, after) {
expect(source.split(before)).toHaveLength(2);
return source.replace(before, after);
}
function assertNoDatabaseLifecycle(trace) {
const violations = trace.filter(event => event.lifecycle?.includes('db'));
if (violations.length) throw new Error('database_lifecycle_forbidden:' + violations.map(event => event.compose.operation).join(','));
}
describe('database lifecycle isolation in the actual deployment remote script', () => {
let fixtureRoot;
let source;
let services;
let closeCommands;
beforeEach(async () => {
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), 'hyb-db-lifecycle-'));
source = await readFile(DEPLOY, 'utf8');
services = YAML.parse(await readFile(COMPOSE, 'utf8')).services;
const bin = path.join(fixtureRoot, 'bin');
await mkdir(bin);
const fakeSource = await readFile(FAKE, 'utf8');
for (const command of ['docker', 'docker-compose', 'curl', 'timeout', 'sleep', 'ssh', 'rsync', 'node', 'git', 'sudo']) {
const file = path.join(bin, command);
await writeFile(file, fakeSource);
await chmod(file, 0o700);
}
for (const command of ['bash', 'mkdir', 'install', 'chmod', 'seq']) await symlink('/usr/bin/' + command, path.join(bin, command));
await mkdir(path.join(fixtureRoot, 'app'));
await mkdir(path.join(fixtureRoot, 'storage'));
await mkdir(path.join(fixtureRoot, 'postgres'));
await writeFile(path.join(fixtureRoot, 'postgres/PG_VERSION'), '16\n');
await writeFile(path.join(fixtureRoot, 'env'), 'APP_ORIGIN=https://example.invalid\n', { mode: 0o600 });
closeCommands = await startCommandServer(fixtureRoot);
});
afterEach(async () => {
expect(process.env.PATH).toBe(ORIGINAL_PATH);
await closeCommands?.();
closeCommands = undefined;
await rm(fixtureRoot, { recursive: true, force: true });
});
async function run({ mode = 'existing', worker = false, minio = false, engine = 'v2', health = 'healthy', initFails = false, unsupportedIsolation = false, driftAfter = '', workerImagePresent = true, workerTagPresent = true, script = remoteSection(source) } = {}) {
await writeFile(path.join(fixtureRoot, 'config.json'), JSON.stringify({ services, mode, engine, health, initFails, unsupportedIsolation, driftAfter, workerImagePresent, workerTagPresent }));
await writeFile(path.join(fixtureRoot, 'trace.jsonl'), '');
const scriptPath = path.join(fixtureRoot, 'remote.sh');
await writeFile(scriptPath, script);
const env = {
PATH: path.join(fixtureRoot, 'bin'), HOME: fixtureRoot,
DOCKER_HOST: 'unix://' + path.join(fixtureRoot, 'nonexistent-docker.sock'), DOCKER_CONFIG: path.join(fixtureRoot, 'docker-config'),
HYB_DEPLOYMENT_FIXTURE_ROOT: fixtureRoot,
REMOTE_DIR: path.join(fixtureRoot, 'app'), REMOTE_ENV_FILE: path.join(fixtureRoot, 'env'),
POSTGRES_DATA_DIR: path.join(fixtureRoot, 'postgres'), STORAGE_HOST_DIR: path.join(fixtureRoot, 'storage'),
COMPOSE_PROJECT_NAME: 'synthetic-project', COMPOSE_FILE: 'compose.yml',
DEPLOY_DATA_MODE: mode, ENABLE_WORKER: worker ? '1' : '0', ENABLE_MINIO: minio ? '1' : '0',
DEPLOY_STATUS_OUTPUT_MODE: 'quiet', BUILD_SHA: '123456789abc'.padEnd(40, '0'), VITE_BUILD_SHA: '123456789abc',
};
let result;
try {
result = { ...await execute('/usr/bin/bash', [scriptPath], { env, timeout: 15000 }), code: 0 };
} catch (error) {
result = { code: error.code, stdout: error.stdout, stderr: error.stderr, killed: error.killed };
}
const trace = (await readFile(path.join(fixtureRoot, 'trace.jsonl'), 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
return { ...result, trace };
}
function assertCompleted(result, worker) {
expect(result.code, result.stdout + result.stderr).toBe(0);
const stages = result.trace.filter(event => ['run', 'up'].includes(event.compose?.operation));
expect(stages.some(event => event.compose.operation === 'run' && event.compose.targets.join() === 'init')).toBe(true);
expect(stages.some(event => event.compose.operation === 'up' && event.compose.targets.join() === 'server')).toBe(true);
expect(stages.some(event => event.compose.operation === 'up' && event.compose.targets.join() === 'worker')).toBe(worker);
expect(result.stdout).toContain('4.10');
}
it('existing release never operates on db while reaching init and the application', async () => {
const result = await run();
assertCompleted(result, false);
assertNoDatabaseLifecycle(result.trace);
});
for (const engine of ['v1', 'v2']) {
for (const mode of ['existing', 'initialize']) {
for (const worker of [false, true]) {
for (const minio of [false, true]) {
it(`${mode}/${engine}/worker=${worker}/minio=${minio} preserves the service boundary`, async () => {
const result = await run({ engine, mode, worker, minio });
assertCompleted(result, worker);
const databaseEvents = result.trace.filter(event => event.lifecycle?.includes('db'));
if (mode === 'existing') assertNoDatabaseLifecycle(result.trace);
else {
expect(databaseEvents).toHaveLength(1);
expect(databaseEvents[0].compose).toMatchObject({ operation: 'up', targets: ['db'] });
}
expect(result.trace.some(event => event.compose?.targets.join() === 'minio,minio_init')).toBe(minio);
const healthIndex = result.trace.findIndex(event => event.command === 'docker' && event.args[0] === 'inspect');
const initIndex = result.trace.findIndex(event => event.compose?.operation === 'run');
expect(healthIndex).toBeGreaterThan(-1);
expect(healthIndex).toBeLessThan(initIndex);
const deadline = result.trace.find(event => event.command === 'timeout');
expect(deadline.args.slice(0, 2)).toEqual(['--signal=KILL', '120']);
});
}
}
}
}
it.each(['missing', 'stopped', 'unhealthy', 'no_health', 'multiple', 'query_error', 'inspect_error'])('existing %s database fails closed without init or application switching', async health => {
const result = await run({ health });
expect(result.code).not.toBe(0);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.some(event => ['run', 'up'].includes(event.compose?.operation))).toBe(false);
});
it.each(['', 'unknown'])('invalid mode %s is rejected before container commands', async mode => {
const result = await run({ mode });
expect(result.code).not.toBe(0);
expect(result.trace).toEqual([]);
});
it.each(['existing', 'initialize'])('missing deadline tooling stops %s before lifecycle operations', async mode => {
await rm(path.join(fixtureRoot, 'bin/timeout'));
const result = await run({ mode });
expect(result.code).toBe(1);
expect(result.stderr).toContain('db_health_timeout_unavailable');
expect(result.trace.some(event => event.lifecycle?.length)).toBe(false);
});
it('waits for starting health before init without starting db', async () => {
const result = await run({ health: 'warming' });
assertCompleted(result, false);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.filter(event => event.args[0] === 'inspect').length).toBeGreaterThanOrEqual(2);
expect(result.trace.find(event => event.command === 'sleep').args).toEqual(['2']);
});
it('initialize starts a missing database before health and init', async () => {
const result = await run({ mode: 'initialize', health: 'missing' });
assertCompleted(result, false);
const start = result.trace.findIndex(event => event.lifecycle?.includes('db'));
const health = result.trace.findIndex(event => event.args[0] === 'inspect');
expect(start).toBeLessThan(health);
});
it.each(['starting', 'query_hang'])('the real timeout terminates %s health and prevents init', async health => {
const shortened = replaceOnce(remoteSection(source), 'timeout --signal=KILL 120 ', 'timeout --signal=KILL 1 ');
const result = await run({ health, script: shortened });
expect(result.code).toBe(1);
expect(result.killed).not.toBe(true);
expect(result.stdout + result.stderr).toContain('db_health_timeout');
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.some(event => ['run', 'up'].includes(event.compose?.operation))).toBe(false);
});
it('init failure prevents server and worker switching', async () => {
const result = await run({ initFails: true, worker: true });
expect(result.code).toBe(23);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.filter(event => event.compose?.operation === 'up')).toEqual([]);
});
it('unsupported isolation fails instead of retrying without protection', async () => {
const result = await run({ unsupportedIsolation: true });
expect(result.code).toBe(2);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.filter(event => event.compose?.operation === 'run')).toHaveLength(1);
expect(result.trace.filter(event => event.compose?.operation === 'up')).toEqual([]);
});
it.each([
['build:init', 'run:init'], ['run:init', 'build:server'], ['build:server', 'up:server'],
['up:server', 'up:worker'], ['build:worker', 'up:worker'],
])('health drift after %s prevents %s without managing db', async (driftAfter, forbiddenStage) => {
const result = await run({ driftAfter, worker: true, workerImagePresent: false });
expect(result.code).toBe(1);
expect(result.stdout).toContain('db_not_healthy');
assertNoDatabaseLifecycle(result.trace);
const stages = result.trace.filter(event => event.compose).map(event => event.compose.operation + ':' + event.compose.targets.join());
expect(stages).toContain(driftAfter);
expect(stages).not.toContain(forbiddenStage);
});
it.each([true, false])('worker image present=%s keeps build outside the guarded switch', async workerImagePresent => {
const result = await run({ worker: true, workerImagePresent });
assertCompleted(result, true);
assertNoDatabaseLifecycle(result.trace);
const build = result.trace.findIndex(event => event.compose?.operation === 'build' && event.compose.targets.join() === 'worker');
expect(build >= 0).toBe(!workerImagePresent);
for (const service of ['server', 'worker']) {
const start = result.trace.findIndex(event => event.compose?.operation === 'up' && event.compose.targets.join() === service);
expect(result.trace[start - 1].args[0]).toBe('inspect');
expect(result.trace[start].args).toContain('--no-build');
}
});
it('a worker container with a missing image tag is rebuilt before switching server', async () => {
const result = await run({ worker: true, workerImagePresent: true, workerTagPresent: false });
assertCompleted(result, true);
assertNoDatabaseLifecycle(result.trace);
const build = result.trace.findIndex(event => event.compose?.operation === 'build' && event.compose.targets.join() === 'worker');
const serverSwitch = result.trace.findIndex(event => event.compose?.operation === 'up' && event.compose.targets.join() === 'server');
expect(build).toBeGreaterThan(-1);
expect(build).toBeLessThan(serverSwitch);
expect(result.trace.some(event => event.command === 'docker' && event.args[0] === 'image')).toBe(true);
});
it('the service analyzer rejects explicit, global and implicit database operations', () => {
for (const args of [['up', '-d', 'db'], ['down'], ['up', '-d'], ['restart', 'db'], ['rm', '-f', 'db'], ['run', '--rm', '-T', '--entrypoint', 'sh', 'init', '-lc', 'echo db'], ['up', '-d', '--build', 'server'], ['up', '-d', 'worker']]) {
expect(lifecycleServices(parseCompose(args), services)).toContain('db');
}
expect(lifecycleServices(parseCompose(['run', '--no-deps', '--rm', '--entrypoint', 'sh', 'init', '-lc', 'echo db']), services)).toEqual(['init']);
expect(lifecycleServices(parseCompose(['exec', '-T', 'server', 'node', 'db']), services)).toEqual([]);
expect(() => parseCompose(['up', '--unknown', 'server'])).toThrow('unknown_compose_option');
});
it.each(['db', 'init', 'server', 'worker'])('real-source mutation %s produces a forbidden database event', async service => {
let mutated = remoteSection(source);
if (service === 'db') mutated = replaceOnce(mutated, 'if [[ "$DEPLOY_DATA_MODE" == "initialize" ]]; then\n "${compose_cmd[@]}" up -d db\nfi', '"${compose_cmd[@]}" up -d db');
else {
const lines = mutated.split('\n');
const matches = lines.filter(line => line.includes('--no-deps') && (service === 'init' ? line.includes(' init -lc ') : line.endsWith(' ' + service)));
expect(matches).toHaveLength(1);
mutated = replaceOnce(mutated, matches[0], matches[0].replace('--no-deps ', ''));
}
const result = await run({ script: mutated, worker: true });
assertCompleted(result, true);
expect(() => assertNoDatabaseLifecycle(result.trace)).toThrow('database_lifecycle_forbidden:');
});
it('unknown external calls cannot escape the private fake PATH', async () => {
const result = await run({ script: 'set -e\nssh unexpected.invalid\n' });
expect(result.code).toBe(97);
expect(result.trace).toEqual([{ command: 'ssh', args: ['unexpected.invalid'] }]);
});
it('the private transport preserves empty, quoted and multiline command arguments', async () => {
const result = await run({ script: "set -e\nssh '' 'space 中文' $'first\\nsecond'\n" });
expect(result.code).toBe(97);
expect(result.trace).toEqual([{ command: 'ssh', args: ['', 'space 中文', 'first\nsecond'] }]);
});
it('the private transport preserves ready stdin validation and failure status', async () => {
const result = await run({ script: `set -e\nprintf '%s' '{"data":{"buildSha":"wrong"}}' | docker compose -p synthetic-project exec -T server node -e 'EXPECTED_BUILD_SHA'\n` });
expect(result.code).toBe(97);
expect(result.stderr).toContain('wrong_ready_build');
expect(result.trace).toHaveLength(1);
});
it('an unavailable private transport fails instead of invoking real commands', async () => {
await closeCommands();
closeCommands = undefined;
const result = await run({ script: 'set -e\ndocker compose version\n' });
expect(result.code).toBe(97);
expect(result.stderr).toContain('transport_failed');
expect(result.trace).toEqual([]);
});
it('the existing wrapper pins mode regardless of an inherited initialize value', async () => {
const wrapper = await readFile(path.join(ROOT, 'deploy/trialrun/deploy-existing.sh'), 'utf8');
const invocations = [...wrapper.matchAll(/DEPLOY_SERVER="\$DEPLOY_SERVER" \\\nREMOTE_DIR="\$\{contract\[REMOTE_DIR\]\}"[\s\S]*?bash "\$ROOT_DIR\/deploy\/trialrun\/deploy\.sh"/g)];
expect(invocations).toHaveLength(1);
const body = invocations[0][0].replace('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"', '/usr/bin/printenv DEPLOY_DATA_MODE');
const result = await execute('/usr/bin/bash', ['-c', 'declare -A contract=();\n' + body], { env: { ROOT_DIR: fixtureRoot, DEPLOY_DATA_MODE: 'initialize' } });
expect(result.stdout.trim()).toBe('existing');
});
});