Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions enferno/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions enferno/static/js/mixins/notification-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions tests/test_session_poll_refresh.py
Original file line number Diff line number Diff line change
@@ -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"
Loading