122 lines
4.8 KiB
Dart
122 lines
4.8 KiB
Dart
|
|
// 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<bool> 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 = <String>[];
|
||
|
|
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<SmolError>()));
|
||
|
|
|
||
|
|
// 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<SmolError>()));
|
||
|
|
restored.pinServer(host, learned.serverStatic);
|
||
|
|
await expectLater(
|
||
|
|
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
|
||
|
|
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)));
|
||
|
|
}
|