From be66e4b3a36609a028d865e07d1b3242c1c04f8a Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis <leptopoda@users.noreply.github.com> Date: Wed, 14 Aug 2024 10:37:42 +0200 Subject: [PATCH] feat(neon_framework): add account_repository Signed-off-by: Nikolas Rimikis <leptopoda@users.noreply.github.com> --- .../packages/account_repository/LICENSE | 1 + .../account_repository/analysis_options.yaml | 5 + .../lib/account_repository.dart | 2 + .../lib/src/account_repository.dart | 329 ++++++++++ .../lib/src/account_storage.dart | 54 ++ .../lib/src/models/account.dart | 36 ++ .../lib/src/models/account.g.dart | 110 ++++ .../lib/src/models/credentials.dart | 61 ++ .../lib/src/models/credentials.g.dart | 175 +++++ .../lib/src/models/login_qr_code.dart | 102 +++ .../lib/src/models/models.dart | 4 + .../lib/src/models/serializers.dart | 11 + .../lib/src/testing/testing.dart | 2 + .../lib/src/testing/testing_account.dart | 32 + .../lib/src/testing/testing_credentials.dart | 17 + .../lib/src/utils/authentication_client.dart | 42 ++ .../lib/src/utils/http_client_builder.dart | 52 ++ .../lib/src/utils/utils.dart | 2 + .../account_repository/lib/testing.dart | 4 + .../packages/account_repository/pubspec.yaml | 39 ++ .../account_repository/pubspec_overrides.yaml | 16 + .../test/account_repository_test.dart | 602 ++++++++++++++++++ .../test/account_storage_test.dart | 127 ++++ .../test/models/account_test.dart | 94 +++ .../test/models/credentials_test.dart | 132 ++++ .../test/models/login_qr_code_test.dart | 41 ++ .../utils/authentication_client_test.dart | 16 + .../test/utils/http_client_builder_test.dart | 95 +++ 28 files changed, 2203 insertions(+) create mode 120000 packages/neon_framework/packages/account_repository/LICENSE create mode 100644 packages/neon_framework/packages/account_repository/analysis_options.yaml create mode 100644 packages/neon_framework/packages/account_repository/lib/account_repository.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/account_repository.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/account_storage.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/account.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/models.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart create mode 100644 packages/neon_framework/packages/account_repository/lib/testing.dart create mode 100644 packages/neon_framework/packages/account_repository/pubspec.yaml create mode 100644 packages/neon_framework/packages/account_repository/pubspec_overrides.yaml create mode 100644 packages/neon_framework/packages/account_repository/test/account_repository_test.dart create mode 100644 packages/neon_framework/packages/account_repository/test/account_storage_test.dart create mode 100644 packages/neon_framework/packages/account_repository/test/models/account_test.dart create mode 100644 packages/neon_framework/packages/account_repository/test/models/credentials_test.dart create mode 100644 packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart create mode 100644 packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart create mode 100644 packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart diff --git a/packages/neon_framework/packages/account_repository/LICENSE b/packages/neon_framework/packages/account_repository/LICENSE new file mode 120000 index 00000000000..f0b83dad961 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/LICENSE @@ -0,0 +1 @@ +../../../../assets/AGPL-3.0.txt \ No newline at end of file diff --git a/packages/neon_framework/packages/account_repository/analysis_options.yaml b/packages/neon_framework/packages/account_repository/analysis_options.yaml new file mode 100644 index 00000000000..bff1b129f3c --- /dev/null +++ b/packages/neon_framework/packages/account_repository/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:neon_lints/dart.yaml + +custom_lint: + rules: + - avoid_exports: false diff --git a/packages/neon_framework/packages/account_repository/lib/account_repository.dart b/packages/neon_framework/packages/account_repository/lib/account_repository.dart new file mode 100644 index 00000000000..49ced2d9d1e --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/account_repository.dart @@ -0,0 +1,2 @@ +export 'src/account_repository.dart'; +export 'src/models/models.dart'; 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 new file mode 100644 index 00000000000..e7a472e7818 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart @@ -0,0 +1,329 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:account_repository/src/models/models.dart'; +import 'package:account_repository/src/utils/utils.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; +import 'package:neon_framework/storage.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'account_storage.dart'; + +/// {@template account_failure} +/// A base failure for the account repository failures. +/// {@endtemplate} +sealed class AccountFailure with EquatableMixin implements Exception { + /// {@macro account_failure} + const AccountFailure(this.error); + + /// The error which was thrown. + final Object error; + + @override + List<Object> get props => [error]; +} + +/// {@template fetch_status_failure} +/// Thrown when fetching the server status fails. +/// {@endtemplate} +final class FetchStatusFailure extends AccountFailure { + /// {@macro fetch_status_failure} + const FetchStatusFailure(super.error); +} + +/// {@template init_login_failure} +/// Thrown when initializing the login flow fails. +/// {@endtemplate} +final class InitLoginFailure extends AccountFailure { + /// {@macro init_login_failure} + const InitLoginFailure(super.error); +} + +/// {@template poll_login_failure} +/// Thrown when polling the login flow fails. +/// {@endtemplate} +final class PollLoginFailure extends AccountFailure { + /// {@macro poll_login_failure} + const PollLoginFailure(super.error); +} + +/// {@template fetch_account_failure} +/// Thrown when fetching the account information fails. +/// {@endtemplate} +final class FetchAccountFailure extends AccountFailure { + /// {@macro fetch_account_failure} + const FetchAccountFailure(super.error); +} + +/// {@template delete_credentials_failure} +/// Thrown when deleting the account credentials fails. +/// +/// This can happen when the user logs out but removing the app password on the server fails. +/// {@endtemplate} +final class DeleteCredentialsFailure extends AccountFailure { + /// {@macro delete_credentials_failure} + const DeleteCredentialsFailure(super.error); +} + +/// {@template account_repository} +/// A repository that manages the account data. +/// {@endtemplate} +class AccountRepository { + /// {@macro account_repository} + AccountRepository({ + required String userAgent, + required http.Client httpClient, + required AccountStorage storage, + }) : _userAgent = userAgent, + _httpClient = httpClient, + _storage = storage; + + final String _userAgent; + final http.Client _httpClient; + final AccountStorage _storage; + + final BehaviorSubject<({String? active, BuiltMap<String, Account> accounts})> _accounts = + BehaviorSubject.seeded((active: null, accounts: BuiltMap())); + + /// A stream of account information. + /// + /// The initial state of the stream will be `(active: null, accounts: BuiltMap())`. + Stream<({Account? active, BuiltList<Account> accounts})> get accounts => _accounts.stream.map((e) { + final active = e.accounts[e.active]; + final accounts = e.accounts.values.toBuiltList(); + + return (active: active, accounts: accounts); + }).asBroadcastStream(); + + /// Whether accounts are logged in. + bool get hasAccounts { + return _accounts.hasValue && _accounts.value.accounts.isNotEmpty; + } + + /// Retrieves the account associated with the given [accountID]. + Account? accountByID(String accountID) { + return _accounts.value.accounts[accountID]; + } + + /// Checks whether the provided [url] is a valid login qr code. + static bool isLogInQRCode({required String url}) { + return LoginQRcode.tryParse(url) != null; + } + + /// Loads the stored account information. + /// + /// If specified [rememberLastUsedAccount] takes precedence over [initialAccount]. + /// If the stored or provided initial account are not in the list of stored credentials + /// they are silently ignored and the default of `first account in the list` is used. + Future<void> loadAccounts({ + String? initialAccount, + bool rememberLastUsedAccount = false, + }) async { + final stored = await _storage.readCredentials(); + final accounts = BuiltMap<String, Account>.build((b) { + for (final credentials in stored) { + b[credentials.id] = Account((b) { + b + ..credentials.replace(credentials) + ..client = buildClient( + httpClient: _httpClient, + userAgent: _userAgent, + credentials: credentials, + ); + }); + } + }); + + var active = initialAccount; + if (rememberLastUsedAccount) { + active = await _storage.readLastAccount(); + } + + if (active == null || !accounts.containsKey(active)) { + active = accounts.keys.firstOrNull; + } + + _accounts.add( + ( + active: active, + accounts: accounts, + ), + ); + } + + /// Fetches the status of the server. + /// + /// May throw a [FetchStatusFailure]. + Future<core.Status> getServerStatus(Uri serverURL) async { + final client = buildUnauthenticatedClient( + httpClient: _httpClient, + userAgent: _userAgent, + serverURL: serverURL, + ); + + try { + final response = await client.authentication.core.getStatus(); + return response.body; + } on http.ClientException catch (error, stackTrace) { + Error.throwWithStackTrace(FetchStatusFailure(error), stackTrace); + } + } + + /// Initializes the Nextcloud login flow. + /// + /// May throw a [InitLoginFailure]. + Future<(Uri loginUrl, String token)> loginFlowInit(Uri serverURL) async { + final client = buildUnauthenticatedClient( + httpClient: _httpClient, + userAgent: _userAgent, + serverURL: serverURL, + ); + + try { + final initResponse = await client.authentication.clientFlowLoginV2.init(); + + final loginUrl = Uri.parse(initResponse.body.login); + final token = initResponse.body.poll.token; + + return (loginUrl, token); + } on http.ClientException catch (error, stackTrace) { + Error.throwWithStackTrace(InitLoginFailure(error), stackTrace); + } + } + + /// Polls the login flow endpoint and returns the retrieved credentials. + /// + /// Throws a [PollLoginFailure] when polling the endpoint fails. The flow must be initialized + /// again if a failure occurs. + Future<Credentials?> loginFlowPoll(Uri serverURL, String token) async { + final client = buildUnauthenticatedClient( + httpClient: _httpClient, + userAgent: _userAgent, + serverURL: serverURL, + ); + + final body = core.ClientFlowLoginV2PollRequestApplicationJson((b) { + b.token = token; + }); + + try { + final resultResponse = await client.authentication.clientFlowLoginV2.poll($body: body); + + final response = resultResponse.body; + return Credentials((b) { + b + ..serverURL = Uri.parse(response.server) + ..username = response.loginName + ..password = response.appPassword; + }); + } on http.ClientException catch (error, stackTrace) { + if (error case DynamiteStatusCodeException(statusCode: 404)) { + return null; + } + + Error.throwWithStackTrace(PollLoginFailure(error), stackTrace); + } + } + + /// Fetches the information for the account with the given [credentials]. + /// + /// May throw a [FetchAccountFailure]. + Future<Account> getAccount(Credentials credentials) async { + final client = buildClient( + credentials: credentials, + userAgent: _userAgent, + httpClient: _httpClient, + ); + + try { + final response = await client.authentication.users.getCurrentUser(); + + return Account((b) { + b + ..client = client + ..credentials.replace(credentials) + ..credentials.username = response.body.ocs.data.id; + }); + } on http.ClientException catch (error, stackTrace) { + Error.throwWithStackTrace(FetchAccountFailure(error), stackTrace); + } + } + + /// Logs in the given [account]. + /// + /// It will also set the active account if none is active yet. + Future<void> logIn(Account account) async { + final value = _accounts.value; + + final active = value.active ?? account.credentials.id; + final accounts = value.accounts.rebuild((b) { + b[account.credentials.id] = account; + }); + + _accounts.add((active: active, accounts: accounts)); + await Future.wait([ + _storage.saveCredentials(accounts.values.map((e) => e.credentials)), + _storage.saveLastAccount(active), + ]); + } + + /// Logs out the user from the server. + /// + /// May throw a [DeleteCredentialsFailure]. + Future<void> logOut(String accountID) async { + final value = _accounts.value; + + Account? account; + final accounts = value.accounts.rebuild((b) { + account = b.remove(accountID); + }); + + if (account == null) { + return; + } + + var active = value.active; + if (active == accountID) { + active = accounts.keys.firstOrNull; + } + + await Future.wait([ + _storage.saveCredentials(accounts.values.map((e) => e.credentials)), + _storage.saveLastAccount(active), + ]); + + _accounts.add((active: active, accounts: accounts)); + + try { + await account?.client.authentication.appPassword.deleteAppPassword(); + } on http.ClientException catch (error, stackTrace) { + Error.throwWithStackTrace(DeleteCredentialsFailure(error), stackTrace); + } + } + + /// Deletes the current user account. + /// + /// Not yet implemented see: https://github.com/nextcloud/neon/issues/2177 + Future<void> deleteAccount() async { + throw UnimplementedError(); + } + + /// Sets the active `account` to [accountID]. + /// + /// Throws an [ArgumentError] when the id is not in the accounts map. + Future<void> switchAccount(String accountID) async { + final value = _accounts.value; + + if (!value.accounts.containsKey(accountID)) { + throw ArgumentError.value(accountID, 'accountID', 'is not a logged in account'); + } + + _accounts.add((active: accountID, accounts: value.accounts)); + await _storage.saveLastAccount(accountID); + } +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart b/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart new file mode 100644 index 00000000000..4be66f4c555 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart @@ -0,0 +1,54 @@ +part of 'account_repository.dart'; + +/// {@template account_repository_storage} +/// Storage for the [AccountRepository]. +/// {@endtemplate} +@immutable +class AccountStorage { + /// {@macro account_repository_storage} + const AccountStorage({ + required this.accountsPersistence, + required this.lastAccountPersistence, + }); + + /// The store for the account list. + final SingleValueStore accountsPersistence; + + /// The store for the last used account. + final SingleValueStore lastAccountPersistence; + + /// Gets a list of logged in credentials from storage. + /// + /// It is not checked whether the stored information is still valid. + Future<BuiltList<Credentials>> readCredentials() async { + if (accountsPersistence.hasValue()) { + return accountsPersistence + .getStringList()! + .map((a) => Credentials.fromJson(json.decode(a) as Map<String, dynamic>)) + .toBuiltList(); + } + + return BuiltList(); + } + + /// Saves the given [credentials] to the storage. + Future<void> saveCredentials(Iterable<Credentials> credentials) async { + final values = credentials.map((a) => json.encode(a.toJson())).toBuiltList(); + + await accountsPersistence.setStringList(values); + } + + /// Retrieves the id of the last used account. + Future<String?> readLastAccount() async { + return lastAccountPersistence.getString(); + } + + /// Sets the last used account to the given [accountID]. + Future<void> saveLastAccount(String? accountID) async { + if (accountID == null) { + await lastAccountPersistence.remove(); + } else { + await lastAccountPersistence.setString(accountID); + } + } +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/account.dart b/packages/neon_framework/packages/account_repository/lib/src/models/account.dart new file mode 100644 index 00000000000..25faa3703d8 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/account.dart @@ -0,0 +1,36 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:built_value/built_value.dart'; +import 'package:nextcloud/nextcloud.dart'; + +part 'account.g.dart'; + +/// Account data. +abstract class Account implements Built<Account, AccountBuilder> { + /// Creates a new Account. + factory Account([void Function(AccountBuilder)? updates]) = _$Account; + const Account._(); + + /// The login and server credentials of the account. + Credentials get credentials; + + /// Url of the server. + Uri get serverURL => credentials.serverURL; + + /// The user id. + String get username => credentials.username; + + /// App password. + String? get password => credentials.password; + + /// The unique ID of the account. + /// + /// Implemented in a primitive way hashing the [username] and [serverURL]. + String get id => credentials.id; + + /// A human readable representation of [username] and [serverURL]. + String get humanReadableID => credentials.humanReadableID; + + /// An authenticated API client. + @BuiltValueField(compare: false, serialize: false) + NextcloudClient get client; +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart b/packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart new file mode 100644 index 00000000000..c7a4e6fbce8 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart @@ -0,0 +1,110 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Account extends Account { + @override + final Credentials credentials; + @override + final NextcloudClient client; + + factory _$Account([void Function(AccountBuilder)? updates]) => (AccountBuilder()..update(updates))._build(); + + _$Account._({required this.credentials, required this.client}) : super._() { + BuiltValueNullFieldError.checkNotNull(credentials, r'Account', 'credentials'); + BuiltValueNullFieldError.checkNotNull(client, r'Account', 'client'); + } + + @override + Account rebuild(void Function(AccountBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + AccountBuilder toBuilder() => AccountBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Account && credentials == other.credentials; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, credentials.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'Account') + ..add('credentials', credentials) + ..add('client', client)) + .toString(); + } +} + +class AccountBuilder implements Builder<Account, AccountBuilder> { + _$Account? _$v; + + CredentialsBuilder? _credentials; + CredentialsBuilder get credentials => _$this._credentials ??= CredentialsBuilder(); + set credentials(CredentialsBuilder? credentials) => _$this._credentials = credentials; + + NextcloudClient? _client; + NextcloudClient? get client => _$this._client; + set client(NextcloudClient? client) => _$this._client = client; + + AccountBuilder(); + + AccountBuilder get _$this { + final $v = _$v; + if ($v != null) { + _credentials = $v.credentials.toBuilder(); + _client = $v.client; + _$v = null; + } + return this; + } + + @override + void replace(Account other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$Account; + } + + @override + void update(void Function(AccountBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + Account build() => _build(); + + _$Account _build() { + _$Account _$result; + try { + _$result = _$v ?? + _$Account._( + credentials: credentials.build(), + client: BuiltValueNullFieldError.checkNotNull(client, r'Account', 'client')); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'credentials'; + credentials.build(); + } catch (e) { + throw BuiltValueNestedFieldError(r'Account', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart new file mode 100644 index 00000000000..4210826c7c1 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:account_repository/src/models/models.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; +import 'package:crypto/crypto.dart'; + +part 'credentials.g.dart'; + +/// Credentials interface. +abstract class Credentials implements Built<Credentials, CredentialsBuilder> { + /// Creates new credentials. + factory Credentials([void Function(CredentialsBuilder) updates]) = _$Credentials; + Credentials._(); + + /// Deserializes the given `Map<String, dynamic>` into a [Credentials]. + factory Credentials.fromJson(Map<String, dynamic> json) { + return serializers.deserializeWith(Credentials.serializer, json)!; + } + + /// Converts this [Credentials] into a `Map<String, dynamic>`. + Map<String, dynamic> toJson() { + return serializers.serializeWith(Credentials.serializer, this)! as Map<String, dynamic>; + } + + /// The serializer that serializes this value. + static Serializer<Credentials> get serializer => _$credentialsSerializer; + + /// Url of the server. + Uri get serverURL; + + /// The user id. + String get username; + + /// App password. + String? get password; + + /// The unique ID of the account. + /// + /// Implemented in a primitive way hashing the [username] and [serverURL]. + @memoized + String get id => sha1.convert(utf8.encode('$username@$serverURL')).toString(); + + /// A human readable representation of [username] and [serverURL]. + @memoized + String get humanReadableID { + // Maybe also show path if it is not '/' ? + final buffer = StringBuffer() + ..write(username) + ..write('@') + ..write(serverURL.host); + + if (serverURL.hasPort) { + buffer + ..write(':') + ..write(serverURL.port); + } + + return buffer.toString(); + } +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart new file mode 100644 index 00000000000..311c9b6c90e --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart @@ -0,0 +1,175 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'credentials.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializer<Credentials> _$credentialsSerializer = _$CredentialsSerializer(); + +class _$CredentialsSerializer implements StructuredSerializer<Credentials> { + @override + final Iterable<Type> types = const [Credentials, _$Credentials]; + @override + final String wireName = 'Credentials'; + + @override + Iterable<Object?> serialize(Serializers serializers, Credentials object, + {FullType specifiedType = FullType.unspecified}) { + final result = <Object?>[ + 'serverURL', + serializers.serialize(object.serverURL, specifiedType: const FullType(Uri)), + 'username', + serializers.serialize(object.username, specifiedType: const FullType(String)), + ]; + Object? value; + value = object.password; + if (value != null) { + result + ..add('password') + ..add(serializers.serialize(value, specifiedType: const FullType(String))); + } + return result; + } + + @override + Credentials deserialize(Serializers serializers, Iterable<Object?> serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = CredentialsBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'serverURL': + result.serverURL = serializers.deserialize(value, specifiedType: const FullType(Uri))! as Uri; + break; + case 'username': + result.username = serializers.deserialize(value, specifiedType: const FullType(String))! as String; + break; + case 'password': + result.password = serializers.deserialize(value, specifiedType: const FullType(String)) as String?; + break; + } + } + + return result.build(); + } +} + +class _$Credentials extends Credentials { + @override + final Uri serverURL; + @override + final String username; + @override + final String? password; + String? __id; + String? __humanReadableID; + + factory _$Credentials([void Function(CredentialsBuilder)? updates]) => + (CredentialsBuilder()..update(updates))._build(); + + _$Credentials._({required this.serverURL, required this.username, this.password}) : super._() { + BuiltValueNullFieldError.checkNotNull(serverURL, r'Credentials', 'serverURL'); + BuiltValueNullFieldError.checkNotNull(username, r'Credentials', 'username'); + } + + @override + String get id => __id ??= super.id; + + @override + String get humanReadableID => __humanReadableID ??= super.humanReadableID; + + @override + Credentials rebuild(void Function(CredentialsBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + CredentialsBuilder toBuilder() => CredentialsBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Credentials && + serverURL == other.serverURL && + username == other.username && + password == other.password; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, serverURL.hashCode); + _$hash = $jc(_$hash, username.hashCode); + _$hash = $jc(_$hash, password.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'Credentials') + ..add('serverURL', serverURL) + ..add('username', username) + ..add('password', password)) + .toString(); + } +} + +class CredentialsBuilder implements Builder<Credentials, CredentialsBuilder> { + _$Credentials? _$v; + + Uri? _serverURL; + Uri? get serverURL => _$this._serverURL; + set serverURL(Uri? serverURL) => _$this._serverURL = serverURL; + + String? _username; + String? get username => _$this._username; + set username(String? username) => _$this._username = username; + + String? _password; + String? get password => _$this._password; + set password(String? password) => _$this._password = password; + + CredentialsBuilder(); + + CredentialsBuilder get _$this { + final $v = _$v; + if ($v != null) { + _serverURL = $v.serverURL; + _username = $v.username; + _password = $v.password; + _$v = null; + } + return this; + } + + @override + void replace(Credentials other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$Credentials; + } + + @override + void update(void Function(CredentialsBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + Credentials build() => _build(); + + _$Credentials _build() { + final _$result = _$v ?? + _$Credentials._( + serverURL: BuiltValueNullFieldError.checkNotNull(serverURL, r'Credentials', 'serverURL'), + username: BuiltValueNullFieldError.checkNotNull(username, r'Credentials', 'username'), + password: password); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart b/packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart new file mode 100644 index 00000000000..e3cbcf1d8d0 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart @@ -0,0 +1,102 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +/// QRcode Login credentials. +/// +/// The Credentials as provided by the server when manually creating an app +/// password. +@immutable +final class LoginQRcode with EquatableMixin { + /// Creates a new LoginQRcode object. + @visibleForTesting + const LoginQRcode({ + required this.credentials, + }); + + /// The server credentials. + final Credentials credentials; + + /// Pattern matching the full QRcode content. + static final _loginQRcodeUrlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$'); + + /// Pattern matching the path part of the QRcode. + /// + /// This is used when launching the app through an intent. + static final _loginQRcodePathRegex = RegExp(r'^/user:(.*)&password:(.*)&server:(.*)$'); + + /// Creates a new `LoginQRcode` object by parsing a url string. + /// + /// If the [url] string is not valid as a LoginQRcode a [FormatException] is + /// thrown. + /// + /// Example: + /// ```dart + /// final loginQRcode = + /// LoginQRcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com'); + /// print(loginQRcode.serverURL); // JohnDoe + /// print(loginQRcode.username); // super_secret + /// print(loginQRcode.password); // example.com + /// + /// LoginQRcode.parse('::Not valid LoginQRcode::'); // Throws FormatException. + /// ``` + static LoginQRcode parse(String url) { + for (final regex in [_loginQRcodeUrlRegex, _loginQRcodePathRegex]) { + final matches = regex.allMatches(url); + if (matches.isEmpty) { + continue; + } + + final match = matches.single; + if (match.groupCount != 3) { + continue; + } + + return LoginQRcode( + credentials: Credentials((b) { + b + ..serverURL = Uri.parse(match.group(3)!) + ..username = match.group(1) + ..password = match.group(2); + }), + ); + } + + throw const FormatException(); + } + + /// Creates a new `LoginQRcode` object by parsing a url string. + /// + /// Returns `null` if the [url] string is not valid as a LoginQRcode. + /// + /// Example: + /// ```dart + /// final loginQRcode = + /// LoginQRcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com'); + /// print(loginQRcode.serverURL); // JohnDoe + /// print(loginQRcode.username); // super_secret + /// print(loginQRcode.password); // example.com + /// + /// final notLoginQRcode = LoginQRcode.tryParse('::Not valid LoginQRcode::'); + /// print(notLoginQRcode); // null + /// ``` + static LoginQRcode? tryParse(String url) { + try { + return parse(url); + } on FormatException { + return null; + } + } + + @override + String toString() { + final username = credentials.username; + final password = credentials.password; + final serverURL = credentials.serverURL; + + return 'nc://login/user:$username&password:$password&server:$serverURL'; + } + + @override + List<Object?> get props => [credentials]; +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/models.dart b/packages/neon_framework/packages/account_repository/lib/src/models/models.dart new file mode 100644 index 00000000000..bb18a5c744b --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/models.dart @@ -0,0 +1,4 @@ +export 'account.dart'; +export 'credentials.dart'; +export 'login_qr_code.dart'; +export 'serializers.dart'; diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart b/packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart new file mode 100644 index 00000000000..7f7864b8cbc --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart @@ -0,0 +1,11 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:built_value/serializer.dart'; +import 'package:built_value/standard_json_plugin.dart'; +import 'package:meta/meta.dart'; + +/// The serializer for the account repository models. +@internal +final Serializers serializers = (Serializers().toBuilder() + ..add(Credentials.serializer) + ..addPlugin(StandardJsonPlugin())) + .build(); diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart new file mode 100644 index 00000000000..5ad500bf9ab --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart @@ -0,0 +1,2 @@ +export 'testing_account.dart'; +export 'testing_credentials.dart'; diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart new file mode 100644 index 00000000000..f9546990689 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart @@ -0,0 +1,32 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:account_repository/src/utils/utils.dart'; +import 'package:account_repository/testing.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http; +import 'package:meta/meta.dart'; + +final _mockClient = http.MockClient((request) async { + throw UnsupportedError('The fake account client can not be used in tests.'); +}); + +/// Creates a mocked [Account] object. +/// +/// The default http client backing this account can not be used to make requests. +/// Provide a custom [http.MockClient] for testing network requests. +@visibleForTesting +Account createAccount({ + Credentials? credentials, + http.Client? httpClient, +}) { + credentials ??= createCredentials(); + + return Account((b) { + b + ..credentials.replace(credentials!) + ..client = buildClient( + httpClient: httpClient ?? _mockClient, + userAgent: 'neon', + credentials: credentials, + ); + }); +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart new file mode 100644 index 00000000000..ae77a64c224 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart @@ -0,0 +1,17 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:meta/meta.dart'; + +/// Creates a mocked [Credentials] object. +@visibleForTesting +Credentials createCredentials({ + Uri? serverURL, + String username = 'username', + String? password = 'password', +}) { + return Credentials((b) { + b + ..serverURL = serverURL ?? Uri.https('serverURL') + ..username = username + ..password = password; + }); +} 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 new file mode 100644 index 00000000000..a17b579097e --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart @@ -0,0 +1,42 @@ +import 'package:meta/meta.dart'; +import 'package:nextcloud/core.dart' as $core; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud/provisioning_api.dart' as $provisioning_api; + +/// An AuthenticationClient client mock for testing. +@internal +@visibleForTesting +AuthenticationClient? mockedClient; + +/// A client handling authentication APIs for easier unit testing. +@internal +class AuthenticationClient { + const AuthenticationClient({ + required this.core, + required this.appPassword, + required this.clientFlowLoginV2, + required this.users, + }); + + final $core.$Client core; + + final $core.$AppPasswordClient appPassword; + + final $core.$ClientFlowLoginV2Client clientFlowLoginV2; + + final $provisioning_api.$UsersClient users; +} + +/// Extension for getting the [AuthenticationClient]. +@internal +extension AuthenticationClientExtension on NextcloudClient { + /// Returns the [mockedClient] or build a [AuthenticationClient]. + AuthenticationClient get authentication => + mockedClient ?? + AuthenticationClient( + core: core, + appPassword: core.appPassword, + clientFlowLoginV2: core.clientFlowLoginV2, + users: provisioningApi.users, + ); +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart new file mode 100644 index 00000000000..4098991eec0 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart @@ -0,0 +1,52 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:http/http.dart' as http; +import 'package:neon_framework/storage.dart'; +import 'package:neon_http_client/neon_http_client.dart'; +import 'package:nextcloud/nextcloud.dart'; + +/// Builds a [NextcloudClient] authenticated with the given [credentials]. +NextcloudClient buildClient({ + required http.Client httpClient, + required String userAgent, + required Credentials credentials, +}) { + final cookieStore = NeonStorage().cookieStore( + accountID: credentials.id, + serverURL: credentials.serverURL, + ); + + final neonHttpClient = NeonHttpClient( + cookieStore: cookieStore, + userAgent: userAgent, + client: httpClient, + timeLimit: kDefaultTimeout, + baseURL: credentials.serverURL, + ); + + return NextcloudClient( + credentials.serverURL, + loginName: credentials.username, + password: credentials.password, + appPassword: credentials.password, + httpClient: neonHttpClient, + ); +} + +/// Builds an unauthenticated [NextcloudClient] for the given [serverURL]. +NextcloudClient buildUnauthenticatedClient({ + required http.Client httpClient, + required String userAgent, + required Uri serverURL, +}) { + final neonHttpClient = NeonHttpClient( + userAgent: userAgent, + client: httpClient, + timeLimit: kDefaultTimeout, + baseURL: serverURL, + ); + + return NextcloudClient( + serverURL, + httpClient: neonHttpClient, + ); +} diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart new file mode 100644 index 00000000000..e83024bd223 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart @@ -0,0 +1,2 @@ +export 'authentication_client.dart'; +export 'http_client_builder.dart'; diff --git a/packages/neon_framework/packages/account_repository/lib/testing.dart b/packages/neon_framework/packages/account_repository/lib/testing.dart new file mode 100644 index 00000000000..d27170b23a8 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/lib/testing.dart @@ -0,0 +1,4 @@ +/// This library contains testing helpers for the account repository. +library; + +export 'src/testing/testing.dart'; diff --git a/packages/neon_framework/packages/account_repository/pubspec.yaml b/packages/neon_framework/packages/account_repository/pubspec.yaml new file mode 100644 index 00000000000..b322cdf6a39 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/pubspec.yaml @@ -0,0 +1,39 @@ +name: account_repository +description: Data repository for the neon account management. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.0.0 + flutter: ^3.22.0 + +dependencies: + built_collection: ^5.1.1 + built_value: ^8.9.2 + crypto: ^3.0.3 + equatable: ^2.0.5 + http: ^1.2.2 + meta: ^1.0.0 + neon_framework: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework + neon_http_client: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/neon_http_client + nextcloud: ^7.0.0 + rxdart: ^0.27.0 + +dev_dependencies: + build_runner: ^2.4.11 + built_value_generator: ^8.9.2 + built_value_test: ^8.9.2 + flutter: + sdk: flutter + mocktail: ^1.0.4 + neon_lints: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_lints + test: ^1.25.2 diff --git a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml new file mode 100644 index 00000000000..198db33e28c --- /dev/null +++ b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml @@ -0,0 +1,16 @@ +# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +dependency_overrides: + cookie_store: + path: ../../../cookie_store + dynamite_runtime: + path: ../../../dynamite/packages/dynamite_runtime + neon_framework: + path: ../.. + neon_http_client: + path: ../neon_http_client + neon_lints: + path: ../../../neon_lints + nextcloud: + path: ../../../nextcloud + sort_box: + path: ../sort_box 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 new file mode 100644 index 00000000000..3564ac02b12 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart @@ -0,0 +1,602 @@ +// ignore_for_file: inference_failure_on_collection_literal + +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_test/matcher.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/testing.dart' show MockNeonStorage; +import 'package:nextcloud/core.dart' as core; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud/provisioning_api.dart' as provisioning_api; +import 'package:test/test.dart'; + +class _FakeStatus extends Fake implements core.Status {} + +class _FakeUri extends Fake implements Uri {} + +class _FakeClient extends Fake implements http.Client {} + +class _FakePollRequest extends Fake implements core.ClientFlowLoginV2PollRequestApplicationJson {} + +class _DynamiteResponseMock<B, H> extends Mock implements DynamiteResponse<B, H> {} + +class _CurrentUserResponseMock extends Mock implements provisioning_api.UsersGetCurrentUserResponseApplicationJson {} + +class _CurrentUserResponseOcsMock extends Mock + implements provisioning_api.UsersGetCurrentUserResponseApplicationJson_Ocs {} + +class _UserDetailsMock extends Mock implements provisioning_api.UserDetails {} + +class _CoreClientMock extends Mock implements core.$Client {} + +class _AppPasswordClientMock extends Mock implements core.$AppPasswordClient {} + +class _ClientFlowLoginV2ClientMock extends Mock implements core.$ClientFlowLoginV2Client {} + +class _UsersClientMock extends Mock implements provisioning_api.$UsersClient {} + +class _AccountStorageMock extends Mock implements AccountStorage {} + +typedef _AccountStream = ({BuiltList<Account> accounts, Account? active}); + +void main() { + late AccountStorage storage; + late AccountRepository repository; + + late core.$Client coreClient; + late core.$AppPasswordClient appPassword; + late core.$ClientFlowLoginV2Client clientFlowLoginV2; + late provisioning_api.$UsersClient users; + + setUpAll(() { + registerFallbackValue(_FakeUri()); + registerFallbackValue(_FakePollRequest()); + MockNeonStorage(); + }); + + setUp(() { + coreClient = _CoreClientMock(); + appPassword = _AppPasswordClientMock(); + clientFlowLoginV2 = _ClientFlowLoginV2ClientMock(); + users = _UsersClientMock(); + + mockedClient = AuthenticationClient( + core: coreClient, + appPassword: appPassword, + clientFlowLoginV2: clientFlowLoginV2, + users: users, + ); + + storage = _AccountStorageMock(); + + repository = AccountRepository( + userAgent: 'userAgent', + httpClient: _FakeClient(), + storage: storage, + ); + }); + + final credentialsList = BuiltList<Credentials>([ + Credentials((b) { + b + ..serverURL = Uri.https('serverUrl') + ..username = 'username' + ..password = 'password'; + }), + Credentials((b) { + b + ..serverURL = Uri.https('other-serverUrl') + ..username = 'username' + ..password = 'password'; + }), + ]); + + final accountsList = List.generate(credentialsList.length, (i) { + return createAccount(credentials: credentialsList[i]); + }).toBuiltList(); + + group('AccountRepository', () { + test('failure equality', () { + expect( + FetchStatusFailure(credentialsList), + equals(FetchStatusFailure(credentialsList)), + ); + + expect( + InitLoginFailure(credentialsList), + equals(InitLoginFailure(credentialsList)), + ); + + expect( + PollLoginFailure(credentialsList), + equals(PollLoginFailure(credentialsList)), + ); + + expect( + FetchAccountFailure(credentialsList), + equals(FetchAccountFailure(credentialsList)), + ); + + expect( + DeleteCredentialsFailure(credentialsList), + equals(DeleteCredentialsFailure(credentialsList)), + ); + }); + + group('accounts', () { + test('emits empty user map before initialized', () { + expect( + repository.accounts, + emits((accounts: BuiltList<Account>(), active: null)), + ); + }); + }); + + test('hasAccounts', () async { + expect(repository.hasAccounts, isFalse); + + when(() => storage.readCredentials()).thenAnswer((_) async => BuiltList()); + await repository.loadAccounts(); + + expect(repository.hasAccounts, isFalse); + + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + await repository.loadAccounts(); + + expect(repository.hasAccounts, isTrue); + }); + + test('accountByID', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + await repository.loadAccounts(); + + expect(repository.accountByID('invalid'), isNull); + expect(repository.accountByID(credentialsList.first.id), isNotNull); + }); + + test('isLogInQRCode', () { + expect( + AccountRepository.isLogInQRCode(url: 'nc://login/user:JohnDoe&password:super_secret&server:example.com'), + isTrue, + ); + + expect( + AccountRepository.isLogInQRCode(url: '/user:JohnDoe&password:super_secret&server:example.com'), + isTrue, + ); + + expect( + AccountRepository.isLogInQRCode(url: 'invalid'), + isFalse, + ); + }); + + group('loadAccounts', () { + test('no active account when no credentials are available', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => BuiltList()); + await repository.loadAccounts(); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', isEmpty) + .having((e) => e.active, 'active', isNull), + ), + ); + + verifyNever(() => storage.readLastAccount()); + }); + + test('emits stored credentials in accounts', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + await repository.loadAccounts(); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList.first)), + ), + ); + + verifyNever(() => storage.readLastAccount()); + }); + + test('uses initialAccount as active', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + await repository.loadAccounts(initialAccount: credentialsList[1].id); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList[1])), + ), + ); + + verifyNever(() => storage.readLastAccount()); + }); + + test('ignores initialAccount when not it accounts', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + await repository.loadAccounts(initialAccount: 'invalid'); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList.first)), + ), + ); + + verifyNever(() => storage.readLastAccount()); + }); + + test('uses last active account when specified', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + when(() => storage.readLastAccount()).thenAnswer((_) async => credentialsList[1].id); + await repository.loadAccounts(rememberLastUsedAccount: true); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList[1])), + ), + ); + + verify(() => storage.readLastAccount()).called(1); + }); + + test('ignores last active account when not in accounts', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + when(() => storage.readLastAccount()).thenAnswer((_) async => 'invalid'); + await repository.loadAccounts(rememberLastUsedAccount: true); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList.first)), + ), + ); + + verify(() => storage.readLastAccount()).called(1); + }); + + test('prefers last active account over initial account', () async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + when(() => storage.readLastAccount()).thenAnswer((_) async => credentialsList[1].id); + await repository.loadAccounts( + rememberLastUsedAccount: true, + initialAccount: credentialsList.first.id, + ); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList[1])), + ), + ); + + verify(() => storage.readLastAccount()).called(1); + }); + }); + + group('getServerStatus', () { + test('fetches the server status', () async { + final mockResponse = _DynamiteResponseMock<core.Status, void>(); + when(() => coreClient.getStatus()).thenAnswer((_) async => mockResponse); + when(() => mockResponse.body).thenReturn(_FakeStatus()); + + await expectLater( + repository.getServerStatus(Uri()), + completion(isA<core.Status>()), + ); + + verify(() => coreClient.getStatus()).called(1); + }); + + test('rethrows http exceptions as `FetchStatusFailure`', () async { + when(() => coreClient.getStatus()).thenThrow(http.ClientException('')); + + await expectLater( + repository.getServerStatus(Uri()), + throwsA(isA<FetchStatusFailure>().having((e) => e.error, 'error', isA<http.ClientException>())), + ); + + verify(() => coreClient.getStatus()).called(1); + }); + }); + + group('loginFlowInit', () { + test('returns login endpoint and token', () async { + when(() => clientFlowLoginV2.init()).thenAnswer((_) async { + return DynamiteResponse( + 200, + core.LoginFlowV2((b) { + b.login = 'https://login_url'; + b.poll + ..token = 'token' + ..endpoint = 'endpoint'; + }), + null, + ); + }); + + await expectLater( + repository.loginFlowInit(Uri()), + completion(equals((Uri.https('login_url'), 'token'))), + ); + + verify(() => clientFlowLoginV2.init()).called(1); + }); + + test('rethrows http exceptions as `InitLoginFailure`', () async { + when(() => clientFlowLoginV2.init()).thenThrow(http.ClientException('')); + + await expectLater( + repository.loginFlowInit(Uri()), + throwsA(isA<InitLoginFailure>().having((e) => e.error, 'error', isA<http.ClientException>())), + ); + + verify(() => clientFlowLoginV2.init()).called(1); + }); + }); + + group('loginFlowPoll', () { + test('returns fetched credentials', () async { + when(() => clientFlowLoginV2.poll($body: any(named: r'$body'))).thenAnswer((_) async { + return DynamiteResponse( + 200, + core.LoginFlowV2Credentials((b) { + b + ..appPassword = 'appPassword' + ..loginName = 'loginName' + ..server = 'https://server'; + }), + null, + ); + }); + + await expectLater( + repository.loginFlowPoll(Uri(), 'token'), + completion( + equalsBuilt( + Credentials((b) { + b + ..password = 'appPassword' + ..username = 'loginName' + ..serverURL = Uri.https('server'); + }), + ), + ), + ); + + verify( + () => clientFlowLoginV2.poll( + $body: any( + named: r'$body', + that: equalsBuilt(core.ClientFlowLoginV2PollRequestApplicationJson((b) => b.token = 'token')), + ), + ), + ).called(1); + }); + + test('returns null when for 404 response credentials', () async { + when(() => clientFlowLoginV2.poll($body: any(named: r'$body'))) + .thenThrow(DynamiteStatusCodeException(http.Response('', 404))); + + await expectLater( + repository.loginFlowPoll(Uri(), 'token'), + completion(isNull), + ); + + verify( + () => clientFlowLoginV2.poll( + $body: any( + named: r'$body', + that: equalsBuilt(core.ClientFlowLoginV2PollRequestApplicationJson((b) => b.token = 'token')), + ), + ), + ).called(1); + }); + + test('rethrows http exceptions as `PollLoginFailure`', () async { + when(() => clientFlowLoginV2.poll($body: any(named: r'$body'))).thenThrow(http.ClientException('')); + + await expectLater( + repository.loginFlowPoll(Uri(), 'token'), + throwsA(isA<PollLoginFailure>().having((e) => e.error, 'error', isA<http.ClientException>())), + ); + + verify( + () => clientFlowLoginV2.poll( + $body: any( + named: r'$body', + that: equalsBuilt(core.ClientFlowLoginV2PollRequestApplicationJson((b) => b.token = 'token')), + ), + ), + ).called(1); + }); + }); + + group('getAccount', () { + test('retrieves account id from server', () async { + final userDetails = _UserDetailsMock(); + when(() => userDetails.id).thenReturn('admin'); + final ocs = _CurrentUserResponseOcsMock(); + when(() => ocs.data).thenReturn(userDetails); + final userStatusResponse = _CurrentUserResponseMock(); + when(() => userStatusResponse.ocs).thenReturn(ocs); + final response = _DynamiteResponseMock<_CurrentUserResponseMock, void>(); + when(() => response.body).thenReturn(userStatusResponse); + + when(() => users.getCurrentUser()).thenAnswer((_) async => response); + + await expectLater( + repository.getAccount(credentialsList.first), + completion(isA<Account>().having((e) => e.username, 'username', equals('admin'))), + ); + + verify(() => users.getCurrentUser()).called(1); + }); + + test('rethrows http exceptions as `FetchAccountFailure`', () async { + when(() => users.getCurrentUser()).thenThrow(http.ClientException('')); + + await expectLater( + repository.getAccount(credentialsList.first), + throwsA(isA<FetchAccountFailure>().having((e) => e.error, 'error', isA<http.ClientException>())), + ); + + verify(() => users.getCurrentUser()).called(1); + }); + }); + + group('logIn', () { + setUp(() async { + when(() => storage.saveCredentials(any())).thenAnswer((_) async => {}); + when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {}); + }); + + final account = createAccount( + credentials: credentialsList.first, + ); + + test('adds account and sets active if null', () async { + await repository.logIn(account); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', equals([accountsList.first])) + .having((e) => e.active, 'active', equals(accountsList.first)), + ), + ); + verify(() => storage.saveCredentials(any(that: contains(credentialsList.first)))).called(1); + verify(() => storage.saveLastAccount(credentialsList.first.id)).called(1); + }); + }); + + group('logOut', () { + setUp(() async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {}); + when(() => storage.saveCredentials(any())).thenAnswer((_) async => {}); + + await repository.loadAccounts(); + + resetMocktailState(); + }); + + test('removes active account', () async { + when(() => appPassword.deleteAppPassword()).thenAnswer( + (_) async => _DynamiteResponseMock<core.AppPasswordDeleteAppPasswordResponseApplicationJson, void>(), + ); + await repository.logOut(credentialsList.first.id); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', equals([accountsList[1]])) + .having((e) => e.active, 'active', equals(accountsList[1])), + ), + ); + + verify(() => appPassword.deleteAppPassword()).called(1); + verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); + 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('')); + + await expectLater( + repository.logOut(credentialsList.first.id), + throwsA(isA<DeleteCredentialsFailure>().having((e) => e.error, 'error', isA<http.ClientException>())), + ); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', equals([accountsList[1]])) + .having((e) => e.active, 'active', equals(accountsList[1])), + ), + ); + + verify(() => appPassword.deleteAppPassword()).called(1); + verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); + verify(() => storage.saveCredentials(any(that: equals([credentialsList[1]])))).called(1); + }); + }); + + group('deleteAccount', () { + test('unimplemented', () async { + expect( + repository.deleteAccount(), + throwsUnimplementedError, + ); + }); + }); + + group('switchAccount', () { + setUp(() async { + when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList); + await repository.loadAccounts(); + + resetMocktailState(); + }); + + test('throws StateError for unregistered account ids', () async { + await expectLater( + repository.switchAccount('invalid'), + throwsArgumentError, + ); + }); + + test('emits and saves active account ', () async { + when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {}); + await repository.switchAccount(credentialsList[1].id); + + await expectLater( + repository.accounts, + emits( + isA<_AccountStream>() + .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList)) + .having((e) => e.active, 'active', equals(accountsList[1])), + ), + ); + + verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1); + }); + }); + }); +} diff --git a/packages/neon_framework/packages/account_repository/test/account_storage_test.dart b/packages/neon_framework/packages/account_repository/test/account_storage_test.dart new file mode 100644 index 00000000000..aadb6ade178 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/account_storage_test.dart @@ -0,0 +1,127 @@ +import 'package:account_repository/account_repository.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/storage.dart'; +import 'package:test/test.dart'; + +// ignore: avoid_implementing_value_types +class _FakeBuiltList extends Fake implements BuiltList<String> {} + +class _SingleValueStoreMock extends Mock implements SingleValueStore {} + +void main() { + late SingleValueStore accountsStore; + late SingleValueStore lastAccountStore; + late AccountStorage storage; + + setUp(() { + registerFallbackValue(_FakeBuiltList()); + + accountsStore = _SingleValueStoreMock(); + lastAccountStore = _SingleValueStoreMock(); + + storage = AccountStorage( + accountsPersistence: accountsStore, + lastAccountPersistence: lastAccountStore, + ); + }); + + final credentialsList = [ + Credentials((b) { + b + ..serverURL = Uri.https('serverUrl') + ..username = 'username' + ..password = 'password'; + }), + Credentials((b) { + b + ..serverURL = Uri.https('other-serverUrl') + ..username = 'username' + ..password = 'password'; + }), + ]; + + final serializedCredentials = BuiltList<String>([ + '{"serverURL":"https://serverurl","username":"username","password":"password"}', + '{"serverURL":"https://other-serverurl","username":"username","password":"password"}', + ]); + + group('AccountStorage', () { + group('readCredentials', () { + test('returns empty list when no value is stored', () async { + when(() => accountsStore.hasValue()).thenReturn(false); + + await expectLater( + storage.readCredentials(), + completion(isEmpty), + ); + + verifyNever(() => accountsStore.getStringList()); + }); + + test('returns list with deserialized accounts', () async { + when(() => accountsStore.hasValue()).thenReturn(true); + when(() => accountsStore.getStringList()).thenReturn(serializedCredentials); + + await expectLater( + storage.readCredentials(), + completion(credentialsList), + ); + + verify(() => accountsStore.getStringList()).called(1); + }); + }); + + group('saveCredentials', () { + test('persists accounts to storage', () async { + when(() => accountsStore.setStringList(any())).thenAnswer((_) async => true); + + await storage.saveCredentials(credentialsList); + + verify(() => accountsStore.setStringList(any(that: equals(serializedCredentials)))).called(1); + }); + }); + + group('readLastAccount', () { + test('returns null when no value is stored', () async { + when(() => lastAccountStore.getString()).thenReturn(null); + + await expectLater( + storage.readLastAccount(), + completion(isNull), + ); + + verify(() => lastAccountStore.getString()).called(1); + }); + + test('returns account id for the stored value', () async { + when(() => lastAccountStore.getString()).thenReturn('accountID'); + + await expectLater( + storage.readLastAccount(), + completion('accountID'), + ); + + verify(() => lastAccountStore.getString()).called(1); + }); + }); + + group('saveLastAccount', () { + test('persists account id to disk', () async { + when(() => lastAccountStore.setString(any())).thenAnswer((_) async => true); + + await storage.saveLastAccount('accountID'); + + verify(() => lastAccountStore.setString('accountID')).called(1); + }); + + test('deletes last account when id is null', () async { + when(() => lastAccountStore.remove()).thenAnswer((_) async => true); + + await storage.saveLastAccount(null); + + verify(() => lastAccountStore.remove()).called(1); + }); + }); + }); +} diff --git a/packages/neon_framework/packages/account_repository/test/models/account_test.dart b/packages/neon_framework/packages/account_repository/test/models/account_test.dart new file mode 100644 index 00000000000..a43e02f2775 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/models/account_test.dart @@ -0,0 +1,94 @@ +import 'package:account_repository/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group('Account', () { + group('constructor', () { + test('works correctly', () { + expect( + createAccount, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createAccount(), + equals(createAccount()), + ); + + expect( + createAccount().hashCode, + equals(createAccount().hashCode), + ); + + expect( + createAccount(), + isNot( + equals( + createAccount( + credentials: createCredentials(password: null), + ), + ), + ), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createAccount().rebuild((_) {}), + equals(createAccount()), + ); + }); + + test('replaces every attribute', () { + expect( + createAccount().rebuild((b) { + b.credentials + ..serverURL = Uri.https('new-serverURL') + ..username = 'new-username' + ..password = 'new-password'; + }), + equals( + createAccount( + credentials: createCredentials( + serverURL: Uri.https('new-serverURL'), + username: 'new-username', + password: 'new-password', + ), + ), + ), + ); + }); + }); + + test('credential getters', () { + final account = createAccount( + credentials: createCredentials(serverURL: Uri(host: 'server')), + ); + + expect(account.serverURL, equals(Uri(host: 'server'))); + expect(account.username, equals('username')); + expect(account.password, equals('password')); + expect(account.id, equals('43c2c7ec8332735e75756dcb08c4fcc6c2b07071')); + expect(account.humanReadableID, equals('username@server')); + }); + + test('to string', () { + expect( + createAccount().toString(), + equals(''' +Account { + credentials=Credentials { + serverURL=https://serverurl, + username=username, + password=password, + }, + client=Instance of 'NextcloudClient', +}'''), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/account_repository/test/models/credentials_test.dart b/packages/neon_framework/packages/account_repository/test/models/credentials_test.dart new file mode 100644 index 00000000000..1ec04cb11c3 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/models/credentials_test.dart @@ -0,0 +1,132 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:account_repository/testing.dart'; +import 'package:built_value_test/matcher.dart'; +import 'package:test/test.dart'; + +void main() { + group('Credentials', () { + group('constructor', () { + test('works correctly', () { + expect( + createCredentials, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createCredentials(), + equalsBuilt(createCredentials()), + ); + + expect( + createCredentials().hashCode, + equals(createCredentials().hashCode), + ); + + expect( + createCredentials(), + isNot(equalsBuilt(createCredentials(password: null))), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createCredentials().rebuild((_) {}), + equalsBuilt(createCredentials()), + ); + }); + + test('replaces every attribute', () { + expect( + createCredentials().rebuild((b) { + b + ..serverURL = Uri.https('new-serverURL') + ..username = 'new-username' + ..password = 'new-password'; + }), + equalsBuilt( + createCredentials( + serverURL: Uri.https('new-serverURL'), + username: 'new-username', + password: 'new-password', + ), + ), + ); + }); + }); + + test('creates id', () { + expect( + createCredentials().id, + equals('b0682d652840ef50a4115cc77109bedd8c577ccc'), + ); + }); + + test('creates humanReadableID', () { + final credentials = createCredentials( + serverURL: Uri.https('example.com'), + username: 'JohnDoe', + ); + + expect( + credentials.humanReadableID, + 'JohnDoe@example.com', + ); + + final credentialsWithDefaultPort = createCredentials( + serverURL: Uri(scheme: 'http', host: 'example.com', port: 80), + username: 'JohnDoe', + password: 'super_secret', + ); + + expect(credentialsWithDefaultPort.humanReadableID, 'JohnDoe@example.com'); + + final credentialsWithPort = createCredentials( + serverURL: Uri(scheme: 'http', host: 'example.com', port: 8080), + username: 'JohnDoe', + password: 'super_secret', + ); + + expect(credentialsWithPort.humanReadableID, 'JohnDoe@example.com:8080'); + }); + + group('serialization', () { + test('fromJson works correctly', () { + expect( + Credentials.fromJson({ + 'serverURL': 'https://serverurl', + 'username': 'username', + 'password': 'password', + }), + equalsBuilt(createCredentials()), + ); + }); + + test('toJson works correctly', () { + expect( + createCredentials().toJson(), + equals({ + 'serverURL': 'https://serverurl', + 'username': 'username', + 'password': 'password', + }), + ); + }); + }); + + test('to string', () { + expect( + createCredentials().toString(), + equals(''' +Credentials { + serverURL=https://serverurl, + username=username, + password=password, +}'''), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart b/packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart new file mode 100644 index 00000000000..da3d3e39842 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart @@ -0,0 +1,41 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('LoginQRcode', () { + const qrCodePath = '/user:JohnDoe&password:super_secret&server:example.com'; + const qrCode = 'nc://login$qrCodePath'; + const invalidUrl = '::Not valid LoginQRcode::'; + final credentials = LoginQRcode( + credentials: Credentials((b) { + b + ..serverURL = Uri.parse('example.com') + ..username = 'JohnDoe' + ..password = 'super_secret'; + }), + ); + + test('parse', () { + expect(LoginQRcode.parse(qrCode), equals(credentials)); + expect(LoginQRcode.parse(qrCodePath), equals(credentials)); + expect(() => LoginQRcode.parse(invalidUrl), throwsFormatException); + }); + + test('tryParse', () { + expect(LoginQRcode.tryParse(qrCode), equals(credentials)); + expect(LoginQRcode.tryParse(qrCodePath), equals(credentials)); + expect(LoginQRcode.tryParse(invalidUrl), null); + }); + + test('equality', () { + expect(credentials, equals(credentials)); + }); + + test('toString', () { + expect( + credentials.toString(), + equals(qrCode), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart b/packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart new file mode 100644 index 00000000000..70444837404 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart @@ -0,0 +1,16 @@ +import 'package:account_repository/src/utils/utils.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:test/test.dart'; + +void main() { + group('AuthenticationClient', () { + test('AuthenticationClientExtension', () { + final client = NextcloudClient(Uri()); + + expect( + client.authentication, + isA<AuthenticationClient>(), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart new file mode 100644 index 00000000000..cc21a2b82a9 --- /dev/null +++ b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart @@ -0,0 +1,95 @@ +import 'package:account_repository/src/models/models.dart'; +import 'package:account_repository/src/utils/utils.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/storage.dart'; +import 'package:neon_http_client/neon_http_client.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:test/test.dart'; + +class _FakeClient extends Fake implements http.Client {} + +class _FakeUri extends Fake implements Uri {} + +// ignore: subtype_of_sealed_class +class _NeonStorageMock extends Mock implements NeonStorage {} + +void main() { + final httpClient = _FakeClient(); + const userAgent = 'neon'; + late NeonStorage storageMock; + + setUpAll(() { + registerFallbackValue(_FakeUri()); + + storageMock = _NeonStorageMock(); + NeonStorage.mocked(storageMock); + when( + () => storageMock.cookieStore( + accountID: any(named: 'accountID'), + serverURL: any(named: 'serverURL'), + ), + ).thenReturn(null); + }); + + test('buildClient', () { + final credentials = Credentials((b) { + b + ..username = 'username' + ..password = 'password' + ..serverURL = Uri.https('serverURL'); + }); + + final client = buildClient( + httpClient: httpClient, + userAgent: userAgent, + credentials: credentials, + ); + + expect( + client, + isA<NextcloudClient>() + .having((c) => c.authentications, 'not empty authentications', isNotEmpty) + .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL'))) + .having( + (c) => c.httpClient, + 'httpClient', + isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(2)), + ), + ); + + verify( + () => storageMock.cookieStore( + accountID: any(named: 'accountID', that: equals(credentials.id)), + serverURL: any(named: 'serverURL', that: equals(Uri.https('serverURL'))), + ), + ).called(1); + }); + + test('buildUnauthenticatedClient', () { + final client = buildUnauthenticatedClient( + httpClient: httpClient, + userAgent: userAgent, + serverURL: Uri.https('serverURL'), + ); + + expect( + client, + isA<NextcloudClient>() + .having((c) => c.authentications, 'empty authentications', isEmpty) + .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL'))) + .having( + (c) => c.httpClient, + 'httpClient', + isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(2)), + ), + ); + + verifyNever( + () => storageMock.cookieStore( + accountID: any(named: 'accountID'), + serverURL: any(named: 'serverURL'), + ), + ); + }); +}