- 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.
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
/**
|
||
* 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_')
|
||
);
|
||
});
|
||
});
|
||
});
|