Repository: aio-libs/aiohttp

Issue:
aiohttp raises SSLError when requesting URLs with FQDN
## Long story short

It seems that aiohttp doesn't work properly when requesting URLs with Fully Qualified Domain Name. This was fixed in urllib3, see https://github.com/urllib3/urllib3/pull/1255 It should probably by fixed also in aiohttp. May be related to https://github.com/aio-libs/aiohttp/issues/3171

## Expected behaviour

aiohttp works when requesting URLs with FQDN

## Actual behaviour

aiohttp raises SSL error:

```python-traceback
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/pavel/.pyenv/versions/3.7.1/lib/python3.7/asyncio/base_events.py", line 573, in run_until_complete
    return future.result()
  File "<stdin>", line 3, in main
  File "<stdin>", line 2, in fetch
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/client.py", line 1005, in __aenter__
    self._resp = await self._coro
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/client.py", line 476, in _request
    timeout=real_timeout
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/connector.py", line 522, in connect
    proto = await self._create_connection(req, traces, timeout)
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/connector.py", line 854, in _create_connection
    req, traces, timeout)
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/connector.py", line 992, in _create_direct_connection
    raise last_exc
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/connector.py", line 974, in _create_direct_connection
    req=req, client_error=client_error)
  File "/home/pavel/Projects/_lab/aiohttp/lib/python3.7/site-packages/aiohttp/connector.py", line 927, in _wrap_create_connection
    req.connection_key, exc) from exc
aiohttp.client_exceptions.ClientConnectorCertificateError: Cannot connect to host github.com.:443 ssl:True [SSLCertVerificationError: (1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'github.com.'. (_ssl.c:1051)")]
```

## Steps to reproduce

Run the following code:

```python
import aiohttp
import asyncio


async def fetch(session, url): 
    async with session.get(url) as response: 
        return await response.text() 
 
async def main(): 
    async with aiohttp.ClientSession() as session: 
        html = await fetch(session, 'https://github.com.') 
        print(html)


loop = asyncio.get_event_loop() 
loop.run_until_complete(main())
```

## Your environment

Python 3.7.1
Ubuntu 18.04

pip freeze

```console
aiohttp==3.5.4
async-timeout==3.0.1
attrs==19.1.0
chardet==3.0.4
idna==2.8
multidict==4.5.2
yarl==1.3.0
```

Type annotations: `print` parameter of `run_app` should be `Optional`
### Describe the bug

The [documentation](https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.run_app) says the following about `run_app`'s `print` parameter:

> Passing None disables output.

Looking at the code, this looks correct:

https://github.com/aio-libs/aiohttp/blob/1f97c55a93c4dd7e7dfe9b5850bdbfa0daa273de/aiohttp/web.py#L419-L424

However, the `print` parameter is typed as a `Callable`, which does not allow a value of `None`. It should be an `Optional[Callable[...]]`.

### To Reproduce

1. Call `run_app` with a `print` parameter set to `None`
2. Run `mypy`

### Expected behavior

`mypy` is not complaining about passing `None` to `print`.

### Logs/tracebacks

```python-traceback
$ mypy --show-error-codes .
xxxx.py:yy: error: Argument "print" to "run_app" has incompatible type "None"; expected "Callable[..., None]"  [arg-type]
```


### Python Version

```console
$ python --version
Python 3.10.8
```


### aiohttp Version

```console
$ python -m pip show aiohttp
Name: aiohttp
Version: 3.8.3
Summary: Async http client/server framework (asyncio)
Home-page: https://github.com/aio-libs/aiohttp
Author: 
Author-email: 
License: Apache 2
Location: /home/babolivier/.virtualenvs/mcs-py310/lib/python3.10/site-packages
Requires: aiosignal, async-timeout, attrs, charset-normalizer, frozenlist, multidict, yarl
Required-by: mautrix
```


### multidict Version

```console
$ python -m pip show multidict
Name: multidict
Version: 6.0.2
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /home/babolivier/.virtualenvs/mcs-py310/lib/python3.10/site-packages
Requires: 
Required-by: aiohttp, yarl
```


### yarl Version

```console
$ python -m pip show yarl
Name: yarl
Version: 1.8.1
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl/
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /home/babolivier/.virtualenvs/mcs-py310/lib/python3.10/site-packages
Requires: idna, multidict
Required-by: aiohttp, mautrix
```


### OS

Arch Linux

### Related component

Server

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct
Type annotations: `print` parameter of `run_app` should be `Optional`
### Describe the bug

