From 91442ede60906952a50062fe1b801795b708410f Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 20 Jul 2026 11:49:21 +0200 Subject: [PATCH] feat(email): HTML branded templates with inline logo, wire remaining notification types - 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. --- backend/app/assets/email_logo.png | Bin 0 -> 22579 bytes backend/app/jobs/mitre_sync_job.py | 23 +++ backend/app/routers/campaigns.py | 14 +- backend/app/routers/users.py | 12 ++ backend/app/services/notification_service.py | 42 +++++ .../app/services/operational_alert_service.py | 10 +- backend/app/services/test_workflow_service.py | 15 ++ backend/app/services/webhook_email_service.py | 104 +++++++++-- backend/app/services/webhook_service.py | 13 ++ backend/tests/test_integration_v2.py | 6 + backend/tests/test_metrics_v2.py | 2 + .../tests/test_notification_email_coverage.py | 161 ++++++++++++++++++ backend/tests/test_password_setup_flow.py | 2 +- backend/tests/test_t107_status_service.py | 2 + backend/tests/test_t108_atomic_import.py | 2 + backend/tests/test_t109_tests_router.py | 3 + backend/tests/test_t110_evidence_router.py | 2 + .../tests/test_t111_test_templates_router.py | 2 + backend/tests/test_t112_system_import.py | 2 + backend/tests/test_templates_crud.py | 2 + backend/tests/test_workflow.py | 2 + 21 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 backend/app/assets/email_logo.png create mode 100644 backend/tests/test_notification_email_coverage.py diff --git a/backend/app/assets/email_logo.png b/backend/app/assets/email_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..952948587caefd40c340f7a4a9b2f61574836457 GIT binary patch literal 22579 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 None: + """Email admins (opted-in) whenever a scheduled background job raises.""" + db = SessionLocal() + try: + from app.services.notification_service import notify_roles_by_email + notify_roles_by_email( + db, roles=["admin"], + preference_key="email_on_system_errors", + subject=f"Aegis Background Job Failed: {event.job_id}", + message=( + f'The scheduled job "{event.job_id}" raised an exception and did ' + f"not complete:\n\n{event.exception}" + ), + ) + db.commit() + except Exception: + logger.exception("Failed to dispatch system-error notification") + finally: + db.close() + + # --------------------------------------------------------------------------- # Job functions # --------------------------------------------------------------------------- @@ -440,6 +462,7 @@ def start_scheduler() -> None: Neither job fires immediately on startup. """ + scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR) # Call scheduler.add_job() scheduler.add_job( _run_mitre_sync, diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index d74dca0..c8cb7c9 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -124,7 +124,7 @@ from app.services.campaign_crud_service import ( from app.services.audit_service import log_action # Import notify_role from app.services.notification_service -from app.services.notification_service import notify_role +from app.services.notification_service import notify_role, notify_roles_by_email from app.services.webhook_service import dispatch_webhook # Assign logger = logging.getLogger(__name__) @@ -543,6 +543,12 @@ def approve_campaign_endpoint( entity_id=campaign.id, details={"start_date": payload.start_date}, ) + notify_roles_by_email( + db, roles=["red_tech"], + preference_key="email_on_assigned_to_campaign", + subject=f"Campaign Activated: {campaign.name}", + message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.', + ) uow.commit() db.refresh(campaign) @@ -868,6 +874,12 @@ def activate_campaign( # Keyword argument: entity_id entity_id=campaign.id, ) + notify_roles_by_email( + db, roles=["red_tech"], + preference_key="email_on_assigned_to_campaign", + subject=f"Campaign Activated: {campaign.name}", + message=f'Campaign "{campaign.name}" has been activated. You may have tests assigned.', + ) # Call log_action() log_action( db, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 655e046..fffe67a 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -185,6 +185,18 @@ def create_user_route( # Reload ORM object attributes from the database db.refresh(user) + from app.services.notification_service import notify_roles_by_email + notify_roles_by_email( + db, roles=["admin"], + preference_key="email_on_new_users", + subject=f"New User Created: {user.full_name or user.email}", + message=( + f'A new user account was created: {user.full_name or user.email} ' + f"({user.email}), role: {user.role}." + ), + exclude_user_id=current_user.id, + ) + # Return user return user diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index c637bbd..5b07e7e 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -291,6 +291,30 @@ def notify_all_users_by_email(db: Session, *, preference_key: str, subject: str, pass # nosec B110 — one user's failure must not skip the rest +def notify_roles_by_email( + db: Session, *, roles: list[str], preference_key: str, subject: str, message: str, + exclude_user_id=None, +) -> None: + """Email every active, opted-in user holding any of *roles*. + + For workflow-wide callouts to a team (e.g. every lead vote) rather than + a single actor's own action — no in-app notification is created here, + only the email; pair with ``create_notification``/``notify_role_with_email`` + if an in-app entry is also needed. + """ + users = db.query(User).filter(User.role.in_(roles), User.is_active == True).all() # noqa: E712 + for user in users: + if exclude_user_id and user.id == exclude_user_id: + continue + if not _preference_allows(user, preference_key): + continue + try: + from app.services.webhook_email_service import send_webhook_email + send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name) + except Exception: + pass # nosec B110 — one user's failure must not skip the rest + + def notify_role_with_email( db: Session, *, @@ -362,6 +386,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None: # Keyword argument: entity_id entity_id=test_id, ) + notify_user_by_email( + db, creator_id, + preference_key="email_on_test_state_change", + subject=f"Test Execution Started: {test_name}", + message=f'Your test "{test_name}" has moved to the execution phase.', + ) # Alternative: new_state == "blue_evaluating" elif new_state == "blue_evaluating": @@ -385,6 +415,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None: # Keyword argument: entity_id entity_id=test_id, ) + notify_user_by_email( + db, user.id, + preference_key="email_on_test_state_change", + subject=f"Test Ready for Blue Evaluation: {test_name}", + message=f'Test "{test_name}" needs blue team evaluation.', + ) # Alternative: new_state == "in_review" elif new_state == "in_review": @@ -414,6 +450,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None: # Keyword argument: entity_id entity_id=test_id, ) + notify_user_by_email( + db, user.id, + preference_key="email_on_test_state_change", + subject=f"Test Ready for Validation: {test_name}", + message=f'Test "{test_name}" is awaiting your review.', + ) # Alternative: new_state == "rejected" and creator_id elif new_state == "rejected" and creator_id: diff --git a/backend/app/services/operational_alert_service.py b/backend/app/services/operational_alert_service.py index 69c16cf..d3c7069 100644 --- a/backend/app/services/operational_alert_service.py +++ b/backend/app/services/operational_alert_service.py @@ -29,7 +29,7 @@ log = logging.getLogger(__name__) def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertInstance) -> None: """Create in-app Notification rows for all admins and leads.""" - from app.services.notification_service import create_notification + from app.services.notification_service import create_notification, notify_roles_by_email admin_roles = {"admin", "red_lead", "blue_lead"} users = db.query(User).filter( @@ -47,6 +47,14 @@ def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertI entity_id = instance.id, ) + if rule.rule_type == AlertRuleType.stale_technique.value: + notify_roles_by_email( + db, roles=list(admin_roles), + preference_key="email_on_stale_coverage", + subject=instance.title, + message=instance.message, + ) + def _dispatch_webhooks(rule: AlertRule, instance: AlertInstance) -> None: """Fire webhook(s) for a triggered alert (all exceptions caught).""" diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index 0615bac..f8e78ef 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -39,6 +39,7 @@ from app.services.notification_service import ( notify_test_state_change, create_notification, notify_role_with_email, + notify_roles_by_email, ) # Assign logger = logging.getLogger(__name__) @@ -1048,6 +1049,13 @@ def validate_as_red_lead( }, ) + notify_roles_by_email( + db, roles=["red_lead", "blue_lead"], + preference_key="email_on_all_team_validations", + subject=f"Red Lead Vote: {test.name}", + message=f'{user.full_name or user.username} cast a "{validation_status}" vote as Red Lead on test "{test.name}".', + exclude_user_id=user.id, + ) _dispatch_dual_validation_effects(db, test, entity, actor=user) return test @@ -1113,6 +1121,13 @@ def validate_as_blue_lead( }, ) + notify_roles_by_email( + db, roles=["red_lead", "blue_lead"], + preference_key="email_on_all_team_validations", + subject=f"Blue Lead Vote: {test.name}", + message=f'{user.full_name or user.username} cast a "{validation_status}" vote as Blue Lead on test "{test.name}".', + exclude_user_id=user.id, + ) _dispatch_dual_validation_effects(db, test, entity, actor=user) return test diff --git a/backend/app/services/webhook_email_service.py b/backend/app/services/webhook_email_service.py index 81d1086..461e118 100644 --- a/backend/app/services/webhook_email_service.py +++ b/backend/app/services/webhook_email_service.py @@ -5,8 +5,9 @@ platform (password setup/reset, test validated, campaign completed, new MITRE techniques synced, etc.) goes through ``send_webhook_email`` here, which POSTs a ``{to, subject, body}`` JSON payload to a single configured webhook URL — intended for a Power Automate flow that actually delivers -the email. An optional API key, if configured, is sent as an -``x-api-key`` header. +the email (the ``body`` is full HTML; the flow's "send email" step must +have its "Is HTML" option enabled). An optional API key, if configured, +is sent as an ``x-api-key`` header. SMTP (``app.services.email_service``) is intentionally left in place but unused and unreachable from the UI — see that module's docstring. @@ -14,7 +15,11 @@ unused and unreachable from the UI — see that module's docstring. from __future__ import annotations +import base64 +import html as html_lib import logging +import re +from pathlib import Path import requests from sqlalchemy.orm import Session @@ -24,22 +29,91 @@ logger = logging.getLogger(__name__) _WEBHOOK_URL_CONFIG_KEY = "email_webhook.url" _WEBHOOK_API_KEY_CONFIG_KEY = "email_webhook.api_key" -# Standard greeting + footer wrapped around every notification email's -# actual message, matching the platform's established template. -_EMAIL_SIGNATURE = ( - "\n\nRegards,\n" - "AEGIS Security Platform\n" - "Purple Team Engineering\n" - "Owned and operated by Enterprise Corp.\n\n" - "Assume breach. Validate controls. Improve continuously.\n\n" - "This is an automated notification. Please do not reply." -) +_LOGO_PATH = Path(__file__).resolve().parent.parent / "assets" / "email_logo.png" +_URL_RE = re.compile(r"^https?://\S+$") + +_logo_b64_cache: str | None = None + + +def _get_logo_base64() -> str: + """Read + cache the inline email logo as a base64 string (empty if missing).""" + global _logo_b64_cache + if _logo_b64_cache is None: + try: + _logo_b64_cache = base64.b64encode(_LOGO_PATH.read_bytes()).decode("ascii") + except OSError: + logger.warning("Email logo asset missing at %s", _LOGO_PATH) + _logo_b64_cache = "" + return _logo_b64_cache + + +def _message_to_html(message: str) -> str: + """Turn a plain-text message (paragraphs separated by blank lines) into + escaped HTML, rendering any lone-URL paragraph as a call-to-action button. + """ + parts: list[str] = [] + for para in message.strip().split("\n\n"): + para = para.strip() + if not para: + continue + if _URL_RE.match(para): + url = html_lib.escape(para, quote=True) + parts.append( + f'

