// Copyright (c) 2017, the gRPC project authors. Please see the AUTHORS file // for details. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. @TestOn('browser') import 'dart:async'; import 'dart:html'; import 'package:async/async.dart'; import 'package:grpc/src/client/call.dart'; import 'package:grpc/src/client/transport/xhr_transport.dart'; import 'package:grpc/src/shared/message.dart'; import 'package:grpc/src/shared/status.dart'; import 'package:mockito/mockito.dart'; import 'package:stream_transform/stream_transform.dart'; import 'package:test/test.dart'; final readyStateChangeEvent = Event('readystatechange', canBubble: false, cancelable: false); final progressEvent = ProgressEvent('onloadstart'); class MockHttpRequest extends Mock implements HttpRequest { MockHttpRequest({int? code}) : status = code ?? 200; // ignore: close_sinks StreamController readyStateChangeController = StreamController(); // ignore: close_sinks StreamController progressController = StreamController(); @override Stream get onReadyStateChange => readyStateChangeController.stream; @override Stream get onProgress => progressController.stream; @override Stream get onError => StreamController().stream; @override final int status; @override int get readyState => super.noSuchMethod(Invocation.getter(#readyState), returnValue: -1); @override Map get responseHeaders => super.noSuchMethod(Invocation.getter(#responseHeaders), returnValue: {}); } class MockXhrClientConnection extends XhrClientConnection { MockXhrClientConnection({int? code}) : _statusCode = code ?? 200, super(Uri.parse('test:8080')); late MockHttpRequest latestRequest; final int _statusCode; @override HttpRequest createHttpRequest() { final request = MockHttpRequest(code: _statusCode); latestRequest = request; return request; } } void main() { test('Make request sends correct headers', () async { final metadata = { 'parameter_1': 'value_1', 'parameter_2': 'value_2' }; final connection = MockXhrClientConnection(); connection.makeRequest('path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString())); verify(connection.latestRequest .setRequestHeader('Content-Type', 'application/grpc-web+proto')); verify(connection.latestRequest .setRequestHeader('X-User-Agent', 'grpc-web-dart/0.1')); verify(connection.latestRequest.setRequestHeader('X-Grpc-Web', '1')); verify(connection.latestRequest .overrideMimeType('text/plain; charset=x-user-defined')); verify(connection.latestRequest.responseType = 'text'); }); test( 'Make request sends correct headers and path if bypassCorsPreflight=true', () async { final metadata = {'header_1': 'value_1', 'header_2': 'value_2'}; final connection = MockXhrClientConnection(); connection.makeRequest('path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString()), callOptions: WebCallOptions(bypassCorsPreflight: true)); expect(metadata, isEmpty); verify(connection.latestRequest.open('POST', 'test:path?%24httpHeaders=header_1%3Avalue_1%0D%0Aheader_2%3Avalue_2%0D%0AContent-Type%3Aapplication%2Fgrpc-web%2Bproto%0D%0AX-User-Agent%3Agrpc-web-dart%2F0.1%0D%0AX-Grpc-Web%3A1%0D%0A')); verify(connection.latestRequest .overrideMimeType('text/plain; charset=x-user-defined')); verify(connection.latestRequest.responseType = 'text'); }); test( 'Make request sends correct headers if call options already have ' 'Content-Type header', () async { final metadata = { 'header_1': 'value_1', 'header_2': 'value_2', 'Content-Type': 'application/json+protobuf' }; final connection = MockXhrClientConnection(); connection.makeRequest('/path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString())); expect(metadata, { 'header_1': 'value_1', 'header_2': 'value_2', 'Content-Type': 'application/json+protobuf', }); }); test('Content-Type header case insensitivity', () async { final metadata = { 'header_1': 'value_1', 'CONTENT-TYPE': 'application/json+protobuf' }; final connection = MockXhrClientConnection(); connection.makeRequest('/path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString())); expect(metadata, { 'header_1': 'value_1', 'CONTENT-TYPE': 'application/json+protobuf', }); final lowerMetadata = { 'header_1': 'value_1', 'content-type': 'application/json+protobuf' }; connection.makeRequest('/path', Duration(seconds: 10), lowerMetadata, (error, _) => fail(error.toString())); expect(lowerMetadata, { 'header_1': 'value_1', 'content-type': 'application/json+protobuf', }); }); test('Make request sends correct headers path if only withCredentials=true', () async { final metadata = {'header_1': 'value_1', 'header_2': 'value_2'}; final connection = MockXhrClientConnection(); connection.makeRequest('path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString()), callOptions: WebCallOptions(withCredentials: true)); expect(metadata, { 'header_1': 'value_1', 'header_2': 'value_2', 'Content-Type': 'application/grpc-web+proto', 'X-User-Agent': 'grpc-web-dart/0.1', 'X-Grpc-Web': '1' }); verify(connection.latestRequest .setRequestHeader('Content-Type', 'application/grpc-web+proto')); verify(connection.latestRequest .setRequestHeader('X-User-Agent', 'grpc-web-dart/0.1')); verify(connection.latestRequest.setRequestHeader('X-Grpc-Web', '1')); verify(connection.latestRequest.open('POST', 'test:path')); verify(connection.latestRequest.withCredentials = true); verify(connection.latestRequest .overrideMimeType('text/plain; charset=x-user-defined')); verify(connection.latestRequest.responseType = 'text'); }); test('Sent data converted to stream properly', () async { final metadata = { 'parameter_1': 'value_1', 'parameter_2': 'value_2' }; final connection = MockXhrClientConnection(); final stream = connection.makeRequest('path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString())); final data = List.filled(10, 0); stream.outgoingMessages.add(data); await stream.terminate(); final expectedData = frame(data); expect(verify(connection.latestRequest.send(captureAny)).captured.single, expectedData); }); test('Stream handles headers properly', () async { final responseHeaders = { 'parameter_1': 'value_1', 'parameter_2': 'value_2', 'content-type': 'application/grpc+proto', }; final transport = MockXhrClientConnection(); final stream = transport.makeRequest('test_path', Duration(seconds: 10), {}, (error, _) => fail(error.toString())); when(transport.latestRequest.responseHeaders).thenReturn(responseHeaders); when(transport.latestRequest.response) .thenReturn(String.fromCharCodes(frame([]))); // Set expectation for request readyState and generate two readyStateChange // events, so that incomingMessages stream completes. final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; when(transport.latestRequest.readyState) .thenAnswer((_) => readyStates.removeAt(0)); transport.latestRequest.readyStateChangeController .add(readyStateChangeEvent); transport.latestRequest.readyStateChangeController .add(readyStateChangeEvent); // Should be only one metadata message with headers augmented with :status // field. final message = await stream.incomingMessages.single as GrpcMetadata; expect(message.metadata, responseHeaders); }); test('Stream handles trailers properly', () async { final requestHeaders = { 'parameter_1': 'value_1', 'content-type': 'application/grpc+proto', }; final responseTrailers = { 'trailer_1': 'value_1', 'trailer_2': 'value_2', }; final connection = MockXhrClientConnection(); final stream = connection.makeRequest('test_path', Duration(seconds: 10), requestHeaders, (error, _) => fail(error.toString())); final encodedTrailers = frame(responseTrailers.entries .map((e) => '${e.key}:${e.value}') .join('\r\n') .codeUnits); encodedTrailers[0] = 0x80; // Mark this frame as trailers. final encodedString = String.fromCharCodes(encodedTrailers); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); when(connection.latestRequest.response).thenReturn(encodedString); // Set expectation for request readyState and generate events so that // incomingMessages stream completes. final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; when(connection.latestRequest.readyState) .thenAnswer((_) => readyStates.removeAt(0)); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); connection.latestRequest.progressController.add(progressEvent); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); // Should be two metadata messages: headers and trailers. final messages = await stream.incomingMessages.whereType().toList(); expect(messages.length, 2); expect(messages.first.metadata, requestHeaders); expect(messages.last.metadata, responseTrailers); }); test('Stream handles empty trailers properly', () async { final requestHeaders = { 'content-type': 'application/grpc+proto', }; final connection = MockXhrClientConnection(); final stream = connection.makeRequest('test_path', Duration(seconds: 10), {}, (error, _) => fail(error.toString())); final encoded = frame(''.codeUnits); encoded[0] = 0x80; // Mark this frame as trailers. final encodedString = String.fromCharCodes(encoded); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); when(connection.latestRequest.response).thenReturn(encodedString); // Set expectation for request readyState and generate events so that // incomingMessages stream completes. final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; when(connection.latestRequest.readyState) .thenAnswer((_) => readyStates.removeAt(0)); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); connection.latestRequest.progressController.add(progressEvent); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); // Should be two metadata messages: headers and trailers. final messages = await stream.incomingMessages.whereType().toList(); expect(messages.length, 2); expect(messages.first.metadata, requestHeaders); expect(messages.last.metadata, isEmpty); }); test('Stream deserializes data properly', () async { final requestHeaders = { 'parameter_1': 'value_1', 'parameter_2': 'value_2', 'content-type': 'application/grpc+proto', }; final connection = MockXhrClientConnection(); final stream = connection.makeRequest('test_path', Duration(seconds: 10), requestHeaders, (error, _) => fail(error.toString())); final data = List.filled(10, 224); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); when(connection.latestRequest.response) .thenReturn(String.fromCharCodes(frame(data))); // Set expectation for request readyState and generate events, so that // incomingMessages stream completes. final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; when(connection.latestRequest.readyState) .thenAnswer((_) => readyStates.removeAt(0)); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); connection.latestRequest.progressController.add(progressEvent); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); // Expect a single data message. final message = await stream.incomingMessages.whereType().single; expect(message.data, data); }); test('GrpcError with error details in response', () async { final connection = MockXhrClientConnection(code: 400); final errors = []; // The incoming messages stream never completes when there's an error, so // using completer. final errorReceived = Completer(); connection.makeRequest('test_path', Duration(seconds: 10), {}, (e, _) { errorReceived.complete(); errors.add(e as GrpcError); }); const errorDetails = 'error details'; when(connection.latestRequest.responseHeaders) .thenReturn({'content-type': 'application/grpc+proto'}); when(connection.latestRequest.readyState).thenReturn(HttpRequest.DONE); when(connection.latestRequest.responseText).thenReturn(errorDetails); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); await errorReceived.future; expect(errors.single.rawResponse, errorDetails); }); test('Stream receives multiple messages', () async { final metadata = { 'parameter_1': 'value_1', 'parameter_2': 'value_2', 'content-type': 'application/grpc+proto', }; final connection = MockXhrClientConnection(); final stream = connection.makeRequest('test_path', Duration(seconds: 10), metadata, (error, _) => fail(error.toString())); final data = >[ List.filled(10, 224), List.filled(5, 124) ]; final encodedStrings = data.map((d) => String.fromCharCodes(frame(d))).toList(); when(connection.latestRequest.responseHeaders).thenReturn(metadata); when(connection.latestRequest.readyState) .thenReturn(HttpRequest.HEADERS_RECEIVED); // At first invocation the response should be the the first message, after // that first + last messages. var first = true; when(connection.latestRequest.response).thenAnswer((_) { if (first) { first = false; return encodedStrings[0]; } return encodedStrings[0] + encodedStrings[1]; }); final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; when(connection.latestRequest.readyState) .thenAnswer((_) => readyStates.removeAt(0)); final queue = StreamQueue(stream.incomingMessages); // Headers. connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); expect(((await queue.next) as GrpcMetadata).metadata, metadata); // Data 1. connection.latestRequest.progressController.add(progressEvent); expect(((await queue.next) as GrpcData).data, data[0]); // Data 2. connection.latestRequest.progressController.add(progressEvent); expect(((await queue.next) as GrpcData).data, data[1]); // Done. connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); expect(await queue.hasNext, isFalse); }); }