-
Notifications
You must be signed in to change notification settings - Fork 4.2k
fix!: session ID stability while maintaining inactivity timeout #36896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
567bd91
fix: session ID stability while maintaining inactivity timeout
ttak-apphelix f4a3d74
fix: linter issue
ttak-apphelix 79dd8f9
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix b5f4baf
fix!: session ID stability while maintaining inactivity timeout
ttak-apphelix 8dd5bd2
fix!: session ID stability while maintaining inactivity timeout
ttak-apphelix f953ed9
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 4f2ca29
fix: update session inactivity middleware to use consistent naming an…
ttak-apphelix 50a212c
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 02c0114
fix: correct logging variable name and improve log levels in session …
ttak-apphelix e50ede8
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 43e0842
fix: enhance session inactivity tracking with TieredCache and custom …
ttak-apphelix 8b935b1
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 81f043f
fix: update session inactivity tracking to use user-specific cache ke…
ttak-apphelix 9f42295
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix c801c23
fix: simplify session save time key handling and improve logging for …
ttak-apphelix 25a339f
fix: remove unused last session save time key and streamline session …
ttak-apphelix 988d8c2
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 495e2d6
fix: enhance session inactivity tracking with improved logging and er…
ttak-apphelix cdb462c
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 6bdf73f
fix: linting fix
ttak-apphelix 4334bac
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 4cf4f90
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix c666449
fix: enhance session inactivity monitoring with improved error handling
ttak-apphelix 47ca3b4
fix: fix linter and import
ttak-apphelix 385eb3f
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 21d1b29
fix: incorporated review comment
ttak-apphelix 3adcbe0
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix b18b107
fix: removed unwanted comments
ttak-apphelix 567dd9a
fix: updated mock for log
ttak-apphelix e282383
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix 68f1514
fix!: session ID stability while maintaining inactivity timeout
ttak-apphelix 390fd8b
fixup! Update openedx/core/djangoapps/session_inactivity_timeout/test…
robrap 040cfef
Merge branch 'master' into ttak/session_id_fix
robrap File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
openedx/core/djangoapps/session_inactivity_timeout/tests/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| """ | ||
| Tests for session inactivity timeout middleware. | ||
| """ |
216 changes: 216 additions & 0 deletions
216
openedx/core/djangoapps/session_inactivity_timeout/tests/test_middleware.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| """ | ||
| Unit tests for SessionInactivityTimeout middleware. | ||
| """ | ||
|
|
||
| from datetime import datetime, timedelta | ||
| from unittest.mock import patch, call, ANY | ||
|
|
||
| import ddt | ||
| from django.contrib.auth.models import AnonymousUser | ||
| from django.contrib.sessions.backends.db import SessionStore | ||
| from django.test import TestCase, override_settings | ||
|
|
||
| from common.djangoapps.student.tests.factories import UserFactory | ||
| from openedx.core.djangolib.testing.utils import get_mock_request | ||
|
|
||
| from openedx.core.djangoapps.session_inactivity_timeout.middleware import ( | ||
| SessionInactivityTimeout, | ||
| LAST_TOUCH_KEYNAME, | ||
| ) | ||
|
|
||
|
|
||
| @ddt.ddt | ||
| class SessionInactivityTimeoutTestCase(TestCase): | ||
| """ | ||
| Test case for SessionInactivityTimeout middleware | ||
| """ | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.user = UserFactory.create() | ||
| self.middleware = SessionInactivityTimeout(get_response=lambda request: None) | ||
| self.request = get_mock_request(self.user) | ||
|
|
||
| self.request.session = SessionStore() | ||
| self.request.session.create() | ||
| self.request.session.modified = False | ||
|
|
||
| def test_process_request_unauthenticated_user_does_nothing(self): | ||
| self.request.user = AnonymousUser() | ||
| response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none | ||
| assert response is None | ||
| assert LAST_TOUCH_KEYNAME not in self.request.session | ||
|
|
||
| @ddt.data( | ||
| None, # No timestamp key in session | ||
| "", # Empty string timestamp in session | ||
| ) | ||
| @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=300) | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime") | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.log") | ||
| @patch( | ||
| "openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils" | ||
| ) | ||
| def test_process_request_first_visit_sets_timestamp( | ||
| self, timestamp_value, mock_monitoring, mock_log, mock_datetime | ||
| ): | ||
| if timestamp_value is not None: | ||
| self.request.session[LAST_TOUCH_KEYNAME] = timestamp_value | ||
| # else: leave the session without the timestamp key (None case) | ||
|
|
||
| mock_now = datetime(2025, 6, 16, 12, 0, 0) | ||
| mock_datetime.utcnow.return_value = mock_now | ||
|
|
||
| response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none | ||
|
|
||
| assert response is None | ||
| assert self.request.session[LAST_TOUCH_KEYNAME] == mock_now.isoformat() | ||
| mock_log.debug.assert_called_once_with("No previous activity timestamp found (first login)") | ||
|
|
||
| mock_monitoring.set_custom_attribute.assert_has_calls([ | ||
| call("session_inactivity.first_login", True), | ||
| call("session_inactivity.proceed_with_period_save", True), | ||
| ], any_order=True) | ||
|
|
||
| @ddt.data( | ||
| "invalid-timestamp", # Invalid ISO format | ||
| "2025-13-01T12:00:00", # Invalid date | ||
| ) | ||
| @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=300) | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.log") | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils") | ||
| def test_process_request_invalid_timestamp_handling( | ||
| self, invalid_timestamp, mock_monitoring, mock_log | ||
| ): | ||
| self.request.session[LAST_TOUCH_KEYNAME] = invalid_timestamp | ||
|
|
||
| # The middleware should raise an exception when it tries to parse the invalid timestamp | ||
| # in the save delay logic (after already catching it once in the timeout logic) | ||
| with self.assertRaises(ValueError): | ||
| self.middleware.process_request(self.request) | ||
|
|
||
| mock_log.warning.assert_called_once() | ||
|
|
||
| mock_monitoring.set_custom_attribute.assert_any_call("session_inactivity.last_touch_error", ANY) | ||
| mock_monitoring.record_exception.assert_called_once() | ||
|
|
||
| @ddt.data( | ||
| # (timeout_seconds, seconds_elapsed, should_logout) | ||
| (300, 299, False), # 299 sec < 300 sec timeout, no logout | ||
| (300, 300, False), # 300 sec = 300 sec timeout, no logout (not exceeded) | ||
| (300, 301, True), # 301 sec > 300 sec timeout, logout occurs | ||
| ) | ||
| @ddt.unpack | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime") | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils") | ||
| def test_process_request_timeout_behavior( | ||
| self, timeout_seconds, seconds_elapsed, should_logout, | ||
| mock_monitoring, mock_datetime | ||
| ): | ||
| with override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=timeout_seconds): | ||
| assert self.request.user.is_authenticated | ||
|
|
||
| last_touch = datetime(2025, 6, 16, 12, 0, 0) | ||
| current_time = last_touch + timedelta(seconds=seconds_elapsed) | ||
| self.request.session[LAST_TOUCH_KEYNAME] = last_touch.isoformat() | ||
| mock_datetime.utcnow.return_value = current_time | ||
| mock_datetime.fromisoformat = datetime.fromisoformat | ||
|
|
||
| response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none | ||
|
|
||
| assert response is None | ||
|
|
||
| if should_logout: | ||
| assert LAST_TOUCH_KEYNAME not in self.request.session | ||
| assert not self.request.user.is_authenticated | ||
| mock_monitoring.set_custom_attribute.assert_any_call( | ||
| "session_inactivity.has_exceeded_timeout_limit", True | ||
| ) | ||
| else: | ||
| assert self.request.user.is_authenticated | ||
| assert LAST_TOUCH_KEYNAME in self.request.session | ||
| mock_monitoring.set_custom_attribute.assert_any_call( | ||
| "session_inactivity.has_exceeded_timeout_limit", False | ||
| ) | ||
|
|
||
| @ddt.data( | ||
| # (save_delay_seconds, seconds_elapsed, should_save) | ||
| # Test save delay behavior (with long timeout to avoid logout) | ||
| (900, 899, False), # 899 sec < 900 sec save delay, no save | ||
| (900, 900, False), # 900 sec = 900 sec save delay, no save (not exceeded) | ||
| (900, 901, True), # 901 sec > 900 sec save delay, save occurs | ||
| ) | ||
| @ddt.unpack | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime") | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils") | ||
| def test_process_request_save_behavior( | ||
| self, save_delay_seconds, seconds_elapsed, should_save, | ||
| mock_monitoring, mock_datetime | ||
| ): | ||
| with override_settings( | ||
| SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=3600, # Long timeout to avoid logout | ||
| SESSION_ACTIVITY_SAVE_DELAY_SECONDS=save_delay_seconds | ||
| ): | ||
| assert self.request.user.is_authenticated | ||
|
|
||
| last_touch = datetime(2025, 6, 16, 12, 0, 0) | ||
| current_time = last_touch + timedelta(seconds=seconds_elapsed) | ||
| self.request.session[LAST_TOUCH_KEYNAME] = last_touch.isoformat() | ||
| mock_datetime.utcnow.return_value = current_time | ||
| mock_datetime.fromisoformat = datetime.fromisoformat | ||
|
|
||
| response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none | ||
|
|
||
| assert response is None | ||
| assert self.request.user.is_authenticated | ||
| assert LAST_TOUCH_KEYNAME in self.request.session | ||
|
|
||
| if should_save: | ||
| assert self.request.session[LAST_TOUCH_KEYNAME] == current_time.isoformat() | ||
| mock_monitoring.set_custom_attribute.assert_has_calls([ | ||
| call("session_inactivity.has_exceeded_timeout_limit", False), | ||
| call("session_inactivity.proceed_with_period_save", True), | ||
| ], any_order=True) | ||
| else: | ||
| # Session should not be saved, timestamp remains the same | ||
| assert self.request.session[LAST_TOUCH_KEYNAME] == last_touch.isoformat() | ||
| mock_monitoring.set_custom_attribute.assert_has_calls([ | ||
| call("session_inactivity.has_exceeded_timeout_limit", False), | ||
| call("session_inactivity.proceed_with_period_save", False), | ||
| ], any_order=True) | ||
|
|
||
| @ddt.data( | ||
| # (seconds_elapsed, should_save) - testing around default 900 second save delay | ||
| (899, False), # 899 sec < 900 sec default, no save | ||
| (900, False), # 900 sec = 900 sec default, no save (not exceeded) | ||
| (901, True), # 901 sec > 900 sec default, save occurs | ||
| ) | ||
| @ddt.unpack | ||
| @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=3600) | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime") | ||
| @patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils") | ||
| def test_process_request_uses_default_save_frequency( | ||
| self, seconds_elapsed, should_save, mock_monitoring, mock_datetime | ||
| ): | ||
| # Don't set SESSION_ACTIVITY_SAVE_DELAY_SECONDS to test default behavior | ||
| last_touch = datetime(2025, 6, 16, 12, 0, 0) | ||
| current_time = last_touch + timedelta(seconds=seconds_elapsed) | ||
| self.request.session[LAST_TOUCH_KEYNAME] = last_touch.isoformat() | ||
| mock_datetime.utcnow.return_value = current_time | ||
| mock_datetime.fromisoformat = datetime.fromisoformat | ||
|
|
||
| response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none | ||
|
|
||
| assert response is None | ||
|
|
||
| if should_save: | ||
| assert self.request.session[LAST_TOUCH_KEYNAME] == current_time.isoformat() | ||
| mock_monitoring.set_custom_attribute.assert_has_calls([ | ||
| call("session_inactivity.has_exceeded_timeout_limit", False), | ||
| call("session_inactivity.proceed_with_period_save", True), | ||
| ], any_order=True) | ||
| else: | ||
| assert self.request.session[LAST_TOUCH_KEYNAME] == last_touch.isoformat() | ||
| mock_monitoring.set_custom_attribute.assert_has_calls([ | ||
| call("session_inactivity.has_exceeded_timeout_limit", False), | ||
| call("session_inactivity.proceed_with_period_save", False), | ||
| ], any_order=True) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.