fix: onboarding restore no longer traps on an abandoned identity or a missing pin

This commit is contained in:
randogoth 2026-09-27 00:09:38 +03:00
parent 7d3c8b423c
commit eaaa3f2ede
3 changed files with 164 additions and 91 deletions

View file

@ -31,6 +31,10 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_Step step = _Step.welcome; _Step step = _Step.welcome;
Uint8List? createdSeed; Uint8List? createdSeed;
bool busy = false; bool busy = false;
// The register step doubles as "pin a key to finish recalling" when a
// restore's recall can't proceed without one yet — same fields, different
// framing and default action, not the generic "register a new address" copy.
bool recallIntent = false;
final seedController = TextEditingController(); final seedController = TextEditingController();
final restoreAddressController = TextEditingController(); final restoreAddressController = TextEditingController();
@ -57,13 +61,33 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
context, error is SmolError ? error.message : error.toString()); context, error is SmolError ? error.message : error.toString());
} }
void _createIdentity() { // Reaching onboarding at all means HomeGuard already found no complete
// identity+account, so a seed still sitting in the store here can only be
// an abandoned attempt from earlier in this same flow (wrong seed, failed
// recall, "Back") — safe to replace rather than reject.
// wipe() is fire-and-forget here, like every other store write in this
// screen (setIdentity, pinServer, ...) — Hive updates its in-memory state
// synchronously and persists to disk in the background, so the identity
// check right after is already consistent without awaiting the write.
void _clearAbandonedIdentity() {
final client = ref.read(clientProvider); final client = ref.read(clientProvider);
final fresh = client.createIdentity(); if (client.identity != null && client.accountAddress() == null) {
setState(() { ref.read(storeProvider).wipe();
createdSeed = fresh.seed; }
step = _Step.backup; }
});
void _createIdentity() {
_clearAbandonedIdentity();
final client = ref.read(clientProvider);
try {
final fresh = client.createIdentity();
setState(() {
createdSeed = fresh.seed;
step = _Step.backup;
});
} on Exception catch (err) {
_showError(err);
}
} }
void _restoreIdentity() { void _restoreIdentity() {
@ -74,6 +98,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
// pinned yet, offline, typo) — recall can always be retried from the // pinned yet, offline, typo) — recall can always be retried from the
// register step or settings afterward. // register step or settings afterward.
void _submitRestore() { void _submitRestore() {
_clearAbandonedIdentity();
final client = ref.read(clientProvider); final client = ref.read(clientProvider);
try { try {
client.restoreIdentity(seedController.text); client.restoreIdentity(seedController.text);
@ -86,6 +111,34 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
setState(() => step = _Step.register); setState(() => step = _Step.register);
return; return;
} }
// The register step has its own address field (it also needs a server
// key, which restore doesn't collect) — carry over what was already
// typed rather than making the user re-enter it.
addressController.text = addressText;
final SmolAddress addr;
try {
addr = parseAddress(addressText);
} on Exception catch (err) {
// A genuine typo, distinct from the merely-unpinned case below — still
// worth an error, but land on the same recall-oriented step to fix it.
_showError(err);
setState(() {
recallIntent = true;
step = _Step.register;
});
return;
}
// Recall needs the host pinned first (SPEC.md §4). That's the expected,
// common state right after a restore — not an error — so check for it
// up front instead of letting recallAccount fail and surfacing that as
// one: this address just needs a key before its first recall can proceed.
if (ref.read(storeProvider).serverPin(addr.host) == null) {
setState(() {
recallIntent = true;
step = _Step.register;
});
return;
}
() async { () async {
try { try {
await client.recallAccount(addressText); await client.recallAccount(addressText);
@ -95,7 +148,10 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
} on Exception catch (err) { } on Exception catch (err) {
if (!mounted) return; if (!mounted) return;
_showError(err); _showError(err);
setState(() => step = _Step.register); setState(() {
recallIntent = true;
step = _Step.register;
});
} }
}(); }();
} }
@ -142,7 +198,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Padding( body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 25), padding: const EdgeInsets.symmetric(horizontal: 25),
child: switch (step) { child: switch (step) {
_Step.welcome => _welcome(context), _Step.welcome => _welcome(context),
@ -176,7 +232,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
children: [ children: [
_header(context, _header(context,
"One keypair is the whole identity: an address, a key and a message."), "One keypair is the whole identity: an address, a key and a message."),
const Spacer(), const SizedBox(height: 40),
PrimaryButton( PrimaryButton(
onPressed: _createIdentity, onPressed: _createIdentity,
child: const Text("Create Identity"), child: const Text("Create Identity"),
@ -222,7 +278,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}, },
child: const Text("Copy seed"), child: const Text("Copy seed"),
), ),
const Spacer(), const SizedBox(height: 40),
PrimaryButton( PrimaryButton(
onPressed: () => setState(() => step = _Step.register), onPressed: () => setState(() => step = _Step.register),
child: const Text("I have backed it up"), child: const Text("I have backed it up"),
@ -266,7 +322,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
text: "Back", text: "Back",
onPressed: () => setState(() => step = _Step.welcome), onPressed: () => setState(() => step = _Step.welcome),
), ),
const Spacer(), const SizedBox(height: 40),
], ],
); );
} }
@ -275,8 +331,12 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_header(context, _header(
"Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel."), context,
recallIntent
? "Pin this server's public key to finish restoring your address."
: "Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel.",
),
const SizedBox(height: 30), const SizedBox(height: 30),
TextField( TextField(
controller: addressController, controller: addressController,
@ -297,25 +357,33 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
fillColor: Theme.of(context).extension<AppColors>()!.cardFill, fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
), ),
), ),
const SizedBox(height: 15), if (!recallIntent) ...[
TextField( const SizedBox(height: 15),
controller: tokenController, TextField(
decoration: InputDecoration( controller: tokenController,
labelText: "Invite token (optional)", decoration: InputDecoration(
filled: true, labelText: "Invite token (optional)",
fillColor: Theme.of(context).extension<AppColors>()!.cardFill, filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
), ),
), ],
const Spacer(), const SizedBox(height: 40),
PrimaryButton( PrimaryButton(
onPressed: busy ? () {} : _submitRegistration, onPressed: busy
? () {}
: (recallIntent ? _submitRecall : _submitRegistration),
child: busy child: busy
? const SmallLoadingSpinner() ? const SmallLoadingSpinner()
: const Text("Pin and Register"), : Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
), ),
TextButton( TextButton(
onPressed: busy ? () {} : _submitRecall, onPressed: busy
child: const Text("Already registered? Recall"), ? () {}
: (recallIntent ? _submitRegistration : _submitRecall),
child: Text(recallIntent
? "Register a new address instead"
: "Already registered? Recall"),
), ),
const SizedBox(height: 80), const SizedBox(height: 80),
], ],

