gongxiangp2/scripts/safe-production-migrate.test.mjs

977 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import {
assertBackupReceiptDocument,
assertExactActiveUploadIdIndex,
assertExactBtreeIndex,
assertExpectedDatabaseIdentity,
assertTargetMigrationEvidence,
databaseFingerprintHash,
normalizeColumnDefault,
requireExactCommit,
sha256,
verifiedBackupSummaryHash,
} from './safe-production-migrate.mjs'
test('accepts only an exact lowercase 40-character commit', () => {
const commit = 'a'.repeat(40)
assert.equal(requireExactCommit(`${commit}\n`, 'commit'), commit)
assert.throws(() => requireExactCommit('a'.repeat(39), 'commit'), /40-character/)
assert.throws(() => requireExactCommit('A'.repeat(40), 'commit'), /40-character/)
})
test('entrypoint validates only user arguments and still rejects unknown flags', () => {
const fixture = mkdtempSync(path.join(os.tmpdir(), 'gx-safe-migrator-entry-'))
try {
const scripts = path.join(fixture, 'scripts')
mkdirSync(scripts)
cpSync(new URL('./safe-production-migrate.mjs', import.meta.url), path.join(scripts, 'safe-production-migrate.mjs'))
const commit = 'f'.repeat(40)
writeFileSync(path.join(fixture, 'DEPLOYMENT_COMMIT'), `${commit}\n`)
// Node 24 会把内建 test runner 的子进程标记继续传给 spawn;迁移入口必须按普通脚本执行。
const childEnv = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !name.startsWith('NODE_TEST_') && name !== 'NODE_V8_COVERAGE'),
)
const run = (args) => spawnSync(
process.execPath,
[path.join(scripts, 'safe-production-migrate.mjs'), ...args],
{
cwd: fixture,
env: { ...childEnv, EXPECTED_COMMIT_SHA: commit, DATABASE_URL: '' },
encoding: 'utf8',
},
)
for (const args of [['--audit'], ['--apply']]) {
const result = run(args)
assert.notEqual(result.status, 0, `stdout=${result.stdout}\nstderr=${result.stderr}`)
assert.match(result.stderr, /DATABASE_URL is required/)
assert.doesNotMatch(result.stderr, /Only --audit/)
}
const unknown = run(['--audit', '--unexpected'])
assert.notEqual(unknown.status, 0)
assert.match(unknown.stderr, /Only --audit \(default\) or --apply is supported/)
} finally {
rmSync(fixture, { recursive: true, force: true })
}
})
test('uses Prisma-compatible SHA-256 migration checksums', () => {
assert.equal(
sha256(Buffer.from('SELECT 1;\n')),
'b4e0497804e46e0a0b0b8c31975b062152d551bac49c3c2e80932567b4085dcd',
)
})
test('normalizes PostgreSQL text defaults without accepting arbitrary values', () => {
assert.equal(normalizeColumnDefault("'/brand/header-logo.png'::text"), '/brand/header-logo.png')
assert.equal(normalizeColumnDefault("'/brand/login-logo.png'::character varying"), '/brand/login-logo.png')
})
test('accepts only a plain non-unique, non-partial btree with exact ordered columns', () => {
assert.doesNotThrow(() => assertExactBtreeIndex(
'CREATE INDEX materials_transcode_lease_idx ON public.materials USING btree (transcode_lease_expires_at, transcode_lease_token)',
'materials',
['transcode_lease_expires_at', 'transcode_lease_token'],
))
assert.throws(() => assertExactBtreeIndex(
'CREATE UNIQUE INDEX materials_transcode_lease_idx ON public.materials USING btree (transcode_lease_expires_at, transcode_lease_token)',
'materials',
['transcode_lease_expires_at', 'transcode_lease_token'],
), /non-unique/)
assert.throws(() => assertExactBtreeIndex(
'CREATE INDEX materials_transcode_lease_idx ON public.materials USING hash (transcode_lease_expires_at, transcode_lease_token)',
'materials',
['transcode_lease_expires_at', 'transcode_lease_token'],
), /columns or method/)
assert.throws(() => assertExactBtreeIndex(
'CREATE INDEX materials_transcode_lease_idx ON public.materials USING btree (transcode_lease_expires_at, transcode_lease_token) WHERE transcode_lease_token IS NOT NULL',
'materials',
['transcode_lease_expires_at', 'transcode_lease_token'],
), /non-partial/)
})
test('checks configured and actual database ports independently', () => {
const names = [
'EXPECTED_DATABASE_HOST',
'EXPECTED_DATABASE_PORT',
'EXPECTED_DATABASE_NAME',
'EXPECTED_DATABASE_USER',
'EXPECTED_DATABASE_SERVER_ADDRESS',
'EXPECTED_DATABASE_SERVER_PORT',
]
const previous = Object.fromEntries(names.map((name) => [name, process.env[name]]))
Object.assign(process.env, {
EXPECTED_DATABASE_HOST: 'postgres.service',
EXPECTED_DATABASE_PORT: '5433',
EXPECTED_DATABASE_NAME: 'materials',
EXPECTED_DATABASE_USER: 'app',
EXPECTED_DATABASE_SERVER_ADDRESS: '10.0.0.12',
EXPECTED_DATABASE_SERVER_PORT: '6432',
})
try {
const identity = { database: 'materials', user: 'app', server_address: '10.0.0.12', server_port: '6432' }
assert.doesNotThrow(() => assertExpectedDatabaseIdentity(
new URL('postgresql://app:secret@postgres.service:5433/materials'),
identity,
))
process.env.EXPECTED_DATABASE_PORT = '5432'
assert.throws(() => assertExpectedDatabaseIdentity(
new URL('postgresql://app:secret@postgres.service:5433/materials'),
identity,
), /fingerprint/)
} finally {
for (const name of names) {
if (previous[name] === undefined) delete process.env[name]
else process.env[name] = previous[name]
}
}
})
test('backup receipt binds commit, database, artifact and fresh restore validation', () => {
const commit = 'b'.repeat(40)
const database = {
configuredHost: 'postgres.service',
configuredPort: '5432',
configuredDatabase: 'materials',
configuredUser: 'app',
serverAddress: '10.0.0.12',
serverPort: '5432',
}
const isolatedDatabase = {
configuredHost: 'restore.service',
configuredPort: '5433',
configuredDatabase: 'gongxiangp2_restore_fixture01',
configuredUser: 'restore_operator',
serverAddress: '10.20.0.20',
serverPort: '5433',
}
const boundedSummary = {
schemaVersion: 1,
sampleLimit: 32,
materials: { count: '999', sampleSha256InputMd5: 'a'.repeat(32) },
users: { count: '12', sampleSha256InputMd5: 'b'.repeat(32) },
systemSettings: { count: '1', sampleSha256InputMd5: 'c'.repeat(32) },
prismaMigrations: { count: '24', sampleSha256InputMd5: 'd'.repeat(32) },
productPackageUploadJobs: { count: '7', sampleSha256InputMd5: 'e'.repeat(32) },
}
const summaryHash = verifiedBackupSummaryHash(boundedSummary)
const receipt = {
schemaVersion: 1,
generatedBy: 'scripts/create-verified-production-backup.mjs',
sourceCommit: commit,
database,
backup: {
format: 'pg_dump-custom',
startedAt: '2026-08-12T01:55:00.000Z',
completedAt: '2026-08-12T02:00:00.000Z',
artifactPath: '/backup/materials.dump',
artifactSize: 1234,
artifactSha256: 'c'.repeat(64),
},
restoreValidation: {
status: 'passed',
startedAt: '2026-08-12T02:01:00.000Z',
completedAt: '2026-08-12T02:10:00.000Z',
artifactSha256: 'c'.repeat(64),
databaseFingerprint: databaseFingerprintHash(database),
isolatedDatabase,
isolatedDatabaseFingerprint: databaseFingerprintHash(isolatedDatabase),
verificationSummary: {
schemaVersion: 1,
maintenanceWindowConfirmation: 'NO_WRITES_DURING_SNAPSHOT',
statementTimeoutMs: 30_000,
sampleLimit: 32,
sourceBefore: boundedSummary,
sourceAfter: boundedSummary,
restored: boundedSummary,
sourceSummarySha256: summaryHash,
restoredSummarySha256: summaryHash,
},
},
}
const context = { expectedCommit: commit, database, maxAgeMinutes: 240 }
assert.doesNotThrow(() => assertBackupReceiptDocument(
receipt,
context,
new Date('2026-08-12T03:00:00.000Z'),
))
assert.throws(() => assertBackupReceiptDocument(
{ ...receipt, sourceCommit: 'd'.repeat(40) },
context,
new Date('2026-08-12T03:00:00.000Z'),
), /sourceCommit/)
assert.throws(() => assertBackupReceiptDocument(
receipt,
context,
new Date('2026-08-13T03:00:00.000Z'),
), /stale/)
assert.throws(() => assertBackupReceiptDocument(
{ ...receipt, restoreValidation: { ...receipt.restoreValidation, status: 'not-tested' } },
context,
new Date('2026-08-12T03:00:00.000Z'),
), /restore validation/)
assert.throws(() => assertBackupReceiptDocument(
{ ...receipt, generatedBy: undefined },
context,
new Date('2026-08-12T03:00:00.000Z'),
), /machine-generated/)
assert.throws(() => assertBackupReceiptDocument(
{
...receipt,
restoreValidation: {
...receipt.restoreValidation,
verificationSummary: {
...receipt.restoreValidation.verificationSummary,
restored: {
...boundedSummary,
materials: { ...boundedSummary.materials, count: '1000' },
},
},
},
},
context,
new Date('2026-08-12T03:00:00.000Z'),
), /summaries do not match/)
})
test('active uploadId idempotency index requires exact unique btree column and predicate', () => {
const index = {
tablename: 'materials',
indexname: 'materials_uploadId_key',
indisunique: true,
indisprimary: false,
indisvalid: true,
indisready: true,
access_method: 'btree',
columns: ['"uploadId"'],
predicate: '(("uploadId" IS NOT NULL) AND ("deletedAt" IS NULL))',
}
assert.doesNotThrow(() => assertExactActiveUploadIdIndex(index))
assert.throws(
() => assertExactActiveUploadIdIndex({ ...index, indisunique: false }),
/active-only uploadId unique btree/,
)
assert.throws(
() => assertExactActiveUploadIdIndex({ ...index, predicate: '("uploadId" IS NOT NULL)' }),
/active-only uploadId unique btree/,
)
assert.throws(
() => assertExactActiveUploadIdIndex({ ...index, columns: ['lower("uploadId")'] }),
/active-only uploadId unique btree/,
)
})
test('SOP extracts the exact receipt basename and hash from a real helper result', async () => {
const fixture = mkdtempSync(path.join(os.tmpdir(), 'gx-backup-sop-extract-'))
try {
const backupDir = path.join(fixture, 'batch')
mkdirSync(backupDir)
const dumpName = 'gongxiangp2-fixture.dump'
const receiptName = 'gongxiangp2-fixture.receipt.json'
const dumpBytes = Buffer.from('PGDMP-streamed-sop-fixture')
const dumpHash = createHash('sha256').update(dumpBytes).digest('hex')
const receipt = {
backup: {
artifactPath: `/backup/${dumpName}`,
artifactSize: dumpBytes.length,
artifactSha256: dumpHash,
},
}
const receiptBytes = Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`)
const receiptHash = createHash('sha256').update(receiptBytes).digest('hex')
writeFileSync(path.join(backupDir, dumpName), dumpBytes)
writeFileSync(path.join(backupDir, receiptName), receiptBytes)
const result = {
artifactPath: `/backup/${dumpName}`,
artifactSize: dumpBytes.length,
artifactSha256: dumpHash,
receiptPath: `/backup/${receiptName}`,
receiptSize: receiptBytes.length,
receiptSha256: receiptHash,
}
const resultPath = path.join(fixture, 'verified-backup-result.txt')
writeFileSync(
resultPath,
`${JSON.stringify(result, null, 2)}\nProduction backup was created, restored to the isolated server and verified.\n`,
)
const sop = (await readFile(new URL('../docs/生产数据库安全增量迁移SOP-2026-08-12.md', import.meta.url), 'utf8'))
.replace(/\r\n/g, '\n')
const startMarker = 'node --input-type=commonjs - "$HOST_RESULT_FILE" "$HOST_BACKUP_DIR" <<\'NODE\'\n'
const start = sop.indexOf(startMarker)
assert.ok(start >= 0, 'SOP evidence extraction Node block is missing')
const bodyStart = start + startMarker.length
const end = sop.indexOf('\nNODE\n', bodyStart)
assert.ok(end > bodyStart, 'SOP evidence extraction Node block terminator is missing')
const source = sop.slice(bodyStart, end)
const extraction = spawnSync(
process.execPath,
['--input-type=commonjs', '-', resultPath, backupDir],
{ input: source, encoding: 'utf8' },
)
assert.equal(extraction.status, 0, extraction.stderr)
assert.deepEqual(extraction.stdout.trim().split(/\r?\n/), [receiptName, receiptHash])
} finally {
rmSync(fixture, { recursive: true, force: true })
}
})
test('target evidence rejects nullable logo columns and structurally wrong indexes', () => {
const migrations = new Map([
['20260811_add_video_transcode_lease', { checksum: 'a' }],
['20260811_update_brand_logo_defaults', { checksum: 'b' }],
['20260812_add_product_package_upload_job_lease', { checksum: 'c' }],
['20260812_align_material_upload_idempotency_index', { checksum: 'd' }],
['20260813_add_material_custody_department', { checksum: 'e' }],
['20260813_add_organization_units_and_employment_state', { checksum: 'f' }],
['20260813_add_user_employee_binding', { checksum: 'g' }],
['20260817_add_material_is_incomplete_shell', { checksum: 'h' }],
['20260820_add_ding_workflow_snapshots', { checksum: 'i' }],
['20260824_expand_ding_workflow_insights', { checksum: 'j' }],
['20260826_add_ding_workflow_demand_type', { checksum: 'k' }],
['20260827_add_ding_workflow_detail_fields', { checksum: 'l' }],
['20260826_add_pack_video_early_access', { checksum: 'm' }],
['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }],
['20260828_add_task_detail_change_approvals', { checksum: 'o' }],
['20260829_add_recoverable_api_key_secret', { checksum: 'p' }],
['20260831_add_private_task_detail', { checksum: 'q' }],
['20260902_add_employee_roster_history', { checksum: 'r' }],
['20260903_add_work_review_brand', { checksum: 's' }],
['20260908_add_brand_knowledge', { checksum: 't' }],
['20260909_expand_brand_knowledge_upload', { checksum: 'u' }],
['20260914_add_brand_knowledge_engagement', { checksum: 'v' }],
['20260914_expand_brand_knowledge_download_count', { checksum: 'w' }],
['20260914_expand_brand_knowledge_preview_slices', { checksum: 'x' }],
['20260915_add_brand_knowledge_soft_delete', { checksum: 'y' }],
['20260918_add_performance_conversion_sync', { checksum: 'z' }],
])
const evidence = {
migrationRows: [
{ migration_name: '20260811_add_video_transcode_lease', checksum: 'a', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260811_update_brand_logo_defaults', checksum: 'b', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260812_add_product_package_upload_job_lease', checksum: 'c', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260812_align_material_upload_idempotency_index', checksum: 'd', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260813_add_material_custody_department', checksum: 'e', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260813_add_organization_units_and_employment_state', checksum: 'f', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260813_add_user_employee_binding', checksum: 'g', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260817_add_material_is_incomplete_shell', checksum: 'h', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260820_add_ding_workflow_snapshots', checksum: 'i', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260824_expand_ding_workflow_insights', checksum: 'j', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260826_add_ding_workflow_demand_type', checksum: 'k', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260827_add_ding_workflow_detail_fields', checksum: 'l', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260826_add_pack_video_early_access', checksum: 'm', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260828_add_task_detail_change_approvals', checksum: 'o', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260829_add_recoverable_api_key_secret', checksum: 'p', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260831_add_private_task_detail', checksum: 'q', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260902_add_employee_roster_history', checksum: 'r', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260903_add_work_review_brand', checksum: 's', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260908_add_brand_knowledge', checksum: 't', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260909_expand_brand_knowledge_upload', checksum: 'u', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260914_add_brand_knowledge_engagement', checksum: 'v', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260914_expand_brand_knowledge_download_count', checksum: 'w', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260914_expand_brand_knowledge_preview_slices', checksum: 'x', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260915_add_brand_knowledge_soft_delete', checksum: 'y', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260918_add_performance_conversion_sync', checksum: 'z', finished_at: 'now', rolled_back_at: null },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'transcode_lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'uploadId', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'task_detail_demand_type', data_type: 'text', is_nullable: 'YES', column_default: null },
{ table_name: 'materials', column_name: 'task_detail_private', data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
{ table_name: 'material_permission_pack_items', column_name: 'can_access_private', data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
{ table_name: 'api_keys', column_name: 'secret_ciphertext', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_keys', column_name: 'secret_nonce', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_keys', column_name: 'secret_auth_tag', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_key_reveal_audits', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'api_key_reveal_audits', column_name: 'api_key_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'api_key_reveal_audits', column_name: 'viewer_user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'api_key_reveal_audits', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'materials', column_name: 'custody_department_id', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'custody_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'custody_reason', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'is_incomplete_shell', data_type: 'boolean', is_nullable: 'NO' },
{ table_name: 'product_package_upload_jobs', column_name: 'worker_id', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'product_package_upload_jobs', column_name: 'lease_token', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'product_package_upload_jobs', column_name: 'lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'organization_units', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'organization_units', column_name: 'source_dept_id', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'organization_units', column_name: 'parent_id', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'organization_units', column_name: 'path', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'organization_units', column_name: 'level', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'organization_units', column_name: 'is_active', data_type: 'boolean', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_profiles', column_name: 'employment_state', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_profiles', column_name: 'resigned_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_profiles', column_name: 'handover_dingtalk_user_id', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'captured_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'profile_count', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'employee_profile_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'dingtalk_user_id', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'name', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'department_name', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'department_path', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'employment_state', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'is_brand_center', data_type: 'boolean', is_nullable: 'NO' },
{ table_name: 'performance_survey_employee_roster_snapshots', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'employee_profile_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'binding_source', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'process_instance_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'is_ongoing', data_type: 'boolean', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'source_updated_at_raw', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'scheme_field_count', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'task_count', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'active_task_count', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'synced_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'ding_workflow_snapshots', column_name: 'brand', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'demand_type', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'requester_name', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'requester_department', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'project_type', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'active_tasks', data_type: 'jsonb', is_nullable: 'YES' },
{ table_name: 'ding_workflow_snapshots', column_name: 'form_fields', data_type: 'jsonb', is_nullable: 'YES' },
{ table_name: 'ding_workflow_sync_states', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'ding_workflow_sync_states', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'ding_workflow_sync_states', column_name: 'source_cursor_raw', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_sync_states', column_name: 'last_completed_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'ding_workflow_sync_states', column_name: 'lease_owner', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'ding_workflow_sync_states', column_name: 'lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'ding_workflow_sync_states', column_name: 'current_ongoing_count', data_type: 'integer', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'material_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'requester_user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'manager_dingtalk_user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'operations', data_type: 'jsonb', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'base_material_updated_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'task_detail_change_requests', column_name: 'expires_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'request_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'event_type', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'business_key', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'next_retry_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'title', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'knowledge_type', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'brand', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'product_category_name', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'file_name', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'file_path', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'mime_type', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'file_size', data_type: 'bigint', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'download_count', data_type: 'integer', is_nullable: 'NO', column_default: '0' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_name', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_path', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_slices', data_type: 'ARRAY', is_nullable: 'NO', column_default: 'ARRAY[]::text[]' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_mime_type', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_size', data_type: 'bigint', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'uploaded_by', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'updated_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'deleted_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
...['brand_knowledge_likes', 'brand_knowledge_favorites'].flatMap((table_name) => [
{ table_name, column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name, column_name: 'knowledge_id', data_type: 'text', is_nullable: 'NO' },
{ table_name, column_name: 'user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name, column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
]),
...[
['id', 'text', 'NO'], ['source_key', 'text', 'NO'], ['source_row_id', 'text', 'YES'],
['product_id', 'text', 'NO'], ['metric_date', 'date', 'NO'],
['product_name', 'text', 'YES'], ['source_brand', 'text', 'YES'], ['category1', 'text', 'YES'],
['category2', 'text', 'YES'], ['category3', 'text', 'YES'], ['payment_amount', 'text', 'YES'],
['payment_users', 'text', 'YES'], ['cart_users', 'text', 'YES'], ['visitors', 'text', 'YES'],
['conversion_rate', 'text', 'YES'], ['favorites', 'text', 'YES'], ['impressions', 'text', 'YES'],
['clicks', 'text', 'YES'], ['click_rate', 'text', 'YES'], ['promotion_spend', 'text', 'YES'],
['promotion_revenue', 'text', 'YES'], ['roi', 'text', 'YES'],
['synced_at', 'timestamp without time zone', 'NO'], ['created_at', 'timestamp without time zone', 'NO'],
['updated_at', 'timestamp without time zone', 'NO'],
].map(([column_name, data_type, is_nullable]) => ({
table_name: 'performance_conversion_metric_snapshots', column_name, data_type, is_nullable,
})),
...[
['id', 'text', 'NO', undefined], ['status', 'text', 'NO', "'idle'::text"], ['source_cursor_raw', 'text', 'YES', undefined],
['last_started_at', 'timestamp without time zone', 'YES'], ['last_completed_at', 'timestamp without time zone', 'YES'],
['last_error', 'text', 'YES'], ['lease_owner', 'text', 'YES'],
['lease_expires_at', 'timestamp without time zone', 'YES'], ['rows_upserted', 'integer', 'NO', '0'],
['products_synced', 'integer', 'NO', '0'], ['created_at', 'timestamp without time zone', 'NO'],
['updated_at', 'timestamp without time zone', 'NO'],
].map(([column_name, data_type, is_nullable, column_default]) => ({
table_name: 'performance_conversion_sync_states', column_name, data_type, is_nullable, column_default,
})),
{ table_name: 'system_settings', column_name: 'headerLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
{ table_name: 'system_settings', column_name: 'loginLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/login-logo.png'::text" },
{ table_name: 'system_settings', column_name: 'logoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
],
indexes: [
{ indexname: 'materials_transcode_lease_idx', indexdef: 'CREATE INDEX materials_transcode_lease_idx ON public.materials USING btree (transcode_lease_expires_at, transcode_lease_token)' },
{
tablename: 'materials',
indexname: 'materials_uploadId_key',
indexdef: 'CREATE UNIQUE INDEX "materials_uploadId_key" ON public.materials USING btree ("uploadId") WHERE (("uploadId" IS NOT NULL) AND ("deletedAt" IS NULL))',
indisunique: true,
indisprimary: false,
indisvalid: true,
indisready: true,
access_method: 'btree',
columns: ['"uploadId"'],
predicate: '(("uploadId" IS NOT NULL) AND ("deletedAt" IS NULL))',
},
{ indexname: 'product_package_upload_jobs_lease_idx', indexdef: 'CREATE INDEX product_package_upload_jobs_lease_idx ON public.product_package_upload_jobs USING btree (status, lease_expires_at)' },
{ indexname: 'materials_is_incomplete_shell_idx', indexdef: 'CREATE INDEX materials_is_incomplete_shell_idx ON public.materials USING btree (is_incomplete_shell)' },
{ indexname: 'ding_workflow_snapshots_ongoing_updated_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_ongoing_updated_idx ON public.ding_workflow_snapshots USING btree (is_ongoing, source_updated_at_raw)' },
{ indexname: 'ding_workflow_snapshots_brand_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_brand_idx ON public.ding_workflow_snapshots USING btree (brand)' },
{ indexname: 'ding_workflow_snapshots_demand_type_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_demand_type_idx ON public.ding_workflow_snapshots USING btree (demand_type)' },
{ indexname: 'ding_workflow_snapshots_requester_department_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_requester_department_idx ON public.ding_workflow_snapshots USING btree (requester_department)' },
{ indexname: 'ding_workflow_sync_states_lease_idx', indexdef: 'CREATE INDEX ding_workflow_sync_states_lease_idx ON public.ding_workflow_sync_states USING btree (lease_expires_at)' },
{ indexname: 'task_detail_change_requests_status_expires_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_expires_idx ON public.task_detail_change_requests USING btree (status, expires_at)' },
{ indexname: 'task_detail_change_requests_status_updated_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_updated_idx ON public.task_detail_change_requests USING btree (status, updated_at)' },
{ indexname: 'task_detail_change_notifications_status_retry_idx', indexdef: 'CREATE INDEX task_detail_change_notifications_status_retry_idx ON public.task_detail_change_notifications USING btree (status, next_retry_at)' },
{ indexname: 'api_key_reveal_audits_api_key_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_api_key_id_created_at_idx ON public.api_key_reveal_audits USING btree (api_key_id, created_at)' },
{ indexname: 'api_key_reveal_audits_viewer_user_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_viewer_user_id_created_at_idx ON public.api_key_reveal_audits USING btree (viewer_user_id, created_at)' },
{ indexname: 'brand_knowledge_product_category_name_idx', indexdef: 'CREATE INDEX brand_knowledge_product_category_name_idx ON public.brand_knowledge USING btree (product_category_name)' },
{ indexname: 'brand_knowledge_deleted_at_idx', indexdef: 'CREATE INDEX brand_knowledge_deleted_at_idx ON public.brand_knowledge USING btree (deleted_at)' },
{
tablename: 'performance_conversion_metric_snapshots',
indexname: 'performance_conversion_metric_snapshots_source_key_key',
indexdef: 'CREATE UNIQUE INDEX performance_conversion_metric_snapshots_source_key_key ON public.performance_conversion_metric_snapshots USING btree (source_key)',
indisunique: true,
indisprimary: false,
indisvalid: true,
indisready: true,
access_method: 'btree',
columns: ['source_key'],
predicate: '',
},
{ indexname: 'performance_conversion_metric_product_date_idx', indexdef: 'CREATE INDEX performance_conversion_metric_product_date_idx ON public.performance_conversion_metric_snapshots USING btree (product_id, metric_date)' },
{ indexname: 'performance_conversion_metric_date_idx', indexdef: 'CREATE INDEX performance_conversion_metric_date_idx ON public.performance_conversion_metric_snapshots USING btree (metric_date)' },
{ indexname: 'performance_conversion_sync_states_lease_idx', indexdef: 'CREATE INDEX performance_conversion_sync_states_lease_idx ON public.performance_conversion_sync_states USING btree (lease_expires_at)' },
...['brand_knowledge_likes', 'brand_knowledge_favorites'].flatMap((table) => [
{
tablename: table,
indexname: `${table}_knowledge_id_user_id_key`,
indexdef: `CREATE UNIQUE INDEX ${table}_knowledge_id_user_id_key ON public.${table} USING btree (knowledge_id, user_id)`,
indisunique: true,
indisprimary: false,
indisvalid: true,
indisready: true,
access_method: 'btree',
columns: ['knowledge_id', 'user_id'],
predicate: '',
},
{ indexname: `${table}_knowledge_id_idx`, indexdef: `CREATE INDEX ${table}_knowledge_id_idx ON public.${table} USING btree (knowledge_id)` },
{ indexname: `${table}_user_id_idx`, indexdef: `CREATE INDEX ${table}_user_id_idx ON public.${table} USING btree (user_id)` },
]),
],
constraints: [
{
tablename: 'materials',
constraint_name: 'materials_private_optimization_check',
contype: 'c',
convalidated: true,
columns: ['task_detail_private', 'materialType', 'task_detail_demand_type'],
expression: `((NOT task_detail_private) OR (((materialType = '任务-详情'::text) AND (task_detail_demand_type = '优化'::text)) IS TRUE))`,
},
...['brand_knowledge_likes', 'brand_knowledge_favorites'].flatMap((table) => [
{
tablename: table,
constraint_name: `${table}_knowledge_id_fkey`,
contype: 'f',
convalidated: true,
columns: ['knowledge_id'],
referenced_table: 'brand_knowledge',
referenced_columns: ['id'],
},
{
tablename: table,
constraint_name: `${table}_user_id_fkey`,
contype: 'f',
convalidated: true,
columns: ['user_id'],
referenced_table: 'users',
referenced_columns: ['id'],
},
]),
],
brandRows: [
{ id: 'local', headerLogoUrl: '/brand/header-logo.png', loginLogoUrl: '/brand/login-logo.png' },
{ id: 'custom', headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/header.png', loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/login.png' },
],
}
assert.doesNotThrow(() => assertTargetMigrationEvidence(migrations, evidence))
const nullableLogo = {
...evidence,
columns: evidence.columns.map((column) => column.column_name === 'headerLogoUrl'
? { ...column, is_nullable: 'YES' }
: column),
}
assert.throws(() => assertTargetMigrationEvidence(migrations, nullableLogo), /column mismatch/)
const uniqueIndex = {
...evidence,
indexes: evidence.indexes.map((index) => index.indexname === 'materials_transcode_lease_idx'
? { ...index, indexdef: index.indexdef.replace('CREATE INDEX', 'CREATE UNIQUE INDEX') }
: index),
}
assert.throws(() => assertTargetMigrationEvidence(migrations, uniqueIndex), /non-unique/)
const wrongUploadIdPredicate = {
...evidence,
indexes: evidence.indexes.map((index) => index.indexname === 'materials_uploadId_key'
? { ...index, predicate: '("uploadId" IS NOT NULL)' }
: index),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, wrongUploadIdPredicate),
/active-only uploadId unique btree/,
)
const missingOrganizationColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'organization_units' && column.column_name === 'source_dept_id'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingOrganizationColumn),
/organization_units\.source_dept_id/,
)
const missingIncompleteShellColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'materials' && column.column_name === 'is_incomplete_shell'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingIncompleteShellColumn),
/materials\.is_incomplete_shell/,
)
const wrongTaskDetailPrivateDefault = {
...evidence,
columns: evidence.columns.map((column) => column.table_name === 'materials' && column.column_name === 'task_detail_private'
? { ...column, column_default: 'true' }
: column),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, wrongTaskDetailPrivateDefault),
/materials\.task_detail_private/,
)
const wrongDownloadCountDefault = {
...evidence,
columns: evidence.columns.map((column) => column.table_name === 'brand_knowledge' && column.column_name === 'download_count'
? { ...column, column_default: null }
: column),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, wrongDownloadCountDefault),
/brand_knowledge\.download_count/,
)
const wrongPreviewSlicesDefault = {
...evidence,
columns: evidence.columns.map((column) => column.table_name === 'brand_knowledge' && column.column_name === 'preview_image_slices'
? { ...column, column_default: null }
: column),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, wrongPreviewSlicesDefault),
/brand_knowledge\.preview_image_slices/,
)
const unexpectedTaskDetailDemandDefault = {
...evidence,
columns: evidence.columns.map((column) => column.table_name === 'materials' && column.column_name === 'task_detail_demand_type'
? { ...column, column_default: "'优化'::text" }
: column),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, unexpectedTaskDetailDemandDefault),
/materials\.task_detail_demand_type/,
)
const missingPrivateAccessColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'material_permission_pack_items' && column.column_name === 'can_access_private'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingPrivateAccessColumn),
/material_permission_pack_items\.can_access_private/,
)
const missingDemandTypeColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'ding_workflow_snapshots' && column.column_name === 'demand_type'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingDemandTypeColumn),
/ding_workflow_snapshots\.demand_type/,
)
const missingRecoverableApiKeyColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'api_keys' && column.column_name === 'secret_ciphertext'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingRecoverableApiKeyColumn),
/api_keys\.secret_ciphertext/,
)
const missingFormFieldsColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'ding_workflow_snapshots' && column.column_name === 'form_fields'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingFormFieldsColumn),
/ding_workflow_snapshots\.form_fields/,
)
const missingRevealAuditIndex = {
...evidence,
indexes: evidence.indexes.filter((index) => index.indexname !== 'api_key_reveal_audits_api_key_id_created_at_idx'),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingRevealAuditIndex),
/api_key_reveal_audits/,
)
const missingPerformanceSyncIndex = {
...evidence,
indexes: evidence.indexes.filter((index) => index.indexname !== 'performance_conversion_sync_states_lease_idx'),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingPerformanceSyncIndex),
/performance_conversion_sync_states/,
)
const wrongPrivateConstraint = {
...evidence,
constraints: evidence.constraints.map((constraint) => constraint.constraint_name === 'materials_private_optimization_check'
? { ...constraint, expression: `((NOT task_detail_private) OR ((materialType = '任务-详情'::text) AND (task_detail_demand_type = '优化'::text)))` }
: constraint),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, wrongPrivateConstraint),
/materials_private_optimization_check/,
)
const legacyStoredLogo = {
...evidence,
brandRows: [{
id: 'legacy',
headerLogoUrl: 'https://s3.1080p.hk/jsxiangche/header-logo.png',
loginLogoUrl: '/brand/login-logo.png',
}],
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, legacyStoredLogo),
/Stored brand logo is untrusted/,
)
})
test('container supply chain and migrator image are immutable while SOP keeps complete gates', async () => {
const [dockerfile, sop, incidentSop, migratorSource, backupSource, uploadIdMigration, dockerIgnore, deploymentSource] = await Promise.all([
readFile(new URL('../Dockerfile', import.meta.url), 'utf8'),
readFile(new URL('../docs/生产数据库安全增量迁移SOP-2026-08-12.md', import.meta.url), 'utf8'),
readFile(new URL('../docs/视频预览任务中断事故复盘-2026-08-11.md', import.meta.url), 'utf8'),
readFile(new URL('./safe-production-migrate.mjs', import.meta.url), 'utf8'),
readFile(new URL('./create-verified-production-backup.mjs', import.meta.url), 'utf8'),
readFile(new URL('../prisma/migrations/20260812_align_material_upload_idempotency_index/migration.sql', import.meta.url), 'utf8'),
readFile(new URL('../.dockerignore', import.meta.url), 'utf8'),
readFile(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8'),
])
const migratorStart = dockerfile.indexOf('FROM base AS migrator')
const runnerStart = dockerfile.indexOf('FROM base AS runner', migratorStart)
const migrator = dockerfile.slice(migratorStart, runnerStart)
const depsStart = dockerfile.indexOf('FROM base AS deps')
const builderStart = dockerfile.indexOf('FROM base AS builder', depsStart)
const deps = dockerfile.slice(depsStart, builderStart)
assert.match(
dockerfile,
/^ARG BASE_IMAGE=node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e$/m,
)
assert.match(deps, /^COPY package\.json package-lock\.json \.\/$/m)
assert.match(deps, /npm ci --prefer-offline --no-audit --no-fund --legacy-peer-deps/)
assert.match(deps, /npm_config_build_from_source=canvas npm_config_nodedir=\/usr\/local npm ci/)
assert.doesNotMatch(deps, /--omit=optional/)
assert.match(deps, /const sharp=require\('sharp'\)/)
assert.doesNotMatch(deps, /\bnpm install\b|writeFileSync|delete pkg\.optionalDependencies/)
assert.match(deps, /lock_sha="\$\(sha256sum package-lock\.json/)
assert.match(deps, /test "\$lock_sha" = "\$\(sha256sum package-lock\.json/)
assert.ok(migrator.includes('LABEL org.opencontainers.image.revision=$VCS_REF'))
assert.match(migrator, /apk add --no-cache openssl postgresql-client/)
assert.match(migrator, /command -v openssl >\/dev\/null/)
assert.match(migrator, /command -v pg_dump >\/dev\/null/)
assert.match(migrator, /command -v pg_restore >\/dev\/null/)
assert.match(migrator, /command -v psql >\/dev\/null/)
assert.match(migrator, /COPY --from=builder \/app\/scripts\/create-verified-production-backup\.mjs/)
assert.match(migrator, /test -x \/app\/node_modules\/prisma\/node_modules\/@prisma\/engines\/schema-engine-linux-musl/)
assert.match(migrator, /chown -R node:node \/app\/node_modules\/prisma\/node_modules\/@prisma\/engines/)
assert.match(migrator, /chmod -R a\+rX,a-w \/app\/prisma \/app\/scripts \/app\/DEPLOYMENT_COMMIT/)
assert.match(migrator, /USER node/)
assert.match(sop, /tar -xOf "\$MIGRATOR_ARCHIVE" manifest\.json/)
assert.match(sop, /entry\.RepoTags\.includes\(image\)/)
assert.match(sop, /\^blobs\\\/sha256\\\/\(\[0-9a-f\]\{64\}\)\$/)
const scriptsExcludeIndex = dockerIgnore.lastIndexOf('scripts/**')
assert.ok(scriptsExcludeIndex >= 0)
assert.ok(dockerIgnore.lastIndexOf('!scripts/safe-production-migrate.mjs') > scriptsExcludeIndex)
assert.ok(dockerIgnore.lastIndexOf('!scripts/create-verified-production-backup.mjs') > scriptsExcludeIndex)
assert.match(dockerfile, /20260812_align_material_upload_idempotency_index/)
assert.match(migratorSource, /'20260812_align_material_upload_idempotency_index'/)
for (const migrationName of [
'20260813_add_material_custody_department',
'20260813_add_organization_units_and_employment_state',
'20260813_add_user_employee_binding',
'20260817_add_material_is_incomplete_shell',
'20260820_add_ding_workflow_snapshots',
'20260824_expand_ding_workflow_insights',
'20260826_add_ding_workflow_demand_type',
'20260827_add_ding_workflow_detail_fields',
'20260826_add_pack_video_early_access',
'20260828_add_pack_item_can_edit_assets',
'20260828_add_task_detail_change_approvals',
'20260831_add_private_task_detail',
'20260902_add_employee_roster_history',
'20260903_add_work_review_brand',
'20260908_add_brand_knowledge',
'20260909_expand_brand_knowledge_upload',
'20260914_add_brand_knowledge_engagement',
'20260914_expand_brand_knowledge_download_count',
'20260914_expand_brand_knowledge_preview_slices',
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
]) {
assert.match(dockerfile, new RegExp(migrationName))
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
assert.ok(sop.includes('`' + migrationName + '`'))
}
assert.match(dockerfile, /20260829_add_recoverable_api_key_secret/)
assert.match(migratorSource, /'20260829_add_recoverable_api_key_secret'/)
assert.match(deploymentSource, /'20260829_add_recoverable_api_key_secret'/)
assert.ok(sop.includes('`20260829_add_recoverable_api_key_secret`'))
assert.match(deploymentSource, /'20260831_add_private_task_detail'/)
assert.ok(sop.includes('`20260831_add_private_task_detail`'))
assert.match(deploymentSource, /'20260902_add_employee_roster_history'/)
assert.ok(sop.includes('`20260902_add_employee_roster_history`'))
assert.match(deploymentSource, /'20260903_add_work_review_brand'/)
assert.ok(sop.includes('`20260903_add_work_review_brand`'))
assert.match(deploymentSource, /'20260908_add_brand_knowledge'/)
assert.ok(sop.includes('`20260908_add_brand_knowledge`'))
assert.match(deploymentSource, /'20260909_expand_brand_knowledge_upload'/)
assert.ok(sop.includes('`20260909_expand_brand_knowledge_upload`'))
assert.match(deploymentSource, /'20260914_add_brand_knowledge_engagement'/)
assert.ok(sop.includes('`20260914_add_brand_knowledge_engagement`'))
assert.match(deploymentSource, /'20260914_expand_brand_knowledge_download_count'/)
assert.ok(sop.includes('`20260914_expand_brand_knowledge_download_count`'))
assert.match(deploymentSource, /'20260914_expand_brand_knowledge_preview_slices'/)
assert.ok(sop.includes('`20260914_expand_brand_knowledge_preview_slices`'))
assert.match(deploymentSource, /'20260915_add_brand_knowledge_soft_delete'/)
assert.ok(sop.includes('`20260915_add_brand_knowledge_soft_delete`'))
assert.match(deploymentSource, /'20260918_add_performance_conversion_sync'/)
assert.ok(sop.includes('`20260918_add_performance_conversion_sync`'))
const indexesQueryStart = migratorSource.indexOf('const indexes = await prisma.$queryRawUnsafe(')
const indexesQueryEnd = migratorSource.indexOf('const constraints = await prisma.$queryRawUnsafe(', indexesQueryStart)
assert.ok(indexesQueryStart >= 0)
assert.ok(indexesQueryEnd > indexesQueryStart)
assert.match(
migratorSource.slice(indexesQueryStart, indexesQueryEnd),
/'brand_knowledge_product_category_name_idx'/,
)
assert.match(
migratorSource.slice(indexesQueryStart, indexesQueryEnd),
/'brand_knowledge_deleted_at_idx'/,
)
assert.match(
migratorSource.slice(indexesQueryStart, indexesQueryEnd),
/'brand_knowledge_likes_knowledge_id_user_id_key'/,
)
assert.match(sop, /materials_private_optimization_check/)
assert.match(sop, /task_detail_private/)
assert.match(sop, /can_access_private/)
assert.match(backupSource, /'--format=custom'/)
assert.match(backupSource, /'--serializable-deferrable'/)
assert.match(backupSource, /\.dump\.partial-/)
assert.match(backupSource, /ALLOW_VERIFIED_BACKUP/)
assert.match(backupSource, /ALLOW_ISOLATED_DATABASE_RESET/)
assert.match(uploadIdMigration, /^BEGIN;$/m)
assert.match(uploadIdMigration, /GROUP BY "uploadId"\s+HAVING COUNT\(\*\) > 1/)
assert.match(uploadIdMigration, /DROP INDEX IF EXISTS "materials_uploadId_key"/)
assert.match(
uploadIdMigration,
/CREATE UNIQUE INDEX "materials_uploadId_key"\s+ON "materials" USING btree \("uploadId"\)\s+WHERE "uploadId" IS NOT NULL AND "deletedAt" IS NULL/,
)
assert.doesNotMatch(uploadIdMigration, /\b(?:DELETE|UPDATE|TRUNCATE)\b/i)
assert.ok(sop.includes('mktemp -d'))
assert.ok(sop.includes('git clone --no-checkout'))
assert.ok(!sop.includes('git reset --hard'))
assert.ok(!sop.includes('git clean -fdx'))
for (const name of [
'EXPECTED_DATABASE_PORT',
'EXPECTED_DATABASE_SERVER_PORT',
'BACKUP_ARTIFACT_ROOT',
'BACKUP_MAX_AGE_MINUTES',
]) {
assert.ok(sop.includes(name), `SOP is missing ${name}`)
}
assert.ok(sop.includes('install -d -m 0750'))
assert.match(sop, /legacy bootstrap `Audit`/)
assert.match(sop, /legacy bootstrap `Apply`,外部验收 503/)
assert.match(sop, /旧容器已停止但仍保留可回退/)
assert.match(sop, /顺序不得改为“先 migration,后 bootstrap”/)
assert.match(sop, /CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT/)
assert.match(sop, /-SkipDeploy` 只构建并推送 runner,\*\*不会\*\*构建 migrator/)
assert.match(sop, /docker save --output "\$MIGRATOR_ARCHIVE"/)
assert.match(sop, /StrictHostKeyChecking=yes/)
assert.match(sop, /docker load --input "\$archive"/)
assert.match(sop, /docker image inspect --format '\{\{\.Id\}\}' "\$image"/)
assert.match(sop, /MigrationAuditFile/)
assert.match(sop, /MigrationAuditSha256/)
assert.match(sop, /RequireMigrationAudit/)
assert.match(incidentSop, /migrator 必须按《生产数据库安全增量迁移 SOP/)
assert.match(incidentSop, /-MigrationAuditFile/)
assert.match(incidentSop, /-MigrationAuditSha256/)
assert.match(incidentSop, /-RequireMigrationAudit/)
assert.match(sop, /receiptNames\.length !== 1 \|\| dumpNames\.length !== 1 \|\| entries\.length !== 2/)
assert.match(sop, /entry\.isFile\(\) && entry\.name\.endsWith\('\.receipt\.json'\)/)
assert.match(sop, /entry\.isFile\(\) && entry\.name\.endsWith\('\.dump'\)/)
assert.match(sop, /stat\.isSymbolicLink\(\)/)
assert.match(sop, /result\.receiptSha256 !== receiptSha256/)
assert.match(sop, /receipt\.backup\.artifactSha256 !== result\.artifactSha256/)
assert.match(sop, /resultText\.slice\(0, markerIndex\)\.trim\(\)/)
assert.doesNotMatch(sop, /lastIndexOf\('\\n\{'/)
assert.match(sop, /fs\.createReadStream\(dumpPath\)/)
assert.match(sop, /result\.artifactSize !== dumpStat\.size/)
assert.doesNotMatch(sop, /fs\.readFileSync\(dumpPath\)/)
assert.match(sop, /BACKUP_RECEIPT_BASENAME="\$\{BACKUP_EVIDENCE\[0\]\}"/)
assert.match(sop, /BACKUP_RECEIPT_PATH="\/backup\/\$BACKUP_RECEIPT_BASENAME"/)
assert.match(sop, /-v "\$HOST_BACKUP_DIR:\/backup:ro"/)
assert.doesNotMatch(sop, /BACKUP_RECEIPT_PATH=\/backup\/receipt\.json/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-db-backup-receipt:\/backup:ro/)
assert.match(sop, /PREFLIGHT_AUDIT_FILE="\$HOST_RESULT_DIR\/preflight-/)
assert.match(sop, /MIGRATION_AUDIT_OUTPUT="\/audit\/\$\(basename "\$PREFLIGHT_AUDIT_FILE"\)"/)
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
assert.match(sop, /二十六份目标 migration/)
assert.match(sop, /二十六条目标 migration/)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)
const postIdentity = migratorSource.indexOf('assertExpectedDatabaseIdentity(databaseUrl, after.identity)', postEvidence)
const compareIdentity = migratorSource.indexOf('Production database identity changed while migrations were being applied', postIdentity)
const writeAudit = migratorSource.indexOf('await writeAuditRecord(record)', compareIdentity)
assert.ok(applyMigration > 0)
assert.ok(postEvidence > applyMigration)
assert.ok(postIdentity > postEvidence)
assert.ok(compareIdentity > postIdentity)
assert.ok(writeAudit > compareIdentity)
})