Repository: GoogleCloudPlatform/cloud-sql-python-connector
Pull request #1079 (2024-05-09 13:36:00)

refactor: move SSL/TLS context creation to ConnectionInfo
This refactor moves all SSL/TLS configuration into its own method `create_ssl_context()` on the `ConnectionInfo` class.

This way `ConnectionInfo()` prepares all the info required to connect and then `create_ssl_context` will use the info to establish the SSL/TLS connection when called from `Connector.connect` at the time of connection.

This refactor also improves the testing infra to be more in line with AlloyDB Python Connector.

Based on Go: https://github.com/GoogleCloudPlatform/cloud-sql-go-connector/pull/761


Patch:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 20d6b71..292f830 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -48,23 +48,23 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+      uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
       with:
         ref: ${{ github.event.pull_request.head.sha }}
         repository: ${{ github.event.pull_request.head.repo.full_name }}
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
+      uses: github/codeql-action/init@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5
       with:
         languages: ${{ matrix.language }}
 
     # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
     # If this step fails, then you should remove it and run the build manually
     - name: Autobuild
-      uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
+      uses: github/codeql-action/autobuild@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
+      uses: github/codeql-action/analyze@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 27cf6c5..d6f1640 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -54,7 +54,7 @@ jobs:
       - run: pip install nox coverage
 
       - name: Checkout base branch
-        uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+        uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
         with:
           ref: ${{ github.base_ref }}
 
@@ -67,7 +67,7 @@ jobs:
           coverage erase
 
       - name: Checkout PR branch
-        uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+        uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml
index 541105f..3cc3667 100644
--- a/.github/workflows/labels.yaml
+++ b/.github/workflows/labels.yaml
@@ -28,7 +28,7 @@ jobs:
       issues: write
       pull-requests: write
     steps:
-      - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+      - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
       - uses: micnncim/action-label-syncer@3abd5ab72fda571e69fffd97bd4e0033dd5f495c # v1.3.0
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index dbfbf4f..19c7424 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -56,7 +56,7 @@ jobs:
         run: pip install nox
 
       - name: Checkout code
-        uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+        uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index d773bae..c7ae5de 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -35,12 +35,12 @@ jobs:
 
     steps:
       - name: "Checkout code"
-        uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+        uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
         with:
           persist-credentials: false
 
       - name: "Run analysis"
-        uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
+        uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3
         with:
           results_file: results.sarif
           results_format: sarif
@@ -65,6 +65,6 @@ jobs:
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
+        uses: github/codeql-action/upload-sarif@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5
         with:
           sarif_file: resultsFiltered.sarif
diff --git a/.kokoro/requirements.txt b/.kokoro/requirements.txt
index 0d05e3b..f058045 100644
--- a/.kokoro/requirements.txt
+++ b/.kokoro/requirements.txt
@@ -290,9 +290,9 @@ jeepney==0.8.0 \
     # via
     #   keyring
     #   secretstorage
