diff options
author | Richard van der Hoff <1389908+richvdh@users.noreply.github.com> | 2021-02-16 16:27:38 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-02-16 16:27:38 +0000 |
commit | 3b754aea27fbe1a17d4f5cec1a8ad7765a91e6fe (patch) | |
tree | 2c94715047def5c27ab6e2e835e0cd95c0061771 /tests | |
parent | Merge branch 'master' into develop (diff) | |
download | synapse-3b754aea27fbe1a17d4f5cec1a8ad7765a91e6fe.tar.xz |
Clean up caching/locking of OIDC metadata load (#9362)
Ensure that we lock correctly to prevent multiple concurrent metadata load requests, and generally clean up the way we construct the metadata cache.
Diffstat (limited to 'tests')
-rw-r--r-- | tests/handlers/test_oidc.py | 71 | ||||
-rw-r--r-- | tests/util/caches/test_cached_call.py | 161 |
2 files changed, 206 insertions, 26 deletions
diff --git a/tests/handlers/test_oidc.py b/tests/handlers/test_oidc.py index ad20400b1d..27bb50e3b5 100644 --- a/tests/handlers/test_oidc.py +++ b/tests/handlers/test_oidc.py @@ -24,7 +24,7 @@ from synapse.handlers.sso import MappingException from synapse.server import HomeServer from synapse.types import UserID -from tests.test_utils import FakeResponse, simple_async_mock +from tests.test_utils import FakeResponse, get_awaitable_result, simple_async_mock from tests.unittest import HomeserverTestCase, override_config try: @@ -131,7 +131,6 @@ class OidcHandlerTestCase(HomeserverTestCase): return config def make_homeserver(self, reactor, clock): - self.http_client = Mock(spec=["get_json"]) self.http_client.get_json.side_effect = get_json self.http_client.user_agent = "Synapse Test" @@ -151,7 +150,15 @@ class OidcHandlerTestCase(HomeserverTestCase): return hs def metadata_edit(self, values): - return patch.dict(self.provider._provider_metadata, values) + """Modify the result that will be returned by the well-known query""" + + async def patched_get_json(uri): + res = await get_json(uri) + if uri == WELL_KNOWN: + res.update(values) + return res + + return patch.object(self.http_client, "get_json", patched_get_json) def assertRenderedError(self, error, error_description=None): self.render_error.assert_called_once() @@ -212,7 +219,14 @@ class OidcHandlerTestCase(HomeserverTestCase): self.http_client.get_json.assert_called_once_with(JWKS_URI) # Throw if the JWKS uri is missing - with self.metadata_edit({"jwks_uri": None}): + original = self.provider.load_metadata + + async def patched_load_metadata(): + m = (await original()).copy() + m.update({"jwks_uri": None}) + return m + + with patch.object(self.provider, "load_metadata", patched_load_metadata): self.get_failure(self.provider.load_jwks(force=True), RuntimeError) # Return empty key set if JWKS are not used @@ -222,55 +236,60 @@ class OidcHandlerTestCase(HomeserverTestCase): self.http_client.get_json.assert_not_called() self.assertEqual(jwks, {"keys": []}) - @override_config({"oidc_config": COMMON_CONFIG}) def test_validate_config(self): """Provider metadatas are extensively validated.""" h = self.provider + def force_load_metadata(): + async def force_load(): + return await h.load_metadata(force=True) + + return get_awaitable_result(force_load()) + # Default test config does not throw - h._validate_metadata() + force_load_metadata() with self.metadata_edit({"issuer": None}): - self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata) + self.assertRaisesRegex(ValueError, "issuer", force_load_metadata) with self.metadata_edit({"issuer": "http://insecure/"}): - self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata) + self.assertRaisesRegex(ValueError, "issuer", force_load_metadata) with self.metadata_edit({"issuer": "https://invalid/?because=query"}): - self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata) + self.assertRaisesRegex(ValueError, "issuer", force_load_metadata) with self.metadata_edit({"authorization_endpoint": None}): self.assertRaisesRegex( - ValueError, "authorization_endpoint", h._validate_metadata + ValueError, "authorization_endpoint", force_load_metadata ) with self.metadata_edit({"authorization_endpoint": "http://insecure/auth"}): self.assertRaisesRegex( - ValueError, "authorization_endpoint", h._validate_metadata + ValueError, "authorization_endpoint", force_load_metadata ) with self.metadata_edit({"token_endpoint": None}): - self.assertRaisesRegex(ValueError, "token_endpoint", h._validate_metadata) + self.assertRaisesRegex(ValueError, "token_endpoint", force_load_metadata) with self.metadata_edit({"token_endpoint": "http://insecure/token"}): - self.assertRaisesRegex(ValueError, "token_endpoint", h._validate_metadata) + self.assertRaisesRegex(ValueError, "token_endpoint", force_load_metadata) with self.metadata_edit({"jwks_uri": None}): - self.assertRaisesRegex(ValueError, "jwks_uri", h._validate_metadata) + self.assertRaisesRegex(ValueError, "jwks_uri", force_load_metadata) with self.metadata_edit({"jwks_uri": "http://insecure/jwks.json"}): - self.assertRaisesRegex(ValueError, "jwks_uri", h._validate_metadata) + self.assertRaisesRegex(ValueError, "jwks_uri", force_load_metadata) with self.metadata_edit({"response_types_supported": ["id_token"]}): self.assertRaisesRegex( - ValueError, "response_types_supported", h._validate_metadata + ValueError, "response_types_supported", force_load_metadata ) with self.metadata_edit( {"token_endpoint_auth_methods_supported": ["client_secret_basic"]} ): # should not throw, as client_secret_basic is the default auth method - h._validate_metadata() + force_load_metadata() with self.metadata_edit( {"token_endpoint_auth_methods_supported": ["client_secret_post"]} @@ -278,7 +297,7 @@ class OidcHandlerTestCase(HomeserverTestCase): self.assertRaisesRegex( ValueError, "token_endpoint_auth_methods_supported", - h._validate_metadata, + force_load_metadata, ) # Tests for configs that require the userinfo endpoint @@ -287,24 +306,24 @@ class OidcHandlerTestCase(HomeserverTestCase): h._user_profile_method = "userinfo_endpoint" self.assertTrue(h._uses_userinfo) - # Revert the profile method and do not request the "openid" scope. + # Revert the profile method and do not request the "openid" scope: this should + # mean that we check for a userinfo endpoint h._user_profile_method = "auto" h._scopes = [] self.assertTrue(h._uses_userinfo) - self.assertRaisesRegex(ValueError, "userinfo_endpoint", h._validate_metadata) + with self.metadata_edit({"userinfo_endpoint": None}): + self.assertRaisesRegex(ValueError, "userinfo_endpoint", force_load_metadata) - with self.metadata_edit( - {"userinfo_endpoint": USERINFO_ENDPOINT, "jwks_uri": None} - ): - # Shouldn't raise with a valid userinfo, even without - h._validate_metadata() + with self.metadata_edit({"jwks_uri": None}): + # Shouldn't raise with a valid userinfo, even without jwks + force_load_metadata() @override_config({"oidc_config": {"skip_verification": True}}) def test_skip_verification(self): """Provider metadata validation can be disabled by config.""" with self.metadata_edit({"issuer": "http://insecure"}): # This should not throw - self.provider._validate_metadata() + get_awaitable_result(self.provider.load_metadata()) def test_redirect_request(self): """The redirect request has the right arguments & generates a valid session cookie.""" diff --git a/tests/util/caches/test_cached_call.py b/tests/util/caches/test_cached_call.py new file mode 100644 index 0000000000..f349b5ced0 --- /dev/null +++ b/tests/util/caches/test_cached_call.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# Copyright 2021 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from unittest.mock import Mock + +from twisted.internet import defer +from twisted.internet.defer import Deferred + +from synapse.util.caches.cached_call import CachedCall, RetryOnExceptionCachedCall + +from tests.test_utils import get_awaitable_result +from tests.unittest import TestCase + + +class CachedCallTestCase(TestCase): + def test_get(self): + """ + Happy-path test case: makes a couple of calls and makes sure they behave + correctly + """ + d = Deferred() + + async def f(): + return await d + + slow_call = Mock(side_effect=f) + + cached_call = CachedCall(slow_call) + + # the mock should not yet have been called + slow_call.assert_not_called() + + # now fire off a couple of calls + completed_results = [] + + async def r(): + res = await cached_call.get() + completed_results.append(res) + + r1 = defer.ensureDeferred(r()) + r2 = defer.ensureDeferred(r()) + + # neither result should be complete yet + self.assertNoResult(r1) + self.assertNoResult(r2) + + # and the mock should have been called *once*, with no params + slow_call.assert_called_once_with() + + # allow the deferred to complete, which should complete both the pending results + d.callback(123) + self.assertEqual(completed_results, [123, 123]) + self.successResultOf(r1) + self.successResultOf(r2) + + # another call to the getter should complete immediately + slow_call.reset_mock() + r3 = get_awaitable_result(cached_call.get()) + self.assertEqual(r3, 123) + slow_call.assert_not_called() + + def test_fast_call(self): + """ + Test the behaviour when the underlying function completes immediately + """ + + async def f(): + return 12 + + fast_call = Mock(side_effect=f) + cached_call = CachedCall(fast_call) + + # the mock should not yet have been called + fast_call.assert_not_called() + + # run the call a couple of times, which should complete immediately + self.assertEqual(get_awaitable_result(cached_call.get()), 12) + self.assertEqual(get_awaitable_result(cached_call.get()), 12) + + # the mock should have been called once + fast_call.assert_called_once_with() + + +class RetryOnExceptionCachedCallTestCase(TestCase): + def test_get(self): + # set up the RetryOnExceptionCachedCall around a function which will fail + # (after a while) + d = Deferred() + + async def f1(): + await d + raise ValueError("moo") + + slow_call = Mock(side_effect=f1) + cached_call = RetryOnExceptionCachedCall(slow_call) + + # the mock should not yet have been called + slow_call.assert_not_called() + + # now fire off a couple of calls + completed_results = [] + + async def r(): + try: + await cached_call.get() + except Exception as e1: + completed_results.append(e1) + + r1 = defer.ensureDeferred(r()) + r2 = defer.ensureDeferred(r()) + + # neither result should be complete yet + self.assertNoResult(r1) + self.assertNoResult(r2) + + # and the mock should have been called *once*, with no params + slow_call.assert_called_once_with() + + # complete the deferred, which should make the pending calls fail + d.callback(0) + self.assertEqual(len(completed_results), 2) + for e in completed_results: + self.assertIsInstance(e, ValueError) + self.assertEqual(e.args, ("moo",)) + + # reset the mock to return a successful result, and make another pair of calls + # to the getter + d = Deferred() + + async def f2(): + return await d + + slow_call.reset_mock() + slow_call.side_effect = f2 + r3 = defer.ensureDeferred(cached_call.get()) + r4 = defer.ensureDeferred(cached_call.get()) + + self.assertNoResult(r3) + self.assertNoResult(r4) + slow_call.assert_called_once_with() + + # let that call complete, and check the results + d.callback(123) + self.assertEqual(self.successResultOf(r3), 123) + self.assertEqual(self.successResultOf(r4), 123) + + # and now more calls to the getter should complete immediately + slow_call.reset_mock() + self.assertEqual(get_awaitable_result(cached_call.get()), 123) + slow_call.assert_not_called() |