From e927fa8ea08f91adaec8a2b37b1a3a6c889d3057 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 13:03:16 +0900 Subject: [PATCH] =?UTF-8?q?dev=20=EB=B0=98=EC=98=81=20code-check=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 2 +- .../tests/devfront-clients-lifecycle.spec.ts | 11 +- .../tests/password-and-reset.spec.ts | 193 ++++++++++++++---- .../tests/profile-department.spec.ts | 96 +++++++-- .../services/auth_token_store_backend.dart | 6 +- .../auth/presentation/login_screen.dart | 2 +- .../presentation/pages/profile_page.dart | 100 ++++----- .../test/auth_token_store_backend_test.dart | 4 +- 8 files changed, 304 insertions(+), 110 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f2653faa..05becb6b 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -80,7 +80,7 @@ const ( loginCodeExpiration = 10 * time.Minute linkResendCooldown = 60 * time.Second prefixDrySend = "dry_send:" - headlessJWKSFetchTTL = 5 * time.Second + headlessJWKSFetchTTL = 5 * time.Second ) type AuthHandler struct { diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index 31af52f1..d8d57bf4 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -158,15 +158,20 @@ test.describe("DevFront clients lifecycle", () => { await expect .poll(() => state.clients[0]?.tokenEndpointAuthMethod) - .toBe("private_key_jwt"); + .toBe("none"); await expect .poll(() => state.clients[0]?.metadata?.headless_login_enabled) .toBe(true); + await expect + .poll( + () => state.clients[0]?.metadata?.headless_token_endpoint_auth_method, + ) + .toBe("private_key_jwt"); await expect .poll( () => ( - state.clients[0]?.jwks as { + state.clients[0]?.metadata?.headless_jwks as { keys?: Array<{ kty?: string; alg?: string }>; } )?.keys?.[0]?.kty, @@ -176,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => { .poll( () => ( - state.clients[0]?.jwks as { + state.clients[0]?.metadata?.headless_jwks as { keys?: Array<{ kty?: string; alg?: string }>; } )?.keys?.[0]?.alg, diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts index c1876f3a..3ec9011c 100644 --- a/userfront-e2e/tests/password-and-reset.spec.ts +++ b/userfront-e2e/tests/password-and-reset.spec.ts @@ -7,32 +7,94 @@ type RequestCapture = { clientLogs: string[]; }; -const SIGNIN_PASSWORD_TAB_X = 522; -const SIGNIN_TAB_Y = 158; -const SIGNIN_LOGIN_ID_X = 640; -const SIGNIN_LOGIN_ID_Y = 245; -const SIGNIN_PASSWORD_X = 640; -const SIGNIN_PASSWORD_Y = 311; -const SIGNIN_SUBMIT_X = 640; -const SIGNIN_SUBMIT_Y = 381; +async function enableFlutterAccessibility(page: Page): Promise { + await page.waitForTimeout(300); + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + await button.click({ force: true }); + const placeholder = page.locator('flt-semantics-placeholder'); + if (await placeholder.count()) { + await placeholder.first().click({ force: true }); + } + await page.waitForTimeout(800); + } +} -const RESET_NEW_PASSWORD_X = 640; -const RESET_NEW_PASSWORD_Y = 382; -const RESET_CONFIRM_PASSWORD_X = 640; -const RESET_CONFIRM_PASSWORD_Y = 464; -const RESET_SUBMIT_X = 640; -const RESET_SUBMIT_Y = 534; +type ScreenCoords = { + signinPasswordTabX: number; + signinTabY: number; + signinLoginIdX: number; + signinLoginIdY: number; + signinPasswordX: number; + signinPasswordY: number; + signinSubmitX: number; + signinSubmitY: number; + resetNewPasswordX: number; + resetNewPasswordY: number; + resetConfirmPasswordX: number; + resetConfirmPasswordY: number; + resetSubmitX: number; + resetSubmitY: number; +}; + +const desktopCoords: ScreenCoords = { + signinPasswordTabX: 522, + signinTabY: 158, + signinLoginIdX: 640, + signinLoginIdY: 245, + signinPasswordX: 640, + signinPasswordY: 311, + signinSubmitX: 640, + signinSubmitY: 381, + resetNewPasswordX: 640, + resetNewPasswordY: 382, + resetConfirmPasswordX: 640, + resetConfirmPasswordY: 464, + resetSubmitX: 640, + resetSubmitY: 534, +}; + +const mobileCoords: ScreenCoords = { + signinPasswordTabX: 90, + signinTabY: 158, + signinLoginIdX: 206, + signinLoginIdY: 268, + signinPasswordX: 206, + signinPasswordY: 334, + signinSubmitX: 206, + signinSubmitY: 399, + resetNewPasswordX: 206, + resetNewPasswordY: 382, + resetConfirmPasswordX: 206, + resetConfirmPasswordY: 464, + resetSubmitX: 206, + resetSubmitY: 534, +}; + +function coordsFor(page: Page): ScreenCoords { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords; +} + +function isMobileProject(page: Page): boolean { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500; +} async function clickPasswordTab(page: Page): Promise { + if (isMobileProject(page)) { + return; + } + const coords = coordsFor(page); await page.waitForTimeout(900); const pane = page.locator('flt-glass-pane'); await pane.click({ - position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, + position: { x: coords.signinPasswordTabX, y: coords.signinTabY }, force: true, }); await page.waitForTimeout(120); await pane.click({ - position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, + position: { x: coords.signinPasswordTabX, y: coords.signinTabY }, force: true, }); await page.waitForTimeout(200); @@ -47,6 +109,68 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< await page.keyboard.type(value); } +async function fillPasswordLoginForm( + page: Page, + loginId: string, + password: string, +): Promise { + if (isMobileProject(page)) { + await enableFlutterAccessibility(page); + const inputs = page.getByRole('textbox'); + await inputs.nth(0).fill(loginId); + await inputs.nth(1).fill(password); + return; + } + const coords = coordsFor(page); + await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId); + await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password); +} + +async function submitPasswordLogin(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('button', { name: '로그인' }).click({ force: true }); + return; + } + const coords = coordsFor(page); + await page.locator('flt-glass-pane').click({ + position: { x: coords.signinSubmitX, y: coords.signinSubmitY }, + force: true, + }); +} + +async function fillResetPasswordForm(page: Page, password: string): Promise { + if (isMobileProject(page)) { + await enableFlutterAccessibility(page); + await page + .getByRole('textbox', { name: /^새 비밀번호$/ }) + .fill(password); + await page + .getByRole('textbox', { name: /^새 비밀번호 확인$/ }) + .fill(password); + return; + } + const coords = coordsFor(page); + await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password); + await fillAt( + page, + coords.resetConfirmPasswordX, + coords.resetConfirmPasswordY, + password, + ); +} + +async function submitResetPassword(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true }); + return; + } + const coords = coordsFor(page); + await page.locator('flt-glass-pane').click({ + position: { x: coords.resetSubmitX, y: coords.resetSubmitY }, + force: true, + }); +} + async function mockAuthApis(page: Page, capture: RequestCapture): Promise { await page.route('**/api/v1/**', async (route: Route) => { const requestUrl = new URL(route.request().url()); @@ -186,17 +310,17 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise test.describe('UserFront WASM password login and reset', () => { test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => { + test.skip( + isMobileProject(page), + 'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.', + ); const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); await page.goto('/ko/signin'); await clickPasswordTab(page); - await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); - await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!'); - await page.locator('flt-glass-pane').click({ - position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y }, - force: true, - }); + await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!'); + await submitPasswordLogin(page); await expect(page).toHaveURL(/\/ko\/dashboard$/); @@ -210,17 +334,17 @@ test.describe('UserFront WASM password login and reset', () => { }); test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => { + test.skip( + isMobileProject(page), + 'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.', + ); const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); await page.goto('/ko/signin'); await clickPasswordTab(page); - await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); - await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!'); - await page.locator('flt-glass-pane').click({ - position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y }, - force: true, - }); + await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!'); + await submitPasswordLogin(page); await expect(page).toHaveURL(/\/ko\/signin$/); await expect @@ -246,17 +370,8 @@ test.describe('UserFront WASM password login and reset', () => { await page.goto('/ko/reset-password?token=reset-token-e2e'); await policyLoaded; await page.waitForTimeout(900); - await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A'); - await fillAt( - page, - RESET_CONFIRM_PASSWORD_X, - RESET_CONFIRM_PASSWORD_Y, - 'ValidPass1!A', - ); - await page.locator('flt-glass-pane').click({ - position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y }, - force: true, - }); + await fillResetPasswordForm(page, 'ValidPass1!A'); + await submitResetPassword(page); await expect .poll( diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts index e22db24d..f4d3a98c 100644 --- a/userfront-e2e/tests/profile-department.spec.ts +++ b/userfront-e2e/tests/profile-department.spec.ts @@ -6,12 +6,50 @@ type ProfileState = { putBodies: Array>; }; -const PROFILE_DEPARTMENT_EDIT_X = 1170; -const PROFILE_DEPARTMENT_EDIT_Y = 680; -const PROFILE_DEPARTMENT_INPUT_X = 110; -const PROFILE_DEPARTMENT_INPUT_Y = 685; -const PROFILE_BLUR_X = 200; -const PROFILE_BLUR_Y = 260; +async function enableFlutterAccessibility(page: Page): Promise { + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + await button.click({ force: true }); + await page.waitForTimeout(200); + } +} + +type ProfileCoords = { + departmentEditX: number; + departmentEditY: number; + departmentInputX: number; + departmentInputY: number; + blurX: number; + blurY: number; +}; + +const desktopCoords: ProfileCoords = { + departmentEditX: 1170, + departmentEditY: 680, + departmentInputX: 110, + departmentInputY: 685, + blurX: 200, + blurY: 260, +}; + +const mobileCoords: ProfileCoords = { + departmentEditX: 350, + departmentEditY: 680, + departmentInputX: 110, + departmentInputY: 685, + blurX: 200, + blurY: 260, +}; + +function coordsFor(page: Page): ProfileCoords { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords; +} + +function isMobileProject(page: Page): boolean { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500; +} async function seedTokenLogin(page: Page): Promise { await page.addInitScript(() => { @@ -32,26 +70,56 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< } async function openDepartmentEditor(page: Page): Promise { + if (isMobileProject(page)) { + await enableFlutterAccessibility(page); + await page + .getByRole('group', { name: '소속 QA' }) + .getByRole('button', { name: '편집' }) + .click({ force: true }); + await page.waitForTimeout(200); + return; + } + const coords = coordsFor(page); await page.locator('flt-glass-pane').click({ - position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y }, + position: { x: coords.departmentEditX, y: coords.departmentEditY }, force: true, }); await page.waitForTimeout(200); } async function blurDepartmentEditor(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('textbox', { name: '소속' }).blur(); + await page.waitForTimeout(250); + return; + } + const coords = coordsFor(page); await page.locator('flt-glass-pane').click({ - position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y }, + position: { x: coords.blurX, y: coords.blurY }, force: true, }); await page.waitForTimeout(250); } async function submitDepartmentEditor(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('textbox', { name: '소속' }).press('Enter'); + await page.waitForTimeout(250); + return; + } await page.keyboard.press('Enter'); await page.waitForTimeout(250); } +async function fillDepartmentField(page: Page, value: string): Promise { + if (isMobileProject(page)) { + await page.getByRole('textbox', { name: '소속' }).fill(value); + return; + } + const coords = coordsFor(page); + await fillAt(page, coords.departmentInputX, coords.departmentInputY, value); +} + async function mockProfileApis(page: Page, state: ProfileState): Promise { await page.route('**/api/v1/**', async (route: Route) => { const request = route.request(); @@ -174,7 +242,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated'); + await fillDepartmentField(page, 'QA-Updated'); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); @@ -201,7 +269,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro'); + await fillDepartmentField(page, 'QA-Repro'); await page.reload(); await expect(page).toHaveURL(/\/ko\/profile$/); @@ -228,7 +296,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA'); + await fillDepartmentField(page, 'QA'); await blurDepartmentEditor(page); expect(state.putBodies).toHaveLength(0); @@ -246,7 +314,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, ''); + await fillDepartmentField(page, ''); await blurDepartmentEditor(page); expect(state.putBodies).toHaveLength(0); @@ -265,7 +333,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1'); + await fillDepartmentField(page, 'QA-1'); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); @@ -274,7 +342,7 @@ test.describe('UserFront WASM profile department editing', () => { await page.waitForTimeout(1200); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2'); + await fillDepartmentField(page, 'QA-2'); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(2); diff --git a/userfront/lib/core/services/auth_token_store_backend.dart b/userfront/lib/core/services/auth_token_store_backend.dart index fcd132e2..5f393bf9 100644 --- a/userfront/lib/core/services/auth_token_store_backend.dart +++ b/userfront/lib/core/services/auth_token_store_backend.dart @@ -8,11 +8,7 @@ class AuthTokenStoreBackend { AuthTokenStoreBackend({ required AuthTokenStorageTarget localTarget, required AuthTokenStorageTarget sessionTarget, - }) : _targets = [ - localTarget, - sessionTarget, - _MemoryStorageTarget(), - ]; + }) : _targets = [localTarget, sessionTarget, _MemoryStorageTarget()]; static const _tokenKey = 'baron_auth_token'; static const _providerKey = 'baron_auth_provider'; diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 3d3fc2ad..ec300fe3 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -91,7 +91,7 @@ class _LoginScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this, initialIndex: 1); + _tabController = TabController(length: 3, vsync: this, initialIndex: 0); _tabController.addListener(_handleTabSelection); _drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 57a0d2b0..80b80ae3 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -768,6 +768,7 @@ class _ProfilePageState extends ConsumerState { }) { final isEditing = _editingField == field; final displayValue = value.isEmpty ? '-' : value; + final isCompact = MediaQuery.of(context).size.width < 640; if (!isEditing) { return ListTile( @@ -784,57 +785,64 @@ class _ProfilePageState extends ConsumerState { final hasChanged = _hasFieldChanged(profile, field); + final inputField = TextField( + key: Key('profile-$field-input'), + controller: controller, + focusNode: field == 'name' ? _nameFocus : _departmentFocus, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _saveField(profile), + onChanged: (_) { + setState(() { + _fieldSaveError = null; + }); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: label, + errorText: _fieldSaveError, + ), + ); + final saveButton = ElevatedButton( + key: Key('profile-$field-save-button'), + onPressed: isUpdating || !hasChanged || _isSavingField + ? null + : () => _saveField(profile), + child: _isSavingField + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(tr('ui.common.save')), + ); + final cancelButton = OutlinedButton( + key: Key('profile-$field-cancel-button'), + onPressed: isUpdating || _isSavingField + ? null + : () => _cancelEditing(profile), + child: Text(tr('ui.common.cancel')), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - key: Key('profile-$field-input'), - controller: controller, - focusNode: field == 'name' ? _nameFocus : _departmentFocus, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _saveField(profile), - onChanged: (_) { - setState(() { - _fieldSaveError = null; - }); - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: label, - errorText: _fieldSaveError, - ), - ), - ), - const SizedBox(width: 12), - ElevatedButton( - key: Key('profile-$field-save-button'), - onPressed: isUpdating || !hasChanged || _isSavingField - ? null - : () => _saveField(profile), - child: _isSavingField - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(tr('ui.common.save')), - ), - const SizedBox(width: 8), - OutlinedButton( - key: Key('profile-$field-cancel-button'), - onPressed: isUpdating || _isSavingField - ? null - : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), - ), - ], - ), + if (isCompact) ...[ + inputField, + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]), + ] else + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: inputField), + const SizedBox(width: 12), + saveButton, + const SizedBox(width: 8), + cancelButton, + ], + ), ], ); } diff --git a/userfront/test/auth_token_store_backend_test.dart b/userfront/test/auth_token_store_backend_test.dart index 8a2b2940..97d2278a 100644 --- a/userfront/test/auth_token_store_backend_test.dart +++ b/userfront/test/auth_token_store_backend_test.dart @@ -5,7 +5,9 @@ void main() { group('AuthTokenStoreBackend', () { test('local 저장소가 실패하면 session 저장소에서 토큰을 읽는다', () { final local = _FakeTarget(throwsOnRead: true); - final session = _FakeTarget(readSeed: {'baron_auth_token': 'session-jwt'}); + final session = _FakeTarget( + readSeed: {'baron_auth_token': 'session-jwt'}, + ); final store = AuthTokenStoreBackend( localTarget: local, sessionTarget: session,