import { execFile } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; import { extractWorksListData } from './extract-works-list-data.mjs'; const execFileAsync = promisify(execFile); const scriptPath = fileURLToPath(new URL('./extract-works-list-data.mjs', import.meta.url)); const fixtureMigration = { schema_version: 7, tables: [ { name: 'puzzle_work_profile', rows: [ { profile_id: 'profile-real-aaa', work_id: 'work-real-aaa', owner_user_id: 'owner-secret-123', author_display_name: 'Alice Secret', author_public_user_code: 'author-code-secret', public_work_code: 'public-code-secret', title: '超长标题'.repeat(20), summary: 'summary '.repeat(80), description: 'description '.repeat(120), publication_status: 'published', play_count: 42, like_count: 7, cover_asset_id: { some: 'asset-secret-cover' }, cover_image_src: { some: 'https://cdn.example.test/cover.png?token=***&sig=abc' }, levels_json: JSON.stringify({ secret: 'level-token-value', data: 'x'.repeat(2000) }), theme_tags_json: JSON.stringify(['化学家', '实验室']), remix_count: 2, updated_at: '2026-05-01T00:00:00Z', }, { profile_id: 'profile-real-bbb', work_id: 'work-real-bbb', owner_user_id: 'owner-secret-123', author_display_name: 'Alice Secret', publication_status: 'draft', play_count: 3, }, ], }, { name: 'custom_world_profile', rows: [ { profile_id: 'world-profile-secret', work_id: 'world-work-secret', owner_user_id: 'world-owner-secret', title: '世界作品', profile_payload_json: '{"large":"' + 'y'.repeat(2000) + '"}', }, ], }, { name: 'public_work_play_daily_stat', rows: [ { source_type: 'puzzle', profile_id: 'profile-real-aaa', owner_user_id: 'owner-secret-123', user_id: 'player-secret-456', source_session_id: 'session-secret-789', played_day: '2026-05-01', play_count: 12, updated_at: '2026-05-02T00:00:00Z', }, ], }, { name: 'user_account', rows: [ { user_id: 'owner-secret-123', phone: '+8613800138000', auth_token: 'auth-token-secret', wallet_balance: 999, }, ], }, { name: 'refresh_session', rows: [{ token: 'refresh-token-secret', source_session_id: 'session-secret-789' }], }, { name: 'profile_wallet_ledger', rows: [{ wallet_id: 'wallet-secret', amount: 100 }], }, ], }; async function withTempDir(fn) { const dir = await mkdtemp(path.join(tmpdir(), 'works-list-test-')); try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); } } describe('extractWorksListData', () => { it('只保留作品 profile 白名单表,禁用的行为/敏感表不会出现在输出 JSON 字符串中', () => { const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' }); const serialized = JSON.stringify(output); expect(Object.keys(output.tables).sort()).toEqual([ 'custom_world_profile', 'puzzle_work_profile', ]); expect(serialized).not.toContain('public_work_play_daily_stat'); expect(serialized).not.toContain('user_account'); expect(serialized).not.toContain('refresh_session'); expect(serialized).not.toContain('profile_wallet_ledger'); expect(serialized).not.toContain('+8613800138000'); expect(serialized).not.toContain('auth-token-secret'); expect(serialized).not.toContain('wallet-secret'); }); it('不会输出 owner/user/session/auth/token/phone/wallet 等敏感原值,owner 稳定映射', () => { const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' }); const serialized = JSON.stringify(output); for (const secret of [ 'owner-secret-123', 'player-secret-456', 'session-secret-789', 'Alice Secret', 'author-code-secret', 'public-code-secret', 'asset-secret-cover', 'SECRET_TOKEN', ]) { expect(serialized).not.toContain(secret); } expect(output.tables.puzzle_work_profile[0].owner_user_id).toBe('user-001'); expect(output.tables.puzzle_work_profile[1].owner_user_id).toBe('user-001'); expect(output.tables.puzzle_work_profile[0].author_display_name).toBe('author-001'); expect(serialized).not.toContain('level-token-value'); }); it('puzzle 数据生成 profileIds/workIds 和 normalizedWorks,并保留列表展示字段', () => { const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' }); expect(output.source).toBe('fixture.local.json'); expect(output.generatedAt).toEqual(expect.any(String)); expect(output.counts.puzzle_work_profile).toBe(2); expect(output.profileIds.puzzle).toEqual(['profile-001', 'profile-002']); expect(output.workIds.puzzle).toEqual(['work-001', 'work-002']); expect(output.normalizedWorks).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'puzzle', workId: 'work-001', profileId: 'profile-001', publicationStatus: 'published', playCount: 42, title: expect.any(String), remixCount: 2, }), ]), ); expect(output.tables.puzzle_work_profile[0].cover_image_src).toBe('https://cdn.example.test/cover.png'); expect(output.tables.puzzle_work_profile[0].theme_tags_json).toBe('["化学家","实验室"]'); }); it('data image、URL token 和绝对输入路径不会泄露到输出', async () => { await withTempDir(async (dir) => { const input = path.join(dir, 'migration.local.json'); const output = path.join(dir, 'works-list.local.json'); await writeFile( input, JSON.stringify({ tables: [ { name: 'puzzle_work_profile', rows: [ { profile_id: 'profile-real', work_id: 'work-real', cover_image_src: { some: 'data:image/png;base64,SECRET_IMAGE_BYTES' }, levels_json: JSON.stringify({ token: 'SECRET_TOKEN_VALUE', title: 'safe' }), }, ], }, ], }), 'utf8', ); await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output]); const extracted = JSON.parse(await readFile(output, 'utf8')); const serialized = JSON.stringify(extracted); expect(extracted.source).toBe('migration.local.json'); expect(serialized).not.toContain(dir); expect(serialized).not.toContain('SECRET_IMAGE_BYTES'); expect(serialized).not.toContain('SECRET_TOKEN_VALUE'); expect(extracted.tables.puzzle_work_profile[0].cover_image_src).toBe('[redacted-data-image]'); }); }); it('sample-output 只输出少量脱敏样例', async () => { await withTempDir(async (dir) => { const input = path.join(dir, 'migration.local.json'); const output = path.join(dir, 'works-list.local.json'); const sampleOutput = path.join(dir, 'works-list.sample.json'); const manyRows = Array.from({ length: 5 }, (_, index) => ({ profile_id: `profile-real-${index}`, work_id: `work-real-${index}`, owner_user_id: `owner-secret-${index}`, title: `作品 ${index}`, publication_status: 'published', play_count: index, })); await writeFile( input, JSON.stringify({ tables: [{ name: 'puzzle_work_profile', rows: manyRows }] }), 'utf8', ); await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output, '--sample-output', sampleOutput]); const sample = JSON.parse(await readFile(sampleOutput, 'utf8')); const serialized = JSON.stringify(sample); expect(sample.tables.puzzle_work_profile).toHaveLength(3); expect(sample.normalizedWorks).toHaveLength(3); expect(serialized).not.toContain('owner-secret-0'); expect(serialized).not.toContain('work-real-0'); }); }); it('CLI 参数缺失时退出非 0 并输出清晰错误', async () => { await expect(execFileAsync(process.execPath, [scriptPath, '--input', 'missing.json'])).rejects.toMatchObject({ code: 1, stderr: expect.stringContaining('--output'), }); }); });