From 479d7ba2e843d5ea93979f2eed68f4d38d146c25 Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 18:36:54 +0200 Subject: 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 --- test/settings/ManageTest.mc | 320 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 test/settings/ManageTest.mc (limited to 'test/settings/ManageTest.mc') 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; + 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; +} -- cgit v1.2.3