#    Copyright (C) 2025 Red Hat, Inc
#
#    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.

import hashlib
import io
import json
import ssl
from unittest import mock
from urllib import parse

from oslo_config import cfg
import requests

from ironic.common import exception
from ironic.common import image_service
from ironic.common import oci_registry
from ironic.tests import base

CONF = cfg.CONF


ACCEPT_INDEX_OR_MANIFEST = (
    'application/vnd.oci.image.index.v1+json, '
    'application/vnd.oci.image.manifest.v1+json'
)


class OciClientTestCase(base.TestCase):

    def setUp(self):
        super().setUp()
        self.client = oci_registry.OciClient(verify=False)

    @mock.patch.object(oci_registry, 'MakeSession',
                       autospec=True)
    def test_client_init_make_session(self, mock_session):
        oci_registry.OciClient(verify=True)
        mock_session.assert_called_once_with(verify=True)
        mock_session.return_value.create.assert_called_once()

    def test__image_to_url(self):
        url = self.client._image_to_url('oci://host/path')
        self.assertEqual('host', url.netloc)
        self.assertEqual('/path', url.path)
        self.assertEqual('oci', url.scheme)

    def test__image_to_url_adds_oci(self):
        url = self.client._image_to_url('host/path')
        self.assertEqual('oci', url.scheme)
        self.assertEqual('host', url.netloc)
        self.assertEqual('/path', url.path)

    def test_image_tag_from_url(self):
        url = self.client._image_to_url('oci://host/path')
        img, tag = self.client._image_tag_from_url(url)
        self.assertEqual('/path', img)
        self.assertEqual('latest', tag)

    def test_image_tag_from_url_with_tag(self):
        url = self.client._image_to_url('oci://host/path:5.3')
        img, tag = self.client._image_tag_from_url(url)
        self.assertEqual('/path', img)
        self.assertEqual('5.3', tag)

    def test_image_tag_from_url_with_digest(self):
        url = self.client._image_to_url('oci://host/path@sha256:f00')
        img, tag = self.client._image_tag_from_url(url)
        self.assertEqual('/path', img)
        self.assertEqual('sha256:f00', tag)

    def test_get_blob_url(self):
        digest = ('sha256:' + 'a' * 64)
        image = 'oci://host/project/container'
        res = self.client.get_blob_url(image, digest)
        self.assertEqual(
            'https://host/v2/project/container/blobs/' + digest,
            res)


