diff --git a/src/chains/signals.py b/src/chains/signals.py index 40eb6582..53c55947 100644 --- a/src/chains/signals.py +++ b/src/chains/signals.py @@ -2,47 +2,91 @@ from typing import Any from django.conf import settings -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete from django.dispatch import receiver -import clients.safe_client_gateway +from clients.safe_client_gateway import HookEvent, flush, hook_event from .models import Chain, Feature, GasPrice, Wallet logger = logging.getLogger(__name__) -def _flush_cgw_chains() -> None: - clients.safe_client_gateway.flush( - cgw_url=settings.CGW_URL, - cgw_flush_token=settings.CGW_FLUSH_TOKEN, - json={"invalidate": "Chains"}, - ) - - @receiver(post_save, sender=Chain) @receiver(post_delete, sender=Chain) -def on_chain_update(sender: Chain, **kwargs: Any) -> None: +def on_chain_update(sender: Chain, instance: Chain, **kwargs: Any) -> None: logger.info("Chain update. Triggering CGW webhook") - _flush_cgw_chains() + if settings.FF_HOOK_EVENTS: + hook_event(HookEvent(type=HookEvent.Type.CHAIN_UPDATE, chain_id=instance.id)) + else: + flush() @receiver(post_save, sender=GasPrice) @receiver(post_delete, sender=GasPrice) -def on_gas_price_update(sender: GasPrice, **kwargs: Any) -> None: +def on_gas_price_update(sender: GasPrice, instance: GasPrice, **kwargs: Any) -> None: logger.info("GasPrice update. Triggering CGW webhook") - _flush_cgw_chains() + if settings.FF_HOOK_EVENTS: + hook_event( + HookEvent(type=HookEvent.Type.CHAIN_UPDATE, chain_id=instance.chain.id) + ) + else: + flush() +# pre_delete is used because on pre_delete the model still has chains +# which is not the case on post_delete @receiver(post_save, sender=Feature) -@receiver(post_delete, sender=Feature) -def on_feature_update(sender: Feature, **kwargs: Any) -> None: +@receiver(pre_delete, sender=Feature) +def on_feature_changed(sender: Feature, instance: Feature, **kwargs: Any) -> None: logger.info("Feature update. Triggering CGW webhook") - _flush_cgw_chains() + if settings.FF_HOOK_EVENTS: + # A Feature change affects all the chains that have this feature + for chain in instance.chains.all(): + hook_event(HookEvent(type=HookEvent.Type.CHAIN_UPDATE, chain_id=chain.id)) + else: + flush() + +@receiver(m2m_changed, sender=Feature.chains.through) +def on_feature_chains_changed( + sender: Feature, instance: Feature, action: str, pk_set: set[int], **kwargs: Any +) -> None: + logger.info("FeatureChains update. Triggering CGW webhook") + if action == "post_add" or action == "post_remove": + if settings.FF_HOOK_EVENTS: + for chain_id in pk_set: + hook_event( + HookEvent(type=HookEvent.Type.CHAIN_UPDATE, chain_id=chain_id) + ) + else: + flush() + +# pre_delete is used because on pre_delete the model still has chains +# which is not the case on post_delete @receiver(post_save, sender=Wallet) -@receiver(post_delete, sender=Wallet) -def on_wallet_update(sender: Wallet, **kwargs: Any) -> None: +@receiver(pre_delete, sender=Wallet) +def on_wallet_changed(sender: Wallet, instance: Wallet, **kwargs: Any) -> None: logger.info("Wallet update. Triggering CGW webhook") - _flush_cgw_chains() + if settings.FF_HOOK_EVENTS: + # A Wallet change affects all the chains that have this wallet + for chain in instance.chains.all(): + hook_event(HookEvent(type=HookEvent.Type.CHAIN_UPDATE, chain_id=chain.id)) + else: + flush() + + +@receiver(m2m_changed, sender=Wallet.chains.through) +def on_wallet_chains_changed( + sender: Wallet, instance: Wallet, action: str, pk_set: set[int], **kwargs: Any +) -> None: + logger.info("WalletChains update. Triggering CGW webhook") + if action == "post_add" or action == "post_remove": + if settings.FF_HOOK_EVENTS: + for chain_id in pk_set: + hook_event( + HookEvent(type=HookEvent.Type.CHAIN_UPDATE, chain_id=chain_id) + ) + else: + flush() diff --git a/src/chains/tests/test_signals.py b/src/chains/tests/test_signals.py index 22dfafb6..79404035 100644 --- a/src/chains/tests/test_signals.py +++ b/src/chains/tests/test_signals.py @@ -1,11 +1,12 @@ import responses from django.test import TestCase, override_settings -from chains.models import Feature, Wallet -from chains.tests.factories import ChainFactory, GasPriceFactory +from ..models import Feature, Wallet +from ..tests.factories import ChainFactory, GasPriceFactory @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) @@ -113,6 +114,7 @@ def test_on_chain_update_with_no_flush_token_set(self) -> None: @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) @@ -165,6 +167,7 @@ def test_on_feature_update_hook_call(self) -> None: @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) @@ -217,6 +220,7 @@ def test_on_wallet_update_hook_call(self) -> None: @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) diff --git a/src/chains/tests/test_signals_ff_hook_events.py b/src/chains/tests/test_signals_ff_hook_events.py new file mode 100644 index 00000000..38493f26 --- /dev/null +++ b/src/chains/tests/test_signals_ff_hook_events.py @@ -0,0 +1,468 @@ +import responses +from django.test import TestCase, override_settings +from faker import Faker + +from ..models import Feature, Wallet +from ..tests.factories import ( + ChainFactory, + FeatureFactory, + GasPriceFactory, + WalletFactory, +) + +fake = Faker() +Faker.seed(0) + + +@override_settings(FF_HOOK_EVENTS=True) +class ChainNetworkHookTestCaseSetupCheck(TestCase): + @responses.activate + @override_settings(CGW_URL=None, CGW_FLUSH_TOKEN="example-token") + def test_no_cgw_call_with_no_url(self) -> None: + ChainFactory.create() + + assert len(responses.calls) == 0 + + @responses.activate + @override_settings(CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN=None) + def test_no_cgw_call_with_no_token(self) -> None: + ChainFactory.create() + + assert len(responses.calls) == 0 + + +@override_settings( + CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", FF_HOOK_EVENTS=True +) +class ChainNetworkHookWithFFHookEventsTestCase(TestCase): + @responses.activate + def test_on_chain_create(self) -> None: + chain_id = fake.pyint() + responses.add( + responses.POST, + "http://127.0.0.1/v1/hooks/events", + status=200, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Basic example-token"} + ), + responses.matchers.json_params_matcher( + {"type": "CHAIN_UPDATE", "chainId": str(chain_id)} + ), + ], + ) + + ChainFactory.create(id=chain_id) + + assert len(responses.calls) == 1 + assert isinstance(responses.calls[0], responses.Call) + assert responses.calls[ + 0 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + assert responses.calls[0].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[0].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_chain_delete(self) -> None: + # Deleting an object sets the primary key to None so we set it in a separate variable + chain_id = fake.pyint() + chain = ChainFactory.create(id=chain_id) + responses.add( + responses.POST, + "http://127.0.0.1/v1/hooks/events", + status=200, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Basic example-token"} + ), + responses.matchers.json_params_matcher( + {"type": "CHAIN_UPDATE", "chainId": str(chain.id)} + ), + ], + ) + + chain.delete() + + # 2 calls: one for creation and one for deletion + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert responses.calls[ + 1 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_chain_update(self) -> None: + chain = ChainFactory.create() + + # Not updating using queryset because hooks are not triggered that way + chain.currency_name = "Ether" + chain.save() + + # 2 calls: one for creation and one for updating + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert responses.calls[ + 1 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[0].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[0].request.headers.get("Authorization") + == "Basic example-token" + ) + + +@override_settings( + CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", FF_HOOK_EVENTS=True +) +class FeatureHookTestCase(TestCase): + @responses.activate + def test_on_feature_create_with_no_chain(self) -> None: + Feature(key="Test Feature").save() + + # Creating a feature with no chains should not trigger any webhook + assert len(responses.calls) == 0 + + @responses.activate + def test_on_feature_create_with_chain(self) -> None: + chain = ChainFactory.create() + FeatureFactory.create(key="Test Feature", chains=(chain,)) + + # 1 call for Chain creation, 1 call for feature creation, 1 call for M2M update + assert len(responses.calls) == 3 + assert isinstance(responses.calls[2], responses.Call) + assert responses.calls[ + 2 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[2].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[2].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_feature_delete_with_no_chain(self) -> None: + feature = Feature(key="Test Feature") + + feature.save() # create + feature.delete() # delete + + # Deleting a feature with no chains should not trigger any webhook + assert len(responses.calls) == 0 + + @responses.activate + def test_on_feature_delete_with_chain(self) -> None: + chain = ChainFactory.create() + feature = FeatureFactory.create(key="Test Feature", chains=(chain,)) + + feature.delete() + + # 1 call for Chain creation, 1 call for feature creation, 1 call for M2M update, 1 call for feature deletion + assert len(responses.calls) == 4 + assert isinstance(responses.calls[3], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[3].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[3].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_feature_update_with_no_chain(self) -> None: + feature = Feature(key="Test Feature") + + feature.save() # create + feature.key = "New Test Feature" + feature.save() # update + + # Updating a feature with no chains should not trigger any webhook + assert len(responses.calls) == 0 + + @responses.activate + def test_on_feature_update_with_chain(self) -> None: + chain = ChainFactory.create() + feature = FeatureFactory.create(key="Test Feature", chains=(chain,)) + + feature.chains.remove(chain) + + # 1 call for Chain creation, 1 call for feature creation, + # 1 call for M2M update, 1 call for removing m2m relationship + assert len(responses.calls) == 4 + assert isinstance(responses.calls[3], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[3].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[3].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_feature_update_with_multiple_chains(self) -> None: + chain_1 = ChainFactory.create() + chain_2 = ChainFactory.create() + + FeatureFactory.create(key="Test Feature", chains=(chain_1, chain_2)) + + # 1 call for Chain 1 creation, 1 call for Chain 2 creation, 1 call for feature creation, + # 1 call for Chain 1 M2M update, 1 call for Chain 2 M2M update, 1 call for Feature update + assert len(responses.calls) == 6 + assert isinstance(responses.calls[3], responses.Call) + assert isinstance(responses.calls[4], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain_2.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain_1.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[3].request.url == "http://127.0.0.1/v1/hooks/events" + assert responses.calls[4].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[3].request.headers.get("Authorization") + == "Basic example-token" + ) + assert ( + responses.calls[4].request.headers.get("Authorization") + == "Basic example-token" + ) + + +@override_settings( + FF_HOOK_EVENTS=True, + CGW_URL="http://127.0.0.1", + CGW_FLUSH_TOKEN="example-token", +) +class WalletHookTestCase(TestCase): + @responses.activate + def test_on_wallet_create_with_no_chain(self) -> None: + Wallet(key="Test Wallet").save() + + # Creating a wallet with no chains should not trigger any webhook + assert len(responses.calls) == 0 + + @responses.activate + def test_on_wallet_create_with_chain(self) -> None: + chain = ChainFactory.create() + WalletFactory.create(key="Test Wallet", chains=(chain,)) + + # 1 call for Chain creation, 1 call for Wallet creation, 1 call for M2M update + assert len(responses.calls) == 3 + assert isinstance(responses.calls[2], responses.Call) + assert responses.calls[ + 2 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[2].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[2].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_wallet_delete_with_no_chain(self) -> None: + wallet = Wallet(key="Test Wallet") + + wallet.save() # create + wallet.delete() # delete + + # deleting a wallet with no chains should not trigger any webhook + assert len(responses.calls) == 0 + + @responses.activate + def test_on_wallet_delete_with_chain(self) -> None: + chain = ChainFactory.create() + wallet = WalletFactory.create(key="Test Wallet", chains=(chain,)) + + wallet.delete() + + # 1 call for Chain creation, 1 call for Wallet creation, 1 call for M2M update, 1 call for Wallet deletion + assert len(responses.calls) == 4 + assert isinstance(responses.calls[3], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[3].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[3].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_wallet_update_with_no_chain(self) -> None: + wallet = Wallet(key="Test Wallet") + + wallet.save() # create + wallet.key = "Test Wallet v2" + wallet.save() # update + + # Updating a wallet with no chains should not trigger any webhook + assert len(responses.calls) == 0 + + @responses.activate + def test_on_wallet_update_with_chain(self) -> None: + chain = ChainFactory.create() + wallet = WalletFactory.create(key="Test Wallet", chains=(chain,)) + + wallet.chains.remove(chain) + + # 1 call for Chain creation, 1 call for Wallet creation, + # 1 call for M2M update, 1 call for removing m2m relationship + assert len(responses.calls) == 4 + assert isinstance(responses.calls[3], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[3].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[3].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_wallet_update_with_multiple_chains(self) -> None: + chain_1 = ChainFactory.create() + chain_2 = ChainFactory.create() + + WalletFactory.create(key="Test Wallet", chains=(chain_1, chain_2)) + + # 1 call for Chain 1 creation, 1 call for Chain 2 creation, 1 call for Wallet creation, + # 1 call for Chain 1 M2M update, 1 call for Chain 2 M2M update, 1 call for Wallet update + assert len(responses.calls) == 6 + assert isinstance(responses.calls[3], responses.Call) + assert isinstance(responses.calls[4], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain_2.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{chain_1.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[3].request.url == "http://127.0.0.1/v1/hooks/events" + assert responses.calls[4].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[3].request.headers.get("Authorization") + == "Basic example-token" + ) + assert ( + responses.calls[4].request.headers.get("Authorization") + == "Basic example-token" + ) + + +@override_settings( + FF_HOOK_EVENTS=True, + CGW_URL="http://127.0.0.1", + CGW_FLUSH_TOKEN="example-token", +) +class GasPriceHookTestCase(TestCase): + def setUp(self) -> None: + self.chain = ( + ChainFactory.create() + ) # chain creation: a GasPrice requires a chain + + @responses.activate + def test_on_gas_price_create(self) -> None: + responses.add( + responses.POST, + "http://127.0.0.1/v1/hooks/events", + status=200, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Basic example-token"} + ), + responses.matchers.json_params_matcher( + {"type": "CHAIN_UPDATE", "chainId": str(self.chain.id)} + ), + ], + ) + + GasPriceFactory.create(chain=self.chain) + + assert len(responses.calls) == 1 + assert isinstance(responses.calls[0], responses.Call) + assert responses.calls[ + 0 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{self.chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[0].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[0].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_gas_price_delete(self) -> None: + gas_price = GasPriceFactory.create(chain=self.chain) # create + gas_price.delete() # delete + + # 2 calls: one for creation and one for deletion + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert responses.calls[ + 1 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{self.chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_gas_price_update(self) -> None: + gas_price = GasPriceFactory.create( + chain=self.chain, fixed_wei_value=1000 + ) # create + + gas_price.fixed_wei_value = 2000 + gas_price.save() # update + + # 2 calls: one for creation and one for updating + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert responses.calls[ + 1 + ].request.body == f'{{"type": "CHAIN_UPDATE", "chainId": "{self.chain.id}"}}'.encode( + "utf-8" + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) diff --git a/src/clients/safe_client_gateway.py b/src/clients/safe_client_gateway.py index 245d5471..04f6a68e 100644 --- a/src/clients/safe_client_gateway.py +++ b/src/clients/safe_client_gateway.py @@ -1,6 +1,8 @@ import logging +from dataclasses import dataclass +from enum import Enum from functools import cache -from typing import Any, Dict, Optional +from typing import Any, Dict from urllib.parse import urljoin import requests @@ -9,6 +11,16 @@ logger = logging.getLogger(__name__) +@dataclass +class HookEvent: + class Type(str, Enum): + CHAIN_UPDATE = "CHAIN_UPDATE" + SAFE_APPS_UPDATE = "SAFE_APPS_UPDATE" + + type: Type + chain_id: int + + @cache def setup_session() -> requests.Session: session = requests.Session() @@ -20,24 +32,37 @@ def setup_session() -> requests.Session: return session -def flush( - cgw_url: Optional[str], cgw_flush_token: Optional[str], json: Dict[str, Any] -) -> None: - if cgw_url is None: - logger.error("CGW_URL is not set. Skipping hook call") - return - if cgw_flush_token is None: - logger.error("CGW_FLUSH_TOKEN is not set. Skipping hook call") - return +def cgw_setup() -> tuple[str, str]: + if settings.CGW_URL is None: + raise ValueError("CGW_URL is not set. Skipping hook call") + if settings.CGW_FLUSH_TOKEN is None: + raise ValueError("CGW_FLUSH_TOKEN is not set. Skipping hook call") + return (settings.CGW_URL, settings.CGW_FLUSH_TOKEN) + + +def flush() -> None: + try: + (url, token) = cgw_setup() + url = urljoin(url, "/v2/flush") + post(url, token, json={"invalidate": "Chains"}) + except Exception as error: + logger.error(error) + - url = urljoin(cgw_url, "/v2/flush") +def hook_event(event: HookEvent) -> None: try: - post = setup_session().post( - url, - json=json, - headers={"Authorization": f"Basic {cgw_flush_token}"}, - timeout=settings.CGW_SESSION_TIMEOUT_SECONDS, - ) - post.raise_for_status() + (url, token) = cgw_setup() + url = urljoin(url, "/v1/hooks/events") + post(url, token, json={"type": event.type, "chainId": str(event.chain_id)}) except Exception as error: logger.error(error) + + +def post(url: str, token: str, json: Dict[str, Any]) -> None: + request = setup_session().post( + url, + json=json, + headers={"Authorization": f"Basic {token}"}, + timeout=settings.CGW_SESSION_TIMEOUT_SECONDS, + ) + request.raise_for_status() diff --git a/src/config/settings.py b/src/config/settings.py index 26ea934b..d865c884 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -232,3 +232,5 @@ allowed_csrf_origins.strip() for allowed_csrf_origins in allowed_csrf_origins.split(",") ] + +FF_HOOK_EVENTS = bool(strtobool(os.getenv("FF_HOOK_EVENTS", "false"))) diff --git a/src/safe_apps/signals.py b/src/safe_apps/signals.py index 6138a762..858134e8 100644 --- a/src/safe_apps/signals.py +++ b/src/safe_apps/signals.py @@ -3,32 +3,114 @@ from django.conf import settings from django.core.cache import caches -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete from django.dispatch import receiver -import clients.safe_client_gateway +from clients.safe_client_gateway import HookEvent, flush, hook_event -from .models import Provider, SafeApp, Tag +from .models import Feature, Provider, SafeApp, Tag logger = logging.getLogger(__name__) -def _flush_cgw_safe_apps() -> None: - clients.safe_client_gateway.flush( - cgw_url=settings.CGW_URL, - cgw_flush_token=settings.CGW_FLUSH_TOKEN, - # Even though the payload is Chains, it actually invalidates all the safe-config related cache - json={"invalidate": "Chains"}, - ) - - @receiver(post_save, sender=SafeApp) @receiver(post_delete, sender=SafeApp) +def on_safe_app_update(sender: SafeApp, instance: SafeApp, **kwargs: Any) -> None: + logger.info("Clearing safe-apps cache") + caches["safe-apps"].clear() + if settings.FF_HOOK_EVENTS: + for chain_id in instance.chain_ids: + hook_event( + HookEvent(type=HookEvent.Type.SAFE_APPS_UPDATE, chain_id=chain_id) + ) + else: + flush() + + @receiver(post_save, sender=Provider) @receiver(post_delete, sender=Provider) +def on_provider_update(sender: Provider, instance: Provider, **kwargs: Any) -> None: + logger.info("Clearing safe-apps cache") + caches["safe-apps"].clear() + if settings.FF_HOOK_EVENTS: + for safe_app in instance.safeapp_set.all(): + for chain_id in safe_app.chain_ids: + hook_event( + HookEvent(type=HookEvent.Type.SAFE_APPS_UPDATE, chain_id=chain_id) + ) + else: + flush() + + +# pre_delete is used because on pre_delete the model still has safe_apps +# which is not the case on post_delete @receiver(post_save, sender=Tag) -@receiver(post_delete, sender=Tag) -def on_safe_app_update(sender: SafeApp, **kwargs: Any) -> None: +@receiver(pre_delete, sender=Tag) +def on_tag_update(sender: Tag, instance: Tag, **kwargs: Any) -> None: logger.info("Clearing safe-apps cache") caches["safe-apps"].clear() - _flush_cgw_safe_apps() + if settings.FF_HOOK_EVENTS: + for safe_app in instance.safe_apps.all(): + for chain_id in safe_app.chain_ids: + hook_event( + HookEvent(type=HookEvent.Type.SAFE_APPS_UPDATE, chain_id=chain_id) + ) + else: + flush() + + +@receiver(m2m_changed, sender=Tag.safe_apps.through) +def on_tag_chains_update( + sender: Tag, instance: Tag, action: str, pk_set: set[int], **kwargs: Any +) -> None: + logger.info("TagChains update. Triggering CGW webhook") + caches["safe-apps"].clear() + if action == "post_add" or action == "post_remove": + if settings.FF_HOOK_EVENTS: + chain_ids = set() + for safe_app in SafeApp.objects.filter(app_id__in=pk_set): + for chain_id in safe_app.chain_ids: + chain_ids.add(chain_id) + for chain_id in chain_ids: + hook_event( + HookEvent(type=HookEvent.Type.SAFE_APPS_UPDATE, chain_id=chain_id) + ) + else: + flush() + + +# pre_delete is used because on pre_delete the model still has safe_apps +# which is not the case on post_delete +@receiver(post_save, sender=Feature) +@receiver(pre_delete, sender=Feature) +def on_feature_update(sender: Feature, instance: Feature, **kwargs: Any) -> None: + logger.info("Feature update. Triggering CGW webhook") + caches["safe-apps"].clear() + if settings.FF_HOOK_EVENTS: + for safe_app in instance.safe_apps.all(): + for chain_id in safe_app.chain_ids: + hook_event( + HookEvent(type=HookEvent.Type.SAFE_APPS_UPDATE, chain_id=chain_id) + ) + else: + flush() + + +@receiver(m2m_changed, sender=Feature.safe_apps.through) +def on_feature_safe_apps_update( + sender: Feature, instance: Feature, action: str, pk_set: set[int], **kwargs: Any +) -> None: + logger.info("FeatureSafeApps update. Triggering CGW webhook") + caches["safe-apps"].clear() + if action == "post_add" or action == "post_remove": + if settings.FF_HOOK_EVENTS: + chain_ids = set() + for safe_app in SafeApp.objects.filter(app_id__in=pk_set): + for chain_id in safe_app.chain_ids: + chain_ids.add(chain_id) + for chain_id in chain_ids: + hook_event( + HookEvent(type=HookEvent.Type.SAFE_APPS_UPDATE, chain_id=chain_id) + ) + else: + flush() diff --git a/src/safe_apps/tests/test_signals.py b/src/safe_apps/tests/test_signals.py index 39cc0339..7f285cc1 100644 --- a/src/safe_apps/tests/test_signals.py +++ b/src/safe_apps/tests/test_signals.py @@ -1,11 +1,12 @@ import responses from django.test import TestCase, override_settings -from safe_apps.models import SafeApp, Tag -from safe_apps.tests.factories import ProviderFactory +from ..models import SafeApp, Tag +from ..tests.factories import ProviderFactory @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) @@ -92,6 +93,7 @@ def test_on_safe_app_delete_hook_call(self) -> None: @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) @@ -176,6 +178,7 @@ def test_on_provider_delete_hook_call(self) -> None: @override_settings( + FF_HOOK_EVENTS=False, CGW_URL="http://127.0.0.1", CGW_FLUSH_TOKEN="example-token", ) diff --git a/src/safe_apps/tests/test_signals_ff_hook_events.py b/src/safe_apps/tests/test_signals_ff_hook_events.py new file mode 100644 index 00000000..37034e10 --- /dev/null +++ b/src/safe_apps/tests/test_signals_ff_hook_events.py @@ -0,0 +1,428 @@ +import responses +from django.test import TestCase, override_settings +from faker import Faker + +from ..models import SafeApp +from .factories import FeatureFactory, ProviderFactory, SafeAppFactory, TagFactory + +fake = Faker() +Faker.seed(0) + + +@override_settings( + FF_HOOK_EVENTS=True, + CGW_URL="http://127.0.0.1", + CGW_FLUSH_TOKEN="example-token", +) +class SafeAppHookTestCase(TestCase): + @responses.activate + def test_on_safe_app_create(self) -> None: + responses.add( + responses.POST, + "http://127.0.0.1/v1/hooks/events", + status=200, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Basic example-token"} + ), + responses.matchers.json_params_matcher( + {"type": "SAFE_APPS_UPDATE", "chainId": "1"} + ), + ], + ) + + SafeApp(app_id=1, chain_ids=[1]).save() + + assert len(responses.calls) == 1 + assert isinstance(responses.calls[0], responses.Call) + assert ( + responses.calls[0].request.body + == b'{"type": "SAFE_APPS_UPDATE", "chainId": "1"}' + ) + assert responses.calls[0].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[0].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_safe_app_update(self) -> None: + responses.add( + responses.POST, + "http://127.0.0.1/v1/hooks/events", + status=200, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Basic example-token"} + ), + responses.matchers.json_params_matcher( + {"type": "SAFE_APPS_UPDATE", "chainId": "1"} + ), + ], + ) + + safe_app = SafeApp(app_id=1, chain_ids=[1]) + safe_app.save() # create + safe_app.name = "Test app" + safe_app.save() # update + + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert ( + responses.calls[1].request.body + == b'{"type": "SAFE_APPS_UPDATE", "chainId": "1"}' + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_safe_app_delete(self) -> None: + responses.add( + responses.POST, + "http://127.0.0.1/v1/hooks/events", + status=200, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Basic example-token"} + ), + responses.matchers.json_params_matcher( + {"type": "SAFE_APPS_UPDATE", "chainId": "1"} + ), + ], + ) + + safe_app = SafeApp(app_id=1, chain_ids=[1]) + safe_app.save() # create + safe_app.delete() # delete + + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert ( + responses.calls[1].request.body + == b'{"type": "SAFE_APPS_UPDATE", "chainId": "1"}' + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) + + +@override_settings( + FF_HOOK_EVENTS=True, + CGW_URL="http://127.0.0.1", + CGW_FLUSH_TOKEN="example-token", +) +class ProviderHookTestCase(TestCase): + @responses.activate + def test_on_provider_create_with_no_safe_app(self) -> None: + ProviderFactory.create() + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_provider_create_with_safe_app(self) -> None: + chain_id = fake.pyint() + provider = ProviderFactory.create() + SafeAppFactory.create(chain_ids=[chain_id], provider=provider) + + # Safe App Creation, Safe App Update + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert responses.calls[ + 1 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_provider_update_with_safe_app(self) -> None: + chain_id = fake.pyint() + provider = ProviderFactory.create() + SafeAppFactory.create(chain_ids=[chain_id], provider=provider) + + provider.name = "New name" + provider.save() + + # Safe App Creation, Safe App Update, Provider update + assert len(responses.calls) == 3 + assert isinstance(responses.calls[2], responses.Call) + assert responses.calls[ + 2 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + assert responses.calls[2].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[2].request.headers.get("Authorization") + == "Basic example-token" + ) + + @responses.activate + def test_on_provider_delete_with_no_safe_app(self) -> None: + provider = ProviderFactory.create() # create + provider.delete() # delete + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_provider_delete_with_safe_app(self) -> None: + chain_id = fake.pyint() + provider = ProviderFactory.create() + SafeAppFactory.create(chain_ids=[chain_id], provider=provider) + + provider.delete() + + # Safe App Creation, Safe App Update, Provider update + assert len(responses.calls) == 2 + assert isinstance(responses.calls[1], responses.Call) + assert responses.calls[ + 1 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + assert responses.calls[1].request.url == "http://127.0.0.1/v1/hooks/events" + assert ( + responses.calls[1].request.headers.get("Authorization") + == "Basic example-token" + ) + + +@override_settings( + FF_HOOK_EVENTS=True, + CGW_URL="http://127.0.0.1", + CGW_FLUSH_TOKEN="example-token", +) +class TagHookTestCase(TestCase): + @responses.activate + def test_on_tag_create_with_no_safe_app(self) -> None: + TagFactory.create() # create + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_tag_create_with_safe_app(self) -> None: + chain_id = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id]) + + TagFactory.create(safe_apps=(safe_app,)) + + # Safe App Creation, Safe App Update, M2M update, Tag create + assert len(responses.calls) == 4 + assert isinstance(responses.calls[3], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + + @responses.activate + def test_on_tag_update_with_no_safe_app(self) -> None: + tag = TagFactory.create() # create + tag.name = "Test Tag" + + tag.save() # update + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_tag_update_with_safe_app(self) -> None: + chain_id = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id]) + tag = TagFactory.create(safe_apps=(safe_app,)) + + tag.name = "test" + tag.save() + + # Safe App Creation, Safe App Update, M2M update, Tag create, Tag update + assert len(responses.calls) == 5 + assert isinstance(responses.calls[4], responses.Call) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + + @responses.activate + def test_on_tag_delete_with_no_safe_app(self) -> None: + tag = TagFactory.create() # create + + tag.delete() # delete + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_tag_delete_with_safe_app(self) -> None: + chain_id = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id]) + tag = TagFactory.create(safe_apps=(safe_app,)) + + tag.delete() + + # Safe App Creation, Safe App Update, M2M update, Tag create, Tag delete + assert len(responses.calls) == 5 + assert isinstance(responses.calls[4], responses.Call) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + + @responses.activate + def test_on_tag_update_with_multiple_safe_apps(self) -> None: + chain_id_1 = fake.pyint() + chain_id_2 = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id_1, chain_id_2]) + + TagFactory.create(safe_apps=(safe_app,)) + + # Safe App Creation for chain 1, Safe App Creation for chain 2, + # Safe App Update for chain 1, Safe App Update for chain 2, + # Tag update for chain 1, M2M update for chain 1 + # Tag update for chain 2, M2M update for chain 2 + assert len(responses.calls) == 8 + assert isinstance(responses.calls[5], responses.Call) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_2}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 5 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_1}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 6 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_1}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 7 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_2}"}}'.encode( + "utf-8" + ) + + +@override_settings( + FF_HOOK_EVENTS=True, + CGW_URL="http://127.0.0.1", + CGW_FLUSH_TOKEN="example-token", +) +class FeatureHookTestCase(TestCase): + @responses.activate + def test_on_feature_create_with_no_safe_app(self) -> None: + FeatureFactory.create() # create + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_feature_create_with_safe_app(self) -> None: + chain_id = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id]) + + FeatureFactory.create(safe_apps=(safe_app,)) + + # Safe App Creation, Safe App Update, M2M update, Feature create + assert len(responses.calls) == 4 + assert isinstance(responses.calls[3], responses.Call) + assert responses.calls[ + 3 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + + @responses.activate + def test_on_feature_update_with_no_safe_app(self) -> None: + feature = FeatureFactory.create() # create + feature.name = "Test Feature" + + feature.save() # update + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_feature_update_with_safe_app(self) -> None: + chain_id = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id]) + feature = FeatureFactory.create(safe_apps=(safe_app,)) + + feature.name = "test" + feature.save() + + # Safe App Creation, Safe App Update, M2M update, Feature create, Feature update + assert len(responses.calls) == 5 + assert isinstance(responses.calls[4], responses.Call) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + + @responses.activate + def test_on_feature_delete_with_no_safe_app(self) -> None: + feature = FeatureFactory.create() # create + + feature.delete() # delete + + assert len(responses.calls) == 0 + + @responses.activate + def test_on_feature_delete_with_safe_app(self) -> None: + chain_id = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id]) + feature = FeatureFactory.create(safe_apps=(safe_app,)) + + feature.delete() + + # Safe App Creation, Safe App Update, M2M update, Feature create, Feature delete + assert len(responses.calls) == 5 + assert isinstance(responses.calls[4], responses.Call) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id}"}}'.encode( + "utf-8" + ) + + @responses.activate + def test_on_feature_update_with_multiple_safe_apps(self) -> None: + chain_id_1 = fake.pyint() + chain_id_2 = fake.pyint() + safe_app = SafeAppFactory.create(chain_ids=[chain_id_1, chain_id_2]) + + FeatureFactory.create(safe_apps=(safe_app,)) + + # Safe App Creation for chain 1, Safe App Creation for chain 2, + # Safe App Update for chain 1, Safe App Update for chain 2, + # Feature update for chain 1, M2M update for chain 1 + # Feature update for chain 2, M2M update for chain 2 + assert len(responses.calls) == 8 + assert isinstance(responses.calls[5], responses.Call) + assert responses.calls[ + 4 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_2}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 5 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_1}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 6 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_1}"}}'.encode( + "utf-8" + ) + assert responses.calls[ + 7 + ].request.body == f'{{"type": "SAFE_APPS_UPDATE", "chainId": "{chain_id_2}"}}'.encode( + "utf-8" + )