summaryrefslogtreecommitdiff
path: root/test/settings/ManageTest.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
commit479d7ba2e843d5ea93979f2eed68f4d38d146c25 (patch)
tree52b0bc58a7456e8675e977698db20e55515b7100 /test/settings/ManageTest.mc
parent151bfda5d4c567ff909a61dc9c90bf8eece1db37 (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/ManageTest.mc')
-rw-r--r--test/settings/ManageTest.mc320
1 files changed, 320 insertions, 0 deletions
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;
+}