Files
core2026/app/gateway-api/test/accountIdentity.integration.test.ts
T

164 lines
7.1 KiB
TypeScript

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();
});
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);
});
});