// The binding layer's proof: the Dart client drives the C ABI end to end — // handles, error codes, async isolates, and the backup round-trip. The // server-backed part runs only when the spike bunshin is up on 19619. import "dart:convert"; import "dart:io"; import "dart:math"; import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; import "package:smol_mail/smol/address.dart"; const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga"; const spikeServer = "127.0.0.1"; const spikePort = 19619; Future spikeUp() async { try { final socket = await Socket.connect(spikeServer, spikePort, timeout: const Duration(milliseconds: 300)); socket.destroy(); return true; } on SocketException { return false; } } void main() async { test("handles, error codes and async isolates work end to end", () async { final dir = await Directory.systemTemp.createTemp("native-binding"); final client = FumiNative("${dir.path}/a.db"); await client.open(); client.setMaster(Uint8List.fromList(List.filled(32, 9))); // The account public key crosses as base32, driven from a worker isolate. expect((await client.accountPk()).length, 52); expect(await client.accountAddress(), isNull); // A pinned but dead host fails by code, never by prose: Unreachable (7). // The pin is scoped to the dead port itself — a default-port pin would // not cover it, and the register would fail NotPinned instead. await client.pinServer("127.0.0.1", spikeServerKey, 19629); client.setMaster(Uint8List.fromList(List.filled(32, 9))); try { await client.register("nobody@127.0.0.1:19629"); fail("register against a dead port must fail"); } on NativeSmolException catch (err) { expect(err.code, smolUnreachable); expect(err.details["host"], "127.0.0.1"); } // Empty folders and contact lookups render, not crash. expect(await client.mail("inbox"), isEmpty); expect((await client.contact("ghost@example.org"))["key"], ""); await client.close(); }); test("the backup round-trip crosses the ABI in both directions", () async { final dir = await Directory.systemTemp.createTemp("native-backup"); final master = Uint8List.fromList(List.generate(32, (i) => i + 3)); final mine = FumiNative("${dir.path}/mine.db"); await mine.open(); mine.setMaster(master); await mine.pinServer("example.org", spikeServerKey, defaultPort); await mine.importContact( "smol://alice@example.org/ayb6y3mwr3cfkcmcaqbn3cgfi6xsrsoqkalykw5s7oqmwqcpnmsq"); final file = await mine.exportBackup(); final restored = FumiNative("${dir.path}/restored.db"); await restored.open(); restored.setMaster(master); final summary = jsonDecode(await restored.importBackup(file)) as Map; expect(summary["contactsAdded"], 1); expect(summary["pinsAdded"], 1); expect(await restored.serverPin("example.org", defaultPort), spikeServerKey); await mine.close(); await restored.close(); }); test("register, send, fetch and describe against the spike bunshin", skip: await spikeUp() ? false : "no bunshin on 127.0.0.1:19619", () async { final dir = await Directory.systemTemp.createTemp("native-e2e"); // Random masters: bunshin refuses a key already bound under another // 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, (_) => Random.secure().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, spikePort); await alice.register("a$run@$spikeServer:$spikePort"); expect(await alice.accountAddress(), "a$run@$spikeServer:$spikePort"); final bob = FumiNative("${dir.path}/bob.db"); await bob.open(); bob.setMaster(bobMaster); await bob.pinServer(spikeServer, spikeServerKey, spikePort); await bob.register("b$run@$spikeServer:$spikePort"); final sent = await alice.send("b$run@$spikeServer:$spikePort", "binding: hello over the ABI", subject: "binding"); expect(sent["change"], "new"); final summary = await bob.fetch(); expect(summary["stored"], 1); // First contact lands in the requests tier (sec 5.8). final requests = await bob.mail("requests"); expect(requests, hasLength(1)); final described = await bob.describe(requests[0]["id"] as String); expect(described["text"], "binding: hello over the ABI"); expect(described["subject"], "binding"); 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, spikePort); 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, spikePort); 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(); }); }