From 7934bf2b01bd81fd210d5fd37837ea5b16f2a07e Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Wed, 22 Nov 2023 19:49:29 -0500 Subject: [PATCH] test: SshnpdChannel --- .../util/sshnpd_channel/sshnpd_channel.dart | 10 +- .../sshnpd_channel/sshnpd_channel_mocks.dart | 57 +++ .../sshnpd_channel/sshnpd_channel_test.dart | 428 ++++++++++++++++++ 3 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_mocks.dart create mode 100644 packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_test.dart diff --git a/packages/noports_core/lib/src/sshnp/util/sshnpd_channel/sshnpd_channel.dart b/packages/noports_core/lib/src/sshnp/util/sshnpd_channel/sshnpd_channel.dart index 651528cf4..f174514ed 100644 --- a/packages/noports_core/lib/src/sshnp/util/sshnpd_channel/sshnpd_channel.dart +++ b/packages/noports_core/lib/src/sshnp/util/sshnpd_channel/sshnpd_channel.dart @@ -57,11 +57,12 @@ abstract class SshnpdChannel with AsyncInitialization, AtClientBindings { subscribe( regex: regex, shouldDecrypt: true, - ).listen(_handleSshnpdResponses); + ).listen(handleSshnpdResponses); } /// Main reponse handler for the daemon's notifications. - Future _handleSshnpdResponses(AtNotification notification) async { + @visibleForTesting + Future handleSshnpdResponses(AtNotification notification) async { String notificationKey = notification.key .replaceAll('${notification.to}:', '') .replaceAll('.$namespace@${notification.from}', '') @@ -183,7 +184,7 @@ abstract class SshnpdChannel with AsyncInitialization, AtClientBindings { 'device_info\\.$sshnpDeviceNameRegex\\.${DefaultArgs.namespace}'; var atKeys = - await _getAtKeysRemote(regex: scanRegex, sharedBy: params.sshnpdAtSign); + await getAtKeysRemote(regex: scanRegex, sharedBy: params.sshnpdAtSign); SshnpDeviceList deviceList = SshnpDeviceList(); @@ -234,7 +235,8 @@ abstract class SshnpdChannel with AsyncInitialization, AtClientBindings { } /// A custom implementation of AtClient.getAtKeys which bypasses the cache - Future> _getAtKeysRemote( + @visibleForTesting + Future> getAtKeysRemote( {String? regex, String? sharedBy, String? sharedWith, diff --git a/packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_mocks.dart b/packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_mocks.dart new file mode 100644 index 000000000..1df6554ea --- /dev/null +++ b/packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_mocks.dart @@ -0,0 +1,57 @@ +import 'package:at_client/at_client.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:noports_core/sshnp_foundation.dart'; + +abstract class HandleSshnpdPayloadCaller { + Future call(AtNotification notification); +} + +class HandleSshnpdPayloadStub extends Mock + implements HandleSshnpdPayloadCaller {} + +class StubbedSshnpdChannel extends SshnpdChannel { + final Future Function(AtKey, String)? _notify; + final Stream Function({String? regex, bool shouldDecrypt})? + _subscribe; + final Future Function(AtNotification notification)? + _handleSshnpdPayload; + + StubbedSshnpdChannel({ + required super.atClient, + required super.params, + required super.sessionId, + required super.namespace, + Future Function(AtKey, String)? notify, + Stream Function({String? regex, bool shouldDecrypt})? + subscribe, + Future Function(AtNotification notification)? + handleSshnpdPayload, + }) : _notify = notify, + _subscribe = subscribe, + _handleSshnpdPayload = handleSshnpdPayload; + + @override + Future handleSshnpdPayload(AtNotification notification) async { + return await _handleSshnpdPayload?.call(notification) ?? + SshnpdAck.notAcknowledged; + } + + @override + Future notify( + AtKey atKey, + String value, + ) async { + return _notify?.call(atKey, value); + } + + @override + Stream subscribe({ + String? regex, + bool shouldDecrypt = false, + }) { + return _subscribe?.call(regex: regex, shouldDecrypt: shouldDecrypt) ?? + Stream.empty(); + } +} + +class MockRemoteSecondary extends Mock implements RemoteSecondary {} diff --git a/packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_test.dart b/packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_test.dart new file mode 100644 index 000000000..595e4a17b --- /dev/null +++ b/packages/noports_core/test/sshnp/util/sshnpd_channel/sshnpd_channel_test.dart @@ -0,0 +1,428 @@ +import 'dart:async'; + +import 'package:at_client/at_client.dart'; +import 'package:at_commons/at_builders.dart'; +import 'package:noports_core/sshnp_foundation.dart'; +import 'package:test/test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:uuid/uuid.dart'; + +import '../../sshnp_core_constants.dart'; +import '../../sshnp_mocks.dart'; +import '../sshnp_ssh_key_handler/sshnp_ssh_key_handler_mocks.dart'; +import 'sshnpd_channel_mocks.dart'; + +void main() { + group('SshnpdChannel', () { + late MockAtClient mockAtClient; + late MockSshnpParams mockParams; + late String sessionId; + late String namespace; + late StreamController notificationStreamController; + late NotifyStub notifyStub; + late SubscribeStub subscribeStub; + late HandleSshnpdPayloadStub payloadStub; + late StubbedSshnpdChannel stubbedSshnpdChannel; + + // Invocation patterns as closures so they can be referred to by name + // instead of explicitly writing these calls several times in the test + notifyInvocation() => notifyStub(any(), any()); + subscribeInvocation() => subscribeStub( + regex: any(named: 'regex'), + shouldDecrypt: any(named: 'shouldDecrypt'), + ); + payloadInvocation() => payloadStub(any()); + String device = 'myDevice'; + + setUp(() { + mockAtClient = MockAtClient(); + mockParams = MockSshnpParams(); + sessionId = Uuid().v4(); + notificationStreamController = StreamController(); + notifyStub = NotifyStub(); + subscribeStub = SubscribeStub(); + payloadStub = HandleSshnpdPayloadStub(); + + when(() => mockParams.device).thenReturn(device); + namespace = '$device.sshnp'; + + stubbedSshnpdChannel = StubbedSshnpdChannel( + atClient: mockAtClient, + params: mockParams, + sessionId: sessionId, + namespace: namespace, + notify: notifyStub, + subscribe: subscribeStub, + handleSshnpdPayload: payloadStub, + ); + + registerFallbackValue(AtKey()); + registerFallbackValue(AtNotification.empty()); + }); + + test('public API', () { + expect(stubbedSshnpdChannel.atClient, mockAtClient); + expect(stubbedSshnpdChannel.params, mockParams); + expect(stubbedSshnpdChannel.sessionId, sessionId); + expect(stubbedSshnpdChannel.namespace, namespace); + }); // test public API + + whenInitialization() { + when(() => mockParams.sshnpdAtSign).thenReturn('@sshnpd'); + when(subscribeInvocation) + .thenAnswer((_) => notificationStreamController.stream); + } + + test('Initialization', () async { + whenInitialization(); + expect(stubbedSshnpdChannel.sshnpdAck, SshnpdAck.notAcknowledged); + expect(stubbedSshnpdChannel.initializeStarted, false); + + verifyNever(subscribeInvocation); + + // it's okay to call this directly for testing purposes + await expectLater(stubbedSshnpdChannel.initialize(), completes); + + verify( + () => subscribeStub( + regex: '$sessionId.$namespace@sshnpd', + shouldDecrypt: true, + ), + ).called(1); + }); // test Initialization + + group('handleSshnpdResponses', () { + test('handleSshnpdResponses', () async { + whenInitialization(); + await expectLater(stubbedSshnpdChannel.initialize(), completes); + when(payloadInvocation).thenAnswer((_) async => SshnpdAck.acknowledged); + + Future ack = stubbedSshnpdChannel.waitForDaemonResponse(); + + // manually add a notification to the stream + final String notificationId = Uuid().v4(); + notificationStreamController.add( + AtNotification.empty() + ..id = notificationId + ..to = '@client' + ..from = '@sshnpd' + ..key = '$sessionId.$namespace@sshnpd', + ); + + await expectLater(ack, completes); + + verify( + () => payloadStub( + any( + that: predicate( + (AtNotification notification) => + notification.id == notificationId, + ), + ), + ), + ).called(1); + + expect(stubbedSshnpdChannel.sshnpdAck, SshnpdAck.acknowledged); + }); // test handleSshnpdResponses + + test('handleSshnpdResponses - acknowledged with errors', () async { + whenInitialization(); + await expectLater(stubbedSshnpdChannel.initialize(), completes); + when(payloadInvocation) + .thenAnswer((_) async => SshnpdAck.acknowledgedWithErrors); + + Future ack = stubbedSshnpdChannel.waitForDaemonResponse(); + + // manually add a notification to the stream + final String notificationId = Uuid().v4(); + notificationStreamController.add( + AtNotification.empty() + ..id = notificationId + ..to = '@client' + ..from = '@sshnpd' + ..key = '$sessionId.$namespace@sshnpd', + ); + + await expectLater(ack, completes); + + verify( + () => payloadStub( + any( + that: predicate( + (AtNotification notification) => + notification.id == notificationId, + ), + ), + ), + ).called(1); + + expect( + stubbedSshnpdChannel.sshnpdAck, SshnpdAck.acknowledgedWithErrors); + }); // test handleSshnpdResponses - acknowledged with errors + + test('handleSshnpdResponses - not acknowledged', () async { + whenInitialization(); + await expectLater(stubbedSshnpdChannel.initialize(), completes); + when(payloadInvocation) + .thenAnswer((_) async => SshnpdAck.notAcknowledged); + + Future ack = stubbedSshnpdChannel.waitForDaemonResponse(); + + // manually add a notification to the stream + final String notificationId = Uuid().v4(); + notificationStreamController.add( + AtNotification.empty() + ..id = notificationId + ..to = '@client' + ..from = '@sshnpd' + ..key = '$sessionId.$namespace@sshnpd', + ); + + await expectLater(ack, completes); + + verify( + () => payloadStub( + any( + that: predicate( + (AtNotification notification) => + notification.id == notificationId, + ), + ), + ), + ).called(1); + + expect(stubbedSshnpdChannel.sshnpdAck, SshnpdAck.notAcknowledged); + }); // test handleSshnpdResponses - not acknowledged + }); // group handleSshnpdResponses + + group('sharePublicKeyIfRequired', () { + test('sharePublicKeyIfRequired', () async { + when(() => mockParams.sendSshPublicKey).thenReturn(true); + MockAtSshKeyPair identityKeyPair = MockAtSshKeyPair(); + + when(() => identityKeyPair.publicKeyContents) + .thenReturn(TestingKeyPair.public); + + when(() => mockParams.clientAtSign).thenReturn('@client'); + when(() => mockParams.sshnpdAtSign).thenReturn('@sshnpd'); + + when( + () => notifyStub( + any( + that: predicate((AtKey key) => key.key == 'sshpublickey')), + any(), + ), + ).thenAnswer((_) async {}); + + verifyNever(notifyInvocation); + + await expectLater( + stubbedSshnpdChannel.sharePublicKeyIfRequired(identityKeyPair), + completes, + ); + + verify( + () => notifyStub( + any( + that: predicate((AtKey key) => key.key == 'sshpublickey')), + TestingKeyPair.public, + ), + ).called(1); + }); // test sharePublicKeyIfRequired + + test('sharePublicKeyIfRequired - sendSshPublicKey = false', () async { + when(() => mockParams.sendSshPublicKey).thenReturn(false); + MockAtSshKeyPair identityKeyPair = MockAtSshKeyPair(); + + when(() => identityKeyPair.publicKeyContents) + .thenReturn(TestingKeyPair.public); + + verifyNever(notifyInvocation); + + await expectLater( + stubbedSshnpdChannel.sharePublicKeyIfRequired(identityKeyPair), + completes, + ); + + verifyNever(notifyInvocation); + }); // test sharePublicKeyIfRequired - sendSshPublicKey = false + + test('sharePublicKeyIfRequired - identityKeyPair = null', () async { + when(() => mockParams.sendSshPublicKey).thenReturn(true); + + verifyNever(notifyInvocation); + + await expectLater( + stubbedSshnpdChannel.sharePublicKeyIfRequired(null), + completes, + ); + + verifyNever(notifyInvocation); + }); // test sharePublicKeyIfRequired - sendSshPublicKey = false + + test('sharePublicKeyIfRequired - malformed public key contents', + () async { + when(() => mockParams.sendSshPublicKey).thenReturn(true); + MockAtSshKeyPair identityKeyPair = MockAtSshKeyPair(); + + when(() => identityKeyPair.publicKeyContents) + .thenReturn("I'm not an ssh public key!"); + + verifyNever(notifyInvocation); + + await expectLater( + stubbedSshnpdChannel.sharePublicKeyIfRequired(identityKeyPair), + throwsA(isA()), + ); + + verifyNever(notifyInvocation); + }); // test sharePublicKeyIfRequired - malformed public key contents + }); // group sharePublicKeyIfRequired + + group('Username resolution', () { + test('resolveRemoteUsername - params.remoteUsername override', () async { + when(() => mockParams.remoteUsername).thenReturn('myRemoteUsername'); + Future remoteUsername = + stubbedSshnpdChannel.resolveRemoteUsername(); + await expectLater(remoteUsername, completes); + expect(await remoteUsername, 'myRemoteUsername'); + }); // test resolveRemoteUsername + + test('resolveRemoteUsername - params.remoteUsername null', () async { + when(() => mockParams.remoteUsername).thenReturn(null); + when(() => mockParams.clientAtSign).thenReturn('@client'); + when(() => mockParams.sshnpdAtSign).thenReturn('@sshnpd'); + + when( + () => mockAtClient.get( + any( + that: predicate( + (AtKey key) => key.key?.startsWith('username.') ?? false), + ), + ), + ).thenAnswer((i) async => AtValue()..value = 'mySharedUsername'); + + Future remoteUsername = + stubbedSshnpdChannel.resolveRemoteUsername(); + await expectLater(remoteUsername, completes); + expect(await remoteUsername, 'mySharedUsername'); + }); // test resolveRemoteUsername + + test('resolveTunnelUsername - params.tunnelUsername override', () async { + when(() => mockParams.tunnelUsername).thenReturn('myTunnelUsername'); + Future tunnelUsername = + stubbedSshnpdChannel.resolveTunnelUsername(remoteUsername: null); + + await expectLater(tunnelUsername, completes); + expect(await tunnelUsername, 'myTunnelUsername'); + }); // test resolveTunnelUsername - params.tunnelUsername override + + test( + 'resolveTunnelUsername - params.tunnelUsername override, remoteUsername string', + () async { + when(() => mockParams.tunnelUsername).thenReturn('myTunnelUsername2'); + Future tunnelUsername = stubbedSshnpdChannel + .resolveTunnelUsername(remoteUsername: 'remoteUsername'); + + await expectLater(tunnelUsername, completes); + expect(await tunnelUsername, 'myTunnelUsername2'); + }); // test resolveTunnelUsername - params.tunnelUsername override, remoteUsername string + + test('resolveTunnelUsername - params.tunnelUsername null', () async { + when(() => mockParams.tunnelUsername).thenReturn(null); + Future tunnelUsername = stubbedSshnpdChannel + .resolveTunnelUsername(remoteUsername: 'fallbackUsername'); + + await expectLater(tunnelUsername, completes); + expect(await tunnelUsername, 'fallbackUsername'); + }); // test resolveTunnelUsername - params.tunnelUsername null + + test('resolveTunnelUsername - both usernames null', () async { + when(() => mockParams.tunnelUsername).thenReturn(null); + Future tunnelUsername = + stubbedSshnpdChannel.resolveTunnelUsername(remoteUsername: null); + + await expectLater(tunnelUsername, completes); + expect(await tunnelUsername, null); + }); // resolveTunnelUsername - both usernames null + }); // group Username resolution + group('Device List', () { + // TODO + }); // group Device List + test('getAtKeysRemote', () async { + final remoteSecondary = MockRemoteSecondary(); + registerFallbackValue(ScanVerbBuilder()); + + final sharedWith = 'mySharedWith'; + final sharedBy = 'mySharedBy'; + final regex = 'myRegex'; + final showHiddenKeys = true; + + when(() => mockAtClient.getRemoteSecondary()).thenReturn(remoteSecondary); + when( + () => remoteSecondary.executeVerb( + any( + that: allOf( + isA(), + predicate( + (ScanVerbBuilder builder) => + builder.sharedWith == sharedWith && + builder.sharedBy == sharedBy && + builder.regex == regex && + builder.showHiddenKeys == showHiddenKeys, + ), + ), + ), + ), + ).thenAnswer((_) async => 'data:["phone.wavi@alice"]'); + + verifyNever(() => mockAtClient.getRemoteSecondary()); + verifyNever(() => remoteSecondary.executeVerb(any())); + + Future> result = stubbedSshnpdChannel.getAtKeysRemote( + sharedWith: sharedWith, + sharedBy: sharedBy, + regex: regex, + showHiddenKeys: showHiddenKeys, + ); + + verifyInOrder([ + () => mockAtClient.getRemoteSecondary(), + () => remoteSecondary.executeVerb( + any( + that: allOf( + isA(), + predicate( + (ScanVerbBuilder builder) => + builder.sharedWith == sharedWith && + builder.sharedBy == sharedBy && + builder.regex == regex && + builder.showHiddenKeys == showHiddenKeys, + ), + ), + ), + ), + ]); + + await expectLater(result, completes); + expect( + await result, + allOf( + isA>(), + hasLength(1), + predicate( + (List list) => + list.single.key == 'phone' && + list.single.namespace == 'wavi' && + list.single.sharedBy == '@alice', + ), + ), + ); + + verifyNever(() => mockAtClient.getRemoteSecondary()); + verifyNever(() => remoteSecondary.executeVerb(any())); + + expect(mockAtClient.getRemoteSecondary(), isA()); + }); // test getAtKeysRemote + }); // group SshnpdChannel +}