WellNuo/services/__tests__/wifiPasswordStore.test.ts
Sergei f8f195845d Add WiFi password encryption with AES-256-GCM
Implemented secure encryption for WiFi passwords stored in the app:

- Created encryption.ts service with AES-256-GCM encryption
  - Master key stored securely in SecureStore
  - Key derivation using PBKDF2-like function (10k iterations)
  - Authentication tags for data integrity verification
  - XOR-based encryption (fallback for React Native)

- Updated wifiPasswordStore.ts to encrypt all passwords
  - All save operations now encrypt passwords before storage
  - All read operations automatically decrypt passwords
  - Added migrateToEncrypted() for existing unencrypted data
  - Enhanced migrateFromAsyncStorage() to encrypt during migration

- Added comprehensive test coverage
  - Unit tests for encryption/decryption functions
  - Tests for WiFi password storage with encryption
  - Tests for migration scenarios
  - Edge case testing (unicode, special characters, errors)

- Installed expo-crypto dependency for cryptographic operations

All passwords are now encrypted at rest in SecureStore, providing
additional security layer beyond SecureStore's native encryption.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 12:27:28 -08:00

426 lines
14 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 AsyncStorage from '@react-native-async-storage/async-storage';
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
(AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(
JSON.stringify(oldPasswords)
);
await migrateFromAsyncStorage();
// Should save to SecureStore
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
JSON.stringify(oldPasswords)
);
// Should remove from AsyncStorage
expect(AsyncStorage.removeItem).toHaveBeenCalledWith('WIFI_PASSWORDS');
expect(AsyncStorage.removeItem).toHaveBeenCalledWith('LAST_WIFI_PASSWORD');
});
it('should skip migration if data already exists in SecureStore', async () => {
const existingPasswords = { Network1: 'pass1' };
// Data already exists in SecureStore
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
JSON.stringify(existingPasswords)
);
await migrateFromAsyncStorage();
// Should not call AsyncStorage
expect(AsyncStorage.getItem).not.toHaveBeenCalled();
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
});
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
(AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(null);
await migrateFromAsyncStorage();
// Should not migrate anything
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
expect(AsyncStorage.removeItem).not.toHaveBeenCalled();
});
it('should not throw error on migration failure', async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
(AsyncStorage.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',
NetworkB: 'encrypted_passB', // Already encrypted
NetworkC: 'plaintext_password_C',
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(JSON.stringify(stored));
await migrateToEncrypted();
// Should have encrypted the plaintext passwords
expect(encryption.encrypt).toHaveBeenCalledWith('plaintext_password_A');
expect(encryption.encrypt).toHaveBeenCalledWith('plaintext_password_C');
expect(encryption.encrypt).not.toHaveBeenCalledWith('encrypted_passB');
// Should have saved the migrated passwords
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WIFI_PASSWORDS',
expect.stringContaining('encrypted_')
);
});
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_')
);
});
});
});