aboutsummaryrefslogtreecommitdiff
path: root/test/settings
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
commitbc4cc19df5f53519ca1aabd3cf161b6d78ffc16c (patch)
tree3242a63f603882d723bd0d718d043c30a9f11784 /test/settings
parent34daecaa25e5bdd995179a2b4b9d922f8bbb3b54 (diff)
fix(pin): the work after a PIN and the change queue run in steps
After a correct or new PIN, promoting pending entries and applying waiting "Require PIN" changes ran in the event of the last PBKDF2 step; 16 waiting changes tripped the watchdog on the Forerunner 70. ChangeQueue.LockJob now does it in steps while the PIN screen shows "Checking...", saves what it did to the results and refreshes the glance after Set PIN too. The list title decrypted every waiting change at each start. Queue items are now [action, box] (aad OTPW-Q-2 + action code), so they are counted without decrypting; 0.9.0 items are still read. A replacement is checked when it arrives and its material is sealed apart (qm.<n>). Large buffers are wiped and UTF-8-checked in pieces (Jobs.Wiper, Bytes.utf8Prefix, SealedBox.openKeep/sealKeep). "Phone changes" reads behind "Reading...", and Confirm all / Reject all act only on the changes shown. Storage full no longer stops the app at the PIN. Found by the review of b8addc8/096b5a4 and a new make watchdog test; the diff was reviewed again and its findings fixed. fr70: make lint, make test (176), make watchdog (19 cases at two steps per event), make screenshots pass; list_locked and list_reset goldens show "Results ready (menu)". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'test/settings')
-rw-r--r--test/settings/ChangeQueueTest.mc239
-rw-r--r--test/settings/ManageTest.mc39
2 files changed, 269 insertions, 9 deletions
diff --git a/test/settings/ChangeQueueTest.mc b/test/settings/ChangeQueueTest.mc
new file mode 100644
index 0000000..8ad0feb
--- /dev/null
+++ b/test/settings/ChangeQueueTest.mc
@@ -0,0 +1,239 @@
+// 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<Array<Application.Storage.ValueType>>;
+ 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<Array<Application.Storage.ValueType>>;
+ 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;
+}
diff --git a/test/settings/ManageTest.mc b/test/settings/ManageTest.mc
index 1f050c5..4c93f60 100644
--- a/test/settings/ManageTest.mc
+++ b/test/settings/ManageTest.mc
@@ -1,6 +1,7 @@
// 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.Application;
import Toybox.Lang;
import Toybox.Test;
@@ -43,9 +44,29 @@ module ManageFixtures {
return s;
}
+ // What PinView runs after a correct PIN: promotion and waiting locks.
+ function applyLocks(v as Vault) as LockJob {
+ var job = new LockJob(v, true);
+ Jobs.runToCompletion(job, 1);
+ return job;
+ }
+
+ // Every queued item, opened (the ones that verify).
+ function load(v as Vault) as Array<ManageAction> {
+ var items = ChangeQueue.items(v);
+ var out = [] as Array<ManageAction>;
+ for (var i = 0; i < items.size(); i++) {
+ var a = ChangeQueue.open(v, items[i], new Jobs.Wiper(), true);
+ if (a != null) {
+ out.add(a);
+ }
+ }
+ return out;
+ }
+
// Confirm the waiting changes, as after a correct PIN.
function confirm(v as Vault) as ChangeQueueJob {
- var job = new ChangeQueueJob(v);
+ var job = new ChangeQueueJob(v, null);
Jobs.runToCompletion(job, 1);
return job;
}
@@ -256,7 +277,7 @@ function manageLocksEntries(logger as Test.Logger) as Boolean {
}
// Waiting "Require PIN" changes apply at the next PIN without "Confirm
-// all" (the PIN screen calls applyLocks); other changes keep waiting, and
+// all" (the PIN screen runs LockJob); other changes keep waiting, and
// the results of a following "Confirm all" list the locks too.
(:test)
function manageQueuedLocksApplyAtNextPin(logger as Test.Logger) as Boolean {
@@ -271,14 +292,14 @@ function manageQueuedLocksApplyAtNextPin(logger as Test.Logger) as Boolean {
s = ManageFixtures.send(vault, ids[1], ManageAction.LOCK);
SettingsFixtures.run(vault, s);
var counted = ChangeQueue.count(vault) == 3 && ChangeQueue.countLocks(vault) == 2;
- ChangeQueue.applyLocks(vault); // still locked: nothing happens
+ ManageFixtures.applyLocks(vault); // still locked: nothing happens
var untouched = ChangeQueue.count(vault) == 3 && !ManageFixtures.isLocked(vault, ids[0]);
StorageFixtures.unlock(vault, StorageFixtures.PIN);
- ChangeQueue.applyLocks(vault);
+ var locks = ManageFixtures.applyLocks(vault);
var left = ChangeQueue.descriptions(vault);
var applied = ManageFixtures.isLocked(vault, ids[0]) && ManageFixtures.isLocked(vault, ids[1])
&& left.size() == 1 && left[0].equals("Delete: " + bank + " Bank (alice@example.com)")
- && ChangeQueue.countLocks(vault) == 0 && vault.ids().indexOf(bank) >= 0;
+ && ChangeQueue.countLocks(vault) == 0 && vault.ids().indexOf(bank) >= 0 && locks.lines.size() == 2;
var job = ManageFixtures.confirm(vault);
var reported = job.lines.size() == 3 && job.lines[0].find(ids[0] + " ") == 0
&& job.lines[0].find("now requires the PIN") != null && SettingsFixtures.hasLine(job.lines, "Deleted " + bank);
@@ -299,7 +320,7 @@ function manageQueueKeepsLateArrivals(logger as Test.Logger) as Boolean {
var safe = ManageFixtures.addLocked(vault, "Safe");
var s = ManageFixtures.send(vault, bank, ManageAction.DELETE);
SettingsFixtures.run(vault, s);
- var job = new ChangeQueueJob(vault);
+ var job = new ChangeQueueJob(vault, null);
job.step(1);
s = ManageFixtures.send(vault, safe, ManageAction.DELETE);
SettingsFixtures.run(vault, s);
@@ -337,10 +358,10 @@ function manageQueueIsSealedAndLimited(logger as Test.Logger) as Boolean {
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];
+ var raw = (vault.storage() as MemoryBackend).raw(ChangeQueue.KEY) as Array<Array<Application.Storage.ValueType>>;
+ var box = raw[0][1] as ByteArray;
box[20] = box[20] ^ 1;
- var loaded = ChangeQueue.load(vault);
+ var loaded = ManageFixtures.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")