Aug 11 - Update photo storage using r2
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
tests/test_storage_backend.py
|
||||
-----------------------------
|
||||
Guards the one thing that makes S3 storage safe in a multi-tenant deployment:
|
||||
the ``t<tenant_id>/`` object-key prefix (app/utils/storage.py).
|
||||
|
||||
Two failure modes are pinned here, both of which would be silent in production:
|
||||
|
||||
1. A prefix regression makes tenant A's presigned URL point at tenant B's
|
||||
object — a cross-tenant data leak with no error anywhere.
|
||||
2. An upload reaching S3Backend.save() with no tenant bound writes an
|
||||
unprefixed key. Nothing would break at write time; the leak appears later
|
||||
when a second tenant's DB happens to hold the same key string.
|
||||
|
||||
S3Backend is constructed with ``object.__new__`` and hand-wired attributes on
|
||||
purpose: its ``__init__`` builds a real boto3 client, and these tests are about
|
||||
key arithmetic, not the network. A stub client records the calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from flask import g
|
||||
|
||||
from app.utils import storage
|
||||
|
||||
|
||||
class _StubClient:
|
||||
"""Minimal S3 client double — records calls, answers HEAD from a key set."""
|
||||
|
||||
def __init__(self, existing=()):
|
||||
self.existing = set(existing)
|
||||
self.put_calls = []
|
||||
self.presigned = []
|
||||
self.deleted = []
|
||||
|
||||
def put_object(self, Bucket=None, Key=None, Body=None, ContentType=None):
|
||||
self.put_calls.append(Key)
|
||||
self.existing.add(Key)
|
||||
return {}
|
||||
|
||||
def head_object(self, Bucket=None, Key=None):
|
||||
if Key in self.existing:
|
||||
return {'ETag': '"x"', 'ContentLength': 1}
|
||||
raise RuntimeError('404')
|
||||
|
||||
def get_object(self, Bucket=None, Key=None):
|
||||
raise RuntimeError('404')
|
||||
|
||||
def generate_presigned_url(self, _op, Params=None, ExpiresIn=None):
|
||||
key = (Params or {}).get('Key')
|
||||
self.presigned.append(key)
|
||||
return f'https://r2.example/{key}'
|
||||
|
||||
def delete_object(self, Bucket=None, Key=None):
|
||||
self.deleted.append(Key)
|
||||
return {}
|
||||
|
||||
|
||||
class _FakeTenant:
|
||||
def __init__(self, tid):
|
||||
self.id = tid
|
||||
|
||||
|
||||
class _FakeUpload:
|
||||
"""Stands in for a werkzeug FileStorage well enough for save()."""
|
||||
|
||||
def __init__(self, filename='photo.jpg', data=b'\xff\xd8\xffbytes'):
|
||||
import io
|
||||
self.filename = filename
|
||||
self.stream = io.BytesIO(data)
|
||||
|
||||
|
||||
def _make_s3_backend(stub):
|
||||
backend = object.__new__(storage.S3Backend)
|
||||
backend.bucket = 'test-bucket'
|
||||
backend.ttl = 60
|
||||
backend.fallback = False
|
||||
backend._client = stub
|
||||
backend._local = storage.LocalBackend()
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mt_ctx(app):
|
||||
"""App context with multi-tenancy on and tenant 7 bound."""
|
||||
with app.test_request_context('/'):
|
||||
app.config['MULTI_TENANT_ENABLED'] = True
|
||||
g.tenant = _FakeTenant(7)
|
||||
try:
|
||||
yield app
|
||||
finally:
|
||||
app.config['MULTI_TENANT_ENABLED'] = False
|
||||
|
||||
|
||||
def test_tenant_key_prefix_reflects_bound_tenant(mt_ctx):
|
||||
assert storage.tenant_key_prefix() == 't7/'
|
||||
|
||||
|
||||
def test_tenant_key_prefix_empty_when_mt_disabled(app):
|
||||
with app.test_request_context('/'):
|
||||
app.config['MULTI_TENANT_ENABLED'] = False
|
||||
g.tenant = _FakeTenant(7)
|
||||
assert storage.tenant_key_prefix() == ''
|
||||
|
||||
|
||||
def test_save_writes_prefixed_object_but_returns_bare_key(mt_ctx):
|
||||
stub = backend_stub = _StubClient()
|
||||
backend = _make_s3_backend(backend_stub)
|
||||
|
||||
key = backend.save(_FakeUpload('evidence.JPG'), 'issue_photos')
|
||||
|
||||
# DB value stays unprefixed and portable.
|
||||
assert key.startswith('uploads/issue_photos/')
|
||||
assert key.endswith('.jpg')
|
||||
assert not key.startswith('t7/')
|
||||
# The object on the wire is tenant-scoped.
|
||||
assert stub.put_calls == [f't7/{key}']
|
||||
|
||||
|
||||
def test_save_refuses_when_no_tenant_bound(app):
|
||||
"""An upload path outside tenant resolution must fail loudly, not silently
|
||||
write a key another tenant could later collide with."""
|
||||
with app.test_request_context('/'):
|
||||
app.config['MULTI_TENANT_ENABLED'] = True
|
||||
g.tenant = None
|
||||
stub = _StubClient()
|
||||
backend = _make_s3_backend(stub)
|
||||
try:
|
||||
with pytest.raises(RuntimeError):
|
||||
backend.save(_FakeUpload(), 'issue_photos')
|
||||
finally:
|
||||
app.config['MULTI_TENANT_ENABLED'] = False
|
||||
assert stub.put_calls == []
|
||||
|
||||
|
||||
def test_media_url_presigns_the_prefixed_key(mt_ctx):
|
||||
stub = _StubClient()
|
||||
backend = _make_s3_backend(stub)
|
||||
|
||||
url = backend.media_url('uploads/issue_photos/abc.jpg')
|
||||
|
||||
assert stub.presigned == ['t7/uploads/issue_photos/abc.jpg']
|
||||
assert url.endswith('t7/uploads/issue_photos/abc.jpg')
|
||||
|
||||
|
||||
def test_exists_and_delete_cover_legacy_unprefixed_key(mt_ctx):
|
||||
"""Objects written before the prefix existed must stay reachable."""
|
||||
key = 'uploads/issue_photos/legacy.jpg'
|
||||
stub = _StubClient(existing={key}) # only the unprefixed object
|
||||
backend = _make_s3_backend(stub)
|
||||
|
||||
assert backend.exists(key) is True
|
||||
|
||||
backend.delete(key)
|
||||
assert stub.deleted == [f't7/{key}', key]
|
||||
Reference in New Issue
Block a user