diff --git a/pkgs/cronet_http/example/integration_test/client_test.dart b/pkgs/cronet_http/example/integration_test/client_test.dart index cc6b80edfc..f126f84fc3 100644 --- a/pkgs/cronet_http/example/integration_test/client_test.dart +++ b/pkgs/cronet_http/example/integration_test/client_test.dart @@ -13,6 +13,8 @@ Future testConformance() async { () => testAll( CronetClient.defaultCronetEngine, canStreamRequestBody: false, + canReceiveSetCookieHeaders: true, + canSendCookieHeaders: true, )); group('from cronet engine', () { diff --git a/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart b/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart index a0823bf71d..3007123a98 100644 --- a/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart +++ b/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart @@ -11,11 +11,19 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('defaultSessionConfiguration', () { - testAll(CupertinoClient.defaultSessionConfiguration); + testAll( + CupertinoClient.defaultSessionConfiguration, + canReceiveSetCookieHeaders: true, + canSendCookieHeaders: true, + ); }); group('fromSessionConfiguration', () { final config = URLSessionConfiguration.ephemeralSessionConfiguration(); - testAll(() => CupertinoClient.fromSessionConfiguration(config), - canWorkInIsolates: false); + testAll( + () => CupertinoClient.fromSessionConfiguration(config), + canWorkInIsolates: false, + canReceiveSetCookieHeaders: true, + canSendCookieHeaders: true, + ); }); } diff --git a/pkgs/http/test/io/client_conformance_test.dart b/pkgs/http/test/io/client_conformance_test.dart index 20bf39f281..65368e57a0 100644 --- a/pkgs/http/test/io/client_conformance_test.dart +++ b/pkgs/http/test/io/client_conformance_test.dart @@ -10,6 +10,9 @@ import 'package:http_client_conformance_tests/http_client_conformance_tests.dart import 'package:test/test.dart'; void main() { - testAll(IOClient.new, preservesMethodCase: false // https://dartbug.com/54187 - ); + testAll( + IOClient.new, preservesMethodCase: false, // https://dartbug.com/54187 + canReceiveSetCookieHeaders: true, + canSendCookieHeaders: true, + ); } diff --git a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart index bd83c02abb..07903b5246 100644 --- a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart +++ b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart @@ -11,10 +11,12 @@ import 'src/multiple_clients_tests.dart'; import 'src/redirect_tests.dart'; import 'src/request_body_streamed_tests.dart'; import 'src/request_body_tests.dart'; +import 'src/request_cookies_test.dart'; import 'src/request_headers_tests.dart'; import 'src/request_methods_tests.dart'; import 'src/response_body_streamed_test.dart'; import 'src/response_body_tests.dart'; +import 'src/response_cookies_test.dart'; import 'src/response_headers_tests.dart'; import 'src/response_status_line_tests.dart'; import 'src/server_errors_test.dart'; @@ -27,10 +29,12 @@ export 'src/multiple_clients_tests.dart' show testMultipleClients; export 'src/redirect_tests.dart' show testRedirect; export 'src/request_body_streamed_tests.dart' show testRequestBodyStreamed; export 'src/request_body_tests.dart' show testRequestBody; +export 'src/request_cookies_test.dart' show testRequestCookies; export 'src/request_headers_tests.dart' show testRequestHeaders; export 'src/request_methods_tests.dart' show testRequestMethods; export 'src/response_body_streamed_test.dart' show testResponseBodyStreamed; export 'src/response_body_tests.dart' show testResponseBody; +export 'src/response_cookies_test.dart' show testResponseCookies; export 'src/response_headers_tests.dart' show testResponseHeaders; export 'src/response_status_line_tests.dart' show testResponseStatusLine; export 'src/server_errors_test.dart' show testServerErrors; @@ -54,6 +58,12 @@ export 'src/server_errors_test.dart' show testServerErrors; /// If [preservesMethodCase] is `false` then tests that assume that the /// [Client] preserves custom request method casing will be skipped. /// +/// If [canSendCookieHeaders] is `false` then tests that require that "cookie" +/// headers be sent by the client will not be run. +/// +/// If [canReceiveSetCookieHeaders] is `false` then tests that require that +/// "set-cookie" headers be received by the client will not be run. +/// /// The tests are run against a series of HTTP servers that are started by the /// tests. If the tests are run in the browser, then the test servers are /// started in another process. Otherwise, the test servers are run in-process. @@ -64,6 +74,8 @@ void testAll( bool redirectAlwaysAllowed = false, bool canWorkInIsolates = true, bool preservesMethodCase = false, + bool canSendCookieHeaders = false, + bool canReceiveSetCookieHeaders = false, }) { testRequestBody(clientFactory()); testRequestBodyStreamed(clientFactory(), @@ -82,4 +94,8 @@ void testAll( testMultipleClients(clientFactory); testClose(clientFactory); testIsolate(clientFactory, canWorkInIsolates: canWorkInIsolates); + testRequestCookies(clientFactory(), + canSendCookieHeaders: canSendCookieHeaders); + testResponseCookies(clientFactory(), + canReceiveSetCookieHeaders: canReceiveSetCookieHeaders); } diff --git a/pkgs/http_client_conformance_tests/lib/src/request_cookies_server.dart b/pkgs/http_client_conformance_tests/lib/src/request_cookies_server.dart new file mode 100644 index 0000000000..44653a7cd5 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_cookies_server.dart @@ -0,0 +1,55 @@ +// 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:convert'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that captures "cookie" headers. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// On Request Received: +/// - send a list of header lines starting with "cookie:" +/// When Receive Anything: +/// - exit +void hybridMain(StreamChannel channel) async { + late ServerSocket server; + + server = (await ServerSocket.bind('localhost', 0)) + ..listen((Socket socket) async { + final request = utf8.decoder.bind(socket).transform(const LineSplitter()); + + final cookies = []; + request.listen((line) { + if (line.toLowerCase().startsWith('cookie:')) { + cookies.add(line); + } + + if (line.isEmpty) { + // A blank line indicates the end of the headers. + channel.sink.add(cookies); + } + }); + + socket.writeAll( + [ + 'HTTP/1.1 200 OK', + 'Access-Control-Allow-Origin: *', + 'Content-Length: 0', + '\r\n', // Add \r\n at the end of this header section. + ], + '\r\n', // Separate each field by \r\n. + ); + await socket.close(); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_cookies_server_vm.dart b/pkgs/http_client_conformance_tests/lib/src/request_cookies_server_vm.dart new file mode 100644 index 0000000000..1f30e5f871 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_cookies_server_vm.dart @@ -0,0 +1,14 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'request_cookies_server.dart'; + +export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_cookies_server_web.dart b/pkgs/http_client_conformance_tests/lib/src/request_cookies_server_web.dart new file mode 100644 index 0000000000..31d961b047 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_cookies_server_web.dart @@ -0,0 +1,11 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'http_client_conformance_tests/src/request_cookies_server.dart')); diff --git a/pkgs/http_client_conformance_tests/lib/src/request_cookies_test.dart b/pkgs/http_client_conformance_tests/lib/src/request_cookies_test.dart new file mode 100644 index 0000000000..a4eb78cf56 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_cookies_test.dart @@ -0,0 +1,56 @@ +// 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 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'request_cookies_server_vm.dart' + if (dart.library.js_interop) 'request_cookies_server_web.dart'; + +// The an HTTP header into [name, value]. +final headerSplitter = RegExp(':[ \t]+'); + +/// Tests that the [Client] correctly sends "cookie" headers in the request. +/// +/// If [canSendCookieHeaders] is `false` then tests that require that "cookie" +/// headers be sent by the client will not be run. +void testRequestCookies(Client client, + {bool canSendCookieHeaders = false}) async { + group('request cookies', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.nextAsInt}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('one cookie', () async { + await client + .get(Uri.http(host, ''), headers: {'cookie': 'SID=298zf09hf012fh2'}); + + final cookies = (await httpServerQueue.next as List).cast(); + expect(cookies, hasLength(1)); + final [header, value] = cookies[0].split(headerSplitter); + expect(header.toLowerCase(), 'cookie'); + expect(value, 'SID=298zf09hf012fh2'); + }, skip: canSendCookieHeaders ? false : 'cannot send cookie headers'); + + test('multiple cookies semicolon separated', () async { + await client.get(Uri.http(host, ''), + headers: {'cookie': 'SID=298zf09hf012fh2; lang=en-US'}); + + final cookies = (await httpServerQueue.next as List).cast(); + expect(cookies, hasLength(1)); + final [header, value] = cookies[0].split(headerSplitter); + expect(header.toLowerCase(), 'cookie'); + expect(value, 'SID=298zf09hf012fh2; lang=en-US'); + }, skip: canSendCookieHeaders ? false : 'cannot send cookie headers'); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_cookies_server.dart b/pkgs/http_client_conformance_tests/lib/src/response_cookies_server.dart new file mode 100644 index 0000000000..392e22832d --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_cookies_server.dart @@ -0,0 +1,44 @@ +// 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:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that returns a custom status line. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// On Request Received: +/// - load response status line from channel +/// - exit +void hybridMain(StreamChannel channel) async { + late HttpServer server; + final clientQueue = StreamQueue(channel.stream); + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + await request.drain(); + final socket = await request.response.detachSocket(writeHeaders: false); + + final headers = (await clientQueue.next) as List; + socket.writeAll( + [ + 'HTTP/1.1 200 OK', + 'Access-Control-Allow-Origin: *', + 'Content-Length: 0', + ...headers, + '\r\n', // Add \r\n at the end of this header section. + ], + '\r\n', // Separate each field by \r\n. + ); + await socket.close(); + unawaited(server.close()); + }); + + channel.sink.add(server.port); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_cookies_server_vm.dart b/pkgs/http_client_conformance_tests/lib/src/response_cookies_server_vm.dart new file mode 100644 index 0000000000..2edbb4581f --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_cookies_server_vm.dart @@ -0,0 +1,14 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'response_cookies_server.dart'; + +export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_cookies_server_web.dart b/pkgs/http_client_conformance_tests/lib/src/response_cookies_server_web.dart new file mode 100644 index 0000000000..cb8e384ed4 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_cookies_server_web.dart @@ -0,0 +1,11 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'http_client_conformance_tests/src/response_cookies_server.dart')); diff --git a/pkgs/http_client_conformance_tests/lib/src/response_cookies_test.dart b/pkgs/http_client_conformance_tests/lib/src/response_cookies_test.dart new file mode 100644 index 0000000000..f8e154d611 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_cookies_test.dart @@ -0,0 +1,92 @@ +// 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 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'response_cookies_server_vm.dart' + if (dart.library.js_interop) 'response_cookies_server_web.dart'; + +/// Tests that the [Client] correctly receives "set-cookie-headers" +/// +/// If [canReceiveSetCookieHeaders] is `false` then tests that require that +/// "set-cookie" headers be received by the client will not be run. +void testResponseCookies(Client client, + {required bool canReceiveSetCookieHeaders}) async { + group('response cookies', () { + late String host; + late StreamChannel httpServerChannel; + late StreamQueue httpServerQueue; + + setUp(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.nextAsInt}'; + }); + + test('single session cookie', () async { + httpServerChannel.sink.add(['Set-Cookie: SID=1231AB3']); + final response = await client.get(Uri.http(host, '')); + + expect(response.headers['set-cookie'], 'SID=1231AB3'); + }, + skip: canReceiveSetCookieHeaders + ? false + : 'cannot receive set-cookie headers'); + + test('multiple session cookies', () async { + // RFC-2616 4.2 says: + // "The field value MAY be preceded by any amount of LWS, though a single + // SP is preferred." and + // "The field-content does not include any leading or trailing LWS ..." + httpServerChannel.sink + .add(['Set-Cookie: SID=1231AB3', 'Set-Cookie: lang=en_US']); + final response = await client.get(Uri.http(host, '')); + + expect( + response.headers['set-cookie'], + matches(r'SID=1231AB3' + r'[ \t]*,[ \t]*' + r'lang=en_US')); + }, + skip: canReceiveSetCookieHeaders + ? false + : 'cannot receive set-cookie headers'); + + test('permanent cookie with expires', () async { + httpServerChannel.sink + .add(['Set-Cookie: id=a3fWa; Expires=Wed, 10 Jan 2024 07:28:00 GMT']); + final response = await client.get(Uri.http(host, '')); + + expect(response.headers['set-cookie'], + 'id=a3fWa; Expires=Wed, 10 Jan 2024 07:28:00 GMT'); + }, + skip: canReceiveSetCookieHeaders + ? false + : 'cannot receive set-cookie headers'); + + test('multiple permanent cookies with expires', () async { + // RFC-2616 4.2 says: + // "The field value MAY be preceded by any amount of LWS, though a single + // SP is preferred." and + // "The field-content does not include any leading or trailing LWS ..." + httpServerChannel.sink.add([ + 'Set-Cookie: id=a3fWa; Expires=Wed, 10 Jan 2024 07:28:00 GMT', + 'Set-Cookie: id=2fasd; Expires=Wed, 21 Oct 2025 07:28:00 GMT' + ]); + final response = await client.get(Uri.http(host, '')); + + expect( + response.headers['set-cookie'], + matches(r'id=a3fWa; Expires=Wed, 10 Jan 2024 07:28:00 GMT' + r'[ \t]*,[ \t]*' + r'id=2fasd; Expires=Wed, 21 Oct 2025 07:28:00 GMT')); + }, + skip: canReceiveSetCookieHeaders + ? false + : 'cannot receive set-cookie headers'); + }); +}