170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
|
|
from govoplan_core.core.object_storage import (
|
|
LocalFilesystemStorageBackend,
|
|
S3StorageBackend,
|
|
StorageBackendError,
|
|
StorageObjectMissing,
|
|
normalize_storage_key,
|
|
)
|
|
|
|
|
|
class _S3Error(RuntimeError):
|
|
def __init__(self, code: str, status: int) -> None:
|
|
super().__init__(code)
|
|
self.response = {
|
|
"Error": {"Code": code},
|
|
"ResponseMetadata": {"HTTPStatusCode": status},
|
|
}
|
|
|
|
|
|
class _FakeS3Client:
|
|
def __init__(self) -> None:
|
|
self.objects: dict[str, bytes] = {}
|
|
self.head_error: Exception | None = None
|
|
|
|
def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
|
|
self.objects[Key] = Body
|
|
|
|
def get_object(self, *, Key: str, **_kwargs):
|
|
try:
|
|
payload = self.objects[Key]
|
|
except KeyError as exc:
|
|
raise _S3Error("NoSuchKey", 404) from exc
|
|
return {"Body": BytesIO(payload), "ContentLength": len(payload)}
|
|
|
|
def head_object(self, *, Key: str, **_kwargs):
|
|
if self.head_error is not None:
|
|
raise self.head_error
|
|
try:
|
|
payload = self.objects[Key]
|
|
except KeyError as exc:
|
|
raise _S3Error("NotFound", 404) from exc
|
|
return {"ContentLength": len(payload)}
|
|
|
|
def delete_object(self, *, Key: str, **_kwargs) -> None:
|
|
self.objects.pop(Key, None)
|
|
|
|
|
|
class ObjectStorageTests(unittest.TestCase):
|
|
def test_local_backend_round_trip_pagination_and_safe_keys(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory:
|
|
backend = LocalFilesystemStorageBackend(Path(directory))
|
|
backend.put_bytes("campaign/a.eml", b"a")
|
|
backend.put_bytes("campaign/b.eml", b"bb")
|
|
|
|
first = backend.list_objects(prefix="campaign/", limit=1)
|
|
second = backend.list_objects(
|
|
prefix="campaign/",
|
|
after=first.next_cursor,
|
|
limit=1,
|
|
)
|
|
|
|
self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
|
|
self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
|
|
self.assertEqual(
|
|
("campaign/a.eml",), tuple(item.key for item in first.objects)
|
|
)
|
|
self.assertEqual("campaign/a.eml", first.next_cursor)
|
|
self.assertEqual(
|
|
("campaign/b.eml",), tuple(item.key for item in second.objects)
|
|
)
|
|
self.assertIsNone(second.next_cursor)
|
|
with self.assertRaises(StorageBackendError):
|
|
normalize_storage_key("../outside")
|
|
with self.assertRaises(StorageObjectMissing):
|
|
backend.get_bytes("campaign/missing.eml")
|
|
|
|
def test_local_listing_is_globally_sorted_and_ignores_symlinks(self) -> None:
|
|
with (
|
|
tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory,
|
|
tempfile.TemporaryDirectory(prefix="govoplan-object-outside-") as outside,
|
|
):
|
|
root = Path(directory)
|
|
backend = LocalFilesystemStorageBackend(root)
|
|
backend.put_bytes("a/x.txt", b"nested")
|
|
backend.put_bytes("a.txt", b"sibling")
|
|
backend.put_bytes("b.txt", b"last")
|
|
outside_file = Path(outside) / "secret.txt"
|
|
outside_file.write_bytes(b"outside")
|
|
(root / "linked-file.txt").symlink_to(outside_file)
|
|
(root / "linked-directory").symlink_to(
|
|
Path(outside), target_is_directory=True
|
|
)
|
|
|
|
first = backend.list_objects(prefix="", limit=2)
|
|
second = backend.list_objects(
|
|
prefix="",
|
|
after=first.next_cursor,
|
|
limit=2,
|
|
)
|
|
|
|
self.assertEqual(
|
|
("a.txt", "a/x.txt"),
|
|
tuple(item.key for item in first.objects),
|
|
)
|
|
self.assertEqual("a/x.txt", first.next_cursor)
|
|
self.assertEqual(
|
|
("b.txt",),
|
|
tuple(item.key for item in second.objects),
|
|
)
|
|
self.assertIsNone(second.next_cursor)
|
|
with self.assertRaises(StorageBackendError):
|
|
backend.exists("../outside")
|
|
|
|
def test_s3_backend_distinguishes_missing_objects_from_backend_failure(
|
|
self,
|
|
) -> None:
|
|
backend = S3StorageBackend(
|
|
bucket="files",
|
|
endpoint_url="http://garage:3900",
|
|
region_name="garage",
|
|
access_key_id="key",
|
|
secret_access_key="secret",
|
|
deployment_managed=True,
|
|
)
|
|
client = _FakeS3Client()
|
|
backend._client = client
|
|
|
|
backend.put_bytes("campaign/message.eml", b"message/rfc822")
|
|
self.assertTrue(backend.exists("campaign/message.eml"))
|
|
self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml"))
|
|
self.assertFalse(backend.exists("campaign/missing.eml"))
|
|
|
|
client.head_error = _S3Error("AccessDenied", 403)
|
|
with self.assertRaises(StorageBackendError):
|
|
backend.exists("campaign/message.eml")
|
|
|
|
def test_external_s3_requires_explicit_https_deployment_trust(self) -> None:
|
|
trusted = S3StorageBackend(
|
|
bucket="files",
|
|
endpoint_url="https://s3.internal.example.test",
|
|
region_name="eu-test-1",
|
|
access_key_id="key",
|
|
secret_access_key="secret",
|
|
endpoint_trusted=True,
|
|
)
|
|
trusted._client = _FakeS3Client()
|
|
trusted.put_bytes("files/document.txt", b"document")
|
|
self.assertEqual(b"document", trusted.get_bytes("files/document.txt"))
|
|
|
|
rejected = S3StorageBackend(
|
|
bucket="files",
|
|
endpoint_url="http://s3.internal.example.test",
|
|
region_name="eu-test-1",
|
|
access_key_id="key",
|
|
secret_access_key="secret",
|
|
endpoint_trusted=True,
|
|
)
|
|
with self.assertRaisesRegex(StorageBackendError, "HTTPS origin"):
|
|
rejected.client
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|