feat: harden file sharing and integrity
This commit is contained in:
219
tests/test_share_lifecycle.py
Normal file
219
tests/test_share_lifecycle.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_files.backend.db.models import FileAsset, FileShare
|
||||
from govoplan_files.backend.storage.common import FileStorageError, utcnow
|
||||
from govoplan_files.backend.storage.files import (
|
||||
get_asset_for_user,
|
||||
list_file_shares,
|
||||
revoke_file_share,
|
||||
share_file,
|
||||
)
|
||||
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
OWNER_ID = "owner-1"
|
||||
RECIPIENT_ID = "recipient-1"
|
||||
GROUP_ID = "group-1"
|
||||
|
||||
|
||||
class FileShareLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(
|
||||
bind=self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
FileAsset.__table__,
|
||||
FileShare.__table__,
|
||||
],
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
self.asset = FileAsset(
|
||||
id="file-1",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_type="user",
|
||||
owner_user_id=OWNER_ID,
|
||||
display_path="shared.pdf",
|
||||
filename="shared.pdf",
|
||||
)
|
||||
self.session.add(self.asset)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
@patch(
|
||||
"govoplan_files.backend.storage.files.user_group_ids",
|
||||
return_value=[],
|
||||
)
|
||||
def test_expired_share_stops_access_immediately(self, _groups) -> None:
|
||||
self.session.add(
|
||||
FileShare(
|
||||
id="expired-share",
|
||||
tenant_id=TENANT_ID,
|
||||
file_asset_id=self.asset.id,
|
||||
target_type="user",
|
||||
target_id=RECIPIENT_ID,
|
||||
permission="read",
|
||||
expires_at=utcnow() - timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(FileStorageError, "No access"):
|
||||
get_asset_for_user(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
user_id=RECIPIENT_ID,
|
||||
asset_id=self.asset.id,
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
list_file_shares(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset_id=self.asset.id,
|
||||
),
|
||||
)
|
||||
|
||||
@patch(
|
||||
"govoplan_files.backend.storage.files.user_group_ids",
|
||||
return_value=[GROUP_ID],
|
||||
)
|
||||
def test_independent_active_grant_survives_other_expiry(self, _groups) -> None:
|
||||
self.session.add_all(
|
||||
[
|
||||
FileShare(
|
||||
id="expired-user-share",
|
||||
tenant_id=TENANT_ID,
|
||||
file_asset_id=self.asset.id,
|
||||
target_type="user",
|
||||
target_id=RECIPIENT_ID,
|
||||
permission="read",
|
||||
expires_at=utcnow() - timedelta(seconds=1),
|
||||
),
|
||||
FileShare(
|
||||
id="active-group-share",
|
||||
tenant_id=TENANT_ID,
|
||||
file_asset_id=self.asset.id,
|
||||
target_type="group",
|
||||
target_id=GROUP_ID,
|
||||
permission="read",
|
||||
expires_at=utcnow() + timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
result = get_asset_for_user(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
user_id=RECIPIENT_ID,
|
||||
asset_id=self.asset.id,
|
||||
)
|
||||
|
||||
self.assertEqual(self.asset.id, result.id)
|
||||
self.assertEqual(
|
||||
["active-group-share"],
|
||||
[
|
||||
share.id
|
||||
for share in list_file_shares(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset_id=self.asset.id,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@patch(
|
||||
"govoplan_files.backend.storage.files.ensure_share_target_exists",
|
||||
return_value=None,
|
||||
)
|
||||
def test_revoke_is_idempotent_and_future_expiry_is_persisted(
|
||||
self, _target_exists
|
||||
) -> None:
|
||||
expiry = utcnow() + timedelta(days=1)
|
||||
share = share_file(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset=self.asset,
|
||||
target_type="user",
|
||||
target_id=RECIPIENT_ID,
|
||||
permission="read",
|
||||
user_id=OWNER_ID,
|
||||
expires_at=expiry,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
revoked, first_changed = revoke_file_share(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset_id=self.asset.id,
|
||||
share_id=share.id,
|
||||
user_id=OWNER_ID,
|
||||
)
|
||||
self.session.commit()
|
||||
repeated, second_changed = revoke_file_share(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset_id=self.asset.id,
|
||||
share_id=share.id,
|
||||
user_id=OWNER_ID,
|
||||
)
|
||||
|
||||
self.assertTrue(first_changed)
|
||||
self.assertFalse(second_changed)
|
||||
self.assertEqual(OWNER_ID, revoked.revoked_by_user_id)
|
||||
self.assertEqual(revoked.revoked_at, repeated.revoked_at)
|
||||
self.assertEqual([], list_file_shares(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset_id=self.asset.id,
|
||||
))
|
||||
self.assertEqual(
|
||||
[share.id],
|
||||
[
|
||||
item.id
|
||||
for item in list_file_shares(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset_id=self.asset.id,
|
||||
include_inactive=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@patch(
|
||||
"govoplan_files.backend.storage.files.ensure_share_target_exists",
|
||||
return_value=None,
|
||||
)
|
||||
def test_past_expiry_is_rejected(self, _target_exists) -> None:
|
||||
with self.assertRaisesRegex(FileStorageError, "future"):
|
||||
share_file(
|
||||
self.session,
|
||||
tenant_id=TENANT_ID,
|
||||
asset=self.asset,
|
||||
target_type="user",
|
||||
target_id=RECIPIENT_ID,
|
||||
permission="read",
|
||||
user_id=OWNER_ID,
|
||||
expires_at=utcnow() - timedelta(seconds=1),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user