diff --git a/README.md b/README.md index d268d4d8..3a2a8d88 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,8 @@ can [become a sponsor on GitHub](https://github.com/sponsors/tsutsu3), or Run these commands as needed whenever you update the images. -1. Run ``flutter clean`` -2. Run ``dart run flutter_launcher_icons` -3. Run ``dart run flutter_native_splash:create`` +1. Run ``dart run flutter_launcher_icons` +2. Run ``dart run flutter_native_splash:create`` #### Android diff --git a/analysis_options.yaml b/analysis_options.yaml index 83414124..d5f10f6e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -44,7 +44,7 @@ analyzer: errors: invalid_annotation_target: ignore exclude: - - test/** + - test/**/*.mocks.dart - lib/**/*.g.dart - lib/screens/settings/about/about.dart diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f6981..320da1ba 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 89a4fb06..e9b4281b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + - - diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png deleted file mode 100644 index 98aaa79b..00000000 Binary files a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png deleted file mode 100644 index f7b60d23..00000000 Binary files a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png deleted file mode 100644 index 02632235..00000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png deleted file mode 100644 index e5adb552..00000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png deleted file mode 100644 index 22193844..00000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 399f6981..320da1ba 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5f2d171d..ffccf00d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,5 +47,7 @@ UIStatusBarHidden + NSFaceIDUsageDescription + Why is my app authenticating using face id? diff --git a/lib/main.dart b/lib/main.dart index d33b47fe..56d52a4a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,7 +80,9 @@ void main() async { configProvider.setBiometricsSupport(canAuthenticateWithBiometrics); if (canAuthenticateWithBiometrics && - availableBiometrics.contains(BiometricType.fingerprint) == false && + !availableBiometrics.contains(BiometricType.fingerprint) && + !availableBiometrics.contains(BiometricType.strong) && + !availableBiometrics.contains(BiometricType.weak) && dbRepository.appConfig.useBiometricAuth == 1) { await configProvider.setUseBiometrics(false); } diff --git a/lib/repository/database.dart b/lib/repository/database.dart index 9debe850..0e488538 100644 --- a/lib/repository/database.dart +++ b/lib/repository/database.dart @@ -49,8 +49,8 @@ class DatabaseRepository { /// /// Throws: /// - [Exception] if the initialization fails or the database cannot be accessed. - Future initialize() async { - final piHoleClientData = await loadDb(); + Future initialize({String? path}) async { + final piHoleClientData = await loadDb(path: path); _servers = piHoleClientData.servers; _appConfig = piHoleClientData.appConfig; _dbInstance = piHoleClientData.dbInstance; @@ -69,6 +69,10 @@ class DatabaseRepository { /// This method is responsible for initializing or opening the database, /// ensuring that necessary tables exist, and fetching the required data. /// + /// Parameters: + /// - [path]: The path to the database file. If not provided, the default + /// path 'pi_hole_client.db' will be used. + /// /// Returns: /// - A `Future` that resolves to a [PiHoleClientData] object containing: /// - `servers`: A list of server configurations from the database. @@ -77,12 +81,12 @@ class DatabaseRepository { /// /// Throws: /// - [Exception] if the database cannot be initialized or data cannot be retrieved. - Future loadDb() async { + Future loadDb({String? path}) async { List? servers; AppDbData? appConfig; Database db = await openDatabase( - 'pi_hole_client.db', + path ?? 'pi_hole_client.db', version: 1, onCreate: (Database db, int version) async { await db.execute(''' @@ -140,7 +144,7 @@ class DatabaseRepository { // Load sensitive data from secure storage final passCode = await _secureStorage.getValue('passCode'); - AppDbData.withSecrets(appConfig!, passCode); + appConfig = AppDbData.withSecrets(appConfig!, passCode); // _secureStorage.readAll() logger.d((await _secureStorage.readAll()).toString()); @@ -175,6 +179,21 @@ class DatabaseRepository { ); } + /// Closes the database connection. + /// + /// This method closes the database connection and releases any resources + /// + /// Returns: + /// - A `Future` that resolves to `true` if the operation is successful. + Future closeDb() async { + try { + await _dbInstance.close(); + return true; + } catch (e) { + return false; + } + } + /// Saves a new server entry into the database. /// /// This method adds a new server record to the 'servers' table in the database. @@ -486,6 +505,11 @@ class DatabaseRepository { }) async { try { if (column == 'passCode') { + if (value == null) { + await _secureStorage.deleteValue('passCode'); + return true; + } + await _secureStorage.saveValue('passCode', value.toString()); return true; } diff --git a/lib/repository/secure_storage.dart b/lib/repository/secure_storage.dart index 351b4134..1a368093 100644 --- a/lib/repository/secure_storage.dart +++ b/lib/repository/secure_storage.dart @@ -1,7 +1,10 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class SecureStorageRepository { - final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + final FlutterSecureStorage _secureStorage; + + SecureStorageRepository({FlutterSecureStorage? secureStorage}) + : _secureStorage = secureStorage ?? const FlutterSecureStorage(); // Save a value securely Future saveValue(String key, String value) async { diff --git a/test/gateways/v5/api_gateway_v5_test.dart b/test/gateways/v5/api_gateway_v5_test.dart index c7470217..7864948c 100644 --- a/test/gateways/v5/api_gateway_v5_test.dart +++ b/test/gateways/v5/api_gateway_v5_test.dart @@ -18,11 +18,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); apiGateway = ApiGatewayV5(server); }); @@ -48,60 +49,71 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success with valid auth token', () async { final mockClient = MockClient(); final apiGateway = ApiGatewayV5(server, client: mockClient); - when(mockClient.get(Uri.parse(url), headers: {})) - .thenAnswer((_) async => http.Response( - jsonEncode({ - 'domains_being_blocked': 121, - 'dns_queries_today': 12, - 'ads_blocked_today': 1, - 'ads_percentage_today': 8.333333, - 'unique_domains': 11, - 'queries_forwarded': 9, - 'queries_cached': 2, - 'clients_ever_seen': 2, - 'unique_clients': 2, - 'dns_queries_all_types': 12, - 'reply_UNKNOWN': 0, - 'reply_NODATA': 0, - 'reply_NXDOMAIN': 1, - 'reply_CNAME': 0, - 'reply_IP': 10, - 'reply_DOMAIN': 1, - 'reply_RRNAME': 0, - 'reply_SERVFAIL': 0, - 'reply_REFUSED': 0, - 'reply_NOTIMP': 0, - 'reply_OTHER': 0, - 'reply_DNSSEC': 0, - 'reply_NONE': 0, - 'reply_BLOB': 0, - 'dns_queries_all_replies': 12, - 'privacy_level': 0, - 'status': 'enabled', - 'gravity_last_updated': { - 'file_exists': true, - 'absolute': 17329, - 'relative': {'days': 4, 'hours': 23, 'minutes': 41} - } - }), - 200)); - - when(mockClient.get( + when(mockClient.get(Uri.parse(url), headers: {})).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'domains_being_blocked': 121, + 'dns_queries_today': 12, + 'ads_blocked_today': 1, + 'ads_percentage_today': 8.333333, + 'unique_domains': 11, + 'queries_forwarded': 9, + 'queries_cached': 2, + 'clients_ever_seen': 2, + 'unique_clients': 2, + 'dns_queries_all_types': 12, + 'reply_UNKNOWN': 0, + 'reply_NODATA': 0, + 'reply_NXDOMAIN': 1, + 'reply_CNAME': 0, + 'reply_IP': 10, + 'reply_DOMAIN': 1, + 'reply_RRNAME': 0, + 'reply_SERVFAIL': 0, + 'reply_REFUSED': 0, + 'reply_NOTIMP': 0, + 'reply_OTHER': 0, + 'reply_DNSSEC': 0, + 'reply_NONE': 0, + 'reply_BLOB': 0, + 'dns_queries_all_replies': 12, + 'privacy_level': 0, + 'status': 'enabled', + 'gravity_last_updated': { + 'file_exists': true, + 'absolute': 17329, + 'relative': {'days': 4, 'hours': 23, 'minutes': 41}, + }, + }), + 200, + ), + ); + + when( + mockClient.get( Uri.parse('http://example.com/admin/api.php?auth=xxx123&enable=0'), - headers: {})).thenAnswer((_) async => http.Response( - jsonEncode({'status': 'enabled'}), 200, headers: { - 'set-cookie': 'sid=$sessinId; path=/; HttpOnly; SameSite=Strict' - })); + headers: {}, + ), + ).thenAnswer( + (_) async => http.Response( + jsonEncode({'status': 'enabled'}), + 200, + headers: { + 'set-cookie': 'sid=$sessinId; path=/; HttpOnly; SameSite=Strict', + }, + ), + ); final response = await apiGateway.loginQuery(); @@ -223,11 +235,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -264,7 +277,7 @@ void main() { 'gravity_last_updated': { 'file_exists': true, 'absolute': 1732972589, - 'relative': {'days': 5, 'hours': 18, 'minutes': 14} + 'relative': {'days': 5, 'hours': 18, 'minutes': 14}, }, 'top_queries': { '1.0.26.172.in-addr.arpa': 3, @@ -276,7 +289,7 @@ void main() { 'google.com': 1, 'google.co.jp': 1, 'yahoo.co.jp': 1, - 'fix.test.com': 1 + 'fix.test.com': 1, }, 'top_ads': {'test.com': 1}, 'top_sources': {'172.26.0.1': 10, 'localhost|127.0.0.1': 6}, @@ -285,7 +298,7 @@ void main() { 'blocked|blocked': 6.25, 'cached|cached': 37.5, 'other|other': 0, - 'dns.google#53|8.8.8.8#53': 56.25 + 'dns.google#53|8.8.8.8#53': 56.25, }, 'querytypes': { 'A (IPv4)': 62.5, @@ -303,8 +316,8 @@ void main() { 'NS': 0, 'OTHER': 0, 'SVCB': 0, - 'HTTPS': 0 - } + 'HTTPS': 0, + }, }; when(mockClient.get(Uri.parse(url), headers: {})) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); @@ -335,11 +348,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -375,11 +389,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -416,11 +431,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -571,7 +587,7 @@ void main() { '1733475300': 0, '1733475900': 0, '1733476500': 0, - '1733477100': 0 + '1733477100': 0, }, 'ads_over_time': { '1733391300': 0, @@ -717,11 +733,11 @@ void main() { '1733475300': 0, '1733475900': 0, '1733476500': 0, - '1733477100': 0 + '1733477100': 0, }, 'clients': [ {'name': '', 'ip': '172.26.0.1'}, - {'name': 'localhost', 'ip': '127.0.0.1'} + {'name': 'localhost', 'ip': '127.0.0.1'}, ], 'over_time': { '1733391300': [0, 0], @@ -867,8 +883,8 @@ void main() { '1733475300': [0, 0], '1733475900': [0, 0], '1733476500': [0, 0], - '1733477100': [0, 0] - } + '1733477100': [0, 0], + }, }; when(mockClient.get(Uri.parse(url), headers: {})) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); @@ -900,11 +916,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -924,7 +941,7 @@ void main() { 'N/A', '-1', 'dns.google#53', - '' + '', ], [ '1733479462', @@ -938,9 +955,9 @@ void main() { 'N/A', '-1', 'dns.google#53', - '' - ] - ] + '', + ], + ], }; when(mockClient.get(Uri.parse(url), headers: {})) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); @@ -976,11 +993,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success when add new domain', () async { @@ -1003,7 +1021,7 @@ void main() { final apiGateway = ApiGatewayV5(server, client: mockClient); final data = { 'success': true, - 'message': 'Not adding google.com as it is already on the list' + 'message': 'Not adding google.com as it is already on the list', }; when(mockClient.get(Uri.parse(url), headers: {})) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); @@ -1054,16 +1072,17 @@ void main() { 'http://example.com/admin/api.php?auth=xxx123&list=white', 'http://example.com/admin/api.php?auth=xxx123&list=regex_white', 'http://example.com/admin/api.php?auth=xxx123&list=black', - 'http://example.com/admin/api.php?auth=xxx123&list=regex_black' + 'http://example.com/admin/api.php?auth=xxx123&list=regex_black', ]; setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -1080,9 +1099,9 @@ void main() { 'date_added': 1733559182, 'date_modified': 1733559182, 'comment': '', - 'groups': [0] - } - ] + 'groups': [0], + }, + ], }, {'data': []}, { @@ -1095,9 +1114,9 @@ void main() { 'date_added': 1733401118, 'date_modified': 1733496612, 'comment': '', - 'groups': [0] - } - ] + 'groups': [0], + }, + ], }, {'data': []}, ]; @@ -1137,11 +1156,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success', () async { @@ -1151,7 +1171,8 @@ void main() { when(mockClient.get(Uri.parse(url), headers: {})) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); - final response = await apiGateway.removeDomainFromList(Domain( + final response = await apiGateway.removeDomainFromList( + Domain( id: 1, domain: 'google.com', type: 0, @@ -1159,7 +1180,9 @@ void main() { dateAdded: DateTime.now(), dateModified: DateTime.now(), comment: '', - groups: [])); + groups: [], + ), + ); expect(response.result, APiResponseType.success); expect(response.message, isNull); @@ -1172,7 +1195,8 @@ void main() { when(mockClient.get(Uri.parse(url), headers: {})) .thenThrow(Exception('Unexpected error test')); - final response = await apiGateway.removeDomainFromList(Domain( + final response = await apiGateway.removeDomainFromList( + Domain( id: 1, domain: 'google.com', type: 0, @@ -1180,7 +1204,9 @@ void main() { dateAdded: DateTime.now(), dateModified: DateTime.now(), comment: '', - groups: [])); + groups: [], + ), + ); expect(response.result, APiResponseType.error); expect(response.message, isNull); @@ -1194,11 +1220,12 @@ void main() { setUp(() { server = Server( - address: 'http://example.com', - alias: 'example', - defaultServer: true, - apiVersion: SupportedApiVersions.v5, - token: 'xxx123'); + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123', + ); }); test('Return success when add new domain', () async { @@ -1219,7 +1246,7 @@ void main() { final apiGateway = ApiGatewayV5(server, client: mockClient); final data = { 'success': true, - 'message': 'Not adding google.com as it is already on the list' + 'message': 'Not adding google.com as it is already on the list', }; when(mockClient.get(Uri.parse(url), headers: {})) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); diff --git a/test/gateways/v6/api_gateway_v6_test.dart b/test/gateways/v6/api_gateway_v6_test.dart index 0c301155..1b77e93b 100644 --- a/test/gateways/v6/api_gateway_v6_test.dart +++ b/test/gateways/v6/api_gateway_v6_test.dart @@ -16,13 +16,11 @@ class SessionManagerMock implements SessionManager { String? _sid; String? _password; - SessionManagerMock(this._sid, this._password); - @override - get sid => _sid; + String? get sid => _sid; @override - get password async { + Future? get password async { try { return _password; } catch (e) { @@ -30,6 +28,8 @@ class SessionManagerMock implements SessionManager { } } + SessionManagerMock(this._sid, this._password); + @override Future save(String sid) async { _sid = sid; @@ -64,7 +64,7 @@ void main() async { final sessinId = 'n9n9f6c3umrumfq2ese1lvu2pg'; final urls = [ 'http://example.com/api/auth', - 'http://example.com/api/dns/blocking' + 'http://example.com/api/dns/blocking', ]; setUp(() { @@ -80,11 +80,14 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); - when(mockClient.post( - Uri.parse(urls[0]), - headers: anyNamed('headers'), - body: anyNamed('body'), - )).thenAnswer((_) async => http.Response( + when( + mockClient.post( + Uri.parse(urls[0]), + headers: anyNamed('headers'), + body: anyNamed('body'), + ), + ).thenAnswer( + (_) async => http.Response( jsonEncode({ 'session': { 'valid': true, @@ -92,18 +95,22 @@ void main() async { 'sid': 'n9n9f6c3umrumfq2ese1lvu2pg', 'csrf': 'Ux87YTIiMOf/GKCefVIOMw=', 'validity': 300, - 'message': 'correct password' + 'message': 'correct password', }, - 'took': 0.039638996124267578 + 'took': 0.039638996124267578, }), - 200)); + 200, + ), + ); int callCount = 0; - when(mockClient.get( - Uri.parse(urls[1]), - headers: anyNamed('headers'), - )).thenAnswer((_) async { + when( + mockClient.get( + Uri.parse(urls[1]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async { callCount++; if (callCount == 1) { return http.Response( @@ -111,9 +118,9 @@ void main() async { 'error': { 'key': 'unauthorized', 'message': 'Unauthorized', - 'hint': null + 'hint': null, }, - 'took': 4.1484832763671875e-05 + 'took': 4.1484832763671875e-05, }), 401, ); @@ -138,10 +145,12 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); - when(mockClient.get( - Uri.parse(urls[1]), - headers: anyNamed('headers'), - )).thenAnswer((_) async { + when( + mockClient.get( + Uri.parse(urls[1]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async { return http.Response( jsonEncode({'blocking': 'enabled', 'timer': null, 'took': 0.003}), 200, @@ -160,39 +169,46 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); - when(mockClient.get( - Uri.parse(urls[1]), - headers: anyNamed('headers'), - )).thenAnswer((_) async { + when( + mockClient.get( + Uri.parse(urls[1]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async { return http.Response( jsonEncode({ 'error': { 'key': 'unauthorized', 'message': 'Unauthorized', - 'hint': null + 'hint': null, }, - 'took': 4.1484832763671875e-05 + 'took': 4.1484832763671875e-05, }), 401, ); }); - when(mockClient.post( - Uri.parse(urls[0]), - headers: anyNamed('headers'), - body: anyNamed('body'), - )).thenAnswer((_) async => http.Response( + when( + mockClient.post( + Uri.parse(urls[0]), + headers: anyNamed('headers'), + body: anyNamed('body'), + ), + ).thenAnswer( + (_) async => http.Response( jsonEncode({ 'session': { 'valid': false, 'totp': false, 'sid': null, 'validity': -1, - 'message': 'password incorrect' + 'message': 'password incorrect', }, - 'took': 0.039638996124267578 + 'took': 0.039638996124267578, }), - 401)); + 401, + ), + ); final response = await apiGateway.loginQuery(); @@ -260,28 +276,32 @@ void main() async { ''' .trimLeft(); - when(mockClient.get( - Uri.parse(urls[1]), - headers: anyNamed('headers'), - )).thenAnswer((_) async { + when( + mockClient.get( + Uri.parse(urls[1]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async { return http.Response( jsonEncode({ 'error': { 'key': 'unauthorized', 'message': 'Unauthorized', - 'hint': null + 'hint': null, }, - 'took': 4.1484832763671875e-05 + 'took': 4.1484832763671875e-05, }), 401, ); }); - when(mockClient.post( - Uri.parse(urls[0]), - headers: anyNamed('headers'), - body: anyNamed('body'), - )).thenAnswer((_) async => http.Response(htmlString, 404)); + when( + mockClient.post( + Uri.parse(urls[0]), + headers: anyNamed('headers'), + body: anyNamed('body'), + ), + ).thenAnswer((_) async => http.Response(htmlString, 404)); final response = await apiGateway.loginQuery(); @@ -299,28 +319,32 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); - when(mockClient.get( - Uri.parse(urls[1]), - headers: anyNamed('headers'), - )).thenAnswer((_) async { + when( + mockClient.get( + Uri.parse(urls[1]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async { return http.Response( jsonEncode({ 'error': { 'key': 'unauthorized', 'message': 'Unauthorized', - 'hint': null + 'hint': null, }, - 'took': 4.1484832763671875e-05 + 'took': 4.1484832763671875e-05, }), 401, ); }); - when(mockClient.post( - Uri.parse(urls[0]), - headers: anyNamed('headers'), - body: anyNamed('body'), - )).thenThrow(Exception('Unexpected error test')); + when( + mockClient.post( + Uri.parse(urls[0]), + headers: anyNamed('headers'), + body: anyNamed('body'), + ), + ).thenThrow(Exception('Unexpected error test')); final response = await apiGateway.loginQuery(); @@ -383,7 +407,7 @@ void main() async { 'NS': 868, 'SVCB': 645, 'HTTPS': 4, - 'OTHER': 845 + 'OTHER': 845, }, 'status': { 'UNKNOWN': 3, @@ -403,7 +427,7 @@ void main() async { 'IN_PROGRESS': 0, 'DBBUSY': 0, 'SPECIAL_DOMAIN': 0, - 'CACHE_STALE': 0 + 'CACHE_STALE': 0, }, 'replies': { 'UNKNOWN': 3, @@ -419,15 +443,15 @@ void main() async { 'OTHER': 0, 'DNSSEC': 31, 'NONE': 0, - 'BLOB': 0 - } + 'BLOB': 0, + }, }, 'clients': {'active': 10, 'total': 22}, 'gravity': { 'domains_being_blocked': 104756, - 'last_update': 1725194639 + 'last_update': 1725194639, }, - 'took': 0.003 + 'took': 0.003, }, { 'ftl': { @@ -436,7 +460,7 @@ void main() async { 'groups': 6, 'lists': 1, 'clients': 5, - 'domains': {'allowed': 10, 'denied': 3} + 'domains': {'allowed': 10, 'denied': 3}, }, 'privacy_level': 0, 'clients': {'total': 10, 'active': 8}, @@ -471,43 +495,43 @@ void main() async { 'tcp_connections': 0, 'dnssec_max_crypto_use': 0, 'dnssec_max_sig_fail': 0, - 'dnssec_max_work': 0 - } + 'dnssec_max_work': 0, + }, }, - 'took': 0.003 + 'took': 0.003, }, {'blocking': 'enabled', 'timer': 15, 'took': 0.003}, { 'domains': [ - {'domain': 'pi-hole.net', 'count': 8516} + {'domain': 'pi-hole.net', 'count': 8516}, ], 'total_queries': 29160, 'blocked_queries': 6379, - 'took': 0.003 + 'took': 0.003, }, { 'domains': [ - {'domain': 'pi-hole.net', 'count': 8516} + {'domain': 'pi-hole.net', 'count': 8516}, ], 'total_queries': 29160, 'blocked_queries': 6379, - 'took': 0.003 + 'took': 0.003, }, { 'clients': [ - {'ip': '192.168.0.44', 'name': 'raspberrypi.lan', 'count': 5896} + {'ip': '192.168.0.44', 'name': 'raspberrypi.lan', 'count': 5896}, ], 'total_queries': 29160, 'blocked_queries': 6379, - 'took': 0.003 + 'took': 0.003, }, { 'clients': [ - {'ip': '192.168.0.44', 'name': 'raspberrypi.lan', 'count': 5896} + {'ip': '192.168.0.44', 'name': 'raspberrypi.lan', 'count': 5896}, ], 'total_queries': 29160, 'blocked_queries': 6379, - 'took': 0.003 + 'took': 0.003, }, { 'upstreams': [ @@ -516,14 +540,14 @@ void main() async { 'name': 'blocklist', 'port': -1, 'count': 0, - 'statistics': {'response': 0, 'variance': 0} + 'statistics': {'response': 0, 'variance': 0}, }, { 'ip': 'cache', 'name': 'cache', 'port': -1, 'count': 2, - 'statistics': {'response': 0, 'variance': 0} + 'statistics': {'response': 0, 'variance': 0}, }, { 'ip': '8.8.8.8', @@ -532,20 +556,22 @@ void main() async { 'count': 8, 'statistics': { 'response': 0.0516872935824924, - 'variance': 0.0049697216173868828 - } + 'variance': 0.0049697216173868828, + }, }, ], 'total_queries': 8, 'forwarded_queries': 6, - 'took': 5.6982040405273438e-05 - } + 'took': 5.6982040405273438e-05, + }, ]; for (int i = 0; i < urls.length; i++) { - when(mockClient.get( - Uri.parse(urls[i]), - headers: anyNamed('headers'), - )).thenAnswer((_) async => http.Response(jsonEncode(data[i]), 200)); + when( + mockClient.get( + Uri.parse(urls[i]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data[i]), 200)); } final response = await apiGateway.realtimeStatus(); @@ -588,10 +614,13 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); final data = {'blocking': 'disabled', 'timer': 15, 'took': 0.003}; - when(mockClient.post(Uri.parse(url), - headers: anyNamed('headers'), - body: jsonEncode({'blocking': false, 'timer': 15}))) - .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + when( + mockClient.post( + Uri.parse(url), + headers: anyNamed('headers'), + body: jsonEncode({'blocking': false, 'timer': 15}), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 200)); final response = await apiGateway.disableServerRequest(15); @@ -603,10 +632,13 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); - when(mockClient.post(Uri.parse(url), - headers: anyNamed('headers'), - body: jsonEncode({'blocking': false, 'timer': 15}))) - .thenThrow(Exception('Unexpected error test')); + when( + mockClient.post( + Uri.parse(url), + headers: anyNamed('headers'), + body: jsonEncode({'blocking': false, 'timer': 15}), + ), + ).thenThrow(Exception('Unexpected error test')); final response = await apiGateway.disableServerRequest(5); @@ -632,10 +664,13 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); final data = {'blocking': 'enabled', 'timer': null, 'took': 0.03}; - when(mockClient.post(Uri.parse(url), - headers: anyNamed('headers'), - body: jsonEncode({'blocking': true, 'timer': null}))) - .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + when( + mockClient.post( + Uri.parse(url), + headers: anyNamed('headers'), + body: jsonEncode({'blocking': true, 'timer': null}), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 200)); final response = await apiGateway.enableServerRequest(); @@ -647,10 +682,13 @@ void main() async { final mockClient = MockClient(); final apiGateway = ApiGatewayV6(server, client: mockClient); - when(mockClient.post(Uri.parse(url), - headers: anyNamed('headers'), - body: jsonEncode({'blocking': true, 'timer': null}))) - .thenThrow(Exception('Unexpected error test')); + when( + mockClient.post( + Uri.parse(url), + headers: anyNamed('headers'), + body: jsonEncode({'blocking': true, 'timer': null}), + ), + ).thenThrow(Exception('Unexpected error test')); final response = await apiGateway.enableServerRequest(); @@ -663,7 +701,7 @@ void main() async { late Server server; final urls = [ 'http://example.com/api/history', - 'http://example.com/api/history/clients' + 'http://example.com/api/history/clients', ]; setUp(() { server = Server( @@ -685,17 +723,17 @@ void main() async { 'total': 2134, 'cached': 525, 'blocked': 413, - 'forwarded': 1196 + 'forwarded': 1196, }, { 'timestamp': 1511820500.583821, 'total': 2014, 'cached': 52, 'blocked': 43, - 'forwarded': 1910 - } + 'forwarded': 1910, + }, ], - 'took': 0.003 + 'took': 0.003, }, { 'clients': { @@ -703,7 +741,7 @@ void main() async { '::1': {'name': 'ip6-localnet', 'total': 2100}, '192.168.1.1': {'name': null, 'total': 254}, '::': {'name': 'pi.hole', 'total': 29}, - '0.0.0.0': {'name': 'other clients', 'total': 14} + '0.0.0.0': {'name': 'other clients', 'total': 14}, }, 'history': [ { @@ -713,23 +751,25 @@ void main() async { '::1': 63, '192.168.1.1': 20, '::': 9, - '0.0.0.0': 0 - } + '0.0.0.0': 0, + }, }, { 'timestamp': 1511820500.583821, - 'data': {'127.0.0.1': 10, '::1': 44, '192.168.1.1': 56, '::': 52} - } + 'data': {'127.0.0.1': 10, '::1': 44, '192.168.1.1': 56, '::': 52}, + }, ], - 'took': 0.003 + 'took': 0.003, }, ]; for (int i = 0; i < urls.length; i++) { - when(mockClient.get( - Uri.parse(urls[i]), - headers: anyNamed('headers'), - )).thenAnswer((_) async => http.Response(jsonEncode(data[i]), 200)); + when( + mockClient.get( + Uri.parse(urls[i]), + headers: anyNamed('headers'), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data[i]), 200)); } final response = await apiGateway.fetchOverTimeData(); @@ -785,7 +825,7 @@ void main() async { 'reply': {'type': 'IP', 'time': 19}, 'list_id': null, 'upstream': 'localhost#5353', - 'dbid': 112421354 + 'dbid': 112421354, }, { 'id': 2, @@ -799,14 +839,14 @@ void main() async { 'reply': {'type': 'IP', 'time': 12.3}, 'list_id': null, 'upstream': 'localhost#5353', - 'dbid': 112421355 - } + 'dbid': 112421355, + }, ], 'cursor': 175881, 'recordsTotal': 1234, 'recordsFiltered': 1234, 'draw': 1, - 'took': 0.003 + 'took': 0.003, }; when(mockClient.get(Uri.parse(url), headers: anyNamed('headers'))) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); @@ -863,32 +903,38 @@ void main() async { 'enabled': true, 'id': 1, 'date_added': 1734008144, - 'date_modified': 1734008144 - } + 'date_modified': 1734008144, + }, ], 'processed': { 'errors': [], 'success': [ - {'item': 'google.com'} - ] + {'item': 'google.com'}, + ], }, - 'took': 0.0042212009429931641 + 'took': 0.0042212009429931641, }; - when(mockClient.post(Uri.parse(url), + when( + mockClient.post( + Uri.parse(url), headers: anyNamed('headers'), body: jsonEncode({ 'domain': 'google.com', 'comment': null, 'groups': [0], - 'enabled': true - }))).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); + 'enabled': true, + }), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); final response = await apiGateway.setWhiteBlacklist('google.com', 'black'); expect(response.result, APiResponseType.success); - expect(response.data!.toJson(), - {'success': true, 'message': 'Added google.com'}); + expect( + response.data!.toJson(), + {'success': true, 'message': 'Added google.com'}, + ); expect(response.message, isNull); }); @@ -907,29 +953,33 @@ void main() async { 'enabled': true, 'id': 8, 'date_added': 1734005851, - 'date_modified': 1734005851 - } + 'date_modified': 1734005851, + }, ], 'processed': { 'errors': [ { 'item': 'google.com', 'error': - 'UNIQUE constraint failed: domainlist.domain, domainlist.type' - } + 'UNIQUE constraint failed: domainlist.domain, domainlist.type', + }, ], - 'success': [] + 'success': [], }, - 'took': 0.000306844711303711 + 'took': 0.000306844711303711, }; - when(mockClient.post(Uri.parse(url), + when( + mockClient.post( + Uri.parse(url), headers: anyNamed('headers'), body: jsonEncode({ 'domain': 'google.com', 'comment': null, 'groups': [0], - 'enabled': true - }))).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); + 'enabled': true, + }), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); final response = await apiGateway.setWhiteBlacklist('google.com', 'black'); @@ -938,7 +988,7 @@ void main() async { expect(response.data!.toJson(), { 'success': false, 'message': - 'UNIQUE constraint failed: domainlist.domain, domainlist.type' + 'UNIQUE constraint failed: domainlist.domain, domainlist.type', }); expect(response.message, isNull); }); @@ -950,16 +1000,22 @@ void main() async { 'error': { 'key': 'uri_error', 'message': 'Invalid request: Specify list to modify more precisely', - 'hint': '/api/domains/xxxx/exact' + 'hint': '/api/domains/xxxx/exact', }, - 'took': 0.00055241584777832031 + 'took': 0.00055241584777832031, }; - when(mockClient.post(Uri.parse(url), headers: anyNamed('headers'), body: { - 'domain': 'google.com', - 'comment': null, - 'groups': [0], - 'enabled': true - })).thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + when( + mockClient.post( + Uri.parse(url), + headers: anyNamed('headers'), + body: { + 'domain': 'google.com', + 'comment': null, + 'groups': [0], + 'enabled': true, + }, + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 200)); final response = await apiGateway.setWhiteBlacklist('google.com', 'black'); @@ -1013,7 +1069,7 @@ void main() async { 'enabled': true, 'id': 299, 'date_added': 1611239095, - 'date_modified': 1612163756 + 'date_modified': 1612163756, }, { 'domain': 'xn--4ca.com', @@ -1025,11 +1081,11 @@ void main() async { 'enabled': true, 'id': 305, 'date_added': 1611240635, - 'date_modified': 1611241276 - } + 'date_modified': 1611241276, + }, ], 'took': 0.012, - 'processed': null + 'processed': null, }; when(mockClient.get(Uri.parse(url), headers: anyNamed('headers'))) .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); @@ -1073,7 +1129,8 @@ void main() async { when(mockClient.delete(Uri.parse(url), headers: anyNamed('headers'))) .thenAnswer((_) async => http.Response(jsonEncode(data), 204)); - final response = await apiGateway.removeDomainFromList(Domain( + final response = await apiGateway.removeDomainFromList( + Domain( id: 1, domain: 'google.com', type: 0, @@ -1081,7 +1138,9 @@ void main() async { dateAdded: DateTime.now(), dateModified: DateTime.now(), comment: '', - groups: [])); + groups: [], + ), + ); expect(response.result, APiResponseType.success); expect(response.message, isNull); @@ -1094,7 +1153,8 @@ void main() async { when(mockClient.delete(Uri.parse(url), headers: anyNamed('headers'))) .thenThrow(Exception('Unexpected error test')); - final response = await apiGateway.removeDomainFromList(Domain( + final response = await apiGateway.removeDomainFromList( + Domain( id: 1, domain: 'google.com', type: 0, @@ -1102,7 +1162,9 @@ void main() async { dateAdded: DateTime.now(), dateModified: DateTime.now(), comment: '', - groups: [])); + groups: [], + ), + ); expect(response.result, APiResponseType.error); expect(response.message, isNull); @@ -1137,25 +1199,29 @@ void main() async { 'enabled': true, 'id': 1, 'date_added': 1734008144, - 'date_modified': 1734008144 - } + 'date_modified': 1734008144, + }, ], 'processed': { 'errors': [], 'success': [ - {'item': 'google.com'} - ] + {'item': 'google.com'}, + ], }, - 'took': 0.0042212009429931641 + 'took': 0.0042212009429931641, }; - when(mockClient.post(Uri.parse(url), + when( + mockClient.post( + Uri.parse(url), headers: anyNamed('headers'), body: jsonEncode({ 'domain': 'google.com', 'comment': null, 'groups': [0], - 'enabled': true - }))).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); + 'enabled': true, + }), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); final response = await apiGateway .addDomainToList({'list': 'black', 'domain': 'google.com'}); @@ -1178,29 +1244,33 @@ void main() async { 'enabled': true, 'id': 8, 'date_added': 1734005851, - 'date_modified': 1734005851 - } + 'date_modified': 1734005851, + }, ], 'processed': { 'errors': [ { 'item': 'google.com', 'error': - 'UNIQUE constraint failed: domainlist.domain, domainlist.type' - } + 'UNIQUE constraint failed: domainlist.domain, domainlist.type', + }, ], - 'success': [] + 'success': [], }, - 'took': 0.000306844711303711 + 'took': 0.000306844711303711, }; - when(mockClient.post(Uri.parse(url), + when( + mockClient.post( + Uri.parse(url), headers: anyNamed('headers'), body: jsonEncode({ 'domain': 'google.com', 'comment': null, 'groups': [0], - 'enabled': true - }))).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); + 'enabled': true, + }), + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 201)); final response = await apiGateway .addDomainToList({'list': 'black', 'domain': 'google.com'}); @@ -1215,16 +1285,22 @@ void main() async { 'error': { 'key': 'uri_error', 'message': 'Invalid request: Specify list to modify more precisely', - 'hint': '/api/domains/xxxx/exact' + 'hint': '/api/domains/xxxx/exact', }, - 'took': 0.00055241584777832031 + 'took': 0.00055241584777832031, }; - when(mockClient.post(Uri.parse(url), headers: anyNamed('headers'), body: { - 'domain': 'google.com', - 'comment': null, - 'groups': [0], - 'enabled': true - })).thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + when( + mockClient.post( + Uri.parse(url), + headers: anyNamed('headers'), + body: { + 'domain': 'google.com', + 'comment': null, + 'groups': [0], + 'enabled': true, + }, + ), + ).thenAnswer((_) async => http.Response(jsonEncode(data), 200)); final response = await apiGateway .addDomainToList({'list': 'black', 'domain': 'google.com'}); diff --git a/test/repository/database_test.dart b/test/repository/database_test.dart new file mode 100644 index 00000000..51de3cc0 --- /dev/null +++ b/test/repository/database_test.dart @@ -0,0 +1,307 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:pi_hole_client/services/session_manager.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:pi_hole_client/models/server.dart'; +import 'package:pi_hole_client/repository/secure_storage.dart'; +import 'package:pi_hole_client/repository/database.dart'; +import './database_test.mocks.dart'; + +/// Initialize sqflite for test. +void sqfliteTestInit() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; +} + +@GenerateMocks([SecureStorageRepository, FlutterSecureStorage]) +void main() async { + // Initialize the sqflite for testing + sqfliteTestInit(); + + // For loading the .env file + TestWidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: '.env'); + + final testDb = 'test.db'; + + group('DatabaseRepository.loadDb', () { + late MockSecureStorageRepository mockSecureStorage; + late DatabaseRepository databaseRepository; + + setUp(() async { + mockSecureStorage = MockSecureStorageRepository(); + databaseRepository = DatabaseRepository(mockSecureStorage); + + when(mockSecureStorage.getValue(any)).thenAnswer((_) async => null); + when(mockSecureStorage.readAll()).thenAnswer( + (_) async => {}, + ); + + // Use test sqflite database. Before each test, initialize the database + await databaseRepository.initialize(path: testDb); + await databaseRepository.deleteServersDataQuery(); + await databaseRepository.restoreAppConfigQuery(); + await databaseRepository.closeDb(); + }); + + tearDown(() async { + await databaseRepository.closeDb(); + }); + + test('should initialize database with default values', () async { + databaseRepository = DatabaseRepository(mockSecureStorage); + await databaseRepository.initialize(path: testDb); + + final servers = databaseRepository.servers; + final appConfig = databaseRepository.appConfig; + expect(servers, []); + expect(appConfig, isNotNull); + expect(appConfig.autoRefreshTime, 5); + expect(appConfig.theme, 0); + expect(appConfig.language, 'en'); + expect(appConfig.overrideSslCheck, 0); + expect(appConfig.oneColumnLegend, 0); + expect(appConfig.reducedDataCharts, 0); + expect(appConfig.logsPerQuery, 2); + expect(appConfig.passCode, isNull); + expect(appConfig.useBiometricAuth, 0); + expect(appConfig.sendCrashReports, 0); + }); + + test('should return one registered server without passcode', () async { + //Mock the secure storage calls + when(mockSecureStorage.getValue('http://localhost:8080_token')) + .thenAnswer((_) async => ''); + when(mockSecureStorage.getValue('http://localhost:8080_basicAuthUser')) + .thenAnswer((_) async => ''); + when( + mockSecureStorage.getValue('http://localhost:8080_basicAuthPassword'), + ).thenAnswer((_) async => ''); + when(mockSecureStorage.getValue('http://localhost:8080_sid')).thenAnswer( + (_) async => '', + ); + when(mockSecureStorage.readAll()).thenAnswer( + (_) async => { + 'http://localhost:8080_token': '', + 'http://localhost:8080_basicAuthUser': '', + 'http://localhost:8080_basicAuthPassword': '', + 'http://localhost:8080_sid': '', + }, + ); + + // Add test server + final server = Server( + address: 'http://localhost:8080', + alias: 'test v6', + defaultServer: false, + apiVersion: 'v6', + sm: SessionManager( + MockSecureStorageRepository(), + 'http://localhost:8080', + ), + ); + final DatabaseRepository databaseRepositoryTmp = + DatabaseRepository(mockSecureStorage); + await databaseRepositoryTmp.initialize(path: testDb); + await databaseRepositoryTmp.saveServerQuery(server); + await databaseRepositoryTmp.closeDb(); + + databaseRepository = DatabaseRepository(mockSecureStorage); + await databaseRepository.initialize(path: testDb); + + // Assert the returned data + final servers = databaseRepository.servers; + final appConfig = databaseRepository.appConfig; + expect(servers, isNotNull); + expect(servers.length, 1); + expect(servers[0].address, 'http://localhost:8080'); + expect(servers[0].alias, 'test v6'); + expect(servers[0].isDefaultServer, 0); + expect(servers[0].apiVersion, 'v6'); + + expect(appConfig, isNotNull); + expect(appConfig.autoRefreshTime, 5); + expect(appConfig.theme, 0); + expect(appConfig.language, 'en'); + expect(appConfig.overrideSslCheck, 0); + expect(appConfig.oneColumnLegend, 0); + expect(appConfig.reducedDataCharts, 0); + expect(appConfig.logsPerQuery, 2); + expect(appConfig.passCode, isNull); + expect(appConfig.useBiometricAuth, 0); + expect(appConfig.sendCrashReports, 0); + }); + + test('should return one registered server with passcode', () async { + //Mock the secure storage calls + when(mockSecureStorage.getValue('passCode')) + .thenAnswer((_) async => '9999'); + when(mockSecureStorage.getValue('http://localhost:8080_token')) + .thenAnswer((_) async => ''); + when(mockSecureStorage.getValue('http://localhost:8080_basicAuthUser')) + .thenAnswer((_) async => ''); + when( + mockSecureStorage.getValue('http://localhost:8080_basicAuthPassword'), + ).thenAnswer((_) async => ''); + when(mockSecureStorage.getValue('http://localhost:8080_sid')).thenAnswer( + (_) async => '', + ); + when(mockSecureStorage.readAll()).thenAnswer( + (_) async => { + 'passCode': '9999', + 'http://localhost:8080_token': '', + 'http://localhost:8080_basicAuthUser': '', + 'http://localhost:8080_basicAuthPassword': '', + 'http://localhost:8080_sid': '', + }, + ); + + // Add test server + final server = Server( + address: 'http://localhost:8080', + alias: 'test v6', + defaultServer: false, + apiVersion: 'v6', + sm: SessionManager( + MockSecureStorageRepository(), + 'http://localhost:8080', + ), + ); + final DatabaseRepository databaseRepositoryTmp = + DatabaseRepository(mockSecureStorage); + await databaseRepositoryTmp.initialize(path: testDb); + await databaseRepositoryTmp.saveServerQuery(server); + await databaseRepositoryTmp.closeDb(); + + databaseRepository = DatabaseRepository(mockSecureStorage); + await databaseRepository.initialize(path: testDb); + + // Assert the returned data + final servers = databaseRepository.servers; + final appConfig = databaseRepository.appConfig; + expect(servers, isNotNull); + expect(servers.length, 1); + expect(servers[0].address, 'http://localhost:8080'); + expect(servers[0].alias, 'test v6'); + expect(servers[0].isDefaultServer, 0); + expect(servers[0].apiVersion, 'v6'); + + expect(appConfig, isNotNull); + expect(appConfig.autoRefreshTime, 5); + expect(appConfig.theme, 0); + expect(appConfig.language, 'en'); + expect(appConfig.overrideSslCheck, 0); + expect(appConfig.oneColumnLegend, 0); + expect(appConfig.reducedDataCharts, 0); + expect(appConfig.logsPerQuery, 2); + expect(appConfig.passCode, '9999'); + expect(appConfig.useBiometricAuth, 0); + expect(appConfig.sendCrashReports, 0); + }); + }); + + group('DatabaseRepository.updateConfigQuery', () { + late MockFlutterSecureStorage mockFlutterSecureStorage; + late SecureStorageRepository secureStorage; + late DatabaseRepository databaseRepository; + + setUp(() async { + mockFlutterSecureStorage = MockFlutterSecureStorage(); + secureStorage = + SecureStorageRepository(secureStorage: mockFlutterSecureStorage); + databaseRepository = DatabaseRepository(secureStorage); + + when(mockFlutterSecureStorage.read(key: anyNamed('key'))) + .thenAnswer((_) async => null); + when(mockFlutterSecureStorage.readAll()).thenAnswer( + (_) async => {}, + ); + + // Use test sqflite database. Before each test, initialize the database + await databaseRepository.initialize(path: testDb); + }); + + tearDown(() async { + await databaseRepository.closeDb(); + }); + + test( + 'should delete key from secure storage when null is passed (Clear)', + () async { + const testColumn = 'passCode'; + + when(mockFlutterSecureStorage.delete(key: testColumn)) + .thenAnswer((_) async {}); + + final result = await databaseRepository.updateConfigQuery( + column: testColumn, + value: null, + ); + + // Check call to delete + // Check not call to write + expect(result, true); + verify(mockFlutterSecureStorage.delete(key: testColumn)).called(1); + verifyNever( + mockFlutterSecureStorage.write( + key: testColumn, + value: anyNamed('value'), + ), + ); + }, + ); + + test( + 'should save value to secure storage when non-null value is passed (Update)', + () async { + const testColumn = 'passCode'; + const testValue = '1234'; + + when(mockFlutterSecureStorage.write(key: testColumn, value: testValue)) + .thenAnswer( + (_) async {}, + ); + + final result = await databaseRepository.updateConfigQuery( + column: testColumn, + value: testValue, + ); + + // Check not call to delete + // Check call to write + expect(result, true); + verifyNever(mockFlutterSecureStorage.delete(key: testColumn)); + verify( + mockFlutterSecureStorage.write( + key: testColumn, + value: testValue, + ), + ).called(1); + }, + ); + + test('should return false if secure storage delete fails', () async { + const testColumn = 'passCode'; + + when(mockFlutterSecureStorage.delete(key: testColumn)) + .thenThrow(Exception('Failed to delete')); + + final result = await databaseRepository.updateConfigQuery( + column: testColumn, + value: null, + ); + + expect(result, false); + verify(mockFlutterSecureStorage.delete(key: testColumn)).called(1); + verifyNever( + mockFlutterSecureStorage.write( + key: testColumn, + value: anyNamed('value'), + ), + ); + }); + }); +} diff --git a/test/repository/database_test.mocks.dart b/test/repository/database_test.mocks.dart new file mode 100644 index 00000000..802248eb --- /dev/null +++ b/test/repository/database_test.mocks.dart @@ -0,0 +1,441 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in pi_hole_client/test/repository/database_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:flutter/foundation.dart' as _i5; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:pi_hole_client/repository/secure_storage.dart' as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeIOSOptions_0 extends _i1.SmartFake implements _i2.IOSOptions { + _FakeIOSOptions_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAndroidOptions_1 extends _i1.SmartFake + implements _i2.AndroidOptions { + _FakeAndroidOptions_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeLinuxOptions_2 extends _i1.SmartFake implements _i2.LinuxOptions { + _FakeLinuxOptions_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeWindowsOptions_3 extends _i1.SmartFake + implements _i2.WindowsOptions { + _FakeWindowsOptions_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeWebOptions_4 extends _i1.SmartFake implements _i2.WebOptions { + _FakeWebOptions_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeMacOsOptions_5 extends _i1.SmartFake implements _i2.MacOsOptions { + _FakeMacOsOptions_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [SecureStorageRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSecureStorageRepository extends _i1.Mock + implements _i3.SecureStorageRepository { + MockSecureStorageRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future saveValue( + String? key, + String? value, + ) => + (super.noSuchMethod( + Invocation.method( + #saveValue, + [ + key, + value, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future getValue(String? key) => (super.noSuchMethod( + Invocation.method( + #getValue, + [key], + ), + returnValue: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future deleteValue(String? key) => (super.noSuchMethod( + Invocation.method( + #deleteValue, + [key], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future clearAll() => (super.noSuchMethod( + Invocation.method( + #clearAll, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future> readAll() => (super.noSuchMethod( + Invocation.method( + #readAll, + [], + ), + returnValue: _i4.Future>.value({}), + ) as _i4.Future>); +} + +/// A class which mocks [FlutterSecureStorage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFlutterSecureStorage extends _i1.Mock + implements _i2.FlutterSecureStorage { + MockFlutterSecureStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.IOSOptions get iOptions => (super.noSuchMethod( + Invocation.getter(#iOptions), + returnValue: _FakeIOSOptions_0( + this, + Invocation.getter(#iOptions), + ), + ) as _i2.IOSOptions); + + @override + _i2.AndroidOptions get aOptions => (super.noSuchMethod( + Invocation.getter(#aOptions), + returnValue: _FakeAndroidOptions_1( + this, + Invocation.getter(#aOptions), + ), + ) as _i2.AndroidOptions); + + @override + _i2.LinuxOptions get lOptions => (super.noSuchMethod( + Invocation.getter(#lOptions), + returnValue: _FakeLinuxOptions_2( + this, + Invocation.getter(#lOptions), + ), + ) as _i2.LinuxOptions); + + @override + _i2.WindowsOptions get wOptions => (super.noSuchMethod( + Invocation.getter(#wOptions), + returnValue: _FakeWindowsOptions_3( + this, + Invocation.getter(#wOptions), + ), + ) as _i2.WindowsOptions); + + @override + _i2.WebOptions get webOptions => (super.noSuchMethod( + Invocation.getter(#webOptions), + returnValue: _FakeWebOptions_4( + this, + Invocation.getter(#webOptions), + ), + ) as _i2.WebOptions); + + @override + _i2.MacOsOptions get mOptions => (super.noSuchMethod( + Invocation.getter(#mOptions), + returnValue: _FakeMacOsOptions_5( + this, + Invocation.getter(#mOptions), + ), + ) as _i2.MacOsOptions); + + @override + void registerListener({ + required String? key, + required _i5.ValueChanged? listener, + }) => + super.noSuchMethod( + Invocation.method( + #registerListener, + [], + { + #key: key, + #listener: listener, + }, + ), + returnValueForMissingStub: null, + ); + + @override + void unregisterListener({ + required String? key, + required _i5.ValueChanged? listener, + }) => + super.noSuchMethod( + Invocation.method( + #unregisterListener, + [], + { + #key: key, + #listener: listener, + }, + ), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListenersForKey({required String? key}) => + super.noSuchMethod( + Invocation.method( + #unregisterAllListenersForKey, + [], + {#key: key}, + ), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListeners() => super.noSuchMethod( + Invocation.method( + #unregisterAllListeners, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future write({ + required String? key, + required String? value, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #write, + [], + { + #key: key, + #value: value, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future read({ + required String? key, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [], + { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future containsKey({ + required String? key, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #containsKey, + [], + { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i4.Future.value(false), + ) as _i4.Future); + + @override + _i4.Future delete({ + required String? key, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [], + { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future> readAll({ + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #readAll, + [], + { + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i4.Future>.value({}), + ) as _i4.Future>); + + @override + _i4.Future deleteAll({ + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #deleteAll, + [], + { + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future isCupertinoProtectedDataAvailable() => (super.noSuchMethod( + Invocation.method( + #isCupertinoProtectedDataAvailable, + [], + ), + returnValue: _i4.Future.value(), + ) as _i4.Future); +}