aboutsummaryrefslogtreecommitdiff
path: root/test/storage/VaultTest.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
commit8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 (patch)
tree80642928d00ab7ee463032db331f41ffcc7ccb11 /test/storage/VaultTest.mc
parentdd5d0f68726fe56ee4d199867d414aea81905cc4 (diff)
test(storage): cover records, vault, PINs, lockout and migrations
34 new tests (65 per device): round trips per entry type, every tampered byte, swapped records, the pending queue, the entry cap, a full store at 67 fill levels, the lockout schedule on a fake clock including restarts, erase, PIN change and reset, auto-lock wiping, and v0 to v1 migration. Adds an in-memory store with a size limit and storage benchmarks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'test/storage/VaultTest.mc')
-rw-r--r--test/storage/VaultTest.mc250
1 files changed, 250 insertions, 0 deletions
diff --git a/test/storage/VaultTest.mc b/test/storage/VaultTest.mc
new file mode 100644
index 0000000..b089596
--- /dev/null
+++ b/test/storage/VaultTest.mc
@@ -0,0 +1,250 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the vault: add/open/update/move/remove, persistence, locked and pending entries, quotas, store full.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+function vaultRoundTripsAndPersists(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var time = new FakeTime();
+ var vault = new Vault(store, time);
+ var originals = [StorageFixtures.totp("A"), StorageFixtures.hotp("B", 3l), StorageFixtures.steam("C"), StorageFixtures.code("D")];
+ for (var i = 0; i < originals.size(); i++) {
+ vault.add(originals[i] as Entry);
+ }
+ // A new Vault on the same store is what the next app start sees.
+ var reopened = new Vault(store, time);
+ var ids = reopened.ids();
+ if (ids.size() != 4) {
+ logger.error("expected 4 ids, got " + ids.size());
+ return false;
+ }
+ for (var i = 0; i < ids.size(); i++) {
+ var expected = originals[i] as Entry;
+ if (!ids[i].equals(expected.id) || !Entry.isValidId(ids[i])) {
+ logger.error("order or id wrong at " + i);
+ return false;
+ }
+ var opened = reopened.open(ids[i]);
+ var same = StorageFixtures.same(logger, opened, expected);
+ opened.wipeSecrets();
+ var header = reopened.header(ids[i]);
+ if (!same || header == null || !header.name.equals(expected.name) || header.secret != null) {
+ return false;
+ }
+ }
+ return true;
+}
+
+(:test)
+function vaultUpdatesMovesAndRemoves(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ var a = vault.add(StorageFixtures.totp("A"));
+ var b = vault.add(StorageFixtures.totp("B"));
+ var c = vault.add(StorageFixtures.hotp("C", 0l));
+ vault.move(c, 0);
+ vault.move(a, 99);
+ var order = vault.ids();
+ if (!order[0].equals(c) || !order[1].equals(b) || !order[2].equals(a)) {
+ logger.error("move gave " + order.toString());
+ return false;
+ }
+ var e = vault.open(b);
+ e.name = "B renamed";
+ vault.update(e);
+ e.wipeSecrets();
+ vault.setCounter(c, 42l);
+ vault.remove(a);
+ var header = vault.header(b);
+ var hotp = vault.open(c);
+ var counter = hotp.counter;
+ hotp.wipeSecrets();
+ var notFound = 0;
+ try {
+ vault.open(a);
+ } catch (err instanceof VaultError) {
+ notFound = err.code;
+ }
+ return header != null && header.name.equals("B renamed") && counter == 42l
+ && vault.ids().size() == 2 && notFound == VaultError.NOT_FOUND;
+}
+
+(:test)
+function vaultRejectsInvalidEntries(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ var e = StorageFixtures.totp("bad");
+ e.digits = 4;
+ var code = 0;
+ try {
+ vault.add(e);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ return code == VaultError.INVALID && vault.ids().size() == 0;
+}
+
+// A damaged record shows as null from header(), DAMAGED from open(), and
+// can still be deleted.
+(:test)
+function vaultReportsDamagedRecords(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var id = vault.add(StorageFixtures.totp("A"));
+ var record = store.raw("e." + id) as ByteArray;
+ record[record.size() - 1] = record[record.size() - 1] ^ 0x80;
+ var code = 0;
+ try {
+ vault.open(id);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ // The header itself is intact, so the list can still show the name.
+ var headerStillReadable = vault.header(id) != null;
+ store.setValue("e." + id, "not a record");
+ var headerGone = vault.header(id) == null;
+ vault.remove(id);
+ return code == VaultError.DAMAGED && headerStillReadable && headerGone && vault.ids().size() == 0;
+}
+
+// Swapping two records' storage keys must not make either open.
+(:test)
+function vaultDetectsSwappedRecords(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var a = vault.add(StorageFixtures.totp("A"));
+ var b = vault.add(StorageFixtures.totp("B"));
+ var recordA = store.getValue("e." + a);
+ store.setValue("e." + a, store.getValue("e." + b));
+ store.setValue("e." + b, recordA);
+ var damaged = 0;
+ var ids = [a, b];
+ for (var i = 0; i < ids.size(); i++) {
+ try {
+ vault.open(ids[i]);
+ } catch (err instanceof VaultError) {
+ if (err.code == VaultError.DAMAGED) {
+ damaged++;
+ }
+ }
+ }
+ return damaged == 2;
+}
+
+(:test)
+function vaultLockedEntriesNeedTheSession(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ var e = StorageFixtures.totp("Locked");
+ e.locked = true;
+ var id = vault.add(e);
+ var record = store.raw("e." + id) as ByteArray;
+ if (Record.keyRef(record) != Record.KEY_MASTER) {
+ logger.error("locked entry not sealed with the master key");
+ return false;
+ }
+ vault.lock();
+ var codes = [] as Array<Number>;
+ try { vault.open(id); } catch (err instanceof VaultError) { codes.add(err.code); }
+ try { vault.remove(id); } catch (err instanceof VaultError) { codes.add(err.code); }
+ try { vault.setCounter(id, 1l); } catch (err instanceof VaultError) { codes.add(err.code); }
+ var header = vault.header(id);
+ if (codes.size() != 3 || codes[0] != VaultError.LOCKED || codes[1] != VaultError.LOCKED || header == null || !header.locked) {
+ logger.error("locked entry accessible while locked: " + codes.toString());
+ return false;
+ }
+ if (!StorageFixtures.unlock(vault, StorageFixtures.PIN)) {
+ logger.error("unlock failed");
+ return false;
+ }
+ var opened = vault.open(id);
+ var ok = StorageFixtures.same(logger, opened, e);
+ opened.wipeSecrets();
+ return ok;
+}
+
+(:test)
+function vaultQueuesLockedEntriesWhileLocked(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ // Before any PIN exists.
+ var first = StorageFixtures.totp("Pending 1");
+ first.locked = true;
+ var id1 = vault.add(first);
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ // While locked.
+ var second = StorageFixtures.code("Pending 2");
+ second.locked = true;
+ var id2 = vault.add(second);
+ if (vault.ids().size() != 0 || vault.pendingIds().size() != 2 || store.getValue("p." + id1) == null) {
+ logger.error("pending entries not queued");
+ return false;
+ }
+ if (!StorageFixtures.unlock(vault, StorageFixtures.PIN) || vault.promotePending() != 2) {
+ logger.error("promotion failed");
+ return false;
+ }
+ var ids = vault.ids();
+ var record = store.raw("e." + id2) as ByteArray;
+ var opened = vault.open(id1);
+ var ok = StorageFixtures.same(logger, opened, first);
+ opened.wipeSecrets();
+ return ok && ids.size() == 2 && vault.pendingIds().size() == 0 && store.getValue("p." + id1) == null
+ && store.getValue("p." + id2) == null && Record.keyRef(record) == Record.KEY_MASTER;
+}
+
+(:test)
+function vaultEnforcesTheEntryCap(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ for (var i = 0; i < Vault.MAX_ENTRIES; i++) {
+ vault.add(StorageFixtures.totp("E" + i));
+ }
+ var code = 0;
+ try {
+ vault.add(StorageFixtures.totp("one too many"));
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ return code == VaultError.LIMIT && vault.ids().size() == Vault.MAX_ENTRIES;
+}
+
+// A full store gives VaultError FULL and leaves no half-added entry behind,
+// whether the record write or the metadata write is the one that fails.
+(:test)
+function vaultHandlesAFullStore(logger as Test.Logger) as Boolean {
+ var sawFull = false;
+ var sawSuccess = false;
+ for (var extra = 0; extra < 600; extra += 9) {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ vault.add(StorageFixtures.totp("fits"));
+ store.capacity = store.usedBytes() + extra;
+ var expected = 2;
+ try {
+ vault.add(StorageFixtures.totp("more"));
+ sawSuccess = true;
+ } catch (err instanceof VaultError) {
+ if (err.code != VaultError.FULL) {
+ logger.error("unexpected error " + err.code);
+ return false;
+ }
+ sawFull = true;
+ expected = 1;
+ }
+ var reopened = new Vault(store, new FakeTime());
+ var keys = store.keys();
+ var records = 0;
+ for (var i = 0; i < keys.size(); i++) {
+ if (keys[i].find("e.") == 0) {
+ records++;
+ }
+ }
+ if (reopened.ids().size() != expected || records != expected) {
+ logger.error("inconsistent store at extra=" + extra + ": ids " + reopened.ids().size() + ", records " + records);
+ return false;
+ }
+ }
+ return sawFull && sawSuccess;
+}