264 lines
15 KiB
JavaScript
264 lines
15 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('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\?\.\(\)/)
|
|
})
|