// Store-level behavior: contact key history (§8), import binding, and the // export/import backup file. import "dart:convert"; 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"; // Each store gets its own boxes; Hive is a per-process singleton, so without // this the "exporting" and "importing" stores would be the same store. Future freshStore(String tag) => SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail"); void main() { setUpAll(() async { TestWidgetsFlutterBinding.ensureInitialized(); final dir = await Directory.systemTemp.createTemp("smol-store-test"); Hive.init(dir.path); }); test("contact history keeps displaced keys, not re-saves", () async { final store = await freshStore("history"); final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32); store.saveContact("alice@example.org", a, true); expect(store.contact("alice@example.org")!.history, isEmpty); store.saveContact("alice@example.org", b, false); final rotated = store.contact("alice@example.org")!; expect(rotated.key, b); expect(rotated.history.length, 1); expect(rotated.history.first.key, a); expect(rotated.history.first.until, greaterThan(0)); // Re-saving the same key is not a rotation and must not add an entry. store.saveContact("alice@example.org", b, true); expect(store.contact("alice@example.org")!.history.length, 1); // A second displacement appends, oldest first. store.saveContact("alice@example.org", c, false); final history = store.contact("alice@example.org")!.history; expect(history.length, 2); expect(history[0].key, a); expect(history[1].key, b); expect(store.allContacts().single.$2.history.length, 2); }); test("importContact binds a smol:// address to the key it carries", () async { final store = await freshStore("import-contact"); final client = SmolClient(store); final identity = identityFromSeed(randomBytes(32)); client.importContact( "smol://bob@example.org/${b32encode(identity.publicKey)}"); final saved = store.contact("bob@example.org")!; expect(saved.verified, isTrue); expect(saved.key, identity.publicKey); }); test("export never contains the seed and round-trips through import", () async { final a = await freshStore("export"); final b = await freshStore("round-trip"); a.setIdentity(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 await a.storeMessage( "inbox", MailRecord("aa", randomBytes(64), receivedAt: 5)); await a.storeMessage("sent", 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); // v2 is sealed to the exporting identity's seed — a restore-on-new-device // scenario, not a transfer to someone else's identity (see the test below). b.setIdentity(a.seed()!); final summary = await b.importData(data); expect(summary.mailAdded, 2); expect(summary.pinsAdded, 1); expect(summary.contactsAdded, 1); expect(b.serverPin("example.org"), a.serverPin("example.org")); expect(b.contact("alice@example.org")!.history.length, 1); expect(b.getMessage("inbox", "aa"), isNotNull); expect(b.getMessage("sent", "bb"), isNotNull); }); 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.pinServer("example.org", randomBytes(32)); final data = a.exportData(); c.setIdentity(randomBytes(32)); // a different seed than a's expect(() => c.importData(data), throwsA(isA())); }); test("v1 legacy export still imports without an identity", () async { final store = await freshStore("import-legacy-v1"); final summary = await store.importData({ "gsmolExport": 1, "servers": {"example.org": b32encode(randomBytes(32))}, }); expect(summary.pinsAdded, 1); }); test("import never overwrites a differing trust binding", () async { final store = await freshStore("conflict"); final mine = randomBytes(32), other = randomBytes(32); store.pinServer("example.org", mine); store.saveContact("alice@example.org", mine, true); final summary = await store.importData({ "gsmolExport": 1, "servers": {"example.org": b32encode(other)}, "contacts": { "alice@example.org": {"key": b32encode(other), "verified": true}, "bob@example.org": {"key": b32encode(randomBytes(32)), "verified": false}, }, }); expect(summary.pinsConflicted, 1); expect(summary.pinsAdded, 0); expect(summary.contactsConflicted, 1); expect(summary.contactsAdded, 1); expect(store.serverPin("example.org"), mine); expect(store.contact("alice@example.org")!.key, mine); expect(store.contact("bob@example.org"), isNotNull); }); test("import skips malformed entries and rejects wrong files", () async { final store = await freshStore("malformed"); final summary = await store.importData({ "gsmolExport": 1, "servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))}, "contacts": { "x": {"key": "nope"}, "y": "not a record", }, "inbox": [ {"id": "ok", "envelope": base64Encode(randomBytes(64)), "receivedAt": 1}, {"id": "bad", "envelope": "!!!not base64!!!"}, "not a record", ], "sent": "not a list", }); expect(summary.malformed, 7); // 2 pins, 2 contacts, 2 mail, 1 sent expect(summary.pinsAdded, 0); expect(summary.mailAdded, 1); expect(store.getMessage("inbox", "ok"), isNotNull); expect(store.getMessage("inbox", "bad"), isNull); expect(() => store.importData({"nope": 1}), throwsA(isA())); }); }