feat(shorebird_cli): login:ci command (#694)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<http.StreamedResponse> 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<AccessCredentials> 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<void> 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();
|
||||
|
||||
@@ -62,6 +62,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
|
||||
addCommand(DoctorCommand());
|
||||
addCommand(InitCommand());
|
||||
addCommand(LoginCommand());
|
||||
addCommand(LoginCiCommand());
|
||||
addCommand(LogoutCommand());
|
||||
addCommand(PatchCommand());
|
||||
addCommand(ReleaseCommand());
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<int> 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...''');
|
||||
}
|
||||
}
|
||||
@@ -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>(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(<String, String>{});
|
||||
|
||||
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 = <AccessCredentials>[];
|
||||
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 = <AccessCredentials>[];
|
||||
|
||||
expect(
|
||||
onRefreshCredentialsCalls,
|
||||
equals([
|
||||
isA<AccessCredentials>().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<AccessCredentials>()
|
||||
.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 = <AccessCredentials>[];
|
||||
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 = <AccessCredentials>[];
|
||||
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 = <AccessCredentials>[];
|
||||
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<AccessCredentials>()
|
||||
.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 = <AccessCredentials>[];
|
||||
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(
|
||||
<String, String>{'SHOREBIRD_TOKEN': token},
|
||||
);
|
||||
auth = buildAuth();
|
||||
final client = auth.client;
|
||||
expect(client, isA<http.Client>());
|
||||
expect(client, isA<AuthenticatedClient>());
|
||||
});
|
||||
|
||||
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(
|
||||
<String, String>{'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<UserNotFoundException>()),
|
||||
);
|
||||
|
||||
expect(auth.email, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('signUp', () {
|
||||
test(
|
||||
'should set the email when claims are valid and user is successfully '
|
||||
|
||||
@@ -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>(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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user