From af1033429cd3175ad4676c229688dacf8a95d4b4 Mon Sep 17 00:00:00 2001 From: Sitaram Kalluri Date: Thu, 5 Oct 2023 16:11:21 +0530 Subject: [PATCH] feat: Implement OTP Store and OTP expiry --- .../at_secondary_server/config/config.yaml | 8 +- .../lib/src/server/at_secondary_config.dart | 16 ++++ .../lib/src/store/otp_store.dart | 73 +++++++++++++++ .../src/verb/handler/enroll_verb_handler.dart | 12 ++- .../src/verb/handler/otp_verb_handler.dart | 64 ++++++++++--- packages/at_secondary_server/pubspec.yaml | 10 ++- .../test/otp_verb_test.dart | 89 ++++++++++++++++--- .../test/enroll_verb_test.dart | 30 ++++++- .../test/otp_verb_test.dart | 47 ++++++++++ 9 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 packages/at_secondary_server/lib/src/store/otp_store.dart create mode 100644 tests/at_functional_test/test/otp_verb_test.dart diff --git a/packages/at_secondary_server/config/config.yaml b/packages/at_secondary_server/config/config.yaml index 4b27f99e4..9dd8cb2f6 100644 --- a/packages/at_secondary_server/config/config.yaml +++ b/packages/at_secondary_server/config/config.yaml @@ -158,4 +158,10 @@ enrollment: # The maximum number of requests allowed within the time window. maxRequestsPerTimeFrame: 5 # The duration of the time window in hours. - timeFrameInHours: 1 \ No newline at end of file + timeFrameInHours: 1 + +# OTP Store Configurations +otp: + # The duration between each garbage collection which removes the expired OTPs from the OTPStore. + # Defaults to 60 minutes + gcDurationInMins: 60 \ No newline at end of file diff --git a/packages/at_secondary_server/lib/src/server/at_secondary_config.dart b/packages/at_secondary_server/lib/src/server/at_secondary_config.dart index e3844660a..cc8b8ee44 100644 --- a/packages/at_secondary_server/lib/src/server/at_secondary_config.dart +++ b/packages/at_secondary_server/lib/src/server/at_secondary_config.dart @@ -129,6 +129,10 @@ class AtSecondaryConfig { static const int _enrollmentExpiryInHours = 48; static int _maxEnrollRequestsAllowed = 5; + // OTP Configurations + // The duration between each garbage collection which removes the expired OTPs from the OTPStore. + static const int _otpGCDurationInMins = 60; + static final int _timeFrameInHours = 1; // For easy of testing, duration in hours is long. Hence introduced "timeFrameInMills" @@ -770,6 +774,18 @@ class AtSecondaryConfig { } } + static int get otpGCDurationInMins { + var result = _getIntEnvVar('otpGCDurationInMins'); + if (result != null) { + return result; + } + try { + return getConfigFromYaml(['otp', 'gcDurationInMins']); + } on ElementNotFoundException { + return _otpGCDurationInMins; + } + } + static set timeFrameInMills(int timeWindowInMills) { _timeFrameInMills = timeWindowInMills; } diff --git a/packages/at_secondary_server/lib/src/store/otp_store.dart b/packages/at_secondary_server/lib/src/store/otp_store.dart new file mode 100644 index 000000000..b3e93a0d8 --- /dev/null +++ b/packages/at_secondary_server/lib/src/store/otp_store.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:meta/meta.dart'; + +/// A class for managing One-Time Passwords (OTPs) using a linked hash map. +/// +/// This class allows you to store OTPs along with their expiration times and +/// periodically remove expired OTPs. +/// +/// The [gcDuration] parameter determines the duration between each garbage +/// collection cycle. Expired OTPs are removed from the store during garbage +/// collection. +class OTPStore { + /// The duration between each garbage collection. Default 60 minutes. + final Duration gcDuration; + + /// The internal store for OTPs and their expiration times. + final LinkedHashMap _otpStore = LinkedHashMap(); + + /// Creates an instance of the OTPStore class. + /// + /// The [gcDuration] parameter specifies the duration between each garbage + /// collection cycle. During garbage collection, expired OTPs are removed + /// from the store. + /// + /// By default, [gcDuration] is set to 60 minutes. + OTPStore({this.gcDuration = const Duration(minutes: 60)}) { + // Initialize a timer for periodic garbage collection. + Timer.periodic(gcDuration, (Timer t) => _removeExpiredOTPs()); + } + + /// Retrieves the duration in millisecondsSinceEpoch associated with the given [key]. + /// + /// Returns `null` if the [key] is not found in the OTP store + int? get(String key) { + return _otpStore[key]; + } + + /// Sets the OTP associated with the given [key] along with its expiration time. + /// + /// The [key] is typically the OTP value itself, and [expiryDurationSinceEpoch] + /// represents the expiration time for the OTP as a duration since the epoch. + void set(String key, int expiryDurationSinceEpoch) { + _otpStore[key] = expiryDurationSinceEpoch; + } + + /// Removes the OTP associated with the given [key] from the store. + /// + /// If the [key] is not found in the OTP store, this method has no effect. + void remove(String key) { + _otpStore.remove(key); + } + + /// Removes expired OTPs from the store. + /// + /// This method is automatically called at regular intervals based on the + /// [gcDuration] specified during object creation. + void _removeExpiredOTPs() { + _otpStore.removeWhere( + (otp, expiryInMills) => + expiryInMills <= DateTime + .now() + .toUtc() + .millisecondsSinceEpoch + ); + } + + @visibleForTesting + int size() { + return _otpStore.length; + } +} diff --git a/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart b/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart index 31b43f930..f8ce3013a 100644 --- a/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart +++ b/packages/at_secondary_server/lib/src/verb/handler/enroll_verb_handler.dart @@ -116,14 +116,12 @@ class EnrollVerbHandler extends AbstractVerbHandler { throw AtThrottleLimitExceeded( 'Enrollment requests have exceeded the limit within the specified time frame'); } - if (!atConnection.getMetaData().isAuthenticated) { - var otp = enrollParams.otp; - if (otp == null || - (await OtpVerbHandler.cache.get(otp.toString()) == null)) { - throw AtEnrollmentException( - 'invalid otp. Cannot process enroll request'); - } + + if ((!atConnection.getMetaData().isAuthenticated) && + (!OtpVerbHandler.isValidOTP(enrollParams.otp))) { + throw AtEnrollmentException('invalid otp. Cannot process enroll request'); } + var enrollNamespaces = enrollParams.namespaces ?? {}; var newEnrollmentId = Uuid().v4(); var key = diff --git a/packages/at_secondary_server/lib/src/verb/handler/otp_verb_handler.dart b/packages/at_secondary_server/lib/src/verb/handler/otp_verb_handler.dart index ac8839cb7..8545b3f39 100644 --- a/packages/at_secondary_server/lib/src/verb/handler/otp_verb_handler.dart +++ b/packages/at_secondary_server/lib/src/verb/handler/otp_verb_handler.dart @@ -1,24 +1,31 @@ import 'dart:collection'; import 'dart:math'; import 'package:at_commons/at_commons.dart'; +import 'package:at_secondary/src/server/at_secondary_config.dart'; +import 'package:at_secondary/src/store/otp_store.dart'; import 'package:at_server_spec/at_server_spec.dart'; +import 'package:meta/meta.dart'; import 'package:uuid/uuid.dart'; import 'abstract_verb_handler.dart'; import 'package:at_server_spec/at_verb_spec.dart'; import 'package:at_persistence_secondary_server/at_persistence_secondary_server.dart'; -import 'package:expire_cache/expire_cache.dart'; class OtpVerbHandler extends AbstractVerbHandler { static Otp otpVerb = Otp(); - static final expireDuration = Duration(seconds: 90); - static ExpireCache cache = - ExpireCache(expireDuration: expireDuration); - OtpVerbHandler(SecondaryKeyStore keyStore) : super(keyStore); + @visibleForTesting + Duration otpExpiryDuration = Duration(minutes: 5); + + static late OTPStore _otpStore; + + OtpVerbHandler(SecondaryKeyStore keyStore, {Duration? gcDuration}) + : super(keyStore) { + gcDuration ??= Duration(minutes: AtSecondaryConfig.otpGCDurationInMins); + _otpStore = OTPStore(gcDuration: gcDuration); + } @override - bool accept(String command) => - command == 'otp:get' || command.startsWith('otp:validate'); + bool accept(String command) => command.startsWith('otp'); @override Verb getVerb() => otpVerb; @@ -29,6 +36,10 @@ class OtpVerbHandler extends AbstractVerbHandler { HashMap verbParams, InboundConnection atConnection) async { final operation = verbParams['operation']; + if (verbParams[AtConstants.ttl] != null) { + otpExpiryDuration = + Duration(seconds: int.parse(verbParams[AtConstants.ttl]!)); + } switch (operation) { case 'get': if (!atConnection.getMetaData().isAuthenticated) { @@ -40,17 +51,36 @@ class OtpVerbHandler extends AbstractVerbHandler { } // If OTP generated do not have digits, generate again. while (RegExp(r'\d').hasMatch(response.data!) == false); - await cache.set(response.data!, response.data!); + _otpStore.set( + response.data!, + DateTime.now() + .toUtc() + .add(otpExpiryDuration) + .millisecondsSinceEpoch); break; case 'validate': String? otp = verbParams['otp']; - if (otp != null && (await cache.get(otp)) == otp) { + bool isValid = isValidOTP(otp); + if (isValid) { response.data = 'valid'; - } else { - response.data = 'invalid'; + return; } - break; + response.data = 'invalid'; + } + } + + static bool isValidOTP(String? otp) { + if (otp == null) { + return false; + } + int? otpExpiry = _otpStore.get(otp); + // Remove the OTP from the OTPStore to prevent reuse of OTP. + _otpStore.remove(otp); + if (otpExpiry != null && + otpExpiry >= DateTime.now().toUtc().millisecondsSinceEpoch) { + return true; } + return false; } /// This function generates a UUID and converts it into a 6-character alpha-numeric string. @@ -100,4 +130,14 @@ class OtpVerbHandler extends AbstractVerbHandler { } return result; } + + /// Retrieves the number of OTPs currently stored in the OTPStore. + /// + /// This method is intended for unit testing purposes to access the size of + /// the OTPStore's internal store. + @visibleForTesting + int size() { + // ignore: invalid_use_of_visible_for_testing_member + return _otpStore.size(); + } } diff --git a/packages/at_secondary_server/pubspec.yaml b/packages/at_secondary_server/pubspec.yaml index 50fc9f8ab..b57da4944 100644 --- a/packages/at_secondary_server/pubspec.yaml +++ b/packages/at_secondary_server/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: at_server_spec: 3.0.15 at_persistence_spec: 2.0.14 at_persistence_secondary_server: 3.0.57 - expire_cache: ^2.0.1 intl: ^0.18.1 json_annotation: ^4.8.0 version: 3.0.2 @@ -35,6 +34,15 @@ dependencies: yaml: 3.1.2 logging: 1.2.0 +dependency_overrides: + at_commons: + git: + url: https://github.com/atsign-foundation/at_libraries.git + path: packages/at_commons + ref: expire_otp_changes + + + dev_dependencies: test: ^1.24.4 coverage: ^1.6.1 diff --git a/packages/at_secondary_server/test/otp_verb_test.dart b/packages/at_secondary_server/test/otp_verb_test.dart index 1f7a0276c..5ec78e61e 100644 --- a/packages/at_secondary_server/test/otp_verb_test.dart +++ b/packages/at_secondary_server/test/otp_verb_test.dart @@ -3,7 +3,6 @@ import 'dart:collection'; import 'package:at_commons/at_commons.dart'; import 'package:at_secondary/src/utils/handler_util.dart'; import 'package:at_secondary/src/verb/handler/otp_verb_handler.dart'; -import 'package:expire_cache/expire_cache.dart'; import 'package:test/test.dart'; import 'test_utils.dart'; @@ -13,19 +12,20 @@ void main() { setUp(() async { await verbTestsSetUp(); }); - test('A test to verify OTP generated is 6-character length', () { + + test('A test to verify OTP generated is 6-character length', () async { Response response = Response(); HashMap verbParams = getVerbParam(VerbSyntax.otp, 'otp:get'); inboundConnection.getMetaData().isAuthenticated = true; OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); - otpVerbHandler.processVerb(response, verbParams, inboundConnection); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); expect(response.data, isNotNull); expect(response.data!.length, 6); assert(RegExp('\\d').hasMatch(response.data!)); }); - test('A test to verify same OTP is not returned', () { + test('A test to verify same OTP is not returned', () async { Set otpSet = {}; for (int i = 1; i <= 1000; i++) { Response response = Response(); @@ -33,7 +33,8 @@ void main() { getVerbParam(VerbSyntax.otp, 'otp:get'); inboundConnection.getMetaData().isAuthenticated = true; OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); - otpVerbHandler.processVerb(response, verbParams, inboundConnection); + await otpVerbHandler.processVerb( + response, verbParams, inboundConnection); expect(response.data, isNotNull); expect(response.data!.length, 6); assert(RegExp('\\d').hasMatch(response.data!)); @@ -42,6 +43,35 @@ void main() { } expect(otpSet.length, 1000); }); + + test('A test to verify otp:get with TTL set is active before TTL is met', + () async { + Response response = Response(); + inboundConnection.getMetaData().isAuthenticated = true; + HashMap verbParams = + getVerbParam(VerbSyntax.otp, 'otp:get:ttl:1'); + OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + String? otp = response.data; + verbParams = getVerbParam(VerbSyntax.otp, 'otp:validate:$otp'); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + expect(response.data, 'valid'); + }); + + test('A test to verify otp:get with TTL set expires after the TTL is met', + () async { + Response response = Response(); + inboundConnection.getMetaData().isAuthenticated = true; + HashMap verbParams = + getVerbParam(VerbSyntax.otp, 'otp:get:ttl:1'); + OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + String? otp = response.data; + await Future.delayed(Duration(seconds: 1)); + verbParams = getVerbParam(VerbSyntax.otp, 'otp:validate:$otp'); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + expect(response.data, 'invalid'); + }); tearDown(() async => await verbTestsTearDown()); }); @@ -90,17 +120,54 @@ void main() { HashMap verbParams = getVerbParam(VerbSyntax.otp, 'otp:get'); inboundConnection.getMetaData().isAuthenticated = true; - OtpVerbHandler.cache = - ExpireCache(expireDuration: Duration(microseconds: 1)); OtpVerbHandler otpVerbHandler = OtpVerbHandler(secondaryKeyStore); - print(OtpVerbHandler.cache.expireDuration.inMicroseconds); - await Future.delayed(Duration(microseconds: 2)); + otpVerbHandler.otpExpiryDuration = Duration(microseconds: 1); + await Future.delayed(Duration(microseconds: 3)); + String? otp = response.data; + response = Response(); await otpVerbHandler.processVerb(response, verbParams, inboundConnection); - verbParams = - getVerbParam(VerbSyntax.otp, 'otp:validate:${response.data}'); + verbParams = getVerbParam(VerbSyntax.otp, 'otp:validate:$otp'); await otpVerbHandler.processVerb(response, verbParams, inboundConnection); expect(response.data, 'invalid'); }); tearDown(() async => await verbTestsTearDown()); }); + + group('A group of tests related to gc on otp store', () { + setUp(() async { + await verbTestsSetUp(); + }); + + test('A test to verify gc removes the expired keys from the otp store', + () async { + Response response = Response(); + inboundConnection.getMetaData().isAuthenticated = true; + + OtpVerbHandler otpVerbHandler = + OtpVerbHandler(secondaryKeyStore, gcDuration: Duration(seconds: 2)); + + HashMap verbParams = + getVerbParam(VerbSyntax.otp, 'otp:get:ttl:1'); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + String? firstOTP = response.data; + expect(firstOTP, isNotEmpty); + + verbParams = getVerbParam(VerbSyntax.otp, 'otp:get:ttl:30'); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + String? secondOTP = response.data; + expect(secondOTP, isNotEmpty); + + await Future.delayed(Duration(seconds: 2)); + expect(otpVerbHandler.size(), 1); + + verbParams = getVerbParam(VerbSyntax.otp, 'otp:validate:$firstOTP'); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + expect(response.data, 'invalid'); + + verbParams = getVerbParam(VerbSyntax.otp, 'otp:validate:$secondOTP'); + await otpVerbHandler.processVerb(response, verbParams, inboundConnection); + expect(response.data, 'valid'); + }); + tearDown(() async => await verbTestsTearDown()); + }); } diff --git a/tests/at_functional_test/test/enroll_verb_test.dart b/tests/at_functional_test/test/enroll_verb_test.dart index afa4e56db..062c91ae3 100644 --- a/tests/at_functional_test/test/enroll_verb_test.dart +++ b/tests/at_functional_test/test/enroll_verb_test.dart @@ -764,9 +764,6 @@ void main() { socketConnection1!, 'config:set:timeFrameInMills=100\n'); configResponse = await read(); expect(configResponse.trim(), 'data:ok'); - await socket_writer(socketConnection1!, 'otp:get'); - otp = await read(); - otp = otp.replaceAll('data:', '').trim(); }); test( @@ -775,6 +772,9 @@ void main() { SecureSocket unAuthenticatedConnection = await secure_socket_connection(firstAtsignServer, firstAtsignPort); socket_listener(unAuthenticatedConnection); + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); var enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(unAuthenticatedConnection, enrollRequest); @@ -782,6 +782,10 @@ void main() { jsonDecode((await read()).replaceAll('data:', '')); expect(enrollmentResponse['status'], 'pending'); expect(enrollmentResponse['enrollmentId'], isNotNull); + + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(unAuthenticatedConnection, enrollRequest); @@ -798,6 +802,10 @@ void main() { SecureSocket unAuthenticatedConnection = await secure_socket_connection(firstAtsignServer, firstAtsignPort); socket_listener(unAuthenticatedConnection); + + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); var enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(unAuthenticatedConnection, enrollRequest); @@ -805,6 +813,10 @@ void main() { jsonDecode((await read()).replaceAll('data:', '')); expect(enrollmentResponse['status'], 'pending'); expect(enrollmentResponse['enrollmentId'], isNotNull); + + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(unAuthenticatedConnection, enrollRequest); @@ -825,6 +837,10 @@ void main() { SecureSocket unAuthenticatedConnection = await secure_socket_connection(firstAtsignServer, firstAtsignPort); socket_listener(unAuthenticatedConnection); + + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); var enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(unAuthenticatedConnection, enrollRequest); @@ -832,6 +848,10 @@ void main() { jsonDecode((await read()).replaceAll('data:', '')); expect(enrollmentResponse['status'], 'pending'); expect(enrollmentResponse['enrollmentId'], isNotNull); + + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(unAuthenticatedConnection, enrollRequest); @@ -844,6 +864,10 @@ void main() { SecureSocket secondUnAuthenticatedConnection2 = await secure_socket_connection(firstAtsignServer, firstAtsignPort); socket_listener(secondUnAuthenticatedConnection2); + + await socket_writer(socketConnection1!, 'otp:get'); + otp = await read(); + otp = otp.replaceAll('data:', '').trim(); enrollRequest = 'enroll:request:{"appName":"wavi","deviceName":"pixel","namespaces":{"wavi":"rw"},"otp":"$otp","apkamPublicKey":"${pkamPublicKeyMap[firstAtsign]!}"}\n'; await socket_writer(secondUnAuthenticatedConnection2, enrollRequest); diff --git a/tests/at_functional_test/test/otp_verb_test.dart b/tests/at_functional_test/test/otp_verb_test.dart new file mode 100644 index 000000000..cf1236198 --- /dev/null +++ b/tests/at_functional_test/test/otp_verb_test.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:at_functional_test/conf/config_util.dart'; +import 'package:test/test.dart'; + +import 'functional_test_commons.dart'; + +void main() { + late SecureSocket authenticatedConnection; + String firstAtSignServer = + ConfigUtil.getYaml()!['first_atsign_server']['first_atsign_url']; + int firstAtSignPort = + ConfigUtil.getYaml()!['first_atsign_server']['first_atsign_port']; + String firstAtSign = + ConfigUtil.getYaml()!['first_atsign_server']['first_atsign_name']; + + setUp(() async { + authenticatedConnection = + await secure_socket_connection(firstAtSignServer, firstAtSignPort); + socket_listener(authenticatedConnection); + }); + + group('A group of tests related to OTP generation and expiration', () { + test('A test to generate OTP and returns valid before OTP is not expired', () async { + await prepare(authenticatedConnection, firstAtSign); + await socket_writer(authenticatedConnection, 'otp:get:ttl:5'); + String otp = (await read()).trim().replaceAll('data:', ''); + expect(otp, isNotEmpty); + + await socket_writer(authenticatedConnection, 'otp:validate:$otp'); + String response = (await read()).replaceAll('data:', '').trim(); + expect(response, 'valid'); + }); + + test('A test to generate OTP and returns invalid when TTL is met', () async { + await prepare(authenticatedConnection, firstAtSign); + await socket_writer(authenticatedConnection, 'otp:get:ttl:1'); + String otp = (await read()).trim().replaceAll('data:', ''); + expect(otp, isNotEmpty); + + await Future.delayed(Duration(seconds: 1)); + await socket_writer(authenticatedConnection, 'otp:validate:$otp'); + String response = (await read()).replaceAll('data:', '').trim(); + expect(response, 'invalid'); + }); + }); +}