@mock.patch.object(requests.sessions.Session, 'get', autospec=True)
class OciClientRequestTestCase(base.TestCase):

    def setUp(self):
        super().setUp()
        self.client = oci_registry.OciClient(verify=True)

    def test_get_manifest_checksum_verifies(self, get_mock):
        fake_csum = 'f' * 64
        get_mock.return_value.status_code = 200
        get_mock.return_value.text = '{}'
        self.assertRaises(
            exception.ImageChecksumError,
            self.client.get_manifest,
            'oci://localhost/local@sha256:' + fake_csum)
        get_mock.return_value.assert_has_calls([
            mock.call.raise_for_status(),
            mock.call.encoding.__bool__()])
        get_mock.assert_called_once_with(
            mock.ANY,
            ('https://localhost/v2/local/manifests/sha256:ffffffffff'
             'ffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)

    def test_get_manifest(self, get_mock):
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_mock.return_value.status_code = 200
        get_mock.return_value.text = '{}'
        get_mock.return_value.headers = {}
        res = self.client.get_manifest(
            'oci://localhost/local@sha256:' + csum)
        self.assertEqual({}, res)
        get_mock.return_value.assert_has_calls([
            mock.call.raise_for_status(),
            mock.call.encoding.__bool__()])
        get_mock.assert_called_once_with(
            mock.ANY,
            'https://localhost/v2/local/manifests/sha256:' + csum,
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)

    def test_get_manifest_with_content_digest(self, get_mock):
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_mock.return_value.status_code = 200
        get_mock.return_value.text = '{}'
        get_mock.return_value.headers = {'docker-content-digest': 'abcd'}
        res = self.client.get_manifest(
            'oci://localhost/local@sha256:' + csum)
        self.assertEqual({'dockerContentDigest': 'abcd'}, res)
        get_mock.return_value.assert_has_calls([
            mock.call.raise_for_status(),
            mock.call.encoding.__bool__()])
        get_mock.assert_called_once_with(
            mock.ANY,
            'https://localhost/v2/local/manifests/sha256:' + csum,
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)

    def test_get_manifest_auth_required(self, get_mock):
        fake_csum = 'f' * 64
        response = mock.Mock()
        response.status_code = 401
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageServiceAuthenticationRequired,
            self.client.get_manifest,
            'oci://localhost/local@sha256:' + fake_csum)
        call_mock = mock.call(
            mock.ANY,
            ('https://localhost/v2/local/manifests/sha256:ffffffffff'
             'ffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)
        # Gets retried.
        get_mock.assert_has_calls([call_mock, call_mock])

    def test_get_manifest_image_access_denied(self, get_mock):
        fake_csum = 'f' * 64
        response = mock.Mock()
        response.status_code = 403
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageNotFound,
            self.client.get_manifest,
            'oci://localhost/local@sha256:' + fake_csum)
        call_mock = mock.call(
            mock.ANY,
            ('https://localhost/v2/local/manifests/sha256:ffffffffff'
             'ffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test_get_manifest_image_not_found(self, get_mock):
        fake_csum = 'f' * 64
        response = mock.Mock()
        response.status_code = 404
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageNotFound,
            self.client.get_manifest,
            'oci://localhost/local@sha256:' + fake_csum)
        call_mock = mock.call(
            mock.ANY,
            ('https://localhost/v2/local/manifests/sha256:ffffffffff'
             'ffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test_get_manifest_image_temporary_failure(self, get_mock):
        fake_csum = 'f' * 64
        response = mock.Mock()
        response.status_code = 500
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.TemporaryFailure,
            self.client.get_manifest,
            'oci://localhost/local@sha256:' + fake_csum)
        call_mock = mock.call(
            mock.ANY,
            ('https://localhost/v2/local/manifests/sha256:ffffffffff'
             'ffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_with_tag(self, resolve_tag_mock, get_mock):
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }
        get_mock.return_value.status_code = 200
        get_mock.return_value.text = '{}'
        get_mock.return_value.headers = {}
        res = self.client.get_artifact_index(
            'oci://localhost/local:tag')
        self.assertEqual({}, res)
        resolve_tag_mock.assert_called_once_with(
            mock.ANY,
            parse.urlparse('oci://localhost/local:tag'))
        get_mock.return_value.assert_has_calls([
            mock.call.raise_for_status(),
            mock.call.encoding.__bool__()])
        get_mock.assert_called_once_with(
            mock.ANY,
            'https://localhost/v2/local/manifests/tag',
            headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
            timeout=60)

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_not_found(self, resolve_tag_mock, get_mock):
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }
        response = mock.Mock()
        response.status_code = 404
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageNotFound,
            self.client.get_artifact_index,
            'oci://localhost/local:tag')
        resolve_tag_mock.assert_called_once_with(
            mock.ANY,
            parse.urlparse('oci://localhost/local:tag'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/manifests/tag',
            headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_not_authorized(self, resolve_tag_mock,
                                               get_mock):
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }
        response = mock.Mock()
        response.status_code = 401
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageServiceAuthenticationRequired,
            self.client.get_artifact_index,
            'oci://localhost/local:tag')
        resolve_tag_mock.assert_called_once_with(
            mock.ANY,
            parse.urlparse('oci://localhost/local:tag'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/manifests/tag',
            headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
            timeout=60)
        # Automatic retry to authenticate
        get_mock.assert_has_calls([call_mock, call_mock])

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_temporaryfailure(self, resolve_tag_mock,
                                                 get_mock):
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }
        response = mock.Mock()
        response.status_code = 500
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.TemporaryFailure,
            self.client.get_artifact_index,
            'oci://localhost/local:tag')
        resolve_tag_mock.assert_called_once_with(
            mock.ANY,
            parse.urlparse('oci://localhost/local:tag'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/manifests/tag',
            headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_access_denied(self, resolve_tag_mock,
                                              get_mock):
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }
        response = mock.Mock()
        response.status_code = 403
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageNotFound,
            self.client.get_artifact_index,
            'oci://localhost/local:tag')
        resolve_tag_mock.assert_called_once_with(
            mock.ANY,
            parse.urlparse('oci://localhost/local:tag'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/manifests/tag',
            headers={'Accept': ACCEPT_INDEX_OR_MANIFEST},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test__resolve_tag(self, get_mock):
        response = mock.Mock()
        response.json.return_value = {'tags': ['latest', 'foo', 'bar']}
        response.status_code = 200
        response.links = {}
        get_mock.return_value = response
        res = self.client._resolve_tag(
            parse.urlparse('oci://localhost/local'))
        self.assertDictEqual({'image': '/local', 'tag': 'latest'}, res)
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test__resolve_tag_if_not_found(self, get_mock):
        response = mock.Mock()
        response.json.return_value = {'tags': ['foo', 'bar']}
        response.status_code = 200
        response.links = {}
        get_mock.return_value = response
        self.assertRaises(
            exception.OciImageTagNotFound,
            self.client._resolve_tag,
            parse.urlparse('oci://localhost/local'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test__resolve_tag_if_no_tags(self, get_mock):
        response = mock.Mock()
        response.json.return_value = {'tags': []}
        response.status_code = 200
        response.links = {}
        get_mock.return_value = response
        self.assertRaises(
            exception.InvalidImageRef,
            self.client._resolve_tag,
            parse.urlparse('oci://localhost/local'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test__resolve_tag_follows_links(self, get_mock):
        response = mock.Mock()
        response.json.return_value = {'tags': ['foo', 'bar']}
        response.status_code = 200
        response.links = {'next': {'url': 'list2'}}
        response2 = mock.Mock()
        response2.json.return_value = {'tags': ['zoo']}
        response2.status_code = 200
        response2.links = {}
        get_mock.side_effect = iter([response, response2])
        res = self.client._resolve_tag(
            parse.urlparse('oci://localhost/local:zoo'))
        self.assertDictEqual({'image': '/local', 'tag': 'zoo'}, res)
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        call_mock_2 = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list2',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock, call_mock_2])

    def test__resolve_tag_auth_needed(self, get_mock):
        response = mock.Mock()
        response.json.return_value = {}
        response.status_code = 401
        response.text = 'Authorization Required'
        response.links = {}
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.ImageServiceAuthenticationRequired,
            self.client._resolve_tag,
            parse.urlparse('oci://localhost/local'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test__resolve_tag_temp_failure(self, get_mock):
        response = mock.Mock()
        response.json.return_value = {}
        response.status_code = 500
        response.text = 'Server on vacation'
        response.links = {}
        exc = requests.exceptions.HTTPError(
            response=response)
        get_mock.side_effect = exc
        self.assertRaises(
            exception.TemporaryFailure,
            self.client._resolve_tag,
            parse.urlparse('oci://localhost/local'))
        call_mock = mock.call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        get_mock.assert_has_calls([call_mock])

    def test__resolve_tag_ssl_error_with_http_fallback_enabled(
            self, get_mock):
        """Test tag resolution falls back to HTTP on SSLError when enabled."""
        CONF.set_override('permit_fallback_to_http_transport', True,
                          group='oci')
        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        response_http = mock.Mock()
        response_http.json.return_value = {'tags': ['latest', 'foo']}
        response_http.status_code = 200
        response_http.links = {}
        get_mock.side_effect = iter([ssl_error, response_http])

        res = self.client._resolve_tag(
            parse.urlparse('oci://localhost/local'))

        self.assertDictEqual({'image': '/local', 'tag': 'latest'}, res)
        self.assertEqual(2, get_mock.call_count)
        # First call should be HTTPS
        get_mock.assert_any_call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        # Second call should be HTTP
        get_mock.assert_any_call(
            mock.ANY,
            'http://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)

    def test__resolve_tag_ssl_error_with_http_fallback_disabled(
            self, get_mock):
        """Test SSLError is raised when tag resolution fallback disabled."""
        CONF.set_override('permit_fallback_to_http_transport', False,
                          group='oci')
        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        get_mock.side_effect = ssl_error

        self.assertRaises(
            requests.exceptions.SSLError,
            self.client._resolve_tag,
            parse.urlparse('oci://localhost/local'))

        # Only one HTTPS call should be made
        self.assertEqual(1, get_mock.call_count)
        get_mock.assert_called_once_with(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)

    def test__resolve_tag_follows_links_with_ssl_error_fallback(
            self, get_mock):
        """Test paginated tag list falls back to HTTP on SSLError."""
        CONF.set_override('permit_fallback_to_http_transport', True,
                          group='oci')
        # First request succeeds with HTTPS
        response1 = mock.Mock()
        response1.json.return_value = {'tags': ['foo', 'bar']}
        response1.status_code = 200
        response1.links = {'next': {'url': 'list2'}}

        # Second (paginated) request fails with SSL, then succeeds with HTTP
        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        response2 = mock.Mock()
        response2.json.return_value = {'tags': ['zoo']}
        response2.status_code = 200
        response2.links = {}

        get_mock.side_effect = iter([response1, ssl_error, response2])

        res = self.client._resolve_tag(
            parse.urlparse('oci://localhost/local:zoo'))

        self.assertDictEqual({'image': '/local', 'tag': 'zoo'}, res)
        self.assertEqual(3, get_mock.call_count)
        # First call for initial tag list
        get_mock.assert_any_call(
            mock.ANY,
            'https://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        # Second call for paginated list (HTTPS fails)
        get_mock.assert_any_call(
            mock.ANY,
            'https://localhost/v2/local/tags/list2',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)
        # Third call for paginated list (HTTP succeeds)
        get_mock.assert_any_call(
            mock.ANY,
            'http://localhost/v2/local/tags/list2',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)

    def test__resolve_tag_after_previous_http_fallback(self, get_mock):
        """Test tag resolution uses HTTP after override scheme is set."""
        # Simulate previous fallback
        self.client._override_protocol_scheme = 'http'
        response = mock.Mock()
        response.json.return_value = {'tags': ['latest', 'foo']}
        response.status_code = 200
        response.links = {}
        get_mock.return_value = response

        res = self.client._resolve_tag(
            parse.urlparse('oci://localhost/local'))

        self.assertDictEqual({'image': '/local', 'tag': 'latest'}, res)
        # Should use HTTP directly
        get_mock.assert_called_once_with(
            mock.ANY,
            'http://localhost/v2/local/tags/list',
            headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
            timeout=60)

    def test_authenticate_noop(self, get_mock):
        """Test authentication when the remote endpoint doesn't require it."""
        response = mock.Mock()
        response.status_code = 200
        get_mock.return_value = response
        self.client.authenticate(
            'oci://localhost/foo/bar:meow',
            username='foo',
            password='bar')
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60)])

    def test_authenticate_401_no_header(self, get_mock):
        """Test authentication when the remote endpoint doesn't require it."""
        response = mock.Mock()
        response.status_code = 401
        response.headers = {}
        get_mock.return_value = response
        self.assertRaisesRegex(
            AttributeError,
            'Unknown authentication method',
            self.client.authenticate,
            'oci://localhost/foo/bar:meow',
            username='foo',
            password='bar')
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60)])

    def test_authenticate_401_bad_header(self, get_mock):
        """Test authentication when the remote endpoint doesn't require it."""
        response = mock.Mock()
        response.status_code = 401
        response.headers = {'www-authenticate': 'magic'}
        get_mock.return_value = response
        self.assertRaisesRegex(
            AttributeError,
            'Unknown www-authenticate value',
            self.client.authenticate,
            'oci://localhost/foo/bar:meow',
            username='foo',
            password='bar')
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60)])

    def test_authenticate_401_bearer_auth(self, get_mock):
        self.assertIsNone(self.client._cached_auth)
        self.assertIsNone(self.client.session.headers.get('Authorization'))
        response = mock.Mock()
        response.status_code = 401
        response.json.return_value = {'token': 'me0w'}
        response.headers = {'www-authenticate': 'bearer realm="foo"'}
        response2 = mock.Mock()
        response2.status_code = 200
        response2.json.return_value = {'token': 'me0w'}
        get_mock.side_effect = iter([response, response2])
        self.client.authenticate(
            'oci://localhost/foo/bar:meow',
            username='',
            password='bar')
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60),
            mock.call(mock.ANY, 'foo',
                      params={'scope': 'repository:foo/bar:pull'},
                      auth=mock.ANY, timeout=60)])
        self.assertIsNotNone(self.client._cached_auth)
        self.assertEqual('bearer me0w',
                         self.client.session.headers['Authorization'])

    def test_authenticate_401_basic_auth_no_username(self, get_mock):
        self.assertIsNone(self.client._cached_auth)
        self.assertIsNone(self.client.session.headers.get('Authorization'))
        response = mock.Mock()
        response.status_code = 401
        response.headers = {'www-authenticate': 'basic service="foo"'}
        get_mock.return_value = response
        self.assertRaises(
            exception.ImageServiceAuthenticationRequired,
            self.client.authenticate,
            'oci://localhost/foo/bar:meow',
            username='',
            password='bar')
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60)])

    def test_authenticate_401_basic_auth(self, get_mock):
        self.assertIsNone(self.client._cached_auth)
        self.assertIsNone(self.client.session.headers.get('Authorization'))
        response = mock.Mock()
        response.status_code = 401
        response.headers = {'www-authenticate': 'basic service="foo"'}
        response2 = mock.Mock()
        response2.status_code = 200
        get_mock.side_effect = iter([response, response2])
        self.client.authenticate(
            'oci://localhost/foo/bar:meow',
            username='user',
            password='bar')
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60),
            mock.call(mock.ANY, 'https://localhost/v2/',
                      params={},
                      auth=mock.ANY, timeout=60)])
        self.assertIsNotNone(self.client._cached_auth)
        self.assertEqual('basic dXNlcjpiYXI=',
                         self.client.session.headers['Authorization'])

    @mock.patch.object(oci_registry.RegistrySessionHelper,
                       'get_token_from_config',
                       autospec=True)
    def test_authenticate_401_fallback_to_service_config(self, token_mock,
                                                         get_mock):
        self.assertIsNone(self.client._cached_auth)
        self.assertIsNone(self.client.session.headers.get('Authorization'))
        response = mock.Mock()
        response.status_code = 401
        response.headers = {
            'www-authenticate': 'bearer realm="https://foo/bar"'}
        response2 = mock.Mock()
        response2.status_code = 200
        response2.json.return_value = {'token': 'me0w'}
        get_mock.side_effect = iter([response, response2])
        self.client.authenticate(
            'oci://localhost/foo/bar:meow',
            username=None,
            password=None)
        get_mock.assert_has_calls([
            mock.call(mock.ANY, 'https://localhost/v2/', timeout=60),
            mock.call(mock.ANY, 'https://foo/bar',
                      params={'scope': 'repository:foo/bar:pull'},
                      auth=mock.ANY, timeout=60)])
        self.assertIsNotNone(self.client._cached_auth)
        self.assertEqual('bearer me0w',
                         self.client.session.headers['Authorization'])
        token_mock.assert_called_once_with('foo')

    @mock.patch.object(hashlib, 'new', autospec=True)
    def test_download_blob_from_manifest(self, mock_hash, get_mock):
        CONF.set_override('secure_cdn_registries', ['localhost'], group='oci')
        self.client.session.headers = {'Authorization': 'bearer zoo'}
        mock_file = mock.MagicMock(spec=io.BytesIO)
        mock_hash.return_value.hexdigest.side_effect = iter([
            ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f'
             '61caaff8a'),
            ('2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e8'
             '86266e7ae')
        ])
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_1 = mock.Mock()
        get_1.status_code = 200
        manifest = {
            'layers': [{
                'digest': ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706'
                           '483bfa0f98a5e886266e7ae')}]
        }
        get_1.text = json.dumps(manifest)
        get_2 = mock.Mock()
        get_2.status_code = 301
        get_2.headers = {'Location': 'https://localhost/foo/sha'}
        get_3 = mock.Mock()
        get_3.status_code = 200
        get_3.iter_content.return_value = ['some', 'content']
        get_mock.side_effect = iter([get_1, get_2, get_3])

        res = self.client.download_blob_from_manifest(
            'oci://localhost/foo/bar@sha256:' + csum,
            mock_file)

        mock_file.write.assert_has_calls([
            mock.call('some'),
            mock.call('content')])
        self.assertEqual(
            ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98'
             'a5e886266e7ae'),
            res)
        get_mock.assert_has_calls([
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/manifests/sha256:44136fa355b'
                 '3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'),
                headers={
                    'Accept': 'application/vnd.oci.image.manifest.v1+json'},
                timeout=60),
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/blobs/sha256:2c26b46b68ffc68f'
                 'f99b453c1d30413413422d706483bfa0f98a5e886266e7ae'),
                stream=True,
                timeout=60),
            mock.call(
                mock.ANY,
                'https://localhost/foo/sha',
                stream=True,
                timeout=60)
        ])

    @mock.patch.object(hashlib, 'new', autospec=True)
    def test_download_blob_from_manifest_code_check(self, mock_hash,
                                                    get_mock):
        mock_file = mock.MagicMock(spec=io.BytesIO)
        mock_hash.return_value.hexdigest.side_effect = iter([
            ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f'
             '61caaff8a'),
            ('2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e8'
             '86266e7ae')
        ])
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_1 = mock.Mock()
        get_1.status_code = 200
        manifest = {
            'layers': [{
                'digest': ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706'
                           '483bfa0f98a5e886266e7ae')}]
        }
        get_1.text = json.dumps(manifest)
        get_2 = mock.Mock()
        get_2.status_code = 301
        get_2.headers = {'Location': 'https://localhost/foo/sha'}
        get_3 = mock.Mock()
        get_3.status_code = 204
        get_3.iter_content.return_value = ['some', 'content']
        get_mock.side_effect = iter([get_1, get_2, get_3])

        self.assertRaisesRegex(
            exception.ImageRefValidationFailed,
            'Got HTTP code 204',
            self.client.download_blob_from_manifest,
            'oci://localhost/foo/bar@sha256:' + csum,
            mock_file)

        mock_file.write.assert_not_called()
        get_mock.assert_has_calls([
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/manifests/sha256:44136fa355b'
                 '3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'),
                headers={
                    'Accept': 'application/vnd.oci.image.manifest.v1+json'},
                timeout=60),
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/blobs/sha256:2c26b46b68ffc68f'
                 'f99b453c1d30413413422d706483bfa0f98a5e886266e7ae'),
                stream=True,
                timeout=60),
            mock.call(
                mock.ANY,
                'https://localhost/foo/sha',
                stream=True,
                timeout=60)
        ])

    @mock.patch.object(hashlib, 'new', autospec=True)
    def test_download_blob_from_manifest_code_401(self, mock_hash,
                                                  get_mock):
        self.client.session.headers = {'Authorization': 'bearer zoo'}
        mock_file = mock.MagicMock(spec=io.BytesIO)
        mock_hash.return_value.hexdigest.side_effect = iter([
            ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f'
             '61caaff8a'),
            ('2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e8'
             '86266e7ae')
        ])
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_1 = mock.Mock()
        get_1.status_code = 200
        manifest = {
            'layers': [{
                'digest': ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706'
                           '483bfa0f98a5e886266e7ae')}]
        }
        get_1.text = json.dumps(manifest)
        get_2 = mock.Mock()
        get_2.status_code = 401
        get_2_exc = requests.exceptions.HTTPError(
            response=get_2)
        # Authentication is automatically retried, hence
        # needing to return exceptions twice.
        get_mock.side_effect = iter([get_1, get_2_exc, get_2_exc])

        self.assertRaises(
            exception.ImageServiceAuthenticationRequired,
            self.client.download_blob_from_manifest,
            'oci://localhost/foo/bar@sha256:' + csum,
            mock_file)

        mock_file.write.assert_not_called()
        get_mock.assert_has_calls([
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/manifests/sha256:44136fa355b'
                 '3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'),
                headers={
                    'Accept': 'application/vnd.oci.image.manifest.v1+json'},
                timeout=60),
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/blobs/sha256:2c26b46b68ffc68f'
                 'f99b453c1d30413413422d706483bfa0f98a5e886266e7ae'),
                stream=True,
                timeout=60),
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/blobs/sha256:2c26b46b68ffc68f'
                 'f99b453c1d30413413422d706483bfa0f98a5e886266e7ae'),
                stream=True,
                timeout=60),
        ])

    @mock.patch.object(hashlib, 'new', autospec=True)
    def test_download_blob_from_manifest_code_404(self, mock_hash,
                                                  get_mock):
        mock_file = mock.MagicMock(spec=io.BytesIO)
        mock_hash.return_value.hexdigest.side_effect = iter([
            ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f'
             '61caaff8a'),
            ('2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e8'
             '86266e7ae')
        ])
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_1 = mock.Mock()
        get_1.status_code = 200
        manifest = {
            'layers': [{
                'digest': ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706'
                           '483bfa0f98a5e886266e7ae')}]
        }
        get_1.text = json.dumps(manifest)
        get_2 = mock.Mock()
        get_2.status_code = 404
        get_2_exc = requests.exceptions.HTTPError(
            response=get_2)
        get_mock.side_effect = iter([get_1, get_2_exc])

        self.assertRaises(
            exception.ImageNotFound,
            self.client.download_blob_from_manifest,
            'oci://localhost/foo/bar@sha256:' + csum,
            mock_file)

        mock_file.write.assert_not_called()
        get_mock.assert_has_calls([
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/manifests/sha256:44136fa355b'
                 '3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'),
                headers={
                    'Accept': 'application/vnd.oci.image.manifest.v1+json'},
                timeout=60),
            mock.call(
                mock.ANY,
                ('https://localhost/v2/foo/bar/blobs/sha256:2c26b46b68ffc68f'
                 'f99b453c1d30413413422d706483bfa0f98a5e886266e7ae'),
                stream=True,
                timeout=60),
        ])

    @mock.patch.object(hashlib, 'new', autospec=True)
    def test_download_blob_from_manifest_code_403(self, mock_hash,
                                                  get_mock):
        mock_file = mock.MagicMock(spec=io.BytesIO)
        mock_hash.return_value.hexdigest.side_effect = iter([
            ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f'
             '61caaff8a'),
            ('2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e8'
             '86266e7ae')
        ])
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_1 = mock.Mock()
        get_1.status_code = 200
        manifest = {
            'layers': [{
                'digest': ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706'
                           '483bfa0f98a5e886266e7ae')}]
        }
        get_1.text = json.dumps(manifest)
        get_2 = mock.Mock()
        get_2.status_code = 403
        get_2_exc = requests.exceptions.HTTPError(
            response=get_2)
        get_mock.side_effect = iter([get_1, get_2_exc])

        self.assertRaises(
            exception.ImageNotFound,
            self.client.download_blob_from_manifest,
            'oci://localhost/foo/bar@sha256:' + csum,
            mock_file)
        mock_file.write.assert_not_called()
        self.assertEqual(2, get_mock.call_count)

    @mock.patch.object(hashlib, 'new', autospec=True)
    def test_download_blob_from_manifest_code_500(self, mock_hash,
                                                  get_mock):
        mock_file = mock.MagicMock(spec=io.BytesIO)
        mock_hash.return_value.hexdigest.side_effect = iter([
            ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f'
             '61caaff8a'),
            ('2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e8'
             '86266e7ae')
        ])
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')
        get_1 = mock.Mock()
        get_1.status_code = 200
        manifest = {
            'layers': [{
                'digest': ('sha256:2c26b46b68ffc68ff99b453c1d30413413422d706'
                           '483bfa0f98a5e886266e7ae')}]
        }
        get_1.text = json.dumps(manifest)
        get_2 = mock.Mock()
        get_2.status_code = 500
        get_2_exc = requests.exceptions.HTTPError(
            response=get_2)
        get_mock.side_effect = iter([get_1, get_2_exc])

        self.assertRaises(
            exception.TemporaryFailure,
            self.client.download_blob_from_manifest,
            'oci://localhost/foo/bar@sha256:' + csum,
            mock_file)
        mock_file.write.assert_not_called()
        self.assertEqual(2, get_mock.call_count)

    def test_authenticate_ssl_error_with_http_fallback_enabled(
            self, get_mock):
        """Test authentication falls back to HTTP on SSLError when enabled."""
        CONF.set_override('permit_fallback_to_http_transport', True,
                          group='oci')
        response_https = mock.Mock()
        response_https.status_code = 200
        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        response_http = mock.Mock()
        response_http.status_code = 200
        get_mock.side_effect = iter([ssl_error, response_http])

        self.client.authenticate('oci://localhost/foo/bar:latest')

        self.assertEqual(2, get_mock.call_count)
        # First call should be HTTPS
        get_mock.assert_any_call(
            mock.ANY, 'https://localhost/v2/', timeout=60)
        # Second call should be HTTP
        get_mock.assert_any_call(
            mock.ANY, 'http://localhost/v2/', timeout=60)
        # Verify override scheme is set
        self.assertEqual('http', self.client._override_protocol_scheme)

    def test_authenticate_ssl_error_with_http_fallback_disabled(
            self, get_mock):
        """Test SSLError is raised when HTTP fallback is disabled."""
        CONF.set_override('permit_fallback_to_http_transport', False,
                          group='oci')
        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        get_mock.side_effect = ssl_error

        self.assertRaises(
            requests.exceptions.SSLError,
            self.client.authenticate,
            'oci://localhost/foo/bar:latest')

        # Only one HTTPS call should be made
        self.assertEqual(1, get_mock.call_count)
        get_mock.assert_called_once_with(
            mock.ANY, 'https://localhost/v2/', timeout=60)

    def test_authenticate_successful_https_no_fallback(self, get_mock):
        """Test normal authentication without needing fallback."""
        response = mock.Mock()
        response.status_code = 200
        get_mock.return_value = response

        self.client.authenticate('oci://localhost/foo/bar:latest')

        # Only HTTPS call should be made
        get_mock.assert_called_once_with(
            mock.ANY, 'https://localhost/v2/', timeout=60)
        # Override scheme should not be set
        self.assertIsNone(self.client._override_protocol_scheme)

    def test_get_manifest_ssl_error_with_http_fallback_enabled(
            self, get_mock):
        """Test manifest retrieval falls back to HTTP on SSLError."""
        CONF.set_override('permit_fallback_to_http_transport', True,
                          group='oci')
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')

        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        response_http = mock.Mock()
        response_http.status_code = 200
        response_http.text = '{}'
        response_http.headers = {}
        get_mock.side_effect = iter([ssl_error, response_http])

        res = self.client.get_manifest(
            'oci://localhost/local@sha256:' + csum)

        self.assertEqual({}, res)
        self.assertEqual(2, get_mock.call_count)
        # First call HTTPS
        get_mock.assert_any_call(
            mock.ANY,
            'https://localhost/v2/local/manifests/sha256:' + csum,
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)
        # Second call HTTP
        get_mock.assert_any_call(
            mock.ANY,
            'http://localhost/v2/local/manifests/sha256:' + csum,
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)

    def test_get_manifest_ssl_error_with_http_fallback_disabled(
            self, get_mock):
        """Test SSLError is raised when manifest fallback is disabled."""
        CONF.set_override('permit_fallback_to_http_transport', False,
                          group='oci')
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')

        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        get_mock.side_effect = ssl_error

        self.assertRaises(
            requests.exceptions.SSLError,
            self.client.get_manifest,
            'oci://localhost/local@sha256:' + csum)

        self.assertEqual(1, get_mock.call_count)

    def test_get_manifest_after_previous_http_fallback(self, get_mock):
        """Test manifest uses HTTP after override scheme is set."""
        # Simulate previous fallback
        self.client._override_protocol_scheme = 'http'
        csum = ('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c0'
                '60f61caaff8a')

        response = mock.Mock()
        response.status_code = 200
        response.text = '{}'
        response.headers = {}
        get_mock.return_value = response

        res = self.client.get_manifest(
            'oci://localhost/local@sha256:' + csum)

        self.assertEqual({}, res)
        # Should use HTTP directly
        get_mock.assert_called_once_with(
            mock.ANY,
            'http://localhost/v2/local/manifests/sha256:' + csum,
            headers={'Accept': 'application/vnd.oci.image.manifest.v1+json'},
            timeout=60)

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_ssl_error_with_http_fallback_enabled(
            self, resolve_tag_mock, get_mock):
        """Test index retrieval falls back to HTTP on SSLError."""
        CONF.set_override('permit_fallback_to_http_transport', True,
                          group='oci')
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }

        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        response_http = mock.Mock()
        response_http.status_code = 200
        response_http.text = '{}'
        response_http.headers = {}
        get_mock.side_effect = iter([ssl_error, response_http])

        res = self.client.get_artifact_index('oci://localhost/local:tag')

        self.assertEqual({}, res)
        self.assertEqual(2, get_mock.call_count)
        # First call HTTPS
        get_mock.assert_any_call(
            mock.ANY,
            'https://localhost/v2/local/manifests/tag',
            headers={'Accept': ('application/vnd.oci.image.index.v1+json, '
                                'application/vnd.oci.image.manifest.v1+json')},
            timeout=60)
        # Second call HTTP
        get_mock.assert_any_call(
            mock.ANY,
            'http://localhost/v2/local/manifests/tag',
            headers={'Accept': ('application/vnd.oci.image.index.v1+json, '
                                'application/vnd.oci.image.manifest.v1+json')},
            timeout=60)

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_ssl_error_with_http_fallback_disabled(
            self, resolve_tag_mock, get_mock):
        """Test SSLError is raised when index fallback is disabled."""
        CONF.set_override('permit_fallback_to_http_transport', False,
                          group='oci')
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }

        ssl_error = requests.exceptions.SSLError('SSL handshake failed')
        get_mock.side_effect = ssl_error

        self.assertRaises(
            requests.exceptions.SSLError,
            self.client.get_artifact_index,
            'oci://localhost/local:tag')

        self.assertEqual(1, get_mock.call_count)

    @mock.patch.object(oci_registry.OciClient, '_resolve_tag',
                       autospec=True)
    def test_get_artifact_index_after_previous_http_fallback(
            self, resolve_tag_mock, get_mock):
        """Test index uses HTTP after override scheme is set."""
        # Simulate previous fallback
        self.client._override_protocol_scheme = 'http'
        resolve_tag_mock.return_value = {
            'image': '/local',
            'tag': 'tag'
        }

        response = mock.Mock()
        response.status_code = 200
        response.text = '{}'
        response.headers = {}
        get_mock.return_value = response

        res = self.client.get_artifact_index('oci://localhost/local:tag')

        self.assertEqual({}, res)
        # Should use HTTP directly
        get_mock.assert_called_once_with(
            mock.ANY,
            'http://localhost/v2/local/manifests/tag',
            headers={'Accept': ('application/vnd.oci.image.index.v1+json, '
                                'application/vnd.oci.image.manifest.v1+json')},
            timeout=60)


class OciClientBuildUrlTestCase(base.TestCase):

    def setUp(self):
        super().setUp()
        # Create an instance for testing
        self.client = oci_registry.OciClient(verify=True)

    def test_build_url_default_https_scheme(self):
        """Test _build_url uses HTTPS by default."""
        url = parse.urlparse('oci://localhost/foo/bar')
        result = self.client._build_url(url, '/test')

        self.assertEqual('https://localhost/v2/test', result)
        self.assertIsNone(self.client._override_protocol_scheme)

    def test_build_url_with_http_scheme(self):
        """Test _build_url with HTTP scheme sets override."""
        url = parse.urlparse('oci://localhost/foo/bar')
        result = self.client._build_url(url, '/test', scheme='http')

        self.assertEqual('http://localhost/v2/test', result)
        self.assertEqual('http', self.client._override_protocol_scheme)

    def test_build_url_uses_cached_override_scheme(self):
        """Test _build_url uses cached override for subsequent calls."""
        self.client._override_protocol_scheme = 'http'
        url = parse.urlparse('oci://localhost/foo/bar')

        # Call with default scheme, should use cached http
        result = self.client._build_url(url, '/test')

        self.assertEqual('http://localhost/v2/test', result)
        self.assertEqual('http', self.client._override_protocol_scheme)

    def test_build_url_override_persists(self):
        """Test override scheme persists across multiple calls."""
        url = parse.urlparse('oci://localhost/foo/bar')

        # First call with http
        result1 = self.client._build_url(
            url, '/test1', scheme='http')
        self.assertEqual('http://localhost/v2/test1', result1)

        # Second call without specifying scheme
        result2 = self.client._build_url(url, '/test2')
        self.assertEqual('http://localhost/v2/test2', result2)

        # Third call explicitly with https, should still use http
        result3 = self.client._build_url(
            url, '/test3', scheme='https')
        self.assertEqual('http://localhost/v2/test3', result3)


