diff --git a/docs/trouble-shooting/issue-269-locale-query-loss.md b/docs/trouble-shooting/issue-269-locale-query-loss.md new file mode 100644 index 00000000..7fc7bee2 --- /dev/null +++ b/docs/trouble-shooting/issue-269-locale-query-loss.md @@ -0,0 +1,51 @@ +# Issue #269 해결 기록: `/{locale}/` 도입 후 query parameter 유실 + +## 개요 +- 대상 이슈: `#269` +- 증상: locale 보정 또는 비로그인 리다이렉트 과정에서 GET query parameter가 유실되거나 형태가 변형됨 +- 영향: OIDC 로그인 연계 파라미터(`login_challenge`, `redirect_uri`, `notice` 등) 전달 실패 가능 + +## 원인 +1. 비로그인 리다이렉트 시 `login_challenge`만 선택 보존하고 나머지 query를 폐기 +2. locale 경로 재작성 시 `uri.queryParameters` 기반 재직렬화로 원본 query 문자열(중복 key, 순서, 인코딩) 보존 실패 +3. `head.length == 2` 휴리스틱으로 locale이 아닌 2글자 경로 prefix까지 locale로 오인 가능 + +## 수정 사항 + +### 1) 비로그인 리다이렉트에서 raw query 전체 보존 +- 파일: `userfront/lib/main.dart` +- 변경: `state.uri.query`를 그대로 `/[locale]/signin`에 연결 + +```dart +final rawQuery = state.uri.query; +if (rawQuery.isNotEmpty) { + return '/$locale/signin?$rawQuery'; +} +return '/$locale/signin'; +``` + +### 2) locale 경로 재작성 시 raw query/fragment 보존 +- 파일: `userfront/lib/core/i18n/locale_utils.dart` +- 변경: `queryParameters` 재직렬화 제거, `uri.query`/`uri.fragment` 원문 유지 + +```dart +final queryPart = uri.hasQuery ? '?${uri.query}' : ''; +final fragmentPart = uri.fragment.isNotEmpty ? '#${uri.fragment}' : ''; +return '$path$queryPart$fragmentPart'; +``` + +### 3) locale 판별 조건 엄격화 +- 파일: `userfront/lib/core/i18n/locale_utils.dart` +- 변경: `head.length == 2` 휴리스틱 제거, `supportedLocaleCodes.contains(head)`만 허용 + +## 테스트 보강 +- 파일: `userfront/test/locale_utils_test.dart` +- 추가/변경: + - raw query 순서 및 중복 key(`a=1&a=2`) 보존 + - fragment 보존 + - unknown 2-letter prefix(`zz`)를 locale로 제거하지 않음 + +## 기대 결과 +- `/signin?redirect_uri=...¬ice=...` -> locale 보정 후 query 100% 유지 +- 비로그인 보호 경로 -> `/[locale]/signin` 이동 시 기존 query 유지 +- 인코딩된 nested `redirect_uri`, 중복 query key, fragment 보존 diff --git a/docs/trouble-shooting/issue-269-test-scenarios.md b/docs/trouble-shooting/issue-269-test-scenarios.md new file mode 100644 index 00000000..1b544545 --- /dev/null +++ b/docs/trouble-shooting/issue-269-test-scenarios.md @@ -0,0 +1,54 @@ +# Issue #269 테스트 시나리오 + +## 목적 +`/{locale}/` 라우팅 도입 이후 query parameter 유실 회귀를 방지합니다. + +## 범위 +- UserFront locale 경로 보정 (`buildLocalizedPath`) +- 비로그인 redirect 경로 생성 (`buildSigninRedirectPath`) +- locale 지원 목록 동기화 (`assets/translations/*.toml` -> `LocaleRegistry`) + +## 테스트 시나리오 + +### S1. locale 보정 시 기본 query 보존 +- 입력: `/signin?redirect_uri=https://example.com` +- 기대: `/ko/signin?redirect_uri=https://example.com` + +### S2. locale 보정 시 raw query 순서/중복 key 보존 +- 입력: `/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2` +- 기대: `/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2` + +### S3. locale 보정 시 fragment 보존 +- 입력: `/signin?notice=qr_login_required#auth` +- 기대: `/ko/signin?notice=qr_login_required#auth` + +### S4. unknown 2-letter prefix 오인 제거 +- 입력: `/zz/signin` +- 기대: `/ko/zz/signin` + +### S5. 비로그인 redirect에서 query 없음 +- 입력: locale=`ko`, uri=`/ko/profile` +- 기대: `/ko/signin` + +### S6. 비로그인 redirect에서 query 전체 보존 +- 입력: locale=`ko`, uri=`/ko/profile?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2¬ice=qr_login_required` +- 기대: `/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2¬ice=qr_login_required` + +### S7. locale 목록 하드코딩 제거 검증 +- 입력: asset 목록 (`assets/translations/en.toml`, `assets/translations/ko.toml`, `assets/translations/template.toml`, 기타 invalid 파일) +- 기대: + - `template.toml` 제외 + - 유효 locale 파일(`en.toml`, `ko.toml`)만 지원 목록에 반영 + +## 실행 방법 +```bash +cd userfront +flutter test test/locale_utils_test.dart +flutter test test/locale_registry_test.dart +``` + +## 자동화 매핑 +- `userfront/test/locale_utils_test.dart` + - S1~S6 전부 커버 +- `userfront/test/locale_registry_test.dart` + - S7 커버 diff --git a/userfront/lib/core/i18n/locale_registry.dart b/userfront/lib/core/i18n/locale_registry.dart new file mode 100644 index 00000000..7e0e7583 --- /dev/null +++ b/userfront/lib/core/i18n/locale_registry.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +const _translationAssetPrefix = 'assets/translations/'; +const _templateFileName = 'template.toml'; +const _safeFallbackLocaleCode = 'en'; + +List extractSupportedLocaleCodesFromAssets(Iterable assets) { + final localeCodes = {}; + for (final asset in assets) { + if (!asset.startsWith(_translationAssetPrefix) || + !asset.endsWith('.toml')) { + continue; + } + final fileName = asset.substring(_translationAssetPrefix.length); + if (fileName.contains('/') || fileName == _templateFileName) { + continue; + } + final rawCode = fileName.substring(0, fileName.length - '.toml'.length); + final normalized = rawCode.toLowerCase().replaceAll('_', '-'); + if (_isValidLocaleCode(normalized)) { + localeCodes.add(normalized); + } + } + + final sorted = localeCodes.toList()..sort(); + return sorted; +} + +class LocaleRegistry { + static final Set _localeCodes = {}; + static bool _initialized = false; + + static Future initialize({AssetBundle? assetBundle}) async { + if (_initialized) { + return; + } + final bundle = assetBundle ?? rootBundle; + try { + final manifest = await AssetManifest.loadFromAssetBundle(bundle); + final extracted = extractSupportedLocaleCodesFromAssets( + manifest.listAssets(), + ); + _localeCodes.addAll(extracted); + } catch (_) { + // manifest 로딩 실패 시 안전 fallback으로 계속 진행합니다. + } + + if (_localeCodes.isEmpty) { + _localeCodes.add(_safeFallbackLocaleCode); + } + _initialized = true; + } + + static List get supportedLocaleCodes { + final sorted = _localeCodes.toList()..sort(); + return List.unmodifiable(sorted); + } + + static String get fallbackLocaleCode { + final supported = supportedLocaleCodes; + if (supported.isEmpty) { + return _safeFallbackLocaleCode; + } + if (supported.contains('en')) { + return 'en'; + } + return supported.first; + } + + static bool contains(String code) { + return _localeCodes.contains(code.toLowerCase()); + } + + @visibleForTesting + static void setSupportedLocaleCodesForTest(Iterable localeCodes) { + _localeCodes + ..clear() + ..addAll( + localeCodes + .map((code) => code.toLowerCase().replaceAll('_', '-')) + .where(_isValidLocaleCode), + ); + if (_localeCodes.isEmpty) { + _localeCodes.add(_safeFallbackLocaleCode); + } + _initialized = true; + } + + @visibleForTesting + static void resetForTest() { + _localeCodes.clear(); + _initialized = false; + } +} + +bool _isValidLocaleCode(String value) { + return RegExp(r'^[a-z]{2,3}$').hasMatch(value); +} diff --git a/userfront/lib/core/i18n/locale_utils.dart b/userfront/lib/core/i18n/locale_utils.dart index 7bbda20f..7c89f051 100644 --- a/userfront/lib/core/i18n/locale_utils.dart +++ b/userfront/lib/core/i18n/locale_utils.dart @@ -1,34 +1,46 @@ import 'dart:ui'; import 'locale_storage.dart'; +import 'locale_registry.dart'; -const supportedLocaleCodes = ['en', 'ko']; -const defaultLocaleCode = 'en'; +String get defaultLocaleCode => LocaleRegistry.fallbackLocaleCode; String normalizeLocaleCode(String? code) { + final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes; + final fallbackLocaleCode = LocaleRegistry.fallbackLocaleCode; + if (code == null || code.isEmpty) { - return defaultLocaleCode; + return fallbackLocaleCode; } - final normalized = code.toLowerCase(); - if (normalized == 'ko' || normalized.startsWith('ko-')) { - return 'ko'; + final normalized = code.toLowerCase().replaceAll('_', '-'); + if (supportedLocaleCodes.contains(normalized)) { + return normalized; } - if (normalized == 'en' || normalized.startsWith('en-')) { - return 'en'; + final languageCode = normalized.split('-').first; + if (supportedLocaleCodes.contains(languageCode)) { + return languageCode; } - return defaultLocaleCode; + return fallbackLocaleCode; } String resolvePreferredLocaleCode() { final stored = LocaleStorage.read(); - if (stored != null && supportedLocaleCodes.contains(stored)) { - return stored; + if (stored != null && stored.isNotEmpty) { + final normalizedStored = normalizeLocaleCode(stored); + if (LocaleRegistry.contains(normalizedStored)) { + return normalizedStored; + } } final deviceLocale = PlatformDispatcher.instance.locale; - return normalizeLocaleCode(deviceLocale.languageCode); + final languageTag = + deviceLocale.countryCode == null || deviceLocale.countryCode!.isEmpty + ? deviceLocale.languageCode + : '${deviceLocale.languageCode}-${deviceLocale.countryCode}'; + return normalizeLocaleCode(languageTag); } String? extractLocaleFromPath(Uri uri) { + final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes; if (uri.pathSegments.isEmpty) { return null; } @@ -40,8 +52,10 @@ String? extractLocaleFromPath(Uri uri) { } String stripLocalePath(Uri uri) { + final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes; final segments = uri.pathSegments; - if (segments.isNotEmpty && supportedLocaleCodes.contains(segments.first)) { + if (segments.isNotEmpty && + supportedLocaleCodes.contains(segments.first.toLowerCase())) { final rest = segments.skip(1).join('/'); if (rest.isEmpty) { return '/'; @@ -52,18 +66,23 @@ String stripLocalePath(Uri uri) { } String buildLocalizedPath(String localeCode, Uri uri) { + final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes; final segments = uri.pathSegments; Iterable restSegments = segments; if (segments.isNotEmpty) { final head = segments.first.toLowerCase(); - if (supportedLocaleCodes.contains(head) || head.length == 2) { + if (supportedLocaleCodes.contains(head)) { restSegments = segments.skip(1); } } final newSegments = [localeCode, ...restSegments]; final path = '/${newSegments.join('/')}'; - if (uri.queryParameters.isEmpty) { - return path; - } - return Uri(path: path, queryParameters: uri.queryParameters).toString(); + final queryPart = uri.hasQuery ? '?${uri.query}' : ''; + final fragmentPart = uri.fragment.isNotEmpty ? '#${uri.fragment}' : ''; + return '$path$queryPart$fragmentPart'; +} + +String buildSigninRedirectPath(String localeCode, Uri uri) { + final queryPart = uri.hasQuery ? '?${uri.query}' : ''; + return '/$localeCode/signin$queryPart'; } diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 7b9eca2d..43d38f95 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -21,6 +21,7 @@ import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; import 'core/i18n/locale_gate.dart'; +import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; import 'core/i18n/toml_asset_loader.dart'; import 'package:logging/logging.dart'; @@ -45,6 +46,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); await EasyLocalization.ensureInitialized(); + await LocaleRegistry.initialize(); // 1. Global Error Handling FlutterError.onError = (details) { @@ -78,11 +80,16 @@ void main() async { runApp( // URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다. () { + final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes; + final supportedLocales = supportedLocaleCodes + .map((code) => Locale(code)) + .toList(growable: false); + final fallbackLocaleCode = LocaleRegistry.fallbackLocaleCode; final initialLocaleCode = extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode(); return EasyLocalization( - supportedLocales: const [Locale('en'), Locale('ko')], - fallbackLocale: const Locale('en'), + supportedLocales: supportedLocales, + fallbackLocale: Locale(fallbackLocaleCode), startLocale: Locale(initialLocaleCode), saveLocale: false, path: 'assets/translations', @@ -143,8 +150,13 @@ final _router = GoRouter( final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; - _routerLogger.info("Navigating to /login, redirect: $redirectUrl"); - return LoginScreen(key: state.pageKey, redirectUrl: redirectUrl); + _routerLogger.info( + "Navigating to /login, redirect: $redirectUrl", + ); + return LoginScreen( + key: state.pageKey, + redirectUrl: redirectUrl, + ); }, ), GoRoute( @@ -153,7 +165,9 @@ final _router = GoRouter( final consentChallenge = state.uri.queryParameters['consent_challenge']; if (consentChallenge == null) { - _routerLogger.warning("Consent screen loaded without a challenge."); + _routerLogger.warning( + "Consent screen loaded without a challenge.", + ); return const Scaffold( body: Center( child: Text('Error: Consent challenge is missing.'), @@ -241,8 +255,7 @@ final _router = GoRouter( return ErrorScreen( errorId: params['id'], errorCode: params['error'], - description: - params['error_description'] ?? params['message'], + description: params['error_description'] ?? params['message'], ); }, ), @@ -252,9 +265,7 @@ final _router = GoRouter( _routerLogger.info("Navigating to /settings (disabled)"); return ErrorScreen( errorCode: 'settings_disabled', - description: tr( - 'msg.userfront.settings.disabled', - ), + description: tr('msg.userfront.settings.disabled'), ); }, ), @@ -333,13 +344,7 @@ final _router = GoRouter( // If not logged in and trying to access a protected page, redirect to /signin if (!isLoggedIn) { _routerLogger.info("Not logged in, redirecting to /signin"); - // Preserve OIDC challenge if present - final loginChallenge = state.uri.queryParameters['login_challenge']; - final locale = requestedLocale; - if (loginChallenge != null) { - return '/$locale/signin?login_challenge=$loginChallenge'; - } - return '/$locale/signin'; + return buildSigninRedirectPath(requestedLocale, state.uri); } // If logged in and trying to access login page, redirect to root (dashboard) diff --git a/userfront/test/locale_registry_test.dart b/userfront/test/locale_registry_test.dart new file mode 100644 index 00000000..48b0e0de --- /dev/null +++ b/userfront/test/locale_registry_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/i18n/locale_registry.dart'; + +void main() { + tearDown(() { + LocaleRegistry.resetForTest(); + }); + + group('locale_registry', () { + test( + 'extractSupportedLocaleCodesFromAssets excludes template and invalid', + () { + final locales = extractSupportedLocaleCodesFromAssets([ + 'assets/translations/template.toml', + 'assets/translations/en.toml', + 'assets/translations/ko.toml', + 'assets/translations/pt_BR.toml', + 'assets/translations/readme.txt', + 'assets/translations/nested/ja.toml', + ]); + + expect(locales, ['en', 'ko']); + }, + ); + + test('fallback locale prefers en when available', () { + LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']); + expect(LocaleRegistry.fallbackLocaleCode, 'en'); + }); + + test('fallback locale uses first sorted code when en is absent', () { + LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'ja']); + expect(LocaleRegistry.fallbackLocaleCode, 'ja'); + }); + }); +} diff --git a/userfront/test/locale_utils_test.dart b/userfront/test/locale_utils_test.dart index 6b16ebdf..a81a8b12 100644 --- a/userfront/test/locale_utils_test.dart +++ b/userfront/test/locale_utils_test.dart @@ -1,7 +1,16 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/i18n/locale_registry.dart'; import 'package:userfront/core/i18n/locale_utils.dart'; void main() { + setUp(() { + LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']); + }); + + tearDown(() { + LocaleRegistry.resetForTest(); + }); + group('locale_utils', () { test('normalizeLocaleCode handles supported locales', () { expect(normalizeLocaleCode('ko'), 'ko'); @@ -50,7 +59,25 @@ void main() { final uri = Uri.parse('/signin?redirect_uri=https://example.com'); expect( buildLocalizedPath('ko', uri), - '/ko/signin?redirect_uri=https%3A%2F%2Fexample.com', + '/ko/signin?redirect_uri=https://example.com', + ); + }); + + test('buildLocalizedPath preserves raw query order and duplicates', () { + final uri = Uri.parse( + '/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2', + ); + expect( + buildLocalizedPath('ko', uri), + '/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2', + ); + }); + + test('buildLocalizedPath preserves fragment', () { + final uri = Uri.parse('/signin?notice=qr_login_required#auth'); + expect( + buildLocalizedPath('ko', uri), + '/ko/signin?notice=qr_login_required#auth', ); }); @@ -59,8 +86,28 @@ void main() { expect(buildLocalizedPath('ko', Uri.parse('/en/profile')), '/ko/profile'); }); - test('buildLocalizedPath drops unknown 2-letter prefix', () { - expect(buildLocalizedPath('ko', Uri.parse('/zz/signin')), '/ko/signin'); + test('buildLocalizedPath keeps unknown 2-letter prefix as path', () { + expect( + buildLocalizedPath('ko', Uri.parse('/zz/signin')), + '/ko/zz/signin', + ); + }); + + test('buildSigninRedirectPath keeps path without query', () { + expect( + buildSigninRedirectPath('ko', Uri.parse('/ko/profile')), + '/ko/signin', + ); + }); + + test('buildSigninRedirectPath preserves full raw query', () { + final uri = Uri.parse( + '/ko/profile?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2¬ice=qr_login_required', + ); + expect( + buildSigninRedirectPath('ko', uri), + '/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2¬ice=qr_login_required', + ); }); }); }