// 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; }