The [documentation](https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.run_app) says the following about `run_app`'s `print` parameter:

> Passing None disables output.

Looking at the code, this looks correct:

https://github.com/aio-libs/aiohttp/blob/1f97c55a93c4dd7e7dfe9b5850bdbfa0daa273de/aiohttp/web.py#L419-L424

However, the `print` parameter is typed as a `Callable`, which does not allow a value of `None`. It should be an `Optional[Callable[...]]`.

### To Reproduce

1. Call `run_app` with a `print` parameter set to `None`
2. Run `mypy`

### Expected behavior

`mypy` is not complaining about passing `None` to `print`.

### Logs/tracebacks

```python-traceback
$ mypy --show-error-codes .
xxxx.py:yy: error: Argument "print" to "run_app" has incompatible type "None"; expected "Callable[..., None]"  [arg-type]
```


### Python Version

```console
$ python --version
Python 3.10.8
```


### aiohttp Version

```console
$ python -m pip show aiohttp
Name: aiohttp
Version: 3.8.3
Summary: Async http client/server framework (asyncio)
Home-page: https://github.com/aio-libs/aiohttp
Author: 
Author-email: 
License: Apache 2
Location: /home/babolivier/.virtualenvs/mcs-py310/lib/python3.10/site-packages
Requires: aiosignal, async-timeout, attrs, charset-normalizer, frozenlist, multidict, yarl
Required-by: mautrix
```


### multidict Version

```console
$ python -m pip show multidict
Name: multidict
Version: 6.0.2
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /home/babolivier/.virtualenvs/mcs-py310/lib/python3.10/site-packages
Requires: 
Required-by: aiohttp, yarl
```


### yarl Version

```console
$ python -m pip show yarl
Name: yarl
Version: 1.8.1
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl/
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /home/babolivier/.virtualenvs/mcs-py310/lib/python3.10/site-packages
Requires: idna, multidict
Required-by: aiohttp, mautrix
```


### OS

Arch Linux

### Related component

Server

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct
Python 3.10 - DeprecationWarning on example "Run a Basic Low-Level Server"
### Describe the bug

In Python 3.10 you'll get a DeprecationWarning If you run the example [Run a Basic Low-Level Server](https://docs.aiohttp.org/en/latest/web_lowlevel.html).

### To Reproduce

Run example code from documentation: 

https://docs.aiohttp.org/en/stable/web_lowlevel.html#run-a-basic-low-level-server

```python
import asyncio
from aiohttp import web


async def handler(request):
    return web.Response(text="OK")


async def main():
    server = web.Server(handler)
    runner = web.ServerRunner(server)
    await runner.setup()
    site = web.TCPSite(runner, 'localhost', 8080)
    await site.start()

    print("======= Serving on http://127.0.0.1:8080/ ======")

    # pause here for very long time by serving HTTP requests and
    # waiting for keyboard interruption
    await asyncio.sleep(100*3600)


loop = asyncio.get_event_loop()

try:
    loop.run_until_complete(main())
except KeyboardInterrupt:
    pass
loop.close()
```

### Expected behavior

No DeprecationWarnings.

### Logs/tracebacks

```python-traceback
(aiohttp-test) root@e6dabeed3c80:~/aiohttp-test# python3 test.py
/root/aiohttp-test/test.py:23: DeprecationWarning: There is no current event loop
  loop = asyncio.get_event_loop()
======= Serving on http://127.0.0.1:8080/ ======
```


### Python Version

```console
3.10.6
```


### aiohttp Version

```console
3.8.4
```


### multidict Version

```console
Name: multidict
Version: 6.0.4
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /root/.local/share/virtualenvs/aiohttp-test-WmGPGqKN/lib/python3.10/site-packages
Requires:
Required-by: aiohttp, yarl
```


### yarl Version

```console
Name: yarl
Version: 1.9.2
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl/
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache-2.0
Location: /root/.local/share/virtualenvs/aiohttp-test-WmGPGqKN/lib/python3.10/site-packages
Requires: idna, multidict
Required-by: aiohttp
```


### OS

Ubuntu 22.04.2 LTS (GNU/Linux 4.14.314-237.533.amzn2.x86_64 x86_64)

### Related component

Server

### Additional context

