From 7ff2aed5e4f50f4f033c63641fa7044ac428a326 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 20 Jun 2023 16:13:04 -0500 Subject: [PATCH] feat(shorebird_cli): `login:ci` command (#694) --- .github/workflows/e2e.yaml | 3 + packages/shorebird_cli/lib/src/auth/auth.dart | 117 ++++++- .../shorebird_cli/lib/src/command_runner.dart | 1 + .../lib/src/commands/commands.dart | 1 + .../lib/src/commands/login_ci_command.dart | 62 ++++ .../test/src/auth/auth_test.dart | 305 +++++++++++++----- .../src/commands/login_ci_command_test.dart | 106 ++++++ 7 files changed, 506 insertions(+), 89 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/login_ci_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/login_ci_command_test.dart diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 0ba3859e..55d04ceb 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5,6 +5,9 @@ on: # At the end of every day - cron: "0 0 * * *" +env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} + jobs: verify_cli_installation: strategy: diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 68f4d507..c56ea403 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:cli_util/cli_util.dart'; import 'package:googleapis_auth/auth_io.dart' as oauth2; +import 'package:googleapis_auth/googleapis_auth.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:scoped/scoped.dart'; @@ -10,6 +11,7 @@ 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_cli/src/logger.dart'; +import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; // A reference to a [Auth] instance. @@ -66,30 +68,75 @@ class LoggingClient extends http.BaseClient { } class AuthenticatedClient extends LoggingClient { - AuthenticatedClient({ - required super.httpClient, + AuthenticatedClient.credentials({ + required http.Client httpClient, required oauth2.AccessCredentials credentials, - required OnRefreshCredentials onRefreshCredentials, + OnRefreshCredentials? onRefreshCredentials, + RefreshCredentials refreshCredentials = oauth2.refreshCredentials, + }) : this._( + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentials, + credentials: credentials, + refreshCredentials: refreshCredentials, + ); + + AuthenticatedClient.token({ + required http.Client httpClient, + required String token, + OnRefreshCredentials? onRefreshCredentials, + RefreshCredentials refreshCredentials = oauth2.refreshCredentials, + }) : this._( + httpClient: httpClient, + token: token, + onRefreshCredentials: onRefreshCredentials, + refreshCredentials: refreshCredentials, + ); + + AuthenticatedClient._({ + required super.httpClient, + OnRefreshCredentials? onRefreshCredentials, + oauth2.AccessCredentials? credentials, + String? token, RefreshCredentials refreshCredentials = oauth2.refreshCredentials, }) : _credentials = credentials, _onRefreshCredentials = onRefreshCredentials, - _refreshCredentials = refreshCredentials; + _refreshCredentials = refreshCredentials, + _token = token; - final OnRefreshCredentials _onRefreshCredentials; + final OnRefreshCredentials? _onRefreshCredentials; final RefreshCredentials _refreshCredentials; - oauth2.AccessCredentials _credentials; + oauth2.AccessCredentials? _credentials; + final String? _token; @override Future send(http.BaseRequest request) async { - if (_credentials.accessToken.hasExpired) { - _credentials = await _refreshCredentials( + var credentials = _credentials; + + if (credentials == null) { + final token = _token!; + credentials = _credentials = await _refreshCredentials( _clientId, - _credentials, + oauth2.AccessCredentials( + // This isn't relevant for a refresh operation. + AccessToken('Bearer', '', DateTime.timestamp()), + token, + _scopes, + ), _baseClient, ); - _onRefreshCredentials(_credentials); + _onRefreshCredentials?.call(credentials); } - final token = _credentials.idToken; + + if (credentials.accessToken.hasExpired) { + credentials = _credentials = await _refreshCredentials( + _clientId, + credentials, + _baseClient, + ); + _onRefreshCredentials?.call(credentials); + } + + final token = credentials.idToken; request.headers['Authorization'] = 'Bearer $token'; return super.send(request); } @@ -114,21 +161,52 @@ class Auth { final String _credentialsDir; final ObtainAccessCredentials _obtainAccessCredentials; final CodePushClientBuilder _buildCodePushClient; + String? _token; String get credentialsFilePath { return p.join(_credentialsDir, 'credentials.json'); } http.Client get client { - final credentials = _credentials; - if (credentials == null) return _httpClient; - return AuthenticatedClient( - credentials: credentials, + if (_credentials == null && _token == null) return _httpClient; + + if (_token != null) { + return AuthenticatedClient.token(token: _token!, httpClient: _httpClient); + } + + return AuthenticatedClient.credentials( + credentials: _credentials!, httpClient: _httpClient, onRefreshCredentials: _flushCredentials, ); } + Future loginCI(void Function(String) prompt) async { + final client = http.Client(); + try { + final credentials = await _obtainAccessCredentials( + _clientId, + _scopes, + client, + prompt, + ); + + final codePushClient = _buildCodePushClient( + httpClient: AuthenticatedClient.credentials( + credentials: credentials, + httpClient: _httpClient, + ), + ); + final user = await codePushClient.getCurrentUser(); + if (user == null) { + throw UserNotFoundException(email: credentials.email!); + } + return credentials; + } finally { + client.close(); + } + } + Future login(void Function(String) prompt) async { if (_credentials != null) { throw UserAlreadyLoggedInException(email: _credentials!.email!); @@ -201,11 +279,16 @@ class Auth { String? get email => _email; - bool get isAuthenticated => _email != null; + bool get isAuthenticated => _email != null || _token != null; void _loadCredentials() { - final credentialsFile = File(credentialsFilePath); + final token = platform.environment['SHOREBIRD_TOKEN']; + if (token != null) { + _token = token; + return; + } + final credentialsFile = File(credentialsFilePath); if (credentialsFile.existsSync()) { try { final contents = credentialsFile.readAsStringSync(); diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index cc85b5bd..607b88cd 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -62,6 +62,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(DoctorCommand()); addCommand(InitCommand()); addCommand(LoginCommand()); + addCommand(LoginCiCommand()); addCommand(LogoutCommand()); addCommand(PatchCommand()); addCommand(ReleaseCommand()); diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 2fa77b9f..31e34146 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -5,6 +5,7 @@ export 'cache/cache.dart'; export 'collaborators/collaborators.dart'; export 'doctor_command.dart'; export 'init_command.dart'; +export 'login_ci_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; export 'patch/patch.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/login_ci_command.dart b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart new file mode 100644 index 00000000..d0c95af2 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart @@ -0,0 +1,62 @@ +import 'package:googleapis_auth/auth_io.dart'; +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/logger.dart'; + +/// {@template login_ci_command} +/// `shorebird login:ci` +/// Login as a CI user. +/// {@endtemplate} +class LoginCiCommand extends ShorebirdCommand { + @override + String get description => 'Login as a CI user.'; + + @override + String get name => 'login:ci'; + + @override + Future run() async { + final AccessCredentials credentials; + + try { + credentials = await auth.loginCI(prompt); + } on UserNotFoundException catch (error) { + logger + ..err( + ''' +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 "${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) { + logger.err(error.toString()); + return ExitCode.software.code; + } + + logger.info(''' + +🎉 ${lightGreen.wrap('Success! Use the following token to login on a CI server:')} + +${lightCyan.wrap(credentials.refreshToken)} + +Example: + +${lightCyan.wrap(r'export SHOREBIRD_TOKEN="$SHOREBIRD_TOKEN" && shorebird patch android')} +'''); + return ExitCode.success.code; + } + + void prompt(String url) { + logger.info(''' +The Shorebird CLI needs your authorization to manage apps, releases, and patches on your behalf. + +In a browser, visit this URL to log in: + +${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))} + +Waiting for your authorization...'''); + } +} diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 5e762b8e..5fbd65a0 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'dart:io'; +import 'dart:io' hide Platform; import 'package:cli_util/cli_util.dart'; import 'package:googleapis_auth/googleapis_auth.dart'; @@ -7,10 +7,12 @@ 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:platform/platform.dart'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/command_runner.dart'; import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; @@ -22,6 +24,8 @@ class _MockLogger extends Mock implements Logger {} class _MockHttpClient extends Mock implements http.Client {} +class _MockPlatform extends Mock implements Platform {} + void main() { group('scoped', () { test('creates instance with default constructor', () { @@ -59,25 +63,35 @@ void main() { late CodePushClient codePushClient; late Logger logger; late Auth auth; + late Platform platform; setUpAll(() { registerFallbackValue(_FakeBaseRequest()); }); R runWithOverrides(R Function() body) { - return runScoped(body, values: {loggerRef.overrideWith(() => logger)}); + return runScoped( + body, + values: { + loggerRef.overrideWith(() => logger), + platformRef.overrideWith(() => platform), + }, + ); } Auth buildAuth() { - return Auth( - credentialsDir: credentialsDir, - httpClient: httpClient, - buildCodePushClient: ({Uri? hostedUri, http.Client? httpClient}) { - return codePushClient; - }, - obtainAccessCredentials: (clientId, scopes, client, userPrompt) async { - return accessCredentials; - }, + return runWithOverrides( + () => Auth( + credentialsDir: credentialsDir, + httpClient: httpClient, + buildCodePushClient: ({Uri? hostedUri, http.Client? httpClient}) { + return codePushClient; + }, + obtainAccessCredentials: + (clientId, scopes, client, userPrompt) async { + return accessCredentials; + }, + ), ); } @@ -92,80 +106,170 @@ void main() { httpClient = _MockHttpClient(); codePushClient = _MockCodePushClient(); logger = _MockLogger(); - auth = buildAuth(); + platform = _MockPlatform(); when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user); + when(() => platform.environment).thenReturn({}); + + auth = buildAuth(); }); group('AuthenticatedClient', () { - test('refreshes and uses new token when credentials are expired.', - () async { - when(() => httpClient.send(any())).thenAnswer( - (_) async => http.StreamedResponse( - const Stream.empty(), - HttpStatus.ok, - ), - ); + group('token', () { + const token = 'shorebird-token'; - final onRefreshCredentialsCalls = []; - final expiredCredentials = AccessCredentials( - AccessToken( - 'Bearer', - 'accessToken', - DateTime.now().subtract(const Duration(minutes: 1)).toUtc(), - ), - '', - [], - idToken: 'expiredIdToken', - ); + test('does not require an onRefreshCredentials callback', () { + expect( + () => AuthenticatedClient.token( + token: token, + httpClient: httpClient, + refreshCredentials: (clientId, credentials, client) async => + accessCredentials, + ), + returnsNormally, + ); + }); - final client = AuthenticatedClient( - credentials: expiredCredentials, - httpClient: httpClient, - onRefreshCredentials: onRefreshCredentialsCalls.add, - refreshCredentials: (clientId, credentials, client) async => - accessCredentials, - ); + test('refreshes and uses new token when credentials are expired.', + () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.ok, + ), + ); - await runWithOverrides( - () => client.get(Uri.parse('https://example.com')), - ); + final onRefreshCredentialsCalls = []; - expect( - onRefreshCredentialsCalls, - equals([ - isA().having((c) => c.idToken, 'token', idToken) - ]), - ); - final captured = verify(() => httpClient.send(captureAny())).captured; - expect(captured, hasLength(1)); - final request = captured.first as http.BaseRequest; - expect(request.headers['Authorization'], equals('Bearer $idToken')); + final client = AuthenticatedClient.token( + token: token, + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentialsCalls.add, + refreshCredentials: (clientId, credentials, client) async => + accessCredentials, + ); + + await runWithOverrides( + () => client.get(Uri.parse('https://example.com')), + ); + + expect( + onRefreshCredentialsCalls, + equals([ + isA() + .having((c) => c.idToken, 'token', idToken) + ]), + ); + final captured = verify(() => httpClient.send(captureAny())).captured; + expect(captured, hasLength(1)); + final request = captured.first as http.BaseRequest; + expect(request.headers['Authorization'], equals('Bearer $idToken')); + }); + + test('uses valid token when credentials valid.', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.ok, + ), + ); + final onRefreshCredentialsCalls = []; + final client = AuthenticatedClient.token( + token: token, + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentialsCalls.add, + refreshCredentials: (clientId, credentials, client) async => + accessCredentials, + ); + + await runWithOverrides( + () async { + await client.get(Uri.parse('https://example.com')); + await client.get(Uri.parse('https://example.com')); + }, + ); + + expect(onRefreshCredentialsCalls.length, equals(1)); + final captured = verify(() => httpClient.send(captureAny())).captured; + expect(captured, hasLength(2)); + var request = captured.first as http.BaseRequest; + expect(request.headers['Authorization'], equals('Bearer $idToken')); + request = captured.last as http.BaseRequest; + expect(request.headers['Authorization'], equals('Bearer $idToken')); + }); }); - test('uses valid token when credentials valid.', () async { - when(() => httpClient.send(any())).thenAnswer( - (_) async => http.StreamedResponse( - const Stream.empty(), - HttpStatus.ok, - ), - ); - final onRefreshCredentialsCalls = []; - final client = AuthenticatedClient( - credentials: accessCredentials, - httpClient: httpClient, - onRefreshCredentials: onRefreshCredentialsCalls.add, - ); + group('credentials', () { + test('refreshes and uses new token when credentials are expired.', + () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.ok, + ), + ); - await runWithOverrides( - () => client.get(Uri.parse('https://example.com')), - ); + final onRefreshCredentialsCalls = []; + final expiredCredentials = AccessCredentials( + AccessToken( + 'Bearer', + 'accessToken', + DateTime.now().subtract(const Duration(minutes: 1)).toUtc(), + ), + '', + [], + idToken: 'expiredIdToken', + ); - expect(onRefreshCredentialsCalls, isEmpty); - final captured = verify(() => httpClient.send(captureAny())).captured; - expect(captured, hasLength(1)); - final request = captured.first as http.BaseRequest; - expect(request.headers['Authorization'], equals('Bearer $idToken')); + final client = AuthenticatedClient.credentials( + credentials: expiredCredentials, + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentialsCalls.add, + refreshCredentials: (clientId, credentials, client) async => + accessCredentials, + ); + + await runWithOverrides( + () => client.get(Uri.parse('https://example.com')), + ); + + expect( + onRefreshCredentialsCalls, + equals([ + isA() + .having((c) => c.idToken, 'token', idToken) + ]), + ); + final captured = verify(() => httpClient.send(captureAny())).captured; + expect(captured, hasLength(1)); + final request = captured.first as http.BaseRequest; + expect(request.headers['Authorization'], equals('Bearer $idToken')); + }); + + test('uses valid token when credentials valid.', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.ok, + ), + ); + final onRefreshCredentialsCalls = []; + final client = AuthenticatedClient.credentials( + credentials: accessCredentials, + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentialsCalls.add, + ); + + await runWithOverrides( + () => client.get(Uri.parse('https://example.com')), + ); + + expect(onRefreshCredentialsCalls, isEmpty); + final captured = verify(() => httpClient.send(captureAny())).captured; + expect(captured, hasLength(1)); + final request = captured.first as http.BaseRequest; + expect(request.headers['Authorization'], equals('Bearer $idToken')); + }); }); }); @@ -194,6 +298,25 @@ void main() { expect(request.headers['Authorization'], equals('Bearer $idToken')); }); + test( + 'returns an authenticated client ' + 'when a token is present.', () async { + const token = 'shorebird-token'; + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.ok, + ), + ); + when(() => platform.environment).thenReturn( + {'SHOREBIRD_TOKEN': token}, + ); + auth = buildAuth(); + final client = auth.client; + expect(client, isA()); + expect(client, isA()); + }); + test( 'returns a plain http client ' 'when credentials are not present.', () async { @@ -242,6 +365,44 @@ void main() { }); }); + group('loginCI', () { + const token = 'shorebird-token'; + setUp(() { + when(() => platform.environment).thenReturn( + {'SHOREBIRD_TOKEN': token}, + ); + auth = buildAuth(); + }); + + test( + 'returns credentials and does not set the email or cache credentials', + () async { + await expectLater( + auth.loginCI((_) {}), + completion(equals(accessCredentials)), + ); + expect(auth.email, isNull); + expect(auth.isAuthenticated, isTrue); + expect(buildAuth().email, isNull); + expect(buildAuth().isAuthenticated, isTrue); + when(() => platform.environment).thenReturn({}); + expect(buildAuth().isAuthenticated, isFalse); + }); + + test('throws when user does not exist', () async { + when( + () => codePushClient.getCurrentUser(), + ).thenAnswer((_) async => null); + + await expectLater( + auth.loginCI((_) {}), + throwsA(isA()), + ); + + expect(auth.email, isNull); + }); + }); + group('signUp', () { test( 'should set the email when claims are valid and user is successfully ' diff --git a/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart b/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart new file mode 100644 index 00000000..5191a526 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart @@ -0,0 +1,106 @@ +import 'package:googleapis_auth/googleapis_auth.dart'; +import 'package:http/http.dart' as http; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:test/test.dart'; + +class _MockAccessCredentials extends Mock implements AccessCredentials {} + +class _MockAuth extends Mock implements Auth {} + +class _MockHttpClient extends Mock implements http.Client {} + +class _MockLogger extends Mock implements Logger {} + +void main() { + group(LoginCiCommand, () { + const email = 'test@email.com'; + + late Auth auth; + late http.Client httpClient; + late Logger logger; + late LoginCiCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + authRef.overrideWith(() => auth), + loggerRef.overrideWith(() => logger) + }, + ); + } + + setUp(() { + auth = _MockAuth(); + httpClient = _MockHttpClient(); + logger = _MockLogger(); + + when(() => auth.client).thenReturn(httpClient); + command = runWithOverrides(LoginCiCommand.new); + }); + + test('exits with code 70 if no user is found', () async { + when( + () => auth.loginCI(any()), + ).thenThrow(UserNotFoundException(email: email)); + + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + + verify( + () => 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.loginCI(any())).thenThrow(error); + + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + + verify(() => auth.loginCI(any())).called(1); + verify(() => logger.err(error.toString())).called(1); + }); + + test('exits with code 0 when logged in successfully', () async { + const token = 'shorebird-token'; + final credentials = _MockAccessCredentials(); + when(() => credentials.refreshToken).thenReturn(token); + when(() => auth.loginCI(any())).thenAnswer((_) async => credentials); + when(() => auth.email).thenReturn(email); + + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + + verify(() => auth.loginCI(any())).called(1); + verify( + () => logger.info(any(that: contains('${lightCyan.wrap(token)}'))), + ).called(1); + }); + + test('prompt is correct', () { + const url = 'http://example.com'; + runWithOverrides(() => command.prompt(url)); + + verify( + () => logger.info(''' +The Shorebird CLI needs your authorization to manage apps, releases, and patches on your behalf. + +In a browser, visit this URL to log in: + +${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))} + +Waiting for your authorization...'''), + ).called(1); + }); + }); +}