summaryrefslogtreecommitdiff
path: root/test/import/ImporterTest.mc
blob: 411e5285efbdbbbcf3d0a5776072d37b0d7a2f6b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// Author:  cirakg & claude ai
// Summary: Tests for the import job: reports, duplicates, replacements, locking, Replace mode, multi-part buffer, failures, a full store, 100 entries.

import Toybox.Lang;
import Toybox.System;
import Toybox.Test;

(:test)
module ImporterFixtures {

    const NOW = 1800000000l;

    function uri(issuer as String, account as String, secret as String) as String {
        return "otpauth://totp/" + issuer + ":" + account + "?secret=" + secret;
    }

    function vault() as Vault {
        return new Vault(new MemoryBackend(), new FakeTime());
    }
}

(:test)
function importerStoresUrisAndReports(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    var text = ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_1) + "\n"
        + ImporterFixtures.uri("Mail", "bob", AegisFixtures.SECRET_2) + "\n"
        + "otpauth://totp/Bad:carol?secret=" + AegisFixtures.SECRET_3 + "&digits=9\n"
        + "otpauth://motp/Old:dan?secret=" + AegisFixtures.SECRET_3;
    var report = AegisFixtures.runImport(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    var ids = vault.ids();
    var first = vault.open(ids[0]);
    var ok = first.name.equals("GitHub") && first.source == Entry.SOURCE_IMPORT && first.created == ImporterFixtures.NOW;
    first.wipeSecrets();
    var summary = report.summary();
    if (!summary.equals("Imported 2 · 1 error · 1 skipped")) {
        logger.error("summary: " + summary);
        return false;
    }
    return ok && ids.size() == 2 && AegisFixtures.hasDetail(report, "URI 3 'Bad': 9 digits")
        && AegisFixtures.hasDetail(report, "URI 4 'Old': skipped, mOTP");
}

// The same type and secret is a duplicate, also of a locked entry while the
// watch is locked (fingerprints); same issuer and account with another
// secret is imported and flagged.
(:test)
function importerFindsDuplicatesAndReplacements(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    var locked = StorageFixtures.totp("Vault");
    locked.secret = Bytes.fromUtf8("12345678901234567890");
    locked.algorithm = Hashes.SHA1;
    locked.locked = true;
    var lockedId = vault.add(locked);
    vault.lock();
    var first = AegisFixtures.runImport(vault, ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_2),
        Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    var text = ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_2) + " "  // duplicate of the first import
        + ImporterFixtures.uri("Other", "x", AegisFixtures.SECRET_1) + " "             // duplicate of the locked entry
        + ImporterFixtures.uri("GitHub", "alice", AegisFixtures.SECRET_3) + " "        // same names, new secret
        + ImporterFixtures.uri("Twice", "y", AegisFixtures.SECRET_3);                  // duplicate within this import
    var second = AegisFixtures.runImport(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    if (second.duplicates != 3 || second.imported != 1) {
        logger.error(second.summary());
        return false;
    }
    return first.imported == 1 && AegisFixtures.hasDetail(second, "duplicate of " + lockedId)
        && AegisFixtures.hasDetail(second, "possible replacement for") && vault.ids().size() == 3;
}

// "Require PIN" locks the whole import; while locked they wait as pending.
(:test)
function importerLocksEntriesAsPending(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    vault.lock();
    var report = AegisFixtures.runImport(vault, ImporterFixtures.uri("Bank", "eve", AegisFixtures.SECRET_1),
        Importer.MODE_APPEND, true, ImporterFixtures.NOW);
    return report.imported == 1 && report.pendingLocked == 1 && vault.pendingIds().size() == 1 && vault.ids().size() == 0
        && report.summary().find("waiting for your PIN") != null;
}

