Skip to content

test(integration): Tier 1 expansion — 23 new cross-layer BitBox specs#569

Merged
TaprootFreak merged 6 commits into
feat/visual-regression-pilotfrom
test/tier1-expansion
May 25, 2026
Merged

test(integration): Tier 1 expansion — 23 new cross-layer BitBox specs#569
TaprootFreak merged 6 commits into
feat/visual-regression-pilotfrom
test/tier1-expansion

Conversation

@TaprootFreak
Copy link
Copy Markdown
Contributor

Summary

Before this PR: test/integration/ had exactly 1 file (kyc_sign_flow_test.dart, 4 tests). Tier 1 — cubit + service + signer cross-layer driven by FakeBitboxCredentials / SimulatedBitboxPlatform — was the documented thin spot of the test pyramid.

This PR triples the breadth: 6 new specs, 23 new tests, 100 % green, no production-code touched.

What's in this PR

Three review-gated bundles, all merged into test/tier1-expansion:

Bundle A — Sell + EIP-7702

  • sell_bitbox_flow_test.dart (5 tests): real SellBitboxCubit + real RealUnitSellPaymentInfoService + MockClient HTTP boundary + FakeBitboxCredentials. Pins happy / cancel / disconnect / malformed-sig / deposit-retry (SellBitboxDepositRetry state with signedSwap + signedDeposit preserved across post-swap broadcast failure).
  • eip7702_delegation_bitbox_test.dart (4 tests): Eip712Signer.signDelegation through FakeBitboxCredentials. Happy + cancel + disconnect + chainId-wiring across mainnet / sepolia / arbitrum.

Bundle B — Connect + Wallet-Creation

  • connect_bitbox_flow_test.dart (4 tests): real ConnectBitboxCubit + real BitboxService + real BitboxManager through installSimulatedBitboxPlatform() at the BitboxUsbPlatform.instance seam. Happy / pair-rejected / observer-disconnect / re-pair-after-disconnect (P461 chore: typo in *_repository.dart #1 contract — init() re-attaches the SAME credentials instance, so isConnected flips true → false → true over a single reference).
  • wallet_creation_bitbox_test.dart (3 tests): real WalletService.createBitboxWallet → real WalletRepository → Drift in-memory → real BitboxService + simulator. Happy (schema + verifyNever(getOrCreateMnemonicKey)) / hardware-failure (rollback) / persistence-round-trip (fresh WalletService on the same DB+Prefs cold-loads without touching the AES-key store).

Bundle C — Auth + Reconnect

  • dfx_auth_sign_ceremony_bitbox_test.dart (4 tests): real DFXAuthService + real SessionCache through the BitBox account boundary. Happy (cold-cache full ceremony) / cancel (no POST, lockCurrentWallet fires) / timeout via fake_async (3-min _signMessageTimeout cap) / 403 country-blocked propagation.
  • bitbox_reconnect_recovery_test.dart (3 tests): real BitboxService observer + SimulatedBitboxPlatform + real SellBitboxCubit.retryAfterConnection. Disconnect-mid-sign-retry / observer-device-loss (credentials slot preserved) / reattach-after-init (P461 surface).

Coverage impact

Tier 1 tests are cross-layer behaviour pins, not line-coverage drivers — every file the new tests touch is already at 100 % via the wave 1-3 sweep. The value is catching regressions that pass per-layer unit tests but break the seam between them (e.g. cubit catches the wrong exception type, sign-ceremony skips lock on cancel, re-pair returns a fresh credentials instance breaking the P461 contract).

Test plan

  • flutter analyze — no issues
  • flutter test test/integration/27 tests green (4 existing + 23 new)
  • CI green (3 jobs in pull-request.yaml)

@TaprootFreak TaprootFreak added the tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR label May 23, 2026
Tier-1 integration tests stitching SellBitboxCubit → FakeBitboxCredentials
boundary → real RealUnitSellPaymentInfoService → MockClient. The cubit and
the service are both real production code; only the BitBox transport and
the HTTP wire are stubbed. This pins:

* happy path — full swap+deposit ceremony emits two BitBox signs and the
  correct broadcast order/wire-shape (unsignedTx + r/s/v padding). Regression
  class: silently double-signing on the device or swapping leg order.

* cancel mid-swap — FakeBitboxBehavior.cancel propagates as
  SigningCancelledException all the way to SellBitboxError instead of
  being silently accepted as a successful sign (PR #322 bug class).

* disconnect — BitboxNotConnectedException is caught EXPLICITLY by the
  cubit's typed catch and emits SellBitboxBitboxRequired, not a generic
  Error state. Regression class: re-pair screen replaced by raw error
  string (PR #341).

* malformed signature — FormatException from a frame-desync hits the
  generic catch and surfaces as SellBitboxError, NOT mis-classified as a
  BitBox disconnect (would mask sig bugs as UX disconnect prompts).

* deposit-retry — transient 5xx on the deposit broadcast lands the cubit
  in SellBitboxDepositRetry with both signed envelopes preserved; the
  user does not have to re-sign on the device. Regression class:
  funds-at-risk loss of the already-signed swap (PR #338).
Tier-1 integration tests for the EIP-7702 delegation sign path. Stitches
Eip712Signer.signDelegation directly against the FakeBitboxCredentials
boundary (the production caller, RealUnitSellPaymentInfoService.confirmPayment,
delegates the EIP-712 sign here without further mutation). Pins:

* happy path on BOTH credential arms — FakeBitbox.success and a real
  EthPrivateKey both produce a 65-byte sig over the same payload, and the
  two sigs differ (no leaked default key path). Regression class: the
  polymorphic switch in _signTypedData wired both arms to the same key
  (PR #318).

* cancel — FakeBitbox.cancel returns "0x" and the post-sign guard in
  _signTypedData converts it into a typed SigningCancelledException,
  instead of letting the empty signature reach the backend.

* chainId-wiring — verified across [1, 11155111, 42161] that
  signDelegation forwards eip7702Data.domain.chainId verbatim to the
  BitBox sign call (both the int argument AND the chainId embedded in
  the json payload's domain). A wrong chainId here makes the BitBox
  display the wrong network — silent until the user spots it.

* disconnect — BitboxNotConnectedException at the BitBox boundary
  surfaces verbatim through Eip712Signer; it is NOT re-wrapped as
  SigningCancelledException (would mask a re-pair situation as a cancel
  prompt).

Test uses a RecordingFakeBitbox subclass to capture (chainId, jsonData)
without mocking the production signer.
DFXAuthService.getAuthToken on a cold cache stitches three layers together
that have each been a source of production incidents: the HTTP signMessage
fetch, the BitBox personal-message sign, and the auth POST. The per-layer
suites pin each branch in isolation, but only a cross-layer test catches
seam regressions (e.g. a refactor that bypasses the empty-signature guard
on the BitBox path, or one that POSTs /v1/auth before the sign returns).

Covers four arcs end-to-end via MockClient on the HTTP boundary and the
existing FakeBitboxCredentials on the hardware boundary:

  - happy path: full sign-then-auth round-trip caches the JWT and the
    signature; a second ensureSignatureFor is a no-op (no device re-trigger)
  - cancel: FakeBitbox returns empty bytes → SigningCancelledException,
    /v1/auth is never POSTed, no JWT cached, lock fires in the finally
  - sign-message timeout: FakeBitboxBehavior.timeout + fake_async crossing
    the 3-min _signMessageTimeout surfaces a TimeoutException and still
    runs lockCurrentWallet (no leaked unlocked-mnemonic state)
  - 403 country-blocked: sign succeeds, /v1/auth returns 403 with body,
    the message propagates and the JWT cache stays empty (signature does
    persist — that's a wallet property, not an auth-response artefact)
The BitBox reconnect arc spans three layers that interact through
side effects: FakeBitboxCredentials flipping behaviour mid-flight,
BitboxService's observer clearing every active credentials reference
on device-loss (without nulling the slot), and SellBitboxCubit
.retryAfterConnection re-checking credentials.isConnected to drop
the BitBox-required gate. Each layer has its own suite, but the
seams between them are where reconnect bugs hide — observers that
orphan the credentials reference, init() that doesn't re-attach
pre-existing credentials, or a cubit that fails to re-evaluate the
gate after the device returns.

Three cross-layer arcs, all inside fakeAsync so the observer's
periodic timer and the cubit's scheduleMicrotask are bound to the
same virtual clock:

  - disconnect-mid-sign-retry: a single FakeBitboxCredentials flips
    success → disconnect → success while signCallCount keeps climbing,
    matching the mid-sign BLE drop arc the real plugin exposes
  - observer-detects-device-loss: simulated device vanishes via
    SimulatedBitboxPlatform; observer clears bitboxManager and flips
    isConnected, but service.getCredentials(addr) still returns the
    same instance — the cubit's reference stays valid for recovery
  - reattach-after-init: persisted credentials built before pairing,
    init() promotes them via setBitbox, retryAfterConnection on
    SellBitboxCubit then settles on SellBitboxEthReady without
    a faucet hop or a cubit rebuild
Stitches ConnectBitboxCubit together with a real BitboxService driven by
the official SimulatedBitboxPlatform so the four BitBox boundary
touchpoints — startScan / pair (init) / getChannelHash / confirmPairing —
run through production code instead of mocks.

The existing unit suite in test/screens/hardware_connect_bitbox/bloc/
exercises the cubit with a _MockBitboxService, so a typo on the SDK
method name or a missed init/observer wiring would only surface at
pairing time on real hardware. This suite pins the same contract
through the simulator and adds an observer-driven device-loss case
plus a re-pair-after-disconnect case that the unit suite can't reach
without mocking the very behaviour it claims to verify.

Downstream WalletService.createBitboxWallet and
DFXAuthService.ensureSignatureFor stay mocked — they have their own
dedicated suites and a regression there must not surface as a noisy
failure at the BitBox boundary.
Stitches WalletService.createBitboxWallet together with a real
BitboxService driven by the simulator, a real WalletRepository backed
by AppDatabase.forTesting(NativeDatabase.memory()), and a real
SettingsRepository on SharedPreferences.setMockInitialValues. The
commit path runs end-to-end:

  simulator getETHAddress → WalletRepository.createViewWallet
  → WalletStorage.insertWallet (walletInfos row, empty seed column)
  → SettingsRepository.saveCurrentWalletId

The hardware-failure case pins the partial-commit contract: a
device-side throw must leave the DB and SharedPreferences untouched,
otherwise a half-paired hardware wallet ends up as the next launch's
current wallet pointing at an address the device never confirmed.

The persistence round-trip cold-load case via a fresh WalletService
instance pins that BitBox rows reload as BitboxWallet WITHOUT touching
the mnemonic-encryption key — getWalletById must dispatch on
WalletType.bitbox and skip the decrypt path entirely.

Style anchor: test/packages/repository/wallet_repository_test.dart for
the in-memory DB + mock SecureStorage setup, and
test/packages/service/wallet_service_test.dart for the
createBitboxWallet contract (which the unit suite covers with mocks
across every layer).
@TaprootFreak TaprootFreak force-pushed the test/tier1-expansion branch from d4aacf7 to a6ec281 Compare May 23, 2026 22:28
@TaprootFreak TaprootFreak changed the base branch from develop to feat/visual-regression-pilot May 23, 2026 22:28
@TaprootFreak TaprootFreak marked this pull request as ready for review May 23, 2026 22:29
@TaprootFreak TaprootFreak merged commit 60247dd into feat/visual-regression-pilot May 25, 2026
14 of 16 checks passed
@TaprootFreak TaprootFreak deleted the test/tier1-expansion branch May 25, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant