Add tests for sending "cookie" and receiving "set-cookie" headers (#1113)

diff --git a/pkgs/cronet_http/example/integration_test/client_test.dart b/pkgs/cronet_http/example/integration_test/client_test.dart
index cc6b80e..f126f84 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 @@
       () => 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 a0823bf..3007123 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 @@
   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 20bf39f..65368e5 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: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 bd83c02..07903b5 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/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/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 @@
 /// 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 @@
   bool redirectAlwaysAllowed = false,
   bool canWorkInIsolates = true,
   bool preservesMethodCase = false,
+  bool canSendCookieHeaders = false,
+  bool canReceiveSetCookieHeaders = false,
 }) {
   testRequestBody(clientFactory());
   testRequestBodyStreamed(clientFactory(),
@@ -82,4 +94,8 @@
   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 0000000..44653a7
--- /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<Object?> 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 = <String>[];
+      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 0000000..1f30e5f
--- /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<StreamChannel<Object?>> startServer() async {
+  final controller = StreamChannelController<Object?>(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 0000000..31d961b
--- /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<StreamChannel<Object?>> 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 0000000..a4eb78c
--- /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<Object?> httpServerChannel;
+    late final StreamQueue<Object?> 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<String>();
+      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<String>();
+      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 0000000..392e228
--- /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<Object?> channel) async {
+  late HttpServer server;
+  final clientQueue = StreamQueue(channel.stream);
+
+  server = (await HttpServer.bind('localhost', 0))
+    ..listen((request) async {
+      await request.drain<void>();
+      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 0000000..2edbb45
--- /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<StreamChannel<Object?>> startServer() async {
+  final controller = StreamChannelController<Object?>(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 0000000..cb8e384
--- /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<StreamChannel<Object?>> 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 0000000..f8e154d
--- /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<Object?> httpServerChannel;
+    late StreamQueue<Object?> 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');
+  });
+}