' + f'Open Link →' + f'

' + ) + else: + escaped = html_lib.escape(para).replace("\n", "
") + parts.append(f'

{escaped}

') + return "".join(parts) def build_email_body(full_name: str | None, message: str) -> str: - """Wrap *message* in the standard greeting + signature template.""" - greeting = full_name or "there" - return f"HI {greeting}\n\n{message}{_EMAIL_SIGNATURE}" + """Wrap *message* in Aegis's branded HTML email template (inline logo).""" + greeting = html_lib.escape(full_name) if full_name else "there" + message_html = _message_to_html(message) + logo_b64 = _get_logo_base64() + logo_tag = ( + f'Aegis' + if logo_b64 + else "" + ) + return f""" + + + + + + +
+ + + + + + + + + + +
+ {logo_tag} +
AEGIS SECURITY PLATFORM
+
Purple Team Engineering
+
+

Hi {greeting},

+
{message_html}
+
+

Assume breach. Validate controls. Improve continuously.

+

Owned and operated by Enterprise Corp. This is an automated notification — please do not reply.

+
+
+ +""" def _read_system_config(db: Session, key: str) -> str | None: diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py index bf2afeb..84266ee 100644 --- a/backend/app/services/webhook_service.py +++ b/backend/app/services/webhook_service.py @@ -184,6 +184,19 @@ def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None "Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)", wh.name, wh.url, event_type, exc, wh.failure_count, ) + if wh.failure_count == 3: + # Email once on the 3rd consecutive failure — signals "this looks + # broken", without alerting on every transient single failure. + from app.services.notification_service import notify_roles_by_email + notify_roles_by_email( + db, roles=["admin"], + preference_key="email_on_webhook_failures", + subject=f"Webhook Delivery Failing: {wh.name}", + message=( + f'The webhook "{wh.name}" ({wh.url}) has failed {wh.failure_count} times ' + f'in a row for event "{event_type}". Last error: {exc}' + ), + ) # --------------------------------------------------------------------------- diff --git a/backend/tests/test_integration_v2.py b/backend/tests/test_integration_v2.py index 4c47b4c..fec34aa 100644 --- a/backend/tests/test_integration_v2.py +++ b/backend/tests/test_integration_v2.py @@ -97,11 +97,17 @@ if "apscheduler.schedulers.background" not in sys.modules: _apsched = ModuleType("apscheduler.schedulers.background") class _FakeBGScheduler: def add_job(self, *a, **kw): pass + def add_listener(self, *a, **kw): pass def start(self): pass def shutdown(self, **kw): pass _apsched.BackgroundScheduler = _FakeBGScheduler sys.modules["apscheduler.schedulers.background"] = _apsched +if "apscheduler.events" not in sys.modules: + _apsched_events = ModuleType("apscheduler.events") + _apsched_events.EVENT_JOB_ERROR = 1 + sys.modules["apscheduler.events"] = _apsched_events + if "taxii2client" not in sys.modules: sys.modules["taxii2client"] = ModuleType("taxii2client") if "taxii2client.v20" not in sys.modules: diff --git a/backend/tests/test_metrics_v2.py b/backend/tests/test_metrics_v2.py index 7a151a5..843d18d 100644 --- a/backend/tests/test_metrics_v2.py +++ b/backend/tests/test_metrics_v2.py @@ -76,6 +76,7 @@ for _mod in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if _mod not in sys.modules: m = ModuleType(_mod) @@ -85,6 +86,7 @@ for _mod in [ elif _mod == "botocore.exceptions": m.ClientError = Exception elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif _mod == "apscheduler.events": m.EVENT_JOB_ERROR = 1 sys.modules[_mod] = m # --------------------------------------------------------------------------- diff --git a/backend/tests/test_notification_email_coverage.py b/backend/tests/test_notification_email_coverage.py new file mode 100644 index 0000000..8b87ad3 --- /dev/null +++ b/backend/tests/test_notification_email_coverage.py @@ -0,0 +1,161 @@ +"""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") diff --git a/backend/tests/test_password_setup_flow.py b/backend/tests/test_password_setup_flow.py index cae4b7b..dcb51d1 100644 --- a/backend/tests/test_password_setup_flow.py +++ b/backend/tests/test_password_setup_flow.py @@ -74,7 +74,7 @@ def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_hea payload = call_kwargs.kwargs["json"] assert payload["to"] == "setpw@test.com" assert payload["subject"] == "Set Your Password" - assert "HI Set Password User" in payload["body"] + assert "Hi Set Password User" in payload["body"] assert "token=" in payload["body"] assert "Purple Team Engineering" in payload["body"] diff --git a/backend/tests/test_t107_status_service.py b/backend/tests/test_t107_status_service.py index 0297d4c..ba2b446 100644 --- a/backend/tests/test_t107_status_service.py +++ b/backend/tests/test_t107_status_service.py @@ -103,6 +103,8 @@ if "apscheduler" not in sys.modules: sys.modules["apscheduler.triggers"] = ModuleType("apscheduler.triggers") sys.modules["apscheduler.triggers.cron"] = ModuleType("apscheduler.triggers.cron") sys.modules["apscheduler.triggers.cron"].CronTrigger = MagicMock + sys.modules["apscheduler.events"] = ModuleType("apscheduler.events") + sys.modules["apscheduler.events"].EVENT_JOB_ERROR = 1 # --------------------------------------------------------------------------- # Imports diff --git a/backend/tests/test_t108_atomic_import.py b/backend/tests/test_t108_atomic_import.py index 214224d..36c351d 100644 --- a/backend/tests/test_t108_atomic_import.py +++ b/backend/tests/test_t108_atomic_import.py @@ -100,6 +100,8 @@ if "apscheduler" not in sys.modules: sys.modules["apscheduler.triggers"] = ModuleType("apscheduler.triggers") sys.modules["apscheduler.triggers.cron"] = ModuleType("apscheduler.triggers.cron") sys.modules["apscheduler.triggers.cron"].CronTrigger = MagicMock + sys.modules["apscheduler.events"] = ModuleType("apscheduler.events") + sys.modules["apscheduler.events"].EVENT_JOB_ERROR = 1 # --------------------------------------------------------------------------- # Imports diff --git a/backend/tests/test_t109_tests_router.py b/backend/tests/test_t109_tests_router.py index 45de66e..2b660e4 100644 --- a/backend/tests/test_t109_tests_router.py +++ b/backend/tests/test_t109_tests_router.py @@ -76,6 +76,7 @@ for mod_name in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if mod_name not in sys.modules: m = ModuleType(mod_name) @@ -92,6 +93,8 @@ for mod_name in [ m.BackgroundScheduler = MagicMock elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif mod_name == "apscheduler.events": + m.EVENT_JOB_ERROR = 1 sys.modules[mod_name] = m # --------------------------------------------------------------------------- diff --git a/backend/tests/test_t110_evidence_router.py b/backend/tests/test_t110_evidence_router.py index fee04cd..b716a91 100644 --- a/backend/tests/test_t110_evidence_router.py +++ b/backend/tests/test_t110_evidence_router.py @@ -74,6 +74,7 @@ for mod_name in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if mod_name not in sys.modules: m = ModuleType(mod_name) @@ -83,6 +84,7 @@ for mod_name in [ elif mod_name == "botocore.exceptions": m.ClientError = Exception elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1 sys.modules[mod_name] = m # --------------------------------------------------------------------------- diff --git a/backend/tests/test_t111_test_templates_router.py b/backend/tests/test_t111_test_templates_router.py index fb42c81..e04bb08 100644 --- a/backend/tests/test_t111_test_templates_router.py +++ b/backend/tests/test_t111_test_templates_router.py @@ -74,6 +74,7 @@ for mod_name in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if mod_name not in sys.modules: m = ModuleType(mod_name) @@ -83,6 +84,7 @@ for mod_name in [ elif mod_name == "botocore.exceptions": m.ClientError = Exception elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1 sys.modules[mod_name] = m # --------------------------------------------------------------------------- diff --git a/backend/tests/test_t112_system_import.py b/backend/tests/test_t112_system_import.py index 20a41d1..2e09500 100644 --- a/backend/tests/test_t112_system_import.py +++ b/backend/tests/test_t112_system_import.py @@ -78,6 +78,7 @@ for mod_name in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if mod_name not in sys.modules: m = ModuleType(mod_name) @@ -87,6 +88,7 @@ for mod_name in [ elif mod_name == "botocore.exceptions": m.ClientError = Exception elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1 sys.modules[mod_name] = m # --------------------------------------------------------------------------- diff --git a/backend/tests/test_templates_crud.py b/backend/tests/test_templates_crud.py index 2653d10..5b46a8d 100644 --- a/backend/tests/test_templates_crud.py +++ b/backend/tests/test_templates_crud.py @@ -77,6 +77,7 @@ for _mod in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if _mod not in sys.modules: m = ModuleType(_mod) @@ -86,6 +87,7 @@ for _mod in [ elif _mod == "botocore.exceptions": m.ClientError = Exception elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif _mod == "apscheduler.events": m.EVENT_JOB_ERROR = 1 sys.modules[_mod] = m # --------------------------------------------------------------------------- diff --git a/backend/tests/test_workflow.py b/backend/tests/test_workflow.py index 558031f..a755274 100644 --- a/backend/tests/test_workflow.py +++ b/backend/tests/test_workflow.py @@ -83,6 +83,7 @@ for _mod in [ "apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", "apscheduler.triggers", "apscheduler.triggers.cron", + "apscheduler.events", ]: if _mod not in sys.modules: m = ModuleType(_mod) @@ -92,6 +93,7 @@ for _mod in [ elif _mod == "botocore.exceptions": m.ClientError = Exception elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock + elif _mod == "apscheduler.events": m.EVENT_JOB_ERROR = 1 sys.modules[_mod] = m # ---------------------------------------------------------------------------