View file

@ -1,61 +0,0 @@
import "dart:io";
import "package:auto_route/auto_route.dart";
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/presentation/routes/app_router.gr.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class StubClient extends SmolClient {
StubClient(super.store);
@override
Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText);
store.setAccount(addr);
return addr;
}
}
void main() {
late SmolStore store;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("dbg4");
Hive.init(dir.path);
store = await SmolStore.open(stateBox: "dbg-state", mailBox: "dbg-mail");
});
testWidgets("direct replace", (tester) async {
final seed = randomBytes(32);
await tester.pumpWidget(ProviderScope(
overrides: [
storeProvider.overrideWithValue(store),
clientProvider.overrideWithValue(StubClient(store)),
],
child: const AppWidget(),
));
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(seed));
await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(store.account(), isNotNull, reason: "recall stub ran");
final element = tester.element(find.text("Back"));
final router = AutoRouter.of(element);
await router.replace(InboxRoute());
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
});
}

View file

@ -33,7 +33,7 @@ class StubClient extends SmolClient {
void main() { void main() {
// Hive box opening is real file IO and must happen outside testWidgets' // Hive box opening is real file IO and must happen outside testWidgets'
// fake-async zone — including the per-test stores. // fake-async zone — including the per-test stores.
late final SmolStore storeA, storeB; late final SmolStore storeA, storeB, storeC;
setUpAll(() async { setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); final dir = await Directory.systemTemp.createTemp("smol-recall-flow");
@ -42,6 +42,8 @@ void main() {
stateBox: "recall-a-state", mailBox: "recall-a-mail"); stateBox: "recall-a-state", mailBox: "recall-a-mail");
storeB = await SmolStore.open( storeB = await SmolStore.open(
stateBox: "recall-b-state", mailBox: "recall-b-mail"); stateBox: "recall-b-state", mailBox: "recall-b-mail");
storeC = await SmolStore.open(
stateBox: "recall-c-state", mailBox: "recall-c-mail");
}); });
Widget app(SmolStore store) => ProviderScope( Widget app(SmolStore store) => ProviderScope(
@ -52,8 +54,9 @@ void main() {
child: const AppWidget(), child: const AppWidget(),
); );
testWidgets("restore with an address recalls and lands in the inbox", testWidgets(
(tester) async { "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 store = storeA;
final seed = randomBytes(32); final seed = randomBytes(32);
await tester.pumpWidget(app(store)); await tester.pumpWidget(app(store));
@ -63,13 +66,26 @@ void main() {
await tester.tap(find.text("Restore From Seed")); await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final fields = find.byType(TextField); var fields = find.byType(TextField);
expect(fields, findsNWidgets(2)); expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(0), hex(seed)); await tester.enterText(fields.at(0), hex(seed));
await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore")); await tester.tap(find.text("Restore"));
await tester.pumpAndSettle(); 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);
fields = find.byType(TextField);
expect(fields, findsNWidgets(2)); // address (carried over), server key
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Pin and Recall"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget); expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), seed); expect(store.seed(), seed);
expect(store.account()!.user, "randogoth"); expect(store.account()!.user, "randogoth");
@ -101,4 +117,54 @@ void main() {
expect(find.text("Inbox"), findsOneWidget); expect(find.text("Inbox"), findsOneWidget);
expect(store.account()!.user, "randogoth"); expect(store.account()!.user, "randogoth");
}); });
testWidgets(
"restoring again after an incomplete attempt replaces the identity "
"instead of refusing it", (tester) async {
final store = storeC;
final abandonedSeed = randomBytes(32);
final realSeed = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
// First attempt: restore a seed but never finish registering — lands on
// the register step, identity set, no account bound.
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
var fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(abandonedSeed));
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(store.seed(), abandonedSeed);
expect(store.account(), isNull);
// Simulate returning to onboarding later (e.g. a cold restart). Pumping
// app(store) directly would just rebuild the existing OnboardingScreen
// state in place (still parked on the register step) 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 seed must not throw "identity already exists".
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(realSeed));
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.
fields = find.byType(TextField);
expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Pin and Recall"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), realSeed);
});
} }