feat: 관리자 계정 식별자와 카카오 영구 교체 지원

관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
This commit is contained in:
2026-08-24 23:25:49 +00:00
parent e8dc7a65aa
commit 10cd1ed6d4
34 changed files with 1496 additions and 143 deletions
@@ -0,0 +1,142 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161';
const secondUserId = '8f610116-64bd-4f18-bf7f-eae1737b227c';
const oldKakaoId = 'account-identity-old-kakao';
const newKakaoId = 'account-identity-new-kakao';
const assertDedicatedSchema = (): void => {
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
}
};
integration('account identity PostgreSQL transaction', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await db.appUser.createMany({
data: [
{
id: firstUserId,
loginId: 'identity-integration-one',
displayName: '이름통합첫째',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
oauthType: 'KAKAO',
oauthId: oldKakaoId,
email: 'identity-one@example.com',
},
{
id: secondUserId,
loginId: 'identity-integration-two',
displayName: '이름통합둘째',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
oauthType: 'KAKAO',
oauthId: 'account-identity-second-kakao',
email: 'identity-two@example.com',
},
],
});
});
afterAll(async () => {
await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await closeDb?.();
});
it('atomically retires the former Kakao ID and rejects its reuse', async () => {
const users = createPostgresUserRepository(db);
const verifiedAt = new Date('2026-08-24T12:00:00.000Z');
await users.setKakaoReplacementApproval(firstUserId, {
until: new Date('2026-08-24T13:00:00.000Z'),
approvedByUserId: 'integration-admin',
reason: '통합 테스트 교체 승인',
});
const replaced = await users.replaceKakaoWithApprovedIdentity(firstUserId, {
oauthId: newKakaoId,
email: 'identity-new@example.com',
oauthInfo: { accessToken: 'not-a-real-token' },
verifiedAt,
});
expect(replaced).toMatchObject({
oauthId: newKakaoId,
email: 'identity-new@example.com',
authRevision: 1,
sessionRevokedBefore: verifiedAt.toISOString(),
kakaoReplacementApprovedUntil: undefined,
});
await expect(users.isKakaoIdentityRetired(oldKakaoId)).resolves.toBe(true);
await expect(
users.linkKakao(secondUserId, {
oauthId: oldKakaoId,
email: 'identity-two@example.com',
oauthInfo: {},
verifiedAt,
})
).rejects.toThrow('permanently retired');
await users.setKakaoReplacementApproval(secondUserId, {
until: new Date('2026-08-24T13:00:00.000Z'),
approvedByUserId: 'integration-admin',
reason: '폐기 ID 재사용 거부',
});
await expect(
users.replaceKakaoWithApprovedIdentity(secondUserId, {
oauthId: oldKakaoId,
email: 'identity-two-new@example.com',
oauthInfo: {},
verifiedAt,
})
).rejects.toThrow();
await expect(users.findById(secondUserId)).resolves.toMatchObject({
oauthId: 'account-identity-second-kakao',
email: 'identity-two@example.com',
});
});
it('changes login ID and nickname together while preserving uniqueness', async () => {
const users = createPostgresUserRepository(db);
const before = await users.findById(firstUserId);
const updated = await users.updateIdentity(firstUserId, {
username: 'identity-integration-renamed',
displayName: '이름통합변경',
changedAt: new Date('2026-08-24T14:00:00.000Z'),
});
expect(updated).toMatchObject({
username: 'identity-integration-renamed',
displayName: '이름통합변경',
});
expect(Date.parse(updated.identityRevision ?? '')).toBeGreaterThan(Date.parse(before?.identityRevision ?? ''));
await expect(
users.updateIdentity(firstUserId, {
username: 'identity-integration-two',
displayName: '이름통합변경',
changedAt: new Date('2026-08-24T14:00:01.000Z'),
})
).rejects.toThrow();
});
});
+87 -1
View File
@@ -77,7 +77,13 @@ const buildCaller = async (
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
if (options.initialOperation) operationRecords.set(options.initialOperation.id, options.initialOperation);
const createdRuntimeActions: Array<Record<string, unknown>> = [];
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
const flushes: Array<{
userId: string;
reason?: string;
iconRevision?: string;
displayName?: string;
identityRevision?: string;
}> = [];
const updatedStatuses: GatewayProfileRecord['status'][] = [];
const updatedMetas: Record<string, unknown>[] = [];
const auditEvents: AdminAuditEventRecord[] = [];
@@ -238,6 +244,8 @@ const buildCaller = async (
userId,
reason,
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
});
},
},
@@ -1946,6 +1954,64 @@ describe('Gateway administrator account controls', () => {
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' });
});
it('approves a time-bounded Kakao replacement only for an already linked account', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'kakao-replacement-target',
password: 'secretpass',
displayName: '교체대상',
oauth: { type: 'KAKAO', id: 'former-kakao-id', email: 'former@example.com', info: {} },
});
const until = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await expect(
harness.caller.admin.users.setKakaoReplacementApproval({
userId: target.id,
until,
reason: '새 Kakao 계정 본인 확인 예정',
})
).resolves.toEqual({ kakaoReplacementApprovedUntil: until });
expect(await harness.users.findById(target.id)).toMatchObject({
kakaoReplacementApprovedUntil: until,
kakaoReplacementApprovedByUserId: harness.admin.id,
kakaoReplacementReason: '새 Kakao 계정 본인 확인 예정',
});
await expect(
harness.caller.admin.users.setKakaoReplacementApproval({
userId: target.id,
until: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(),
reason: '기간 상한 검증',
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('changes login ID and nickname while publishing a current-general projection', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'rename-target',
password: 'secretpass',
displayName: '이전닉네임',
});
const result = await harness.caller.admin.users.updateIdentity({
userId: target.id,
username: 'renamed-target',
displayName: '새닉네임',
reason: '사용자 본인 요청 확인',
});
expect(result).toMatchObject({ username: 'renamed-target', displayName: '새닉네임' });
expect(await harness.users.findByUsername('rename-target')).toBeNull();
expect(await harness.users.findByUsername('renamed-target')).toMatchObject({ displayName: '새닉네임' });
expect(harness.flushes.at(-1)).toEqual({
userId: target.id,
reason: 'admin-account-identity-updated',
displayName: '새닉네임',
identityRevision: result.identityRevision,
});
});
it('grants and revokes profile-scoped recovery access with an audit trail', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
@@ -2043,6 +2109,8 @@ describe('Gateway administrator account controls', () => {
displayName: 'Root Target',
});
await harness.users.updateRoles(target.id, ['user', 'admin']);
target.oauthType = 'KAKAO';
target.oauthId = 'protected-root-kakao-id';
await expect(
harness.caller.admin.users.updateSanctions({
@@ -2051,7 +2119,25 @@ describe('Gateway administrator account controls', () => {
reason: '루트 계정 보호 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
harness.caller.admin.users.updateIdentity({
userId: target.id,
username: 'root-target-renamed',
displayName: '변경 금지 대상',
reason: '루트 계정 보호 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
harness.caller.admin.users.setKakaoReplacementApproval({
userId: target.id,
until: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
reason: '루트 계정 보호 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect((await harness.users.findById(target.id))?.sanctions).toEqual({});
const protectedUser = await harness.users.findByUsername('root-target');
expect(protectedUser).toMatchObject({ displayName: 'Root Target' });
expect(protectedUser?.kakaoReplacementApprovedUntil).toBeUndefined();
});
it('keeps the global audit feed behind its dedicated capability', async () => {
+45 -1
View File
@@ -675,6 +675,11 @@ describe('gateway auth flow', () => {
});
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
await users.setKakaoReplacementApproval(emailOwner.id, {
until: new Date(Date.now() + 60_000),
approvedByUserId: 'admin-user-id',
reason: '이메일 보존 계정의 교체 승인',
});
kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' });
@@ -701,12 +706,13 @@ describe('gateway auth flow', () => {
expect(passwordSet.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true);
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
id: emailOwner.id,
username: 'email-owner',
email: 'tester@example.com',
});
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-replaced');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
});
@@ -880,6 +886,44 @@ describe('gateway auth flow', () => {
});
});
it('replaces an approved Kakao identity, retires the old ID, and invalidates older sessions', async () => {
const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({
kakaoId: 'new-kakao-id',
kakaoEmail: 'new-kakao@example.com',
});
const user = await users.createUser({
username: 'approved-replacement-user',
password: 'replacement-password',
oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} },
});
const oldSession = await sessions.createSession(user);
setSessionHeader(oldSession.sessionToken);
await users.setKakaoReplacementApproval(user.id, {
until: new Date(Date.now() + 60_000),
approvedByUserId: 'admin-user-id',
reason: '사용자 본인 확인 완료',
});
const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken });
const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' });
expect(await users.findById(user.id)).toMatchObject({
oauthId: 'new-kakao-id',
email: 'new-kakao@example.com',
kakaoReplacementApprovedUntil: undefined,
authRevision: 1,
});
await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true);
await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull();
kakaoProfile.id = 'old-kakao-id';
kakaoProfile.email = 'old-kakao@example.com';
const retiredStart = await caller.auth.kakaoStart({ mode: 'login' });
await expect(
caller.auth.kakaoExchange({ code: 'oauth-code', state: retiredStart.state })
).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('영구 폐기') });
});
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
const { caller, users, kakaoProfile } = buildCaller({
kakaoId: 'stable-kakao-id',
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260824090000_allow_interim_profile_deploy',
gatewaySchemaHead: '20260824120000_add_account_identity_management',
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
});
});