diff options
57 files changed, 1061 insertions, 6 deletions
diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc index 31d4021..463780c 100644 --- a/source/app/Fixtures.mc +++ b/source/app/Fixtures.mc @@ -1,5 +1,5 @@ // Author: cirakg & claude ai -// Summary: Debug-only fake entries (and, in UI tests, a fake import via the settings form); release builds get empty stubs. +// Summary: Debug-only fake entries (and, in UI tests, a fake import and filled-in forms via settings); release builds get empty stubs. // // All data is fabricated: the secrets are the RFC 4226/6238 test keys and the // names are made up. The UI-test build (source/util/TestMode.mc) wipes the @@ -67,9 +67,39 @@ module Fixtures { vault.lock(); if (TestMode.ON) { startImport(); + sendForms(vault); } } + // UI-test build: the other forms, filled in as the phone would (README + // 16, M7 end-to-end): Add OTP (a TOTP whose code the screenshot shows), + // Add code (a Code 128 card whose screenshot must decode) and Manage + // entry (Bank's group renamed: Bank is locked, so the change waits for + // the PIN, which the UI flow confirms from the menu). They are applied + // after the import, in README 10.3 order, so the new entries end the + // list. + function sendForms(vault as Vault) as Void { + Application.Properties.setValue("otpType", Entry.OTP_TOTP); + Application.Properties.setValue("otpIssuer", "Mailbox"); + Application.Properties.setValue("otpAccount", "fixture@example.com"); + Application.Properties.setValue("otpSecret", "JBSW Y3DP EHPK 3PXP"); // lint: allow-literal (the classic "Hello!" demo key) + Application.Properties.setValue("otpAlgorithm", Hashes.SHA1); + Application.Properties.setValue("otpDigits", 6); + Application.Properties.setValue("otpPeriod", 30); + Application.Properties.setValue("otpGroup", "Personal"); + Application.Properties.setValue("otpAdd", true); + Application.Properties.setValue("codeName", "Locker"); + Application.Properties.setValue("codeSymbology", Codes.CODE128); + Application.Properties.setValue("codePayload", "FAKE-0042"); + Application.Properties.setValue("codeGroup", "Personal"); + Application.Properties.setValue("codeAdd", true); + var ids = vault.ids(); + Application.Properties.setValue("mgId", ids[2]); // Bank + Application.Properties.setValue("mgAction", ManageAction.EDIT); + Application.Properties.setValue("mgGroup", "Money"); + Application.Properties.setValue("mgApply", true); + } + // UI-test build: an Aegis import of 100 entries through the settings // form, the same path as the phone: 97 new, one duplicate of GitHub, one // error and one skipped type. The results screen opens when it's done. diff --git a/test/golden/fenix7/about.png b/test/golden/fenix7/about.png Binary files differindex 9df582a..0dbbb80 100644 --- a/test/golden/fenix7/about.png +++ b/test/golden/fenix7/about.png diff --git a/test/golden/fenix7/code_form.png b/test/golden/fenix7/code_form.png Binary files differnew file mode 100644 index 0000000..2f6de04 --- /dev/null +++ b/test/golden/fenix7/code_form.png diff --git a/test/golden/fenix7/import_results.png b/test/golden/fenix7/import_results.png Binary files differindex 34c1cf7..9a14885 100644 --- a/test/golden/fenix7/import_results.png +++ b/test/golden/fenix7/import_results.png diff --git a/test/golden/fenix7/list.png b/test/golden/fenix7/list.png Binary files differindex ffcc3ff..b456505 100644 --- a/test/golden/fenix7/list.png +++ b/test/golden/fenix7/list.png diff --git a/test/golden/fenix7/menu.png b/test/golden/fenix7/menu.png Binary files differindex 3ceb597..b6b7386 100644 --- a/test/golden/fenix7/menu.png +++ b/test/golden/fenix7/menu.png diff --git a/test/golden/fenix7/otp_form.png b/test/golden/fenix7/otp_form.png Binary files differnew file mode 100644 index 0000000..7990539 --- /dev/null +++ b/test/golden/fenix7/otp_form.png diff --git a/test/golden/fenix7/queue.png b/test/golden/fenix7/queue.png Binary files differnew file mode 100644 index 0000000..6422077 --- /dev/null +++ b/test/golden/fenix7/queue.png diff --git a/test/golden/fenix7/queue_results.png b/test/golden/fenix7/queue_results.png Binary files differnew file mode 100644 index 0000000..478dfd9 --- /dev/null +++ b/test/golden/fenix7/queue_results.png diff --git a/test/golden/fr255s/about.png b/test/golden/fr255s/about.png Binary files differindex 0198345..51dc136 100644 --- a/test/golden/fr255s/about.png +++ b/test/golden/fr255s/about.png diff --git a/test/golden/fr255s/code_form.png b/test/golden/fr255s/code_form.png Binary files differnew file mode 100644 index 0000000..f218b4d --- /dev/null +++ b/test/golden/fr255s/code_form.png diff --git a/test/golden/fr255s/import_results.png b/test/golden/fr255s/import_results.png Binary files differindex d2cdc19..6f500d5 100644 --- a/test/golden/fr255s/import_results.png +++ b/test/golden/fr255s/import_results.png diff --git a/test/golden/fr255s/list.png b/test/golden/fr255s/list.png Binary files differindex df30245..c373315 100644 --- a/test/golden/fr255s/list.png +++ b/test/golden/fr255s/list.png diff --git a/test/golden/fr255s/menu.png b/test/golden/fr255s/menu.png Binary files differindex 047504c..fb804e5 100644 --- a/test/golden/fr255s/menu.png +++ b/test/golden/fr255s/menu.png diff --git a/test/golden/fr255s/otp_form.png b/test/golden/fr255s/otp_form.png Binary files differnew file mode 100644 index 0000000..3bba7b6 --- /dev/null +++ b/test/golden/fr255s/otp_form.png diff --git a/test/golden/fr255s/queue.png b/test/golden/fr255s/queue.png Binary files differnew file mode 100644 index 0000000..2101705 --- /dev/null +++ b/test/golden/fr255s/queue.png diff --git a/test/golden/fr255s/queue_results.png b/test/golden/fr255s/queue_results.png Binary files differnew file mode 100644 index 0000000..c447958 --- /dev/null +++ b/test/golden/fr255s/queue_results.png diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png Binary files differindex 7d0b222..1765b65 100644 --- a/test/golden/fr70/about.png +++ b/test/golden/fr70/about.png diff --git a/test/golden/fr70/code_form.png b/test/golden/fr70/code_form.png Binary files differnew file mode 100644 index 0000000..9c0d502 --- /dev/null +++ b/test/golden/fr70/code_form.png diff --git a/test/golden/fr70/import_results.png b/test/golden/fr70/import_results.png Binary files differindex 2da3dcc..f3e3afa 100644 --- a/test/golden/fr70/import_results.png +++ b/test/golden/fr70/import_results.png diff --git a/test/golden/fr70/list.png b/test/golden/fr70/list.png Binary files differindex eaa657e..ace90e9 100644 --- a/test/golden/fr70/list.png +++ b/test/golden/fr70/list.png diff --git a/test/golden/fr70/menu.png b/test/golden/fr70/menu.png Binary files differindex b35e078..f4fdc4b 100644 --- a/test/golden/fr70/menu.png +++ b/test/golden/fr70/menu.png diff --git a/test/golden/fr70/otp_form.png b/test/golden/fr70/otp_form.png Binary files differnew file mode 100644 index 0000000..0156b6b --- /dev/null +++ b/test/golden/fr70/otp_form.png diff --git a/test/golden/fr70/queue.png b/test/golden/fr70/queue.png Binary files differnew file mode 100644 index 0000000..ccd0747 --- /dev/null +++ b/test/golden/fr70/queue.png diff --git a/test/golden/fr70/queue_results.png b/test/golden/fr70/queue_results.png Binary files differnew file mode 100644 index 0000000..9bd2136 --- /dev/null +++ b/test/golden/fr70/queue_results.png diff --git a/test/golden/fr965/about.png b/test/golden/fr965/about.png Binary files differindex 211487d..f48bea7 100644 --- a/test/golden/fr965/about.png +++ b/test/golden/fr965/about.png diff --git a/test/golden/fr965/code_form.png b/test/golden/fr965/code_form.png Binary files differnew file mode 100644 index 0000000..ed6108c --- /dev/null +++ b/test/golden/fr965/code_form.png diff --git a/test/golden/fr965/import_results.png b/test/golden/fr965/import_results.png Binary files differindex c827d20..4d57962 100644 --- a/test/golden/fr965/import_results.png +++ b/test/golden/fr965/import_results.png diff --git a/test/golden/fr965/list.png b/test/golden/fr965/list.png Binary files differindex d0904b2..10d8016 100644 --- a/test/golden/fr965/list.png +++ b/test/golden/fr965/list.png diff --git a/test/golden/fr965/menu.png b/test/golden/fr965/menu.png Binary files differindex ba1149d..71ba71e 100644 --- a/test/golden/fr965/menu.png +++ b/test/golden/fr965/menu.png diff --git a/test/golden/fr965/otp_form.png b/test/golden/fr965/otp_form.png Binary files differnew file mode 100644 index 0000000..41ff390 --- /dev/null +++ b/test/golden/fr965/otp_form.png diff --git a/test/golden/fr965/queue.png b/test/golden/fr965/queue.png Binary files differnew file mode 100644 index 0000000..97773db --- /dev/null +++ b/test/golden/fr965/queue.png diff --git a/test/golden/fr965/queue_results.png b/test/golden/fr965/queue_results.png Binary files differnew file mode 100644 index 0000000..9f41421 --- /dev/null +++ b/test/golden/fr965/queue_results.png diff --git a/test/golden/instinct3amoled45mm/about.png b/test/golden/instinct3amoled45mm/about.png Binary files differindex 72fde99..bbad66e 100644 --- a/test/golden/instinct3amoled45mm/about.png +++ b/test/golden/instinct3amoled45mm/about.png diff --git a/test/golden/instinct3amoled45mm/code_form.png b/test/golden/instinct3amoled45mm/code_form.png Binary files differnew file mode 100644 index 0000000..0c3757c --- /dev/null +++ b/test/golden/instinct3amoled45mm/code_form.png diff --git a/test/golden/instinct3amoled45mm/import_results.png b/test/golden/instinct3amoled45mm/import_results.png Binary files differindex 0dbf559..b45e972 100644 --- a/test/golden/instinct3amoled45mm/import_results.png +++ b/test/golden/instinct3amoled45mm/import_results.png diff --git a/test/golden/instinct3amoled45mm/list.png b/test/golden/instinct3amoled45mm/list.png Binary files differindex d4b9323..90535b3 100644 --- a/test/golden/instinct3amoled45mm/list.png +++ b/test/golden/instinct3amoled45mm/list.png diff --git a/test/golden/instinct3amoled45mm/menu.png b/test/golden/instinct3amoled45mm/menu.png Binary files differindex 6c6f81e..0fab0ff 100644 --- a/test/golden/instinct3amoled45mm/menu.png +++ b/test/golden/instinct3amoled45mm/menu.png diff --git a/test/golden/instinct3amoled45mm/otp_form.png b/test/golden/instinct3amoled45mm/otp_form.png Binary files differnew file mode 100644 index 0000000..14363ff --- /dev/null +++ b/test/golden/instinct3amoled45mm/otp_form.png diff --git a/test/golden/instinct3amoled45mm/queue.png b/test/golden/instinct3amoled45mm/queue.png Binary files differnew file mode 100644 index 0000000..0b3eef0 --- /dev/null +++ b/test/golden/instinct3amoled45mm/queue.png diff --git a/test/golden/instinct3amoled45mm/queue_results.png b/test/golden/instinct3amoled45mm/queue_results.png Binary files differnew file mode 100644 index 0000000..15a4159 --- /dev/null +++ b/test/golden/instinct3amoled45mm/queue_results.png diff --git a/test/golden/venusq2/code_form.png b/test/golden/venusq2/code_form.png Binary files differnew file mode 100644 index 0000000..9d441d2 --- /dev/null +++ b/test/golden/venusq2/code_form.png diff --git a/test/golden/venusq2/import_results.png b/test/golden/venusq2/import_results.png Binary files differindex 647ca21..d908572 100644 --- a/test/golden/venusq2/import_results.png +++ b/test/golden/venusq2/import_results.png diff --git a/test/golden/venusq2/list.png b/test/golden/venusq2/list.png Binary files differindex f778d10..f0be24f 100644 --- a/test/golden/venusq2/list.png +++ b/test/golden/venusq2/list.png diff --git a/test/golden/venusq2/menu.png b/test/golden/venusq2/menu.png Binary files differindex 6393819..b0e82c1 100644 --- a/test/golden/venusq2/menu.png +++ b/test/golden/venusq2/menu.png diff --git a/test/golden/venusq2/otp_form.png b/test/golden/venusq2/otp_form.png Binary files differnew file mode 100644 index 0000000..433eff2 --- /dev/null +++ b/test/golden/venusq2/otp_form.png diff --git a/test/golden/venux1/code_form.png b/test/golden/venux1/code_form.png Binary files differnew file mode 100644 index 0000000..868919c --- /dev/null +++ b/test/golden/venux1/code_form.png diff --git a/test/golden/venux1/import_results.png b/test/golden/venux1/import_results.png Binary files differindex 1d1251d..3f27b17 100644 --- a/test/golden/venux1/import_results.png +++ b/test/golden/venux1/import_results.png diff --git a/test/golden/venux1/list.png b/test/golden/venux1/list.png Binary files differindex 8ba6f1f..95aa309 100644 --- a/test/golden/venux1/list.png +++ b/test/golden/venux1/list.png diff --git a/test/golden/venux1/menu.png b/test/golden/venux1/menu.png Binary files differindex 6930419..5a81418 100644 --- a/test/golden/venux1/menu.png +++ b/test/golden/venux1/menu.png diff --git a/test/golden/venux1/otp_form.png b/test/golden/venux1/otp_form.png Binary files differnew file mode 100644 index 0000000..880fa66 --- /dev/null +++ b/test/golden/venux1/otp_form.png diff --git a/test/settings/CodeFormTest.mc b/test/settings/CodeFormTest.mc new file mode 100644 index 0000000..9d51797 --- /dev/null +++ b/test/settings/CodeFormTest.mc @@ -0,0 +1,176 @@ +// Author: cirakg & claude ai +// Summary: Tests for the Add code form: a valid and invalid payload for every symbology, options, notes, size warnings and the cache. + +import Toybox.Lang; +import Toybox.Test; + +(:test) +module CodeFormCases { + + // [symbology, payload, part of the status] for contents every + // symbology accepts (all fake). + const VALID = [ + [Codes.QR, "https://example.com/card/0042", ""], + [Codes.CODE128, "Member-0042", ""], + [Codes.CODE39, "CARD 0042", ""], + [Codes.EAN13, " 590123412345 ", "check digit 7 added"], + [Codes.EAN8, "96385074", ""], + [Codes.UPCA, "03600029145", "check digit 2 added"], + [Codes.UPCE, "0123456", ""], + [Codes.ITF, "123456789", ""], // with its check digit: 10 digits + [Codes.CODABAR, "40156", "start and stop characters A...A added"], + [Codes.DATAMATRIX, "PARCEL-FAKE-0042", ""], + [Codes.PDF417, "Boarding pass FAKE 0042", ""], + [Codes.AZTEC, "RAIL-FAKE-0042", ""] + ]; + + // [symbology, payload, check-digit mode, expected status part]. + function invalid() as Array<Array> { + return [ + [Codes.EAN13, "5901234123450", Codes.CHECK_ADD_VERIFY, + "Not added: EAN-13: check digit should be 7, got 0. Choose 'Fix if wrong' to correct it"], + [Codes.EAN13, "59012341234", Codes.CHECK_ADD_VERIFY, "EAN-13 needs 12 or 13 digits, got 11"], + [Codes.EAN13, "590123412345", Codes.CHECK_OFF, "EAN-13 with the check digit off needs all 13 digits"], + [Codes.EAN8, "12A4567", Codes.CHECK_ADD_VERIFY, "EAN-8 takes digits only"], + [Codes.UPCA, "123", Codes.CHECK_ADD_VERIFY, "UPC-A needs 11 or 12 digits, got 3"], + [Codes.UPCE, "2123456", Codes.CHECK_ADD_VERIFY, "UPC-E: the number system must be 0 or 1, got 2"], + [Codes.CODE128, "Café", Codes.CHECK_ADD_VERIFY, "Code 128: 'é' (position 4) is not a printable ASCII character"], + [Codes.CODE39, "card", Codes.CHECK_ADD_VERIFY, "Code 39: 'c' (position 1)"], + [Codes.ITF, "123", Codes.CHECK_OFF, "ITF needs an even number of digits: add a leading 0"], + [Codes.CODABAR, "A12X3B", Codes.CHECK_ADD_VERIFY, "Codabar: 'X' isn't allowed there"], + [Codes.QR, SettingsFixtures.repeat("a", 2400), Codes.CHECK_ADD_VERIFY, "QR: too long for a QR code"], + [Codes.DATAMATRIX, SettingsFixtures.repeat("a", 1600), Codes.CHECK_ADD_VERIFY, "too long for Data Matrix"], + [Codes.PDF417, SettingsFixtures.repeat("a1", 1500), Codes.CHECK_ADD_VERIFY, "PDF417: too"], + [Codes.AZTEC, SettingsFixtures.repeat("é", 2000), Codes.CHECK_ADD_VERIFY, "Aztec: too long"] + ] as Array<Array>; + } +} + +// Every symbology accepts a typical payload; 2D codes are cached encoded. +(:test) +function settingsAddsEverySymbology(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + var ok = true; + for (var i = 0; i < CodeFormCases.VALID.size(); i++) { + var c = CodeFormCases.VALID[i] as Array; + var symbology = c[0] as Number; + SettingsFixtures.code(s, symbology, "Card " + i, c[1] as String); + SettingsFixtures.run(vault, s); + var status = SettingsFixtures.text(s, CodeForm.STATUS); + var ids = vault.ids(); + if (ids.size() != i + 1 || status.find("Added " + ids[i] + " Card " + i) != 0 || status.find(c[2] as String) == null) { + logger.error(Codes.name(symbology) + ": " + status); + ok = false; + continue; + } + var cached = vault.loadCodeCache(ids[i]); + if (Codes.isMatrix(symbology) != (cached != null && Codes.Matrix.fromBytes(cached) != null)) { + logger.error(Codes.name(symbology) + ": cache " + (cached != null)); + ok = false; + } + if (!SettingsFixtures.text(s, CodeForm.PAYLOAD).equals("") || Forms.flag(s, CodeForm.SWITCH)) { + logger.error(Codes.name(symbology) + ": form not cleared"); + ok = false; + } + } + // Linear contents are stored trimmed. + return ok && SettingsFixtures.secretOf(vault, vault.ids()[3]).equals("590123412345"); +} + +// Every symbology's typical mistakes give the encoder's message; nothing +// is added and only the contents are cleared. +(:test) +function settingsRejectsInvalidCodes(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var cases = CodeFormCases.invalid(); + var ok = true; + for (var i = 0; i < cases.size(); i++) { + var c = cases[i]; + var s = SettingsFixtures.store(); + SettingsFixtures.code(s, c[0] as Number, "Card", c[1] as String); + s.set("codeCheckDigit", c[2] as Number); + SettingsFixtures.run(vault, s); + var status = SettingsFixtures.text(s, CodeForm.STATUS); + if (status.find(c[3] as String) == null || vault.ids().size() != 0 || !SettingsFixtures.text(s, "codeName").equals("Card") + || !SettingsFixtures.text(s, CodeForm.PAYLOAD).equals("")) { + logger.error("case " + i + " (" + Codes.name(c[0] as Number) + "): " + status); + ok = false; + } + } + return ok; +} + +(:test) +function settingsChecksCodeFields(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.code(s, Codes.QR, " ", "x"); + SettingsFixtures.run(vault, s); + var ok = SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Name: enter a name for the card"); + SettingsFixtures.code(s, Codes.QR, "Card", " "); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Contents: missing"); + SettingsFixtures.code(s, 12, "Card", "x"); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Type: choose one of the 12 barcode types"); + SettingsFixtures.code(s, Codes.QR, "Card", SettingsFixtures.repeat("é", 2049)); + SettingsFixtures.run(vault, s); + return ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Contents: too long (4098 bytes, at most 4096)") + && vault.ids().size() == 0; +} + +// The form's options end up in Entry.options; "Fix if wrong" corrects the +// check digit and says so. +(:test) +function settingsStoresCodeOptions(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.code(s, Codes.EAN13, "Gym", "5901234123450"); + s.set("codeCheckDigit", Codes.CHECK_FIX); + s.set("codeShowText", false); + s.set("codeEcLevel", 3); + s.set("codeGroup", "Sport"); + s.set("codeLocked", false); + SettingsFixtures.run(vault, s); + if (vault.ids().size() != 1) { + logger.error(SettingsFixtures.text(s, CodeForm.STATUS)); + return false; + } + var e = vault.open(vault.ids()[0]); + var ok = e.options == (3 | (Codes.CHECK_FIX << Codes.OPT_CHECK_SHIFT)) && e.group.equals("Sport") && e.type == Codes.EAN13; + e.wipeSecrets(); + return ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "check digit fixed: 7 (was 0)"); +} + +// Codes that encode but can't be shown at the minimum module size on this +// screen are added with the README 12.2 warning. +(:test) +function settingsWarnsWhenACodeIsTooLarge(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + // About version 39 at level L: over 360 px at 2 px per module. + SettingsFixtures.code(s, Codes.QR, "Big", SettingsFixtures.repeat("Ab", 1400)); + s.set("codeEcLevel", 0); + SettingsFixtures.run(vault, s); + var qr = SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Too large for this screen: needs 2 px per module"); + SettingsFixtures.code(s, Codes.CODE128, "Wide", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"); + SettingsFixtures.run(vault, s); + return qr && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Too large for this screen: needs") && vault.ids().size() == 2; +} + +// A locked card sent while locked waits as pending; no cache is written +// for it (its key isn't available). +(:test) +function settingsLockedCodeWaitsForThePin(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + StorageFixtures.setPin(vault, StorageFixtures.PIN); + vault.lock(); + var s = SettingsFixtures.store(); + SettingsFixtures.code(s, Codes.DATAMATRIX, "Parcel", "PARCEL-FAKE-0042"); + s.set("codeLocked", true); + SettingsFixtures.run(vault, s); + var id = vault.pendingIds().size() == 1 ? vault.pendingIds()[0] : ""; + return id.length() > 0 && vault.storage().getValue("x." + id) == null + && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "It waits for your PIN on the watch"); +} diff --git a/test/settings/ManageTest.mc b/test/settings/ManageTest.mc new file mode 100644 index 0000000..0adf785 --- /dev/null +++ b/test/settings/ManageTest.mc @@ -0,0 +1,320 @@ +// Author: cirakg & claude ai +// Summary: Tests for the Manage entry form: every action on unlocked entries, the PIN queue for locked ones, IDs, repeats and damage. + +import Toybox.Lang; +import Toybox.Test; + +(:test) +module ManageFixtures { + + // A vault with GitHub (TOTP), VPN (HOTP, counter 5) and Gym (EAN-13), + // all unlocked, and a PIN set (session unlocked). + function vault() as Vault { + var v = SettingsFixtures.vault(); + StorageFixtures.setPin(v, StorageFixtures.PIN); + v.add(StorageFixtures.totp("GitHub")); + v.add(StorageFixtures.hotp("VPN", 5l)); + var gym = new Entry(); + gym.kind = Entry.KIND_CODE; + gym.type = Codes.EAN13; + gym.name = "Gym"; + gym.payload = Bytes.fromUtf8("590123412345"); + gym.options = Codes.DEFAULT_OPTIONS; + v.add(gym); + return v; + } + + // Add a locked entry (needs the session unlocked) and return its ID. + function addLocked(v as Vault, name as String) as String { + var e = StorageFixtures.totp(name); + e.locked = true; + return v.add(e); + } + + function isLocked(v as Vault, id as String) as Boolean { + var h = v.header(id); + return h != null && h.locked; + } + + // A Manage form for `id`, switched on (not run yet). + function send(v as Vault, id as String, action as Number) as Forms.MemoryStore { + var s = SettingsFixtures.store(); + SettingsFixtures.manage(s, id, action); + return s; + } + + // Confirm the waiting changes, as after a correct PIN. + function confirm(v as Vault) as ChangeQueueJob { + var job = new ChangeQueueJob(v); + Jobs.runToCompletion(job, 1); + return job; + } +} + +// Edit details: blank keeps a field, "-" clears it; the form is cleared. +(:test) +function manageEditsDetails(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var id = vault.ids()[0]; + var s = ManageFixtures.send(vault, id.toLower(), ManageAction.EDIT); // IDs are case-insensitive + s.set("mgName", "GitLab"); + s.set("mgGroup", "-"); + SettingsFixtures.run(vault, s); + var e = vault.open(id); + var ok = e.name.equals("GitLab") && e.account.equals("alice@example.com") && e.group.equals(""); + e.wipeSecrets(); + return ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Edited " + id + ": GitLab (alice@example.com)") + && SettingsFixtures.text(s, "mgId").equals("") && !Forms.flag(s, ManageForm.SWITCH); +} + +(:test) +function manageRejectsBadEdits(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = ManageFixtures.send(vault, ids[0], ManageAction.EDIT); + SettingsFixtures.run(vault, s); + var ok = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Edit details: fill in a new name, account or group"); + s = ManageFixtures.send(vault, ids[2], ManageAction.EDIT); + s.set("mgAccount", "bob"); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "is a barcode, which has no account"); + s = ManageFixtures.send(vault, ids[2], ManageAction.EDIT); + s.set("mgName", "-"); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Name: a barcode needs a name"); + s = ManageFixtures.send(vault, ids[0], ManageAction.EDIT); + s.set("mgName", "-"); + s.set("mgAccount", "-"); + SettingsFixtures.run(vault, s); + return ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "An OTP entry needs an issuer or an account") + && SettingsFixtures.secretOf(vault, ids[0]).length() > 0 && vault.anyHeader(ids[0]) != null + && (vault.anyHeader(ids[0]) as Entry).name.equals("GitHub"); +} + +// Move to an absolute position (clamped); applying it twice changes nothing. +(:test) +function manageMovesEntries(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = ManageFixtures.send(vault, ids[2], ManageAction.MOVE); + s.set("mgPosition", 1); + SettingsFixtures.run(vault, s); + var moved = vault.ids()[0].equals(ids[2]) && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "to position 1"); + s = ManageFixtures.send(vault, ids[0], ManageAction.MOVE); + s.set("mgPosition", 99); + SettingsFixtures.run(vault, s); + var last = vault.ids()[2].equals(ids[0]) && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "to position 3"); + s = ManageFixtures.send(vault, ids[0], ManageAction.MOVE); + s.set("mgPosition", 0); + SettingsFixtures.run(vault, s); + return moved && last && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Position: 1 or more"); +} + +(:test) +function manageDeletesAndSetsCounters(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = ManageFixtures.send(vault, ids[1], ManageAction.COUNTER); + s.set("mgCounter", 1000l); + SettingsFixtures.run(vault, s); + var vpn = vault.header(ids[1]); + var counter = vpn != null && vpn.counter == 1000l; + s = ManageFixtures.send(vault, ids[0], ManageAction.COUNTER); + s.set("mgCounter", 3l); + SettingsFixtures.run(vault, s); + var notHotp = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "is not a HOTP entry"); + s = ManageFixtures.send(vault, ids[0], ManageAction.DELETE); + SettingsFixtures.run(vault, s); + return counter && notHotp && vault.ids().size() == 2 && vault.ids().indexOf(ids[0]) < 0 + && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Deleted " + ids[0] + " GitHub (alice@example.com)"); +} + +// Replace secret (OTP, Base32 checked) and contents (code, checked by the +// encoder with the entry's own options). +(:test) +function manageReplacesSecretsAndContents(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = ManageFixtures.send(vault, ids[0], ManageAction.REPLACE); + s.set("mgSecret", AegisFixtures.SECRET_3); + SettingsFixtures.run(vault, s); + var secret = SettingsFixtures.secretOf(vault, ids[0]).equals("abcdefghijklmnopqrst") + && SettingsFixtures.text(s, "mgSecret").equals(""); + s = ManageFixtures.send(vault, ids[0], ManageAction.REPLACE); + s.set("mgSecret", "AB1"); + SettingsFixtures.run(vault, s); + var badSecret = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Secret: '1' is not a Base32 character (position 3)") + && SettingsFixtures.secretOf(vault, ids[0]).equals("abcdefghijklmnopqrst") && SettingsFixtures.text(s, "mgSecret").equals(""); + s = ManageFixtures.send(vault, ids[2], ManageAction.REPLACE); + s.set("mgSecret", "400638133393"); + SettingsFixtures.run(vault, s); + var contents = SettingsFixtures.secretOf(vault, ids[2]).equals("400638133393") + && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Replaced the contents of " + ids[2] + " Gym"); + s = ManageFixtures.send(vault, ids[2], ManageAction.REPLACE); + s.set("mgSecret", "123"); + SettingsFixtures.run(vault, s); + return secret && badSecret && contents && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "EAN-13 needs 12 or 13 digits") + && SettingsFixtures.secretOf(vault, ids[2]).equals("400638133393"); +} + +// Unknown, malformed, pending and damaged IDs. +(:test) +function manageChecksTheId(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var s = ManageFixtures.send(vault, "K7F", ManageAction.DELETE); + SettingsFixtures.run(vault, s); + var ok = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Entry ID: 'K7F' is not an ID"); + s = ManageFixtures.send(vault, "ZZZZ", ManageAction.DELETE); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "No entry ZZZZ"); + s = ManageFixtures.send(vault, "", ManageAction.DELETE); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Entry ID: missing"); + vault.lock(); + var pending = StorageFixtures.totp("Bank"); + pending.locked = true; + var pendingId = vault.add(pending); + s = ManageFixtures.send(vault, pendingId, ManageAction.DELETE); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, pendingId + " waits for your PIN on the watch"); + // A damaged record can still be deleted, nothing else. + var damaged = vault.ids()[0]; + vault.storage().setValue("e." + damaged, "garbage"); + s = ManageFixtures.send(vault, damaged, ManageAction.MOVE); + SettingsFixtures.run(vault, s); + ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, damaged + " is damaged"); + s = ManageFixtures.send(vault, damaged, ManageAction.DELETE); + SettingsFixtures.run(vault, s); + return ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Deleted damaged entry " + damaged) && vault.ids().size() == 2; +} + +// The same Manage form twice is applied once. +(:test) +function manageAppliesTheSameFormOnce(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = ManageFixtures.send(vault, ids[1], ManageAction.COUNTER); + s.set("mgCounter", 9l); + SettingsFixtures.run(vault, s); + vault.setCounter(ids[1], 10l); // the user pressed "next" on the watch + SettingsFixtures.manage(s, ids[1], ManageAction.COUNTER); + s.set("mgCounter", 9l); + SettingsFixtures.run(vault, s); + var header = vault.header(ids[1]); + return header != null && header.counter == 10l && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Already applied"); +} + +// Sensitive actions on a locked entry wait for the PIN even while the +// session is unlocked (README 7.9); confirming applies them. +(:test) +function manageQueuesLockedEntryChanges(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var bank = ManageFixtures.addLocked(vault, "Bank"); + var safe = ManageFixtures.addLocked(vault, "Safe"); + var s = ManageFixtures.send(vault, bank, ManageAction.DELETE); + SettingsFixtures.run(vault, s); + var queued = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Waiting for your PIN on the watch: Delete: " + bank + " Bank") + && vault.ids().indexOf(bank) >= 0 && ChangeQueue.count(vault) == 1; + s = ManageFixtures.send(vault, safe, ManageAction.REPLACE); + s.set("mgSecret", AegisFixtures.SECRET_3); + SettingsFixtures.run(vault, s); + s = ManageFixtures.send(vault, safe, ManageAction.UNLOCK); + SettingsFixtures.run(vault, s); + var descriptions = ChangeQueue.descriptions(vault); + var listed = descriptions.size() == 3 && descriptions[1].equals("Replace contents: " + safe + " Safe (alice@example.com)"); + var job = ManageFixtures.confirm(vault); + var header = vault.header(safe); + var applied = vault.ids().indexOf(bank) < 0 && header != null && !header.locked + && SettingsFixtures.secretOf(vault, safe).equals("abcdefghijklmnopqrst") && ChangeQueue.count(vault) == 0; + if (!applied) { + logger.error(job.lines.toString()); + } + return queued && listed && applied && SettingsFixtures.hasLine(job.lines, "Deleted " + bank); +} + +// "Require PIN" applies at once while unlocked, waits while locked. +(:test) +function manageLocksEntries(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = ManageFixtures.send(vault, ids[0], ManageAction.LOCK); + SettingsFixtures.run(vault, s); + var now = ManageFixtures.isLocked(vault, ids[0]) && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "now requires the PIN"); + vault.lock(); + s = ManageFixtures.send(vault, ids[1], ManageAction.LOCK); + SettingsFixtures.run(vault, s); + var waits = !ManageFixtures.isLocked(vault, ids[1]) && ChangeQueue.count(vault) == 1; + // In lower case: the same form again would just be "Already applied". + s = ManageFixtures.send(vault, ids[0].toLower(), ManageAction.LOCK); + SettingsFixtures.run(vault, s); + var already = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "already requires the PIN"); + StorageFixtures.unlock(vault, StorageFixtures.PIN); + ManageFixtures.confirm(vault); + return now && waits && already && ManageFixtures.isLocked(vault, ids[1]); +} + +// Changes that arrive while the queue is being applied stay queued. +(:test) +function manageQueueKeepsLateArrivals(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var bank = ManageFixtures.addLocked(vault, "Bank"); + var safe = ManageFixtures.addLocked(vault, "Safe"); + var s = ManageFixtures.send(vault, bank, ManageAction.DELETE); + SettingsFixtures.run(vault, s); + var job = new ChangeQueueJob(vault); + job.step(1); + s = ManageFixtures.send(vault, safe, ManageAction.DELETE); + SettingsFixtures.run(vault, s); + Jobs.runToCompletion(job, 1); + var left = ChangeQueue.descriptions(vault); + return vault.ids().indexOf(bank) < 0 && vault.ids().indexOf(safe) >= 0 && left.size() == 1 + && left[0].find(safe) != null; +} + +// A queued change whose entry disappeared meanwhile is reported, not applied. +(:test) +function manageQueueSurvivesMissingEntries(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var bank = ManageFixtures.addLocked(vault, "Bank"); + var s = ManageFixtures.send(vault, bank, ManageAction.UNLOCK); + SettingsFixtures.run(vault, s); + vault.remove(bank); + var job = ManageFixtures.confirm(vault); + return SettingsFixtures.hasLine(job.lines, "Not applied: Don't require PIN: " + bank) && ChangeQueue.count(vault) == 0; +} + +// Queue items are sealed one by one: a tampered item is dropped, the +// others survive; the queue holds at most MAX_ITEMS. +(:test) +function manageQueueIsSealedAndLimited(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var a = new ManageAction(); + a.id = "K7F2"; + a.action = ManageAction.REPLACE; + a.material = Bytes.fromUtf8("secret"); + a.decoded = true; + a.label = "Bank"; + ChangeQueue.add(vault, a); + var b = new ManageAction(); + b.id = "M3P9"; + b.action = ManageAction.DELETE; + ChangeQueue.add(vault, b); + var raw = (vault.storage() as MemoryBackend).raw(ChangeQueue.KEY) as Array<ByteArray>; + var box = raw[0]; + box[20] = box[20] ^ 1; + var loaded = ChangeQueue.load(vault); + var sealed = loaded.size() == 1 && loaded[0].id.equals("M3P9") && loaded[0].action == ManageAction.DELETE; + var roundTrip = ManageAction.fromBytes(a.toBytes()); + var same = roundTrip != null && roundTrip.decoded && roundTrip.label.equals("Bank") + && Bytes.equal(roundTrip.material as ByteArray, Bytes.fromUtf8("secret")); + ChangeQueue.clear(vault); + var limited = false; + for (var i = 0; i <= ChangeQueue.MAX_ITEMS; i++) { + try { + ChangeQueue.add(vault, b); + } catch (e instanceof VaultError) { + limited = i == ChangeQueue.MAX_ITEMS && e.code == VaultError.LIMIT; + } + } + return sealed && same && limited && ManageAction.fromBytes([1, 2, 3]b) == null; +} diff --git a/test/settings/OtpFormTest.mc b/test/settings/OtpFormTest.mc new file mode 100644 index 0000000..9e6249e --- /dev/null +++ b/test/settings/OtpFormTest.mc @@ -0,0 +1,182 @@ +// Author: cirakg & claude ai +// Summary: Tests for the Add OTP form: every type, every validation message, clearing, warnings, idempotency, pending locked entries. + +import Toybox.Lang; +import Toybox.Test; + +// A TOTP form is added with all its fields; the form is cleared (secret +// included), the switch turned off and the status names the new ID. +(:test) +function settingsAddsTotpAndClearsTheForm(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1); + s.set("otpAlgorithm", Hashes.SHA256); + s.set("otpDigits", 8); + s.set("otpPeriod", 60); + s.set("otpGroup", "Work"); + var job = SettingsFixtures.run(vault, s); + if (vault.ids().size() != 1) { + logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS)); + return false; + } + var id = vault.ids()[0]; + var e = vault.open(id); + var fields = e.name.equals("GitHub") && e.account.equals("alice") && e.group.equals("Work") && e.algorithm == Hashes.SHA256 + && e.digits == 8 && e.period == 60 && e.source == Entry.SOURCE_MANUAL && !e.locked; + e.wipeSecrets(); + return fields && SettingsFixtures.secretOf(vault, id).equals("12345678901234567890") + && SettingsFixtures.text(s, OtpForm.STATUS).equals("Added " + id + " GitHub (alice)") + && SettingsFixtures.text(s, "otpSecret").equals("") && SettingsFixtures.text(s, "otpIssuer").equals("") + && !Forms.flag(s, OtpForm.SWITCH) && Forms.number(s, "otpDigits") == 6 && job.forms == 1 && job.changed + && SettingsFixtures.hasLine(job.lines, "Add OTP: Added " + id); +} + +// HOTP keeps its counter; Steam ignores algorithm, digits and period. +(:test) +function settingsAddsHotpAndSteam(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_1); + s.set("otpCounter", 42l); + SettingsFixtures.run(vault, s); + SettingsFixtures.otp(s, Entry.OTP_STEAM, "Steam", "erin", AegisFixtures.SECRET_3); + s.set("otpAlgorithm", Hashes.SHA512); + s.set("otpDigits", 8); + s.set("otpPeriod", 10); + SettingsFixtures.run(vault, s); + if (vault.ids().size() != 2) { + logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS)); + return false; + } + var hotp = vault.open(vault.ids()[0]); + var steam = vault.open(vault.ids()[1]); + var ok = hotp.type == Entry.OTP_HOTP && hotp.counter == 42l && steam.type == Entry.OTP_STEAM + && steam.algorithm == Hashes.SHA1 && steam.period == Otp.STEAM_PERIOD; + hotp.wipeSecrets(); + steam.wipeSecrets(); + return ok; +} + +// Each invalid field gives its message; nothing is added, only the secret +// is cleared (so the rest can be corrected) and the switch goes off. +(:test) +function settingsRejectsInvalidOtpForms(logger as Test.Logger) as Boolean { + // [field, value, expected message part] + var cases = [ + ["otpSecret", "JBSW1Y3D","Not added: Secret: '1' is not a Base32 character (position 5)"], + ["otpSecret", " ", "Secret: missing"], + ["otpSecret", "JBSWY3DP=A", "Secret: characters after '=' padding (position 10)"], + ["otpDigits", 9, "Digits: choose 6, 7 or 8"], + ["otpDigits", "eight", "Digits: choose 6, 7 or 8"], + ["otpPeriod", 0, "Period: 1 to 300 seconds"], + ["otpPeriod", 301, "Period: 1 to 300 seconds"], + ["otpAlgorithm", 5, "Algorithm: choose SHA1, SHA256 or SHA512"], + ["otpType", 7, "Type: choose TOTP, HOTP or Steam"], + ["otpIssuer", "Git\nHub", "Issuer: control character at position 4"], + ["otpGroup", SettingsFixtures.repeat("g", 33), "Group: too long (33 bytes, at most 32)"], + ["otpAccount", SettingsFixtures.repeat("é", 33), "Account: too long (66 bytes, at most 64)"] + ]; + var ok = true; + for (var i = 0; i < cases.size(); i++) { + var c = cases[i] as Array; + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_2); + s.set("otpLocked", true); // a field no case changes: it must be kept + s.set(c[0] as String, c[1] as String or Number); + SettingsFixtures.run(vault, s); + var status = SettingsFixtures.text(s, OtpForm.STATUS); + if (status.find(c[2] as String) == null || vault.ids().size() != 0 || vault.pendingIds().size() != 0 + || Forms.flag(s, OtpForm.SWITCH) || !SettingsFixtures.text(s, "otpSecret").equals("") + || !Forms.flag(s, "otpLocked")) { + logger.error("case " + i + ": status '" + status + "'"); + ok = false; + } + } + return ok; +} + +(:test) +function settingsNeedsANameAndACounter(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_TOTP, " ", "", AegisFixtures.SECRET_2); + SettingsFixtures.run(vault, s); + var names = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Fill in the issuer or the account"); + SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_2); + s.set("otpCounter", -1l); + SettingsFixtures.run(vault, s); + return names && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Counter: a whole number, 0 or more") && vault.ids().size() == 0; +} + +// A secret below 80 bits is accepted with a warning (README 10.4). +(:test) +function settingsWarnsAboutShortSecrets(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "Old", "x", "JBSW Y3DP"); // 5 bytes; the space is ignored + var job = SettingsFixtures.run(vault, s); + return vault.ids().size() == 1 && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Warning: the secret is only 40 bits") + && SettingsFixtures.hasLine(job.lines, "Warning: the secret is only 40 bits"); +} + +// The same form sent twice is applied once (README 10.3, rule 3); any +// change makes it a new request. +(:test) +function settingsAppliesTheSameOtpFormOnce(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1); + SettingsFixtures.run(vault, s); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1); + SettingsFixtures.run(vault, s); + var repeated = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Already added") && vault.ids().size() == 1 + && SettingsFixtures.text(s, "otpSecret").equals(""); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice (work)", AegisFixtures.SECRET_1); + SettingsFixtures.run(vault, s); + return repeated && vault.ids().size() == 2; +} + +// A locked entry sent while the watch is locked waits as pending (README 7.7). +(:test) +function settingsLockedOtpWaitsForThePin(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + StorageFixtures.setPin(vault, StorageFixtures.PIN); + vault.lock(); + var s = SettingsFixtures.store(); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "Bank", "carol", AegisFixtures.SECRET_1); + s.set("otpLocked", true); + SettingsFixtures.run(vault, s); + return vault.pendingIds().size() == 1 && vault.ids().size() == 0 + && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "It waits for your PIN on the watch"); +} + +// Nothing switched on: no form is looked at, nothing is written. +(:test) +function settingsIgnoresFormsThatAreOff(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + s.set("otpIssuer", "GitHub"); + s.set("otpSecret", AegisFixtures.SECRET_1); + var job = SettingsFixtures.run(vault, s); + return job.forms == 0 && vault.ids().size() == 0 && SettingsFixtures.text(s, "otpSecret").equals(AegisFixtures.SECRET_1) + && job.lines.size() == 0; +} + +// Field values of unexpected types are read safely (the phone promises +// nothing): numbers as text, longs, whole floats. +(:test) +function settingsReadsLooselyTypedNumbers(logger as Test.Logger) as Boolean { + var s = new Forms.MemoryStore(); + s.set("a", "42"); + s.set("b", 7l); + s.set("c", 3.0f); + s.set("d", 3.5f); + s.set("e", "-12"); + s.set("f", "x1"); + s.set("g", true); + return Forms.number(s, "a") == 42 && Forms.number(s, "b") == 7 && Forms.number(s, "c") == 3 && Forms.number(s, "d") == null + && Forms.long(s, "e") == -12l && Forms.number(s, "f") == null && Forms.number(s, "g") == null + && Forms.number(s, "missing") == null && Forms.text(s, "g").equals("") && !Forms.flag(s, "a"); +} diff --git a/test/settings/SettingsJobTest.mc b/test/settings/SettingsJobTest.mc new file mode 100644 index 0000000..07dbc64 --- /dev/null +++ b/test/settings/SettingsJobTest.mc @@ -0,0 +1,175 @@ +// Author: cirakg & claude ai +// Summary: Tests for the whole settings run: README order, the import through its form, the About fields and "Encrypt names". + +import Toybox.Application; +import Toybox.Lang; +import Toybox.Test; + +// All forms at once are applied in README 10.3 order: Import, Add OTP, +// Add code, Manage. +(:test) +function settingsAppliesFormsInOrder(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var first = vault.ids()[0]; + var s = SettingsFixtures.store(); + SettingsFixtures.manage(s, first, ManageAction.DELETE); + SettingsFixtures.code(s, Codes.QR, "Member", "https://example.com/m/42"); + SettingsFixtures.otp(s, Entry.OTP_TOTP, "Mail", "bob", AegisFixtures.SECRET_2); + s.set("impText1", ImporterFixtures.uri("Proton", "zoe", AegisFixtures.SECRET_3)); + s.set("impApply", true); + var job = SettingsFixtures.run(vault, s); + var lines = job.lines; + var order = ["Import: Imported 1", "Add OTP: Added", "Add code: Added", "Manage: Deleted " + first]; + var at = 0; + for (var i = 0; i < lines.size() && at < order.size(); i++) { + if (lines[i].find(order[at] as String) == 0) { + at++; + } + } + if (at != order.size() || job.forms != 4) { + logger.error(lines.toString()); + return false; + } + // 3 fixtures + 3 added - 1 deleted. + return vault.ids().size() == 5 && SettingsFixtures.text(s, "impText1").equals("") && !Forms.flag(s, "impApply") + && SettingsFixtures.text(s, "impStatus").equals("Imported 1"); +} + +// The import form keeps its M5 behaviour: a repeat is ignored, a cancel +// leaves the text. +(:test) +function settingsImportFormIsAppliedOnce(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + var uri = ImporterFixtures.uri("Proton", "zoe", AegisFixtures.SECRET_3); + s.set("impText1", uri); + s.set("impApply", true); + SettingsFixtures.run(vault, s); + s.set("impText1", uri); + s.set("impApply", true); + SettingsFixtures.run(vault, s); + var repeated = SettingsFixtures.text(s, "impStatus").equals("Already imported") && vault.ids().size() == 1; + s.set("impText1", "partial"); + s.set("impMode", Importer.MODE_CANCEL); + s.set("impApply", true); + SettingsFixtures.run(vault, s); + return repeated && SettingsFixtures.text(s, "impStatus").equals("Waiting import cancelled") + && SettingsFixtures.text(s, "impText1").equals("partial"); +} + +// The About fields list IDs and names, the version and the storage use; +// they are rewritten only when something changed. +(:test) +function settingsWritesTheAboutFields(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var ids = vault.ids(); + var s = SettingsFixtures.store(); + var job = SettingsFixtures.run(vault, s); + var catalog = SettingsFixtures.text(s, "catalog"); + var expected = ids[0] + " GitHub (alice@example.com) · " + ids[1] + " VPN (alice@example.com) · " + ids[2] + " Gym"; + if (!catalog.equals(expected)) { + logger.error("catalog: " + catalog); + return false; + } + var fresh = !AboutScan.needed(vault) && job.forms == 0 && job.lines.size() == 0; + vault.add(StorageFixtures.totp("New")); + return fresh && AboutScan.needed(vault) && SettingsFixtures.text(s, "version").equals(AppInfo.VERSION) + && SettingsFixtures.contains(logger, s, "storageUse", "3 of 150 entries, 1 KB of about 100 KB"); +} + +// A long catalog is cut with a count of what's left out. +(:test) +function settingsShortensTheCatalog(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + for (var i = 0; i < 60; i++) { + var e = StorageFixtures.totp("Service number " + i); + vault.add(e); + } + var s = SettingsFixtures.store(); + SettingsFixtures.run(vault, s); + var catalog = SettingsFixtures.text(s, "catalog"); + return catalog.length() <= AboutScan.CATALOG_MAX_CHARS + 60 && catalog.find("more (see Entry IDs on the watch)") != null; +} + +// "Encrypt names" moves names into the encrypted part of every entry it +// can reach, keeps them off the phone's catalog, and is undone when off. +// Locked entries wait for the session. +(:test) +function settingsAppliesEncryptNames(logger as Test.Logger) as Boolean { + var vault = ManageFixtures.vault(); + var bank = ManageFixtures.addLocked(vault, "Bank"); + vault.lock(); + vault.encryptNames = true; + var s = SettingsFixtures.store(); + SettingsFixtures.run(vault, s); + var ids = vault.ids(); + var unlockedDone = (vault.anyHeader(ids[0]) as Entry).namesEncrypted && (vault.anyHeader(ids[0]) as Entry).name.equals("") + && (vault.header(ids[0]) as Entry).name.equals("GitHub"); + var lockedWaits = !(vault.anyHeader(bank) as Entry).namesEncrypted && AboutScan.needed(vault); + var catalog = SettingsFixtures.text(s, "catalog"); + var namesHidden = catalog.find("GitHub") == null && catalog.find(ids[0] + " TOTP") != null && catalog.find(ids[2] + " EAN-13") != null; + StorageFixtures.unlock(vault, StorageFixtures.PIN); + SettingsFixtures.run(vault, s); + var lockedDone = (vault.anyHeader(bank) as Entry).namesEncrypted && !AboutScan.needed(vault); + vault.encryptNames = false; + SettingsFixtures.run(vault, s); + var back = !(vault.anyHeader(ids[0]) as Entry).namesEncrypted && (vault.anyHeader(ids[0]) as Entry).name.equals("GitHub") + && SettingsFixtures.text(s, "catalog").find("GitHub") != null; + // A new entry follows the option. + vault.encryptNames = true; + var id = vault.add(StorageFixtures.totp("Later")); + var follows = (vault.anyHeader(id) as Entry).namesEncrypted; + if (!(unlockedDone && lockedWaits && namesHidden && lockedDone && back && follows)) { + logger.error([unlockedDone, lockedWaits, namesHidden, lockedDone, back, follows].toString() + " " + catalog); + return false; + } + return true; +} + +// hasWork: nothing to do once the About fields are current and every +// switch is off. +(:test) +function settingsHasWorkOnlyWhenNeeded(logger as Test.Logger) as Boolean { + var vault = SettingsFixtures.vault(); + var s = SettingsFixtures.store(); + var before = SettingsJob.hasWork(vault, s); + SettingsFixtures.run(vault, s); + var after = SettingsJob.hasWork(vault, s); + s.set(CodeForm.SWITCH, true); + return before && !after && SettingsJob.hasWork(vault, s); +} + +// The watch's side of the properties contract (tools/check_resources.py +// checks the phone's side): every field the forms read exists in +// properties.xml (reading an undefined property crashes the app) and the +// values a cleared form goes back to are the properties' defaults. +(:test) +function settingsDefaultsMatchProperties(logger as Test.Logger) as Boolean { + var forms = [OtpForm.defaults(), CodeForm.defaults(), ManageForm.defaults()] as Array<Dictionary>; + var ok = true; + for (var f = 0; f < forms.size(); f++) { + var d = forms[f]; + var keys = d.keys(); + for (var i = 0; i < keys.size(); i++) { + var key = keys[i] as String; + var actual = Application.Properties.getValue(key); + var expected = (d[key] as Object).toString(); + if (actual == null || !actual.toString().equals(expected)) { + logger.error(key + ": property " + actual + ", form default " + expected); + ok = false; + } + } + } + var others = [OtpForm.SWITCH, OtpForm.STATUS, CodeForm.SWITCH, CodeForm.STATUS, ManageForm.SWITCH, ManageForm.STATUS, + "impApply", "impStatus", "impMode", "impLocked", "catalog", "version", "storageUse"]; + for (var i = 0; i < others.size(); i++) { + if (Application.Properties.getValue(others[i] as String) == null) { + logger.error((others[i] as String) + " is missing"); + ok = false; + } + } + for (var i = 1; i <= SettingsJob.IMPORT_TEXT_FIELDS; i++) { + ok = ok && Application.Properties.getValue("impText" + i) != null; + } + return ok; +} diff --git a/test/settings/SettingsSupport.mc b/test/settings/SettingsSupport.mc new file mode 100644 index 0000000..66ec969 --- /dev/null +++ b/test/settings/SettingsSupport.mc @@ -0,0 +1,110 @@ +// Author: cirakg & claude ai +// Summary: Helpers for the settings tests: a form store with every default, form builders and a job runner (fake data only). + +import Toybox.Application; +import Toybox.Lang; +import Toybox.Test; + +(:test) +module SettingsFixtures { + + function vault() as Vault { + return new Vault(new MemoryBackend(), new FakeTime()); + } + + // A store holding every form field at its default (as properties.xml). + function store() as Forms.MemoryStore { + var s = new Forms.MemoryStore(); + copy(s, OtpForm.defaults() as Dictionary<String, Application.Properties.ValueType>); + copy(s, CodeForm.defaults() as Dictionary<String, Application.Properties.ValueType>); + copy(s, ManageForm.defaults() as Dictionary<String, Application.Properties.ValueType>); + for (var i = 1; i <= SettingsJob.IMPORT_TEXT_FIELDS; i++) { + s.set("impText" + i, ""); + } + s.set("impMode", 0); + s.set("impLocked", false); + var off = ["otpAdd", "codeAdd", "mgApply", "impApply"]; + for (var i = 0; i < off.size(); i++) { + s.set(off[i] as String, false); + } + return s; + } + + function copy(s as Forms.MemoryStore, values as Dictionary<String, Application.Properties.ValueType>) as Void { + var keys = values.keys(); + for (var i = 0; i < keys.size(); i++) { + s.set(keys[i], values[keys[i]] as Application.Properties.ValueType); + } + } + + // Fill in Add OTP and turn its switch on. + function otp(s as Forms.Store, type as Number, issuer as String, account as String, secret as String) as Void { + s.set("otpType", type); + s.set("otpIssuer", issuer); + s.set("otpAccount", account); + s.set("otpSecret", secret); + s.set(OtpForm.SWITCH, true); + } + + function code(s as Forms.Store, symbology as Number, name as String, payload as String) as Void { + s.set("codeName", name); + s.set("codeSymbology", symbology); + s.set("codePayload", payload); + s.set(CodeForm.SWITCH, true); + } + + function manage(s as Forms.Store, id as String, action as Number) as Void { + s.set("mgId", id); + s.set("mgAction", action); + s.set(ManageForm.SWITCH, true); + } + + // Run every form to the end, as the app's timer would. + function run(v as Vault, s as Forms.Store) as SettingsJob { + var job = new SettingsJob(v, s, 1800000000l); + Jobs.runToCompletion(job, 1); + return job; + } + + function text(s as Forms.Store, key as String) as String { + var v = s.get(key); + return v instanceof String ? v : ""; + } + + // True when the field's text contains `part`; logs it otherwise. + function contains(logger as Test.Logger, s as Forms.Store, key as String, part as String) as Boolean { + var value = text(s, key); + if (value.find(part) != null) { + return true; + } + logger.error(key + " = '" + value + "', expected to contain '" + part + "'"); + return false; + } + + // True when some line starts with `prefix`. + function hasLine(lines as Array<String>, prefix as String) as Boolean { + for (var i = 0; i < lines.size(); i++) { + if (lines[i].find(prefix) == 0) { + return true; + } + } + return false; + } + + // A string of n copies of `unit`. + function repeat(unit as String, n as Number) as String { + var out = ""; + for (var i = 0; i < n; i++) { + out += unit; + } + return out; + } + + // The secret of an entry, as text (the fixtures use ASCII keys). + function secretOf(v as Vault, id as String) as String { + var e = v.open(id); + var text = Bytes.toUtf8(e.kind == Entry.KIND_OTP ? e.secret as ByteArray : e.payload as ByteArray); + e.wipeSecrets(); + return text != null ? text : ""; + } +} diff --git a/tools/ui_flow.py b/tools/ui_flow.py index e54b530..edfc9a4 100644 --- a/tools/ui_flow.py +++ b/tools/ui_flow.py @@ -19,8 +19,13 @@ drawing each screen, so every step waits for the screen it expects instead of sleeping. Menus are native Menu2 views that print nothing; for those the script waits a fixed time. -The flow covers: the results of an Aegis import sent through the settings -form (the fixture sets the form, as the phone would), the list, a TOTP entry, a HOTP entry and its next code, the +The flow covers: the results of an Aegis import and of the Add OTP, Add +code and Manage entry forms, all sent through the settings (the fixture +sets the forms, as the phone would), the two entries those forms added (a +TOTP whose code the golden image shows, and a Code 128 card whose +screenshot must decode), the Manage form's change to the locked Bank entry +waiting for the PIN and confirmed from the menu (watches with buttons), the +list, a TOTP entry, a HOTP entry and its next code, the PIN screen, a wrong PIN, a correct PIN opening a locked entry, auto-lock closing it, the menu, the About screen (watches with buttons; touch-only menus have no focus to move), the lockout after 5 wrong PINs, and an EAN-13 @@ -49,6 +54,7 @@ import zxingcpp EXIT_OK, EXIT_DIFFERENT, EXIT_FLOW = 0, 1, 2 SCREEN_TIMEOUT_S = 20.0 AUTO_LOCK_TIMEOUT_S = 25.0 # the UI-test build locks after 10 s without input +AUTO_LOCK_IDLE_S = 13.0 # idle time after which that lock has surely happened IMPORT_TIMEOUT_S = 90.0 # the 100-entry fixture import runs on a timer after start-up IMPORT_MEMORY_BUDGET = 0.70 # README 18: peak memory at most 70 % of the watch-app memory MENU_SETTLE_S = 1.5 # Menu2 has no "shown" line @@ -66,6 +72,9 @@ MEMBER_QR = ( # source/app/Fixtures.mc: a version 6 QR PARCEL_DM = "PARCEL-FAKE-0042-7731" # source/app/Fixtures.mc: Data Matrix TICKET_PDF417 = "Boarding pass FAKE 0042 seat 12A" # source/app/Fixtures.mc: PDF417 RAIL_AZTEC = "RAIL-FAKE-0042 Zone 1-3" # source/app/Fixtures.mc: Aztec +LOCKER_CODE128 = "FAKE-0042" # source/app/Fixtures.mc: the Add code form's Code 128 card +# The Add OTP form's TOTP ("Hello!" demo key) at TestMode.NOW is 309848, next +# 489290 (RFC 6238 computed in Python); the golden image otp_form shows them. QR_TIMEOUT_S = 30.0 # the QR is encoded in timer steps the first time @@ -350,6 +359,30 @@ def flow_import(f: Flow) -> None: f.expect("list") +def flow_forms(f: Flow) -> None: + """The entries the Add code and Add OTP forms added end the list: the + list wraps, so UP from the first row reaches them.""" + f.prev() # the Code 128 card (added last) + f.act() + f.open_focused_row() + f.expect("code") + f.shot("code_form", expect_code=LOCKER_CODE128) + f.act() + f.back() + f.expect("list") + f.prev() # the TOTP from Add OTP + f.act() + f.open_focused_row() + f.expect("otp") + f.shot("otp_form") + f.act() + f.back() + f.expect("list") + f.next() + f.next() # back to the first row (GitHub) + time.sleep(SETTLE_S) + + def flow_entries(f: Flow) -> None: """The list, a TOTP entry, a HOTP entry and its next code.""" f.shot("list") @@ -403,9 +436,10 @@ def flow_menu(f: Flow) -> None: f.shot("menu") f.act() if f.sim.has("down"): - # Locked with a PIN: Change PIN, Reset PIN, Settings results, Entry - # IDs, Clock status, About. - for _ in range(5): + # Locked with a PIN and a phone change waiting: Phone changes, + # Change PIN, Reset PIN, Settings results, Entry IDs, Clock status, + # About. + for _ in range(6): f.next() time.sleep(MENU_SETTLE_S) f.act() @@ -419,6 +453,32 @@ def flow_menu(f: Flow) -> None: f.expect("list") +def flow_queue(f: Flow) -> None: + """The Manage form's edit of the locked Bank entry waits for the PIN: + menu, Phone changes, Confirm all, PIN, results. Touch-only + menus have no focus to select with, so those watches skip it.""" + if not f.sim.has("down"): + return + f.menu() + time.sleep(MENU_SETTLE_S) + f.select() # Phone changes (the first item while one waits) + time.sleep(MENU_SETTLE_S) + f.shot("queue") + f.act() + f.select() # Confirm all + f.expect("pin") + f.opened_pin_screen() + f.act() + f.enter_pin(FIXTURE_PIN) + f.expect("results") + f.shot("queue_results") + f.act() + f.back() + f.expect("list") + # The PIN unlocked the session; the lockout flow needs it locked again. + time.sleep(AUTO_LOCK_IDLE_S) + + def flow_lockout(f: Flow) -> None: """Five wrong PINs start the 30 s wait.""" f.act() @@ -471,9 +531,11 @@ def flow_code(f: Flow) -> None: def run_flow(f: Flow) -> None: """The scripted session; see the module docstring.""" flow_import(f) + flow_forms(f) flow_entries(f) flow_locked_entry(f) flow_menu(f) + flow_queue(f) flow_lockout(f) flow_code(f) |
