// End-to-end against a live server: register an address, send sealed mail to // ourselves, fetch it back, exercise the accept-token round trip (§5.8) // between the requests and main tiers, restore onto a second store, and // check the server is drained afterwards. Skips when nothing listens on // 127.0.0.1:1961, so `devbox run test` does not depend on a server. // // The server key is pinned directly: on this machine the bunshin.service // static key is a documented, operator-supplied value — exactly the trusted // channel SPEC.md §4 asks a pin to come from. import "dart:io"; import "dart:math"; import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.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"; const host = "127.0.0.1"; const port = 1961; const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; Uint8List randomBytes(int n) => Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); Future serverUp() async { try { final probe = await Socket.connect(host, port, timeout: const Duration(seconds: 2)); probe.destroy(); return true; } catch (_) { return false; } } Future freshStore(String tag, String dir) => SmolStore.open( dbPath: "$dir/$tag.db", stateBox: "$tag-state", readBox: "$tag-read"); void main() { test("register, send to self, fetch, unseal, drain", () async { if (!await serverUp()) { markTestSkipped("no server on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e"); Hive.init(dir.path); final store = await freshStore("main", dir.path); final client = SmolClient(store); final warnings = []; client.onWarning = warnings.add; await client.createIdentity(); final me = client.identity!; final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); store.pinServer(host, serverKey, port); await client.registerAccount(address.short); expect(client.accountAddress()!.short, address.short); await client.send(address.short, "hello e2e", "sealed and signed"); await client.send(address.short, "second", "another sealed envelope"); final summary = await client.fetch(); expect(summary.stored, 2); expect(summary.rejected, isEmpty); // §5.8: we have not accepted ourselves as a correspondent yet, so this // unsolicited self-mail lands in the requests tier, not the main one. expect(store.listMessages("inbox"), isEmpty); final requests = store.listMessages("requests"); expect(requests.length, 2); final subjects = requests.map((m) => client.describe(m).subject).toSet(); expect(subjects, {"hello e2e", "second"}); final hello = requests .firstWhere((m) => client.describe(m).subject == "hello e2e"); final opened = client.describe(hello); expect(opened.error, isNull); // fumi's describe splits the trailing newline into the frontmatter // parse, so the body arrives trimmed. expect(opened.body, "sealed and signed"); expect(opened.sender, me.publicKey); // The server must be drained: everything that verified was acknowledged. final again = await client.fetch(); expect(again.stored, 0); // Accept ourselves as a correspondent (§5.8): the change is pushed to // the server right away. This next message carries our own Accept field, // but no MAC yet, so it still lands in requests. await client.acceptContact(address.short); await client.send(address.short, "third", "still unsolicited"); expect((await client.fetch()).stored, 1); expect(store.listMessages("requests").length, 3); expect(store.listMessages("inbox"), isEmpty); // Having now learned our own token, the next one carries a matching MAC // and reaches the main tier. await client.send(address.short, "fourth", "now accepted"); expect((await client.fetch()).stored, 1); final mainTier = store.listMessages("inbox"); expect(mainTier.length, 1); expect(client.describe(mainTier.single).subject, "fourth"); // The sent copy is sealed to ourselves and readable (§5.6). final sent = store.listMessages("sent"); expect(sent.length, 4); expect(client.describe(sent.first).error, isNull); // A restored seed can rebind the address without REGISTER; the address // must resolve to this identity's key. final recalled = await client.recallAccount(address.short); expect(recalled.short, address.short); expect( () => client.recallAccount("nobody@$host"), throwsA(isA())); // Restore on a second device: same master, fresh store, no pin. Unpinned // recall is refused; re-registering a taken name is refused; recall with // the operator-supplied key then binds the account without REGISTER. final secondStore = await freshStore("second", dir.path); final secondDevice = SmolClient(secondStore); secondStore.restoreMaster(store.master()!, 0); await expectLater( secondDevice.recallAccount(address.short), throwsA(isA())); secondStore.pinServer(host, serverKey, port); await expectLater( secondDevice.registerAccount(address.short), throwsA(isA())); final bound = await secondDevice.recallAccount(address.short); expect(bound.short, address.short); expect(secondDevice.accountAddress()!.user, user); // Re-resolving our own address finds the same key and says so quietly. final outcome = await client.refreshContact(address.short); expect(outcome.warn, isFalse); expect(outcome.message, contains("key unchanged")); expect(store.contact(address.short)!.history, isEmpty); }, timeout: const Timeout(Duration(minutes: 2))); test("leave mail on server keeps mail until deleted, with dedupe on refetch", () async { if (!await serverUp()) { markTestSkipped("no server on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e-keep"); Hive.init(dir.path); final store = await freshStore("keep", dir.path); final client = SmolClient(store); await client.createIdentity(); final user = "e2ekeep${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); store.pinServer(host, serverKey, port); await client.registerAccount(address.short); await store.setLeaveOnServer(true); await client.send(address.short, "kept", "stays on the server until deleted"); final first = await client.fetch(); expect(first.stored, 1); final record = store.listMessages("requests").single; expect(record.keptOnServer, isTrue); expect(client.describe(record).subject, "kept"); // Re-paging from scratch must not duplicate it locally (the seen-id // dedupe), even though the server still has it (nothing was deleted). final second = await client.fetch(reset: true); expect(second.stored, 0); expect(store.listMessages("requests").length, 1); // Deleting removes it locally and from the server too: a further full // re-page after deletion comes back empty rather than resurrecting it. await client.deleteMessage("requests", record); expect(store.listMessages("requests"), isEmpty); final third = await client.fetch(reset: true); expect(third.stored, 0); expect(store.listMessages("requests"), isEmpty); }, timeout: const Timeout(Duration(minutes: 2))); }