// Regression tests for the onboarding recall flows: restoring a master and // recalling the registered address must land the user in the inbox. The // client's network operations are stubbed; what is under test is the UI and // router flow itself. import "dart:io"; import "dart:math"; import "dart:typed_data"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/client.dart"; import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/store.dart"; import "package:smol_mail/smol/ui.dart"; Uint8List randomBytes(int n) => Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); // A syntactically valid server key for the stubbed pin step: the flow under // test is the UI routing, not the handshake. const fakePin = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; /// A [SmolClient] whose network operations complete instantly, so the test /// exercises the flow rather than the network. [restoreAndRecall] still /// enforces the pin gate (SPEC.md §4), since that gate is exactly what the /// restore flow's UX is regression-tested against. class StubClient extends SmolClient { StubClient(super.store); @override Future restoreAndRecall(String masterHex, String addressText) async { final addr = parseAddress(addressText); if (store.serverPin(addr.host) == null) { throw SmolError("no pinned key for ${addr.host}"); } store.restoreMaster(unhex(masterHex.trim()), 0); store.native.setAccount(addr.short); return addr; } @override Future recallAccount(String addressText) async { final addr = parseAddress(addressText); store.native.setAccount(addr.short); return addr; } } void main() { // Hive box opening is real file IO and must happen outside testWidgets' // fake-async zone — including the per-test stores. late final SmolStore storeA, storeB, storeC; setUpAll(() async { TestWidgetsFlutterBinding.ensureInitialized(); final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); Hive.init(dir.path); storeA = await SmolStore.open( dbPath: "${dir.path}/a.db", stateBox: "recall-a-state", readBox: "recall-a-read"); storeB = await SmolStore.open( dbPath: "${dir.path}/b.db", stateBox: "recall-b-state", readBox: "recall-b-read"); storeC = await SmolStore.open( dbPath: "${dir.path}/c.db", stateBox: "recall-c-state", readBox: "recall-c-read"); }); Widget app(SmolStore store) => ProviderScope( overrides: [ storeProvider.overrideWithValue(store), clientProvider.overrideWithValue(StubClient(store)), ], child: const AppWidget(), ); testWidgets( "restore with an address, but no pin yet, asks for the server key " "and then recalls into the inbox", (tester) async { final store = storeA; final master = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); var fields = find.byType(TextField); expect(fields, findsNWidgets(2)); await tester.enterText(fields.at(0), hex(master)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Recall needs the server pinned first (SPEC.md §4) — that's the normal // state right after a restore, so this lands on the register step framed // for recall (no error, no Invite token field) rather than the inbox yet. expect(find.text("Inbox"), findsNothing); expect(find.text("Pin this server's public key to finish restoring your address."), findsOneWidget); expect(find.text("Invite token (optional)"), findsNothing); // Restoring has no well-defined "register a new address instead" escape // hatch until the rotation index is known (§2). expect(find.text("Register a new address instead"), findsNothing); fields = find.byType(TextField); expect(fields, findsNWidgets(2)); // address (carried over), server key await tester.enterText(fields.at(1), fakePin); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.master(), master); expect(parseAddress(SmolClient(store).accountAddress()!.short).user, "randogoth"); }); testWidgets("restore requires an address before it will submit", (tester) async { final store = storeB; await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); final fields = find.byType(TextField); await tester.enterText(fields.at(0), hex(randomBytes(32))); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Nothing was submitted, so nothing was persisted; still on this step. expect(find.text("Restore"), findsOneWidget); expect(find.text("Inbox"), findsNothing); expect(store.master(), isNull); }); testWidgets( "restoring after an abandoned Create Identity attempt replaces it " "instead of refusing it", (tester) async { final store = storeC; final realMaster = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); // First attempt: create a fresh identity but never finish registering — // lands on the backup step, master set, no account bound. await tester.tap(find.text("Create Identity")); await tester.pumpAndSettle(); expect(store.master(), isNotNull); expect(SmolClient(store).accountAddress(), isNull); // Simulate returning to onboarding later (e.g. a cold restart). Pumping // app(store) directly would just rebuild the existing OnboardingScreen // state in place rather than really restarting, so tear the tree down // first to force a fresh app state — HomeGuard then sends an // identity-without-account back to welcome. await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); // Restoring a different master must not throw "identity already exists". await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); final fields = find.byType(TextField); await tester.enterText(fields.at(0), hex(realMaster)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Lands on the recall-framed register step (no pin yet); pin it and finish. expect(find.byType(TextField), findsNWidgets(2)); await tester.enterText(find.byType(TextField).at(1), fakePin); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.master(), realMaster); }); }