class TestRegistrySessionHelper(base.TestCase):

    def test_get_token_from_config_default(self):
        self.assertIsNone(
            oci_registry.RegistrySessionHelper.get_token_from_config(
                'foo.bar'))

    def test_get_token_from_config(self):
        CONF.set_override('authentication_config', '/tmp/foo',
                          group='oci')
        read_data = """{"auths": {"foo.fqdn": {"auth": "secret"}}}"""
        with mock.patch('builtins.open', mock.mock_open(
                read_data=read_data)):
            res = oci_registry.RegistrySessionHelper.get_token_from_config(
                'foo.fqdn')
        self.assertEqual('secret', res)

    def test_get_token_from_config_no_match(self):
        CONF.set_override('authentication_config', '/tmp/foo',
                          group='oci')
        read_data = """{"auths": {"bar.fqdn": {}}}"""
        with mock.patch('builtins.open', mock.mock_open(
                read_data=read_data)):
            res = oci_registry.RegistrySessionHelper.get_token_from_config(
                'foo.fqdn')
        self.assertIsNone(res)

    def test_get_token_from_config_bad_file(self):
        CONF.set_override('authentication_config', '/tmp/foo',
                          group='oci')
        read_data = """{"auths":..."""
        with mock.patch('builtins.open', mock.mock_open(
                read_data=read_data)):
            res = oci_registry.RegistrySessionHelper.get_token_from_config(
                'foo.fqdn')
        self.assertIsNone(res)


