import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { assertMysql57Metadata, assertNotApplicableMatchesDevelopmentConfiguration, assertCurrentReleasedBaselinePublished, assertSafeDevelopmentConfiguration, buildCapturedArtifacts, buildMysqldumpArguments, buildNotApplicableArtifacts, normalizeSchemaSql, persistBaselineArtifacts, runBaselineGeneration, assertRemoteBranchMatches, verifyBaselineDirectory, } from './schema-baseline.mjs'; const validConfig = Object.freeze({ APP_ENV: 'development', MYSQL_ENABLED: 'true', MYSQL_HOST: '127.0.0.1', MYSQL_PORT: '3306', MYSQL_DATABASE: 'a_share_analysis_dev', MYSQL_USERNAME: 'schema_reader', MYSQL_PASSWORD: 'never-place-this-in-arguments', MYSQL_CHARSET: 'utf8mb4', MYSQL_COLLATION: 'utf8mb4_general_ci', }); const validMetadata = Object.freeze({ applicationVersion: 'v0.0.5', gitCommit: '0a69aa1f6f5cc75d72f21c43d96f5ff648e2ec93', mysqlClientVersion: '5.7.44', mysqldumpClientVersion: '5.7.44', serverVersion: '5.7.44-log', databaseCharset: 'utf8mb4', databaseCollation: 'utf8mb4_general_ci', generatedAt: '2026-08-13T03:00:00.000Z', }); function makeTemporaryDirectory() { return mkdtempSync(path.join(tmpdir(), 'a-share-schema-baseline-')); } function replaceCapturedSql(target, sql) { const checksum = createHash('sha256').update(sql).digest('hex'); const manifestPath = path.join(target, 'manifest.yaml'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); manifest.schema_sha256 = checksum; writeFileSync(path.join(target, 'schema.sql'), sql); writeFileSync(path.join(target, 'checksum.sha256'), `${checksum} schema.sql\n`); writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); } test('拒绝任何非开发环境和非固定开发库配置', () => { assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, APP_ENV: 'production' }), /只允许 development/, ); assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_DATABASE: 'a_share_analysis', }), /a_share_analysis_dev/, ); assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_HOST: 'forbidden-remote.example.invalid', }), /开发机回环地址/, ); }); test('拒绝错误字符集、排序规则和不完整的启用配置', () => { assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_CHARSET: 'utf8' }), /utf8mb4/, ); assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_COLLATION: 'utf8mb4_0900_ai_ci', }), /utf8mb4_general_ci/, ); assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_USERNAME: '' }), /MYSQL_USERNAME/, ); assert.throws( () => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_PASSWORD: '' }), /MYSQL_PASSWORD/, ); }); test('只接受 MySQL 5.7 客户端、服务端和固定数据库字符规则', () => { assert.doesNotThrow(() => assertMysql57Metadata(validMetadata)); assert.throws( () => assertMysql57Metadata({ ...validMetadata, serverVersion: '8.0.39' }), /服务端必须为 5\.7/, ); assert.throws( () => assertMysql57Metadata({ ...validMetadata, mysqlClientVersion: '8.0.39' }), /mysql 客户端必须为 5\.7/, ); assert.throws( () => assertMysql57Metadata({ ...validMetadata, databaseCollation: 'utf8mb4_bin' }), /utf8mb4_general_ci/, ); }); test('mysqldump 只导出完整结构且密码不进入命令参数', () => { const args = buildMysqldumpArguments(validConfig); for (const required of ['--no-data', '--routines', '--triggers', '--events']) { assert.ok(args.includes(required), `缺少强制导出参数:${required}`); } assert.equal(args.at(-1), 'a_share_analysis_dev'); assert.equal(args.join(' ').includes(validConfig.MYSQL_PASSWORD), false); assert.equal(args.some((argument) => argument.startsWith('--password')), false); }); test('规范化移除环境噪声和账号绑定但保留全部业务对象', () => { const fixtureDirectory = path.join(import.meta.dirname, 'fixtures'); const input = readFileSync(path.join(fixtureDirectory, 'mysql57-schema-input.sql'), 'utf8'); const expected = readFileSync( path.join(fixtureDirectory, 'mysql57-schema-expected.sql'), 'utf8', ); const actual = normalizeSchemaSql(input); assert.equal(actual, expected); assert.match(actual, /NOT NULL AUTO_INCREMENT/); assert.doesNotMatch(actual, /AUTO_INCREMENT=128/); assert.doesNotMatch(actual, /DEFINER=`/); for (const objectName of [ 'CREATE TABLE', 'VIEW `active_market_snapshot`', 'TRIGGER `market_snapshot_before_insert`', 'PROCEDURE `refresh_market_snapshot`', 'EVENT `purge_market_snapshot`', ]) { assert.ok(actual.includes(objectName), `规范化误删对象:${objectName}`); } }); test('规范化不改写例程字符串中的业务文本', () => { const sql = [ 'CREATE PROCEDURE `describe_schema`()', "BEGIN SELECT 'DEFINER=`business`@`text` AUTO_INCREMENT=128' AS description; END;", '', ].join('\n'); assert.equal(normalizeSchemaSql(sql), sql); }); test('数据扫描拒绝同行顶层写入但保留例程中的合法 DML', () => { assert.throws( () => normalizeSchemaSql('CREATE TABLE `sample` (`id` int); INSERT INTO `sample` VALUES (1);\n'), /INSERT INTO/, ); const routineSql = [ 'DELIMITER ;;', 'CREATE PROCEDURE `seed_fixture`()', 'BEGIN', 'INSERT INTO `sample` VALUES (1);', 'END ;;', 'DELIMITER ;', '', ].join('\n'); assert.equal(normalizeSchemaSql(routineSql), routineSql); for (const prefixedSql of [ '/* comment */\nINSERT INTO `sample` VALUES (1);\n', '/*!40101 SET @x=1 */ INSERT INTO `sample` VALUES (1);\n', '/*!40101 INSERT INTO `sample` VALUES (1) */;\n', '/*!40101 REPLACE INTO `sample` VALUES (1) */;\n', 'CREATE TABLE `sample` (`id` int); -- comment\nREPLACE INTO `sample` VALUES (1);\n', ]) { assert.throws(() => normalizeSchemaSql(prefixedSql), /INSERT INTO|REPLACE INTO/); } }); test('数据扫描拒绝所有顶层数据修改和装载语句', () => { for (const statement of [ 'UPDATE `sample` SET `id` = 2', 'DELETE FROM `sample`', 'TRUNCATE TABLE `sample`', "LOAD DATA LOCAL INFILE '/tmp/sample.csv' INTO TABLE `sample`", "LOAD XML LOCAL INFILE '/tmp/sample.xml' INTO TABLE `sample`", ]) { assert.throws(() => normalizeSchemaSql(`${statement};\n`), /禁止的数据操作/); } }); test('安全扫描拒绝导出噪声、疑似凭据、连接串和生产主机', () => { for (const unsafeSql of [ '-- API_TOKEN=supersecret\nCREATE TABLE `sample` (`id` int);\n', '-- CLIENT_SECRET: supersecret\nCREATE TABLE `sample` (`id` int);\n', '-- mysql://schema_reader:secret@127.0.0.1/a_share_analysis_dev\nCREATE TABLE `sample` (`id` int);\n', '-- production host: prod-db.internal\nCREATE TABLE `sample` (`id` int);\n', ]) { assert.throws(() => normalizeSchemaSql(unsafeSql), /Schema SQL 包含禁止内容/); } const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); persistBaselineArtifacts( target, buildCapturedArtifacts(validMetadata, 'CREATE TABLE `sample` (`id` int);\n'), ); replaceCapturedSql( target, '-- MySQL dump 10.13 Distrib 5.7.44\nCREATE TABLE `sample` (`id` int);\n', ); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /MySQL dump/); }); test('真实基线的清单、校验文件和 SQL 必须三方一致', () => { const sql = 'CREATE TABLE `sample` (`id` bigint NOT NULL AUTO_INCREMENT);\n'; const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); const artifacts = buildCapturedArtifacts(validMetadata, sql); persistBaselineArtifacts(target, artifacts); const result = verifyBaselineDirectory(target, 'v0.0.5'); assert.equal(result.status, 'captured'); assert.equal(result.applicationVersion, 'v0.0.5'); assert.equal(result.schemaSha256, artifacts.schemaSha256); assert.equal(readFileSync(path.join(target, 'checksum.sha256'), 'utf8'), `${artifacts.schemaSha256} schema.sql\n`); }); test('拒绝静默覆盖已存在且内容不同的版本基线', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); const artifacts = buildCapturedArtifacts(validMetadata, 'CREATE TABLE `sample` (`id` int);\n'); persistBaselineArtifacts(target, artifacts); assert.doesNotThrow(() => persistBaselineArtifacts(target, artifacts)); assert.throws( () => persistBaselineArtifacts( target, buildCapturedArtifacts(validMetadata, 'CREATE TABLE `changed` (`id` int);\n'), ), /禁止覆盖/, ); }); test('MySQL 未启用时只生成可验证不适用清单', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); const artifacts = buildNotApplicableArtifacts(validMetadata); persistBaselineArtifacts(target, artifacts); const result = verifyBaselineDirectory(target, 'v0.0.5'); assert.equal(result.status, 'not_applicable'); assert.equal(result.reason, 'mysql_disabled'); assert.throws(() => readFileSync(path.join(target, 'schema.sql')), /ENOENT/); assert.throws(() => readFileSync(path.join(target, 'checksum.sha256')), /ENOENT/); }); test('未启用入口不调用数据库且拒绝额外命令参数', () => { const root = makeTemporaryDirectory(); const envFile = path.join(root, 'development.env'); writeFileSync( envFile, 'APP_ENV=development\nMYSQL_ENABLED=false\nMYSQL_DATABASE=a_share_analysis_dev\nMYSQL_CHARSET=utf8mb4\nMYSQL_COLLATION=utf8mb4_general_ci\n', ); let executionCount = 0; const result = runBaselineGeneration({ repositoryRoot: root, envFile, applicationVersion: 'v0.0.5', gitCommit: validMetadata.gitCommit, generatedAt: validMetadata.generatedAt, executeFile() { executionCount += 1; throw new Error('未启用路径不应执行数据库命令'); }, }); assert.equal(result.status, 'not_applicable'); assert.equal(executionCount, 0); assert.throws( () => runBaselineGeneration({ ...result, commandArguments: ['--env', 'production.env'] }), /不接受命令参数/, ); }); test('启用入口分别校验 mysql 与 mysqldump 客户端版本', () => { const root = makeTemporaryDirectory(); const envFile = path.join(root, 'development.env'); writeFileSync( envFile, Object.entries(validConfig) .map(([key, value]) => `${key}=${value}`) .join('\n'), ); assert.throws( () => runBaselineGeneration({ repositoryRoot: root, envFile, applicationVersion: 'v0.0.5', gitCommit: validMetadata.gitCommit, generatedAt: validMetadata.generatedAt, executeFile(command, args) { if (args[0] === '--version') { return command === 'mysql' ? 'mysql Ver 8.0.39 for macos' : 'mysqldump Ver 10.13 Distrib 5.7.44, for macos'; } if (command === 'mysql') { return '5.7.44-log\tutf8mb4\tutf8mb4_general_ci\n'; } return 'CREATE TABLE `sample` (`id` int);\n'; }, }), /mysql 客户端必须为 5\.7/, ); }); test('幂等重跑仍核对清单提交与发布标签提交', () => { const root = makeTemporaryDirectory(); const envFile = path.join(root, 'development.env'); writeFileSync( envFile, 'APP_ENV=development\nMYSQL_ENABLED=false\nMYSQL_DATABASE=a_share_analysis_dev\nMYSQL_CHARSET=utf8mb4\nMYSQL_COLLATION=utf8mb4_general_ci\n', ); const target = path.join(root, 'database', 'schema-baselines', 'v0.0.5'); persistBaselineArtifacts(target, buildNotApplicableArtifacts(validMetadata)); assert.throws( () => runBaselineGeneration({ repositoryRoot: root, envFile, applicationVersion: 'v0.0.5', gitCommit: 'ffffffffffffffffffffffffffffffffffffffff', }), /提交与发布标签不一致/, ); }); test('校验能发现 SQL 被修改且生成阶段拒绝空基线', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); persistBaselineArtifacts( target, buildCapturedArtifacts(validMetadata, 'CREATE TABLE `sample` (`id` int);\n'), ); writeFileSync(path.join(target, 'schema.sql'), 'CREATE TABLE `tampered` (`id` int);\n'); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /校验值不一致/); assert.throws( () => buildCapturedArtifacts({ ...validMetadata, applicationVersion: 'v0.0.4' }, ''), /不能为空/, ); }); test('基线清单中的提交必须等于发布标签指向的提交', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); persistBaselineArtifacts(target, buildNotApplicableArtifacts(validMetadata)); assert.throws( () => verifyBaselineDirectory(target, 'v0.0.5', 'ffffffffffffffffffffffffffffffffffffffff'), /提交与发布标签不一致/, ); }); test('校验拒绝篡改成错误 MySQL 版本或伪造对象范围的真实清单', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); persistBaselineArtifacts( target, buildCapturedArtifacts(validMetadata, 'CREATE TABLE `sample` (`id` int);\n'), ); const manifestPath = path.join(target, 'manifest.yaml'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); manifest.mysql_server_version = '8.0.39'; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7/); manifest.mysql_server_version = '5.7.44-log'; manifest.objects.events = false; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /events/); }); test('不适用清单必须明确记录 MySQL 未启用且所有对象未捕获', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); persistBaselineArtifacts(target, buildNotApplicableArtifacts(validMetadata)); const manifestPath = path.join(target, 'manifest.yaml'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); manifest.mysql_server_version = '5.7.44'; manifest.objects.tables = true; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /不适用清单/); }); test('不适用清单拒绝未知对象键和伪造的格式元数据', () => { const root = makeTemporaryDirectory(); const target = path.join(root, 'v0.0.5'); persistBaselineArtifacts(target, buildNotApplicableArtifacts(validMetadata)); const manifestPath = path.join(target, 'manifest.yaml'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); manifest.format_version = 999; manifest.generator = 'unknown'; manifest.schema_sha256 = 'forged'; manifest.objects = { a: false, b: false, c: false, d: false, e: false }; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /清单格式/); }); test('不适用基线只在当前开发环境仍关闭 MySQL 时有效', () => { const root = makeTemporaryDirectory(); const envDirectory = path.join(root, 'deploy', 'env', 'development'); const envFile = path.join(envDirectory, 'api.env.example'); const result = { status: 'not_applicable' }; mkdirSync(envDirectory, { recursive: true }); writeFileSync( envFile, 'APP_ENV=development\nMYSQL_ENABLED=false\nMYSQL_DATABASE=a_share_analysis_dev\nMYSQL_CHARSET=utf8mb4\nMYSQL_COLLATION=utf8mb4_general_ci\n', ); assert.doesNotThrow(() => assertNotApplicableMatchesDevelopmentConfiguration(root, result), ); writeFileSync( envFile, Object.entries(validConfig) .map(([key, value]) => `${key}=${value}`) .join('\n'), ); assert.throws( () => assertNotApplicableMatchesDevelopmentConfiguration(root, result), /已启用 MySQL.*not_applicable/, ); }); test('严格发布门禁通过已提交且远程同步的当前基线', () => { assert.doesNotThrow(() => assertCurrentReleasedBaselinePublished(process.cwd())); }); test('远程分支校验实时读取 origin 而不是陈旧 tracking ref', () => { assert.doesNotThrow(() => assertRemoteBranchMatches('/repo', 'main', (_root, args) => { assert.deepEqual(args, ['ls-remote', 'origin', 'refs/heads/main']); return 'abc123\trefs/heads/main'; }, 'abc123'), ); assert.throws( () => assertRemoteBranchMatches('/repo', 'main', () => 'def456\trefs/heads/main', 'abc123'), /尚未完整推送到 origin/, ); });