diff --git a/test/integration/bitbox_reconnect_recovery_test.dart b/test/integration/bitbox_reconnect_recovery_test.dart new file mode 100644 index 00000000..ed8e9edc --- /dev/null +++ b/test/integration/bitbox_reconnect_recovery_test.dart @@ -0,0 +1,335 @@ +// Cross-layer integration test for the BitBox reconnect-recovery story. +// +// Three seams come together when a device drops in the middle of a checkout +// and the user re-plugs to retry: +// +// 1. FakeBitboxCredentials — flips behaviour mid-flight so a single +// credentials instance can simulate success → disconnect → success on +// a retry, the same arc the real BitBox plugin exposes via mid-sign +// BLE link drops. +// +// 2. BitboxService observer — periodically probes the simulator-backed +// platform interface for present devices and clears every active +// `BitboxCredentials` in place when the device list goes empty. The +// observer must NOT null the credentials reference (a follow-up +// `init()` re-attaches the manager via `setBitbox`). +// +// 3. SellBitboxCubit.retryAfterConnection — the cubit-side recovery hook +// the screen calls after the BitBox-required gate flips back to +// connected. The cubit re-checks `credentials.isConnected` and emits +// `SellBitboxEthReady` (or the faucet/error branch) accordingly. +// +// Tests run inside `fakeAsync` so the observer's periodic timer and the +// cubit's microtask-based `_checkEthBalance` are both bound to the same +// virtual clock — no wallclock-sleeps, deterministic tick counting. + +import 'dart:typed_data'; + +import 'package:bitbox_flutter/bitbox_flutter.dart'; +import 'package:bitbox_flutter/testing.dart'; +import 'package:bitbox_flutter/usb/bitbox_usb_platform_interface.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.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/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/real_unit_sell_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +class _MockFaucet extends Mock implements DfxFaucetService {} + +class _MockBlockchain extends Mock implements DfxBlockchainApiService {} + +class _MockSellService extends Mock implements RealUnitSellPaymentInfoService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + +SellPaymentInfo _info({ + double ethBalance = 1.0, + double requiredGasEth = 0.001, +}) => SellPaymentInfo( + id: 42, + eip7702: Eip7702Data.fromJson(const { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': '0xmgr', + 'delegatorAddress': '0xdr', + 'userNonce': 7, + 'domain': { + 'name': 'RealUnit', + 'version': '1', + 'chainId': 1, + 'verifyingContract': '0xverify', + }, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xd', + 'delegator': '0xdr', + 'authority': '0xauth', + 'caveats': >[], + 'salt': 0, + }, + 'tokenAddress': '0xtoken', + 'amountWei': '12345', + 'depositAddress': '0xdeposit', + }), + amount: 100, + exchangeRate: 1.0, + rate: 1.0, + beneficiary: const BeneficiaryDto(iban: 'CH...'), + estimatedAmount: 100.0, + currency: Currency.chf, + depositAddress: '0xdeposit', + tokenAddress: '0xtoken', + chainId: 1, + ethBalance: ethBalance, + requiredGasEth: requiredGasEth, +); + +void main() { + late BitboxUsbPlatform previousPlatform; + late SimulatedBitboxPlatform platform; + + // Tight observer cadence so device-loss surfaces in a few virtual ticks + // instead of the production 5 s default. fakeAsync makes the wall-clock + // value irrelevant, but keeping it short still tightens the test runtime. + const fastInterval = Duration(milliseconds: 50); + const observerSettleTime = Duration(milliseconds: 150); + + const knownAddress = '0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'; + + setUp(() { + previousPlatform = BitboxUsbPlatform.instance; + platform = installSimulatedBitboxPlatform(); + }); + + tearDown(() { + BitboxUsbPlatform.instance = previousPlatform; + }); + + // Pair the service inside an existing fakeAsync zone — the periodic timer + // the observer installs must be bound to the fake clock so async.elapse + // pumps it. + BitboxService pairedServiceSync(FakeAsync async) { + final service = BitboxService(connectionStatusInterval: fastInterval); + late List devices; + service.getAllUsbDevices().then((d) => devices = d); + async.flushMicrotasks(); + service.init(devices.single); + async.flushMicrotasks(); + return service; + } + + group('BitboxCredentials × FakeBitbox mid-sign disconnect-and-retry', () { + test( + 'flips success → disconnect → success: credentials.isConnected reflects both phases', + () async { + // The fake's `behavior` is mutable, which is the whole point — a + // single instance can simulate a sign that succeeds, then a BLE drop + // (real `BitboxCredentials.isConnected` flips to false via + // observer-clear), then a re-pair that brings sign back online. This + // pins the contract `FakeBitboxCredentials → BitboxCredentials` + // exposes to the cubit's mid-flow recovery code. + final fake = FakeBitboxCredentials(signDelay: Duration.zero); + + // Phase 1: device connected, sign succeeds. + expect(fake.isConnected, isTrue); + final firstSig = await fake.signPersonalMessage(_payload()); + expect(firstSig.length, greaterThan(0)); + expect(fake.signCallCount, 1); + + // Phase 2: BLE link drops mid-flow → next sign throws disconnect. + fake.behavior = FakeBitboxBehavior.disconnect; + expect( + fake.isConnected, + isFalse, + reason: 'disconnect behaviour must surface as isConnected=false', + ); + await expectLater( + fake.signPersonalMessage(_payload()), + throwsA(isA()), + ); + // The throw still incremented the call count — proves the fake + // actually ran the sign branch instead of short-circuiting on the + // connection check (the real BitboxCredentials checks connectivity + // inside `_synchronizeBoundedSign`, not before the call). + expect(fake.signCallCount, 2); + + // Phase 3: user re-plugs → behaviour flips back, sign succeeds again. + fake.behavior = FakeBitboxBehavior.success; + expect(fake.isConnected, isTrue); + final retrySig = await fake.signPersonalMessage(_payload()); + expect(retrySig.length, greaterThan(0)); + expect(fake.signCallCount, 3); + }, + ); + }); + + group('BitboxService observer × cubit recovery', () { + test( + 'observer detects device-loss: credentials cleared in place, reference preserved', + () { + // Critical to the cubit recovery loop: SellBitboxCubit holds the + // `BitboxCredentials` reference inside `appStore.wallet.currentAccount + // .primaryAddress`. The observer must clear connectivity on the + // existing reference rather than nulling the slot — otherwise a + // re-init() has nothing to re-attach and the screen stays stuck on + // SellBitboxBitboxRequired even after the device is back. + fakeAsync((async) { + final service = pairedServiceSync(async); + final credentials = service.getCredentials(knownAddress); + + // Pair-time invariants. + expect(credentials.isConnected, isTrue); + expect(credentials.bitboxManager, isNotNull); + + // Device vanishes. + platform.when( + SimulatedBitboxMethod.getDevices, + (_) async => const [], + ); + + service.startConnectionStatusObserver(); + async.elapse(observerSettleTime); + + // After device-loss: the manager is cleared but the credentials + // instance is still the same object — getCredentials returns it + // unchanged so the cubit's `is BitboxCredentials` guard still + // matches the right slot. + expect( + credentials.isConnected, + isFalse, + reason: 'observer must mark credentials as disconnected', + ); + expect( + credentials.bitboxManager, + isNull, + reason: 'observer must clear the manager so the next sign fails fast', + ); + expect( + service.getCredentials(knownAddress), + same(credentials), + reason: + 'observer must not orphan the credentials — same address must keep returning the same instance', + ); + + service.stopConnectionStatusObserver(); + }); + }, + ); + + test( + 're-attach after init: credentials produced before pair are promoted by init() and the cubit recovers', + () { + // Full cross-layer arc: persisted credentials (built before pairing), + // pair-via-init promotes them, the cubit's BitboxRequired gate clears + // on `retryAfterConnection`. Pins the seam SellBitboxCubit relies on + // — `credentials.isConnected` flipping must be enough to flip the + // cubit's state without rebuilding the wallet. + fakeAsync((async) { + // Step 1: build credentials BEFORE the service is paired. + final service = BitboxService(connectionStatusInterval: fastInterval); + final credentials = service.getCredentials(knownAddress); + expect( + credentials.isConnected, + isFalse, + reason: 'pre-pair credentials must report disconnected', + ); + + // Wire the cubit. Faucet stubbed so the eth-balance branch the + // cubit takes on the retry is the cheap one (no auth round-trip). + final faucet = _MockFaucet(); + final blockchain = _MockBlockchain(); + final sellService = _MockSellService(); + final appStore = _MockAppStore(); + final wallet = _MockWallet(); + final account = _MockWalletAccount(); + + when(() => appStore.wallet).thenReturn(wallet); + when( + () => appStore.apiConfig, + ).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.primaryAddress).thenReturn(knownAddress); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(credentials); + + final cubit = SellBitboxCubit( + paymentInfo: _info(), + faucetService: faucet, + blockchainService: blockchain, + sellService: sellService, + appStore: appStore, + ); + + // The constructor schedules `_checkEthBalance` via scheduleMicrotask. + async.flushMicrotasks(); + expect( + cubit.state, + isA(), + reason: 'disconnected BitBox must surface the BitboxRequired gate', + ); + + // Step 2: pair the device. init() re-attaches the manager to every + // pre-existing credentials instance via setBitbox(). + late List devices; + service.getAllUsbDevices().then((d) => devices = d); + async.flushMicrotasks(); + service.init(devices.single); + async.flushMicrotasks(); + + expect( + credentials.isConnected, + isTrue, + reason: 'init() must re-attach the manager to credentials created before pairing', + ); + + // Step 3: cubit-side recovery — the screen calls + // retryAfterConnection() once the gate flips back to connected. + // With ethBalance >= requiredGasEth the cubit must settle on + // SellBitboxEthReady, proving the recovery walked the same + // _checkEthBalance branch as a freshly-mounted screen would. + cubit.retryAfterConnection(); + async.flushMicrotasks(); + + expect( + cubit.state, + isA(), + reason: + 'retryAfterConnection on a re-attached BitBox must clear the gate without rebuilding the cubit', + ); + // No faucet hop on the recovery path — the payment info already + // has enough gas, so the cubit takes the EthReady branch directly. + verifyNever(() => faucet.requestFaucet()); + + // The cubit's stream is no longer subscribed to externally; close + // it inside the same zone to flush the cancellation microtask. + cubit.close(); + async.flushMicrotasks(); + }); + }, + ); + }); +} + +/// 32-byte personal-message payload. The real BitBox plugin signs whatever +/// the wallet account hashes for `signMessage`; size doesn't matter for the +/// fake's behaviour switch — only the behaviour mode does. +Uint8List _payload() => Uint8List.fromList(List.filled(32, 0xab)); diff --git a/test/integration/connect_bitbox_flow_test.dart b/test/integration/connect_bitbox_flow_test.dart new file mode 100644 index 00000000..5be6cd39 --- /dev/null +++ b/test/integration/connect_bitbox_flow_test.dart @@ -0,0 +1,315 @@ +// Cross-layer integration tests for the BitBox pairing flow. +// +// These tests stitch the ConnectBitboxCubit together with a *real* +// BitboxService driven by the official bitbox_flutter simulator +// (SimulatedBitboxPlatform installed at the BitboxUsbPlatform.instance +// seam). The four touchpoints the cubit makes against the device boundary — +// startScan / pair (init) / getChannelHash / confirmPairing — go through +// the production code, not a mock. Downstream collaborators that don't +// belong to the BitBox boundary (WalletService.createBitboxWallet, +// DFXAuthService.ensureSignatureFor) are stubbed: they have their own +// dedicated suites, and a regression in those layers must not surface as +// a noisy failure here. +// +// Style anchor: test/integration/kyc_sign_flow_test.dart and +// test/packages/hardware_wallet/bitbox_service_test.dart (the simulator +// install / tearDown pattern). + +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; +import 'package:bitbox_flutter/testing.dart'; +import 'package:bitbox_flutter/usb/bitbox_usb_platform_interface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.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:realunit_wallet/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart'; + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAuthService extends Mock implements DFXAuthService {} + +class _MockBitboxWallet extends Mock implements BitboxWallet {} + +class _FakeBitboxWalletAccount extends Fake implements BitboxWalletAccount {} + +void main() { + // Polling cadence inside the cubit is hard-wired to 500 ms / 100 ms in + // production. Keep the observer tick fast so the disconnect cases settle + // well under the per-test timeout without leaning on long real-time waits. + const fastObserverInterval = Duration(milliseconds: 25); + + late BitboxUsbPlatform previousPlatform; + late SimulatedBitboxPlatform platform; + late BitboxService service; + late _MockWalletService walletService; + late _MockAuthService authService; + late _MockBitboxWallet wallet; + + setUpAll(() { + // _startScanning gates the startScan call on `DeviceInfo.instance.isIOS`; + // the test default platform is android and would skip the call entirely. + // Force iOS so the startScan touchpoint is actually exercised. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + registerFallbackValue(_FakeBitboxWalletAccount()); + }); + + tearDownAll(() { + debugDefaultTargetPlatformOverride = null; + }); + + setUp(() { + previousPlatform = BitboxUsbPlatform.instance; + platform = installSimulatedBitboxPlatform(); + service = BitboxService(connectionStatusInterval: fastObserverInterval); + walletService = _MockWalletService(); + authService = _MockAuthService(); + wallet = _MockBitboxWallet(); + + when(() => walletService.createBitboxWallet(any())).thenAnswer((_) async => wallet); + when(() => wallet.currentAccount).thenReturn(_FakeBitboxWalletAccount()); + when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); + }); + + tearDown(() { + BitboxUsbPlatform.instance = previousPlatform; + }); + + // Short host-side timeouts so the bounce-back / failure paths surface + // promptly. Production defaults are 75 s / 30 s / 120 s. + ConnectBitboxCubit makeCubit() => ConnectBitboxCubit( + service, + walletService, + authService, + confirmPairingTimeout: const Duration(milliseconds: 500), + createWalletTimeout: const Duration(milliseconds: 500), + pairingPinTimeout: const Duration(milliseconds: 500), + ); + + Future waitForState( + ConnectBitboxCubit cubit, { + Duration timeout = const Duration(seconds: 3), + }) async { + if (cubit.state is T) return; + await cubit.stream.firstWhere((s) => s is T).timeout(timeout); + } + + // The simulator's default channel hash is a constant string — the cubit's + // polling loop snapshots an initial "prior hash" and then waits for a + // *different* value to surface. Rotate the hash to something fresh so the + // first observed hash counts as a new pairing session. + void primeFreshChannelHash() { + platform.when( + SimulatedBitboxMethod.getChannelHash, + (_) async => 'fresh-channel-hash', + ); + } + + group('$ConnectBitboxCubit × BitboxService × SimulatedBitboxPlatform', () { + test( + 'happy: initial pair → channel-hash shown → confirmPairing → BitboxConnected', + () async { + // Drives the four BitBox touchpoints end-to-end against the + // simulator: getAllUsbDevices (scan loop) → init (connect+initBitBox) + // → getChannelHash → confirmPairing (channelHashVerify). Failure on + // any of these silently flips the state machine back to + // BitboxNotConnected, so the assertions pin both the terminal state + // AND the touchpoint counts. + primeFreshChannelHash(); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + expect((cubit.state as BitboxCheckHash).channelHash, 'fresh-channel-hash'); + + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + expect( + platform.count(SimulatedBitboxMethod.initBitBox), + greaterThanOrEqualTo(1), + reason: 'init must have driven the simulator init touchpoint', + ); + expect( + platform.count(SimulatedBitboxMethod.getChannelHash), + greaterThanOrEqualTo(1), + reason: 'channel-hash must be polled at least once before the user verifies', + ); + expect( + platform.count(SimulatedBitboxMethod.channelHashVerify), + 1, + reason: 'confirmPairing routes exactly one verify call to the device', + ); + verify(() => walletService.createBitboxWallet(any())).called(1); + verify(() => authService.ensureSignatureFor(any())).called(1); + }, + ); + + test( + 'pair-rejected: simulator rejects channelHashVerify → cubit bounces to BitboxNotConnected', + () async { + // confirmPairing surfaces the simulator's reject as + // `Exception("Failed to verify")` inside BitboxService.confirmPairing. + // The cubit's confirmPairing catches that, drops _pendingInit, and + // re-arms the scan loop — so a typed BitboxNotConnected state is the + // user-visible result, not a bare thrown future. + primeFreshChannelHash(); + platform.when( + SimulatedBitboxMethod.channelHashVerify, + (_) async => false, + ); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + + // Subscribe AFTER BitboxCheckHash so the post-rejection + // NotConnected transition is observable. The scan loop normally + // re-emits BitboxNotConnected → BitboxFound on every tick, so we + // anchor on confirmPairing kicking the cubit back to NotConnected + // before any subsequent BitboxFound restart. + final transitions = []; + final sub = cubit.stream.listen(transitions.add); + addTearDown(sub.cancel); + + await cubit.confirmPairing(); + // Flush a microtask hop so the catch's emit propagates through the + // bloc stream to the listener — `confirmPairing` resolves on the + // same synchronous tick the emit fires, so the listener has not yet + // observed BitboxNotConnected at that exact moment. + await Future.delayed(Duration.zero); + + expect( + transitions.whereType(), + isNotEmpty, + reason: 'confirmPairing must emit BitboxPairing before bouncing', + ); + expect( + transitions.whereType(), + isNotEmpty, + reason: 'a rejected channel-hash verify must surface as BitboxNotConnected', + ); + // The wallet must NOT be created or signed for when pairing fails: + // createBitboxWallet is downstream of confirmPairing in the cubit + // body, and a stray call here would mean we persisted a half-paired + // hardware wallet row. + verifyNever(() => walletService.createBitboxWallet(any())); + verifyNever(() => authService.ensureSignatureFor(any())); + }, + ); + + test( + 'observer-disconnect: device vanishes after pair → observer clears credentials', + () async { + // The cubit arms `service.startConnectionStatusObserver()` as the + // last act of a successful confirmPairing. After that, the periodic + // observer in BitboxService polls the device list; on the first + // empty result it must call clearBitbox() on every cached set of + // credentials. This pins the bridge between cubit success and the + // post-pairing recovery loop. + primeFreshChannelHash(); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + // Take a handle on the credentials the observer is supposed to + // clear. SimulatedBitboxPlatform's default ETH address is the same + // string the cubit's downstream createBitboxWallet would normally + // derive — feed that into getCredentials so observer-driven + // clearBitbox() and our assertion see the same map entry. + final credentials = service.getCredentials( + '0x1111111111111111111111111111111111111111', + ); + expect( + credentials.isConnected, + isTrue, + reason: 'after pairing, credentials must come back connected', + ); + + // Simulate the user yanking the cable: every subsequent device + // probe returns an empty list. The observer is already running + // (started inside confirmPairing's success branch) so we don't + // need to call startConnectionStatusObserver() ourselves. + platform.when( + SimulatedBitboxMethod.getDevices, + (_) async => const [], + ); + + // Two intervals' worth of virtual time → the observer is guaranteed + // to have ticked at least once and entered the device-loss branch. + await Future.delayed(fastObserverInterval * 4); + + expect( + credentials.isConnected, + isFalse, + reason: 'observer must clear the credentials on device-loss', + ); + }, + ); + + test( + 're-pair-after-disconnect: first pair OK, device vanishes, reappears → re-pair succeeds', + () async { + // Defends the P461 #1 contract: after an observer-driven device-loss + // the same credentials reference must heal once init() is called + // again. Without that, a wallet built before the disconnect stays + // permanently broken and every sign throws BitboxNotConnected. + primeFreshChannelHash(); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + final credentials = service.getCredentials( + '0x1111111111111111111111111111111111111111', + ); + expect(credentials.isConnected, isTrue); + + // Device disappears → observer fires clearBitbox(). + platform.when( + SimulatedBitboxMethod.getDevices, + (_) async => const [], + ); + await Future.delayed(fastObserverInterval * 4); + expect( + credentials.isConnected, + isFalse, + reason: 'pre-condition: observer must have cleared the credentials', + ); + + // Device reappears. Drive a fresh pair via the service directly — + // the cubit's own scan loop was cancelled when it transitioned past + // BitboxFound, so we exercise the same `init()` re-attach branch the + // user's "unplug + replug + tap pair again" gesture would hit. + platform.when( + SimulatedBitboxMethod.getDevices, + (_) async => platform.devices, + ); + final devices = await service.getAllUsbDevices(); + expect(devices, hasLength(1)); + final initOk = await service.init(devices.single); + expect(initOk, isTrue); + + expect( + credentials.isConnected, + isTrue, + reason: + 'the same credentials reference must re-attach on reconnect ' + '(P461 #1 — getCredentials returns the cached instance)', + ); + }, + ); + }); +} diff --git a/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart b/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart new file mode 100644 index 00000000..c2e9c7bd --- /dev/null +++ b/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart @@ -0,0 +1,286 @@ +// Cross-layer integration test for the DFX auth-sign ceremony driven by a +// BitBox wallet. Stitches together every layer the cold-cache JWT round-trip +// touches: +// +// DFXAuthService.getAuthToken +// → getSignMessage (HTTP via MockClient) +// → BitboxWalletAccount.signMessage +// → BitboxCredentials.signPersonalMessage (FakeBitboxCredentials) +// → getAuthResponse (HTTP via MockClient) +// → JWT stored in SessionCache +// +// These tests live in `test/integration/` rather than the per-layer suites +// because the BitBox cancel / disconnect / timeout / 403 paths only show +// their real shape when the auth service, the wallet account, the hardware +// credentials, and the wire surface are exercised together. The single-layer +// suites are still the canonical place for fine-grained branch pinning; this +// file guards the seams. +// +// HTTP boundary: `MockClient` from `package:http/testing`. Hardware boundary: +// `FakeBitboxCredentials` (the existing test helper that already implements +// `signPersonalMessage` with selectable behaviour). The DFXAuthService and +// SessionCache instances are the real production code. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:fake_async/fake_async.dart'; +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/dfx_auth_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/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +/// Concrete DFXAuthService that exposes the BitBox-backed wallet account as +/// the current wallet — same shim other DFXAuthService suites use to avoid +/// dragging the AppStore's full wallet graph into the test. +class _BitboxAuthService extends DFXAuthService { + _BitboxAuthService(super.appStore, super.walletService, this._account); + + final AWalletAccount _account; + + @override + AWalletAccount get wallet => _account; + + @override + String get walletAddress => _account.primaryAddress.address.hexEip55; +} + +void main() { + setUpAll(() { + // Required by mocktail for the `appStore.httpClient` stub when the test + // overrides it with a fresh MockClient mid-flow. + registerFallbackValue(MockClient((_) async => http.Response('', 200))); + }); + + group('DFXAuthService.getAuthToken × BitboxCredentials sign ceremony', () { + late _MockAppStore appStore; + late _MockWalletService walletService; + late SessionCache sessionCache; + late _MockCacheRepository cacheRepo; + late FakeBitboxCredentials credentials; + late BitboxWalletAccount account; + + const signMessage = 'Sign me to log in to api.dfx.swiss'; + const jwt = 'jwt-fresh-token'; + + setUp(() { + appStore = _MockAppStore(); + walletService = _MockWalletService(); + cacheRepo = _MockCacheRepository(); + // Production SessionCache wired to a stubbed CacheRepository so the + // signature/JWT writes resolve without hitting disk. + when(() => cacheRepo.read(any())).thenAnswer((_) async => null); + when(() => cacheRepo.write(any(), any())).thenAnswer((_) async => 1); + when(() => cacheRepo.delete(any())).thenAnswer((_) async {}); + sessionCache = SessionCache(cacheRepo); + + credentials = FakeBitboxCredentials(signDelay: Duration.zero); + account = BitboxWalletAccount(0, credentials); + + when(() => appStore.sessionCache).thenReturn(sessionCache); + when( + () => appStore.apiConfig, + ).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + // Software-wallet unlock/lock are no-ops on a BitBox path — every sign + // crosses BLE/USB, never the local AES-GCM mnemonic store. Stubbing + // them lets the production `getSignature` finally-block run unchanged. + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + _BitboxAuthService buildService(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return _BitboxAuthService(appStore, walletService, account); + } + + test( + 'happy path: cold cache → signMessage GET → BitBox sign → auth POST 201 → JWT cached', + () async { + final calls = []; + Map? authBody; + final client = MockClient((request) async { + calls.add('${request.method} ${request.url.path}'); + if (request.method == 'GET' && request.url.path == '/v1/auth/signMessage') { + // Address query param must be the credentials' EIP-55 address. + expect( + request.url.queryParameters['address'], + credentials.address.hexEip55, + ); + return http.Response(jsonEncode({'message': signMessage}), 200); + } + if (request.method == 'POST' && request.url.path == '/v1/auth') { + authBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'accessToken': jwt}), 201); + } + return http.Response('unexpected ${request.method} ${request.url}', 500); + }); + + final service = buildService(client); + + final token = await service.getAuthToken(); + + expect(token, jwt); + // The full ceremony happened in order: sign-message GET, then auth POST. + expect(calls, [ + 'GET /v1/auth/signMessage', + 'POST /v1/auth', + ]); + // BitBox signed exactly once — the cancel/disconnect guards never + // forced a retry on the happy path. + expect(credentials.signCallCount, 1); + // The wire body carries the BitBox-derived signature alongside the + // wallet name and the address. + expect(authBody!['wallet'], 'RealUnit'); + expect(authBody!['address'], credentials.address.hexEip55); + final wireSig = authBody!['signature'] as String; + expect(wireSig, startsWith('0x')); + // 65-byte secp256k1 signature → 0x + 130 hex chars. + expect(wireSig.length, 132); + + // SessionCache holds both the JWT and the signature — a second + // `ensureSignatureFor` call must short-circuit without re-signing. + expect(sessionCache.authToken, jwt); + expect(sessionCache.signature, wireSig); + expect(sessionCache.signatureAddress, credentials.address.hexEip55); + + await service.ensureSignatureFor(account); + expect( + credentials.signCallCount, + 1, + reason: 'ensureSignatureFor must not re-trigger the device when the cache is warm', + ); + }, + ); + + test( + 'cancel mid-sign: FakeBitbox returns empty bytes → SigningCancelledException, no POST, no JWT', + () async { + credentials.behavior = FakeBitboxBehavior.cancel; + final calls = []; + final client = MockClient((request) async { + calls.add('${request.method} ${request.url.path}'); + if (request.method == 'GET') { + return http.Response(jsonEncode({'message': signMessage}), 200); + } + // If the POST ever fires the cancel guard didn't fire — fail loudly. + return http.Response('cancel did not short-circuit', 500); + }); + + final service = buildService(client); + + await expectLater( + service.getAuthToken(), + throwsA(isA()), + ); + + // signMessage was fetched but the cancel happened before any POST. + expect(calls, ['GET /v1/auth/signMessage']); + expect(credentials.signCallCount, 1); + expect(sessionCache.authToken, isNull); + expect(sessionCache.signature, isNull); + // The cancel path still walks the unlock/lock finally — verify the + // lock fires exactly once so a future refactor can't leak an unlocked + // software-wallet state on the BitBox path. + verify(() => walletService.lockCurrentWallet()).called(1); + }, + ); + + test( + 'sign-message timeout: hung FakeBitbox + fake_async crosses the 3-min cap → TimeoutException + lock fires', + () { + credentials.behavior = FakeBitboxBehavior.timeout; + // Disable any default signDelay so the only thing keeping the sign + // pending is the FakeBitboxBehavior.timeout `Completer().future`. + credentials.signDelay = Duration.zero; + + fakeAsync((async) { + final client = MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'message': signMessage}), 200); + } + return http.Response('should never POST on a timed-out sign', 500); + }); + final service = buildService(client); + + Object? caught; + unawaited( + service.getAuthToken().catchError((Object e) { + caught = e; + return null; + }), + ); + + // Just under the 3-min `_signMessageTimeout`: still pending. + async.elapse(const Duration(minutes: 2, seconds: 59)); + expect(caught, isNull); + + // Crossing the cap surfaces a TimeoutException. The BitBox sign + // hangs forever otherwise — this guard is what stops the UI from + // wedging on a frozen device. + async.elapse(const Duration(seconds: 2)); + expect(caught, isA()); + + // The unlock/lock finally MUST run even on timeout — otherwise a + // hung BitBox would leak the decrypted-mnemonic state on the + // software-wallet side of the same code path. + verify(() => walletService.lockCurrentWallet()).called(1); + }); + }, + ); + + test( + '403 country-blocked: sign succeeds, auth POST returns 403 with message → Exception propagated to caller', + () async { + final client = MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'message': signMessage}), 200); + } + return http.Response( + jsonEncode({'statusCode': 403, 'message': 'country-blocked: CH'}), + 403, + ); + }); + final service = buildService(client); + + await expectLater( + service.getAuthToken(), + throwsA( + isA().having( + (e) => e.toString(), + 'toString()', + contains('country-blocked: CH'), + ), + ), + ); + + // The sign DID happen — the 403 is a server-side decision after the + // BitBox produced a real signature. Critical guard against a future + // refactor that tries to short-circuit the sign on a country check. + expect(credentials.signCallCount, 1); + // …but no JWT was cached: the 403 must abort the cache write. + expect(sessionCache.authToken, isNull); + // The signature however IS persisted (it's a property of the wallet, + // not the auth response). A retry against a different region must + // not re-trigger the device. + expect(sessionCache.signature, isNotNull); + }, + ); + }); +} diff --git a/test/integration/eip7702_delegation_bitbox_test.dart b/test/integration/eip7702_delegation_bitbox_test.dart new file mode 100644 index 00000000..aa86077c --- /dev/null +++ b/test/integration/eip7702_delegation_bitbox_test.dart @@ -0,0 +1,215 @@ +// Cross-layer integration tests for the EIP-7702 delegation sign flow. +// +// The BitBox-gated EIP-712 delegation sign is invoked from +// `RealUnitSellPaymentInfoService.confirmPayment` on every BitBox sell, but +// the typed-data sign itself happens in [Eip712Signer.signDelegation]. This +// file stitches: +// +// Eip712Signer.signDelegation +// → FakeBitboxCredentials.signTypedDataV4 (BitBox boundary) +// → empty-sig / disconnect guards +// +// and pins the chainId-wiring + signature-routing contract: the chainId +// passed to the BitBox sign call MUST come from the EIP-7702 domain (NOT +// from a hard-coded constant, NOT from the asset config), so a multi-chain +// rollout that changes the domain chainId does not silently sign on the +// wrong chain. +// +// We exercise BOTH credential types (FakeBitboxCredentials + EthPrivateKey) +// to lock in the polymorphic switch inside `Eip712Signer._signTypedData` — +// a regression that broke the EthPrivateKey arm shipped in PR #318. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:web3dart/web3dart.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +/// FakeBitboxCredentials extension that records every `(chainId, jsonData)` +/// the production EIP-712 signer hands to the BitBox boundary. Lets a test +/// assert chainId wiring without mocking the production code it is testing. +class _RecordingFakeBitbox extends FakeBitboxCredentials { + _RecordingFakeBitbox({super.behavior}) : super(signDelay: Duration.zero); + + final List chainIds = []; + final List jsonPayloads = []; + + @override + Future signTypedDataV4(int chainId, String jsonData) { + chainIds.add(chainId); + jsonPayloads.add(jsonData); + return super.signTypedDataV4(chainId, jsonData); + } +} + +// MetaMask Delegation Framework v1.3.0, CREATE2 — identical on all EVM chains. +// These are the only delegator/manager addresses the production service +// accepts, but `Eip712Signer.signDelegation` itself does NOT validate them; +// the test fixture nonetheless mirrors them so the EIP-712 envelope is +// realistic. +const _metaMaskDelegator = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b'; +const _delegationManager = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; + +/// Builds an [Eip7702Data] with the requested [chainId]. The default value +/// (1) covers the mainnet path; explicit values (e.g. 11155111 for Sepolia, +/// 42161 for Arbitrum) cover the chainId-wiring case below. +Eip7702Data _data({int chainId = 1}) => Eip7702Data.fromJson({ + 'relayerAddress': '0x0000000000000000000000000000000000000011', + 'delegationManagerAddress': _delegationManager, + 'delegatorAddress': _metaMaskDelegator, + 'userNonce': 7, + 'domain': { + 'name': 'RealUnit', + 'version': '1', + 'chainId': chainId, + 'verifyingContract': _delegationManager, + }, + 'types': { + 'Delegation': >[ + {'name': 'delegate', 'type': 'address'}, + {'name': 'delegator', 'type': 'address'}, + {'name': 'authority', 'type': 'bytes32'}, + {'name': 'caveats', 'type': 'Caveat[]'}, + {'name': 'salt', 'type': 'uint256'}, + ], + 'Caveat': >[ + {'name': 'enforcer', 'type': 'address'}, + {'name': 'terms', 'type': 'bytes'}, + ], + }, + 'message': { + 'delegate': '0x0000000000000000000000000000000000000014', + 'delegator': '0x0000000000000000000000000000000000000015', + 'authority': '0x0000000000000000000000000000000000000000000000000000000000000016', + 'caveats': >[], + 'salt': 0, + }, + 'tokenAddress': '0x0000000000000000000000000000000000000017', + 'amountWei': '100', + 'depositAddress': '0x0000000000000000000000000000000000000018', +}); + +void main() { + group('Eip712Signer.signDelegation × FakeBitboxCredentials', () { + test( + 'happy: FakeBitbox success + EthPrivateKey fallback → both produce a 65-byte EIP-712 sig', + () async { + // BitBox arm — FakeBitboxCredentials.signTypedDataV4 returns an + // EIP-712-V4 signature derived from the deterministic test private + // key. The signDelegation helper must NOT mangle the chainId / json + // it forwards (e.g. by re-serialising with mixed key orders), and + // must NOT trip the empty-sig guard on a real 65-byte payload. + final bitbox = _RecordingFakeBitbox(); + final bitboxSig = await Eip712Signer.signDelegation( + credentials: bitbox, + eip7702Data: _data(), + ); + + expect(bitboxSig, startsWith('0x')); + // 65 bytes = 0x + 130 hex chars. A regression that returned a 64-byte + // sig (missing v) or a 0x-only payload (empty-sig guard misfire) + // would surface here. + expect(bitboxSig.length, 132); + expect(bitbox.signCallCount, 1); + + // EthPrivateKey fallback arm — same call, different credential type. + // The switch inside `Eip712Signer._signTypedData` must route this to + // EthSigUtil.signTypedData and NOT throw UnsupportedError. (A bug in + // PR #318 made every non-BitBox credential throw — caught by this + // arm specifically.) Use a fixed second key whose value differs from + // the FakeBitboxCredentials test key — comparing the two sigs pins + // that the EthPrivateKey arm actually went through its OWN key, not + // a leaked default from the BitBox arm. + final pk = EthPrivateKey.fromHex(_secondTestPrivateKeyHex); + final pkSig = await Eip712Signer.signDelegation( + credentials: pk, + eip7702Data: _data(), + ); + expect(pkSig, startsWith('0x')); + expect(pkSig.length, 132); + + // The two sigs MUST differ — they were produced by different keys + // over the same payload. An accidental wiring that always signed + // with the test key (e.g. a leaked default) would surface as + // equality here. + expect(pkSig, isNot(equals(bitboxSig))); + }, + ); + + test( + 'cancel: FakeBitbox.cancel → "0x" → SigningCancelledException propagates', + () async { + // The BitBox swift wrapper returns `'0x'` when the user cancels on + // the device. `_signTypedData`'s post-sign guard must convert that + // into a typed `SigningCancelledException` — otherwise the empty sig + // is sent on the wire and the cancel is misread as a success. + final bitbox = _RecordingFakeBitbox(behavior: FakeBitboxBehavior.cancel); + + await expectLater( + Eip712Signer.signDelegation(credentials: bitbox, eip7702Data: _data()), + throwsA(isA()), + ); + expect(bitbox.signCallCount, 1); + }, + ); + + test( + 'chainId-wiring: signDelegation forwards eip7702Data.domain.chainId to the BitBox sign call', + () async { + // The chainId argument to BitboxCredentials.signTypedDataV4 must be + // the chainId from the EIP-7702 domain, NOT a constant (`1`), NOT + // the app's asset chainId (which can differ on a multi-chain + // rollout), NOT `0`. A wrong chainId here makes the BitBox display + // the wrong network to the user — silent until the user spots it. + for (final chainId in const [1, 11155111, 42161]) { + final bitbox = _RecordingFakeBitbox(); + await Eip712Signer.signDelegation( + credentials: bitbox, + eip7702Data: _data(chainId: chainId), + ); + expect( + bitbox.chainIds.single, + chainId, + reason: 'signDelegation must forward domain.chainId=$chainId unchanged', + ); + // The chainId must also appear inside the json payload's domain — + // the BitBox firmware re-derives the digest from the json, so if + // the json's chainId disagrees with the integer arg the signature + // would be over a different digest than the wallet expects. + expect(bitbox.jsonPayloads.single, contains('"chainId":$chainId')); + } + }, + ); + + test( + 'disconnect: FakeBitbox.disconnect throws BitboxNotConnectedException at the BitBox boundary', + () async { + // `BitboxCredentials.signTypedDataV4` throws BitboxNotConnectedException + // when the BLE link is dropped (the real class checks bitboxManager + // == null up front, the fake mirrors that). The exception must NOT + // be swallowed inside Eip712Signer — the caller + // (RealUnitSellPaymentInfoService.confirmPayment) relies on the + // typed exception to drive the re-pair UI. + final bitbox = _RecordingFakeBitbox(behavior: FakeBitboxBehavior.disconnect); + + await expectLater( + Eip712Signer.signDelegation(credentials: bitbox, eip7702Data: _data()), + throwsA(isA()), + ); + // It must NOT have been re-wrapped as SigningCancelledException. + await expectLater( + Eip712Signer.signDelegation(credentials: bitbox, eip7702Data: _data()), + throwsA(isNot(isA())), + ); + }, + ); + }); +} + +/// Second deterministic test private key — distinct from the one inside +/// [FakeBitboxCredentials] so a sig produced with this key cannot collide +/// with one produced by the BitBox arm. Do NOT reuse outside tests. +const _secondTestPrivateKeyHex = 'aa1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; diff --git a/test/integration/sell_bitbox_flow_test.dart b/test/integration/sell_bitbox_flow_test.dart new file mode 100644 index 00000000..c721f883 --- /dev/null +++ b/test/integration/sell_bitbox_flow_test.dart @@ -0,0 +1,417 @@ +// Cross-layer integration tests for the BitBox sell flow. +// +// These tests stitch together three layers that the BitBox sell ceremony +// touches end-to-end, all of which have had production regressions in the +// past two months (PR #322, #338, #341, #514): +// +// SellBitboxCubit +// → FakeBitboxCredentials.signToSignature (BitBox boundary, no real +// BLE/USB stack — controllable success/cancel/disconnect/malformed) +// → RealUnitSellPaymentInfoService (real production class) +// → MockClient (last-mile HTTP boundary stand-in) +// +// We deliberately wire up the real RealUnitSellPaymentInfoService instead of +// stubbing it — the goal is to pin the failure-handling contract between the +// cubit and the service (state transitions on swap-OK + deposit-fail, etc.) +// AND the wire-format the service produces on success. A regression in the +// service that broke either contract has shipped to production twice; both +// would be caught by these tests. +// +// They run headless (no device, no simulator), so they live under +// `test/integration/` and run as part of `flutter test`. + +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/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/real_unit_sell_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_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:realunit_wallet/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +class _MockFaucet extends Mock implements DfxFaucetService {} + +class _MockBlockchain extends Mock implements DfxBlockchainApiService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +// The eip7702 sub-object is required by SellPaymentInfo but is not exercised +// by the BitBox sell path (BitBox uses the txHash branch, software wallets +// use the eip7702 branch — `confirmPaymentWithTxHash` takes only the hash). +// We still need a syntactically valid envelope so SellPaymentInfo.fromJson +// stays happy. +Map _eip7702Json() => { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': '0xmgr', + 'delegatorAddress': '0xdr', + 'userNonce': 7, + 'domain': { + 'name': 'RealUnit', + 'version': '1', + 'chainId': 1, + 'verifyingContract': '0xverify', + }, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xd', + 'delegator': '0xdr', + 'authority': '0xauth', + 'caveats': >[], + 'salt': 0, + }, + 'tokenAddress': '0xtoken', + 'amountWei': '12345', + 'depositAddress': '0xdeposit', +}; + +SellPaymentInfo _info({double ethBalance = 1.0}) => SellPaymentInfo( + id: 42, + eip7702: Eip7702Data.fromJson(_eip7702Json()), + amount: 100, + exchangeRate: 1.0, + rate: 1.0, + beneficiary: const BeneficiaryDto(iban: 'CH...'), + estimatedAmount: 100.0, + currency: Currency.chf, + depositAddress: '0xdeposit', + tokenAddress: '0xtoken', + chainId: 1, + ethBalance: ethBalance, + requiredGasEth: 0.001, +); + +// Arbitrary EIP-1559 raw bytes. The deterministic test private key inside +// FakeBitboxCredentials signs them, so we don't need a real RLP-encoded tx — +// the cubit just hex-decodes, calls signToSignature, repacks (r, s, v). +const _rawSwap = '0xdeadbeef'; +const _rawDeposit = '0xcafebabe'; + +void main() { + late _MockFaucet faucet; + late _MockBlockchain blockchain; + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockWalletAccount account; + late _MockWalletService walletService; + late SessionCache session; + late FakeBitboxCredentials creds; + + setUp(() { + faucet = _MockFaucet(); + blockchain = _MockBlockchain(); + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockWalletAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + // Pre-seed an auth token so the service skips its sign-message round-trip. + session.setAuthToken('jwt-test'); + + creds = FakeBitboxCredentials(signDelay: Duration.zero); + + when(() => appStore.wallet).thenReturn(wallet); + when( + () => appStore.apiConfig, + ).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.primaryAddress).thenReturn(creds.address.hexEip55); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(creds); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + // Build a real [RealUnitSellPaymentInfoService] backed by [client]. The cubit + // talks to this service object — there is no service-level stub between the + // cubit and the HTTP boundary. + RealUnitSellPaymentInfoService buildSellService(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitSellPaymentInfoService(appStore, walletService); + } + + SellBitboxCubit buildCubit(RealUnitSellPaymentInfoService sellService) => SellBitboxCubit( + paymentInfo: _info(), + faucetService: faucet, + blockchainService: blockchain, + sellService: sellService, + appStore: appStore, + ); + + // Waits for the constructor's first non-Checking emit and asserts it is + // EthReady — the precondition every flow test in this file shares. + Future settleToEthReady(SellBitboxCubit cubit) async { + final state = await cubit.stream.firstWhere((s) => s is! SellBitboxCheckingEth); + expect( + state, + isA(), + reason: + 'fixture has ethBalance > requiredGasEth and a connected FakeBitbox; ' + '_checkEthBalance should land in SellBitboxEthReady', + ); + } + + group('sell flow cross-layer: cubit → BitBox boundary → service → MockClient', () { + test( + 'happy: full swap + deposit broadcast with FakeBitboxBehavior.success', + () async { + // Script the server: unsigned txs → swap broadcast → deposit broadcast → confirm. + // The service is real; the MockClient is the last-mile substitute for + // the network. Asserts go on the side-effects of the real service, + // not on the stubbed sellService. + var unsignedCalls = 0; + var broadcastCalls = 0; + var confirmCalls = 0; + final broadcastBodies = >[]; + + final client = MockClient((request) async { + if (request.url.path.endsWith('/unsigned-transactions')) { + unsignedCalls++; + return http.Response( + jsonEncode({'swap': _rawSwap, 'deposit': _rawDeposit}), + 200, + ); + } + if (request.url.path.endsWith('/broadcast')) { + broadcastCalls++; + broadcastBodies.add(jsonDecode(request.body) as Map); + // Deposit broadcast is the third broadcast (swap top-of-confirmDeposit, + // swap-inside-helper, deposit-inside-helper). Production code makes + // TWO broadcast calls actually — pin via the captured bodies below. + return http.Response(jsonEncode({'txHash': '0xtx-$broadcastCalls'}), 200); + } + if (request.url.path.endsWith('/confirm')) { + confirmCalls++; + return http.Response('{}', 200); + } + fail('unexpected request: ${request.url}'); + }); + + final cubit = buildCubit(buildSellService(client)); + await settleToEthReady(cubit); + + await cubit.proceedToSwap(); + expect(cubit.state, isA()); + + await cubit.confirmSwap(); + expect(cubit.state, isA()); + + await cubit.confirmDeposit(); + expect(cubit.state, isA()); + + // Two BitBox sign calls: swap + deposit. A regression that + // double-signed (e.g. retried inside the cubit on a transient + // exception) would surface here, not in production. + expect(creds.signCallCount, 2); + + // Wire shape: one unsigned-txs, two broadcasts (swap + deposit), + // one confirm — the cubit MUST broadcast the swap first then the + // deposit, otherwise the backend rejects the second-leg. + expect(unsignedCalls, 1); + expect(broadcastCalls, 2); + expect(confirmCalls, 1); + expect(broadcastBodies[0]['unsignedTx'], _rawSwap); + expect(broadcastBodies[1]['unsignedTx'], _rawDeposit); + // Signatures must be hex strings of the right shape — the cubit + // pads the (r, s) BigInts to 32 bytes each. + expect((broadcastBodies[0]['r'] as String).length, 66); + expect((broadcastBodies[0]['s'] as String).length, 66); + + await cubit.close(); + }, + ); + + test( + 'cancel: FakeBitboxBehavior.cancel mid-swap → cubit settles in SellBitboxError', + () async { + // FakeBitboxCredentials.signToSignature throws SigningCancelledException + // on cancel. The cubit's catch (e) clause swallows that into a + // SellBitboxError carrying the exception's toString. This pins the + // path that a cancelled-on-device sign does NOT silently succeed + // (regression class: empty signature accepted as valid). + final client = MockClient((request) async { + if (request.url.path.endsWith('/unsigned-transactions')) { + return http.Response( + jsonEncode({'swap': _rawSwap, 'deposit': _rawDeposit}), + 200, + ); + } + fail( + 'cancel path must not reach broadcast/confirm — ' + 'unexpected request: ${request.url}', + ); + }); + + final cubit = buildCubit(buildSellService(client)); + await settleToEthReady(cubit); + await cubit.proceedToSwap(); + + creds.behavior = FakeBitboxBehavior.cancel; + await cubit.confirmSwap(); + + final state = cubit.state as SellBitboxError; + expect(state.message, contains('SigningCancelledException')); + // Cancel must NOT consume more than the one attempted sign — a + // regression that auto-retried on cancel would surface as 2 here. + expect(creds.signCallCount, 1); + + await cubit.close(); + }, + ); + + test( + 'disconnect: FakeBitboxBehavior.disconnect → BitboxNotConnectedException → SellBitboxBitboxRequired', + () async { + final client = MockClient((request) async { + if (request.url.path.endsWith('/unsigned-transactions')) { + return http.Response( + jsonEncode({'swap': _rawSwap, 'deposit': _rawDeposit}), + 200, + ); + } + fail( + 'disconnect path must not reach broadcast/confirm — ' + 'unexpected request: ${request.url}', + ); + }); + + final cubit = buildCubit(buildSellService(client)); + await settleToEthReady(cubit); + await cubit.proceedToSwap(); + + creds.behavior = FakeBitboxBehavior.disconnect; + await cubit.confirmSwap(); + + // The cubit must catch BitboxNotConnectedException EXPLICITLY (typed + // catch) and emit SellBitboxBitboxRequired so the UI can re-pair — + // a generic catch (e) would land in SellBitboxError and the user + // would see a raw "BitBox is not connected" string instead of the + // re-pair screen. + expect(cubit.state, isA()); + + await cubit.close(); + }, + ); + + test( + 'malformed-sig: FakeBitboxBehavior.malformed → typed error in cubit (SellBitboxError)', + () async { + // FakeBitboxCredentials.signToSignature throws FormatException on + // malformed mode (mimicking a frame-desync regression upstream). + // The cubit's generic catch (e) maps it to SellBitboxError — + // critically NOT to SellBitboxBitboxRequired (would mask a sig bug + // as a UX disconnect prompt) and NOT silently to a "successful" sign + // (would broadcast garbage and the backend would reject). + final client = MockClient((request) async { + if (request.url.path.endsWith('/unsigned-transactions')) { + return http.Response( + jsonEncode({'swap': _rawSwap, 'deposit': _rawDeposit}), + 200, + ); + } + fail( + 'malformed path must not reach broadcast/confirm — ' + 'unexpected request: ${request.url}', + ); + }); + + final cubit = buildCubit(buildSellService(client)); + await settleToEthReady(cubit); + await cubit.proceedToSwap(); + + creds.behavior = FakeBitboxBehavior.malformed; + await cubit.confirmSwap(); + + final state = cubit.state as SellBitboxError; + expect(state.message, contains('Malformed')); + // It must NOT have been re-classified as a BitBox disconnect. + expect(cubit.state, isNot(isA())); + + await cubit.close(); + }, + ); + + test( + 'deposit-retry: swap OK + deposit broadcast 5xx → SellBitboxDepositRetry preserves both signed envelopes', + () async { + // This pins the bug class behind PR #338: a transient RPC failure on + // the deposit broadcast must NOT lose the already-signed swap+deposit. + // Losing them would force the user to re-confirm on the device twice + // (worst case: a partial swap with no follow-up deposit). The retry + // state must carry both signed envelopes verbatim. + var broadcastCalls = 0; + final client = MockClient((request) async { + if (request.url.path.endsWith('/unsigned-transactions')) { + return http.Response( + jsonEncode({'swap': _rawSwap, 'deposit': _rawDeposit}), + 200, + ); + } + if (request.url.path.endsWith('/broadcast')) { + broadcastCalls++; + // First broadcast (swap top-of-confirmDeposit) returns OK; + // second broadcast (deposit inside helper) explodes with 502. + if (broadcastCalls == 2) { + return http.Response( + jsonEncode({'statusCode': 502, 'code': 'BAD_GATEWAY', 'message': 'rpc 502'}), + 502, + ); + } + return http.Response(jsonEncode({'txHash': '0xswaphash'}), 200); + } + fail('confirm must not be reached on deposit-fail: ${request.url}'); + }); + + final cubit = buildCubit(buildSellService(client)); + await settleToEthReady(cubit); + await cubit.proceedToSwap(); + await cubit.confirmSwap(); + await cubit.confirmDeposit(); + + // The cubit must NOT have advanced to SellBitboxSuccess on a + // mid-flight broadcast failure — losing the signed envelopes would + // be a silent funds-at-risk regression. + final state = cubit.state as SellBitboxDepositRetry; + expect(state.signedSwapTransaction.unsignedTx, _rawSwap); + expect(state.signedDepositTransaction.unsignedTx, _rawDeposit); + expect(state.errorMessage, contains('rpc 502')); + // The signatures must have survived intact — same shape as in the + // happy path. A regression that dropped them to '0x' would mean the + // user has to re-sign on the device. + expect(state.signedSwapTransaction.r.length, 66); + expect(state.signedDepositTransaction.r.length, 66); + // Two BitBox sign calls only — the retry must not have triggered an + // extra sign yet (that happens on retryDeposit). + expect(creds.signCallCount, 2); + + await cubit.close(); + }, + ); + }); +} diff --git a/test/integration/wallet_creation_bitbox_test.dart b/test/integration/wallet_creation_bitbox_test.dart new file mode 100644 index 00000000..afa86523 --- /dev/null +++ b/test/integration/wallet_creation_bitbox_test.dart @@ -0,0 +1,237 @@ +// Cross-layer integration tests for the BitBox wallet-creation flow. +// +// These tests stitch the WalletService together with a *real* BitboxService +// driven by the official bitbox_flutter simulator, a *real* WalletRepository +// backed by a Drift in-memory database (AppDatabase.forTesting + +// NativeDatabase.memory), and a *real* SettingsRepository on +// SharedPreferences.setMockInitialValues. The seam under test is: +// +// WalletService.createBitboxWallet +// → BitboxService.bitboxManager.getETHAddress (via the simulator) +// → WalletRepository.createViewWallet (writes walletInfos) +// → SettingsRepository.saveCurrentWalletId (persists current id) +// +// Style anchors: +// * test/integration/connect_bitbox_flow_test.dart (simulator install + +// tearDown pattern) +// * test/packages/repository/wallet_repository_test.dart (in-memory DB + +// mock SecureStorage setup) +// * test/packages/service/wallet_service_test.dart (the createBitboxWallet +// contract — pinned here against the *real* downstream stack instead of +// the mocks the unit suite uses). + +import 'dart:typed_data'; + +import 'package:bitbox_flutter/testing.dart'; +import 'package:bitbox_flutter/usb/bitbox_usb_platform_interface.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/repository/wallet_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/storage/database.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/packages/storage/wallet_storage.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockSecureStorage extends Mock implements SecureStorage {} + +class _MockAppStore extends Mock implements AppStore {} + +// SimulatedBitboxPlatform's default ETH address. The simulator always +// returns this string from getETHAddress regardless of the chainId / path, +// so it is what createBitboxWallet ends up persisting on the view row. +const _simulatedAddress = '0x1111111111111111111111111111111111111111'; + +void main() { + late BitboxUsbPlatform previousPlatform; + late SimulatedBitboxPlatform platform; + late AppDatabase db; + late _MockSecureStorage secureStorage; + late WalletRepository walletRepository; + late SettingsRepository settingsRepository; + late BitboxService bitboxService; + late _MockAppStore appStore; + late WalletService service; + + // Deterministic 256-bit AES-GCM key — content is irrelevant for the + // round-trip assertions, only that the encrypt/decrypt static helpers + // would receive consistent bytes if they were ever called. The bitbox + // path must NOT touch this key (no seed to wrap), and the + // persistence-round-trip test pins that contract. + final mnemonicKey = Uint8List.fromList(List.generate(32, (i) => i)); + + setUp(() async { + previousPlatform = BitboxUsbPlatform.instance; + // The production cubit drives `BitboxService.init(device)` (→ connect + + // initBitBox) before `WalletService.createBitboxWallet` ever calls + // `bitboxManager.getETHAddress`. These tests target the WalletService + // boundary directly, so we relax the simulator's `requireOpen` guard + // instead of replaying the pairing handshake just to satisfy it. + platform = installSimulatedBitboxPlatform(requireOpen: false); + + SharedPreferences.setMockInitialValues(const {}); + final prefs = await SharedPreferences.getInstance(); + + db = AppDatabase.forTesting(NativeDatabase.memory()); + secureStorage = _MockSecureStorage(); + walletRepository = WalletRepository(db, secureStorage); + settingsRepository = SettingsRepository(prefs); + // Real BitboxService — connectionStatusInterval is irrelevant here, + // we never arm the observer in these tests. + bitboxService = BitboxService(); + appStore = _MockAppStore(); + service = WalletService(bitboxService, walletRepository, settingsRepository, appStore); + + when(() => secureStorage.getOrCreateMnemonicKey()).thenAnswer((_) async => mnemonicKey); + }); + + tearDown(() async { + await db.close(); + BitboxUsbPlatform.instance = previousPlatform; + }); + + group('$WalletService.createBitboxWallet × $BitboxService × $WalletRepository', () { + test( + 'happy: createBitboxWallet writes a view row to the in-memory DB and marks it current', + () async { + // The full commit path runs end-to-end: + // simulator getETHAddress → WalletRepository.createViewWallet + // → WalletStorage.insertWallet → walletInfos row + // → SettingsRepository.saveCurrentWalletId + // The unit suite covers each layer in isolation with mocks; this + // pins the same contract through the *real* downstream stack so a + // wiring regression (wrong WalletType.index, missing setCurrentWallet + // call, dropped address) surfaces here. + final wallet = await service.createBitboxWallet('Hardware'); + + expect(wallet, isA()); + expect(wallet.name, 'Hardware'); + // The DB-assigned id must be the same one the wallet carries and + // the same one persisted as current — otherwise the next launch + // lands on a different wallet than the one we just created. + expect(wallet.id, greaterThan(0)); + expect(settingsRepository.currentWalletId, wallet.id); + + // Inspect the persisted row directly via the storage extension, + // not via the repository, so a regression that bypasses + // createViewWallet still surfaces. + final row = await db.getWalletById(wallet.id); + expect(row, isNotNull); + expect(row!.name, 'Hardware'); + expect(row.type, WalletType.bitbox.index); + // BitBox view rows have NO encrypted seed — only the cached + // address. Anything in the seed column would mean we persisted + // unencrypted material or accidentally re-used the software path. + expect(row.seed, isEmpty); + expect(row.address.toLowerCase(), _simulatedAddress.toLowerCase()); + + // Simulator must have been the one to hand back the address — + // pin the touchpoint count so a future refactor that derives the + // address from a cache or settings layer instead surfaces here. + expect( + platform.count(SimulatedBitboxMethod.getETHAddress), + 1, + reason: 'createBitboxWallet must derive the address from the device exactly once', + ); + // Encryption key must NOT be touched — BitBox rows carry no seed + // and have no use for the AES-GCM key the software path relies on. + verifyNever(() => secureStorage.getOrCreateMnemonicKey()); + }, + ); + + test( + 'hardware-failure: simulator throws on getETHAddress → no row, no current-id write', + () async { + // Defends the partial-commit contract: a transport drop or device + // reject mid-derivation must propagate cleanly and leave the DB + // and SharedPreferences untouched. Without this guard, a half- + // paired hardware wallet would already be the "current" wallet on + // the next launch and the user would land on a dashboard pointing + // at an address the device has never confirmed. + platform.throwOn( + SimulatedBitboxMethod.getETHAddress, + Exception('USB transport dropped'), + ); + + await expectLater( + service.createBitboxWallet('Hardware'), + throwsA(isA()), + ); + + // No row landed. + final hadWallet = await db.hasWallet; + expect( + hadWallet, + isFalse, + reason: 'a failed BitBox derivation must not leave a half-committed walletInfos row', + ); + // Current-id must not have been persisted — otherwise the next + // launch points at a wallet that does not exist. + expect(settingsRepository.currentWalletId, isNull); + // Simulator was hit exactly once before throwing. + expect(platform.count(SimulatedBitboxMethod.getETHAddress), 1); + }, + ); + + test( + 'persistence-round-trip: cold-load via getWalletById finds the Bitbox row and skips decryption', + () async { + // The "cold-load" half of the contract: after createBitboxWallet + // commits, a fresh WalletService instance (mimicking app restart) + // must be able to materialise the same wallet by id WITHOUT ever + // touching the mnemonic-encryption key — BitBox rows have an + // empty seed column and the SoftwareWallet branch is unreachable. + final created = await service.createBitboxWallet('Hardware'); + // Mirror the caller-side contract: HomeBloc / the connect flow set + // AppStore.wallet themselves once createBitboxWallet returns. Pin + // that the wallet returned by createBitboxWallet is the same + // instance the caller would surface in-app. + appStore.wallet = created; + verify(() => appStore.wallet = created).called(1); + + // Cold-load: fresh service instance, fresh mock secure storage so + // any stray `getOrCreateMnemonicKey()` call would be observable + // (mocktail returns null for unstubbed methods → would crash the + // decrypt path). Reuse the same DB and SharedPreferences so the + // persisted state survives the "restart". + final coldSecureStorage = _MockSecureStorage(); + final coldRepo = WalletRepository(db, coldSecureStorage); + final coldBitbox = BitboxService(); + final coldService = WalletService( + coldBitbox, + coldRepo, + settingsRepository, + _MockAppStore(), + ); + + final reloaded = await coldService.getWalletById(created.id); + + expect( + reloaded, + isA(), + reason: 'BitBox rows must reload as BitboxWallet, never as SoftwareViewWallet', + ); + expect(reloaded.id, created.id); + expect(reloaded.name, 'Hardware'); + // The currentAccount on a BitboxWallet pulls credentials from the + // *new* BitboxService — pin that the address survives the round + // trip and matches the one the simulator originally derived. + expect( + reloaded.currentAccount.primaryAddress.address.hexEip55.toLowerCase(), + _simulatedAddress.toLowerCase(), + ); + + // The critical pin: cold-load must NOT attempt to decrypt a seed + // that doesn't exist. The bitbox branch in getWalletById bypasses + // the secure storage entirely; this verifies that contract through + // the *real* DB row, not a stubbed WalletInfo. + verifyNever(() => coldSecureStorage.getOrCreateMnemonicKey()); + }, + ); + }); +}