From df39882a8b6da3d35c84ea8f9593975d7e34d7a8 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 19 Apr 2023 20:08:19 -0400 Subject: [PATCH 01/17] feat(shorebird_cli): Add account create command --- packages/shorebird_cli/lib/src/auth/auth.dart | 29 ++- .../lib/src/commands/account/account.dart | 2 + .../src/commands/account/account_command.dart | 19 ++ .../account/create_account_command.dart | 99 ++++++++++ .../lib/src/commands/account_command.dart | 29 --- .../lib/src/commands/commands.dart | 2 +- .../lib/src/commands/doctor_command.dart | 1 - .../lib/src/commands/login_command.dart | 9 +- .../test/src/auth/auth_test.dart | 10 + .../account/create_account_command_test.dart | 173 ++++++++++++++++++ .../src/commands/account_command_test.dart | 48 ----- .../test/src/commands/login_command_test.dart | 20 ++ .../lib/src/code_push_client.dart | 46 ++++- .../test/src/code_push_client_test.dart | 103 +++++++++++ 14 files changed, 503 insertions(+), 87 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/account/account.dart create mode 100644 packages/shorebird_cli/lib/src/commands/account/account_command.dart create mode 100644 packages/shorebird_cli/lib/src/commands/account/create_account_command.dart delete mode 100644 packages/shorebird_cli/lib/src/commands/account_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart delete mode 100644 packages/shorebird_cli/test/src/commands/account_command_test.dart diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index a0d88c010..ada849525 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -111,7 +111,13 @@ class Auth { ); } - Future login(void Function(String) prompt) async { + /// Logs the string returned by [prompt] to the console and obtains auth + /// credentials. If [verifyEmail] is true, check that there is a Shorebird + /// user with the same email address as the newly-authenticated user. + Future login( + void Function(String) prompt, { + bool verifyEmail = true, + }) async { if (_credentials != null) return; final client = http.Client(); @@ -123,11 +129,22 @@ class Auth { prompt, ); - final codePushClient = _buildCodePushClient(httpClient: this.client); - - final user = await codePushClient.getCurrentUser(); - - _email = user.email; + if (_credentials?.email == null) { + throw Exception('Failed to obtain access credentials.'); + } + + if (verifyEmail) { + final codePushClient = _buildCodePushClient(httpClient: this.client); + final user = await codePushClient.getCurrentUser(); + if (user.email != _credentials!.email) { + throw Exception( + 'The email address of the authenticated user does not match the ' + 'email address of the user in the Shorebird database.', + ); + } + } + + _email = _credentials!.email; _flushCredentials(_credentials!); } finally { client.close(); diff --git a/packages/shorebird_cli/lib/src/commands/account/account.dart b/packages/shorebird_cli/lib/src/commands/account/account.dart new file mode 100644 index 000000000..b5db90683 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/account.dart @@ -0,0 +1,2 @@ +export 'account_command.dart'; +export 'create_account_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/account/account_command.dart b/packages/shorebird_cli/lib/src/commands/account/account_command.dart new file mode 100644 index 000000000..4b09c2d8b --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/account_command.dart @@ -0,0 +1,19 @@ +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; + +/// {@template account_command} +/// `shorebird account` +/// Manage your Shorebird account. +/// {@endtemplate} +class AccountCommand extends ShorebirdCommand { + /// {@macro account_command} + AccountCommand({required super.logger, super.auth}) { + addSubcommand(CreateAccountCommand(logger: logger)); + } + + @override + String get name => 'account'; + + @override + String get description => 'Manage your Shorebird account.'; +} diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart new file mode 100644 index 000000000..19d35e703 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template create_account_command} +/// `shorebird account create` +/// Create a new Shorebird account. +/// {@endtemplate} +class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { + /// {@macro create_account_command} + CreateAccountCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + }); + + @override + String get description => 'Create a new Shorebird account.'; + + @override + String get name => 'create'; + + @override + Future run() async { + final CodePushClient client; + + if (!auth.isAuthenticated) { + try { + await auth.login(prompt, verifyEmail: false); + client = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + } catch (error) { + logger.err(error.toString()); + return ExitCode.software.code; + } + } else { + // If the user already has a JWT, check if they already have an account. + logger.info( + 'Already logged in as ${auth.email}, checking for existing account', + ); + + client = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + + try { + final user = await client.getCurrentUser(); + logger.info(''' +You already have an account, ${user.displayName}! + +To subscribe, run ${green.wrap('shorebird account subscribe')}. +'''); + return ExitCode.success.code; + } catch (_) {} + } + + logger.info('Authorized as ${auth.email}'); + + final name = logger.prompt('What is your name?'); + + final progress = logger.progress('Creating account'); + try { + final newUser = await client.createUser(name: name); + final paymentLink = await client.createPaymentLink(); + progress.complete( + ''' + +🎉 ${lightGreen.wrap('Welcome to Shorebird, ${newUser.displayName}! You have successfully created an account as <${auth.email}>.')} + +🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. +🚪 To logout, use: "${lightCyan.wrap('shorebird logout')}". + +The next step is to purchase a Shorebird subscription. To subcribe, visit ${lightCyan.wrap('$paymentLink')} or run ${green.wrap('shorebird account subscribe')} later. +''', + ); + return ExitCode.success.code; + } catch (error) { + progress.fail(error.toString()); + return ExitCode.software.code; + } + } + + void prompt(String url) { + logger.info(''' +Shorebird is currently only open to trusted testers. To participate, you will need a Google account for authentication. + +The first step is to sign in with a Google account. Please follow the sign-in link below: + +${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))} + +Waiting for your authorization...'''); + } +} diff --git a/packages/shorebird_cli/lib/src/commands/account_command.dart b/packages/shorebird_cli/lib/src/commands/account_command.dart deleted file mode 100644 index 90b38b3c2..000000000 --- a/packages/shorebird_cli/lib/src/commands/account_command.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:async'; - -import 'package:mason_logger/mason_logger.dart'; -import 'package:shorebird_cli/src/command.dart'; - -class AccountCommand extends ShorebirdCommand { - AccountCommand({required super.logger, super.auth}); - - @override - String get name => 'account'; - - @override - String get description => 'Show information about the logged-in user'; - - @override - Future run() async { - if (!auth.isAuthenticated) { - logger.info( - 'You are not logged in.' - ' Run ${green.wrap('shorebird login')} to log in.', - ); - return ExitCode.success.code; - } - - logger.info('You are logged in as <${auth.email}>'); - - return ExitCode.success.code; - } -} diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 02eec28af..51e19be65 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -1,4 +1,4 @@ -export 'account_command.dart'; +export 'account/account.dart'; export 'apps/apps.dart'; export 'build/build_command.dart'; export 'cache/cache.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/doctor_command.dart b/packages/shorebird_cli/lib/src/commands/doctor_command.dart index 0f164c81f..0e5f5b3b4 100644 --- a/packages/shorebird_cli/lib/src/commands/doctor_command.dart +++ b/packages/shorebird_cli/lib/src/commands/doctor_command.dart @@ -7,7 +7,6 @@ import 'package:shorebird_cli/src/validators/validators.dart'; import 'package:shorebird_cli/src/version.dart'; /// {@template doctor_command} -/// /// `shorebird doctor` /// A command that checks for potential issues with the current shorebird /// environment. diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 755a5c614..13d708537 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -1,8 +1,8 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// {@template login_command} -/// /// `shorebird login` /// Login as a new Shorebird user. /// {@endtemplate} @@ -34,6 +34,13 @@ class LoginCommand extends ShorebirdCommand { 🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. 🚪 To logout use: "${lightCyan.wrap('shorebird logout')}".'''); return ExitCode.success.code; + } on UserNotFoundException { + logger.err( + """ + +We don't recognize that email address. Run the `shorebird account create` command to create an account.""", + ); + return ExitCode.software.code; } catch (error) { logger.err(error.toString()); return ExitCode.software.code; diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 8c0e3b6ec..c643596d1 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -183,6 +183,16 @@ void main() { expect(auth.email, isNull); expect(auth.isAuthenticated, isFalse); }); + + test( + 'does not fetch current user if verifyEmail is false', + () async { + await auth.login((_) {}, verifyEmail: false); + verifyNever(() => codePushClient.getCurrentUser()); + expect(buildAuth().email, email); + expect(buildAuth().isAuthenticated, isTrue); + }, + ); }); group('logout', () { diff --git a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart new file mode 100644 index 000000000..ca79699df --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart @@ -0,0 +1,173 @@ +import 'package:http/http.dart' as http; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/commands/account/account.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +class _MockAuth extends Mock implements Auth {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + +class _MockHttpClient extends Mock implements http.Client {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockUser extends Mock implements User {} + +void main() { + const userName = 'John Doe'; + const email = 'tester@shorebird.dev'; + final paymentLink = Uri.parse('https://example.com/payment-link'); + + late Auth auth; + late CodePushClient codePushClient; + late http.Client httpClient; + late Logger logger; + late Progress progress; + late User user; + + late CreateAccountCommand createAccountCommand; + + group(CreateAccountCommand, () { + setUp(() { + auth = _MockAuth(); + codePushClient = _MockCodePushClient(); + httpClient = _MockHttpClient(); + logger = _MockLogger(); + progress = _MockProgress(); + user = _MockUser(); + + createAccountCommand = CreateAccountCommand( + logger: logger, + auth: auth, + buildCodePushClient: ({required httpClient, hostedUri}) => + codePushClient, + ); + + when(() => auth.client).thenReturn(httpClient); + when(() => auth.email).thenReturn(email); + when(() => auth.credentialsFilePath).thenReturn('credentials.json'); + when(() => auth.isAuthenticated).thenReturn(false); + when( + () => auth.login(any(), verifyEmail: any(named: 'verifyEmail')), + ).thenAnswer((_) async {}); + + when(() => codePushClient.createUser(name: userName)) + .thenAnswer((_) async => user); + when(() => codePushClient.createPaymentLink()) + .thenAnswer((_) async => paymentLink); + when(() => codePushClient.getCurrentUser()).thenThrow( + Exception('failed to get current user'), + ); + + when(() => logger.err(any())).thenReturn(null); + when(() => logger.info(any())).thenReturn(null); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.prompt(any())).thenReturn(userName); + + when(() => progress.complete(any())).thenReturn(null); + when(() => progress.fail(any())).thenReturn(null); + + when(() => user.displayName).thenReturn(userName); + }); + + test('exits with code 70 when login fails', () async { + when(() => auth.isAuthenticated).thenReturn(false); + when( + () => auth.login(any(), verifyEmail: any(named: 'verifyEmail')), + ).thenThrow(Exception('login failed')); + + final result = await createAccountCommand.run(); + + expect(result, ExitCode.software.code); + verify(() => auth.login(any(), verifyEmail: false)).called(1); + verify(() => logger.err(any(that: contains('login failed')))).called(1); + }); + + test( + 'exits with code 0 and prints message and exits if user already has an ' + 'account', () async { + when(() => auth.isAuthenticated).thenReturn(true); + when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user); + + final result = await createAccountCommand.run(); + + expect(result, ExitCode.success.code); + verify( + () => logger.info(any(that: contains('You already have an account'))), + ).called(1); + }); + + test( + 'proceeds with account creation if user is authenticated but does not ' + 'have an account', + () async { + when(() => auth.isAuthenticated).thenReturn(true); + final result = await createAccountCommand.run(); + + expect(result, ExitCode.success.code); + verify(() => codePushClient.createUser(name: userName)).called(1); + verify(() => codePushClient.createPaymentLink()).called(1); + }, + ); + + test('exits with code 70 if createUser fails', () async { + const errorMessage = 'failed to create user'; + when(() => auth.isAuthenticated).thenReturn(false); + when(() => codePushClient.createUser(name: any(named: 'name'))) + .thenThrow(Exception(errorMessage)); + + final result = await createAccountCommand.run(); + + expect(result, ExitCode.software.code); + verify(() => auth.login(any(), verifyEmail: false)).called(1); + verify(() => codePushClient.createUser(name: userName)).called(1); + verifyNever(() => codePushClient.createPaymentLink()); + verify(() => progress.fail(any(that: contains(errorMessage)))).called(1); + }); + + test('exits with code 70 if createPaymentLink fails', () async { + const errorMessage = 'failed to create payment link'; + + when(() => auth.isAuthenticated).thenReturn(false); + when(() => codePushClient.createPaymentLink()) + .thenThrow(Exception(errorMessage)); + + final result = await createAccountCommand.run(); + + expect(result, ExitCode.software.code); + verify(() => auth.login(any(), verifyEmail: false)).called(1); + verify(() => codePushClient.createUser(name: userName)).called(1); + verify(() => codePushClient.createPaymentLink()).called(1); + verify(() => progress.fail(any(that: contains(errorMessage)))).called(1); + }); + + test('exits with code 0, creates account with name provided by user', + () async { + final result = await createAccountCommand.run(); + + expect(result, ExitCode.success.code); + verify(() => auth.login(any(), verifyEmail: false)).called(1); + verify(() => logger.prompt('What is your name?')).called(1); + verify(() => codePushClient.createUser(name: userName)).called(1); + verify(() => codePushClient.createPaymentLink()).called(1); + verify( + () => progress.complete( + any( + that: stringContainsInOrder([ + 'Welcome to Shorebird', + userName, + email, + 'purchase a Shorebird subscription', + paymentLink.toString(), + ]), + ), + ), + ).called(1); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/account_command_test.dart b/packages/shorebird_cli/test/src/commands/account_command_test.dart deleted file mode 100644 index b9a06c300..000000000 --- a/packages/shorebird_cli/test/src/commands/account_command_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:mason_logger/mason_logger.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shorebird_cli/src/auth/auth.dart'; -import 'package:shorebird_cli/src/commands/account_command.dart'; -import 'package:test/test.dart'; - -class _MockAuth extends Mock implements Auth {} - -class _MockLogger extends Mock implements Logger {} - -void main() { - group('AccountCommand', () { - const email = 'hello@shorebird.dev'; - - late Auth auth; - late Logger logger; - late AccountCommand accountCommand; - - setUp(() { - auth = _MockAuth(); - logger = _MockLogger(); - accountCommand = AccountCommand(logger: logger, auth: auth); - - when(() => auth.isAuthenticated).thenReturn(true); - when(() => auth.email).thenReturn(email); - }); - - test("doesn't do anything if no user is logged in", () async { - when(() => auth.isAuthenticated).thenReturn(false); - - final result = await accountCommand.run(); - - expect(result, equals(ExitCode.success.code)); - verify( - () => logger.info(any(that: contains('You are not logged in'))), - ).called(1); - }); - - test('prints the email address of the current user', () async { - final result = await accountCommand.run(); - - expect(result, equals(ExitCode.success.code)); - verify( - () => logger.info('You are logged in as '), - ).called(1); - }); - }); -} diff --git a/packages/shorebird_cli/test/src/commands/login_command_test.dart b/packages/shorebird_cli/test/src/commands/login_command_test.dart index 3694884fa..f5ecd98e0 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -5,6 +5,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/commands/login_command.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; class _MockAuth extends Mock implements Auth {} @@ -58,6 +59,25 @@ void main() { verify(() => logger.err(error.toString())).called(1); }); + test('exits with code 70 if user does not have an account', () async { + when(() => auth.login(any())).thenThrow(UserNotFoundException()); + + final result = await loginCommand.run(); + + expect(result, equals(ExitCode.software.code)); + verify(() => auth.login(any())).called(1); + verify( + () => logger.err( + any( + that: stringContainsInOrder([ + "We don't recognize that email address", + 'shorebird account create', + ]), + ), + ), + ).called(1); + }); + test('exits with code 0 when logged in successfully', () async { when(() => auth.login(any())).thenAnswer((_) async {}); when(() => auth.email).thenReturn(email); diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index aec48492a..0e30935f6 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -21,6 +21,14 @@ class CodePushException implements Exception { String toString() => '$message${details != null ? '\n$details' : ''}'; } +/// {@template user_not_found_exception}o +/// Thrown when an attempt to fetch a User object results in a 404. +/// {@endtemplate} +class UserNotFoundException implements Exception { + /// {@macro user_not_found_exception} + UserNotFoundException(); +} + /// {@template code_push_client} /// Dart client for the Shorebird CodePush API. /// {@endtemplate} @@ -45,7 +53,9 @@ class CodePushClient { final uri = Uri.parse('$hostedUri/api/v1/users/me'); final response = await _httpClient.get(uri); - if (response.statusCode != HttpStatus.ok) { + if (response.statusCode == HttpStatus.notFound) { + throw UserNotFoundException(); + } else if (response.statusCode != HttpStatus.ok) { throw _parseErrorResponse(response.body); } @@ -156,6 +166,21 @@ class CodePushClient { return Patch.fromJson(body); } + /// Generates a Stripe payment link for the current user. + Future createPaymentLink() async { + final response = await _httpClient.post( + Uri.parse('$hostedUri/api/v1/subscriptions/payment_link'), + ); + + if (response.statusCode != HttpStatus.ok) { + throw _parseErrorResponse(response.body); + } + + return CreatePaymentLinkResponse.fromJson( + json.decode(response.body) as Json, + ).paymentLink; + } + /// Create a new release for the app with the provided [appId]. Future createRelease({ required String appId, @@ -178,6 +203,25 @@ class CodePushClient { return Release.fromJson(body); } + /// Create a new Shorebird user with the provided [name]. + /// + /// The email associated with the user's JWT will be used as the user's email. + Future createUser({ + required String name, + }) async { + final response = await _httpClient.post( + Uri.parse('$hostedUri/api/v1/users'), + body: jsonEncode(CreateUserRequest(name: name).toJson()), + ); + + if (response.statusCode != HttpStatus.created) { + throw _parseErrorResponse(response.body); + } + + final body = json.decode(response.body) as Json; + return User.fromJson(body); + } + /// Delete the app with the provided [appId]. Future deleteApp({required String appId}) async { final response = await _httpClient.delete( diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index 19bd0fd69..dd3d0c1f8 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -60,6 +60,15 @@ void main() { uri = Uri.parse('${codePushClient.hostedUri}/api/v1/users/me'); }); + test('throws UserNotFoundException if reponse is a 404', () async { + when(() => httpClient.get(uri)) + .thenAnswer((_) async => http.Response('', HttpStatus.notFound)); + expect( + codePushClient.getCurrentUser(), + throwsA(isA()), + ); + }); + test('throws exception if the http request fails', () { when(() => httpClient.get(uri)) .thenAnswer((_) async => http.Response('', HttpStatus.badRequest)); @@ -616,6 +625,49 @@ void main() { }); }); + group('createPaymentLink', () { + test('throws an exception if the http request fails', () { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', HttpStatus.badRequest)); + + expect( + codePushClient.createPaymentLink(), + throwsA( + isA().having( + (e) => e.message, + 'message', + CodePushClient.unknownErrorMessage, + ), + ), + ); + }); + + test('returns a payment link if the http request succeeds', () { + final link = Uri.parse('http://test.com'); + + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer( + (_) async => http.Response( + jsonEncode(CreatePaymentLinkResponse(paymentLink: link).toJson()), + HttpStatus.ok, + ), + ); + + expect( + codePushClient.createPaymentLink(), + completion(link), + ); + }); + }); + group('createRelease', () { const version = '1.0.0'; test('throws an exception if the http request fails (unknown)', () async { @@ -727,6 +779,57 @@ void main() { }); }); + group('createUser', () { + const userName = 'Jane Doe'; + final user = User( + id: 1, + email: 'tester@shorebird.dev', + displayName: userName, + ); + + test('throws an exception if the http request fails', () { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response('', HttpStatus.failedDependency), + ); + + expect( + codePushClient.createUser(name: userName), + throwsA( + isA().having( + (e) => e.message, + 'message', + CodePushClient.unknownErrorMessage, + ), + ), + ); + }); + + test('returns a User when the http request succeeds', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response( + jsonEncode(user.toJson()), + HttpStatus.created, + ), + ); + + final result = await codePushClient.createUser(name: userName); + + expect(result.toJson(), user.toJson()); + }); + }); + group('deleteApp', () { test('throws an exception if the http request fails (unknown)', () async { when( From 38e92d4914e9533895f765fcb94298706088cac9 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 19 Apr 2023 20:25:01 -0400 Subject: [PATCH 02/17] coverage --- .../account/create_account_command.dart | 1 - .../shorebird_cli/test/src/auth/auth_test.dart | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart index 19d35e703..061382a27 100644 --- a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -70,7 +70,6 @@ To subscribe, run ${green.wrap('shorebird account subscribe')}. final paymentLink = await client.createPaymentLink(); progress.complete( ''' - 🎉 ${lightGreen.wrap('Welcome to Shorebird, ${newUser.displayName}! You have successfully created an account as <${auth.email}>.')} 🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index c643596d1..9b009c5c1 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -184,6 +184,23 @@ void main() { expect(auth.isAuthenticated, isFalse); }); + test( + "throws exception if credentials email doesn't match current user", + () async { + when(() => codePushClient.getCurrentUser()).thenAnswer( + (_) async => const User( + id: 123, + email: 'email@email.com', + ), + ); + + await expectLater(auth.login((_) {}), throwsException); + + expect(auth.email, isNull); + expect(auth.isAuthenticated, isFalse); + }, + ); + test( 'does not fetch current user if verifyEmail is false', () async { From 2ca701b68ac3a091d5e0af7d82f5ba867f8fb97c Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 19 Apr 2023 20:29:15 -0400 Subject: [PATCH 03/17] coverage --- .../account/create_account_command_test.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart index ca79699df..e957ae3cd 100644 --- a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart @@ -75,6 +75,24 @@ void main() { when(() => user.displayName).thenReturn(userName); }); + test('has a description', () { + expect(createAccountCommand.description, isNotEmpty); + }); + + test('login prompt is correct', () { + createAccountCommand.prompt('https://shorebird.dev'); + verify( + () => logger.info(''' +Shorebird is currently only open to trusted testers. To participate, you will need a Google account for authentication. + +The first step is to sign in with a Google account. Please follow the sign-in link below: + +${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap('https://shorebird.dev')))} + +Waiting for your authorization...'''), + ).called(1); + }); + test('exits with code 70 when login fails', () async { when(() => auth.isAuthenticated).thenReturn(false); when( From 32e6bc58ed11b7586f544cd2130d52e16604f5ec Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 13:03:31 -0400 Subject: [PATCH 04/17] Refactor auth and create account command, update copy --- packages/shorebird_cli/lib/src/auth/auth.dart | 32 +----- .../account/create_account_command.dart | 103 ++++++++++-------- .../lib/src/commands/login_command.dart | 46 +++++--- .../test/src/auth/auth_test.dart | 54 +-------- .../account/create_account_command_test.dart | 85 ++++++--------- .../test/src/commands/login_command_test.dart | 51 +++++++-- 6 files changed, 166 insertions(+), 205 deletions(-) diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index ada849525..b26892a39 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -6,9 +6,7 @@ import 'package:googleapis_auth/auth_io.dart' as oauth2; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/auth/jwt.dart'; -import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/command_runner.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; final _clientId = oauth2.ClientId( /// Shorebird CLI's OAuth 2.0 identifier. @@ -82,20 +80,17 @@ class Auth { http.Client? httpClient, String? credentialsDir, ObtainAccessCredentials? obtainAccessCredentials, - CodePushClientBuilder? buildCodePushClient, }) : _httpClient = httpClient ?? http.Client(), _credentialsDir = credentialsDir ?? applicationConfigHome(executableName), _obtainAccessCredentials = obtainAccessCredentials ?? - oauth2.obtainAccessCredentialsViaUserConsent, - _buildCodePushClient = buildCodePushClient ?? CodePushClient.new { + oauth2.obtainAccessCredentialsViaUserConsent { _loadCredentials(); } final http.Client _httpClient; final String _credentialsDir; final ObtainAccessCredentials _obtainAccessCredentials; - final CodePushClientBuilder _buildCodePushClient; String get credentialsFilePath { return p.join(_credentialsDir, 'credentials.json'); @@ -111,13 +106,7 @@ class Auth { ); } - /// Logs the string returned by [prompt] to the console and obtains auth - /// credentials. If [verifyEmail] is true, check that there is a Shorebird - /// user with the same email address as the newly-authenticated user. - Future login( - void Function(String) prompt, { - bool verifyEmail = true, - }) async { + Future getCredentials(void Function(String) prompt) async { if (_credentials != null) return; final client = http.Client(); @@ -129,21 +118,6 @@ class Auth { prompt, ); - if (_credentials?.email == null) { - throw Exception('Failed to obtain access credentials.'); - } - - if (verifyEmail) { - final codePushClient = _buildCodePushClient(httpClient: this.client); - final user = await codePushClient.getCurrentUser(); - if (user.email != _credentials!.email) { - throw Exception( - 'The email address of the authenticated user does not match the ' - 'email address of the user in the Shorebird database.', - ); - } - } - _email = _credentials!.email; _flushCredentials(_credentials!); } finally { @@ -170,7 +144,7 @@ class Auth { _credentials = oauth2.AccessCredentials.fromJson( json.decode(contents) as Map, ); - _email = _credentials?.email; + _email = _credentials!.email; } catch (_) {} } } diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart index 061382a27..1b84f9ab9 100644 --- a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -25,71 +25,78 @@ class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { @override Future run() async { - final CodePushClient client; - - if (!auth.isAuthenticated) { - try { - await auth.login(prompt, verifyEmail: false); - client = buildCodePushClient( - httpClient: auth.client, - hostedUri: hostedUri, - ); - } catch (error) { - logger.err(error.toString()); - return ExitCode.software.code; - } - } else { - // If the user already has a JWT, check if they already have an account. - logger.info( - 'Already logged in as ${auth.email}, checking for existing account', - ); - - client = buildCodePushClient( - httpClient: auth.client, - hostedUri: hostedUri, - ); - - try { - final user = await client.getCurrentUser(); - logger.info(''' -You already have an account, ${user.displayName}! + if (auth.isAuthenticated) { + logger + ..info('You are already logged in as <${auth.email}>.') + ..info("Run 'shorebird logout' to log out and try again."); + return ExitCode.success.code; + } + + try { + await auth.getCredentials(prompt); + } catch (error) { + logger.err(error.toString()); + return ExitCode.software.code; + } + + final client = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + try { + final user = await client.getCurrentUser(); + // TODO(bryanoltman): change this message based on the user's subscription + // status. + logger.info(''' +You already have an account, ${user.displayName}! To subscribe, run ${green.wrap('shorebird account subscribe')}. '''); - return ExitCode.success.code; - } catch (_) {} - } - logger.info('Authorized as ${auth.email}'); + return ExitCode.success.code; + } on UserNotFoundException { + // Do nothing, it is expected that we don't have a user record for this + // email address. + } catch (error) { + logger.err(error.toString()); + return ExitCode.software.code; + } - final name = logger.prompt('What is your name?'); + final name = logger.prompt(''' +Tell us your name to finish creating your account:'''); final progress = logger.progress('Creating account'); + final User newUser; try { - final newUser = await client.createUser(name: name); - final paymentLink = await client.createPaymentLink(); - progress.complete( - ''' -🎉 ${lightGreen.wrap('Welcome to Shorebird, ${newUser.displayName}! You have successfully created an account as <${auth.email}>.')} - -🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. -🚪 To logout, use: "${lightCyan.wrap('shorebird logout')}". - -The next step is to purchase a Shorebird subscription. To subcribe, visit ${lightCyan.wrap('$paymentLink')} or run ${green.wrap('shorebird account subscribe')} later. -''', - ); - return ExitCode.success.code; + newUser = await client.createUser(name: name); } catch (error) { + auth.logout(); progress.fail(error.toString()); return ExitCode.software.code; } + + progress.complete( + lightGreen.wrap('Account created for ${newUser.email}!'), + ); + + logger.info( + ''' + +🎉 ${lightGreen.wrap('Welcome to Shorebird, ${newUser.displayName}!')} +🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. +🚪 To logout, use: "${lightCyan.wrap('shorebird logout')}". +⬆️ To upgrade your account, use: "${lightCyan.wrap('shorebird account subscribe')}". + +Enjoy! Please let us know via Discord if we can help.''', + ); + return ExitCode.success.code; } void prompt(String url) { logger.info(''' -Shorebird is currently only open to trusted testers. To participate, you will need a Google account for authentication. +Shorebird currently requires a Google account for authentication. If you'd like to use a different kind of auth, please let us know: ${lightCyan.wrap('https://github.com/shorebirdtech/shorebird/issues/335')}. -The first step is to sign in with a Google account. Please follow the sign-in link below: +Follow the link below to authenticate: ${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))} diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 13d708537..e15ac23d1 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -1,14 +1,19 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// {@template login_command} /// `shorebird login` /// Login as a new Shorebird user. /// {@endtemplate} -class LoginCommand extends ShorebirdCommand { +class LoginCommand extends ShorebirdCommand with ShorebirdConfigMixin { /// {@macro login_command} - LoginCommand({required super.logger, super.auth}); + LoginCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + }); @override String get description => 'Login as a new Shorebird user.'; @@ -26,25 +31,40 @@ class LoginCommand extends ShorebirdCommand { } try { - await auth.login(prompt); - logger.info(''' - -🎉 ${lightGreen.wrap('Welcome to Shorebird! You are now logged in as <${auth.email}>.')} + await auth.getCredentials(prompt); + final client = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); -🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. -🚪 To logout use: "${lightCyan.wrap('shorebird logout')}".'''); - return ExitCode.success.code; + // This will throw a UserNotFound exception if no user exists with an + // email address matching the provided credentials. + await client.getCurrentUser(); } on UserNotFoundException { - logger.err( - """ + logger + ..err( + ''' -We don't recognize that email address. Run the `shorebird account create` command to create an account.""", - ); +We could not find a Shorebird account for ${auth.email}.''', + ) + ..info( + """If you have not yet created an account, you can do so by running "${green.wrap('shorebird account create')}". If you believe this is an error, please reach out to us via Discord, we're happy to help!""", + ); + auth.logout(); return ExitCode.software.code; } catch (error) { logger.err(error.toString()); + auth.logout(); return ExitCode.software.code; } + + logger.info(''' + +🎉 ${lightGreen.wrap('Welcome to Shorebird! You are now logged in as <${auth.email}>.')} + +🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}. +🚪 To logout use: "${lightCyan.wrap('shorebird logout')}".'''); + return ExitCode.success.code; } void prompt(String url) { diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 9b009c5c1..2c01055dc 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -4,21 +4,17 @@ import 'package:googleapis_auth/googleapis_auth.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; class _FakeBaseRequest extends Fake implements http.BaseRequest {} class _MockHttpClient extends Mock implements http.Client {} -class _MockCodePushClient extends Mock implements CodePushClient {} - void main() { group('Auth', () { const idToken = '''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg'''; const email = 'test@email.com'; - const user = User(id: 42, email: email); const refreshToken = ''; const scopes = []; final accessToken = AccessToken( @@ -36,7 +32,6 @@ void main() { late String credentialsDir; late http.Client httpClient; - late CodePushClient codePushClient; late Auth auth; setUpAll(() { @@ -47,9 +42,6 @@ void main() { return Auth( credentialsDir: credentialsDir, httpClient: httpClient, - buildCodePushClient: ({Uri? hostedUri, http.Client? httpClient}) { - return codePushClient; - }, obtainAccessCredentials: (clientId, scopes, client, userPrompt) async { return accessCredentials; }, @@ -59,10 +51,7 @@ void main() { setUp(() { credentialsDir = Directory.systemTemp.createTempSync().path; httpClient = _MockHttpClient(); - codePushClient = _MockCodePushClient(); auth = buildAuth(); - - when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user); }); group('AuthenticatedClient', () { @@ -143,7 +132,7 @@ void main() { HttpStatus.ok, ), ); - await auth.login((_) {}); + await auth.getCredentials((_) {}); final client = auth.client; expect(client, isA()); expect(client, isA()); @@ -165,56 +154,21 @@ void main() { }); }); - group('login', () { + group('getCredentials', () { test( 'should set the email when claims are valid ' 'and current user exists', () async { - await auth.login((_) {}); + await auth.getCredentials((_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); expect(buildAuth().email, email); expect(buildAuth().isAuthenticated, isTrue); }); - - test('should not set the email when user does not exist', () async { - const exception = CodePushException(message: 'oops'); - when(() => codePushClient.getCurrentUser()).thenThrow(exception); - await expectLater(auth.login((_) {}), throwsA(exception)); - expect(auth.email, isNull); - expect(auth.isAuthenticated, isFalse); - }); - - test( - "throws exception if credentials email doesn't match current user", - () async { - when(() => codePushClient.getCurrentUser()).thenAnswer( - (_) async => const User( - id: 123, - email: 'email@email.com', - ), - ); - - await expectLater(auth.login((_) {}), throwsException); - - expect(auth.email, isNull); - expect(auth.isAuthenticated, isFalse); - }, - ); - - test( - 'does not fetch current user if verifyEmail is false', - () async { - await auth.login((_) {}, verifyEmail: false); - verifyNever(() => codePushClient.getCurrentUser()); - expect(buildAuth().email, email); - expect(buildAuth().isAuthenticated, isTrue); - }, - ); }); group('logout', () { test('clears session and wipes state', () async { - await auth.login((_) {}); + await auth.getCredentials((_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); diff --git a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart index e957ae3cd..0028624dd 100644 --- a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart @@ -21,7 +21,6 @@ class _MockUser extends Mock implements User {} void main() { const userName = 'John Doe'; const email = 'tester@shorebird.dev'; - final paymentLink = Uri.parse('https://example.com/payment-link'); late Auth auth; late CodePushClient codePushClient; @@ -52,20 +51,13 @@ void main() { when(() => auth.email).thenReturn(email); when(() => auth.credentialsFilePath).thenReturn('credentials.json'); when(() => auth.isAuthenticated).thenReturn(false); - when( - () => auth.login(any(), verifyEmail: any(named: 'verifyEmail')), - ).thenAnswer((_) async {}); + when(() => auth.getCredentials(any())).thenAnswer((_) async {}); when(() => codePushClient.createUser(name: userName)) .thenAnswer((_) async => user); - when(() => codePushClient.createPaymentLink()) - .thenAnswer((_) async => paymentLink); - when(() => codePushClient.getCurrentUser()).thenThrow( - Exception('failed to get current user'), - ); + when(() => codePushClient.getCurrentUser()) + .thenThrow(UserNotFoundException()); - when(() => logger.err(any())).thenReturn(null); - when(() => logger.info(any())).thenReturn(null); when(() => logger.progress(any())).thenReturn(progress); when(() => logger.prompt(any())).thenReturn(userName); @@ -73,6 +65,7 @@ void main() { when(() => progress.fail(any())).thenReturn(null); when(() => user.displayName).thenReturn(userName); + when(() => user.email).thenReturn(email); }); test('has a description', () { @@ -83,9 +76,9 @@ void main() { createAccountCommand.prompt('https://shorebird.dev'); verify( () => logger.info(''' -Shorebird is currently only open to trusted testers. To participate, you will need a Google account for authentication. +Shorebird currently requires a Google account for authentication. If you'd like to use a different kind of auth, please let us know: ${lightCyan.wrap('https://github.com/shorebirdtech/shorebird/issues/335')}. -The first step is to sign in with a Google account. Please follow the sign-in link below: +Follow the link below to authenticate: ${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap('https://shorebird.dev')))} @@ -93,23 +86,22 @@ Waiting for your authorization...'''), ).called(1); }); - test('exits with code 70 when login fails', () async { + test('exits with code 70 when getCredentials fails', () async { when(() => auth.isAuthenticated).thenReturn(false); when( - () => auth.login(any(), verifyEmail: any(named: 'verifyEmail')), + () => auth.getCredentials(any()), ).thenThrow(Exception('login failed')); final result = await createAccountCommand.run(); expect(result, ExitCode.software.code); - verify(() => auth.login(any(), verifyEmail: false)).called(1); + verify(() => auth.getCredentials(any())).called(1); verify(() => logger.err(any(that: contains('login failed')))).called(1); }); test( 'exits with code 0 and prints message and exits if user already has an ' 'account', () async { - when(() => auth.isAuthenticated).thenReturn(true); when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user); final result = await createAccountCommand.run(); @@ -120,47 +112,27 @@ Waiting for your authorization...'''), ).called(1); }); - test( - 'proceeds with account creation if user is authenticated but does not ' - 'have an account', - () async { - when(() => auth.isAuthenticated).thenReturn(true); - final result = await createAccountCommand.run(); - - expect(result, ExitCode.success.code); - verify(() => codePushClient.createUser(name: userName)).called(1); - verify(() => codePushClient.createPaymentLink()).called(1); - }, - ); - - test('exits with code 70 if createUser fails', () async { - const errorMessage = 'failed to create user'; - when(() => auth.isAuthenticated).thenReturn(false); - when(() => codePushClient.createUser(name: any(named: 'name'))) - .thenThrow(Exception(errorMessage)); + test('exits with code 70 if an unknown error occurs in getCurrentUser', + () async { + when(() => codePushClient.getCurrentUser()).thenThrow(Exception('oh no')); final result = await createAccountCommand.run(); expect(result, ExitCode.software.code); - verify(() => auth.login(any(), verifyEmail: false)).called(1); - verify(() => codePushClient.createUser(name: userName)).called(1); - verifyNever(() => codePushClient.createPaymentLink()); - verify(() => progress.fail(any(that: contains(errorMessage)))).called(1); + verify(() => logger.err(any(that: contains('oh no')))).called(1); }); - test('exits with code 70 if createPaymentLink fails', () async { - const errorMessage = 'failed to create payment link'; - - when(() => auth.isAuthenticated).thenReturn(false); - when(() => codePushClient.createPaymentLink()) + test('exits with code 70 if createUser fails', () async { + const errorMessage = 'failed to create user'; + when(() => codePushClient.createUser(name: any(named: 'name'))) .thenThrow(Exception(errorMessage)); final result = await createAccountCommand.run(); expect(result, ExitCode.software.code); - verify(() => auth.login(any(), verifyEmail: false)).called(1); + verify(() => auth.getCredentials(any())).called(1); + verify(() => auth.logout()).called(1); verify(() => codePushClient.createUser(name: userName)).called(1); - verify(() => codePushClient.createPaymentLink()).called(1); verify(() => progress.fail(any(that: contains(errorMessage)))).called(1); }); @@ -169,19 +141,26 @@ Waiting for your authorization...'''), final result = await createAccountCommand.run(); expect(result, ExitCode.success.code); - verify(() => auth.login(any(), verifyEmail: false)).called(1); - verify(() => logger.prompt('What is your name?')).called(1); - verify(() => codePushClient.createUser(name: userName)).called(1); - verify(() => codePushClient.createPaymentLink()).called(1); + verify(() => auth.getCredentials(any())).called(1); + verify( + () => + logger.prompt('Tell us your name to finish creating your account:'), + ).called(1); verify( () => progress.complete( + any( + that: stringContainsInOrder(['Account created', email]), + ), + ), + ).called(1); + verify(() => codePushClient.createUser(name: userName)).called(1); + verify( + () => logger.info( any( that: stringContainsInOrder([ 'Welcome to Shorebird', userName, - email, - 'purchase a Shorebird subscription', - paymentLink.toString(), + 'shorebird account subscribe', ]), ), ), diff --git a/packages/shorebird_cli/test/src/commands/login_command_test.dart b/packages/shorebird_cli/test/src/commands/login_command_test.dart index f5ecd98e0..fc45f8ac7 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; @@ -10,6 +11,10 @@ import 'package:test/test.dart'; class _MockAuth extends Mock implements Auth {} +class _MockHttpClient extends Mock implements http.Client {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + class _MockLogger extends Mock implements Logger {} void main() { @@ -19,18 +24,34 @@ void main() { late Directory applicationConfigHome; late Logger logger; late Auth auth; + late CodePushClient codePushClient; + late http.Client httpClient; late LoginCommand loginCommand; setUp(() { applicationConfigHome = Directory.systemTemp.createTempSync(); - logger = _MockLogger(); auth = _MockAuth(); - loginCommand = LoginCommand(auth: auth, logger: logger); + codePushClient = _MockCodePushClient(); + httpClient = _MockHttpClient(); + logger = _MockLogger(); + loginCommand = LoginCommand( + auth: auth, + logger: logger, + buildCodePushClient: ({required httpClient, hostedUri}) => + codePushClient, + ); + when(() => auth.client).thenReturn(httpClient); when(() => auth.credentialsFilePath).thenReturn( p.join(applicationConfigHome.path, 'credentials.json'), ); when(() => auth.isAuthenticated).thenReturn(false); + when(() => auth.email).thenReturn(email); + when(() => auth.getCredentials(any())).thenAnswer((_) async => {}); + when(() => auth.logout()).thenReturn(null); + + when(() => codePushClient.getCurrentUser()) + .thenAnswer((_) async => const User(id: 1, email: email)); }); test('exits with code 0 when already logged in', () async { @@ -50,42 +71,48 @@ void main() { test('exits with code 70 when error occurs', () async { final error = Exception('oops something went wrong!'); - when(() => auth.login(any())).thenThrow(error); + when(() => auth.getCredentials(any())).thenThrow(error); final result = await loginCommand.run(); expect(result, equals(ExitCode.software.code)); - verify(() => auth.login(any())).called(1); + verify(() => auth.getCredentials(any())).called(1); + verify(() => auth.logout()).called(1); verify(() => logger.err(error.toString())).called(1); }); test('exits with code 70 if user does not have an account', () async { - when(() => auth.login(any())).thenThrow(UserNotFoundException()); + when(() => codePushClient.getCurrentUser()) + .thenThrow(UserNotFoundException()); final result = await loginCommand.run(); expect(result, equals(ExitCode.software.code)); - verify(() => auth.login(any())).called(1); + verify(() => auth.getCredentials(any())).called(1); + verify(() => auth.logout()).called(1); verify( () => logger.err( any( - that: stringContainsInOrder([ - "We don't recognize that email address", - 'shorebird account create', - ]), + that: stringContainsInOrder( + ['We could not find a Shorebird account for', email], + ), ), ), ).called(1); + verify( + () => logger.info(any(that: contains('shorebird account create'))), + ).called(1); }); test('exits with code 0 when logged in successfully', () async { - when(() => auth.login(any())).thenAnswer((_) async {}); + when(() => auth.getCredentials(any())).thenAnswer((_) async {}); when(() => auth.email).thenReturn(email); final result = await loginCommand.run(); expect(result, equals(ExitCode.success.code)); - verify(() => auth.login(any())).called(1); + verify(() => auth.getCredentials(any())).called(1); + verifyNever(() => auth.logout()); verify( () => logger.info( any(that: contains('You are now logged in as <$email>.')), From 0c2928e1cbbee789858f977cbf3e78d4be0df788 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 13:07:19 -0400 Subject: [PATCH 05/17] Coverage --- .../account/create_account_command_test.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart index 0028624dd..e07184179 100644 --- a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart @@ -86,8 +86,19 @@ Waiting for your authorization...'''), ).called(1); }); + test('exits with code 0 if user is logged in', () async { + when(() => auth.isAuthenticated).thenReturn(true); + + final result = await createAccountCommand.run(); + + expect(result, ExitCode.success.code); + + verify( + () => logger.info(any(that: contains('You are already logged in '))), + ).called(1); + }); + test('exits with code 70 when getCredentials fails', () async { - when(() => auth.isAuthenticated).thenReturn(false); when( () => auth.getCredentials(any()), ).thenThrow(Exception('login failed')); From 54345e719054fb4a5b0e9ea984006393a787a57f Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 13:12:24 -0400 Subject: [PATCH 06/17] Remove createPaymentLink for now --- .../lib/src/code_push_client.dart | 15 ------- .../test/src/code_push_client_test.dart | 43 ------------------- 2 files changed, 58 deletions(-) diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index 0e30935f6..6b04e9297 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -166,21 +166,6 @@ class CodePushClient { return Patch.fromJson(body); } - /// Generates a Stripe payment link for the current user. - Future createPaymentLink() async { - final response = await _httpClient.post( - Uri.parse('$hostedUri/api/v1/subscriptions/payment_link'), - ); - - if (response.statusCode != HttpStatus.ok) { - throw _parseErrorResponse(response.body); - } - - return CreatePaymentLinkResponse.fromJson( - json.decode(response.body) as Json, - ).paymentLink; - } - /// Create a new release for the app with the provided [appId]. Future createRelease({ required String appId, diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index dd3d0c1f8..18434847b 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -625,49 +625,6 @@ void main() { }); }); - group('createPaymentLink', () { - test('throws an exception if the http request fails', () { - when( - () => httpClient.post( - any(), - headers: any(named: 'headers'), - ), - ).thenAnswer((_) async => http.Response('', HttpStatus.badRequest)); - - expect( - codePushClient.createPaymentLink(), - throwsA( - isA().having( - (e) => e.message, - 'message', - CodePushClient.unknownErrorMessage, - ), - ), - ); - }); - - test('returns a payment link if the http request succeeds', () { - final link = Uri.parse('http://test.com'); - - when( - () => httpClient.post( - any(), - headers: any(named: 'headers'), - ), - ).thenAnswer( - (_) async => http.Response( - jsonEncode(CreatePaymentLinkResponse(paymentLink: link).toJson()), - HttpStatus.ok, - ), - ); - - expect( - codePushClient.createPaymentLink(), - completion(link), - ); - }); - }); - group('createRelease', () { const version = '1.0.0'; test('throws an exception if the http request fails (unknown)', () async { From 4897cfa9e17cb2a4b06f96084e4acd043c1f2352 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 15:30:53 -0400 Subject: [PATCH 07/17] Feedback --- packages/shorebird_cli/lib/src/auth/auth.dart | 93 ++++++++++++++- .../account/create_account_command.dart | 62 +++------- .../lib/src/commands/login_command.dart | 36 ++---- .../cancel_subscription_command.dart | 2 +- .../test/src/auth/auth_test.dart | 86 +++++++++++++- .../account/create_account_command_test.dart | 111 +++++++----------- .../test/src/commands/login_command_test.dart | 75 ++++-------- .../lib/src/code_push_client.dart | 12 +- 8 files changed, 259 insertions(+), 218 deletions(-) diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index b26892a39..3695adc93 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -6,7 +6,9 @@ import 'package:googleapis_auth/auth_io.dart' as oauth2; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/auth/jwt.dart'; +import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/command_runner.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; final _clientId = oauth2.ClientId( /// Shorebird CLI's OAuth 2.0 identifier. @@ -80,17 +82,20 @@ class Auth { http.Client? httpClient, String? credentialsDir, ObtainAccessCredentials? obtainAccessCredentials, + CodePushClientBuilder? buildCodePushClient, }) : _httpClient = httpClient ?? http.Client(), _credentialsDir = credentialsDir ?? applicationConfigHome(executableName), _obtainAccessCredentials = obtainAccessCredentials ?? - oauth2.obtainAccessCredentialsViaUserConsent { + oauth2.obtainAccessCredentialsViaUserConsent, + _buildCodePushClient = buildCodePushClient ?? CodePushClient.new { _loadCredentials(); } final http.Client _httpClient; final String _credentialsDir; final ObtainAccessCredentials _obtainAccessCredentials; + final CodePushClientBuilder _buildCodePushClient; String get credentialsFilePath { return p.join(_credentialsDir, 'credentials.json'); @@ -106,8 +111,10 @@ class Auth { ); } - Future getCredentials(void Function(String) prompt) async { - if (_credentials != null) return; + Future login(void Function(String) prompt) async { + if (_credentials != null) { + throw UserAlreadyLoggedInException(email: _credentials!.email!); + } final client = http.Client(); try { @@ -118,7 +125,14 @@ class Auth { prompt, ); - _email = _credentials!.email; + final codePushClient = _buildCodePushClient(httpClient: this.client); + + final user = await codePushClient.getCurrentUser(); + if (user == null) { + throw UserNotFoundException(email: _credentials!.email!); + } + + _email = user.email; _flushCredentials(_credentials!); } finally { client.close(); @@ -127,6 +141,42 @@ class Auth { void logout() => _clearCredentials(); + Future signUp({ + required void Function(String) authPrompt, + required String Function() namePrompt, + }) async { + if (_credentials != null) { + throw UserAlreadyLoggedInException(email: _credentials!.email!); + } + + final client = http.Client(); + final User newUser; + try { + _credentials = await _obtainAccessCredentials( + _clientId, + _scopes, + client, + authPrompt, + ); + + final codePushClient = _buildCodePushClient(httpClient: this.client); + + final existingUser = await codePushClient.getCurrentUser(); + if (existingUser != null) { + throw UserAlreadyExistsException(existingUser); + } + + newUser = await codePushClient.createUser(name: namePrompt()); + + _email = newUser.email; + _flushCredentials(_credentials!); + } finally { + client.close(); + } + + return newUser; + } + oauth2.AccessCredentials? _credentials; String? _email; @@ -144,7 +194,7 @@ class Auth { _credentials = oauth2.AccessCredentials.fromJson( json.decode(contents) as Map, ); - _email = _credentials!.email; + _email = _credentials?.email; } catch (_) {} } } @@ -183,3 +233,36 @@ extension on oauth2.AccessCredentials { return claims['email'] as String?; } } + +/// Thrown when an already authenticated user attempts to log in or sign up. +class UserAlreadyLoggedInException implements Exception { + /// {@macro user_already_logged_in_exception} + UserAlreadyLoggedInException({required this.email}); + + /// The email of the already authenticated user, as derived from the stored + /// auth credentials. + final String email; +} + +/// {@template user_not_found_exception} +/// Thrown when an attempt to fetch a User object results in a 404. +/// {@endtemplate} +class UserNotFoundException implements Exception { + /// {@macro user_not_found_exception} + UserNotFoundException({required this.email}); + + /// The email used to locate the user, as derived from the stored auth + /// credentials. + final String email; +} + +/// {@template user_already_exists_exception} +/// Thrown when an attempt to create a User object results in a 409. +/// {@endtemplate} +class UserAlreadyExistsException implements Exception { + /// {@macro user_already_exists_exception} + UserAlreadyExistsException(this.user); + + /// The extant user. + final User user; +} diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart index 1b84f9ab9..c3ba8d247 100644 --- a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -11,11 +12,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// {@endtemplate} class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { /// {@macro create_account_command} - CreateAccountCommand({ - required super.logger, - super.auth, - super.buildCodePushClient, - }); + CreateAccountCommand({required super.logger, super.auth}); @override String get description => 'Create a new Shorebird account.'; @@ -25,60 +22,30 @@ class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { @override Future run() async { - if (auth.isAuthenticated) { + final User newUser; + try { + newUser = await auth.signUp( + authPrompt: authPrompt, + namePrompt: namePrompt, + ); + } on UserAlreadyLoggedInException catch (error) { logger - ..info('You are already logged in as <${auth.email}>.') + ..info('You are already logged in as <${error.email}>.') ..info("Run 'shorebird logout' to log out and try again."); return ExitCode.success.code; - } - - try { - await auth.getCredentials(prompt); - } catch (error) { - logger.err(error.toString()); - return ExitCode.software.code; - } - - final client = buildCodePushClient( - httpClient: auth.client, - hostedUri: hostedUri, - ); - - try { - final user = await client.getCurrentUser(); + } on UserAlreadyExistsException catch (error) { // TODO(bryanoltman): change this message based on the user's subscription // status. logger.info(''' -You already have an account, ${user.displayName}! +You already have an account, ${error.user.displayName}! To subscribe, run ${green.wrap('shorebird account subscribe')}. '''); - return ExitCode.success.code; - } on UserNotFoundException { - // Do nothing, it is expected that we don't have a user record for this - // email address. } catch (error) { logger.err(error.toString()); return ExitCode.software.code; } - final name = logger.prompt(''' -Tell us your name to finish creating your account:'''); - - final progress = logger.progress('Creating account'); - final User newUser; - try { - newUser = await client.createUser(name: name); - } catch (error) { - auth.logout(); - progress.fail(error.toString()); - return ExitCode.software.code; - } - - progress.complete( - lightGreen.wrap('Account created for ${newUser.email}!'), - ); - logger.info( ''' @@ -92,7 +59,7 @@ Enjoy! Please let us know via Discord if we can help.''', return ExitCode.success.code; } - void prompt(String url) { + void authPrompt(String url) { logger.info(''' Shorebird currently requires a Google account for authentication. If you'd like to use a different kind of auth, please let us know: ${lightCyan.wrap('https://github.com/shorebirdtech/shorebird/issues/335')}. @@ -102,4 +69,7 @@ ${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))} Waiting for your authorization...'''); } + + String namePrompt() => logger.prompt(''' +Tell us your name to finish creating your account:'''); } diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index e15ac23d1..9f1f695a2 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -1,19 +1,14 @@ import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// {@template login_command} /// `shorebird login` /// Login as a new Shorebird user. /// {@endtemplate} -class LoginCommand extends ShorebirdCommand with ShorebirdConfigMixin { +class LoginCommand extends ShorebirdCommand { /// {@macro login_command} - LoginCommand({ - required super.logger, - super.auth, - super.buildCodePushClient, - }); + LoginCommand({required super.logger, super.auth}); @override String get description => 'Login as a new Shorebird user.'; @@ -23,38 +18,25 @@ class LoginCommand extends ShorebirdCommand with ShorebirdConfigMixin { @override Future run() async { - if (auth.isAuthenticated) { + try { + await auth.login(prompt); + } on UserAlreadyLoggedInException catch (error) { logger - ..info('You are already logged in as <${auth.email}>.') + ..info('You are already logged in as <${error.email}>.') ..info("Run 'shorebird logout' to log out and try again."); return ExitCode.success.code; - } - - try { - await auth.getCredentials(prompt); - final client = buildCodePushClient( - httpClient: auth.client, - hostedUri: hostedUri, - ); - - // This will throw a UserNotFound exception if no user exists with an - // email address matching the provided credentials. - await client.getCurrentUser(); - } on UserNotFoundException { + } on UserNotFoundException catch (error) { logger ..err( ''' - -We could not find a Shorebird account for ${auth.email}.''', +We could not find a Shorebird account for ${error.email}.''', ) ..info( """If you have not yet created an account, you can do so by running "${green.wrap('shorebird account create')}". If you believe this is an error, please reach out to us via Discord, we're happy to help!""", ); - auth.logout(); return ExitCode.software.code; } catch (error) { logger.err(error.toString()); - auth.logout(); return ExitCode.software.code; } diff --git a/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart b/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart index ca68f0df1..0048febbc 100644 --- a/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart +++ b/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart @@ -34,7 +34,7 @@ class CancelSubscriptionCommand extends ShorebirdCommand final User user; try { - user = await client.getCurrentUser(); + user = (await client.getCurrentUser())!; } catch (error) { logger.err(error.toString()); return ExitCode.software.code; diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 2c01055dc..781bfa49a 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -4,17 +4,22 @@ import 'package:googleapis_auth/googleapis_auth.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; class _FakeBaseRequest extends Fake implements http.BaseRequest {} class _MockHttpClient extends Mock implements http.Client {} +class _MockCodePushClient extends Mock implements CodePushClient {} + void main() { group('Auth', () { const idToken = '''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg'''; const email = 'test@email.com'; + const name = 'Jane Doe'; + const user = User(id: 42, email: email); const refreshToken = ''; const scopes = []; final accessToken = AccessToken( @@ -32,6 +37,7 @@ void main() { late String credentialsDir; late http.Client httpClient; + late CodePushClient codePushClient; late Auth auth; setUpAll(() { @@ -42,6 +48,9 @@ void main() { return Auth( credentialsDir: credentialsDir, httpClient: httpClient, + buildCodePushClient: ({Uri? hostedUri, http.Client? httpClient}) { + return codePushClient; + }, obtainAccessCredentials: (clientId, scopes, client, userPrompt) async { return accessCredentials; }, @@ -51,7 +60,10 @@ void main() { setUp(() { credentialsDir = Directory.systemTemp.createTempSync().path; httpClient = _MockHttpClient(); + codePushClient = _MockCodePushClient(); auth = buildAuth(); + + when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user); }); group('AuthenticatedClient', () { @@ -132,7 +144,7 @@ void main() { HttpStatus.ok, ), ); - await auth.getCredentials((_) {}); + await auth.login((_) {}); final client = auth.client; expect(client, isA()); expect(client, isA()); @@ -154,21 +166,87 @@ void main() { }); }); - group('getCredentials', () { + group('login', () { test( 'should set the email when claims are valid ' 'and current user exists', () async { - await auth.getCredentials((_) {}); + await auth.login((_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); expect(buildAuth().email, email); expect(buildAuth().isAuthenticated, isTrue); }); + + test('should not set the email when user does not exist', () async { + when(() => codePushClient.getCurrentUser()) + .thenAnswer((_) async => null); + await expectLater( + auth.login((_) {}), + throwsA(isA()), + ); + expect(auth.email, isNull); + expect(auth.isAuthenticated, isFalse); + }); + }); + + group('signUp', () { + test( + 'should set the email when claims are valid and user is successfully ' + 'created', () async { + when(() => codePushClient.getCurrentUser()) + .thenAnswer((_) async => null); + when(() => codePushClient.createUser(name: any())) + .thenAnswer((_) async => user); + + final newUser = await auth.signUp( + authPrompt: (_) {}, + namePrompt: () => name, + ); + expect(user, newUser); + expect(auth.email, email); + expect(auth.isAuthenticated, isTrue); + expect(buildAuth().email, email); + expect(buildAuth().isAuthenticated, isTrue); + }); + + test('throws UserAlreadyExistsException if user already exists', + () async { + when(() => codePushClient.getCurrentUser()) + .thenAnswer((_) async => user); + + await expectLater( + auth.signUp( + authPrompt: (_) {}, + namePrompt: () => name, + ), + throwsA(isA()), + ); + verifyNever(() => codePushClient.createUser(name: any(named: 'name'))); + expect(auth.email, isNull); + expect(auth.isAuthenticated, isFalse); + }); + + test('throws exception if createUser fails', () async { + when(() => codePushClient.getCurrentUser()) + .thenAnswer((_) async => null); + when(() => codePushClient.createUser(name: any(named: 'name'))) + .thenThrow(Exception('oh no!')); + + await expectLater( + auth.signUp( + authPrompt: (_) {}, + namePrompt: () => name, + ), + throwsA(isA()), + ); + expect(auth.email, isNull); + expect(auth.isAuthenticated, isFalse); + }); }); group('logout', () { test('clears session and wipes state', () async { - await auth.getCredentials((_) {}); + await auth.login((_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); diff --git a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart index e07184179..86323f678 100644 --- a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart @@ -1,4 +1,3 @@ -import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; @@ -8,14 +7,8 @@ import 'package:test/test.dart'; class _MockAuth extends Mock implements Auth {} -class _MockCodePushClient extends Mock implements CodePushClient {} - -class _MockHttpClient extends Mock implements http.Client {} - class _MockLogger extends Mock implements Logger {} -class _MockProgress extends Mock implements Progress {} - class _MockUser extends Mock implements User {} void main() { @@ -23,10 +16,7 @@ void main() { const email = 'tester@shorebird.dev'; late Auth auth; - late CodePushClient codePushClient; - late http.Client httpClient; late Logger logger; - late Progress progress; late User user; late CreateAccountCommand createAccountCommand; @@ -34,36 +24,18 @@ void main() { group(CreateAccountCommand, () { setUp(() { auth = _MockAuth(); - codePushClient = _MockCodePushClient(); - httpClient = _MockHttpClient(); logger = _MockLogger(); - progress = _MockProgress(); user = _MockUser(); createAccountCommand = CreateAccountCommand( logger: logger, auth: auth, - buildCodePushClient: ({required httpClient, hostedUri}) => - codePushClient, ); - when(() => auth.client).thenReturn(httpClient); - when(() => auth.email).thenReturn(email); when(() => auth.credentialsFilePath).thenReturn('credentials.json'); - when(() => auth.isAuthenticated).thenReturn(false); - when(() => auth.getCredentials(any())).thenAnswer((_) async {}); - when(() => codePushClient.createUser(name: userName)) - .thenAnswer((_) async => user); - when(() => codePushClient.getCurrentUser()) - .thenThrow(UserNotFoundException()); - - when(() => logger.progress(any())).thenReturn(progress); when(() => logger.prompt(any())).thenReturn(userName); - when(() => progress.complete(any())).thenReturn(null); - when(() => progress.fail(any())).thenReturn(null); - when(() => user.displayName).thenReturn(userName); when(() => user.email).thenReturn(email); }); @@ -73,7 +45,7 @@ void main() { }); test('login prompt is correct', () { - createAccountCommand.prompt('https://shorebird.dev'); + createAccountCommand.authPrompt('https://shorebird.dev'); verify( () => logger.info(''' Shorebird currently requires a Google account for authentication. If you'd like to use a different kind of auth, please let us know: ${lightCyan.wrap('https://github.com/shorebirdtech/shorebird/issues/335')}. @@ -86,8 +58,22 @@ Waiting for your authorization...'''), ).called(1); }); + test('namePrompt asks user for name', () { + final name = createAccountCommand.namePrompt(); + expect(name, userName); + verify( + () => + logger.prompt('Tell us your name to finish creating your account:'), + ).called(1); + }); + test('exits with code 0 if user is logged in', () async { - when(() => auth.isAuthenticated).thenReturn(true); + when( + () => auth.signUp( + authPrompt: any(named: 'authPrompt'), + namePrompt: any(named: 'namePrompt'), + ), + ).thenThrow(UserAlreadyLoggedInException(email: email)); final result = await createAccountCommand.run(); @@ -98,22 +84,15 @@ Waiting for your authorization...'''), ).called(1); }); - test('exits with code 70 when getCredentials fails', () async { - when( - () => auth.getCredentials(any()), - ).thenThrow(Exception('login failed')); - - final result = await createAccountCommand.run(); - - expect(result, ExitCode.software.code); - verify(() => auth.getCredentials(any())).called(1); - verify(() => logger.err(any(that: contains('login failed')))).called(1); - }); - test( 'exits with code 0 and prints message and exits if user already has an ' 'account', () async { - when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user); + when( + () => auth.signUp( + authPrompt: any(named: 'authPrompt'), + namePrompt: any(named: 'namePrompt'), + ), + ).thenThrow(UserAlreadyExistsException(user)); final result = await createAccountCommand.run(); @@ -123,48 +102,38 @@ Waiting for your authorization...'''), ).called(1); }); - test('exits with code 70 if an unknown error occurs in getCurrentUser', - () async { - when(() => codePushClient.getCurrentUser()).thenThrow(Exception('oh no')); - - final result = await createAccountCommand.run(); - - expect(result, ExitCode.software.code); - verify(() => logger.err(any(that: contains('oh no')))).called(1); - }); - - test('exits with code 70 if createUser fails', () async { - const errorMessage = 'failed to create user'; - when(() => codePushClient.createUser(name: any(named: 'name'))) - .thenThrow(Exception(errorMessage)); + test('exits with code 70 when signUp fails', () async { + when( + () => auth.signUp( + authPrompt: any(named: 'authPrompt'), + namePrompt: any(named: 'namePrompt'), + ), + ).thenThrow(Exception('login failed')); final result = await createAccountCommand.run(); expect(result, ExitCode.software.code); - verify(() => auth.getCredentials(any())).called(1); - verify(() => auth.logout()).called(1); - verify(() => codePushClient.createUser(name: userName)).called(1); - verify(() => progress.fail(any(that: contains(errorMessage)))).called(1); + verify(() => logger.err(any(that: contains('login failed')))).called(1); }); test('exits with code 0, creates account with name provided by user', () async { + when( + () => auth.signUp( + authPrompt: any(named: 'authPrompt'), + namePrompt: any(named: 'namePrompt'), + ), + ).thenAnswer((_) async => user); + final result = await createAccountCommand.run(); expect(result, ExitCode.success.code); - verify(() => auth.getCredentials(any())).called(1); verify( - () => - logger.prompt('Tell us your name to finish creating your account:'), - ).called(1); - verify( - () => progress.complete( - any( - that: stringContainsInOrder(['Account created', email]), - ), + () => auth.signUp( + authPrompt: any(named: 'authPrompt'), + namePrompt: any(named: 'namePrompt'), ), ).called(1); - verify(() => codePushClient.createUser(name: userName)).called(1); verify( () => logger.info( any( diff --git a/packages/shorebird_cli/test/src/commands/login_command_test.dart b/packages/shorebird_cli/test/src/commands/login_command_test.dart index fc45f8ac7..27005b3f4 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -1,20 +1,14 @@ import 'dart:io'; -import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/commands/login_command.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; class _MockAuth extends Mock implements Auth {} -class _MockHttpClient extends Mock implements http.Client {} - -class _MockCodePushClient extends Mock implements CodePushClient {} - class _MockLogger extends Mock implements Logger {} void main() { @@ -24,39 +18,22 @@ void main() { late Directory applicationConfigHome; late Logger logger; late Auth auth; - late CodePushClient codePushClient; - late http.Client httpClient; late LoginCommand loginCommand; setUp(() { applicationConfigHome = Directory.systemTemp.createTempSync(); - auth = _MockAuth(); - codePushClient = _MockCodePushClient(); - httpClient = _MockHttpClient(); logger = _MockLogger(); - loginCommand = LoginCommand( - auth: auth, - logger: logger, - buildCodePushClient: ({required httpClient, hostedUri}) => - codePushClient, - ); + auth = _MockAuth(); + loginCommand = LoginCommand(auth: auth, logger: logger); - when(() => auth.client).thenReturn(httpClient); when(() => auth.credentialsFilePath).thenReturn( p.join(applicationConfigHome.path, 'credentials.json'), ); - when(() => auth.isAuthenticated).thenReturn(false); - when(() => auth.email).thenReturn(email); - when(() => auth.getCredentials(any())).thenAnswer((_) async => {}); - when(() => auth.logout()).thenReturn(null); - - when(() => codePushClient.getCurrentUser()) - .thenAnswer((_) async => const User(id: 1, email: email)); }); test('exits with code 0 when already logged in', () async { - when(() => auth.isAuthenticated).thenReturn(true); - when(() => auth.email).thenReturn(email); + when(() => auth.login(any())) + .thenThrow(UserAlreadyLoggedInException(email: email)); final result = await loginCommand.run(); expect(result, equals(ExitCode.success.code)); @@ -69,50 +46,40 @@ void main() { ).called(1); }); - test('exits with code 70 when error occurs', () async { - final error = Exception('oops something went wrong!'); - when(() => auth.getCredentials(any())).thenThrow(error); + test('exits with code 70 if no user is found', () async { + when(() => auth.login(any())) + .thenThrow(UserNotFoundException(email: email)); final result = await loginCommand.run(); expect(result, equals(ExitCode.software.code)); - verify(() => auth.getCredentials(any())).called(1); - verify(() => auth.logout()).called(1); - verify(() => logger.err(error.toString())).called(1); - }); - - test('exits with code 70 if user does not have an account', () async { - when(() => codePushClient.getCurrentUser()) - .thenThrow(UserNotFoundException()); - - final result = await loginCommand.run(); - - expect(result, equals(ExitCode.software.code)); - verify(() => auth.getCredentials(any())).called(1); - verify(() => auth.logout()).called(1); verify( - () => logger.err( - any( - that: stringContainsInOrder( - ['We could not find a Shorebird account for', email], - ), - ), - ), + () => logger.err('We could not find a Shorebird account for $email.'), ).called(1); verify( () => logger.info(any(that: contains('shorebird account create'))), ).called(1); }); + test('exits with code 70 when error occurs', () async { + final error = Exception('oops something went wrong!'); + when(() => auth.login(any())).thenThrow(error); + + final result = await loginCommand.run(); + expect(result, equals(ExitCode.software.code)); + + verify(() => auth.login(any())).called(1); + verify(() => logger.err(error.toString())).called(1); + }); + test('exits with code 0 when logged in successfully', () async { - when(() => auth.getCredentials(any())).thenAnswer((_) async {}); + when(() => auth.login(any())).thenAnswer((_) async {}); when(() => auth.email).thenReturn(email); final result = await loginCommand.run(); expect(result, equals(ExitCode.success.code)); - verify(() => auth.getCredentials(any())).called(1); - verifyNever(() => auth.logout()); + verify(() => auth.login(any())).called(1); verify( () => logger.info( any(that: contains('You are now logged in as <$email>.')), diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index 6b04e9297..f8a273b2b 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -21,14 +21,6 @@ class CodePushException implements Exception { String toString() => '$message${details != null ? '\n$details' : ''}'; } -/// {@template user_not_found_exception}o -/// Thrown when an attempt to fetch a User object results in a 404. -/// {@endtemplate} -class UserNotFoundException implements Exception { - /// {@macro user_not_found_exception} - UserNotFoundException(); -} - /// {@template code_push_client} /// Dart client for the Shorebird CodePush API. /// {@endtemplate} @@ -49,12 +41,12 @@ class CodePushClient { final Uri hostedUri; /// Fetches the currently logged-in user. - Future getCurrentUser() async { + Future getCurrentUser() async { final uri = Uri.parse('$hostedUri/api/v1/users/me'); final response = await _httpClient.get(uri); if (response.statusCode == HttpStatus.notFound) { - throw UserNotFoundException(); + return null; } else if (response.statusCode != HttpStatus.ok) { throw _parseErrorResponse(response.body); } From afdd6e27fea1f47f8b1453a31f53d6d156914314 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 15:38:01 -0400 Subject: [PATCH 08/17] Fix tests --- .../shorebird_cli/test/src/auth/auth_test.dart | 2 +- .../test/src/code_push_client_test.dart | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 781bfa49a..578defa22 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -195,7 +195,7 @@ void main() { 'created', () async { when(() => codePushClient.getCurrentUser()) .thenAnswer((_) async => null); - when(() => codePushClient.createUser(name: any())) + when(() => codePushClient.createUser(name: any(named: 'name'))) .thenAnswer((_) async => user); final newUser = await auth.signUp( diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index 18434847b..cf529e5f1 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -60,13 +60,10 @@ void main() { uri = Uri.parse('${codePushClient.hostedUri}/api/v1/users/me'); }); - test('throws UserNotFoundException if reponse is a 404', () async { + test('returns null if reponse is a 404', () async { when(() => httpClient.get(uri)) .thenAnswer((_) async => http.Response('', HttpStatus.notFound)); - expect( - codePushClient.getCurrentUser(), - throwsA(isA()), - ); + expect(await codePushClient.getCurrentUser(), isNull); }); test('throws exception if the http request fails', () { @@ -90,10 +87,11 @@ void main() { (_) async => http.Response(jsonEncode(user.toJson()), HttpStatus.ok), ); - final repsonseUser = await codePushClient.getCurrentUser(); - expect(repsonseUser.id, user.id); - expect(repsonseUser.email, user.email); - expect(repsonseUser.hasActiveSubscription, user.hasActiveSubscription); + final responseUser = await codePushClient.getCurrentUser(); + expect(responseUser, isNotNull); + expect(responseUser!.id, user.id); + expect(responseUser.email, user.email); + expect(responseUser.hasActiveSubscription, user.hasActiveSubscription); }); }); From a3e2e9e85a20d59ed86e329c5fa5a0d9d74c69c5 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 15:42:26 -0400 Subject: [PATCH 09/17] Update comment Co-authored-by: Felix Angelov --- packages/shorebird_cli/lib/src/auth/auth.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 3695adc93..84ddbd147 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -263,6 +263,6 @@ class UserAlreadyExistsException implements Exception { /// {@macro user_already_exists_exception} UserAlreadyExistsException(this.user); - /// The extant user. + /// The existing user. final User user; } From e465b77e73259cdcc3452279abdafbbd55610b51 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 15:58:25 -0400 Subject: [PATCH 10/17] Coverage --- .../test/src/auth/auth_test.dart | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 578defa22..2d80ec8b6 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -1,8 +1,10 @@ +import 'dart:convert'; import 'dart:io'; import 'package:googleapis_auth/googleapis_auth.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; @@ -57,6 +59,11 @@ void main() { ); } + void writeCredentials() { + File(p.join(credentialsDir, 'credentials.json')) + .writeAsStringSync(jsonEncode(accessCredentials.toJson())); + } + setUp(() { credentialsDir = Directory.systemTemp.createTempSync().path; httpClient = _MockHttpClient(); @@ -177,13 +184,29 @@ void main() { expect(buildAuth().isAuthenticated, isTrue); }); + test('throws UserAlreadyLoggedInException if user is authenticated', + () async { + writeCredentials(); + auth = buildAuth(); + + await expectLater( + auth.login((_) {}), + throwsA(isA()), + ); + + expect(auth.email, isNotNull); + expect(auth.isAuthenticated, isTrue); + }); + test('should not set the email when user does not exist', () async { when(() => codePushClient.getCurrentUser()) .thenAnswer((_) async => null); + await expectLater( auth.login((_) {}), throwsA(isA()), ); + expect(auth.email, isNull); expect(auth.isAuthenticated, isFalse); }); @@ -209,6 +232,23 @@ void main() { expect(buildAuth().isAuthenticated, isTrue); }); + test('throws UserAlreadyLoggedInException if user is authenticated', + () async { + writeCredentials(); + auth = buildAuth(); + + await expectLater( + auth.signUp( + authPrompt: (_) {}, + namePrompt: () => name, + ), + throwsA(isA()), + ); + + expect(auth.email, isNotNull); + expect(auth.isAuthenticated, isTrue); + }); + test('throws UserAlreadyExistsException if user already exists', () async { when(() => codePushClient.getCurrentUser()) From 6b85b1f7aa9245d0824d08419d3bdc603f8651cc Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 15:59:39 -0400 Subject: [PATCH 11/17] green -> lightCyan --- .../lib/src/commands/account/create_account_command.dart | 2 +- packages/shorebird_cli/lib/src/commands/login_command.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart index c3ba8d247..d90d8c84b 100644 --- a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -38,7 +38,7 @@ class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { // status. logger.info(''' You already have an account, ${error.user.displayName}! -To subscribe, run ${green.wrap('shorebird account subscribe')}. +To subscribe, run ${lightCyan.wrap('shorebird account subscribe')}. '''); return ExitCode.success.code; } catch (error) { diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 9f1f695a2..9ece93cef 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -32,7 +32,7 @@ class LoginCommand extends ShorebirdCommand { We could not find a Shorebird account for ${error.email}.''', ) ..info( - """If you have not yet created an account, you can do so by running "${green.wrap('shorebird account create')}". If you believe this is an error, please reach out to us via Discord, we're happy to help!""", + """If you have not yet created an account, you can do so by running "${lightCyan.wrap('shorebird account create')}". If you believe this is an error, please reach out to us via Discord, we're happy to help!""", ); return ExitCode.software.code; } catch (error) { From e785f934cc15267379f4592813d005c93cf6ebae Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 16:13:19 -0400 Subject: [PATCH 12/17] More lightCyan Co-authored-by: Felix Angelov --- .../lib/src/commands/account/create_account_command.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart index d90d8c84b..a046db396 100644 --- a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -31,7 +31,7 @@ class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { } on UserAlreadyLoggedInException catch (error) { logger ..info('You are already logged in as <${error.email}>.') - ..info("Run 'shorebird logout' to log out and try again."); + ..info("Run ${lightCyan.wrap('shorebird logout')} to log out and try again."); return ExitCode.success.code; } on UserAlreadyExistsException catch (error) { // TODO(bryanoltman): change this message based on the user's subscription From df58c44b88e0ac046474d3813bc3184f3eff6955 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 16:13:58 -0400 Subject: [PATCH 13/17] Even more lightCyan Co-authored-by: Felix Angelov --- packages/shorebird_cli/lib/src/commands/login_command.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 9ece93cef..1515a745a 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -23,7 +23,7 @@ class LoginCommand extends ShorebirdCommand { } on UserAlreadyLoggedInException catch (error) { logger ..info('You are already logged in as <${error.email}>.') - ..info("Run 'shorebird logout' to log out and try again."); + ..info("Run ${lightCyan.wrap('shorebird logout')} to log out and try again."); return ExitCode.success.code; } on UserNotFoundException catch (error) { logger From fc5435b081c766971465174d3147198400ca16d6 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 16:14:33 -0400 Subject: [PATCH 14/17] Test grouping --- .../account/create_account_command_test.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart index 86323f678..721329467 100644 --- a/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/account/create_account_command_test.dart @@ -12,16 +12,16 @@ class _MockLogger extends Mock implements Logger {} class _MockUser extends Mock implements User {} void main() { - const userName = 'John Doe'; - const email = 'tester@shorebird.dev'; + group(CreateAccountCommand, () { + const userName = 'John Doe'; + const email = 'tester@shorebird.dev'; - late Auth auth; - late Logger logger; - late User user; + late Auth auth; + late Logger logger; + late User user; - late CreateAccountCommand createAccountCommand; + late CreateAccountCommand createAccountCommand; - group(CreateAccountCommand, () { setUp(() { auth = _MockAuth(); logger = _MockLogger(); From 0abe018bdc8283f42de855ebbe1b78683efd8e17 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 16:18:16 -0400 Subject: [PATCH 15/17] Better error handling in cancel subscription command --- .../subscription/cancel_subscription_command.dart | 6 +++++- .../cancel_subscription_command_test.dart | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart b/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart index 0048febbc..51eae6c8d 100644 --- a/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart +++ b/packages/shorebird_cli/lib/src/commands/subscription/cancel_subscription_command.dart @@ -34,7 +34,11 @@ class CancelSubscriptionCommand extends ShorebirdCommand final User user; try { - user = (await client.getCurrentUser())!; + final currentUser = await client.getCurrentUser(); + if (currentUser == null) { + throw Exception('Failed to retrieve user information.'); + } + user = currentUser; } catch (error) { logger.err(error.toString()); return ExitCode.software.code; diff --git a/packages/shorebird_cli/test/src/commands/subscription/cancel_subscription_command_test.dart b/packages/shorebird_cli/test/src/commands/subscription/cancel_subscription_command_test.dart index 069f4f1a5..9f4154208 100644 --- a/packages/shorebird_cli/test/src/commands/subscription/cancel_subscription_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/subscription/cancel_subscription_command_test.dart @@ -84,6 +84,20 @@ void main() { ).called(1); }); + test('prints an error if fetch current user returns null', () async { + when(() => auth.isAuthenticated).thenReturn(true); + when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => null); + + final result = await command.run(); + + expect(result, ExitCode.software.code); + verify( + () => logger.err( + any(that: contains('Failed to retrieve user information')), + ), + ).called(1); + }); + test( 'prints an error if the user does not have an active subscription', () async { From f26b62234d9e327dc7bdbbf47bdb7fe3154583f9 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 16:20:07 -0400 Subject: [PATCH 16/17] Fix tests --- packages/shorebird_cli/lib/src/commands/login_command.dart | 4 +++- .../shorebird_cli/test/src/commands/login_command_test.dart | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 1515a745a..e0c94fb10 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -23,7 +23,9 @@ class LoginCommand extends ShorebirdCommand { } on UserAlreadyLoggedInException catch (error) { logger ..info('You are already logged in as <${error.email}>.') - ..info("Run ${lightCyan.wrap('shorebird logout')} to log out and try again."); + ..info( + "Run ${lightCyan.wrap('shorebird logout')} to log out and try again.", + ); return ExitCode.success.code; } on UserNotFoundException catch (error) { logger diff --git a/packages/shorebird_cli/test/src/commands/login_command_test.dart b/packages/shorebird_cli/test/src/commands/login_command_test.dart index 27005b3f4..2fce41a57 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -42,7 +42,9 @@ void main() { () => logger.info('You are already logged in as <$email>.'), ).called(1); verify( - () => logger.info("Run 'shorebird logout' to log out and try again."), + () => logger.info( + "Run ${lightCyan.wrap('shorebird logout')} to log out and try again.", + ), ).called(1); }); From 14deebfb48313bec7bc1852296b600b99431f333 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 20 Apr 2023 16:21:51 -0400 Subject: [PATCH 17/17] formatting --- .../lib/src/commands/account/create_account_command.dart | 4 +++- packages/shorebird_cli/lib/src/commands/login_command.dart | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart index a046db396..58c708bad 100644 --- a/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart +++ b/packages/shorebird_cli/lib/src/commands/account/create_account_command.dart @@ -31,7 +31,9 @@ class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin { } on UserAlreadyLoggedInException catch (error) { logger ..info('You are already logged in as <${error.email}>.') - ..info("Run ${lightCyan.wrap('shorebird logout')} to log out and try again."); + ..info( + 'Run ${lightCyan.wrap('shorebird logout')} to log out and try again.', + ); return ExitCode.success.code; } on UserAlreadyExistsException catch (error) { // TODO(bryanoltman): change this message based on the user's subscription diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index e0c94fb10..0038400cf 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -24,7 +24,7 @@ class LoginCommand extends ShorebirdCommand { logger ..info('You are already logged in as <${error.email}>.') ..info( - "Run ${lightCyan.wrap('shorebird logout')} to log out and try again.", + 'Run ${lightCyan.wrap('shorebird logout')} to log out and try again.', ); return ExitCode.success.code; } on UserNotFoundException catch (error) {