Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions eodag/plugins/download/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,8 +806,10 @@ def stream_download(
if flatten_top_dirs:
rel_path = os.path.join(
product.properties["title"],
re.sub(rf"^{common_path}/?", "", rel_path),
re.sub(rf"^{re.escape(common_path)}/?", "", rel_path),
)
# Normalize to forward slashes for S3 path consistency across platforms
rel_path = rel_path.replace("\\", "/")

asset_match = assets_by_path.get(f"{obj.bucket_name}/{obj.key}")
data_type = asset_match.get("type") if asset_match else None
Expand Down Expand Up @@ -847,7 +849,8 @@ def _get_commonpath(
chunk_paths.append(
self.get_chunk_dest_path(product, product_chunk, build_safe=build_safe)
)
return os.path.commonpath(chunk_paths)
# Normalize to forward slashes for S3 path consistency across platforms
return os.path.commonpath(chunk_paths).replace("\\", "/")

def get_product_bucket_name_and_prefix(
self, product: EOProduct, url: Optional[str] = None
Expand Down
21 changes: 20 additions & 1 deletion eodag/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,11 @@ def mutate_dict_in_place(func: Callable[[Any], Any], mapping: dict[Any, Any]) ->
allowing to also modify values of nested dicts that may be level-1 values of
mapping.

>>> mapping = {"a": 1, "nested": {"b": 2}}
>>> mutate_dict_in_place(lambda value: value * 10, mapping)
>>> mapping
{'a': 10, 'nested': {'b': 20}}

:param func: A function to apply to each value of mapping which is not a dict object
:param mapping: A Python dict object
:returns: None
Expand Down Expand Up @@ -506,7 +511,14 @@ def merge_mappings(mapping1: dict[Any, Any], mapping2: dict[Any, Any]) -> None:

def maybe_generator(obj: Any) -> Iterator[Any]:
"""Generator function that get an arbitrary object and generate values from it if
the object is a generator."""
the object is a generator.

>>> list(maybe_generator(value for value in (1, 2, 3)))
[1, 2, 3]
>>> list(maybe_generator("value"))
['value']
"""

if isinstance(obj, types.GeneratorType):
for elt in obj:
yield elt
Expand Down Expand Up @@ -1003,6 +1015,8 @@ def string_to_jsonpath(*args: Any, force: bool = False) -> Union[str, JSONPath]:
... Slice(start=None, end=None, step=None),
... )
True
>>> string_to_jsonpath("$.foo[bar]")
Child(Child(Root(), Fields('foo')), Fields('bar'))

:param args: Last arg as input string value, to be converted
:param force: force conversion even if input string is not detected as a :class:`jsonpath_ng.JSONPath`
Expand Down Expand Up @@ -1655,6 +1669,8 @@ def guess_file_type(file: str) -> str:
'image/tiff'
>>> guess_file_type('foo.grib')
'application/x-grib'
>>> guess_file_type('foo.unknown_eodag_extension')
'application/octet-stream'

:param file: file url or path
:returns: guessed mime type
Expand Down Expand Up @@ -1828,6 +1844,9 @@ def get_collection_dates(

>>> get_collection_dates({})
(None, None)

>>> get_collection_dates({"extent": {"temporal": {"interval": []}}})
(None, None)
"""
extent_interval = (
collection_dict.get("extent", {})
Expand Down
28 changes: 17 additions & 11 deletions tests/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@
from eodag.api.collection import Collection, CollectionsDict, CollectionsList
from eodag.api.search_result import SearchResult
from eodag.cli import download, eodag_cli, list_col, search_crunch
from eodag.api.provider import Provider, ProviderConfig, ProvidersDict
from eodag.config import (
load_default_config,
load_stac_provider_config,
get_ext_collections_conf,
AUTH_TOPIC_KEYS,
EXT_COLLECTIONS_CONF_URI,
PluginConfig,
get_ext_collections_conf,
load_default_config,
load_stac_provider_config,
)
from eodag.api.provider import ProviderConfig, ProvidersDict, Provider
from eodag.config import PluginConfig
from eodag.plugins.apis.ecmwf import EcmwfApi
from eodag.plugins.authentication.base import Authentication
from eodag.plugins.authentication.aws_auth import AwsAuth
from eodag.plugins.authentication.header import HeaderAuth
from eodag.plugins.authentication.aws_auth import AwsAuth, raise_if_auth_error
from eodag.plugins.authentication.header import HeaderAuth, HTTPHeaderAuth
from eodag.plugins.authentication.openid_connect import CodeAuthorizedAuth
from eodag.plugins.authentication.header import HTTPHeaderAuth
from eodag.plugins.authentication.token_exchange import OIDCTokenExchangeAuth
from eodag.plugins.authentication.qsauth import HttpQueryStringAuth
from eodag.plugins.base import PluginTopic
from eodag.plugins.crunch.filter_date import FilterDate
Expand All @@ -73,10 +73,12 @@
)
from eodag.plugins.download.http import HTTPDownload
from eodag.plugins.manager import PluginManager
from eodag.plugins.search import PreparedSearch
from eodag.plugins.search.base import Search
from eodag.plugins.search.build_search_result import ecmwf_temporal_to_eodag
from eodag.plugins.search.csw import CSWSearch
from eodag.plugins.search import PreparedSearch
from eodag.plugins.search.qssearch import QueryStringSearch
from eodag.types.bbox import BBox
from eodag.types import model_fields_to_annotated
from eodag.types.queryables import CommonQueryables, Queryables, QueryablesDict
from eodag.utils import (
Expand All @@ -87,6 +89,8 @@
DEFAULT_SEARCH_TIMEOUT,
USER_AGENT,
get_bucket_name_and_prefix,
get_geometry_from_ecmwf_area,
get_geometry_from_ecmwf_feature,
get_geometry_from_various,
makedirs,
merge_mappings,
Expand All @@ -109,7 +113,9 @@
from eodag.utils.yaml import cached_yaml_load_all
from eodag.utils.dates import get_timestamp, to_iso_utc_string
from eodag.utils.env import is_env_var_true
from eodag.utils.requests import fetch_json
from eodag.utils.requests import LocalFileAdapter, fetch_json
from eodag.utils.import_system import import_all_modules, patch_owslib_requests
from eodag.utils.notebook import NotebookWidgets, check_ipython, check_notebook
from eodag.utils.s3 import (
list_files_in_s3_zipped_object,
update_assets_from_s3,
Expand Down Expand Up @@ -142,5 +148,5 @@
)
from eodag.utils.stac_reader import fetch_stac_items, _TextOpener
from tests import TEST_RESOURCES_PATH
from usgs.api import USGSAuthExpiredError, USGSError
from usgs.api import TMPFILE as USGS_TMPFILE
from usgs.api import USGSAuthExpiredError, USGSError
61 changes: 59 additions & 2 deletions tests/units/test_auth_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import pickle
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest import mock

import boto3
Expand All @@ -34,16 +35,19 @@
from eodag.api.product._product import EOProduct
from eodag.api.provider import ProvidersDict
from eodag.plugins.authentication.eoiam import _EOIAMSessionAuth
from eodag.plugins.authentication.openid_connect import CodeAuthorizedAuth
from eodag.utils import MockResponse
from eodag.utils.exceptions import RequestError
from tests.context import (
HTTP_REQ_TIMEOUT,
USER_AGENT,
AuthenticationError,
CodeAuthorizedAuth,
HeaderAuth,
MisconfiguredError,
OIDCTokenExchangeAuth,
PluginManager,
RequestError,
TimeOutError,
raise_if_auth_error,
)


Expand Down Expand Up @@ -885,6 +889,59 @@ def test_plugins_download_aws_presigned_url(self, mock_s3_resource):
self.assertIn("Expires", url)


class TestAuthPluginTokenExchange(unittest.TestCase):
def test_plugins_auth_oidc_token_exchange_success_and_timeout(self):
"""OIDC token exchange returns a token and closes sessions on timeout."""
plugin = OIDCTokenExchangeAuth.__new__(OIDCTokenExchangeAuth)
response = mock.Mock()
response.json.return_value = {"access_token": "target-token"}
session = mock.Mock()
session.post.return_value = response
plugin.subject = mock.Mock(
session=session,
authenticate=mock.Mock(
return_value=CodeAuthorizedAuth("subject-token", where="header")
),
)
plugin.config = SimpleNamespace(
subject_issuer="issuer",
token_uri="https://token.test",
client_id="client",
audience="audience",
token_key="access_token",
ssl_verify=False,
)
result = plugin.authenticate()
self.assertEqual(result.token, "target-token")
session.close.assert_called_once_with()
self.assertEqual(
session.post.call_args.kwargs["data"]["grant_type"], plugin.GRANT_TYPE
)

session.post.side_effect = requests.exceptions.Timeout()
with self.assertRaises(TimeOutError):
plugin.authenticate()
self.assertEqual(session.close.call_count, 2)


class TestAwsAuthHelpers(unittest.TestCase):
def test_plugins_auth_aws_auth_error_only_for_known_credential_errors(self):
"""AWS credential errors raise only for recognized credential messages."""
from botocore.exceptions import ClientError

error = ClientError(
{
"Error": {"Code": "AccessDenied", "Message": "bad key"},
"ResponseMetadata": {"HTTPStatusCode": 403},
},
"GetObject",
)
with self.assertRaises(AuthenticationError):
raise_if_auth_error(error, "provider")
error.response["Error"]["Message"] = "not a credential problem"
raise_if_auth_error(error, "provider")


class TestAuthPluginEOIAMAuth(BaseAuthPluginTest):
@classmethod
def setUpClass(cls):
Expand Down
Loading
Loading