diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
| commit | 479d7ba2e843d5ea93979f2eed68f4d38d146c25 (patch) | |
| tree | 52b0bc58a7456e8675e977698db20e55515b7100 /test/settings | |
| parent | 151bfda5d4c567ff909a61dc9c90bf8eece1db37 (diff) | |
test(settings): cover every form, symbology and Manage action; send the forms end to end in the UI flow
34 Monkey C tests (154 per device): validation messages, idempotency, the
queue, the About scan, "Encrypt names" and the properties contract. The
UI-test build fills in Add OTP, Add code and Manage like the phone; the flow
checks the new TOTP (309 848 at the frozen time), decodes the Code 128 card
and confirms the queued change to the locked entry with the PIN.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'test/settings')
| -rw-r--r-- | test/settings/CodeFormTest.mc | 176 | ||||
| -rw-r--r-- | test/settings/ManageTest.mc | 320 | ||||
| -rw-r--r-- | test/settings/OtpFormTest.mc | 182 | ||||
| -rw-r--r-- | test/settings/SettingsJobTest.mc | 175 | ||||
| -rw-r--r-- | test/settings/SettingsSupport.mc | 110 |
5 files changed, 963 insertions, 0 deletions
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 : ""; + } +} |