Related to #6752 and possibly to #6757 #5572

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:
[GitMate.io](https://gitmate.io) thinks the contributor most likely able to help you is @asvetlov.

Possibly related issues are https://github.com/aio-libs/aiohttp/issues/2920 (AIOHttp failing after some requests), https://github.com/aio-libs/aiohttp/issues/660 (aiohttp.request hangs on some URLs), https://github.com/aio-libs/aiohttp/issues/206 (SSL issue with aiohttp.request), https://github.com/aio-libs/aiohttp/issues/1403 ([QUESTION] aiohttp.ClientSession.request('GET') issue), and https://github.com/aio-libs/aiohttp/issues/3523 (aiohttp not forwarding cookies with Session requests).
> Hostname mismatch, certificate is not valid for 'github.com.'

Your trusted CA chain is probably broken/invalid/misconfigured. It's not aiohttp's fault.
@webknjaz Thank you for taking the time to look at this.

If you are correct, why does the following code work correctly (with the same environment)?

```python
import aiohttp
import asyncio


async def fetch(session, url): 
    async with session.get(url) as response: 
        return await response.text() 
 
async def main(): 
    async with aiohttp.ClientSession() as session: 
        html = await fetch(session, 'https://github.com') 
        print(html)


loop = asyncio.get_event_loop() 
loop.run_until_complete(main())
```

Note that the only difference is `https://github.com` vs `https://github.com.` (the dot at the end of the URL).

And Python-Requests with `urllib3==1.24.1` works fine too, while it doesn't work for older versions of urllib3 where this wasn't fixed, see https://github.com/urllib3/urllib3/pull/1255.
Ah, ok. But strictly speaking, certificate has `CN=github.com` which doesn't match `github.com.`
So from the TLS PoV, everything works as expected.
Yes, it doesn't match. Where do you think this should be fixed if not in `aiohttp` then? BTW, check this https://github.com/haikuginger/urllib3/blob/68f3475b421f81d0e78eb0c2271d27d8b75bea05/urllib3/connection.py#L128-L144 and the discussion here https://bugs.python.org/issue31997
Yea, I saw that. So I decided to do some research with what I have on my machine.

Google Chrome:
- enter `https://github.com./`, observe browser sending a request to `https://github.com./`
- enter `https://github.com.../`, observe browser sending a request to `https://github.com./`
- no TLS errors
- github's backend replies with `Location` headers

curl:
- ```console
  ➜ curl -Iv https://github.com./
  *   Trying 192.30.253.112...
  * TCP_NODELAY set
  * Connected to github.com (192.30.253.112) port 443 (#0)
  * Initializing NSS with certpath: sql:/etc/pki/nssdb
  *   CAfile: none
    CApath: none
  * loaded libnssckbi.so
  * ALPN, server accepted to use http/1.1
  * SSL connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
  * Server certificate:
  *       subject: CN=github.com,O="GitHub, Inc.",L=San Francisco,ST=California,C=US,serialNumber=5157550,incorporationState=Delaware,incorporationCountry=US,businessCategory=Private Organization
  *       start date: May 08 00:00:00 2018 GMT
  *       expire date: Jun 03 12:00:00 2020 GMT
  *       common name: github.com
  *       issuer: CN=DigiCert SHA2 Extended Validation Server CA,OU=www.digicert.com,O=DigiCert Inc,C=US
  > HEAD / HTTP/1.1
  > Host: github.com
  > User-Agent: curl/7.63.0
  > Accept: */*
  > 
  < HTTP/1.1 200 OK
  HTTP/1.1 200 OK
  < Server: GitHub.com
  Server: GitHub.com
  < Date: Tue, 05 Mar 2019 11:00:12 GMT
  Date: Tue, 05 Mar 2019 11:00:12 GMT
  < Content-Type: text/html; charset=utf-8
  Content-Type: text/html; charset=utf-8
  < Status: 200 OK
  Status: 200 OK
  < Vary: X-PJAX
  Vary: X-PJAX
  < ETag: W/"86a3fca1a8a21cf08b73b0a956a47d89"
  ETag: W/"86a3fca1a8a21cf08b73b0a956a47d89"
  < Cache-Control: max-age=0, private, must-revalidate
  Cache-Control: max-age=0, private, must-revalidate
  < Set-Cookie: has_recent_activity=1; path=/; expires=Tue, 05 Mar 2019 12:00:12 -0000
  Set-Cookie: has_recent_activity=1; path=/; expires=Tue, 05 Mar 2019 12:00:12 -0000
  < Set-Cookie: _octo=GH1.1.320186990.1551783612; domain=.github.com; path=/; expires=Fri, 05 Mar 2021 11:00:12 -0000
  Set-Cookie: _octo=GH1.1.320186990.1551783612; domain=.github.com; path=/; expires=Fri, 05 Mar 2021 11:00:12 -0000
  < Set-Cookie: logged_in=no; domain=.github.com; path=/; expires=Sat, 05 Mar 2039 11:00:12 -0000; secure; HttpOnly
  Set-Cookie: logged_in=no; domain=.github.com; path=/; expires=Sat, 05 Mar 2039 11:00:12 -0000; secure; HttpOnly
  < Set-Cookie: _gh_sess=aVhCRytZY3VmRHdvWFV2aCtYZUNGUFRkL3dkTEIwRThWV2lUcC8xdlRUeW5sd3NDNTgyK3pUb1JDeGtRalVoU29TUUtsRjcwQldaVnBmcWNmWGs5TDZ3bjFGUXVDUGpESmJ0MVJYenE4L3ExejhjTVByY08xK01pU1hTRE40dExLVStBRjlWVWJYZ3RIMG9PTnJPNnhuSjQ1S1NNTzMrbmJZWkQxc3E2cU5tUml5b2psc1NlOVpBK3plQ01weTV5UTNTVU93a3oxS0V0bkQ0L2ZHRXNyUT09LS0xUHdYUjFuRDNmQ2kzSUo5dnlBV0VBPT0%3D--f23d58ffe1fe58e363c6ec7dfa00adc64287e53b; path=/; secure; HttpOnly
  Set-Cookie: _gh_sess=aVhCRytZY3VmRHdvWFV2aCtYZUNGUFRkL3dkTEIwRThWV2lUcC8xdlRUeW5sd3NDNTgyK3pUb1JDeGtRalVoU29TUUtsRjcwQldaVnBmcWNmWGs5TDZ3bjFGUXVDUGpESmJ0MVJYenE4L3ExejhjTVByY08xK01pU1hTRE40dExLVStBRjlWVWJYZ3RIMG9PTnJPNnhuSjQ1S1NNTzMrbmJZWkQxc3E2cU5tUml5b2psc1NlOVpBK3plQ01weTV5UTNTVU93a3oxS0V0bkQ0L2ZHRXNyUT09LS0xUHdYUjFuRDNmQ2kzSUo5dnlBV0VBPT0%3D--f23d58ffe1fe58e363c6ec7dfa00adc64287e53b; path=/; secure; HttpOnly
  < X-Request-Id: 34295258-8eb5-4678-992f-79dbab314bc0
  X-Request-Id: 34295258-8eb5-4678-992f-79dbab314bc0
  < Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
  Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
  < X-Frame-Options: deny
  X-Frame-Options: deny
  < X-Content-Type-Options: nosniff
  X-Content-Type-Options: nosniff
  < X-XSS-Protection: 1; mode=block
  X-XSS-Protection: 1; mode=block
  < Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
  Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
  < Expect-CT: max-age=2592000, report-uri="https://api.github.com/_private/browser/errors"
  Expect-CT: max-age=2592000, report-uri="https://api.github.com/_private/browser/errors"
  < Content-Security-Policy: default-src 'none'; base-uri 'self'; block-all-mixed-content; connect-src 'self' uploads.github.com www.githubstatus.com collector.githubapp.com api.github.com www.google-analytics.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com wss://live.github.com; font-src github.githubassets.com; form-action 'self' github.com gist.github.com; frame-ancestors 'none'; frame-src render.githubusercontent.com; img-src 'self' data: github.githubassets.com identicons.github.com collector.githubapp.com github-cloud.s3.amazonaws.com *.githubusercontent.com; manifest-src 'self'; media-src 'none'; script-src github.githubassets.com; style-src 'unsafe-inline' github.githubassets.com
  Content-Security-Policy: default-src 'none'; base-uri 'self'; block-all-mixed-content; connect-src 'self' uploads.github.com www.githubstatus.com collector.githubapp.com api.github.com www.google-analytics.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com wss://live.github.com; font-src github.githubassets.com; form-action 'self' github.com gist.github.com; frame-ancestors 'none'; frame-src render.githubusercontent.com; img-src 'self' data: github.githubassets.com identicons.github.com collector.githubapp.com github-cloud.s3.amazonaws.com *.githubusercontent.com; manifest-src 'self'; media-src 'none'; script-src github.githubassets.com; style-src 'unsafe-inline' github.githubassets.com
  < X-GitHub-Request-Id: 71BE:48F2:1117C18:1FE14AE:5C7E56BC
  X-GitHub-Request-Id: 71BE:48F2:1117C18:1FE14AE:5C7E56BC

  < 
  * Connection #0 to host github.com left intact
  ```
- ```console
  ➜ curl -Iv https://github.com.../
  * Could not resolve host: github.com...
  * Closing connection 0
  curl: (6) Could not resolve host: github.com...
  ```

So it looks like there's no agreement on what clients should do but a single-dot case is handled gracefully.
Ref: https://github.com/curl/curl/issues/716
SNI note: https://tools.ietf.org/html/rfc6066#section-3

> [...] The hostname is represented as a byte
   string using ASCII encoding without a trailing dot. [...]
> the discussion here https://bugs.python.org/issue31997

Right, this clears things up about who should handle trailing dot which is application layer, according to @tiran. This seems fair.

Another excerpt:
> IMO the problem should be handled in high level libraries such as urllib. urllib should use the FQDN with trailing dot for DNS resolution, then strip off the trailing dot and use the FQDN for HTTP Host header and server_hostname.
Verdict
=====

`aiohttp.web` client should probably do manual ceretificate verification against a name with trailing dot stripped. But it still should use whatever user provided to peform the DNS relolution.




Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -75,7 +75,6 @@
     "aiohttpsession": ("https://aiohttp-session.readthedocs.io/en/stable/", None),
     "aiohttpdemos": ("https://aiohttp-demos.readthedocs.io/en/latest/", None),
     "aiojobs": ("https://aiojobs.readthedocs.io/en/stable/", None),
-    "asynctest": ("https://asynctest.readthedocs.io/en/latest/", None),
 }
 
 # Add any paths that contain templates here, relative to this directory.


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -432,6 +432,25 @@ def test_ceil_call_later() -> None:
     loop.call_at.assert_called_with(21.0, cb)
 
 
