aboutsummaryrefslogtreecommitdiff
path: root/test/import/ImporterTest.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
commit42f9331e693f5058fcfcd7aef5a92cabc04c20ee (patch)
treef3dab2657eb592ee468e6cebc9b47d9da7e08683 /test/import/ImporterTest.mc
parentd537f4e154a40075405b0ab2fed14fbe2eeaac81 (diff)
test(import): cover parsers, importer and a seeded fuzz corpus; import 100 entries in the UI flow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'test/import/ImporterTest.mc')
-rw-r--r--test/import/ImporterTest.mc208
1 files changed, 208 insertions, 0 deletions
diff --git a/test/import/ImporterTest.mc b/test/import/ImporterTest.mc
new file mode 100644
index 0000000..411e528
--- /dev/null
+++ b/test/import/ImporterTest.mc
@@ -0,0 +1,208 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the import job: reports, duplicates, replacements, locking, Replace mode, multi-part buffer, failures, a full store, 100 entries.
+
+import Toybox.Lang;
+import Toybox.System;
+import Toybox.Test;
+
+(:test)
+module ImporterFixtures {
+
+ const NOW = 1800000000l;
+
+ function uri(issuer as String, account as String, secret as String) as String {
+ return "otpauth://totp/" + issuer + ":" + account + "?secret=" + secret;
+ }
+
+ function vault() as Vault {
+ return new Vault(new MemoryBackend(), new FakeTime());
+ }
+}
+
+(:test)
+function importerStoresUrisAndReports(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ var text = ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_1) + "\n"
+ + ImporterFixtures.uri("Mail", "bob", AegisFixtures.SECRET_2) + "\n"
+ + "otpauth://totp/Bad:carol?secret=" + AegisFixtures.SECRET_3 + "&digits=9\n"
+ + "otpauth://motp/Old:dan?secret=" + AegisFixtures.SECRET_3;
+ var report = AegisFixtures.runImport(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var ids = vault.ids();
+ var first = vault.open(ids[0]);
+ var ok = first.name.equals("GitHub") && first.source == Entry.SOURCE_IMPORT && first.created == ImporterFixtures.NOW;
+ first.wipeSecrets();
+ var summary = report.summary();
+ if (!summary.equals("Imported 2 · 1 error · 1 skipped")) {
+ logger.error("summary: " + summary);
+ return false;
+ }
+ return ok && ids.size() == 2 && AegisFixtures.hasDetail(report, "URI 3 'Bad': 9 digits")
+ && AegisFixtures.hasDetail(report, "URI 4 'Old': skipped, mOTP");
+}
+
+// The same type and secret is a duplicate, also of a locked entry while the
+// watch is locked (fingerprints); same issuer and account with another
+// secret is imported and flagged.
+(:test)
+function importerFindsDuplicatesAndReplacements(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ var locked = StorageFixtures.totp("Vault");
+ locked.secret = Bytes.fromUtf8("12345678901234567890");
+ locked.algorithm = Hashes.SHA1;
+ locked.locked = true;
+ var lockedId = vault.add(locked);
+ vault.lock();
+ var first = AegisFixtures.runImport(vault, ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_2),
+ Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var text = ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_2) + " " // duplicate of the first import
+ + ImporterFixtures.uri("Other", "x", AegisFixtures.SECRET_1) + " " // duplicate of the locked entry
+ + ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_3) + " " // same names, new secret
+ + ImporterFixtures.uri("Twice", "y", AegisFixtures.SECRET_3); // duplicate within this import
+ var second = AegisFixtures.runImport(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ if (second.duplicates != 3 || second.imported != 1) {
+ logger.error(second.summary());
+ return false;
+ }
+ return first.imported == 1 && AegisFixtures.hasDetail(second, "duplicate of " + lockedId)
+ && AegisFixtures.hasDetail(second, "possible replacement for") && vault.ids().size() == 3;
+}
+
+// "Require PIN" locks the whole import; while locked they wait as pending.
+(:test)
+function importerLocksEntriesAsPending(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ var report = AegisFixtures.runImport(vault, ImporterFixtures.uri("Bank", "eve", AegisFixtures.SECRET_1),
+ Importer.MODE_APPEND, true, ImporterFixtures.NOW);
+ return report.imported == 1 && report.pendingLocked == 1 && vault.pendingIds().size() == 1 && vault.ids().size() == 0
+ && report.summary().find("waiting for your PIN") != null;
+}
+
+// Replace mode deletes earlier imported entries, never manual ones.
+(:test)
+function importerReplaceModeKeepsManualEntries(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ var manualId = vault.add(StorageFixtures.totp("Manual"));
+ // (The manual fixture uses SECRET_1's bytes, so the imports use other secrets.)
+ AegisFixtures.runImport(vault, ImporterFixtures.uri("Old", "a", AegisFixtures.SECRET_2) + " "
+ + ImporterFixtures.uri("Older", "b", AegisFixtures.SECRET_3), Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ // The same secret as a replaced entry is not a duplicate any more.
+ var report = AegisFixtures.runImport(vault, ImporterFixtures.uri("New", "c", AegisFixtures.SECRET_2),
+ Importer.MODE_REPLACE, false, ImporterFixtures.NOW);
+ var ids = vault.ids();
+ var newest = vault.header(ids[1]);
+ return report.removed == 2 && report.imported == 1 && report.duplicates == 0 && ids.size() == 2
+ && ids[0].equals(manualId) && newest != null && newest.name.equals("New");
+}
+
+// JSON cut in two parts: the first waits (sealed in storage), the second
+// completes the import and clears the buffer.
+(:test)
+function importerJoinsMultiPartJson(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ var store = vault.storage() as MemoryBackend;
+ var full = AegisFixtures.export(3, [AegisFixtures.totp("A", "a", AegisFixtures.SECRET_1),
+ AegisFixtures.totp("B", "b", AegisFixtures.SECRET_2), AegisFixtures.totp("C", "c", AegisFixtures.SECRET_3)], "");
+ var cut = full.length() / 2;
+ var part1 = AegisFixtures.runImport(vault, full.substring(0, cut) as String, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var waiting = part1.waitingChars == cut && ImportBuffer.hasPending(store) && vault.ids().size() == 0
+ && part1.summary().find("Waiting for more") != null;
+ // The buffer is sealed: the secret's Base32 text isn't readable in storage.
+ var chunk = store.raw("imp.0") as ByteArray;
+ var sealed = Bytes.toHex(chunk).find(Bytes.toHex(Bytes.fromUtf8(AegisFixtures.SECRET_1))) == null;
+ var part2 = AegisFixtures.runImport(vault, full.substring(cut, full.length()) as String, Importer.MODE_APPEND, false,
+ ImporterFixtures.NOW + 60);
+ return waiting && sealed && part2.imported == 3 && !ImportBuffer.hasPending(store);
+}
+
+(:test)
+function importerDiscardsExpiredParts(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ var full = AegisFixtures.export(3, [AegisFixtures.totp("A", "a", AegisFixtures.SECRET_1)], "");
+ var cut = full.length() / 2;
+ AegisFixtures.runImport(vault, full.substring(0, cut) as String, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var late = AegisFixtures.runImport(vault, full.substring(cut, full.length()) as String, Importer.MODE_APPEND, false,
+ ImporterFixtures.NOW + ImportBuffer.MAX_AGE_S + 1);
+ return late.failure != null && vault.ids().size() == 0 && !ImportBuffer.hasPending(vault.storage());
+}
+
+(:test)
+function importerExplainsUnusableText(logger as Test.Logger) as Boolean {
+ var cases = [
+ ["", "empty"],
+ ["hello world", "not recognised"],
+ ["otpauth-migration://offline?data=CjEKCkhlbGxvId6tvu8", "Google Authenticator"],
+ ["{\"version\": 1, \"header\": {\"slots\": [1]}, \"db\": \"abc\"}", "encrypted"],
+ ["{\"version\": 1, \"db\": [1,]}", "not valid JSON"]
+ ];
+ for (var i = 0; i < cases.size(); i++) {
+ var c = cases[i] as Array<String>;
+ var report = AegisFixtures.runImport(ImporterFixtures.vault(), c[0], Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var failure = report.failure;
+ if (failure == null || failure.find(c[1]) == null) {
+ logger.error("case " + i + ": " + failure);
+ return false;
+ }
+ }
+ return true;
+}
+
+// A store that fills up mid-import keeps the whole entries stored so far.
+(:test)
+function importerStopsCleanlyWhenTheStoreIsFull(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ var store = vault.storage() as MemoryBackend;
+ vault.add(StorageFixtures.totp("Existing"));
+ store.capacity = store.usedBytes() + 500;
+ var text = "";
+ for (var i = 0; i < 10; i++) {
+ text += ImporterFixtures.uri("E" + i, "a", Base32.encode(Bytes.fromUtf8("fake" + (200000 + i).toString()))) + " ";
+ }
+ var report = AegisFixtures.runImport(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var reopened = new Vault(store, new FakeTime());
+ logger.debug("full store: " + report.summary() + " " + report.details.toString());
+ return report.imported >= 1 && report.imported < 10 && AegisFixtures.hasDetail(report, "Stopped")
+ && reopened.ids().size() == 1 + report.imported;
+}
+
+// Fingerprints: same type and secret give the same 8 bytes, anything else
+// differs; they are in the header and survive a round trip.
+(:test)
+function importerFingerprintsIdentifySecrets(logger as Test.Logger) as Boolean {
+ var vault = ImporterFixtures.vault();
+ var a = StorageFixtures.totp("A");
+ var b = StorageFixtures.totp("B");
+ var c = StorageFixtures.hotp("C", 0l);
+ var d = StorageFixtures.totp("D");
+ d.secret = Bytes.fromUtf8("another secret");
+ var fa = vault.keyManager.fingerprint(a);
+ var id = vault.add(a);
+ var header = vault.header(id);
+ return fa.size() == 8 && Bytes.equal(fa, vault.keyManager.fingerprint(b))
+ && !Bytes.equal(fa, vault.keyManager.fingerprint(c)) && !Bytes.equal(fa, vault.keyManager.fingerprint(d))
+ && header != null && Bytes.equal(header.fingerprint as ByteArray, fa);
+}
+
+// 100 entries from one Aegis export: all stored, timed for PERFORMANCE.md.
+(:test)
+function importerHandlesOneHundredEntries(logger as Test.Logger) as Boolean {
+ var entries = [] as Array<String>;
+ for (var i = 0; i < 100; i++) {
+ // Distinct fake secrets: Base32 of a counter-based 10-byte value.
+ var raw = Bytes.fromUtf8("fake" + (100000 + i).toString());
+ entries.add(AegisFixtures.totp("Issuer " + i, "user" + i + "@example.com", Base32.encode(raw)));
+ }
+ var text = AegisFixtures.export(3, entries, "");
+ var vault = ImporterFixtures.vault();
+ var start = System.getTimer();
+ var job = new Importer(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
+ var steps = 0;
+ while (!job.step(1)) {
+ steps++;
+ }
+ var elapsed = System.getTimer() - start;
+ logger.debug("BENCH import_100_aegis ms=" + elapsed + " steps=" + steps + " json_bytes=" + text.length());
+ return job.report.imported == 100 && vault.ids().size() == 100;
+}