def test_side_effect_list(): router = Router() route = router.get("https://foo.bar/").mock( return_value=httpx.Response(409), side_effect=[httpx.Response(404), httpcore.NetworkError, httpx.Response(201)], ) request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 404 assert response.request == request request = httpx.Request("GET", "https://foo.bar") with pytest.raises(httpcore.NetworkError): router.handler(request) request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 201 assert response.request == request with pytest.raises(StopIteration): request = httpx.Request("GET", "https://foo.bar") router.handler(request) route.side_effect = None request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 409 assert response.request == request
async def test_verify_error(tmp_path: Path, client: AsyncClient, respx_mock: respx.Router) -> None: config = await configure(tmp_path, "oidc") token = await create_upstream_oidc_token(groups=["admin"]) assert config.oidc issuer = config.oidc.issuer config_url = urljoin(issuer, "/.well-known/openid-configuration") jwks_url = urljoin(issuer, "/.well-known/jwks.json") respx_mock.get(config_url).respond(404) respx_mock.get(jwks_url).respond(404) await mock_oidc_provider_token(respx_mock, "some-code", token) return_url = "https://example.com/foo" r = await client.get("/login", params={"rd": return_url}) assert r.status_code == 307 url = urlparse(r.headers["Location"]) query = parse_qs(url.query) # Returning from OpenID Connect login should fail because we haven't # registered the signing key, and therefore attempting to retrieve it will # fail, causing a token verification error. r = await client.get("/login", params={ "code": "some-code", "state": query["state"][0] }) assert r.status_code == 403 assert "token verification failed" in r.text
def test_mod_response(): router = Router() route1a = router.get("https://foo.bar/baz/") % 409 route1b = router.get("https://foo.bar/baz/") % 404 route2 = router.get("https://foo.bar") % dict(status_code=201) route3 = router.post("https://fox.zoo/") % httpx.Response(401, json={"error": "x"}) request = httpx.Request("GET", "https://foo.bar/baz/") resolved = router.resolve(request) assert resolved.response.status_code == 404 assert resolved.route is route1b assert route1a is route1b request = httpx.Request("GET", "https://foo.bar/") resolved = router.resolve(request) assert resolved.response.status_code == 201 assert resolved.route is route2 request = httpx.Request("POST", "https://fox.zoo/") resolved = router.resolve(request) assert resolved.response.status_code == 401 assert resolved.response.json() == {"error": "x"} assert resolved.route is route3 with pytest.raises(TypeError, match="Route can only"): router.route() % []
async def mock_oidc_provider_config( respx_mock: respx.Router, keypair: Optional[RSAKeyPair] = None, kid: Optional[str] = None, ) -> None: """Mock out the API for the upstream OpenID Connect provider. Parameters ---------- respx_mock : `respx.Router` The mock router. keypair : `gafaelfawr.keypair.RSAKeyPair`, optional The keypair to use. Defaults to the configured issuer keypair. kid : `str`, optional The key ID to return. Defaults to the first key ID in the configuration. """ config = await config_dependency() assert config.oidc mock = MockOIDCConfig(config, keypair, kid) issuer = config.oidc.issuer config_url = urljoin(issuer, "/.well-known/openid-configuration") respx_mock.get(config_url).mock(side_effect=mock.get_config) jwks_url = urljoin(issuer, "/jwks.json") respx_mock.get(jwks_url).mock(side_effect=mock.get_jwks)
def test_url_pattern_lookup(lookups, url, expected): router = Router(assert_all_mocked=False) route = router.get(**lookups) % 418 request = httpx.Request("GET", url) response = router.handler(request) assert bool(response.status_code == 418) is expected assert route.called is expected
async def test_empty_router(): router = Router() request = httpx.Request("GET", "https://example.org/") with pytest.raises(AllMockedAssertionError): router.resolve(request) with pytest.raises(AllMockedAssertionError): await router.aresolve(request)
def test_mod_response(): router = Router() route1a = router.get("https://foo.bar") % 404 route1b = router.get("https://foo.bar") % dict(status_code=201) route2 = router.get("https://ham.spam/egg/") % MockResponse(202) route3 = router.post("https://fox.zoo/") % httpx.Response( 401, json={"error": "x"}) request = httpx.Request("GET", "https://foo.bar") matched_route, response = router.match(request) assert response.status_code == 404 assert matched_route is route1a request = httpx.Request("GET", "https://foo.bar") matched_route, response = router.match(request) assert response.status_code == 201 assert matched_route is route1b assert route1a is route1b request = httpx.Request("GET", "https://ham.spam/egg/") matched_route, response = router.match(request) assert response.status_code == 202 assert matched_route is route2 request = httpx.Request("POST", "https://fox.zoo/") matched_route, response = router.match(request) assert response.status_code == 401 assert response.json() == {"error": "x"} assert matched_route is route3
async def test_async_side_effect(): router = Router() async def effect(request): return httpx.Response(204) router.get("https://foo.bar/").mock(side_effect=effect) request = httpx.Request("GET", "https://foo.bar/") response = await router.async_handler(request) assert response.status_code == 204
def test_base_url(url, lookups, expected): router = Router(base_url="https://foo.bar/api/", assert_all_mocked=False) route = router.get(**lookups).respond(201) request = httpx.Request("GET", url) resolved = router.resolve(request) assert bool(resolved.route is route) is expected if expected: assert bool(resolved.response.status_code == 201) is expected else: assert resolved.response.status_code == 200 # auto mocked
async def test_empty_router__auto_mocked(): router = Router(assert_all_mocked=False) request = httpx.Request("GET", "https://example.org/") resolved = router.resolve(request) assert resolved.route is None assert resolved.response.status_code == 200 resolved = await router.aresolve(request) assert resolved.route is None assert resolved.response.status_code == 200
def test_resolve(args, kwargs, expected): router = Router(assert_all_mocked=False) route = router.route(*args, **kwargs).respond(status_code=201) request = httpx.Request( "GET", "https://foo.bar/baz/", cookies={"foo": "bar", "ham": "spam"} ) resolved = router.resolve(request) assert bool(resolved.route is route) is expected if expected: assert bool(resolved.response.status_code == 201) is expected else: assert resolved.response.status_code == 200 # auto mocked
def test_side_effect_with_reserved_route_kwarg(): router = Router() def foobar(request, route): assert isinstance(route, Route) return httpx.Response(202) router.get(path__regex=r"/(?P<route>\w+)/").mock(side_effect=foobar) with warnings.catch_warnings(record=True) as w: request = httpx.Request("GET", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 202 assert len(w) == 1
def test_base_url(url, expected): router = Router(base_url="https://foo.bar/", assert_all_mocked=False) route = router.route(method="GET", url="/baz/").respond(201) request = httpx.Request("GET", url) matched_route, response = router.match(request) assert bool(matched_route is route) is expected if expected: assert bool(response.status_code == 201) is expected else: assert not response response = router.resolve(request) assert bool(response.status_code == 201) is expected
async def mock_oidc_provider_token(respx_mock: respx.Router, code: str, token: OIDCToken) -> None: """Mock out the API for the upstream OpenID Connect provider. Parameters ---------- respx_mock : `respx.Router` The mock router. code : `str` The code that Gafaelfawr must send to redeem for a token. token : `gafaelfawr.models.oidc.OIDCToken` The token to return after authentication. """ config = await config_dependency() assert config.oidc mock = MockOIDCToken(config, code, token) respx_mock.post(config.oidc.token_url).mock(side_effect=mock.post_token)
def test_pass_through(): router = Router(assert_all_mocked=False) route = router.route(method="GET").pass_through() request = httpx.Request("GET", "https://foo.bar/baz/") matched_route, response = router.match(request) assert matched_route is route assert matched_route.is_pass_through assert response is request route.pass_through(False) matched_route, response = router.match(request) assert matched_route is route assert not matched_route.is_pass_through assert response is not None
async def test_http_client(respx_mock: respx.Router) -> None: app = FastAPI() respx_mock.get("https://www.google.com").respond(200) @app.get("/") async def handler( http_client: AsyncClient = Depends(http_client_dependency), ) -> Dict[str, str]: assert isinstance(http_client, AsyncClient) await http_client.get("https://www.google.com") return {} @app.on_event("shutdown") async def shutdown_event() -> None: await http_client_dependency.aclose() async with LifespanManager(app): async with AsyncClient(app=app, base_url="http://example.com") as c: r = await c.get("/") assert r.status_code == 200
def test_side_effect_decorator(): router = Router() @router.route(host="ham.spam", path__regex=r"/(?P<slug>\w+)/") def foobar(request, slug): return httpx.Response(200, json={"slug": slug}) @router.post("https://example.org/") def example(request): return httpx.Response(201, json={"message": "OK"}) request = httpx.Request("GET", "https://ham.spam/egg/") response = router.resolve(request) assert response.status_code == 200 assert response.json() == {"slug": "egg"} request = httpx.Request("POST", "https://example.org/") response = router.resolve(request) assert response.status_code == 201 assert response.json() == {"message": "OK"}
async def test_connection_error(tmp_path: Path, client: AsyncClient, respx_mock: respx.Router) -> None: config = await configure(tmp_path, "oidc") assert config.oidc return_url = "https://example.com/foo" r = await client.get("/login", params={"rd": return_url}) assert r.status_code == 307 url = urlparse(r.headers["Location"]) query = parse_qs(url.query) # Register a connection error for the callback request to the OIDC # provider and check that an appropriate error is shown to the user. token_url = config.oidc.token_url respx_mock.post(token_url).mock(side_effect=ConnectError) r = await client.get("/login", params={ "code": "some-code", "state": query["state"][0] }) assert r.status_code == 403 assert "Cannot contact authentication provider" in r.text
def test_side_effect_no_match(): router = Router() def no_match(request): request.respx_was_here = True return None router.get(url__startswith="https://foo.bar/").mock(side_effect=no_match) router.get(url__eq="https://foo.bar/baz/").mock(return_value=httpx.Response(204)) request = httpx.Request("GET", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 204 assert response.request.respx_was_here is True
async def mock_github( respx_mock: respx.Router, code: str, user_info: GitHubUserInfo, *, paginate_teams: bool = False, expect_revoke: bool = False, ) -> None: """Set up the mocks for a GitHub userinfo call. Parameters ---------- respx_mock : `respx.Router` The mock router. code : `str` The code that Gafaelfawr must send to redeem a token. user_info : `gafaelfawr.providers.github.GitHubUserInfo` User information to use to synthesize GitHub API responses. paginate_teams : `bool`, optional Whether to paginate the team results. Default: `False` expect_revoke : `bool`, optional Whether to expect a revocation of the token after returning all user information. Default: `False` """ config = await config_dependency() assert config.github mock = MockGitHub( respx_mock, config.github, code, user_info, paginate_teams, expect_revoke, ) token_url = GitHubProvider._TOKEN_URL respx_mock.post(token_url).mock(side_effect=mock.post_token) emails_url = GitHubProvider._EMAILS_URL respx_mock.get(emails_url).mock(side_effect=mock.get_emails) teams_url = GitHubProvider._TEAMS_URL respx_mock.get(url__startswith=teams_url).mock(side_effect=mock.get_teams) respx_mock.get(GitHubProvider._USER_URL).mock(side_effect=mock.get_user)
def test_side_effect_list(): router = Router() router.get("https://foo.bar/").side_effect( [httpx.Response(404), httpx.Response(201)]) request = httpx.Request("GET", "https://foo.bar") response = router.resolve(request) assert response.status_code == 404 assert response.request == request request = httpx.Request("GET", "https://foo.bar") response = router.resolve(request) assert response.status_code == 201 assert response.request == request
def test_pass_through(): router = Router(assert_all_mocked=False) route = router.get("https://foo.bar/", path="/baz/").pass_through() request = httpx.Request("GET", "https://foo.bar/baz/") matched_route, response = router.match(request) assert matched_route is route assert matched_route.is_pass_through assert response is request with pytest.raises(PassThrough): router.handler(request) route.pass_through(False) matched_route, response = router.match(request) assert matched_route is route assert not matched_route.is_pass_through assert response is not None
def test_pass_through(): router = Router(assert_all_mocked=False) route = router.get("https://foo.bar/", path="/baz/").pass_through() request = httpx.Request("GET", "https://foo.bar/baz/") with pytest.raises(PassThrough) as exc_info: router.resolve(request) assert exc_info.value.origin is route assert exc_info.value.origin.is_pass_through route.pass_through(False) resolved = router.resolve(request) assert resolved.route is route assert not resolved.route.is_pass_through assert resolved.response is not None
def test_side_effect_with_route_kwarg(): router = Router() def foobar(request, route, slug): response = httpx.Response(201, json={"id": route.call_count + 1, "slug": slug}) if route.call_count > 0: route.mock(return_value=httpx.Response(501)) return response router.post(path__regex=r"/(?P<slug>\w+)/").mock(side_effect=foobar) request = httpx.Request("POST", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 201 assert response.json() == {"id": 1, "slug": "baz"} response = router.handler(request) assert response.status_code == 201 assert response.json() == {"id": 2, "slug": "baz"} response = router.handler(request) assert response.status_code == 501
def test_side_effect_exception(): router = Router() router.get("https://foo.bar/").side_effect(httpx.ConnectError) router.get("https://ham.spam/").side_effect(httpcore.NetworkError) router.get("https://egg.plant/").side_effect(httpcore.NetworkError()) request = httpx.Request("GET", "https://foo.bar") with pytest.raises(httpx.ConnectError) as e: router.resolve(request) assert e.value.request == request request = httpx.Request("GET", "https://ham.spam") with pytest.raises(httpcore.NetworkError) as e: router.resolve(request) request = httpx.Request("GET", "https://egg.plant") with pytest.raises(httpcore.NetworkError) as e: router.resolve(request)
async def test_callback_error( tmp_path: Path, client: AsyncClient, respx_mock: respx.Router, caplog: LogCaptureFixture, ) -> None: """Test an error return from the OIDC token endpoint.""" config = await configure(tmp_path, "oidc") assert config.oidc return_url = "https://example.com/foo" r = await client.get("/login", params={"rd": return_url}) assert r.status_code == 307 url = urlparse(r.headers["Location"]) query = parse_qs(url.query) # Build an error response to return from the OIDC token URL and register # it as a result. response = { "error": "error_code", "error_description": "description", } respx_mock.post(config.oidc.token_url).respond(400, json=response) # Simulate the return from the OpenID Connect provider. caplog.clear() r = await client.get( "/oauth2/callback", params={ "code": "some-code", "state": query["state"][0] }, ) assert r.status_code == 403 assert "error_code: description" in r.text assert parse_log(caplog) == [ { "event": f"Retrieving ID token from {config.oidc.token_url}", "httpRequest": { "requestMethod": "GET", "requestUrl": ANY, "remoteIp": "127.0.0.1", }, "return_url": return_url, "severity": "info", }, { "error": "error_code: description", "event": "Authentication provider failed", "httpRequest": { "requestMethod": "GET", "requestUrl": ANY, "remoteIp": "127.0.0.1", }, "return_url": return_url, "severity": "warning", }, ] # Change the mock error response to not contain an error. We should then # internally raise the exception for the return status, which should # translate into an internal server error. respx_mock.post(config.oidc.token_url).respond(400, json={"foo": "bar"}) r = await client.get("/login", params={"rd": return_url}) query = parse_qs(urlparse(r.headers["Location"]).query) r = await client.get( "/oauth2/callback", params={ "code": "some-code", "state": query["state"][0] }, ) assert r.status_code == 403 assert "Cannot contact authentication provider" in r.text # Now try a reply that returns 200 but doesn't have the field we # need. respx_mock.post(config.oidc.token_url).respond(json={"foo": "bar"}) r = await client.get("/login", params={"rd": return_url}) query = parse_qs(urlparse(r.headers["Location"]).query) r = await client.get( "/oauth2/callback", params={ "code": "some-code", "state": query["state"][0] }, ) assert r.status_code == 403 assert "No id_token in token reply" in r.text # Return invalid JSON, which should raise an error during JSON decoding. respx_mock.post(config.oidc.token_url).respond(content=b"foo") r = await client.get("/login", params={"rd": return_url}) query = parse_qs(urlparse(r.headers["Location"]).query) r = await client.get( "/oauth2/callback", params={ "code": "some-code", "state": query["state"][0] }, ) assert r.status_code == 403 assert "not valid JSON" in r.text # Finally, return invalid JSON and an error reply. respx_mock.post(config.oidc.token_url).respond(400, content=b"foo") r = await client.get("/login", params={"rd": return_url}) query = parse_qs(urlparse(r.headers["Location"]).query) r = await client.get( "/oauth2/callback", params={ "code": "some-code", "state": query["state"][0] }, ) assert r.status_code == 403 assert "Cannot contact authentication provider" in r.text
def test_rollback(): router = Router() route = router.get("https://foo.bar/") % 404 pattern = route.pattern assert route.name is None router.snapshot() # 1. get 404 route.return_value = httpx.Response(200) router.post("https://foo.bar/").mock( side_effect=[httpx.Response(400), httpx.Response(201)] ) router.snapshot() # 2. get 200, post _route = router.get("https://foo.bar/", name="foobar") _route = router.get("https://foo.bar/baz/", name="foobar") assert _route is route assert route.name == "foobar" assert route.pattern != pattern route.return_value = httpx.Response(418) request = httpx.Request("GET", "https://foo.bar/baz/") response = router.handler(request) assert response.status_code == 418 request = httpx.Request("POST", "https://foo.bar") response = router.handler(request) assert response.status_code == 400 assert len(router.routes) == 2 assert router.calls.call_count == 2 assert route.call_count == 1 assert route.return_value.status_code == 418 router.snapshot() # 3. directly rollback, should be identical router.rollback() assert len(router.routes) == 2 assert router.calls.call_count == 2 assert route.call_count == 1 assert route.return_value.status_code == 418 router.patch("https://foo.bar/") assert len(router.routes) == 3 route.rollback() # get 200 assert router.calls.call_count == 2 assert route.call_count == 0 assert route.return_value.status_code == 200 request = httpx.Request("GET", "https://foo.bar") response = router.handler(request) assert response.status_code == 200 router.rollback() # 2. get 404, post request = httpx.Request("POST", "https://foo.bar") response = router.handler(request) assert response.status_code == 400 assert len(router.routes) == 2 router.rollback() # 1. get 404 assert len(router.routes) == 1 assert router.calls.call_count == 0 assert route.return_value is None router.rollback() # Empty inital state assert len(router.routes) == 0 assert route.return_value is None # Idempotent route.rollback() router.rollback() assert len(router.routes) == 0 assert route.name is None assert route.pattern == pattern assert route.return_value is None
def test_multiple_pattern_values_type_error(): router = Router() with pytest.raises(TypeError, match="Got multiple values for pattern 'method'"): router.post(method__in=("PUT", "PATCH")) with pytest.raises(TypeError, match="Got multiple values for pattern 'url'"): router.get("https://foo.bar", url__regex=r"https://example.org$")
async def test_key_retrieval(tmp_path: Path, respx_mock: respx.Router, factory: ComponentFactory) -> None: config = await configure(tmp_path, "oidc-no-kids") factory.reconfigure(config) assert config.oidc verifier = factory.create_token_verifier() # Initial working JWKS configuration. jwks = config.issuer.keypair.public_key_as_jwks("some-kid") # Register that handler at the well-known JWKS endpoint. This will return # a connection refused from the OpenID Connect endpoint. jwks_url = urljoin(config.oidc.issuer, "/.well-known/jwks.json") oidc_url = urljoin(config.oidc.issuer, "/.well-known/openid-configuration") respx_mock.get(jwks_url).respond(json=jwks.dict()) respx_mock.get(oidc_url).respond(404) # Check token verification with this configuration. token = await create_upstream_oidc_token(kid="some-kid") assert await verifier.verify_oidc_token(token) # Wrong algorithm for the key. jwks.keys[0].alg = "ES256" respx_mock.get(jwks_url).respond(json=jwks.dict()) with pytest.raises(UnknownAlgorithmException): await verifier.verify_oidc_token(token) # Should go back to working if we fix the algorithm and add more keys. # Add an explicit 404 from the OpenID connect endpoint. respx_mock.get(oidc_url).respond(404) jwks.keys[0].alg = ALGORITHM keypair = RSAKeyPair.generate() jwks.keys.insert(0, keypair.public_key_as_jwks("a-kid").keys[0]) respx_mock.get(jwks_url).respond(json=jwks.dict()) assert await verifier.verify_oidc_token(token) # Try with a new key ID and return a malformed reponse. respx_mock.get(jwks_url).respond(json=["foo"]) token = await create_upstream_oidc_token(kid="malformed") with pytest.raises(FetchKeysException): await verifier.verify_oidc_token(token) # Return a 404 error. respx_mock.get(jwks_url).respond(404) with pytest.raises(FetchKeysException): await verifier.verify_oidc_token(token) # Fix the JWKS handler but register a malformed URL as the OpenID Connect # configuration endpoint, which should be checked first. jwks.keys[1].kid = "another-kid" respx_mock.get(jwks_url).respond(json=jwks.dict()) respx_mock.get(oidc_url).respond(json=["foo"]) token = await create_upstream_oidc_token(kid="another-kid") with pytest.raises(FetchKeysException): await verifier.verify_oidc_token(token) # Try again with a working OpenID Connect configuration. respx_mock.get(oidc_url).respond(json={"jwks_uri": jwks_url}) assert await verifier.verify_oidc_token(token)