feat: 카카오 계정의 본인 복구와 인증 후 연결 교체를 추가한다

This commit is contained in:
Hide_D committed 2026-11-07 21:31:16 +09:00
1 parent d1cdf8a85f
commit c81d304073
22 files changed
+1338 -199

No files matched your search

@@ -2,12 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { snapshotKakaoLink } from '../src/auth/kakaoLinkPolicy.js';
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 bootstrapId = '00000000-0000-4000-8000-000000000042';
const selfUserId = '00000000-0000-4000-8000-000000000043';
const oldKakaoId = 'account-identity-old-kakao';
const newKakaoId = 'account-identity-new-kakao';
@@ -30,9 +33,34 @@ integration('account identity PostgreSQL transaction', () => {
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.kakaoIdentityLinkHistory.deleteMany({
where: { userId: { in: [firstUserId, secondUserId, selfUserId] } },
});
await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } });
await db.appUser.createMany({
data: [
{
id: bootstrapId,
loginId: 'identity-bootstrap',
displayName: '복구통합관리자',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['superuser'],
sanctions: {},
createdAt: new Date('2000-01-01'),
},
{
id: selfUserId,
loginId: 'identity-self',
displayName: '직접복구계정',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
oauthType: 'KAKAO',
oauthId: 'self-db-old',
email: 'self-db@example.test',
},
{
id: firstUserId,
loginId: 'identity-integration-one',
@@ -63,7 +91,10 @@ integration('account identity PostgreSQL transaction', () => {
afterAll(async () => {
await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await db?.kakaoIdentityLinkHistory.deleteMany({
where: { userId: { in: [firstUserId, secondUserId, selfUserId] } },
});
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } });
await closeDb?.();
});
@@ -160,4 +191,93 @@ integration('account identity PostgreSQL transaction', () => {
expect(restored?.oauthId).toBe(before?.oauthId);
expect(restored?.sanctions).toEqual(before?.sanctions);
});
it('commits identity, imported password, proof and history together, and restricts former identities to their owner', async () => {
const users = createPostgresUserRepository(db);
await db.appUser.update({ where: { id: selfUserId }, data: { passwordResetRequired: true } });
const before = await users.findById(selfUserId);
const now = new Date();
const input = {
expected: snapshotKakaoLink(before!),
authorization: 'EMAIL' as const,
oauthId: 'self-db-new',
email: 'self-db@example.test',
oauthInfo: {},
verifiedAt: now,
talkVerifiedUntil: new Date(now.getTime() + 86400000),
password: 'new-self-password',
};
const restored = await users.completeKakaoIdentityLink(selfUserId, input);
expect(restored).toMatchObject({
oauthId: 'self-db-new',
passwordResetRequired: false,
authRevision: (before?.authRevision ?? 0) + 1,
});
expect(await users.verifyPassword(restored, 'new-self-password')).toBe(true);
expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId);
expect(await users.isKakaoIdentityRetired('self-db-old')).toBe(false);
expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(1);
await expect(users.completeKakaoIdentityLink(selfUserId, input)).rejects.toThrow();
await expect(
users.createUser({
username: 'identity-history-claim',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'self-db-old', email: 'claim@example.test', info: {} },
})
).rejects.toThrow();
const prior = await users.findById(selfUserId);
await users.completeKakaoIdentityLink(selfUserId, {
...input,
expected: snapshotKakaoLink(prior!),
oauthId: 'self-db-old',
});
expect((await users.findById(selfUserId))?.oauthId).toBe('self-db-old');
const snapshot = await users.findById(selfUserId);
const conflicting = await users.findById(secondUserId);
await expect(
users.completeKakaoIdentityLink(selfUserId, {
...input,
expected: snapshotKakaoLink(snapshot!),
oauthId: conflicting!.oauthId!,
password: 'must-not-be-saved',
})
).rejects.toThrow();
expect((await users.findById(selfUserId))?.passwordHash).toBe(snapshot?.passwordHash);
expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(2);
await expect(
users.markKakaoTalkVerified(selfUserId, new Date(Date.now() + 10000), 0, 'self-db-old')
).rejects.toThrow();
});
it('allows only one concurrent recovery against the same PostgreSQL snapshot', async () => {
const users = createPostgresUserRepository(db);
const user = await users.findById(selfUserId);
const input = {
expected: snapshotKakaoLink(user!),
authorization: 'EMAIL' as const,
email: 'self-db@example.test',
oauthInfo: {},
verifiedAt: new Date(),
talkVerifiedUntil: new Date(Date.now() + 86400000),
};
const results = await Promise.allSettled(
['self-db-race-one', 'self-db-race-two'].map((oauthId) =>
users.completeKakaoIdentityLink(selfUserId, { ...input, oauthId })
)
);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1);
expect((await users.findById(selfUserId))?.authRevision).toBe((user?.authRevision ?? 0) + 1);
});
it('keeps former identity ownership after the service account is deleted', async () => {
const users = createPostgresUserRepository(db);
await users.deleteUser(selfUserId);
expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId);
await expect(
users.createUser({
username: 'deleted-owner-claim',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'self-db-old', email: 'deleted-owner-claim@example.test', info: {} },
})
).rejects.toThrow();
});
});
+275 -56
View File
@@ -269,6 +269,7 @@ const buildCaller = (
flushPublisher,
userIconUpload,
kakaoProfile,
kakaoClient,
sentTalkMessages,
refreshTokenCalls,
sealPassword,
@@ -640,11 +641,17 @@ describe('gateway auth flow', () => {
});
setSessionHeader(register.sessionToken);
const start = await caller.auth.kakaoStart({ mode: 'verify' });
const verified = await caller.auth.kakaoExchange({
const choice = await caller.auth.kakaoExchange({
code: 'oauth-code',
state: start.state,
});
if (choice.status !== 'account_recovery') throw new Error('Expected link confirmation.');
const verified = await caller.auth.kakaoLinkWithPassword({
oauthSessionId: choice.oauthSessionId,
username: 'verify-user',
credential: sealPassword('verify-password'),
});
expect(verified.status).toBe('otp');
if (verified.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
@@ -685,11 +692,6 @@ 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' });
@@ -715,15 +717,16 @@ describe('gateway auth flow', () => {
});
expect(passwordSet.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toMatchObject({ id: emailOwner.id });
expect((await users.findById(emailOwner.id))?.passwordResetRequired).toBe(true);
if (passwordSet.status !== 'otp') throw new Error('Expected OTP.');
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
await caller.auth.kakaoOtp({ challengeId: passwordSet.challengeId, code: code! });
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-replaced');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(false);
expect(await users.findDetachedKakaoOwner('original-kakao-id')).toBe(emailOwner.id);
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ id: emailOwner.id });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
});
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
@@ -896,42 +899,41 @@ 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({
it('completes an approved administrator replacement only after fresh talk proof and keeps old-ID ownership', async () => {
const f = buildCaller({ kakaoId: 'new-kakao-id', kakaoEmail: 'new-kakao@example.com' });
const user = await f.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: '사용자 본인 확인 완료',
await f.users.updateRoles(user.id, ['admin']);
await f.users.setKakaoReplacementApproval(user.id, {
until: new Date(Date.now() + 60000),
approvedByUserId: 'operator',
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({
const old = await f.sessions.createSession(user);
f.setSessionHeader(old.sessionToken);
const start = await f.caller.auth.kakaoStart({ mode: 'verify' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
if (choice.status !== 'account_recovery') throw new Error('Expected confirmation.');
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect((await f.users.findById(user.id))?.oauthId).toBe('old-kakao-id');
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).not.toBeNull();
const code = f.sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: code! });
expect(await f.users.findById(user.id)).toMatchObject({
oauthId: 'new-kakao-id',
email: 'new-kakao@example.com',
kakaoReplacementApprovedUntil: undefined,
authRevision: 1,
kakaoReplacementApprovedUntil: undefined,
});
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('영구 폐기') });
expect(await f.users.isKakaoIdentityRetired('old-kakao-id')).toBe(false);
expect(await f.users.findDetachedKakaoOwner('old-kakao-id')).toBe(user.id);
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).toBeNull();
});
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
@@ -1133,22 +1135,12 @@ describe('gateway auth flow', () => {
});
it('blocks Kakao login while a ban is active', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const register = await caller.auth.registerLocal({
const { caller, users } = buildCaller();
await users.createUser({
username: 'kakao-banned-user',
credential: sealPassword('kakao-banned-password'),
displayName: '카카오제재유저',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
password: 'kakao-banned-password',
oauth: { type: 'KAKAO', id: '1', email: 'tester@example.com', info: {} },
});
setSessionHeader(register.sessionToken);
const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' });
await caller.auth.kakaoExchange({
code: 'oauth-code',
state: verifyStart.state,
});
const stored = await users.findByUsername('kakao-banned-user');
expect(stored).not.toBeNull();
if (stored) {
@@ -1948,3 +1940,230 @@ describe('account deletion cancellation', () => {
expect(canCancelDeletion({ ...legacy!, legacyMemberNo: undefined }, new Date('2026-11-07'))).toBe(false);
});
});
describe('staged Kakao self recovery', () => {
const prepare = async (
options: { differentEmail?: boolean; resetPassword?: boolean; privileged?: boolean } = {}
) => {
const f = buildCaller({
kakaoId: 'new-self-kakao',
kakaoEmail: options.differentEmail ? 'new@example.test' : 'owner@example.test',
});
const user = await f.users.createUser({
username: 'self-recover',
password: 'originalpass',
oauth: { type: 'KAKAO', id: 'old-self-kakao', email: 'owner@example.test', info: {} },
});
if (options.privileged) await f.users.updateRoles(user.id, ['admin']);
user.passwordResetRequired = options.resetPassword ?? false;
user.kakaoTalkVerifiedUntil = new Date(Date.now() + 86400000).toISOString();
const old = await f.sessions.createSession(user);
const start = await f.caller.auth.kakaoStart({ mode: 'login' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
return { ...f, user, old, choice };
};
const otpCode = (f: { sentTalkMessages: string[] }): string =>
f.sentTalkMessages.at(-1)!.match(/인증 코드는 (\d{4})/)![1]!;
it('recovers an ordinary matching-email account without approval and only commits after fresh OTP', async () => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected confirmation.');
const oldChallenge = await f.oauthSessions.createLoginChallenge({
userId: f.user.id,
code: '1111',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180000).toISOString(),
createdAt: new Date().toISOString(),
authRevision: 0,
oauthId: 'old-self-kakao',
});
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected fresh OTP despite a cached proof.');
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull();
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull();
await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: '0000' })).rejects.toBeDefined();
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
expect((await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).status).toBe('login');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).toBeNull();
await expect(f.caller.auth.kakaoOtp({ challengeId: oldChallenge.id, code: '1111' })).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBe(f.user.id);
expect(await f.users.isKakaoIdentityRetired('old-self-kakao')).toBe(false);
await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).rejects.toBeDefined();
f.kakaoProfile.id = 'old-self-kakao';
const start = await f.caller.auth.kakaoStart({ mode: 'login' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
if (choice.status !== 'account_recovery') throw new Error('Expected same-owner recovery.');
const back = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: choice.oauthSessionId,
action: 'link_existing',
});
if (back.status !== 'otp') throw new Error('Expected OTP.');
await f.caller.auth.kakaoOtp({ challengeId: back.challengeId, code: otpCode(f) });
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
await expect(
f.users.createUser({
username: 'claim-other',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'new-self-kakao', email: 'other@example.test', info: {} },
})
).rejects.toThrow();
});
it('accepts the old password for a different email, keeps retries possible and verifies new talk ownership', async () => {
const f = await prepare({ differentEmail: true });
if (f.choice.status !== 'join') throw new Error('Expected no email match.');
await expect(
f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('wrongpass'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
const otp = await f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect((await f.users.findById(f.user.id))?.email).toBe('owner@example.test');
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) });
expect(await f.users.findById(f.user.id)).toMatchObject({
oauthId: 'new-self-kakao',
email: 'new@example.test',
});
});
it('keeps migrated passwords and old identities intact until the final OTP commit', async () => {
const f = await prepare({ resetPassword: true });
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
const setup = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (setup.status !== 'password_setup') throw new Error('Expected password setup.');
const hash = f.user.passwordHash;
const otp = await f.caller.auth.kakaoSetPassword({
oauthSessionId: setup.oauthSessionId,
credential: f.sealPassword('new-valid-pass'),
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect(f.user.passwordHash).toBe(hash);
expect(f.user.passwordResetRequired).toBe(true);
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) });
expect(f.user.passwordResetRequired).toBe(false);
expect(await f.users.verifyPassword(f.user, 'new-valid-pass')).toBe(true);
});
it('still requires approval for privileged accounts even with the password', async () => {
const f = await prepare({ privileged: true });
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
await expect(
f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(f.user.oauthId).toBe('old-self-kakao');
});
it.each(['roles', 'password', 'sanction', 'identity', 'expired', 'provider'] as const)(
'does not commit if %s changes during OTP',
async (change) => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
if (change === 'roles') await f.users.updateRoles(f.user.id, ['admin']);
if (change === 'password') await f.users.updatePassword(f.user.id, 'changed-elsewhere');
if (change === 'sanction')
await f.users.updateSanctions(f.user.id, {
bannedUntil: new Date(Date.now() + 86400000).toISOString(),
});
if (change === 'identity') f.user.authRevision = 1;
if (change === 'expired') {
const challenge = await f.oauthSessions.getLoginChallengeForUser(f.user.id);
const pending = await f.oauthSessions.getSession(challenge!.oauthSessionId!);
pending!.createdAt = new Date(Date.now() - 301000).toISOString();
}
if (change === 'provider') f.kakaoProfile.id = 'another-kakao';
await expect(
f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })
).rejects.toBeDefined();
expect(f.user.oauthId).toBe('old-self-kakao');
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull();
}
);
it('does not change any identity when sending the new talk challenge fails', async () => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
vi.spyOn(f.kakaoClient, 'sendTalkMessage').mockRejectedValue(new Error('synthetic provider failure'));
await expect(
f.caller.auth.kakaoResolveAccount({ oauthSessionId: f.choice.oauthSessionId, action: 'link_existing' })
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '카카오톡 인증 코드를 보내지 못했습니다.' });
expect(f.user.oauthId).toBe('old-self-kakao');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull();
});
it('checks password-based recovery over real HTTP before issuing the new challenge', async () => {
const f = await prepare({ differentEmail: true });
if (f.choice.status !== 'join') throw new Error('Expected join fallback.');
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/gateway/api/trpc',
trpcOptions: { router: appRouter, ...trpcJsonBodyHttpServerOptions, createContext: () => f.context },
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (name: string, input: object) =>
fetch(`${address}/gateway/api/trpc/${name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
expect(
(
await post('auth.kakaoLinkWithPassword', {
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('incorrect'),
})
).status
).toBe(401);
expect(
(
await post('auth.kakaoLinkWithPassword', {
oauthSessionId: 'invalid',
username: f.user.username,
credential: f.sealPassword('originalpass'),
})
).status
).toBe(400);
const response = await post('auth.kakaoLinkWithPassword', {
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
});
expect(response.status).toBe(200);
const data = (await response.json()) as { result: { data: { status: string; challengeId: string } } };
expect(data.result.data.status).toBe('otp');
expect(f.user.oauthId).toBe('old-self-kakao');
expect(
(await post('auth.kakaoOtp', { challengeId: data.result.data.challengeId, code: otpCode(f) })).status
).toBe(200);
expect(f.user.oauthId).toBe('new-self-kakao');
} finally {
await app.close();
}
});
});
@@ -104,6 +104,34 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
}
});
it('preserves the exact pending OAuth session and old identity revision in a one-use challenge', async () => {
const challenge = await store.createLoginChallenge({
userId: randomUUID(),
code: '1234',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180000).toISOString(),
createdAt: new Date().toISOString(),
oauthSessionId: randomUUID(),
authRevision: 7,
oauthId: 'identity-before-change',
});
userIds.add(challenge.userId);
challengeIds.add(challenge.id);
const results = await Promise.all([
store.verifyLoginChallenge(challenge.id, '1234'),
store.verifyLoginChallenge(challenge.id, '1234'),
]);
expect(results.filter((r) => r.status === 'verified')).toEqual([
{
status: 'verified',
userId: challenge.userId,
oauthSessionId: challenge.oauthSessionId,
authRevision: 7,
oauthId: 'identity-before-change',
},
]);
});
it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge();
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20261022000000_add_open_requests',
gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
});
});