feat(gateway): recover orphaned Kakao account links

This commit is contained in:
2026-08-08 10:01:23 +00:00
parent 4374c490ac
commit 5c0aac5561
13 changed files with 634 additions and 27 deletions
+97 -7
View File
@@ -24,6 +24,7 @@ const buildCaller = (
profileListError?: Error;
kakaoId?: string;
kakaoEmail?: string;
kakaoSignupAlreadyRegistered?: boolean;
allowKakaoRefresh?: boolean;
} = {}
) => {
@@ -69,7 +70,8 @@ const buildCaller = (
accessTokenExpiresIn: 3600,
};
},
signup: async () => ({ id: '1' }),
signup: async () =>
options.kakaoSignupAlreadyRegistered ? { msg: 'already registered' as const } : { id: kakaoProfile.id },
getMe: async () => ({
id: kakaoProfile.id,
kakaoAccount: {
@@ -472,9 +474,9 @@ describe('gateway auth flow', () => {
expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
});
it('rejects a new Kakao identity when its verified email is already registered', async () => {
const { caller, users, kakaoProfile } = buildCaller();
await users.createUser({
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
const emailOwner = await users.createUser({
username: 'email-owner',
password: 'owner-password',
oauth: {
@@ -484,12 +486,100 @@ describe('gateway auth flow', () => {
info: {},
},
});
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' });
await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({
code: 'CONFLICT',
message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'),
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(recovery).toMatchObject({
status: 'account_recovery',
action: 'link_existing',
email: 'tester@example.com',
});
if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');
const linked = await caller.auth.kakaoResolveAccount({
oauthSessionId: recovery.oauthSessionId,
action: 'link_existing',
});
expect(linked.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
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');
});
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
const { caller, users, sealPassword } = buildCaller({ kakaoSignupAlreadyRegistered: true });
const start = await caller.auth.kakaoStart({ mode: 'login' });
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(recovery).toMatchObject({
status: 'account_recovery',
action: 'rejoin',
email: 'tester@example.com',
});
if (recovery.status !== 'account_recovery') throw new Error('Expected rejoin choice.');
const confirmed = await caller.auth.kakaoResolveAccount({
oauthSessionId: recovery.oauthSessionId,
action: 'rejoin',
});
expect(confirmed).toMatchObject({ status: 'join', email: 'tester@example.com' });
if (confirmed.status !== 'join') throw new Error('Expected registration session.');
const registered = await caller.auth.register({
oauthSessionId: confirmed.oauthSessionId,
username: 'rejoined-user',
credential: sealPassword('rejoined-password'),
displayName: '재가입사용자',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
expect(registered.status).toBe('otp');
expect(await users.findByUsername('rejoined-user')).toMatchObject({
oauthType: 'KAKAO',
oauthId: '1',
email: 'tester@example.com',
});
});
it('does not let the registration mutation bypass the recovery confirmation', async () => {
const { caller, users, sealPassword } = buildCaller();
await users.createUser({
username: 'retained-owner',
password: 'owner-password',
oauth: {
type: 'KAKAO',
id: 'former-kakao-id',
email: 'tester@example.com',
info: {},
},
});
const start = await caller.auth.kakaoStart({ mode: 'login' });
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');
await expect(
caller.auth.register({
oauthSessionId: recovery.oauthSessionId,
username: 'bypass-user',
credential: sealPassword('bypass-password'),
displayName: '우회사용자',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('복구 여부를 먼저 선택'),
});
});
@@ -0,0 +1,79 @@
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 userId = '3dd08c49-279e-41ad-a12b-51b914cc51c8';
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('Kakao account relink PostgreSQL boundary', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let initialized = false;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
initialized = true;
closeDb = () => connector.disconnect();
await db.appUser.deleteMany({ where: { id: userId } });
await db.appUser.create({
data: {
id: userId,
loginId: 'kakao-relink-integration',
displayName: '카카오 재연결 통합',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
oauthType: 'KAKAO',
oauthId: 'former-kakao-id',
email: 'retained@example.test',
kakaoVerifiedAt: new Date('2026-08-01T00:00:00.000Z'),
kakaoTalkVerifiedUntil: new Date('2026-08-20T00:00:00.000Z'),
},
});
});
afterAll(async () => {
if (initialized) {
await db.appUser.deleteMany({ where: { id: userId } });
}
await closeDb?.();
});
it('relinks the new provider identity while preserving email ownership and resetting the old talk proof', async () => {
const users = createPostgresUserRepository(db);
const linked = await users.relinkKakaoByEmail(userId, {
oauthId: 'replacement-kakao-id',
email: 'retained@example.test',
oauthInfo: {
accessToken: 'replacement-access-token',
accessTokenValidUntil: '2026-08-08T12:00:00.000Z',
},
verifiedAt: new Date('2026-08-08T10:00:00.000Z'),
});
expect(linked).toMatchObject({
id: userId,
oauthType: 'KAKAO',
oauthId: 'replacement-kakao-id',
email: 'retained@example.test',
kakaoTalkVerifiedUntil: undefined,
});
await expect(users.findByOauthId('KAKAO', 'former-kakao-id')).resolves.toBeNull();
await expect(users.findByOauthId('KAKAO', 'replacement-kakao-id')).resolves.toMatchObject({ id: userId });
});
});
+28 -1
View File
@@ -7,12 +7,13 @@ import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => {
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
const prefix = `gateway-oauth-test:${randomUUID()}`;
const client = createClient({ url: redisUrl });
const store = new RedisOAuthSessionStore(client, prefix, 300);
const userIds = new Set<string>();
const challengeIds = new Set<string>();
const sessionIds = new Set<string>();
beforeAll(async () => {
await client.connect();
@@ -22,6 +23,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () =>
const keys = [
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`),
];
if (keys.length > 0) {
await client.del(keys);
@@ -43,6 +45,31 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () =>
return challenge;
};
it('preserves and consumes a retained-email recovery target once', async () => {
const targetUserId = randomUUID();
const session = await store.createSession({
mode: 'login',
intent: 'link_existing',
targetUserId,
kakaoId: 'replacement-kakao-id',
email: 'retained@example.test',
accessToken: 'access-token',
refreshToken: 'refresh-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
createdAt: new Date().toISOString(),
});
sessionIds.add(session.id);
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
id: session.id,
intent: 'link_existing',
targetUserId,
email: 'retained@example.test',
});
await expect(store.consumeSession(session.id)).resolves.toBeNull();
});
it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge();