// Author: cirakg & claude ai // Summary: Tests for the change queue's item format: codes readable without decrypting, material stored apart, 0.9.0 items, LockJob and stepwise promotion. import Toybox.Application; import Toybox.Lang; import Toybox.Test; (:test) module QueueFixtures { // A queued action for `id`. function action(id as String, code as Number) as ManageAction { var a = new ManageAction(); a.id = id; a.action = code; a.label = "Bank"; return a; } // Queue an action the way 0.9.0 did: one bare box, material inside. function addOld(v as Vault, a as ManageAction) as Void { var stored = ChangeQueue.items(v); stored.add(SealedBox.seal(v.keyManager.deviceKeys(), Bytes.fromUtf8(ChangeQueue.AAD_V1), a.toBytes())); v.storage().setValue(ChangeQueue.KEY, stored); ChangeQueue.changed(); } function backend(v as Vault) as MemoryBackend { return v.storage() as MemoryBackend; } } // Items are [code, box] or [code, box, material number]; a REPLACE's // material is a sealed value of its own, not part of the item's box. (:test) function queueStoresMaterialApart(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var bank = ManageFixtures.addLocked(vault, "Bank"); var r = QueueFixtures.action(bank, ManageAction.REPLACE); r.material = Bytes.fromUtf8("abcdefghijklmnopqrst"); r.decoded = true; ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.DELETE)); ChangeQueue.add(vault, r); var raw = QueueFixtures.backend(vault).raw(ChangeQueue.KEY) as Array>; var items = ChangeQueue.items(vault); var shape = raw.size() == 2 && raw[0].size() == 2 && ChangeQueue.codeOf(items[0]) == ManageAction.DELETE && raw[1].size() == 3 && ChangeQueue.codeOf(items[1]) == ManageAction.REPLACE && ChangeQueue.refOf(items[1]) == 0; var stored = QueueFixtures.backend(vault).raw(ChangeQueue.MATERIAL_KEY + "0"); var opened = ChangeQueue.open(vault, raw[1] as Application.Storage.ValueType, new Jobs.Wiper(), true); var same = opened != null && opened.material != null && Bytes.equal(opened.material as ByteArray, Bytes.fromUtf8("abcdefghijklmnopqrst")) && opened.ref == 0; // The material opens only for its own number and entry. raw[1][2] = 1; var other = QueueFixtures.action(bank, ManageAction.REPLACE); other.material = Bytes.fromUtf8("other"); ChangeQueue.add(vault, other); // takes number 0 again var swapped = ChangeQueue.open(vault, raw[1] as Application.Storage.ValueType, new Jobs.Wiper(), true) == null; if (!shape || !same || !swapped) { logger.error("shape " + shape + " same " + same + " swapped " + swapped); } return shape && same && swapped && stored instanceof ByteArray && r.material != null && r.ref == -1; } // Counting reads only the codes: a damaged box still counts, and no item // is opened (a tampered code fails only when the item is opened). (:test) function queueCountsWithoutDecrypting(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var ids = vault.ids(); vault.lock(); ChangeQueue.add(vault, QueueFixtures.action(ids[0], ManageAction.LOCK)); ChangeQueue.add(vault, QueueFixtures.action(ids[1], ManageAction.LOCK)); ChangeQueue.add(vault, QueueFixtures.action(ids[2], ManageAction.DELETE)); var raw = QueueFixtures.backend(vault).raw(ChangeQueue.KEY) as Array>; var box = raw[0][1] as ByteArray; box[5] = box[5] ^ 1; var counted = ChangeQueue.countLocks(vault) == 2 && ChangeQueue.countToConfirm(vault) == 1; // A LOCK relabelled DELETE doesn't open. raw[1][0] = ManageAction.DELETE; ChangeQueue.changed(); var relabelled = ChangeQueue.countLocks(vault) == 1 && ChangeQueue.open(vault, raw[1] as Application.Storage.ValueType, new Jobs.Wiper(), true) == null && ChangeQueue.open(vault, raw[0] as Application.Storage.ValueType, new Jobs.Wiper(), true) == null && ChangeQueue.open(vault, raw[2] as Application.Storage.ValueType, new Jobs.Wiper(), true) != null; return counted && relabelled; } // Items queued by 0.9.0 still count (as "to confirm"), describe, apply at // "Confirm all", and their locks apply at the next PIN. (:test) function queueReadsOldItems(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var ids = vault.ids(); var bank = ManageFixtures.addLocked(vault, "Bank"); vault.lock(); QueueFixtures.addOld(vault, QueueFixtures.action(ids[0], ManageAction.LOCK)); QueueFixtures.addOld(vault, QueueFixtures.action(bank, ManageAction.DELETE)); var counted = ChangeQueue.count(vault) == 2 && ChangeQueue.countLocks(vault) == 0 && ChangeQueue.countToConfirm(vault) == 2; var described = ChangeQueue.descriptions(vault); var listed = described.size() == 2 && described[1].equals("Delete: " + bank + " Bank"); StorageFixtures.unlock(vault, StorageFixtures.PIN); var locks = ManageFixtures.applyLocks(vault); var locked = ManageFixtures.isLocked(vault, ids[0]) && locks.lines.size() == 1 && ChangeQueue.count(vault) == 1; var job = ManageFixtures.confirm(vault); if (!counted || !listed || !locked) { logger.error("counted " + counted + " listed " + listed + " locked " + locked + " " + described.toString()); } return counted && listed && locked && vault.ids().indexOf(bank) < 0 && ChangeQueue.count(vault) == 0 && SettingsFixtures.hasLine(job.lines, "Deleted " + bank); } // A REPLACE is checked before it is queued: contents too long fail at once; // valid ones are queued as checked contents and applied after the PIN. (:test) function queueChecksReplaceFirst(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var card = StorageFixtures.code("Card"); card.type = Codes.QR; card.locked = true; var id = vault.add(card); var s = ManageFixtures.send(vault, id, ManageAction.REPLACE); var long = ""; for (var i = 0; i < Entry.MAX_PAYLOAD_BYTES / 8 + 1; i++) { long += "abcdefgh"; } s.set("mgSecret", long); SettingsFixtures.run(vault, s); var refused = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Replace: too long") && ChangeQueue.count(vault) == 0; s = ManageFixtures.send(vault, id, ManageAction.REPLACE); s.set("mgSecret", "new contents"); SettingsFixtures.run(vault, s); var queued = ManageFixtures.load(vault); var checked = queued.size() == 1 && queued[0].decoded && queued[0].ref == 0 && Bytes.equal(queued[0].material as ByteArray, Bytes.fromUtf8("new contents")); var job = ManageFixtures.confirm(vault); var applied = SettingsFixtures.secretOf(vault, id).equals("new contents"); if (!applied) { logger.error(job.lines.toString()); } return refused && checked && applied; } // A REPLACE's material record goes with its item: rejected, confirmed, or // dropped as damaged by clear(). (:test) function queueDeletesMaterialWithItem(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var bank = ManageFixtures.addLocked(vault, "Bank"); var r = QueueFixtures.action(bank, ManageAction.REPLACE); r.material = Bytes.fromUtf8("abcdefghijklmnopqrst"); r.decoded = true; var key = ChangeQueue.MATERIAL_KEY + "0"; ChangeQueue.add(vault, r); var stored = vault.storage().getValue(key) != null; ChangeQueue.clear(vault); var rejected = vault.storage().getValue(key) == null; ChangeQueue.add(vault, r); ManageFixtures.confirm(vault); var confirmed = vault.storage().getValue(key) == null && ChangeQueue.count(vault) == 0 && SettingsFixtures.secretOf(vault, bank).equals("abcdefghijklmnopqrst"); // A missing material record makes its item fail to open (dropped). ChangeQueue.add(vault, r); vault.storage().deleteValue(key); var job = ManageFixtures.confirm(vault); return stored && rejected && confirmed && ChangeQueue.count(vault) == 0 && job.lines.size() == 1 && job.lines[0].equals("Not applied: a change was damaged"); } // LockJob drops a lock that can't apply (with a line), keeps the other // items in order, and leaves its lines for a "Confirm all" once. (:test) function queueLockJobAppliesAndDrops(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var ids = vault.ids(); var bank = ManageFixtures.addLocked(vault, "Bank"); vault.lock(); ChangeQueue.add(vault, QueueFixtures.action(ids[0], ManageAction.LOCK)); ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.DELETE)); ChangeQueue.add(vault, QueueFixtures.action("ZZZZ", ManageAction.LOCK)); ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.FAVORITE)); StorageFixtures.unlock(vault, StorageFixtures.PIN); var job = ManageFixtures.applyLocks(vault); var items = ChangeQueue.items(vault); var kept = items.size() == 2 && ChangeQueue.codeOf(items[0]) == ManageAction.DELETE && ChangeQueue.codeOf(items[1]) == ManageAction.FAVORITE; var lines = job.lines.size() == 2 && job.lines[1].find("Not applied: Require PIN: ZZZZ") == 0; var taken = ChangeQueue.takeLockLines().size() == 2 && ChangeQueue.takeLockLines().size() == 0; if (!kept || !lines) { logger.error(job.lines.toString()); } return kept && lines && taken && ManageFixtures.isLocked(vault, ids[0]); } // Pending entries are promoted a few per step, each saved on its own. (:test) function queuePromotesInSteps(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); vault.lock(); for (var i = 0; i < 8; i++) { var e = StorageFixtures.totp("Pending " + i); e.locked = true; vault.add(e); } StorageFixtures.unlock(vault, StorageFixtures.PIN); var job = new LockJob(vault, true); job.step(1); var partly = vault.pendingIds().size() > 0 && vault.pendingIds().size() < 8; var steps = 1; while (!job.step(1)) { steps++; } return partly && steps > 2 && vault.pendingIds().size() == 0 && vault.ids().size() == 11 && ManageFixtures.isLocked(vault, vault.ids()[10]); } // "Confirm all" and "Reject all" act only on the items the menu read // (DescribeJob.keys): changes that arrive meanwhile keep waiting. (:test) function queueActsOnWhatWasShown(logger as Test.Logger) as Boolean { var vault = ManageFixtures.vault(); var bank = ManageFixtures.addLocked(vault, "Bank"); var safe = ManageFixtures.addLocked(vault, "Safe"); ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.DELETE)); var menu = new DescribeJob(vault); Jobs.runToCompletion(menu, 1); ChangeQueue.add(vault, QueueFixtures.action(safe, ManageAction.DELETE)); // while the PIN is typed var job = new ChangeQueueJob(vault, menu.keys); Jobs.runToCompletion(job, 1); var confirmed = vault.ids().indexOf(bank) < 0 && vault.ids().indexOf(safe) >= 0 && ChangeQueue.count(vault) == 1 && SettingsFixtures.hasLine(job.lines, "1 change arrived meanwhile and still waits"); menu = new DescribeJob(vault); Jobs.runToCompletion(menu, 1); ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.FAVORITE)); var left = ChangeQueue.remove(vault, menu.keys); // "Reject all" var items = ChangeQueue.items(vault); if (!confirmed) { logger.error(job.lines.toString()); } return confirmed && left == 1 && ChangeQueue.codeOf(items[0]) == ManageAction.FAVORITE && menu.keys.size() == 1; }