Skip to content
Merged
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
34 changes: 26 additions & 8 deletions backend/secuscan/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,13 +624,31 @@ async def deliver_via_rule(
channel = str(rule.get("channel_type", "")).lower()
target = str(rule.get("target_url_or_email", ""))

if channel == NotificationChannelType.WEBHOOK.value:
ok, error = await send_webhook(target, payload)
elif channel == NotificationChannelType.EMAIL.value:
ok, error = await send_email(target, payload)
else:
ok, error = False, f"Unsupported channel type: {channel}"

config = get_delivery_configuration()
max_retries = config["max_retries"]
backoff = config["backoff_factor_seconds"]

attempt = 0

while True:
if channel == NotificationChannelType.WEBHOOK.value:
ok, error = await send_webhook(target, payload)
elif channel == NotificationChannelType.EMAIL.value:
ok, error = await send_email(target, payload)
else:
ok, error = False, f"Unsupported channel type: {channel}"

if ok:
break

if attempt >= max_retries:
break

attempt += 1

if backoff > 0:
await asyncio.sleep(backoff * attempt)
status = (
NotificationDeliveryStatus.SUCCESS if ok else NotificationDeliveryStatus.FAILED
)
Expand Down Expand Up @@ -958,7 +976,7 @@ async def process_slack_notification(db: Database, task_id: str) -> None:
(task_id,),
)
total_findings = len(findings)

severity_counts: Dict[str, int] = {}
for row in findings:
sev = str(row.get("severity") or "info").lower()
Expand All @@ -974,7 +992,7 @@ async def process_slack_notification(db: Database, task_id: str) -> None:

# Status-specific formatting
status_icon = "βœ…" if status == "COMPLETED" else "❌" if status == "FAILED" else "ℹ️"

blocks = [
{
"type": "header",
Expand Down
36 changes: 36 additions & 0 deletions testing/backend/unit/test_notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,42 @@ async def test_deliver_records_failure_on_webhook_error(test_db):
assert row["status"] == NotificationDeliveryStatus.FAILED.value
assert row["error_message"] == "connection refused"

@pytest.mark.asyncio
async def test_deliver_via_rule_retries_before_success(test_db):
_, finding_id = await _seed_finding(test_db)
rule_id = await _seed_rule(test_db)

finding = await test_db.fetchone(
"SELECT * FROM findings WHERE id = ?", (finding_id,)
)
rule = await test_db.fetchone(
"SELECT * FROM notification_rules WHERE id = ?", (rule_id,)
)

with (
patch(
"backend.secuscan.notification_service.get_delivery_configuration",
return_value={
"webhook_timeout_seconds": 10,
"webhook_connect_timeout_seconds": 3,
"max_retries": 2,
"backoff_factor_seconds": 0,
},
),
patch(
"backend.secuscan.notification_service.send_webhook",
new=AsyncMock(
side_effect=[
(False, "temporary error"),
(True, None),
]
),
) as mock_send,
):
result = await deliver_via_rule(test_db, rule, finding)

assert result.status == NotificationDeliveryStatus.SUCCESS
assert mock_send.await_count == 2

@pytest.mark.asyncio
async def test_email_placeholder_records_success(test_db):
Expand Down
Loading