// 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, packed items. 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; } // Packed items (M8 memory) keep every field, resolve the group when // unpacked, and leave no second copy of the secret. (:test) function importItemsPackAndUnpack(logger as Test.Logger) as Boolean { var e = new Entry(); e.type = Entry.OTP_HOTP; e.name = "Bänk €"; e.account = "bob"; e.favorite = true; e.algorithm = Hashes.SHA512; e.digits = 8; e.period = 60; e.counter = 5000000000l; var secret = [1, 2, 3, 4, 5]b; e.secret = secret; var item = new ImportItem(7); item.label = e.name; item.note = "issuer differs"; item.groupRef = "uuid:g-1"; item.entry = e; var packed = item.pack(); var wiped = secret[0] == 0 && secret[4] == 0 && e.secret == null; var back = ImportItem.unpack(packed, {"g-1" => "Work"} as Dictionary); var b = back.entry; if (!wiped || b == null) { logger.error("wiped " + wiped + ", entry " + b); return false; } var fieldsOk = back.index == 7 && back.label.equals("Bänk €") && "issuer differs".equals(back.note) && back.problem == null && b.kind == Entry.KIND_OTP && b.source == Entry.SOURCE_IMPORT && b.type == Entry.OTP_HOTP && b.name.equals("Bänk €") && b.account.equals("bob") && b.favorite && b.algorithm == Hashes.SHA512 && b.digits == 8 && b.period == 60 && b.counter == 5000000000l && Bytes.equal(b.secret as ByteArray, [1, 2, 3, 4, 5]b) && b.group.equals("Work") && b.validate() == null; var bad = new ImportItem(3); bad.label = "x"; bad.problem = "secret missing"; bad.groupRef = "name:Private"; var badBack = ImportItem.unpack(bad.pack(), {} as Dictionary); var named = new ImportItem(4); named.groupRef = "name: Private "; var namedEntry = new Entry(); namedEntry.name = "Mail"; namedEntry.secret = [9]b; named.entry = namedEntry; var namedBack = ImportItem.unpack(named.pack(), {} as Dictionary).entry; var missing = new ImportItem(5); missing.groupRef = "uuid:nope"; var missingEntry = new Entry(); missingEntry.name = "Mail"; missingEntry.secret = [9]b; missing.entry = missingEntry; var missingBack = ImportItem.unpack(missing.pack(), {} as Dictionary).entry; return fieldsOk && badBack.entry == null && "secret missing".equals(badBack.problem) && badBack.index == 3 && namedBack != null && namedBack.group.equals("Private") && missingBack != null && missingBack.group.equals(""); }