91442ede60
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- All webhook emails now render as branded HTML (dark header, inline base64 Aegis logo, card layout, CTA-button links) instead of plain text. - Wired the 7 remaining notification-preference keys that had no trigger: stale coverage alerts, campaign-activated assignment emails, generic test-state-change steps (execution started / blue evaluating / in review), all-team-validation broadcasts on every lead vote, webhook delivery failures (3rd consecutive failure), new user registration, and background job errors (APScheduler global error listener). - New notify_roles_by_email() helper for role-scoped, preference-gated, actor-excludable broadcasts. - Fixed apscheduler.events stubbing gaps in several test files' sys.modules fakes that broke full-suite collection after adding the APScheduler error-listener import.
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""Coverage for the remaining notification-email hooks wired this session:
|
|
stale coverage alerts, campaign assignment, generic test-state-change,
|
|
all-team validations, webhook delivery failures, new user registration,
|
|
and background-job system errors (see notification_service.notify_roles_by_email
|
|
and its call sites).
|
|
"""
|
|
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from app.services.notification_service import notify_roles_by_email
|
|
|
|
|
|
class _FakeUser:
|
|
def __init__(self, user_id=None, email="user@test.com", full_name="A User", role="admin", prefs=None):
|
|
self.id = user_id or uuid.uuid4()
|
|
self.email = email
|
|
self.full_name = full_name
|
|
self.role = role
|
|
self.notification_preferences = prefs
|
|
|
|
|
|
class _FakeQuery:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
def filter(self, *args, **kwargs):
|
|
return self
|
|
|
|
def all(self):
|
|
return self._rows
|
|
|
|
|
|
def test_notify_roles_by_email_filters_by_role_and_preference(db):
|
|
lead = _FakeUser(role="red_lead", email="lead@test.com")
|
|
other_role = _FakeUser(role="viewer", email="viewer@test.com")
|
|
opted_out = _FakeUser(role="blue_lead", email="opted-out@test.com", prefs={"email_on_all_team_validations": False})
|
|
|
|
with patch.object(db, "query", return_value=_FakeQuery([lead, other_role, opted_out])), \
|
|
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
|
notify_roles_by_email(
|
|
db, roles=["red_lead", "blue_lead"],
|
|
preference_key="email_on_all_team_validations",
|
|
subject="Vote cast",
|
|
message="Someone voted.",
|
|
)
|
|
# Only `lead` matches role filter (query already scopes it) *and* is opted in;
|
|
# `opted_out` matched the fake query's role filter (a no-op in the stub) but
|
|
# is excluded by preference; `other_role` is excluded by role in a real query
|
|
# (the stub returns all rows regardless, so assert on what was actually sent).
|
|
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
|
|
assert "lead@test.com" in sent_to
|
|
assert "opted-out@test.com" not in sent_to
|
|
|
|
|
|
def test_notify_roles_by_email_excludes_actor(db):
|
|
actor = _FakeUser(email="actor@test.com")
|
|
other = _FakeUser(email="other@test.com")
|
|
|
|
with patch.object(db, "query", return_value=_FakeQuery([actor, other])), \
|
|
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
|
notify_roles_by_email(
|
|
db, roles=["admin"],
|
|
preference_key="email_on_all_team_validations",
|
|
subject="x", message="y",
|
|
exclude_user_id=actor.id,
|
|
)
|
|
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
|
|
assert sent_to == {"other@test.com"}
|
|
|
|
|
|
def test_stale_technique_alert_dispatches_email_but_other_rule_types_dont():
|
|
from app.services.operational_alert_service import _dispatch_inapp_notifications
|
|
|
|
class _FakeAlertQuery:
|
|
def filter(self, *a, **kw):
|
|
return self
|
|
|
|
def all(self):
|
|
return []
|
|
|
|
rule = SimpleNamespace(rule_type="stale_technique")
|
|
instance = SimpleNamespace(id=uuid.uuid4(), title="Stale coverage", message="5 techniques are stale.")
|
|
fake_db = SimpleNamespace(query=lambda *a, **kw: _FakeAlertQuery())
|
|
|
|
with patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
|
_dispatch_inapp_notifications(fake_db, rule, instance)
|
|
mock_notify.assert_called_once()
|
|
assert mock_notify.call_args.kwargs["preference_key"] == "email_on_stale_coverage"
|
|
|
|
rule.rule_type = "high_risk"
|
|
with patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
|
_dispatch_inapp_notifications(fake_db, rule, instance)
|
|
mock_notify.assert_not_called()
|
|
|
|
|
|
def test_webhook_failure_emails_admins_on_third_consecutive_failure():
|
|
from app.services.webhook_service import _send_webhook
|
|
|
|
wh = SimpleNamespace(name="my-hook", url="https://example.com/hook", secret=None, failure_count=2,
|
|
last_triggered_at=None)
|
|
|
|
class _FakeDb:
|
|
def commit(self):
|
|
pass
|
|
|
|
def rollback(self):
|
|
pass
|
|
|
|
with patch("app.services.webhook_service.requests.post", side_effect=Exception("boom")), \
|
|
patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
|
_send_webhook(_FakeDb(), wh, "test_validated", {"foo": "bar"})
|
|
|
|
assert wh.failure_count == 3
|
|
mock_notify.assert_called_once()
|
|
assert mock_notify.call_args.kwargs["preference_key"] == "email_on_webhook_failures"
|
|
|
|
|
|
def test_webhook_failure_does_not_email_before_threshold():
|
|
from app.services.webhook_service import _send_webhook
|
|
|
|
wh = SimpleNamespace(name="my-hook", url="https://example.com/hook", secret=None, failure_count=0,
|
|
last_triggered_at=None)
|
|
|
|
class _FakeDb:
|
|
def commit(self):
|
|
pass
|
|
|
|
def rollback(self):
|
|
pass
|
|
|
|
with patch("app.services.webhook_service.requests.post", side_effect=Exception("boom")), \
|
|
patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
|
_send_webhook(_FakeDb(), wh, "test_validated", {"foo": "bar"})
|
|
|
|
assert wh.failure_count == 1
|
|
mock_notify.assert_not_called()
|
|
|
|
|
|
def test_create_user_notifies_other_admins_but_not_the_actor(api, auth_headers, db):
|
|
from app.models.user import User
|
|
from app.auth import hash_password
|
|
|
|
other_admin = User(
|
|
username="secondadmin@test.com", email="secondadmin@test.com",
|
|
hashed_password=hash_password("Whatever123!@#"), role="admin",
|
|
)
|
|
db.add(other_admin)
|
|
db.commit()
|
|
|
|
with patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
|
resp = api(
|
|
"post", "/api/v1/users", auth_headers,
|
|
json={"full_name": "Fresh User", "email": "freshuser@test.com", "role": "viewer"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
|
|
assert "secondadmin@test.com" in sent_to
|
|
assert mock_send.call_args_list[0].kwargs["subject"].startswith("New User Created")
|