From 54b69dc7e6a92a2a1d60069ec79a1d0267e369a5 Mon Sep 17 00:00:00 2001 From: joshua Date: Fri, 3 Jul 2026 18:32:16 +0200 Subject: [PATCH] fix(kyc): send the user's app language on registration instead of hardcoded DE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit #657 P9 M2: completeRegistration always sent lang: 'DE' regardless of the user's locale. The service now takes an optional named lang parameter (uppercased on the wire, default DE so existing call sites keep their behaviour) and the submit cubit forwards SettingsRepository.language — the same stored-or-system source the rest of the app uses. Pinned by a new frozen service test (RED on the old code: the parameter did not exist) and a cubit forwarding test. Co-Authored-By: Claude Fable 5 --- .../dfx/real_unit_registration_service.dart | 10 +- .../kyc_registration_submit_cubit.dart | 11 +- .../registration/kyc_registration_page.dart | 2 + ...l_unit_registration_service_lang_test.dart | 130 ++++++++++++++++++ .../kyc/steps/kyc_registration_page_test.dart | 4 + .../kyc_registration_submit_cubit_test.dart | 40 ++++-- 6 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 test/packages/service/dfx/real_unit_registration_service_lang_test.dart diff --git a/lib/packages/service/dfx/real_unit_registration_service.dart b/lib/packages/service/dfx/real_unit_registration_service.dart index ab4f1711e..81d4d8335 100644 --- a/lib/packages/service/dfx/real_unit_registration_service.dart +++ b/lib/packages/service/dfx/real_unit_registration_service.dart @@ -71,21 +71,21 @@ class RealUnitRegistrationService extends DFXAuthService { return responseDto.status; } - /// registers a wallet and and adds the wallet to the new user - Future completeRegistration(Registration registration) async { + /// registers a wallet and adds the wallet to the new user + Future completeRegistration(Registration registration, {String lang = 'DE'}) async { // EIP-712 registration signature requires the private key — promote the // view-wallet (if any) to a fully unlocked SoftwareWallet first, then // lock back down in the finally so a throw mid-sequence doesn't leave the // key resident. await walletService.ensureCurrentWalletUnlocked(); try { - return await _completeRegistration(registration); + return await _completeRegistration(registration, lang: lang.toUpperCase()); } finally { await walletService.lockCurrentWallet(); } } - Future _completeRegistration(Registration registration) async { + Future _completeRegistration(Registration registration, {required String lang}) async { final credentials = appStore.wallet.primaryAccount.primaryAddress; // BitBox firmware rejects non-ASCII bytes in EIP-712 string fields. // Transliterate everything that goes into the signed envelope AND the @@ -136,7 +136,7 @@ class RealUnitRegistrationService extends DFXAuthService { registrationDate: registrationDate, walletAddress: credentials.address.hexEip55, signature: signature, - lang: 'DE', + lang: lang, kycData: KycPersonalData( accountType: KycAccountType.fromUserType(registration.type), firstName: registration.firstName, diff --git a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart index f25775ff1..4fa856632 100644 --- a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart +++ b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart @@ -2,6 +2,7 @@ import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; @@ -15,12 +16,15 @@ part 'kyc_registration_submit_state.dart'; class KycRegistrationSubmitCubit extends Cubit { final DfxKycService _kycService; final RealUnitRegistrationService _registrationService; + final SettingsRepository _settingsRepository; KycRegistrationSubmitCubit( RealUnitRegistrationService registrationService, DfxKycService kycService, + SettingsRepository settingsRepository, ) : _registrationService = registrationService, _kycService = kycService, + _settingsRepository = settingsRepository, super(KycRegistrationSubmitInitial()); Future submit({ @@ -72,7 +76,12 @@ class KycRegistrationSubmitCubit extends Cubit { Future _doCompleteRegistration(Registration registration) async { try { - final status = await _registrationService.completeRegistration(registration); + // Audit #657 P9 M2: send the user's app language instead of a + // hardcoded DE. + final status = await _registrationService.completeRegistration( + registration, + lang: _settingsRepository.language, + ); // The API returns a structured `RegistrationStatus` in every // success case — including `alreadyRegistered` // (DFXswiss/api#3733). We forward whatever the backend says and diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart index f73be532e..062c7cd1f 100644 --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; @@ -41,6 +42,7 @@ class KycRegistrationPage extends StatelessWidget { create: (_) => KycRegistrationSubmitCubit( getIt(), getIt(), + getIt(), ), ), BlocProvider( diff --git a/test/packages/service/dfx/real_unit_registration_service_lang_test.dart b/test/packages/service/dfx/real_unit_registration_service_lang_test.dart new file mode 100644 index 000000000..75ef11893 --- /dev/null +++ b/test/packages/service/dfx/real_unit_registration_service_lang_test.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:web3dart/web3dart.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +const _testPrivateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; + +final _privKey = EthPrivateKey.fromHex(_testPrivateKeyHex); + +/// Audit #657 P9 M2: the registration language was hardcoded to `DE` +/// regardless of the user's app language. `completeRegistration` must accept +/// the caller's language via an optional named `lang` parameter and send it +/// uppercased on the wire; omitting it keeps the previous `DE` behaviour so +/// existing call sites stay valid. +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.primaryAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(_privKey); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + RealUnitRegistrationService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitRegistrationService(appStore, walletService); + } + + Registration buildRegistration() => const Registration( + type: RegistrationUserType.human, + email: 'a@b.com', + firstName: 'Ada', + lastName: 'Lovelace', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + kycAllowed: true, + ), + addressStreet: 'Bahnhofstrasse', + addressStreetNumber: '1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + kycAllowed: true, + ), + swissTaxResidence: true, + ); + + Future> sentBody( + Future Function(RealUnitRegistrationService service) act, + ) async { + Map? body; + final client = MockClient((request) async { + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + await act(build(client)); + expect(body, isNotNull); + return body!; + } + + group('completeRegistration registration language (audit #657 P9 M2)', () { + test('sends the caller-provided language uppercased (en → EN)', () async { + final body = await sentBody( + (service) => service.completeRegistration(buildRegistration(), lang: 'en'), + ); + expect(body['lang'], 'EN'); + }); + + test('sends the caller-provided language uppercased (de → DE)', () async { + final body = await sentBody( + (service) => service.completeRegistration(buildRegistration(), lang: 'de'), + ); + expect(body['lang'], 'DE'); + }); + + test('defaults to DE when no language is provided (backward compat)', () async { + final body = await sentBody( + (service) => service.completeRegistration(buildRegistration()), + ); + expect(body['lang'], 'DE'); + }); + }); +} diff --git a/test/screens/kyc/steps/kyc_registration_page_test.dart b/test/screens/kyc/steps/kyc_registration_page_test.dart index 7bde53f13..3dc6e83e5 100644 --- a/test/screens/kyc/steps/kyc_registration_page_test.dart +++ b/test/screens/kyc/steps/kyc_registration_page_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; @@ -32,6 +33,8 @@ class MockDfxCountryService extends Mock implements DfxCountryService {} class MockDfxKycService extends Mock implements DfxKycService {} +class MockSettingsRepository extends Mock implements SettingsRepository {} + void main() { late KycRegistrationStepCubit registrationStepCubit; late KycRegistrationSubmitCubit registrationSubmitCubit; @@ -65,6 +68,7 @@ void main() { getIt.registerSingleton(MockRealUnitRegistrationService()); getIt.registerSingleton(MockDfxCountryService()); getIt.registerSingleton(MockDfxKycService()); + getIt.registerSingleton(MockSettingsRepository()); } setUpAll(() { diff --git a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart index ef669cbf1..4f42e55cc 100644 --- a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart +++ b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; @@ -17,6 +18,8 @@ class _MockDfxKycService extends Mock implements DfxKycService {} class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} +class _MockSettingsRepository extends Mock implements SettingsRepository {} + const _country = Country( id: 41, symbol: 'CH', @@ -66,6 +69,7 @@ Future _submitFromRegistration( void main() { late DfxKycService kycService; late RealUnitRegistrationService registrationService; + late SettingsRepository settingsRepository; setUpAll(() { registerFallbackValue(_registration()); @@ -74,10 +78,12 @@ void main() { setUp(() { kycService = _MockDfxKycService(); registrationService = _MockRealUnitRegistrationService(); + settingsRepository = _MockSettingsRepository(); + when(() => settingsRepository.language).thenReturn('de'); }); KycRegistrationSubmitCubit buildCubit() => - KycRegistrationSubmitCubit(registrationService, kycService); + KycRegistrationSubmitCubit(registrationService, kycService, settingsRepository); group('$KycRegistrationSubmitCubit submit', () { blocTest( @@ -85,7 +91,7 @@ void main() { setUp: () { when(() => kycService.getUser()).thenAnswer((_) async => _user()); when( - () => registrationService.completeRegistration(any()), + () => registrationService.completeRegistration(any(), lang: any(named: 'lang')), ).thenAnswer((_) async => RegistrationStatus.completed); }, build: buildCubit, @@ -96,12 +102,30 @@ void main() { ], ); + blocTest( + 'forwards the app language from settings to completeRegistration (audit #657 P9 M2)', + setUp: () { + when(() => settingsRepository.language).thenReturn('en'); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when( + () => registrationService.completeRegistration(any(), lang: any(named: 'lang')), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: buildCubit, + act: (cubit) => _submitFromRegistration(cubit, _registration()), + verify: (_) { + verify( + () => registrationService.completeRegistration(any(), lang: 'en'), + ).called(1); + }, + ); + blocTest( 'emits BitboxRequired with the registration payload when BitBox not connected', setUp: () { when(() => kycService.getUser()).thenAnswer((_) async => _user()); when( - () => registrationService.completeRegistration(any()), + () => registrationService.completeRegistration(any(), lang: any(named: 'lang')), ).thenThrow(const BitboxNotConnectedException()); }, build: buildCubit, @@ -116,7 +140,7 @@ void main() { 'emits Success(alreadyRegistered) when the API reports already-registered (was: silent ApiException swallow)', setUp: () { when(() => kycService.getUser()).thenAnswer((_) async => _user()); - when(() => registrationService.completeRegistration(any())).thenAnswer( + when(() => registrationService.completeRegistration(any(), lang: any(named: 'lang'))).thenAnswer( (_) async => RegistrationStatus.alreadyRegistered, ); }, @@ -132,7 +156,7 @@ void main() { 'emits Failure on backend ApiException (account-exists no longer silently masked)', setUp: () { when(() => kycService.getUser()).thenAnswer((_) async => _user()); - when(() => registrationService.completeRegistration(any())).thenThrow( + when(() => registrationService.completeRegistration(any(), lang: any(named: 'lang'))).thenThrow( const ApiException( statusCode: 409, code: 'ACCOUNT_EXISTS', @@ -152,7 +176,7 @@ void main() { 'emits Failure on generic post-sign exception (network/parse/empty-sig)', setUp: () { when(() => kycService.getUser()).thenAnswer((_) async => _user()); - when(() => registrationService.completeRegistration(any())).thenThrow( + when(() => registrationService.completeRegistration(any(), lang: any(named: 'lang'))).thenThrow( Exception('Signature was empty'), ); }, @@ -196,7 +220,7 @@ void main() { 'retries the sign after reconnect and emits Success', setUp: () { when( - () => registrationService.completeRegistration(any()), + () => registrationService.completeRegistration(any(), lang: any(named: 'lang')), ).thenAnswer((_) async => RegistrationStatus.completed); }, build: buildCubit, @@ -211,7 +235,7 @@ void main() { 'still emits BitboxRequired on retry when wallet is still disconnected', setUp: () { when( - () => registrationService.completeRegistration(any()), + () => registrationService.completeRegistration(any(), lang: any(named: 'lang')), ).thenThrow(const BitboxNotConnectedException()); }, build: buildCubit,