- Create wifiPasswordStore service for encrypted password storage - Replace AsyncStorage with SecureStore for WiFi credentials - Add automatic migration from AsyncStorage to SecureStore - Integrate WiFi password cleanup into logout process - Add comprehensive test suite for password storage operations - Update setup-wifi screen to use secure storage Security improvements: - WiFi passwords now stored encrypted via expo-secure-store - Passwords automatically cleared on user logout - Seamless migration for existing users 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
318 lines
9.7 KiB
TypeScript
318 lines
9.7 KiB
TypeScript
/**
|
||
* Tests for WiFi Password Secure Storage Service
|
||
*/
|
||
|
||
import * as SecureStore from 'expo-secure-store';
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import {
|
||
saveWiFiPassword,
|
||
getWiFiPassword,
|
||
getAllWiFiPasswords,
|
||
removeWiFiPassword,
|
||
clearAllWiFiPasswords,
|
||
migrateFromAsyncStorage,
|
||
} 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(),
|
||
},
|
||
}));
|
||
|
||
describe('wifiPasswordStore', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
describe('saveWiFiPassword', () => {
|
||
it('should save a new WiFi password', async () => {
|
||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
|
||
|
||
await saveWiFiPassword('MyNetwork', 'password123');
|
||
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||
'WIFI_PASSWORDS',
|
||
JSON.stringify({ MyNetwork: 'password123' })
|
||
);
|
||
});
|
||
|
||
it('should update an existing WiFi password', async () => {
|
||
const existingPasswords = { Network1: 'pass1', Network2: 'pass2' };
|
||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
|
||
JSON.stringify(existingPasswords)
|
||
);
|
||
|
||
await saveWiFiPassword('Network1', 'newpass1');
|
||
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||
'WIFI_PASSWORDS',
|
||
JSON.stringify({ Network1: 'newpass1', Network2: 'pass2' })
|
||
);
|
||
});
|
||
|
||
it('should add password to existing map', async () => {
|
||
const existingPasswords = { Network1: 'pass1' };
|
||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
|
||
JSON.stringify(existingPasswords)
|
||
);
|
||
|
||
await saveWiFiPassword('Network2', 'pass2');
|
||
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||
'WIFI_PASSWORDS',
|
||
JSON.stringify({ Network1: 'pass1', Network2: 'pass2' })
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('getWiFiPassword', () => {
|
||
it('should return password for existing network', async () => {
|
||
const passwords = { MyNetwork: 'password123', OtherNetwork: 'pass456' };
|
||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
|
||
JSON.stringify(passwords)
|
||
);
|
||
|
||
const password = await getWiFiPassword('MyNetwork');
|
||
|
||
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 saved passwords', async () => {
|
||
const passwords = { Network1: 'pass1', Network2: 'pass2', Network3: 'pass3' };
|
||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(
|
||
JSON.stringify(passwords)
|
||
);
|
||
|
||
const result = await getAllWiFiPasswords();
|
||
|
||
expect(result).toEqual(passwords);
|
||
});
|
||
|
||
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('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(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||
'WIFI_PASSWORDS',
|
||
JSON.stringify({ [ssid]: password })
|
||
);
|
||
});
|
||
|
||
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(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||
'WIFI_PASSWORDS',
|
||
JSON.stringify({ [ssid]: password })
|
||
);
|
||
});
|
||
|
||
it('should handle unicode characters', async () => {
|
||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(null);
|
||
|
||
const ssid = '我的网络';
|
||
const password = 'пароль123';
|
||
|
||
await saveWiFiPassword(ssid, password);
|
||
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||
'WIFI_PASSWORDS',
|
||
JSON.stringify({ [ssid]: password })
|
||
);
|
||
});
|
||
});
|
||
});
|