+async def test_ceil_timeout_round(loop) -> None:
+    async with helpers.ceil_timeout(7.5) as cm:
+        if sys.version_info >= (3, 11):
+            frac, integer = modf(cm.when())
+        else:
+            frac, integer = modf(cm.deadline)
+        assert frac == 0
+
+
+async def test_ceil_timeout_small(loop) -> None:
+    async with helpers.ceil_timeout(1.1) as cm:
+        if sys.version_info >= (3, 11):
+            frac, integer = modf(cm.when())
+        else:
+            frac, integer = modf(cm.deadline)
+        # a chance for exact integer with zero fraction is negligible
+        assert frac != 0
+
+
 def test_ceil_call_later_with_small_threshold() -> None:
     cb = mock.Mock()
     loop = mock.Mock()
@@ -455,25 +474,6 @@ async def test_ceil_timeout_none(loop) -> None:
             assert cm.deadline is None
 
 
-async def test_ceil_timeout_round(loop) -> None:
-    async with helpers.ceil_timeout(7.5) as cm:
-        if sys.version_info >= (3, 11):
-            frac, integer = modf(cm.when())
-        else:
-            frac, integer = modf(cm.deadline)
-        assert frac == 0
-
-
-async def test_ceil_timeout_small(loop) -> None:
-    async with helpers.ceil_timeout(1.1) as cm:
-        if sys.version_info >= (3, 11):
-            frac, integer = modf(cm.when())
-        else:
-            frac, integer = modf(cm.deadline)
-        # a chance for exact integer with zero fraction is negligible
-        assert frac != 0
-
-
 async def test_ceil_timeout_small_with_overriden_threshold(loop) -> None:
     async with helpers.ceil_timeout(1.5, ceil_threshold=1) as cm:
         if sys.version_info >= (3, 11):
diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py
--- a/tests/test_web_urldispatcher.py
+++ b/tests/test_web_urldispatcher.py
@@ -1,4 +1,5 @@
 import asyncio
+import functools
 import pathlib
 from typing import Optional
 from unittest import mock
@@ -275,6 +276,21 @@ async def test_access_special_resource(
         assert r.status == 403
 
 
+async def test_partially_applied_handler(aiohttp_client: AiohttpClient) -> None:
+    app = web.Application()
+
+    async def handler(data: bytes, request: web.Request) -> web.Response:
+        return web.Response(body=data)
+
+    app.router.add_route("GET", "/", functools.partial(handler, b"hello"))
+
+    client = await aiohttp_client(app)
+
+    r = await client.get("/")
+    data = await r.read()
+    assert data == b"hello"
+
+
 async def test_static_head(
     tmp_path: pathlib.Path, aiohttp_client: AiohttpClient
 ) -> None: