diff --git a/packages/better_networking/better_networking_example/pubspec.lock b/packages/better_networking/better_networking_example/pubspec.lock index 8e46ff9e..79fa58be 100644 --- a/packages/better_networking/better_networking_example/pubspec.lock +++ b/packages/better_networking/better_networking_example/pubspec.lock @@ -150,18 +150,18 @@ packages: dependency: transitive description: name: flutter_web_auth_2 - sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0-alpha.3" flutter_web_auth_2_platform_interface: dependency: transitive description: name: flutter_web_auth_2_platform_interface - sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0-alpha.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/packages/better_networking/lib/services/oauth_callback_server.dart b/packages/better_networking/lib/services/oauth_callback_server.dart new file mode 100644 index 00000000..73bb884f --- /dev/null +++ b/packages/better_networking/lib/services/oauth_callback_server.dart @@ -0,0 +1,498 @@ +import 'dart:io'; +import 'dart:async'; +import 'dart:developer' show log; + +/// A lightweight HTTP server for handling OAuth callbacks on desktop platforms. +/// This provides a standard localhost callback URL that's compatible with most OAuth providers. +class OAuthCallbackServer { + HttpServer? _server; + late int _port; + String? _path; + final Completer _completer = Completer(); + Timer? _timeoutTimer; + Timer? _heartbeatTimer; + DateTime? _lastHeartbeat; + bool _browserTabActive = false; + + /// Starts the HTTP server and returns the callback URL. + /// + /// [path] - Optional path for the callback endpoint (defaults to '/callback') + /// Returns the full callback URL (e.g., 'http://localhost:8080/callback') + Future start({String path = '/callback'}) async { + _path = path; + + // Try to bind to a random available port starting from 8080 + for (int port = 8080; port <= 8090; port++) { + try { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + _port = port; + break; + } catch (e) { + // Port is busy, try next one + if (port == 8090) { + throw Exception( + 'Unable to find available port for OAuth callback server', + ); + } + } + } + + if (_server == null) { + throw Exception('Failed to start OAuth callback server'); + } + + _server!.listen(_handleRequest); + + final callbackUrl = 'http://localhost:$_port$_path'; + log('OAuth callback server started at: $callbackUrl'); + + return callbackUrl; + } + + /// Waits for the OAuth callback and returns the full callback URL with query parameters. + /// + /// [timeout] - Optional timeout duration (defaults to 3 minutes) + /// Throws [TimeoutException] if no callback is received within the timeout period. + /// Throws [Exception] if the browser tab is closed without completing authorization. + Future waitForCallback({ + Duration timeout = const Duration(minutes: 3), + }) async { + // Set up timeout timer + _timeoutTimer = Timer(timeout, () { + if (!_completer.isCompleted) { + _completer.completeError( + TimeoutException( + 'OAuth callback timeout: No response received within ${timeout.inMinutes} minutes. ' + 'The user may have closed the browser tab without completing authorization.', + timeout, + ), + ); + + // Automatically stop the server on timeout + _stopServerOnError( + 'OAuth flow timed out after ${timeout.inMinutes} minutes', + ); + } + }); + + // Set up heartbeat monitoring to detect if browser tab is closed + _startHeartbeatMonitoring(); + + try { + return await _completer.future; + } finally { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _stopHeartbeatMonitoring(); + } + } + + /// Stops the HTTP server and cleans up resources. + Future stop() async { + if (_server == null) { + log('OAuth callback server already stopped'); + return; + } + + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _stopHeartbeatMonitoring(); + + try { + await _server?.close(); + log('OAuth callback server stopped gracefully'); + } catch (e) { + log('Error during graceful server stop: $e'); + } finally { + _server = null; + } + } + + /// Cancels the waiting callback operation. + /// This is useful when the user wants to cancel the OAuth flow manually. + void cancel([String reason = 'OAuth flow cancelled by user']) { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _stopHeartbeatMonitoring(); + + if (!_completer.isCompleted) { + _completer.completeError(Exception('OAuth callback cancelled: $reason')); + } + + // Automatically stop the server when cancelled + _stopServerOnError(reason); + } + + /// Starts heartbeat monitoring to detect browser tab closure + void _startHeartbeatMonitoring() { + _lastHeartbeat = DateTime.now(); + _browserTabActive = true; + + // Check for heartbeat every 5 seconds + _heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + final now = DateTime.now(); + + // If no heartbeat received for 5 seconds and we had an active tab before + if (_browserTabActive && + _lastHeartbeat != null && + now.difference(_lastHeartbeat!).inSeconds > 5) { + log( + 'Browser tab appears to be closed (no heartbeat for ${now.difference(_lastHeartbeat!).inSeconds}s)', + ); + + if (!_completer.isCompleted) { + _completer.completeError( + Exception( + 'OAuth authorization cancelled: Browser tab was closed without completing the authorization process. ' + 'Please try again and complete the authorization in your browser.', + ), + ); + } + timer.cancel(); + + // Automatically stop the server when browser tab is closed + _stopServerOnError( + 'Browser tab closed without completing authorization', + ); + } + }); + } + + /// Stops heartbeat monitoring + void _stopHeartbeatMonitoring() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + _lastHeartbeat = null; + _browserTabActive = false; + } + + /// Stops the server immediately due to an error condition + /// This is used for automatic cleanup when errors occur + void _stopServerOnError(String reason) { + if (_server == null) { + log('OAuth callback server already stopped, skipping error stop'); + return; + } + + log('Stopping OAuth callback server due to error: $reason'); + + // Cancel any active timers + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _stopHeartbeatMonitoring(); + + // Close the server without waiting + _server + ?.close(force: true) + .then((_) { + log('OAuth callback server forcefully stopped due to error'); + }) + .catchError((error) { + log('Error while force-stopping server: $error'); + }); + + _server = null; + } + + void _handleRequest(HttpRequest request) { + log('OAuth request received: ${request.uri}'); + + try { + // Handle heartbeat requests + if (request.uri.path == '/heartbeat') { + _lastHeartbeat = DateTime.now(); + _browserTabActive = true; + request.response + ..statusCode = HttpStatus.ok + ..headers.add('Access-Control-Allow-Origin', '*') + ..headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + ..headers.add('Access-Control-Allow-Headers', 'Content-Type') + ..write('ok') + ..close(); + return; + } + + // Handle OPTIONS preflight requests for CORS + if (request.method == 'OPTIONS') { + request.response + ..statusCode = HttpStatus.ok + ..headers.add('Access-Control-Allow-Origin', '*') + ..headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + ..headers.add('Access-Control-Allow-Headers', 'Content-Type') + ..close(); + return; + } + + // Check if this is an authorization callback (has 'code' or 'error' parameters) + final uri = request.uri; + final hasCode = uri.queryParameters.containsKey('code'); + final hasError = uri.queryParameters.containsKey('error'); + + String responseHtml; + + if (hasError) { + final error = uri.queryParameters['error'] ?? 'unknown_error'; + final errorDescription = + uri.queryParameters['error_description'] ?? + 'No description provided'; + responseHtml = _generateErrorHtml(error, errorDescription); + + // Complete the future with error and stop server + if (!_completer.isCompleted) { + _completer.completeError( + Exception('OAuth authorization failed: $error - $errorDescription'), + ); + } + + // Send response to browser first, then stop server after a delay + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..write(responseHtml) + ..close(); + + // Stop server after sending response (with a small delay) + Timer(const Duration(seconds: 1), () { + _stopServerOnError('OAuth authorization error: $error'); + }); + + return; + } else if (hasCode) { + responseHtml = _generateSuccessHtml(); + } else { + responseHtml = _generateInfoHtml(); + } + + // Send response to the browser + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..write(responseHtml) + ..close(); + + // Complete the future with the full callback URL + if (!_completer.isCompleted) { + _completer.complete(request.uri.toString()); + } + + // For successful authorization, schedule server stop after response + if (hasCode) { + Timer(const Duration(seconds: 6), () { + _stopServerOnError('OAuth flow completed successfully'); + }); + } + } catch (e) { + log('Error handling OAuth callback request: $e'); + + // Complete with error if not already completed + if (!_completer.isCompleted) { + _completer.completeError( + Exception('OAuth callback request handling failed: $e'), + ); + } + + // Try to send an error response + try { + request.response + ..statusCode = HttpStatus.internalServerError + ..write('Internal server error occurred during OAuth callback') + ..close(); + } catch (responseError) { + log('Failed to send error response: $responseError'); + } + + // Stop server due to error + _stopServerOnError('Request handling error: $e'); + } + } + + String _generateSuccessHtml() { + return ''' + + + + OAuth Authentication Successful + + + +
+
+
Authentication Successful!
+
+ Your OAuth authorization was completed successfully. + You can now close this window and return to API Dash. +
+
This window will close automatically in 5 seconds...
+
+ + + + '''; + } + + String _generateErrorHtml(String error, String errorDescription) { + return ''' + + + + OAuth Authentication Failed + + + +
+
+
Authentication Failed
+
+ The OAuth authorization was not completed successfully. + Please try again from API Dash. +
+
+ Error: $error
+ Description: $errorDescription +
+
This window will close automatically in 10 seconds...
+
+ + + + '''; + } + + String _generateInfoHtml() { + return ''' + + + + OAuth Callback Server + + + +
+
+
OAuth Callback Server
+
+ This is the OAuth callback endpoint for API Dash. + If you're seeing this page, the callback server is running correctly. + Please return to API Dash to complete the OAuth flow. +
+
+ + + + '''; + } +} diff --git a/packages/better_networking/lib/services/services.dart b/packages/better_networking/lib/services/services.dart index d155e9c7..6ae0ee19 100644 --- a/packages/better_networking/lib/services/services.dart +++ b/packages/better_networking/lib/services/services.dart @@ -1,2 +1,3 @@ export 'http_client_manager.dart'; export 'http_service.dart'; +export 'oauth_callback_server.dart'; diff --git a/packages/better_networking/lib/utils/auth/handle_auth.dart b/packages/better_networking/lib/utils/auth/handle_auth.dart index 63cc802e..bea9d765 100644 --- a/packages/better_networking/lib/utils/auth/handle_auth.dart +++ b/packages/better_networking/lib/utils/auth/handle_auth.dart @@ -192,7 +192,8 @@ Future handleAuth( switch (oauth2.grantType) { case OAuth2GrantType.authorizationCode: - final res = await oAuth2AuthorizationCodeGrantHandler( + // Use localhost callback server for desktop platforms, fallback to custom scheme for mobile + final res = await oAuth2AuthorizationCodeGrant( identifier: oauth2.clientId, secret: oauth2.clientSecret, authorizationEndpoint: Uri.parse(oauth2.authorizationUrl), @@ -205,13 +206,27 @@ Future handleAuth( credentialsFile: credentialsFile, scope: oauth2.scope, ); - debugPrint(res.credentials.accessToken); + + // Clean up the callback server if it exists and is still running + // Note: The server might have already stopped itself due to timeout/error/completion + final server = res.$2; + if (server != null) { + try { + await server.stop(); + } catch (e) { + debugPrint( + 'Error stopping OAuth callback server (might already be stopped): $e', + ); + } + } + + debugPrint(res.$1.credentials.accessToken); // Add the access token to the request headers updatedHeaders.add( NameValueModel( name: 'Authorization', - value: 'Bearer ${res.credentials.accessToken}', + value: 'Bearer ${res.$1.credentials.accessToken}', ), ); updatedHeaderEnabledList.add(true); diff --git a/packages/better_networking/lib/utils/auth/oauth2_utils.dart b/packages/better_networking/lib/utils/auth/oauth2_utils.dart index 3516e9b9..9ecfb28a 100644 --- a/packages/better_networking/lib/utils/auth/oauth2_utils.dart +++ b/packages/better_networking/lib/utils/auth/oauth2_utils.dart @@ -4,10 +4,17 @@ import 'dart:io'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:oauth2/oauth2.dart' as oauth2; -import '../../models/models.dart'; +import '../../models/auth/auth_oauth2_model.dart'; import '../../services/http_client_manager.dart'; +import '../../services/oauth_callback_server.dart'; +import '../platform_utils.dart'; -Future oAuth2AuthorizationCodeGrantHandler({ +/// Advanced OAuth2 authorization code grant handler that returns both the client and server +/// for cases where you need manual control over the callback server lifecycle. +/// +/// Returns a tuple of (oauth2.Client, OAuthCallbackServer?) where the server is null for mobile platforms. +/// The server should be stopped by the caller to clean up resources. +Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ required String identifier, required String secret, required Uri authorizationEndpoint, @@ -17,19 +24,17 @@ Future oAuth2AuthorizationCodeGrantHandler({ String? state, String? scope, }) async { + // Check for existing valid credentials first if (await credentialsFile.exists()) { try { final json = await credentialsFile.readAsString(); - final credentials = oauth2.Credentials.fromJson(json); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { log('Using existing valid credentials'); - - return oauth2.Client( - credentials, - identifier: identifier, - secret: secret, + return ( + oauth2.Client(credentials, identifier: identifier, secret: secret), + null, ); } } catch (e) { @@ -42,50 +47,120 @@ Future oAuth2AuthorizationCodeGrantHandler({ final httpClientManager = HttpClientManager(); final baseClient = httpClientManager.createClientWithJsonAccept(requestId); - final grant = oauth2.AuthorizationCodeGrant( - identifier, - authorizationEndpoint, - tokenEndpoint, - secret: secret, - httpClient: baseClient, - ); - - final authorizationUrl = grant.getAuthorizationUrl( - redirectUrl, - scopes: scope != null ? [scope] : null, - state: state, - ); - log('Generated authorization URL: ${authorizationUrl.toString()}'); - log('Expected redirect URL: ${redirectUrl.toString()}'); - - final uri = await FlutterWebAuth2.authenticate( - url: authorizationUrl.toString(), - callbackUrlScheme: redirectUrl.scheme, - options: const FlutterWebAuth2Options(useWebview: true), - ); + OAuthCallbackServer? callbackServer; + Uri actualRedirectUrl = redirectUrl; try { - // Use standard oauth2 package for other providers + // Use localhost callback server for desktop platforms + if (PlatformUtils.shouldUseLocalhostCallback) { + callbackServer = OAuthCallbackServer(); + final localhostUrl = await callbackServer.start(); + actualRedirectUrl = Uri.parse(localhostUrl); + log('Using localhost callback server: $localhostUrl'); + } else { + log('Using custom scheme callback: ${redirectUrl.toString()}'); + } + + final grant = oauth2.AuthorizationCodeGrant( + identifier, + authorizationEndpoint, + tokenEndpoint, + secret: secret, + httpClient: baseClient, + ); + + final authorizationUrl = grant.getAuthorizationUrl( + actualRedirectUrl, + scopes: scope != null ? [scope] : null, + state: state, + ); + + log('Generated authorization URL: ${authorizationUrl.toString()}'); + log('Expected redirect URL: ${actualRedirectUrl.toString()}'); + + String callbackUri; + + if (PlatformUtils.shouldUseLocalhostCallback && callbackServer != null) { + // For desktop: Open the authorization URL in the default browser + // and wait for the callback on the localhost server with a 3-minute timeout + await _openUrlInBrowser(authorizationUrl.toString()); + + try { + callbackUri = await callbackServer.waitForCallback( + timeout: const Duration(minutes: 3), + ); + // Convert the relative callback to full URL + callbackUri = + 'http://localhost${Uri.parse(callbackUri).path}${Uri.parse(callbackUri).query.isNotEmpty ? '?${Uri.parse(callbackUri).query}' : ''}'; + } on TimeoutException catch (e) { + log('OAuth callback timeout: ${e.message}'); + throw Exception( + 'OAuth authorization timed out after 3 minutes. ' + 'Please try again and complete the authorization in your browser. ' + 'If you closed the browser tab, please restart the OAuth flow.', + ); + } catch (e) { + // Handle custom exceptions like browser tab closure + final errorMessage = e.toString(); + if (errorMessage.contains('Browser tab was closed')) { + log('OAuth authorization cancelled: Browser tab closed'); + throw Exception( + 'OAuth authorization was cancelled because the browser tab was closed. ' + 'Please try again and complete the authorization process without closing the browser tab.', + ); + } else if (errorMessage.contains('OAuth callback cancelled')) { + log('OAuth authorization cancelled by user'); + throw Exception( + 'OAuth authorization was cancelled. Please try again if you want to complete the authentication.', + ); + } else { + log('OAuth callback error: $errorMessage'); + throw Exception('OAuth authorization failed: $errorMessage'); + } + } + } else { + // For mobile: Use the standard flutter_web_auth_2 approach + callbackUri = await FlutterWebAuth2.authenticate( + url: authorizationUrl.toString(), + callbackUrlScheme: actualRedirectUrl.scheme, + options: const FlutterWebAuth2Options( + useWebview: true, + windowName: 'OAuth Authorization - API Dash', + ), + ); + } + + log('Received callback URI: $callbackUri'); + + // Parse the callback URI and handle the authorization response + final callbackUriParsed = Uri.parse(callbackUri); final client = await grant.handleAuthorizationResponse( - Uri.parse(uri).queryParameters, + callbackUriParsed.queryParameters, ); log('OAuth2 authorization successful, saving credentials'); await credentialsFile.writeAsString(client.credentials.toJson()); log(client.credentials.toJson()); - // Clean up the HTTP client - httpClientManager.closeClient(requestId); - - return client; + return (client, callbackServer); } catch (e) { - log('Error handling authorization response: $e'); - log('URI query parameters: ${Uri.parse(uri).queryParameters}'); - - // Clean up the HTTP client on error - httpClientManager.closeClient(requestId); - + log('Error during OAuth2 flow: $e'); + // Clean up the callback server immediately on error + if (callbackServer != null) { + try { + await callbackServer.stop(); + log('Callback server stopped due to OAuth2 flow error'); + } catch (serverError) { + log( + 'Error stopping callback server during error cleanup: $serverError', + ); + } + } + // Re-throw the original error rethrow; + } finally { + // Clean up HTTP client + httpClientManager.closeClient(requestId); } } @@ -222,3 +297,32 @@ Future oAuth2ResourceOwnerPasswordGrantHandler({ rethrow; } } + +/// Opens a URL in the default system browser. +/// This is used for desktop platforms where we want to open the OAuth authorization URL +/// in the user's default browser and use localhost callback server to capture the response. +Future _openUrlInBrowser(String url) async { + try { + if (PlatformUtils.isDesktop) { + Process? process; + if (Platform.isMacOS) { + process = await Process.start('open', [url]); + } else if (Platform.isWindows) { + process = await Process.start('rundll32', [ + 'url.dll,FileProtocolHandler', + url, + ]); + } else if (Platform.isLinux) { + process = await Process.start('xdg-open', [url]); + } + + if (process != null) { + await process.exitCode; // Wait for the process to complete + } + } + } catch (e) { + log('Error opening URL in browser: $e'); + // Fallback: throw an exception so the calling code can handle it + throw Exception('Failed to open authorization URL in browser: $e'); + } +} diff --git a/packages/better_networking/lib/utils/platform_utils.dart b/packages/better_networking/lib/utils/platform_utils.dart new file mode 100644 index 00000000..5a6650a7 --- /dev/null +++ b/packages/better_networking/lib/utils/platform_utils.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; + +/// Platform detection utilities for the better_networking package. +class PlatformUtils { + /// Returns true if running on desktop platforms (macOS, Windows, Linux). + static bool get isDesktop => + !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux); + + /// Returns true if running on mobile platforms (iOS, Android). + static bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + /// Returns true if running on web. + static bool get isWeb => kIsWeb; + + /// Returns true if OAuth should use localhost callback server. + /// This is true for desktop platforms. + static bool get shouldUseLocalhostCallback => isDesktop; +} diff --git a/packages/better_networking/lib/utils/utils.dart b/packages/better_networking/lib/utils/utils.dart index 7857ed81..ad25219f 100644 --- a/packages/better_networking/lib/utils/utils.dart +++ b/packages/better_networking/lib/utils/utils.dart @@ -2,6 +2,7 @@ export 'content_type_utils.dart'; export 'graphql_utils.dart'; export 'http_request_utils.dart'; export 'http_response_utils.dart'; +export 'platform_utils.dart'; export 'string_utils.dart' hide RandomStringGenerator; export 'uri_utils.dart'; export 'auth/handle_auth.dart'; diff --git a/packages/better_networking/pubspec.yaml b/packages/better_networking/pubspec.yaml index 7c95f848..9ad62859 100644 --- a/packages/better_networking/pubspec.yaml +++ b/packages/better_networking/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: xml: ^6.3.0 oauth1: ^2.1.0 oauth2: ^2.0.3 - flutter_web_auth_2: ^4.1.0 + flutter_web_auth_2: ^5.0.0-alpha.3 dev_dependencies: flutter_test: diff --git a/packages/better_networking/test/services/oauth_callback_server_test.dart b/packages/better_networking/test/services/oauth_callback_server_test.dart new file mode 100644 index 00000000..4ef2440c --- /dev/null +++ b/packages/better_networking/test/services/oauth_callback_server_test.dart @@ -0,0 +1,101 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:better_networking/services/oauth_callback_server.dart'; + +void main() { + group('OAuthCallbackServer', () { + late OAuthCallbackServer server; + + setUp(() { + server = OAuthCallbackServer(); + }); + + tearDown(() async { + await server.stop(); + }); + + test('should start server and return callback URL', () async { + final callbackUrl = await server.start(); + + expect(callbackUrl, startsWith('http://localhost:')); + expect(callbackUrl, endsWith('/callback')); + + // Verify the server is actually running by making a simple HTTP request + final client = HttpClient(); + final uri = Uri.parse(callbackUrl); + final request = await client.getUrl(uri); + final response = await request.close(); + + expect(response.statusCode, equals(HttpStatus.ok)); + client.close(); + }); + + test('should start server with custom path', () async { + final callbackUrl = await server.start(path: '/custom/oauth'); + + expect(callbackUrl, startsWith('http://localhost:')); + expect(callbackUrl, endsWith('/custom/oauth')); + }); + + test('should handle callback and return full URL', () async { + final callbackUrl = await server.start(); + + // Start waiting for callback in a separate isolate + final callbackFuture = server.waitForCallback(); + + // Simulate an OAuth callback with query parameters + final client = HttpClient(); + final uri = Uri.parse('$callbackUrl?code=test_code&state=test_state'); + final request = await client.getUrl(uri); + final response = await request.close(); + + expect(response.statusCode, equals(HttpStatus.ok)); + + // Verify we get the callback URL with parameters + final receivedCallback = await callbackFuture; + expect(receivedCallback, contains('code=test_code')); + expect(receivedCallback, contains('state=test_state')); + + client.close(); + }); + + test('should find available port when default is busy', () async { + // Start a server on port 8080 to make it busy + final busyServer = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 8080, + ); + + try { + final callbackUrl = await server.start(); + + // Should find a different port + expect(callbackUrl, startsWith('http://localhost:')); + expect(callbackUrl, isNot(contains(':8080'))); + } finally { + await busyServer.close(); + } + }); + + test('should throw exception when no ports available', () async { + // This test would be difficult to implement without actually occupying all ports, + // so we'll skip it for now. In a real scenario, you could mock the HttpServer.bind method. + }); + + test('should stop server cleanly', () async { + final callbackUrl = await server.start(); + await server.stop(); + + // Verify server is stopped by trying to connect + final client = HttpClient(); + final uri = Uri.parse(callbackUrl); + + expect(() async { + final request = await client.getUrl(uri); + await request.close(); + }, throwsA(isA())); + + client.close(); + }); + }); +} diff --git a/packages/better_networking/test/utils/platform_utils_test.dart b/packages/better_networking/test/utils/platform_utils_test.dart new file mode 100644 index 00000000..63f84038 --- /dev/null +++ b/packages/better_networking/test/utils/platform_utils_test.dart @@ -0,0 +1,34 @@ +import 'package:test/test.dart'; +import 'package:better_networking/utils/platform_utils.dart'; + +void main() { + group('PlatformUtils', () { + test('should detect platform types correctly', () { + // Note: These tests will behave differently based on the platform running the tests + // In a real CI environment, you might want to mock these or run platform-specific tests + + expect(PlatformUtils.isDesktop, isA()); + expect(PlatformUtils.isMobile, isA()); + expect(PlatformUtils.isWeb, isA()); + expect(PlatformUtils.shouldUseLocalhostCallback, isA()); + }); + + test('should have mutually exclusive platform types', () { + // At most one of these should be true (could be none if running on an unknown platform) + final platformCount = [ + PlatformUtils.isDesktop, + PlatformUtils.isMobile, + PlatformUtils.isWeb, + ].where((x) => x).length; + + expect(platformCount, lessThanOrEqualTo(1)); + }); + + test('shouldUseLocalhostCallback should match isDesktop', () { + expect( + PlatformUtils.shouldUseLocalhostCallback, + equals(PlatformUtils.isDesktop), + ); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 660db639..3b1ea421 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -672,18 +672,18 @@ packages: dependency: transitive description: name: flutter_web_auth_2 - sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0-alpha.3" flutter_web_auth_2_platform_interface: dependency: transitive description: name: flutter_web_auth_2_platform_interface - sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0-alpha.0" flutter_web_plugins: dependency: transitive description: flutter