248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
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'),
|
||
});
|
||
});
|
||
});
|