diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-19 15:27:37 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-19 15:27:37 +0200 |
| commit | 8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 (patch) | |
| tree | 80642928d00ab7ee463032db331f41ffcc7ccb11 /test/storage/VaultTest.mc | |
| parent | dd5d0f68726fe56ee4d199867d414aea81905cc4 (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.mc | 250 |
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; +} |