-jinja2==3.1.3 \
-    --hash=sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa \
-    --hash=sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90
+jinja2==3.1.4 \
+    --hash=sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369 \
+    --hash=sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d
     # via gcp-releasetool
 keyring==23.11.0 \
     --hash=sha256:3dd30011d555f1345dec2c262f0153f2f0ca6bca041fb1dc4588349bb4c0ac1e \
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b30abfd..dbea6dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## [1.9.2](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/compare/v1.9.1...v1.9.2) (2024-05-14)
+
+
+### Dependencies
+
+* update dependency cryptography to v42.0.7 ([#1076](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/issues/1076)) ([a4aec7e](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/commit/a4aec7ee406cd3e4480833479d394d67c80e9352))
+
 ## [1.9.1](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/compare/v1.9.0...v1.9.1) (2024-04-17)
 
 
diff --git a/google/cloud/sql/connector/connector.py b/google/cloud/sql/connector/connector.py
index 049a4db..dbf2982 100755
--- a/google/cloud/sql/connector/connector.py
+++ b/google/cloud/sql/connector/connector.py
@@ -330,10 +330,17 @@ class Connector:
 
             # async drivers are unblocking and can be awaited directly
             if driver in ASYNC_DRIVERS:
-                return await connector(ip_address, instance_data.context, **kwargs)
+                return await connector(
+                    ip_address,
+                    instance_data.create_ssl_context(enable_iam_auth),
+                    **kwargs,
+                )
             # synchronous drivers are blocking and run using executor
             connect_partial = partial(
-                connector, ip_address, instance_data.context, **kwargs
+                connector,
+                ip_address,
+                instance_data.create_ssl_context(enable_iam_auth),
+                **kwargs,
             )
             return await self._loop.run_in_executor(None, connect_partial)
 
diff --git a/google/cloud/sql/connector/exceptions.py b/google/cloud/sql/connector/exceptions.py
index fa56bdf..16b9e17 100644
--- a/google/cloud/sql/connector/exceptions.py
+++ b/google/cloud/sql/connector/exceptions.py
@@ -62,3 +62,11 @@ class DnsNameResolutionError(Exception):
     Exception to be raised when the DnsName of a PSC connection to a
     Cloud SQL instance can not be resolved to a proper IP address.
     """
+
+
+class RefreshNotValidError(Exception):
+    """
+    Exception to be raised when the task returned from refresh is not valid.
+    """
+
+    pass
diff --git a/google/cloud/sql/connector/instance.py b/google/cloud/sql/connector/instance.py
index d10c0e7..d9ef8f0 100644
--- a/google/cloud/sql/connector/instance.py
+++ b/google/cloud/sql/connector/instance.py
@@ -17,6 +17,7 @@ limitations under the License.
 from __future__ import annotations
 
 import asyncio
+from dataclasses import dataclass
 from enum import Enum
 import logging
 import re
@@ -29,6 +30,7 @@ import aiohttp
 from google.cloud.sql.connector.client import CloudSQLClient
 from google.cloud.sql.connector.exceptions import AutoIAMAuthNotSupported
 from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
+from google.cloud.sql.connector.exceptions import RefreshNotValidError
 from google.cloud.sql.connector.exceptions import TLSVersionError
 from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
 from google.cloud.sql.connector.refresh_utils import _is_valid
@@ -79,33 +81,39 @@ class IPTypes(Enum):
         return cls(ip_type_str.upper())
 
 
+@dataclass
 class ConnectionInfo:
+    """Contains all necessary information to connect securely to the
+    server-side Proxy running on a Cloud SQL instance."""
+
+    client_cert: str
+    server_ca_cert: str
+    private_key: bytes
     ip_addrs: Dict[str, Any]
-    context: ssl.SSLContext
     database_version: str
     expiration: datetime.datetime
+    context: ssl.SSLContext | None = None
 
-    def __init__(
-        self,
-        ephemeral_cert: str,
-        database_version: str,
-        ip_addrs: Dict[str, Any],
-        private_key: bytes,
-        server_ca_cert: str,
-        expiration: datetime.datetime,
-        enable_iam_auth: bool,
-    ) -> None:
-        self.ip_addrs = ip_addrs
-        self.database_version = database_version
-        self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    def create_ssl_context(self, enable_iam_auth: bool = False) -> ssl.SSLContext:
+        """Constructs a SSL/TLS context for the given connection info.
+
+        Cache the SSL context to ensure we don't read from disk repeatedly when
+        configuring a secure connection.
+        """
+        # if SSL context is cached, use it
+        if self.context is not None:
+            return self.context
+        context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
 
         # update ssl.PROTOCOL_TLS_CLIENT default
-        self.context.check_hostname = False
+        context.check_hostname = False
 
+        # TODO: remove if/else when Python 3.10 is min version. PEP 644 has been
+        # implemented. The ssl module requires OpenSSL 1.1.1 or newer.
         # verify OpenSSL version supports TLSv1.3
         if ssl.HAS_TLSv1_3:
             # force TLSv1.3 if supported by client
-            self.context.minimum_version = ssl.TLSVersion.TLSv1_3
+            context.minimum_version = ssl.TLSVersion.TLSv1_3
         # fallback to TLSv1.2 for older versions of OpenSSL
         else:
             if enable_iam_auth:
@@ -119,18 +127,20 @@ class ConnectionInfo:
                 f"({ssl.OPENSSL_VERSION}), falling back to TLSv1.2\n"
                 "Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
             )
-            self.context.minimum_version = ssl.TLSVersion.TLSv1_2
-        self.expiration = expiration
+            context.minimum_version = ssl.TLSVersion.TLSv1_2
 
         # tmpdir and its contents are automatically deleted after the CA cert
         # and ephemeral cert are loaded into the SSLcontext. The values
         # need to be written to files in order to be loaded by the SSLContext
         with TemporaryDirectory() as tmpdir:
             ca_filename, cert_filename, key_filename = write_to_file(
-                tmpdir, server_ca_cert, ephemeral_cert, private_key
+                tmpdir, self.server_ca_cert, self.client_cert, self.private_key
             )
-            self.context.load_cert_chain(cert_filename, keyfile=key_filename)
-            self.context.load_verify_locations(cafile=ca_filename)
+            context.load_cert_chain(cert_filename, keyfile=key_filename)
+            context.load_verify_locations(cafile=ca_filename)
+        # set class attribute to cache context for subsequent calls
+        self.context = context
+        return context
 
     def get_preferred_ip(self, ip_type: IPTypes) -> str:
         """Returns the first IP address for the instance, according to the preference
@@ -272,12 +282,11 @@ class RefreshAheadCache:
 
         return ConnectionInfo(
             ephemeral_cert,
-            metadata["database_version"],
-            metadata["ip_addresses"],
-            priv_key,
             metadata["server_ca_cert"],
+            priv_key,
+            metadata["ip_addresses"],
+            metadata["database_version"],
             expiration,
-            self._enable_iam_auth,
         )
 
     def _schedule_refresh(self, delay: int) -> asyncio.Task:
@@ -303,6 +312,11 @@ class RefreshAheadCache:
                     await asyncio.sleep(delay)
                 refresh_task = asyncio.create_task(self._perform_refresh())
                 refresh_data = await refresh_task
+                # check that refresh is valid
+                if not await _is_valid(refresh_task):
+                    raise RefreshNotValidError(
+                        f"['{self._instance_connection_string}']: Invalid refresh operation. Certficate appears to be expired."
+                    )
             except asyncio.CancelledError:
                 logger.debug(
                     f"['{self._instance_connection_string}']: Schedule refresh task cancelled."
diff --git a/google/cloud/sql/connector/version.py b/google/cloud/sql/connector/version.py
index 971c208..ef5e1a1 100644
--- a/google/cloud/sql/connector/version.py
+++ b/google/cloud/sql/connector/version.py
@@ -12,4 +12,4 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-__version__ = "1.9.1"
+__version__ = "1.9.2"
diff --git a/requirements.txt b/requirements.txt
index 6278ad8..cc8e14c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
 aiohttp==3.9.5
-cryptography==42.0.6
-Requests==2.31.0
+cryptography==42.0.7
+Requests==2.32.2
 google-auth==2.29.0


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8e6b5b7..8f25415 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -61,7 +61,7 @@ jobs:
             }
 
       - name: Checkout code
-        uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+        uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -178,7 +178,7 @@ jobs:
             }
 
       - name: Checkout code
-        uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
+        uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/requirements-test.txt b/requirements-test.txt
index dcd84c0..d723752 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,11 +1,11 @@
-pytest==8.2.0
+pytest==8.2.1
 mock==5.1.0
 pytest-cov==5.0.0
-pytest-asyncio==0.23.6
+pytest-asyncio==0.23.7
 SQLAlchemy[asyncio]==2.0.30
 sqlalchemy-pytds==1.0.0
 sqlalchemy-stubs==0.4
-PyMySQL==1.1.0
+PyMySQL==1.1.1
 pg8000==1.31.2
 asyncpg==0.29.0
 python-tds==1.15.0
diff --git a/tests/conftest.py b/tests/conftest.py
index 1be12fb..dd5c395 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -105,7 +105,7 @@ def kwargs() -> Any:
     return kwargs
 
 
-@pytest.fixture(scope="session")
+@pytest.fixture
 def fake_instance() -> FakeCSQLInstance:
     return FakeCSQLInstance()
 
@@ -133,7 +133,10 @@ async def fake_client(
         sqlserver_client_cert_uri, sqlserver_instance.generate_ephemeral
     )
     client_session = await aiohttp_client(app)
-    return CloudSQLClient("", "", fake_credentials, client=client_session)
+    client = CloudSQLClient("", "", fake_credentials, client=client_session)
+    # add instance to client to control cert expiration etc.
+    client.instance = fake_instance
+    return client
 
 
 @pytest.fixture
diff --git a/tests/unit/mocks.py b/tests/unit/mocks.py
index dfefee3..bc9a50a 100644
--- a/tests/unit/mocks.py
+++ b/tests/unit/mocks.py
@@ -31,7 +31,6 @@ from cryptography.x509.oid import NameOID
 from google.auth.credentials import Credentials
 
 from google.cloud.sql.connector.connector import _DEFAULT_UNIVERSE_DOMAIN
-from google.cloud.sql.connector.instance import ConnectionInfo
 from google.cloud.sql.connector.utils import generate_keys
 from google.cloud.sql.connector.utils import write_to_file
 
@@ -85,36 +84,6 @@ class FakeCredentials:
         return self.token is not None and not self.expired
 
 
-class BadRefresh(Exception):
-    pass
-
-
-class MockMetadata(ConnectionInfo):
-    """Mock class for ConnectionInfo"""
-
-    def __init__(
-        self, expiration: datetime.datetime, ip_addrs: Dict = {"PRIMARY": "0.0.0.0"}
-    ) -> None:
-        self.expiration = expiration
-        self.ip_addrs = ip_addrs
-
-
-async def instance_metadata_success(*args: Any, **kwargs: Any) -> MockMetadata:
-    return MockMetadata(
-        datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=10)
-    )
-
-
-async def instance_metadata_expired(*args: Any, **kwargs: Any) -> MockMetadata:
-    return MockMetadata(
-        datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=10)
-    )
-
-
-async def instance_metadata_error(*args: Any, **kwargs: Any) -> None:
-    raise BadRefresh("something went wrong...")
-
-
 def generate_cert(
     project: str,
     name: str,
@@ -166,6 +135,9 @@ def client_key_signed_cert(
     cert: x509.CertificateBuilder,
     priv_key: rsa.RSAPrivateKey,
     client_key: rsa.RSAPublicKey,
+    cert_before: datetime.datetime = datetime.datetime.now(datetime.timezone.utc),
+    cert_expiration: datetime.datetime = datetime.datetime.now(datetime.timezone.utc)
+    + datetime.timedelta(hours=1),
 ) -> str:
     """
     Create a PEM encoded certificate that is signed by given public key.
@@ -185,8 +157,8 @@ def client_key_signed_cert(
         .issuer_name(issuer)
         .public_key(client_key)
         .serial_number(x509.random_serial_number())
-        .not_valid_before(cert._not_valid_before)
-        .not_valid_after(cert._not_valid_after)  # type: ignore
+        .not_valid_before(cert_before)
+        .not_valid_after(cert_expiration)  # type: ignore
     )
     return (
         cert.sign(priv_key, hashes.SHA256(), default_backend())
@@ -231,7 +203,7 @@ class FakeCSQLInstance:
             "PRIVATE": "10.0.0.1",
         },
         cert_before: datetime = datetime.datetime.now(datetime.timezone.utc),
-        cert_expiry: datetime = datetime.datetime.now(datetime.timezone.utc)
+        cert_expiration: datetime = datetime.datetime.now(datetime.timezone.utc)
         + datetime.timedelta(hours=1),
     ) -> None:
         self.project = project
@@ -240,10 +212,10 @@ class FakeCSQLInstance:
         self.db_version = db_version
         self.ip_addrs = ip_addrs
         self.cert_before = cert_before
-        self.cert_expiry = cert_expiry
+        self.cert_expiration = cert_expiration
         # create self signed CA cert
         self.server_ca, self.server_key = generate_cert(
-            self.project, self.name, cert_before, cert_expiry
+            self.project, self.name, cert_before, cert_expiration
         )
         self.server_cert = self.server_ca.sign(self.server_key, hashes.SHA256())
         self.server_cert_pem = self.server_cert.public_bytes(
@@ -257,7 +229,7 @@ class FakeCSQLInstance:
             "serverCaCert": {
                 "cert": self.server_cert_pem,
                 "instance": self.name,
-                "expirationTime": str(self.server_cert.not_valid_after_utc),
+                "expirationTime": str(self.cert_expiration),
             },
             "dnsName": "abcde.12345.us-central1.sql.goog",
             "ipAddresses": ip_addrs,
@@ -273,13 +245,17 @@ class FakeCSQLInstance:
             pub_key.encode("UTF-8"), default_backend()
         )  # type: ignore
         ephemeral_cert = client_key_signed_cert(
-            self.server_ca, self.server_key, client_key
+            self.server_ca,
+            self.server_key,
+            client_key,
+            self.cert_before,
+            self.cert_expiration,
         )
         response = {
             "ephemeralCert": {
                 "kind": "sql#sslCert",
                 "cert": ephemeral_cert,
-                "expirationTime": str(self.server_cert.not_valid_after_utc),
+                "expirationTime": str(self.cert_expiration),
             }
         }
         return web.Response(content_type="application/json", body=json.dumps(response))
diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py
index 136fddb..ef2ea96 100644
--- a/tests/unit/test_instance.py
+++ b/tests/unit/test_instance.py
@@ -13,6 +13,7 @@ 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.
 """
+
 import asyncio
 import datetime
 from typing import Tuple
@@ -33,6 +34,7 @@ from google.cloud.sql.connector.instance import ConnectionInfo
 from google.cloud.sql.connector.instance import IPTypes
 from google.cloud.sql.connector.instance import RefreshAheadCache
 from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
+from google.cloud.sql.connector.refresh_utils import _is_valid
 from google.cloud.sql.connector.utils import generate_keys
 
 
@@ -86,64 +88,54 @@ async def test_Instance_init(
 
 
 @pytest.mark.asyncio
-async def test_schedule_refresh_replaces_result(
-    cache: RefreshAheadCache, test_rate_limiter: AsyncRateLimiter
-) -> None:
+async def test_schedule_refresh_replaces_result(cache: RefreshAheadCache) -> None:
     """
     Test to check whether _schedule_refresh replaces a valid result with another valid result
     """
-    # allow more frequent refreshes for tests
-    setattr(cache, "_refresh_rate_limiter", test_rate_limiter)
-
-    # stub _perform_refresh to return a "valid" MockMetadata object
-    setattr(cache, "_perform_refresh", mocks.instance_metadata_success)
-
-    old_metadata = await cache._current
-
-    # schedule refresh immediately and await it
-    refresh_task = cache._schedule_refresh(0)
-    refresh_metadata = await refresh_task
 
-    # check that current metadata has been replaced with refresh metadata
-    assert cache._current.result() == refresh_metadata
-    assert old_metadata != cache._current.result()
-    assert isinstance(cache._current.result(), mocks.MockMetadata)
-    # cleanup cache
-    await cache.close()
+    # check current refresh is valid
+    assert await _is_valid(cache._current) is True
+    current_refresh = cache._current
+    # schedule new refresh
+    await cache._schedule_refresh(0)
+    new_refresh = cache._current
+    # verify current has been replaced with new refresh
+    assert current_refresh != new_refresh
+    # check new refresh is valid
+    assert await _is_valid(new_refresh) is True
 
 
 @pytest.mark.asyncio
 async def test_schedule_refresh_wont_replace_valid_result_with_invalid(
-    cache: RefreshAheadCache, test_rate_limiter: AsyncRateLimiter
+    cache: RefreshAheadCache,
 ) -> None:
     """
     Test to check whether _perform_refresh won't replace a valid _current
     value with an invalid one
     """
-    # allow more frequent refreshes for tests
-    setattr(cache, "_refresh_rate_limiter", test_rate_limiter)
-
-    await cache._current
-    old_task = cache._current
-
-    # stub _perform_refresh to throw an error
-    setattr(cache, "_perform_refresh", mocks.instance_metadata_error)
-
-    # schedule refresh immediately
-    refresh_task = cache._schedule_refresh(0)
-
-    # wait for invalid refresh to finish
-    with pytest.raises(mocks.BadRefresh):
-        assert await refresh_task
-
-    # check that invalid refresh did not replace valid current metadata
-    assert cache._current == old_task
-    assert isinstance(await cache._current, ConnectionInfo)
+    # check current refresh is valid
+    assert await _is_valid(cache._current) is True
+    current_refresh = cache._current
+    # set certificate to be expired
+    cache._client.instance.cert_expiration = datetime.datetime.now(
+        datetime.timezone.utc
+    ) - datetime.timedelta(minutes=10)
+    # cert not_valid_before has to be before expiry
+    cache._client.instance.cert_before = datetime.datetime.now(
+        datetime.timezone.utc
+    ) - datetime.timedelta(minutes=20)
+    # schedule new refresh
+    new_refresh = cache._schedule_refresh(0)
+    # check new refresh is invalid
+    assert await _is_valid(new_refresh) is False
+    # check current was not replaced
+    assert await current_refresh == await cache._current
 
 
 @pytest.mark.asyncio
 async def test_schedule_refresh_replaces_invalid_result(
-    cache: RefreshAheadCache, test_rate_limiter: AsyncRateLimiter
+    cache: RefreshAheadCache,
+    test_rate_limiter: AsyncRateLimiter,
 ) -> None:
     """
     Test to check whether _perform_refresh will replace an invalid refresh result with
@@ -151,27 +143,32 @@ async def test_schedule_refresh_replaces_invalid_result(
     """
     # allow more frequent refreshes for tests
     setattr(cache, "_refresh_rate_limiter", test_rate_limiter)
-
-    # stub _perform_refresh to throw an error
-    setattr(cache, "_perform_refresh", mocks.instance_metadata_error)
-
-    # set current to invalid data (error)
+    # set certificate to be expired
+    cache._client.instance.cert_expiration = datetime.datetime.now(
+        datetime.timezone.utc
+    ) - datetime.timedelta(minutes=10)
+    # cert not_valid_before has to be before expiry
+    cache._client.instance.cert_before = datetime.datetime.now(
+        datetime.timezone.utc
+    ) - datetime.timedelta(minutes=20)
+    # set current to invalid (expired)
     cache._current = cache._schedule_refresh(0)
 
-    # check that current is now invalid (error)
-    with pytest.raises(mocks.BadRefresh):
-        assert await cache._current
+    # check current is invalid
+    assert await _is_valid(cache._current) is False
 
-    # stub _perform_refresh to return a valid MockMetadata instance
-    setattr(cache, "_perform_refresh", mocks.instance_metadata_success)
+    # set certificate to valid
+    cache._client.instance.cert_before = datetime.datetime.now(datetime.timezone.utc)
+    cache._client.instance.cert_expiration = datetime.datetime.now(
+        datetime.timezone.utc
+    ) + datetime.timedelta(hours=1)
 
     # schedule refresh immediately and await it
     refresh_task = cache._schedule_refresh(0)
-    refresh_metadata = await refresh_task
+    await refresh_task
 
-    # check that current is now valid MockMetadata
-    assert cache._current.result() == refresh_metadata
-    assert isinstance(cache._current.result(), mocks.MockMetadata)
+    # check that current is now valid
+    assert await _is_valid(cache._current) is True
 
 
 @pytest.mark.asyncio
@@ -367,3 +364,16 @@ async def test_AutoIAMAuthNotSupportedError(fake_client: CloudSQLClient) -> None
     )
     with pytest.raises(AutoIAMAuthNotSupported):
         await cache._current
+
+
+def test_ConnectionInfo_caches_sslcontext() -> None:
+    info = ConnectionInfo(
+        "cert", "cert", "key".encode(), {}, "POSTGRES", datetime.datetime.now()
+    )
+    # context should default to None
+    assert info.context is None
+    # cache a 'context'
+    info.context = "context"
+    # caling create_ssl_context should no-op with an existing 'context'
+    info.create_ssl_context()
+    assert info.context == "context"
diff --git a/tests/unit/test_refresh_utils.py b/tests/unit/test_refresh_utils.py
index 9b76b03..aded1c9 100644
--- a/tests/unit/test_refresh_utils.py
+++ b/tests/unit/test_refresh_utils.py
@@ -15,7 +15,6 @@ limitations under the License.
 """
 import asyncio
 import datetime
-from typing import no_type_check
 
 from conftest import SCOPES  # type: ignore
 import google.auth
@@ -23,8 +22,6 @@ from google.auth.credentials import Credentials
 import google.oauth2.credentials
 from mock import Mock
 from mock import patch
-from mocks import instance_metadata_expired
-from mocks import instance_metadata_success
 import pytest  # noqa F401 Needed to run the tests
 
 from google.cloud.sql.connector.refresh_utils import _downscope_credentials
@@ -40,25 +37,43 @@ def credentials() -> Credentials:
     return credentials
 
 
+class FakeConnectionInfo:
+    def __init__(self, expiration: datetime.datetime) -> None:
+        self.expiration = expiration
+
+
+async def set_expiration(expiration: datetime.datetime) -> FakeConnectionInfo:
+    return FakeConnectionInfo(expiration)
+
+
 @pytest.mark.asyncio
-@no_type_check
 async def test_is_valid_with_valid_metadata() -> None:
     """
     Test to check that valid metadata with expiration in future returns True.
     """
+
     # task that returns class with expiration 10 mins in future
-    task = asyncio.create_task(instance_metadata_success())
+    task = asyncio.create_task(
+        set_expiration(
+            datetime.datetime.now(datetime.timezone.utc)
+            + datetime.timedelta(minutes=10)
+        )
+    )
     assert await _is_valid(task)
 
 
 @pytest.mark.asyncio
-@no_type_check
 async def test_is_valid_with_expired_metadata() -> None:
     """
     Test to check that invalid metadata with expiration in past returns False.
     """
     # task that returns class with expiration 10 mins in past
-    task = asyncio.create_task(instance_metadata_expired())
+    task = asyncio.create_task(
+        set_expiration(
+            datetime.datetime.now(datetime.timezone.utc)
+            - datetime.timedelta(minutes=10)
+        )
+    )
     assert not await _is_valid(task)