// Replace mode deletes earlier imported entries, never manual ones.
(:test)
function importerReplaceModeKeepsManualEntries(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    var manualId = vault.add(StorageFixtures.totp("Manual"));
    // (The manual fixture uses SECRET_1's bytes, so the imports use other secrets.)
    AegisFixtures.runImport(vault, ImporterFixtures.uri("Old", "a", AegisFixtures.SECRET_2) + " "
        + ImporterFixtures.uri("Older", "b", AegisFixtures.SECRET_3), Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    // The same secret as a replaced entry is not a duplicate any more.
    var report = AegisFixtures.runImport(vault, ImporterFixtures.uri("New", "c", AegisFixtures.SECRET_2),
        Importer.MODE_REPLACE, false, ImporterFixtures.NOW);
    var ids = vault.ids();
    var newest = vault.header(ids[1]);
    return report.removed == 2 && report.imported == 1 && report.duplicates == 0 && ids.size() == 2
        && ids[0].equals(manualId) && newest != null && newest.name.equals("New");
}

// JSON cut in two parts: the first waits (sealed in storage), the second
// completes the import and clears the buffer.
(:test)
function importerJoinsMultiPartJson(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    var store = vault.storage() as MemoryBackend;
    var full = AegisFixtures.export(3, [AegisFixtures.totp("A", "a", AegisFixtures.SECRET_1),
        AegisFixtures.totp("B", "b", AegisFixtures.SECRET_2), AegisFixtures.totp("C", "c", AegisFixtures.SECRET_3)], "");
    var cut = full.length() / 2;
    var part1 = AegisFixtures.runImport(vault, full.substring(0, cut) as String, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    var waiting = part1.waitingChars == cut && ImportBuffer.hasPending(store) && vault.ids().size() == 0
        && part1.summary().find("Waiting for more") != null;
    // The buffer is sealed: the secret's Base32 text isn't readable in storage.
    var chunk = store.raw("imp.0") as ByteArray;
    var sealed = Bytes.toHex(chunk).find(Bytes.toHex(Bytes.fromUtf8(AegisFixtures.SECRET_1))) == null;
    var part2 = AegisFixtures.runImport(vault, full.substring(cut, full.length()) as String, Importer.MODE_APPEND, false,
        ImporterFixtures.NOW + 60);
    return waiting && sealed && part2.imported == 3 && !ImportBuffer.hasPending(store);
}

(:test)
function importerDiscardsExpiredParts(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    var full = AegisFixtures.export(3, [AegisFixtures.totp("A", "a", AegisFixtures.SECRET_1)], "");
    var cut = full.length() / 2;
    AegisFixtures.runImport(vault, full.substring(0, cut) as String, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    var late = AegisFixtures.runImport(vault, full.substring(cut, full.length()) as String, Importer.MODE_APPEND, false,
        ImporterFixtures.NOW + ImportBuffer.MAX_AGE_S + 1);
    return late.failure != null && vault.ids().size() == 0 && !ImportBuffer.hasPending(vault.storage());
}

(:test)
function importerExplainsUnusableText(logger as Test.Logger) as Boolean {
    var cases = [
        ["", "empty"],
        ["hello world", "not recognised"],
        ["otpauth-migration://offline?data=CjEKCkhlbGxvId6tvu8", "Google Authenticator"],
        ["{\"version\": 1, \"header\": {\"slots\": [1]}, \"db\": \"abc\"}", "encrypted"],
        ["{\"version\": 1, \"db\": [1,]}", "not valid JSON"]
    ];
    for (var i = 0; i < cases.size(); i++) {
        var c = cases[i] as Array<String>;
        var report = AegisFixtures.runImport(ImporterFixtures.vault(), c[0], Importer.MODE_APPEND, false, ImporterFixtures.NOW);
        var failure = report.failure;
        if (failure == null || failure.find(c[1]) == null) {
            logger.error("case " + i + ": " + failure);
            return false;
        }
    }
    return true;
}

// A store that fills up mid-import keeps the whole entries stored so far.
(:test)
function importerStopsCleanlyWhenTheStoreIsFull(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    var store = vault.storage() as MemoryBackend;
    vault.add(StorageFixtures.totp("Existing"));
    store.capacity = store.usedBytes() + 500;
    var text = "";
    for (var i = 0; i < 10; i++) {
        text += ImporterFixtures.uri("E" + i, "a", Base32.encode(Bytes.fromUtf8("fake" + (200000 + i).toString()))) + " ";
    }
    var report = AegisFixtures.runImport(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    var reopened = new Vault(store, new FakeTime());
    logger.debug("full store: " + report.summary() + " " + report.details.toString());
    return report.imported >= 1 && report.imported < 10 && AegisFixtures.hasDetail(report, "Stopped")
        && reopened.ids().size() == 1 + report.imported;
}

// Fingerprints: same type and secret give the same 8 bytes, anything else
// differs; they are in the header and survive a round trip.
(:test)
function importerFingerprintsIdentifySecrets(logger as Test.Logger) as Boolean {
    var vault = ImporterFixtures.vault();
    var a = StorageFixtures.totp("A");
    var b = StorageFixtures.totp("B");
    var c = StorageFixtures.hotp("C", 0l);
    var d = StorageFixtures.totp("D");
    d.secret = Bytes.fromUtf8("another secret");
    var fa = vault.keyManager.fingerprint(a);
    var id = vault.add(a);
    var header = vault.header(id);
    return fa.size() == 8 && Bytes.equal(fa, vault.keyManager.fingerprint(b))
        && !Bytes.equal(fa, vault.keyManager.fingerprint(c)) && !Bytes.equal(fa, vault.keyManager.fingerprint(d))
        && header != null && Bytes.equal(header.fingerprint as ByteArray, fa);
}

// 100 entries from one Aegis export: all stored, timed for PERFORMANCE.md.
(:test)
function importerHandlesOneHundredEntries(logger as Test.Logger) as Boolean {
    var entries = [] as Array<String>;
    for (var i = 0; i < 100; i++) {
        // Distinct fake secrets: Base32 of a counter-based 10-byte value.
        var raw = Bytes.fromUtf8("fake" + (100000 + i).toString());
        entries.add(AegisFixtures.totp("Issuer " + i, "user" + i + "@example.com", Base32.encode(raw)));
    }
    var text = AegisFixtures.export(3, entries, "");
    var vault = ImporterFixtures.vault();
    var start = System.getTimer();
    var job = new Importer(vault, text, Importer.MODE_APPEND, false, ImporterFixtures.NOW);
    var steps = 0;
    while (!job.step(1)) {
        steps++;
    }
    var elapsed = System.getTimer() - start;
    logger.debug("BENCH import_100_aegis ms=" + elapsed + " steps=" + steps + " json_bytes=" + text.length());
    return job.report.imported == 100 && vault.ids().size() == 100;
}