diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index 52567f58..53af236a 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -116,6 +116,23 @@ class CodePushClientWrapper { ); } + /// Fetches the currently authenticated user. + Future getCurrentUser() async { + final progress = logger.progress('Fetching account'); + final PrivateUser? user; + try { + user = await codePushClient.getCurrentUser(); + progress.complete(); + } catch (error) { + _handleErrorAndExit(error, progress: progress); + } + if (user == null) { + logger.err('Could not find current user.'); + throw ProcessExit(ExitCode.software.code); + } + return user; + } + /// Fetches the organization memberships for the current user. Future> getOrganizationMemberships() async { final progress = logger.progress('Fetching organizations'); 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 00000000..2f2b4dd0 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/account.dart @@ -0,0 +1,3 @@ +export 'account_command.dart'; +export 'orgs_command.dart'; +export 'whoami_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 00000000..647d9d01 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/account_command.dart @@ -0,0 +1,19 @@ +import 'package:shorebird_cli/src/commands/account/account.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; + +/// {@template account_command} +/// Commands for inspecting the current Shorebird account. +/// {@endtemplate} +class AccountCommand extends ShorebirdCommand { + /// {@macro account_command} + AccountCommand() { + addSubcommand(OrgsCommand()); + addSubcommand(WhoamiCommand()); + } + + @override + String get name => 'account'; + + @override + String get description => 'Manage your Shorebird account.'; +} diff --git a/packages/shorebird_cli/lib/src/commands/account/orgs_command.dart b/packages/shorebird_cli/lib/src/commands/account/orgs_command.dart new file mode 100644 index 00000000..f33e44f3 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/orgs_command.dart @@ -0,0 +1,85 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template orgs_command} +/// `shorebird account orgs` +/// List the organizations the current user belongs to. +/// {@endtemplate} +class OrgsCommand extends ShorebirdCommand { + /// {@macro orgs_command} + OrgsCommand(); + + @override + String get name => 'orgs'; + + @override + String get description => + 'List the organizations you belong to.\n\n' + 'Example output (space-separated: id name type role):\n' + ' 1 Acme Corp team admin\n' + ' 2 user@example.com personal owner\n\n' + 'Type is "personal" or "team". Role is "owner", "admin", or ' + '"developer".\n\n' + '${ShorebirdCommand.jsonHint('shorebird account orgs --json')}'; + + @override + Future run() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + final List memberships; + try { + memberships = await codePushClientWrapper.getOrganizationMemberships(); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch organizations.', + ); + return e.exitCode; + } + rethrow; + } + + if (isJsonMode) { + emitJsonSuccess({ + 'organizations': [ + for (final m in memberships) + { + 'id': m.organization.id, + 'name': m.organization.name, + 'type': m.organization.organizationType.name, + 'role': m.role.name, + }, + ], + }); + return ExitCode.success.code; + } + + if (memberships.isEmpty) { + logger.info('No organizations found.'); + return ExitCode.success.code; + } + + for (final membership in memberships) { + final org = membership.organization; + logger.info( + '${org.id} ${lightCyan.wrap(org.name)} ' + '${org.organizationType.name} ${membership.role.name}', + ); + } + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/account/whoami_command.dart b/packages/shorebird_cli/lib/src/commands/account/whoami_command.dart new file mode 100644 index 00000000..dfb2ecb5 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/account/whoami_command.dart @@ -0,0 +1,86 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template whoami_command} +/// `shorebird account whoami` +/// Show the currently authenticated Shorebird user. +/// {@endtemplate} +class WhoamiCommand extends ShorebirdCommand { + /// {@macro whoami_command} + WhoamiCommand(); + + @override + String get name => 'whoami'; + + @override + String get description => + 'Show the currently authenticated Shorebird user.\n\n' + 'Example output:\n' + ' ID: 42\n' + ' Email: user@example.com\n' + ' Display name: Example User\n' + ' Plan: paid\n' + ' Overage limit: 10000\n\n' + 'Plan is "paid" (active Shorebird subscription) or "free".\n' + 'Overage limit is the max pay-as-you-go patch installs allowed ' + 'beyond your plan ("none" if unset).\n\n' + '${ShorebirdCommand.jsonHint('shorebird account whoami --json')}'; + + @override + Future run() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + final PrivateUser user; + try { + user = await codePushClientWrapper.getCurrentUser(); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch account.', + ); + return e.exitCode; + } + rethrow; + } + + final plan = (user.hasActiveSubscription ?? false) ? 'paid' : 'free'; + + if (isJsonMode) { + emitJsonSuccess({ + 'user': { + 'id': user.id, + 'email': user.email, + 'display_name': user.displayName, + 'plan': plan, + 'overage_limit': user.patchOverageLimit, + }, + }); + return ExitCode.success.code; + } + + logger + ..info('ID: ${user.id}') + ..info('Email: ${user.email}'); + if (user.displayName != null) { + logger.info('Display name: ${user.displayName}'); + } + logger + ..info('Plan: $plan') + ..info('Overage limit: ${user.patchOverageLimit ?? 'none'}'); + + 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 94c518b5..c351a128 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -1,3 +1,4 @@ +export 'account/account.dart'; export 'cache/cache.dart'; export 'create_command.dart'; export 'doctor_command.dart'; diff --git a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart index b3174f33..a7ad2f89 100644 --- a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart +++ b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart @@ -70,6 +70,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { help: 'The build of the local engine to use as the host platform.', ); + addCommand(AccountCommand()); addCommand(CacheCommand()); addCommand(CreateCommand()); addCommand(DoctorCommand()); diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index 7c2ed7b7..1802cbba 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -279,6 +279,49 @@ void main() { }); }); + group('getCurrentUser', () { + test('exits with code 70 when fetching the user fails', () async { + const error = 'something went wrong'; + when(() => codePushClient.getCurrentUser()).thenThrow(error); + + await expectLater( + () async => runWithOverrides(codePushClientWrapper.getCurrentUser), + exitsWithCode(ExitCode.software), + ); + verify(() => progress.fail(error)).called(1); + }); + + test('exits with code 70 when no current user is found', () async { + when( + () => codePushClient.getCurrentUser(), + ).thenAnswer((_) async => null); + + await expectLater( + () async => runWithOverrides(codePushClientWrapper.getCurrentUser), + exitsWithCode(ExitCode.software), + ); + verify(() => logger.err('Could not find current user.')).called(1); + }); + + test('returns the current user on success', () async { + const expectedUser = PrivateUser( + id: 1, + email: 'user@example.com', + jwtIssuer: 'https://accounts.google.com', + ); + when( + () => codePushClient.getCurrentUser(), + ).thenAnswer((_) async => expectedUser); + + final user = await runWithOverrides( + codePushClientWrapper.getCurrentUser, + ); + + expect(user, equals(expectedUser)); + verify(() => progress.complete()).called(1); + }); + }); + group('getOrganizationMemberships', () { test( 'exits with code 70 when getting organization memberships fails', diff --git a/packages/shorebird_cli/test/src/commands/account/orgs_command_test.dart b/packages/shorebird_cli/test/src/commands/account/orgs_command_test.dart new file mode 100644 index 00000000..ca3d6587 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/account/orgs_command_test.dart @@ -0,0 +1,249 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/account/orgs_command.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(OrgsCommand, () { + final teamMembership = OrganizationMembership( + organization: Organization( + id: 1, + name: 'Acme Corp', + organizationType: OrganizationType.team, + createdAt: DateTime(2026, 1, 15), + updatedAt: DateTime(2026, 1, 16), + ), + role: Role.admin, + ); + final personalMembership = OrganizationMembership( + organization: Organization( + id: 2, + name: 'user@example.com', + organizationType: OrganizationType.personal, + createdAt: DateTime(2026, 1, 10), + updatedAt: DateTime(2026, 1, 11), + ), + role: Role.owner, + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late Progress progress; + late OrgsCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + progress = MockProgress(); + shorebirdValidator = MockShorebirdValidator(); + command = runWithOverrides(OrgsCommand.new)..testArgResults = argResults; + + when(() => logger.progress(any())).thenReturn(progress); + when(() => argResults.rest).thenReturn([]); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + ), + ).thenAnswer((_) async {}); + when( + () => codePushClientWrapper.getOrganizationMemberships(), + ).thenAnswer((_) async => [teamMembership, personalMembership]); + }); + + test('has correct description', () { + expect( + command.description, + startsWith('List the organizations you belong to.'), + ); + }); + + group('when validation fails', () { + final exception = UserNotAuthorizedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + ), + ).thenThrow(exception); + }); + + test('returns the precondition failure exit code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + test('requires user to be authenticated', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + group('human-readable output', () { + test('prints one line per organization', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + final lines = verify( + () => logger.info(captureAny()), + ).captured.cast(); + expect(lines, hasLength(2)); + expect(lines[0], allOf(contains('Acme Corp'), contains('admin'))); + expect( + lines[1], + allOf(contains('user@example.com'), contains('owner')), + ); + }); + + group('when there are no organizations', () { + setUp(() { + when( + () => codePushClientWrapper.getOrganizationMemberships(), + ).thenAnswer((_) async => []); + }); + + test('prints an empty-state message', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info('No organizations found.')).called(1); + }); + }); + }); + + group('when API fetch fails', () { + setUp(() { + when( + () => codePushClientWrapper.getOrganizationMemberships(), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with flat organization fields', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + final orgs = data['organizations'] as List; + expect(orgs, hasLength(2)); + final firstOrg = orgs.first as Map; + expect(firstOrg['id'], 1); + expect(firstOrg['name'], 'Acme Corp'); + expect(firstOrg['type'], 'team'); + expect(firstOrg['role'], 'admin'); + }); + + test('does not leak timestamps or protocol-internal fields', () async { + final captured = []; + await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + final decoded = jsonDecode(captured.first) as Map; + final orgs = + ((decoded['data'] as Map)['organizations'] + as List) + .cast>(); + for (final org in orgs) { + expect(org.containsKey('created_at'), isFalse); + expect(org.containsKey('updated_at'), isFalse); + expect(org.containsKey('organization_type'), isFalse); + expect(org.containsKey('organization'), isFalse); + } + }); + + test('emits empty array when there are no organizations', () async { + when( + () => codePushClientWrapper.getOrganizationMemberships(), + ).thenAnswer((_) async => []); + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + final decoded = jsonDecode(captured.first) as Map; + final data = decoded['data'] as Map; + expect(data['organizations'], isEmpty); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/account/whoami_command_test.dart b/packages/shorebird_cli/test/src/commands/account/whoami_command_test.dart new file mode 100644 index 00000000..6444ca79 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/account/whoami_command_test.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/account/whoami_command.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(WhoamiCommand, () { + const user = PrivateUser( + id: 1, + email: 'user@example.com', + displayName: 'Example User', + hasActiveSubscription: true, + jwtIssuer: 'https://accounts.google.com', + patchOverageLimit: 10000, + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late Progress progress; + late WhoamiCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + progress = MockProgress(); + shorebirdValidator = MockShorebirdValidator(); + command = runWithOverrides(WhoamiCommand.new) + ..testArgResults = argResults; + + when(() => logger.progress(any())).thenReturn(progress); + when(() => argResults.rest).thenReturn([]); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + ), + ).thenAnswer((_) async {}); + when( + () => codePushClientWrapper.getCurrentUser(), + ).thenAnswer((_) async => user); + }); + + test('has correct description', () { + expect( + command.description, + startsWith('Show the currently authenticated Shorebird user.'), + ); + }); + + group('when validation fails', () { + final exception = UserNotAuthorizedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + ), + ).thenThrow(exception); + }); + + test('returns the precondition failure exit code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + test('requires user to be authenticated', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + group('human-readable output', () { + test('prints account fields', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('user@example.com'))), + ).called(1); + verify( + () => logger.info(any(that: contains('Example User'))), + ).called(1); + verify( + () => logger.info(any(that: contains('Plan: paid'))), + ).called(1); + verify( + () => logger.info(any(that: contains('Overage limit: 10000'))), + ).called(1); + }); + + group('when display name is null', () { + const userNoName = PrivateUser( + id: 2, + email: 'noname@example.com', + jwtIssuer: 'https://accounts.google.com', + ); + + setUp(() { + when( + () => codePushClientWrapper.getCurrentUser(), + ).thenAnswer((_) async => userNoName); + }); + + test('omits the display name line', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verifyNever( + () => logger.info(any(that: contains('Display name'))), + ); + }); + }); + + group('when user is on the free plan', () { + const userNoSub = PrivateUser( + id: 3, + email: 'noplan@example.com', + jwtIssuer: 'https://accounts.google.com', + ); + + setUp(() { + when( + () => codePushClientWrapper.getCurrentUser(), + ).thenAnswer((_) async => userNoSub); + }); + + test('prints plan as free', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('Plan: free'))), + ).called(1); + }); + + test('prints overage limit as none when unset', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('Overage limit: none'))), + ).called(1); + }); + }); + }); + + group('when API fetch fails', () { + setUp(() { + when( + () => codePushClientWrapper.getCurrentUser(), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with projected user fields', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + final userData = data['user'] as Map; + expect(userData['id'], 1); + expect(userData['email'], 'user@example.com'); + expect(userData['display_name'], 'Example User'); + expect(userData['plan'], 'paid'); + expect(userData['overage_limit'], 10000); + }); + + test('does not leak protocol-internal fields', () async { + final captured = []; + await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + final decoded = jsonDecode(captured.first) as Map; + final userData = + (decoded['data'] as Map)['user'] + as Map; + expect(userData.containsKey('stripe_customer_id'), isFalse); + expect(userData.containsKey('jwt_issuer'), isFalse); + expect(userData.containsKey('has_active_subscription'), isFalse); + }); + }); + }); +}