feat: 레거시 재이관과 계정 복구 기반을 추가
중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
@@ -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