From ca90969eb4fef7b7c14de3cc9ba37f959926bc98 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Mon, 14 Sep 2026 15:21:51 -0400 Subject: [PATCH] Fix fleet alerts sent immediately instead of respecting scheduled starttime Bug Fix: Fleet alert scheduling regression Problem: - User sets notification to run at 3:30 PM (future time) - Fleet alert sent to agents immediately at 3:10 PM instead of waiting - Agents see notification 20 minutes early Root Cause: - GET /api/endpoints/alerts query filtered for: - alertfleet = True - isactive = True - endtime is NULL OR endtime > now (not expired) - Not acknowledged - MISSING CHECK: starttime is NULL OR starttime <= now (has started) - Result: Any notification with future starttime appeared immediately Solution: - Added starttime filter to fleet alert query (routes.py:4151-4153) - Query now checks: db.or_( Notification.starttime.is_(None), Notification.starttime <= now ) - Notifications with future starttime excluded until that time Behavior After Fix: - Notification with no starttime: appears immediately (existing behavior) - Notification with past starttime: appears immediately (existing behavior) - Notification with future starttime: waits until that time (FIXED) - Notification with past endtime: never appears (existing behavior) Testing: - tests/test_plugins/test_endpoints_fleet_alert_scheduling.py - test_fleet_alert_respects_future_starttime (regression test) - test_fleet_alert_with_no_starttime_appears_immediately - test_fleet_alert_past_starttime_appears - test_fleet_alert_expired_does_not_appear Timeline Example: - 3:10 PM: User creates notification with starttime=3:30 PM, alertfleet=True - 3:15 PM: Agent polls /api/endpoints/alerts -> empty (starttime not reached) - 3:35 PM: Agent polls again -> alert appears (starttime has passed) Impact: - Agents poll every 60 seconds, so scheduled alerts appear within 1 minute of starttime - No database migration needed (starttime column already exists) - Backwards compatible (NULL starttime still means immediate) Related: - Notification.is_current property already checks starttime (models/notification.py:162-169) - Shopfloor board respects starttime correctly - Only fleet alert endpoint had this regression Co-Authored-By: Claude Sonnet 4.5 --- plugins/endpoints/api/routes.py | 3 + .../test_endpoints_fleet_alert_scheduling.py | 254 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/test_plugins/test_endpoints_fleet_alert_scheduling.py diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index d7dd772e..e4ded727 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -4148,6 +4148,9 @@ def get_fleet_alerts(): NotificationAcknowledgement.assetid == asset_id)) .filter(Notification.alertfleet.is_(True), Notification.isactive.is_(True), + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= now), db.or_( Notification.endtime.is_(None), Notification.endtime > now), diff --git a/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py b/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py new file mode 100644 index 00000000..b034332a --- /dev/null +++ b/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py @@ -0,0 +1,254 @@ +"""Test fleet alert scheduling respects starttime. + +Regression test for bug where fleet alerts with future starttime were sent +immediately instead of waiting for the scheduled time. +""" + +import pytest +from datetime import datetime, timedelta, timezone + + +def test_fleet_alert_respects_future_starttime(client, auth_headers, sample_asset): + """Fleet alerts with future starttime should not appear until that time.""" + from plugins.notifications.models import Notification, NotificationType + from plugins.endpoints.models import EndpointState + from shopdb.api import db + + # Create notification type + ntype = NotificationType( + typename='System Maintenance', + typecolor='#ffc107', + isactive=True + ) + db.session.add(ntype) + db.session.flush() + + # Create endpoint state for the asset + state = EndpointState( + assetid=sample_asset.assetid, + hostname='TEST-PC-01', + agentversion='1.0.0', + lastreportedat=datetime.utcnow() + ) + db.session.add(state) + db.session.flush() + + now = datetime.now(timezone.utc).replace(tzinfo=None) + future_time = now + timedelta(minutes=20) # 20 minutes from now + end_time = future_time + timedelta(hours=1) + + # Create fleet alert scheduled for 20 minutes in the future + notification = Notification( + notification='Scheduled maintenance at 3:30 PM', + notificationtypeid=ntype.notificationtypeid, + starttime=future_time, + endtime=end_time, + isactive=True, + alertfleet=True, + alertscope=None, # All endpoints + alerttimeout=0 + ) + db.session.add(notification) + db.session.commit() + + # Agent polls for fleet alerts NOW (before starttime) + response = client.get( + '/api/endpoints/alerts?hostname=TEST-PC-01', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + + # BUG FIX: Alert should NOT appear because starttime is in the future + alerts = data['data'] + assert len(alerts) == 0, 'Fleet alert with future starttime should not appear yet' + + # Simulate time passing - set starttime to the past + notification.starttime = now - timedelta(minutes=5) + db.session.commit() + + # Agent polls again (now starttime has passed) + response = client.get( + '/api/endpoints/alerts?hostname=TEST-PC-01', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + + # Now the alert SHOULD appear + alerts = data['data'] + assert len(alerts) == 1 + assert alerts[0]['notificationId'] == notification.notificationid + assert alerts[0]['title'] == 'Scheduled maintenance at 3:30 PM' + + +def test_fleet_alert_with_no_starttime_appears_immediately(client, auth_headers, sample_asset): + """Fleet alerts with no starttime should appear immediately.""" + from plugins.notifications.models import Notification, NotificationType + from plugins.endpoints.models import EndpointState + from shopdb.api import db + + # Create notification type + ntype = NotificationType( + typename='System Alert', + typecolor='#dc3545', + isactive=True + ) + db.session.add(ntype) + db.session.flush() + + # Create endpoint state + state = EndpointState( + assetid=sample_asset.assetid, + hostname='TEST-PC-02', + agentversion='1.0.0', + lastreportedat=datetime.utcnow() + ) + db.session.add(state) + db.session.flush() + + # Create fleet alert with no starttime (immediate) + notification = Notification( + notification='Immediate alert - urgent', + notificationtypeid=ntype.notificationtypeid, + starttime=None, # No starttime = immediate + endtime=None, + isactive=True, + alertfleet=True, + alertscope=None, + alerttimeout=0 + ) + db.session.add(notification) + db.session.commit() + + # Agent polls for fleet alerts + response = client.get( + '/api/endpoints/alerts?hostname=TEST-PC-02', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + + # Alert should appear immediately + alerts = data['data'] + assert len(alerts) == 1 + assert alerts[0]['notificationId'] == notification.notificationid + assert alerts[0]['title'] == 'Immediate alert - urgent' + + +def test_fleet_alert_past_starttime_appears(client, auth_headers, sample_asset): + """Fleet alerts with past starttime should appear.""" + from plugins.notifications.models import Notification, NotificationType + from plugins.endpoints.models import EndpointState + from shopdb.api import db + + ntype = NotificationType( + typename='Update', + typecolor='#17a2b8', + isactive=True + ) + db.session.add(ntype) + db.session.flush() + + state = EndpointState( + assetid=sample_asset.assetid, + hostname='TEST-PC-03', + agentversion='1.0.0', + lastreportedat=datetime.utcnow() + ) + db.session.add(state) + db.session.flush() + + now = datetime.now(timezone.utc).replace(tzinfo=None) + past_time = now - timedelta(hours=1) + + # Create fleet alert that started 1 hour ago + notification = Notification( + notification='Alert started 1 hour ago', + notificationtypeid=ntype.notificationtypeid, + starttime=past_time, + endtime=None, + isactive=True, + alertfleet=True, + alertscope=None, + alerttimeout=0 + ) + db.session.add(notification) + db.session.commit() + + # Agent polls for fleet alerts + response = client.get( + '/api/endpoints/alerts?hostname=TEST-PC-03', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + + # Alert should appear (starttime is in the past) + alerts = data['data'] + assert len(alerts) == 1 + assert alerts[0]['notificationId'] == notification.notificationid + + +def test_fleet_alert_expired_does_not_appear(client, auth_headers, sample_asset): + """Fleet alerts with past endtime should not appear.""" + from plugins.notifications.models import Notification, NotificationType + from plugins.endpoints.models import EndpointState + from shopdb.api import db + + ntype = NotificationType( + typename='Expired', + typecolor='#6c757d', + isactive=True + ) + db.session.add(ntype) + db.session.flush() + + state = EndpointState( + assetid=sample_asset.assetid, + hostname='TEST-PC-04', + agentversion='1.0.0', + lastreportedat=datetime.utcnow() + ) + db.session.add(state) + db.session.flush() + + now = datetime.now(timezone.utc).replace(tzinfo=None) + past_start = now - timedelta(hours=2) + past_end = now - timedelta(hours=1) + + # Create expired fleet alert + notification = Notification( + notification='This alert already ended', + notificationtypeid=ntype.notificationtypeid, + starttime=past_start, + endtime=past_end, # Ended 1 hour ago + isactive=True, + alertfleet=True, + alertscope=None, + alerttimeout=0 + ) + db.session.add(notification) + db.session.commit() + + # Agent polls for fleet alerts + response = client.get( + '/api/endpoints/alerts?hostname=TEST-PC-04', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + + # Alert should NOT appear (endtime is in the past) + alerts = data['data'] + assert len(alerts) == 0