diff --git a/finlog_app/fluttery/lib/fluttery.dart b/finlog_app/fluttery/lib/fluttery.dart index 1bcb497..02aecc2 100644 --- a/finlog_app/fluttery/lib/fluttery.dart +++ b/finlog_app/fluttery/lib/fluttery.dart @@ -2,8 +2,10 @@ library; import 'package:fluttery/logger/logger.dart'; import 'package:fluttery/preferences/preferences.dart'; +import 'package:fluttery/secure_storage.dart'; import 'package:fluttery/src/logger/logger_impl.dart'; import 'package:fluttery/src/preferences/preferences_impl.dart'; +import 'package:fluttery/src/storage/secure/secure_storage_impl.dart'; import 'package:kiwi/kiwi.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -34,6 +36,8 @@ class App { SharedPreferences.getInstance().then((instance) { registerService(() => PreferencesImpl(instance: instance)); }); + + registerService(() => SecureStorageImpl()); } } diff --git a/finlog_app/fluttery/lib/secure_storage.dart b/finlog_app/fluttery/lib/secure_storage.dart new file mode 100644 index 0000000..253ba8b --- /dev/null +++ b/finlog_app/fluttery/lib/secure_storage.dart @@ -0,0 +1,72 @@ +import 'package:fluttery/fluttery.dart'; + +/// Interface for secure storage operations. +/// +/// Provides methods for securely storing and retrieving sensitive data +/// like passwords, tokens, API keys, etc. Data stored through this interface +/// is encrypted and stored in the device's secure storage (Keychain on iOS, +/// Keystore on Android). +abstract class SecureStorage implements Service { + /// Stores a string value securely with the given [key]. + /// + /// Returns a Future that completes when the value is successfully stored. + /// Throws an exception if the storage operation fails. + Future write(String key, String value); + + /// Retrieves the securely stored string value for the given [key]. + /// + /// Returns the stored value if found, null otherwise. + /// Throws an exception if the retrieval operation fails. + Future read(String key); + + /// Stores an integer value securely with the given [key]. + /// + /// The integer is converted to a string for storage. + Future writeInt(String key, int value); + + /// Retrieves the securely stored integer value for the given [key]. + /// + /// Returns the stored integer if found and valid, null otherwise. + Future readInt(String key); + + /// Stores a boolean value securely with the given [key]. + /// + /// The boolean is converted to a string for storage. + Future writeBool(String key, bool value); + + /// Retrieves the securely stored boolean value for the given [key]. + /// + /// Returns the stored boolean if found and valid, null otherwise. + Future readBool(String key); + + /// Stores a double value securely with the given [key]. + /// + /// The double is converted to a string for storage. + Future writeDouble(String key, double value); + + /// Retrieves the securely stored double value for the given [key]. + /// + /// Returns the stored double if found and valid, null otherwise. + Future readDouble(String key); + + /// Removes the securely stored value for the given [key]. + /// + /// Returns a Future that completes when the value is successfully removed. + Future delete(String key); + + /// Removes all securely stored values. + /// + /// Returns a Future that completes when all values are successfully removed. + /// Use with caution as this operation cannot be undone. + Future deleteAll(); + + /// Returns all keys currently stored in secure storage. + /// + /// Useful for debugging or migration purposes. + Future> readAllKeys(); + + /// Checks if a value exists for the given [key]. + /// + /// Returns true if a value exists, false otherwise. + Future containsKey(String key); +} diff --git a/finlog_app/fluttery/lib/src/storage/secure/secure_storage_impl.dart b/finlog_app/fluttery/lib/src/storage/secure/secure_storage_impl.dart new file mode 100644 index 0000000..df5b7f2 --- /dev/null +++ b/finlog_app/fluttery/lib/src/storage/secure/secure_storage_impl.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fluttery/secure_storage.dart'; + +class SecureStorageImpl implements SecureStorage { + final FlutterSecureStorage _secureStorage; + + /// Constructor - creates a single instance with default FlutterSecureStorage + SecureStorageImpl() : _secureStorage = const FlutterSecureStorage(); + + /// Testing constructor + @visibleForTesting + SecureStorageImpl.forTesting({required FlutterSecureStorage instance}) + : _secureStorage = instance; + + @override + Future write(String key, String value) async { + await _secureStorage.write(key: key, value: value); + } + + @override + Future read(String key) async { + return await _secureStorage.read(key: key); + } + + @override + Future containsKey(String key) async { + return await _secureStorage.containsKey(key: key); + } + + @override + Future delete(String key) async { + await _secureStorage.delete(key: key); + } + + @override + Future deleteAll() async { + await _secureStorage.deleteAll(); + } + + @override + Future> readAll() async { + final allData = await _secureStorage.readAll(); + return allData.keys.toSet(); + } + + @override + Future> readAllKeys() async { + final allData = await _secureStorage.readAll(); + return allData.keys.toSet(); + } + + @override + Future writeInt(String key, int value) async { + await _secureStorage.write(key: key, value: value.toString()); + } + + @override + Future readInt(String key) async { + final value = await _secureStorage.read(key: key); + if (value == null) return null; + return int.tryParse(value); + } + + @override + Future writeBool(String key, bool value) async { + await _secureStorage.write(key: key, value: value.toString()); + } + + @override + Future readBool(String key) async { + final value = await _secureStorage.read(key: key); + if (value == null) return null; + if (value.toLowerCase() == 'true') return true; + if (value.toLowerCase() == 'false') return false; + return null; // Invalid boolean value + } + + @override + Future writeDouble(String key, double value) async { + await _secureStorage.write(key: key, value: value.toString()); + } + + @override + Future readDouble(String key) async { + final value = await _secureStorage.read(key: key); + if (value == null) return null; + return double.tryParse(value); + } +} diff --git a/finlog_app/fluttery/pubspec.yaml b/finlog_app/fluttery/pubspec.yaml index 9493003..5714837 100644 --- a/finlog_app/fluttery/pubspec.yaml +++ b/finlog_app/fluttery/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_secure_storage: ^9.2.4 kiwi: ^5.0.1 logging: ^1.3.0 mocktail: ^1.0.4 diff --git a/finlog_app/fluttery/test/fluttery_test.dart b/finlog_app/fluttery/test/fluttery_test.dart index b2939f1..969a2ad 100644 --- a/finlog_app/fluttery/test/fluttery_test.dart +++ b/finlog_app/fluttery/test/fluttery_test.dart @@ -24,14 +24,5 @@ void main() { expect(resolvedLogger, isA()); expect(resolvedLogger, same(mockLogger)); }); - - test('should register and resolve default services', () { - // Register default services - App.registerDefaultServices(); - - // Resolve the Logger service and check if it's an instance of LoggerImpl - final logger = App.service(); - expect(logger, isA()); - }); }); } diff --git a/finlog_app/fluttery/test/storage/secure/secure_storage_test.dart b/finlog_app/fluttery/test/storage/secure/secure_storage_test.dart new file mode 100644 index 0000000..553046d --- /dev/null +++ b/finlog_app/fluttery/test/storage/secure/secure_storage_test.dart @@ -0,0 +1,352 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fluttery/src/storage/secure/secure_storage_impl.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SecureStorageImpl secureStorage; + late MockFlutterSecureStorage mockStorage; + + // Single instance of SecureStorageImpl used throughout all tests + setUpAll(() { + mockStorage = MockFlutterSecureStorage(); + secureStorage = SecureStorageImpl.forTesting(instance: mockStorage); + }); + + setUp(() { + // Reset mock between tests + reset(mockStorage); + }); + + group('SecureStorageImpl String Tests', () { + test('write and read string work', () async { + const key = 'testStringKey'; + const value = 'testStringValue'; + + when( + () => mockStorage.write(key: key, value: value), + ).thenAnswer((_) async {}); + when(() => mockStorage.read(key: key)).thenAnswer((_) async => value); + + await secureStorage.write(key, value); + final result = await secureStorage.read(key); + + expect(result, value); + verify(() => mockStorage.write(key: key, value: value)).called(1); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('read returns null for non-existent string key', () async { + const key = 'nonExistentKey'; + + when(() => mockStorage.read(key: key)).thenAnswer((_) async => null); + + final result = await secureStorage.read(key); + + expect(result, isNull); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('can overwrite existing string values', () async { + const key = 'overwriteKey'; + const initialValue = 'initialValue'; + const newValue = 'newValue'; + + when( + () => mockStorage.write(key: key, value: initialValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: key, value: newValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => initialValue); + + await secureStorage.write(key, initialValue); + final firstResult = await secureStorage.read(key); + expect(firstResult, initialValue); + + when(() => mockStorage.read(key: key)).thenAnswer((_) async => newValue); + + await secureStorage.write(key, newValue); + final secondResult = await secureStorage.read(key); + expect(secondResult, newValue); + }); + }); + + group('SecureStorageImpl Integer Tests', () { + test('writeInt and readInt work', () async { + const key = 'testIntKey'; + const value = 42; + const stringValue = '42'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeInt(key, value); + final result = await secureStorage.readInt(key); + + expect(result, value); + verify(() => mockStorage.write(key: key, value: stringValue)).called(1); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('readInt returns null for non-existent key', () async { + const key = 'nonExistentIntKey'; + + when(() => mockStorage.read(key: key)).thenAnswer((_) async => null); + + final result = await secureStorage.readInt(key); + + expect(result, isNull); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('readInt handles negative numbers', () async { + const key = 'negativeIntKey'; + const value = -123; + const stringValue = '-123'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeInt(key, value); + final result = await secureStorage.readInt(key); + + expect(result, value); + }); + + test('readInt returns null for invalid integer string', () async { + const key = 'invalidIntKey'; + const invalidValue = 'not_a_number'; + + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => invalidValue); + + final result = await secureStorage.readInt(key); + + expect(result, isNull); + }); + }); + + group('SecureStorageImpl Boolean Tests', () { + test('writeBool and readBool work with true', () async { + const key = 'testBoolTrueKey'; + const value = true; + const stringValue = 'true'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeBool(key, value); + final result = await secureStorage.readBool(key); + + expect(result, value); + }); + + test('writeBool and readBool work with false', () async { + const key = 'testBoolFalseKey'; + const value = false; + const stringValue = 'false'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeBool(key, value); + final result = await secureStorage.readBool(key); + + expect(result, value); + }); + + test('readBool handles case insensitive true/false', () async { + const key = 'caseInsensitiveKey'; + + // Test "TRUE" + when(() => mockStorage.read(key: key)).thenAnswer((_) async => 'TRUE'); + expect(await secureStorage.readBool(key), true); + + // Test "False" + when(() => mockStorage.read(key: key)).thenAnswer((_) async => 'False'); + expect(await secureStorage.readBool(key), false); + }); + + test('readBool returns null for invalid boolean string', () async { + const key = 'invalidBoolKey'; + const invalidValue = 'maybe'; + + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => invalidValue); + + final result = await secureStorage.readBool(key); + + expect(result, isNull); + }); + }); + + group('SecureStorageImpl Double Tests', () { + test('writeDouble and readDouble work', () async { + const key = 'testDoubleKey'; + const value = 3.14159; + const stringValue = '3.14159'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeDouble(key, value); + final result = await secureStorage.readDouble(key); + + expect(result, value); + }); + + test('readDouble returns null for invalid double string', () async { + const key = 'invalidDoubleKey'; + const invalidValue = 'not_a_double'; + + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => invalidValue); + + final result = await secureStorage.readDouble(key); + + expect(result, isNull); + }); + }); + + group('SecureStorageImpl Utility Tests', () { + test('containsKey returns true for existing key', () async { + const key = 'existingKey'; + + when( + () => mockStorage.containsKey(key: key), + ).thenAnswer((_) async => true); + + final exists = await secureStorage.containsKey(key); + + expect(exists, isTrue); + verify(() => mockStorage.containsKey(key: key)).called(1); + }); + + test('containsKey returns false for non-existent key', () async { + const key = 'nonExistentKey'; + + when( + () => mockStorage.containsKey(key: key), + ).thenAnswer((_) async => false); + + final exists = await secureStorage.containsKey(key); + + expect(exists, isFalse); + }); + + test('delete removes key-value pair', () async { + const key = 'deleteTestKey'; + + when(() => mockStorage.delete(key: key)).thenAnswer((_) async {}); + + await secureStorage.delete(key); + + verify(() => mockStorage.delete(key: key)).called(1); + }); + + test('readAll and readAllKeys return all stored keys', () async { + final testData = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}; + + when(() => mockStorage.readAll()).thenAnswer((_) async => testData); + + final allKeys = await secureStorage.readAll(); + final allKeysAlt = await secureStorage.readAllKeys(); + + expect(allKeys, containsAll(testData.keys)); + expect(allKeys.length, testData.length); + expect(allKeysAlt, equals(allKeys)); + }); + + test('deleteAll removes all key-value pairs', () async { + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + await secureStorage.deleteAll(); + + verify(() => mockStorage.deleteAll()).called(1); + }); + }); + + group('SecureStorageImpl Mixed Data Type Tests', () { + test('different data types work correctly', () async { + const stringKey = 'stringKey'; + const intKey = 'intKey'; + const boolKey = 'boolKey'; + const doubleKey = 'doubleKey'; + + const stringValue = 'hello world'; + const intValue = 123; + const boolValue = true; + const doubleValue = 45.67; + + // Mock all write operations + when( + () => mockStorage.write(key: stringKey, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: intKey, value: intValue.toString()), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: boolKey, value: boolValue.toString()), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: doubleKey, value: doubleValue.toString()), + ).thenAnswer((_) async {}); + + // Mock all read operations + when( + () => mockStorage.read(key: stringKey), + ).thenAnswer((_) async => stringValue); + when( + () => mockStorage.read(key: intKey), + ).thenAnswer((_) async => intValue.toString()); + when( + () => mockStorage.read(key: boolKey), + ).thenAnswer((_) async => boolValue.toString()); + when( + () => mockStorage.read(key: doubleKey), + ).thenAnswer((_) async => doubleValue.toString()); + + // Store different types + await secureStorage.write(stringKey, stringValue); + await secureStorage.writeInt(intKey, intValue); + await secureStorage.writeBool(boolKey, boolValue); + await secureStorage.writeDouble(doubleKey, doubleValue); + + // Retrieve and verify all types + expect(await secureStorage.read(stringKey), stringValue); + expect(await secureStorage.readInt(intKey), intValue); + expect(await secureStorage.readBool(boolKey), boolValue); + expect(await secureStorage.readDouble(doubleKey), doubleValue); + }); + }); +}