@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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QR Login Crypto Helpers (X25519 ECDH + AES-256-GCM)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
generateEphemeralKeypair,
|
|
9
|
+
encryptShareForTransfer,
|
|
10
|
+
decryptShareFromTransfer,
|
|
11
|
+
} from './qr-crypto';
|
|
12
|
+
|
|
13
|
+
// Node 20+ supports X25519 in crypto.subtle.
|
|
14
|
+
// jsdom doesn't provide its own Web Crypto — it falls through to Node's.
|
|
15
|
+
// If this environment doesn't support X25519, skip gracefully.
|
|
16
|
+
const supportsX25519 = async (): Promise<boolean> => {
|
|
17
|
+
try {
|
|
18
|
+
await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('QR Crypto (X25519 ECDH + AES-GCM)', async () => {
|
|
26
|
+
const supported = await supportsX25519();
|
|
27
|
+
|
|
28
|
+
it.skipIf(!supported)('generateEphemeralKeypair returns a public key string and private CryptoKey', async () => {
|
|
29
|
+
const keypair = await generateEphemeralKeypair();
|
|
30
|
+
|
|
31
|
+
expect(keypair.publicKey).toBeTruthy();
|
|
32
|
+
expect(typeof keypair.publicKey).toBe('string');
|
|
33
|
+
|
|
34
|
+
// X25519 public keys are 32 bytes → 44 chars in base64
|
|
35
|
+
expect(keypair.publicKey.length).toBeGreaterThanOrEqual(40);
|
|
36
|
+
|
|
37
|
+
expect(keypair.privateKey).toBeTruthy();
|
|
38
|
+
expect(keypair.privateKey.type).toBe('private');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it.skipIf(!supported)('generates unique keypairs on each call', async () => {
|
|
42
|
+
const kp1 = await generateEphemeralKeypair();
|
|
43
|
+
const kp2 = await generateEphemeralKeypair();
|
|
44
|
+
|
|
45
|
+
expect(kp1.publicKey).not.toBe(kp2.publicKey);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.skipIf(!supported)('encrypt + decrypt round-trip produces the original share', async () => {
|
|
49
|
+
const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
|
|
50
|
+
|
|
51
|
+
// Device B generates keypair
|
|
52
|
+
const deviceB = await generateEphemeralKeypair();
|
|
53
|
+
|
|
54
|
+
// Device A encrypts the share for Device B
|
|
55
|
+
const encrypted = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
|
|
56
|
+
|
|
57
|
+
expect(encrypted.ciphertext).toBeTruthy();
|
|
58
|
+
expect(encrypted.iv).toBeTruthy();
|
|
59
|
+
expect(encrypted.senderPublicKey).toBeTruthy();
|
|
60
|
+
|
|
61
|
+
// Device B decrypts
|
|
62
|
+
const decrypted = await decryptShareFromTransfer(encrypted, deviceB.privateKey);
|
|
63
|
+
|
|
64
|
+
expect(decrypted).toBe(deviceShare);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it.skipIf(!supported)('decryption with wrong private key fails', async () => {
|
|
68
|
+
const deviceShare = 'secret-share-data-12345';
|
|
69
|
+
|
|
70
|
+
const deviceB = await generateEphemeralKeypair();
|
|
71
|
+
const wrongKey = await generateEphemeralKeypair();
|
|
72
|
+
|
|
73
|
+
const encrypted = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
|
|
74
|
+
|
|
75
|
+
// Try to decrypt with a different private key — should fail
|
|
76
|
+
await expect(
|
|
77
|
+
decryptShareFromTransfer(encrypted, wrongKey.privateKey)
|
|
78
|
+
).rejects.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it.skipIf(!supported)('encrypted payload is different each time (unique IV + sender key)', async () => {
|
|
82
|
+
const deviceShare = 'some-share';
|
|
83
|
+
|
|
84
|
+
const deviceB = await generateEphemeralKeypair();
|
|
85
|
+
|
|
86
|
+
const enc1 = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
|
|
87
|
+
const enc2 = await encryptShareForTransfer(deviceShare, deviceB.publicKey);
|
|
88
|
+
|
|
89
|
+
// Different IVs
|
|
90
|
+
expect(enc1.iv).not.toBe(enc2.iv);
|
|
91
|
+
|
|
92
|
+
// Different sender keys (each call generates a new ephemeral pair)
|
|
93
|
+
expect(enc1.senderPublicKey).not.toBe(enc2.senderPublicKey);
|
|
94
|
+
|
|
95
|
+
// Different ciphertexts
|
|
96
|
+
expect(enc1.ciphertext).not.toBe(enc2.ciphertext);
|
|
97
|
+
|
|
98
|
+
// But both decrypt to the same share
|
|
99
|
+
const dec1 = await decryptShareFromTransfer(enc1, deviceB.privateKey);
|
|
100
|
+
const dec2 = await decryptShareFromTransfer(enc2, deviceB.privateKey);
|
|
101
|
+
|
|
102
|
+
expect(dec1).toBe(deviceShare);
|
|
103
|
+
expect(dec2).toBe(deviceShare);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.skipIf(!supported)('handles various share lengths correctly', async () => {
|
|
107
|
+
const shares = [
|
|
108
|
+
'short',
|
|
109
|
+
'a'.repeat(66), // typical SSS hex share
|
|
110
|
+
'a'.repeat(1000), // unusually long
|
|
111
|
+
'🔐🔑', // unicode
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (const share of shares) {
|
|
115
|
+
const deviceB = await generateEphemeralKeypair();
|
|
116
|
+
const encrypted = await encryptShareForTransfer(share, deviceB.publicKey);
|
|
117
|
+
const decrypted = await decryptShareFromTransfer(encrypted, deviceB.privateKey);
|
|
118
|
+
|
|
119
|
+
expect(decrypted).toBe(share);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/qr-crypto.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QR Login Crypto Helpers
|
|
3
|
+
*
|
|
4
|
+
* X25519 ECDH key exchange + AES-256-GCM encryption for cross-device
|
|
5
|
+
* share transfer. Uses the Web Crypto API exclusively — no external deps.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* Device B generates an ephemeral X25519 keypair, shares the public key.
|
|
9
|
+
* Device A derives a shared secret via ECDH, encrypts the device share
|
|
10
|
+
* with AES-256-GCM, and sends the ciphertext.
|
|
11
|
+
* Device B decrypts with the same derived shared secret.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { bufferToBase64, base64ToBuffer } from './crypto';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface EphemeralKeypair {
|
|
21
|
+
/** Base64-encoded X25519 public key (sent to server / encoded in QR) */
|
|
22
|
+
publicKey: string;
|
|
23
|
+
|
|
24
|
+
/** Raw CryptoKey — kept in memory on Device B, never leaves the device */
|
|
25
|
+
privateKey: CryptoKey;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EncryptedSharePayload {
|
|
29
|
+
/** Base64-encoded AES-256-GCM ciphertext */
|
|
30
|
+
ciphertext: string;
|
|
31
|
+
|
|
32
|
+
/** Base64-encoded 12-byte IV */
|
|
33
|
+
iv: string;
|
|
34
|
+
|
|
35
|
+
/** Base64-encoded ephemeral public key of the *sender* (Device A) */
|
|
36
|
+
senderPublicKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Key Generation
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate an ephemeral X25519 keypair for the new device (Device B).
|
|
45
|
+
* The public key is shared via QR / short code; the private key stays in memory.
|
|
46
|
+
*/
|
|
47
|
+
export const generateEphemeralKeypair = async (): Promise<EphemeralKeypair> => {
|
|
48
|
+
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'Web Crypto API is not available. This feature requires a secure context (HTTPS). ' +
|
|
51
|
+
'If you are running a Capacitor dev server over HTTP, use a production build or configure HTTPS.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
56
|
+
{ name: 'X25519' },
|
|
57
|
+
false,
|
|
58
|
+
['deriveBits']
|
|
59
|
+
) as CryptoKeyPair;
|
|
60
|
+
|
|
61
|
+
const rawPublicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
publicKey: bufferToBase64(rawPublicKey),
|
|
65
|
+
privateKey: keyPair.privateKey,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Shared Secret Derivation
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Import a raw X25519 public key from base64.
|
|
75
|
+
*/
|
|
76
|
+
const importPublicKey = async (base64Key: string): Promise<CryptoKey> => {
|
|
77
|
+
const keyBytes = base64ToBuffer(base64Key);
|
|
78
|
+
|
|
79
|
+
return crypto.subtle.importKey(
|
|
80
|
+
'raw',
|
|
81
|
+
keyBytes,
|
|
82
|
+
{ name: 'X25519' },
|
|
83
|
+
false,
|
|
84
|
+
[]
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Derive a 256-bit AES-GCM key from our private key + their public key
|
|
90
|
+
* using X25519 ECDH → HKDF-SHA256.
|
|
91
|
+
*
|
|
92
|
+
* The raw ECDH output is fed through HKDF with a context-specific info
|
|
93
|
+
* string to produce the final encryption key (NIST SP 800-56C compliant).
|
|
94
|
+
*/
|
|
95
|
+
const deriveSharedAesKey = async (
|
|
96
|
+
ourPrivateKey: CryptoKey,
|
|
97
|
+
theirPublicKeyBase64: string
|
|
98
|
+
): Promise<CryptoKey> => {
|
|
99
|
+
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
|
|
100
|
+
|
|
101
|
+
const sharedBits = await crypto.subtle.deriveBits(
|
|
102
|
+
{ name: 'X25519', public: theirPublicKey },
|
|
103
|
+
ourPrivateKey,
|
|
104
|
+
256
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Import raw ECDH output as HKDF key material
|
|
108
|
+
const hkdfKey = await crypto.subtle.importKey(
|
|
109
|
+
'raw',
|
|
110
|
+
sharedBits,
|
|
111
|
+
'HKDF',
|
|
112
|
+
false,
|
|
113
|
+
['deriveKey']
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Derive the final AES-256-GCM key via HKDF-SHA256
|
|
117
|
+
const encoder = new TextEncoder();
|
|
118
|
+
|
|
119
|
+
return crypto.subtle.deriveKey(
|
|
120
|
+
{
|
|
121
|
+
name: 'HKDF',
|
|
122
|
+
hash: 'SHA-256',
|
|
123
|
+
salt: new Uint8Array(32), // fixed empty salt (ephemeral keys provide freshness)
|
|
124
|
+
info: encoder.encode('learncard-qr-login-v1'),
|
|
125
|
+
},
|
|
126
|
+
hkdfKey,
|
|
127
|
+
{ name: 'AES-GCM', length: 256 },
|
|
128
|
+
false,
|
|
129
|
+
['encrypt', 'decrypt']
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Encrypt / Decrypt
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Encrypt a device share for transfer to Device B.
|
|
139
|
+
*
|
|
140
|
+
* Called by Device A (the logged-in device):
|
|
141
|
+
* 1. Generates its own ephemeral X25519 keypair
|
|
142
|
+
* 2. Derives a shared secret from its private key + Device B's public key
|
|
143
|
+
* 3. Encrypts the share with AES-256-GCM
|
|
144
|
+
* 4. Returns the ciphertext + IV + Device A's ephemeral public key
|
|
145
|
+
*
|
|
146
|
+
* @param deviceShare - Plaintext device share to encrypt
|
|
147
|
+
* @param recipientPublicKey - Base64-encoded X25519 public key from Device B
|
|
148
|
+
*/
|
|
149
|
+
export const encryptShareForTransfer = async (
|
|
150
|
+
deviceShare: string,
|
|
151
|
+
recipientPublicKey: string
|
|
152
|
+
): Promise<EncryptedSharePayload> => {
|
|
153
|
+
// Generate sender's ephemeral keypair
|
|
154
|
+
const senderKeypair = await generateEphemeralKeypair();
|
|
155
|
+
|
|
156
|
+
// Derive shared AES key
|
|
157
|
+
const aesKey = await deriveSharedAesKey(senderKeypair.privateKey, recipientPublicKey);
|
|
158
|
+
|
|
159
|
+
// Encrypt
|
|
160
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
161
|
+
const encoder = new TextEncoder();
|
|
162
|
+
|
|
163
|
+
const ciphertextBuffer = await crypto.subtle.encrypt(
|
|
164
|
+
{ name: 'AES-GCM', iv },
|
|
165
|
+
aesKey,
|
|
166
|
+
encoder.encode(deviceShare)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
ciphertext: bufferToBase64(ciphertextBuffer),
|
|
171
|
+
iv: bufferToBase64(iv.buffer),
|
|
172
|
+
senderPublicKey: senderKeypair.publicKey,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Decrypt a device share received from Device A.
|
|
178
|
+
*
|
|
179
|
+
* Called by Device B (the new device):
|
|
180
|
+
* 1. Derives the shared secret from its private key + Device A's public key
|
|
181
|
+
* 2. Decrypts the ciphertext with AES-256-GCM
|
|
182
|
+
*
|
|
183
|
+
* @param payload - The encrypted payload from Device A
|
|
184
|
+
* @param recipientPrivateKey - Device B's ephemeral private CryptoKey
|
|
185
|
+
*/
|
|
186
|
+
export const decryptShareFromTransfer = async (
|
|
187
|
+
payload: EncryptedSharePayload,
|
|
188
|
+
recipientPrivateKey: CryptoKey
|
|
189
|
+
): Promise<string> => {
|
|
190
|
+
// Derive shared AES key
|
|
191
|
+
const aesKey = await deriveSharedAesKey(recipientPrivateKey, payload.senderPublicKey);
|
|
192
|
+
|
|
193
|
+
// Decrypt
|
|
194
|
+
const ivBytes = base64ToBuffer(payload.iv);
|
|
195
|
+
const ciphertextBytes = base64ToBuffer(payload.ciphertext);
|
|
196
|
+
|
|
197
|
+
const plaintextBuffer = await crypto.subtle.decrypt(
|
|
198
|
+
{ name: 'AES-GCM', iv: ivBytes },
|
|
199
|
+
aesKey,
|
|
200
|
+
ciphertextBytes
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const decoder = new TextDecoder();
|
|
204
|
+
|
|
205
|
+
return decoder.decode(plaintextBuffer);
|
|
206
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QR Login Push Notification helper
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { notifyDevicesForQrSession } from './qr-login';
|
|
8
|
+
|
|
9
|
+
const SERVER_URL = 'https://api.example.com';
|
|
10
|
+
|
|
11
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
12
|
+
|
|
13
|
+
describe('notifyDevicesForQrSession', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mockFetch = vi.fn();
|
|
16
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.unstubAllGlobals();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('sends a POST to /qr-login/notify with correct body', async () => {
|
|
24
|
+
mockFetch.mockResolvedValueOnce(
|
|
25
|
+
new Response(JSON.stringify({ sent: true, deviceCount: 2 }), { status: 200 })
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const result = await notifyDevicesForQrSession(
|
|
29
|
+
{ serverUrl: SERVER_URL },
|
|
30
|
+
'session-123',
|
|
31
|
+
'12345678',
|
|
32
|
+
'firebase-token-abc',
|
|
33
|
+
'firebase'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({ sent: true, deviceCount: 2 });
|
|
37
|
+
|
|
38
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
39
|
+
|
|
40
|
+
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
41
|
+
|
|
42
|
+
expect(url).toBe(`${SERVER_URL}/qr-login/notify`);
|
|
43
|
+
expect(init.method).toBe('POST');
|
|
44
|
+
|
|
45
|
+
const body = JSON.parse(init.body as string);
|
|
46
|
+
|
|
47
|
+
expect(body.authToken).toBe('firebase-token-abc');
|
|
48
|
+
expect(body.providerType).toBe('firebase');
|
|
49
|
+
expect(body.sessionId).toBe('session-123');
|
|
50
|
+
expect(body.shortCode).toBe('12345678');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('defaults providerType to firebase', async () => {
|
|
54
|
+
mockFetch.mockResolvedValueOnce(
|
|
55
|
+
new Response(JSON.stringify({ sent: true, deviceCount: 1 }), { status: 200 })
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await notifyDevicesForQrSession(
|
|
59
|
+
{ serverUrl: SERVER_URL },
|
|
60
|
+
'session-456',
|
|
61
|
+
'87654321',
|
|
62
|
+
'token-xyz'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string);
|
|
66
|
+
|
|
67
|
+
expect(body.providerType).toBe('firebase');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns { sent: false, deviceCount: 0 } on HTTP error', async () => {
|
|
71
|
+
mockFetch.mockResolvedValueOnce(new Response('Server Error', { status: 500 }));
|
|
72
|
+
|
|
73
|
+
const result = await notifyDevicesForQrSession(
|
|
74
|
+
{ serverUrl: SERVER_URL },
|
|
75
|
+
'session-err',
|
|
76
|
+
'00000000',
|
|
77
|
+
'token'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({ sent: false, deviceCount: 0 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns { sent: false, deviceCount: 0 } on network failure', async () => {
|
|
84
|
+
mockFetch.mockRejectedValueOnce(new TypeError('Network error'));
|
|
85
|
+
|
|
86
|
+
const result = await notifyDevicesForQrSession(
|
|
87
|
+
{ serverUrl: SERVER_URL },
|
|
88
|
+
'session-net',
|
|
89
|
+
'11111111',
|
|
90
|
+
'token'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({ sent: false, deviceCount: 0 });
|
|
94
|
+
});
|
|
95
|
+
});
|