| // Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'dart:io'; | |
| import 'package:dtd/dtd.dart' | |
| show CoreDtdServiceConstants, FileSystemServiceConstants, RpcErrorCodes; | |
| import 'package:dtd_impl/dtd.dart'; | |
| import 'package:json_rpc_2/json_rpc_2.dart'; | |
| import 'package:test/test.dart'; | |
| import 'package:web_socket_channel/web_socket_channel.dart'; | |
| void main() { | |
| late Peer client; | |
| late DartToolingDaemon? dtd; | |
| late String uri; | |
| tearDown(() async { | |
| await dtd?.close(); | |
| }); | |
| group('auth tokens', () { | |
| test('forbids connections where the URI auth code is invalid', () async { | |
| dtd = await DartToolingDaemon.startService([]); | |
| expect(dtd!.uri!.path, isNotEmpty); // Has code. | |
| expect( | |
| () async => await WebSocket.connect( | |
| dtd!.uri!.replace(path: 'someInvalidCode').toString(), | |
| ), | |
| throwsA( | |
| isA<WebSocketException>().having( | |
| (e) => e.message, | |
| 'message', | |
| matches( | |
| RegExp("^Connection to '.*' was not upgraded to websocket\$"), | |
| ), | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('forbids connections where the URI auth code is missing', () async { | |
| dtd = await DartToolingDaemon.startService([]); | |
| expect( | |
| () async => | |
| await WebSocket.connect(dtd!.uri!.replace(path: '').toString()), | |
| throwsA( | |
| isA<WebSocketException>().having( | |
| (e) => e.message, | |
| 'message', | |
| matches( | |
| RegExp("^Connection to '.*' was not upgraded to websocket\$"), | |
| ), | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('allows connections with no URI auth code if started with ' | |
| '--disable-service-auth-codes', () async { | |
| dtd = await DartToolingDaemon.startService([ | |
| '--disable-service-auth-codes', | |
| ]); | |
| expect(dtd!.uri!.path, isEmpty); // No code. | |
| // Expect no exception. | |
| final ws = await WebSocket.connect(dtd!.uri!.toString()); | |
| await ws.close(); | |
| }); | |
| }); | |
| group('Host and Origin security', () { | |
| test('forbids connections with invalid Host header', () async { | |
| dtd = await DartToolingDaemon.startService([]); | |
| expect( | |
| () async => await WebSocket.connect( | |
| dtd!.uri!.toString(), | |
| headers: {HttpHeaders.hostHeader: 'evil.example.com'}, | |
| ), | |
| throwsA( | |
| isA<WebSocketException>().having( | |
| (e) => e.message, | |
| 'message', | |
| contains('was not upgraded to websocket'), | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('forbids connections with invalid Origin header', () async { | |
| dtd = await DartToolingDaemon.startService([]); | |
| expect( | |
| () async => await WebSocket.connect( | |
| dtd!.uri!.toString(), | |
| headers: {'Origin': 'http://evil.example.com'}, | |
| ), | |
| throwsA( | |
| isA<WebSocketException>().having( | |
| (e) => e.message, | |
| 'message', | |
| contains('was not upgraded to websocket'), | |
| ), | |
| ), | |
| ); | |
| }); | |
| test( | |
| 'allows connections with legitimate Host and Origin headers', | |
| () async { | |
| dtd = await DartToolingDaemon.startService([]); | |
| final ws = await WebSocket.connect( | |
| dtd!.uri!.toString(), | |
| headers: {'Origin': 'http://localhost'}, | |
| ); | |
| await ws.close(); | |
| }, | |
| ); | |
| }); | |
| group('dtd', () { | |
| setUp(() async { | |
| dtd = await DartToolingDaemon.startService([]); | |
| // Wait for server to start and print to the port to stdout. | |
| uri = dtd!.uri!.toString(); | |
| client = _createClient(uri); | |
| }); | |
| tearDown(() async { | |
| await client.close(); | |
| }); | |
| group('streams', () { | |
| final streamId = 'testStream'; | |
| final eventKind = 'test'; | |
| final eventData = {'the': 'data'}; | |
| test('basics', () async { | |
| var completer = Completer<Map<Object?, Object?>>(); | |
| client.registerMethod('streamNotify', (Parameters parameters) { | |
| completer.complete(parameters.asMap); | |
| }); | |
| final listenResult = await client.sendRequest('streamListen', { | |
| 'streamId': streamId, | |
| }); | |
| expect(listenResult, {'type': 'Success'}); | |
| final postResult = await client.sendRequest('postEvent', { | |
| 'streamId': streamId, | |
| 'eventKind': eventKind, | |
| 'eventData': eventData, | |
| }); | |
| expect(postResult, {'type': 'Success'}); | |
| final dataFromTheStream = await completer.future; | |
| expect(dataFromTheStream, { | |
| 'streamId': streamId, | |
| 'eventKind': eventKind, | |
| 'eventData': eventData, | |
| 'timestamp': anything, | |
| }); | |
| // Now cancel the stream | |
| completer = Completer<Map<Object?, Object?>>(); // Reset the completer | |
| final cancelResult = await client.sendRequest('streamCancel', { | |
| 'streamId': streamId, | |
| }); | |
| expect(cancelResult, {'type': 'Success'}); | |
| final postResult2 = await client.sendRequest('postEvent', { | |
| 'streamId': streamId, | |
| 'eventKind': eventKind, | |
| 'eventData': eventData, | |
| }); | |
| expect(postResult2, {'type': 'Success'}); | |
| expect( | |
| completer.future.timeout( | |
| const Duration(seconds: 1), | |
| onTimeout: () => throw TimeoutException('Timed out'), | |
| ), | |
| throwsA(isA<TimeoutException>()), | |
| ); | |
| }); | |
| test('streamListen the same stream', () async { | |
| final listenResult = await client.sendRequest('streamListen', { | |
| 'streamId': streamId, | |
| }); | |
| expect(listenResult, {'type': 'Success'}); | |
| expect( | |
| () => client.sendRequest('streamListen', {'streamId': streamId}), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kStreamAlreadySubscribed, | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('stop listening to a stream that is not being listened to', () { | |
| expect( | |
| () => client.sendRequest('streamCancel', {'streamId': streamId}), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kStreamNotSubscribed, | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('postEvent when there are no listeners', () async { | |
| final postResult = await client.sendRequest('postEvent', { | |
| 'streamId': streamId, | |
| 'eventKind': eventKind, | |
| 'eventData': eventData, | |
| }); | |
| expect(postResult, {'type': 'Success'}); | |
| }); | |
| }); | |
| group('service methods', () { | |
| final service1 = 'foo1'; | |
| final method1 = 'bar1'; | |
| final method2 = 'bar2'; | |
| final data1 = {'data': 1}; | |
| final response1 = {'response': 1}; | |
| test('basics', () async { | |
| client.registerMethod('$service1.$method1', (Parameters parameters) { | |
| return response1; | |
| }); | |
| final registerResult = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| }); | |
| expect(registerResult, {'type': 'Success'}); | |
| final register2Result = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method2, | |
| }); | |
| expect(register2Result, {'type': 'Success'}); | |
| final methodResponse = await client.sendRequest( | |
| '$service1.$method1', | |
| data1, | |
| ); | |
| expect(methodResponse, response1); | |
| }); | |
| test('disallows dots in service name', () async { | |
| expect( | |
| () => client.sendRequest('registerService', { | |
| 'service': 'a.b', | |
| 'method': method1, | |
| }), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kServiceNameInvalid, | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('allows dots in service method name', () async { | |
| final registerResult = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': 'a.b', | |
| }); | |
| expect(registerResult, {'type': 'Success'}); | |
| }); | |
| test('disconnecting while handling a service request returns an error ' | |
| 'to the caller', () async { | |
| // Register a never-completing request that client2 can call. | |
| final requestStartedCompleter = Completer<void>(); | |
| client.registerMethod('$service1.$method1', (Parameters parameters) { | |
| requestStartedCompleter.complete(); // Signal the request has started. | |
| return Completer<void>().future; // Never complete. | |
| }); | |
| final registerResult = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| }); | |
| expect(registerResult, {'type': 'Success'}); | |
| // Begin a call to that method. | |
| final client2 = _createClient(uri); | |
| final responseFuture = client2.sendRequest('$service1.$method1', {}); | |
| await requestStartedCompleter.future; | |
| // Disconnect client1 so it never responses. | |
| await client.close(); | |
| // Expect that we complete with the expected RPC error. | |
| expect( | |
| responseFuture, | |
| throwsA( | |
| isA<RpcException>() | |
| .having((e) => e.code, 'code', -32000) | |
| .having( | |
| (e) => e.data, | |
| 'data', | |
| containsPair( | |
| 'full', | |
| 'Bad state: The client closed with pending request ' | |
| '"$service1.$method1".', | |
| ), | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('registering a service method that already exists', () async { | |
| final registerResult = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| }); | |
| expect(registerResult, {'type': 'Success'}); | |
| expect( | |
| () => client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| }), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kServiceMethodAlreadyRegistered, | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('calling a method that does not exist', () { | |
| expect( | |
| () => client.sendRequest('zoo.abc', {}), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcException.methodNotFound('zoo.abc').code, | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('calling a method without a dot', () { | |
| expect( | |
| () => client.sendRequest('abc', {}), | |
| throwsA( | |
| isA<RpcException>() | |
| .having( | |
| (e) => e.code, | |
| 'code', | |
| RpcException.methodNotFound('abc').code, | |
| ) | |
| .having((e) => e.message, 'message', 'Unknown method "abc".'), | |
| ), | |
| ); | |
| }); | |
| test('different clients cannot register the same service', () async { | |
| final client2 = _createClient(uri); | |
| final registerResult = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| }); | |
| expect(registerResult, {'type': 'Success'}); | |
| expect( | |
| () => client2.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method2, | |
| }), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kServiceAlreadyRegistered, | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('clients cannot register an internal service', () async { | |
| expect( | |
| () => client.sendRequest('registerService', { | |
| 'service': FileSystemServiceConstants.serviceName, | |
| 'method': method2, | |
| }), | |
| throwsA( | |
| isA<RpcException>() | |
| .having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kServiceAlreadyRegistered, | |
| ) | |
| .having( | |
| (e) => e.data, | |
| 'data', | |
| containsPair( | |
| 'details', | |
| 'Service \'FileSystem\' is already registered as a DTD ' | |
| 'internal service.', | |
| ), | |
| ), | |
| ), | |
| ); | |
| }); | |
| test('releases service methods on disconnect', () async { | |
| final client2 = _createClient(uri); | |
| final registerResult = await client.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| }); | |
| expect(registerResult, {'type': 'Success'}); | |
| await client.close(); | |
| // TODO: replace this polling when notification streams are implemented. | |
| Object? client2RegisterResult; | |
| for (var i = 0; i < 10; i++) { | |
| try { | |
| // The service method registration should succeed once the other | |
| // finishes closing. | |
| client2RegisterResult = await client2.sendRequest( | |
| 'registerService', | |
| {'service': service1, 'method': method1}, | |
| ); | |
| break; | |
| } catch (_) {} | |
| await Future<void>.delayed(const Duration(seconds: 1)); | |
| } | |
| expect(client2RegisterResult, {'type': 'Success'}); | |
| }); | |
| group('sends notifications', () { | |
| late Peer client2; | |
| setUp(() { | |
| client2 = _createClient(uri); | |
| }); | |
| tearDown(() async { | |
| await client2.close(); | |
| }); | |
| test('when a service method is registered', () async { | |
| // Subscribe to the services stream. | |
| final serviceStream = StreamController<Map<Object?, Object?>>(); | |
| client.registerMethod('streamNotify', (Parameters parameters) { | |
| if (parameters['streamId'].asString == | |
| CoreDtdServiceConstants.servicesStreamId) { | |
| serviceStream.add(parameters.asMap); | |
| } | |
| }); | |
| await client.sendRequest('streamListen', { | |
| 'streamId': CoreDtdServiceConstants.servicesStreamId, | |
| }); | |
| // Register a method on a second client. | |
| await client2.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| 'capabilities': {'supportsFoo': true}, | |
| }); | |
| // Expect we had a service registered event. | |
| final event = await serviceStream.stream.firstWhere( | |
| (event) => (event['eventData'] as Map?)?['service'] == 'foo1', | |
| ); | |
| expect(event['streamId'], CoreDtdServiceConstants.servicesStreamId); | |
| expect( | |
| event['eventKind'], | |
| CoreDtdServiceConstants.serviceRegisteredKind, | |
| ); | |
| expect(event['eventData'], { | |
| 'service': 'foo1', | |
| 'method': 'bar1', | |
| 'capabilities': {'supportsFoo': true}, | |
| }); | |
| }); | |
| test( | |
| 'when a service method is registered before subscribing', | |
| () async { | |
| // Register a method on a second client _first_. | |
| await client2.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| 'capabilities': {'supportsFoo': true}, | |
| }); | |
| // Subscribe to the services stream. | |
| var serviceStream = StreamController<Map<Object?, Object?>>(); | |
| client.registerMethod('streamNotify', (Parameters parameters) { | |
| if (parameters['streamId'].asString == | |
| CoreDtdServiceConstants.servicesStreamId) { | |
| serviceStream.add(parameters.asMap); | |
| } | |
| }); | |
| await client.sendRequest('streamListen', { | |
| 'streamId': CoreDtdServiceConstants.servicesStreamId, | |
| }); | |
| // Expect we had a service registered event. | |
| final event = await serviceStream.stream.first; | |
| expect(event['streamId'], CoreDtdServiceConstants.servicesStreamId); | |
| expect( | |
| event['eventKind'], | |
| CoreDtdServiceConstants.serviceRegisteredKind, | |
| ); | |
| expect(event['eventData'], { | |
| 'service': 'foo1', | |
| 'method': 'bar1', | |
| 'capabilities': {'supportsFoo': true}, | |
| }); | |
| }, | |
| ); | |
| test('when a service method is unregistered', () async { | |
| // Subscribe to the services stream. | |
| var serviceStream = StreamController<Map<Object?, Object?>>(); | |
| client.registerMethod('streamNotify', (Parameters parameters) { | |
| if (parameters['streamId'].asString == | |
| CoreDtdServiceConstants.servicesStreamId) { | |
| serviceStream.add(parameters.asMap); | |
| } | |
| }); | |
| await client.sendRequest('streamListen', { | |
| 'streamId': CoreDtdServiceConstants.servicesStreamId, | |
| }); | |
| // Register a method on a second client and then close it so the | |
| // service is removed. | |
| await client2.sendRequest('registerService', { | |
| 'service': service1, | |
| 'method': method1, | |
| 'capabilities': {'supportsFoo': true}, | |
| }); | |
| await client2.close(); | |
| // Expect we had a service unregistered event (after the registered | |
| // event). | |
| final event = await serviceStream.stream.skip(1).firstWhere((event) { | |
| return event['eventKind'] == | |
| CoreDtdServiceConstants.serviceUnregisteredKind && | |
| (event['eventData'] as Map?)?['service'] == 'foo1'; | |
| }); | |
| expect(event['streamId'], CoreDtdServiceConstants.servicesStreamId); | |
| expect(event['eventData'], { | |
| 'service': 'foo1', | |
| 'method': 'bar1', | |
| // No capabilities on unregister. | |
| }); | |
| }); | |
| }); | |
| }); | |
| group('file system service', () { | |
| test('denies symlink escapes from IDE workspace roots', () async { | |
| final tempRoot = await Directory.systemTemp.createTemp('dtd_test.'); | |
| final workspace = Directory('${tempRoot.path}/workspace')..createSync(); | |
| final outside = Directory('${tempRoot.path}/outside')..createSync(); | |
| final secretFile = File('${outside.path}/secret.txt') | |
| ..createSync() | |
| ..writeAsStringSync('TOP-SECRET'); | |
| final escapeLink = Link('${workspace.path}/escape-link') | |
| ..createSync(outside.path, recursive: true); | |
| addTearDown(() async { | |
| if (tempRoot.existsSync()) { | |
| await tempRoot.delete(recursive: true); | |
| } | |
| }); | |
| await client.sendRequest( | |
| '${FileSystemServiceConstants.serviceName}.' | |
| '${FileSystemServiceConstants.setIDEWorkspaceRoots}', | |
| { | |
| 'secret': dtd!.secret, | |
| 'roots': [workspace.uri.toString()], | |
| }, | |
| ); | |
| expect( | |
| () => client.sendRequest( | |
| '${FileSystemServiceConstants.serviceName}.' | |
| '${FileSystemServiceConstants.readFileAsString}', | |
| {'uri': File('${escapeLink.path}/secret.txt').uri.toString()}, | |
| ), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kPermissionDenied, | |
| ), | |
| ), | |
| ); | |
| expect( | |
| () => client.sendRequest( | |
| '${FileSystemServiceConstants.serviceName}.' | |
| '${FileSystemServiceConstants.writeFileAsString}', | |
| { | |
| 'uri': File('${escapeLink.path}/created.txt').uri.toString(), | |
| 'contents': 'WRITE-THROUGH-LINK', | |
| 'encoding': 'utf-8', | |
| }, | |
| ), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kPermissionDenied, | |
| ), | |
| ), | |
| ); | |
| expect( | |
| () => client.sendRequest( | |
| '${FileSystemServiceConstants.serviceName}.' | |
| '${FileSystemServiceConstants.listDirectoryContents}', | |
| {'uri': Directory(escapeLink.path).uri.toString()}, | |
| ), | |
| throwsA( | |
| isA<RpcException>().having( | |
| (e) => e.code, | |
| 'code', | |
| RpcErrorCodes.kPermissionDenied, | |
| ), | |
| ), | |
| ); | |
| expect(secretFile.readAsStringSync(), 'TOP-SECRET'); | |
| expect(File('${outside.path}/created.txt').existsSync(), isFalse); | |
| }); | |
| }); | |
| }); | |
| group('dtd arguments', () { | |
| test('allow explicit port', () async { | |
| const testPort = 8123; | |
| dtd = await DartToolingDaemon.startService(['--port=$testPort']); | |
| uri = dtd!.uri!.toString(); | |
| expect(Uri.parse(uri).port, testPort); | |
| }); | |
| group('ping-interval', () { | |
| test('0s by default', () async { | |
| // Default is now 0 (disabled), not 15s, due to IntelliJ issue. | |
| // https://github.com/flutter/dart-intellij-third-party/issues/205 | |
| dtd = await DartToolingDaemon.startService([]); | |
| expect(dtd!.pingInterval, isNull); | |
| }); | |
| test('explicitly enabled', () async { | |
| dtd = await DartToolingDaemon.startService(['--ping-interval=1']); | |
| expect(dtd!.pingInterval, const Duration(seconds: 1)); | |
| }); | |
| test('explicitly disabled', () async { | |
| dtd = await DartToolingDaemon.startService(['--ping-interval=0']); | |
| expect(dtd!.pingInterval, isNull); | |
| }); | |
| }); | |
| }); | |
| } | |
| Peer _createClient(String uri) { | |
| final channel = WebSocketChannel.connect(Uri.parse(uri)); | |
| final client = Peer(channel.cast()); | |
| unawaited(client.listen()); | |
| return client; | |
| } |