test: patrol flows for fetch cancel, rotation and unchanged-key refresh
This commit is contained in:
parent
f7452832ea
commit
fa0809f021
4 changed files with 240 additions and 34 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:flutter_test/flutter_test.dart";
|
||||||
import "package:hive_flutter/hive_flutter.dart";
|
import "package:hive_flutter/hive_flutter.dart";
|
||||||
import "package:path_provider/path_provider.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.
|
// this machine — the trusted channel SPEC.md §4 asks a pin to come from.
|
||||||
const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
|
const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
|
||||||
|
|
||||||
Future<Widget> bootApp() async {
|
// main()'s boot, minus runApp: the test pumps the same tree. The container is
|
||||||
// main()'s boot, minus runApp: the test pumps the same tree.
|
// the one the UI reads from, so the tests drive the same SmolClient the
|
||||||
|
// screens do — the fetch cancellation test needs exactly that.
|
||||||
|
Future<ProviderContainer> boot(PatrolIntegrationTester $) async {
|
||||||
final dataDir = await getApplicationSupportDirectory();
|
final dataDir = await getApplicationSupportDirectory();
|
||||||
Hive.init(dataDir.path);
|
Hive.init(dataDir.path);
|
||||||
final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db");
|
final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db");
|
||||||
applyPresetServer(store);
|
applyPresetServer(store);
|
||||||
return ProviderScope(
|
final container =
|
||||||
overrides: [storeProvider.overrideWithValue(store)],
|
ProviderContainer(overrides: [storeProvider.overrideWithValue(store)]);
|
||||||
|
await $.pumpWidgetAndSettle(UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
child: const AppWidget(),
|
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<String> 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<void> 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() {
|
void main() {
|
||||||
patrolTest("smoke: onboarding, register, self-send, fetch, read in requests",
|
patrolTest("smoke: onboarding, register, self-send, fetch, read in requests",
|
||||||
($) async {
|
($) async {
|
||||||
await $.pumpWidgetAndSettle(await bootApp());
|
final container = await boot($);
|
||||||
|
final user = await onboard($, container);
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Compose the first self-addressed mail.
|
// Compose the first self-addressed mail.
|
||||||
await $(Icons.edit).tap();
|
await $(Icons.edit).tap();
|
||||||
|
|
@ -82,7 +108,7 @@ void main() {
|
||||||
await $.pumpAndSettle();
|
await $.pumpAndSettle();
|
||||||
|
|
||||||
// First contact is unsolicited, so it landed in requests (sec 5.8) —
|
// 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 <unreadable>.
|
// and the sec 5.6 sent copy must read back as plain text, not <unreadable>.
|
||||||
await $("smoke subject").tap();
|
await $("smoke subject").tap();
|
||||||
await $.pumpAndSettle();
|
await $.pumpAndSettle();
|
||||||
await $("smoke body from patrol").waitUntilVisible();
|
await $("smoke body from patrol").waitUntilVisible();
|
||||||
|
|
@ -94,4 +120,128 @@ void main() {
|
||||||
await $.pumpAndSettle();
|
await $.pumpAndSettle();
|
||||||
await $("smoke body from patrol").waitUntilVisible();
|
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<void>.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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ mod tests {
|
||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
|
|
||||||
let server: [u8; KEY_LEN] = unb32(
|
let server: [u8; KEY_LEN] = unb32(
|
||||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|
@ -146,7 +146,7 @@ mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
let server: [u8; KEY_LEN] = unb32(
|
let server: [u8; KEY_LEN] = unb32(
|
||||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|
@ -254,7 +254,7 @@ mod tests {
|
||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
|
|
||||||
let server: [u8; KEY_LEN] = unb32(
|
let server: [u8; KEY_LEN] = unb32(
|
||||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|
@ -328,7 +328,7 @@ mod tests {
|
||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
|
|
||||||
let server: [u8; KEY_LEN] = unb32(
|
let server: [u8; KEY_LEN] = unb32(
|
||||||
"g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq",
|
"wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ patrol:
|
||||||
app_name: kirakira
|
app_name: kirakira
|
||||||
flavor: integration
|
flavor: integration
|
||||||
test_directory: integration_test
|
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:
|
android:
|
||||||
package_name: com.app.smol_mail.integration
|
package_name: com.app.smol_mail.integration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
import "dart:math";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "package:flutter_test/flutter_test.dart";
|
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/client.dart";
|
||||||
import "package:smol_mail/native/ffi.dart";
|
import "package:smol_mail/native/ffi.dart";
|
||||||
|
|
||||||
const spikeServerKey = "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq";
|
const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga";
|
||||||
const spikeServer = "127.0.0.1";
|
const spikeServer = "127.0.0.1";
|
||||||
const spikePort = 19619;
|
const spikePort = 19619;
|
||||||
|
|
||||||
|
|
@ -90,7 +91,8 @@ void main() async {
|
||||||
// username, so identities must be fresh per run.
|
// username, so identities must be fresh per run.
|
||||||
final aliceMaster =
|
final aliceMaster =
|
||||||
Uint8List.fromList(List.generate(32, (i) => i * 7 + DateTime.now().microsecondsSinceEpoch % 251));
|
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 run = DateTime.now().millisecondsSinceEpoch.toRadixString(36);
|
||||||
|
|
||||||
final alice = FumiNative("${dir.path}/alice.db");
|
final alice = FumiNative("${dir.path}/alice.db");
|
||||||
|
|
@ -124,4 +126,55 @@ void main() async {
|
||||||
await alice.close();
|
await alice.close();
|
||||||
await bob.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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue