fix(auth): resume Kakao OTP during auto login
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import { assert } from 'chai';
|
||||||
|
import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '../ts/gateway/loginFlow';
|
||||||
|
|
||||||
|
describe('automatic login failure flow', () => {
|
||||||
|
it('prompts for OTP without treating the challenge as a terminal login failure', () => {
|
||||||
|
assert.equal(
|
||||||
|
classifyAutoLoginFailure({
|
||||||
|
result: false,
|
||||||
|
silent: false,
|
||||||
|
reqOTP: true,
|
||||||
|
reason: '인증 코드를 입력해주세요',
|
||||||
|
}, 0),
|
||||||
|
'prompt_otp'
|
||||||
|
);
|
||||||
|
assert.equal(OTP_REQUIRED_MESSAGE, '인증 코드 입력이 필요합니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing retry and alert behavior for non-OTP failures', () => {
|
||||||
|
assert.equal(
|
||||||
|
classifyAutoLoginFailure({
|
||||||
|
result: false,
|
||||||
|
silent: false,
|
||||||
|
reason: '자동 로그인: 절차 오류',
|
||||||
|
}, 0),
|
||||||
|
'retry'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
classifyAutoLoginFailure({
|
||||||
|
result: false,
|
||||||
|
silent: false,
|
||||||
|
reason: '로그인할 수 없습니다.',
|
||||||
|
}, 1),
|
||||||
|
'alert'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
classifyAutoLoginFailure({
|
||||||
|
result: false,
|
||||||
|
silent: true,
|
||||||
|
reason: 'failed',
|
||||||
|
}, 1),
|
||||||
|
'silent'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,5 +36,6 @@ export type AutoLoginResponse = {
|
|||||||
export type AutoLoginFailed = {
|
export type AutoLoginFailed = {
|
||||||
result: false,
|
result: false,
|
||||||
silent: boolean,
|
silent: boolean,
|
||||||
|
reqOTP?: boolean,
|
||||||
reason: string,
|
reason: string,
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-25
@@ -12,8 +12,9 @@ import { delay } from '@util/delay';
|
|||||||
import { Modal } from 'bootstrap';
|
import { Modal } from 'bootstrap';
|
||||||
import '@/gateway/common';
|
import '@/gateway/common';
|
||||||
import { isString } from 'lodash-es';
|
import { isString } from 'lodash-es';
|
||||||
import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI';
|
import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI';
|
||||||
import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login';
|
import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login';
|
||||||
|
import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '@/gateway/loginFlow';
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
getOAuthToken: (mode: string, scope_list: string[]) => void;
|
getOAuthToken: (mode: string, scope_list: string[]) => void;
|
||||||
@@ -51,9 +52,20 @@ function getToken(): [number, string] | undefined {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetToken() {
|
function resetToken() {
|
||||||
localStorage.removeItem(LOGIN_TOKEN_KEY);
|
localStorage.removeItem(LOGIN_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showOTPModal() {
|
||||||
|
const modalEl = unwrap(document.querySelector('#modalOTP'));
|
||||||
|
if (!modalOTP) {
|
||||||
|
modalOTP = new Modal(modalEl);
|
||||||
|
modalEl.addEventListener('shown.bs.modal', function () {
|
||||||
|
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modalOTP.show();
|
||||||
|
}
|
||||||
|
|
||||||
async function tryAutoLogin() {
|
async function tryAutoLogin() {
|
||||||
try {
|
try {
|
||||||
@@ -93,13 +105,18 @@ async function tryAutoLogin() {
|
|||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
if (!loginResult.result) {
|
if (!loginResult.result) {
|
||||||
if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) {
|
const failureAction = classifyAutoLoginFailure(loginResult, attempt);
|
||||||
|
if (failureAction === 'retry') {
|
||||||
console.warn('auto login failed by procedure error. retrying once.');
|
console.warn('auto login failed by procedure error. retrying once.');
|
||||||
await delay(150);
|
await delay(150);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (failureAction === 'prompt_otp') {
|
||||||
if (!loginResult.silent) {
|
alert(OTP_REQUIRED_MESSAGE);
|
||||||
|
showOTPModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (failureAction === 'alert') {
|
||||||
alert(loginResult.reason);
|
alert(loginResult.reason);
|
||||||
}
|
}
|
||||||
console.error(loginResult.reason);
|
console.error(loginResult.reason);
|
||||||
@@ -208,14 +225,7 @@ async function doLoginUsingOAuth() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalEl = unwrap(document.querySelector('#modalOTP'))
|
showOTPModal();
|
||||||
if (!modalOTP) {
|
|
||||||
modalOTP = new Modal(modalEl);
|
|
||||||
modalEl.addEventListener('shown.bs.modal', function () {
|
|
||||||
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
modalOTP.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function postOAuthResult(mode: string) {
|
function postOAuthResult(mode: string) {
|
||||||
@@ -314,14 +324,7 @@ $(async function ($) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalEl = unwrap(document.querySelector('#modalOTP'))
|
showOTPModal();
|
||||||
if (!modalOTP) {
|
|
||||||
modalOTP = new Modal(modalEl);
|
|
||||||
modalEl.addEventListener('shown.bs.modal', function () {
|
|
||||||
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
modalOTP.show();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#otp_form').on('submit', async function (e) {
|
$('#otp_form').on('submit', async function (e) {
|
||||||
@@ -393,4 +396,4 @@ window.fitIframe = function () {
|
|||||||
const iframe = unwrap(document.querySelector<HTMLIFrameElement>('#running_map'));//TODO: 근황 여러개 볼 수 있도록?
|
const iframe = unwrap(document.querySelector<HTMLIFrameElement>('#running_map'));//TODO: 근황 여러개 볼 수 있도록?
|
||||||
const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight;
|
const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight;
|
||||||
iframe.style.height = `${scrollHeight}px`;
|
iframe.style.height = `${scrollHeight}px`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AutoLoginFailed } from '@/defs/API/Login';
|
||||||
|
|
||||||
|
export const OTP_REQUIRED_MESSAGE = '인증 코드 입력이 필요합니다.';
|
||||||
|
|
||||||
|
export type AutoLoginFailureAction = 'retry' | 'prompt_otp' | 'alert' | 'silent';
|
||||||
|
|
||||||
|
export function classifyAutoLoginFailure(result: AutoLoginFailed, attempt: number): AutoLoginFailureAction {
|
||||||
|
if (result.reason === '자동 로그인: 절차 오류' && attempt === 0) {
|
||||||
|
return 'retry';
|
||||||
|
}
|
||||||
|
if (result.reqOTP) {
|
||||||
|
return 'prompt_otp';
|
||||||
|
}
|
||||||
|
if (result.silent) {
|
||||||
|
return 'silent';
|
||||||
|
}
|
||||||
|
return 'alert';
|
||||||
|
}
|
||||||
@@ -19,6 +19,30 @@ class LoginByToken extends LoginByID
|
|||||||
{
|
{
|
||||||
static array $sensitiveArgs = ['hashedToken'];
|
static array $sensitiveArgs = ['hashedToken'];
|
||||||
|
|
||||||
|
public static function kakaoFailureResponse(bool $reqOTP, string $reason): string | array
|
||||||
|
{
|
||||||
|
if (!$reqOTP) {
|
||||||
|
return $reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'result' => false,
|
||||||
|
'silent' => false,
|
||||||
|
'reqOTP' => true,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shouldDiscardTokenAfterKakaoFailure(bool $reqOTP): bool
|
||||||
|
{
|
||||||
|
return !$reqOTP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pendingSessionTokenID(bool $reqOTP, int $tokenID): ?int
|
||||||
|
{
|
||||||
|
return $reqOTP ? $tokenID : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function getRequiredSessionMode(): int
|
public function getRequiredSessionMode(): int
|
||||||
{
|
{
|
||||||
return \sammo\BaseAPI::NO_LOGIN;
|
return \sammo\BaseAPI::NO_LOGIN;
|
||||||
@@ -117,13 +141,24 @@ class LoginByToken extends LoginByID
|
|||||||
if ($userInfo['oauth_type'] == 'KAKAO') {
|
if ($userInfo['oauth_type'] == 'KAKAO') {
|
||||||
$oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo);
|
$oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo);
|
||||||
if ($oauthFailResult !== null) {
|
if ($oauthFailResult !== null) {
|
||||||
$session->login($userInfo['no'], $userInfo['name'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}'));
|
|
||||||
[$oauthReqOTP, $oauthFailReason] = $oauthFailResult;
|
[$oauthReqOTP, $oauthFailReason] = $oauthFailResult;
|
||||||
$RootDB->delete(
|
$session->login(
|
||||||
'login_token',
|
$userInfo['no'],
|
||||||
'id = %i', $token_id
|
$userInfo['name'],
|
||||||
|
$userInfo['grade'],
|
||||||
|
true,
|
||||||
|
$userInfo['token_valid_until'],
|
||||||
|
static::pendingSessionTokenID($oauthReqOTP, $token_id),
|
||||||
|
Json::decode($userInfo['acl'] ?? '{}')
|
||||||
);
|
);
|
||||||
return $oauthFailReason;
|
if (static::shouldDiscardTokenAfterKakaoFailure($oauthReqOTP)) {
|
||||||
|
$RootDB->delete(
|
||||||
|
'login_token',
|
||||||
|
'id = %i',
|
||||||
|
$token_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return static::kakaoFailureResponse($oauthReqOTP, $oauthFailReason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace sammo\API\Login;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../src/sammo/API/Login/LoginByID.php';
|
||||||
|
require_once __DIR__ . '/../src/sammo/API/Login/LoginByToken.php';
|
||||||
|
|
||||||
|
final class LoginByTokenKakaoFailureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testOtpChallengePreservesTokenAndReturnsPromptMetadata(): void
|
||||||
|
{
|
||||||
|
self::assertFalse(LoginByToken::shouldDiscardTokenAfterKakaoFailure(true));
|
||||||
|
self::assertSame(321, LoginByToken::pendingSessionTokenID(true, 321));
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'result' => false,
|
||||||
|
'silent' => false,
|
||||||
|
'reqOTP' => true,
|
||||||
|
'reason' => '인증 코드를 입력해주세요',
|
||||||
|
],
|
||||||
|
LoginByToken::kakaoFailureResponse(true, '인증 코드를 입력해주세요')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonOtpKakaoFailureKeepsLegacyErrorAndDiscardsToken(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(LoginByToken::shouldDiscardTokenAfterKakaoFailure(false));
|
||||||
|
self::assertNull(LoginByToken::pendingSessionTokenID(false, 321));
|
||||||
|
self::assertSame(
|
||||||
|
'카카오 API 앱이 등록되지 않았습니다.',
|
||||||
|
LoginByToken::kakaoFailureResponse(false, '카카오 API 앱이 등록되지 않았습니다.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {createRequire} from 'node:module';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const artifactDir = process.env.REF_AUTO_LOGIN_OTP_ARTIFACT_DIR;
|
||||||
|
if (!artifactDir) {
|
||||||
|
throw new Error('REF_AUTO_LOGIN_OTP_ARTIFACT_DIR is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const playwrightRequire = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
|
||||||
|
let chromium;
|
||||||
|
try {
|
||||||
|
({chromium} = playwrightRequire('playwright'));
|
||||||
|
} catch {
|
||||||
|
({chromium} = playwrightRequire('@playwright/test'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundleDir = path.join(projectRoot, 'dist_js/gateway');
|
||||||
|
const loginToken = [1, [321, 'browser-test-token'], Date.now()];
|
||||||
|
let autoLoginCount = 0;
|
||||||
|
let manualLoginBody = null;
|
||||||
|
let otpBody = '';
|
||||||
|
let otpAccepted = false;
|
||||||
|
|
||||||
|
const loginHTML = `<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>자동로그인 OTP 검증</title>
|
||||||
|
<script>var kakao_oauth_client_id = ''; var kakao_oauth_redirect_uri = '';</script>
|
||||||
|
<link rel="stylesheet" href="/dist_js/gateway/common_ts.css">
|
||||||
|
<link rel="stylesheet" href="/dist_js/gateway/login.css">
|
||||||
|
<script src="/dist_js/gateway/vendors.js"></script>
|
||||||
|
<script src="/dist_js/gateway/login.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container" style="margin-top:120px;max-width:450px">
|
||||||
|
<form id="main_form" method="post" action="#">
|
||||||
|
<label for="username">계정명</label>
|
||||||
|
<input id="username" name="username" type="text">
|
||||||
|
<label for="password">비밀번호</label>
|
||||||
|
<input id="password" name="password" type="password">
|
||||||
|
<input id="global_salt" name="global_salt" type="hidden" value="browser-test-salt">
|
||||||
|
<button type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
<button id="btn_kakao_login" type="button">카카오 로그인</button>
|
||||||
|
<a id="oauth_change_pw" href="#">비밀번호 초기화</a>
|
||||||
|
</main>
|
||||||
|
<div class="modal fade" id="modalOTP" tabindex="-1" role="dialog" aria-labelledby="otp-title" aria-hidden="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form id="otp_form" method="post" action="#">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="otp-title">인증 코드 필요</h5>
|
||||||
|
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div>인증 코드가 필요합니다.<br><br>카카오톡의 '나와의 채팅'란을 확인해 주세요.</div>
|
||||||
|
<label for="otp_code">인증 코드</label>
|
||||||
|
<input type="number" class="form-control" name="otp" id="otp_code" placeholder="인증 코드">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="submit" class="btn btn-primary">제출</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const json = (response, body) => {
|
||||||
|
response.writeHead(200, {'content-type': 'application/json; charset=utf-8'});
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBody = async request => {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of request) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||||
|
if (requestURL.pathname === '/') {
|
||||||
|
response.writeHead(200, {'content-type': 'text/html; charset=utf-8'});
|
||||||
|
response.end(otpAccepted ? '<!doctype html><p id="logged-in">로그인 완료</p>' : loginHTML);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestURL.pathname.startsWith('/dist_js/gateway/')) {
|
||||||
|
const fileName = path.basename(requestURL.pathname);
|
||||||
|
const filePath = path.join(bundleDir, fileName);
|
||||||
|
const contentType = fileName.endsWith('.css') ? 'text/css' : 'text/javascript';
|
||||||
|
response.writeHead(200, {'content-type': `${contentType}; charset=utf-8`});
|
||||||
|
response.end(fs.readFileSync(filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestURL.pathname === '/api.php') {
|
||||||
|
const apiPath = requestURL.searchParams.get('path');
|
||||||
|
const rawBody = await readBody(request);
|
||||||
|
if (apiPath === 'Login/ReqNonce') {
|
||||||
|
json(response, {result: true, loginNonce: `nonce-${autoLoginCount + 1}`});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (apiPath === 'Login/LoginByToken') {
|
||||||
|
autoLoginCount += 1;
|
||||||
|
json(response, {
|
||||||
|
result: false,
|
||||||
|
silent: false,
|
||||||
|
reqOTP: true,
|
||||||
|
reason: '인증 코드를 입력해주세요',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (apiPath === 'Login/LoginByID') {
|
||||||
|
manualLoginBody = JSON.parse(rawBody);
|
||||||
|
json(response, {result: false, reqOTP: false, reason: '수동 로그인 요청 확인'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (requestURL.pathname === '/oauth_kakao/j_check_OTP.php') {
|
||||||
|
otpBody = await readBody(request);
|
||||||
|
otpAccepted = true;
|
||||||
|
json(response, {result: true, reset: false, validUntil: '2026-08-18 00:00:00'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
assert(address && typeof address !== 'string');
|
||||||
|
const baseURL = `http://127.0.0.1:${address.port}/`;
|
||||||
|
|
||||||
|
const browser = await chromium.launch({headless: true});
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: {width: 1280, height: 960},
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
});
|
||||||
|
await context.addInitScript(token => {
|
||||||
|
if (!localStorage.getItem('sammo_login_token')) {
|
||||||
|
localStorage.setItem('sammo_login_token', JSON.stringify(token));
|
||||||
|
}
|
||||||
|
}, loginToken);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const openAndAcceptOtpPrompt = async navigation => {
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
const navigationPromise = navigation();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
assert.equal(dialog.message(), '인증 코드 입력이 필요합니다.');
|
||||||
|
await dialog.accept();
|
||||||
|
await navigationPromise;
|
||||||
|
await page.locator('#modalOTP.show').waitFor({state: 'visible'});
|
||||||
|
await page.waitForFunction(() => document.activeElement?.id === 'otp_code');
|
||||||
|
};
|
||||||
|
|
||||||
|
await openAndAcceptOtpPrompt(() => page.goto(baseURL, {waitUntil: 'domcontentloaded'}));
|
||||||
|
assert.equal(autoLoginCount, 1);
|
||||||
|
assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken);
|
||||||
|
|
||||||
|
fs.mkdirSync(artifactDir, {recursive: true});
|
||||||
|
const screenshotPath = path.join(artifactDir, 'auto-login-otp-modal.png');
|
||||||
|
await page.screenshot({path: screenshotPath, fullPage: true});
|
||||||
|
const modalGeometry = await page.locator('#modalOTP .modal-dialog').evaluate(element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height},
|
||||||
|
display: style.display,
|
||||||
|
opacity: style.opacity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', {name: '취소'}).click();
|
||||||
|
await page.locator('#modalOTP').waitFor({state: 'hidden'});
|
||||||
|
assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken);
|
||||||
|
|
||||||
|
await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'}));
|
||||||
|
assert.equal(autoLoginCount, 2);
|
||||||
|
await page.getByRole('button', {name: '취소'}).click();
|
||||||
|
await page.locator('#modalOTP').waitFor({state: 'hidden'});
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
|
await page.locator('#username').fill('manual-user');
|
||||||
|
await page.locator('#password').fill('manual-password');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const manualDialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('#main_form').evaluate(form => form.requestSubmit());
|
||||||
|
const manualDialog = await manualDialogPromise;
|
||||||
|
assert.equal(manualDialog.message(), '수동 로그인 요청 확인');
|
||||||
|
await manualDialog.accept();
|
||||||
|
assert.equal(manualLoginBody?.username, 'manual-user');
|
||||||
|
assert.match(manualLoginBody?.password ?? '', /^[0-9a-f]{128}$/);
|
||||||
|
|
||||||
|
await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'}));
|
||||||
|
assert.equal(autoLoginCount, 3);
|
||||||
|
await page.locator('#otp_code').fill('1234');
|
||||||
|
const successDialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.getByRole('button', {name: '제출'}).click();
|
||||||
|
const successDialog = await successDialogPromise;
|
||||||
|
assert.equal(successDialog.message(), '로그인되었습니다. 2026-08-18 00:00:00까지 유효합니다.');
|
||||||
|
await successDialog.accept();
|
||||||
|
await page.locator('#logged-in').waitFor({state: 'visible'});
|
||||||
|
assert.match(otpBody, /name="otp"/);
|
||||||
|
assert.match(otpBody, /\r\n1234\r\n/);
|
||||||
|
|
||||||
|
const resultPath = path.join(artifactDir, 'auto-login-otp-result.json');
|
||||||
|
fs.writeFileSync(resultPath, `${JSON.stringify({
|
||||||
|
url: baseURL,
|
||||||
|
viewport: {width: 1280, height: 960, deviceScaleFactor: 1},
|
||||||
|
autoLoginCount,
|
||||||
|
preservedTokenAfterCancel: true,
|
||||||
|
reopenedAfterReload: true,
|
||||||
|
manualLoginSubmitted: true,
|
||||||
|
otpSuccessNavigated: true,
|
||||||
|
modalGeometry,
|
||||||
|
screenshotPath,
|
||||||
|
}, null, 2)}\n`, {mode: 0o600});
|
||||||
|
|
||||||
|
console.log(`automatic-login OTP Chromium flow verified: ${resultPath}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user