class MakeSessionTLSTestCase(base.TestCase):

    def test_make_session_defaults(self):
        """Default config returns session with TLS 1.3."""
        ms = oci_registry.MakeSession(verify=True)
        session = ms.create()
        adapter = session.get_adapter('https://example.com')
        self.assertIsInstance(
            adapter, image_service.TLSHTTPAdapter)
        self.assertEqual(
            ssl.TLSVersion.TLSv1_3,
            adapter._ssl_context.minimum_version)

    def test_make_session_tls_minimum_version_1_2(self):
        CONF.set_override(
            'webserver_tls_minimum_version', '1.2')
        ms = oci_registry.MakeSession(verify=True)
        session = ms.create()
        adapter = session.get_adapter('https://example.com')
        self.assertIsInstance(
            adapter, image_service.TLSHTTPAdapter)
        self.assertEqual(
            ssl.TLSVersion.TLSv1_2,
            adapter._ssl_context.minimum_version)

    def test_make_session_tls_ciphers(self):
        CONF.set_override(
            'webserver_tls_ciphers', 'ECDHE+AESGCM')
        with mock.patch.object(
                ssl.SSLContext, 'set_ciphers',
                autospec=True) as mock_set_ciphers:
            ms = oci_registry.MakeSession(verify=True)
            session = ms.create()
            adapter = session.get_adapter(
                'https://example.com')
            self.assertIsInstance(
                adapter, image_service.TLSHTTPAdapter)
            mock_set_ciphers.assert_called_once_with(
                'ECDHE+AESGCM')

    def test_make_session_tls_all_options(self):
        CONF.set_override(
            'webserver_tls_minimum_version', '1.3')
        CONF.set_override(
            'webserver_tls_ciphers', 'ECDHE+AESGCM')
        with mock.patch.object(
                ssl.SSLContext, 'set_ciphers',
                autospec=True) as mock_set_ciphers:
            ms = oci_registry.MakeSession(verify=True)
            session = ms.create()
            adapter = session.get_adapter(
                'https://example.com')
            self.assertIsInstance(
                adapter, image_service.TLSHTTPAdapter)
            self.assertEqual(
                ssl.TLSVersion.TLSv1_3,
                adapter._ssl_context.minimum_version)
            mock_set_ciphers.assert_called_once_with(
                'ECDHE+AESGCM')

    def test_make_session_verification_disabled(self):
        """SSL context defers verification to requests lib."""
        ms = oci_registry.MakeSession(verify=True)
        session = ms.create()
        adapter = session.get_adapter('https://example.com')
        self.assertFalse(
            adapter._ssl_context.check_hostname)
        self.assertEqual(
            ssl.CERT_NONE,
            adapter._ssl_context.verify_mode)
