From 86c0191b6705ac387be7e1b9a161ab325e4865e8 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 1 Aug 2024 19:45:51 +0200 Subject: [PATCH] test: client-certificate follow-ups (#2508) --- playwright/_impl/_element_handle.py | 3 - playwright/_impl/_frame.py | 3 - playwright/_impl/_locator.py | 6 +- playwright/_impl/_page.py | 2 - .../client/self-signed/cert.pem | 28 ++++ .../client/self-signed/csr.pem | 26 ++++ .../client/self-signed/key.pem | 52 ++++++++ ...test_browsercontext_client_certificates.py | 126 +++++++++++++++--- ...test_browsercontext_client_certificates.py | 119 ++++++++++++++--- 9 files changed, 319 insertions(+), 46 deletions(-) create mode 100644 tests/assets/client-certificates/client/self-signed/cert.pem create mode 100644 tests/assets/client-certificates/client/self-signed/csr.pem create mode 100644 tests/assets/client-certificates/client/self-signed/key.pem diff --git a/playwright/_impl/_element_handle.py b/playwright/_impl/_element_handle.py index 74e5bdf..39e43a6 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -206,7 +206,6 @@ class ElementHandle(JSHandle): "setInputFiles", { "timeout": timeout, - "noWaitAfter": noWaitAfter, **converted, }, ) @@ -246,7 +245,6 @@ class ElementHandle(JSHandle): position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) else: @@ -254,7 +252,6 @@ class ElementHandle(JSHandle): position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index bfeef14..7dcfe0f 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -703,7 +703,6 @@ class Frame(ChannelOwner): "selector": selector, "strict": strict, "timeout": timeout, - "noWaitAfter": noWaitAfter, **converted, }, ) @@ -792,7 +791,6 @@ class Frame(ChannelOwner): position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) @@ -802,7 +800,6 @@ class Frame(ChannelOwner): position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 0213ff9..5218979 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -213,7 +213,7 @@ class Locator: noWaitAfter: bool = None, force: bool = None, ) -> None: - await self.fill("", timeout=timeout, noWaitAfter=noWaitAfter, force=force) + await self.fill("", timeout=timeout, force=force) def locator( self, @@ -631,7 +631,7 @@ class Locator: timeout: float = None, noWaitAfter: bool = None, ) -> None: - await self.type(text, delay=delay, timeout=timeout, noWaitAfter=noWaitAfter) + await self.type(text, delay=delay, timeout=timeout) async def uncheck( self, @@ -685,7 +685,6 @@ class Locator: position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) else: @@ -693,7 +692,6 @@ class Locator: position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 97af978..88c6da7 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -1279,7 +1279,6 @@ class Page(ChannelOwner): position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) @@ -1289,7 +1288,6 @@ class Page(ChannelOwner): position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) diff --git a/tests/assets/client-certificates/client/self-signed/cert.pem b/tests/assets/client-certificates/client/self-signed/cert.pem new file mode 100644 index 0000000..3c07717 --- /dev/null +++ b/tests/assets/client-certificates/client/self-signed/cert.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEyzCCArOgAwIBAgIUYps4gh4MqFYg8zqQhHYL7zYfbLkwDQYJKoZIhvcNAQEL +BQAwDjEMMAoGA1UEAwwDQm9iMB4XDTI0MDcxOTEyNDc0MFoXDTI1MDcxOTEyNDc0 +MFowDjEMMAoGA1UEAwwDQm9iMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC +AgEA179eTsqcc1c3AOQHzCZEyYLPta2CCAscUFqcEZ9vWvjW0uzOv9TDlB33Unov +jch4CElZOBhzTadVsbmnYKpxwyVU89WCuQKvedz4k1vu7S1YryfNbmS8PWbnQ4ds +9NB7SgJNHZILvx9DXuWeFEyzRIo1984z4HheBzrkf791LqpYKaKziANUo8h8t0dm +TX/boOz8cEnQNwtTC0ZX3aD0obG/UAhr/22ZGPo/E659fh4ptyYX2LrIUHGy+Eux +nJ9Y4cTqa88Ee6K6AkDiT/AoNQNxE4X++jqLuie8j/ZYpI1Oll38GwKVOyy1msRL +toGmISNwkMIQDGABrJlxgpP4QQAQ+08v9srzXOlkdxdr7OCP81r+ccBXiSQEe7BA +kdJ8l98l5dprJ++GJ+SZcV4+/iGR0dKU2IdAG5HiKZIFn6ch9Ux+UMqeGaYCpkHr +TiietHwcXgtVBlE0jFmB/HspmI/O0abK+grMmueaH7XtTI8YHnw0mUpL8+yp7mfA +7zFusgFgyiBPXeD/NQgg8vja67k++d1VGoXm2xr+5WPQCSbgQoMkkOBMLHWJTefd +6F4Z5M+oI0VwYbf6eQW246wJgpCHSPR0Vdijd6MAGRWKUuLfDsA9+12iGbKvwJ2e +nJlStft2V2LZcjBfdIMbigW1aSVNN5w6m6YVrQPry3WPkWcCAwEAAaMhMB8wHQYD +VR0OBBYEFPxKWTFQJSg4HD2qjxL0dnXX/z4qMA0GCSqGSIb3DQEBCwUAA4ICAQBz +4H1d5eGRU9bekUvi7LbZ5CP/I6w6PL/9AlXqO3BZKxplK7fYGHd3uqyDorJEsvjV +hxwvFlEnS0JIU3nRzhJU/h4Yaivf1WLRFwGZ4TPBjX9KFU27exFWD3rppazkWybJ +i4WuEdP3TJMdKLcNTtXWUDroDOgPlS66u6oZ+mUyUROil+B+fgQgVDhjRc5fvRgZ +Lng8wuejCo3ExQyxkwn2G5guyIimgHmOQghPtLO5xlc67Z4GPUZ1m4tC+BCiFO4D +YIXl3QiIpmU7Pss39LLKMGXXAgLRqyMzqE52lsznu18v5vDLfTaRH4u/wjzULhXz +SrV1IUJmhgEXta4EeDmPH0itgKtkbwjgCOD7drrFrJq/EnvIaJ5cpxiI1pFmYD8g +VVD7/KT/CyT1Uz1dI8QaP/JX8XEgtMJaSkPfjPErIViN9rh9ECCNLgFyv7Y0Plar +A6YlvdyV1Rta/BHndf5Hqz9QWNhbFCMQRGVQNEcoKwpFyjAE9SXoKJvFIK/w5WXu +qKzIYA26QXE3p734Xu1n8QiFJIyltVHbyUlD0k06194t5a2WK+/eDeReIsk0QOI8 +FGqhyPZ7YjR5tSZTmgljtViqBO5AA23QOVFqtjOUrjXP5pTbPJel99Z/FTkqSwvB +Rt4OX7HfuokWQDTT0TMn5jVtJyi54cH7f9MmsNJ23g== +-----END CERTIFICATE----- diff --git a/tests/assets/client-certificates/client/self-signed/csr.pem b/tests/assets/client-certificates/client/self-signed/csr.pem new file mode 100644 index 0000000..4c99e13 --- /dev/null +++ b/tests/assets/client-certificates/client/self-signed/csr.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIEUzCCAjsCAQAwDjEMMAoGA1UEAwwDQm9iMIICIjANBgkqhkiG9w0BAQEFAAOC +Ag8AMIICCgKCAgEA179eTsqcc1c3AOQHzCZEyYLPta2CCAscUFqcEZ9vWvjW0uzO +v9TDlB33Unovjch4CElZOBhzTadVsbmnYKpxwyVU89WCuQKvedz4k1vu7S1YryfN +bmS8PWbnQ4ds9NB7SgJNHZILvx9DXuWeFEyzRIo1984z4HheBzrkf791LqpYKaKz +iANUo8h8t0dmTX/boOz8cEnQNwtTC0ZX3aD0obG/UAhr/22ZGPo/E659fh4ptyYX +2LrIUHGy+EuxnJ9Y4cTqa88Ee6K6AkDiT/AoNQNxE4X++jqLuie8j/ZYpI1Oll38 +GwKVOyy1msRLtoGmISNwkMIQDGABrJlxgpP4QQAQ+08v9srzXOlkdxdr7OCP81r+ +ccBXiSQEe7BAkdJ8l98l5dprJ++GJ+SZcV4+/iGR0dKU2IdAG5HiKZIFn6ch9Ux+ +UMqeGaYCpkHrTiietHwcXgtVBlE0jFmB/HspmI/O0abK+grMmueaH7XtTI8YHnw0 +mUpL8+yp7mfA7zFusgFgyiBPXeD/NQgg8vja67k++d1VGoXm2xr+5WPQCSbgQoMk +kOBMLHWJTefd6F4Z5M+oI0VwYbf6eQW246wJgpCHSPR0Vdijd6MAGRWKUuLfDsA9 ++12iGbKvwJ2enJlStft2V2LZcjBfdIMbigW1aSVNN5w6m6YVrQPry3WPkWcCAwEA +AaAAMA0GCSqGSIb3DQEBCwUAA4ICAQCb07d2IjUy1PeHCj/2k/z9FrZSo6K3c8y6 +b/u/MZ0AXPKLPDSo7UYpOJ8Z2cBiJ8jQapjTSEL8POUYqcvCmP55R6u68KmvINHo ++Ly7pP+xPrbA4Q0WmPnz37hQn+I1he0GuEQyjZZqUln9zwp67TsWNKxKtCH+1j8M +Ltzx6kuHCdPtDUtv291yhVRqvbjiDs+gzdQYNJtAkUbHwHFxu8oZhg8QZGyXYMN8 +TGoQ1LTezFZXJtX69K7WnrDGrjsgB6EMvwkqAFSYNH0LFvI0xo13OOgXr9mrwohA +76uZtjXL9B15EqrMce6mdUZi46QJuQ2avTi57Lz+fqvsBYdQO89VcFSmqu2nfspN +QZDrooyjHrlls8MpoBd8fde9oT4uA4/d9SJtuHUnjgGN7Qr7eTruWXL8wVMwFnvL +igWE4detO9y2gpRLq6uEqzWYMGtN9PXJCGU8C8m9E2EBUKMrT/bpNbboatLcgRrW +acj0BRVqoVzk1sRq7Sa6ejywqgARvIhTehg6DqdMdcENCPQ7rxDRu5PSDM8/mwIj +0KYl8d2PlECB4ofRyLcy17BZzjP6hSnkGzcFk0/bChZOSIRnwvKbvfXnB45hhPk8 +XwT/6UNSwC2STP3gtOmLqrWj+OE0gy0AkDMvP3UnQVGMUvgfYg+N4ROCVtlqzxe9 +W65c05Mm1g== +-----END CERTIFICATE REQUEST----- diff --git a/tests/assets/client-certificates/client/self-signed/key.pem b/tests/assets/client-certificates/client/self-signed/key.pem new file mode 100644 index 0000000..70d5e3d --- /dev/null +++ b/tests/assets/client-certificates/client/self-signed/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +dg6m3MAyZ1IXjHlogOdmGvnq07/w +-----END PRIVATE KEY----- diff --git a/tests/async/test_browsercontext_client_certificates.py b/tests/async/test_browsercontext_client_certificates.py index 14892ec..6e223b9 100644 --- a/tests/async/test_browsercontext_client_certificates.py +++ b/tests/async/test_browsercontext_client_certificates.py @@ -15,16 +15,20 @@ import sys import threading from pathlib import Path -from typing import Dict, Generator, cast +from typing import Dict, Generator, Optional, cast +import OpenSSL.crypto +import OpenSSL.SSL import pytest from twisted.internet import reactor as _twisted_reactor from twisted.internet import ssl from twisted.internet.selectreactor import SelectReactor from twisted.web import resource, server +from twisted.web.http import Request -from playwright.async_api import Browser, BrowserType, Playwright, Request, expect +from playwright.async_api import Browser, BrowserType, Playwright, expect +ssl.optionsForClientTLS reactor = cast(SelectReactor, _twisted_reactor) @@ -34,17 +38,61 @@ def _skip_webkit_darwin(browser_name: str) -> None: pytest.skip("WebKit does not proxy localhost on macOS") -class Simple(resource.Resource): +class HttpsResource(resource.Resource): + serverCertificate: ssl.PrivateCertificate isLeaf = True + def _verify_cert_chain(self, cert: Optional[OpenSSL.crypto.X509]) -> bool: + if not cert: + return False + store = OpenSSL.crypto.X509Store() + store.add_cert(self.serverCertificate.original) + store_ctx = OpenSSL.crypto.X509StoreContext(store, cert) + try: + store_ctx.verify_certificate() + return True + except OpenSSL.crypto.X509StoreContextError: + return False + def render_GET(self, request: Request) -> bytes: - return b"Hello, world!" + tls_socket: OpenSSL.SSL.Connection = request.transport.getHandle() # type: ignore + cert = tls_socket.get_peer_certificate() + parts = [] + + if self._verify_cert_chain(cert): + request.setResponseCode(200) + parts.append( + { + "key": "message", + "value": f"Hello {cert.get_subject().CN}, your certificate was issued by {cert.get_issuer().CN}!", # type: ignore + } + ) + elif cert and cert.get_subject(): + request.setResponseCode(403) + parts.append( + { + "key": "message", + "value": f"Sorry {cert.get_subject().CN}, certificates from {cert.get_issuer().CN} are not welcome here.", + } + ) + else: + request.setResponseCode(401) + parts.append( + { + "key": "message", + "value": "Sorry, but you need to provide a client certificate to continue.", + } + ) + return b"".join( + [ + f'
{part["value"]}
'.encode() + for part in parts + ] + ) @pytest.fixture(scope="session", autouse=True) def _client_certificate_server(assetdir: Path) -> Generator[None, None, None]: - server.Site(Simple()) - certAuthCert = ssl.Certificate.loadPEM( (assetdir / "client-certificates/server/server_cert.pem").read_text() ) @@ -54,7 +102,10 @@ def _client_certificate_server(assetdir: Path) -> Generator[None, None, None]: ) contextFactory = serverCert.options(certAuthCert) - site = server.Site(Simple()) + contextFactory.requireCertificate = False + resource = HttpsResource() + resource.serverCertificate = serverCert + site = server.Site(resource) def _run() -> None: reactor.listenSSL(8000, site, contextFactory) @@ -65,6 +116,27 @@ def _client_certificate_server(assetdir: Path) -> Generator[None, None, None]: thread.join() +async def test_should_throw_with_untrusted_client_certs( + playwright: Playwright, assetdir: Path +) -> None: + serverURL = "https://localhost:8000/" + request = await playwright.request.new_context( + # TODO: Remove this once we can pass a custom CA. + ignore_https_errors=True, + client_certificates=[ + { + "origin": serverURL, + "certPath": assetdir + / "client-certificates/client/self-signed/cert.pem", + "keyPath": assetdir / "client-certificates/client/self-signed/key.pem", + } + ], + ) + with pytest.raises(Exception, match="alert unknown ca"): + await request.get(serverURL) + await request.dispose() + + async def test_should_work_with_new_context(browser: Browser, assetdir: Path) -> None: context = await browser.new_context( # TODO: Remove this once we can pass a custom CA. @@ -79,14 +151,24 @@ async def test_should_work_with_new_context(browser: Browser, assetdir: Path) -> ) page = await context.new_page() await page.goto("https://localhost:8000") - await expect(page.get_by_text("alert certificate required")).to_be_visible() + await expect(page.get_by_test_id("message")).to_have_text( + "Sorry, but you need to provide a client certificate to continue." + ) await page.goto("https://127.0.0.1:8000") - await expect(page.get_by_text("Hello, world!")).to_be_visible() + await expect(page.get_by_test_id("message")).to_have_text( + "Hello Alice, your certificate was issued by localhost!" + ) - with pytest.raises(Exception, match="alert certificate required"): - await page.context.request.get("https://localhost:8000") + response = await page.context.request.get("https://localhost:8000") + assert ( + "Sorry, but you need to provide a client certificate to continue." + in await response.text() + ) response = await page.context.request.get("https://127.0.0.1:8000") - assert "Hello, world!" in await response.text() + assert ( + "Hello Alice, your certificate was issued by localhost!" + in await response.text() + ) await context.close() @@ -108,9 +190,13 @@ async def test_should_work_with_new_persistent_context( ) page = await context.new_page() await page.goto("https://localhost:8000") - await expect(page.get_by_text("alert certificate required")).to_be_visible() + await expect(page.get_by_test_id("message")).to_have_text( + "Sorry, but you need to provide a client certificate to continue." + ) await page.goto("https://127.0.0.1:8000") - await expect(page.get_by_text("Hello, world!")).to_be_visible() + await expect(page.get_by_test_id("message")).to_have_text( + "Hello Alice, your certificate was issued by localhost!" + ) await context.close() @@ -128,8 +214,14 @@ async def test_should_work_with_global_api_request_context( } ], ) - with pytest.raises(Exception, match="alert certificate required"): - await request.get("https://localhost:8000") + response = await request.get("https://localhost:8000") + assert ( + "Sorry, but you need to provide a client certificate to continue." + in await response.text() + ) response = await request.get("https://127.0.0.1:8000") - assert "Hello, world!" in await response.text() + assert ( + "Hello Alice, your certificate was issued by localhost!" + in await response.text() + ) await request.dispose() diff --git a/tests/sync/test_browsercontext_client_certificates.py b/tests/sync/test_browsercontext_client_certificates.py index 442540e..601d6ea 100644 --- a/tests/sync/test_browsercontext_client_certificates.py +++ b/tests/sync/test_browsercontext_client_certificates.py @@ -15,15 +15,18 @@ import sys import threading from pathlib import Path -from typing import Dict, Generator, cast +from typing import Dict, Generator, Optional, cast +import OpenSSL.crypto +import OpenSSL.SSL import pytest from twisted.internet import reactor as _twisted_reactor from twisted.internet import ssl from twisted.internet.selectreactor import SelectReactor from twisted.web import resource, server +from twisted.web.http import Request -from playwright.sync_api import Browser, BrowserType, Playwright, Request, expect +from playwright.sync_api import Browser, BrowserType, Playwright, expect reactor = cast(SelectReactor, _twisted_reactor) @@ -34,17 +37,61 @@ def _skip_webkit_darwin(browser_name: str) -> None: pytest.skip("WebKit does not proxy localhost on macOS") -class Simple(resource.Resource): +class HttpsResource(resource.Resource): + serverCertificate: ssl.PrivateCertificate isLeaf = True + def _verify_cert_chain(self, cert: Optional[OpenSSL.crypto.X509]) -> bool: + if not cert: + return False + store = OpenSSL.crypto.X509Store() + store.add_cert(self.serverCertificate.original) + store_ctx = OpenSSL.crypto.X509StoreContext(store, cert) + try: + store_ctx.verify_certificate() + return True + except OpenSSL.crypto.X509StoreContextError: + return False + def render_GET(self, request: Request) -> bytes: - return b"Hello, world!" + tls_socket: OpenSSL.SSL.Connection = request.transport.getHandle() # type: ignore + cert = tls_socket.get_peer_certificate() + parts = [] + + if self._verify_cert_chain(cert): + request.setResponseCode(200) + parts.append( + { + "key": "message", + "value": f"Hello {cert.get_subject().CN}, your certificate was issued by {cert.get_issuer().CN}!", # type: ignore + } + ) + elif cert and cert.get_subject(): + request.setResponseCode(403) + parts.append( + { + "key": "message", + "value": f"Sorry {cert.get_subject().CN}, certificates from {cert.get_issuer().CN} are not welcome here.", + } + ) + else: + request.setResponseCode(401) + parts.append( + { + "key": "message", + "value": "Sorry, but you need to provide a client certificate to continue.", + } + ) + return b"".join( + [ + f'
{part["value"]}
'.encode() + for part in parts + ] + ) @pytest.fixture(scope="session", autouse=True) def _client_certificate_server(assetdir: Path) -> Generator[None, None, None]: - server.Site(Simple()) - certAuthCert = ssl.Certificate.loadPEM( (assetdir / "client-certificates/server/server_cert.pem").read_text() ) @@ -54,7 +101,10 @@ def _client_certificate_server(assetdir: Path) -> Generator[None, None, None]: ) contextFactory = serverCert.options(certAuthCert) - site = server.Site(Simple()) + contextFactory.requireCertificate = False + resource = HttpsResource() + resource.serverCertificate = serverCert + site = server.Site(resource) def _run() -> None: reactor.listenSSL(8000, site, contextFactory) @@ -65,6 +115,27 @@ def _client_certificate_server(assetdir: Path) -> Generator[None, None, None]: thread.join() +def test_should_throw_with_untrusted_client_certs( + playwright: Playwright, assetdir: Path +) -> None: + serverURL = "https://localhost:8000/" + request = playwright.request.new_context( + # TODO: Remove this once we can pass a custom CA. + ignore_https_errors=True, + client_certificates=[ + { + "origin": serverURL, + "certPath": assetdir + / "client-certificates/client/self-signed/cert.pem", + "keyPath": assetdir / "client-certificates/client/self-signed/key.pem", + } + ], + ) + with pytest.raises(Exception, match="alert unknown ca"): + request.get(serverURL) + request.dispose() + + def test_should_work_with_new_context(browser: Browser, assetdir: Path) -> None: context = browser.new_context( # TODO: Remove this once we can pass a custom CA. @@ -79,14 +150,21 @@ def test_should_work_with_new_context(browser: Browser, assetdir: Path) -> None: ) page = context.new_page() page.goto("https://localhost:8000") - expect(page.get_by_text("alert certificate required")).to_be_visible() + expect(page.get_by_test_id("message")).to_have_text( + "Sorry, but you need to provide a client certificate to continue." + ) page.goto("https://127.0.0.1:8000") - expect(page.get_by_text("Hello, world!")).to_be_visible() + expect(page.get_by_test_id("message")).to_have_text( + "Hello Alice, your certificate was issued by localhost!" + ) - with pytest.raises(Exception, match="alert certificate required"): - page.context.request.get("https://localhost:8000") + response = page.context.request.get("https://localhost:8000") + assert ( + "Sorry, but you need to provide a client certificate to continue." + in response.text() + ) response = page.context.request.get("https://127.0.0.1:8000") - assert "Hello, world!" in response.text() + assert "Hello Alice, your certificate was issued by localhost!" in response.text() context.close() @@ -108,9 +186,13 @@ def test_should_work_with_new_persistent_context( ) page = context.new_page() page.goto("https://localhost:8000") - expect(page.get_by_text("alert certificate required")).to_be_visible() + expect(page.get_by_test_id("message")).to_have_text( + "Sorry, but you need to provide a client certificate to continue." + ) page.goto("https://127.0.0.1:8000") - expect(page.get_by_text("Hello, world!")).to_be_visible() + expect(page.get_by_test_id("message")).to_have_text( + "Hello Alice, your certificate was issued by localhost!" + ) context.close() @@ -128,8 +210,11 @@ def test_should_work_with_global_api_request_context( } ], ) - with pytest.raises(Exception, match="alert certificate required"): - request.get("https://localhost:8000") + response = request.get("https://localhost:8000") + assert ( + "Sorry, but you need to provide a client certificate to continue." + in response.text() + ) response = request.get("https://127.0.0.1:8000") - assert "Hello, world!" in response.text() + assert "Hello Alice, your certificate was issued by localhost!" in response.text() request.dispose()