diff --git a/packages/neon_framework/lib/src/blocs/accounts.dart b/packages/neon_framework/lib/src/blocs/accounts.dart index 541b8e34b15..7ceb0df6537 100644 --- a/packages/neon_framework/lib/src/blocs/accounts.dart +++ b/packages/neon_framework/lib/src/blocs/accounts.dart @@ -111,6 +111,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { for (final app in allAppImplementations) { app.blocsCache.pruneAgainst(accounts); } + unawaited(checkRemoteWipe(accounts)); }); } @@ -129,6 +130,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { final weatherStatusBlocs = AccountCache(); final maintenanceModeBlocs = AccountCache(); final referencesBlocs = AccountCache(); + final remoteWipeChecks = {}; @override void dispose() { @@ -154,7 +156,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { @override Future removeAccount(Account account) async { try { - await _accountRepository.logOut(account.id); + await _accountRepository.logOut(account); } on DeleteCredentialsFailure catch (error, stackTrace) { log.info( 'Error deleting the app password.', @@ -228,4 +230,45 @@ class _AccountsBloc extends Bloc implements AccountsBloc { account: account, capabilities: getCapabilitiesBlocFor(account).capabilities, ); + + Future checkRemoteWipe(BuiltList accounts) async { + for (final account in accounts) { + // Only check each account once per app start + if (remoteWipeChecks.contains(account)) { + return; + } + remoteWipeChecks.add(account); + + log.finer('Checking remote wipe status for account ${account.id}.'); + + try { + final wipe = await _accountRepository.getRemoteWipeStatus(account); + if (!wipe) { + return; + } + + log.finer('Wiping account ${account.id}.'); + + await removeAccount(account); + + try { + await _accountRepository.postRemoteWipeSuccess(account); + } on PostRemoteWipeSuccessFailure catch (error, stackTrace) { + log.finer( + 'Failed to post remote wipe success for account ${account.id}.', + error, + stackTrace, + ); + } + + log.finer('Wiped account ${account.id}.'); + } on GetRemoteWipeStatusFailure catch (error, stackTrace) { + log.finer( + 'Failed to get remote wipe status for account ${account.id}.', + error, + stackTrace, + ); + } + } + } } diff --git a/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart index a8bdc465372..c5649f434bd 100644 --- a/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart +++ b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart @@ -70,6 +70,22 @@ final class DeleteCredentialsFailure extends AccountFailure { const DeleteCredentialsFailure(super.error); } +/// {@template get_remote_wipe_status_failure} +/// Thrown when getting the device remote wipe status fails. +/// {@endtemplate} +final class GetRemoteWipeStatusFailure extends AccountFailure { + /// {@macro get_remote_wipe_status_failure} + const GetRemoteWipeStatusFailure(super.error); +} + +/// {@template post_remote_wipe_success_failure} +/// Thrown when posting the device remote wipe success fails. +/// {@endtemplate} +final class PostRemoteWipeSuccessFailure extends AccountFailure { + /// {@macro post_remote_wipe_success_failure} + const PostRemoteWipeSuccessFailure(super.error); +} + /// {@template account_repository} /// A repository that manages the account data. /// {@endtemplate} @@ -275,20 +291,15 @@ class AccountRepository { /// Logs out the user from the server. /// /// May throw a [DeleteCredentialsFailure]. - Future logOut(String accountID) async { + Future logOut(Account account) async { final value = _accounts.value; - Account? account; final accounts = value.accounts.rebuild((b) { - account = b.remove(accountID); + b.remove(account.id); }); - if (account == null) { - return; - } - var active = value.active; - if (active == accountID) { + if (active == account.id) { active = accounts.keys.firstOrNull; } @@ -300,7 +311,7 @@ class AccountRepository { _accounts.add((active: active, accounts: accounts)); try { - await account?.client.authentication.appPassword.deleteAppPassword(); + await account.client.authentication.appPassword.deleteAppPassword(); } on http.ClientException catch (error, stackTrace) { Error.throwWithStackTrace(DeleteCredentialsFailure(error), stackTrace); } @@ -326,4 +337,53 @@ class AccountRepository { _accounts.add((active: accountID, accounts: value.accounts)); await _storage.saveLastAccount(accountID); } + + /// Gets the device remote wipe status. + /// + /// May throw a [GetRemoteWipeStatusFailure]. + Future getRemoteWipeStatus(Account account) async { + final client = buildUnauthenticatedClient( + httpClient: _httpClient, + userAgent: _userAgent, + serverURL: account.credentials.serverURL, + ); + + try { + final response = await client.authentication.wipe.checkWipe( + $body: core.WipeCheckWipeRequestApplicationJson( + (b) => b..token = account.credentials.appPassword, + ), + ); + + // This is always true, as otherwise 404 is returned, but just to be safe in the future use the returned value. + return response.body.wipe; + } on http.ClientException catch (error, stackTrace) { + if (error case DynamiteStatusCodeException() when error.statusCode == 404) { + return false; + } + + Error.throwWithStackTrace(GetRemoteWipeStatusFailure(error), stackTrace); + } + } + + /// Posts the remote wipe success. + /// + /// May throw a [PostRemoteWipeSuccessFailure]. + Future postRemoteWipeSuccess(Account account) async { + final client = buildUnauthenticatedClient( + httpClient: _httpClient, + userAgent: _userAgent, + serverURL: account.credentials.serverURL, + ); + + try { + await client.authentication.wipe.wipeDone( + $body: core.WipeWipeDoneRequestApplicationJson( + (b) => b..token = account.credentials.appPassword, + ), + ); + } on http.ClientException catch (error, stackTrace) { + Error.throwWithStackTrace(PostRemoteWipeSuccessFailure(error), stackTrace); + } + } } diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart index a17b579097e..8318971ba2d 100644 --- a/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart +++ b/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart @@ -16,6 +16,7 @@ class AuthenticationClient { required this.appPassword, required this.clientFlowLoginV2, required this.users, + required this.wipe, }); final $core.$Client core; @@ -25,6 +26,8 @@ class AuthenticationClient { final $core.$ClientFlowLoginV2Client clientFlowLoginV2; final $provisioning_api.$UsersClient users; + + final $core.$WipeClient wipe; } /// Extension for getting the [AuthenticationClient]. @@ -38,5 +41,6 @@ extension AuthenticationClientExtension on NextcloudClient { appPassword: core.appPassword, clientFlowLoginV2: core.clientFlowLoginV2, users: provisioningApi.users, + wipe: core.wipe, ); } diff --git a/packages/neon_framework/packages/account_repository/test/account_repository_test.dart b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart index 3a2c0c0a647..2f24cbd25e4 100644 --- a/packages/neon_framework/packages/account_repository/test/account_repository_test.dart +++ b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart @@ -4,6 +4,7 @@ import 'package:account_repository/account_repository.dart'; import 'package:account_repository/src/testing/testing.dart'; import 'package:account_repository/src/utils/authentication_client.dart'; import 'package:built_collection/built_collection.dart'; +import 'package:built_value/json_object.dart'; import 'package:built_value_test/matcher.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; @@ -38,6 +39,14 @@ class _ClientFlowLoginV2ClientMock extends Mock implements core.$ClientFlowLogin class _UsersClientMock extends Mock implements provisioning_api.$UsersClient {} +class _WipeClientMock extends Mock implements core.$WipeClient {} + +class _WipeCheckResponseMock extends Mock implements core.WipeCheckWipeResponseApplicationJson {} + +class _FakeWipeCheckRequest extends Fake implements core.WipeCheckWipeRequestApplicationJson {} + +class _FakeWipeDoneRequest extends Fake implements core.WipeWipeDoneRequestApplicationJson {} + class _AccountStorageMock extends Mock implements AccountStorage {} typedef _AccountStream = ({BuiltList accounts, Account? active}); @@ -50,10 +59,13 @@ void main() { late core.$AppPasswordClient appPassword; late core.$ClientFlowLoginV2Client clientFlowLoginV2; late provisioning_api.$UsersClient users; + late core.$WipeClient wipe; setUpAll(() { registerFallbackValue(_FakeUri()); registerFallbackValue(_FakePollRequest()); + registerFallbackValue(_FakeWipeCheckRequest()); + registerFallbackValue(_FakeWipeDoneRequest()); MockNeonStorage(); }); @@ -62,12 +74,14 @@ void main() { appPassword = _AppPasswordClientMock(); clientFlowLoginV2 = _ClientFlowLoginV2ClientMock(); users = _UsersClientMock(); + wipe = _WipeClientMock(); mockedClient = AuthenticationClient( core: coreClient, appPassword: appPassword, clientFlowLoginV2: clientFlowLoginV2, users: users, + wipe: wipe, ); storage = _AccountStorageMock(); @@ -506,7 +520,8 @@ void main() { when(() => appPassword.deleteAppPassword()).thenAnswer( (_) async => _DynamiteResponseMock(), ); - await repository.logOut(credentialsList.first.id); + final accounts = await repository.accounts.first; + await repository.logOut(accounts.accounts.first); await expectLater( repository.accounts, @@ -522,24 +537,12 @@ void main() { verify(() => storage.saveCredentials(any(that: equals([credentialsList[1]])))).called(1); }); - test('tries to remove invalid account', () async { - when(() => appPassword.deleteAppPassword()).thenThrow(http.ClientException('')); - - await expectLater( - repository.logOut('invalid'), - completes, - ); - - verifyNever(() => appPassword.deleteAppPassword()); - verifyNever(() => storage.saveLastAccount(any())); - verifyNever(() => storage.saveCredentials(any())); - }); - test('rethrows http exceptions as `DeleteCredentialsFailure`', () async { when(() => appPassword.deleteAppPassword()).thenThrow(http.ClientException('')); + final accounts = await repository.accounts.first; await expectLater( - repository.logOut(credentialsList.first.id), + repository.logOut(accounts.accounts.first), throwsA(isA().having((e) => e.error, 'error', isA())), ); @@ -598,5 +601,127 @@ void main() { verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); }); }); + + group('getRemoteWipeStatus', () { + group('retrieves remote wipe status from server', () { + test('should wipe', () async { + final wipeCheckResponse = _WipeCheckResponseMock(); + when(() => wipeCheckResponse.wipe).thenReturn(true); + final response = _DynamiteResponseMock<_WipeCheckResponseMock, void>(); + when(() => response.body).thenReturn(wipeCheckResponse); + + when(() => wipe.checkWipe($body: any(named: r'$body'))).thenAnswer((_) async => response); + + await expectLater( + repository.getRemoteWipeStatus(accountsList.first), + completion(true), + ); + + verify( + () => wipe.checkWipe( + $body: any( + named: r'$body', + that: isA().having( + (b) => b.token, + 'token', + 'appPassword', + ), + ), + ), + ).called(1); + }); + + test('should not wipe', () async { + when(() => wipe.checkWipe($body: any(named: r'$body'))) + .thenThrow(DynamiteStatusCodeException(http.Response('', 404))); + + await expectLater( + repository.getRemoteWipeStatus(accountsList.first), + completion(false), + ); + + verify( + () => wipe.checkWipe( + $body: any( + named: r'$body', + that: isA().having( + (b) => b.token, + 'token', + 'appPassword', + ), + ), + ), + ).called(1); + }); + }); + + test('rethrows http exceptions as `GetRemoteWipeStatusFailure`', () async { + when(() => wipe.checkWipe($body: any(named: r'$body'))).thenThrow(http.ClientException('')); + + await expectLater( + repository.getRemoteWipeStatus(accountsList.first), + throwsA(isA().having((e) => e.error, 'error', isA())), + ); + + verify( + () => wipe.checkWipe( + $body: any( + named: r'$body', + that: isA().having( + (b) => b.token, + 'token', + 'appPassword', + ), + ), + ), + ).called(1); + }); + }); + + group('postRemoteWipeSuccess', () { + test('posts remote wipe success server', () async { + final response = _DynamiteResponseMock(); + when(() => response.body).thenReturn(JsonObject('')); + + when(() => wipe.wipeDone($body: any(named: r'$body'))).thenAnswer((_) async => response); + + await repository.postRemoteWipeSuccess(accountsList.first); + + verify( + () => wipe.wipeDone( + $body: any( + named: r'$body', + that: isA().having( + (b) => b.token, + 'token', + 'appPassword', + ), + ), + ), + ).called(1); + }); + + test('rethrows http exceptions as `PostRemoteWipeSuccessFailure`', () async { + when(() => wipe.wipeDone($body: any(named: r'$body'))).thenThrow(http.ClientException('')); + + await expectLater( + repository.postRemoteWipeSuccess(accountsList.first), + throwsA(isA().having((e) => e.error, 'error', isA())), + ); + + verify( + () => wipe.wipeDone( + $body: any( + named: r'$body', + that: isA().having( + (b) => b.token, + 'token', + 'appPassword', + ), + ), + ), + ).called(1); + }); + }); }); } diff --git a/packages/neon_framework/test/accounts_bloc_test.dart b/packages/neon_framework/test/accounts_bloc_test.dart new file mode 100644 index 00000000000..38254255dc2 --- /dev/null +++ b/packages/neon_framework/test/accounts_bloc_test.dart @@ -0,0 +1,194 @@ +// ignore_for_file: discarded_futures + +import 'package:account_repository/account_repository.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/src/blocs/accounts.dart'; +import 'package:rxdart/rxdart.dart'; + +class _MockAccountRepository extends Mock implements AccountRepository {} + +class _MockAccount extends Mock implements Account {} + +class _FakeAccount extends Fake implements Account {} + +void main() { + late BehaviorSubject<({BuiltList accounts, Account? active})> accounts; + late AccountRepository accountRepository; + late AccountsBloc bloc; + + setUpAll(() { + registerFallbackValue(_FakeAccount()); + }); + + setUp(() { + accounts = BehaviorSubject(); + + accountRepository = _MockAccountRepository(); + when(() => accountRepository.accounts).thenAnswer((_) => accounts); + + bloc = AccountsBloc( + allAppImplementations: BuiltSet(), + accountRepository: accountRepository, + ); + + verify(() => accountRepository.accounts).called(1); + }); + + tearDown(() { + bloc.dispose(); + + accounts.close(); + + verifyNoMoreInteractions(accountRepository); + }); + + group('removeAccount', () { + test('Success', () { + when(() => accountRepository.logOut(any())).thenAnswer((_) async {}); + + final account = _MockAccount(); + + bloc.removeAccount(account); + verify(() => accountRepository.logOut(account)).called(1); + }); + test('DeleteCredentialsFailure', () { + when(() => accountRepository.logOut(any())).thenThrow(const DeleteCredentialsFailure('')); + + final account = _MockAccount(); + + bloc.removeAccount(account); + verify(() => accountRepository.logOut(account)).called(1); + }); + }); + + test('setActiveAccount', () { + when(() => accountRepository.switchAccount(any())).thenAnswer((_) async {}); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + bloc.setActiveAccount(account); + verify(() => accountRepository.switchAccount('id')).called(1); + }); + + test('accounts', () async { + when(() => accountRepository.getRemoteWipeStatus(any())).thenAnswer((_) async => false); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: null)); + + await Future.delayed(const Duration(milliseconds: 1)); + + expect(bloc.accounts, emits(BuiltList([account]))); + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + }); + + test('activeAccount', () async { + when(() => accountRepository.getRemoteWipeStatus(any())).thenAnswer((_) async => false); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: account)); + + await Future.delayed(const Duration(milliseconds: 1)); + + expect(bloc.activeAccount, emits(account)); + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + }); + + test('hasAccounts', () async { + when(() => accountRepository.getRemoteWipeStatus(any())).thenAnswer((_) async => false); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: account)); + + await Future.delayed(const Duration(milliseconds: 1)); + + expect(bloc.hasAccounts, true); + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + }); + + test('accountByID', () { + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + when(() => accountRepository.accountByID(any())).thenReturn(account); + + expect(bloc.accountByID('id'), account); + verify(() => accountRepository.accountByID('id')).called(1); + }); + + group('Remote wipe', () { + group('Should not wipe', () { + test('Status false', () async { + when(() => accountRepository.getRemoteWipeStatus(any())).thenAnswer((_) async => false); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: account)); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + }); + + test('GetRemoteWipeStatusFailure', () async { + when(() => accountRepository.getRemoteWipeStatus(any())).thenThrow(const GetRemoteWipeStatusFailure('')); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: account)); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + }); + }); + + group('Should wipe', () { + setUp(() { + when(() => accountRepository.getRemoteWipeStatus(any())).thenAnswer((_) async => true); + when(() => accountRepository.logOut(any())).thenAnswer((_) async {}); + }); + + test('Post success', () async { + when(() => accountRepository.postRemoteWipeSuccess(any())).thenAnswer((_) async {}); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: account)); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + verify(() => accountRepository.logOut(account)).called(1); + verify(() => accountRepository.postRemoteWipeSuccess(account)).called(1); + }); + + test('PostRemoteWipeSuccessFailure', () async { + when(() => accountRepository.postRemoteWipeSuccess(any())).thenThrow(const PostRemoteWipeSuccessFailure('')); + + final account = _MockAccount(); + when(() => account.id).thenReturn('id'); + + accounts.add((accounts: BuiltList([account]), active: account)); + + await Future.delayed(const Duration(milliseconds: 1)); + + verify(() => accountRepository.getRemoteWipeStatus(account)).called(1); + verify(() => accountRepository.logOut(account)).called(1); + verify(() => accountRepository.postRemoteWipeSuccess(account)).called(1); + }); + }); + }); +}