From 50a707f6e11f54aa17c63fa4136e5f630e1c9b1b Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sat, 7 Dec 2024 18:04:36 +0900 Subject: [PATCH] refactor(api): update apigateway (#38) --- .env.sample | 2 +- .github/workflows/ci.yaml | 46 + .gitignore | 4 +- lib/base.dart | 13 +- lib/constants/api_versions.dart | 4 + lib/functions/convert.dart | 7 + lib/functions/refresh_server_status.dart | 9 +- lib/functions/server_management.dart | 5 +- lib/functions/status_updater.dart | 13 +- lib/gateways/api_gateway_factory.dart | 18 + lib/gateways/api_gateway_interface.dart | 325 +---- lib/gateways/v5/api_gateway_v5.dart | 587 ++++---- lib/models/gateways.dart | 162 +++ lib/models/repository/database.dart | 4 +- lib/models/server.dart | 38 +- lib/providers/domains_list_provider.dart | 11 +- lib/providers/servers_provider.dart | 17 +- lib/repository/database.dart | 2 +- lib/screens/domains/domains.dart | 9 +- lib/screens/domains/domains_list.dart | 13 +- lib/screens/home/home_appbar.dart | 24 +- lib/screens/logs/logs.dart | 11 +- .../servers/add_server_fullscreen.dart | 73 +- lib/screens/servers/servers_list_item.dart | 18 +- lib/screens/servers/servers_tile_item.dart | 18 +- pubspec.lock | 8 + pubspec.yaml | 1 + test/gateways/v5/api_gateway_v5_test.dart | 1261 +++++++++++++++++ .../v5/api_gateway_v5_test.mocks.dart | 282 ++++ 29 files changed, 2361 insertions(+), 624 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 lib/constants/api_versions.dart create mode 100644 lib/functions/convert.dart create mode 100644 lib/gateways/api_gateway_factory.dart create mode 100644 lib/models/gateways.dart create mode 100644 test/gateways/v5/api_gateway_v5_test.dart create mode 100644 test/gateways/v5/api_gateway_v5_test.mocks.dart diff --git a/.env.sample b/.env.sample index 0a7d570b..90ec8a14 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,4 @@ SENTRY_DSN= ENABLE_SENTRY=false # Select one of the following: debug, info, warning, error -LOG_LEVEL=debug \ No newline at end of file +LOG_LEVEL=info \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..745de868 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,46 @@ +name: Dart Tests + +on: + push: + branches: + - main + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.flutter_version }} + cache: true + # optional parameters follow + cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache + cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path + pub-cache-key: "flutter-pub:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache of dart pub get dependencies + pub-cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path + + - name: Install dependencies + run: flutter pub get + + - name: Prepare env file + run: cp .env.sample .env + + - name: Run tests + run: flutter test --coverage + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 88b0e92d..400a3466 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,6 @@ app.*.map.json untranslated.txt # Env file -.env \ No newline at end of file +.env + +coverage/ \ No newline at end of file diff --git a/lib/base.dart b/lib/base.dart index 25eeeb28..96607b82 100644 --- a/lib/base.dart +++ b/lib/base.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:animations/animations.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:pi_hole_client/widgets/navigation_rail.dart'; @@ -54,11 +55,15 @@ class _BaseState extends State with WidgetsBindingObserver { final result = await Future.wait( [apiGateway!.realtimeStatus(), apiGateway.fetchOverTimeData()]); - if (result[0]['result'] == 'success' && result[1]['result'] == 'success') { - statusProvider.setRealtimeStatus(result[0]['data']); - statusProvider.setOvertimeData(result[1]['data']); + final realtimeStatusResponse = result[0] as RealtimeStatusResponse; + final overTimeDataResponse = result[1] as FetchOverTimeDataResponse; + + if (realtimeStatusResponse.result == APiResponseType.success && + overTimeDataResponse.result == APiResponseType.success) { + statusProvider.setRealtimeStatus(realtimeStatusResponse.data!); + statusProvider.setOvertimeData(overTimeDataResponse.data!); serversProvider.updateselectedServerStatus( - result[0]['data'].status == 'enabled' ? true : false); + realtimeStatusResponse.data!.status == 'enabled' ? true : false); statusProvider.setOvertimeDataLoadingStatus(1); statusProvider.setStatusLoading(LoadStatus.loaded); diff --git a/lib/constants/api_versions.dart b/lib/constants/api_versions.dart new file mode 100644 index 00000000..c494556c --- /dev/null +++ b/lib/constants/api_versions.dart @@ -0,0 +1,4 @@ +class SupportedApiVersions { + static const String v5 = 'v5'; + static const String v6 = 'v6'; +} diff --git a/lib/functions/convert.dart b/lib/functions/convert.dart new file mode 100644 index 00000000..f04ef7b7 --- /dev/null +++ b/lib/functions/convert.dart @@ -0,0 +1,7 @@ +import 'package:pi_hole_client/models/domain.dart'; + +List parseDomainList(dynamic jsonList) { + return (jsonList as List) + .map((item) => Domain.fromJson(item as Map)) + .toList(); +} diff --git a/lib/functions/refresh_server_status.dart b/lib/functions/refresh_server_status.dart index 5b8b1829..4d03b564 100644 --- a/lib/functions/refresh_server_status.dart +++ b/lib/functions/refresh_server_status.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -19,12 +20,12 @@ Future refreshServerStatus(BuildContext context) async { final result = await apiGateway?.realtimeStatus(); if (!context.mounted) return; - if (result['result'] == "success") { + if (result?.result == APiResponseType.success) { serversProvider.updateselectedServerStatus( - result['data'].status == 'enabled' ? true : false); + result!.data?.status == 'enabled' ? true : false); statusProvider.setIsServerConnected(true); - statusProvider.setRealtimeStatus(result['data']); - } else if (result['result'] == 'ssl_error') { + statusProvider.setRealtimeStatus(result.data!); + } else if (result?.result == APiResponseType.sslError) { statusProvider.setIsServerConnected(false); if (statusProvider.getStatusLoading == LoadStatus.loading) { statusProvider.setStatusLoading(LoadStatus.error); diff --git a/lib/functions/server_management.dart b/lib/functions/server_management.dart index d183f2ba..e52dd5a8 100644 --- a/lib/functions/server_management.dart +++ b/lib/functions/server_management.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -19,7 +20,7 @@ void enableServer(BuildContext context) async { process.open(AppLocalizations.of(context)!.enablingServer); final result = await apiGateway?.enableServerRequest(); process.close(); - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { serversProvider.updateselectedServerStatus(true); showSnackBar( appConfigProvider: appConfigProvider, @@ -44,7 +45,7 @@ void disableServer(int time, BuildContext context) async { final result = await apiGateway?.disableServerRequest(time); process.close(); if (!context.mounted) return; - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { serversProvider.updateselectedServerStatus(false); showSnackBar( appConfigProvider: appConfigProvider, diff --git a/lib/functions/status_updater.dart b/lib/functions/status_updater.dart index d84e79bf..a30197c4 100644 --- a/lib/functions/status_updater.dart +++ b/lib/functions/status_updater.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:pi_hole_client/providers/status_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -36,10 +37,10 @@ class StatusUpdater { final apiGateway = serversProvider.selectedApiGateway; String selectedUrlBefore = serversProvider.selectedServer!.address; final statusResult = await apiGateway?.realtimeStatus(); - if (statusResult['result'] == 'success') { + if (statusResult?.result == APiResponseType.success) { serversProvider.updateselectedServerStatus( - statusResult['data'].status == 'enabled' ? true : false); - statusProvider.setRealtimeStatus(statusResult['data']); + statusResult!.data!.status == 'enabled' ? true : false); + statusProvider.setRealtimeStatus(statusResult.data!); if (statusProvider.isServerConnected == false) { statusProvider.setIsServerConnected(true); } @@ -73,9 +74,9 @@ class StatusUpdater { final apiGateway = serversProvider.selectedApiGateway; String statusUrlBefore = serversProvider.selectedServer!.address; final statusResult = await apiGateway?.fetchOverTimeData(); - if (statusResult['result'] == 'success') { - statusProvider.setOvertimeData(statusResult['data']); - List clients = statusResult['data'].clients.map((client) { + if (statusResult?.result == APiResponseType.success) { + statusProvider.setOvertimeData(statusResult!.data!); + List clients = statusResult.data!.clients.map((client) { if (client.name != '') { return client.name.toString(); } else { diff --git a/lib/gateways/api_gateway_factory.dart b/lib/gateways/api_gateway_factory.dart new file mode 100644 index 00000000..4daade91 --- /dev/null +++ b/lib/gateways/api_gateway_factory.dart @@ -0,0 +1,18 @@ +import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; +import 'package:pi_hole_client/gateways/v5/api_gateway_v5.dart'; +// import 'package:pi_hole_client/gateways/v5/api_gateway_v6.dart'; +import 'package:pi_hole_client/models/server.dart'; + +class ApiGatewayFactory { + static ApiGateway create(Server server) { + final version = server.apiVersion; + if (version == 'v5') { + return ApiGatewayV5(server); + } else if (version == 'v6') { + // return ApiGatewayV6(server); + throw Exception('Not implemented yet'); + } else { + throw Exception('Unsupported server version: $version'); + } + } +} diff --git a/lib/gateways/api_gateway_interface.dart b/lib/gateways/api_gateway_interface.dart index 55444c86..d8b28fdd 100644 --- a/lib/gateways/api_gateway_interface.dart +++ b/lib/gateways/api_gateway_interface.dart @@ -1,291 +1,84 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:http/http.dart'; -import 'package:http/http.dart' as http; -import 'package:pi_hole_client/functions/encode_basic_auth.dart'; import 'package:pi_hole_client/models/domain.dart'; -import 'package:pi_hole_client/models/server.dart'; -import 'package:pi_hole_client/functions/logger.dart'; -import 'package:pi_hole_client/models/app_log.dart'; +import 'package:pi_hole_client/models/gateways.dart'; abstract interface class ApiGateway { + /// Handles the login process to a Pi-hole server using its API. + /// + /// ### Returns + /// - A [LoginQueryResponse] object containing the result of the login query. + Future loginQuery(); + /// Fetches real-time status information from a Pi-hole server. - Future realtimeStatus(); + /// + /// ### Returns + /// - A [RealtimeStatusResponse] object containing the result of the real-time status query. + Future realtimeStatus(); /// Disables a Pi-hole server - dynamic disableServerRequest(int time); + /// + /// ### Parameters + /// - [time]: The time in seconds to disable the server for. + /// + /// ### Returns + /// - A [DisableServerResponse] object containing the result of the disable server query. + Future disableServerRequest(int time); /// Enables a Pi-hole server - dynamic enableServerRequest(); + /// + /// ### Returns + /// - An [EnableServerResponse] object containing the result of the enable server query. + Future enableServerRequest(); /// Fetches over-time data from a Pi-hole server. - Future fetchOverTimeData(); + /// + /// ### Returns + /// - A [FetchOverTimeDataResponse] object containing the result of the over-time data query. + Future fetchOverTimeData(); /// Fetches log data from a Pi-hole server within a specified time range. - Future fetchLogs(DateTime from, DateTime until); - - /// Adds a domain to the whitelist or blacklist on a Pi-hole server. - Future setWhiteBlacklist(String domain, String list); - - /// Fetches domain lists (whitelist, blacklist, and regex-based lists) from a Pi-hole server. - Future getDomainLists(); - - /// Removes a domain from a specific list on a Pi-hole server. - Future removeDomainFromList(Domain domain); - - /// Adds a domain to a specified list on a Pi-hole server. - Future addDomainToList(Map domainData); - - /// Checks if both the username and password are non-null and non-empty. /// - /// Parameters: - /// - username: The username to check. - /// - password: The password to check. + /// ### Parameters + /// - [from]: The start date and time of the log data to fetch. + /// - [until]: The end date and time of the log data to fetch. /// - /// Returns: - /// - `true` if both the username and password are provided and not empty, - /// `false` otherwise. - static bool checkBasicAuth(String? username, String? password) { - if (username != null && - password != null && - username != '' && - password != '') { - return true; - } else { - return false; - } - } + /// ### Returns + /// - A [FetchLogsResponse] object containing the result of the fetch logs query. + Future fetchLogs(DateTime from, DateTime until); - /// Sends an HTTP request using the specified method and parameters. - /// - /// Parameters: - /// - [method] The HTTP method to use (e.g., 'GET', 'POST'). - /// - [apiKey] The API key to use for authentication. Use v5 only. - /// - [url] The URL to send the request to. - /// - [headers] The headers to send with the request. - /// - [body] The body of the request. - /// - [timeout] The timeout for the request in seconds. Default is 10 seconds. - /// - [basicAuth] Basic authentication `username` and `password`. + /// Adds a domain to the whitelist or blacklist on a Pi-hole server. /// - /// Returns - /// - A `Response` object containing the response from the server. + /// ### Parameters + /// - [domain]: The domain to add to the list. + /// - [list]: The list to add the domain to (`black`, `regex_black`, `white`, `regex_white`). /// - /// Exceptions: - /// - Throws a `TimeoutException` if the request times out. - static Future httpClient({ - /// The HTTP method to use - required String method, - - /// The API key to use for authentication. Use v5 only - String? apiKey, - - /// The URL to send the request to - required String url, - - /// The headers to send with the request - Map? headers, - - /// The body of the request - Map? body, - - /// The timeout for the request. Default is 10 seconds - int timeout = 10, - - /// The basic authentication credentials to use for authentication - Map? basicAuth, - }) async { - final Map authHeaders = headers != null ? {...headers} : {}; - - if (basicAuth != null && - checkBasicAuth(basicAuth['username'], basicAuth['password'])) { - authHeaders['Authorization'] = - 'Basic ${encodeBasicAuth(basicAuth['username'], basicAuth['password'])}'; - } - - switch (method.toUpperCase()) { - case 'POST': - return await http - .post(Uri.parse(url), headers: authHeaders, body: body) - .timeout(Duration(seconds: timeout)); + /// ### Returns + /// - A [SetWhiteBlacklistResponse] object containing the result of the set white/blacklist query. + Future setWhiteBlacklist( + String domain, String list); - case 'GET': - default: - return await http - .get(Uri.parse(url), headers: authHeaders) - .timeout(Duration(seconds: timeout)); - } - } - - /// Handles the login process to a Pi-hole server using its API. - /// - /// This function performs the following steps: - /// 1. Sends a GET request to verify the server's current status using the provided `address` and `token`. - /// 2. Toggles the Pi-hole's status between enabled and disabled depending on the current status. - /// 3. Validates the response to determine the success or failure of the login attempt. + /// Fetches domain lists (whitelist, blacklist, and regex-based lists) from a Pi-hole server. /// - /// It returns a `Map` containing the result of the operation and, in case of failure, a detailed log. + /// ### Returns + /// - A [GetDomainLists] object containing the result of the get domain lists query. + Future getDomainLists(); + + /// Removes a domain from a specific list on a Pi-hole server. /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. + /// ### Parameters + /// - [domain]: The domain to remove from the list. /// - /// ### Returns: - /// - `Map`: A result object with the following keys: - /// - `result`: A string indicating the outcome of the operation (`success`, `auth_error`, `no_connection`, etc.). - /// - `status`: The current Pi-hole status (`enabled` or `disabled`) if the login is successful. - /// - `phpSessId`: The PHP session ID if the login is successful. - /// - `log` (`AppLog`): Detailed log information in case of errors or unexpected responses. + /// ### Returns + /// - A [RemoveDomainFromListResponse] object containing the result of the remove domain from list query. + Future removeDomainFromList(Domain domain); + + /// Adds a domain to a specified list on a Pi-hole server. /// - /// #### Possible results: - /// - `success`: The login was successful, and the server's status was toggled. - /// - `auth_error`: There was an authentication error. - /// - `no_connection`: The server could not be reached. - /// - `socket`: A `SocketException` occurred. - /// - `timeout`: A `TimeoutException` occurred. - /// - `ssl_error`: A `HandshakeException` occurred. - /// - `error`: A general error occurred. + /// ### Parameters + /// - [domainData]: The domain data to add to the list. /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - `FormatException`: Malformed response body or unexpected data format. - /// - General exceptions: Any other errors encountered during execution. - static Future loginQuery(Server server) async { - try { - final status = await http.get( - Uri.parse( - '${server.address}/admin/api.php?auth=${server.token}&summaryRaw'), - headers: - checkBasicAuth(server.basicAuthUser, server.basicAuthPassword) == - true - ? { - 'Authorization': - 'Basic ${encodeBasicAuth(server.basicAuthUser!, server.basicAuthPassword!)}' - } - : null); - if (status.statusCode == 200) { - final statusParsed = jsonDecode(status.body); - if (statusParsed.runtimeType != List && - statusParsed['status'] != null) { - final enableOrDisable = await http.get( - Uri.parse(statusParsed['status'] == 'enabled' - ? '${server.address}/admin/api.php?auth=${server.token}&enable=0' - : '${server.address}/admin/api.php?auth=${server.token}&disable=0'), - headers: checkBasicAuth( - server.basicAuthUser, server.basicAuthPassword) == - true - ? { - 'Authorization': - 'Basic ${encodeBasicAuth(server.basicAuthUser!, server.basicAuthPassword!)}' - } - : null); - if (enableOrDisable.statusCode == 200) { - if (enableOrDisable.body == 'Not authorized!' || - enableOrDisable.body == - 'Session expired! Please re-login on the Pi-hole dashboard.' || - // enableOrDisable.body == []) { - enableOrDisable.body.isEmpty) { - logger.i(enableOrDisable.body); - return { - 'result': 'auth_error', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - statusCode: status.statusCode.toString(), - message: 'auth_error_1', - resBody: status.body) - }; - } else { - final enableOrDisableParsed = jsonDecode(enableOrDisable.body); - if (enableOrDisableParsed.runtimeType != List) { - final phpSessId = enableOrDisable.headers['set-cookie']! - .split(';')[0] - .split('=')[1]; - return { - 'result': 'success', - 'status': statusParsed['status'], - 'phpSessId': phpSessId, - }; - } else { - return { - 'result': 'auth_error', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - statusCode: status.statusCode.toString(), - message: 'auth_error_2', - resBody: status.body) - }; - } - } - } else { - return { - 'result': 'auth_error', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - statusCode: status.statusCode.toString(), - message: 'auth_error_3', - resBody: status.body) - }; - } - } else { - return { - 'result': 'auth_error', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - statusCode: status.statusCode.toString(), - message: 'auth_error', - resBody: status.body) - }; - } - } else { - return { - 'result': 'no_connection', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - statusCode: status.statusCode.toString(), - message: 'no_connection_2', - resBody: status.body) - }; - } - } on SocketException { - return { - 'result': 'socket', - 'log': AppLog( - type: 'login', dateTime: DateTime.now(), message: 'SocketException') - }; - } on TimeoutException { - return { - 'result': 'timeout', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - message: 'TimeoutException') - }; - } on HandshakeException { - return { - 'result': 'ssl_error', - 'log': AppLog( - type: 'login', - dateTime: DateTime.now(), - message: 'HandshakeException') - }; - } on FormatException { - return { - 'result': 'auth_error', - 'log': AppLog( - type: 'login', dateTime: DateTime.now(), message: 'FormatException') - }; - } catch (e) { - return { - 'result': 'error', - 'log': AppLog( - type: 'login', dateTime: DateTime.now(), message: e.toString()) - }; - } - } + /// ### Returns + /// - An [AddDomainToListResponse] object containing the result of the add domain to list query. + Future addDomainToList( + Map domainData); } diff --git a/lib/gateways/v5/api_gateway_v5.dart b/lib/gateways/v5/api_gateway_v5.dart index 323fc199..32a1e67e 100644 --- a/lib/gateways/v5/api_gateway_v5.dart +++ b/lib/gateways/v5/api_gateway_v5.dart @@ -1,18 +1,240 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:http/http.dart'; import 'package:http/http.dart' as http; +import 'package:pi_hole_client/functions/convert.dart'; +import 'package:pi_hole_client/models/app_log.dart'; import 'package:pi_hole_client/models/domain.dart'; import 'package:pi_hole_client/functions/encode_basic_auth.dart'; +import 'package:pi_hole_client/models/gateways.dart'; +import 'package:pi_hole_client/models/log.dart'; import 'package:pi_hole_client/models/overtime_data.dart'; import 'package:pi_hole_client/models/realtime_status.dart'; import 'package:pi_hole_client/models/server.dart'; import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; +import 'package:pi_hole_client/functions/logger.dart'; class ApiGatewayV5 implements ApiGateway { final Server server; + final http.Client client; - ApiGatewayV5(this.server); + /// Creates a new instance of the `ApiGatewayV5` class. + /// + /// Parameters: + /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. + /// - `client` (`http.Client`): An optional HTTP client to use for requests. If not provided, a new client will be created. Add for testing purposes. + ApiGatewayV5(this.server, {http.Client? client}) + : client = client ?? http.Client(); + + /// Checks if both the username and password are non-null and non-empty. + /// + /// Parameters: + /// - username: The username to check. + /// - password: The password to check. + /// + /// Returns: + /// - `true` if both the username and password are provided and not empty, + /// `false` otherwise. + bool checkBasicAuth(String? username, String? password) { + if (username != null && + password != null && + username != '' && + password != '') { + return true; + } else { + return false; + } + } + + /// Sends an HTTP request using the specified method and parameters. + /// + /// Parameters: + /// - [method] The HTTP method to use (e.g., 'GET', 'POST'). + /// - [apiKey] The API key to use for authentication. Use v5 only. + /// - [url] The URL to send the request to. + /// - [headers] The headers to send with the request. + /// - [body] The body of the request. + /// - [timeout] The timeout for the request in seconds. Default is 10 seconds. + /// - [basicAuth] Basic authentication `username` and `password`. + /// + /// Returns + /// - A `Response` object containing the response from the server. + /// + /// Exceptions: + /// - Throws a `TimeoutException` if the request times out. + Future httpClient({ + /// The HTTP method to use + required String method, + + /// The URL to send the request to + required String url, + + /// The headers to send with the request + Map? headers, + + /// The body of the request + Map? body, + + /// The timeout for the request. Default is 10 seconds + int timeout = 10, + + /// The basic authentication credentials to use for authentication + Map? basicAuth, + }) async { + final Map authHeaders = headers != null ? {...headers} : {}; + + if (basicAuth != null && + checkBasicAuth(basicAuth['username'], basicAuth['password'])) { + authHeaders['Authorization'] = + 'Basic ${encodeBasicAuth(basicAuth['username'], basicAuth['password'])}'; + } + + switch (method.toUpperCase()) { + case 'POST': + return await client + .post(Uri.parse(url), headers: authHeaders, body: body) + .timeout(Duration(seconds: timeout)); + + case 'GET': + default: + return await client + .get(Uri.parse(url), headers: authHeaders) + .timeout(Duration(seconds: timeout)); + } + } + + /// Handles the login process to a Pi-hole server using its API. + /// + /// This function performs the following steps: + /// 1. Sends a GET request to verify the server's current status using the provided `address` and `token`. + /// 2. Toggles the Pi-hole's status between enabled and disabled depending on the current status. + /// 3. Validates the response to determine the success or failure of the login attempt. + @override + Future loginQuery() async { + try { + final status = await httpClient( + method: 'get', + url: + '${server.address}/admin/api.php?auth=${server.token}&summaryRaw', + basicAuth: { + 'username': server.basicAuthUser, + 'password': server.basicAuthPassword + }); + if (status.statusCode == 200) { + final statusParsed = jsonDecode(status.body); + if (statusParsed.runtimeType != List && + statusParsed['status'] != null) { + final enableOrDisable = await httpClient( + method: 'get', + url: statusParsed['status'] == 'enabled' + ? '${server.address}/admin/api.php?auth=${server.token}&enable=0' + : '${server.address}/admin/api.php?auth=${server.token}&disable=0', + basicAuth: { + 'username': server.basicAuthUser, + 'password': server.basicAuthPassword + }); + if (enableOrDisable.statusCode == 200) { + if (enableOrDisable.body == 'Not authorized!' || + enableOrDisable.body == + 'Session expired! Please re-login on the Pi-hole dashboard.' || + // enableOrDisable.body == []) { + enableOrDisable.body.isEmpty) { + logger.i(enableOrDisable.body); + return LoginQueryResponse( + result: APiResponseType.authError, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + statusCode: status.statusCode.toString(), + message: 'auth_error_1', + resBody: status.body)); + } else { + final enableOrDisableParsed = jsonDecode(enableOrDisable.body); + if (enableOrDisableParsed.runtimeType != List) { + final phpSessId = enableOrDisable.headers['set-cookie']! + .split(';')[0] + .split('=')[1]; + return LoginQueryResponse( + result: APiResponseType.success, + status: statusParsed['status'], + phpSessId: phpSessId); + } else { + return LoginQueryResponse( + result: APiResponseType.authError, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + statusCode: status.statusCode.toString(), + message: 'auth_error_2', + resBody: status.body)); + } + } + } else { + return LoginQueryResponse( + result: APiResponseType.authError, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + statusCode: status.statusCode.toString(), + message: 'auth_error_3', + resBody: status.body)); + } + } else { + return LoginQueryResponse( + result: APiResponseType.authError, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + statusCode: status.statusCode.toString(), + message: 'auth_error', + resBody: status.body)); + } + } else { + return LoginQueryResponse( + result: APiResponseType.noConnection, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + statusCode: status.statusCode.toString(), + message: 'no_connection_2', + resBody: status.body)); + } + } on SocketException { + return LoginQueryResponse( + result: APiResponseType.socket, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + message: 'SocketException')); + } on TimeoutException { + return LoginQueryResponse( + result: APiResponseType.timeout, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + message: 'TimeoutException')); + } on HandshakeException { + return LoginQueryResponse( + result: APiResponseType.sslError, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + message: 'HandshakeException')); + } on FormatException { + return LoginQueryResponse( + result: APiResponseType.authError, + log: AppLog( + type: 'login', + dateTime: DateTime.now(), + message: 'FormatException')); + } catch (e) { + return LoginQueryResponse( + result: APiResponseType.error, + log: AppLog( + type: 'login', dateTime: DateTime.now(), message: e.toString())); + } + } /// Fetches real-time status information from a Pi-hole server. /// @@ -20,24 +242,10 @@ class ApiGatewayV5 implements ApiGateway { /// detailed status and metrics, including top items, forward destinations, /// query sources, and query types. It parses the response and returns the /// data in a structured format. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `socket`, `timeout`, `ssl_error`, `error`). - /// - `data`: A `RealtimeStatus` object containing the server's realtime status data if the operation is successful. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. @override - Future realtimeStatus() async { + Future realtimeStatus() async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&summaryRaw&topItems&getForwardDestinations&getQuerySources&topClientsBlocked&getQueryTypes', @@ -47,41 +255,30 @@ class ApiGatewayV5 implements ApiGateway { }); final body = jsonDecode(response.body); if (body['status'] != null) { - return {'result': 'success', 'data': RealtimeStatus.fromJson(body)}; + return RealtimeStatusResponse( + result: APiResponseType.success, + data: RealtimeStatus.fromJson(body)); } else { - return {'result': 'error'}; + return RealtimeStatusResponse(result: APiResponseType.error); } } on SocketException { - return {'result': 'socket'}; + return RealtimeStatusResponse(result: APiResponseType.socket); } on TimeoutException { - return {'result': 'timeout'}; + return RealtimeStatusResponse(result: APiResponseType.timeout); } on HandshakeException { - return {'result': 'ssl_error'}; + return RealtimeStatusResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return RealtimeStatusResponse(result: APiResponseType.error); } } /// Disables a Pi-hole server /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - `time` (`int`): The time in seconds to disable the server. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `no_connection`, `ssl_error`, `error`). - /// - `status`: The current Pi-hole status (`enabled` or `disabled`) if the operation is successful. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. + /// This method sends a GET request to the specified Pi-hole server to disable @override - dynamic disableServerRequest(int time) async { + Future disableServerRequest(int time) async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&disable=$time', @@ -91,41 +288,29 @@ class ApiGatewayV5 implements ApiGateway { }); final body = jsonDecode(response.body); if (body.runtimeType != List && body['status'] != null) { - return {'result': 'success', 'status': body['status']}; + return DisableServerResponse( + result: APiResponseType.success, status: body['status']); } else { - return {'result': 'error'}; + return DisableServerResponse(result: APiResponseType.error); } } on SocketException { - return {'result': 'no_connection'}; + return DisableServerResponse(result: APiResponseType.noConnection); } on TimeoutException { - return {'result': 'no_connection'}; + return DisableServerResponse(result: APiResponseType.noConnection); } on HandshakeException { - return {'result': 'ssl_error'}; + return DisableServerResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return DisableServerResponse(result: APiResponseType.error); } } /// Enables a Pi-hole server /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `no_connection`, `ssl_error`, `error`). - /// - `status`: The current Pi-hole status (`enabled` or `disabled`) if the operation is successful. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. - /// + /// This method sends a GET request to the specified Pi-hole server to enable @override - dynamic enableServerRequest() async { + Future enableServerRequest() async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&enable', basicAuth: { @@ -134,18 +319,19 @@ class ApiGatewayV5 implements ApiGateway { }); final body = jsonDecode(response.body); if (body.runtimeType != List && body['status'] != null) { - return {'result': 'success', 'status': body['status']}; + return EnableServerResponse( + result: APiResponseType.success, status: body['status']); } else { - return {'result': 'error'}; + return EnableServerResponse(result: APiResponseType.error); } } on SocketException { - return {'result': 'no_connection'}; + return EnableServerResponse(result: APiResponseType.noConnection); } on TimeoutException { - return {'result': 'no_connection'}; + return EnableServerResponse(result: APiResponseType.noConnection); } on HandshakeException { - return {'result': 'ssl_error'}; + return EnableServerResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return EnableServerResponse(result: APiResponseType.error); } } @@ -154,25 +340,10 @@ class ApiGatewayV5 implements ApiGateway { /// This method retrieves various over-time data points from the specified /// Pi-hole server, including queries over time (in 10-minute intervals), client /// activity, and client names. The data is parsed and returned in a structured format. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `socket`, `timeout`, `ssl_error`, `error`). - /// - `data`: An `OverTimeData` object containing the server's over-time data if the operation is successful. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. - // TODO: Hardcoded 10 minutes? The error occuer if the time is set anything other than 10 minutes. @override - Future fetchOverTimeData() async { + Future fetchOverTimeData() async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&overTimeData10mins&overTimeDataClients&getClientNames', @@ -182,15 +353,16 @@ class ApiGatewayV5 implements ApiGateway { }); final body = jsonDecode(response.body); var data = OverTimeData.fromJson(body); - return {'result': 'success', 'data': data}; + return FetchOverTimeDataResponse( + result: APiResponseType.success, data: data); } on SocketException { - return {'result': 'socket'}; + return FetchOverTimeDataResponse(result: APiResponseType.socket); } on TimeoutException { - return {'result': 'timeout'}; + return FetchOverTimeDataResponse(result: APiResponseType.timeout); } on HandshakeException { - return {'result': 'ssl_error'}; + return FetchOverTimeDataResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return FetchOverTimeDataResponse(result: APiResponseType.error); } } @@ -199,27 +371,10 @@ class ApiGatewayV5 implements ApiGateway { /// This method retrieves query logs from the given Pi-hole server for a /// specified time period. The logs are returned in a structured format /// for further analysis or display. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - `from` (`DateTime`): The start date and time for the log data. - /// - `until` (`DateTime`): The end date and time for the log data. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `token`, `socket`, `timeout`, `ssl_error`, `error`). - /// - `data`: A list of log entries if the operation is successful. - /// - /// ### Exceptions: - /// - `FormatException`: Malformed response body or unexpected data format. - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. @override - Future fetchLogs(DateTime from, DateTime until) async { + Future fetchLogs(DateTime from, DateTime until) async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&getAllQueries&from=${from.millisecondsSinceEpoch ~/ 1000}&until=${until.millisecondsSinceEpoch ~/ 1000}', @@ -229,17 +384,21 @@ class ApiGatewayV5 implements ApiGateway { 'password': server.basicAuthPassword }); final body = jsonDecode(response.body); - return {'result': 'success', 'data': body['data']}; + return FetchLogsResponse( + result: APiResponseType.success, + data: (body['data'] as List) + .map((item) => Log.fromJson(item)) + .toList()); } on FormatException { - return {'result': 'token'}; + return FetchLogsResponse(result: APiResponseType.authError); } on SocketException { - return {'result': 'socket'}; + return FetchLogsResponse(result: APiResponseType.socket); } on TimeoutException { - return {'result': 'timeout'}; + return FetchLogsResponse(result: APiResponseType.timeout); } on HandshakeException { - return {'result': 'ssl_error'}; + return FetchLogsResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return FetchLogsResponse(result: APiResponseType.error); } } @@ -248,26 +407,11 @@ class ApiGatewayV5 implements ApiGateway { /// This method interacts with the Pi-hole server's API to add the specified domain /// to either the whitelist or the blacklist, depending on the provided `list` parameter. /// It validates the server's response to confirm the operation's success. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - `domain` (`String`): The domain to add to the whitelist or blacklist. - /// - `list` (`String`): The list to add the domain to (`white` or `black`). - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `socket`, `timeout`, `ssl_error`, `error`). - /// - `data`: The server's response data if the operation is successful. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. @override - Future setWhiteBlacklist(String domain, String list) async { + Future setWhiteBlacklist( + String domain, String list) async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&list=$list&add=$domain', @@ -278,25 +422,28 @@ class ApiGatewayV5 implements ApiGateway { if (response.statusCode == 200) { final json = jsonDecode(response.body); if (json.runtimeType == List) { - return {'result': 'error', 'message': 'not_exists'}; + return SetWhiteBlacklistResponse( + result: APiResponseType.error, message: 'not_exists'); } else { if (json['success'] == true) { - return {'result': 'success', 'data': json}; + return SetWhiteBlacklistResponse( + result: APiResponseType.success, + data: DomainResult.fromJson(json)); } else { - return {'result': 'error'}; + return SetWhiteBlacklistResponse(result: APiResponseType.error); } } } else { - return {'result': 'error'}; + return SetWhiteBlacklistResponse(result: APiResponseType.error); } } on SocketException { - return {'result': 'socket'}; + return SetWhiteBlacklistResponse(result: APiResponseType.socket); } on TimeoutException { - return {'result': 'timeout'}; + return SetWhiteBlacklistResponse(result: APiResponseType.timeout); } on HandshakeException { - return {'result': 'ssl_error'}; + return SetWhiteBlacklistResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return SetWhiteBlacklistResponse(result: APiResponseType.error); } } @@ -304,30 +451,10 @@ class ApiGatewayV5 implements ApiGateway { /// /// This method retrieves the whitelist, regex whitelist, blacklist, and regex blacklist /// from the specified Pi-hole server. Each list is processed and returned in a structured format. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `no_connection`, `ssl_error`, `auth_error`, `error`). - /// - `data`: A map containing the following - /// - `whitelist`: A list of `Domain` objects representing the whitelist. - /// - `whitelistRegex`: A list of `Domain` objects representing the regex whitelist. - /// - `blacklist`: A list of `Domain` objects representing the blacklist. - /// - `blacklistRegex`: A list of `Domain` objects representing the regex blacklist. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - `FormatException`: Malformed response body or unexpected data format. - /// - General exceptions: Any other errors encountered during execution. @override - Future getDomainLists() async { + Future getDomainLists() async { Map? headers; - if (ApiGateway.checkBasicAuth( - server.basicAuthUser, server.basicAuthPassword) == + if (checkBasicAuth(server.basicAuthUser, server.basicAuthPassword) == true) { headers = { 'Authorization': @@ -337,21 +464,25 @@ class ApiGatewayV5 implements ApiGateway { try { final results = await Future.wait([ - http.get( - Uri.parse( - '${server.address}/admin/api.php?auth=${server.token}&list=white'), + httpClient( + method: 'get', + url: + '${server.address}/admin/api.php?auth=${server.token}&list=white', headers: headers), - http.get( - Uri.parse( - '${server.address}/admin/api.php?auth=${server.token}&list=regex_white'), + httpClient( + method: 'get', + url: + '${server.address}/admin/api.php?auth=${server.token}&list=regex_white', headers: headers), - http.get( - Uri.parse( - '${server.address}/admin/api.php?auth=${server.token}&list=black'), + httpClient( + method: 'get', + url: + '${server.address}/admin/api.php?auth=${server.token}&list=black', headers: headers), - http.get( - Uri.parse( - '${server.address}/admin/api.php?auth=${server.token}&list=regex_black'), + httpClient( + method: 'get', + url: + '${server.address}/admin/api.php?auth=${server.token}&list=regex_black', headers: headers), ]); @@ -359,32 +490,30 @@ class ApiGatewayV5 implements ApiGateway { results[1].statusCode == 200 && results[2].statusCode == 200 && results[3].statusCode == 200) { - return { - 'result': 'success', - 'data': { - 'whitelist': jsonDecode(results[0].body)['data'] - .map((item) => Domain.fromJson(item)), - 'whitelistRegex': jsonDecode(results[1].body)['data'] - .map((item) => Domain.fromJson(item)), - 'blacklist': jsonDecode(results[2].body)['data'] - .map((item) => Domain.fromJson(item)), - 'blacklistRegex': jsonDecode(results[3].body)['data'] - .map((item) => Domain.fromJson(item)), - } - }; + return GetDomainLists( + result: APiResponseType.success, + data: DomainListResult( + whitelist: parseDomainList(jsonDecode(results[0].body)['data']), + whitelistRegex: + parseDomainList(jsonDecode(results[1].body)['data']), + blacklist: parseDomainList(jsonDecode(results[2].body)['data']), + blacklistRegex: + parseDomainList(jsonDecode(results[3].body)['data']), + ), + ); } else { - return {'result': 'error'}; + return GetDomainLists(result: APiResponseType.error); } } on SocketException { - return {'result': 'no_connection'}; + return GetDomainLists(result: APiResponseType.socket); } on TimeoutException { - return {'result': 'no_connection'}; + return GetDomainLists(result: APiResponseType.timeout); } on HandshakeException { - return {'result': 'ssl_error'}; + return GetDomainLists(result: APiResponseType.sslError); } on FormatException { - return {'result': 'auth_error'}; + return GetDomainLists(result: APiResponseType.authError); } catch (e) { - return {'result': 'error'}; + return GetDomainLists(result: APiResponseType.error); } } @@ -394,23 +523,9 @@ class ApiGatewayV5 implements ApiGateway { /// from the given list, which can be one of the following: whitelist, blacklist, /// regex whitelist, or regex blacklist. The operation's success or failure is determined /// by the server's response. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - `domain` (`Domain`): The domain object to remove from the list. - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `not_exists`, `socket`, `timeout`, `ssl_error`, `error`). - /// - `message`: A string indicating the reason for the operation's outcome. - /// - /// ### Exceptions: - /// - `SocketException`: Network issues prevent connection to the server. - /// - `TimeoutException`: The request times out. - /// - `HandshakeException`: SSL/TLS handshake fails. - /// - General exceptions: Any other errors encountered during execution. @override - Future removeDomainFromList(Domain domain) async { + Future removeDomainFromList( + Domain domain) async { String getType(int type) { switch (type) { case 0: @@ -431,7 +546,7 @@ class ApiGatewayV5 implements ApiGateway { } try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&list=${getType(domain.type)}&sub=${domain.domain}', @@ -442,25 +557,27 @@ class ApiGatewayV5 implements ApiGateway { if (response.statusCode == 200) { final json = jsonDecode(response.body); if (json.runtimeType == List) { - return {'result': 'error', 'message': 'not_exists'}; + return RemoveDomainFromListResponse( + result: APiResponseType.error, message: 'not_exists'); } else { if (json['success'] == true) { - return {'result': 'success'}; + return RemoveDomainFromListResponse( + result: APiResponseType.success); } else { - return {'result': 'error'}; + return RemoveDomainFromListResponse(result: APiResponseType.error); } } } else { - return {'result': 'error'}; + return RemoveDomainFromListResponse(result: APiResponseType.error); } } on SocketException { - return {'result': 'socket'}; + return RemoveDomainFromListResponse(result: APiResponseType.noConnection); } on TimeoutException { - return {'result': 'timeout'}; + return RemoveDomainFromListResponse(result: APiResponseType.noConnection); } on HandshakeException { - return {'result': 'ssl_error'}; + return RemoveDomainFromListResponse(result: APiResponseType.sslError); } catch (e) { - return {'result': 'error'}; + return RemoveDomainFromListResponse(result: APiResponseType.error); } } @@ -470,18 +587,11 @@ class ApiGatewayV5 implements ApiGateway { /// such as the whitelist, blacklist, regex whitelist, or regex blacklist. It checks the server's /// response to determine whether the operation was successful or if the domain already exists /// in the list. - /// - /// ### Parameters: - /// - `server` (`Server`): The server object containing the Pi-hole address, token, and optional basic authentication credentials. - /// - `domainData` (`Map`): A map containing the following keys - /// - /// ### Returns: - /// - `Map`: A result object with the following keys - /// - `result`: A string indicating the outcome of the operation (`success`, `already_added`, `no_connection`, `ssl_error`, `auth_error`, `error`). @override - Future addDomainToList(Map domainData) async { + Future addDomainToList( + Map domainData) async { try { - final response = await ApiGateway.httpClient( + final response = await httpClient( method: 'get', url: '${server.address}/admin/api.php?auth=${server.token}&list=${domainData['list']}&add=${domainData['domain']}', @@ -492,32 +602,33 @@ class ApiGatewayV5 implements ApiGateway { if (response.statusCode == 200) { final json = jsonDecode(response.body); if (json.runtimeType == List) { - return {'result': 'error'}; + return AddDomainToListResponse(result: APiResponseType.error); } else { if (json['success'] == true && json['message'] == 'Added ${domainData['domain']}') { - return {'result': 'success'}; + return AddDomainToListResponse(result: APiResponseType.success); } else if (json['success'] == true && json['message'] == 'Not adding ${domainData['domain']} as it is already on the list') { - return {'result': 'already_added'}; + return AddDomainToListResponse( + result: APiResponseType.alreadyAdded); } else { - return {'result': 'error'}; + return AddDomainToListResponse(result: APiResponseType.error); } } } else { - return {'result': 'error'}; + return AddDomainToListResponse(result: APiResponseType.error); } } on SocketException { - return {'result': 'no_connection'}; + return AddDomainToListResponse(result: APiResponseType.noConnection); } on TimeoutException { - return {'result': 'no_connection'}; + return AddDomainToListResponse(result: APiResponseType.noConnection); } on HandshakeException { - return {'result': 'ssl_error'}; + return AddDomainToListResponse(result: APiResponseType.sslError); } on FormatException { - return {'result': 'auth_error'}; + return AddDomainToListResponse(result: APiResponseType.authError); } catch (e) { - return {'result': 'error'}; + return AddDomainToListResponse(result: APiResponseType.error); } } } diff --git a/lib/models/gateways.dart b/lib/models/gateways.dart new file mode 100644 index 00000000..a3e5f430 --- /dev/null +++ b/lib/models/gateways.dart @@ -0,0 +1,162 @@ +import 'package:pi_hole_client/models/app_log.dart'; +import 'package:pi_hole_client/models/domain.dart'; +import 'package:pi_hole_client/models/log.dart'; +import 'package:pi_hole_client/models/overtime_data.dart'; +import 'package:pi_hole_client/models/realtime_status.dart'; + +enum APiResponseType { + success, + authError, + noConnection, + socket, + timeout, + sslError, + error, + alreadyAdded +} + +/// A response object for the login query. +/// +/// When Successful [result], [status] and [phpSessId] are returned. +/// When error, [result] and [log] are returned. +class LoginQueryResponse { + final APiResponseType result; + final AppLog? log; + final String? status; + final String? phpSessId; + + LoginQueryResponse({ + required this.result, + this.log, + this.status, + this.phpSessId, + }); +} + +class RealtimeStatusResponse { + final APiResponseType result; + final RealtimeStatus? data; + + RealtimeStatusResponse({ + required this.result, + this.data, + }); +} + +class DisableServerResponse { + final APiResponseType result; + final String? status; + + DisableServerResponse({ + required this.result, + this.status, + }); +} + +class EnableServerResponse { + final APiResponseType result; + final String? status; + + EnableServerResponse({ + required this.result, + this.status, + }); +} + +class FetchOverTimeDataResponse { + final APiResponseType result; + final OverTimeData? data; + + FetchOverTimeDataResponse({ + required this.result, + this.data, + }); +} + +class FetchLogsResponse { + final APiResponseType result; + final List? data; + + FetchLogsResponse({ + required this.result, + this.data, + }); +} + +class DomainResult { + final bool success; + final String message; + + DomainResult({ + required this.success, + required this.message, + }); + + factory DomainResult.fromJson(Map json) { + return DomainResult( + success: json['success'], + message: json['message'], + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + }; + } +} + +class SetWhiteBlacklistResponse { + final APiResponseType result; + final String? message; + final DomainResult? data; + + SetWhiteBlacklistResponse({ + required this.result, + this.message, + this.data, + }); +} + +class DomainListResult { + final List whitelist; + final List whitelistRegex; + final List blacklist; + final List blacklistRegex; + + DomainListResult({ + required this.whitelist, + required this.whitelistRegex, + required this.blacklist, + required this.blacklistRegex, + }); +} + +class GetDomainLists { + final APiResponseType result; + final DomainListResult? data; + + GetDomainLists({ + required this.result, + this.data, + }); +} + +class RemoveDomainFromListResponse { + final APiResponseType result; + final String? message; + + RemoveDomainFromListResponse({ + required this.result, + this.message, + }); +} + +class AddDomainToListResponse { + final APiResponseType result; + + AddDomainToListResponse({ + required this.result, + }); +} diff --git a/lib/models/repository/database.dart b/lib/models/repository/database.dart index 3a9ad7ec..08d41897 100644 --- a/lib/models/repository/database.dart +++ b/lib/models/repository/database.dart @@ -25,7 +25,7 @@ class ServerDbData { final String alias; final String? token; final int isDefaultServer; - final String? apiVersion; + final String apiVersion; final String? basicAuthUser; final String? basicAuthPassword; @@ -45,7 +45,7 @@ class ServerDbData { alias: map['alias'] as String, token: map['token'] as String?, isDefaultServer: map['isDefaultServer'] as int, - apiVersion: map['apiVersion'] as String?, + apiVersion: map['apiVersion'] as String, basicAuthUser: map['basicAuthUser'] as String?, basicAuthPassword: map['basicAuthPassword'] as String?, ); diff --git a/lib/models/server.dart b/lib/models/server.dart index 5b3dd037..26999084 100644 --- a/lib/models/server.dart +++ b/lib/models/server.dart @@ -6,25 +6,25 @@ class Server { final String address; /// Pi-hole server alias - String alias; + final String alias; /// Pi-hole server API token - String? token; + final String? token; /// Whether this server is the default server - bool defaultServer; + final bool defaultServer; /// Wheter this server is enabled(selected) - bool? enabled; + final bool? enabled; /// Pi-hole API version - String? apiVersion; + final String apiVersion; /// Basic authentication username - String? basicAuthUser; + final String? basicAuthUser; /// Basic authentication password - String? basicAuthPassword; + final String? basicAuthPassword; Server({ required this.address, @@ -32,8 +32,30 @@ class Server { this.token, required this.defaultServer, this.enabled, - this.apiVersion, + required this.apiVersion, this.basicAuthUser, this.basicAuthPassword, }); + + Server copyWith({ + String? address, + String? alias, + String? token, + bool? defaultServer, + bool? enabled, + String? apiVersion, + String? basicAuthUser, + String? basicAuthPassword, + }) { + return Server( + address: address ?? this.address, + alias: alias ?? this.alias, + token: token ?? this.token, + defaultServer: defaultServer ?? this.defaultServer, + enabled: enabled ?? this.enabled, + apiVersion: apiVersion ?? this.apiVersion, + basicAuthUser: basicAuthUser ?? this.basicAuthUser, + basicAuthPassword: basicAuthPassword ?? this.basicAuthPassword, + ); + } } diff --git a/lib/providers/domains_list_provider.dart b/lib/providers/domains_list_provider.dart index da0f1dbf..1e8cff70 100644 --- a/lib/providers/domains_list_provider.dart +++ b/lib/providers/domains_list_provider.dart @@ -1,5 +1,6 @@ import 'package:pi_hole_client/constants/enums.dart'; import 'package:flutter/material.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:pi_hole_client/models/server.dart'; import 'package:pi_hole_client/models/domain.dart'; @@ -101,18 +102,18 @@ class DomainsListProvider with ChangeNotifier { Future fetchDomainsList(Server server) async { final apiGateway = serversProvider?.selectedApiGateway; final result = await apiGateway?.getDomainLists(); - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { final List whitelist = [ - ...result['data']['whitelist'], - ...result['data']['whitelistRegex'] + ...result!.data!.whitelist, + ...result.data!.whitelistRegex ]; _whitelistDomains = whitelist; _filteredWhitelistDomains = whitelist.where((i) => i.domain.contains(_searchTerm)).toList(); final List blacklist = [ - ...result['data']['blacklist'], - ...result['data']['blacklistRegex'] + ...result.data!.blacklist, + ...result.data!.blacklistRegex ]; _blacklistDomains = blacklist; _filteredBlacklistDomains = diff --git a/lib/providers/servers_provider.dart b/lib/providers/servers_provider.dart index 11d01a51..3ab5c21b 100644 --- a/lib/providers/servers_provider.dart +++ b/lib/providers/servers_provider.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:pi_hole_client/gateways/api_gateway_factory.dart'; import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; import 'package:pi_hole_client/gateways/v5/api_gateway_v5.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:pi_hole_client/models/repository/database.dart'; import 'package:pi_hole_client/repository/database.dart'; import 'package:pi_hole_client/providers/app_config_provider.dart'; @@ -98,16 +100,13 @@ class ServersProvider with ChangeNotifier { Future setDefaultServer(Server server) async { final updated = await _repository.setDefaultServerQuery(server.address); if (updated == true) { - List newServers = _serversList.map((s) { + _serversList = _serversList.map((s) { if (s.address == server.address) { - s.defaultServer = true; - return s; + return s.copyWith(defaultServer: true); } else { - s.defaultServer = false; - return s; + return s.copyWith(defaultServer: false); } }).toList(); - _serversList = newServers; notifyListeners(); return true; } else { @@ -164,8 +163,8 @@ class ServersProvider with ChangeNotifier { } Future login(Server serverObj) async { - final result = await ApiGateway.loginQuery(serverObj); - if (result['result'] == 'success') { + final result = await ApiGatewayFactory.create(serverObj).loginQuery(); + if (result.result == APiResponseType.success) { _selectedServer = serverObj; notifyListeners(); return true; @@ -188,7 +187,7 @@ class ServersProvider with ChangeNotifier { void updateselectedServerStatus(bool enabled) { if (_selectedServer != null) { - _selectedServer!.enabled = enabled; + _selectedServer = _selectedServer!.copyWith(enabled: enabled); notifyListeners(); } } diff --git a/lib/repository/database.dart b/lib/repository/database.dart index 7d19ca41..017bf58b 100644 --- a/lib/repository/database.dart +++ b/lib/repository/database.dart @@ -85,7 +85,7 @@ class DatabaseRepository { address TEXT PRIMARY KEY NOT NULL, alias TEXT NOT NULL, isDefaultServer NUMERIC NOT NULL, - apiVersion TEXT + apiVersion TEXT NOT NULL ) """); await db.execute(""" diff --git a/lib/screens/domains/domains.dart b/lib/screens/domains/domains.dart index 24fe748f..d7a9d0fa 100644 --- a/lib/screens/domains/domains.dart +++ b/lib/screens/domains/domains.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -80,16 +81,16 @@ class _DomainListsWidgetState extends State process.close(); - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { domainsListProvider.removeDomainFromList(domain); showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.domainRemoved, color: Colors.green); - } else if (result['result'] == 'error' && - result['message'] != null && - result['message'] == 'not_exists') { + } else if (result!.result == APiResponseType.error && + result.message != null && + result.message == 'not_exists') { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.domainNotExists, diff --git a/lib/screens/domains/domains_list.dart b/lib/screens/domains/domains_list.dart index 7d85983c..de611037 100644 --- a/lib/screens/domains/domains_list.dart +++ b/lib/screens/domains/domains_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -77,16 +78,16 @@ class _DomainsListState extends State { process.close(); - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { domainsListProvider.removeDomainFromList(domain); Navigator.pop(context); showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.domainRemoved, color: Colors.green); - } else if (result['result'] == 'error' && - result['message'] != null && - result['message'] == 'not_exists') { + } else if (result?.result == APiResponseType.error && + result!.message != null && + result.message == 'not_exists') { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.domainNotExists, @@ -107,13 +108,13 @@ class _DomainsListState extends State { process.close(); - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { domainsListProvider.fetchDomainsList(serversProvider.selectedServer!); showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.domainAdded, color: Colors.green); - } else if (result['result'] == 'already_added') { + } else if (result?.result == APiResponseType.alreadyAdded) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.domainAlreadyAdded, diff --git a/lib/screens/home/home_appbar.dart b/lib/screens/home/home_appbar.dart index feedc7c1..93d5d00c 100644 --- a/lib/screens/home/home_appbar.dart +++ b/lib/screens/home/home_appbar.dart @@ -1,7 +1,8 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; -import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; +import 'package:pi_hole_client/gateways/api_gateway_factory.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -37,11 +38,11 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { process.open(AppLocalizations.of(context)!.refreshingData); final result = await apiGateway?.realtimeStatus(); process.close(); - if (result['result'] == "success") { + if (result?.result == APiResponseType.success) { serversProvider.updateselectedServerStatus( - result['data'].status == 'enabled' ? true : false); + result!.data!.status == 'enabled' ? true : false); statusProvider.setIsServerConnected(true); - statusProvider.setRealtimeStatus(result['data']); + statusProvider.setRealtimeStatus(result.data!); } else { statusProvider.setIsServerConnected(false); if (statusProvider.getStatusLoading == LoadStatus.loading) { @@ -73,14 +74,15 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { alias: server.alias, token: server.token!, defaultServer: server.defaultServer, - enabled: result['status'] == 'enabled' ? true : false)); + apiVersion: server.apiVersion, + enabled: result.status == 'enabled' ? true : false)); final statusResult = await apiGateway?.realtimeStatus(); - if (statusResult['result'] == 'success') { - statusProvider.setRealtimeStatus(statusResult['data']); + if (statusResult?.result == APiResponseType.success) { + statusProvider.setRealtimeStatus(statusResult!.data!); } final overtimeDataResult = await apiGateway?.fetchOverTimeData(); - if (overtimeDataResult['result'] == 'success') { - statusProvider.setOvertimeData(overtimeDataResult['data']); + if (overtimeDataResult?.result == APiResponseType.success) { + statusProvider.setOvertimeData(overtimeDataResult!.data!); statusProvider.setOvertimeDataLoadingStatus(1); } else { statusProvider.setOvertimeDataLoadingStatus(2); @@ -92,9 +94,9 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { final ProcessModal process = ProcessModal(context: context); process.open(AppLocalizations.of(context)!.connecting); - final result = await ApiGateway.loginQuery(server); + final result = await ApiGatewayFactory.create(server).loginQuery(); process.close(); - if (result['result'] == 'success') { + if (result.result == APiResponseType.success) { await connectSuccess(result); } else { showSnackBar( diff --git a/lib/screens/logs/logs.dart b/lib/screens/logs/logs.dart index 240a7a61..74fd8240 100644 --- a/lib/screens/logs/logs.dart +++ b/lib/screens/logs/logs.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -119,10 +120,10 @@ class _LogsState extends State { await apiGateway?.fetchLogs(minusHoursTimestamp, timestamp); if (mounted) { setState(() => _isLoadingMore = false); - if (result['result'] == 'success') { + if (result?.result == APiResponseType.success) { List items = []; - if (result['data'] != null) { - result['data'].forEach((item) => items.add(Log.fromJson(item))); + if (result!.data != null) { + result.data?.forEach((item) => items.add(item)); } if (replaceOldLogs == true) { setState(() { @@ -247,8 +248,8 @@ class _LogsState extends State { ); final result = await apiGateway?.setWhiteBlacklist(log.url, list); loading.close(); - if (result['result'] == 'success') { - if (result['data']['message'].toString().contains('Added')) { + if (result?.result == APiResponseType.success) { + if (result!.data!.message.contains('Added')) { if (!mounted) return; showSnackBar( appConfigProvider: appConfigProvider, diff --git a/lib/screens/servers/add_server_fullscreen.dart b/lib/screens/servers/add_server_fullscreen.dart index 571a91bd..13512dba 100644 --- a/lib/screens/servers/add_server_fullscreen.dart +++ b/lib/screens/servers/add_server_fullscreen.dart @@ -3,8 +3,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:pi_hole_client/constants/api_versions.dart'; import 'package:pi_hole_client/constants/urls.dart'; -import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; +import 'package:pi_hole_client/gateways/api_gateway_factory.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -29,8 +31,6 @@ class AddServerFullscreen extends StatefulWidget { enum ConnectionType { http, https } -enum PiHoleVersion { v5, v6 } - class _AddServerFullscreenState extends State { TextEditingController addressFieldController = TextEditingController(); String? addressFieldError; @@ -41,7 +41,7 @@ class _AddServerFullscreenState extends State { TextEditingController aliasFieldController = TextEditingController(); TextEditingController tokenFieldController = TextEditingController(); ConnectionType connectionType = ConnectionType.http; - PiHoleVersion piHoleVersion = PiHoleVersion.v5; + String piHoleVersion = SupportedApiVersions.v5; TextEditingController basicAuthUser = TextEditingController(); TextEditingController basicAuthPassword = TextEditingController(); bool defaultCheckbox = false; @@ -151,9 +151,7 @@ class _AddServerFullscreenState extends State { connectionType = widget.server!.address.split(':')[0] == 'https' ? ConnectionType.https : ConnectionType.http; - piHoleVersion = widget.server!.apiVersion == '5' - ? PiHoleVersion.v5 - : PiHoleVersion.v6; + piHoleVersion = widget.server!.apiVersion; defaultCheckbox = widget.server!.defaultServer; }); } @@ -197,18 +195,20 @@ class _AddServerFullscreenState extends State { alias: aliasFieldController.text, token: tokenFieldController.text, defaultServer: false, + apiVersion: piHoleVersion, basicAuthUser: basicAuthUser.text, basicAuthPassword: basicAuthPassword.text); - final result = await ApiGateway.loginQuery(serverObj); + final result = await ApiGatewayFactory.create(serverObj).loginQuery(); if (!mounted) return; - if (result['result'] == 'success') { + if (result.result == APiResponseType.success) { Navigator.pop(context); serversProvider.addServer(Server( address: serverObj.address, alias: serverObj.alias, token: serverObj.token, defaultServer: defaultCheckbox, - enabled: result['status'] == 'enabled' ? true : false, + apiVersion: piHoleVersion, + enabled: result.status == 'enabled' ? true : false, basicAuthUser: basicAuthUser.text, basicAuthPassword: basicAuthPassword.text)); } else { @@ -216,42 +216,42 @@ class _AddServerFullscreenState extends State { setState(() { isConnecting = false; }); - if (result['result'] == 'socket') { + if (result.result == APiResponseType.socket) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.checkAddress, color: Colors.red); - appConfigProvider.addLog(result['log']); - } else if (result['result'] == 'timeout') { + appConfigProvider.addLog(result.log!); + } else if (result.result == APiResponseType.timeout) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.connectionTimeout, color: Colors.red); - appConfigProvider.addLog(result['log']); - } else if (result['result'] == 'no_connection') { + appConfigProvider.addLog(result.log!); + } else if (result.result == APiResponseType.noConnection) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.cantReachServer, color: Colors.red); - appConfigProvider.addLog(result['log']); - } else if (result['result'] == 'auth_error') { + appConfigProvider.addLog(result.log!); + } else if (result.result == APiResponseType.authError) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.tokenNotValid, color: Colors.red); - appConfigProvider.addLog(result['log']); - } else if (result['result'] == 'ssl_error') { + appConfigProvider.addLog(result.log!); + } else if (result.result == APiResponseType.sslError) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.sslErrorLong, color: Colors.red); - appConfigProvider.addLog(result['log']); + appConfigProvider.addLog(result.log!); } else { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.unknownError, color: Colors.red); - appConfigProvider.addLog(result['log']); + appConfigProvider.addLog(result.log!); } } else { isConnecting = false; @@ -272,15 +272,17 @@ class _AddServerFullscreenState extends State { alias: aliasFieldController.text, token: tokenFieldController.text, defaultServer: false, + apiVersion: piHoleVersion, basicAuthUser: basicAuthUser.text, basicAuthPassword: basicAuthPassword.text); - final result = await ApiGateway.loginQuery(serverObj); - if (result['result'] == 'success') { + final result = await ApiGatewayFactory.create(serverObj).loginQuery(); + if (result.result == APiResponseType.success) { Server server = Server( address: widget.server!.address, alias: aliasFieldController.text, token: tokenFieldController.text, defaultServer: defaultCheckbox, + apiVersion: piHoleVersion, basicAuthUser: basicAuthUser.text, basicAuthPassword: basicAuthPassword.text); final result = await serversProvider.editServer(server); @@ -302,27 +304,27 @@ class _AddServerFullscreenState extends State { setState(() { isConnecting = false; }); - if (result['result'] == 'socket') { + if (result.result == APiResponseType.socket) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.checkAddress, color: Colors.red); - } else if (result['result'] == 'timeout') { + } else if (result.result == APiResponseType.timeout) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.connectionTimeout, color: Colors.red); - } else if (result['result'] == 'no_connection') { + } else if (result.result == APiResponseType.noConnection) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.cantReachServer, color: Colors.red); - } else if (result['result'] == 'auth_error') { + } else if (result.result == APiResponseType.authError) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.tokenNotValid, color: Colors.red); - } else if (result['result'] == 'ssl_error') { + } else if (result.result == APiResponseType.sslError) { showSnackBar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.sslErrorLong, @@ -614,15 +616,16 @@ class _AddServerFullscreenState extends State { Container( padding: const EdgeInsets.symmetric(vertical: 10), width: double.maxFinite, - child: SegmentedButton( + child: SegmentedButton( segments: const [ - ButtonSegment(value: PiHoleVersion.v5, label: Text("v5")), ButtonSegment( - value: PiHoleVersion.v6, - label: Text("v6"), - enabled: false), // TODO: enable + value: SupportedApiVersions.v5, + label: Text(SupportedApiVersions.v5)), + ButtonSegment( + value: SupportedApiVersions.v6, + label: Text(SupportedApiVersions.v6)) ], - selected: {piHoleVersion}, + selected: {piHoleVersion}, onSelectionChanged: (value) => setState(() => piHoleVersion = value.first), ), @@ -632,7 +635,7 @@ class _AddServerFullscreenState extends State { padding: const EdgeInsets.only(top: 30, bottom: 10)), Padding( padding: const EdgeInsets.symmetric(vertical: 10), - child: piHoleVersion == PiHoleVersion.v5 + child: piHoleVersion == SupportedApiVersions.v5 ? buildV5Settings(context) : buildV6Settings(context), ), diff --git a/lib/screens/servers/servers_list_item.dart b/lib/screens/servers/servers_list_item.dart index e6a121ae..26bc3210 100644 --- a/lib/screens/servers/servers_list_item.dart +++ b/lib/screens/servers/servers_list_item.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:expandable/expandable.dart'; -import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; +import 'package:pi_hole_client/gateways/api_gateway_factory.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -122,15 +123,16 @@ class _ServersListItemState extends State alias: server.alias, token: server.token!, defaultServer: server.defaultServer, - enabled: result['status'] == 'enabled' ? true : false), + apiVersion: server.apiVersion, + enabled: result.status == 'enabled' ? true : false), toHomeTab: true); final statusResult = await apiGateway?.realtimeStatus(); - if (statusResult['result'] == 'success') { - statusProvider.setRealtimeStatus(statusResult['data']); + if (statusResult?.result == APiResponseType.success) { + statusProvider.setRealtimeStatus(statusResult!.data!); } final overtimeDataResult = await apiGateway?.fetchOverTimeData(); - if (overtimeDataResult['result'] == 'success') { - statusProvider.setOvertimeData(overtimeDataResult['data']); + if (overtimeDataResult?.result == APiResponseType.success) { + statusProvider.setOvertimeData(overtimeDataResult!.data!); statusProvider.setOvertimeDataLoadingStatus(1); } else { statusProvider.setOvertimeDataLoadingStatus(2); @@ -142,9 +144,9 @@ class _ServersListItemState extends State final ProcessModal process = ProcessModal(context: context); process.open(AppLocalizations.of(context)!.connecting); - final result = await ApiGateway.loginQuery(server); + final result = await ApiGatewayFactory.create(server).loginQuery(); process.close(); - if (result['result'] == 'success') { + if (result.result == APiResponseType.success) { await connectSuccess(result); } else { showSnackBar( diff --git a/lib/screens/servers/servers_tile_item.dart b/lib/screens/servers/servers_tile_item.dart index 67140df2..8b86e614 100644 --- a/lib/screens/servers/servers_tile_item.dart +++ b/lib/screens/servers/servers_tile_item.dart @@ -1,7 +1,8 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; -import 'package:pi_hole_client/gateways/api_gateway_interface.dart'; +import 'package:pi_hole_client/gateways/api_gateway_factory.dart'; +import 'package:pi_hole_client/models/gateways.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -93,16 +94,17 @@ class _ServersTileItemState extends State alias: server.alias, token: server.token!, defaultServer: server.defaultServer, - enabled: result['status'] == 'enabled' ? true : false), + apiVersion: server.apiVersion, + enabled: result.status == 'enabled' ? true : false), toHomeTab: true); final apiGateway = serversProvider.selectedApiGateway; final statusResult = await apiGateway?.realtimeStatus(); - if (statusResult['result'] == 'success') { - statusProvider.setRealtimeStatus(statusResult['data']); + if (statusResult?.result == APiResponseType.success) { + statusProvider.setRealtimeStatus(statusResult!.data!); } final overtimeDataResult = await apiGateway?.fetchOverTimeData(); - if (overtimeDataResult['result'] == 'success') { - statusProvider.setOvertimeData(overtimeDataResult['data']); + if (overtimeDataResult?.result == APiResponseType.success) { + statusProvider.setOvertimeData(overtimeDataResult!.data!); statusProvider.setOvertimeDataLoadingStatus(1); } else { statusProvider.setOvertimeDataLoadingStatus(2); @@ -115,9 +117,9 @@ class _ServersTileItemState extends State final ProcessModal process = ProcessModal(context: context); process.open(AppLocalizations.of(context)!.connecting); - final result = await ApiGateway.loginQuery(server); + final result = await ApiGatewayFactory.create(server).loginQuery(); process.close(); - if (result['result'] == 'success') { + if (result.result == APiResponseType.success) { await connectSuccess(result); } else if (mounted) { showSnackBar( diff --git a/pubspec.lock b/pubspec.lock index 38bcae78..da5fdc1f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -754,6 +754,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.3" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d57f4b48..9dfd827c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,6 +89,7 @@ dev_dependencies: flutter_lints: ^5.0.0 freezed: ^2.5.7 json_serializable: ^6.8.0 + mockito: ^5.4.4 flutter_launcher_icons: diff --git a/test/gateways/v5/api_gateway_v5_test.dart b/test/gateways/v5/api_gateway_v5_test.dart new file mode 100644 index 00000000..292698f2 --- /dev/null +++ b/test/gateways/v5/api_gateway_v5_test.dart @@ -0,0 +1,1261 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:pi_hole_client/constants/api_versions.dart'; +import 'package:pi_hole_client/gateways/v5/api_gateway_v5.dart'; +import 'package:pi_hole_client/models/domain.dart'; +import 'package:pi_hole_client/models/gateways.dart'; +import 'package:pi_hole_client/models/server.dart'; +import './api_gateway_v5_test.mocks.dart'; + +@GenerateMocks([http.Client]) +void main() { + group('checkBasicAuth', () { + late ApiGatewayV5 apiGateway; + late Server server; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + apiGateway = ApiGatewayV5(server); + }); + + test('checkBasicAuth returns true for valid credentials', () { + expect(apiGateway.checkBasicAuth('username', 'password'), isTrue); + }); + + test('checkBasicAuth returns false for invalid username', () { + expect(apiGateway.checkBasicAuth('', 'password'), isFalse); + expect(apiGateway.checkBasicAuth(null, 'password'), isFalse); + }); + + test('checkBasicAuth returns false for invalid blank blank password', () { + expect(apiGateway.checkBasicAuth('username', ''), isFalse); + expect(apiGateway.checkBasicAuth('username', null), isFalse); + }); + }); + + group('loginQuery', () { + late Server server; + final sessinId = 'n9n9f6c3umrumfq2ese1lvu2pg'; + final url = 'http://example.com/admin/api.php?auth=xxx123&summaryRaw'; + + setUp(() { + server = Server( + 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( + Uri.parse('http://example.com/admin/api.php?auth=xxx123&enable=0'), + headers: {})).thenAnswer((_) async => http.Response( + jsonEncode({"status": "enabled"}), 200, headers: { + 'set-cookie': + 'PHPSESSID=$sessinId; path=/; HttpOnly; SameSite=Strict' + })); + + final response = await apiGateway.loginQuery(); + + expect(response.result, APiResponseType.success); + expect(response.phpSessId, sessinId); + expect(response.status, 'enabled'); + expect(response.log, isNull); + }); + + test('Return error with invalid auth token', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + final response = await apiGateway.loginQuery(); + + expect(response.result, APiResponseType.authError); + expect(response.phpSessId, isNull); + expect(response.status, isNull); + expect(response.log, isNotNull); + expect(response.log?.type, 'login'); + expect(response.log?.message, 'auth_error'); + expect(response.log?.statusCode, '200'); + expect(response.log?.resBody, '[]'); + }); + + test('Return error when accessing non Pi-hole server', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + // example.com's 404 page + final htmlString = ''' + + + + Example Domain + + + + + + + + +
+

Example Domain

+

This domain is for use in illustrative examples in documents. You may use this + domain in literature without prior coordination or asking for permission.

+

More information...

+
+ + + ''' + .trimLeft(); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(htmlString, 404)); + + final response = await apiGateway.loginQuery(); + + expect(response.result, APiResponseType.noConnection); + expect(response.phpSessId, isNull); + expect(response.status, isNull); + expect(response.log, isNotNull); + expect(response.log?.type, 'login'); + expect(response.log?.message, 'no_connection_2'); + expect(response.log?.statusCode, '404'); + expect(response.log?.resBody, htmlString); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.loginQuery(); + + expect(response.result, APiResponseType.error); + expect(response.phpSessId, isNull); + expect(response.status, isNull); + expect(response.log?.type, 'login'); + expect(response.log?.message, 'Exception: Unexpected error test'); + }); + }); + + group('realtimeStatus', () { + late Server server; + final url = + 'http://example.com/admin/api.php?auth=xxx123&summaryRaw&topItems&getForwardDestinations&getQuerySources&topClientsBlocked&getQueryTypes'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = { + "domains_being_blocked": 121860, + "dns_queries_today": 16, + "ads_blocked_today": 1, + "ads_percentage_today": 6.25, + "unique_domains": 11, + "queries_forwarded": 9, + "queries_cached": 6, + "clients_ever_seen": 2, + "unique_clients": 2, + "dns_queries_all_types": 16, + "reply_UNKNOWN": 0, + "reply_NODATA": 0, + "reply_NXDOMAIN": 3, + "reply_CNAME": 0, + "reply_IP": 10, + "reply_DOMAIN": 3, + "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": 16, + "privacy_level": 0, + "status": "enabled", + "gravity_last_updated": { + "file_exists": true, + "absolute": 1732972589, + "relative": {"days": 5, "hours": 18, "minutes": 14} + }, + "top_queries": { + "1.0.26.172.in-addr.arpa": 3, + "8.8.8.8.in-addr.arpa": 3, + "github.com": 2, + "gitlab.com": 1, + "sample.com": 1, + "test.com": 1, + "google.com": 1, + "google.co.jp": 1, + "yahoo.co.jp": 1, + "fix.test.com": 1 + }, + "top_ads": {"test.com": 1}, + "top_sources": {"172.26.0.1": 10, "localhost|127.0.0.1": 6}, + "top_sources_blocked": {"172.26.0.1": 1}, + "forward_destinations": { + "blocked|blocked": 6.25, + "cached|cached": 37.5, + "other|other": 0, + "dns.google#53|8.8.8.8#53": 56.25 + }, + "querytypes": { + "A (IPv4)": 62.5, + "AAAA (IPv6)": 0, + "ANY": 0, + "SRV": 0, + "SOA": 0, + "PTR": 37.5, + "TXT": 0, + "NAPTR": 0, + "MX": 0, + "DS": 0, + "RRSIG": 0, + "DNSKEY": 0, + "NS": 0, + "OTHER": 0, + "SVCB": 0, + "HTTPS": 0 + } + }; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway.realtimeStatus(); + + expect(response.result, APiResponseType.success); + expect(response.data, isNotNull); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.realtimeStatus(); + + expect(response.result, APiResponseType.error); + expect(response.data, isNull); + }); + }); + + group('disableServerRequest', () { + late Server server; + final url = 'http://example.com/admin/api.php?auth=xxx123&disable=5'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = {"status": "disabled"}; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway.disableServerRequest(5); + + expect(response.result, APiResponseType.success); + expect(response.status, 'disabled'); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.disableServerRequest(5); + + expect(response.result, APiResponseType.error); + expect(response.status, isNull); + }); + }); + + group('enableServerRequest', () { + late Server server; + final url = 'http://example.com/admin/api.php?auth=xxx123&enable'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = {"status": "enabled"}; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway.enableServerRequest(); + + expect(response.result, APiResponseType.success); + expect(response.status, 'enabled'); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.enableServerRequest(); + + expect(response.result, APiResponseType.error); + expect(response.status, isNull); + }); + }); + + group('fetchOverTimeData', () { + late Server server; + final url = + 'http://example.com/admin/api.php?auth=xxx123&overTimeData10mins&overTimeDataClients&getClientNames'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = { + "domains_over_time": { + "1733391300": 0, + "1733391900": 0, + "1733392500": 0, + "1733393100": 0, + "1733393700": 0, + "1733394300": 0, + "1733394900": 0, + "1733395500": 0, + "1733396100": 0, + "1733396700": 3, + "1733397300": 0, + "1733397900": 0, + "1733398500": 0, + "1733399100": 0, + "1733399700": 0, + "1733400300": 2, + "1733400900": 7, + "1733401500": 0, + "1733402100": 0, + "1733402700": 0, + "1733403300": 0, + "1733403900": 2, + "1733404500": 0, + "1733405100": 0, + "1733405700": 0, + "1733406300": 0, + "1733406900": 0, + "1733407500": 2, + "1733408100": 0, + "1733408700": 0, + "1733409300": 0, + "1733409900": 0, + "1733410500": 0, + "1733411100": 0, + "1733411700": 0, + "1733412300": 0, + "1733412900": 0, + "1733413500": 0, + "1733414100": 0, + "1733414700": 0, + "1733415300": 0, + "1733415900": 0, + "1733416500": 0, + "1733417100": 0, + "1733417700": 0, + "1733418300": 0, + "1733418900": 0, + "1733419500": 0, + "1733420100": 0, + "1733420700": 0, + "1733421300": 0, + "1733421900": 0, + "1733422500": 0, + "1733423100": 0, + "1733423700": 0, + "1733424300": 0, + "1733424900": 0, + "1733425500": 0, + "1733426100": 0, + "1733426700": 0, + "1733427300": 0, + "1733427900": 0, + "1733428500": 0, + "1733429100": 0, + "1733429700": 0, + "1733430300": 0, + "1733430900": 0, + "1733431500": 0, + "1733432100": 0, + "1733432700": 0, + "1733433300": 0, + "1733433900": 0, + "1733434500": 0, + "1733435100": 0, + "1733435700": 0, + "1733436300": 0, + "1733436900": 0, + "1733437500": 0, + "1733438100": 0, + "1733438700": 0, + "1733439300": 0, + "1733439900": 0, + "1733440500": 0, + "1733441100": 0, + "1733441700": 0, + "1733442300": 0, + "1733442900": 0, + "1733443500": 0, + "1733444100": 0, + "1733444700": 0, + "1733445300": 0, + "1733445900": 0, + "1733446500": 0, + "1733447100": 0, + "1733447700": 0, + "1733448300": 0, + "1733448900": 0, + "1733449500": 0, + "1733450100": 0, + "1733450700": 0, + "1733451300": 0, + "1733451900": 0, + "1733452500": 0, + "1733453100": 0, + "1733453700": 0, + "1733454300": 0, + "1733454900": 0, + "1733455500": 0, + "1733456100": 0, + "1733456700": 0, + "1733457300": 0, + "1733457900": 0, + "1733458500": 0, + "1733459100": 0, + "1733459700": 0, + "1733460300": 0, + "1733460900": 0, + "1733461500": 0, + "1733462100": 0, + "1733462700": 0, + "1733463300": 0, + "1733463900": 0, + "1733464500": 0, + "1733465100": 0, + "1733465700": 0, + "1733466300": 0, + "1733466900": 0, + "1733467500": 0, + "1733468100": 0, + "1733468700": 0, + "1733469300": 0, + "1733469900": 0, + "1733470500": 0, + "1733471100": 0, + "1733471700": 0, + "1733472300": 0, + "1733472900": 0, + "1733473500": 0, + "1733474100": 0, + "1733474700": 0, + "1733475300": 0, + "1733475900": 0, + "1733476500": 0, + "1733477100": 0 + }, + "ads_over_time": { + "1733391300": 0, + "1733391900": 0, + "1733392500": 0, + "1733393100": 0, + "1733393700": 0, + "1733394300": 0, + "1733394900": 0, + "1733395500": 0, + "1733396100": 0, + "1733396700": 0, + "1733397300": 0, + "1733397900": 0, + "1733398500": 0, + "1733399100": 0, + "1733399700": 0, + "1733400300": 0, + "1733400900": 1, + "1733401500": 0, + "1733402100": 0, + "1733402700": 0, + "1733403300": 0, + "1733403900": 0, + "1733404500": 0, + "1733405100": 0, + "1733405700": 0, + "1733406300": 0, + "1733406900": 0, + "1733407500": 0, + "1733408100": 0, + "1733408700": 0, + "1733409300": 0, + "1733409900": 0, + "1733410500": 0, + "1733411100": 0, + "1733411700": 0, + "1733412300": 0, + "1733412900": 0, + "1733413500": 0, + "1733414100": 0, + "1733414700": 0, + "1733415300": 0, + "1733415900": 0, + "1733416500": 0, + "1733417100": 0, + "1733417700": 0, + "1733418300": 0, + "1733418900": 0, + "1733419500": 0, + "1733420100": 0, + "1733420700": 0, + "1733421300": 0, + "1733421900": 0, + "1733422500": 0, + "1733423100": 0, + "1733423700": 0, + "1733424300": 0, + "1733424900": 0, + "1733425500": 0, + "1733426100": 0, + "1733426700": 0, + "1733427300": 0, + "1733427900": 0, + "1733428500": 0, + "1733429100": 0, + "1733429700": 0, + "1733430300": 0, + "1733430900": 0, + "1733431500": 0, + "1733432100": 0, + "1733432700": 0, + "1733433300": 0, + "1733433900": 0, + "1733434500": 0, + "1733435100": 0, + "1733435700": 0, + "1733436300": 0, + "1733436900": 0, + "1733437500": 0, + "1733438100": 0, + "1733438700": 0, + "1733439300": 0, + "1733439900": 0, + "1733440500": 0, + "1733441100": 0, + "1733441700": 0, + "1733442300": 0, + "1733442900": 0, + "1733443500": 0, + "1733444100": 0, + "1733444700": 0, + "1733445300": 0, + "1733445900": 0, + "1733446500": 0, + "1733447100": 0, + "1733447700": 0, + "1733448300": 0, + "1733448900": 0, + "1733449500": 0, + "1733450100": 0, + "1733450700": 0, + "1733451300": 0, + "1733451900": 0, + "1733452500": 0, + "1733453100": 0, + "1733453700": 0, + "1733454300": 0, + "1733454900": 0, + "1733455500": 0, + "1733456100": 0, + "1733456700": 0, + "1733457300": 0, + "1733457900": 0, + "1733458500": 0, + "1733459100": 0, + "1733459700": 0, + "1733460300": 0, + "1733460900": 0, + "1733461500": 0, + "1733462100": 0, + "1733462700": 0, + "1733463300": 0, + "1733463900": 0, + "1733464500": 0, + "1733465100": 0, + "1733465700": 0, + "1733466300": 0, + "1733466900": 0, + "1733467500": 0, + "1733468100": 0, + "1733468700": 0, + "1733469300": 0, + "1733469900": 0, + "1733470500": 0, + "1733471100": 0, + "1733471700": 0, + "1733472300": 0, + "1733472900": 0, + "1733473500": 0, + "1733474100": 0, + "1733474700": 0, + "1733475300": 0, + "1733475900": 0, + "1733476500": 0, + "1733477100": 0 + }, + "clients": [ + {"name": "", "ip": "172.26.0.1"}, + {"name": "localhost", "ip": "127.0.0.1"} + ], + "over_time": { + "1733391300": [0, 0], + "1733391900": [0, 0], + "1733392500": [0, 0], + "1733393100": [0, 0], + "1733393700": [0, 0], + "1733394300": [0, 0], + "1733394900": [0, 0], + "1733395500": [0, 0], + "1733396100": [0, 0], + "1733396700": [3, 0], + "1733397300": [0, 0], + "1733397900": [0, 0], + "1733398500": [0, 0], + "1733399100": [0, 0], + "1733399700": [0, 0], + "1733400300": [0, 2], + "1733400900": [7, 0], + "1733401500": [0, 0], + "1733402100": [0, 0], + "1733402700": [0, 0], + "1733403300": [0, 0], + "1733403900": [0, 2], + "1733404500": [0, 0], + "1733405100": [0, 0], + "1733405700": [0, 0], + "1733406300": [0, 0], + "1733406900": [0, 0], + "1733407500": [0, 2], + "1733408100": [0, 0], + "1733408700": [0, 0], + "1733409300": [0, 0], + "1733409900": [0, 0], + "1733410500": [0, 0], + "1733411100": [0, 0], + "1733411700": [0, 0], + "1733412300": [0, 0], + "1733412900": [0, 0], + "1733413500": [0, 0], + "1733414100": [0, 0], + "1733414700": [0, 0], + "1733415300": [0, 0], + "1733415900": [0, 0], + "1733416500": [0, 0], + "1733417100": [0, 0], + "1733417700": [0, 0], + "1733418300": [0, 0], + "1733418900": [0, 0], + "1733419500": [0, 0], + "1733420100": [0, 0], + "1733420700": [0, 0], + "1733421300": [0, 0], + "1733421900": [0, 0], + "1733422500": [0, 0], + "1733423100": [0, 0], + "1733423700": [0, 0], + "1733424300": [0, 0], + "1733424900": [0, 0], + "1733425500": [0, 0], + "1733426100": [0, 0], + "1733426700": [0, 0], + "1733427300": [0, 0], + "1733427900": [0, 0], + "1733428500": [0, 0], + "1733429100": [0, 0], + "1733429700": [0, 0], + "1733430300": [0, 0], + "1733430900": [0, 0], + "1733431500": [0, 0], + "1733432100": [0, 0], + "1733432700": [0, 0], + "1733433300": [0, 0], + "1733433900": [0, 0], + "1733434500": [0, 0], + "1733435100": [0, 0], + "1733435700": [0, 0], + "1733436300": [0, 0], + "1733436900": [0, 0], + "1733437500": [0, 0], + "1733438100": [0, 0], + "1733438700": [0, 0], + "1733439300": [0, 0], + "1733439900": [0, 0], + "1733440500": [0, 0], + "1733441100": [0, 0], + "1733441700": [0, 0], + "1733442300": [0, 0], + "1733442900": [0, 0], + "1733443500": [0, 0], + "1733444100": [0, 0], + "1733444700": [0, 0], + "1733445300": [0, 0], + "1733445900": [0, 0], + "1733446500": [0, 0], + "1733447100": [0, 0], + "1733447700": [0, 0], + "1733448300": [0, 0], + "1733448900": [0, 0], + "1733449500": [0, 0], + "1733450100": [0, 0], + "1733450700": [0, 0], + "1733451300": [0, 0], + "1733451900": [0, 0], + "1733452500": [0, 0], + "1733453100": [0, 0], + "1733453700": [0, 0], + "1733454300": [0, 0], + "1733454900": [0, 0], + "1733455500": [0, 0], + "1733456100": [0, 0], + "1733456700": [0, 0], + "1733457300": [0, 0], + "1733457900": [0, 0], + "1733458500": [0, 0], + "1733459100": [0, 0], + "1733459700": [0, 0], + "1733460300": [0, 0], + "1733460900": [0, 0], + "1733461500": [0, 0], + "1733462100": [0, 0], + "1733462700": [0, 0], + "1733463300": [0, 0], + "1733463900": [0, 0], + "1733464500": [0, 0], + "1733465100": [0, 0], + "1733465700": [0, 0], + "1733466300": [0, 0], + "1733466900": [0, 0], + "1733467500": [0, 0], + "1733468100": [0, 0], + "1733468700": [0, 0], + "1733469300": [0, 0], + "1733469900": [0, 0], + "1733470500": [0, 0], + "1733471100": [0, 0], + "1733471700": [0, 0], + "1733472300": [0, 0], + "1733472900": [0, 0], + "1733473500": [0, 0], + "1733474100": [0, 0], + "1733474700": [0, 0], + "1733475300": [0, 0], + "1733475900": [0, 0], + "1733476500": [0, 0], + "1733477100": [0, 0] + } + }; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway.fetchOverTimeData(); + + expect(response.result, APiResponseType.success); + expect(response.data, isNotNull); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.fetchOverTimeData(); + + expect(response.result, APiResponseType.error); + expect(response.data, isNull); + }); + }); + + group('fetchLogs', () { + late Server server; + final url = + 'http://example.com/admin/api.php?auth=xxx123&getAllQueries&from=1733472267&until=1733479467'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = { + "data": [ + [ + "1733479389", + "A", + "google.com", + "172.26.0.1", + "2", + "0", + "4", + "324", + "N/A", + "-1", + "dns.google#53", + "" + ], + [ + "1733479462", + "A", + "google.co.jp", + "172.26.0.1", + "2", + "0", + "4", + "742", + "N/A", + "-1", + "dns.google#53", + "" + ] + ] + }; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final from = DateTime.fromMillisecondsSinceEpoch(1733472267 * 1000); + final until = DateTime.fromMillisecondsSinceEpoch(1733479467 * 1000); + final response = await apiGateway.fetchLogs(from, until); + + expect(response.result, APiResponseType.success); + expect(response.data, isNotNull); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final from = DateTime.fromMillisecondsSinceEpoch(1733472267 * 1000); + final until = DateTime.fromMillisecondsSinceEpoch(1733479467 * 1000); + final response = await apiGateway.fetchLogs(from, until); + + expect(response.result, APiResponseType.error); + expect(response.data, isNull); + }); + }); + + group('setWhiteBlacklist', () { + late Server server; + final url = + 'http://example.com/admin/api.php?auth=xxx123&list=black&add=google.com'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success when add new domain', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = {"success": true, "message": "Added google.com"}; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = + await apiGateway.setWhiteBlacklist('google.com', 'black'); + + expect(response.result, APiResponseType.success); + expect(response.data!.toJson(), data); + expect(response.message, isNull); + }); + + test('Return success when add exist domain', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = { + "success": true, + "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)); + + final response = + await apiGateway.setWhiteBlacklist('google.com', 'black'); + + expect(response.result, APiResponseType.success); + expect(response.data!.toJson(), data); + expect(response.message, isNull); + }); + + test('Return error with invalid list type', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = + 'Invalid list [supported: black, regex_black, white, regex_white]'; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = + await apiGateway.setWhiteBlacklist('google.com', 'black'); + + expect(response.result, APiResponseType.error); + expect(response.data, isNull); + expect(response.message, isNull); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = + await apiGateway.setWhiteBlacklist('google.com', 'black'); + + expect(response.result, APiResponseType.error); + expect(response.data, isNull); + expect(response.message, isNull); + }); + }); + + group('getDomainLists', () { + late Server server; + final urls = [ + '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' + ]; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = [ + { + "data": [ + { + "id": 14, + "type": 0, + "domain": "example.com", + "enabled": 1, + "date_added": 1733559182, + "date_modified": 1733559182, + "comment": "", + "groups": [0] + } + ] + }, + {"data": []}, + { + "data": [ + { + "id": 2, + "type": 1, + "domain": "test.com", + "enabled": 1, + "date_added": 1733401118, + "date_modified": 1733496612, + "comment": "", + "groups": [0] + } + ] + }, + {"data": []}, + ]; + for (var i = 0; i < urls.length; i++) { + when(mockClient.get(Uri.parse(urls[i]), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data[i]), 200)); + } + + final response = await apiGateway.getDomainLists(); + + expect(response.result, APiResponseType.success); + expect(response.data, isNotNull); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = {"data": []}; + for (var i = 0; i < urls.length - 1; i++) { + when(mockClient.get(Uri.parse(urls[i]), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + } + when(mockClient.get(Uri.parse(urls[3]), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.getDomainLists(); + + expect(response.result, APiResponseType.error); + expect(response.data, isNull); + }); + }); + + group('removeDomainFromList', () { + late Server server; + final url = + 'http://example.com/admin/api.php?auth=xxx123&list=white&sub=google.com'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = {"success": true, "message": null}; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway.removeDomainFromList(Domain( + id: 1, + domain: 'google.com', + type: 0, + enabled: 1, + dateAdded: DateTime.now(), + dateModified: DateTime.now(), + comment: '', + groups: [])); + + expect(response.result, APiResponseType.success); + expect(response.message, isNull); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway.removeDomainFromList(Domain( + id: 1, + domain: 'google.com', + type: 0, + enabled: 1, + dateAdded: DateTime.now(), + dateModified: DateTime.now(), + comment: '', + groups: [])); + + expect(response.result, APiResponseType.error); + expect(response.message, isNull); + }); + }); + + group('addDomainToList', () { + late Server server; + final url = + 'http://example.com/admin/api.php?auth=xxx123&list=black&add=google.com'; + + setUp(() { + server = Server( + address: 'http://example.com', + alias: 'example', + defaultServer: true, + apiVersion: SupportedApiVersions.v5, + token: 'xxx123'); + }); + + test('Return success when add new domain', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = {"success": true, "message": "Added google.com"}; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway + .addDomainToList({"list": "black", "domain": "google.com"}); + + expect(response.result, APiResponseType.success); + }); + + test('Return success when add exist domain', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = { + "success": true, + "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)); + + final response = await apiGateway + .addDomainToList({"list": "black", "domain": "google.com"}); + + expect(response.result, APiResponseType.alreadyAdded); + }); + + test('Return error with invalid list type', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + final data = + 'Invalid list [supported: black, regex_black, white, regex_white]'; + when(mockClient.get(Uri.parse(url), headers: {})) + .thenAnswer((_) async => http.Response(jsonEncode(data), 200)); + + final response = await apiGateway + .addDomainToList({"list": "black", "domain": "google.com"}); + + expect(response.result, APiResponseType.error); + }); + + test('Return error when unexpected exception occurs', () async { + final mockClient = MockClient(); + final apiGateway = ApiGatewayV5(server, client: mockClient); + + when(mockClient.get(Uri.parse(url), headers: {})) + .thenThrow(Exception('Unexpected error test')); + + final response = await apiGateway + .addDomainToList({"list": "black", "domain": "google.com"}); + + expect(response.result, APiResponseType.error); + }); + }); +} diff --git a/test/gateways/v5/api_gateway_v5_test.mocks.dart b/test/gateways/v5/api_gateway_v5_test.mocks.dart new file mode 100644 index 00000000..530cbd8f --- /dev/null +++ b/test/gateways/v5/api_gateway_v5_test.mocks.dart @@ -0,0 +1,282 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in pi_hole_client/test/gateways/v5/api_gateway_v5_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i6; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// 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 _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future read( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future); + + @override + _i3.Future<_i6.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) as _i3.Future<_i6.Uint8List>); + + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method( + #send, + [request], + ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), + ) as _i3.Future<_i2.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +}