feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention

This commit is contained in:
randogoth 2026-09-27 11:07:11 +03:00
parent eaaa3f2ede
commit c693e6fcb9
27 changed files with 1190 additions and 592 deletions

View file

@ -1,4 +1,4 @@
// Regression tests for the onboarding recall flows: restoring a seed and
// 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.
@ -14,14 +14,28 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
/// A [SmolClient] whose recall completes instantly, so the test exercises
/// the flow rather than the network.
/// 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.setAccount(addr);
return addr;
}
@override
Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText);
@ -58,7 +72,7 @@ void main() {
"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 seed = randomBytes(32);
final master = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget);
@ -68,7 +82,7 @@ void main() {
var fields = find.byType(TextField);
expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(0), hex(seed));
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();
@ -80,6 +94,9 @@ void main() {
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), b32encode(randomBytes(32)));
@ -87,84 +104,71 @@ void main() {
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), seed);
expect(store.master(), master);
expect(store.account()!.user, "randogoth");
});
testWidgets("restore without an address recalls from the register step",
testWidgets("restore requires an address before it will submit",
(tester) async {
final store = storeB;
final seed = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
var fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(seed));
final fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(randomBytes(32)));
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
// The register step: address, server key, optional invite token.
fields = find.byType(TextField);
expect(fields, findsNWidgets(3));
await tester.enterText(fields.at(0), "randogoth@smol.place");
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Already registered? Recall"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.account()!.user, "randogoth");
// 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 again after an incomplete attempt replaces the identity "
"restoring after an abandoned Create Identity attempt replaces it "
"instead of refusing it", (tester) async {
final store = storeC;
final abandonedSeed = randomBytes(32);
final realSeed = randomBytes(32);
final realMaster = 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"));
// 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();
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.master(), isNotNull);
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.
// 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 seed must not throw "identity already exists".
// Restoring a different master 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));
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.
fields = find.byType(TextField);
expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
expect(find.byType(TextField), findsNWidgets(2));
await tester.enterText(find.byType(TextField).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);
expect(store.master(), realMaster);
});
}