diff --git a/hookwise/__init__.py b/hookwise/__init__.py index 39b2816..e360401 100644 --- a/hookwise/__init__.py +++ b/hookwise/__init__.py @@ -1,6 +1,5 @@ import logging import os -import secrets import uuid from typing import Any, cast @@ -18,10 +17,13 @@ def create_app() -> Flask: app = Flask(__name__, template_folder="../templates", static_folder="../static") secret_key = os.environ.get("SECRET_KEY") if not secret_key: - secret_key = secrets.token_hex(32) - _logger.critical( - "SECRET_KEY not set! Sessions will be invalidated on restart. Set SECRET_KEY in your environment." - ) + if os.environ.get("DEBUG_MODE", "false").lower() == "true": + secret_key = "dev-secret-key" + _logger.warning("SECRET_KEY not set, using default for development.") + else: + _logger.critical("SECRET_KEY must be set in production!") + raise RuntimeError("SECRET_KEY env var is required") + app.config["SECRET_KEY"] = secret_key app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( "DATABASE_URL", "postgresql://hookwise:hookwise_pass@postgres:5432/hookwise" diff --git a/hookwise/api.py b/hookwise/api.py index c9f3177..694785a 100644 --- a/hookwise/api.py +++ b/hookwise/api.py @@ -25,7 +25,7 @@ def _register() -> None: from .routes import main_bp # --- History & Logs --- - + @main_bp.route("/api/activity/history") @auth_required def get_activity_history() -> Any: @@ -41,7 +41,7 @@ def get_activity_history() -> Any: # This mimics the log_to_web calls in tasks.py message = log.error_message or "Processed" level = "info" - + if log.status == "failed": message = log.error_message or "Unknown error" level = "error" @@ -66,7 +66,7 @@ def get_activity_history() -> Any: message = f"Closed ticket (ID: {log.ticket_id})" level = "success" # Removed the dead 'skipped' action branch as it's handled by log.status - + payload_data = {"raw": log.payload} if log.payload and log.payload.startswith(("{", "[")): try: @@ -74,36 +74,32 @@ def get_activity_history() -> Any: except (json.JSONDecodeError, TypeError): pass - history.append({ - "timestamp": log.created_at.isoformat(), - "message": message, - "level": level, - "config_name": log.config.name if log.config else "System", - "payload": payload_data, - "ticket_id": log.ticket_id - }) + history.append( + { + "timestamp": log.created_at.isoformat(), + "message": message, + "level": level, + "config_name": log.config.name if log.config else "System", + "payload": payload_data, + "ticket_id": log.ticket_id, + } + ) return jsonify(history) @main_bp.route("/api/activity/trigger-timeout-check", methods=["POST"]) @auth_required def trigger_timeout_check() -> Any: from .tasks import check_webhook_timeouts + try: # Trigger the task in the background task = check_webhook_timeouts.delay() - return jsonify({ - "status": "success", - "message": "Manual timeout check triggered in background.", - "task_id": task.id - }) + return jsonify( + {"status": "success", "message": "Manual timeout check triggered in background.", "task_id": task.id} + ) except Exception as e: current_app.logger.error(f"Failed to enqueue timeout check: {e}") - return jsonify({ - "status": "error", - "message": "Failed to enqueue timeout check", - "details": str(e) - }), 503 - + return jsonify({"status": "error", "message": "Failed to enqueue timeout check", "details": str(e)}), 503 @main_bp.route("/history") @auth_required diff --git a/hookwise/client.py b/hookwise/client.py index 1b1c4a9..b40bac7 100644 --- a/hookwise/client.py +++ b/hookwise/client.py @@ -9,15 +9,19 @@ logger = logging.getLogger(__name__) + class ConnectWiseError(Exception): pass + class TicketNotFoundError(ConnectWiseError): pass + class TicketRequestError(ConnectWiseError): pass + class ConnectWiseClient: def __init__(self) -> None: self.base_url: str = os.getenv("CW_URL", "https://api-na.myconnectwise.net/v4_6_release/apis/3.0") diff --git a/hookwise/extensions.py b/hookwise/extensions.py index 6f26a41..236dc42 100644 --- a/hookwise/extensions.py +++ b/hookwise/extensions.py @@ -17,6 +17,7 @@ def build_redis_uri(password, host, port, db=0): return f"redis://:{quoted_password}@{host}:{port}/{db}" return f"redis://{host}:{port}/{db}" + _redis_password = os.environ.get("REDIS_PASSWORD") _redis_host = os.environ.get("REDIS_HOST", "localhost") _redis_port = os.environ.get("REDIS_PORT", 6379) diff --git a/hookwise/routes.py b/hookwise/routes.py index eff41b8..7ba10e1 100644 --- a/hookwise/routes.py +++ b/hookwise/routes.py @@ -116,13 +116,13 @@ def index() -> Any: if last_activity: if last_activity.tzinfo is None: last_activity = last_activity.replace(tzinfo=timezone.utc) - + # Calculation: Next stale is either last_seen + timeout OR last_alert + timeout # We show whichever is further in the future timeout_delta = timedelta(hours=config.timeout_hours or 24) - + next_alert_from_seen = last_activity + timeout_delta - + if config.last_stale_alert_at: last_alert = config.last_stale_alert_at if last_alert.tzinfo is None: diff --git a/hookwise/tasks.py b/hookwise/tasks.py index 66e71f3..ae931bc 100644 --- a/hookwise/tasks.py +++ b/hookwise/tasks.py @@ -278,10 +278,7 @@ def check_webhook_timeouts() -> None: if not config.timeout_ticket_id: # No ticket open yet (or was closed), create one - summary = ( - f"[TIMEOUT] Webhook Endpoint: {config.name} - " - f"No data for {config.timeout_hours}h" - ) + summary = f"[TIMEOUT] Webhook Endpoint: {config.name} - No data for {config.timeout_hours}h" description = ( f"The webhook endpoint '{config.name}' has not received any data for over " f"{config.timeout_hours} hours.\n" @@ -307,8 +304,7 @@ def check_webhook_timeouts() -> None: config.last_stale_alert_at = now updates += 1 logger.warning( - f"Created timeout ticket #{config.timeout_ticket_id} " - f"for endpoint '{config.name}'" + f"Created timeout ticket #{config.timeout_ticket_id} for endpoint '{config.name}'" ) log_msg = ( f"Timeout alert: Created ticket #{config.timeout_ticket_id} " @@ -319,22 +315,21 @@ def check_webhook_timeouts() -> None: "timeout_alert", config.id, f"Created timeout ticket #{config.timeout_ticket_id}", - commit=False + commit=False, ) req_id = f"timeout-{int(time.time())}" log_entry = WebhookLog( config_id=config.id, request_id=req_id, - payload=json.dumps({ - "alert": "stale_endpoint", - "timeout_hours": config.timeout_hours - }), + payload=json.dumps( + {"alert": "stale_endpoint", "timeout_hours": config.timeout_hours} + ), status="processed", action="create", ticket_id=config.timeout_ticket_id, source_ip="system", - error_message=f"Created timeout ticket #{config.timeout_ticket_id}" + error_message=f"Created timeout ticket #{config.timeout_ticket_id}", ) db.session.add(log_entry) db.session.commit() @@ -343,27 +338,26 @@ def check_webhook_timeouts() -> None: log_to_web( f"Timeout alert failure: Could not create ticket for {config.name}", "error", - config.name + config.name, ) log_audit( "timeout_error", config.id, "Failed to create timeout ticket in CW API", - commit=False + commit=False, ) req_id = f"timeout-err-{int(time.time())}" log_entry = WebhookLog( config_id=config.id, request_id=req_id, - payload=json.dumps({ - "alert": "stale_endpoint", - "timeout_hours": config.timeout_hours - }), + payload=json.dumps( + {"alert": "stale_endpoint", "timeout_hours": config.timeout_hours} + ), status="failed", action="create", source_ip="system", - error_message="Failed to create ticket in ConnectWise API." + error_message="Failed to create ticket in ConnectWise API.", ) db.session.add(log_entry) db.session.commit() @@ -387,29 +381,31 @@ def check_webhook_timeouts() -> None: log_to_web( f"Timeout alert repeated: Added note to ticket #{config.timeout_ticket_id}", "warning", - config.name + config.name, ) - + log_entry = WebhookLog( config_id=config.id, request_id=req_id, - payload=json.dumps({ - "alert": "stale_endpoint_repeat", - "timeout_hours": config.timeout_hours, - "timeout_ticket_id": config.timeout_ticket_id, - "last_stale_alert_at": ( - config.last_stale_alert_at.isoformat() - if config.last_stale_alert_at - else None - ), - "created_at": config.created_at.isoformat(), - "hours_stale": round(hours_since_activity, 2) - }), + payload=json.dumps( + { + "alert": "stale_endpoint_repeat", + "timeout_hours": config.timeout_hours, + "timeout_ticket_id": config.timeout_ticket_id, + "last_stale_alert_at": ( + config.last_stale_alert_at.isoformat() + if config.last_stale_alert_at + else None + ), + "created_at": config.created_at.isoformat(), + "hours_stale": round(hours_since_activity, 2), + } + ), status="processed", action="update", ticket_id=config.timeout_ticket_id, source_ip="system", - error_message=f"Added repeat alert note to ticket #{config.timeout_ticket_id}" + error_message=f"Added repeat alert note to ticket #{config.timeout_ticket_id}", ) db.session.add(log_entry) db.session.commit() @@ -421,12 +417,14 @@ def check_webhook_timeouts() -> None: log_entry = WebhookLog( config_id=config.id, request_id=req_id, - payload=json.dumps({ - "alert": "stale_endpoint_repeat_failed", - "timeout_hours": config.timeout_hours, - "timeout_ticket_id": config.timeout_ticket_id, - "hours_stale": round(hours_since_activity, 2) - }), + payload=json.dumps( + { + "alert": "stale_endpoint_repeat_failed", + "timeout_hours": config.timeout_hours, + "timeout_ticket_id": config.timeout_ticket_id, + "hours_stale": round(hours_since_activity, 2), + } + ), status="failed", action="update", ticket_id=config.timeout_ticket_id, @@ -434,7 +432,7 @@ def check_webhook_timeouts() -> None: error_message=( f"ConnectWise API failed to add repeat alert note to ticket " f"#{config.timeout_ticket_id}" - ) + ), ) db.session.add(log_entry) db.session.commit() @@ -555,21 +553,19 @@ def _resolve_timeout_alert(config: WebhookConfig) -> None: if config.timeout_ticket_id: ticket_id = config.timeout_ticket_id resolution = f"Webhook data received again for endpoint '{config.name}'. Automatically closing timeout alert." - + try: if cw_client.close_ticket(ticket_id, resolution, status_name=config.close_status): logger.info(f"Closed timeout ticket #{ticket_id} for endpoint '{config.name}'") log_to_web(f"Timeout alert resolved: Closed ticket #{ticket_id}", "success", config.name) - + import time from .models import WebhookLog from .utils import log_audit + log_audit( - "timeout_resolve", - config.id, - f"Automatically closed timeout ticket #{ticket_id}", - commit=False + "timeout_resolve", config.id, f"Automatically closed timeout ticket #{ticket_id}", commit=False ) log_entry = WebhookLog( config_id=config.id, @@ -578,14 +574,12 @@ def _resolve_timeout_alert(config: WebhookConfig) -> None: status="processed", action="close", ticket_id=ticket_id, - source_ip="system" + source_ip="system", ) db.session.add(log_entry) config.timeout_ticket_id = None else: - logger.warning( - f"Failed to close timeout ticket #{ticket_id} for endpoint '{config.name}'. " - ) + logger.warning(f"Failed to close timeout ticket #{ticket_id} for endpoint '{config.name}'. ") except TicketNotFoundError: logger.warning( f"Timeout ticket #{ticket_id} for endpoint '{config.name}' " diff --git a/migrations/versions/3f8d9c123abc_add_last_stale_alert_at_to_webhookconfig.py b/migrations/versions/3f8d9c123abc_add_last_stale_alert_at_to_webhookconfig.py index b903dbc..ec0b1f3 100644 --- a/migrations/versions/3f8d9c123abc_add_last_stale_alert_at_to_webhookconfig.py +++ b/migrations/versions/3f8d9c123abc_add_last_stale_alert_at_to_webhookconfig.py @@ -21,7 +21,7 @@ def upgrade(): conn = op.get_bind() inspector = sa.inspect(conn) columns = [c["name"] for c in inspector.get_columns("webhook_config")] - + if "last_stale_alert_at" not in columns: with op.batch_alter_table("webhook_config", schema=None) as batch_op: batch_op.add_column(sa.Column("last_stale_alert_at", sa.DateTime(), nullable=True)) @@ -33,7 +33,7 @@ def downgrade(): conn = op.get_bind() inspector = sa.inspect(conn) columns = [c["name"] for c in inspector.get_columns("webhook_config")] - + if "last_stale_alert_at" in columns: with op.batch_alter_table("webhook_config", schema=None) as batch_op: batch_op.drop_column("last_stale_alert_at") diff --git a/tests/test_secret_key_fix.py b/tests/test_secret_key_fix.py new file mode 100644 index 0000000..a2e62a1 --- /dev/null +++ b/tests/test_secret_key_fix.py @@ -0,0 +1,48 @@ +import os +import unittest +from unittest.mock import patch + +from hookwise import create_app + + +class TestSecretKeyFix(unittest.TestCase): + @patch.dict(os.environ, {}, clear=True) + def test_secret_key_missing_production_raises_error(self): + # Ensure SECRET_KEY and DEBUG_MODE are not in env + os.environ["DEBUG_MODE"] = "false" + if "SECRET_KEY" in os.environ: + del os.environ["SECRET_KEY"] + + # We need to set GUI_PASSWORD to avoid its RuntimeError + os.environ["GUI_PASSWORD"] = "testpass" + os.environ["DATABASE_URL"] = "sqlite:///:memory:" + + with self.assertRaises(RuntimeError) as cm: + create_app() + self.assertEqual(str(cm.exception), "SECRET_KEY env var is required") + + @patch.dict(os.environ, {}, clear=True) + def test_secret_key_missing_debug_uses_dev_key(self): + os.environ["DEBUG_MODE"] = "true" + if "SECRET_KEY" in os.environ: + del os.environ["SECRET_KEY"] + + # GUI_PASSWORD will also use default in debug mode, but setting it explicitly is safer + os.environ["GUI_PASSWORD"] = "testpass" + os.environ["DATABASE_URL"] = "sqlite:///:memory:" + + app = create_app() + self.assertEqual(app.config.get("SECRET_KEY"), "dev-secret-key") + + @patch.dict(os.environ, {}, clear=True) + def test_secret_key_provided_is_used(self): + os.environ["SECRET_KEY"] = "provided-secret" + os.environ["GUI_PASSWORD"] = "testpass" + os.environ["DATABASE_URL"] = "sqlite:///:memory:" + + app = create_app() + self.assertEqual(app.config.get("SECRET_KEY"), "provided-secret") + + +if __name__ == "__main__": + unittest.main()