From 8ac04a0c2f5cd949a45866f938bd1aa72fbe56e2 Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 15:27:37 +0200 Subject: 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 --- test/storage/StorageSupport.mc | 250 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 test/storage/StorageSupport.mc (limited to 'test/storage/StorageSupport.mc') diff --git a/test/storage/StorageSupport.mc b/test/storage/StorageSupport.mc new file mode 100644 index 0000000..51a0c7c --- /dev/null +++ b/test/storage/StorageSupport.mc @@ -0,0 +1,250 @@ +// Author: cirakg & claude ai +// Summary: Test doubles for storage: an in-memory store with a size limit, a hand-set clock, and fake entries. +// +// MemoryBackend copies values on the way in and out, as Application.Storage +// does, so a test can't pass by keeping a reference to stored data. raw() +// gives tests the stored object itself, for tampering. + +import Toybox.Application; +import Toybox.Lang; +import Toybox.Test; + +(:test) +class MemoryBackend extends Persist.Backend { + + // Total size limit in (approximate) bytes; setValue throws above it. + var capacity as Number = 1000000; + private var _data as Dictionary = {} as Dictionary; + + function initialize() { + Backend.initialize(); + } + + function getValue(key as String) as Application.Storage.ValueType { + return MemoryBackend.copyOf(_data[key]); + } + + function setValue(key as String, value as Application.Storage.ValueType) as Void { + var hadKey = _data.hasKey(key); + var old = _data[key]; + _data[key] = MemoryBackend.copyOf(value); + if (usedBytes() > capacity) { + if (hadKey) { + _data[key] = old; + } else { + _data.remove(key); + } + throw new Lang.StorageFullException("Storage full"); + } + } + + function deleteValue(key as String) as Void { + _data.remove(key); + } + + // The stored object itself (not a copy), for tampering in tests. + function raw(key as String) as Application.Storage.ValueType { + return _data[key]; + } + + function keys() as Array { + return _data.keys() as Array; + } + + function usedBytes() as Number { + var total = 0; + var keys = _data.keys(); + for (var i = 0; i < keys.size(); i++) { + var key = keys[i] as String; + total += key.length() + MemoryBackend.sizeOf(_data[key]); + } + return total; + } + + // Rough serialized size, enough to make "store full" reachable. + static function sizeOf(value as Application.Storage.ValueType) as Number { + if (value instanceof ByteArray) { + return value.size(); + } + if (value instanceof String) { + return value.length(); + } + if (value instanceof Array) { + var total = 0; + for (var i = 0; i < value.size(); i++) { + total += sizeOf(value[i] as Application.Storage.ValueType); + } + return total; + } + if (value instanceof Dictionary) { + var total = 0; + var keys = value.keys(); + for (var i = 0; i < keys.size(); i++) { + total += sizeOf(keys[i] as Application.Storage.ValueType) + sizeOf(value[keys[i]] as Application.Storage.ValueType); + } + return total; + } + return 8; + } + + // Deep copy of arrays, dictionaries and byte arrays. + static function copyOf(value as Application.Storage.ValueType) as Application.Storage.ValueType { + if (value instanceof ByteArray) { + return Bytes.copy(value); + } + if (value instanceof Array) { + var out = [] as Array; + for (var i = 0; i < value.size(); i++) { + out.add(copyOf(value[i] as Application.Storage.ValueType)); + } + return out; + } + if (value instanceof Dictionary) { + var out = {} as Dictionary; + var keys = value.keys(); + for (var i = 0; i < keys.size(); i++) { + out[keys[i] as Application.Storage.KeyType] = copyOf(value[keys[i]] as Application.Storage.ValueType); + } + return out; + } + return value; + } +} + +// A clock the test sets by hand. +(:test) +class FakeTime extends TimeSource { + + var trusted as Long? = 1800000000l; + var uptime as Number = 1000; + + function initialize() { + TimeSource.initialize(); + } + + function trustedUnix() as Long? { + return trusted; + } + + function uptimeMs() as Number { + return uptime; + } + + // Advance both clocks (the trusted one only when it is synced). + function advance(seconds as Number) as Void { + uptime += seconds * 1000; + var t = trusted; + if (t != null) { + trusted = t + seconds; + } + } +} + +(:test) +module StorageFixtures { + + const PIN = "246810"; + const OTHER_PIN = "135799"; + const ITERATIONS = 2000; // Kdf.MIN_ITERATIONS: the cheapest allowed + + // A fake TOTP entry (the secret is the RFC 4226 test key, not a real one). + function totp(name as String) as Entry { + var e = new Entry(); + e.kind = Entry.KIND_OTP; + e.type = Entry.OTP_TOTP; + e.name = name; + e.account = "alice@example.com"; + e.group = "Work"; + e.algorithm = Hashes.SHA256; + e.digits = 8; + e.period = 60; + e.secret = Bytes.fromUtf8("12345678901234567890"); + e.created = 1700000000l; + return e; + } + + function hotp(name as String, counter as Long) as Entry { + var e = totp(name); + e.type = Entry.OTP_HOTP; + e.algorithm = Hashes.SHA1; + e.digits = 6; + e.period = 30; + e.counter = counter; + return e; + } + + function steam(name as String) as Entry { + var e = totp(name); + e.type = Entry.OTP_STEAM; + e.algorithm = Hashes.SHA1; + e.digits = 6; + e.period = 30; + return e; + } + + function code(name as String) as Entry { + var e = new Entry(); + e.kind = Entry.KIND_CODE; + e.type = 3; + e.name = name; + e.payload = Bytes.fromUtf8("CARD-0000-1111-2222 éß"); + e.options = 0x102; + e.source = Entry.SOURCE_IMPORT; + e.favorite = true; + return e; + } + + function pin(text as String) as ByteArray { + return Bytes.fromUtf8(text); + } + + // Set the first PIN (leaves the session unlocked). + function setPin(vault as Vault, text as String) as Void { + var job = vault.beginSetPin(pin(text), ITERATIONS); + Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP); + vault.finishSetPin(job); + } + + // One unlock attempt; true when the PIN was right. + function unlock(vault as Vault, text as String) as Boolean { + var job = vault.beginUnlock(pin(text)); + Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP); + return vault.finishUnlock(job); + } + + // True when the two entries hold the same fields (secrets included). + function same(logger as Test.Logger, a as Entry, b as Entry) as Boolean { + var problems = [] as Array; + if (!a.id.equals(b.id)) { problems.add("id"); } + if (a.kind != b.kind || a.type != b.type) { problems.add("kind/type"); } + if (!a.name.equals(b.name) || !a.account.equals(b.account) || !a.group.equals(b.group)) { problems.add("names"); } + if (a.locked != b.locked || a.source != b.source || a.favorite != b.favorite) { problems.add("flags"); } + if (a.counter != b.counter || a.created != b.created) { problems.add("counter/created"); } + if (a.namesEncrypted != b.namesEncrypted) { problems.add("namesEncrypted"); } + if (a.algorithm != b.algorithm || a.digits != b.digits || a.period != b.period || a.options != b.options) { + problems.add("parameters"); + } + if (!sameOptional(a.secret, b.secret) || !sameOptional(a.payload, b.payload)) { problems.add("secret/payload"); } + if (problems.size() > 0) { + logger.error("entries differ in " + problems.toString()); + return false; + } + return true; + } + + function sameOptional(a as ByteArray?, b as ByteArray?) as Boolean { + if (a == null || b == null) { + return a == null && b == null; + } + return Bytes.equal(a, b); + } + + function allZero(data as ByteArray) as Boolean { + for (var i = 0; i < data.size(); i++) { + if (data[i] != 0) { + return false; + } + } + return true; + } +} -- cgit v1.2.3