diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py index 7507547..ae9a064 100644 --- a/backend/app/api/api_v1/endpoints/domains.py +++ b/backend/app/api/api_v1/endpoints/domains.py @@ -145,6 +145,20 @@ def _get_domain_selectors_from_db(db: Session, domain_name: str) -> List[str]: return [] +def _get_domain_selectors_map_from_db(db: Session, domain_names: List[str]) -> Dict[str, List[str]]: + """Return manually configured DKIM selectors for all requested domains.""" + if not domain_names: + return {} + + rows = db.query(Domain.name, Domain.dkim_selectors).filter(Domain.name.in_(domain_names)).all() + selectors_by_domain: Dict[str, List[str]] = {} + for name, selectors in rows: + selectors_by_domain[name] = [ + selector.strip() for selector in (selectors or "").split(",") if selector.strip() + ] + return selectors_by_domain + + @router.get("/summary", response_model=DomainSummaryResponse) async def get_domains_summary(db: Session = Depends(get_db)): """ @@ -161,9 +175,10 @@ async def get_domains_summary(db: Session = Depends(get_db)): # Perform DNS checks concurrently for all domains provider = get_default_provider() + manual_selectors_by_domain = _get_domain_selectors_map_from_db(db, domains) async def _dns_for_domain(domain_name: str) -> DomainDNSResult: - manual_selectors = _get_domain_selectors_from_db(db, domain_name) + manual_selectors = manual_selectors_by_domain.get(domain_name, []) report_selectors = _get_selectors_from_reports(store, domain_name) combined = list(dict.fromkeys(manual_selectors + report_selectors)) try: diff --git a/backend/app/api/api_v1/endpoints/setup.py b/backend/app/api/api_v1/endpoints/setup.py index ca2dca1..9ebc88d 100644 --- a/backend/app/api/api_v1/endpoints/setup.py +++ b/backend/app/api/api_v1/endpoints/setup.py @@ -1,6 +1,11 @@ -from fastapi import APIRouter, HTTPException, status +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, Security, status +from fastapi.security import HTTPAuthorizationCredentials from pydantic import BaseModel, EmailStr +from app.core.security import api_key_header, require_admin_auth, security_bearer + router = APIRouter() # Simple in-memory storage for setup status (for Milestone 1) @@ -33,6 +38,17 @@ class SystemConfigRequest(BaseModel): base_url: str +async def require_setup_write_auth( + request: Request, + api_key: Optional[str] = Security(api_key_header), + bearer: Optional[HTTPAuthorizationCredentials] = Security(security_bearer), +) -> dict: + """Allow unauthenticated first-time setup writes, then require admin auth.""" + if not setup_status["is_setup_complete"]: + return {"auth_type": "initial_setup"} + return await require_admin_auth(request=request, api_key=api_key, bearer=bearer) + + @router.get("/status", response_model=SetupStatusResponse) async def get_setup_status(): """Get the current setup status""" @@ -43,7 +59,10 @@ async def get_setup_status(): @router.post("/admin", status_code=201) -async def setup_admin(request: AdminSetupRequest): +async def setup_admin( + request: AdminSetupRequest, + _auth: dict = Depends(require_setup_write_auth), +): """ Setup admin user during initial system configuration. For Milestone 1, this simply stores the admin email in memory. @@ -60,7 +79,10 @@ async def setup_admin(request: AdminSetupRequest): @router.post("/system", status_code=200) -async def setup_system(request: SystemConfigRequest): +async def setup_system( + request: SystemConfigRequest, + _auth: dict = Depends(require_setup_write_auth), +): """ Setup system configuration. For Milestone 1, this simply stores the app name in memory. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f88c1a4..93ee9a9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -71,15 +71,14 @@ class Settings(BaseSettings): # LOGTO_APP_SECRET: the Client Secret of the same application. # LOGTO_REDIRECT_URI (optional): override the default callback URL. # Defaults to /api/v1/auth/callback. - # LOGTO_SKIP_SSL_VERIFY (optional): set to false to enable SSL certificate - # verification when connecting to the Logto OIDC endpoint. - # Defaults to true (verification disabled) to support - # self-signed certificates out of the box. + # LOGTO_SKIP_SSL_VERIFY (optional): set to true only when connecting to a + # self-hosted Logto endpoint with a self-signed certificate. + # Defaults to false so TLS certificates are verified. LOGTO_ENDPOINT: Optional[str] = None LOGTO_APP_ID: Optional[str] = None LOGTO_APP_SECRET: Optional[str] = None LOGTO_REDIRECT_URI: Optional[str] = None - LOGTO_SKIP_SSL_VERIFY: bool = True + LOGTO_SKIP_SSL_VERIFY: bool = False @property def logto_configured(self) -> bool: diff --git a/backend/app/main.py b/backend/app/main.py index ac3f06a..8fb7929 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,7 +20,6 @@ from app.core.security import add_api_key, generate_api_key, require_admin_auth from app.middleware.auth import AuthRedirectMiddleware from app.middleware.security import SecurityHeadersMiddleware from app.models.mail_source import MailSource # noqa: F401 – ensure table is registered -from app.models.user import User # noqa: F401 – ensure User mapper is registered from app.services.gmail_client import GmailClient from app.services.imap_client import IMAPClient from app.services.report_store import ReportStore @@ -136,7 +135,7 @@ def _poll_single_gmail_source(source: MailSource) -> None: ) -def _poll_all_enabled_sources() -> None: +def _poll_all_enabled_sources() -> list[MailSource]: """Iterate over all enabled mail sources and poll each one.""" db = SessionLocal() try: @@ -148,7 +147,7 @@ def _poll_all_enabled_sources() -> None: if not enabled_sources: logger.info("No enabled mail sources configured – polling skipped") - return + return enabled_sources for source in enabled_sources: if source.method == "GMAIL_API": @@ -167,19 +166,23 @@ def _poll_all_enabled_sources() -> None: source.id, source.method, ) + return enabled_sources -def _next_sleep_seconds(min_sleep: int = 60) -> int: +def _next_sleep_seconds( + min_sleep: int = 60, enabled_sources: list[MailSource] | None = None +) -> int: """Return how many seconds to sleep until the next polling cycle.""" try: - db = SessionLocal() - try: - intervals = [ - s.polling_interval or 60 - for s in db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712 - ] - finally: - db.close() + if enabled_sources is None: + db = SessionLocal() + try: + enabled_sources = ( + db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712 + ) + finally: + db.close() + intervals = [s.polling_interval or 60 for s in enabled_sources] return max(min_sleep, min(intervals, default=3600) * 60) except Exception: # pylint: disable=broad-exception-caught return 3600 @@ -191,11 +194,18 @@ async def scheduled_imap_polling(): while True: logger.info("Starting scheduled IMAP polling for DMARC reports") try: - _poll_all_enabled_sources() + enabled_sources = _poll_all_enabled_sources() except Exception as e: # pylint: disable=broad-exception-caught logger.error("Error in IMAP polling task: %s", str(e)) + enabled_sources = None - await asyncio.sleep(_next_sleep_seconds()) + try: + await asyncio.sleep(_next_sleep_seconds(enabled_sources=enabled_sources)) + except asyncio.CancelledError: + raise + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error sleeping in IMAP polling task: %s", str(e)) + await asyncio.sleep(3600) except asyncio.CancelledError: logger.info("IMAP polling task cancelled") diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py index 6bb9700..a1d2523 100644 --- a/backend/app/middleware/security.py +++ b/backend/app/middleware/security.py @@ -53,19 +53,14 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # Content Security Policy (CSP) # Restricts sources of content that can be loaded # - # SECURITY TODO: Current CSP includes 'unsafe-inline' and 'unsafe-eval' which - # weaken XSS protection. To remove these: + # SECURITY TODO: Current CSP includes 'unsafe-inline' which weakens + # XSS protection. To remove it: # # For script-src 'unsafe-inline': # 1. Move all inline "}) + + assert result["valid"] is False + assert "unsafe HTML" in result["errors"]["description"] + + def test_description_length_limit(self): + result = validate_domain_config({"name": "example.com", "description": "a" * 501}) + + assert result["valid"] is False + assert "too long" in result["errors"]["description"] diff --git a/backend/app/tests/test_main_polling.py b/backend/app/tests/test_main_polling.py new file mode 100644 index 0000000..04d1707 --- /dev/null +++ b/backend/app/tests/test_main_polling.py @@ -0,0 +1,60 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +class TestNextSleepSeconds: + def test_uses_shortest_enabled_source_interval_without_db_query(self): + from app.main import _next_sleep_seconds + + slow = SimpleNamespace(polling_interval=30) + fast = SimpleNamespace(polling_interval=5) + + with patch("app.main.SessionLocal") as mock_session: + result = _next_sleep_seconds(enabled_sources=[slow, fast]) + + assert result == 300 + mock_session.assert_not_called() + + def test_respects_min_sleep(self): + from app.main import _next_sleep_seconds + + source = SimpleNamespace(polling_interval=0) + + assert _next_sleep_seconds(min_sleep=120, enabled_sources=[source]) == 3600 + + def test_queries_database_when_sources_not_supplied(self): + from app.main import _next_sleep_seconds + + source = SimpleNamespace(polling_interval=2) + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [source] + + with patch("app.main.SessionLocal", return_value=mock_db): + result = _next_sleep_seconds() + + assert result == 120 + mock_db.close.assert_called_once() + + def test_database_exception_falls_back_to_one_hour(self): + from app.main import _next_sleep_seconds + + with patch("app.main.SessionLocal", side_effect=RuntimeError("db unavailable")): + assert _next_sleep_seconds() == 3600 + + +@pytest.mark.asyncio +async def test_scheduled_imap_polling_sleep_exception_falls_back_then_cancels(): + from app.main import scheduled_imap_polling + + with ( + patch("app.main._poll_all_enabled_sources", return_value=[]), + patch("app.main._next_sleep_seconds", return_value=60), + patch( + "app.main.asyncio.sleep", + side_effect=[RuntimeError("sleep failed"), asyncio.CancelledError()], + ), + ): + await scheduled_imap_polling() diff --git a/backend/app/tests/test_report_store.py b/backend/app/tests/test_report_store.py index 5dbace5..fb4de17 100644 --- a/backend/app/tests/test_report_store.py +++ b/backend/app/tests/test_report_store.py @@ -58,6 +58,39 @@ class TestReportStore: assert len(reports) == 1 assert reports[0]["report_id"] == "rpt-001" + def test_get_report_by_id_returns_matching_report(self): + store = ReportStore.get_instance() + report = _sample_report("test.com") + store.add_report(report) + + assert store.get_report_by_id("rpt-001") is report + + def test_get_report_by_id_returns_none_for_missing_report(self): + store = ReportStore.get_instance() + store.add_report(_sample_report("test.com")) + + assert store.get_report_by_id("rpt-missing") is None + + def test_get_domain_sources_returns_sources_sorted_by_count(self): + store = ReportStore.get_instance() + report = _sample_report("test.com") + report["records"].append( + { + "source_ip": "203.0.113.2", + "count": 12, + "disposition": "none", + "dkim_result": "fail", + "spf_result": "pass", + "header_from": "test.com", + } + ) + store.add_report(report) + + sources = store.get_domain_sources("test.com") + + assert [source["source_ip"] for source in sources] == ["203.0.113.2", "203.0.113.1"] + assert [source["count"] for source in sources] == [12, 5] + def test_clear(self): store = ReportStore.get_instance() store.add_report(_sample_report("test.com")) diff --git a/backend/app/tests/test_setup_endpoints.py b/backend/app/tests/test_setup_endpoints.py index e66f8b9..93c43a6 100644 --- a/backend/app/tests/test_setup_endpoints.py +++ b/backend/app/tests/test_setup_endpoints.py @@ -8,6 +8,7 @@ import pytest from fastapi.testclient import TestClient from app.api.api_v1.endpoints.setup import setup_status +from app.core.security import _api_keys, add_api_key @pytest.fixture(autouse=True) @@ -57,8 +58,7 @@ class TestSetupAdmin: assert response.status_code == 201 assert "message" in response.json() - def test_admin_setup_fails_if_already_complete(self, client: TestClient): - # Mark setup as complete + def test_admin_setup_requires_auth_if_already_complete(self, client: TestClient): setup_status["is_setup_complete"] = True response = client.post( @@ -69,6 +69,28 @@ class TestSetupAdmin: "password": "pass", }, ) + + assert response.status_code == 401 + + def test_admin_setup_fails_if_already_complete(self, client: TestClient): + # Mark setup as complete + setup_status["is_setup_complete"] = True + api_key = "a" * 64 + add_api_key(api_key) + + try: + response = client.post( + "/api/v1/setup/admin", + json={ + "email": "admin2@example.com", + "username": "admin2", + "password": "pass", + }, + headers={"X-API-Key": api_key}, + ) + finally: + _api_keys.discard(api_key) + assert response.status_code == 400 assert "already completed" in response.json()["detail"] @@ -104,3 +126,30 @@ class TestSetupSystem: json={"app_name": "Test", "base_url": "https://test.example.com"}, ) assert setup_status["is_setup_complete"] is True + + def test_system_setup_requires_auth_if_already_complete(self, client: TestClient): + setup_status["is_setup_complete"] = True + + response = client.post( + "/api/v1/setup/system", + json={"app_name": "Changed", "base_url": "https://changed.example.com"}, + ) + + assert response.status_code == 401 + + def test_system_setup_allows_authenticated_updates_after_complete(self, client: TestClient): + setup_status["is_setup_complete"] = True + api_key = "b" * 64 + add_api_key(api_key) + + try: + response = client.post( + "/api/v1/setup/system", + json={"app_name": "Changed", "base_url": "https://changed.example.com"}, + headers={"X-API-Key": api_key}, + ) + finally: + _api_keys.discard(api_key) + + assert response.status_code == 200 + assert setup_status["app_name"] == "Changed"