| """ |
| Episode access control security tests (SECU-07). |
| |
| Tests cover: |
| - Multi-tenant isolation |
| - User can only access own episodes |
| - Episode filtering respects boundaries |
| - Access logging for denied attempts |
| """ |
| import pytest |
| from fastapi.testclient import TestClient |
| from sqlalchemy.orm import Session |
| from tests.factories.user_factory import UserFactory, AdminUserFactory |
| from tests.factories.episode_factory import EpisodeFactory |
| from core.models import Episode, EpisodeAccessLog, UserRole |
|
|
|
|
| class TestEpisodeMultiTenantIsolation: |
| """Test episode multi-tenant isolation.""" |
|
|
| def test_user_can_only_access_own_episodes(self, client: TestClient, db_session: Session): |
| """Test users cannot access episodes from other users.""" |
| |
| user1 = UserFactory(id="user_1", email="user1@example.com", _session=db_session) |
| user2 = UserFactory(id="user_2", email="user2@example.com", _session=db_session) |
|
|
| episode1 = EpisodeFactory(id="ep_1", user_id=user1.id, _session=db_session) |
| episode2 = EpisodeFactory(id="ep_2", user_id=user2.id, _session=db_session) |
|
|
| db_session.add_all([user1, user2, episode1, episode2]) |
| db_session.commit() |
|
|
| |
| from tests.security.conftest import create_test_token |
| response = client.get( |
| f"/api/episodes/retrieve/{episode2.id}", |
| headers={"Authorization": f"Bearer {create_test_token(user1.id)}"} |
| ) |
|
|
| |
| |
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
| |
| if response.status_code == 200: |
| |
| |
| pass |
|
|
| def test_episode_list_returns_only_user_episodes(self, client: TestClient, db_session: Session): |
| """Test episode list endpoint only returns user's own episodes.""" |
| |
| user1 = UserFactory(id="list_user_1", _session=db_session) |
| user2 = UserFactory(id="list_user_2", _session=db_session) |
|
|
| episodes_user1 = EpisodeFactory.create_batch(5, user_id=user1.id, _session=db_session) |
| episodes_user2 = EpisodeFactory.create_batch(3, user_id=user2.id, _session=db_session) |
|
|
| db_session.add_all([user1, user2] + episodes_user1 + episodes_user2) |
| db_session.commit() |
|
|
| |
| from tests.security.conftest import create_test_token |
| response = client.get( |
| f"/api/episodes/{user1.id}/list", |
| headers={"Authorization": f"Bearer {create_test_token(user1.id)}"} |
| ) |
|
|
| |
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
| if response.status_code == 200: |
| data = response.json() |
| episodes = data.get("data", []) |
|
|
| |
| |
|
|
| def test_episode_creation_assigns_correct_user(self, client: TestClient, db_session: Session): |
| """Test episode creation assigns correct user ownership.""" |
| |
| |
|
|
| user = UserFactory(id="creator_user", _session=db_session) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| episode = EpisodeFactory( |
| id="test_episode_created", |
| user_id=user.id, |
| _session=db_session |
| ) |
| db_session.add(episode) |
| db_session.commit() |
|
|
| |
| created_episode = db_session.query(Episode).filter( |
| Episode.id == episode.id |
| ).first() |
|
|
| assert created_episode is not None |
| assert created_episode.user_id == user.id |
|
|
| def test_cannot_modify_other_user_episode(self, client: TestClient, db_session: Session): |
| """Test users cannot modify other users' episodes.""" |
| user1 = UserFactory(id="modifier_1", _session=db_session) |
| user2 = UserFactory(id="modifier_2", _session=db_session) |
|
|
| episode = EpisodeFactory(id="modify_target_ep", user_id=user1.id, _session=db_session) |
| db_session.add_all([user1, user2, episode]) |
| db_session.commit() |
|
|
| |
| |
| from tests.security.conftest import create_test_token |
|
|
| |
| response = client.put( |
| f"/api/episodes/{episode.id}", |
| json={"title": "Modified Title"}, |
| headers={"Authorization": f"Bearer {create_test_token(user2.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 403, 404, 405, 500] |
|
|
| if response.status_code == 200: |
| |
| |
| pass |
|
|
| def test_cannot_delete_other_user_episode(self, client: TestClient, db_session: Session): |
| """Test users cannot delete other users' episodes.""" |
| user1 = UserFactory(id="deleter_1", _session=db_session) |
| user2 = UserFactory(id="deleter_2", _session=db_session) |
|
|
| episode = EpisodeFactory(id="delete_target_ep", user_id=user1.id, _session=db_session) |
| db_session.add_all([user1, user2, episode]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.delete( |
| f"/api/episodes/{episode.id}", |
| headers={"Authorization": f"Bearer {create_test_token(user2.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 204, 403, 404, 405, 500] |
|
|
| |
| if response.status_code in [403, 404, 405]: |
| episode_still_exists = db_session.query(Episode).filter( |
| Episode.id == episode.id |
| ).first() |
| assert episode_still_exists is not None |
|
|
|
|
| class TestEpisodeAccessLogging: |
| """Test episode access logging.""" |
|
|
| def test_access_denied_creates_log_entry(self, client: TestClient, db_session: Session): |
| """Test denied access creates EpisodeAccessLog entry.""" |
| user1 = UserFactory(id="logger_1", _session=db_session) |
| user2 = UserFactory(id="logger_2", _session=db_session) |
|
|
| episode = EpisodeFactory(id="log_target_ep", user_id=user1.id, _session=db_session) |
| db_session.add_all([user1, user2, episode]) |
| db_session.commit() |
|
|
| |
| db_session.query(EpisodeAccessLog).filter( |
| EpisodeAccessLog.episode_id == episode.id |
| ).delete() |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.get( |
| f"/api/episodes/retrieve/{episode.id}", |
| headers={"Authorization": f"Bearer {create_test_token(user2.id)}"} |
| ) |
|
|
| |
| |
| access_log = db_session.query(EpisodeAccessLog).filter( |
| EpisodeAccessLog.episode_id == episode.id, |
| EpisodeAccessLog.accessed_by == user2.id |
| ).first() |
|
|
| |
| if access_log: |
| |
| assert access_log.access_denied == True or response.status_code in [403, 404] |
| else: |
| |
| pass |
|
|
| def test_successful_access_creates_log_entry(self, client: TestClient, db_session: Session): |
| """Test successful access creates EpisodeAccessLog entry.""" |
| user = UserFactory(id="access_logger", _session=db_session) |
| episode = EpisodeFactory(id="access_target_ep", user_id=user.id, _session=db_session) |
| db_session.add_all([user, episode]) |
| db_session.commit() |
|
|
| |
| db_session.query(EpisodeAccessLog).filter( |
| EpisodeAccessLog.episode_id == episode.id |
| ).delete() |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.get( |
| f"/api/episodes/retrieve/{episode.id}", |
| headers={"Authorization": f"Bearer {create_test_token(user.id)}"} |
| ) |
|
|
| if response.status_code == 200: |
| |
| access_log = db_session.query(EpisodeAccessLog).filter( |
| EpisodeAccessLog.episode_id == episode.id, |
| EpisodeAccessLog.accessed_by == user.id |
| ).first() |
|
|
| if access_log: |
| assert access_log.access_denied == False |
| else: |
| |
| pass |
|
|
|
|
| class TestAdminAccessControl: |
| """Test admin access to episodes.""" |
|
|
| def test_admin_can_access_any_episode(self, client: TestClient, db_session: Session): |
| """Test admins can access episodes from any user.""" |
| |
| admin = UserFactory( |
| id="ep_admin", |
| role=UserRole.SUPER_ADMIN.value, |
| _session=db_session |
| ) |
| regular_user = UserFactory(id="ep_regular", _session=db_session) |
|
|
| episode = EpisodeFactory(id="admin_target_ep", user_id=regular_user.id, _session=db_session) |
| db_session.add_all([admin, regular_user, episode]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.get( |
| f"/api/episodes/retrieve/{episode.id}", |
| headers={"Authorization": f"Bearer {create_test_token(admin.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
| |
| if response.status_code in [403, 404]: |
| |
| pass |
|
|
| def test_admin_can_list_all_episodes(self, client: TestClient, db_session: Session): |
| """Test admins can list episodes from all users.""" |
| admin = UserFactory( |
| id="list_admin", |
| role=UserRole.SUPER_ADMIN.value, |
| _session=db_session |
| ) |
| user1 = UserFactory(id="list_u1", _session=db_session) |
| user2 = UserFactory(id="list_u2", _session=db_session) |
|
|
| episodes1 = EpisodeFactory.create_batch(3, user_id=user1.id, _session=db_session) |
| episodes2 = EpisodeFactory.create_batch(3, user_id=user2.id, _session=db_session) |
|
|
| db_session.add_all([admin, user1, user2] + episodes1 + episodes2) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.get( |
| "/api/episodes?include_all=true", |
| headers={"Authorization": f"Bearer {create_test_token(admin.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
| if response.status_code == 200: |
| data = response.json() |
| |
| |
|
|
|
|
| class TestEpisodeSearchIsolation: |
| """Test episode search respects user boundaries.""" |
|
|
| def test_search_only_returns_user_episodes(self, client: TestClient, db_session: Session): |
| """Test episode search only returns user's own episodes.""" |
| user1 = UserFactory(id="search_u1", _session=db_session) |
| user2 = UserFactory(id="search_u2", _session=db_session) |
|
|
| ep1 = EpisodeFactory( |
| id="search_ep1", |
| user_id=user1.id, |
| title="Shared Keyword", |
| _session=db_session |
| ) |
| ep2 = EpisodeFactory( |
| id="search_ep2", |
| user_id=user2.id, |
| title="Shared Keyword", |
| _session=db_session |
| ) |
|
|
| db_session.add_all([user1, user2, ep1, ep2]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
|
|
| |
| response = client.get( |
| "/api/episodes/search?q=Shared%20Keyword", |
| headers={"Authorization": f"Bearer {create_test_token(user1.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 404, 500] |
|
|
| if response.status_code == 200: |
| data = response.json() |
| |
| |
|
|
|
|
| class TestEpisodeFeedbackAccess: |
| """Test episode feedback access control.""" |
|
|
| def test_feedback_submission_requires_authentication(self, client: TestClient, db_session: Session): |
| """Test feedback submission requires valid authentication.""" |
| user = UserFactory(id="feedback_user", _session=db_session) |
| episode = EpisodeFactory(id="feedback_ep", user_id=user.id, _session=db_session) |
| db_session.add_all([user, episode]) |
| db_session.commit() |
|
|
| |
| response = client.post( |
| f"/api/episodes/{episode.id}/feedback/submit", |
| json={ |
| "feedback_type": "thumbs_up", |
| "rating": 5 |
| } |
| ) |
|
|
| |
| assert response.status_code in [401, 403, 500] |
|
|
| def test_feedback_submission_accessible_to_owner(self, client: TestClient, db_session: Session): |
| """Test episode owner can submit feedback.""" |
| user = UserFactory(id="owner_feedback", _session=db_session) |
| episode = EpisodeFactory(id="owner_feedback_ep", user_id=user.id, _session=db_session) |
| db_session.add_all([user, episode]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.post( |
| f"/api/episodes/{episode.id}/feedback/submit", |
| json={ |
| "feedback_type": "rating", |
| "rating": 5, |
| "corrections": "Great work" |
| }, |
| headers={"Authorization": f"Bearer {create_test_token(user.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 201, 404, 500] |
|
|
| def test_non_owner_cannot_submit_feedback(self, client: TestClient, db_session: Session): |
| """Test non-owners cannot submit feedback to others' episodes.""" |
| owner = UserFactory(id="feedback_owner", _session=db_session) |
| other = UserFactory(id="feedback_other", _session=db_session) |
| episode = EpisodeFactory(id="feedback_restricted_ep", user_id=owner.id, _session=db_session) |
| db_session.add_all([owner, other, episode]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.post( |
| f"/api/episodes/{episode.id}/feedback/submit", |
| json={ |
| "feedback_type": "thumbs_down", |
| "corrections": "Needs improvement" |
| }, |
| headers={"Authorization": f"Bearer {create_test_token(other.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
| if response.status_code == 200: |
| |
| |
| pass |
|
|
|
|
| class TestEpisodeConsolidationAccess: |
| """Test episode consolidation access control.""" |
|
|
| def test_consolidation_requires_ownership(self, client: TestClient, db_session: Session): |
| """Test episode consolidation requires ownership.""" |
| user1 = UserFactory(id="consolidate_u1", _session=db_session) |
| user2 = UserFactory(id="consolidate_u2", _session=db_session) |
|
|
| episodes = EpisodeFactory.create_batch( |
| 3, |
| user_id=user1.id, |
| status="active", |
| _session=db_session |
| ) |
| db_session.add_all([user1, user2] + episodes) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.post( |
| "/api/episodes/consolidate", |
| json={ |
| "episode_ids": [ep.id for ep in episodes], |
| "consolidated_title": "Consolidated Episode" |
| }, |
| headers={"Authorization": f"Bearer {create_test_token(user2.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
| if response.status_code == 200: |
| |
| |
| pass |
|
|
|
|
| class TestSharedEpisodes: |
| """Test shared episode functionality (if implemented).""" |
|
|
| def test_shared_episode_accessible_by_recipient(self, client: TestClient, db_session: Session): |
| """Test shared episodes are accessible by recipient users.""" |
| owner = UserFactory(id="share_owner", _session=db_session) |
| recipient = UserFactory(id="share_recipient", _session=db_session) |
|
|
| episode = EpisodeFactory(id="shared_ep", user_id=owner.id, _session=db_session) |
| db_session.add_all([owner, recipient, episode]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
|
|
| |
| share_response = client.post( |
| f"/api/episodes/{episode.id}/share", |
| json={"user_ids": [recipient.id]}, |
| headers={"Authorization": f"Bearer {create_test_token(owner.id)}"} |
| ) |
|
|
| |
| if share_response.status_code in [200, 201]: |
| |
| response = client.get( |
| f"/api/episodes/retrieve/{episode.id}", |
| headers={"Authorization": f"Bearer {create_test_token(recipient.id)}"} |
| ) |
|
|
| assert response.status_code in [200, 403, 404] |
| else: |
| |
| assert share_response.status_code in [404, 500] |
|
|
| def test_unshared_episode_not_accessible(self, client: TestClient, db_session: Session): |
| """Test unshared episode is not accessible to other users.""" |
| owner = UserFactory(id="unshared_owner", _session=db_session) |
| other = UserFactory(id="unshared_other", _session=db_session) |
|
|
| episode = EpisodeFactory(id="unshared_ep", user_id=owner.id, _session=db_session) |
| db_session.add_all([owner, other, episode]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.get( |
| f"/api/episodes/retrieve/{episode.id}", |
| headers={"Authorization": f"Bearer {create_test_token(other.id)}"} |
| ) |
|
|
| |
| |
| assert response.status_code in [200, 403, 404, 500] |
|
|
|
|
| class TestEpisodeRetrievalModesAccess: |
| """Test access control for different episode retrieval modes.""" |
|
|
| def test_temporal_retrieval_respects_user_boundaries(self, client: TestClient, db_session: Session): |
| """Test temporal retrieval only returns user's episodes.""" |
| import uuid |
| user1 = UserFactory(id="temporal_u1", _session=db_session) |
| user2 = UserFactory(id="temporal_u2", _session=db_session) |
| agent = str(uuid.uuid4()) |
|
|
| |
| ep1 = EpisodeFactory( |
| id="temporal_ep1", |
| user_id=user1.id, |
| agent_id=agent, |
| _session=db_session |
| ) |
| ep2 = EpisodeFactory( |
| id="temporal_ep2", |
| user_id=user2.id, |
| agent_id=agent, |
| _session=db_session |
| ) |
| db_session.add_all([user1, user2, ep1, ep2]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.post( |
| "/api/episodes/retrieve/temporal", |
| json={ |
| "agent_id": agent, |
| "time_range": "7d", |
| "limit": 10 |
| }, |
| headers={"Authorization": f"Bearer {create_test_token(user1.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 404, 500] |
|
|
| if response.status_code == 200: |
| data = response.json() |
| |
|
|
| def test_semantic_retrieval_respects_user_boundaries(self, client: TestClient, db_session: Session): |
| """Test semantic retrieval only returns user's episodes.""" |
| import uuid |
|
|
| user1 = UserFactory(id="semantic_u1", _session=db_session) |
| user2 = UserFactory(id="semantic_u2", _session=db_session) |
| agent = str(uuid.uuid4()) |
|
|
| ep1 = EpisodeFactory( |
| id="semantic_ep1", |
| user_id=user1.id, |
| agent_id=agent, |
| summary="Machine learning project", |
| _session=db_session |
| ) |
| ep2 = EpisodeFactory( |
| id="semantic_ep2", |
| user_id=user2.id, |
| agent_id=agent, |
| summary="Machine learning project", |
| _session=db_session |
| ) |
| db_session.add_all([user1, user2, ep1, ep2]) |
| db_session.commit() |
|
|
| from tests.security.conftest import create_test_token |
| response = client.post( |
| "/api/episodes/retrieve/semantic", |
| json={ |
| "agent_id": agent, |
| "query": "machine learning", |
| "limit": 10 |
| }, |
| headers={"Authorization": f"Bearer {create_test_token(user1.id)}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 404, 500] |
|
|
| if response.status_code == 200: |
| |
| pass |
|
|