merge: 최신 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user