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

256 lines
14 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/)
})
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('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 reads both migration checksums and rejects missing or mistyped capability 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'])
for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) 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)
})
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']) {
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\?\.\(\)/)
})