feat: Add CLI support for authenticating with new API keys (#3646)
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
cspell:words pubspec erickzanardo xcframeworks cupertino codesign codecov rkishan appbundle proto tlsv kingdomseed Peetee Aditya
|
||||
-->
|
||||
|
||||
## 1.6.91 (March 25, 2026)
|
||||
|
||||
- 🔐 Support API keys for authentication ([docs](https://docs.shorebird.dev/account/api-keys/))
|
||||
- `login:ci` command has been deprecated
|
||||
- Create API keys through the [web console](https://console.shorebird.dev)
|
||||
|
||||
## 1.6.90 (March 24, 2026)
|
||||
|
||||
- 🐦 Support for Flutter 3.41.5 & Dart 3.11.3
|
||||
|
||||
@@ -42,16 +42,6 @@ const microsoftJwtIssuerPrefix = 'https://login.microsoftonline.com/';
|
||||
/// The environment variable that holds the Shorebird CI token.
|
||||
const shorebirdTokenEnvVar = 'SHOREBIRD_TOKEN';
|
||||
|
||||
/// Callback for obtaining access credentials.
|
||||
typedef ObtainAccessCredentials =
|
||||
Future<oauth2.AccessCredentials> Function(
|
||||
oauth2.ClientId clientId,
|
||||
List<String> scopes,
|
||||
http.Client client,
|
||||
void Function(String) userPrompt, {
|
||||
oauth2.AuthEndpoints authEndpoints,
|
||||
});
|
||||
|
||||
/// Callback for refreshing access credentials.
|
||||
typedef RefreshCredentials =
|
||||
Future<oauth2.AccessCredentials> Function(
|
||||
@@ -196,6 +186,27 @@ class AuthenticatedClient extends http.BaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP client that authenticates requests using an API key.
|
||||
///
|
||||
/// Unlike [AuthenticatedClient], this client does not perform any token
|
||||
/// refresh or exchange — the API key is sent directly in the Authorization
|
||||
/// header and the server handles validation.
|
||||
class ApiKeyClient extends http.BaseClient {
|
||||
/// Creates a new [ApiKeyClient].
|
||||
ApiKeyClient({required String apiKey, required http.Client httpClient})
|
||||
: _apiKey = apiKey,
|
||||
_baseClient = httpClient;
|
||||
|
||||
final String _apiKey;
|
||||
final http.Client _baseClient;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
request.headers['Authorization'] = 'Bearer $_apiKey';
|
||||
return _baseClient.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
/// An OAuth 2.0 authentication provider.
|
||||
class Auth {
|
||||
/// Creates a new [Auth] instance.
|
||||
@@ -203,16 +214,12 @@ class Auth {
|
||||
http.Client? httpClient,
|
||||
String? credentialsDir,
|
||||
Uri? authServiceUri,
|
||||
ObtainAccessCredentials? obtainAccessCredentials,
|
||||
ObtainCredentialsViaLoopbackLogin? obtainCredentialsViaLoopbackLogin,
|
||||
CodePushClientBuilder? buildCodePushClient,
|
||||
}) : _httpClient = httpClient ?? _defaultHttpClient,
|
||||
_credentialsDir =
|
||||
credentialsDir ?? applicationConfigHome(executableName),
|
||||
_authServiceUri = authServiceUri ?? shorebirdEnv.authServiceUri,
|
||||
_obtainAccessCredentials =
|
||||
obtainAccessCredentials ??
|
||||
oauth2.obtainAccessCredentialsViaUserConsent,
|
||||
_obtainCredentialsViaLoopbackLogin =
|
||||
obtainCredentialsViaLoopbackLogin ??
|
||||
shorebird_oauth.obtainCredentialsViaLoopbackLogin,
|
||||
@@ -225,10 +232,10 @@ class Auth {
|
||||
final http.Client _httpClient;
|
||||
final String _credentialsDir;
|
||||
final Uri _authServiceUri;
|
||||
final ObtainAccessCredentials _obtainAccessCredentials;
|
||||
final ObtainCredentialsViaLoopbackLogin _obtainCredentialsViaLoopbackLogin;
|
||||
final CodePushClientBuilder _buildCodePushClient;
|
||||
CiToken? _token;
|
||||
String? _apiKey;
|
||||
|
||||
/// The path to the credentials file.
|
||||
String get credentialsFilePath {
|
||||
@@ -237,8 +244,8 @@ class Auth {
|
||||
|
||||
/// The underlying HTTP client.
|
||||
http.Client get client {
|
||||
if (_credentials == null && _token == null) {
|
||||
return _httpClient;
|
||||
if (_apiKey != null) {
|
||||
return ApiKeyClient(apiKey: _apiKey!, httpClient: _httpClient);
|
||||
}
|
||||
|
||||
if (_token != null) {
|
||||
@@ -249,70 +256,30 @@ class Auth {
|
||||
);
|
||||
}
|
||||
|
||||
return AuthenticatedClient.credentials(
|
||||
credentials: _credentials!,
|
||||
httpClient: _httpClient,
|
||||
authServiceUri: _authServiceUri,
|
||||
onRefreshCredentials: _flushCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets a CI token for the current user.
|
||||
Future<CiToken> loginCI(
|
||||
AuthProvider authProvider, {
|
||||
required void Function(String) prompt,
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final credentials = await _obtainCredentials(
|
||||
authProvider,
|
||||
client: client,
|
||||
prompt: prompt,
|
||||
if (_credentials != null) {
|
||||
return AuthenticatedClient.credentials(
|
||||
credentials: _credentials!,
|
||||
httpClient: _httpClient,
|
||||
authServiceUri: _authServiceUri,
|
||||
onRefreshCredentials: _flushCredentials,
|
||||
);
|
||||
|
||||
final codePushClient = _buildCodePushClient(
|
||||
httpClient: AuthenticatedClient.credentials(
|
||||
credentials: credentials,
|
||||
httpClient: _httpClient,
|
||||
authServiceUri: _authServiceUri,
|
||||
),
|
||||
hostedUri: shorebirdEnv.hostedUri,
|
||||
);
|
||||
final user = await codePushClient.getCurrentUser();
|
||||
if (user == null) {
|
||||
throw UserNotFoundException(email: credentials.email!);
|
||||
}
|
||||
if (credentials.refreshToken == null) {
|
||||
throw Exception('No refresh token found.');
|
||||
}
|
||||
|
||||
return CiToken(
|
||||
refreshToken: credentials.refreshToken!,
|
||||
authProvider: authProvider,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
return _httpClient;
|
||||
}
|
||||
|
||||
/// Logs in the user.
|
||||
Future<void> login(
|
||||
AuthProvider authProvider, {
|
||||
required void Function(String) prompt,
|
||||
}) async {
|
||||
/// Logs in the user via the Shorebird loopback OAuth flow.
|
||||
Future<void> login({required void Function(String) prompt}) async {
|
||||
if (isAuthenticated) {
|
||||
// Because isAuthenticated is checks for the presence of either an email
|
||||
// or a CI token, and because this method is for logging in without a CI
|
||||
// token, we can safely assume that _email is not null.
|
||||
throw UserAlreadyLoggedInException(email: _email!);
|
||||
throw UserAlreadyLoggedInException(email: _email);
|
||||
}
|
||||
|
||||
final client = http.Client();
|
||||
try {
|
||||
_credentials = await _obtainCredentials(
|
||||
authProvider,
|
||||
client: client,
|
||||
prompt: prompt,
|
||||
_credentials = await _obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: client,
|
||||
authBaseUrl: _authServiceUri,
|
||||
userPrompt: prompt,
|
||||
);
|
||||
|
||||
final codePushClient = _buildCodePushClient(
|
||||
@@ -332,30 +299,6 @@ class Auth {
|
||||
}
|
||||
}
|
||||
|
||||
Future<oauth2.AccessCredentials> _obtainCredentials(
|
||||
AuthProvider authProvider, {
|
||||
required http.Client client,
|
||||
required void Function(String) prompt,
|
||||
}) async {
|
||||
// Shorebird uses its own login flow; Google and Microsoft use the
|
||||
// standard OAuth consent flow. This branching can be removed once the
|
||||
// Google/Microsoft providers are fully removed from the CLI.
|
||||
if (authProvider == AuthProvider.shorebird) {
|
||||
return _obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: client,
|
||||
authBaseUrl: _authServiceUri,
|
||||
userPrompt: prompt,
|
||||
);
|
||||
}
|
||||
return _obtainAccessCredentials(
|
||||
authProvider.clientId,
|
||||
authProvider.scopes,
|
||||
client,
|
||||
prompt,
|
||||
authEndpoints: authProvider.authEndpoints,
|
||||
);
|
||||
}
|
||||
|
||||
/// Logs out the user.
|
||||
///
|
||||
/// If a Shorebird refresh token is available, revokes the server-side
|
||||
@@ -401,26 +344,43 @@ class Auth {
|
||||
String? get email => _email;
|
||||
|
||||
/// Whether the user is authenticated.
|
||||
bool get isAuthenticated => _email != null || _token != null;
|
||||
bool get isAuthenticated =>
|
||||
_email != null || _token != null || _apiKey != null;
|
||||
|
||||
void _loadCredentials() {
|
||||
final envToken = platform.environment[shorebirdTokenEnvVar];
|
||||
if (envToken != null) {
|
||||
final trimmed = envToken.trim();
|
||||
logger.detail('[env] $shorebirdTokenEnvVar detected');
|
||||
|
||||
// New API key format — pass through directly, no refresh needed.
|
||||
if (trimmed.startsWith('sb_api_')) {
|
||||
_apiKey = trimmed;
|
||||
logger.detail('[env] $shorebirdTokenEnvVar parsed as API key');
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy CiToken format — still supported, but deprecated.
|
||||
try {
|
||||
_token = CiToken.fromBase64(envToken.trim());
|
||||
_token = CiToken.fromBase64(trimmed);
|
||||
logger.warn(
|
||||
'SHOREBIRD_TOKEN contains a legacy CI token from '
|
||||
'`shorebird login:ci`. '
|
||||
'This format is deprecated and will stop working in a future '
|
||||
'release. '
|
||||
'Create an API key at https://console.shorebird.dev instead.',
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
logger
|
||||
..err('''
|
||||
Failed to parse CI token from environment. This likely means that your CI token is incorrectly formatted.
|
||||
|
||||
Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar environment variable, and try again.''')
|
||||
..err(
|
||||
'Failed to parse $shorebirdTokenEnvVar. Expected an API key '
|
||||
'(sb_api_...) or a legacy CI token.',
|
||||
)
|
||||
..detail(e.toString());
|
||||
rethrow;
|
||||
}
|
||||
|
||||
logger.detail('[env] $shorebirdTokenEnvVar parsed');
|
||||
logger.detail('[env] $shorebirdTokenEnvVar parsed as legacy CiToken');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -482,11 +442,11 @@ extension JwtClaims on oauth2.AccessCredentials {
|
||||
/// 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});
|
||||
UserAlreadyLoggedInException({this.email});
|
||||
|
||||
/// The email of the already authenticated user, as derived from the stored
|
||||
/// auth credentials.
|
||||
final String email;
|
||||
/// The email of the already authenticated user, or `null` when
|
||||
/// authenticated via an environment variable (API key / CI token).
|
||||
final String? email;
|
||||
}
|
||||
|
||||
/// {@template user_not_found_exception}
|
||||
|
||||
@@ -1,91 +1,28 @@
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:shorebird_cli/src/auth/auth.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_command.dart';
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'
|
||||
as api;
|
||||
|
||||
/// {@template login_ci_command}
|
||||
/// `shorebird login:ci`
|
||||
/// Login as a CI user.
|
||||
/// Deprecated — directs users to API keys instead.
|
||||
/// {@endtemplate}
|
||||
class LoginCiCommand extends ShorebirdCommand {
|
||||
/// {@macro login_ci_command}
|
||||
LoginCiCommand() {
|
||||
argParser.addOption(
|
||||
'provider',
|
||||
abbr: 'p',
|
||||
allowed: _supportedProviders.map((e) => e.name),
|
||||
defaultsTo: api.AuthProvider.google.name,
|
||||
help: 'The authentication provider to use. Defaults to Google.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Providers supported for CI login. Shorebird auth tokens cannot be used
|
||||
/// for CI authentication.
|
||||
static final _supportedProviders = api.AuthProvider.values
|
||||
.where((p) => p != api.AuthProvider.shorebird)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
String get description => 'Login as a CI user.';
|
||||
String get description => 'Login as a CI user (deprecated).';
|
||||
|
||||
@override
|
||||
String get name => 'login:ci';
|
||||
|
||||
@override
|
||||
Future<int> run() async {
|
||||
final api.AuthProvider provider;
|
||||
if (results.wasParsed('provider')) {
|
||||
provider = api.AuthProvider.values.byName(
|
||||
results['provider'] as String,
|
||||
);
|
||||
} else {
|
||||
provider = logger.chooseOne(
|
||||
'Choose an auth provider',
|
||||
choices: _supportedProviders,
|
||||
display: (p) => p.displayName,
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
'''
|
||||
${lightYellow.wrap('⚠ shorebird login:ci is deprecated.')}
|
||||
|
||||
final CiToken ciToken;
|
||||
try {
|
||||
ciToken = await auth.loginCI(provider, prompt: 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, go to "${link(uri: Uri.parse('https://console.shorebird.dev'))}" to create one. If you believe this is an error, please reach out to us via Discord, we're happy to help!''',
|
||||
);
|
||||
return ExitCode.software.code;
|
||||
} on Exception catch (error) {
|
||||
logger.err(error.toString());
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
To authenticate in CI, create an API key at ${link(uri: Uri.parse('https://console.shorebird.dev'))} and set it as your ${lightCyan.wrap('SHOREBIRD_TOKEN')} environment variable.
|
||||
|
||||
logger.info('''
|
||||
|
||||
🎉 ${lightGreen.wrap('Success! Use the following token to login on a CI server:')}
|
||||
|
||||
${lightCyan.wrap(ciToken.toBase64())}
|
||||
|
||||
Example:
|
||||
|
||||
${lightCyan.wrap('export $shorebirdTokenEnvVar="\$SHOREBIRD_TOKEN" && shorebird patch android')}
|
||||
''');
|
||||
Existing tokens from login:ci will continue to work for now, but will stop working in a future release.''',
|
||||
);
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
|
||||
/// Prompt the user to visit the provided [url] to authorize the CLI.
|
||||
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...''');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:shorebird_cli/src/auth/auth.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_command.dart';
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
|
||||
|
||||
/// {@template login_command}
|
||||
/// `shorebird login`
|
||||
@@ -18,8 +17,13 @@ class LoginCommand extends ShorebirdCommand {
|
||||
@override
|
||||
Future<int> run() async {
|
||||
if (auth.isAuthenticated) {
|
||||
final emailDisplay = auth.email;
|
||||
logger
|
||||
..info('You are already logged in as <${auth.email}>.')
|
||||
..info(
|
||||
emailDisplay != null
|
||||
? 'You are already logged in as <$emailDisplay>.'
|
||||
: 'You are already authenticated via API key.',
|
||||
)
|
||||
..info(
|
||||
'Run ${lightCyan.wrap('shorebird logout')} to log out and try again.',
|
||||
);
|
||||
@@ -27,7 +31,7 @@ class LoginCommand extends ShorebirdCommand {
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.login(AuthProvider.shorebird, prompt: prompt);
|
||||
await auth.login(prompt: prompt);
|
||||
} on UserNotFoundException catch (error) {
|
||||
final consoleUri = Uri.https('console.shorebird.dev');
|
||||
logger
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated code. Do not modify.
|
||||
const packageVersion = '1.6.90';
|
||||
const packageVersion = '1.6.91';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: shorebird_cli
|
||||
description: Command-line tool to interact with Shorebird's services.
|
||||
version: 1.6.90
|
||||
version: 1.6.91
|
||||
repository: https://github.com/shorebirdtech/shorebird
|
||||
resolution: workspace
|
||||
|
||||
|
||||
@@ -222,16 +222,6 @@ void main() {
|
||||
buildCodePushClient: ({Uri? hostedUri, http.Client? httpClient}) {
|
||||
return codePushClient;
|
||||
},
|
||||
obtainAccessCredentials:
|
||||
(
|
||||
clientId,
|
||||
scopes,
|
||||
client,
|
||||
userPrompt, {
|
||||
AuthEndpoints authEndpoints = const GoogleAuthEndpoints(),
|
||||
}) async {
|
||||
return accessCredentials;
|
||||
},
|
||||
obtainCredentialsViaLoopbackLogin:
|
||||
({
|
||||
required http.Client httpClient,
|
||||
@@ -833,7 +823,7 @@ void main() {
|
||||
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
|
||||
);
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
final client = auth.client;
|
||||
expect(client, isA<http.Client>());
|
||||
@@ -849,6 +839,71 @@ void main() {
|
||||
expect(request.headers['Authorization'], equals('Bearer $idToken'));
|
||||
});
|
||||
|
||||
group('when SHOREBIRD_TOKEN is an API key', () {
|
||||
setUp(() {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
shorebirdTokenEnvVar: 'sb_api_abc123',
|
||||
});
|
||||
});
|
||||
|
||||
test('parses as API key and sets isAuthenticated', () {
|
||||
auth = buildAuth();
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(auth.email, isNull);
|
||||
verify(
|
||||
() => logger.detail('[env] $shorebirdTokenEnvVar detected'),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.detail(
|
||||
'[env] $shorebirdTokenEnvVar parsed as API key',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('trims whitespace from API key', () {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
shorebirdTokenEnvVar: ' sb_api_abc123 \n',
|
||||
});
|
||||
auth = buildAuth();
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
});
|
||||
|
||||
test('returns an ApiKeyClient from client getter', () {
|
||||
auth = buildAuth();
|
||||
final client = auth.client;
|
||||
expect(client, isA<ApiKeyClient>());
|
||||
});
|
||||
|
||||
test('ApiKeyClient sends correct Authorization header', () async {
|
||||
when(() => httpClient.send(any())).thenAnswer(
|
||||
(_) async =>
|
||||
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
|
||||
);
|
||||
auth = buildAuth();
|
||||
final client = auth.client;
|
||||
|
||||
await runWithOverrides(
|
||||
() => client.get(Uri.parse('https://example.com')),
|
||||
);
|
||||
|
||||
final captured = verify(() => httpClient.send(captureAny())).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final request = captured.first as http.BaseRequest;
|
||||
expect(
|
||||
request.headers['Authorization'],
|
||||
equals('Bearer sb_api_abc123'),
|
||||
);
|
||||
});
|
||||
|
||||
test('takes priority over credentials file', () {
|
||||
writeCredentials();
|
||||
auth = buildAuth();
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(auth.email, isNull);
|
||||
expect(auth.client, isA<ApiKeyClient>());
|
||||
});
|
||||
});
|
||||
|
||||
group('when token is invalid', () {
|
||||
setUp(() {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
@@ -857,7 +912,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'logs and throws error when token string is not valid base64',
|
||||
'logs and throws error when token string is not valid',
|
||||
() async {
|
||||
expect(buildAuth, throwsA(isFormatException));
|
||||
verify(
|
||||
@@ -865,14 +920,14 @@ void main() {
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.err(
|
||||
'''
|
||||
Failed to parse CI token from environment. This likely means that your CI token is incorrectly formatted.
|
||||
|
||||
Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar environment variable, and try again.''',
|
||||
'Failed to parse $shorebirdTokenEnvVar. Expected an API key '
|
||||
'(sb_api_...) or a legacy CI token.',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => logger.detail('[env] $shorebirdTokenEnvVar parsed'),
|
||||
() => logger.detail(
|
||||
'[env] $shorebirdTokenEnvVar parsed as legacy CiToken',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -914,7 +969,18 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
() => logger.detail('[env] $shorebirdTokenEnvVar detected'),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.detail('[env] $shorebirdTokenEnvVar parsed'),
|
||||
() => logger.warn(
|
||||
'SHOREBIRD_TOKEN contains a legacy CI token from '
|
||||
'`shorebird login:ci`. '
|
||||
'This format is deprecated and will stop working in a future '
|
||||
'release. '
|
||||
'Create an API key at https://console.shorebird.dev instead.',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.detail(
|
||||
'[env] $shorebirdTokenEnvVar parsed as legacy CiToken',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
@@ -933,7 +999,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
'should set the email when claims are valid and current user exists',
|
||||
() async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
@@ -942,39 +1008,6 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
},
|
||||
);
|
||||
|
||||
group('with custom auth provider', () {
|
||||
test(
|
||||
'''should set the email when claims are valid and current user exists''',
|
||||
() async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.microsoft, prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(buildAuth().email, email);
|
||||
expect(buildAuth().isAuthenticated, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('with Shorebird auth provider', () {
|
||||
test(
|
||||
'should set the email when login succeeds',
|
||||
() async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(
|
||||
AuthProvider.shorebird,
|
||||
prompt: (_) {},
|
||||
),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(buildAuth().email, email);
|
||||
expect(buildAuth().isAuthenticated, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'throws UserAlreadyLoggedInException if user is authenticated',
|
||||
() async {
|
||||
@@ -983,7 +1016,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
),
|
||||
throwsA(isA<UserAlreadyLoggedInException>()),
|
||||
);
|
||||
@@ -993,6 +1026,29 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'throws UserAlreadyLoggedInException when authenticated via API key',
|
||||
() async {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
shorebirdTokenEnvVar: 'sb_api_abc123',
|
||||
});
|
||||
auth = buildAuth();
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => auth.login(prompt: (_) {}),
|
||||
),
|
||||
throwsA(
|
||||
isA<UserAlreadyLoggedInException>().having(
|
||||
(e) => e.email,
|
||||
'email',
|
||||
isNull,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group('when login credentials are corrupted', () {
|
||||
setUp(() {
|
||||
accessCredentials = oauth2.AccessCredentials(
|
||||
@@ -1008,7 +1064,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
test('proceeds with login', () async {
|
||||
expect(auth.email, isNull);
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, equals(email));
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
@@ -1022,7 +1078,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
),
|
||||
throwsA(isA<UserNotFoundException>()),
|
||||
);
|
||||
@@ -1032,96 +1088,10 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
});
|
||||
});
|
||||
|
||||
group('loginCI', () {
|
||||
setUp(() {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
shorebirdTokenEnvVar: ciToken.toBase64(),
|
||||
});
|
||||
auth = buildAuth();
|
||||
});
|
||||
|
||||
test(
|
||||
'returns a CI token and does not set the email or cache credentials',
|
||||
() async {
|
||||
final token = await runWithOverrides(
|
||||
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
);
|
||||
expect(token.authProvider, ciToken.authProvider);
|
||||
expect(token.refreshToken, ciToken.refreshToken);
|
||||
expect(auth.email, isNull);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(buildAuth().email, isNull);
|
||||
expect(buildAuth().isAuthenticated, isTrue);
|
||||
when(() => platform.environment).thenReturn({});
|
||||
expect(buildAuth().isAuthenticated, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
group('with Shorebird auth provider', () {
|
||||
test(
|
||||
'returns a CI token with Shorebird provider',
|
||||
() async {
|
||||
final token = await runWithOverrides(
|
||||
() => auth.loginCI(
|
||||
AuthProvider.shorebird,
|
||||
prompt: (_) {},
|
||||
),
|
||||
);
|
||||
expect(
|
||||
token.authProvider,
|
||||
AuthProvider.shorebird,
|
||||
);
|
||||
expect(token.refreshToken, refreshToken);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when user does not exist', () async {
|
||||
when(
|
||||
() => codePushClient.getCurrentUser(),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
),
|
||||
throwsA(isA<UserNotFoundException>()),
|
||||
);
|
||||
|
||||
expect(auth.email, isNull);
|
||||
});
|
||||
|
||||
group('when credentials are missing a refresh token', () {
|
||||
setUp(() {
|
||||
accessCredentials = oauth2.AccessCredentials(
|
||||
accessToken,
|
||||
null,
|
||||
scopes,
|
||||
idToken: idToken,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws if credentials are missing a refresh token', () async {
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(e) => e.toString(),
|
||||
'toString',
|
||||
'Exception: No refresh token found.',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('logout', () {
|
||||
test('clears session and wipes state', () async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
@@ -1139,9 +1109,59 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
expect(buildAuth().isAuthenticated, isFalse);
|
||||
});
|
||||
|
||||
test('clears credentials file when it exists', () async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
expect(File(auth.credentialsFilePath).existsSync(), isTrue);
|
||||
|
||||
when(
|
||||
() => httpClient.post(any(), headers: any(named: 'headers')),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response('{"ok":true}', 200),
|
||||
);
|
||||
|
||||
await runWithOverrides(() => auth.logout());
|
||||
expect(File(auth.credentialsFilePath).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
group('when authenticated via API key', () {
|
||||
setUp(() {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
shorebirdTokenEnvVar: 'sb_api_abc123',
|
||||
});
|
||||
auth = buildAuth();
|
||||
});
|
||||
|
||||
test('remains authenticated because env var is still set', () async {
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
await runWithOverrides(() => auth.logout());
|
||||
// _apiKey is not cleared by _clearCredentials, so the instance
|
||||
// still considers itself authenticated.
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('when authenticated via CI token', () {
|
||||
setUp(() {
|
||||
when(() => platform.environment).thenReturn(<String, String>{
|
||||
shorebirdTokenEnvVar: ciToken.toBase64(),
|
||||
});
|
||||
auth = buildAuth();
|
||||
});
|
||||
|
||||
test('remains authenticated because env var is still set', () async {
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
await runWithOverrides(() => auth.logout());
|
||||
// _token is not cleared by _clearCredentials, so the instance
|
||||
// still considers itself authenticated.
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('revokes server session with refresh token', () async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
|
||||
when(
|
||||
@@ -1168,7 +1188,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
test('logs detail when server returns non-2xx', () async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
|
||||
when(
|
||||
@@ -1189,7 +1209,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
test('clears credentials even if server revocation fails', () async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
() => auth.login(prompt: (_) {}),
|
||||
);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
|
||||
@@ -1237,6 +1257,14 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
group('OauthValues', () {
|
||||
group('clientId', () {
|
||||
test('returns a ClientId for google', () {
|
||||
expect(AuthProvider.google.clientId, isNotNull);
|
||||
});
|
||||
|
||||
test('returns a ClientId for microsoft', () {
|
||||
expect(AuthProvider.microsoft.clientId, isNotNull);
|
||||
});
|
||||
|
||||
test('throws UnsupportedError for shorebird', () {
|
||||
expect(
|
||||
() => AuthProvider.shorebird.clientId,
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import 'package:args/args.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:scoped_deps/scoped_deps.dart';
|
||||
import 'package:shorebird_cli/src/auth/auth.dart';
|
||||
import 'package:shorebird_cli/src/commands/commands.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
group(LoginCiCommand, () {
|
||||
const email = 'test@email.com';
|
||||
|
||||
late ArgResults results;
|
||||
late Auth auth;
|
||||
late http.Client httpClient;
|
||||
late ShorebirdLogger logger;
|
||||
late LoginCiCommand command;
|
||||
|
||||
@@ -31,156 +24,40 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(AuthProvider.google);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
auth = MockAuth();
|
||||
httpClient = MockHttpClient();
|
||||
logger = MockShorebirdLogger();
|
||||
results = MockArgResults();
|
||||
|
||||
when(() => results.wasParsed('provider')).thenReturn(false);
|
||||
when(() => results['provider']).thenReturn(null);
|
||||
when(() => auth.client).thenReturn(httpClient);
|
||||
when(() => auth.loginCI(any(), prompt: any(named: 'prompt'))).thenAnswer(
|
||||
(_) async => const CiToken(
|
||||
// "shorebird-token" in base64
|
||||
refreshToken: 'c2hvcmViaXJkLXRva2Vu', // cspell:disable-line
|
||||
authProvider: AuthProvider.google,
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => logger.chooseOne<AuthProvider>(
|
||||
any(),
|
||||
choices: any(named: 'choices'),
|
||||
display: any(named: 'display'),
|
||||
),
|
||||
).thenReturn(AuthProvider.google);
|
||||
|
||||
command = runWithOverrides(
|
||||
() => LoginCiCommand()..testArgResults = results,
|
||||
);
|
||||
command = runWithOverrides(LoginCiCommand.new);
|
||||
});
|
||||
|
||||
group('provider', () {
|
||||
group('when provider is passed as an arg', () {
|
||||
const provider = AuthProvider.google;
|
||||
|
||||
setUp(() {
|
||||
when(() => results.wasParsed('provider')).thenReturn(true);
|
||||
when(() => results['provider']).thenReturn(provider.name);
|
||||
});
|
||||
|
||||
test('uses the passed provider', () async {
|
||||
await runWithOverrides(() => command.run());
|
||||
|
||||
verify(
|
||||
() => auth.loginCI(provider, prompt: any(named: 'prompt')),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when provider is not passed as an arg', () {
|
||||
const provider = AuthProvider.microsoft;
|
||||
|
||||
setUp(() {
|
||||
when(() => results.wasParsed('provider')).thenReturn(false);
|
||||
when(
|
||||
() => logger.chooseOne<AuthProvider>(
|
||||
any(),
|
||||
choices: any(named: 'choices'),
|
||||
display: captureAny(named: 'display'),
|
||||
),
|
||||
).thenReturn(provider);
|
||||
});
|
||||
|
||||
test('uses the provider chosen by the user', () async {
|
||||
await runWithOverrides(() => command.run());
|
||||
|
||||
verify(
|
||||
() => auth.loginCI(provider, prompt: any(named: 'prompt')),
|
||||
).called(1);
|
||||
final captured =
|
||||
verify(
|
||||
() => logger.chooseOne<AuthProvider>(
|
||||
any(),
|
||||
choices: any(named: 'choices'),
|
||||
display: captureAny(named: 'display'),
|
||||
),
|
||||
).captured.single
|
||||
as String Function(AuthProvider);
|
||||
expect(captured(AuthProvider.google), contains('Google'));
|
||||
});
|
||||
});
|
||||
test('has correct name', () {
|
||||
expect(command.name, equals('login:ci'));
|
||||
});
|
||||
|
||||
test('exits with code 70 if no user is found', () async {
|
||||
when(
|
||||
() => auth.loginCI(any(), prompt: any(named: 'prompt')),
|
||||
).thenThrow(UserNotFoundException(email: email));
|
||||
test('has correct description', () {
|
||||
expect(command.description, contains('deprecated'));
|
||||
});
|
||||
|
||||
test('shows deprecation message and exits with code 0', () async {
|
||||
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('https://console.shorebird.dev'))),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('exits with code 70 when error occurs', () async {
|
||||
final error = Exception('oops something went wrong!');
|
||||
when(
|
||||
() => auth.loginCI(any(), prompt: any(named: 'prompt')),
|
||||
).thenThrow(error);
|
||||
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.software.code));
|
||||
|
||||
verify(() => auth.loginCI(any(), prompt: any(named: 'prompt'))).called(1);
|
||||
verify(() => logger.err(error.toString())).called(1);
|
||||
});
|
||||
|
||||
test('exits with code 0 when logged in successfully', () async {
|
||||
const token = CiToken(
|
||||
// "shorebird-token" in base64
|
||||
refreshToken: 'c2hvcmViaXJkLXRva2Vu', // cspell:disable-line
|
||||
authProvider: AuthProvider.google,
|
||||
);
|
||||
when(
|
||||
() => auth.loginCI(any(), prompt: any(named: 'prompt')),
|
||||
).thenAnswer((_) async => token);
|
||||
when(() => auth.email).thenReturn(email);
|
||||
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
|
||||
verify(() => auth.loginCI(any(), prompt: any(named: 'prompt'))).called(1);
|
||||
verify(
|
||||
() => logger.info(
|
||||
any(that: contains('${lightCyan.wrap(token.toBase64())}')),
|
||||
),
|
||||
).called(1);
|
||||
final captured = verify(
|
||||
() => logger.info(captureAny()),
|
||||
).captured;
|
||||
|
||||
final message = captured.single as String;
|
||||
expect(message, contains('shorebird login:ci is deprecated'));
|
||||
expect(message, contains('console.shorebird.dev'));
|
||||
expect(message, contains('SHOREBIRD_TOKEN'));
|
||||
});
|
||||
|
||||
test('prompt is correct', () {
|
||||
const url = 'http://example.com';
|
||||
runWithOverrides(() => command.prompt(url));
|
||||
test('does not trigger any auth flow', () async {
|
||||
await runWithOverrides(command.run);
|
||||
|
||||
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);
|
||||
verifyNoMoreInteractions(auth);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:scoped_deps/scoped_deps.dart';
|
||||
import 'package:shorebird_cli/src/auth/auth.dart';
|
||||
import 'package:shorebird_cli/src/commands/login_command.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
@@ -33,10 +32,6 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(AuthProvider.shorebird);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
applicationConfigHome = Directory.systemTemp.createTempSync();
|
||||
auth = MockAuth();
|
||||
@@ -49,12 +44,20 @@ void main() {
|
||||
() => auth.credentialsFilePath,
|
||||
).thenReturn(p.join(applicationConfigHome.path, 'credentials.json'));
|
||||
when(
|
||||
() => auth.login(any(), prompt: any(named: 'prompt')),
|
||||
() => auth.login(prompt: any(named: 'prompt')),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
command = runWithOverrides(LoginCommand.new);
|
||||
});
|
||||
|
||||
test('has correct name', () {
|
||||
expect(command.name, 'login');
|
||||
});
|
||||
|
||||
test('has correct description', () {
|
||||
expect(command.description, 'Login as a new Shorebird user.');
|
||||
});
|
||||
|
||||
group('when user is already logged in', () {
|
||||
setUp(() {
|
||||
when(() => auth.isAuthenticated).thenReturn(true);
|
||||
@@ -75,14 +78,36 @@ void main() {
|
||||
'''Run ${lightCyan.wrap('shorebird logout')} to log out and try again.''',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(() => auth.login(any(), prompt: any(named: 'prompt')));
|
||||
verifyNever(() => auth.login(prompt: any(named: 'prompt')));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('when user is authenticated via API key', () {
|
||||
setUp(() {
|
||||
when(() => auth.isAuthenticated).thenReturn(true);
|
||||
when(() => auth.email).thenReturn(null);
|
||||
});
|
||||
|
||||
test('prints API key message and exits with code 0', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => logger.info('You are already authenticated via API key.'),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(
|
||||
'''Run ${lightCyan.wrap('shorebird logout')} to log out and try again.''',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(() => auth.login(prompt: any(named: 'prompt')));
|
||||
});
|
||||
});
|
||||
|
||||
test('exits with code 70 if no user is found', () async {
|
||||
when(
|
||||
() => auth.login(any(), prompt: any(named: 'prompt')),
|
||||
() => auth.login(prompt: any(named: 'prompt')),
|
||||
).thenThrow(UserNotFoundException(email: email));
|
||||
|
||||
final result = await runWithOverrides(command.run);
|
||||
@@ -99,26 +124,26 @@ void main() {
|
||||
test('exits with code 70 when error occurs', () async {
|
||||
final error = Exception('oops something went wrong!');
|
||||
when(
|
||||
() => auth.login(any(), prompt: any(named: 'prompt')),
|
||||
() => auth.login(prompt: any(named: 'prompt')),
|
||||
).thenThrow(error);
|
||||
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.software.code));
|
||||
|
||||
verify(() => auth.login(any(), prompt: any(named: 'prompt'))).called(1);
|
||||
verify(() => auth.login(prompt: any(named: 'prompt'))).called(1);
|
||||
verify(() => logger.err(error.toString())).called(1);
|
||||
});
|
||||
|
||||
test('exits with code 0 when logged in successfully', () async {
|
||||
when(
|
||||
() => auth.login(any(), prompt: any(named: 'prompt')),
|
||||
() => auth.login(prompt: any(named: 'prompt')),
|
||||
).thenAnswer((_) async {});
|
||||
when(() => auth.email).thenReturn(email);
|
||||
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
|
||||
verify(() => auth.login(any(), prompt: any(named: 'prompt'))).called(1);
|
||||
verify(() => auth.login(prompt: any(named: 'prompt'))).called(1);
|
||||
verify(
|
||||
() => logger.info(
|
||||
any(that: contains('You are now logged in as <$email>.')),
|
||||
|
||||
Reference in New Issue
Block a user