2026-09-28 23:49:39 +03:00
|
|
|
// 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.
|
2026-09-26 22:45:38 +03:00
|
|
|
//
|
2026-09-28 23:49:39 +03:00
|
|
|
// 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.
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
import "dart:io";
|
2026-09-28 23:49:39 +03:00
|
|
|
import "dart:math";
|
2026-09-27 11:07:11 +03:00
|
|
|
import "dart:typed_data";
|
2026-09-28 23:49:39 +03:00
|
|
|
|
2026-09-26 22:45:38 +03:00
|
|
|
import "package:flutter_test/flutter_test.dart";
|
|
|
|
|
import "package:hive_flutter/hive_flutter.dart";
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
import "package:smol_mail/smol/address.dart";
|
2026-09-26 22:45:38 +03:00
|
|
|
import "package:smol_mail/smol/client.dart";
|
|
|
|
|
import "package:smol_mail/smol/errors.dart";
|
|
|
|
|
import "package:smol_mail/smol/store.dart";
|
2026-09-28 23:49:39 +03:00
|
|
|
import "package:smol_mail/smol/ui.dart";
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
const host = "127.0.0.1";
|
|
|
|
|
const port = 1961;
|
2026-09-28 23:49:39 +03:00
|
|
|
const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
|
|
|
|
|
|
|
|
|
|
Uint8List randomBytes(int n) =>
|
|
|
|
|
Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256)));
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
Future<bool> serverUp() async {
|
|
|
|
|
try {
|
|
|
|
|
final probe = await Socket.connect(host, port,
|
|
|
|
|
timeout: const Duration(seconds: 2));
|
|
|
|
|
probe.destroy();
|
|
|
|
|
return true;
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
Future<SmolStore> freshStore(String tag, String dir) => SmolStore.open(
|
|
|
|
|
dbPath: "$dir/$tag.db", stateBox: "$tag-state", readBox: "$tag-read");
|
|
|
|
|
|
2026-09-26 22:45:38 +03:00
|
|
|
void main() {
|
|
|
|
|
test("register, send to self, fetch, unseal, drain", () async {
|
|
|
|
|
if (!await serverUp()) {
|
2026-09-28 23:49:39 +03:00
|
|
|
markTestSkipped("no server on $host:$port");
|
2026-09-26 22:45:38 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final dir = await Directory.systemTemp.createTemp("smol-e2e");
|
|
|
|
|
Hive.init(dir.path);
|
2026-09-28 23:49:39 +03:00
|
|
|
final store = await freshStore("main", dir.path);
|
2026-09-26 22:45:38 +03:00
|
|
|
final client = SmolClient(store);
|
|
|
|
|
|
|
|
|
|
final warnings = <String>[];
|
|
|
|
|
client.onWarning = warnings.add;
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
await client.createIdentity();
|
2026-09-27 11:07:11 +03:00
|
|
|
final me = client.identity!;
|
2026-09-26 22:45:38 +03:00
|
|
|
final user = "e2e${hex(randomBytes(4))}";
|
|
|
|
|
final address = parseAddress("$user@$host");
|
2026-09-30 08:52:57 +03:00
|
|
|
store.pinServer(host, serverKey, port);
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
await client.registerAccount(address.short);
|
2026-09-28 23:49:39 +03:00
|
|
|
expect(client.accountAddress()!.short, address.short);
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-09-27 11:07:11 +03:00
|
|
|
// §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();
|
2026-09-26 22:45:38 +03:00
|
|
|
expect(subjects, {"hello e2e", "second"});
|
2026-09-28 23:49:39 +03:00
|
|
|
final hello = requests
|
|
|
|
|
.firstWhere((m) => client.describe(m).subject == "hello e2e");
|
2026-09-26 22:45:38 +03:00
|
|
|
final opened = client.describe(hello);
|
|
|
|
|
expect(opened.error, isNull);
|
2026-09-28 23:49:39 +03:00
|
|
|
// 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);
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
// The server must be drained: everything that verified was acknowledged.
|
|
|
|
|
final again = await client.fetch();
|
|
|
|
|
expect(again.stored, 0);
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
// 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.
|
2026-09-27 11:07:11 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
// Having now learned our own token, the next one carries a matching MAC
|
|
|
|
|
// and reaches the main tier.
|
2026-09-27 11:07:11 +03:00
|
|
|
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");
|
|
|
|
|
|
2026-09-26 22:45:38 +03:00
|
|
|
// The sent copy is sealed to ourselves and readable (§5.6).
|
|
|
|
|
final sent = store.listMessages("sent");
|
2026-09-27 11:07:11 +03:00
|
|
|
expect(sent.length, 4);
|
2026-09-26 22:45:38 +03:00
|
|
|
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<SmolError>()));
|
|
|
|
|
|
2026-09-27 11:07:11 +03:00
|
|
|
// Restore on a second device: same master, fresh store, no pin. Unpinned
|
2026-09-26 22:45:38 +03:00
|
|
|
// recall is refused; re-registering a taken name is refused; recall with
|
|
|
|
|
// the operator-supplied key then binds the account without REGISTER.
|
2026-09-28 23:49:39 +03:00
|
|
|
final secondStore = await freshStore("second", dir.path);
|
|
|
|
|
final secondDevice = SmolClient(secondStore);
|
|
|
|
|
secondStore.restoreMaster(store.master()!, 0);
|
|
|
|
|
await expectLater(
|
2026-09-26 22:45:38 +03:00
|
|
|
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
|
2026-09-30 08:52:57 +03:00
|
|
|
secondStore.pinServer(host, serverKey, port);
|
2026-09-26 22:45:38 +03:00
|
|
|
await expectLater(
|
|
|
|
|
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
|
|
|
|
|
final bound = await secondDevice.recallAccount(address.short);
|
|
|
|
|
expect(bound.short, address.short);
|
2026-09-28 23:49:39 +03:00
|
|
|
expect(secondDevice.accountAddress()!.user, user);
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
// 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)));
|
2026-09-27 11:07:11 +03:00
|
|
|
|
|
|
|
|
test("leave mail on server keeps mail until deleted, with dedupe on refetch",
|
|
|
|
|
() async {
|
|
|
|
|
if (!await serverUp()) {
|
2026-09-28 23:49:39 +03:00
|
|
|
markTestSkipped("no server on $host:$port");
|
2026-09-27 11:07:11 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final dir = await Directory.systemTemp.createTemp("smol-e2e-keep");
|
|
|
|
|
Hive.init(dir.path);
|
2026-09-28 23:49:39 +03:00
|
|
|
final store = await freshStore("keep", dir.path);
|
2026-09-27 11:07:11 +03:00
|
|
|
final client = SmolClient(store);
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
await client.createIdentity();
|
2026-09-27 11:07:11 +03:00
|
|
|
final user = "e2ekeep${hex(randomBytes(4))}";
|
|
|
|
|
final address = parseAddress("$user@$host");
|
2026-09-30 08:52:57 +03:00
|
|
|
store.pinServer(host, serverKey, port);
|
2026-09-27 11:07:11 +03:00
|
|
|
await client.registerAccount(address.short);
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
await store.setLeaveOnServer(true);
|
2026-09-27 11:07:11 +03:00
|
|
|
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");
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
// Re-paging from scratch must not duplicate it locally (the seen-id
|
2026-09-27 11:07:11 +03:00
|
|
|
// dedupe), even though the server still has it (nothing was deleted).
|
2026-09-28 23:49:39 +03:00
|
|
|
final second = await client.fetch(reset: true);
|
2026-09-27 11:07:11 +03:00
|
|
|
expect(second.stored, 0);
|
|
|
|
|
expect(store.listMessages("requests").length, 1);
|
|
|
|
|
|
2026-09-28 23:49:39 +03:00
|
|
|
// Deleting removes it locally and from the server too: a further full
|
|
|
|
|
// re-page after deletion comes back empty rather than resurrecting it.
|
2026-09-27 11:07:11 +03:00
|
|
|
await client.deleteMessage("requests", record);
|
|
|
|
|
expect(store.listMessages("requests"), isEmpty);
|
2026-09-28 23:49:39 +03:00
|
|
|
final third = await client.fetch(reset: true);
|
2026-09-27 11:07:11 +03:00
|
|
|
expect(third.stored, 0);
|
|
|
|
|
expect(store.listMessages("requests"), isEmpty);
|
|
|
|
|
}, timeout: const Timeout(Duration(minutes: 2)));
|
2026-09-26 22:45:38 +03:00
|
|
|
}
|