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 <noreply@anthropic.com>
This commit is contained in:
Proudlock
2026-09-14 15:21:51 -04:00
parent d11ba5c5d6
commit ca90969eb4
2 changed files with 257 additions and 0 deletions

View File

@@ -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),

View File

@@ -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