diff --git a/enferno/app.py b/enferno/app.py index 15ef748af..9ab916902 100755 --- a/enferno/app.py +++ b/enferno/app.py @@ -142,6 +142,20 @@ def register_extensions(app): security = Security(app, user_datastore, **security_options) session.init_app(app) + + # Background polls (e.g. the notification poller) carry X-Silent-Poll and must + # not slide the server-side session, otherwise the idle timeout never fires. + from types import MethodType + from flask import request + + def _should_set_storage(self, app, sess): + if request.headers.get("X-Silent-Poll") and not sess.modified: + return False + return sess.modified or app.config["SESSION_REFRESH_EACH_REQUEST"] + + app.session_interface.should_set_storage = MethodType( + _should_set_storage, app.session_interface + ) babel.init_app(app, locale_selector=get_locale, default_domain="messages", default_locale="en") rds.init_app(app) mail.init_app(app) diff --git a/enferno/static/js/mixins/notification-mixin.js b/enferno/static/js/mixins/notification-mixin.js index fd849de5d..c75cb5a73 100644 --- a/enferno/static/js/mixins/notification-mixin.js +++ b/enferno/static/js/mixins/notification-mixin.js @@ -126,6 +126,8 @@ const notificationMixin = { } }, async loadNotifications(options) { + const { silent, ...loadOptions } = options || {}; + options = loadOptions; const isFirstPage = options?.page === 1; const status = this.notifications.status; @@ -153,7 +155,8 @@ const notificationMixin = { } const queryParams = new URLSearchParams(query); - const { data } = await axios.get(`/admin/api/notifications?${queryParams.toString()}`); + const config = silent ? { headers: { 'X-Silent-Poll': '1' } } : undefined; + const { data } = await axios.get(`/admin/api/notifications?${queryParams.toString()}`, config); if (!data) return; @@ -255,7 +258,8 @@ const notificationMixin = { } }, async refetchNotifications() { - this.loadNotifications({ page: 1 }); + // Automatic background poll: must not slide the idle session timeout. + this.loadNotifications({ page: 1, silent: true }); }, decrementNotificationsCount() { if (this.notifications.unreadCount > 0) { diff --git a/tests/test_session_poll_refresh.py b/tests/test_session_poll_refresh.py new file mode 100644 index 000000000..34c37def8 --- /dev/null +++ b/tests/test_session_poll_refresh.py @@ -0,0 +1,45 @@ +"""Verify the notification poll no longer slides the server-side session TTL. + +A request carrying X-Silent-Poll must NOT refresh the session expiry, so an idle +user is eventually logged out. A normal request must still refresh it. +""" + + +def _session_key(app): + client = app.session_interface.client + keys = [k for k in client.keys() if b"session" in k or k.startswith(b"session:")] + assert keys, f"no session key in store; keys={client.keys()}" + return client, keys[0] + + +def test_silent_poll_does_not_refresh_ttl(app, admin_client): + admin_client.get("/admin/api/notifications") + client, key = _session_key(app) + + # Simulate idle time passing: 100s from expiring. + client.expire(key, 100) + assert client.ttl(key) <= 100 + + # Background poll carries the silent header. + resp = admin_client.get("/admin/api/notifications", headers={"X-Silent-Poll": "1"}) + assert resp.status_code == 200 + + refreshed = client.ttl(key) + print(f"\nTTL after silent poll (was forced to <=100): {refreshed}") + assert refreshed <= 100, "silent poll still refreshed the session TTL" + + +def test_normal_request_still_refreshes_ttl(app, admin_client): + admin_client.get("/admin/api/notifications") + client, key = _session_key(app) + + client.expire(key, 100) + assert client.ttl(key) <= 100 + + # Normal (non-silent) request: a real user action. + resp = admin_client.get("/admin/api/notifications") + assert resp.status_code == 200 + + refreshed = client.ttl(key) + print(f"\nTTL after normal request (was forced to <=100): {refreshed}") + assert refreshed > 100, "normal request should still refresh the session TTL"