Browse Source

ref: Have RedisSessionStore properties declared explicitly (#27109)

Replace the implicit assignment of attributes (via __getattr__ and
__setattr__) to be stored to Redis under arbitrary names. This clarifies
the source of the attributes and prevents some infinite recursion bugs.
Ryan Skonnord 3 years ago
parent
commit
1c0706fb09

+ 16 - 3
src/sentry/pipeline/__init__.py

@@ -5,7 +5,7 @@ from sentry import analytics
 from sentry.models import Organization
 from sentry.models import Organization
 from sentry.utils import json
 from sentry.utils import json
 from sentry.utils.hashlib import md5_text
 from sentry.utils.hashlib import md5_text
-from sentry.utils.session_store import RedisSessionStore
+from sentry.utils.session_store import RedisSessionStore, redis_property
 from sentry.web.frontend.base import BaseView
 from sentry.web.frontend.base import BaseView
 from sentry.web.helpers import render_to_response
 from sentry.web.helpers import render_to_response
 
 
@@ -106,6 +106,17 @@ class NestedPipelineView(PipelineView):
         return nested_pipeline.current_step()
         return nested_pipeline.current_step()
 
 
 
 
+class PipelineSessionStore(RedisSessionStore):
+    uid = redis_property("uid")
+    provider_model_id = redis_property("provider_model_id")
+    provider_key = redis_property("provider_key")
+    org_id = redis_property("org_id")
+    signature = redis_property("signature")
+    step_index = redis_property("step_index")
+    config = redis_property("config")
+    data = redis_property("data")
+
+
 class Pipeline:
 class Pipeline:
     """
     """
     Pipeline provides a mechanism to guide the user through a request
     Pipeline provides a mechanism to guide the user through a request
@@ -138,7 +149,7 @@ class Pipeline:
 
 
     @classmethod
     @classmethod
     def get_for_request(cls, request):
     def get_for_request(cls, request):
-        state = RedisSessionStore(request, cls.pipeline_name, ttl=INTEGRATION_EXPIRATION_TTL)
+        state = PipelineSessionStore(request, cls.pipeline_name, ttl=INTEGRATION_EXPIRATION_TTL)
         if not state.is_valid():
         if not state.is_valid():
             return None
             return None
 
 
@@ -167,7 +178,9 @@ class Pipeline:
 
 
         self.request = request
         self.request = request
         self.organization = organization
         self.organization = organization
-        self.state = RedisSessionStore(request, self.pipeline_name, ttl=INTEGRATION_EXPIRATION_TTL)
+        self.state = PipelineSessionStore(
+            request, self.pipeline_name, ttl=INTEGRATION_EXPIRATION_TTL
+        )
         self.provider = self.provider_manager.get(provider_key)
         self.provider = self.provider_manager.get(provider_key)
         self.provider_model = provider_model
         self.provider_model = provider_model
 
 

+ 22 - 8
src/sentry/utils/session_store.py

@@ -13,6 +13,14 @@ class RedisSessionStore:
     the request session. Useful for storing data too large to be stored into
     the request session. Useful for storing data too large to be stored into
     the session cookie.
     the session cookie.
 
 
+    The attributes to be backed by Redis must be declared in a subclass using
+    the `redis_property` function. Do not instantiate RedisSessionStore without
+    extending it to add properties. For example:
+
+    >>> class HotDogSessionStore(RedisSessionStore):
+    >>>     bun = redis_property("bun")
+    >>>     condiment = redis_property("condiment")
+
     NOTE: Assigning attributes immediately saves their value back into the
     NOTE: Assigning attributes immediately saves their value back into the
           redis key assigned for this store. Be aware of the multiple
           redis key assigned for this store. Be aware of the multiple
           round-trips implication of this.
           round-trips implication of this.
@@ -39,9 +47,9 @@ class RedisSessionStore:
     """
     """
 
 
     def __init__(self, request, prefix, ttl=EXPIRATION_TTL):
     def __init__(self, request, prefix, ttl=EXPIRATION_TTL):
-        self.__dict__["request"] = request
-        self.__dict__["prefix"] = prefix
-        self.__dict__["ttl"] = ttl
+        self.request = request
+        self.prefix = prefix
+        self.ttl = ttl
 
 
     @property
     @property
     def _client(self):
     def _client(self):
@@ -86,19 +94,25 @@ class RedisSessionStore:
 
 
         return loads(state_json)
         return loads(state_json)
 
 
-    def __getattr__(self, key):
-        state = self.get_state()
+
+def redis_property(key: str):
+    """Declare a property backed by Redis on a RedisSessionStore class."""
+
+    def getter(store: "RedisSessionStore"):
+        state = store.get_state()
 
 
         try:
         try:
             return state[key] if state else None
             return state[key] if state else None
         except KeyError as e:
         except KeyError as e:
             raise AttributeError(e)
             raise AttributeError(e)
 
 
-    def __setattr__(self, key, value):
-        state = self.get_state()
+    def setter(store: "RedisSessionStore", value):
+        state = store.get_state()
 
 
         if state is None:
         if state is None:
             return
             return
 
 
         state[key] = value
         state[key] = value
-        self._client.setex(self.redis_key, self.ttl, dumps(state))
+        store._client.setex(store.redis_key, store.ttl, dumps(state))
+
+    return property(getter, setter)

+ 25 - 31
tests/sentry/utils/test_session_store.py

@@ -1,55 +1,49 @@
 from unittest import TestCase
 from unittest import TestCase
 
 
-from django.http import HttpRequest
+from django.test import Client, RequestFactory
 
 
-from sentry.utils.session_store import RedisSessionStore
+from sentry.utils.session_store import RedisSessionStore, redis_property
 
 
 
 
 class RedisSessionStoreTestCase(TestCase):
 class RedisSessionStoreTestCase(TestCase):
-    def test_store_values(self):
-        request = HttpRequest()
-        request.session = {}
+    class TestRedisSessionStore(RedisSessionStore):
+        some_value = redis_property("some_value")
+
+    def setUp(self) -> None:
+        self.request = RequestFactory().get("")
+        self.request.session = Client().session
 
 
-        store = RedisSessionStore(request, "test-store")
-        store.regenerate()
+        self.store = self.TestRedisSessionStore(self.request, "test-store")
+
+    def test_store_values(self):
+        self.store.regenerate()
 
 
-        assert "store:test-store" in request.session
+        assert "store:test-store" in self.request.session
 
 
-        store.some_value = "test_value"
-        store2 = RedisSessionStore(request, "test-store")
+        self.store.some_value = "test_value"
+        store2 = self.TestRedisSessionStore(self.request, "test-store")
 
 
         assert store2.is_valid()
         assert store2.is_valid()
         assert store2.some_value == "test_value"
         assert store2.some_value == "test_value"
 
 
         with self.assertRaises(AttributeError):
         with self.assertRaises(AttributeError):
-            store.missing_key
+            self.store.missing_key
 
 
-        store.clear()
+        self.store.clear()
+        assert self.request.session.modified
 
 
     def test_store_complex_object(self):
     def test_store_complex_object(self):
-        request = HttpRequest()
-        request.session = {}
+        self.store.regenerate({"some_value": {"deep_object": "value"}})
 
 
-        store = RedisSessionStore(request, "test-store")
-        store.regenerate({"some_value": {"deep_object": "value"}})
-
-        store2 = RedisSessionStore(request, "test-store")
+        store2 = self.TestRedisSessionStore(self.request, "test-store")
 
 
         assert store2.some_value["deep_object"] == "value"
         assert store2.some_value["deep_object"] == "value"
 
 
-        store.clear()
+        self.store.clear()
 
 
     def test_uninitialized_store(self):
     def test_uninitialized_store(self):
-        request = HttpRequest()
-        request.session = {}
-
-        store = RedisSessionStore(request, "test-store")
-
-        assert not store.is_valid()
-        assert store.get_state() is None
-        assert store.some_key is None
-
-        store.setting_but_no_state = "anything"
-        assert store.setting_but_no_state is None
+        assert not self.store.is_valid()
+        assert self.store.get_state() is None
+        assert self.store.some_value is None
 
 
-        store.clear()
+        self.store.clear()