From 42f9331e693f5058fcfcd7aef5a92cabc04c20ee Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 01:51:05 +0200 Subject: test(import): cover parsers, importer and a seeded fuzz corpus; import 100 entries in the UI flow Co-Authored-By: Claude Opus 5.5 --- test/import/ImporterTest.mc | 208 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 test/import/ImporterTest.mc (limited to 'test/import/ImporterTest.mc') 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; + 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; + 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; +} -- cgit v1.2.3