183 lines
7.3 KiB
Dart
183 lines
7.3 KiB
Dart
// 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<SmolAddress> 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<SmolAddress> 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);
|
|
});
|
|
}
|