feat: 레거시 재이관과 계정 복구 기반을 추가

중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
2026-08-17 15:55:32 +00:00
parent 50e7d894e4
commit fc7de05017
33 changed files with 1720 additions and 150 deletions
@@ -0,0 +1,71 @@
import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
const fixture = async (name: string): Promise<ArchivedJsonValue> =>
JSON.parse(await readFile(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) as ArchivedJsonValue;
describe('normalizeArchivedGeneral', () => {
it('normalizes the sanitized CHE legacy-flat keyset without leaking connection metadata', async () => {
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
await fixture('che-old-general-legacy-flat-v0.json'),
'fallback'
);
expect(sourceFormat).toBe('legacy-flat-v0');
expect(snapshot).toMatchObject({
schemaVersion: 1,
identity: { name: '구형테스트장수', nationId: 3 },
stats: { leadership: 81, strength: 73, intelligence: 66 },
mastery: { infantry: 101, archery: 202, cavalry: 303, special: 404, siege: 505 },
battle: {
battles: 20,
wins: 12,
losses: 8,
winRate: 60,
killRate: 125,
tactics: { total: { wins: 3, draws: 1, losses: 2 } },
},
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
availability: { mastery: true, battleAggregates: true, tactics: true },
});
expect(JSON.stringify(snapshot)).not.toMatch(/"(?:ip|lastconnect|refresh)"/iu);
});
it('normalizes the sanitized HWE ref-flat keyset and marks absent battle records unavailable', async () => {
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
await fixture('hwe-old-general-ref-flat-v1.json'),
'fallback'
);
expect(sourceFormat).toBe('ref-flat-v1');
expect(snapshot).toMatchObject({
identity: { name: '신형테스트장수', officerLevel: 7 },
stats: {
leadership: 91,
strength: 82,
intelligence: 74,
leadershipExperience: 11,
},
traits: { personality: 'che_의리', specialDomestic: 'che_상재', specialWar: 'che_신산' },
mastery: { infantry: 111, archery: 222, cavalry: 333, special: 444, siege: 555 },
battle: { battles: null, wins: null, losses: null, winRate: null, killRate: null },
availability: { mastery: true, battleAggregates: false, tactics: false },
});
expect(snapshot.availability.battleDetailLogs).toBe(false);
expect(snapshot.availability.battleResultLogs).toBe(false);
});
it('keeps an already-normalized version 1 snapshot stable', async () => {
const first = normalizeArchivedGeneral(await fixture('che-old-general-legacy-flat-v0.json'), 'fallback');
const second = normalizeArchivedGeneral(
first.snapshot as unknown as ArchivedJsonValue,
first.snapshot.identity.name
);
expect(second.sourceFormat).toBe('core-snapshot-v1');
expect(second.snapshot).toEqual(first.snapshot);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { PoolClient } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { quoteQualifiedIdentifier, upsertRows } from '../src/db.js';
describe('qualified archive identifiers', () => {
it('quotes a schema-qualified table and still parameterizes values', async () => {
const query = vi.fn().mockResolvedValue({});
await upsertRows(
{ query } as unknown as PoolClient,
'legacy_archive.general',
[{ id: 1, data: { ok: true } }],
['id']
);
expect(query).toHaveBeenCalledOnce();
expect(query.mock.calls[0]?.[0]).toContain('INSERT INTO "legacy_archive"."general"');
expect(query.mock.calls[0]?.[1]).toEqual([1, JSON.stringify({ ok: true })]);
});
it.each(['legacy_archive.general.extra', 'legacy-archive.general', 'legacy_archive.General', 'public.;drop'])(
'rejects unsafe qualified identifier %s',
(value) => expect(() => quoteQualifiedIdentifier(value)).toThrow('Unsafe SQL identifier')
);
});
@@ -0,0 +1,50 @@
{
"name": "구형테스트장수",
"leader": 81,
"power": 73,
"intel": 66,
"leader2": 4,
"power2": 5,
"intel2": 6,
"nation": 3,
"city": 7,
"level": 8,
"personal": 2,
"special": 4,
"special2": 5,
"experience": 12345,
"explevel": 8,
"dedication": 765,
"dedlevel": 4,
"dex0": 101,
"dex10": 202,
"dex20": 303,
"dex30": 404,
"dex40": 505,
"warnum": 20,
"killnum": 12,
"deathnum": 8,
"firenum": 7,
"killcrew": 2500,
"deathcrew": 2000,
"ttw": 3,
"ttd": 1,
"ttl": 2,
"tlw": 4,
"tld": 0,
"tll": 1,
"tiw": 5,
"tid": 2,
"til": 3,
"picture": "default.jpg",
"imgsvr": 0,
"horse": 1,
"weap": 2,
"book": 3,
"item": 4,
"history": "<C>●</>첫 기록<br><Y>●</>둘째 기록<br>",
"recent_war": "2020-01-02 03:04:05",
"ip": "192.0.2.1",
"lastconnect": "2020-01-02 03:04:05",
"refresh": 10
}
@@ -0,0 +1,36 @@
{
"name": "신형테스트장수",
"leadership": 91,
"strength": 82,
"intel": 74,
"leadership_exp": 11,
"strength_exp": 12,
"intel_exp": 13,
"nation": 4,
"city": 8,
"officer_level": 7,
"officer_city": 8,
"personal": "che_의리",
"special": "che_상재",
"special2": "che_신산",
"experience": 22222,
"explevel": 9,
"dedication": 999,
"dedlevel": 5,
"dex1": 111,
"dex2": 222,
"dex3": 333,
"dex4": 444,
"dex5": 555,
"picture": "default.jpg",
"imgsvr": 1,
"horse": "che_적토마",
"weapon": "che_청룡언월도",
"book": null,
"item": null,
"history": ["<C>●</>최신 기록", "<Y>●</>이전 기록"],
"recent_war": null,
"aux": "",
"ip": "198.51.100.1",
"lastconnect": "2026-01-02 03:04:05"
}
+146
View File
@@ -0,0 +1,146 @@
import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js';
const sourceRows = {
ng_games: [
{
id: 1,
server_id: 'che_fixture_001',
date: new Date('2020-01-01T00:00:00.000Z'),
winner_nation: 3,
map: 'che',
season: 1,
scenario: 2,
scenario_name: 'fixture',
env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }),
},
],
ng_old_generals: [
{
id: 2,
server_id: 'che_fixture_001',
general_no: 10,
owner: 42,
name: 'fixture-general',
last_yearmonth: 22012,
turntime: new Date('2020-02-01T00:00:00.000Z'),
data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first<br>second<br>' }),
},
],
} satisfies Record<string, Array<Record<string, unknown>>>;
const sourcePool = (): MariaPool => {
const seen = new Set<string>();
return {
query: vi.fn(async (sql: string) => {
const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? '';
if (seen.has(table)) return [];
seen.add(table);
return sourceRows[table as keyof typeof sourceRows] ?? [];
}),
} as unknown as MariaPool;
};
const targetPool = (failPattern?: string) => {
const queries: Array<{ sql: string; values: readonly unknown[] }> = [];
const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => {
queries.push({ sql, values });
if (failPattern && sql.includes(failPattern)) {
failPattern = undefined;
throw new Error('synthetic archive write failure');
}
if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) {
return { rows: [{ id: '77' }], rowCount: 1 } as QueryResult<{ id: string }>;
}
return { rows: [], rowCount: 0 } as unknown as QueryResult;
});
const client = { query, release: vi.fn() } as unknown as PoolClient;
return {
pool: { connect: vi.fn(async () => client) } as unknown as PgPool,
queries,
};
};
describe('legacy archive game migration', () => {
it('uses the official profile allowlist and resolves the best opening timestamp', () => {
expect(isLegacyArchiveProfile('che')).toBe(true);
expect(isLegacyArchiveProfile('hwe')).toBe(true);
expect(isLegacyArchiveProfile('custom')).toBe(false);
expect(
resolveLegacyGameOpenedAt(
{ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' },
new Date('2020-01-01T00:00:00.000Z'),
'fixture'
).toISOString()
).toBe('2020-01-02T00:00:00.000Z');
expect(
resolveLegacyGameOpenedAt(
{ starttime: '2020-01-03T00:00:00.000Z' },
new Date('2020-01-01T00:00:00.000Z'),
'fixture'
).toISOString()
).toBe('2020-01-03T00:00:00.000Z');
expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe(
'2020-01-01T00:00:00.000Z'
);
});
it('keeps dry-run target-read-only while reporting normalized formats', async () => {
const summary = await migrateGame(sourcePool(), null, false, 'che');
expect(summary).toMatchObject({
apply: false,
importRunId: null,
counts: { ng_games: 1, ng_old_generals: 1 },
sourceFormatSummary: { 'legacy-flat-v0': 1 },
});
});
it('records a completed import run and writes only archive tables for historical snapshots', async () => {
const target = targetPool();
const summary = await migrateGame(sourcePool(), target.pool, true, 'che');
const sql = target.queries.map((entry) => entry.sql).join('\n');
expect(summary.importRunId).toBe('77');
expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"');
expect(sql).toContain('INSERT INTO "legacy_archive"."general"');
expect(sql).not.toContain('INSERT INTO "ng_games"');
expect(sql).not.toContain('INSERT INTO "ng_old_generals"');
expect(sql).toContain(`SET "status" = 'COMPLETED'`);
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan(
target.queries.findIndex((entry) => entry.sql === 'COMMIT')
);
});
it('rolls back archive writes and records a failed import run', async () => {
const target = targetPool('INSERT INTO "legacy_archive"."general"');
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
'synthetic archive write failure'
);
const sql = target.queries.map((entry) => entry.sql).join('\n');
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
expect(sql).toContain(`SET "status" = 'FAILED'`);
expect(sql).not.toContain(`SET "status" = 'COMPLETED'`);
});
it('rolls back archive writes when completing the import run fails', async () => {
const target = targetPool(`SET "status" = 'COMPLETED'`);
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
'synthetic archive write failure'
);
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false);
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true);
});
it('rejects an unsupported profile before reading or writing', async () => {
await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow(
'Unsupported legacy archive profile'
);
});
});
@@ -0,0 +1,113 @@
import type { PoolClient } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { upsertRows } from '../src/db.js';
import { mapMember, MEMBER_PRESERVED_COLUMNS, preflightMemberConflicts } from '../src/gateway.js';
const memberRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
GRADE: 1,
acl: '{}',
penalty: '{}',
oauth_info: '{}',
oauth_type: 'KAKAO',
oauth_id: null,
PW: 'a'.repeat(128),
salt: 'member-salt',
ID: 'LegacyUser',
NAME: '레거시유저',
EMAIL: 'USER@EXAMPLE.TEST',
PICTURE: 'default.jpg',
IMGSVR: 0,
third_use: 0,
token_valid_until: null,
delete_after: null,
REG_DATE: '2020-01-01 00:00:00',
REG_NUM: 0,
BLOCK_NUM: 0,
BLOCK_DATE: null,
...overrides,
});
describe('legacy gateway member migration', () => {
it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => {
const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null);
expect(mapped).toMatchObject({
login_id: 'legacyuser',
email: 'user@example.test',
password_reset_required: true,
oauth_type: 'KAKAO',
oauth_id: null,
kakao_verified_at: null,
});
});
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
const client = { query } as unknown as PoolClient;
await upsertRows(
client,
'app_user',
[
{
id: 'legacy-id',
login_id: 'legacy-user',
password_hash: 'legacy-hash',
oauth_info: '{}',
legacy_data: '{}',
},
],
['id'],
{ preserveOnConflict: ['password_hash', 'oauth_info'] }
);
const sql = String(query.mock.calls[0]?.[0]);
expect(sql).toContain('"login_id" = EXCLUDED."login_id"');
expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"');
expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"');
expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"');
});
it('preserves renamed login and display identities along with every live credential field', () => {
expect(MEMBER_PRESERVED_COLUMNS).toEqual(
expect.arrayContaining([
'login_id',
'display_name',
'password_hash',
'password_salt',
'password_reset_required',
'roles',
'sanctions',
'oauth_id',
'email',
'updated_at',
'last_login_at',
'created_at',
])
);
expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data');
});
it('rejects a source member when another target account already owns its identity', async () => {
const query = vi.fn(async (..._args: unknown[]) => ({
rows: [
{
id: 'another-target-id',
login_id: 'legacyuser',
display_name: '다른사용자',
email: 'other@example.test',
},
],
rowCount: 1,
}));
const client = { query } as unknown as PoolClient;
const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null);
await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow(
'Target account identity collision in legacy member batch'
);
expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"');
});
});