diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6052f6804d..936cbd039e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,13 @@ + + + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index fb7040ed8f..f2686f9bd9 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -63,6 +63,14 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "errorWebAuthOperationalErrorTitle": "Something went wrong", + "@errorWebAuthOperationalErrorTitle": { + "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, + "errorWebAuthOperationalError": "An unexpected error occurred.", + "@errorWebAuthOperationalError": { + "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)." + }, "errorAccountLoggedInTitle": "Account already logged in", "@errorAccountLoggedInTitle": { "description": "Error title on attempting to log into an account that's already logged in." @@ -281,6 +289,17 @@ "@loginFormSubmitLabel": { "description": "Button text to submit login credentials." }, + "loginMethodDivider": "OR", + "@loginMethodDivider": { + "description": "Text on the divider between the username/password form and the third-party login options. Uppercase (for languages with letter case)." + }, + "signInWithFoo": "Sign in with {method}", + "@signInWithFoo": { + "description": "Button to use {method} to sign in to the app.", + "placeholders": { + "method": {"type": "String", "example": "Google"} + } + }, "loginAddAnAccountPageTitle": "Add an account", "@loginAddAnAccountPageTitle": { "description": "Page title for screen to add a Zulip account." diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8f08f0cdda..5f5d9c5ea2 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -22,8 +22,21 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + com.zulip.flutter + CFBundleURLSchemes + + zulip + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/lib/api/model/web_auth.dart b/lib/api/model/web_auth.dart new file mode 100644 index 0000000000..490c4b79db --- /dev/null +++ b/lib/api/model/web_auth.dart @@ -0,0 +1,87 @@ +import 'dart:math'; + +import 'package:convert/convert.dart'; +import 'package:flutter/foundation.dart'; + +/// The authentication information contained in the zulip:// redirect URL. +class WebAuthPayload { + final Uri realm; + final String email; + final int? userId; // TODO(server-5) new in FL 108 + final String otpEncryptedApiKey; + + WebAuthPayload._({ + required this.realm, + required this.email, + required this.userId, + required this.otpEncryptedApiKey, + }); + + factory WebAuthPayload.parse(Uri url) { + if ( + url case Uri( + scheme: 'zulip', + host: 'login', + queryParameters: { + 'realm': String realmStr, + 'email': String email, + // 'user_id' handled below + 'otp_encrypted_api_key': String otpEncryptedApiKey, + }, + ) + ) { + final Uri? realm = Uri.tryParse(realmStr); + if (realm == null) throw const FormatException(); + + // TODO(server-5) require in queryParameters (new in FL 108) + final userIdStr = url.queryParameters['user_id']; + int? userId; + if (userIdStr != null) { + userId = int.tryParse(userIdStr, radix: 10); + if (userId == null) throw const FormatException(); + } + + if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(otpEncryptedApiKey)) { + throw const FormatException(); + } + + return WebAuthPayload._( + otpEncryptedApiKey: otpEncryptedApiKey, + email: email, + userId: userId, + realm: realm, + ); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + String decodeApiKey(String otp) { + final otpBytes = hex.decode(otp); + final otpEncryptedApiKeyBytes = hex.decode(otpEncryptedApiKey); + if (otpBytes.length != otpEncryptedApiKeyBytes.length) { + throw const FormatException(); + } + return String.fromCharCodes(Iterable.generate(otpBytes.length, + (i) => otpBytes[i] ^ otpEncryptedApiKeyBytes[i])); + } +} + +String generateOtp() { + final rand = Random.secure(); + final Uint8List bytes = Uint8List.fromList( + List.generate(32, (_) => rand.nextInt(256))); + return hex.encode(bytes); +} + +/// For tests, create an OTP-encrypted API key. +@visibleForTesting +String debugEncodeApiKey(String apiKey, String otp) { + final apiKeyBytes = apiKey.codeUnits; + assert(apiKeyBytes.every((byte) => byte <= 0xff)); + final otpBytes = hex.decode(otp); + assert(apiKeyBytes.length == otpBytes.length); + return hex.encode(List.generate(otpBytes.length, + (i) => apiKeyBytes[i] ^ otpBytes[i])); +} diff --git a/lib/model/binding.dart b/lib/model/binding.dart index bb9a54ac11..31ed514973 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -75,6 +75,11 @@ abstract class ZulipBinding { /// to a [GlobalStore]. Future loadGlobalStore(); + /// Checks whether the platform can launch [url], via package:url_launcher. + /// + /// This wraps [url_launcher.canLaunchUrl]. + Future canLaunchUrl(Uri url); + /// Pass [url] to the underlying platform, via package:url_launcher. /// /// This wraps [url_launcher.launchUrl]. @@ -83,6 +88,16 @@ abstract class ZulipBinding { url_launcher.LaunchMode mode = url_launcher.LaunchMode.platformDefault, }); + /// Checks whether [closeInAppWebView] is supported, via package:url_launcher. + /// + /// This wraps [url_launcher.supportsCloseForLaunchMode]. + Future supportsCloseForLaunchMode(url_launcher.LaunchMode mode); + + /// Closes the current in-app web view, via package:url_launcher. + /// + /// This wraps [url_launcher.closeInAppWebView]. + Future closeInAppWebView(); + /// Provides device and operating system information, /// via package:device_info_plus. /// @@ -159,6 +174,9 @@ class LiveZulipBinding extends ZulipBinding { return LiveGlobalStore.load(); } + @override + Future canLaunchUrl(Uri url) => url_launcher.canLaunchUrl(url); + @override Future launchUrl( Uri url, { @@ -167,6 +185,16 @@ class LiveZulipBinding extends ZulipBinding { return url_launcher.launchUrl(url, mode: mode); } + @override + Future supportsCloseForLaunchMode(url_launcher.LaunchMode mode) async { + return url_launcher.supportsCloseForLaunchMode(mode); + } + + @override + Future closeInAppWebView() async { + return url_launcher.closeInAppWebView(); + } + @override Future deviceInfo() async { final deviceInfo = await device_info_plus.DeviceInfoPlugin().deviceInfo; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index e32d39dd10..dce9a1a64a 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -17,7 +17,7 @@ import 'store.dart'; import 'subscription_list.dart'; import 'text.dart'; -class ZulipApp extends StatelessWidget { +class ZulipApp extends StatefulWidget { const ZulipApp({super.key, this.navigatorObservers}); /// Whether the app's widget tree is ready. @@ -79,6 +79,34 @@ class ZulipApp extends StatelessWidget { _ready.value = true; } + @override + State createState() => _ZulipAppState(); +} + +class _ZulipAppState extends State with WidgetsBindingObserver { + @override + Future didPushRouteInformation(routeInformation) async { + if (routeInformation case RouteInformation( + uri: Uri(scheme: 'zulip', host: 'login') && var url) + ) { + await LoginPage.handleWebAuthUrl(url); + return true; + } + return super.didPushRouteInformation(routeInformation); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = ThemeData( @@ -123,12 +151,12 @@ class ZulipApp extends StatelessWidget { supportedLocales: ZulipLocalizations.supportedLocales, theme: theme, - navigatorKey: navigatorKey, - navigatorObservers: navigatorObservers ?? const [], + navigatorKey: ZulipApp.navigatorKey, + navigatorObservers: widget.navigatorObservers ?? const [], builder: (BuildContext context, Widget? child) { - if (!ready.value) { + if (!ZulipApp.ready.value) { SchedulerBinding.instance.addPostFrameCallback( - (_) => _declareReady()); + (_) => widget._declareReady()); } GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); return child!; diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index 2c316d08e1..ce70671d93 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -1,16 +1,23 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../api/exception.dart'; +import '../api/model/web_auth.dart'; import '../api/route/account.dart'; import '../api/route/realm.dart'; import '../api/route/users.dart'; +import '../log.dart'; +import '../model/binding.dart'; import '../model/store.dart'; import 'app.dart'; import 'dialog.dart'; import 'input.dart'; import 'page.dart'; import 'store.dart'; +import 'text.dart'; class _LoginSequenceRoute extends MaterialWidgetRoute { _LoginSequenceRoute({ @@ -176,9 +183,8 @@ class _AddAccountPageState extends State { return; } - // TODO(#36): support login methods beyond username/password Navigator.push(context, - PasswordLoginPage.buildRoute(serverSettings: serverSettings)); + LoginPage.buildRoute(serverSettings: serverSettings)); } finally { setState(() { _inProgress = false; @@ -235,23 +241,113 @@ class _AddAccountPageState extends State { } } -class PasswordLoginPage extends StatefulWidget { - const PasswordLoginPage({super.key, required this.serverSettings}); - - final GetServerSettingsResult serverSettings; +class LoginPage extends StatefulWidget { + const LoginPage({super.key, required this.serverSettings}); static Route buildRoute({required GetServerSettingsResult serverSettings}) { return _LoginSequenceRoute( - page: PasswordLoginPage(serverSettings: serverSettings)); + page: LoginPage(serverSettings: serverSettings, key: _lastBuiltKey)); + } + + final GetServerSettingsResult serverSettings; + + /// Log in using the payload of a web-auth URL like zulip://login?… + static Future handleWebAuthUrl(Uri url) async { + return _lastBuiltKey.currentState?.handleWebAuthUrl(url); } + /// A key for the page from the last [buildRoute] call. + static final _lastBuiltKey = GlobalKey<_LoginPageState>(); + + /// The OTP to use, instead of an app-generated one, for testing. + @visibleForTesting + static String? debugOtpOverride; + @override - State createState() => _PasswordLoginPageState(); + State createState() => _LoginPageState(); } -class _PasswordLoginPageState extends State { +class _LoginPageState extends State { bool _inProgress = false; + String? get _otp { + String? result; + assert(() { + result = LoginPage.debugOtpOverride; + return true; + }()); + return result ?? __otp; + } + String? __otp; + + Future handleWebAuthUrl(Uri url) async { + setState(() { + _inProgress = true; + }); + try { + await ZulipBinding.instance.closeInAppWebView(); + + if (_otp == null) throw Error(); + final payload = WebAuthPayload.parse(url); + if (payload.realm.origin != widget.serverSettings.realmUrl.origin) throw Error(); + final apiKey = payload.decodeApiKey(_otp!); + await _tryInsertAccountAndNavigate( + // TODO(server-5): Rely on userId from payload. + userId: payload.userId ?? await _getUserId(payload.email, apiKey), + email: payload.email, + apiKey: apiKey, + ); + } catch (e) { + assert(debugLog(e.toString())); + if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + // Could show different error messages for different failure modes. + await showErrorDialog(context: context, + title: zulipLocalizations.errorWebAuthOperationalErrorTitle, + message: zulipLocalizations.errorWebAuthOperationalError); + } finally { + setState(() { + _inProgress = false; + __otp = null; + }); + } + } + + Future _beginWebAuth(ExternalAuthenticationMethod method) async { + __otp = generateOtp(); + try { + final url = widget.serverSettings.realmUrl.resolve(method.loginUrl) + .replace(queryParameters: {'mobile_flow_otp': _otp!}); + + // Could set [_inProgress]… but we'd need to unset it if the web-auth + // attempt is aborted (by the user closing the browser, for example), + // and I don't think we can reliably know when that happens. + await ZulipBinding.instance.launchUrl(url, mode: LaunchMode.inAppBrowserView); + } catch (e) { + assert(debugLog(e.toString())); + + if (e is PlatformException + && defaultTargetPlatform == TargetPlatform.iOS + && e.message != null && e.message!.startsWith('Error while launching')) { + // Ignore; I've seen this on my iPhone even when auth succeeds. + // Specifically, Apple web auth…which on iOS should be replaced by + // Apple native auth; that's #462. + // Possibly related: + // https://github.com/flutter/flutter/issues/91660 + // but in that issue, people report authentication not succeeding. + // TODO(#462) remove this? + return; + } + + if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + // Could show different error messages for different failure modes. + await showErrorDialog(context: context, + title: zulipLocalizations.errorWebAuthOperationalErrorTitle, + message: zulipLocalizations.errorWebAuthOperationalError); + } + } + Future _tryInsertAccountAndNavigate({ required String email, required String apiKey, @@ -312,6 +408,26 @@ class _PasswordLoginPageState extends State { assert(!PerAccountStoreWidget.debugExistsOf(context)); final zulipLocalizations = ZulipLocalizations.of(context); + final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods; + + final loginForm = Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + _UsernamePasswordForm(loginPageState: this), + if (externalAuthenticationMethods.isNotEmpty) ...[ + const OrDivider(), + ...externalAuthenticationMethods.map((method) { + final icon = method.displayIcon; + return OutlinedButton.icon( + icon: icon != null + ? Image.network(icon, width: 24, height: 24) + : null, + onPressed: !_inProgress + ? () => _beginWebAuth(method) + : null, + label: Text(zulipLocalizations.signInWithFoo(method.displayName))); + }), + ], + ]); + return Scaffold( appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle), bottom: _inProgress @@ -319,18 +435,25 @@ class _PasswordLoginPageState extends State { child: LinearProgressIndicator(minHeight: 4)) // 4 restates default : null), body: SafeArea( - minimum: const EdgeInsets.all(8), + minimum: const EdgeInsets.symmetric(horizontal: 8), + bottom: false, child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: _UsernamePasswordForm(loginPageState: this))))); + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 8), + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 8), + // TODO also detect vertical scroll gestures that start on the + // left or the right of this box + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: loginForm)))))); } } class _UsernamePasswordForm extends StatefulWidget { const _UsernamePasswordForm({required this.loginPageState}); - final _PasswordLoginPageState loginPageState; + final _LoginPageState loginPageState; @override State<_UsernamePasswordForm> createState() => _UsernamePasswordFormState(); @@ -488,3 +611,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> { ]))); } } + +// Loosely based on the corresponding element in the web app. +class OrDivider extends StatelessWidget { + const OrDivider({super.key}); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + + const divider = Expanded( + child: Divider(color: Color(0xffdedede), thickness: 2)); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + divider, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: Text(zulipLocalizations.loginMethodDivider, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xff575757), + height: 1.5, + ).merge(weightVariableTextStyle(context, wght: 600)))), + divider, + ])); + } +} diff --git a/test/api/model/web_auth_test.dart b/test/api/model/web_auth_test.dart new file mode 100644 index 0000000000..f836b1f962 --- /dev/null +++ b/test/api/model/web_auth_test.dart @@ -0,0 +1,98 @@ +import 'package:checks/checks.dart'; +import 'package:convert/convert.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/web_auth.dart'; + +import '../../example_data.dart' as eg; + +void main() { + group('WebAuthPayload', () { + const otp = '186f6d085a5621ebaf1ccfc05033e8acba57dae03f061705ac1e58c402c30a31'; + final encryptedApiKey = debugEncodeApiKey(eg.selfAccount.apiKey, otp); + final wellFormed = Uri.parse( + 'zulip://login?otp_encrypted_api_key=$encryptedApiKey' + '&email=self%40example&user_id=1&realm=https%3A%2F%2Fchat.example%2F'); + + test('basic happy case', () { + final payload = WebAuthPayload.parse(wellFormed); + check(payload) + ..otpEncryptedApiKey.equals(encryptedApiKey) + ..email.equals('self@example') + ..userId.equals(1) + ..realm.equals(Uri.parse('https://chat.example/')); + check(payload.decodeApiKey(otp)).equals(eg.selfAccount.apiKey); + }); + + // TODO(server-5) remove + test('legacy: no userId', () { + final queryParams = {...wellFormed.queryParameters}..remove('user_id'); + final payload = WebAuthPayload.parse( + wellFormed.replace(queryParameters: queryParams)); + check(payload) + ..otpEncryptedApiKey.equals(encryptedApiKey) + ..email.equals('self@example') + ..userId.isNull() + ..realm.equals(Uri.parse('https://chat.example/')); + check(payload.decodeApiKey(otp)).equals(eg.selfAccount.apiKey); + }); + + test('parse fails when an expected field is missing', () { + final queryParams = {...wellFormed.queryParameters}..remove('email'); + final input = wellFormed.replace(queryParameters: queryParams); + check(() => WebAuthPayload.parse(input)).throws(); + }); + + test('parse fails when otp_encrypted_api_key is wrong length', () { + final queryParams = {...wellFormed.queryParameters} + ..['otp_encrypted_api_key'] = 'asdf'; + final input = wellFormed.replace(queryParameters: queryParams); + check(() => WebAuthPayload.parse(input)).throws(); + }); + + test('parse fails when host is not "login"', () { + final input = wellFormed.replace(host: 'foo'); + check(() => WebAuthPayload.parse(input)).throws(); + }); + + test('parse fails when scheme is not "zulip"', () { + final input = wellFormed.replace(scheme: 'https'); + check(() => WebAuthPayload.parse(input)).throws(); + }); + + test('decodeApiKey fails when otp is wrong length', () { + final payload = WebAuthPayload.parse(wellFormed); + check(() => payload.decodeApiKey('asdf')).throws(); + }); + }); + + group('generateOtp', () { + test('smoke, and check all 256 byte values are used', () { + // This is a probabilistic test. We've chosen `n` so that when the test + // should pass, the probability it fails is < 1e-9. See analysis below. + const n = 216; + final manyOtps = List.generate(n, (_) => generateOtp()); + + final bytesThatAppear = {}; + for (final otp in manyOtps) { + final bytes = hex.decode(otp); + check(bytes).length.equals(32); + bytesThatAppear.addAll(bytes); + } + + // Each possible value gets n * 32 opportunities to show up, + // each with probability 1/256; so the probability of missing all of those + // is exp(- n * 32 / 256) < 2e-12, and there are 256 such possible + // byte values so the probability that any of them gets missed is < 1e-9. + for (final byteValue in Iterable.generate(256)) { + check(bytesThatAppear).contains(byteValue); + } + }); + }); +} + +extension WebAuthPayloadChecks on Subject { + Subject get otpEncryptedApiKey => has((x) => x.otpEncryptedApiKey, 'otpEncryptedApiKey'); + Subject get email => has((x) => x.email, 'email'); + Subject get userId => has((x) => x.userId, 'userId'); + Subject get realm => has((x) => x.realm, 'realm'); +} diff --git a/test/example_data.dart b/test/example_data.dart index e542d1e3c4..e6608a3f9b 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -21,22 +21,37 @@ const String recentZulipVersion = '8.0'; const int recentZulipFeatureLevel = 185; const int futureZulipFeatureLevel = 9999; -GetServerSettingsResult serverSettings() { +GetServerSettingsResult serverSettings({ + Map? authenticationMethods, + List? externalAuthenticationMethods, + int? zulipFeatureLevel, + String? zulipVersion, + String? zulipMergeBase, + bool? pushNotificationsEnabled, + bool? isIncompatible, + bool? emailAuthEnabled, + bool? requireEmailFormatUsernames, + Uri? realmUrl, + String? realmName, + String? realmIcon, + String? realmDescription, + bool? realmWebPublicAccessEnabled, +}) { return GetServerSettingsResult( - authenticationMethods: {}, - externalAuthenticationMethods: [], - zulipFeatureLevel: recentZulipFeatureLevel, - zulipVersion: recentZulipVersion, - zulipMergeBase: recentZulipVersion, - pushNotificationsEnabled: true, - isIncompatible: false, - emailAuthEnabled: true, - requireEmailFormatUsernames: true, - realmUrl: realmUrl, - realmName: 'Example Zulip organization', - realmIcon: '$realmUrl/icon.png', - realmDescription: 'An example Zulip organization', - realmWebPublicAccessEnabled: false, + authenticationMethods: authenticationMethods ?? {}, + externalAuthenticationMethods: externalAuthenticationMethods ?? [], + zulipFeatureLevel: zulipFeatureLevel ?? recentZulipFeatureLevel, + zulipVersion: zulipVersion ?? recentZulipVersion, + zulipMergeBase: zulipMergeBase ?? recentZulipVersion, + pushNotificationsEnabled: pushNotificationsEnabled ?? true, + isIncompatible: isIncompatible ?? false, + emailAuthEnabled: emailAuthEnabled ?? true, + requireEmailFormatUsernames: requireEmailFormatUsernames ?? true, + realmUrl: realmUrl ?? _realmUrl, + realmName: realmName ?? 'Example Zulip organization', + realmIcon: realmIcon ?? '$realmUrl/icon.png', + realmDescription: realmDescription ?? 'An example Zulip organization', + realmWebPublicAccessEnabled: realmWebPublicAccessEnabled ?? false, ); } @@ -108,14 +123,14 @@ final User selfUser = user(fullName: 'Self User', email: 'self@example'); final Account selfAccount = account( id: 1001, user: selfUser, - apiKey: 'asdfqwer', + apiKey: 'dQcEJWTq3LczosDkJnRTwf31zniGvMrO', // A Zulip API key is 32 digits of base64. ); final User otherUser = user(fullName: 'Other User', email: 'other@example'); final Account otherAccount = account( id: 1002, user: otherUser, - apiKey: 'sdfgwert', + apiKey: '6dxT4b73BYpCTU+i4BB9LAKC5h/CufqY', // A Zulip API key is 32 digits of base64. ); final User thirdUser = user(fullName: 'Third User', email: 'third@example'); diff --git a/test/model/binding.dart b/test/model/binding.dart index 394d89d988..1a561da92d 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -66,7 +66,9 @@ class TestZulipBinding extends ZulipBinding { void reset() { ZulipApp.debugReset(); _resetStore(); + _resetCanLaunchUrl(); _resetLaunchUrl(); + _resetCloseInAppWebView(); _resetDeviceInfo(); _resetFirebase(); _resetNotifications(); @@ -119,6 +121,36 @@ class TestZulipBinding extends ZulipBinding { return Future.value(globalStore); } + /// The value that `ZulipBinding.instance.canLaunchUrl()` should return. + /// + /// See also [takeCanLaunchUrlCalls]. + bool canLaunchUrlResult = true; + + void _resetCanLaunchUrl() { + canLaunchUrlResult = true; + _canLaunchUrlCalls = null; + } + + /// Consume the log of calls made to `ZulipBinding.instance.canLaunchUrl()`. + /// + /// This returns a list of the arguments to all calls made + /// to `ZulipBinding.instance.canLaunchUrl()` since the last call to + /// either this method or [reset]. + /// + /// See also [canLaunchUrlResult]. + List takeCanLaunchUrlCalls() { + final result = _canLaunchUrlCalls; + _canLaunchUrlCalls = null; + return result ?? []; + } + List? _canLaunchUrlCalls; + + @override + Future canLaunchUrl(Uri url) async { + (_canLaunchUrlCalls ??= []).add(url); + return canLaunchUrlResult; + } + /// The value that `ZulipBinding.instance.launchUrl()` should return. /// /// See also [takeLaunchUrlCalls]. @@ -152,6 +184,26 @@ class TestZulipBinding extends ZulipBinding { return launchUrlResult; } + @override + Future supportsCloseForLaunchMode(url_launcher.LaunchMode mode) async => true; + + void _resetCloseInAppWebView() { + _closeInAppWebViewCallCount = 0; + } + + /// Read and reset the count of calls to `ZulipBinding.instance.closeInAppWebView()`. + int takeCloseInAppWebViewCallCount() { + final result = _closeInAppWebViewCallCount; + _closeInAppWebViewCallCount = 0; + return result; + } + int _closeInAppWebViewCallCount = 0; + + @override + Future closeInAppWebView() async { + _closeInAppWebViewCallCount++; + } + /// The value that `ZulipBinding.instance.deviceInfo()` should return. /// /// See also [takeDeviceInfoCalls]. diff --git a/test/test_images.dart b/test/test_images.dart index 9ec4b32e16..c7a04c264f 100644 --- a/test/test_images.dart +++ b/test/test_images.dart @@ -1,8 +1,26 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +/// Set [debugNetworkImageHttpClientProvider] to return a constant image. +/// +/// Returns the [FakeImageHttpClient] that handles the requests. +/// +/// The caller must set [debugNetworkImageHttpClientProvider] back to null +/// before the end of the test. +// TODO(upstream) simplify callers by using addTearDown: https://github.com/flutter/flutter/issues/123189 +// See also: https://github.com/flutter/flutter/issues/121917 +FakeImageHttpClient prepareBoringImageHttpClient() { + final httpClient = FakeImageHttpClient(); + debugNetworkImageHttpClientProvider = () => httpClient; + httpClient.request.response + ..statusCode = HttpStatus.ok + ..content = kSolidBlueAvatar; + return httpClient; +} + class FakeImageHttpClient extends Fake implements HttpClient { final FakeImageHttpClientRequest request = FakeImageHttpClientRequest(); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index c5028bb0ff..f1ac188291 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -14,7 +14,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; -import 'content_test.dart'; +import '../test_images.dart'; /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index e6b0bc553b..5298ee09c3 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -27,23 +25,6 @@ import 'dialog_checks.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; -/// Set [debugNetworkImageHttpClientProvider] to return a constant image. -/// -/// Returns the [FakeImageHttpClient] that handles the requests. -/// -/// The caller must set [debugNetworkImageHttpClientProvider] back to null -/// before the end of the test. -// TODO(upstream) simplify callers by using addTearDown: https://github.com/flutter/flutter/issues/123189 -// See also: https://github.com/flutter/flutter/issues/121917 -FakeImageHttpClient prepareBoringImageHttpClient() { - final httpClient = FakeImageHttpClient(); - debugNetworkImageHttpClientProvider = () => httpClient; - httpClient.request.response - ..statusCode = HttpStatus.ok - ..content = kSolidBlueAvatar; - return httpClient; -} - void main() { // For testing a new content feature: // diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index f53af3544b..8684a66c0d 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -1,18 +1,25 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:zulip/api/model/web_auth.dart'; import 'package:zulip/api/route/account.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/model/binding.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/login.dart'; -import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/page.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../stdlib_checks.dart'; +import '../test_images.dart'; +import '../test_navigation.dart'; +import 'dialog_checks.dart'; +import 'page_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -56,8 +63,18 @@ void main() { // TODO test AddAccountPage - group('PasswordLoginPage', () { + group('LoginPage', () { late FakeApiConnection connection; + late List> pushedRoutes; + + void takeStartingRoutes() { + final expected = [ + (Subject it) => it.isA().page.isA(), + (Subject it) => it.isA().page.isA(), + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } Future prepare(WidgetTester tester, GetServerSettingsResult serverSettings) async { @@ -67,13 +84,16 @@ void main() { realmUrl: serverSettings.realmUrl, zulipFeatureLevel: serverSettings.zulipFeatureLevel); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - home: GlobalStoreWidget( - child: PasswordLoginPage(serverSettings: serverSettings)))); - await tester.pump(); // load global store + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + final navigator = await ZulipApp.navigator; + navigator.push(LoginPage.buildRoute(serverSettings: serverSettings)); + await tester.pumpAndSettle(); + takeStartingRoutes(); + check(pushedRoutes).isEmpty(); } final findUsernameInput = find.byWidgetPredicate((widget) => @@ -84,62 +104,118 @@ void main() { && (widget.autofillHints ?? []).contains(AutofillHints.password)); final findSubmitButton = find.widgetWithText(ElevatedButton, 'Log in'); - void checkFetchApiKey({required String username, required String password}) { - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/fetch_api_key') - ..bodyFields.deepEquals({ - 'username': username, - 'password': password, - }); - } + group('username/password login', () { + void checkFetchApiKey({required String username, required String password}) { + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/fetch_api_key') + ..bodyFields.deepEquals({ + 'username': username, + 'password': password, + }); + } + + testWidgets('basic happy case', (tester) async { + final serverSettings = eg.serverSettings(); + await prepare(tester, serverSettings); + check(testBinding.globalStore.accounts).isEmpty(); + + await tester.enterText(findUsernameInput, eg.selfAccount.email); + await tester.enterText(findPasswordInput, 'p455w0rd'); + connection.prepare(json: FetchApiKeyResult( + apiKey: eg.selfAccount.apiKey, + email: eg.selfAccount.email, + userId: eg.selfAccount.userId, + ).toJson()); + await tester.tap(findSubmitButton); + checkFetchApiKey(username: eg.selfAccount.email, password: 'p455w0rd'); + await tester.idle(); + check(testBinding.globalStore.accounts).single + .equals(eg.selfAccount.copyWith( + id: testBinding.globalStore.accounts.single.id)); + }); + + testWidgets('account already exists', (tester) async { + final serverSettings = eg.serverSettings(); + await prepare(tester, serverSettings); + check(testBinding.globalStore.accounts).isEmpty(); + testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + + await tester.enterText(findUsernameInput, eg.selfAccount.email); + await tester.enterText(findPasswordInput, 'p455w0rd'); + connection.prepare(json: FetchApiKeyResult( + apiKey: eg.selfAccount.apiKey, + email: eg.selfAccount.email, + userId: eg.selfAccount.userId, + ).toJson()); + await tester.tap(findSubmitButton); + await tester.pumpAndSettle(); + + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorAccountLoggedInTitle))); + }); - testWidgets('basic happy case', (tester) async { - final serverSettings = eg.serverSettings(); - await prepare(tester, serverSettings); - check(testBinding.globalStore.accounts).isEmpty(); - - await tester.enterText(findUsernameInput, eg.selfAccount.email); - await tester.enterText(findPasswordInput, 'p455w0rd'); - connection.prepare(json: FetchApiKeyResult( - apiKey: eg.selfAccount.apiKey, - email: eg.selfAccount.email, - userId: eg.selfAccount.userId, - ).toJson()); - await tester.tap(findSubmitButton); - checkFetchApiKey(username: eg.selfAccount.email, password: 'p455w0rd'); - await tester.idle(); - check(testBinding.globalStore.accounts).single - .equals(eg.selfAccount.copyWith( - id: testBinding.globalStore.accounts.single.id)); + // TODO test validators on the TextFormField widgets + // TODO test navigation, i.e. the call to pushAndRemoveUntil + // TODO test _getUserId case + // TODO test handling failure in fetchApiKey request + // TODO test _inProgress logic }); - testWidgets('account already exists', (tester) async { - final serverSettings = eg.serverSettings(); - await prepare(tester, serverSettings); - check(testBinding.globalStore.accounts).isEmpty(); - testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - - await tester.enterText(findUsernameInput, eg.selfAccount.email); - await tester.enterText(findPasswordInput, 'p455w0rd'); - connection.prepare(json: FetchApiKeyResult( - apiKey: eg.selfAccount.apiKey, - email: eg.selfAccount.email, - userId: eg.selfAccount.userId, - ).toJson()); - await tester.tap(findSubmitButton); - await tester.pumpAndSettle(); + group('web auth', () { + testWidgets('basic happy case', (tester) async { + final method = ExternalAuthenticationMethod( + name: 'google', + displayName: 'Google', + displayIcon: eg.realmUrl.resolve('/static/images/authentication_backends/googl_e-icon.png').toString(), + loginUrl: '/accounts/login/social/google', + signupUrl: '/accounts/register/social/google', + ); + final serverSettings = eg.serverSettings( + externalAuthenticationMethods: [method]); + prepareBoringImageHttpClient(); // icon on social-auth button + await prepare(tester, serverSettings); + check(testBinding.globalStore.accounts).isEmpty(); + + const otp = '186f6d085a5621ebaf1ccfc05033e8acba57dae03f061705ac1e58c402c30a31'; + LoginPage.debugOtpOverride = otp; + await tester.tap(find.textContaining('Google')); + + final expectedUrl = eg.realmUrl.resolve(method.loginUrl) + .replace(queryParameters: {'mobile_flow_otp': otp}); + check(testBinding.takeLaunchUrlCalls()) + .deepEquals([(url: expectedUrl, mode: UrlLaunchMode.inAppBrowserView)]); + + // TODO test _inProgress logic? + + final encoded = debugEncodeApiKey(eg.selfAccount.apiKey, otp); + final url = Uri(scheme: 'zulip', host: 'login', queryParameters: { + 'otp_encrypted_api_key': encoded, + 'email': eg.selfAccount.email, + 'user_id': eg.selfAccount.userId.toString(), + 'realm': eg.selfAccount.realmUrl.toString(), + }); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final findAlertDialogWithExistsMessage = find.widgetWithText( - AlertDialog, zulipLocalizations.errorAccountLoggedInTitle); - check(findAlertDialogWithExistsMessage.evaluate()).isNotEmpty(); - }); + final ByteData message = const JSONMethodCodec().encodeMethodCall( + MethodCall('pushRouteInformation', {'location': url.toString()})); + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', message, null); + + await tester.idle(); + check(testBinding.takeCloseInAppWebViewCallCount()).equals(1); + + final account = testBinding.globalStore.accounts.single; + check(account).equals(eg.selfAccount.copyWith(id: account.id)); + check(pushedRoutes).single.isA() + ..accountId.equals(account.id) + ..page.isA(); - // TODO test validators on the TextFormField widgets - // TODO test navigation, i.e. the call to pushAndRemoveUntil - // TODO test _getUserId case - // TODO test handling failure in fetchApiKey request - // TODO test _inProgress logic + debugNetworkImageHttpClientProvider = null; + }); + + // TODO failures, such as: invalid loginUrl; URL can't be launched; + // WebAuthPayload.realm doesn't match the realm the UI is about + }); }); }