219 lines
9.4 KiB
TypeScript
219 lines
9.4 KiB
TypeScript
import { generateKeyPairSync } from 'node:crypto';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
|
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
|
|
const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR ? resolve(process.env.KAKAO_OTP_ARTIFACT_DIR) : null;
|
|
const response = (data: unknown) => ({ result: { data } });
|
|
const errorResponse = (path: string, message: string) => ({
|
|
error: {
|
|
message,
|
|
code: -32001,
|
|
data: {
|
|
code: 'UNAUTHORIZED',
|
|
httpStatus: 401,
|
|
path,
|
|
},
|
|
},
|
|
});
|
|
const operationNames = (route: Route): string[] => {
|
|
const url = new URL(route.request().url());
|
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
|
};
|
|
|
|
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
const challenge = {
|
|
status: 'otp' as const,
|
|
challengeId: '11111111-1111-4111-8111-111111111111',
|
|
expiresAt: '2026-08-08T06:00:00.000Z',
|
|
attemptsRemaining: 3,
|
|
};
|
|
|
|
const installFixture = async (page: Page, source: 'password' | 'oauth') => {
|
|
let loggedIn = false;
|
|
let otpAttempts = 0;
|
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
|
const operations = operationNames(route);
|
|
const results = await Promise.all(
|
|
operations.map(async (operation) => {
|
|
if (operation === 'me') {
|
|
return response(
|
|
loggedIn
|
|
? {
|
|
id: 'kakao-otp-user',
|
|
username: 'kakao-otp-user',
|
|
displayName: '카카오 인증 사용자',
|
|
roles: [],
|
|
picture: 'default.jpg',
|
|
kakaoVerified: true,
|
|
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
|
|
createdAt: '2026-08-08T00:00:00.000Z',
|
|
}
|
|
: null
|
|
);
|
|
}
|
|
if (operation === 'lobby.notice') return response('');
|
|
if (operation === 'lobby.profiles') return response([]);
|
|
if (operation === 'auth.passwordKey') {
|
|
return response({ keyId: 'playwright-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
|
|
}
|
|
if (operation === 'auth.login') return response({ ...challenge, successStatus: 'login' });
|
|
if (operation === 'auth.kakaoExchange') {
|
|
return response({ ...challenge, successStatus: source === 'oauth' ? 'verified' : 'login' });
|
|
}
|
|
if (operation === 'auth.kakaoOtp') {
|
|
otpAttempts += 1;
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 800));
|
|
if (otpAttempts === 1) {
|
|
return errorResponse(operation, '인증 번호가 틀렸습니다. 2회 더 시도할 수 있습니다.');
|
|
}
|
|
loggedIn = true;
|
|
return response({
|
|
status: 'login',
|
|
user: {
|
|
id: 'kakao-otp-user',
|
|
username: 'kakao-otp-user',
|
|
displayName: '카카오 인증 사용자',
|
|
roles: [],
|
|
picture: 'default.jpg',
|
|
kakaoVerified: true,
|
|
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
|
|
createdAt: '2026-08-08T00:00:00.000Z',
|
|
},
|
|
sessionToken: 'verified-session-token',
|
|
issuedAt: '2026-08-08T05:57:00.000Z',
|
|
validUntil: '2026-08-18T05:57:00.000Z',
|
|
});
|
|
}
|
|
throw new Error(`Unhandled Kakao OTP fixture operation: ${operation}`);
|
|
})
|
|
);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(results),
|
|
});
|
|
});
|
|
return { otpAttempts: () => otpAttempts };
|
|
};
|
|
|
|
const verifyDialog = async (page: Page, artifactName: string) => {
|
|
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
|
|
const input = dialog.getByLabel('인증 코드');
|
|
const submit = dialog.getByRole('button', { name: '제출' });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog).toContainText("카카오톡의 '나와의 채팅'란을 확인해 주세요.");
|
|
await expect(input).toBeFocused();
|
|
|
|
const geometry = await dialog.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
backgroundColor: style.backgroundColor,
|
|
border: style.border,
|
|
fontSize: style.fontSize,
|
|
};
|
|
});
|
|
expect(geometry.width).toBeLessThanOrEqual(500);
|
|
expect(geometry.width).toBeGreaterThan(350);
|
|
expect(geometry.backgroundColor).toBe('rgb(48, 48, 48)');
|
|
expect(geometry.border).toBe('1px solid rgb(68, 68, 68)');
|
|
|
|
await submit.hover();
|
|
await page.waitForTimeout(200);
|
|
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(55, 90, 127)');
|
|
await input.press('Tab');
|
|
await page.keyboard.press('Tab');
|
|
await expect(submit).toBeFocused();
|
|
await page.waitForTimeout(200);
|
|
expect(await submit.evaluate((element) => getComputedStyle(element).boxShadow)).toMatch(
|
|
/^rgba\(85, 115, 146, 0\.49\d\) 0px 0px 0px 4px$/
|
|
);
|
|
|
|
if (artifactRoot) {
|
|
await mkdir(artifactRoot, { recursive: true });
|
|
await page.screenshot({ path: resolve(artifactRoot, `${artifactName}.png`), fullPage: true });
|
|
await writeFile(
|
|
resolve(artifactRoot, `${artifactName}.json`),
|
|
`${JSON.stringify(geometry, null, 2)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
const verifyPointerActive = async () => {
|
|
const box = await submit.boundingBox();
|
|
if (!box) throw new Error('OTP submit button has no rendered geometry.');
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.down();
|
|
expect(await submit.evaluate((element) => element.matches(':active'))).toBe(true);
|
|
await page.waitForTimeout(200);
|
|
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(44, 72, 102)');
|
|
await page.mouse.move(1, 1);
|
|
await page.mouse.up();
|
|
};
|
|
const submitAndObserveDisabled = () =>
|
|
submit.evaluate(
|
|
(element) =>
|
|
new Promise<{ disabled: boolean; opacity: string }>((resolveDisabled) => {
|
|
(element as HTMLButtonElement).click();
|
|
setTimeout(
|
|
() =>
|
|
resolveDisabled({
|
|
disabled: (element as HTMLButtonElement).disabled,
|
|
opacity: getComputedStyle(element).opacity,
|
|
}),
|
|
200
|
|
);
|
|
})
|
|
);
|
|
|
|
await input.fill('0000');
|
|
await verifyPointerActive();
|
|
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
|
|
await expect(dialog.getByRole('alert')).toContainText('2회 더 시도');
|
|
await expect(input).toBeFocused();
|
|
|
|
await input.fill('1234');
|
|
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
|
|
await expect(page).toHaveURL(/\/gateway\/lobby(?:\?verified=1)?$/);
|
|
await expect
|
|
.poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token')))
|
|
.toBe('verified-session-token');
|
|
return geometry;
|
|
};
|
|
|
|
for (const viewport of [
|
|
{ name: 'desktop', width: 1200, height: 900 },
|
|
{ name: 'mobile', width: 390, height: 844 },
|
|
] as const) {
|
|
test(`completes password-login KakaoTalk OTP on ${viewport.name}`, async ({ page }) => {
|
|
const fixture = await installFixture(page, 'password');
|
|
await page.setViewportSize(viewport);
|
|
await page.goto('/gateway/');
|
|
await page.getByLabel('계정명').fill('kakao-otp-user');
|
|
await page.getByLabel('비밀번호').fill('password-for-browser-fixture');
|
|
await page.getByRole('button', { name: '로그인', exact: true }).click();
|
|
|
|
const geometry = await verifyDialog(page, `kakao-otp-password-${viewport.name}`);
|
|
expect(geometry.width).toBe(viewport.name === 'desktop' ? 500 : 374);
|
|
expect(geometry.y).toBe(viewport.name === 'desktop' ? 28 : 8);
|
|
expect(fixture.otpAttempts()).toBe(2);
|
|
});
|
|
}
|
|
|
|
test('completes the same KakaoTalk OTP flow after OAuth callback', async ({ page }) => {
|
|
const fixture = await installFixture(page, 'oauth');
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
|
|
|
await verifyDialog(page, 'kakao-otp-oauth-callback');
|
|
await expect(page).toHaveURL(/\/gateway\/lobby\?verified=1$/);
|
|
expect(fixture.otpAttempts()).toBe(2);
|
|
});
|