977 lines
57 KiB
JavaScript
977 lines
57 KiB
JavaScript
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)
|
||
})
|