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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Regression tests for the onboarding recall flows: restoring a seed and
|
||||
// Regression tests for the onboarding recall flows: restoring a master and
|
||||
// recalling the registered address must land the user in the inbox. The
|
||||
// client's network operations are stubbed; what is under test is the UI and
|
||||
// router flow itself.
|
||||
|
|
@ -14,14 +14,28 @@ import "package:smol_mail/data/providers/providers.dart";
|
|||
import "package:smol_mail/presentation/app_widget.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";
|
||||
|
||||
/// A [SmolClient] whose recall completes instantly, so the test exercises
|
||||
/// the flow rather than the network.
|
||||
/// A [SmolClient] whose network operations complete instantly, so the test
|
||||
/// exercises the flow rather than the network. [restoreAndRecall] still
|
||||
/// enforces the pin gate (SPEC.md §4), since that gate is exactly what the
|
||||
/// restore flow's UX is regression-tested against.
|
||||
class StubClient extends SmolClient {
|
||||
StubClient(super.store);
|
||||
|
||||
@override
|
||||
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
||||
final addr = parseAddress(addressText);
|
||||
if (store.serverPin(addr.host) == null) {
|
||||
throw SmolError("no pinned key for ${addr.host}");
|
||||
}
|
||||
store.restoreMaster(unhex(masterHex.trim()), 0);
|
||||
store.setAccount(addr);
|
||||
return addr;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SmolAddress> recallAccount(String addressText) async {
|
||||
final addr = parseAddress(addressText);
|
||||
|
|
@ -58,7 +72,7 @@ void main() {
|
|||
"restore with an address, but no pin yet, asks for the server key "
|
||||
"and then recalls into the inbox", (tester) async {
|
||||
final store = storeA;
|
||||
final seed = randomBytes(32);
|
||||
final master = randomBytes(32);
|
||||
await tester.pumpWidget(app(store));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Create Identity"), findsOneWidget);
|
||||
|
|
@ -68,7 +82,7 @@ void main() {
|
|||
|
||||
var fields = find.byType(TextField);
|
||||
expect(fields, findsNWidgets(2));
|
||||
await tester.enterText(fields.at(0), hex(seed));
|
||||
await tester.enterText(fields.at(0), hex(master));
|
||||
await tester.enterText(fields.at(1), "randogoth@smol.place");
|
||||
await tester.tap(find.text("Restore"));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -80,6 +94,9 @@ void main() {
|
|||
expect(find.text("Pin this server's public key to finish restoring your address."),
|
||||
findsOneWidget);
|
||||
expect(find.text("Invite token (optional)"), findsNothing);
|
||||
// Restoring has no well-defined "register a new address instead" escape
|
||||
// hatch until the rotation index is known (§2).
|
||||
expect(find.text("Register a new address instead"), findsNothing);
|
||||
fields = find.byType(TextField);
|
||||
expect(fields, findsNWidgets(2)); // address (carried over), server key
|
||||
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
|
||||
|
|
@ -87,84 +104,71 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("Inbox"), findsOneWidget);
|
||||
expect(store.seed(), seed);
|
||||
expect(store.master(), master);
|
||||
expect(store.account()!.user, "randogoth");
|
||||
});
|
||||
|
||||
testWidgets("restore without an address recalls from the register step",
|
||||
testWidgets("restore requires an address before it will submit",
|
||||
(tester) async {
|
||||
final store = storeB;
|
||||
final seed = randomBytes(32);
|
||||
await tester.pumpWidget(app(store));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text("Restore From Seed"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
var fields = find.byType(TextField);
|
||||
await tester.enterText(fields.at(0), hex(seed));
|
||||
final fields = find.byType(TextField);
|
||||
await tester.enterText(fields.at(0), hex(randomBytes(32)));
|
||||
await tester.tap(find.text("Restore"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The register step: address, server key, optional invite token.
|
||||
fields = find.byType(TextField);
|
||||
expect(fields, findsNWidgets(3));
|
||||
await tester.enterText(fields.at(0), "randogoth@smol.place");
|
||||
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
|
||||
await tester.tap(find.text("Already registered? Recall"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("Inbox"), findsOneWidget);
|
||||
expect(store.account()!.user, "randogoth");
|
||||
// Nothing was submitted, so nothing was persisted; still on this step.
|
||||
expect(find.text("Restore"), findsOneWidget);
|
||||
expect(find.text("Inbox"), findsNothing);
|
||||
expect(store.master(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"restoring again after an incomplete attempt replaces the identity "
|
||||
"restoring after an abandoned Create Identity attempt replaces it "
|
||||
"instead of refusing it", (tester) async {
|
||||
final store = storeC;
|
||||
final abandonedSeed = randomBytes(32);
|
||||
final realSeed = randomBytes(32);
|
||||
final realMaster = randomBytes(32);
|
||||
await tester.pumpWidget(app(store));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// First attempt: restore a seed but never finish registering — lands on
|
||||
// the register step, identity set, no account bound.
|
||||
await tester.tap(find.text("Restore From Seed"));
|
||||
// First attempt: create a fresh identity but never finish registering —
|
||||
// lands on the backup step, master set, no account bound.
|
||||
await tester.tap(find.text("Create Identity"));
|
||||
await tester.pumpAndSettle();
|
||||
var fields = find.byType(TextField);
|
||||
await tester.enterText(fields.at(0), hex(abandonedSeed));
|
||||
await tester.tap(find.text("Restore"));
|
||||
await tester.pumpAndSettle();
|
||||
expect(store.seed(), abandonedSeed);
|
||||
expect(store.master(), isNotNull);
|
||||
expect(store.account(), isNull);
|
||||
|
||||
// Simulate returning to onboarding later (e.g. a cold restart). Pumping
|
||||
// app(store) directly would just rebuild the existing OnboardingScreen
|
||||
// state in place (still parked on the register step) rather than really
|
||||
// restarting, so tear the tree down first to force a fresh app state —
|
||||
// HomeGuard then sends an identity-without-account back to welcome.
|
||||
// state in place rather than really restarting, so tear the tree down
|
||||
// first to force a fresh app state — HomeGuard then sends an
|
||||
// identity-without-account back to welcome.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpWidget(app(store));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Create Identity"), findsOneWidget);
|
||||
|
||||
// Restoring a different seed must not throw "identity already exists".
|
||||
// Restoring a different master must not throw "identity already exists".
|
||||
await tester.tap(find.text("Restore From Seed"));
|
||||
await tester.pumpAndSettle();
|
||||
fields = find.byType(TextField);
|
||||
await tester.enterText(fields.at(0), hex(realSeed));
|
||||
final fields = find.byType(TextField);
|
||||
await tester.enterText(fields.at(0), hex(realMaster));
|
||||
await tester.enterText(fields.at(1), "randogoth@smol.place");
|
||||
await tester.tap(find.text("Restore"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Lands on the recall-framed register step (no pin yet); pin it and finish.
|
||||
fields = find.byType(TextField);
|
||||
expect(fields, findsNWidgets(2));
|
||||
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
|
||||
expect(find.byType(TextField), findsNWidgets(2));
|
||||
await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32)));
|
||||
await tester.tap(find.text("Pin and Recall"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("Inbox"), findsOneWidget);
|
||||
expect(store.seed(), realSeed);
|
||||
expect(store.master(), realMaster);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,24 +163,28 @@ void main() {
|
|||
});
|
||||
|
||||
test("frontmatter parses and fails closed (§5.5)", () {
|
||||
const spec =
|
||||
"---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
|
||||
const inReplyTo =
|
||||
"4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5";
|
||||
final spec =
|
||||
"---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
|
||||
final parsed = parseFrontmatter(spec);
|
||||
expect(parsed.fields["Subject"], "Re: the thing");
|
||||
expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d");
|
||||
expect(parsed.fields["subject"], "Re: the thing");
|
||||
expect(parsed.fields["in-reply-to"], inReplyTo);
|
||||
expect(parsed.body, "Body text starts here.");
|
||||
// a malformed line invalidates the whole block, which fails closed toward display
|
||||
expect(parseFrontmatter("---\nno colon here\n---\nrest").body,
|
||||
"---\nno colon here\n---\nrest");
|
||||
expect(parseFrontmatter("---\nSubject: x\nno end").body,
|
||||
"---\nSubject: x\nno end");
|
||||
expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1");
|
||||
expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["a"], "1");
|
||||
// keys compare case-insensitively; the first occurrence wins (§5.5)
|
||||
expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x");
|
||||
expect(buildFrontmatter(const {}, "---\nactual body"),
|
||||
"---\n---\n---\nactual body");
|
||||
expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere");
|
||||
expect(buildFrontmatter(const {}, "plain"), "plain");
|
||||
expect(
|
||||
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"],
|
||||
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"],
|
||||
isNull);
|
||||
});
|
||||
|
||||
|
|
@ -195,6 +199,9 @@ void main() {
|
|||
expect(parsed.identity, key);
|
||||
expect(parsed.user, "bob");
|
||||
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
|
||||
// §3: never two separators in a row
|
||||
expect(() => parseAddress("a..b@h"), throwsA(isA<SmolError>()));
|
||||
expect(parseAddress("a.b_c@h").user, "a.b_c");
|
||||
// §3: fingerprints are the first 20 base32 characters in groups of four
|
||||
final b32 = b32encode(key);
|
||||
expect(
|
||||
|
|
@ -210,27 +217,31 @@ void main() {
|
|||
});
|
||||
|
||||
test("rotation chains validate, break and oversize per §7", () {
|
||||
const username = "alice";
|
||||
final old = identityFromSeed(randomBytes(32));
|
||||
final mid = randomBytes(32);
|
||||
final fresh = randomBytes(32);
|
||||
final when = nowSeconds();
|
||||
final chain = [
|
||||
makeCert(old, mid, when),
|
||||
makeCert(identityFromSeed(mid), fresh, when),
|
||||
makeCert(username, old, mid, when),
|
||||
makeCert(username, identityFromSeed(mid), fresh, when),
|
||||
];
|
||||
expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
|
||||
expect(walkChain(old.publicKey, old.publicKey, []), isTrue);
|
||||
expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
|
||||
expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue);
|
||||
expect(
|
||||
walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
|
||||
walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
|
||||
isFalse);
|
||||
final forged = List<Uint8List>.from(chain);
|
||||
forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when);
|
||||
forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when);
|
||||
expect(
|
||||
walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
|
||||
walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
|
||||
expect(
|
||||
walkChain(old.publicKey, ed25519PublicKey(fresh),
|
||||
walkChain(username, old.publicKey, ed25519PublicKey(fresh),
|
||||
List.filled(17, chain[0])),
|
||||
isFalse);
|
||||
// a chain signed for a different username must not validate (§7)
|
||||
expect(
|
||||
walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), isFalse);
|
||||
expect(chain[0].length, certLen);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -63,11 +63,11 @@ void main() {
|
|||
expect(saved.key, identity.publicKey);
|
||||
});
|
||||
|
||||
test("export never contains the seed and round-trips through import",
|
||||
test("export never contains the master secret and round-trips through import",
|
||||
() async {
|
||||
final a = await freshStore("export");
|
||||
final b = await freshStore("round-trip");
|
||||
a.setIdentity(randomBytes(32));
|
||||
a.setMaster(randomBytes(32));
|
||||
a.pinServer("example.org", randomBytes(32));
|
||||
a.saveContact("alice@example.org", randomBytes(32), true);
|
||||
a.saveContact("alice@example.org", randomBytes(32), false); // history grows
|
||||
|
|
@ -77,12 +77,12 @@ void main() {
|
|||
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
|
||||
|
||||
final data = a.exportData();
|
||||
expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol
|
||||
expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse);
|
||||
expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol
|
||||
expect(jsonEncode(data).contains(hex(a.master()!)), isFalse);
|
||||
|
||||
// v2 is sealed to the exporting identity's seed — a restore-on-new-device
|
||||
// v2 is sealed to the exporting identity's master — a restore-on-new-device
|
||||
// scenario, not a transfer to someone else's identity (see the test below).
|
||||
b.setIdentity(a.seed()!);
|
||||
b.setMaster(a.master()!);
|
||||
final summary = await b.importData(data);
|
||||
expect(summary.mailAdded, 2);
|
||||
expect(summary.pinsAdded, 1);
|
||||
|
|
@ -96,14 +96,58 @@ void main() {
|
|||
test("v2 import refuses a different identity's export", () async {
|
||||
final a = await freshStore("export-wrong-identity");
|
||||
final c = await freshStore("round-trip-wrong-identity");
|
||||
a.setIdentity(randomBytes(32));
|
||||
a.setMaster(randomBytes(32));
|
||||
a.pinServer("example.org", randomBytes(32));
|
||||
final data = a.exportData();
|
||||
|
||||
c.setIdentity(randomBytes(32)); // a different seed than a's
|
||||
c.setMaster(randomBytes(32)); // a different master than a's
|
||||
expect(() => c.importData(data), throwsA(isA<SmolError>()));
|
||||
});
|
||||
|
||||
test("accept tokens: accept/block gate the sync set, tiers split the inbox view",
|
||||
() async {
|
||||
final store = await freshStore("accept-tokens");
|
||||
final master = randomBytes(32);
|
||||
store.setMaster(master);
|
||||
final alice = randomBytes(32);
|
||||
store.saveContact("alice@example.org", alice, true);
|
||||
|
||||
// No one accepted yet: an empty set is already complete, so it may sync.
|
||||
var (sync, tokens) = store.tokenSet(master);
|
||||
expect(sync, 1);
|
||||
expect(tokens, isEmpty);
|
||||
|
||||
store.accept("alice@example.org", alice);
|
||||
(sync, tokens) = store.tokenSet(master);
|
||||
expect(sync, 1);
|
||||
expect(tokens, [tokenFor(master, alice)]);
|
||||
expect(store.accepted("alice@example.org")!.active, isTrue);
|
||||
|
||||
store.block("alice@example.org");
|
||||
expect(store.accepted("alice@example.org")!.active, isFalse);
|
||||
expect(store.tokenSet(master).$2, isEmpty);
|
||||
// Re-accepting keeps the identity frozen at the original acceptance,
|
||||
// so the token a correspondent already holds keeps working.
|
||||
store.accept("alice@example.org", randomBytes(32));
|
||||
expect(store.accepted("alice@example.org")!.identity, alice);
|
||||
|
||||
expect(() => store.block("bob@example.org"), throwsA(isA<SmolError>()));
|
||||
|
||||
// A restored master must not silently replace the server's set.
|
||||
store.setSyncOk(false);
|
||||
(sync, tokens) = store.tokenSet(master);
|
||||
expect(sync, 0);
|
||||
expect(tokens, isEmpty);
|
||||
|
||||
await store.storeIfNew(
|
||||
"inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain));
|
||||
await store.storeIfNew("inbox",
|
||||
MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests));
|
||||
expect(store.listMessages("inbox").map((r) => r.id), ["aa"]);
|
||||
expect(store.listMessages("requests").map((r) => r.id), ["bb"]);
|
||||
expect(store.getMessage("requests", "bb"), isNotNull);
|
||||
});
|
||||
|
||||
test("v1 legacy export still imports without an identity", () async {
|
||||
final store = await freshStore("import-legacy-v1");
|
||||
final summary = await store.importData({
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@
|
|||
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
|
||||
"time": 1730000000,
|
||||
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
|
||||
"id": "b05d15a2ab5293164eda564de02a6901",
|
||||
"id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553",
|
||||
"unpadded_plaintext_len": 143
|
||||
},
|
||||
"noise": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue