diff --git a/integration_test/smoke_test.dart b/integration_test/smoke_test.dart index 7a6c75a..4989479 100644 --- a/integration_test/smoke_test.dart +++ b/integration_test/smoke_test.dart @@ -10,6 +10,7 @@ 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:path_provider/path_provider.dart"; @@ -26,43 +27,68 @@ const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2. // this machine — the trusted channel SPEC.md §4 asks a pin to come from. const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; -Future bootApp() async { - // main()'s boot, minus runApp: the test pumps the same tree. +// main()'s boot, minus runApp: the test pumps the same tree. The container is +// the one the UI reads from, so the tests drive the same SmolClient the +// screens do — the fetch cancellation test needs exactly that. +Future boot(PatrolIntegrationTester $) async { final dataDir = await getApplicationSupportDirectory(); Hive.init(dataDir.path); final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); applyPresetServer(store); - return ProviderScope( - overrides: [storeProvider.overrideWithValue(store)], + final container = + ProviderContainer(overrides: [storeProvider.overrideWithValue(store)]); + await $.pumpWidgetAndSettle(UncontrolledProviderScope( + container: container, child: const AppWidget(), - ); + )); + return container; +} + +// Onboarding into a registered account: a fresh identity, backed up, then +// registered under a name no server has bound yet. Returns the username. +Future onboard(PatrolIntegrationTester $, ProviderContainer container) async { + await $("Create Identity").tap(); + await $.pumpAndSettle(); + await $("I have backed it up").waitUntilVisible(); + await $("I have backed it up").tap(); + await $.pumpAndSettle(); + + final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; + // Enter text by TextField, not by its labelText: the decoration label is + // a RichText that is not hit-testable, so a text finder times out. + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), serverKey); + await $("Pin and Register").tap(); + + // The register round trip is real network: the inbox tabs only exist + // behind HomeGuard, so reaching them proves the account bound. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + return user; +} + +// Imports the account's own smol:// address as a contact — the §8 verified +// path — so later steps have an entry to re-resolve. Leaves the app on the +// Contacts screen. +Future importSelfContact(PatrolIntegrationTester $, ProviderContainer container) async { + final client = container.read(clientProvider); + final uri = client.accountAddress()!.uri(client.identity!.publicKey); + await $("Contacts").tap(); + await $.pumpAndSettle(); + await $(Icons.person_add).tap(); + await $.pumpAndSettle(); + await $.enterText($(TextField).at(0), uri); + await $("Import").tap(); + await $("contact imported (verified key)").waitUntilVisible(); + // The snackbar floats over the bottom navigation bar; let it expire + // before the next navigation tap. + await $.pump(const Duration(seconds: 5)); } void main() { patrolTest("smoke: onboarding, register, self-send, fetch, read in requests", ($) async { - await $.pumpWidgetAndSettle(await bootApp()); - - // Onboarding: a fresh identity, backed up, registered under a name no - // server has bound yet — registration must succeed against the real - // server over Wi-Fi, through the Dart-resolved dial path. - await $("Create Identity").tap(); - await $.pumpAndSettle(); - await $("I have backed it up").waitUntilVisible(); - await $("I have backed it up").tap(); - await $.pumpAndSettle(); - - final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; - // Enter text by TextField, not by its labelText: the decoration label is - // a RichText that is not hit-testable, so a text finder times out. - await $.enterText($(TextField).at(0), "$user@$testHost:1961"); - await $.enterText($(TextField).at(1), serverKey); - await $("Pin and Register").tap(); - - // The register round trip is real network: the inbox tabs only exist - // behind HomeGuard, so reaching them proves the account bound. - await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); - await $("Requests").waitUntilVisible(); + final container = await boot($); + final user = await onboard($, container); // Compose the first self-addressed mail. await $(Icons.edit).tap(); @@ -82,7 +108,7 @@ void main() { await $.pumpAndSettle(); // First contact is unsolicited, so it landed in requests (sec 5.8) — - // and the §5.6 sent copy must read back as plain text, not . + // and the sec 5.6 sent copy must read back as plain text, not . await $("smoke subject").tap(); await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); @@ -94,4 +120,128 @@ void main() { await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); }); + + // Checklist item 11: a fetch interrupted mid-run keeps everything that + // already arrived, and fetching again completes the set without re-storing + // what the first pass took. There is no cancel control in the UI yet, so + // the test raises the facade's cancellation flag — the same call a cancel + // button would make; when one exists, drive it from the UI instead. + patrolTest( + "smoke: cancelled fetch keeps what arrived and resume completes it", + ($) async { + final container = await boot($); + final user = await onboard($, container); + final client = container.read(clientProvider); + final address = "$user@$testHost:1961"; + + // 20 letters with 40 KiB bodies: the fetch has real pages to chew + // through, so a cancel 600 ms in lands mid-run, not after it. + for (var i = 0; i < 20; i++) { + await client.send(address, "cancel $i", "cancel body $i\n${"x" * 40960}"); + } + + final fetch = client.fetch(); + await Future.delayed(const Duration(milliseconds: 600)); + client.cancelFetch(); + final summary = await fetch; + container.read(revisionProvider.notifier).bump(); + await $.pumpAndSettle(); + + // If the cancel landed mid-run, part of the page arrived and the + // warning banner says so; if the fetch had already finished, nothing + // was lost either way and the resume below is a no-op. + if (summary.stored < 20) { + await $("fetch cancelled; partial results kept").waitUntilVisible(); + } + + // The UI's fetch resumes and completes the set. The requests badge + // counts every unread message, so a re-stored duplicate would push + // the count past 20. + await $(Icons.cloud_download).tap(); + await $.pumpAndSettle(duration: const Duration(seconds: 5)); + await $("Requests (20)") + .waitUntilVisible(timeout: const Duration(seconds: 30)); + }); + + // Checklist item 13: after rotating, a correspondent re-resolving the + // address sees the signed-chain rotation banner — a warning, not the + // terminal mismatch — and mail sealed to the superseded key still reads. + patrolTest( + "smoke: rotation re-resolves through the chain; old mail stays readable", + ($) async { + final container = await boot($); + final user = await onboard($, container); + final client = container.read(clientProvider); + final address = "$user@$testHost:1961"; + + // A letter sealed to the pre-rotation key, waiting in requests. + await client.send(address, "rotation subject", + "rotation body before the key change"); + await client.fetch(); + container.read(revisionProvider.notifier).bump(); + await $.pumpAndSettle(); + + // The correspondent entry, bound to the current key, must exist before + // the rotation so re-resolving has a known key to move away from. + await importSelfContact($, container); + + // Rotate: the dialog pushes the next index's key with a certificate. + await $("Settings").tap(); + await $.pumpAndSettle(); + // The rotate row sits below the settings list's fold, and a SliverList + // builds lazily — off-screen rows do not exist as widgets until + // scrolled to, so bring it into the tree before tapping. + await $.scrollUntilVisible(finder: $("Rotate identity key")); + await $("Rotate identity key").tap(); + await $.pumpAndSettle(); + await $("Rotate").tap(); + await $(RegExp("rotated; new key")).waitUntilVisible(); + await $.pump(const Duration(seconds: 5)); + + // Re-resolve: the chain validates, so the outcome is the rotation + // banner, and the superseded key moves to the §8 history section. + await $("Contacts").tap(); + await $.pumpAndSettle(); + await $(client.accountAddress()!.short).tap(); + await $.pumpAndSettle(); + await $("Re-resolve").tap(); + await $(RegExp("rotated its key; a signed chain confirms it")) + .waitUntilVisible(timeout: const Duration(seconds: 30)); + await $("previous keys (1)").waitUntilVisible(); + await $("Dismiss").tap(); + + // Mail sealed to the old key: the master still derives it, so the + // letter reads exactly as before the rotation. The AppBar's back arrow + // pops the detail route; the native back press is avoided here because + // it killed the patrol connection at this point in an earlier run. + await $(Icons.arrow_back).tap(); + await $.pumpAndSettle(); + await $("Mail").tap(); + await $.pumpAndSettle(); + await $("Requests (1)").tap(); + await $.pumpAndSettle(); + await $("rotation subject").tap(); + await $.pumpAndSettle(); + await $("rotation body before the key change").waitUntilVisible(); + }); + + // Checklist item 14: re-resolving a contact whose key did not change is a + // quiet confirmation — no banner, no history section. + patrolTest("smoke: re-resolving an unchanged contact is quiet", ($) async { + final container = await boot($); + await onboard($, container); + final client = container.read(clientProvider); + final short = client.accountAddress()!.short; + + await importSelfContact($, container); + + await $(short).tap(); + await $.pumpAndSettle(); + await $("Re-resolve").tap(); + await $("$short: key unchanged") + .waitUntilVisible(timeout: const Duration(seconds: 30)); + // A banner would have carried the rotation warning instead; history + // records a rotation, and there was none. + expect($("previous keys (1)"), findsNothing); + }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index faec893..b04be2b 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -64,7 +64,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -146,7 +146,7 @@ mod tests { use std::sync::Arc; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -254,7 +254,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -328,7 +328,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() diff --git a/pubspec.yaml b/pubspec.yaml index 9738da7..1c448de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,9 @@ patrol: app_name: kirakira flavor: integration test_directory: integration_test + # A failing device test leaves the phone in an unknown state; the screenshot + # is the only way to see it from the host. + screenshot_on_failure: true android: package_name: com.app.smol_mail.integration diff --git a/test/native_binding_test.dart b/test/native_binding_test.dart index 7d354a6..3a36ebc 100644 --- a/test/native_binding_test.dart +++ b/test/native_binding_test.dart @@ -4,6 +4,7 @@ import "dart:convert"; import "dart:io"; +import "dart:math"; import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; @@ -11,7 +12,7 @@ import "package:flutter_test/flutter_test.dart"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; -const spikeServerKey = "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq"; +const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga"; const spikeServer = "127.0.0.1"; const spikePort = 19619; @@ -90,7 +91,8 @@ void main() async { // username, so identities must be fresh per run. final aliceMaster = Uint8List.fromList(List.generate(32, (i) => i * 7 + DateTime.now().microsecondsSinceEpoch % 251)); - final bobMaster = Uint8List.fromList(List.generate(32, (i) => i * 13 + 5)); + final bobMaster = + Uint8List.fromList(List.generate(32, (_) => Random.secure().nextInt(256))); final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); final alice = FumiNative("${dir.path}/alice.db"); @@ -124,4 +126,55 @@ void main() async { await alice.close(); await bob.close(); }); + + // sec 7's readability invariant: mail sealed to the pre-rotation key must + // still open after a rotation, because the account derives every superseded + // key from the master. + test("old mail stays readable across a rotation", + skip: await spikeUp() + ? false + : "no bunshin on 127.0.0.1:19619", () async { + final dir = await Directory.systemTemp.createTemp("native-rotate"); + // Random masters: bunshin refuses a key already bound under another + // username, so identities must be fresh per run. + final rng = Random.secure(); + final aliceMaster = + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final bobMaster = + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + + final alice = FumiNative("${dir.path}/alice.db"); + await alice.open(); + alice.setMaster(aliceMaster); + await alice.pinServer(spikeServer, spikeServerKey); + await alice.register("a$run@$spikeServer:$spikePort"); + + final bob = FumiNative("${dir.path}/bob.db"); + await bob.open(); + bob.setMaster(bobMaster); + // The onboarding screen zeroes its own master reference on dispose; + // whatever holds the master after that must not share that buffer. + bobMaster.fillRange(0, 32, 0); + await bob.pinServer(spikeServer, spikeServerKey); + await bob.register("b$run@$spikeServer:$spikePort"); + + await alice.send("b$run@$spikeServer:$spikePort", + "before the rotation", + subject: "pre-rotation"); + final summary = await bob.fetch(); + expect(summary["stored"], 1); + final requests = await bob.mail("requests"); + final preRotationId = requests[0]["id"] as String; + final before = await bob.describe(preRotationId); + expect(before["subject"], "pre-rotation"); + + await bob.rotate(); + final after = await bob.describe(preRotationId); + expect(after["subject"], "pre-rotation", + reason: "mail sealed to the superseded key must stay readable"); + + await alice.close(); + await bob.close(); + }); }