// End-to-end against a live reference server: register an address on a // locally running smolmaild, send a sealed message to ourselves, fetch it back, // 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. // // To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && // uv run smolmaild.py serve --key server.key --db mail.db) // then `devbox run test`. import "dart:io"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.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"; const host = "127.0.0.1"; const port = 1961; Future serverUp() async { try { final probe = await Socket.connect(host, port, timeout: const Duration(seconds: 2)); probe.destroy(); return true; } catch (_) { return false; } } void main() { test("register, send to self, fetch, unseal, drain", () async { if (!await serverUp()) { markTestSkipped("no smolmaild on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e"); Hive.init(dir.path); final store = await SmolStore.open(); final client = SmolClient(store); // First contact is trust-on-first-use: learn the key the handshake reveals, // then pin it — the flow a user with an operator-supplied key skips. final warnings = []; client.onWarning = warnings.add; final me = client.createIdentity(); final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); final learned = await client.connect(address, requirePin: false); // §8: the first, unpinned session must be announced as unverified. expect(warnings, isNotEmpty); expect(warnings.single, contains("not pinned")); store.pinServer(host, learned.serverStatic); learned.session.wire.close(); await client.registerAccount(address.short); expect(store.account()?.user, user); 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); final inbox = store.listMessages("inbox"); expect(inbox.length, 2); // receivedAt has second granularity, so the order of the two is not // guaranteed; assert on the pair, then open the one we care about. final subjects = inbox.map((m) => client.describe(m).subject).toSet(); expect(subjects, {"hello e2e", "second"}); final hello = inbox.firstWhere( (m) => client.describe(m).subject == "hello e2e"); final opened = client.describe(hello); expect(opened.error, isNull); expect(opened.body, "sealed and signed\n"); expect(hex(opened.sender!), hex(me.publicKey)); // The server must be drained: everything that verified was acknowledged. final again = await client.fetch(); expect(again.stored, 0); // The sent copy is sealed to ourselves and readable (§5.6). final sent = store.listMessages("sent"); expect(sent.length, 2); 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 seed, 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 restored = await SmolStore.open( stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); final secondDevice = SmolClient(restored); restored.setIdentity(me.seed); expect( secondDevice.recallAccount(address.short), throwsA(isA())); restored.pinServer(host, learned.serverStatic); await expectLater( secondDevice.registerAccount(address.short), throwsA(isA())); final bound = await secondDevice.recallAccount(address.short); expect(bound.short, address.short); expect(restored.account()!.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))); }