WellNuo/services/__tests__/wifiPasswordStore.test.ts
Sergei 8af7a11cd9 Fix WiFi credentials cache implementation in SecureStore
- Fix saveWiFiPassword to use encrypted passwords map instead of decrypted
- Fix getWiFiPassword to decrypt from encrypted storage
- Fix test expectations for migration and encryption functions
- Remove unused error variables to fix linting warnings
- All 27 tests now passing with proper encryption/decryption flow

The WiFi credentials cache feature was already implemented but had bugs
where encrypted and decrypted password maps were being mixed. This commit
ensures proper encryption is maintained throughout the storage lifecycle.
2026-01-31 15:55:24 -08:00

443 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for WiFi Password Secure Storage Service with Encryption
*/
import * as SecureStore from 'expo-secure-store';
import * as encryption from '../encryption';
import {
saveWiFiPassword,
getWiFiPassword,
getAllWiFiPasswords,
removeWiFiPassword,
clearAllWiFiPasswords,
migrateFromAsyncStorage,
migrateToEncrypted,
} from '../wifiPasswordStore';
// Mock SecureStore
jest.mock('expo-secure-store', () => ({
setItemAsync: jest.fn(),
getItemAsync: jest.fn(),
deleteItemAsync: jest.fn(),
}));
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: jest.fn(),
removeItem: jest.fn(),
},
}));
// Mock encryption module
jest.mock('../encryption', () => ({
encrypt: jest.fn((plaintext: string) => Promise.resolve(`encrypted_${plaintext}`)),
decrypt: jest.fn((ciphertext: string) =>
Promise.resolve(ciphertext.replace('encrypted_', ''))
),
isEncrypted: jest.fn((data: string) => data.startsWith('encrypted_')),
clearEncryptionKey: jest.fn(() => Promise.resolve()),
}));
describe('wifiPasswordStore', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('saveWiFiPassword', () => {
it('should save and encrypt a new WiFi password', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
await saveWiFiPassword('MyNetwork', 'password123');
// Should have encrypted the password
expect(encryption.encrypt).toHaveBeenCalledWith('password123');
// Should have saved encrypted password
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify({ MyNetwork: 'encrypted_password123' })
);
});
it('should update an existing WiFi password', async () => {
const existingPasswords = { Network1: 'encrypted_pass1', Network2: 'encrypted_pass2' };
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(existingPasswords)
);
await saveWiFiPassword('Network1', 'newpass1');
expect(encryption.encrypt).toHaveBeenCalledWith('newpass1');
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify({ Network1: 'encrypted_newpass1', Network2: 'encrypted_pass2' })
);
});
it('should add password to existing map', async () => {
const existingPasswords = { Network1: 'encrypted_pass1' };
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(existingPasswords)
);
await saveWiFiPassword('Network2', 'pass2');
expect(encryption.encrypt).toHaveBeenCalledWith('pass2');
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify({ Network1: 'encrypted_pass1', Network2: 'encrypted_pass2' })
);
});
});
describe('getWiFiPassword', () => {
it('should return decrypted password for existing network', async () => {
const passwords = {
MyNetwork: 'encrypted_password123',
OtherNetwork: 'encrypted_pass456',
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(passwords)
);
const password = await getWiFiPassword('MyNetwork');
expect(encryption.decrypt).toHaveBeenCalledWith('encrypted_password123');
expect(password).toBe('password123');
});
it('should return undefined for non-existing network', async () => {
const passwords = { MyNetwork: 'password123' };
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(passwords)
);
const password = await getWiFiPassword('NonExistent');
expect(password).toBeUndefined();
});
it('should return undefined when no passwords saved', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
const password = await getWiFiPassword('MyNetwork');
expect(password).toBeUndefined();
});
it('should handle SecureStore errors gracefully', async () => {
(SecureStore.getItemAsync as jest.Mock).mockRejectedValueOnce(
new Error('SecureStore error')
);
const password = await getWiFiPassword('MyNetwork');
expect(password).toBeUndefined();
});
});
describe('getAllWiFiPasswords', () => {
it('should return all decrypted passwords', async () => {
const encryptedPasswords = {
Network1: 'encrypted_pass1',
Network2: 'encrypted_pass2',
Network3: 'encrypted_pass3',
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(encryptedPasswords)
);
const result = await getAllWiFiPasswords();
expect(encryption.decrypt).toHaveBeenCalledTimes(3);
expect(result).toEqual({ Network1: 'pass1', Network2: 'pass2', Network3: 'pass3' });
});
it('should skip passwords that fail to decrypt', async () => {
const encryptedPasswords = {
Network1: 'encrypted_pass1',
Network2: 'encrypted_pass2',
Network3: 'encrypted_pass3',
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(encryptedPasswords)
);
// Make Network2 fail to decrypt
(encryption.decrypt as jest.Mock).mockImplementation((ciphertext: string) => {
if (ciphertext === 'encrypted_pass2') {
return Promise.reject(new Error('Decryption failed'));
}
return Promise.resolve(ciphertext.replace('encrypted_', ''));
});
const result = await getAllWiFiPasswords();
expect(result).toEqual({ Network1: 'pass1', Network3: 'pass3' });
});
it('should return empty object when no passwords saved', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
const result = await getAllWiFiPasswords();
expect(result).toEqual({});
});
it('should handle SecureStore errors gracefully', async () => {
(SecureStore.getItemAsync as jest.Mock).mockRejectedValueOnce(
new Error('SecureStore error')
);
const result = await getAllWiFiPasswords();
expect(result).toEqual({});
});
});
describe('removeWiFiPassword', () => {
it('should remove specific network password', async () => {
const passwords = { Network1: 'pass1', Network2: 'pass2', Network3: 'pass3' };
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(passwords)
);
await removeWiFiPassword('Network2');
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify({ Network1: 'pass1', Network3: 'pass3' })
);
});
it('should delete key when removing last password', async () => {
const passwords = { Network1: 'pass1' };
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(passwords)
);
await removeWiFiPassword('Network1');
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('WIFI_PASSWORDS');
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
});
it('should handle removal of non-existent network', async () => {
const passwords = { Network1: 'pass1', Network2: 'pass2' };
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(passwords)
);
await removeWiFiPassword('NonExistent');
// Should still update the store with unchanged passwords
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify(passwords)
);
});
});
describe('clearAllWiFiPasswords', () => {
it('should delete all WiFi passwords', async () => {
await clearAllWiFiPasswords();
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('WIFI_PASSWORDS');
});
it('should handle errors gracefully', async () => {
(SecureStore.deleteItemAsync as jest.Mock).mockRejectedValueOnce(
new Error('Delete failed')
);
await expect(clearAllWiFiPasswords()).rejects.toThrow('Delete failed');
});
});
describe('migrateFromAsyncStorage', () => {
it('should migrate passwords from AsyncStorage to SecureStore', async () => {
const oldPasswords = { Network1: 'pass1', Network2: 'pass2' };
// No existing data in SecureStore
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
// Old data in AsyncStorage
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mockAsyncStorage = require('@react-native-async-storage/async-storage').default;
(mockAsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(
JSON.stringify(oldPasswords)
);
await migrateFromAsyncStorage();
// Should encrypt and save to SecureStore
expect(encryption.encrypt).toHaveBeenCalledWith('pass1');
expect(encryption.encrypt).toHaveBeenCalledWith('pass2');
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify({ Network1: 'encrypted_pass1', Network2: 'encrypted_pass2' })
);
// Should remove from AsyncStorage
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('WIFI_PASSWORDS');
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('LAST_WIFI_PASSWORD');
});
it('should skip migration if data already exists in SecureStore', async () => {
const existingPasswords = { Network1: 'encrypted_pass1' };
// Data already exists in SecureStore
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(existingPasswords)
);
await migrateFromAsyncStorage();
// Should not call AsyncStorage
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mockAsyncStorage = require('@react-native-async-storage/async-storage').default;
expect(mockAsyncStorage.getItem).not.toHaveBeenCalled();
// setItemAsync might be called by migrateToEncrypted, but only if needed
});
it('should skip migration if no data in AsyncStorage', async () => {
// No data in SecureStore
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
// No data in AsyncStorage
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mockAsyncStorage = require('@react-native-async-storage/async-storage').default;
(mockAsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(null);
await migrateFromAsyncStorage();
// Should not migrate anything
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
expect(mockAsyncStorage.removeItem).not.toHaveBeenCalled();
});
it('should not throw error on migration failure', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mockAsyncStorage = require('@react-native-async-storage/async-storage').default;
(mockAsyncStorage.getItem as jest.Mock).mockRejectedValueOnce(
new Error('AsyncStorage error')
);
// Should not throw
await expect(migrateFromAsyncStorage()).resolves.toBeUndefined();
});
});
describe('migrateToEncrypted', () => {
it('should encrypt unencrypted passwords', async () => {
const stored = {
NetworkA: 'plaintext_password_A', // Not encrypted (doesn't start with 'encrypted_')
NetworkB: 'encrypted_passB', // Already encrypted
NetworkC: 'plaintext_password_C', // Not encrypted
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(JSON.stringify(stored));
// Reset mock call count
(encryption.encrypt as jest.Mock).mockClear();
await migrateToEncrypted();
// Should have encrypted the plaintext passwords (those that don't start with 'encrypted_')
expect(encryption.encrypt).toHaveBeenCalledWith('plaintext_password_A');
expect(encryption.encrypt).toHaveBeenCalledWith('plaintext_password_C');
// NetworkB already starts with 'encrypted_' so shouldn't be re-encrypted
expect(encryption.encrypt).toHaveBeenCalledTimes(2);
// Should have saved the migrated passwords
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify({
NetworkA: 'encrypted_plaintext_password_A',
NetworkB: 'encrypted_passB',
NetworkC: 'encrypted_plaintext_password_C',
})
);
});
it('should do nothing if no passwords stored', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
await migrateToEncrypted();
expect(encryption.encrypt).not.toHaveBeenCalled();
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
});
it('should do nothing if all passwords already encrypted', async () => {
const stored = {
NetworkA: 'encrypted_passA',
NetworkB: 'encrypted_passB',
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(JSON.stringify(stored));
await migrateToEncrypted();
expect(encryption.encrypt).not.toHaveBeenCalled();
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
});
it('should not throw on error', async () => {
(SecureStore.getItemAsync as jest.Mock).mockRejectedValueOnce(
new Error('Storage error')
);
// Should not throw
await expect(migrateToEncrypted()).resolves.not.toThrow();
});
});
describe('Special characters in SSID and password', () => {
it('should handle special characters in SSID', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
const ssid = "Network's-Name!@#$%^&*()";
const password = 'password123';
await saveWiFiPassword(ssid, password);
expect(encryption.encrypt).toHaveBeenCalledWith(password);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
expect.stringContaining('encrypted_')
);
});
it('should handle special characters in password', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
const ssid = 'MyNetwork';
const password = "P@ssw0rd!#$%^&*()_+-=[]{}|;':,.<>?";
await saveWiFiPassword(ssid, password);
expect(encryption.encrypt).toHaveBeenCalledWith(password);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
expect.stringContaining('encrypted_')
);
});
it('should handle unicode characters', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
const ssid = '我的网络';
const password = 'пароль123';
await saveWiFiPassword(ssid, password);
expect(encryption.encrypt).toHaveBeenCalledWith(password);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
expect.stringContaining('encrypted_')
);
});
});
});