feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention
This commit is contained in:
parent
eaaa3f2ede
commit
c693e6fcb9
27 changed files with 1190 additions and 592 deletions
|
|
@ -1,13 +1,15 @@
|
|||
// 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.
|
||||
// locally running smolmaild, send sealed messages to ourselves, fetch them
|
||||
// back, exercise the accept-token round trip (§5.8) between the requests and
|
||||
// main tiers, 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 "dart:typed_data";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:hive_flutter/hive_flutter.dart";
|
||||
|
||||
|
|
@ -47,7 +49,8 @@ void main() {
|
|||
final warnings = <String>[];
|
||||
client.onWarning = warnings.add;
|
||||
|
||||
final me = client.createIdentity();
|
||||
final master = client.createIdentity();
|
||||
final me = client.identity!;
|
||||
final user = "e2e${hex(randomBytes(4))}";
|
||||
final address = parseAddress("$user@$host");
|
||||
final learned = await client.connect(address, requirePin: false);
|
||||
|
|
@ -67,13 +70,16 @@ void main() {
|
|||
expect(summary.stored, 2);
|
||||
expect(summary.rejected, isEmpty);
|
||||
|
||||
final inbox = store.listMessages("inbox");
|
||||
expect(inbox.length, 2);
|
||||
// §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);
|
||||
// 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();
|
||||
final subjects = requests.map((m) => client.describe(m).subject).toSet();
|
||||
expect(subjects, {"hello e2e", "second"});
|
||||
final hello = inbox.firstWhere(
|
||||
final hello = requests.firstWhere(
|
||||
(m) => client.describe(m).subject == "hello e2e");
|
||||
final opened = client.describe(hello);
|
||||
expect(opened.error, isNull);
|
||||
|
|
@ -84,9 +90,27 @@ void main() {
|
|||
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 — we cannot know our own token before receiving and parsing
|
||||
// a message that carries it — 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 from that message's Accept field, 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, 2);
|
||||
expect(sent.length, 4);
|
||||
expect(client.describe(sent.first).error, isNull);
|
||||
|
||||
// A restored seed can rebind the address without REGISTER; the address
|
||||
|
|
@ -96,13 +120,13 @@ void main() {
|
|||
expect(
|
||||
() => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>()));
|
||||
|
||||
// Restore on a second device: same seed, fresh store, no pin. Unpinned
|
||||
// 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 restored = await SmolStore.open(
|
||||
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
|
||||
final secondDevice = SmolClient(restored);
|
||||
restored.setIdentity(me.seed);
|
||||
restored.setMaster(master);
|
||||
expect(
|
||||
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
|
||||
restored.pinServer(host, learned.serverStatic);
|
||||
|
|
@ -118,4 +142,50 @@ void main() {
|
|||
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 smolmaild on $host:$port");
|
||||
return;
|
||||
}
|
||||
final dir = await Directory.systemTemp.createTemp("smol-e2e-keep");
|
||||
Hive.init(dir.path);
|
||||
final store =
|
||||
await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail");
|
||||
final client = SmolClient(store);
|
||||
|
||||
client.createIdentity();
|
||||
final user = "e2ekeep${hex(randomBytes(4))}";
|
||||
final address = parseAddress("$user@$host");
|
||||
final learned = await client.connect(address, requirePin: false);
|
||||
store.pinServer(host, learned.serverStatic);
|
||||
learned.session.wire.close();
|
||||
await client.registerAccount(address.short);
|
||||
|
||||
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-fetching from scratch must not duplicate it locally (storeIfNew's
|
||||
// dedupe), even though the server still has it (nothing was deleted).
|
||||
store.setCursor(0, Uint8List(idLen));
|
||||
final second = await client.fetch();
|
||||
expect(second.stored, 0);
|
||||
expect(store.listMessages("requests").length, 1);
|
||||
|
||||
// Deleting removes it from the server too: a further full re-page after
|
||||
// deletion must come back empty rather than resurrecting it.
|
||||
await client.deleteMessage("requests", record);
|
||||
expect(store.listMessages("requests"), isEmpty);
|
||||
store.setCursor(0, Uint8List(idLen));
|
||||
final third = await client.fetch();
|
||||
expect(third.stored, 0);
|
||||
expect(store.listMessages("requests"), isEmpty);
|
||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue