Skip to content

Commit 11a1afd

Browse files
LsHanaharuatrk8
andauthored
Some imprvmnts (#15)
Co-authored-by: ruatrk8 <kirill.trishin@raiffeisen.ru>
1 parent 9533792 commit 11a1afd

File tree

6 files changed

+76
-8
lines changed

6 files changed

+76
-8
lines changed

safe_s3_storage/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ class KasperskyScanEngineThreatDetectedError(BaseError):
1313
file_name: str
1414

1515

16+
@dataclasses.dataclass
17+
class KasperskyScanEngineConnectionStatusError(BaseError): ...
18+
19+
1620
@dataclasses.dataclass
1721
class NotAllowedMimeTypeError(BaseError):
1822
file_name: str

safe_s3_storage/file_validator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def _split_file_base_name_and_extensions(file_name: str) -> tuple[str, str | Non
4040
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
4141
class FileValidator:
4242
kaspersky_scan_engine: KasperskyScanEngineClient | None = None
43-
allowed_mime_types: list[str]
43+
allowed_mime_types: list[str] | None = None
4444
scan_images_with_antivirus: bool = True
4545
max_file_size_bytes: int = 10 * 1024 * 1024 # 10 MB
4646
max_image_size_bytes: int = 50 * 1024 * 1024 # 50 MB
@@ -58,7 +58,7 @@ def _validate_mime_type(self, *, file_name: str, file_content: bytes) -> str:
5858
mime_type = "application/octet-stream"
5959
else:
6060
mime_type = "text/plain"
61-
if mime_type in self.allowed_mime_types:
61+
if self.allowed_mime_types is None or mime_type in self.allowed_mime_types:
6262
return mime_type
6363

6464
raise exceptions.NotAllowedMimeTypeError(

safe_s3_storage/kaspersky_scan_engine.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import base64
22
import dataclasses
33
import enum
4+
import logging
45
import typing
56

67
import httpx
78
import pydantic
89
import stamina
910

10-
from safe_s3_storage.exceptions import KasperskyScanEngineThreatDetectedError
11+
from safe_s3_storage.exceptions import KasperskyScanEngineConnectionStatusError, KasperskyScanEngineThreatDetectedError
12+
13+
14+
kaspersky_logger: typing.Final = logging.getLogger(__name__)
15+
kaspersky_logger.setLevel(logging.ERROR)
1116

1217

1318
class KasperskyScanEngineRequest(pydantic.BaseModel):
@@ -47,9 +52,12 @@ async def scan_memory(self, *, file_name: str, file_content: bytes) -> None:
4752
payload: typing.Final = KasperskyScanEngineRequest(
4853
timeout=str(self.timeout_ms), object=base64.b64encode(file_content).decode(), name=self.client_name
4954
).model_dump(mode="json")
50-
response: typing.Final = await stamina.retry(on=httpx.HTTPError, attempts=self.max_retries)(
51-
self._send_scan_memory_request
52-
)(payload)
55+
try:
56+
response: typing.Final = await stamina.retry(on=httpx.HTTPError, attempts=self.max_retries)(
57+
self._send_scan_memory_request
58+
)(payload)
59+
except httpx.HTTPStatusError as exc:
60+
raise KasperskyScanEngineConnectionStatusError from exc
5361
validated_response: typing.Final = KasperskyScanEngineResponse.model_validate_json(response)
5462
if validated_response.scanResult == KasperskyScanEngineScanResult.DETECT:
5563
raise KasperskyScanEngineThreatDetectedError(response=response, file_name=file_name)

safe_s3_storage/s3_service.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import typing
44

55
from types_aiobotocore_s3 import S3Client
6-
from types_aiobotocore_s3.type_defs import GetObjectOutputTypeDef
6+
from types_aiobotocore_s3.type_defs import GetObjectOutputTypeDef, HeadObjectOutputTypeDef
77

88
from safe_s3_storage.exceptions import FailedToReplaceS3BaseUrlWithProxyBaseUrlError, InvalidS3PathError
99
from safe_s3_storage.file_validator import ValidatedFile
@@ -92,3 +92,12 @@ async def create_file_url(
9292
s3_file_presigned_url=presigned_url, proxy_base_url=proxy_base_url
9393
)
9494
return proxy_base_url.removesuffix("/") + presigned_url_without_prefix
95+
96+
async def delete_file(self, *, s3_path: str) -> bool:
97+
bucket_name, object_key = _extract_bucket_name_and_object_key(s3_path)
98+
await self.s3_client.delete_object(Bucket=bucket_name, Key=object_key)
99+
return True
100+
101+
async def collect_file_head(self, *, s3_path: str) -> HeadObjectOutputTypeDef:
102+
bucket_name, object_key = _extract_bucket_name_and_object_key(s3_path)
103+
return await self.s3_client.head_object(Bucket=bucket_name, Key=object_key)

tests/test_file_validator.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import faker
55
import httpx
66
import pytest
7+
from httpx import codes as status_codes
78

89
from safe_s3_storage import exceptions
10+
from safe_s3_storage.exceptions import KasperskyScanEngineConnectionStatusError
911
from safe_s3_storage.file_validator import (
1012
_IMAGE_CONVERSION_FORMAT_TO_MIME_TYPE_AND_EXTENSION_MAP,
1113
FileValidator,
@@ -56,7 +58,21 @@ def get_mocked_kaspersky_scan_engine_client(*, faker: faker.Faker, ok_response:
5658
service_url=faker.url(schemes=["http"]),
5759
client_name=faker.pystr(),
5860
httpx_client=httpx.AsyncClient(
59-
transport=httpx.MockTransport(lambda _: httpx.Response(200, json=scan_response.model_dump(mode="json")))
61+
transport=httpx.MockTransport(
62+
lambda _: httpx.Response(status_codes.OK, json=scan_response.model_dump(mode="json"))
63+
)
64+
),
65+
)
66+
67+
68+
def get_mocked_kaspersky_scan_engine_client_bad_response(
69+
*, faker: faker.Faker, status_code: int = status_codes.OK
70+
) -> KasperskyScanEngineClient:
71+
return KasperskyScanEngineClient(
72+
service_url=faker.url(schemes=["http"]),
73+
client_name=faker.pystr(),
74+
httpx_client=httpx.AsyncClient(
75+
transport=httpx.MockTransport(lambda _: httpx.Response(status_code, json="")),
6076
),
6177
)
6278

@@ -86,6 +102,12 @@ async def test_fails_to_convert_image(self, faker: faker.Faker, png_file: bytes)
86102
file_name=faker.file_name(), file_content=png_file[:50]
87103
)
88104

105+
async def test_all_mime_types_allowed(self, faker: faker.Faker) -> None:
106+
validated_file: typing.Final = await FileValidator(allowed_mime_types=None).validate_file(
107+
file_name=faker.file_name(), file_content=generate_binary_content(faker)
108+
)
109+
assert validated_file is not None
110+
89111
@pytest.mark.parametrize("image_conversion_format", list(ImageConversionFormat))
90112
async def test_ok_image(
91113
self, faker: faker.Faker, png_file: bytes, image_conversion_format: ImageConversionFormat
@@ -154,3 +176,10 @@ async def test_antivirus_passes_on_images(self, faker: faker.Faker, png_file: by
154176
kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=True),
155177
allowed_mime_types=["image/png"],
156178
).validate_file(file_name=faker.file_name(), file_content=png_file)
179+
180+
async def test_antivirus_no_connection(self, faker: faker.Faker, png_file: bytes) -> None:
181+
kasper: typing.Final = get_mocked_kaspersky_scan_engine_client_bad_response(
182+
faker=faker, status_code=status_codes.GATEWAY_TIMEOUT
183+
)
184+
with pytest.raises(KasperskyScanEngineConnectionStatusError):
185+
await kasper.scan_memory(file_name=faker.file_name(), file_content=png_file)

tests/test_s3_service.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ async def test_fails_to_parse_s3_path(self, faker: faker.Faker) -> None:
8282
await S3Service(s3_client=mock.Mock()).read_file(s3_path=faker.pystr())
8383

8484

85+
class TestS3ServiceDelete:
86+
async def test_ok_delete(self, faker: faker.Faker) -> None:
87+
bucket_name, s3_key = faker.pystr(), faker.pystr()
88+
s3_client_mock: typing.Final = mock.AsyncMock(delete_object=mock.AsyncMock(return_value={}))
89+
90+
await S3Service(s3_client=s3_client_mock).delete_file(s3_path=f"{bucket_name}/{s3_key}")
91+
s3_client_mock.delete_object.assert_called_once_with(Bucket=bucket_name, Key=s3_key)
92+
93+
94+
class TestS3ServiceHead:
95+
async def test_ok_head(self, faker: faker.Faker) -> None:
96+
bucket_name, s3_key = faker.pystr(), faker.pystr()
97+
s3_client_mock: typing.Final = mock.AsyncMock(head_object=mock.AsyncMock(return_value={}))
98+
99+
await S3Service(s3_client=s3_client_mock).collect_file_head(s3_path=f"{bucket_name}/{s3_key}")
100+
s3_client_mock.head_object.assert_called_once_with(Bucket=bucket_name, Key=s3_key)
101+
102+
85103
class TestS3ServiceCreateFileUrl:
86104
async def test_call(self, faker: faker.Faker) -> None:
87105
bucket_name, s3_key, display_file_name = faker.pystr(), faker.pystr(), faker.pystr()

0 commit comments

Comments
 (0)