570 lines
28 KiB
JavaScript
570 lines
28 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`)
|
||
const run = (args) => spawnSync(
|
||
process.execPath,
|
||
[path.join(scripts, 'safe-production-migrate.mjs'), ...args],
|
||
{
|
||
cwd: fixture,
|
||
env: { ...process.env, EXPECTED_COMMIT_SHA: commit, DATABASE_URL: '' },
|
||
encoding: 'utf8',
|
||
},
|
||
)
|
||
for (const args of [['--audit'], ['--apply']]) {
|
||
const result = run(args)
|
||
assert.notEqual(result.status, 0)
|
||
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' }],
|
||
])
|
||
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 },
|
||
],
|
||
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: '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: '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: '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: '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)' },
|
||
],
|
||
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 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] = 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'),
|
||
])
|
||
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(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',
|
||
]) {
|
||
assert.match(dockerfile, new RegExp(migrationName))
|
||
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
|
||
assert.ok(sop.includes('`' + migrationName + '`'))
|
||
}
|
||
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/)
|
||
|
||
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)
|
||
})
|