@learncard/sss-key-manager 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/dist/sss-key-manager.cjs.development.js +126 -36
- package/dist/sss-key-manager.cjs.development.js.map +2 -2
- package/dist/sss-key-manager.cjs.production.min.js +6 -6
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +126 -36
- package/dist/sss-key-manager.esm.js.map +2 -2
- package/package.json +62 -52
- package/src/api-client.ts +257 -0
- package/src/atomic-operations.test.ts +327 -0
- package/src/atomic-operations.ts +275 -0
- package/src/auth-coordinator.test.ts +13 -0
- package/src/auth-coordinator.ts +12 -0
- package/src/critical-paths.test.ts +380 -0
- package/src/crypto.test.ts +214 -0
- package/src/crypto.ts +203 -0
- package/src/index.ts +146 -0
- package/src/key-manager.test.ts +330 -0
- package/src/key-manager.ts +323 -0
- package/src/passkey.test.ts +59 -0
- package/src/passkey.ts +222 -0
- package/src/qr-crypto.test.ts +122 -0
- package/src/qr-crypto.ts +206 -0
- package/src/qr-login-notify.test.ts +95 -0
- package/src/qr-login.test.ts +548 -0
- package/src/qr-login.ts +339 -0
- package/src/recovery-phrase.test.ts +287 -0
- package/src/recovery-phrase.ts +131 -0
- package/src/sss-strategy.test.ts +1956 -0
- package/src/sss-strategy.ts +1119 -0
- package/src/sss.test.ts +242 -0
- package/src/sss.ts +49 -0
- package/src/storage.test.ts +530 -0
- package/src/storage.ts +467 -0
- package/src/types.ts +200 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage layer tests for SSS device share management.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Basic device share CRUD (store, get, has, delete)
|
|
6
|
+
* - Share version metadata (store, get, round-trip)
|
|
7
|
+
* - listAllDeviceShares skips :version metadata keys
|
|
8
|
+
* - deleteDeviceShare cleans up companion :version key
|
|
9
|
+
* - clearAllShares (full wipe and per-user) removes version metadata
|
|
10
|
+
* - Multi-user isolation
|
|
11
|
+
*
|
|
12
|
+
* Uses jsdom's fake IndexedDB + real Web Crypto (AES-GCM).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import 'fake-indexeddb/auto';
|
|
16
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
storeDeviceShare,
|
|
20
|
+
getDeviceShare,
|
|
21
|
+
hasDeviceShare,
|
|
22
|
+
deleteDeviceShare,
|
|
23
|
+
clearAllShares,
|
|
24
|
+
listAllDeviceShares,
|
|
25
|
+
storeShareVersion,
|
|
26
|
+
getShareVersion,
|
|
27
|
+
} from './storage';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Wipe the entire IndexedDB database between tests for isolation. */
|
|
34
|
+
const wipeDB = async () => {
|
|
35
|
+
await clearAllShares(); // deletes the whole DB when called with no arg
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Tests
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
describe('SSS Storage', () => {
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
await wipeDB();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// -----------------------------------------------------------------------
|
|
48
|
+
// Basic device share lifecycle
|
|
49
|
+
// -----------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('device share CRUD', () => {
|
|
52
|
+
it('hasDeviceShare returns false when nothing is stored', async () => {
|
|
53
|
+
expect(await hasDeviceShare()).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('store → get round-trip returns the original share', async () => {
|
|
57
|
+
await storeDeviceShare('my-secret-share');
|
|
58
|
+
|
|
59
|
+
expect(await hasDeviceShare()).toBe(true);
|
|
60
|
+
expect(await getDeviceShare()).toBe('my-secret-share');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('deleteDeviceShare removes the share', async () => {
|
|
64
|
+
await storeDeviceShare('to-delete');
|
|
65
|
+
|
|
66
|
+
expect(await hasDeviceShare()).toBe(true);
|
|
67
|
+
|
|
68
|
+
await deleteDeviceShare();
|
|
69
|
+
|
|
70
|
+
expect(await hasDeviceShare()).toBe(false);
|
|
71
|
+
expect(await getDeviceShare()).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('scoped shares are isolated by id', async () => {
|
|
75
|
+
const idA = 'sss-device-share:user-a';
|
|
76
|
+
const idB = 'sss-device-share:user-b';
|
|
77
|
+
|
|
78
|
+
await storeDeviceShare('share-a', idA);
|
|
79
|
+
await storeDeviceShare('share-b', idB);
|
|
80
|
+
|
|
81
|
+
expect(await getDeviceShare(idA)).toBe('share-a');
|
|
82
|
+
expect(await getDeviceShare(idB)).toBe('share-b');
|
|
83
|
+
|
|
84
|
+
await deleteDeviceShare(idA);
|
|
85
|
+
|
|
86
|
+
expect(await hasDeviceShare(idA)).toBe(false);
|
|
87
|
+
expect(await getDeviceShare(idB)).toBe('share-b');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
// Share version metadata
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
describe('share version metadata', () => {
|
|
96
|
+
it('getShareVersion returns null when no version is stored', async () => {
|
|
97
|
+
expect(await getShareVersion()).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('store → get round-trip returns the correct version', async () => {
|
|
101
|
+
await storeShareVersion(3);
|
|
102
|
+
|
|
103
|
+
expect(await getShareVersion()).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('overwriting a version replaces it', async () => {
|
|
107
|
+
await storeShareVersion(1);
|
|
108
|
+
|
|
109
|
+
expect(await getShareVersion()).toBe(1);
|
|
110
|
+
|
|
111
|
+
await storeShareVersion(5);
|
|
112
|
+
|
|
113
|
+
expect(await getShareVersion()).toBe(5);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('scoped versions are isolated by id', async () => {
|
|
117
|
+
const idA = 'sss-device-share:user-a';
|
|
118
|
+
const idB = 'sss-device-share:user-b';
|
|
119
|
+
|
|
120
|
+
await storeShareVersion(2, idA);
|
|
121
|
+
await storeShareVersion(7, idB);
|
|
122
|
+
|
|
123
|
+
expect(await getShareVersion(idA)).toBe(2);
|
|
124
|
+
expect(await getShareVersion(idB)).toBe(7);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('version persists independently of the device share', async () => {
|
|
128
|
+
const id = 'sss-device-share:user-x';
|
|
129
|
+
|
|
130
|
+
await storeDeviceShare('the-share', id);
|
|
131
|
+
await storeShareVersion(4, id);
|
|
132
|
+
|
|
133
|
+
// Re-store the share (e.g. after rotation) — version should remain
|
|
134
|
+
await storeDeviceShare('new-share', id);
|
|
135
|
+
|
|
136
|
+
expect(await getShareVersion(id)).toBe(4);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// -----------------------------------------------------------------------
|
|
141
|
+
// listAllDeviceShares — must skip :version metadata keys
|
|
142
|
+
// -----------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe('listAllDeviceShares', () => {
|
|
145
|
+
it('returns empty array when nothing stored', async () => {
|
|
146
|
+
const entries = await listAllDeviceShares();
|
|
147
|
+
|
|
148
|
+
expect(entries).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns stored shares with previews', async () => {
|
|
152
|
+
const id = 'sss-device-share:user-1';
|
|
153
|
+
|
|
154
|
+
await storeDeviceShare('abcdefgh12345678', id);
|
|
155
|
+
|
|
156
|
+
const entries = await listAllDeviceShares();
|
|
157
|
+
|
|
158
|
+
expect(entries).toHaveLength(1);
|
|
159
|
+
expect(entries[0]!.id).toBe(id);
|
|
160
|
+
expect(entries[0]!.preview).toContain('...');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does NOT include :version metadata keys as separate entries', async () => {
|
|
164
|
+
const id = 'sss-device-share:user-1';
|
|
165
|
+
|
|
166
|
+
await storeDeviceShare('abcdefgh12345678', id);
|
|
167
|
+
await storeShareVersion(3, id);
|
|
168
|
+
|
|
169
|
+
const entries = await listAllDeviceShares();
|
|
170
|
+
|
|
171
|
+
// Should only see the share entry, NOT the :version entry
|
|
172
|
+
expect(entries).toHaveLength(1);
|
|
173
|
+
expect(entries[0]!.id).toBe(id);
|
|
174
|
+
|
|
175
|
+
// No entry should have an id ending in :version
|
|
176
|
+
const versionEntries = entries.filter(e => e.id.endsWith(':version'));
|
|
177
|
+
|
|
178
|
+
expect(versionEntries).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('attaches shareVersion to the correct entry', async () => {
|
|
182
|
+
const id = 'sss-device-share:user-1';
|
|
183
|
+
|
|
184
|
+
await storeDeviceShare('abcdefgh12345678', id);
|
|
185
|
+
await storeShareVersion(5, id);
|
|
186
|
+
|
|
187
|
+
const entries = await listAllDeviceShares();
|
|
188
|
+
|
|
189
|
+
expect(entries).toHaveLength(1);
|
|
190
|
+
expect(entries[0]!.shareVersion).toBe(5);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('shareVersion is undefined for entries without a stored version', async () => {
|
|
194
|
+
const id = 'sss-device-share:legacy-user';
|
|
195
|
+
|
|
196
|
+
await storeDeviceShare('legacysharedata1', id);
|
|
197
|
+
|
|
198
|
+
const entries = await listAllDeviceShares();
|
|
199
|
+
|
|
200
|
+
expect(entries).toHaveLength(1);
|
|
201
|
+
expect(entries[0]!.shareVersion).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('handles multiple users with and without versions', async () => {
|
|
205
|
+
const idA = 'sss-device-share:user-a';
|
|
206
|
+
const idB = 'sss-device-share:user-b';
|
|
207
|
+
|
|
208
|
+
await storeDeviceShare('shareAAAAAAAAAAAA', idA);
|
|
209
|
+
await storeShareVersion(2, idA);
|
|
210
|
+
|
|
211
|
+
await storeDeviceShare('shareBBBBBBBBBBBB', idB);
|
|
212
|
+
// user-b has no version (legacy)
|
|
213
|
+
|
|
214
|
+
const entries = await listAllDeviceShares();
|
|
215
|
+
|
|
216
|
+
expect(entries).toHaveLength(2);
|
|
217
|
+
|
|
218
|
+
const entryA = entries.find(e => e.id === idA);
|
|
219
|
+
const entryB = entries.find(e => e.id === idB);
|
|
220
|
+
|
|
221
|
+
expect(entryA).toBeDefined();
|
|
222
|
+
expect(entryA!.shareVersion).toBe(2);
|
|
223
|
+
|
|
224
|
+
expect(entryB).toBeDefined();
|
|
225
|
+
expect(entryB!.shareVersion).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// -----------------------------------------------------------------------
|
|
230
|
+
// deleteDeviceShare — must also remove companion :version key
|
|
231
|
+
// -----------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('deleteDeviceShare cleans up version metadata', () => {
|
|
234
|
+
it('removes the :version key when deleting a share', async () => {
|
|
235
|
+
const id = 'sss-device-share:user-cleanup';
|
|
236
|
+
|
|
237
|
+
await storeDeviceShare('share-to-delete', id);
|
|
238
|
+
await storeShareVersion(3, id);
|
|
239
|
+
|
|
240
|
+
// Sanity check
|
|
241
|
+
expect(await getShareVersion(id)).toBe(3);
|
|
242
|
+
|
|
243
|
+
await deleteDeviceShare(id);
|
|
244
|
+
|
|
245
|
+
// Share should be gone
|
|
246
|
+
expect(await hasDeviceShare(id)).toBe(false);
|
|
247
|
+
|
|
248
|
+
// Version should also be gone
|
|
249
|
+
expect(await getShareVersion(id)).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('deleting one user does not affect another user\'s version', async () => {
|
|
253
|
+
const idA = 'sss-device-share:user-a';
|
|
254
|
+
const idB = 'sss-device-share:user-b';
|
|
255
|
+
|
|
256
|
+
await storeDeviceShare('share-a', idA);
|
|
257
|
+
await storeShareVersion(1, idA);
|
|
258
|
+
|
|
259
|
+
await storeDeviceShare('share-b', idB);
|
|
260
|
+
await storeShareVersion(2, idB);
|
|
261
|
+
|
|
262
|
+
await deleteDeviceShare(idA);
|
|
263
|
+
|
|
264
|
+
// User A is fully gone
|
|
265
|
+
expect(await hasDeviceShare(idA)).toBe(false);
|
|
266
|
+
expect(await getShareVersion(idA)).toBeNull();
|
|
267
|
+
|
|
268
|
+
// User B is untouched
|
|
269
|
+
expect(await getDeviceShare(idB)).toBe('share-b');
|
|
270
|
+
expect(await getShareVersion(idB)).toBe(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('after deletion, listAllDeviceShares has no orphaned entries', async () => {
|
|
274
|
+
const id = 'sss-device-share:user-x';
|
|
275
|
+
|
|
276
|
+
await storeDeviceShare('share-x-data-here', id);
|
|
277
|
+
await storeShareVersion(7, id);
|
|
278
|
+
|
|
279
|
+
await deleteDeviceShare(id);
|
|
280
|
+
|
|
281
|
+
const entries = await listAllDeviceShares();
|
|
282
|
+
|
|
283
|
+
expect(entries).toHaveLength(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// -----------------------------------------------------------------------
|
|
288
|
+
// clearAllShares — full wipe and per-user
|
|
289
|
+
// -----------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
describe('clearAllShares', () => {
|
|
292
|
+
it('per-user clear removes share and version for that user only', async () => {
|
|
293
|
+
const idA = 'sss-device-share:user-a';
|
|
294
|
+
const idB = 'sss-device-share:user-b';
|
|
295
|
+
|
|
296
|
+
await storeDeviceShare('share-a', idA);
|
|
297
|
+
await storeShareVersion(1, idA);
|
|
298
|
+
|
|
299
|
+
await storeDeviceShare('share-b', idB);
|
|
300
|
+
await storeShareVersion(2, idB);
|
|
301
|
+
|
|
302
|
+
await clearAllShares(idA);
|
|
303
|
+
|
|
304
|
+
expect(await hasDeviceShare(idA)).toBe(false);
|
|
305
|
+
expect(await getShareVersion(idA)).toBeNull();
|
|
306
|
+
|
|
307
|
+
expect(await getDeviceShare(idB)).toBe('share-b');
|
|
308
|
+
expect(await getShareVersion(idB)).toBe(2);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('full wipe (no id) removes everything', async () => {
|
|
312
|
+
await storeDeviceShare('share-1', 'sss-device-share:u1');
|
|
313
|
+
await storeShareVersion(1, 'sss-device-share:u1');
|
|
314
|
+
|
|
315
|
+
await storeDeviceShare('share-2', 'sss-device-share:u2');
|
|
316
|
+
await storeShareVersion(5, 'sss-device-share:u2');
|
|
317
|
+
|
|
318
|
+
await clearAllShares();
|
|
319
|
+
|
|
320
|
+
const entries = await listAllDeviceShares();
|
|
321
|
+
|
|
322
|
+
expect(entries).toHaveLength(0);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// -----------------------------------------------------------------------
|
|
327
|
+
// Encryption integrity — ensures AES-GCM additional data binding
|
|
328
|
+
// -----------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
describe('encryption integrity', () => {
|
|
331
|
+
it('share encrypted under one id cannot be read under a different id', async () => {
|
|
332
|
+
// Store under id A
|
|
333
|
+
await storeDeviceShare('secret-share', 'id-a');
|
|
334
|
+
|
|
335
|
+
// Try to read under id B — should fail because AES-GCM uses id as AD
|
|
336
|
+
// getDeviceShare catches decrypt errors and returns null
|
|
337
|
+
const result = await getDeviceShare('id-b');
|
|
338
|
+
|
|
339
|
+
expect(result).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('re-storing a share re-encrypts with fresh IV', async () => {
|
|
343
|
+
const id = 'sss-device-share:user-reencrypt';
|
|
344
|
+
|
|
345
|
+
await storeDeviceShare('the-share', id);
|
|
346
|
+
|
|
347
|
+
const first = await getDeviceShare(id);
|
|
348
|
+
|
|
349
|
+
await storeDeviceShare('the-share', id);
|
|
350
|
+
|
|
351
|
+
const second = await getDeviceShare(id);
|
|
352
|
+
|
|
353
|
+
// Both decrypt to the same value
|
|
354
|
+
expect(first).toBe('the-share');
|
|
355
|
+
expect(second).toBe('the-share');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// -----------------------------------------------------------------------
|
|
360
|
+
// Auto-cleanup of orphaned entries in listAllDeviceShares
|
|
361
|
+
// -----------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
describe('listAllDeviceShares auto-cleanup', () => {
|
|
364
|
+
it('auto-removes entries that fail to decrypt (orphaned shares)', async () => {
|
|
365
|
+
const goodId = 'sss-device-share:good-user';
|
|
366
|
+
const orphanId = 'sss-device-share:orphan-user';
|
|
367
|
+
|
|
368
|
+
// Store a valid share
|
|
369
|
+
await storeDeviceShare('valid-share-data!', goodId);
|
|
370
|
+
|
|
371
|
+
// Manually inject an orphaned entry that can't be decrypted —
|
|
372
|
+
// a raw string instead of a proper EncryptedPayload
|
|
373
|
+
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
374
|
+
const req = indexedDB.open('lcb-sss-keys', 1);
|
|
375
|
+
req.onsuccess = () => resolve(req.result);
|
|
376
|
+
req.onerror = () => reject(req.error);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await new Promise<void>((resolve, reject) => {
|
|
380
|
+
const t = db.transaction('shares', 'readwrite');
|
|
381
|
+
const s = t.objectStore('shares');
|
|
382
|
+
// Store garbage data that will fail AES-GCM decryption
|
|
383
|
+
const req = s.put({ version: 1, iv: 'bad', cipher: 'bad', keyVersion: 1 }, orphanId);
|
|
384
|
+
req.onsuccess = () => resolve();
|
|
385
|
+
req.onerror = () => reject(req.error);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
db.close();
|
|
389
|
+
|
|
390
|
+
// First call: auto-cleans the orphan, returns only the valid entry
|
|
391
|
+
const entries = await listAllDeviceShares();
|
|
392
|
+
|
|
393
|
+
expect(entries).toHaveLength(1);
|
|
394
|
+
expect(entries[0]!.id).toBe(goodId);
|
|
395
|
+
|
|
396
|
+
// Second call: orphan is gone permanently
|
|
397
|
+
const entries2 = await listAllDeviceShares();
|
|
398
|
+
|
|
399
|
+
expect(entries2).toHaveLength(1);
|
|
400
|
+
expect(entries2[0]!.id).toBe(goodId);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// -----------------------------------------------------------------------
|
|
405
|
+
// Concurrent access — master key serialisation
|
|
406
|
+
// -----------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
describe('concurrent access', () => {
|
|
409
|
+
it('concurrent storeDeviceShare calls do not corrupt each other', async () => {
|
|
410
|
+
const idA = 'sss-device-share:concurrent-a';
|
|
411
|
+
const idB = 'sss-device-share:concurrent-b';
|
|
412
|
+
|
|
413
|
+
// Fire two stores simultaneously
|
|
414
|
+
await Promise.all([
|
|
415
|
+
storeDeviceShare('share-aaa', idA),
|
|
416
|
+
storeDeviceShare('share-bbb', idB),
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
// Both should be readable
|
|
420
|
+
expect(await getDeviceShare(idA)).toBe('share-aaa');
|
|
421
|
+
expect(await getDeviceShare(idB)).toBe('share-bbb');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Session-only storage (public computer mode)
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
import {
|
|
431
|
+
isPublicComputerMode,
|
|
432
|
+
setPublicComputerMode,
|
|
433
|
+
createAdaptiveStorage,
|
|
434
|
+
} from './storage';
|
|
435
|
+
|
|
436
|
+
describe('Session-only storage (public computer mode)', () => {
|
|
437
|
+
beforeEach(() => {
|
|
438
|
+
sessionStorage.clear();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe('isPublicComputerMode / setPublicComputerMode', () => {
|
|
442
|
+
it('defaults to false', () => {
|
|
443
|
+
expect(isPublicComputerMode()).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('returns true after enabling', () => {
|
|
447
|
+
setPublicComputerMode(true);
|
|
448
|
+
|
|
449
|
+
expect(isPublicComputerMode()).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('returns false after disabling', () => {
|
|
453
|
+
setPublicComputerMode(true);
|
|
454
|
+
setPublicComputerMode(false);
|
|
455
|
+
|
|
456
|
+
expect(isPublicComputerMode()).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe('adaptive storage routes by mode', () => {
|
|
461
|
+
it('stores in sessionStorage when public mode is on', async () => {
|
|
462
|
+
setPublicComputerMode(true);
|
|
463
|
+
const storage = createAdaptiveStorage();
|
|
464
|
+
|
|
465
|
+
await storage.storeDeviceShare('session-share', 'test-id');
|
|
466
|
+
|
|
467
|
+
expect(await storage.getDeviceShare('test-id')).toBe('session-share');
|
|
468
|
+
expect(await storage.hasDeviceShare('test-id')).toBe(true);
|
|
469
|
+
|
|
470
|
+
// Verify it's in sessionStorage, not IndexedDB
|
|
471
|
+
expect(sessionStorage.getItem('sss:test-id')).toBe('session-share');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('stores share version in sessionStorage when public mode is on', async () => {
|
|
475
|
+
setPublicComputerMode(true);
|
|
476
|
+
const storage = createAdaptiveStorage();
|
|
477
|
+
|
|
478
|
+
await storage.storeShareVersion(5, 'test-id');
|
|
479
|
+
|
|
480
|
+
expect(await storage.getShareVersion('test-id')).toBe(5);
|
|
481
|
+
expect(sessionStorage.getItem('sss:test-id:version')).toBe('5');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('clearAllShares removes session data for a specific id', async () => {
|
|
485
|
+
setPublicComputerMode(true);
|
|
486
|
+
const storage = createAdaptiveStorage();
|
|
487
|
+
|
|
488
|
+
await storage.storeDeviceShare('share-data', 'user-1');
|
|
489
|
+
await storage.storeShareVersion(3, 'user-1');
|
|
490
|
+
|
|
491
|
+
await storage.clearAllShares('user-1');
|
|
492
|
+
|
|
493
|
+
expect(await storage.hasDeviceShare('user-1')).toBe(false);
|
|
494
|
+
expect(await storage.getShareVersion('user-1')).toBeNull();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('clearAllShares with no id removes all sss: keys', async () => {
|
|
498
|
+
setPublicComputerMode(true);
|
|
499
|
+
const storage = createAdaptiveStorage();
|
|
500
|
+
|
|
501
|
+
await storage.storeDeviceShare('share-a', 'id-a');
|
|
502
|
+
await storage.storeDeviceShare('share-b', 'id-b');
|
|
503
|
+
await storage.storeShareVersion(1, 'id-a');
|
|
504
|
+
|
|
505
|
+
// Add a non-sss key to prove it's not removed
|
|
506
|
+
sessionStorage.setItem('unrelated-key', 'keep-me');
|
|
507
|
+
|
|
508
|
+
await storage.clearAllShares();
|
|
509
|
+
|
|
510
|
+
expect(await storage.hasDeviceShare('id-a')).toBe(false);
|
|
511
|
+
expect(await storage.hasDeviceShare('id-b')).toBe(false);
|
|
512
|
+
expect(sessionStorage.getItem('unrelated-key')).toBe('keep-me');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('uses IndexedDB when public mode is off', async () => {
|
|
516
|
+
setPublicComputerMode(false);
|
|
517
|
+
const storage = createAdaptiveStorage();
|
|
518
|
+
|
|
519
|
+
await storage.storeDeviceShare('idb-share', 'idb-test');
|
|
520
|
+
|
|
521
|
+
expect(await storage.getDeviceShare('idb-test')).toBe('idb-share');
|
|
522
|
+
|
|
523
|
+
// sessionStorage should NOT have it
|
|
524
|
+
expect(sessionStorage.getItem('sss:idb-test')).toBeNull();
|
|
525
|
+
|
|
526
|
+
// Clean up
|
|
527
|
+
await storage.clearAllShares('idb-test');
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
});
|