merge: validate invader resume watchdog fix

This commit is contained in:
2026-08-12 15:35:57 +00:00
37 changed files with 1778 additions and 131 deletions
+23
View File
@@ -5,6 +5,7 @@ use sammo\CentennialAllStarGrowth;
use sammo\CentennialAllStarGrowthService;
use sammo\General;
use sammo\GameConst;
use sammo\InheritancePointManager;
$loader = require __DIR__ . '/../vendor/autoload.php';
$loader->addPsr4('sammo\\', __DIR__ . '/../hwe/sammo', true);
@@ -371,6 +372,28 @@ final class CentennialAllStarGrowthTest extends TestCase
self::assertSame(900000, CentennialAllStarGrowth::dexFloor(900000, 1));
}
public function testSynchronizedDexDoesNotIncreaseInheritancePoint(): void
{
$vars = $this->emptyGeneralVars();
$vars['dex1'] = 1_300_000;
$vars['dex2'] = 1_300_000;
$aux = CentennialAllStarGrowthService::initialAux(
$this->singleDexTarget('inheritance', 0),
[]
);
$aux['granted']['dex1'] = 1_000_000;
$general = $this->createStateGeneralMock($vars, $aux);
self::assertSame(
1_400.0,
InheritancePointManager::getInstance()->getDexInheritancePoint(
$general,
1_000_000,
[1, 2, 3, 4, 5]
)
);
}
public function testNpcDexStopsAtFortyPercentOfHistoricalTarget(): void
{
$target = 900000;
+51 -1
View File
@@ -146,14 +146,64 @@ final class GameClockBoundaryTest extends TestCase
self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue'));
}
public function testGatewayFormatsLogicalOpenTimeBeforeReturningIt(): void
public function testGatewayReadsClockOnlyAfterClosedReservationResponse(): void
{
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
self::assertIsString($source);
$closedBranch = strpos($source, "if(file_exists(__DIR__.'/.htaccess'))");
$closedBranchEnd = strpos($source, '//TODO: 천통시에도 예약 오픈 알림이 필요..?');
$clockDetection = strpos($source, 'GameClock::isInitialized($gameStor)');
$clockRead = strpos($source, 'GameClock::fromStorage($gameStor)');
self::assertNotFalse($closedBranch);
self::assertNotFalse($closedBranchEnd);
self::assertNotFalse($clockDetection);
self::assertNotFalse($clockRead);
self::assertGreaterThan($closedBranch, $closedBranchEnd);
self::assertGreaterThan($closedBranchEnd, $clockDetection);
self::assertGreaterThan($closedBranchEnd, $clockRead);
}
public function testGatewayPreservesWallClockProfilesAndFormatsLogicalOpenTime(): void
{
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
self::assertIsString($source);
self::assertStringContainsString(
'$usesLogicalClock = GameClock::isInitialized($gameStor);',
$source,
);
self::assertStringContainsString(
'$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));',
$source,
);
self::assertStringContainsString(
'$admin[\'isOpen\'] = new \\DateTimeImmutable((string)$admin[\'opentime\']) <= GameClock::readWallTime();',
$source,
);
}
public function testNationGeneralListProjectsStoredTimesAtApiBoundary(): void
{
$source = file_get_contents(__DIR__ . '/../hwe/sammo/API/Nation/GeneralList.php');
self::assertIsString($source);
self::assertStringContainsString(
'$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;',
$source,
);
self::assertStringContainsString(
'$env[\'turntime\'] = $formatStoredTime($env[\'turntime\']);',
$source,
);
self::assertStringContainsString(
'$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID][\'turntime\']);',
$source,
);
self::assertStringContainsString(
"? (string)\$value",
$source,
);
}
}
+4
View File
@@ -109,6 +109,10 @@ final class GameClockTest extends TestCase
]);
self::assertFalse(GameClock::isInitialized($legacyStorage));
$reservedResetStorage = $this->createMock(KVStorage::class);
$reservedResetStorage->method('getValues')->willReturn([]);
self::assertFalse(GameClock::isInitialized($reservedResetStorage));
$partialStorage = $this->createMock(KVStorage::class);
$partialStorage->method('getValues')->willReturn([
'clock_tick' => 0,
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace sammo;
use PHPUnit\Framework\TestCase;
final class GeneralPictureSchemaTest extends TestCase
{
/**
* @param list<string> $arguments
* @return array{int, string, string}
*/
private function runMigrationCommand(array $arguments): array
{
$command = array_merge(
[PHP_BINARY, __DIR__ . '/../scripts/migrate-general-picture.php'],
$arguments,
);
$pipes = [];
$process = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
);
self::assertIsResource($process);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
self::assertIsString($stdout);
self::assertIsString($stderr);
return [$exitCode, $stdout, $stderr];
}
public function testGameAndAccountSchemasAcceptRemoteUserIconPaths(): void
{
$gameSchema = file_get_contents(__DIR__ . '/../hwe/sql/schema.sql');
$accountSchema = file_get_contents(__DIR__ . '/../f_install/sql/common_schema.sql');
self::assertIsString($gameSchema);
self::assertIsString($accountSchema);
self::assertMatchesRegularExpression('/`picture`\s+VARCHAR\(64\)\s+NOT NULL/i', $gameSchema);
self::assertMatchesRegularExpression('/`PICTURE`\s+VARCHAR\(64\)/i', $accountSchema);
foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) {
self::assertMatchesRegularExpression(
sprintf('/`l%dpic`\s+VARCHAR\(64\)/i', $level),
$gameSchema,
);
self::assertMatchesRegularExpression(
sprintf('/`l%dimgsvr`\s+INT\(1\)\s+NULL\s+DEFAULT\s+NULL/i', $level),
$gameSchema,
);
}
$longestRemotePath = 'users/core/' . str_repeat('a', 32) . '.jpeg?=20260807';
self::assertGreaterThan(40, strlen($longestRemotePath));
self::assertLessThanOrEqual(64, strlen($longestRemotePath));
}
public function testExistingGameMigrationWidensAllConstrainedPictureColumns(): void
{
$migration = file_get_contents(__DIR__ . '/../scripts/migrate-general-picture.php');
self::assertIsString($migration);
self::assertStringContainsString(
'ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL',
$migration,
);
self::assertStringContainsString('"l{$level}pic"', $migration);
self::assertStringContainsString('"l{$level}imgsvr"', $migration);
self::assertStringContainsString("ALTER TABLE emperior", $migration);
self::assertStringContainsString("ADD COLUMN `\$imgsvrField` INT(1) NULL DEFAULT NULL", $migration);
self::assertStringContainsString('HAVING COUNT(*) = 1', $migration);
self::assertStringContainsString('ambiguous historical values remain NULL', $migration);
self::assertStringContainsString("? 'picture_capacity'", $migration);
self::assertStringContainsString("['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']", $migration);
self::assertStringContainsString("require \$serverDirectory . '/lib.php'", $migration);
self::assertStringContainsString("require \$serverDirectory . '/func.php'", $migration);
self::assertStringNotContainsString("'/hwe/lib.php'", $migration);
self::assertStringNotContainsString("'/hwe/func.php'", $migration);
self::assertStringContainsString('SELECT GET_LOCK(%s, 0)', $migration);
self::assertStringContainsString('SELECT RELEASE_LOCK(%s)', $migration);
self::assertStringContainsString('if (!$serverClosed)', $migration);
self::assertStringContainsString('--server-closed is an operator confirmation', $migration);
self::assertStringNotContainsString('\\sammo\\tryLock()', $migration);
self::assertStringNotContainsString('\\sammo\\unlock()', $migration);
self::assertStringNotContainsString('UPDATE general', $migration);
self::assertStringContainsString("\$state === 'ready'", $migration);
}
public function testUnificationPreservesChiefImageServerAndCentennialMatchingUsesIt(): void
{
$gameRule = file_get_contents(__DIR__ . '/../hwe/func_gamerule.php');
$candidateSql = file_get_contents(__DIR__ . '/../src/centennial_allstar_candidates.sql');
self::assertIsString($gameRule);
self::assertIsString($candidateSql);
self::assertStringContainsString('name,picture,imgsvr,belong,officer_level', $gameRule);
foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) {
self::assertStringContainsString(
"'l{$level}imgsvr' => \$chiefs[{$level}]['imgsvr']",
$gameRule,
);
self::assertStringContainsString("e.l{$level}imgsvr", $candidateSql);
}
self::assertStringContainsString('c.imgsvr IS NULL', $candidateSql);
self::assertStringContainsString("JSON_VALUE(og.data, '$.imgsvr')", $candidateSql);
}
public function testMigrationRequiresAnExplicitSafeServerPrefixBeforeLoadingConfiguration(): void
{
[$helpExit, $helpOutput, $helpError] = $this->runMigrationCommand(['--help']);
self::assertSame(0, $helpExit);
self::assertStringContainsString('--server=PREFIX', $helpOutput);
self::assertStringContainsString('--server-closed', $helpOutput);
self::assertSame('', $helpError);
[$missingExit, $missingOutput, $missingError] = $this->runMigrationCommand(['--status']);
self::assertSame(2, $missingExit);
self::assertSame('', $missingOutput);
self::assertStringContainsString('--server must name one game-server directory', $missingError);
[$traversalExit, $traversalOutput, $traversalError] = $this->runMigrationCommand([
'--server=../hwe',
'--status',
]);
self::assertSame(2, $traversalExit);
self::assertSame('', $traversalOutput);
self::assertStringContainsString('--server must name one game-server directory', $traversalError);
}
public function testScriptsDirectoryIsDeniedOverApache(): void
{
$accessRules = file_get_contents(__DIR__ . '/../scripts/.htaccess');
self::assertIsString($accessRules);
self::assertStringContainsString('Require all denied', $accessRules);
self::assertStringContainsString('Deny from all', $accessRules);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\ImageSyncClient;
require_once dirname(__DIR__) . '/src/sammo/ImageSyncClient.php';
final class ImageSyncClientTest extends TestCase
{
public function testBuildRequestSignsTheExactBody(): void
{
$secret = str_repeat('c', 32);
$request = ImageSyncClient::buildRequest(
'core',
$secret,
str_repeat('a', 40),
1786013000000,
'core-request-1234'
);
$headers = implode("\n", $request['headers']);
$expected = hash_hmac(
'sha256',
"1786013000000.core-request-1234.{$request['body']}",
$secret
);
self::assertStringContainsString("X-Image-Signature: {$expected}", $headers);
self::assertSame('{"commit":"' . str_repeat('a', 40) . '"}', $request['body']);
}
public function testBuildRequestRejectsAbbreviatedCommit(): void
{
$this->expectException(InvalidArgumentException::class);
ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef');
}
}
+36
View File
@@ -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 앱이 등록되지 않았습니다.')
);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\RemoteImageUploadException;
use sammo\RemoteUserIconUploadClient;
require_once dirname(__DIR__) . '/src/sammo/RemoteUserIconUploadClient.php';
final class RemoteUserIconUploadClientTest extends TestCase
{
public function testBuildRequestBindsExpiryPathContentTypeAndBody(): void
{
$secret = str_repeat('u', 32);
$body = "\x89PNG\r\n\x1a\nbody";
$url = 'https://sam-image.hided.net/v1/uploads/user-icons/core/' . str_repeat('a', 32) . '.png';
$request = RemoteUserIconUploadClient::buildRequest(
$url,
'core',
$secret,
'image/png',
$body,
1786012860,
'core-upload-1234'
);
$expected = hash_hmac(
'sha256',
'1786012860.core-upload-1234./v1/uploads/user-icons/core/' . str_repeat('a', 32)
. '.png.image/png.' . hash('sha256', $body),
$secret
);
self::assertStringContainsString("X-Image-Signature: {$expected}", implode("\n", $request['headers']));
self::assertStringNotContainsString($secret, implode("\n", $request['headers']));
}
public function testBuildRequestRejectsAPathOutsideTheCallerScope(): void
{
$this->expectException(InvalidArgumentException::class);
RemoteUserIconUploadClient::buildRequest(
'https://sam-image.hided.net/v1/uploads/user-icons/core2026/' . str_repeat('a', 32) . '.png',
'core',
str_repeat('u', 32),
'image/png',
'body'
);
}
public function testBuildRequestAcceptsScopedEditorContent(): void
{
$request = RemoteUserIconUploadClient::buildRequest(
'https://sam-image.hided.net/v1/uploads/content/core/' . str_repeat('b', 32) . '.jpeg',
'core',
str_repeat('u', 32),
'image/jpeg',
"\xff\xd8\xffbody",
1786012860,
'core-content-1234'
);
self::assertStringContainsString('X-Image-Client: core', implode("\n", $request['headers']));
}
public function testLogFailureWritesSafeOperatorEntryWithoutTraceOrSecret(): void
{
$secret = 'secret-' . str_repeat('z', 64);
$systemMessages = [];
$structuredEntries = [];
RemoteUserIconUploadClient::logFailure(
'user-icon',
new RuntimeException("request failed with {$secret}"),
static function (string $message) use (&$systemMessages): void {
$systemMessages[] = $message;
},
static function (string $type, string $message, string $path, array $trace) use (&$structuredEntries): void {
$structuredEntries[] = compact('type', 'message', 'path', 'trace');
}
);
self::assertSame(['Remote user icon upload failed: Unexpected RuntimeException'], $systemMessages);
self::assertCount(1, $structuredEntries);
self::assertSame('RemoteImageUploadFailure', $structuredEntries[0]['type']);
self::assertSame($systemMessages[0], $structuredEntries[0]['message']);
self::assertSame([], $structuredEntries[0]['trace']);
self::assertStringNotContainsString($secret, json_encode($structuredEntries, JSON_THROW_ON_ERROR));
}
public function testLogFailureKeepsOnlyClientGeneratedSafeReason(): void
{
$structuredEntries = [];
RemoteUserIconUploadClient::logFailure(
'content-image',
new RemoteImageUploadException('Image upload rejected (401)'),
static function (): void {},
static function (string $type, string $message, string $path, array $trace) use (&$structuredEntries): void {
$structuredEntries[] = compact('type', 'message', 'path', 'trace');
}
);
self::assertSame(
'Remote content image upload failed: Image upload rejected (401)',
$structuredEntries[0]['message']
);
self::assertSame([], $structuredEntries[0]['trace']);
}
public function testLogFailureRejectsAnUnapprovedClientExceptionMessage(): void
{
$secret = 'secret-' . str_repeat('q', 64);
$structuredEntries = [];
RemoteUserIconUploadClient::logFailure(
'user-icon',
new RemoteImageUploadException("unexpected response {$secret}"),
static function (): void {},
static function (string $type, string $message, string $path, array $trace) use (&$structuredEntries): void {
$structuredEntries[] = compact('type', 'message', 'path', 'trace');
}
);
self::assertSame(
'Remote user icon upload failed: Unexpected sammo\\RemoteImageUploadException',
$structuredEntries[0]['message']
);
self::assertStringNotContainsString($secret, json_encode($structuredEntries, JSON_THROW_ON_ERROR));
}
}
+236
View File
@@ -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()));
}