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/StorageSupport.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/StorageSupport.mc')
| -rw-r--r-- | test/storage/StorageSupport.mc | 250 |
1 files changed, 250 insertions, 0 deletions
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<Application.Storage.KeyType, Application.Storage.ValueType> = {} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>; + + 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<String> { + return _data.keys() as Array<String>; + } + + 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<Application.Storage.ValueType>; + 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<Application.Storage.KeyType, Application.Storage.ValueType>; + 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<String>; + 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; + } +} |
