""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import pytest from uuid import uuid4 from unittest.mock import Mock, MagicMock, patch, PropertyMock from datetime import datetime from app.repositories.widget_app import WidgetAppRepository from app.core.security import widget_api_key_lookup from app.models.user import User from app.models.widget_app import WidgetApp @pytest.fixture def mock_db(): """Create a mock database session.""" db = MagicMock() db.query = MagicMock() db.add = MagicMock() db.commit = MagicMock() db.refresh = MagicMock() db.delete = MagicMock() db.rollback = MagicMock() return db @pytest.fixture def widget_app_repo(mock_db) -> WidgetAppRepository: """Create a WidgetAppRepository instance with mock db.""" return WidgetAppRepository(mock_db) @pytest.fixture def org_id(): """Generate a random organization ID.""" return uuid4() @pytest.fixture def user_id(): """Generate a random user ID.""" return uuid4() @pytest.fixture def mock_widget_app(org_id, user_id): """Create a mock WidgetApp object.""" app = Mock(spec=WidgetApp) app.id = uuid4() app.name = "Test App" app.description = "Test description" app.organization_id = org_id app.created_by = user_id app.is_active = True app.api_key_hash = "$2b$12$mockedhashvalue" app.created_at = datetime.utcnow() app.updated_at = datetime.utcnow() return app class TestWidgetAppCreation: """Tests for widget app creation.""" @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_create_app_generates_valid_key( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test that create_app generates a valid API key.""" mock_generate.return_value = "wak_test_key_12345678901234567890123456789012345" mock_hash.return_value = "$2b$12$hashedkey" # Mock refresh to set the id on the app def mock_refresh(app): app.id = uuid4() mock_db.refresh.side_effect = mock_refresh app, plain_key = widget_app_repo.create_app( organization_id=org_id, created_by=user_id, name="Test App", description="Test description" ) assert app is not None assert app.name == "Test App" assert app.description == "Test description" assert app.organization_id == org_id assert app.created_by == user_id assert app.is_active is True assert plain_key == "wak_test_key_12345678901234567890123456789012345" mock_db.add.assert_called_once() mock_db.commit.assert_called_once() mock_db.refresh.assert_called_once() @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_create_app_without_description( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test creating app without optional description.""" mock_generate.return_value = "wak_test_key_12345678901234567890123456789012345" mock_hash.return_value = "$2b$12$hashedkey" def mock_refresh(app): app.id = uuid4() mock_db.refresh.side_effect = mock_refresh app, plain_key = widget_app_repo.create_app( organization_id=org_id, created_by=user_id, name="Simple App" ) assert app is not None assert app.name == "Simple App" assert app.description is None assert plain_key.startswith("wak_") @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_create_multiple_apps_unique_keys( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test that multiple apps get unique API keys.""" keys = [ "wak_key1_12345678901234567890123456789012345", "wak_key2_12345678901234567890123456789012345" ] mock_generate.side_effect = keys mock_hash.return_value = "$2b$12$hashedkey" ids = [uuid4(), uuid4()] call_count = [0] def mock_refresh(app): app.id = ids[call_count[0]] call_count[0] += 1 mock_db.refresh.side_effect = mock_refresh app1, key1 = widget_app_repo.create_app( organization_id=org_id, created_by=user_id, name="App 1" ) app2, key2 = widget_app_repo.create_app( organization_id=org_id, created_by=user_id, name="App 2" ) assert key1 != key2 assert app1.id != app2.id class TestWidgetAppValidation: """A key is found by one indexed lookup; bcrypt only ever runs for keys issued before the lookup column, once each.""" @pytest.fixture def repo(self, db): return WidgetAppRepository(db) @pytest.fixture def admin(self, db, test_organization): user = User(id=uuid4(), email="apps@example.com", hashed_password="x", is_active=True, organization_id=test_organization.id, full_name="Apps Admin") db.add(user) db.commit() return user @pytest.fixture def new_app(self, repo, test_organization, admin): return repo.create_app(organization_id=test_organization.id, created_by=admin.id, name="Site") @pytest.fixture def no_bcrypt(self): with patch('app.repositories.widget_app.verify_widget_api_key', side_effect=AssertionError("bcrypt ran")) as verify: yield verify def test_a_new_key_is_one_lookup(self, repo, new_app, no_bcrypt): app, key = new_app assert repo.validate_api_key(key).id == app.id def test_a_wrong_key_never_runs_bcrypt_once_every_app_is_indexed(self, repo, new_app, no_bcrypt): assert repo.validate_api_key("wak_" + "x" * 43) is None def test_a_key_issued_before_the_lookup_is_checked_once_then_indexed(self, db, repo, new_app): app, key = new_app app.api_key_lookup = None db.commit() assert repo.validate_api_key(key).id == app.id assert app.api_key_lookup == widget_api_key_lookup(key) with patch('app.repositories.widget_app.verify_widget_api_key', side_effect=AssertionError("bcrypt ran")): assert repo.validate_api_key(key).id == app.id def test_a_regenerated_key_replaces_the_old_one(self, repo, new_app, test_organization, no_bcrypt): app, old_key = new_app _, new_key = repo.regenerate_api_key(app.id, test_organization.id) assert repo.validate_api_key(old_key) is None assert repo.validate_api_key(new_key).id == app.id def test_a_deactivated_apps_key_is_refused(self, repo, new_app, test_organization, no_bcrypt): app, key = new_app repo.deactivate_app(app.id, test_organization.id) assert repo.validate_api_key(key) is None class TestWidgetAppRetrieval: """Tests for retrieving widget apps.""" def test_get_app_by_id(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test getting app by ID.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query retrieved = widget_app_repo.get_app_by_id(mock_widget_app.id, org_id) assert retrieved is not None assert retrieved.id == mock_widget_app.id assert retrieved.name == mock_widget_app.name def test_get_app_by_id_wrong_org(self, widget_app_repo, mock_db, org_id): """Test that apps are org-scoped.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query wrong_org = uuid4() retrieved = widget_app_repo.get_app_by_id(uuid4(), wrong_org) assert retrieved is None def test_get_app_by_id_nonexistent(self, widget_app_repo, mock_db, org_id): """Test getting non-existent app returns None.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query fake_id = uuid4() retrieved = widget_app_repo.get_app_by_id(fake_id, org_id) assert retrieved is None def test_get_apps_by_organization(self, widget_app_repo, mock_db, org_id, user_id): """Test listing all apps for an organization.""" apps = [ Mock(spec=WidgetApp, id=uuid4(), name=f"App {i}", organization_id=org_id, is_active=True) for i in range(3) ] mock_query = MagicMock() mock_filter = MagicMock() mock_filter2 = MagicMock() mock_order = MagicMock() mock_filter.filter.return_value = mock_filter2 mock_filter2.order_by.return_value = mock_order mock_order.all.return_value = apps mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query result = widget_app_repo.get_apps_by_organization(org_id) assert len(result) == 3 assert all(app.organization_id == org_id for app in result) def test_get_apps_by_organization_exclude_inactive(self, widget_app_repo, mock_db, org_id): """Test that inactive apps are excluded by default.""" active_app = Mock(spec=WidgetApp, id=uuid4(), name="Active App", is_active=True) mock_query = MagicMock() mock_filter = MagicMock() mock_filter2 = MagicMock() mock_order = MagicMock() mock_filter.filter.return_value = mock_filter2 mock_filter2.order_by.return_value = mock_order mock_order.all.return_value = [active_app] mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query apps = widget_app_repo.get_apps_by_organization(org_id, include_inactive=False) assert len(apps) == 1 assert apps[0].id == active_app.id def test_get_apps_by_organization_include_inactive(self, widget_app_repo, mock_db, org_id): """Test getting all apps including inactive ones.""" apps = [ Mock(spec=WidgetApp, id=uuid4(), name="Active App", is_active=True), Mock(spec=WidgetApp, id=uuid4(), name="Inactive App", is_active=False) ] mock_query = MagicMock() mock_filter = MagicMock() mock_order = MagicMock() mock_filter.order_by.return_value = mock_order mock_order.all.return_value = apps mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query result = widget_app_repo.get_apps_by_organization(org_id, include_inactive=True) assert len(result) == 2 def test_get_apps_by_organization_sorted(self, widget_app_repo, mock_db, org_id): """Test that apps are retrieved and have created_at timestamps.""" # Create mock apps with explicit attribute values app1 = MagicMock() app1.id = uuid4() app1.name = "First App" app1.created_at = datetime.utcnow() app2 = MagicMock() app2.id = uuid4() app2.name = "Second App" app2.created_at = datetime.utcnow() app3 = MagicMock() app3.id = uuid4() app3.name = "Third App" app3.created_at = datetime.utcnow() apps = [app1, app2, app3] mock_query = MagicMock() mock_filter = MagicMock() mock_filter2 = MagicMock() mock_order = MagicMock() mock_filter.filter.return_value = mock_filter2 mock_filter2.order_by.return_value = mock_order mock_order.all.return_value = apps mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query result = widget_app_repo.get_apps_by_organization(org_id) assert len(result) == 3 assert all(app.created_at is not None for app in result) app_names = {app.name for app in result} assert app_names == {"First App", "Second App", "Third App"} class TestWidgetAppUpdate: """Tests for updating widget apps.""" def test_update_app_name(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test updating app name.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query updated = widget_app_repo.update_app( mock_widget_app.id, org_id, name="New Name" ) assert updated is not None assert updated.name == "New Name" mock_db.commit.assert_called_once() def test_update_app_description(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test updating app description.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query updated = widget_app_repo.update_app( mock_widget_app.id, org_id, description="New description" ) assert updated is not None assert updated.description == "New description" def test_update_app_is_active(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test updating app active status.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query updated = widget_app_repo.update_app( mock_widget_app.id, org_id, is_active=False ) assert updated is not None assert updated.is_active is False def test_update_app_multiple_fields(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test updating multiple fields at once.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query updated = widget_app_repo.update_app( mock_widget_app.id, org_id, name="New Name", description="New Description", is_active=False ) assert updated is not None assert updated.name == "New Name" assert updated.description == "New Description" assert updated.is_active is False def test_update_app_nonexistent(self, widget_app_repo, mock_db, org_id): """Test updating non-existent app returns None.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query fake_id = uuid4() updated = widget_app_repo.update_app(fake_id, org_id, name="New Name") assert updated is None def test_update_app_wrong_org(self, widget_app_repo, mock_db, mock_widget_app): """Test updating app from wrong org returns None.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query wrong_org = uuid4() updated = widget_app_repo.update_app(mock_widget_app.id, wrong_org, name="New Name") assert updated is None class TestWidgetAppDeletion: """Tests for deleting/deactivating widget apps.""" def test_deactivate_app(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test soft delete (deactivation).""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query success = widget_app_repo.deactivate_app(mock_widget_app.id, org_id) assert success is True assert mock_widget_app.is_active is False mock_db.commit.assert_called_once() def test_deactivate_app_nonexistent(self, widget_app_repo, mock_db, org_id): """Test deactivating non-existent app returns False.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query fake_id = uuid4() success = widget_app_repo.deactivate_app(fake_id, org_id) assert success is False def test_delete_app_hard(self, widget_app_repo, mock_db, mock_widget_app, org_id): """Test hard delete (permanent removal).""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query success = widget_app_repo.delete_app(mock_widget_app.id, org_id) assert success is True mock_db.delete.assert_called_once_with(mock_widget_app) mock_db.commit.assert_called_once() def test_delete_app_nonexistent(self, widget_app_repo, mock_db, org_id): """Test hard deleting non-existent app returns False.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query fake_id = uuid4() success = widget_app_repo.delete_app(fake_id, org_id) assert success is False class TestAPIKeyRegeneration: """Tests for regenerating API keys.""" @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_regenerate_api_key( self, mock_hash, mock_generate, widget_app_repo, mock_db, mock_widget_app, org_id ): """Test API key regeneration.""" old_hash = mock_widget_app.api_key_hash mock_generate.return_value = "wak_new_key_12345678901234567890123456789012345" mock_hash.return_value = "$2b$12$newhash" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = mock_widget_app mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query result = widget_app_repo.regenerate_api_key(mock_widget_app.id, org_id) assert result is not None updated_app, new_key = result assert new_key == "wak_new_key_12345678901234567890123456789012345" assert new_key.startswith("wak_") mock_db.commit.assert_called_once() def test_regenerate_api_key_nonexistent(self, widget_app_repo, mock_db, org_id): """Test regenerating key for non-existent app returns None.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query fake_id = uuid4() result = widget_app_repo.regenerate_api_key(fake_id, org_id) assert result is None def test_regenerate_api_key_wrong_org(self, widget_app_repo, mock_db, mock_widget_app): """Test regenerating key from wrong org returns None.""" mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query wrong_org = uuid4() result = widget_app_repo.regenerate_api_key(mock_widget_app.id, wrong_org) assert result is None class TestOrganizationIsolation: """Tests for ensuring organization-level isolation.""" def test_apps_isolated_by_organization(self, widget_app_repo, mock_db, user_id): """Test that apps from different orgs are isolated.""" org1 = uuid4() org2 = uuid4() app1 = Mock(spec=WidgetApp, id=uuid4(), name="Org1 App", organization_id=org1) app2 = Mock(spec=WidgetApp, id=uuid4(), name="Org2 App", organization_id=org2) call_count = [0] def get_apps_for_org(org_id): if org_id == org1: return [app1] elif org_id == org2: return [app2] return [] mock_query = MagicMock() mock_filter = MagicMock() mock_filter2 = MagicMock() mock_order = MagicMock() def setup_filter(*args): mock_filter.filter.return_value = mock_filter2 mock_filter2.order_by.return_value = mock_order # Return different results based on call if call_count[0] == 0: mock_order.all.return_value = [app1] else: mock_order.all.return_value = [app2] call_count[0] += 1 return mock_filter mock_query.filter.side_effect = setup_filter mock_db.query.return_value = mock_query org1_apps = widget_app_repo.get_apps_by_organization(org1) org2_apps = widget_app_repo.get_apps_by_organization(org2) assert len(org1_apps) == 1 assert len(org2_apps) == 1 assert org1_apps[0].id == app1.id assert org2_apps[0].id == app2.id def test_cannot_access_other_org_app(self, widget_app_repo, mock_db): """Test that one org cannot access another org's app.""" org1 = uuid4() org2 = uuid4() mock_query = MagicMock() mock_filter = MagicMock() mock_filter.first.return_value = None # App not found for wrong org mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query app1_id = uuid4() retrieved = widget_app_repo.get_app_by_id(app1_id, org2) assert retrieved is None class TestAPIKeyFormat: """Tests for API key format and security.""" @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_api_key_format( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test that generated API keys follow the correct format.""" # Test key with proper format test_key = "wak_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno" mock_generate.return_value = test_key mock_hash.return_value = "$2b$12$hashedkey" def mock_refresh(app): app.id = uuid4() mock_db.refresh.side_effect = mock_refresh _, plain_key = widget_app_repo.create_app(org_id, user_id, "Test App") # Should start with wak_ assert plain_key.startswith("wak_") # Should be URL-safe base64 (only alphanumeric, -, _) key_suffix = plain_key[4:] # Remove wak_ prefix assert all(c.isalnum() or c in ['-', '_'] for c in key_suffix) # Should have sufficient length assert len(key_suffix) >= 40 @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_api_key_hash_storage( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test that API keys are stored as hashes, not plain text.""" plain_key = "wak_plaintext_key_12345678901234567890" hashed_key = "$2b$12$hashedvalue123456789012345678901234" mock_generate.return_value = plain_key mock_hash.return_value = hashed_key def mock_refresh(app): app.id = uuid4() mock_db.refresh.side_effect = mock_refresh app, returned_key = widget_app_repo.create_app(org_id, user_id, "Test App") # The stored hash should not equal the plain key assert app.api_key_hash != returned_key # The hash should look like a bcrypt hash assert app.api_key_hash.startswith("$2b$") @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_api_key_uniqueness( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test that each generated API key is unique.""" keys = [f"wak_unique_key_{i}_{'x' * 30}" for i in range(10)] mock_generate.side_effect = keys mock_hash.return_value = "$2b$12$hashedkey" ids = [uuid4() for _ in range(10)] call_count = [0] def mock_refresh(app): app.id = ids[call_count[0]] call_count[0] += 1 mock_db.refresh.side_effect = mock_refresh generated_keys = set() for i in range(10): _, plain_key = widget_app_repo.create_app( org_id, user_id, f"App {i}" ) generated_keys.add(plain_key) # All keys should be unique assert len(generated_keys) == 10 class TestErrorHandling: """Tests for error handling.""" @patch('app.repositories.widget_app.generate_widget_api_key') @patch('app.repositories.widget_app.hash_widget_api_key') def test_create_app_rollback_on_error( self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id ): """Test that database rollback occurs on error during creation.""" mock_generate.return_value = "wak_test_key" mock_hash.return_value = "$2b$12$hash" mock_db.commit.side_effect = Exception("Database error") with pytest.raises(Exception, match="Database error"): widget_app_repo.create_app(org_id, user_id, "Test App") mock_db.rollback.assert_called_once()