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/AegisTest.mc | 128 ++++++++++++++++++++++++++ test/import/FuzzTest.mc | 107 ++++++++++++++++++++++ test/import/ImportSupport.mc | 76 ++++++++++++++++ test/import/ImporterTest.mc | 208 +++++++++++++++++++++++++++++++++++++++++++ test/import/JsonTest.mc | 128 ++++++++++++++++++++++++++ test/import/OtpAuthTest.mc | 99 ++++++++++++++++++++ 6 files changed, 746 insertions(+) create mode 100644 test/import/AegisTest.mc create mode 100644 test/import/FuzzTest.mc create mode 100644 test/import/ImportSupport.mc create mode 100644 test/import/ImporterTest.mc create mode 100644 test/import/JsonTest.mc create mode 100644 test/import/OtpAuthTest.mc (limited to 'test/import') diff --git a/test/import/AegisTest.mc b/test/import/AegisTest.mc new file mode 100644 index 0000000..eaad039 --- /dev/null +++ b/test/import/AegisTest.mc @@ -0,0 +1,128 @@ +// Author: cirakg & claude ai +// Summary: Tests for the Aegis reader: database versions 1-3, types, parameters, groups, icons, encryption and bad input. + +import Toybox.Lang; +import Toybox.Test; + +// Database version 3: types, algorithms, digits, periods, groups listed +// after the entries, favourites, icons and Unicode. +(:test) +function aegisReadsDatabaseVersion3(logger as Test.Logger) as Boolean { + var icon = ""; + for (var i = 0; i < 200; i++) { + icon += "iVBORw0KGgoAAAANSUhEUgAAA"; + } + var entries = [ + AegisFixtures.entry("totp", "GitHub", "alice", AegisFixtures.SECRET_1, "SHA1", 6, 30, + "\"icon\": \"" + icon + "\", \"icon_mime\": \"image/png\", \"groups\": [\"g-work\", \"g-other\"], "), + AegisFixtures.entry("totp", "Bänk €", "bob", AegisFixtures.SECRET_2, "SHA256", 8, 60, "\"favorite\": true, "), + "{\"type\": \"hotp\", \"name\": \"carol\", \"issuer\": \"VPN\", \"info\": {\"secret\": \"" + AegisFixtures.SECRET_3 + + "\", \"algo\": \"SHA512\", \"digits\": 7, \"period\": 30, \"counter\": 12}}", + AegisFixtures.entry("steam", "Steam", "dave", AegisFixtures.SECRET_1, "SHA1", 5, 30, ""), + AegisFixtures.entry("motp", "Old", "erin", AegisFixtures.SECRET_1, "MD5", 6, 10, ""), + AegisFixtures.entry("yandex", "Ya", "frank", AegisFixtures.SECRET_1, "SHA256", 8, 30, ""), + AegisFixtures.entry("totp", "Odd", "gina", AegisFixtures.SECRET_1, "SHA1", 6, 20, "\"groups\": [\"g-missing\"], "), + AegisFixtures.entry("totp", "Slow", "hal", AegisFixtures.SECRET_1, "SHA1", 6, 50, "") + ]; + var groups = "[{\"uuid\": \"g-work\", \"name\": \"Work\"}, {\"uuid\": \"g-other\", \"name\": \"Other\"}]"; + var reader = AegisFixtures.read(AegisFixtures.export(3, entries, groups)); + if (reader.error != null || reader.items.size() != 8) { + logger.error("error " + reader.error + ", items " + reader.items.size()); + return false; + } + var it = reader.items; + var github = it[0].entry; + var bank = it[1].entry; + var vpn = it[2].entry; + var steam = it[3].entry; + var odd = it[6].entry; + var slow = it[7].entry; + if (github == null || bank == null || vpn == null || steam == null || odd == null || slow == null) { + for (var i = 0; i < it.size(); i++) { + logger.error("item " + i + ": " + it[i].problem + " / " + it[i].skipped); + } + return false; + } + var checks = [ + github.name.equals("GitHub") && github.account.equals("alice"), + github.group.equals("Work"), + bank.name.equals("Bänk €") && bank.algorithm == Hashes.SHA256 && bank.digits == 8 && bank.period == 60, + bank.favorite, + vpn.type == Entry.OTP_HOTP && vpn.counter == 12l && vpn.digits == 7 && vpn.algorithm == Hashes.SHA512, + steam.type == Entry.OTP_STEAM, + it[4].skipped != null && it[5].skipped != null, + odd.group.equals("") && odd.period == 20 && slow.period == 50, + Bytes.equal(github.secret as ByteArray, Bytes.fromUtf8("12345678901234567890")) + ]; + for (var i = 0; i < checks.size(); i++) { + if (!(checks[i] as Boolean)) { + logger.error("check " + i + " failed"); + return false; + } + } + return true; +} + +// Database versions 1 and 2 name the group directly. +(:test) +function aegisReadsOlderDatabaseVersions(logger as Test.Logger) as Boolean { + for (var v = 1; v <= 2; v++) { + var reader = AegisFixtures.read(AegisFixtures.export(v, + [AegisFixtures.entry("totp", "Mail", "ann", AegisFixtures.SECRET_1, "SHA1", 6, 30, "\"group\": \"Private\", ")], "")); + var e = reader.items.size() == 1 ? reader.items[0].entry : null; + if (e == null || !e.group.equals("Private")) { + logger.error("database version " + v + " failed: " + reader.error); + return false; + } + } + return true; +} + +(:test) +function aegisRefusesEncryptedExports(logger as Test.Logger) as Boolean { + var slots = "{\"version\": 1, \"header\": {\"slots\": [{\"type\": 1}], \"params\": {}}, \"db\": \"c2VjcmV0\"}"; + var dbString = "{\"version\": 1, \"header\": {\"slots\": null}, \"db\": \"c2VjcmV0\"}"; + var a = AegisFixtures.read(slots); + var b = AegisFixtures.read(dbString); + return a.error != null && (a.error as String).find("encrypted") != null + && b.error != null && (b.error as String).find("encrypted") != null; +} + +(:test) +function aegisReportsBadEntriesAndDocuments(logger as Test.Logger) as Boolean { + var entries = [ + AegisFixtures.entry("totp", "Nine", "x", AegisFixtures.SECRET_1, "SHA1", 9, 30, ""), + "{\"type\": \"totp\", \"name\": \"dup\", \"info\": {\"secret\": \"" + AegisFixtures.SECRET_1 + "\", \"secret\": \"" + + AegisFixtures.SECRET_2 + "\", \"algo\": \"SHA1\", \"digits\": 6, \"period\": 30}}", + AegisFixtures.entry("totp", "Bad", "y", "NOT-BASE32!", "SHA1", 6, 30, ""), + "{\"type\": \"totp\", \"name\": \"z\", \"info\": {\"secret\": \"" + AegisFixtures.SECRET_1 + "\", \"digits\": 6.5}}" + ]; + var reader = AegisFixtures.read(AegisFixtures.export(3, entries, "")); + var it = reader.items; + if (reader.error != null || it.size() != 4) { + logger.error("error " + reader.error); + return false; + } + var problems = [it[0].problem, it[1].problem, it[2].problem, it[3].problem]; + var needles = ["9 digits", "twice", "Base32", "whole number"]; + for (var i = 0; i < 4; i++) { + var p = problems[i]; + if (p == null || (p as String).find(needles[i]) == null) { + logger.error("entry " + i + ": " + p); + return false; + } + } + var notJson = AegisFixtures.read("{\"version\": 1, \"db\": [1,]}"); + var notAegis = AegisFixtures.read("{\"hello\": \"world\"}"); + var newer = AegisFixtures.read("{\"version\": 2, \"db\": {\"version\": 3, \"entries\": []}}"); + var deep = "{\"version\": 1, \"x\": " + "[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]" + "}"; + return notJson.error != null && notAegis.error != null && newer.error != null + && (newer.error as String).find("version 2") != null && AegisFixtures.read(deep).error != null; +} + +(:test) +function aegisSaysWhenTheTextIsIncomplete(logger as Test.Logger) as Boolean { + var full = AegisFixtures.export(3, [AegisFixtures.totp("A", "a", AegisFixtures.SECRET_1), AegisFixtures.totp("B", "b", AegisFixtures.SECRET_2)], ""); + return AegisFixtures.status(full.substring(0, full.length() / 2) as String) == AegisReader.INCOMPLETE + && AegisFixtures.status(full) == AegisReader.DONE; +} diff --git a/test/import/FuzzTest.mc b/test/import/FuzzTest.mc new file mode 100644 index 0000000..7573ffa --- /dev/null +++ b/test/import/FuzzTest.mc @@ -0,0 +1,107 @@ +// Author: cirakg & claude ai +// Summary: Runs the seeded fuzz corpus through the tokenizer, the Aegis reader and the importer: always a result or a typed error, never a crash. +// +// README 17.3. Each input must end in a definite state within a bounded +// number of steps (no endless loop), and anything the importer stores must +// be a valid entry. Watchdog limits aren't enforced in unit tests; the +// per-step bounds are covered by the UI flow on the Forerunner 255S. + +import Toybox.Lang; +import Toybox.Test; + +(:test) +function fuzzTokenizerAlwaysTerminates(logger as Test.Logger) as Boolean { + var inputs = FuzzCorpus.JSON; + for (var i = 0; i < inputs.size(); i++) { + var data = Bytes.fromHex(inputs[i] as String); + var tok = new Json.Tokenizer(data); + var finished = false; + // Every token consumes at least one byte, so size + 2 calls suffice. + for (var n = 0; n < data.size() + 2; n++) { + var t = tok.next(); + if (t == Json.T_END || t == Json.T_INCOMPLETE || t == Json.T_ERROR) { + finished = true; + break; + } + } + if (!finished) { + logger.error("tokenizer didn't finish on input " + i); + return false; + } + } + return true; +} + +(:test) +function fuzzAegisReaderGivesDefiniteResults(logger as Test.Logger) as Boolean { + var inputs = FuzzCorpus.JSON; + var done = 0; + for (var i = 0; i < inputs.size(); i++) { + var data = Bytes.fromHex(inputs[i] as String); + var reader = new AegisReader(data); + var status = AegisReader.RUNNING; + for (var n = 0; n < data.size() + 2 && status == AegisReader.RUNNING; n++) { + status = reader.step(); + } + if (status == AegisReader.RUNNING || (status == AegisReader.FAILED && reader.error == null)) { + logger.error("input " + i + ": status " + status); + return false; + } + if (status == AegisReader.DONE) { + done++; + for (var k = 0; k < reader.items.size(); k++) { + var item = reader.items[k]; + var e = item.entry; + if (e == null ? item.problem == null && item.skipped == null : e.validate() != null) { + logger.error("input " + i + " item " + k + " is neither valid nor explained"); + return false; + } + } + } + } + logger.debug("FUZZ aegis inputs=" + inputs.size() + " complete=" + done); + return done > 0; +} + +// The whole importer on every input that is valid text: a report that +// explains everything, and only valid entries in the vault. +(:test) +function fuzzImporterNeverStoresInvalidEntries(logger as Test.Logger) as Boolean { + var all = [] as Array; + all.addAll(FuzzCorpus.JSON as Array); + all.addAll(FuzzCorpus.URIS as Array); + var imported = 0; + for (var i = 0; i < all.size(); i++) { + var text = Bytes.toUtf8(Bytes.fromHex(all[i])); + if (text == null) { + continue; + } + var vault = new Vault(new MemoryBackend(), new FakeTime()); + var job = new Importer(vault, text, Importer.MODE_APPEND, false, 1800000000l); + var finished = false; + for (var n = 0; n < 5000; n++) { + if (job.step(1)) { + finished = true; + break; + } + } + var r = job.report; + if (!finished || (r.failure == null && r.waitingChars < 0 && r.summary().find("Imported") != 0)) { + logger.error("input " + i + " didn't finish cleanly: " + r.summary()); + return false; + } + var ids = vault.ids(); + for (var k = 0; k < ids.size(); k++) { + var e = vault.open(ids[k]); + var problem = e.validate(); + e.wipeSecrets(); + if (problem != null) { + logger.error("input " + i + " stored an invalid entry: " + problem); + return false; + } + } + imported += ids.size(); + } + logger.debug("FUZZ importer inputs=" + all.size() + " entries_stored=" + imported); + return imported > 0; +} diff --git a/test/import/ImportSupport.mc b/test/import/ImportSupport.mc new file mode 100644 index 0000000..ba891a7 --- /dev/null +++ b/test/import/ImportSupport.mc @@ -0,0 +1,76 @@ +// Author: cirakg & claude ai +// Summary: Builders for fake Aegis exports and otpauth text used by the import tests (all secrets are RFC test keys). + +import Toybox.Lang; +import Toybox.Test; + +(:test) +module AegisFixtures { + + const SECRET_1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // lint: allow-literal (RFC 4226 key "12345678901234567890") + const SECRET_2 = "JBSWY3DPEHPK3PXP"; // lint: allow-literal (the classic "Hello!" demo key) + const SECRET_3 = "MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U"; // lint: allow-literal (Base32 of "abcdefghijklmnopqrst") + + // One Aegis entry. extra is inserted verbatim (other fields, icon...). + function entry(type as String, issuer as String, name as String, secret as String, algo as String, digits as Number, + period as Number, extra as String) as String { + return "{\"type\": \"" + type + "\", \"uuid\": \"0000\", \"name\": \"" + name + "\", \"issuer\": \"" + issuer + + "\", \"note\": \"private note\", " + extra + + "\"info\": {\"secret\": \"" + secret + "\", \"algo\": \"" + algo + "\", \"digits\": " + digits + + ", \"period\": " + period + "}}"; + } + + function totp(issuer as String, name as String, secret as String) as String { + return entry("totp", issuer, name, secret, "SHA1", 6, 30, ""); + } + + // A whole unencrypted export (vault version 1) of the given database + // version, with entries joined by commas and groups after the entries. + function export(dbVersion as Number, entries as Array, groups as String) as String { + var joined = ""; + for (var i = 0; i < entries.size(); i++) { + joined += (i > 0 ? ",\n" : "") + entries[i]; + } + return "{\"version\": 1, \"header\": {\"slots\": null, \"params\": null}, \"db\": {\"version\": " + dbVersion + + ", \"entries\": [\n" + joined + "\n]" + (groups.length() > 0 ? ", \"groups\": " + groups : "") + "}}"; + } + + // Runs the reader to the end (or a non-running status). + function read(text as String) as AegisReader { + var reader = new AegisReader(Bytes.fromUtf8(text)); + for (var i = 0; i < 10000; i++) { + if (reader.step() != AegisReader.RUNNING) { + return reader; + } + } + return reader; + } + + function status(text as String) as Number { + var reader = new AegisReader(Bytes.fromUtf8(text)); + for (var i = 0; i < 10000; i++) { + var s = reader.step(); + if (s != AegisReader.RUNNING) { + return s; + } + } + return -1; + } + + // Runs an import to completion on vault and returns its report. + function runImport(vault as Vault, text as String, mode as Number, locked as Boolean, now as Long) as ImportReport { + var job = new Importer(vault, text, mode, locked, now); + Jobs.runToCompletion(job, 1); + return job.report; + } + + // Some detail line contains needle. + function hasDetail(report as ImportReport, needle as String) as Boolean { + for (var i = 0; i < report.details.size(); i++) { + if (report.details[i].find(needle) != null) { + return true; + } + } + return false; + } +} 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; +} diff --git a/test/import/JsonTest.mc b/test/import/JsonTest.mc new file mode 100644 index 0000000..c98aaa4 --- /dev/null +++ b/test/import/JsonTest.mc @@ -0,0 +1,128 @@ +// Author: cirakg & claude ai +// Summary: Tests for the streaming JSON tokenizer: tokens, escapes, numbers, errors, incomplete input, limits. + +import Toybox.Lang; +import Toybox.Test; + +(:test) +module JsonFixtures { + + // All tokens of text, as "kind:value" strings (values for keys, strings + // and numbers), ending with the final token. + function tokens(text as String) as Array { + var tok = new Json.Tokenizer(Bytes.fromUtf8(text)); + var out = [] as Array; + for (var i = 0; i < 200; i++) { + var t = tok.next(); + if (t == Json.T_KEY) { + out.add("K:" + tok.text); + } else if (t == Json.T_STRING) { + out.add("S:" + tok.text); + } else if (t == Json.T_NUMBER) { + out.add(tok.numberIsInteger ? "N:" + tok.number : "N:float"); + } else { + out.add("T" + t); + } + if (t == Json.T_END || t == Json.T_INCOMPLETE || t == Json.T_ERROR) { + return out; + } + } + return out; + } + + function last(text as String) as Number { + var tok = new Json.Tokenizer(Bytes.fromUtf8(text)); + for (var i = 0; i < 500; i++) { + var t = tok.next(); + if (t == Json.T_END || t == Json.T_INCOMPLETE || t == Json.T_ERROR) { + return t; + } + } + return -1; + } +} + +(:test) +function jsonTokenizesAllValueKinds(logger as Test.Logger) as Boolean { + var got = JsonFixtures.tokens("{\"a\": [1, -20, true, false, null, \"x\\\"y\\n\\u00e9\\ud83d\\ude00\"], \"b\": {}}"); + var expected = ["T1", "K:a", "T3", "N:1", "N:-20", "T8", "T9", "T10", "S:x\"y\né😀", "T4", "K:b", "T1", "T2", "T2", "T0"]; + if (!got.toString().equals(expected.toString())) { + logger.error("got " + got.toString()); + return false; + } + return true; +} + +(:test) +function jsonNumbers(logger as Test.Logger) as Boolean { + var got = JsonFixtures.tokens("[0, 1.5, 1e3, -0, 123456789012345678901, 999999999999999999]"); + var expected = ["T3", "N:0", "N:float", "N:float", "N:0", "N:float", "N:999999999999999999", "T4", "T0"]; + if (!got.toString().equals(expected.toString())) { + logger.error("got " + got.toString()); + return false; + } + return true; +} + +(:test) +function jsonRejectsInvalidDocuments(logger as Test.Logger) as Boolean { + var bad = [ + "[1,]", "{\"a\" 1}", "[01]", "[\"\\x\"]", "[\"\\ud800\"]", "[\"\\udc00\"]", "{a: 1}", "[1] [2]", + "[\"\\u0000\"]", "[1.]", "[-]", "[tru]", "{\"a\":1,}", "[\"a\tb\"]", "[1 2]", "]" + ]; + for (var i = 0; i < bad.size(); i++) { + if (JsonFixtures.last(bad[i] as String) != Json.T_ERROR) { + logger.error("accepted invalid JSON " + bad[i]); + return false; + } + } + return true; +} + +// Input that stops inside the document is "incomplete", not an error, so +// a multi-part import can wait for the rest. +(:test) +function jsonReportsIncompleteInput(logger as Test.Logger) as Boolean { + var partial = ["{", "{\"a\"", "{\"a\":", "{\"a\": \"abc", "{\"a\": 12", "[tr", "[1, {\"b\": [", "{\"a\": \"x\\"]; + for (var i = 0; i < partial.size(); i++) { + if (JsonFixtures.last(partial[i] as String) != Json.T_INCOMPLETE) { + logger.error("not incomplete: " + partial[i]); + return false; + } + } + return JsonFixtures.last("") == Json.T_INCOMPLETE && JsonFixtures.last(" 7 ") == Json.T_END; +} + +(:test) +function jsonEnforcesTheDepthLimit(logger as Test.Logger) as Boolean { + var ok = ""; + var tooDeep = ""; + for (var i = 0; i < Json.MAX_DEPTH; i++) { + ok += "["; + } + tooDeep = ok + "["; + var close = ""; + for (var i = 0; i < Json.MAX_DEPTH; i++) { + close += "]"; + } + return JsonFixtures.last(ok + close) == Json.T_END && JsonFixtures.last(tooDeep + close + "]") == Json.T_ERROR; +} + +// Long strings (icons) are skipped without decoding, including escaped +// quotes inside them; long keys are errors. +(:test) +function jsonSkipsLongStrings(logger as Test.Logger) as Boolean { + var big = ""; + for (var i = 0; i < 300; i++) { + big += "ABCDEFGHIJ"; + } + var withQuote = big + "\\\"" + big; + var got = JsonFixtures.tokens("{\"icon\": \"" + big + "\", \"q\": \"" + withQuote + "\", \"n\": \"ok\"}"); + var expected = ["T1", "K:icon", "T11", "K:q", "T11", "K:n", "S:ok", "T2", "T0"]; + if (!got.toString().equals(expected.toString())) { + logger.error("got " + got.toString()); + return false; + } + return JsonFixtures.last("{\"" + big + "\": 1}") == Json.T_ERROR + && JsonFixtures.last("{\"icon\": \"" + big) == Json.T_INCOMPLETE; +} diff --git a/test/import/OtpAuthTest.mc b/test/import/OtpAuthTest.mc new file mode 100644 index 0000000..67f43f5 --- /dev/null +++ b/test/import/OtpAuthTest.mc @@ -0,0 +1,99 @@ +// Author: cirakg & claude ai +// Summary: Tests for otpauth:// parsing: splitting, labels, parameters, Steam, separators and every validation error. + +import Toybox.Lang; +import Toybox.Test; + +(:test) +module OtpAuthFixtures { + + // RFC 4226 test key "12345678901234567890" in Base32. + const SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // lint: allow-literal (RFC test key) + + function parse(uri as String) as ImportItem { + return OtpAuth.parse(uri, 1); + } + + function entry(logger as Test.Logger, uri as String) as Entry? { + var item = parse(uri); + if (item.entry == null) { + logger.error("no entry for " + uri + ": " + item.problem + " / " + item.skipped); + } + return item.entry; + } +} + +(:test) +function otpauthSplitsRunTogetherUris(logger as Test.Logger) as Boolean { + var s = OtpAuthFixtures.SECRET; + var uris = OtpAuth.splitUris("junk otpauth://totp/A?secret=" + s + " \n OTPAUTH://totp/B?secret=" + s + + "otpauth://hotp/C?secret=" + s + "&counter=1\n"); + return uris.size() == 3 && (uris[0] as String).find("totp/A") != null && (uris[1] as String).find("totp/B") != null + && (uris[2] as String).find("counter=1") != null && (uris[2] as String).find("\n") == null; +} + +(:test) +function otpauthLabelsAndDefaults(logger as Test.Logger) as Boolean { + var e = OtpAuthFixtures.entry(logger, "otpauth://totp/ACME%20Co:john%40example.com?secret=" + OtpAuthFixtures.SECRET); + var bare = OtpAuthFixtures.entry(logger, "otpauth://totp/alice?secret=" + OtpAuthFixtures.SECRET.toLower()); + if (e == null || bare == null) { + return false; + } + return e.name.equals("ACME Co") && e.account.equals("john@example.com") && e.algorithm == Hashes.SHA1 + && e.digits == 6 && e.period == 30 && e.type == Entry.OTP_TOTP && e.source == Entry.SOURCE_IMPORT + && Bytes.equal(e.secret as ByteArray, Bytes.fromUtf8("12345678901234567890")) + && bare.name.equals("") && bare.account.equals("alice"); +} + +(:test) +function otpauthParametersAndSeparators(logger as Test.Logger) as Boolean { + var s = OtpAuthFixtures.SECRET; + var e = OtpAuthFixtures.entry(logger, + "otpauth://totp/Label:bob?SECRET=" + s + ";Algorithm=sha512;digits=8&period=60&image=https%3A%2F%2Fx&issuer=Big+Bank"); + var item = OtpAuthFixtures.parse("otpauth://totp/Old:bob?secret=" + s + "&issuer=New"); + if (e == null) { + return false; + } + return e.name.equals("Big Bank") && e.algorithm == Hashes.SHA512 && e.digits == 8 && e.period == 60 + && item.entry != null && (item.entry as Entry).name.equals("New") && item.note != null + && e.account.equals("bob"); +} + +(:test) +function otpauthHotpAndSteam(logger as Test.Logger) as Boolean { + var s = OtpAuthFixtures.SECRET; + var h = OtpAuthFixtures.entry(logger, "otpauth://hotp/VPN:dave?secret=" + s + "&counter=42"); + var noCounter = OtpAuthFixtures.parse("otpauth://hotp/VPN:dave?secret=" + s); + var steam = OtpAuthFixtures.entry(logger, "otpauth://steam/Steam:erin?secret=" + s); + var encoder = OtpAuthFixtures.entry(logger, "otpauth://totp/Steam:erin?secret=" + s + "&encoder=steam&digits=5"); + return h != null && h.type == Entry.OTP_HOTP && h.counter == 42l + && noCounter.problem != null && (noCounter.problem as String).find("counter") != null + && steam != null && steam.type == Entry.OTP_STEAM && encoder != null && encoder.type == Entry.OTP_STEAM; +} + +(:test) +function otpauthReportsEveryProblem(logger as Test.Logger) as Boolean { + var s = OtpAuthFixtures.SECRET; + var cases = [ + ["otpauth://totp/A:b", "secret missing"], + ["otpauth://totp/A:b?secret=" + s + "&digits=9", "9 digits"], + ["otpauth://totp/A:b?secret=" + s + "&digits=x", "not a whole number"], + ["otpauth://totp/A:b?secret=" + s + "&period=0", "period 0"], + ["otpauth://totp/A:b?secret=" + s + "&algorithm=MD5", "algorithm"], + ["otpauth://totp/A:b?secret=ABC1DEFG", "position 4"], + ["otpauth://totp/A%2:b?secret=" + s, "% escape"], + ["otpauth://totp/?secret=" + s, "issuer or an account"], + ["otpauth://totp", "no '/'"] + ]; + for (var i = 0; i < cases.size(); i++) { + var c = cases[i] as Array; + var item = OtpAuthFixtures.parse(c[0]); + var problem = item.problem; + if (item.entry != null || problem == null || problem.find(c[1]) == null) { + logger.error("case " + i + ": problem " + problem); + return false; + } + } + var motp = OtpAuthFixtures.parse("otpauth://motp/A:b?secret=" + s); + return motp.skipped != null && motp.entry == null; +} -- cgit v1.2.3