gongxiangp2/scripts/git-incremental-deploy.test.mjs

319 lines
17 KiB
JavaScript

import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import { createHash } from 'node:crypto'
import { aiModelRegistryEvidence } from './fixtures/ai-model-registry-evidence.mjs'
const bash = process.platform === 'win32' && existsSync('C:/Program Files/Git/bin/bash.exe') ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
import {
extractRemoteTemplate,
parseArguments,
renderRemoteTemplate,
} from './git-incremental-deploy-macos.mjs'
test('generated remote deployment payload is valid bash', () => {
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
const payload = extractRemoteTemplate(source).replace(/__[A-Z0-9_]+__/g, "''")
const result = spawnSync(bash, ['-n'], {
input: payload,
encoding: 'utf8',
})
assert.equal(result.status, 0, result.stderr || result.stdout)
})
test('Mac entry renders every canonical remote deployment token', () => {
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
const template = extractRemoteTemplate(source)
const tokens = [...new Set(template.match(/__[A-Z0-9_]+__/g) ?? [])]
const values = Object.fromEntries(tokens.map((token, index) => [token, token === '__COMMIT__'
? 'a'.repeat(40)
: `value ${index} with quote ' safely escaped`]))
const payload = renderRemoteTemplate(template, values)
assert.doesNotMatch(payload, /__[A-Z0-9_]+__/)
const result = spawnSync(bash, ['-n'], { input: payload, encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr || result.stdout)
})
test('Mac entry requires an exact commit outside DryRun', () => {
assert.throws(() => parseArguments([]), /正式部署必须/)
assert.throws(() => parseArguments(['--commit', 'abc']), /40 位小写/)
assert.equal(parseArguments(['--dry-run']).dryRun, true)
assert.equal(parseArguments(['--commit', 'b'.repeat(40)]).commit, 'b'.repeat(40))
assert.equal(parseArguments(['--commit', 'b'.repeat(40), '--skip-deploy']).skipDeploy, true)
assert.throws(
() => parseArguments(['--commit', 'b'.repeat(40), '--reuse-verified-image', '--skip-deploy']),
/不能与 --skip-deploy/,
)
})
test('deployment SSH hops keep long builds and cutovers alive', () => {
const macSource = readFileSync(new URL('./git-incremental-deploy-macos.mjs', import.meta.url), 'utf8')
const powerShellSource = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
const payload = extractRemoteTemplate(powerShellSource)
for (const source of [macSource, payload]) {
assert.match(source, /ServerAliveInterval=3/)
assert.match(source, /ServerAliveCountMax=10/)
assert.match(source, /TCPKeepAlive=yes/)
}
})
test('migration deployment gate recognizes the latest approved schema migration', () => {
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
const gateStart = source.indexOf('const expectedMigrationNames = new Set([')
const gateEnd = source.indexOf(']);', gateStart)
assert.ok(gateStart >= 0 && gateEnd > gateStart, 'migration gate list is missing')
const gate = source.slice(gateStart, gateEnd)
assert.match(gate, /20261008_permission_group_department_identity/)
assert.match(gate, /20261008_team_function_configs/)
assert.match(gate, /20260831_add_private_task_detail/)
assert.match(gate, /20260908_add_brand_knowledge/)
assert.match(gate, /20260914_add_brand_knowledge_engagement/)
assert.match(gate, /20260914_expand_brand_knowledge_download_count/)
assert.match(gate, /20260914_expand_brand_knowledge_preview_slices/)
assert.match(gate, /20260915_add_brand_knowledge_soft_delete/)
assert.match(gate, /20260918_add_performance_conversion_sync/)
assert.match(gate, /20261012_detail_generator/)
assert.match(gate, /20261012_ai_model_registry/)
assert.match(gate, /20261013_ai_model_capabilities/)
assert.match(gate, /20261013_ai_model_verifications/)
})
test('direct replacement runs database and dependency gates before switching traffic', () => {
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
.replace(/\r\n/g, '\n')
const functionStart = source.indexOf('direct_replace_active_container() {')
const functionEnd = source.indexOf('\n}\n\ncontainer_upstream_connection_count()', functionStart)
assert.ok(functionStart >= 0 && functionEnd > functionStart, 'direct replacement function is missing')
const direct = source.slice(functionStart, functionEnd)
const switchTraffic = direct.indexOf('write_proxy_config "${replacement_ip}:3000"')
assert.ok(switchTraffic > 0, 'direct traffic switch is missing')
for (const gate of [
'verify_transcode_lease_schema "$name"',
'verify_task_detail_upload_lease_schema "$name"',
'verify_brand_logo_defaults "$name"',
'verify_material_upload_idempotency_schema "$name"',
'verify_candidate_database_identity "$name"',
'verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"',
'verify_video_transcode_capability "$name"',
'verify_task_detail_upload_capability "$name"',
'verify_detail_generator_schema "$name"',
'verify_detail_generator_dependencies "$name"',
]) {
const position = direct.indexOf(gate)
assert.ok(position > 0 && position < switchTraffic, `${gate} must run before the direct traffic switch`)
}
assert.match(direct, /fail_direct_replace_with_restore "New direct container database identity differs/)
assert.match(direct, /fail_direct_replace_with_restore "New direct container cannot authenticate to S3/)
assert.ok(direct.indexOf('prepare_detail_generator_release') < direct.indexOf('docker stop --time 10'), 'configuration and private volume must be validated before stopping the old slot')
})
function deploySource() { return readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8').replace(/\r\n/g, '\n') }
function shellFunction(name) {
const source = deploySource()
const start = source.indexOf(`${name}() {`)
const end = source.indexOf('\n}\n\n', start)
assert.ok(start >= 0 && end > start, `missing ${name}`)
return source.slice(start, end + 3)
}
test('direct rollback restores an absent or partial replacement from the immutable image', () => {
const rollback = shellFunction('restore_direct_previous_container')
const image = `sha256:${'c'.repeat(64)}`
for (const scenario of [
{ present: 0, removeFails: 0, status: 0, removed: false },
{ present: 1, removeFails: 0, status: 0, removed: true },
{ present: 1, removeFails: 1, status: 1, removed: true },
]) {
const input = `set -u
present=${scenario.present}
remove_fails=${scenario.removeFails}
removed=0
docker() {
case "$1" in
container) [ "$present" = 1 ] ;;
rm) removed=1; [ "$remove_fails" = 0 ] ;;
start) return 0 ;;
*) return 90 ;;
esac
}
create_container() { printf 'created:%s:%s\\n' "$1" "$2"; }
verify_container_uploads_mount_metadata() { return 0; }
verify_container_upload_dir_config() { return 0; }
verify_container_uploads_storage() { return 0; }
wait_for_container_health() { printf '172.18.0.9\\n'; }
write_proxy_config() { printf 'proxy:%s\\n' "$1"; }
switch_proxy() { return 0; }
verify_proxy_target() { printf 'proxy-verified\\n'; }
write_current_container() { return 0; }
resume_video_transcode() { printf 'video-resumed\\n'; }
resume_task_detail_upload() { printf 'upload-resumed\\n'; }
${rollback}
restore_direct_previous_container material-blue '${image}'
status=$?
printf 'removed:%s\\n' "$removed" >&2
exit "$status"
`
const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' })
assert.equal(result.status, scenario.status, result.stderr || result.stdout)
assert.equal(result.stderr.includes('removed:1'), scenario.removed)
if (scenario.status === 0) {
assert.match(result.stdout, new RegExp(`created:material-blue:${image}`))
assert.match(result.stdout, /proxy:172\.18\.0\.9:3000/)
assert.ok(result.stdout.indexOf('proxy-verified') < result.stdout.indexOf('video-resumed'))
assert.ok(result.stdout.indexOf('proxy-verified') < result.stdout.indexOf('upload-resumed'))
} else assert.doesNotMatch(result.stdout, /created:|proxy:/)
}
const invalid = spawnSync(bash, ['-s'], { input: `docker() { echo docker-was-called; }\n${rollback}\nrestore_direct_previous_container material-blue mutable:latest\n`, encoding: 'utf8' })
assert.equal(invalid.status, 1)
assert.doesNotMatch(invalid.stdout, /docker-was-called/)
const direct = shellFunction('direct_replace_active_container')
assert.match(direct, /previous_image="\$\(docker inspect -f '\{\{\.Image\}\}'/)
assert.doesNotMatch(direct, /\.Config\.Image/)
})
test('detail beta configuration validates enabled key/base without exposing provider secrets', () => {
const source = shellFunction('prepare_detail_generator_release')
const code = source.match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1]
const env = { ...process.env, DETAIL_GENERATOR_ENABLED: 'true', DETAIL_GENERATOR_API_KEY: 'synthetic-private-key', DETAIL_GENERATOR_BASE_URL: 'https://model.example.invalid/v1', DETAIL_GENERATOR_STORAGE_DIR: '/app/detail-generator-private' }
for (const input of [
{ env, status: 0, output: 'true' },
{ env: { ...env, DETAIL_GENERATOR_API_KEY: '' }, status: 1, output: '' },
{ env: { ...env, DETAIL_GENERATOR_BASE_URL: '' }, status: 1, output: '' },
{ env: { ...env, DETAIL_GENERATOR_BASE_URL: 'https://user:synthetic-private-key@model.example.invalid' }, status: 1, output: '' },
{ env: { ...env, DETAIL_GENERATOR_ENABLED: '', DETAIL_GENERATOR_API_KEY: '' }, status: 0, output: 'false' },
{ env: { ...env, DETAIL_GENERATOR_ENABLED: 'false', DETAIL_GENERATOR_API_KEY: '' }, status: 0, output: 'false' },
]) {
const result = spawnSync(process.execPath, ['-e', code], { env: input.env, encoding: 'utf8' })
assert.equal(result.status, input.status)
assert.equal(result.stdout, input.output)
assert.doesNotMatch(result.stdout + result.stderr, /synthetic-private-key/)
}
assert.match(source, /--env-file "\$ENV_FILE_PATH"/)
assert.doesNotMatch(source, /-e [^\n]*DETAIL_GENERATOR_API_KEY/)
})
test('fixed private storage rejects overlapping uploads before creating directories', () => {
const check = shellFunction('detail_private_mount_is_safe')
const privateRoot = '/mnt/PoolOne/apps/youyidian-3d/detail-generator-private'
for (const upload of [privateRoot, '/mnt/PoolOne/apps/youyidian-3d', `${privateRoot}/uploads`]) {
const result = spawnSync(bash, ['-s'], { input: `DETAIL_PRIVATE_MOUNT='${privateRoot}'\nUPLOADS_MOUNT='${upload}'\n${check}\ndetail_private_mount_is_safe\n`, encoding: 'utf8' })
assert.equal(result.status, 1, `overlap accepted: ${upload}`)
}
assert.match(check, /while \[ "\$ancestor" != "\/" \]/)
assert.match(check, /\[ ! -L "\$ancestor" \]/)
const prepare = shellFunction('prepare_detail_generator_release')
assert.match(prepare, /install -d -m 0700 -o 1001 -g 1001/)
assert.match(prepare, /1001:1001:700/)
})
test('enabled containers bind private persistence and verify actual Node22 PSD/PNG without model calls', () => {
const create = shellFunction('create_container')
assert.match(create, /if \[ "\$DETAIL_FEATURE_ENABLED" = "true" \]/)
assert.match(create, /--mount "type=bind,source=\$\{DETAIL_PRIVATE_MOUNT\},target=\/app\/detail-generator-private"/)
assert.match(create, /--env-file "\$sanitized_env_file"/)
assert.doesNotMatch(create, /DETAIL_GENERATOR_API_KEY/)
const schema = shellFunction('verify_detail_generator_schema')
assert.match(schema, /20261012_detail_generator/)
assert.match(schema, /completed\[0\]\.checksum !== checksum/)
for (const table of ['projects', 'assets', 'jobs', 'brand_profiles']) assert.match(schema, new RegExp(`detail_generator_${table}`))
const runtime = shellFunction('verify_detail_generator_dependencies')
assert.match(runtime, /\/opt\/detail-renderer\/bin\/node/)
assert.match(runtime, /!== 22/)
assert.match(runtime, /renderRequest/)
assert.match(runtime, /format:'psd'/)
assert.match(runtime, /format:'png'/)
assert.match(runtime, /8BPS/)
assert.match(runtime, /\.deploy-probe-/)
assert.doesNotMatch(runtime, /fetch\(|generateDetailImage|runDetailGeneratorWorker/)
})
test('AI model cutover gate checks every migration and rejects missing, mistyped or wrongly nullable verification state', async () => {
const code = shellFunction('verify_detail_generator_schema').match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1]
const columns = [
...aiModelRegistryEvidence().columns,
...Object.entries({
detail_generator_projects: ['id', 'owner_id', 'document', 'revision', 'deleted_at'],
detail_generator_assets: ['id', 'project_id', 'owner_id', 'storage_key', 'byte_size'],
detail_generator_jobs: ['id', 'project_id', 'owner_id', 'status', 'idempotency_key', 'fingerprint', 'project_revision', 'payload', 'checkpoint', 'lease_token', 'lease_expires_at'],
detail_generator_brand_profiles: ['id', 'name', 'profile', 'revision'],
}).flatMap(([table_name, names]) => names.map(column_name => ({ table_name, column_name }))),
]
const checksum = createHash('sha256').update('schema fixture\n').digest('hex')
const run = async (changedColumns = columns, missingMigration = '') => {
const state = { exitCode: 0 }, checked = []
class PrismaClient {
async $queryRawUnsafe(sql, name) {
if (sql.includes('_prisma_migrations')) { checked.push(name); return name === missingMigration ? [] : [{ checksum }] }
return changedColumns
}
async $disconnect() {}
}
await runInNewContext(code, { process: state, require: name => ({ fs: { readFileSync: () => 'schema fixture\r\n' }, crypto: { createHash }, '@prisma/client': { PrismaClient } })[name] })
return { ...state, checked }
}
const accepted = await run()
assert.equal(accepted.exitCode, 0)
assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications'])
for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) assert.equal((await run(columns, migration)).exitCode, 1)
assert.equal((await run(columns.filter(row => row.column_name !== 'capability_metadata'))).exitCode, 1)
assert.equal((await run(columns.map(row => row.column_name === 'capability_metadata' ? { ...row, data_type: 'text' } : row))).exitCode, 1)
assert.equal((await run(columns.filter(row => row.table_name !== 'ai_model_verifications'))).exitCode, 1, 'missing verification table must block cutover')
for (const field of ['stage', 'context_hash', 'task_id', 'evidence']) {
const target = row => row.table_name === 'ai_model_verifications' && row.column_name === field
assert.equal((await run(columns.filter(row => !target(row)))).exitCode, 1, `missing ${field} must block cutover`)
assert.equal((await run(columns.map(row => target(row) ? { ...row, data_type: 'boolean' } : row))).exitCode, 1, `mistyped ${field} must block cutover`)
assert.equal((await run(columns.map(row => target(row) ? { ...row, is_nullable: row.is_nullable === 'YES' ? 'NO' : 'YES' } : row))).exitCode, 1, `wrong nullability for ${field} must block cutover`)
}
})
test('AI registry migration files are present in builder and immutable runner verification scope', () => {
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8')
for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) {
assert.ok(dockerfile.includes(`test -f "/app/prisma/migrations/${migration}/migration.sql"`))
assert.ok(dockerfile.includes(`COPY --from=builder --chown=root:root /app/prisma/migrations/${migration} ./prisma/migrations/${migration}`))
}
})
test('detail uploads keep multipart headroom without expanding all proxy routes', () => {
assert.match(deploySource(), /location ~ "\^\/api\/detail-generator\/projects\/\[0-9a-fA-F-\]\{36\}\/assets\\\$" \{\n\s+client_max_body_size 17m;\n\s+proxy_request_buffering off;/)
})
test('brand knowledge upload stays bounded in the route and stable proxy', () => {
const deploySource = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
.replace(/\r\n/g, '\n')
const routeSource = readFileSync(
new URL('../app/api/department-dashboard/brand-knowledge/route.ts', import.meta.url),
'utf8',
)
assert.match(
deploySource,
/location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 53m;\n\s+proxy_request_buffering off;/,
)
const headerGuard = routeSource.indexOf('assertBoundedMultipartHeaders(')
const acquireSlot = routeSource.indexOf('boundedMultipartRequestLimiter.acquire()')
const parseBody = routeSource.indexOf('request.formData()')
const parsedFiles = routeSource.indexOf('assertMultipartFiles(', parseBody)
assert.ok(headerGuard > 0)
assert.ok(acquireSlot > headerGuard)
assert.ok(parseBody > acquireSlot)
assert.ok(parsedFiles > parseBody)
assert.match(routeSource, /BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES/)
assert.match(routeSource, /BRAND_KNOWLEDGE_MAX_REQUEST_BYTES/)
assert.match(routeSource, /resolveOwnedMergedUpload/)
assert.match(routeSource, /publishMergedUploadByHardLink/)
assert.doesNotMatch(routeSource, /formData\.get\('file'\)/)
assert.match(routeSource, /import \{ isWebFileLike \} from '@\/lib\/web-file'/)
assert.doesNotMatch(routeSource, /instanceof File/)
assert.match(routeSource, /releaseRequestSlot\?\.\(\)/)
})