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'; 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) | 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.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', 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?.kakaoIdentityLinkHistory.deleteMany({ where: { userId: { in: [firstUserId, secondUserId, selfUserId] } }, }); await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } }); 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(); }); it('cancels only the authenticated deletion snapshot once and persists the new auth revision', async () => { const users = createPostgresUserRepository(db); const deadline = new Date(Date.now() + 86400000); await users.scheduleDeletion(firstUserId, deadline); const before = await users.findById(firstUserId); const revision = before?.authRevision ?? 0; expect(await users.cancelDeletion(firstUserId, new Date(deadline.getTime() - 1).toISOString(), revision)).toBe( false ); expect(await users.cancelDeletion(firstUserId, deadline.toISOString(), revision + 1)).toBe(false); const results = await Promise.all([ users.cancelDeletion(firstUserId, deadline.toISOString(), revision), users.cancelDeletion(firstUserId, deadline.toISOString(), revision), ]); expect(results.filter(Boolean)).toHaveLength(1); const restored = await users.findById(firstUserId); expect(restored?.deleteAfter).toBeUndefined(); expect(restored?.authRevision).toBe(revision + 1); 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(); }); });