Skip to content

Commit

Permalink
test(cat-voices): user service, keychain, keychain provider (#1056)
Browse files Browse the repository at this point in the history
* fix: Keychain metadata typo fix + migration tests

* fix: secure storage key id lookup

* test: vault keychain

* test: vault keychain provider

* test: KeychainToUnlockTransformer

* test: UserService

* test: Session cubit

* refactor: services as interfaces with factory methods

* fix: analyzer

* fix: mocktail spelling

* refactor: session cubit group
  • Loading branch information
damian-molinski authored Oct 28, 2024
1 parent 32ef686 commit 7302840
Show file tree
Hide file tree
Showing 18 changed files with 797 additions and 79 deletions.
3 changes: 2 additions & 1 deletion .config/dictionaries/project.dic
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,5 @@ libcatalyst
staticlib
addrof
Lifetimeable
cbindgen
cbindgen
mocktail
31 changes: 17 additions & 14 deletions catalyst_voices/lib/dependency/dependencies.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@ final class Dependencies extends DependencyProvider {
authenticationRepository: get(),
),
)
..registerLazySingleton<SessionCubit>(() {
return SessionCubit(
get<UserService>(),
get<RegistrationService>(),
get<RegistrationProgressNotifier>(),
);
})
..registerLazySingleton<SessionCubit>(
() {
return SessionCubit(
get<UserService>(),
get<RegistrationService>(),
get<RegistrationProgressNotifier>(),
);
},
dispose: (cubit) async => cubit.close(),
)
// Factory will rebuild it each time needed
..registerFactory<RegistrationCubit>(() {
return RegistrationCubit(
Expand Down Expand Up @@ -74,17 +77,17 @@ final class Dependencies extends DependencyProvider {
);
registerLazySingleton<RegistrationService>(() {
return RegistrationService(
get<TransactionConfigRepository>(),
get<KeychainProvider>(),
get<CatalystCardano>(),
get<KeyDerivation>(),
transactionConfigRepository: get<TransactionConfigRepository>(),
keychainProvider: get<KeychainProvider>(),
cardano: get<CatalystCardano>(),
keyDerivation: get<KeyDerivation>(),
);
});
registerLazySingleton<UserService>(
() {
return UserServiceImpl(
get<KeychainProvider>(),
get<UserStorage>(),
return UserService(
keychainProvider: get<KeychainProvider>(),
userStorage: get<UserStorage>(),
);
},
dispose: (service) => unawaited(service.dispose()),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:catalyst_voices_blocs/src/bloc_error_emitter_mixin.dart';
import 'package:catalyst_voices_blocs/src/session/session_state.dart';
import 'package:catalyst_voices_models/catalyst_voices_models.dart';
Expand All @@ -13,12 +15,16 @@ final class SessionCubit extends Cubit<SessionState>
final RegistrationService _registrationService;
final RegistrationProgressNotifier _registrationProgressNotifier;

final _logger = Logger('SessionBloc');
final _logger = Logger('SessionCubit');

bool _hasKeychain = false;
bool _isUnlocked = false;
Account? _account;

StreamSubscription<bool>? _keychainSub;
StreamSubscription<bool>? _keychainUnlockedSub;
StreamSubscription<Account?>? _accountSub;

final String _dummyKeychainId = 'TestUserKeychainID';
static const LockFactor dummyUnlockFactor = PasswordLockFactor('Test1234');
final _dummySeedPhrase = SeedPhrase.fromMnemonic(
Expand All @@ -31,19 +37,19 @@ final class SessionCubit extends Cubit<SessionState>
this._registrationService,
this._registrationProgressNotifier,
) : super(const VisitorSessionState(isRegistrationInProgress: false)) {
_userService.watchKeychain
_keychainSub = _userService.watchKeychain
.map((keychain) => keychain != null)
.distinct()
.listen(_onHasKeychainChanged);

_userService.watchKeychain
_keychainUnlockedSub = _userService.watchKeychain
.transform(KeychainToUnlockTransformer())
.distinct()
.listen(_onActiveKeychainUnlockChanged);

_registrationProgressNotifier.addListener(_onRegistrationProgressChanged);

_userService.watchAccount.listen(_onActiveAccountChanged);
_accountSub = _userService.watchAccount.listen(_onActiveAccountChanged);
}

Future<bool> unlock(LockFactor lockFactor) {
Expand Down Expand Up @@ -76,6 +82,23 @@ final class SessionCubit extends Cubit<SessionState>
await _userService.useAccount(account);
}

@override
Future<void> close() async {
await _keychainSub?.cancel();
_keychainSub = null;

await _keychainUnlockedSub?.cancel();
_keychainUnlockedSub = null;

_registrationProgressNotifier
.removeListener(_onRegistrationProgressChanged);

await _accountSub?.cancel();
_accountSub = null;

return super.close();
}

void _onHasKeychainChanged(bool hasKeychain) {
_logger.fine('Has keychain changed [$hasKeychain]');

Expand Down
4 changes: 3 additions & 1 deletion catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.5
flutter_secure_storage: ^9.2.2
formz: ^0.7.0
meta: ^1.10.0
result_type: ^0.2.0
Expand All @@ -38,4 +39,5 @@ dev_dependencies:
catalyst_analysis: ^2.0.0
flutter_test:
sdk: flutter
test: ^1.24.9
mocktail: ^1.0.1
plugin_platform_interface: ^2.1.8
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import 'package:catalyst_voices_blocs/src/session/session.dart';
import 'package:catalyst_voices_models/catalyst_voices_models.dart';
import 'package:catalyst_voices_services/catalyst_voices_services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

void main() {
late final KeychainProvider keychainProvider;
late final UserStorage userStorage;

late final UserService userService;
late final RegistrationService registrationService;
late final RegistrationProgressNotifier notifier;

late SessionCubit sessionCubit;

setUpAll(() {
keychainProvider = VaultKeychainProvider();
userStorage = SecureUserStorage();

userService = UserService(
keychainProvider: keychainProvider,
userStorage: userStorage,
);
registrationService = _MockRegistrationService();
notifier = RegistrationProgressNotifier();
});

setUp(() {
FlutterSecureStorage.setMockInitialValues({});
sessionCubit = SessionCubit(userService, registrationService, notifier);
});

tearDown(() async {
await sessionCubit.close();

reset(registrationService);
});

group(SessionCubit, () {
test('when no keychain is found session is in Visitor state', () async {
// Given

// When
await userService.removeCurrentAccount();

// Then
expect(userService.keychain, isNull);
expect(sessionCubit.state, isA<VisitorSessionState>());
});

test('when no keychain is found session is in Visitor state', () async {
// Given

// When
await userService.removeCurrentAccount();

// Gives time for stream to emit.
await Future<void>.delayed(const Duration(milliseconds: 100));

// Then
expect(userService.keychain, isNull);
expect(sessionCubit.state, isA<VisitorSessionState>());
expect(
sessionCubit.state,
const VisitorSessionState(isRegistrationInProgress: false),
);
});

test(
'when no keychain is found but there is a registration progress '
'session is in Visitor state with correct flag', () async {
// Given
final keychainProgress = KeychainProgress(
seedPhrase: SeedPhrase(),
password: 'Test1234',
);

// When
notifier.value = RegistrationProgress(keychainProgress: keychainProgress);

await userService.removeCurrentAccount();

// Gives time for stream to emit.
await Future<void>.delayed(const Duration(milliseconds: 100));

// Then
expect(userService.keychain, isNull);
expect(sessionCubit.state, isA<VisitorSessionState>());
expect(
sessionCubit.state,
const VisitorSessionState(isRegistrationInProgress: true),
);
});

test('when keychain is locked session is in Guest state', () async {
// Given
const keychainId = 'id';
const lockFactor = PasswordLockFactor('Test1234');

// When
final keychain = await keychainProvider.create(keychainId);
await keychain.setLock(lockFactor);
await keychain.lock();

await userService.useKeychain(keychainId);

// Gives time for stream to emit.
await Future<void>.delayed(const Duration(milliseconds: 100));

// Then
expect(userService.keychain, isNotNull);
expect(sessionCubit.state, isNot(isA<VisitorSessionState>()));
expect(sessionCubit.state, isA<GuestSessionState>());
});

test('when keychain is unlocked session is in Active state', () async {
// Given
const keychainId = 'id';
const lockFactor = PasswordLockFactor('Test1234');

// When
final keychain = await keychainProvider.create(keychainId);
await keychain.setLock(lockFactor);

await userService.useKeychain(keychainId);
await userService.keychain?.unlock(lockFactor);

// Gives time for stream to emit.
await Future<void>.delayed(const Duration(milliseconds: 100));

// Then
expect(userService.keychain, isNotNull);
expect(sessionCubit.state, isNot(isA<VisitorSessionState>()));
expect(sessionCubit.state, isNot(isA<GuestSessionState>()));
expect(sessionCubit.state, isA<ActiveAccountSessionState>());
});
});
}

class _MockRegistrationService extends Mock implements RegistrationService {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,45 @@ import 'package:equatable/equatable.dart';

// TODO(damian-molinski): Migrate serialization to json_serializable.
final class KeychainMetadata extends Equatable {
final DateTime createAt;
final DateTime createdAt;
final DateTime updatedAt;

const KeychainMetadata({
required this.createAt,
required this.createdAt,
required this.updatedAt,
});

factory KeychainMetadata.fromJson(Map<String, dynamic> json) {
// Typo migration
if (!json.containsKey('createdAt') && json.containsKey('createAt')) {
json['createdAt'] = json['createAt'];
}
return KeychainMetadata(
createAt: DateTime.parse(json['createAt'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}

KeychainMetadata copyWith({
DateTime? createAt,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return KeychainMetadata(
createAt: createAt ?? this.createAt,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}

Map<String, dynamic> toJson() {
return {
'createAt': createAt.toIso8601String(),
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}

@override
List<Object?> get props => [
createAt,
createdAt,
updatedAt,
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'package:catalyst_voices_models/catalyst_voices_models.dart';
import 'package:test/test.dart';

void main() {
group('Serialization', () {
test('json createAt to createdAt', () {
// Given
final createdAt = DateTime.timestamp();

// When
final json = {
'createAt': createdAt.toIso8601String(),
'updatedAt': DateTime.timestamp().toIso8601String(),
};

// Then
final metadata = KeychainMetadata.fromJson(json);

expect(metadata.createdAt, createdAt);
});
});

group('Equality', () {
test('same source dates equals', () {
// Given
final now = DateTime.now();

// When
final metadataOne = KeychainMetadata(createdAt: now, updatedAt: now);
final metadataTwo = KeychainMetadata(createdAt: now, updatedAt: now);

// Then
expect(metadataOne, metadataTwo);
});

test('different source dates equals', () {
// Given
final now = DateTime.now();
final isPast = now.subtract(const Duration(days: 1));

// When
final metadataOne = KeychainMetadata(createdAt: now, updatedAt: now);
final metadataTwo = KeychainMetadata(
createdAt: isPast,
updatedAt: isPast,
);

// Then
expect(metadataOne, isNot(metadataTwo));
});
});
}
Loading

0 comments on commit 7302840

Please sign in to comment.