diff --git a/.github/workflows/at_libraries.yaml b/.github/workflows/at_libraries.yaml index 8c9a7aeb..722dc6bc 100644 --- a/.github/workflows/at_libraries.yaml +++ b/.github/workflows/at_libraries.yaml @@ -65,6 +65,7 @@ jobs: - at_onboarding_cli - at_commons - at_utils + - at_register steps: - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 diff --git a/packages/at_onboarding_cli/bin/register_cli.dart b/packages/at_onboarding_cli/bin/register_cli.dart index a6cca120..237a5517 100644 --- a/packages/at_onboarding_cli/bin/register_cli.dart +++ b/packages/at_onboarding_cli/bin/register_cli.dart @@ -3,4 +3,4 @@ import 'package:at_onboarding_cli/src/register_cli/register.dart' Future main(List args) async { await register_cli.main(args); -} +} \ No newline at end of file diff --git a/packages/at_onboarding_cli/example/get_cram_key.dart b/packages/at_onboarding_cli/example/get_cram_key.dart index 1af1b0f5..b728503d 100644 --- a/packages/at_onboarding_cli/example/get_cram_key.dart +++ b/packages/at_onboarding_cli/example/get_cram_key.dart @@ -1,5 +1,5 @@ import 'package:args/args.dart'; -import 'package:at_onboarding_cli/src/util/onboarding_util.dart'; +import 'package:at_register/at_register.dart'; import 'util/custom_arg_parser.dart'; @@ -7,12 +7,12 @@ Future main(args) async { final argResults = CustomArgParser(getArgParser()).parse(args); // this step sends an OTP to the registered email - await OnboardingUtil().requestAuthenticationOtp( + await RegistrarApiAccessor().requestAuthenticationOtp( argResults['atsign']); // requires a registered atsign // the following step validates the email that was sent in the above step - String? verificationCode = OnboardingUtil().getVerificationCodeFromUser(); - String cramKey = await OnboardingUtil().getCramKey(argResults['atsign'], + String? verificationCode = ApiUtil.readCliVerificationCode(); + String cramKey = await RegistrarApiAccessor().getCramKey(argResults['atsign'], verificationCode); // verification code received on the registered email print('Your cram key is: $cramKey'); diff --git a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart index 5284dc66..ca4b4c82 100644 --- a/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart +++ b/packages/at_onboarding_cli/lib/src/onboard/at_onboarding_service_impl.dart @@ -18,9 +18,9 @@ import 'package:encrypt/encrypt.dart'; import 'package:zxing2/qrcode.dart'; import 'package:image/image.dart'; import 'package:path/path.dart' as path; +import 'package:at_register/at_register.dart'; import '../util/home_directory_util.dart'; -import '../util/onboarding_util.dart'; ///class containing service that can onboard/activate/authenticate @signs class AtOnboardingServiceImpl implements AtOnboardingService { @@ -97,7 +97,7 @@ class AtOnboardingServiceImpl implements AtOnboardingService { // get cram_secret from either from AtOnboardingPreference // or fetch from the registrar using verification code sent to email - atOnboardingPreference.cramSecret ??= await OnboardingUtil() + atOnboardingPreference.cramSecret ??= await RegistrarApiAccessor() .getCramUsingOtp(_atSign, atOnboardingPreference.registrarUrl); if (atOnboardingPreference.cramSecret == null) { logger.info('Root Server address is ${atOnboardingPreference.rootDomain}:' diff --git a/packages/at_onboarding_cli/lib/src/register_cli/register.dart b/packages/at_onboarding_cli/lib/src/register_cli/register.dart index eedc8eb5..da595ca4 100644 --- a/packages/at_onboarding_cli/lib/src/register_cli/register.dart +++ b/packages/at_onboarding_cli/lib/src/register_cli/register.dart @@ -1,26 +1,20 @@ -import 'dart:collection'; import 'dart:io'; import 'package:args/args.dart'; import 'package:at_client/at_client.dart'; import 'package:at_onboarding_cli/src/activate_cli/activate_cli.dart' as activate_cli; -import 'package:at_onboarding_cli/src/util/api_call_status.dart'; -import 'package:at_onboarding_cli/src/util/at_onboarding_exceptions.dart'; -import 'package:at_onboarding_cli/src/util/register_api_result.dart'; -import 'package:at_onboarding_cli/src/util/register_api_task.dart'; +import 'package:at_onboarding_cli/src/register_cli/registration_flow.dart'; import 'package:at_utils/at_logger.dart'; - -import '../util/onboarding_util.dart'; -import '../util/registrar_api_constants.dart'; +import 'package:at_register/at_register.dart'; ///Class containing logic to register a free atsign to email provided ///through [args] by utilizing methods defined in [RegisterUtil] ///Requires List args containing the following arguments: email class Register { Future main(List args) async { - Map params = HashMap(); - OnboardingUtil registerUtil = OnboardingUtil(); + RegisterParams registerParams = RegisterParams(); + RegistrarApiAccessor registrarApiAccessor = RegistrarApiAccessor(); final argParser = ArgParser() ..addOption('email', @@ -40,188 +34,58 @@ class Register { if (!argResults.wasParsed('email')) { stderr.writeln( '[Unable to run Register CLI] Please enter your email address' - '\n[Usage] dart run register.dart -e email@email.com\n[Options]\n${argParser.usage}'); + '\n[Usage] dart run bin/register.dart -e email@email.com\n[Options]\n${argParser.usage}'); exit(6); } - if (registerUtil.validateEmail(argResults['email'])) { - params['email'] = argResults['email']; + if (ApiUtil.enforceEmailRegex(argResults['email'])) { + registerParams.email = argResults['email']; } else { stderr.writeln( '[Unable to run Register CLI] You have entered an invalid email address. Check your email address and try again.'); exit(7); } - //set the following parameter to RegisterApiConstants.apiHostStaging - //to use the staging environment - params['authority'] = RegistrarApiConstants.apiHostProd; - - //create stream of tasks each of type [RegisterApiTask] and then - // call start on the stream of tasks - await RegistrationFlow(params, registerUtil) - .add(GetFreeAtsign()) - .add(RegisterAtsign()) - .add(ValidateOtp()) - .start(); - - activate_cli.main(['-a', params['atsign']!, '-c', params['cramkey']!]); - } -} - -///class that handles multiple tasks of type [RegisterApiTask] -///Initialized with a params map that needs to be populated with - email and api host address -///[add] method can be used to add tasks[RegisterApiTask] to the [processFlow] -///[start] needs to be called after all required tasks are added to the [processFlow] -class RegistrationFlow { - List processFlow = []; - RegisterApiResult result = RegisterApiResult(); - late OnboardingUtil registerUtil; - Map params; - - RegistrationFlow(this.params, this.registerUtil); - - RegistrationFlow add(RegisterApiTask task) { - processFlow.add(task); - return this; - } - - Future start() async { - for (RegisterApiTask task in processFlow) { - task.init(params, registerUtil); - if (RegistrarApiConstants.isDebugMode) { - print('Current Task: $task [params=$params]\n'); - } - result = await task.run(); - if (result.apiCallStatus == ApiCallStatus.retry) { - while ( - task.shouldRetry() && result.apiCallStatus == ApiCallStatus.retry) { - result = await task.run(); - task.retryCount++; - } - } - if (result.apiCallStatus == ApiCallStatus.success) { - params.addAll(result.data); - } else { - throw AtOnboardingException(result.exceptionMessage); - } - } - } -} - -///This is a [RegisterApiTask] that fetches a free atsign -///throws [AtException] with concerned message which was encountered in the -///HTTP GET/POST request -class GetFreeAtsign extends RegisterApiTask { - @override - Future run() async { - stdout - .writeln('[Information] Getting your randomly generated free atSign…'); - try { - List atsignList = - await registerUtil.getFreeAtSigns(authority: params['authority']!); - result.data['atsign'] = atsignList[0]; - stdout.writeln('[Information] Your new atSign is **@${atsignList[0]}**'); - result.apiCallStatus = ApiCallStatus.success; - } on Exception catch (e) { - result.exceptionMessage = e.toString(); - result.apiCallStatus = - shouldRetry() ? ApiCallStatus.retry : ApiCallStatus.failure; - } + GetFreeAtsign getFreeAtsignTask = GetFreeAtsign( + apiAccessorInstance: registrarApiAccessor, allowRetry: true); - return result; - } -} + RegisterAtsign registerAtsignTask = + RegisterAtsign(apiAccessorInstance: registrarApiAccessor, allowRetry: true); -///This is a [RegisterApiTask] that registers a free atsign fetched in -///[GetFreeAtsign] to the email provided as args -///throws [AtException] with concerned message which was encountered in the -///HTTP GET/POST request -class RegisterAtsign extends RegisterApiTask { - @override - Future run() async { - stdout.writeln( - '[Information] Sending verification code to: ${params['email']}'); - try { - result.data['otpSent'] = (await registerUtil.registerAtSign( - params['atsign']!, params['email']!, - authority: params['authority']!)) - .toString(); - stdout.writeln( - '[Information] Verification code sent to: ${params['email']}'); - result.apiCallStatus = ApiCallStatus.success; - } on Exception catch (e) { - result.exceptionMessage = e.toString(); - result.apiCallStatus = - shouldRetry() ? ApiCallStatus.retry : ApiCallStatus.failure; - } - return result; - } -} + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: registrarApiAccessor, allowRetry: true); -///This is a [RegisterApiTask] that validates the otp which was sent as a part -///of [RegisterAtsign] to email provided in args -///throws [AtException] with concerned message which was encountered in the -///HTTP GET/POST request -class ValidateOtp extends RegisterApiTask { - @override - void init(Map params, OnboardingUtil registerUtil) { - params['confirmation'] = 'false'; - this.params = params; - this.registerUtil = registerUtil; - result.data = HashMap(); - } + // create a queue of tasks each of type [RegisterTask] and then + // call start on the RegistrationFlow object + await RegistrationFlow(registerParams) + .add(getFreeAtsignTask) + .add(registerAtsignTask) + .add(validateOtpTask) + .start(); - @override - Future run() async { - if (params['otp'] == null) { - params['otp'] = registerUtil.getVerificationCodeFromUser(); - } - stdout.writeln('[Information] Validating your verification code...'); - try { - String apiResponse = await registerUtil.validateOtp( - params['atsign']!, params['email']!, params['otp']!, - confirmation: params['confirmation']!, - authority: params['authority']!); - if (apiResponse == 'retry') { - stderr.writeln( - '[Unable to proceed] The verification code you entered is either invalid or expired.\n' - ' Check your verification code and try again.'); - params['otp'] = registerUtil.getVerificationCodeFromUser(); - result.apiCallStatus = ApiCallStatus.retry; - result.exceptionMessage = - 'Incorrect otp entered 3 times. Max retries reached.'; - } else if (apiResponse == 'follow-up') { - params.update('confirmation', (value) => 'true'); - result.data['otp'] = params['otp']; - result.apiCallStatus = ApiCallStatus.retry; - } else if (apiResponse.startsWith("@")) { - result.data['cramkey'] = apiResponse.split(":")[1]; - stdout.writeln( - '[Information] Your cram secret: ${result.data['cramkey']}'); - stdout.writeln('[Success] Your atSign **@${params['atsign']}** has been' - ' successfully registered to ${params['email']}'); - result.apiCallStatus = ApiCallStatus.success; - } - } on Exception catch (e) { - result.exceptionMessage = e.toString(); - result.apiCallStatus = - shouldRetry() ? ApiCallStatus.retry : ApiCallStatus.failure; - } - return result; + activate_cli + .main(['-a', registerParams.atsign!, '-c', registerParams.cram!]); } } Future main(List args) async { Register register = Register(); - AtSignLogger.root_level = 'severe'; + AtSignLogger.root_level = 'info'; try { await register.main(args); + } on MaximumAtsignQuotaException { + stdout.writeln( + '[Unable to proceed] This email address already has 10 free atSigns associated with it.\n' + 'To register a new atSign to this email address, please log into the dashboard \'my.atsign.com/login\'.\n' + 'Remove at least 1 atSign from your account and then try again.\n' + 'Alternatively, you can retry this process with a different email address.'); + exit(0); } on FormatException catch (e) { if (e.toString().contains('Missing argument')) { stderr.writeln( '[Unable to run Register CLI] Please re-run with your email address'); - stderr - .writeln('Usage: \'dart run register_cli.dart -e email@email.com\''); + stderr.writeln( + 'Usage: \'dart run bin/register_cli.dart -e email@email.com\''); exit(1); } else if (e.toString().contains('Could not find an option or flag')) { stderr @@ -237,11 +101,11 @@ Future main(List args) async { stderr.writeln('Cause: $e'); exit(3); } - } on AtOnboardingException catch (e) { + } on AtException catch (e) { stderr.writeln( '[Error] Failed getting an atsign. It looks like something went wrong on our side.\n' 'Please try again or contact support@atsign.com, quoting the text displayed below.'); - stderr.writeln('Cause: $e ExceptionType:${e.runtimeType}'); + stderr.writeln('Cause: ${e.message} ExceptionType:${e.runtimeType}'); exit(4); } on Exception catch (e) { if (e diff --git a/packages/at_onboarding_cli/lib/src/register_cli/registration_flow.dart b/packages/at_onboarding_cli/lib/src/register_cli/registration_flow.dart new file mode 100644 index 00000000..6b87ebe7 --- /dev/null +++ b/packages/at_onboarding_cli/lib/src/register_cli/registration_flow.dart @@ -0,0 +1,53 @@ +import 'package:at_register/at_register.dart'; + +/// Processes tasks of type [RegisterTask] +/// Initialized with a params map that needs to be populated with - email and api host address +/// [add] method can be used to add tasks[RegisterTask] to the [processQueue] +/// [start] needs to be called after all required tasks are added to the [processQueue] +class RegistrationFlow { + List processQueue = []; + RegisterTaskResult _result = RegisterTaskResult(); + late RegisterParams params; + String defaultExceptionMessage = 'Could not complete the task. Please retry'; + + RegistrationFlow(this.params); + + RegistrationFlow add(RegisterTask task) { + processQueue.add(task); + return this; + } + + Future start() async { + for (RegisterTask task in processQueue) { + try { + _result = await task.run(params); + task.logger.finer('Attempt: ${task.retryCount} | params[$params]'); + task.logger.finer('Result: $_result'); + + while (_result.apiCallStatus == ApiCallStatus.retry && + task.shouldRetry()) { + _result = await task.retry(params); + task.logger.finer('Attempt: ${task.retryCount} | params[$params]'); + task.logger.finer('Result: $_result'); + } + if (_result.apiCallStatus == ApiCallStatus.success) { + params.addFromJson(_result.data); + } else { + throw _result.exception ?? + AtRegisterException('${task.name}: $defaultExceptionMessage'); + } + } on MaximumAtsignQuotaException { + rethrow; + } on ExhaustedVerificationCodeRetriesException { + rethrow; + } on InvalidVerificationCodeException { + rethrow; + } on AtRegisterException { + rethrow; + } on Exception catch (e) { + throw AtRegisterException(e.toString()); + } + } + return _result; + } +} diff --git a/packages/at_onboarding_cli/lib/src/util/api_call_status.dart b/packages/at_onboarding_cli/lib/src/util/api_call_status.dart deleted file mode 100644 index 56ad3637..00000000 --- a/packages/at_onboarding_cli/lib/src/util/api_call_status.dart +++ /dev/null @@ -1 +0,0 @@ -enum ApiCallStatus { success, failure, retry } diff --git a/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart b/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart index b2f3ee1e..0292a3a5 100644 --- a/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart +++ b/packages/at_onboarding_cli/lib/src/util/at_onboarding_preference.dart @@ -2,7 +2,7 @@ import 'dart:core'; import 'package:at_chops/at_chops.dart'; import 'package:at_client/at_client.dart'; -import 'package:at_onboarding_cli/src/util/registrar_api_constants.dart'; +import 'package:at_register/at_register.dart'; class AtOnboardingPreference extends AtClientPreference { /// specify path of .atKeysFile containing encryption keys @@ -28,7 +28,7 @@ class AtOnboardingPreference extends AtClientPreference { bool skipSync = false; /// the hostName of the registrar which will be used to activate the atsign - String registrarUrl = RegistrarApiConstants.apiHostProd; + String registrarUrl = RegistrarConstants.apiHostProd; String? appName; diff --git a/packages/at_onboarding_cli/lib/src/util/onboarding_util.dart b/packages/at_onboarding_cli/lib/src/util/onboarding_util.dart deleted file mode 100644 index 5ef2b4bc..00000000 --- a/packages/at_onboarding_cli/lib/src/util/onboarding_util.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:at_client/at_client.dart' as at_client; -import 'package:at_onboarding_cli/src/util/registrar_api_constants.dart'; -import 'package:http/http.dart'; -import 'package:http/io_client.dart'; - -///class containing utilities to perform registration of a free atsign -class OnboardingUtil { - IOClient? _ioClient; - - void _createClient() { - HttpClient ioc = HttpClient(); - ioc.badCertificateCallback = - (X509Certificate cert, String host, int port) => true; - _ioClient = IOClient(ioc); - } - - /// Returns a Future> containing free available atSigns of count provided as input. - Future> getFreeAtSigns( - {int amount = 1, - String authority = RegistrarApiConstants.apiHostProd}) async { - List atSigns = []; - Response response; - for (int i = 0; i < amount; i++) { - // get request at my.atsign.com/api/app/v3/get-free-atsign/ - response = - await getRequest(authority, RegistrarApiConstants.pathGetFreeAtSign); - if (response.statusCode == 200) { - String atSign = jsonDecode(response.body)['data']['atsign']; - atSigns.add(atSign); - } else { - throw at_client.AtClientException.message( - '${response.statusCode} ${response.reasonPhrase}'); - } - } - return atSigns; - } - - /// Registers the [atSign] provided in the input to the provided [email] - /// The `atSign` provided should be an unregistered and free atsign - /// Returns true if the request to send the OTP was successful. - /// Sends an OTP to the `email` provided. - /// Throws [AtException] if [atSign] is invalid - Future registerAtSign(String atSign, String email, - {oldEmail, String authority = RegistrarApiConstants.apiHostProd}) async { - Response response = - await postRequest(authority, RegistrarApiConstants.pathRegisterAtSign, { - 'atsign': atSign, - 'email': email, - 'oldEmail': oldEmail, - }); - if (response.statusCode == 200) { - Map jsonDecoded = jsonDecode(response.body); - bool sentSuccessfully = - jsonDecoded['message'].toLowerCase().contains('success'); - return sentSuccessfully; - } else { - throw at_client.AtClientException.message( - '${response.statusCode} ${response.reasonPhrase}'); - } - } - - /// Registers the [atSign] provided in the input to the provided [email] - /// The `atSign` provided should be an unregistered and free atsign - /// Validates the OTP against the atsign and registers it to the provided email if OTP is valid. - /// Returns the CRAM secret of the atsign which is registered. - /// - /// [confirmation] - Mandatory parameter for validateOTP API call. First request to be sent with confirmation as false, in this - /// case API will return cram key if the user is new otherwise will return list of already existing atsigns. - /// If the user already has existing atsigns user will have to select a listed atsign old/new and place a second call - /// to the same API endpoint with confirmation set to true with previously received OTP. The second follow-up call - /// is automated by this client using new atsign for user simplicity - /// - ///return value - Case 1("verified") - the API has registered the atsign to provided email and CRAM key present in HTTP_RESPONSE Body. - /// Case 2("follow-up"): User already has existing atsigns and new atsign registered successfully. To receive the CRAM key, follow-up by calling - /// the API with one of the existing listed atsigns, with confirmation set to true. - /// Case 3("retry"): Incorrect OTP send request again with correct OTP. - /// Throws [AtException] if [atSign] or [otp] is invalid - Future validateOtp(String atSign, String email, String otp, - {String confirmation = 'true', - String authority = RegistrarApiConstants.apiHostProd}) async { - Response response = - await postRequest(authority, RegistrarApiConstants.pathValidateOtp, { - 'atsign': atSign, - 'email': email, - 'otp': otp, - 'confirmation': confirmation, - }); - if (response.statusCode == 200) { - Map jsonDecoded = jsonDecode(response.body); - Map dataFromResponse = {}; - if (jsonDecoded.containsKey('data')) { - dataFromResponse.addAll(jsonDecoded['data']); - } - if ((jsonDecoded.containsKey('message') && - (jsonDecoded['message'] as String) - .toLowerCase() - .contains('verified')) && - jsonDecoded.containsKey('cramkey')) { - return jsonDecoded['cramkey']; - } else if (jsonDecoded.containsKey('data') && - dataFromResponse.containsKey('newAtsign')) { - return 'follow-up'; - } else if (jsonDecoded.containsKey('message') && - jsonDecoded['message'] == - 'The code you have entered is invalid or expired. Please try again?') { - return 'retry'; - } else if (jsonDecoded.containsKey('message') && - (jsonDecoded['message'] == - 'Oops! You already have the maximum number of free atSigns. Please select one of your existing atSigns.')) { - stdout.writeln( - '[Unable to proceed] This email address already has 10 free atSigns associated with it.\n' - 'To register a new atSign to this email address, please log into the dashboard \'my.atsign.com/login\'.\n' - 'Remove at least 1 atSign from your account and then try again.\n' - 'Alternatively, you can retry this process with a different email address.'); - exit(1); - } else { - throw at_client.AtClientException.message( - '${response.statusCode} ${jsonDecoded['message']}'); - } - } else { - throw at_client.AtClientException.message( - '${response.statusCode} ${response.reasonPhrase}'); - } - } - - /// Accepts a registered [atsign] as a parameter and sends a one-time verification code - /// to the email that the atsign is registered with - /// Throws an exception in the following cases: - /// 1) HTTP 400 BAD_REQUEST - /// 2) Invalid atsign - Future requestAuthenticationOtp(String atsign, - {String authority = RegistrarApiConstants.apiHostProd}) async { - Response response = await postRequest(authority, - RegistrarApiConstants.requestAuthenticationOtpPath, {'atsign': atsign}); - String apiResponseMessage = jsonDecode(response.body)['message']; - if (response.statusCode == 200) { - if (apiResponseMessage.contains('Sent Successfully')) { - stdout.writeln( - '[Information] Successfully sent verification code to your registered e-mail'); - return; - } - throw at_client.InternalServerError( - 'Unable to send verification code for authentication.\nCause: $apiResponseMessage'); - } - throw at_client.InvalidRequestException(apiResponseMessage); - } - - /// Returns the cram key for an atsign by fetching it from the registrar API - /// Accepts a registered [atsign], the verification code that was sent to - /// the registered email - /// Throws exception in the following cases: - /// 1) HTTP 400 BAD_REQUEST - Future getCramKey(String atsign, String verificationCode, - {String authority = RegistrarApiConstants.apiHostProd}) async { - Response response = await postRequest( - authority, - RegistrarApiConstants.getCramKeyWithOtpPath, - {'atsign': atsign, 'otp': verificationCode}); - Map jsonDecodedBody = jsonDecode(response.body); - if (response.statusCode == 200) { - if (jsonDecodedBody['message'] == 'Verified') { - String cram = jsonDecodedBody['cramkey']; - cram = cram.split(':')[1]; - stdout.writeln('[Information] CRAM Key fetched successfully'); - return cram; - } - throw at_client.InvalidDataException( - 'Invalid verification code. Please enter a valid verification code'); - } - throw at_client.InvalidDataException(jsonDecodedBody['message']); - } - - /// calls utility methods from [OnboardingUtil] that - /// 1) send verification code to the registered email - /// 2) fetch the CRAM key from registrar using the verification code - Future getCramUsingOtp(String atsign, String registrarUrl) async { - await requestAuthenticationOtp(atsign, authority: registrarUrl); - return await getCramKey(atsign, getVerificationCodeFromUser(), - authority: registrarUrl); - } - - /// generic GET request - Future getRequest(String authority, String path) async { - if (_ioClient == null) _createClient(); - Uri uri = Uri.https(authority, path); - Response response = await _ioClient!.get(uri, headers: { - 'Authorization': RegistrarApiConstants.authorization, - 'Content-Type': RegistrarApiConstants.contentType, - }); - return response; - } - - /// generic POST request - Future postRequest( - String authority, String path, Map data) async { - if (_ioClient == null) _createClient(); - - Uri uri = Uri.https(authority, path); - - String body = json.encode(data); - if (RegistrarApiConstants.isDebugMode) { - stdout.writeln('Sending request to url: $uri\nRequest Body: $body'); - } - Response response = await _ioClient!.post( - uri, - body: body, - headers: { - 'Authorization': RegistrarApiConstants.authorization, - 'Content-Type': RegistrarApiConstants.contentType, - }, - ); - if (RegistrarApiConstants.isDebugMode) { - print('Got Response: ${response.body}'); - } - return response; - } - - bool validateEmail(String email) { - return RegExp( - r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") - .hasMatch(email); - } - - bool validateVerificationCode(String otp) { - if (otp.length == 4) { - return RegExp(r"^[a-zA-z0-9]").hasMatch(otp); - } - return false; - } - - /// Method to get verification code from user input - /// validates code locally and retries taking user input if invalid - /// Returns only when the user has provided a 4-length String only containing numbers and alphabets - String getVerificationCodeFromUser() { - String? otp; - stdout.writeln( - '[Action Required] Enter your verification code: (verification code is not case-sensitive)'); - otp = stdin.readLineSync()!.toUpperCase(); - while (!validateVerificationCode(otp!)) { - stderr.writeln( - '[Unable to proceed] The verification code you entered is invalid.\n' - 'Please check your email for a 4-character verification code.\n' - 'If you cannot see the code in your inbox, please check your spam/junk/promotions folders.\n' - '[Action Required] Enter your verification code:'); - otp = stdin.readLineSync()!.toUpperCase(); - } - return otp; - } -} diff --git a/packages/at_onboarding_cli/lib/src/util/register_api_result.dart b/packages/at_onboarding_cli/lib/src/util/register_api_result.dart deleted file mode 100644 index c6e170f9..00000000 --- a/packages/at_onboarding_cli/lib/src/util/register_api_result.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'api_call_status.dart'; - -class RegisterApiResult { - dynamic data; - - late ApiCallStatus apiCallStatus; - - String? exceptionMessage; -} diff --git a/packages/at_onboarding_cli/lib/src/util/register_api_task.dart b/packages/at_onboarding_cli/lib/src/util/register_api_task.dart deleted file mode 100644 index d45b0e0d..00000000 --- a/packages/at_onboarding_cli/lib/src/util/register_api_task.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:collection'; - -import 'package:at_onboarding_cli/src/util/onboarding_util.dart'; -import 'package:at_onboarding_cli/src/util/register_api_result.dart'; - -/// Represents a task in an AtSign registration cycle -abstract class RegisterApiTask { - static final maximumRetries = 3; - - int retryCount = 1; - - late Map params; - - late OnboardingUtil registerUtil; - - RegisterApiResult result = RegisterApiResult(); - - ///Initializes the Task object with necessary parameters - ///[params] is a map that contains necessary data to complete atsign - /// registration process - void init(Map params, OnboardingUtil registerUtil) { - this.params = params; - result.data = HashMap(); - this.registerUtil = registerUtil; - } - - ///Implementing classes need to implement required logic in this method to - ///complete their sub-process in the AtSign registration process - Future run(); - - ///In case the task has returned a [RegisterApiResult] with status retry, this method checks and returns if the call can be retried - bool shouldRetry() { - return retryCount < maximumRetries; - } -} diff --git a/packages/at_onboarding_cli/lib/src/util/registrar_api_constants.dart b/packages/at_onboarding_cli/lib/src/util/registrar_api_constants.dart deleted file mode 100644 index f6cb3ba2..00000000 --- a/packages/at_onboarding_cli/lib/src/util/registrar_api_constants.dart +++ /dev/null @@ -1,22 +0,0 @@ -class RegistrarApiConstants { - /// Authorities - static const String apiHostProd = 'my.atsign.com'; - static const String apiHostStaging = 'my.atsign.wtf'; - - /// API Paths - static const String pathGetFreeAtSign = '/api/app/v3/get-free-atsign'; - static const String pathRegisterAtSign = '/api/app/v3/register-person'; - static const String pathValidateOtp = '/api/app/v3/validate-person'; - static const String requestAuthenticationOtpPath = - '/api/app/v3/authenticate/atsign'; - static const String getCramKeyWithOtpPath = - '/api/app/v3/authenticate/atsign/activate'; - - /// API headers - static const String contentType = 'application/json'; - static const String authorization = '477b-876u-bcez-c42z-6a3d'; - - /// DebugMode: setting it to true will print more logs to aid understanding - /// the inner working of Register_cli - static const bool isDebugMode = false; -} diff --git a/packages/at_onboarding_cli/pubspec.yaml b/packages/at_onboarding_cli/pubspec.yaml index 62f8521a..acf0c6de 100644 --- a/packages/at_onboarding_cli/pubspec.yaml +++ b/packages/at_onboarding_cli/pubspec.yaml @@ -28,6 +28,13 @@ dependencies: at_server_status: ^1.0.4 at_utils: ^3.0.16 +dependency_overrides: + at_register: + git: + url: https://github.com/atsign-foundation/at_libraries.git + path: packages/at_register + ref: at_register_package + dev_dependencies: lints: ^2.1.0 test: ^1.24.2 diff --git a/packages/at_onboarding_cli/test/at_register_cli_test.dart b/packages/at_onboarding_cli/test/at_register_cli_test.dart new file mode 100644 index 00000000..395c71fe --- /dev/null +++ b/packages/at_onboarding_cli/test/at_register_cli_test.dart @@ -0,0 +1,240 @@ +import 'package:at_onboarding_cli/src/register_cli/registration_flow.dart'; +import 'package:at_register/at_register.dart'; +import 'package:at_utils/at_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockRegistrarApiCall extends Mock implements RegistrarApiAccessor {} + +void main() { + RegistrarApiAccessor accessorInstance = MockRegistrarApiCall(); + AtSignLogger.root_level = 'finer'; + + group('A group of tests to validate GetFreeAtsign', () { + setUp(() => resetMocktailState()); + + test('validate behaviour of GetFreeAtsign - encounters exception', + () async { + when(() => accessorInstance.getFreeAtSign()) + .thenThrow(Exception('random exception')); + + RegisterParams params = RegisterParams()..email = 'abcd@gmail.com'; + GetFreeAtsign getFreeAtsign = GetFreeAtsign( + apiAccessorInstance: accessorInstance, allowRetry: true); + try { + await RegistrationFlow(params).add(getFreeAtsign).start(); + } on Exception catch (e) { + expect(e.runtimeType, AtRegisterException); + expect(e.toString().contains('random exception'), true); + } + expect(getFreeAtsign.retryCount, getFreeAtsign.maximumRetries); + expect(getFreeAtsign.shouldRetry(), false); + }); + }); + + group('Group of tests to validate behaviour of RegisterAtsign', () { + setUp(() => resetMocktailState()); + + test('RegisterAtsign params reading and updating - negative case', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => accessorInstance.registerAtSign(atsign, email)) + .thenAnswer((_) => Future.value(false)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: accessorInstance, allowRetry: true); + try { + await RegistrationFlow(params).add(registerAtsignTask).start(); + } on Exception catch (e) { + expect(e.runtimeType, AtRegisterException); + assert(e.toString().contains('Could not complete the task')); + } + expect(registerAtsignTask.retryCount, registerAtsignTask.maximumRetries); + expect(registerAtsignTask.shouldRetry(), false); + }); + + test( + 'verify behaviour of RegistrationFlow processing exception in RegisterAtsign', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => accessorInstance.registerAtSign(atsign, email)) + .thenThrow(Exception('another new random exception')); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: accessorInstance, allowRetry: true); + bool exceptionFlag = false; + try { + await RegistrationFlow(params).add(registerAtsignTask).start(); + } on Exception catch (e) { + assert(e.toString().contains('another new random exception')); + exceptionFlag = true; + } + expect(exceptionFlag, true); + expect(registerAtsignTask.retryCount, registerAtsignTask.maximumRetries); + expect(registerAtsignTask.shouldRetry(), false); + }); + }); + + group('A group of tests to verify ValidateOtp task behaviour', () { + setUp(() => resetMocktailState()); + + test( + 'validate positive behaviour of ValidateOtp task - need to followUp with confirmation set to true', + () async { + // In this test Registration flow is supposed to call the validateOtpTask + // with confirmation first set to false and then with true without dev + // intervention + String atsign = '@charlie123'; + String email = 'third-group@email'; + String otp = 'bcde'; + String cram = 'craaaaaaaaaaaam1234'; + + var mockApiRespData = { + 'atsign': ['@old-atsign'], + 'newAtsign': atsign + }; + ValidateOtpResult validateOtpResult = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.followUp + ..apiCallStatus = ApiCallStatus.success + ..data = {'data': mockApiRespData}; + when(() => accessorInstance.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + ValidateOtpResult validateOtpResult2 = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.verified + ..apiCallStatus = ApiCallStatus.success + ..data = {RegistrarConstants.cramKeyName: '$atsign:$cram'}; + when(() => accessorInstance.validateOtp(atsign, email, otp, + confirmation: true)) + .thenAnswer((invocation) => Future.value(validateOtpResult2)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: accessorInstance, allowRetry: true); + RegisterTaskResult result = + await RegistrationFlow(params).add(validateOtpTask).start(); + + expect(result.data[RegistrarConstants.cramKeyName], cram); + expect(params.confirmation, true); + expect(validateOtpResult2.taskStatus, ValidateOtpStatus.verified); + + expect(validateOtpTask.retryCount, 2); + expect(validateOtpTask.shouldRetry(), true); + }); + + test('validate behaviour of ValidateOtp task - 3 otp retries exhausted', + () async { + String atsign = '@charlie-retry'; + String email = 'third-group-test-3@email'; + String otp = 'bcaa'; + String cram = 'craaaaaaaaaaaam'; + ValidateOtpResult validateOtpResult = ValidateOtpResult(); + validateOtpResult.taskStatus = ValidateOtpStatus.retry; + validateOtpResult.apiCallStatus = ApiCallStatus.success; + validateOtpResult.data = { + RegistrarConstants.cramKeyName: '$atsign:$cram' + }; + when(() => accessorInstance.validateOtp(atsign, email, otp, + confirmation: any(named: "confirmation"))) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: accessorInstance, allowRetry: true); + bool exceptionCaughtFlag = false; + try { + await RegistrationFlow(params).add(validateOtpTask).start(); + } on Exception catch (e) { + print(e.runtimeType); + print(e.toString()); + assert(e is ExhaustedVerificationCodeRetriesException); + exceptionCaughtFlag = true; + } + expect(exceptionCaughtFlag, true); + expect(validateOtpTask.retryCount, validateOtpTask.maximumRetries); + expect(validateOtpTask.shouldRetry(), false); + }); + }); + + group('test to validate all 3 API calls in sequence', () { + setUp(() => resetMocktailState()); + + test('verify all 3 API calls at once', () async { + String atsign = '@lewis'; + String email = 'lewis44@gmail.com'; + String cram = 'craaaaaaaaaaaaam'; + String otp = 'Agbr'; + + // mock for get-free-atsign + when(() => accessorInstance.getFreeAtSign()) + .thenAnswer((invocation) => Future.value(atsign)); + // mock for register-atsign + when(() => accessorInstance.registerAtSign(atsign, email)) + .thenAnswer((_) => Future.value(true)); + // rest of the mocks for validate-otp + var mockApiRespData = { + 'atsign': ['@old-atsign'], + 'newAtsign': atsign + }; + ValidateOtpResult validateOtpResult = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.followUp + ..apiCallStatus = ApiCallStatus.success + ..data = {'data': mockApiRespData}; + when(() => accessorInstance.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + ValidateOtpResult validateOtpResult2 = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.verified + ..apiCallStatus = ApiCallStatus.success + ..data = {RegistrarConstants.cramKeyName: '$atsign:$cram'}; + when(() => accessorInstance.validateOtp(atsign, email, otp, + confirmation: true)) + .thenAnswer((invocation) => Future.value(validateOtpResult2)); + + RegisterParams params = RegisterParams() + ..email = email + ..otp = otp + ..confirmation = false; + + GetFreeAtsign getFreeAtsignTask = GetFreeAtsign( + apiAccessorInstance: accessorInstance, allowRetry: true); + + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: accessorInstance, allowRetry: true); + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: accessorInstance, allowRetry: true); + + print(await accessorInstance.getFreeAtSign()); + RegisterTaskResult result = await RegistrationFlow(params) + .add(getFreeAtsignTask) + .add(registerAtsignTask) + .add(validateOtpTask) + .start(); + + expect(params.atsign, atsign); + expect(result.data[RegistrarConstants.cramKeyName], cram); + expect(params.confirmation, true); + }); + }); +} diff --git a/packages/at_register/.gitignore b/packages/at_register/.gitignore new file mode 100644 index 00000000..3cceda55 --- /dev/null +++ b/packages/at_register/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/at_register/CHANGELOG.md b/packages/at_register/CHANGELOG.md new file mode 100644 index 00000000..6bf3e825 --- /dev/null +++ b/packages/at_register/CHANGELOG.md @@ -0,0 +1,2 @@ +## 1.0.0 +- Initial version. diff --git a/packages/at_register/LICENSE b/packages/at_register/LICENSE new file mode 100644 index 00000000..caf63d72 --- /dev/null +++ b/packages/at_register/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020, The @ Foundation +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/at_register/README.md b/packages/at_register/README.md new file mode 100644 index 00000000..56a961c8 --- /dev/null +++ b/packages/at_register/README.md @@ -0,0 +1,81 @@ +The Atsign FoundationThe Atsign Foundation + +[![pub package](https://img.shields.io/pub/v/at_register)](https://pub.dev/packages/at_lookup) [![pub points](https://img.shields.io/pub/points/at_register?logo=dart)](https://pub.dev/packages/at_lookup/score) [![gitHub license](https://img.shields.io/badge/license-BSD3-blue.svg)](./LICENSE) + +# at_register + +## Overview: +This package contains code components that can interact with the AtRegistrar API and process the data received from the +API. +This serves as a collection of code components +that can be consumed by other packages to fetch free atsigns and register them to emails. + +Note: +If you're looking for a utility to get and activate a free atsign, +please feel free to take a look at `at_onboarding_cli/register_cli` +(or) any of the at_platforms apps available on PlayStore/AppStore + +## Get started: + +### Installation: + +To add this package as the dependency, add it to your pubspec.yaml + +```yaml +dependencies: + at_register: ^1.0.0 +``` + +#### Add to your project + +```sh +dart pub get +``` + +#### Import in your application code + +```dart +import 'package:at_register/at_register.dart'; +``` + +### Clone it from GitHub + +Feel free to fork a copy of the source from the [GitHub Repo](https://github.com/atsign-foundation/at_libraries) + +## Usage +### 0) Creating a RegisterParams object +```dart +RegisterParams params = RegisterParams()..email = 'email@email.com'; +``` + +### 1) To fetch a free atsign + +```dart +GetFreeAtsign getFreeAtsignTask = GetFreeAtsign(); +RegisterTaskResult result = await getFreeAtsignTask.run(params); +print(result.data[RegistrarConstants.atsignName]); +``` + +### 2) To register the free atsign fetched in (1) + +```dart +params.atsign = '@alice'; // preferably use the one fetched in (1) +RegisterAtsign registerAtsignTask = RegisterAtsign(); +RegisterTaskResult result = await registerAtsignTask.run(params); +print(result.data[RegistrarConstants.otpSentName]); // contains true/false if verification code was delivered to email +``` + +### 3) To validate the verification code + +```dart +params.otp = 'AB1C'; // to be fetched from user +ValidateOtp validateOtpTask = ValidateOtp(); +RegisterTaskResult result = await validateOtp.run(params); +print(result.data[RegistrarConstants.cramKeyName]); +``` +Please refer to [examples](https://github.com/atsign-foundation/at_libraries/blob/doc_at_lookup/at_lookup/example/bin/example.dart) for more details. + +## Open source usage and contributions + +This is freely licensed open source code, so feel free to use it as is, suggest changes or enhancements or create your +own version. See CONTRIBUTING.md for detailed guidance on how to setup tools, tests and make a pull request. \ No newline at end of file diff --git a/packages/at_register/analysis_options.yaml b/packages/at_register/analysis_options.yaml new file mode 100644 index 00000000..0246f5be --- /dev/null +++ b/packages/at_register/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types +# +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/at_register/example/at_register_example.dart b/packages/at_register/example/at_register_example.dart new file mode 100644 index 00000000..5f7cf0e1 --- /dev/null +++ b/packages/at_register/example/at_register_example.dart @@ -0,0 +1,32 @@ +import 'package:at_register/at_register.dart'; + +void main() async { + String email = ''; + RegisterParams registerParams = RegisterParams()..email = email; + + GetFreeAtsign getFreeAtsignTask = GetFreeAtsign(); + RegisterTaskResult result = await getFreeAtsignTask.run(registerParams); + registerParams.addFromJson(result.data); + + RegisterAtsign registerAtsignTask = RegisterAtsign(); + result = await registerAtsignTask.run(registerParams); + registerParams.addFromJson(result.data); + + // verification code sent to email provided in the beginning + // check the same email and enter that verification code through terminal/stdin + registerParams.otp = ApiUtil.readCliVerificationCode(); + ValidateOtp validateOtpTask = ValidateOtp(); + result = await validateOtpTask.run(registerParams); + if (result.apiCallStatus == ApiCallStatus.success) { + print(result.data[RegistrarConstants.cramKeyName]); + } else { + // this is the case where the email has existing atsigns + // set task.params.confirmation to true, select an atsign (existing/new) + // from the list of atsigns returned in the previous call(ValidateOtp with confirmation set to false) + String newAtsign = result.data[RegistrarConstants.newAtsignName]; + registerParams.atsign = newAtsign; + registerParams.confirmation = true; + result = await validateOtpTask.run(registerParams); + print(result.data[RegistrarConstants.cramKeyName]); + } +} diff --git a/packages/at_register/example/at_register_usage_explained.dart b/packages/at_register/example/at_register_usage_explained.dart new file mode 100644 index 00000000..da1bb828 --- /dev/null +++ b/packages/at_register/example/at_register_usage_explained.dart @@ -0,0 +1,62 @@ +import 'package:at_register/at_register.dart'; + +Future main() async { + RegisterParams params = RegisterParams()..email = 'abcd@email.com'; + RegistrarApiAccessor accessorInstance = RegistrarApiAccessor(); + + /// Example for GetFreeAtsign task + GetFreeAtsign getFreeAtsignTask = + GetFreeAtsign(apiAccessorInstance: accessorInstance); + RegisterTaskResult getFreeAtsignResult = await getFreeAtsignTask.run(params); + // api call status present in result.apiCallStatus + print(getFreeAtsignResult.apiCallStatus); + // all relevant data present in result.data which is a Map + print(getFreeAtsignResult.data); + + // this step is optional + // Can be used to propagate the data received in the current task to the next + params.addFromJson(getFreeAtsignResult.data); + // ---------------------------------------------------- + + /// Example for RegisterAtsign task + RegisterAtsign registerAtsignTask = + RegisterAtsign(apiAccessorInstance: accessorInstance); + RegisterTaskResult registerAtsignResult = + await registerAtsignTask.run(params); + // registerAtsignResult.data should have a key named 'otpSent' which contains + // true/false reg the status of verificationCodeSent to provided email + print(registerAtsignResult.data[RegistrarConstants.otpSentName]); + + params.addFromJson(registerAtsignResult.data); + // -------------------------------------------------------- + + /// Example for ValidateOtp task + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: accessorInstance); + // Note: confirmation is set to false by default + RegisterTaskResult validateOtpResult = await validateOtpTask.run(params); + + // CASE 1: if this is the first atsign for the provided email, CRAM should be + // present in validateOtpResult.data with key RegistrarConstants.cramKeyName + print(validateOtpResult.data[RegistrarConstants.cramKeyName]); + + // CASE 2: if this is not the first atsign, data contains the list of + // existing atsigns registered to that email + print(validateOtpResult.data[RegistrarConstants.fetchedAtsignListName]); + // and the new atsign fetched in the previous task + print(validateOtpResult.data[RegistrarConstants.newAtsignName]); + // either select the newAtsign or one of the existing atsigns and re-run the + // validateOtpTask but this time with confirmation set to true + // now this will return a result with the cram key in result.data + List fetchedAtsignList = + validateOtpResult.data[RegistrarConstants.fetchedAtsignListName]; + // selecting the first atsign from the fetchedAtsignList for demonstration + params.atsign = fetchedAtsignList[0]; + validateOtpResult = await validateOtpTask.run(params); + print(validateOtpResult.data[RegistrarConstants.cramKeyName]); + + // CASE 3: if the otp is incorrect, fetch the correct otp from user and re-run + // the validateOtpTask + params.otp = 'AB14'; // correct otp + validateOtpResult = await validateOtpTask.retry(params); +} diff --git a/packages/at_register/lib/at_register.dart b/packages/at_register/lib/at_register.dart new file mode 100644 index 00000000..2d8f64d0 --- /dev/null +++ b/packages/at_register/lib/at_register.dart @@ -0,0 +1,14 @@ +library at_register; + +export 'src/api-interactions/registrar_api_accessor.dart'; +export 'src/api-interactions/get_free_atsign.dart'; +export 'src/api-interactions/register_atsign.dart'; +export 'src/api-interactions/validate_otp.dart'; +export 'src/util/api_call_status.dart'; +export 'src/util/register_task_result.dart'; +export 'src/util/validate_otp_task_result.dart'; +export 'src/util/register_task.dart'; +export 'src/config/registrar_constants.dart'; +export 'src/util/register_params.dart'; +export 'src/util/api_util.dart'; +export 'src/util/at_register_exception.dart'; diff --git a/packages/at_register/lib/src/api-interactions/get_free_atsign.dart b/packages/at_register/lib/src/api-interactions/get_free_atsign.dart new file mode 100644 index 00000000..6dbcdd4d --- /dev/null +++ b/packages/at_register/lib/src/api-interactions/get_free_atsign.dart @@ -0,0 +1,53 @@ +import 'package:at_commons/at_commons.dart'; + +import 'package:at_register/at_register.dart'; + +/// A [RegisterTask] that fetches a free atsign +/// +/// Throws an [AtException] with the concerned message encountered in the +/// HTTP GET/POST request. +/// +/// Example usage: +/// ```dart +/// GetFreeAtsign getFreeAtsignInstance = GetFreeAtsign(); +/// RegisterTaskResult result = await getFreeAtsignInstance.run(registerParams); +/// ``` +/// The fetched atsign will be stored in RegisterTaskResult.data[[RegistrarConstants.atsignName]] +class GetFreeAtsign extends RegisterTask { + GetFreeAtsign( + {RegistrarApiAccessor? apiAccessorInstance, bool allowRetry = false}) + : super( + registrarApiAccessorInstance: apiAccessorInstance, + allowRetry: allowRetry); + + @override + String get name => 'GetFreeAtsignTask'; + + @override + Future run(RegisterParams params) async { + validateInputParams(params); + logger.info('Getting a randomly generated free atSign...'); + RegisterTaskResult result = RegisterTaskResult(); + try { + String atsign = await registrarApiAccessor.getFreeAtSign( + authority: RegistrarConstants.authority); + logger.info('Fetched free atsign: $atsign'); + result.data['atsign'] = atsign; + result.apiCallStatus = ApiCallStatus.success; + } on Exception catch (e) { + if (canThrowException()) { + throw AtRegisterException(e.toString()); + } + ApiUtil.handleException(result, e, shouldRetry()); + } + return result; + } + + @override + void validateInputParams(params) { + if (params.email.isNullOrEmpty) { + throw IllegalArgumentException( + 'email cannot be null for get-free-atsign-task'); + } + } +} diff --git a/packages/at_register/lib/src/api-interactions/register_atsign.dart b/packages/at_register/lib/src/api-interactions/register_atsign.dart new file mode 100644 index 00000000..708cd128 --- /dev/null +++ b/packages/at_register/lib/src/api-interactions/register_atsign.dart @@ -0,0 +1,67 @@ +import 'package:at_commons/at_commons.dart'; + +import 'package:at_register/at_register.dart'; + +/// User selects an atsign from the list fetched in [GetFreeAtsign]. +/// +/// Registers the selected atsign to the email provided through [RegisterParams]. +/// +/// Sets [RegisterTaskResult.apiCallStatus] if the HTTP GET/POST request gets +/// any response other than STATUS_OK. +/// +/// Example usage: +/// ```dart +/// RegisterAtsign registerAtsignInstance = RegisterAtsign(); +/// RegisterTaskResult result = await registerAtsignInstance.run(registerParams); +/// ``` +/// If verification code has been successfully delivered to email, +/// result.data[[RegistrarConstants.otpSentName]] will be set to true, otherwise false +class RegisterAtsign extends RegisterTask { + RegisterAtsign( + {RegistrarApiAccessor? apiAccessorInstance, bool allowRetry = false}) + : super( + registrarApiAccessorInstance: apiAccessorInstance, + allowRetry: allowRetry); + + @override + String get name => 'RegisterAtsignTask'; + + @override + Future run(RegisterParams params) async { + validateInputParams(params); + logger.info('Sending verification code to: ${params.email}'); + RegisterTaskResult result = RegisterTaskResult(); + try { + bool otpSent = await registrarApiAccessor.registerAtSign( + params.atsign!, params.email!, + authority: RegistrarConstants.authority); + result.data[RegistrarConstants.otpSentName] = otpSent; + if (otpSent) { + logger.info('Verification code sent to: ${params.email}'); + result.apiCallStatus = ApiCallStatus.success; + } else { + logger.severe('Could NOT Verification code sent to: ${params.email}.' + ' Please try again'); + result.apiCallStatus = ApiCallStatus.retry; + } + } on Exception catch (e) { + if (canThrowException()) { + throw AtRegisterException(e.toString()); + } + ApiUtil.handleException(result, e, shouldRetry()); + } + return result; + } + + @override + void validateInputParams(RegisterParams params) { + if (params.atsign.isNullOrEmpty) { + throw IllegalArgumentException( + 'Atsign cannot be null for register-atsign-task'); + } + if (params.email.isNullOrEmpty) { + throw IllegalArgumentException( + 'e-mail cannot be null for register-atsign-task'); + } + } +} diff --git a/packages/at_register/lib/src/api-interactions/registrar_api_accessor.dart b/packages/at_register/lib/src/api-interactions/registrar_api_accessor.dart new file mode 100644 index 00000000..61aaecd2 --- /dev/null +++ b/packages/at_register/lib/src/api-interactions/registrar_api_accessor.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:at_commons/at_commons.dart'; +import 'package:at_utils/at_logger.dart'; +import 'package:http/http.dart' as http; + +import 'package:at_register/at_register.dart'; + +/// Contains methods that actually perform the RegistrarAPI calls +/// and handle/process the response +class RegistrarApiAccessor { + AtSignLogger logger = AtSignLogger('RegistrarApiAccessor'); + + /// Returns a Future> containing free available atSigns + /// based on [count] provided as input. + Future getFreeAtSign( + {String authority = RegistrarConstants.apiHostProd}) async { + http.Response response = await ApiUtil.getRequest( + authority, RegistrarConstants.getFreeAtSignApiPath); + if (response.statusCode == 200) { + String? atsign = jsonDecode(response.body)['data']['atsign']; + if (atsign != null) { + return atsign; + } + } + throw AtRegisterException( + 'Could not fetch atsign | ${response.statusCode}:${response.reasonPhrase}'); + } + + /// Sends verification code to the provided [email] for the [atSign] provided + /// + /// The `atSign` provided should be an unregistered and free atsign + /// + /// Returns true if verification code is successfully delivered. + /// + /// Throws [AtRegisterException] if [atSign] is invalid + /// + /// Note: atsign will not be considered registered at this stage. The verification + /// of verificationCode/otp will take place in [validateOtp] + Future registerAtSign(String atSign, String email, + {oldEmail, String authority = RegistrarConstants.apiHostProd}) async { + final response = await ApiUtil.postRequest( + authority, RegistrarConstants.registerAtSignApiPath, { + 'atsign': atSign, + 'email': email, + 'oldEmail': oldEmail, + }); + if (response.statusCode == 200) { + final jsonDecoded = jsonDecode(response.body) as Map; + // will be set to true if API response contains message with 'success' + bool sentSuccessfully = + jsonDecoded['message'].toLowerCase().contains('success'); + return sentSuccessfully; + } else { + throw AtRegisterException( + '${response.statusCode} ${response.reasonPhrase}'); + } + } + + /// Registers the [atSign] provided in the input to the provided [email] + /// The `atSign` provided should be an unregistered and free atsign + /// Validates the OTP against the atsign and registers it to the provided email if OTP is valid. + /// Returns the CRAM secret of the atsign which is registered. + /// + /// ToDo: what would be the best place to Put the paragraph below + /// + /// [confirmation] - Mandatory parameter for validateOTP API call. First request to be sent with confirmation as FALSE, in this + /// case API will return cram key if the user is new otherwise will return list of already existing atsigns. + /// If the user already has existing atsigns user will have to select a listed atsign old/new and place a second call + /// to the same API endpoint with confirmation set to true with previously received OTP. The second follow-up call + /// is automated by this client using new atsign for user simplicity + /// + /// Returns: + /// + /// Case 1("verified") - the API has registered the atsign to provided email and CRAM key present in HTTP_RESPONSE Body. + /// + /// Case 2("follow-up"): User already has existing atsigns and new atsign + /// registered successfully. To receive the CRAM key, retry the call with one + /// of the existing listed atsigns and confirmation set to true. + /// + /// Case 3("retry"): Incorrect OTP send request again with correct OTP. + /// + /// Throws [AtException] if [atSign] or [otp] is invalid + Future validateOtp(String atSign, String email, String otp, + {bool confirmation = true, + String authority = RegistrarConstants.apiHostProd}) async { + final response = await ApiUtil.postRequest( + authority, RegistrarConstants.validateOtpApiPath, { + 'atsign': atSign, + 'email': email, + 'otp': otp, + 'confirmation': confirmation.toString(), + }); + + if (response.statusCode == 200) { + ValidateOtpResult validateOtpResult = ValidateOtpResult(); + final jsonDecodedResponse = jsonDecode(response.body); + _processValidateOtpApiResponse(jsonDecodedResponse, validateOtpResult); + return validateOtpResult; + } else { + throw AtRegisterException( + 'Failed to Validate VerificationCode | ${response.statusCode} ${response.reasonPhrase}'); + } + } + + /// processes API response for [validateOtp] call and populates [result] + void _processValidateOtpApiResponse( + Map apiResponse, ValidateOtpResult result) { + String? message = apiResponse['message'].toString().toLowerCase(); + if (apiResponse.containsKey('data')) { + result.data.addAll(apiResponse['data'] as Map); + } + + if (message == ValidateOtpStatus.verified.name && + apiResponse.containsKey(RegistrarConstants.cramKeyName)) { + result.taskStatus = ValidateOtpStatus.verified; + } else if (apiResponse.containsKey('data') && + apiResponse.containsKey(RegistrarConstants.newAtsignName)) { + result.data[RegistrarConstants.fetchedAtsignListName] = + apiResponse[RegistrarConstants.atsignName]; + result.taskStatus = ValidateOtpStatus.followUp; + } else if (message == + 'The code you have entered is invalid or expired. Please try again?') { + result.taskStatus = ValidateOtpStatus.retry; + result.exception = apiResponse['message']; + } else if (message == + 'Oops! You already have the maximum number of free atSigns. Please select one of your existing atSigns.') { + throw MaximumAtsignQuotaException( + 'Maximum free atsign limit reached for current email'); + } else { + throw AtRegisterException(message); + } + } + + /// Accepts a registered [atsign] as a parameter and sends a one-time verification code + /// to the email that the atsign is registered with + /// + /// Throws an exception in the following cases: + /// 1) HTTP 400 BAD_REQUEST + /// 2) Invalid atsign + Future requestAuthenticationOtp(String atsign, + {String authority = RegistrarConstants.apiHostProd}) async { + final response = await ApiUtil.postRequest(authority, + RegistrarConstants.requestAuthenticationOtpPath, {'atsign': atsign}); + final apiResponseMessage = jsonDecode(response.body)['message']; + if (response.statusCode == 200 && + apiResponseMessage.contains('Sent Successfully')) { + logger.info( + 'Successfully sent verification code to your registered e-mail'); + return; + } + throw AtRegisterException( + 'Unable to send verification code for authentication. | Cause: $apiResponseMessage'); + } + + /// Returns the cram key for an atsign by fetching it from the registrar API + /// + /// Accepts a registered [atsign], the verification code that was sent to + /// the registered email + /// + /// Throws exception in the following cases: 1) HTTP 400 BAD_REQUEST + Future getCramKey(String atsign, String verificationCode, + {String authority = RegistrarConstants.apiHostProd}) async { + final response = await ApiUtil.postRequest( + authority, + RegistrarConstants.getCramKeyWithOtpPath, + {'atsign': atsign, 'otp': verificationCode}); + final jsonDecodedBody = jsonDecode(response.body) as Map; + if (response.statusCode == 200) { + if (jsonDecodedBody['message'] == 'Verified') { + String cram = jsonDecodedBody['cramkey']; + cram = cram.split(':')[1]; + logger.info('CRAM Key fetched successfully'); + return cram; + } + // If API call status is HTTP.OK / 200, but the response message does not + // contain 'Verified', that indicates incorrect verification provided by user + throw InvalidVerificationCodeException( + 'Invalid verification code. Please enter a valid verification code'); + } + throw InvalidDataException(jsonDecodedBody['message']); + } + + /// calls utility methods from [RegistrarApiAccessor] that + /// + /// 1) send verification code to the registered email + /// + /// 2) fetch the CRAM key from registrar using the verification code + Future getCramUsingOtp(String atsign, String registrarUrl) async { + await requestAuthenticationOtp(atsign, authority: registrarUrl); + return await getCramKey(atsign, ApiUtil.readCliVerificationCode(), + authority: registrarUrl); + } +} diff --git a/packages/at_register/lib/src/api-interactions/validate_otp.dart b/packages/at_register/lib/src/api-interactions/validate_otp.dart new file mode 100644 index 00000000..baf37463 --- /dev/null +++ b/packages/at_register/lib/src/api-interactions/validate_otp.dart @@ -0,0 +1,125 @@ +import 'package:at_commons/at_commons.dart'; +import 'package:at_utils/at_utils.dart'; + +import '../../at_register.dart'; + +/// Task for validating the verification_code sent as part of the registration process. +/// +/// Example usage: +/// ```dart +/// ValidateOtp validateOtpInstance = ValidateOtp(); +/// RegisterTaskResult result = await validateOtpInstance.run(registerParams); +/// ``` +/// CASE 1: 'If the email provided through registerParams does NOT have any existing atsigns': +/// cramKey will be present in result.data[[RegistrarConstants.cramKeyName]] +/// +/// CASE 2: 'If the email provided through registerParams has existing atsigns': +/// list of existingAtsigns will be present in result.data[[RegistrarConstants.fetchedAtsignListName]] +/// and the new atsign in result.data[[RegistrarConstants.newAtsignName]]; +/// Now, to fetch the cram key select one atsign (existing/new); populate this atsign +/// in registerParams and retry this task. Output will be as described in 'CASE 1' +class ValidateOtp extends RegisterTask { + ValidateOtp( + {RegistrarApiAccessor? apiAccessorInstance, bool allowRetry = false}) + : super( + registrarApiAccessorInstance: apiAccessorInstance, + allowRetry: allowRetry); + + @override + String get name => 'ValidateOtpTask'; + + @override + Future run(RegisterParams params) async { + validateInputParams(params); + RegisterTaskResult result = RegisterTaskResult(); + try { + logger.info('Validating code with ${params.atsign}...'); + params.atsign = AtUtils.fixAtSign(params.atsign!); + final validateOtpApiResult = await registrarApiAccessor.validateOtp( + params.atsign!, + params.email!, + params.otp!, + confirmation: params.confirmation, + authority: RegistrarConstants.authority, + ); + + switch (validateOtpApiResult.taskStatus) { + case ValidateOtpStatus.retry: + if (canThrowException()) { + throw InvalidVerificationCodeException( + 'Verification Failed: Incorrect verification code provided'); + } + logger.warning('Invalid or expired verification code. Retrying...'); + params.otp ??= ApiUtil + .readCliVerificationCode(); // retry reading otp from user through stdin + if (shouldRetry()) { + result.apiCallStatus = ApiCallStatus.retry; + result.exception = InvalidVerificationCodeException( + 'Verification Failed: Incorrect verification code provided. Please retry the process again'); + } else { + result.apiCallStatus = ApiCallStatus.failure; + result.exception = ExhaustedVerificationCodeRetriesException( + 'Exhausted verification code retry attempts. Please restart the process'); + } + break; + + case ValidateOtpStatus.followUp: + params.confirmation = true; + result.data['otp'] = params.otp; + result.fetchedAtsignList = validateOtpApiResult.data['atsign']; + result.data[RegistrarConstants.newAtsignName] = + validateOtpApiResult.data[RegistrarConstants.newAtsignName]; + result.apiCallStatus = ApiCallStatus.retry; + logger.finer( + 'Provided email has existing atsigns, please select one atsign and retry this task'); + break; + + case ValidateOtpStatus.verified: + result.data[RegistrarConstants.cramKeyName] = validateOtpApiResult + .data[RegistrarConstants.cramKeyName] + .split(":")[1]; + logger.info('Cram secret verified'); + logger.info('Successful registration for ${params.email}'); + result.apiCallStatus = ApiCallStatus.success; + break; + + case ValidateOtpStatus.failure: + + case null: + + default: + result.apiCallStatus = ApiCallStatus.failure; + result.exception = validateOtpApiResult.exception; + break; + } + } on MaximumAtsignQuotaException { + rethrow; + } on ExhaustedVerificationCodeRetriesException { + rethrow; + } on InvalidVerificationCodeException { + rethrow; + } on Exception catch (e) { + if (canThrowException()) { + throw AtRegisterException(e.toString()); + } + ApiUtil.handleException(result, e, shouldRetry()); + } + return result; + } + + @override + void validateInputParams(RegisterParams params) { + if (params.atsign.isNullOrEmpty) { + throw IllegalArgumentException( + 'Atsign cannot be null for register-atsign-task'); + } + if (params.email.isNullOrEmpty) { + throw IllegalArgumentException( + 'e-mail cannot be null for register-atsign-task'); + } + if (params.otp.isNullOrEmpty) { + throw InvalidVerificationCodeException( + 'Verification code cannot be null/empty'); + } + } +} diff --git a/packages/at_register/lib/src/config/registrar_constants.dart b/packages/at_register/lib/src/config/registrar_constants.dart new file mode 100644 index 00000000..5e306996 --- /dev/null +++ b/packages/at_register/lib/src/config/registrar_constants.dart @@ -0,0 +1,32 @@ +class RegistrarConstants { + /// Authorities + static const String apiHostProd = 'my.atsign.com'; + static const String apiHostStaging = 'my.atsign.wtf'; + + /// Select [Prod/Staging] + /// Change to [apiHostStaging] to use AtRegister in a staging env + static const String authority = apiHostProd; + + /// API Paths + static const String getFreeAtSignApiPath = '/api/app/v3/get-free-atsign'; + static const String registerAtSignApiPath = '/api/app/v3/register-person'; + static const String validateOtpApiPath = '/api/app/v3/validate-person'; + static const String requestAuthenticationOtpPath = + '/api/app/v3/authenticate/atsign'; + static const String getCramKeyWithOtpPath = + '/api/app/v3/authenticate/atsign/activate'; + + /// API headers + static const String contentType = 'application/json'; + static const String authorization = '477b-876u-bcez-c42z-6a3d'; + + /// DebugMode: setting it to true will print more logs to aid understanding + /// the inner working of Register_cli + static const bool isDebugMode = true; + + static const String cramKeyName = 'cramkey'; + static const String atsignName = 'atsign'; + static const String otpSentName = 'otpSent'; + static const String fetchedAtsignListName = 'fetchedAtsignList'; + static const String newAtsignName = 'newAtsign'; +} diff --git a/packages/at_register/lib/src/util/api_call_status.dart b/packages/at_register/lib/src/util/api_call_status.dart new file mode 100644 index 00000000..ab322e77 --- /dev/null +++ b/packages/at_register/lib/src/util/api_call_status.dart @@ -0,0 +1,3 @@ +enum ApiCallStatus { success, failure, retry } + +enum ValidateOtpStatus { verified, followUp, retry, failure } diff --git a/packages/at_register/lib/src/util/api_util.dart b/packages/at_register/lib/src/util/api_util.dart new file mode 100644 index 00000000..a812d1d9 --- /dev/null +++ b/packages/at_register/lib/src/util/api_util.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_utils/at_logger.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +import '../../at_register.dart'; + +class ApiUtil { + static IOClient? _ioClient; + + static void _createClient() { + HttpClient ioc = HttpClient(); + ioc.badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + _ioClient = IOClient(ioc); + } + + /// generic GET request + static Future getRequest(String authority, String path) async { + if (_ioClient == null) _createClient(); + Uri uri = Uri.https(authority, path); + http.Response response = + (await _ioClient!.get(uri, headers: { + 'Authorization': RegistrarConstants.authorization, + 'Content-Type': RegistrarConstants.contentType, + })); + return response; + } + + /// generic POST request + static Future postRequest( + String authority, String path, Map data) async { + if (_ioClient == null) _createClient(); + + Uri uri = Uri.https(authority, path); + + String body = json.encode(data); + http.Response response = await _ioClient!.post( + uri, + body: body, + headers: { + 'Authorization': RegistrarConstants.authorization, + 'Content-Type': RegistrarConstants.contentType, + }, + ); + if (RegistrarConstants.isDebugMode) { + AtSignLogger('AtRegister') + .shout('Sent request to url: $uri | Request Body: $body'); + AtSignLogger('AtRegister').shout('Got Response: ${response.body}'); + } + return response; + } + + static bool enforceEmailRegex(String email) { + return RegExp( + r"^[a-zA-Z0-9.a-zA-Z0-9!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") + .hasMatch(email); + } + + static bool enforceOtpRegex(String otp) { + if (otp.length == 4) { + return RegExp(r"^[a-zA-z0-9]").hasMatch(otp); + } + return false; + } + + static String formatExceptionMessage(String exception) { + return exception.replaceAll('Exception:', ''); + } + + /// Method to get verification code from user input + /// validates code locally and retries taking user input if invalid + /// Returns only when the user has provided a 4-length String only containing numbers and alphabets + static String readCliVerificationCode() { + String? otp; + stdout.writeln( + '[Action Required] Enter your verification code: (verification code is not case-sensitive)'); + otp = stdin.readLineSync()!.toUpperCase(); + while (!enforceOtpRegex(otp!)) { + stderr.writeln( + '[Unable to proceed] The verification code you entered is invalid.\n' + 'Please check your email for a 4-character verification code.\n' + 'If you cannot see the code in your inbox, please check your spam/junk/promotions folders.\n' + '[Action Required] Enter your verification code:'); + otp = stdin.readLineSync()!.toUpperCase(); + } + return otp; + } + + /// Populates [RegisterTaskResult.apiCallStatus] based on [_retryCount] + /// Formats and populates [RegisterTaskResult.exception] based on Exception [e] + static void handleException( + RegisterTaskResult result, Exception e, bool shouldRetry) { + result.apiCallStatus = + shouldRetry ? ApiCallStatus.retry : ApiCallStatus.failure; + + String formattedExceptionMessage = + ApiUtil.formatExceptionMessage(e.toString()); + result.exception = AtRegisterException(formattedExceptionMessage); + } + + static String readUserAtsignChoice(List? atsigns) { + if (atsigns == null) { + throw AtRegisterException('Fetched atsigns list is null'); + } else if (atsigns.length == 1) { + return atsigns[0]; + } + stdout.writeln( + 'Please select one atsign from the list above. Input the number of the atsign you wish to select.'); + stdout.writeln( + 'For example, type \'2\'+\'Enter\' to select the second atsign (or) just hit \'Enter\' to select the first one'); + stdout.writeln('Valid range is 1 - ${atsigns.length + 1}'); + int? choice = int.tryParse(stdin.readLineSync()!); + if (choice == null) { + return atsigns[0]; + } else { + return atsigns[choice]; + } + } +} diff --git a/packages/at_register/lib/src/util/at_register_exception.dart b/packages/at_register/lib/src/util/at_register_exception.dart new file mode 100644 index 00000000..5a6b0f72 --- /dev/null +++ b/packages/at_register/lib/src/util/at_register_exception.dart @@ -0,0 +1,25 @@ +import 'package:at_commons/at_commons.dart'; + +class AtRegisterException extends AtException { + AtRegisterException(String message, + {Intent? intent, ExceptionScenario? exceptionScenario}) + : super(message, intent: intent, exceptionScenario: exceptionScenario); +} + +class MaximumAtsignQuotaException extends AtException { + MaximumAtsignQuotaException(String message, + {Intent? intent, ExceptionScenario? exceptionScenario}) + : super(message, intent: intent, exceptionScenario: exceptionScenario); +} + +class InvalidVerificationCodeException extends AtException { + InvalidVerificationCodeException(String message, + {Intent? intent, ExceptionScenario? exceptionScenario}) + : super(message, intent: intent, exceptionScenario: exceptionScenario); +} + +class ExhaustedVerificationCodeRetriesException extends AtException { + ExhaustedVerificationCodeRetriesException(String message, + {Intent? intent, ExceptionScenario? exceptionScenario}) + : super(message, intent: intent, exceptionScenario: exceptionScenario); +} diff --git a/packages/at_register/lib/src/util/register_params.dart b/packages/at_register/lib/src/util/register_params.dart new file mode 100644 index 00000000..806b8d7b --- /dev/null +++ b/packages/at_register/lib/src/util/register_params.dart @@ -0,0 +1,41 @@ +import 'package:at_commons/at_commons.dart'; +import 'package:at_register/at_register.dart'; + +class RegisterParams { + String? atsign; + String? email; + String? oldEmail; + bool confirmation = false; + String? otp; + String? cram; + + /// Populates the current instance of [RegisterParams] using the fields from the json + /// + /// Usage: + /// + /// ```RegisterParams params = RegisterParams();``` + /// + /// ```params.addFromJson(json);``` + addFromJson(Map json) { + if (json.containsKey('atsign') && json['atsign'].runtimeType == String) { + atsign = json['atsign']; + } + if (json.containsKey('otp')) { + otp = json['otp']; + } + if (json.containsKey('email')) { + email = json['email']; + } + if (json.containsKey('oldEmail')) { + oldEmail = json['oldEmail']; + } + if (json.containsKey(RegistrarConstants.cramKeyName)) { + cram = json['cramkey']; + } + } + + @override + String toString() { + return 'atsign: $atsign | email: $email | otp: ${otp.isNullOrEmpty ? 'null' : '****'} | oldEmail: $oldEmail | confirmation: $confirmation'; + } +} diff --git a/packages/at_register/lib/src/util/register_task.dart b/packages/at_register/lib/src/util/register_task.dart new file mode 100644 index 00000000..c86906a7 --- /dev/null +++ b/packages/at_register/lib/src/util/register_task.dart @@ -0,0 +1,65 @@ +import 'package:at_register/at_register.dart'; +import 'package:at_utils/at_logger.dart'; + +/// Represents a task in an AtSign registration cycle +abstract class RegisterTask { + late String name; + + final maximumRetries = 4; // 1 run attempt + 3 retries = 4 + + int _retryCount = 1; + + int get retryCount => _retryCount; + + late bool _allowRetry = false; + + late RegistrarApiAccessor _registrarApiAccessor; + RegistrarApiAccessor get registrarApiAccessor => _registrarApiAccessor; + + late AtSignLogger logger; + + RegisterTask( + {RegistrarApiAccessor? registrarApiAccessorInstance, + bool allowRetry = false}) { + _registrarApiAccessor = + registrarApiAccessorInstance ?? RegistrarApiAccessor(); + _allowRetry = allowRetry; + logger = AtSignLogger(name); + } + + /// Implementing classes need to implement required logic in this method to + /// complete their sub-process in the AtSign registration process + /// + /// If [allowRetry] is set to true, the task will rethrow all exceptions + /// otherwise will catch the exception and store the exception message in + /// [RegisterTaskResult.exception] + Future run(RegisterParams params); + + Future retry(RegisterParams params) async { + increaseRetryCount(); + return await run(params); + } + + /// Each task implementation will have a set of params that are required to + /// complete the respective task. This method will ensure all required params + /// are provided/populated + void validateInputParams(RegisterParams params); + + bool canThrowException() { + // cannot throw exception if retries allowed + return !_allowRetry; + } + + /// Increases retry count by 1 + /// + /// This method is to ensure that retryCount cannot be reduced + void increaseRetryCount() { + _retryCount++; + } + + /// In case the task has returned a [RegisterTaskResult] with status retry, + /// this method checks and returns if the task can be retried + bool shouldRetry() { + return _retryCount < maximumRetries; + } +} diff --git a/packages/at_register/lib/src/util/register_task_result.dart b/packages/at_register/lib/src/util/register_task_result.dart new file mode 100644 index 00000000..2dc1d068 --- /dev/null +++ b/packages/at_register/lib/src/util/register_task_result.dart @@ -0,0 +1,20 @@ +import 'dart:collection'; + +import 'api_call_status.dart'; + +class RegisterTaskResult { + Map data = HashMap(); + + late ApiCallStatus apiCallStatus; + + List? fetchedAtsignList; + + Exception? exception; + + @override + String toString() { + return 'Data: $data | ' + 'ApiCallStatus: ${apiCallStatus.name} | ' + 'exception(if encountered): $exception'; + } +} diff --git a/packages/at_register/lib/src/util/validate_otp_task_result.dart b/packages/at_register/lib/src/util/validate_otp_task_result.dart new file mode 100644 index 00000000..283990c1 --- /dev/null +++ b/packages/at_register/lib/src/util/validate_otp_task_result.dart @@ -0,0 +1,7 @@ +import '../../at_register.dart'; + +/// This class object if for internal use +/// Used to return +class ValidateOtpResult extends RegisterTaskResult { + ValidateOtpStatus? taskStatus; +} diff --git a/packages/at_register/pubspec.yaml b/packages/at_register/pubspec.yaml new file mode 100644 index 00000000..622ba975 --- /dev/null +++ b/packages/at_register/pubspec.yaml @@ -0,0 +1,17 @@ +name: at_register +description: Package that has code to interact with the AtRegistrar API +version: 1.0.0 +repository: https://github.com/atsign-foundation/at_libraries + +environment: + sdk: '>=2.15.1 <4.0.0' + +dependencies: + at_commons: ^4.0.3 + at_utils: ^3.0.16 + http: ^1.2.1 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 + mocktail: ^1.0.3 diff --git a/packages/at_register/test/at_register_allow_retry_false_test.dart b/packages/at_register/test/at_register_allow_retry_false_test.dart new file mode 100644 index 00000000..4d565b3c --- /dev/null +++ b/packages/at_register/test/at_register_allow_retry_false_test.dart @@ -0,0 +1,318 @@ +import 'package:at_commons/at_commons.dart'; +import 'package:at_register/at_register.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockRegistrarApiAccessor extends Mock implements RegistrarApiAccessor {} + +void main() { + MockRegistrarApiAccessor mockRegistrarApiAccessor = + MockRegistrarApiAccessor(); + + group('Validate individual task behaviour with invalid params', () { + test('validate behaviour of GetFreeAtsign', () async { + RegisterParams params = RegisterParams()..email = ''; + var getFreeAtsignTask = + GetFreeAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + expect(() => getFreeAtsignTask.run(params), + throwsA(predicate((e) => e is IllegalArgumentException))); + }); + + test('validate behaviour of RegisterAtsign', () async { + RegisterParams params = RegisterParams() + ..email = 'email@email' + ..atsign = null; + + var registerAtsignTask = + RegisterAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + expect(() => registerAtsignTask.run(params), + throwsA(predicate((e) => e is IllegalArgumentException))); + }); + + test('validate behaviour of ValidateOtp with null atsign', () async { + RegisterParams params = RegisterParams() + ..email = 'email@email' + ..otp = null; + + var validateOtpTask = + ValidateOtp(apiAccessorInstance: mockRegistrarApiAccessor); + expect(() => validateOtpTask.run(params), + throwsA(predicate((e) => e is IllegalArgumentException))); + }); + + test('validate behaviour of ValidateOtp with null verification code', + () async { + RegisterParams params = RegisterParams() + ..email = 'email@email' + ..atsign = '@abcd' + ..otp = null; + var validateOtpTask = + ValidateOtp(apiAccessorInstance: mockRegistrarApiAccessor); + expect(() => validateOtpTask.run(params), + throwsA(predicate((e) => e is InvalidVerificationCodeException))); + }); + }); + + group('A group of tests to validate GetFreeAtsign', () { + setUp(() => resetMocktailState()); + + test('validate behaviour of GetFreeAtsign', () async { + when(() => mockRegistrarApiAccessor.getFreeAtSign()) + .thenAnswer((invocation) => Future.value('@alice')); + + RegisterParams params = RegisterParams()..email = 'abcd@gmail.com'; + GetFreeAtsign getFreeAtsign = + GetFreeAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + final result = await getFreeAtsign.run(params); + + expect(result.data[RegistrarConstants.atsignName], '@alice'); + }); + + test('validate behaviour of GetFreeAtsign - encounters exception', + () async { + when(() => mockRegistrarApiAccessor.getFreeAtSign()) + .thenThrow(Exception('random exception')); + + RegisterParams params = RegisterParams()..email = 'abcd@gmail.com'; + GetFreeAtsign getFreeAtsign = + GetFreeAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + bool exceptionFlag = false; + try { + await getFreeAtsign.run(params); + } on Exception catch (e) { + expect(e.runtimeType, AtRegisterException); + expect(e.toString().contains('random exception'), true); + exceptionFlag = true; + } + // validates that exception was thrown + expect(exceptionFlag, true); + }); + }); + + group('Group of tests to validate behaviour of RegisterAtsign', () { + setUp(() => resetMocktailState()); + + test('RegisterAtsign params reading and updating - positive case', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenAnswer((_) => Future.value(true)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = + RegisterAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + RegisterTaskResult result = await registerAtsignTask.run(params); + + expect(result.apiCallStatus, ApiCallStatus.success); + expect(result.data['otpSent'], true); + }); + + test('RegisterAtsign params reading and updating - negative case', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenAnswer((_) => Future.value(false)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = + RegisterAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + RegisterTaskResult result = await registerAtsignTask.run(params); + + expect(result.apiCallStatus, ApiCallStatus.retry); + expect(result.data['otpSent'], false); + }); + + test('verify behaviour of RegisterAtsign processing an exception', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenThrow(Exception('another random exception')); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + + RegisterAtsign registerAtsignTask = + RegisterAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + bool exceptionFlag = false; + try { + await registerAtsignTask.run(params); + } on Exception catch (e) { + expect(e.runtimeType, AtRegisterException); + expect(e.toString().contains('random exception'), true); + exceptionFlag = true; + } + // validates that an exception was thrown + expect(exceptionFlag, true); + }); + + test( + 'verify behaviour of RegistrationFlow processing exception in RegisterAtsign', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenThrow(Exception('another new random exception')); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = + RegisterAtsign(apiAccessorInstance: mockRegistrarApiAccessor); + bool exceptionFlag = false; + try { + await registerAtsignTask.run(params); + } on Exception catch (e) { + assert(e.toString().contains('another new random exception')); + exceptionFlag = true; + } + expect(exceptionFlag, true); + }); + }); + + group('A group of tests to verify ValidateOtp task behaviour', () { + setUp(() => resetMocktailState()); + + test( + 'validate positive behaviour of ValidateOtp task - received cram in first call', + () async { + String atsign = '@charlie'; + String email = 'third-group@email'; + String otp = 'Abcd'; + String cram = 'craaaaaaaaaaaam'; + ValidateOtpResult validateOtpResult = ValidateOtpResult(); + validateOtpResult.taskStatus = ValidateOtpStatus.verified; + validateOtpResult.apiCallStatus = ApiCallStatus.success; + validateOtpResult.data = { + RegistrarConstants.cramKeyName: '$atsign:$cram' + }; + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: mockRegistrarApiAccessor); + RegisterTaskResult result = await validateOtpTask.run(params); + + expect(result.data[RegistrarConstants.cramKeyName], cram); + }); + + test( + 'validate positive behaviour of ValidateOtp task - need to followUp with confirmation set to true', + () async { + String atsign = '@charlie123'; + String atsign2 = '@cheesecake'; + String email = 'third-group@email'; + String otp = 'bcde'; + String cram = 'craaaaaaaaaaaam1234'; + + var mockApiRespData = { + 'atsign': ['@old-atsign'], + 'newAtsign': atsign + }; + ValidateOtpResult validateOtpResult = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.followUp + ..apiCallStatus = ApiCallStatus.success + ..data = mockApiRespData; + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + ValidateOtpResult validateOtpResult2 = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.verified + ..apiCallStatus = ApiCallStatus.success + ..data = {RegistrarConstants.cramKeyName: '$atsign:$cram'}; + when(() => mockRegistrarApiAccessor.validateOtp(atsign2, email, otp, + confirmation: true)) + .thenAnswer((invocation) => Future.value(validateOtpResult2)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false // confirmation needs to be false for first call + ..email = email + ..otp = otp; + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: mockRegistrarApiAccessor); + RegisterTaskResult result = await validateOtpTask.run(params); + expect(params.confirmation, true); // confirmation set to true by the Task + expect(result.apiCallStatus, ApiCallStatus.retry); + expect(result.fetchedAtsignList, ['@old-atsign']); + expect(result.data[RegistrarConstants.newAtsignName], atsign); + + // The above case is when an email has already existing atsigns, select an atsign + // from the list and retry the task with confirmation set to 'true' + // mimic-ing a user selecting an atsign and proceeding ahead + params.atsign = atsign2; + result = await validateOtpTask.run(params); + expect(result.apiCallStatus, ApiCallStatus.success); + expect(result.data[RegistrarConstants.cramKeyName], cram); + }); + + test('validate behaviour of ValidateOtp task - incorrect otp', () async { + String atsign = '@charlie-otp-incorrect'; + String email = 'third-group-test-3-3@email'; + String otp = 'otpp'; // invalid otp + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + ValidateOtpResult validateOtpResult = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.retry + ..apiCallStatus = ApiCallStatus.success; + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: mockRegistrarApiAccessor); + + expect( + () async => await validateOtpTask.run(params), + throwsA(predicate((e) => + e is InvalidVerificationCodeException && + e.message.contains('Incorrect verification code provided')))); + }); + + test( + 'validate behaviour of ValidateOtp task - maximum free atsign limit reached', + () async { + String atsign = '@charlie-otp-incorrect'; + String email = 'third-group-test-3-3@email'; + String otp = 'otpp'; + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenThrow( + MaximumAtsignQuotaException('maximum free atsign limit reached')); + + ValidateOtp validateOtpTask = + ValidateOtp(apiAccessorInstance: mockRegistrarApiAccessor); + + expect(() async => await validateOtpTask.run(params), + throwsA(predicate((e) => e is MaximumAtsignQuotaException))); + }); + }); +} diff --git a/packages/at_register/test/at_register_allow_retry_true_test.dart b/packages/at_register/test/at_register_allow_retry_true_test.dart new file mode 100644 index 00000000..60b865a4 --- /dev/null +++ b/packages/at_register/test/at_register_allow_retry_true_test.dart @@ -0,0 +1,262 @@ +import 'package:at_register/at_register.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockRegistrarApiAccessor extends Mock implements RegistrarApiAccessor {} + +/// This test file validates the behaviour of implementations of [RegisterTask] +/// with optional param of [RegisterTask.run] 'allowRetry' set to true. +/// +/// Expected behaviour with this param set to true is that the task handles the +/// exceptions and returns a valid [RegisterTaskResult] object +void main() { + MockRegistrarApiAccessor mockRegistrarApiAccessor = + MockRegistrarApiAccessor(); + + group('A group of tests to validate GetFreeAtsign', () { + setUp(() => resetMocktailState()); + + test('validate behaviour of GetFreeAtsign', () async { + when(() => mockRegistrarApiAccessor.getFreeAtSign()) + .thenAnswer((invocation) => Future.value('@alice')); + + RegisterParams params = RegisterParams()..email = 'abcd@gmail.com'; + GetFreeAtsign getFreeAtsign = GetFreeAtsign( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + final result = await getFreeAtsign.run(params); + expect(result.data[RegistrarConstants.atsignName], '@alice'); + }); + + test('validate behaviour of GetFreeAtsign - encounters exception', + () async { + String testExceptionMessage = 'random exception'; + when(() => mockRegistrarApiAccessor.getFreeAtSign()) + .thenThrow(Exception(testExceptionMessage)); + + RegisterParams params = RegisterParams()..email = 'abcd@gmail.com'; + GetFreeAtsign getFreeAtsignTask = GetFreeAtsign( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + RegisterTaskResult? result = await getFreeAtsignTask.run(params); + + expect(result.apiCallStatus, ApiCallStatus.retry); + expect(result.exception!.toString().contains(testExceptionMessage), true); + }); + }); + + group('Group of tests to validate behaviour of RegisterAtsign', () { + setUp(() => resetMocktailState()); + + test('RegisterAtsign params reading and updating - positive case', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenAnswer((_) => Future.value(true)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + RegisterTaskResult result = await registerAtsignTask.run(params); + expect(result.apiCallStatus, ApiCallStatus.success); + expect(result.data['otpSent'], true); + }); + + test('RegisterAtsign params reading and updating - negative case', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenAnswer((_) => Future.value(false)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + RegisterTaskResult result = await registerAtsignTask.run(params); + + expect(result.apiCallStatus, ApiCallStatus.retry); + expect(result.data['otpSent'], false); /// ToDo: discuss + }); + + test('verify behaviour of RegisterAtsign processing an exception', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + String testException = 'another random exception'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenThrow(Exception(testException)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + RegisterTaskResult? result = await registerAtsignTask.run(params); + + expect(registerAtsignTask.shouldRetry(), true); + expect(result.exception!.toString().contains(testException), true); + }); + + test( + 'verify behaviour of RegistrationFlow processing exception in RegisterAtsign', + () async { + String atsign = '@bobby'; + String email = 'second-group@email'; + String testExceptionMessage = 'another new random exception'; + when(() => mockRegistrarApiAccessor.registerAtSign(atsign, email)) + .thenThrow(Exception(testExceptionMessage)); + + RegisterParams params = RegisterParams() + ..atsign = atsign + ..email = email; + RegisterAtsign registerAtsignTask = RegisterAtsign( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + + var result = await registerAtsignTask.run(params); + expect(result.exception!.toString().contains(testExceptionMessage), true); + expect(registerAtsignTask.retryCount, 1); + }); + }); + + group('A group of tests to verify ValidateOtp task behaviour', () { + setUp(() => resetMocktailState()); + + test( + 'validate positive behaviour of ValidateOtp task - received cram in first call', + () async { + String atsign = '@charlie'; + String email = 'third-group@email'; + String otp = 'Abcd'; + String cram = 'craaaaaaaaaaaam'; + ValidateOtpResult validateOtpResult = ValidateOtpResult(); + validateOtpResult.taskStatus = ValidateOtpStatus.verified; + validateOtpResult.apiCallStatus = ApiCallStatus.success; + validateOtpResult.data = { + RegistrarConstants.cramKeyName: '$atsign:$cram' + }; + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + ValidateOtp validateOtpTask = ValidateOtp( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + RegisterTaskResult result = await validateOtpTask.run(params); + + expect(result.data[RegistrarConstants.cramKeyName], cram); + }); + + test( + 'validate positive behaviour of ValidateOtp task - need to followUp with confirmation set to true', + () async { + String atsign = '@charlie123'; + String atsign2 = '@cheesecake'; + String email = 'third-group@email'; + String otp = 'bcde'; + String cram = 'craaaaaaaaaaaam1234'; + + var mockApiRespData = { + 'atsign': ['@old-atsign'], + 'newAtsign': atsign + }; + ValidateOtpResult validateOtpResult = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.followUp + ..apiCallStatus = ApiCallStatus.success + ..data = {'data': mockApiRespData}; + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + ValidateOtpResult validateOtpResult2 = ValidateOtpResult() + ..taskStatus = ValidateOtpStatus.verified + ..apiCallStatus = ApiCallStatus.success + ..data = {RegistrarConstants.cramKeyName: '$atsign:$cram'}; + when(() => mockRegistrarApiAccessor.validateOtp(atsign2, email, otp, + confirmation: true)) + .thenAnswer((invocation) => Future.value(validateOtpResult2)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false // confirmation needs to be false for first call + ..email = email + ..otp = otp; + + ValidateOtp validateOtpTask = ValidateOtp( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + RegisterTaskResult result = await validateOtpTask.run(params); + expect(params.confirmation, + true); // confirmation set to true by RegisterTask + expect(result.apiCallStatus, ApiCallStatus.retry); + print(result.data); + + // The above case is when an email has already existing atsigns, select an atsign + // from the list and retry the task with confirmation set to 'true' + params.atsign = atsign2; + result = await validateOtpTask.run(params); + expect(result.apiCallStatus, ApiCallStatus.success); + expect(result.data[RegistrarConstants.cramKeyName], cram); + }); + + test('validate behaviour of ValidateOtp task - invalid OTP', () async { + String atsign = '@charlie-otp-retry'; + String email = 'third-group-test-3@email'; + String otp = 'bcaa'; + String cram = 'craaaaaaaaaaaam'; + + ValidateOtpResult validateOtpResult = ValidateOtpResult(); + validateOtpResult.taskStatus = ValidateOtpStatus.retry; + validateOtpResult.apiCallStatus = ApiCallStatus.success; + validateOtpResult.data = { + RegistrarConstants.cramKeyName: '$atsign:$cram' + }; + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: any(named: "confirmation"))) + .thenAnswer((invocation) => Future.value(validateOtpResult)); + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + ValidateOtp validateOtpTask = ValidateOtp( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + + RegisterTaskResult result = await validateOtpTask.run(params); + expect(result.apiCallStatus, ApiCallStatus.retry); + }); + + test( + 'validate behaviour of ValidateOtp task - maximum free atsign limit reached', + () async { + String atsign = '@charlie-otp-incorrect'; + String email = 'third-group-test-3-3@email'; + String otp = 'otpp'; + + var params = RegisterParams() + ..atsign = atsign + ..confirmation = false + ..email = email + ..otp = otp; + + when(() => mockRegistrarApiAccessor.validateOtp(atsign, email, otp, + confirmation: false)) + .thenThrow( + MaximumAtsignQuotaException('maximum free atsign limit reached')); + + ValidateOtp validateOtpTask = ValidateOtp( + apiAccessorInstance: mockRegistrarApiAccessor, allowRetry: true); + + expect(() async => await validateOtpTask.run(params), + throwsA(predicate((e) => e is MaximumAtsignQuotaException))); + }); + }); +} diff --git a/tests/at_onboarding_cli_functional_tests/pubspec.yaml b/tests/at_onboarding_cli_functional_tests/pubspec.yaml index 077ad73f..bea5480a 100644 --- a/tests/at_onboarding_cli_functional_tests/pubspec.yaml +++ b/tests/at_onboarding_cli_functional_tests/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: at_onboarding_cli: path: ../../packages/at_onboarding_cli + at_register: + path: ../../packages/at_register dev_dependencies: lints: ^1.0.0