From 6eb99da749ce2b61ea674e96f6b37e35f2565461 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:08:04 +0000 Subject: [PATCH 1/5] Initial plan From 50aa5bd5daf7bc76a3b61479f6c4c1d4cc4912b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:21:35 +0000 Subject: [PATCH 2/5] Fix pylint warnings: logging, globals, exceptions, imports, duplicates Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/576427d4-4f6a-46d2-b75f-6862ecbcf526 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- backend/app/api/api_v1/endpoints/domains.py | 8 ++- backend/app/api/api_v1/endpoints/reports.py | 24 ++++---- backend/app/core/config.py | 13 ++-- backend/app/core/security.py | 21 ++++--- backend/app/main.py | 66 +++++++++++---------- backend/app/middleware/security.py | 12 ++-- backend/app/services/dmarc_parser.py | 20 ++++--- backend/app/services/imap_client.py | 36 +++++------ backend/app/services/report_store.py | 4 +- backend/app/tests/conftest.py | 11 ++-- backend/app/tests/test_data.py | 49 +++++++++++++++ backend/app/tests/test_dmarc_parser.py | 49 +-------------- backend/app/tests/test_reports_api.py | 49 +-------------- backend/app/tests/test_security.py | 5 +- backend/app/utils/domain_validator.py | 5 +- backend/app/utils/stats_summarizer.py | 23 +++---- 16 files changed, 182 insertions(+), 213 deletions(-) create mode 100644 backend/app/tests/test_data.py diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py index 451019a..d897871 100644 --- a/backend/app/api/api_v1/endpoints/domains.py +++ b/backend/app/api/api_v1/endpoints/domains.py @@ -248,9 +248,13 @@ async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID # For Milestone 1, return mock DNS record data # In a future milestone, this will be replaced with actual DNS lookups + mock_dmarc_record = ( + "v=DMARC1; p=none; rua=mailto:dmarc@example.com;" + " ruf=mailto:forensic@example.com; pct=100" + ) return DNSRecordResponse( dmarc=True, - dmarcRecord="v=DMARC1; p=none; rua=mailto:dmarc@example.com; ruf=mailto:forensic@example.com; pct=100", + dmarcRecord=mock_dmarc_record, spf=True, spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all", dkim=True, @@ -299,7 +303,7 @@ async def get_domain_reports( date = datetime.now() - timedelta(days=i) date_str = date.strftime("%Y-%m-%d") - # TODO: Replace with actual historical data in future milestone + # TODO: Replace with actual historical data in future milestone # pylint: disable=fixme # For now, generate mock data with variation for demonstration purposes compliance_rate = random.uniform(80, 100) # nosec B311 - Mock data only diff --git a/backend/app/api/api_v1/endpoints/reports.py b/backend/app/api/api_v1/endpoints/reports.py index 419c6c1..39b9ff0 100644 --- a/backend/app/api/api_v1/endpoints/reports.py +++ b/backend/app/api/api_v1/endpoints/reports.py @@ -47,16 +47,16 @@ def _validate_mime_type(file_content: bytes) -> None: try: mime_type = magic.from_buffer(file_content, mime=True) if mime_type not in ALLOWED_MIME_TYPES: - logger.warning(f"Rejected file with MIME type: {mime_type}") + logger.warning("Rejected file with MIME type: %s", mime_type) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. File must be XML, ZIP, or GZIP format.", ) except HTTPException: raise - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught # If magic fails, log but continue (fallback to extension check) - logger.warning(f"MIME type detection failed: {str(e)}") + logger.warning("MIME type detection failed: %s", str(e)) def _validate_upload_file(file: UploadFile, file_content: bytes) -> None: @@ -89,15 +89,14 @@ def _handle_upload_value_error(filename: str, error_message: str) -> None: Always raises — never returns. """ - logger.error(f"ValueError processing report {filename}: {error_message}") + logger.error("ValueError processing report %s: %s", filename, error_message) if "too large" in error_message.lower(): raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File too large" ) - elif "zip bomb" in error_message.lower(): + if "zip bomb" in error_message.lower(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid archive file") - else: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid report format") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid report format") class UploadResponse(BaseModel): @@ -196,13 +195,13 @@ async def upload_report(file: UploadFile = File(...)): except ValueError as e: # Security: Sanitize error messages from parser _handle_upload_value_error(file.filename, str(e)) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught # Security: Don't expose internal errors to client - logger.error(f"Unexpected error processing report {file.filename}: {str(e)}") + logger.error("Unexpected error processing report %s: %s", file.filename, str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error processing report. Please contact support if this persists.", - ) + ) from e @router.get("/domains", response_model=List[str]) @@ -300,10 +299,11 @@ async def get_domain_reports_paginated( if sort_field == "total_count": all_reports.sort( - key=lambda r: r.get("summary", {}).get("total_count", 0), reverse=(sort_order == "desc") + key=lambda r: r.get("summary", {}).get("total_count", 0), + reverse=sort_order == "desc", ) else: - all_reports.sort(key=lambda r: r.get(sort_field, ""), reverse=(sort_order == "desc")) + all_reports.sort(key=lambda r: r.get(sort_field, ""), reverse=sort_order == "desc") # Apply pagination total = len(all_reports) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a25d929..97c0003 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,7 @@ from typing import List, Optional, Union # Try to import from pydantic_settings first (newer versions) try: - from pydantic import EmailStr, validator + from pydantic import EmailStr, validator # pylint: disable=ungrouped-imports from pydantic_settings import BaseSettings except ImportError: # Fall back to older pydantic version @@ -47,7 +47,7 @@ class Settings(BaseSettings): CLOUDFLARE_ZONE_ID: Optional[str] = None @validator("SECRET_KEY", pre=True, always=True) - def validate_secret_key(cls, v: Optional[str]) -> str: + def validate_secret_key(cls, v: Optional[str]) -> str: # pylint: disable=no-self-argument """Validate and generate SECRET_KEY if not provided.""" # Default insecure key that should never be used DEFAULT_INSECURE_KEY = "CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION" @@ -66,17 +66,18 @@ class Settings(BaseSettings): # Check if key is too short if len(v) < 32: logger.warning( - f"SECRET_KEY is too short ({len(v)} characters). " - "Recommended minimum is 32 characters for security." + "SECRET_KEY is too short (%s characters). " + "Recommended minimum is 32 characters for security.", + len(v), ) return v @validator("BACKEND_CORS_ORIGINS", pre=True) - def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]: + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]: # pylint: disable=no-self-argument if isinstance(v, str) and not v.startswith("["): return [i.strip() for i in v.split(",")] - elif isinstance(v, (list, str)): + if isinstance(v, (list, str)): return v raise ValueError(v) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index ccd4be2..56836ca 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -75,7 +75,7 @@ def add_api_key(api_key: str) -> bool: if api_key in _api_keys: return False _api_keys.add(api_key) - logger.info(f"API key added (ends with: ...{api_key[-8:]})") + logger.info("API key added (ends with: ...%s)", api_key[-8:]) return True @@ -92,7 +92,7 @@ def verify_api_key(api_key: str) -> bool: return api_key in _api_keys -async def get_api_key(api_key_header: Optional[str] = Security(api_key_header)) -> str: +async def get_api_key(api_key_value: Optional[str] = Security(api_key_header)) -> str: """ Dependency to verify API key authentication. @@ -105,24 +105,23 @@ async def get_api_key(api_key_header: Optional[str] = Security(api_key_header)) Raises: HTTPException: If API key is missing or invalid """ - if not api_key_header: + if not api_key_value: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key", headers={"WWW-Authenticate": "ApiKey"}, ) - if not verify_api_key(api_key_header): - logger.warning( - f"Invalid API key attempt: ...{api_key_header[-8:] if len(api_key_header) >= 8 else 'invalid'}" - ) + if not verify_api_key(api_key_value): + suffix = api_key_value[-8:] if len(api_key_value) >= 8 else "invalid" + logger.warning("Invalid API key attempt: ...%s", suffix) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key", headers={"WWW-Authenticate": "ApiKey"}, ) - return api_key_header + return api_key_value async def verify_token( @@ -153,12 +152,12 @@ async def verify_token( payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) return payload except JWTError as e: - logger.warning(f"Invalid JWT token: {str(e)}") + logger.warning("Invalid JWT token: %s", str(e)) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token", headers={"WWW-Authenticate": "Bearer"}, - ) + ) from e async def require_admin_auth( @@ -192,7 +191,7 @@ async def require_admin_auth( ) return {"auth_type": "jwt", "payload": payload} except JWTError as e: - logger.warning(f"Invalid JWT token: {str(e)}") + logger.warning("Invalid JWT token: %s", str(e)) # No valid authentication provided raise HTTPException( diff --git a/backend/app/main.py b/backend/app/main.py index fdb50a8..dba8d6c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from app.core.security import add_api_key, generate_api_key, require_admin_auth from app.middleware.security import SecurityHeadersMiddleware from app.services.imap_client import IMAPClient from app.services.report_store import ReportStore -from fastapi import BackgroundTasks, Depends, FastAPI, Request +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles @@ -27,7 +27,7 @@ last_check_time = None async def scheduled_imap_polling(): """Background task for periodically checking IMAP for new DMARC reports""" - global last_check_time + global last_check_time # pylint: disable=global-statement try: # How often to check for emails (in seconds) @@ -46,19 +46,24 @@ async def scheduled_imap_polling(): if results["success"]: logger.info( - f"IMAP polling completed: {results['processed']} emails processed, " - f"{results['reports_found']} reports found" + "IMAP polling completed: %s emails processed, %s reports found", + results["processed"], + results["reports_found"], ) # If new domains were found, log them if results["new_domains"]: - logger.info(f"New domains found: {', '.join(results['new_domains'])}") + logger.info( + "New domains found: %s", ", ".join(results["new_domains"]) + ) else: - logger.error(f"IMAP polling failed: {results.get('error', 'Unknown error')}") + logger.error( + "IMAP polling failed: %s", results.get("error", "Unknown error") + ) - except Exception as e: - logger.error(f"Error in IMAP polling task: {str(e)}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error in IMAP polling task: %s", str(e)) # Wait for the next check interval await asyncio.sleep(check_interval) @@ -69,7 +74,7 @@ async def scheduled_imap_polling(): def create_app() -> FastAPI: """Create and configure the FastAPI application""" - app = FastAPI( + application = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", version="0.1.0", @@ -78,11 +83,11 @@ def create_app() -> FastAPI: # Add security headers middleware # Determine environment from settings or environment variable environment = os.getenv("ENVIRONMENT", "development") - app.add_middleware(SecurityHeadersMiddleware, environment=environment) + application.add_middleware(SecurityHeadersMiddleware, environment=environment) # Improved CORS configuration - restrict to specific methods and headers if settings.BACKEND_CORS_ORIGINS: - app.add_middleware( + application.add_middleware( CORSMiddleware, allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], allow_credentials=True, @@ -103,20 +108,20 @@ def create_app() -> FastAPI: ) # Include API router - app.include_router(api_router, prefix=settings.API_V1_STR) + application.include_router(api_router, prefix=settings.API_V1_STR) # Mount static files directory - app.mount( + application.mount( "/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static", ) # Set up event handlers for startup and shutdown - @app.on_event("startup") + @application.on_event("startup") async def startup_event(): """Initialize background tasks and security on application startup""" - global background_task + global background_task # pylint: disable=global-statement # Generate and provide admin API key api_key = generate_api_key() @@ -124,19 +129,20 @@ def create_app() -> FastAPI: # Security: Log only last 8 characters for reference logger.warning( - "=" * 80 + "\n" - "IMPORTANT: Admin API Key Generated\n" - f"API Key (last 8 chars): ...{api_key[-8:]}\n" + "%s\nIMPORTANT: Admin API Key Generated\n" + "API Key (last 8 chars): ...%s\n" "Full key stored securely in memory.\n" "For production, retrieve the key through secure configuration management.\n" - "Use this key in the X-API-Key header for admin endpoints.\n" - "=" * 80 + "Use this key in the X-API-Key header for admin endpoints.\n%s", + "=" * 80, + api_key[-8:], + "=" * 80, ) # In development, also log the full key for convenience # This should be removed in production or controlled by environment variable if os.getenv("ENVIRONMENT", "development") == "development": - logger.info(f"Development Mode - Full API Key: {api_key}") + logger.info("Development Mode - Full API Key: %s", api_key) # Check if IMAP credentials are configured if all([settings.IMAP_SERVER, settings.IMAP_USERNAME, settings.IMAP_PASSWORD]): @@ -145,7 +151,7 @@ def create_app() -> FastAPI: else: logger.warning("IMAP credentials not fully configured, polling disabled") - @app.on_event("shutdown") + @application.on_event("shutdown") async def shutdown_event(): """Clean up background tasks on application shutdown""" if background_task: @@ -156,7 +162,7 @@ def create_app() -> FastAPI: except asyncio.CancelledError: pass - return app + return application app = create_app() @@ -202,9 +208,9 @@ async def domains(request: Request): async def domain_details(request: Request, domain_id: str): """View detailed reports for a specific domain""" store = ReportStore.get_instance() - domains = store.get_domains() + known_domains = store.get_domains() - if domain_id not in domains: + if domain_id not in known_domains: # Domain not found, redirect to domains list return templates.TemplateResponse( "domains.html", {"request": request, "error": f"Domain {domain_id} not found"} @@ -243,15 +249,13 @@ async def upload_page(request: Request): # API endpoint to manually trigger IMAP polling @app.post("/api/v1/admin/trigger-poll") -async def trigger_imap_poll( - background_tasks: BackgroundTasks, auth: dict = Depends(require_admin_auth) -): +async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)): """ Manually trigger IMAP polling (admin only - requires authentication) Security: Requires either X-API-Key header or Bearer token """ - global last_check_time + global last_check_time # pylint: disable=global-statement try: # Create IMAP client and fetch reports @@ -269,8 +273,8 @@ async def trigger_imap_poll( "new_domains": results["new_domains"], "authenticated_by": auth.get("auth_type"), } - except Exception as e: - logger.error(f"Error triggering IMAP poll: {str(e)}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error triggering IMAP poll: %s", str(e)) return { "success": False, "error": "Failed to trigger IMAP poll. Check server logs for details.", diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py index aba4c3a..6bb9700 100644 --- a/backend/app/middleware/security.py +++ b/backend/app/middleware/security.py @@ -78,11 +78,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP csp_directives = [ "default-src 'self'", - # TODO: Remove 'unsafe-inline' - requires moving inline scripts to external files - # TODO: Remove 'unsafe-eval' - no eval usage detected, safe to remove after testing - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net", - # TODO: Remove 'unsafe-inline' - requires moving inline styles to CSS or using nonces - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net", + # TODO: Remove 'unsafe-inline' - requires moving inline scripts to external files # pylint: disable=fixme + # TODO: Remove 'unsafe-eval' - no eval usage detected, safe to remove after testing # pylint: disable=fixme + "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + " https://cdn.tailwindcss.com https://cdn.jsdelivr.net", + # TODO: Remove 'unsafe-inline' - requires moving inline styles to CSS or using nonces # pylint: disable=fixme + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com" + " https://cdn.jsdelivr.net", "font-src 'self' https://fonts.gstatic.com", "img-src 'self' data: https:", "connect-src 'self'", diff --git a/backend/app/services/dmarc_parser.py b/backend/app/services/dmarc_parser.py index c5944f2..2adaf61 100644 --- a/backend/app/services/dmarc_parser.py +++ b/backend/app/services/dmarc_parser.py @@ -236,20 +236,24 @@ class DMARCParser: # Log parse results for debugging total_count = report["summary"]["total_count"] - logger.info(f"Parsed DMARC report for domain: {report.get('domain')}") - logger.info(f"Found {len(records)} record entries with {total_count} total messages") + logger.info("Parsed DMARC report for domain: %s", report.get("domain")) logger.info( - f"Messages passed: {report['summary']['passed_count']}, " - f"failed: {report['summary']['failed_count']}" + "Found %s record entries with %s total messages", len(records), total_count + ) + logger.info( + "Messages passed: %s, failed: %s", + report["summary"]["passed_count"], + report["summary"]["failed_count"], ) if records: logger.info( - f"Sample record - SPF: {records[0].get('spf_result')}, " - f"DKIM: {records[0].get('dkim_result')}" + "Sample record - SPF: %s, DKIM: %s", + records[0].get("spf_result"), + records[0].get("dkim_result"), ) return report except Exception as e: - logger.error(f"Error parsing DMARC XML: {str(e)}") - raise ValueError(f"Error parsing DMARC XML: {str(e)}") + logger.error("Error parsing DMARC XML: %s", str(e)) + raise ValueError(f"Error parsing DMARC XML: {str(e)}") from e diff --git a/backend/app/services/imap_client.py b/backend/app/services/imap_client.py index 7813ed9..73780ed 100644 --- a/backend/app/services/imap_client.py +++ b/backend/app/services/imap_client.py @@ -18,7 +18,7 @@ class IMAPClient: Client for retrieving DMARC reports from an IMAP mailbox """ - def __init__( + def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments self, server: str = None, port: int = None, @@ -63,7 +63,7 @@ class IMAPClient: if mailbox_name.startswith(" "): mailbox_name = mailbox_name[1:] available_mailboxes.append(mailbox_name) - except Exception: + except Exception: # pylint: disable=broad-exception-caught # Silently skip mailboxes that can't be parsed; they are simply # omitted from the returned list so callers should expect it may # be incomplete. Some IMAP servers return non-standard list @@ -130,8 +130,8 @@ class IMAPClient: } return True, "Connection successful", stats - except Exception as e: - logger.error(f"IMAP connection test failed: {str(e)}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("IMAP connection test failed: %s", str(e)) return False, f"Connection failed: {str(e)}", {} def _process_single_email(self, mail, email_id: bytes, stats: dict) -> None: @@ -139,7 +139,7 @@ class IMAPClient: try: status, msg_data = mail.fetch(email_id, "(RFC822)") if status != "OK": - logger.error(f"Error fetching email ID {email_id}") + logger.error("Error fetching email ID %s", email_id) return raw_email = msg_data[0][1] @@ -155,7 +155,7 @@ class IMAPClient: mail.store(email_id, "+FLAGS", "\\Deleted") stats["processed"] += 1 - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught error_msg = f"Error processing email ID {email_id}: {str(e)}" logger.error(error_msg) stats["errors"].append(error_msg) @@ -225,8 +225,8 @@ class IMAPClient: return stats - except Exception as e: - logger.error(f"Error fetching DMARC reports: {str(e)}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error fetching DMARC reports: %s", str(e)) return { "success": False, "error": f"Error connecting to mailbox: {str(e)}", @@ -339,12 +339,12 @@ class IMAPClient: # Check content type content_type = part.get_content_type() - if ( - content_type == "application/zip" - or content_type == "application/gzip" - or content_type == "application/x-gzip" - or content_type == "application/xml" - or content_type == "text/xml" + if content_type in ( + "application/zip", + "application/gzip", + "application/x-gzip", + "application/xml", + "text/xml", ): return True @@ -390,8 +390,10 @@ class IMAPClient: self.report_store.add_report(report) reports_found += 1 - logger.info(f"Successfully processed DMARC report: {filename}") - except Exception as e: - logger.error(f"Error processing attachment {filename}: {str(e)}") + logger.info("Successfully processed DMARC report: %s", filename) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error( + "Error processing attachment %s: %s", filename, str(e) + ) return reports_found diff --git a/backend/app/services/report_store.py b/backend/app/services/report_store.py index a605a29..d62e220 100644 --- a/backend/app/services/report_store.py +++ b/backend/app/services/report_store.py @@ -155,7 +155,7 @@ class ReportStore: return sorted_reports[:limit] return sorted_reports - def get_domain_sources(self, domain: str, days: int = 30) -> List[Dict[str, Any]]: + def get_domain_sources(self, domain: str, _days: int = 30) -> List[Dict[str, Any]]: """ Get sending sources for a domain @@ -206,6 +206,6 @@ class ReportStore: self.domain_summary.pop(domain, None) self.domain_sources.pop(domain, None) return True - except Exception: + except Exception: # pylint: disable=broad-exception-caught # If any exception occurs during deletion, return False return False diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 97c2ac5..61088df 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,9 +1,10 @@ # Import all models so Base.metadata knows every table -import app.models.domain # noqa: F401 -import app.models.report # noqa: F401 -import app.models.user # noqa: F401 +import app.models.domain # noqa: F401 # pylint: disable=unused-import +import app.models.report # noqa: F401 # pylint: disable=unused-import +import app.models.user # noqa: F401 # pylint: disable=unused-import import pytest from app.core.database import Base, get_db +from app.main import create_app from app.services.report_store import ReportStore from fastapi import FastAPI from fastapi.testclient import TestClient @@ -14,8 +15,6 @@ from sqlalchemy.orm import sessionmaker @pytest.fixture() def test_app() -> FastAPI: """Create a fresh FastAPI application instance for testing.""" - from app.main import create_app - application = create_app() return application @@ -36,7 +35,7 @@ def db_session(): @pytest.fixture() -def client(test_app: FastAPI, db_session): +def client(test_app: FastAPI, db_session): # pylint: disable=redefined-outer-name """Create a TestClient with a DB override for the test app.""" def override_get_db(): diff --git a/backend/app/tests/test_data.py b/backend/app/tests/test_data.py new file mode 100644 index 0000000..24642ec --- /dev/null +++ b/backend/app/tests/test_data.py @@ -0,0 +1,49 @@ +"""Shared test data for DMARC report tests.""" + +SAMPLE_XML = """\ + + + + google.com + noreply-dmarc-support@google.com + 123456789 + + 1597449600 + 1597535999 + + + + example.com + r + r +

none

+ none + 100 +
+ + + 203.0.113.1 + 2 + + none + pass + fail + + + + example.com + + + + example.com + pass + default + + + example.com + fail + + + +
+""" diff --git a/backend/app/tests/test_dmarc_parser.py b/backend/app/tests/test_dmarc_parser.py index 9a8adc7..72a3934 100644 --- a/backend/app/tests/test_dmarc_parser.py +++ b/backend/app/tests/test_dmarc_parser.py @@ -3,54 +3,7 @@ import zipfile import pytest from app.services.dmarc_parser import DMARCParser - -SAMPLE_XML = """\ - - - - google.com - noreply-dmarc-support@google.com - 123456789 - - 1597449600 - 1597535999 - - - - example.com - r - r -

none

- none - 100 -
- - - 203.0.113.1 - 2 - - none - pass - fail - - - - example.com - - - - example.com - pass - default - - - example.com - fail - - - -
-""" +from app.tests.test_data import SAMPLE_XML class TestDMARCParser: diff --git a/backend/app/tests/test_reports_api.py b/backend/app/tests/test_reports_api.py index 337b712..f7a3a3a 100644 --- a/backend/app/tests/test_reports_api.py +++ b/backend/app/tests/test_reports_api.py @@ -1,56 +1,9 @@ import io import zipfile +from app.tests.test_data import SAMPLE_XML from fastapi.testclient import TestClient -SAMPLE_XML = """\ - - - - google.com - noreply-dmarc-support@google.com - 123456789 - - 1597449600 - 1597535999 - - - - example.com - r - r -

none

- none - 100 -
- - - 203.0.113.1 - 2 - - none - pass - fail - - - - example.com - - - - example.com - pass - default - - - example.com - fail - - - -
-""" - def _make_zip(xml_content: str) -> bytes: """Create a ZIP file containing the given XML content.""" diff --git a/backend/app/tests/test_security.py b/backend/app/tests/test_security.py index 466a136..d99bebe 100644 --- a/backend/app/tests/test_security.py +++ b/backend/app/tests/test_security.py @@ -4,6 +4,7 @@ Security-focused tests for DMARQ application. Covers API key management, domain validation, file upload limits, and XML parsing security. """ +import app.services.dmarc_parser as parser_module import pytest from app.core.security import add_api_key, generate_api_key, verify_api_key from app.services.dmarc_parser import DMARCParser @@ -123,8 +124,6 @@ class TestXMLParsingSecurity: """Test XML parsing security (defusedxml, XXE protection).""" def test_defusedxml_is_used(self): - import app.services.dmarc_parser as parser_module - assert hasattr(parser_module, "ET") module_info = str(getattr(parser_module.ET, "__name__", "")) + str( getattr(parser_module.ET, "__module__", "") @@ -149,5 +148,5 @@ class TestXMLParsingSecurity: result = DMARCParser.parse_file(xxe_payload, "test.xml") org_name = result.get("org_name", "") assert "root:" not in org_name and "/bin" not in org_name - except Exception: + except Exception: # pylint: disable=broad-exception-caught pass # Expected – defusedxml blocks DTD processing diff --git a/backend/app/utils/domain_validator.py b/backend/app/utils/domain_validator.py index a329249..faca6c8 100644 --- a/backend/app/utils/domain_validator.py +++ b/backend/app/utils/domain_validator.py @@ -56,7 +56,7 @@ def _validate_domain_labels( return True, None, None -def validate_domain( +def validate_domain( # pylint: disable=too-many-return-statements domain_name: str, check_dns: bool = True ) -> Tuple[bool, Optional[str], Optional[str]]: """ @@ -103,7 +103,6 @@ def validate_domain( if check_dns: try: socket.gethostbyname(domain_name) - return True, None, None except socket.gaierror: # We could consider this valid if we don't require DNS resolution, # but since DMARC requires valid DNS, we'll mark it as warning @@ -133,7 +132,7 @@ def validate_domain_config(domain_data: Dict) -> Dict[str, Union[bool, str]]: # Validate domain name if "name" in domain_data: # Don't check DNS for domain config validation - is_valid, error_msg, error_code = validate_domain(domain_data["name"], check_dns=False) + is_valid, error_msg, _ = validate_domain(domain_data["name"], check_dns=False) if not is_valid: errors["name"] = error_msg else: diff --git a/backend/app/utils/stats_summarizer.py b/backend/app/utils/stats_summarizer.py index 1cfe071..098f6f5 100644 --- a/backend/app/utils/stats_summarizer.py +++ b/backend/app/utils/stats_summarizer.py @@ -63,10 +63,10 @@ class StatsSummarizer: return None # Read cache file - with open(cache_file, "r") as f: + with open(cache_file, "r", encoding="utf-8") as f: return json.load(f) - except Exception as e: - logger.warning(f"Error reading cache file {cache_file}: {str(e)}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("Error reading cache file %s: %s", cache_file, str(e)) return None def save_summary(self, stats: Dict[str, Any], domain_id: Optional[str] = None) -> bool: @@ -87,12 +87,12 @@ class StatsSummarizer: stats["cached_at"] = datetime.now().isoformat() # Write to cache file - with open(cache_file, "w") as f: + with open(cache_file, "w", encoding="utf-8") as f: json.dump(stats, f) return True - except Exception as e: - logger.error(f"Error writing cache file {cache_file}: {str(e)}") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error writing cache file %s: %s", cache_file, str(e)) return False def invalidate_cache(self, domain_id: Optional[str] = None) -> None: @@ -126,12 +126,13 @@ class StatsSummarizer: """ if domain_id is None: return os.path.join(self.cache_dir, "global_summary.json") - else: - # Sanitize domain_id to use as filename - safe_domain = domain_id.replace(".", "_").replace("/", "_") - return os.path.join(self.cache_dir, f"domain_{safe_domain}.json") + # Sanitize domain_id to use as filename + safe_domain = domain_id.replace(".", "_").replace("/", "_") + return os.path.join(self.cache_dir, f"domain_{safe_domain}.json") - def calculate_summary_statistics(self, db, domain_id: Optional[str] = None) -> Dict[str, Any]: + def calculate_summary_statistics( + self, _db, domain_id: Optional[str] = None + ) -> Dict[str, Any]: """ Calculate summary statistics from the database From f2a015971dc0037847d471fb1aeec9116f9dad0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:29:14 +0000 Subject: [PATCH 3/5] Plan: resolve linter contradictions Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/1e4a1f06-55b9-4040-853e-6aaf9ee574c8 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- backend/app/core/config.py | 4 +++- backend/app/main.py | 8 ++------ backend/app/services/dmarc_parser.py | 4 +--- backend/app/services/imap_client.py | 4 +--- backend/app/utils/stats_summarizer.py | 4 +--- 5 files changed, 8 insertions(+), 16 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 97c0003..fdc01fc 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -74,7 +74,9 @@ class Settings(BaseSettings): return v @validator("BACKEND_CORS_ORIGINS", pre=True) - def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]: # pylint: disable=no-self-argument + def assemble_cors_origins( + cls, v: Union[str, List[str]] + ) -> List[str]: # pylint: disable=no-self-argument if isinstance(v, str) and not v.startswith("["): return [i.strip() for i in v.split(",")] if isinstance(v, (list, str)): diff --git a/backend/app/main.py b/backend/app/main.py index dba8d6c..0bb6eed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -53,14 +53,10 @@ async def scheduled_imap_polling(): # If new domains were found, log them if results["new_domains"]: - logger.info( - "New domains found: %s", ", ".join(results["new_domains"]) - ) + logger.info("New domains found: %s", ", ".join(results["new_domains"])) else: - logger.error( - "IMAP polling failed: %s", results.get("error", "Unknown error") - ) + logger.error("IMAP polling failed: %s", results.get("error", "Unknown error")) except Exception as e: # pylint: disable=broad-exception-caught logger.error("Error in IMAP polling task: %s", str(e)) diff --git a/backend/app/services/dmarc_parser.py b/backend/app/services/dmarc_parser.py index 2adaf61..d892582 100644 --- a/backend/app/services/dmarc_parser.py +++ b/backend/app/services/dmarc_parser.py @@ -237,9 +237,7 @@ class DMARCParser: # Log parse results for debugging total_count = report["summary"]["total_count"] logger.info("Parsed DMARC report for domain: %s", report.get("domain")) - logger.info( - "Found %s record entries with %s total messages", len(records), total_count - ) + logger.info("Found %s record entries with %s total messages", len(records), total_count) logger.info( "Messages passed: %s, failed: %s", report["summary"]["passed_count"], diff --git a/backend/app/services/imap_client.py b/backend/app/services/imap_client.py index 73780ed..fd73b3b 100644 --- a/backend/app/services/imap_client.py +++ b/backend/app/services/imap_client.py @@ -392,8 +392,6 @@ class IMAPClient: reports_found += 1 logger.info("Successfully processed DMARC report: %s", filename) except Exception as e: # pylint: disable=broad-exception-caught - logger.error( - "Error processing attachment %s: %s", filename, str(e) - ) + logger.error("Error processing attachment %s: %s", filename, str(e)) return reports_found diff --git a/backend/app/utils/stats_summarizer.py b/backend/app/utils/stats_summarizer.py index 098f6f5..07ebada 100644 --- a/backend/app/utils/stats_summarizer.py +++ b/backend/app/utils/stats_summarizer.py @@ -130,9 +130,7 @@ class StatsSummarizer: safe_domain = domain_id.replace(".", "_").replace("/", "_") return os.path.join(self.cache_dir, f"domain_{safe_domain}.json") - def calculate_summary_statistics( - self, _db, domain_id: Optional[str] = None - ) -> Dict[str, Any]: + def calculate_summary_statistics(self, _db, domain_id: Optional[str] = None) -> Dict[str, Any]: """ Calculate summary statistics from the database From 01d03311365e49f5fca5ab236a27526ce4ba4403 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:31:15 +0000 Subject: [PATCH 4/5] Resolve linter contradictions: consolidate config, fix isort first-party, pylint 10/10 Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/1e4a1f06-55b9-4040-853e-6aaf9ee574c8 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .flake8 | 21 ++++++++ .github/workflows/test.yml | 4 +- backend/app/api/api_v1/api.py | 3 +- backend/app/api/api_v1/endpoints/domains.py | 3 +- backend/app/api/api_v1/endpoints/health.py | 3 +- backend/app/api/api_v1/endpoints/imap.py | 5 +- backend/app/api/api_v1/endpoints/reports.py | 5 +- backend/app/api/api_v1/endpoints/stats.py | 5 +- backend/app/core/config.py | 4 +- backend/app/core/database.py | 3 +- backend/app/core/security.py | 3 +- backend/app/main.py | 11 ++-- backend/app/models/domain.py | 3 +- backend/app/models/report.py | 3 +- backend/app/models/user.py | 3 +- backend/app/tests/conftest.py | 13 ++--- backend/app/tests/test_dmarc_parser.py | 1 + backend/app/tests/test_models.py | 3 +- backend/app/tests/test_reports_api.py | 3 +- backend/app/tests/test_security.py | 3 +- pyproject.toml | 18 +++++++ setup.cfg | 56 --------------------- 22 files changed, 88 insertions(+), 88 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..a77894b --- /dev/null +++ b/.flake8 @@ -0,0 +1,21 @@ +[flake8] +# Keep in sync with black's line-length in pyproject.toml [tool.black] +max-line-length = 100 +max-complexity = 10 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + *.egg-info, + migrations +# Ignored rules – must not conflict with black: +# E203 – whitespace before ':' (black formats slices this way) +# W503 – line break before binary operator (black prefers this style) +# E501 – line too long (black already enforces max-line-length; avoid double-reporting) +extend-ignore = E203, W503, E501 +per-file-ignores = + # Allow unused imports in __init__.py (re-exports) + __init__.py: F401 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d42973..390698b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,11 +80,11 @@ jobs: - name: Run Flake8 run: | - flake8 backend/app --max-line-length=100 --extend-ignore=E203,W503,E501 + flake8 backend/app - name: Run Pylint run: | - pylint backend/app --max-line-length=100 --disable=C0111,R0903 + pylint backend/app continue-on-error: true docker: diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 9bff591..0341762 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -1,6 +1,7 @@ -from app.api.api_v1.endpoints import domains, health, imap, reports, setup, stats from fastapi import APIRouter +from app.api.api_v1.endpoints import domains, health, imap, reports, setup, stats + api_router = APIRouter() # Include all endpoint routers diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py index d897871..0a52866 100644 --- a/backend/app/api/api_v1/endpoints/domains.py +++ b/backend/app/api/api_v1/endpoints/domains.py @@ -2,10 +2,11 @@ import random # Used for mock data generation - TODO: Replace with actual histo from datetime import datetime, timedelta from typing import Any, Dict, List, Optional -from app.services.report_store import ReportStore from fastapi import APIRouter, HTTPException, Path, Query, status from pydantic import BaseModel +from app.services.report_store import ReportStore + router = APIRouter() diff --git a/backend/app/api/api_v1/endpoints/health.py b/backend/app/api/api_v1/endpoints/health.py index f6e9413..a721120 100644 --- a/backend/app/api/api_v1/endpoints/health.py +++ b/backend/app/api/api_v1/endpoints/health.py @@ -1,6 +1,7 @@ -from app.api.api_v1.endpoints.setup import setup_status from fastapi import APIRouter +from app.api.api_v1.endpoints.setup import setup_status + router = APIRouter() diff --git a/backend/app/api/api_v1/endpoints/imap.py b/backend/app/api/api_v1/endpoints/imap.py index b66328a..f5cf803 100644 --- a/backend/app/api/api_v1/endpoints/imap.py +++ b/backend/app/api/api_v1/endpoints/imap.py @@ -2,11 +2,12 @@ import logging from datetime import datetime from typing import Any, Dict, Optional -from app.core.security import require_admin_auth -from app.services.imap_client import IMAPClient from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel +from app.core.security import require_admin_auth +from app.services.imap_client import IMAPClient + router = APIRouter() logger = logging.getLogger(__name__) diff --git a/backend/app/api/api_v1/endpoints/reports.py b/backend/app/api/api_v1/endpoints/reports.py index 39b9ff0..a83d5bf 100644 --- a/backend/app/api/api_v1/endpoints/reports.py +++ b/backend/app/api/api_v1/endpoints/reports.py @@ -1,11 +1,12 @@ import logging from typing import List +from fastapi import APIRouter, File, HTTPException, UploadFile, status +from pydantic import BaseModel + from app.services.dmarc_parser import DMARCParser from app.services.report_store import ReportStore from app.utils.domain_validator import DomainValidationError, validate_domain -from fastapi import APIRouter, File, HTTPException, UploadFile, status -from pydantic import BaseModel logger = logging.getLogger(__name__) diff --git a/backend/app/api/api_v1/endpoints/stats.py b/backend/app/api/api_v1/endpoints/stats.py index f7ec140..7863b1a 100644 --- a/backend/app/api/api_v1/endpoints/stats.py +++ b/backend/app/api/api_v1/endpoints/stats.py @@ -1,9 +1,10 @@ from typing import Any, Dict +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + from app.core.database import get_db from app.utils.stats_summarizer import StatsSummarizer -from fastapi import APIRouter, Depends, Path, Query -from sqlalchemy.orm import Session router = APIRouter() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index fdc01fc..717c513 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -74,9 +74,9 @@ class Settings(BaseSettings): return v @validator("BACKEND_CORS_ORIGINS", pre=True) - def assemble_cors_origins( + def assemble_cors_origins( # pylint: disable=no-self-argument cls, v: Union[str, List[str]] - ) -> List[str]: # pylint: disable=no-self-argument + ) -> List[str]: if isinstance(v, str) and not v.startswith("["): return [i.strip() for i in v.split(",")] if isinstance(v, (list, str)): diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 96329ab..3e809fe 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,10 +1,11 @@ from typing import Generator -from app.core.config import get_settings from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker +from app.core.config import get_settings + settings = get_settings() # Configure SQLAlchemy diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 56836ca..2f2e76e 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -4,12 +4,13 @@ import secrets from datetime import datetime, timedelta from typing import Any, Optional, Union -from app.core.config import get_settings from fastapi import HTTPException, Security, status from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from passlib.context import CryptContext +from app.core.config import get_settings + settings = get_settings() logger = logging.getLogger(__name__) diff --git a/backend/app/main.py b/backend/app/main.py index 0bb6eed..169e51a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,17 +3,18 @@ import logging import os from datetime import datetime +from fastapi import Depends, FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + from app.api.api_v1.api import api_router from app.core.config import get_settings from app.core.security import add_api_key, generate_api_key, require_admin_auth from app.middleware.security import SecurityHeadersMiddleware from app.services.imap_client import IMAPClient from app.services.report_store import ReportStore -from fastapi import Depends, FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates # Set up logging logger = logging.getLogger(__name__) diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 1c46c31..9c61c91 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -1,9 +1,10 @@ from datetime import datetime -from app.core.database import Base from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text from sqlalchemy.orm import relationship +from app.core.database import Base + class Domain(Base): """Domain model representing a monitored domain""" diff --git a/backend/app/models/report.py b/backend/app/models/report.py index 612e867..3099054 100644 --- a/backend/app/models/report.py +++ b/backend/app/models/report.py @@ -1,9 +1,10 @@ from datetime import datetime -from app.core.database import Base from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text from sqlalchemy.orm import relationship +from app.core.database import Base + class DMARCReport(Base): """DMARC Aggregate Report model""" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2e2ff25..1370a83 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,8 @@ -from app.core.database import Base from sqlalchemy import Boolean, Column, Integer, String from sqlalchemy.orm import relationship +from app.core.database import Base + class User(Base): """User model""" diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 61088df..fe182f4 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,16 +1,17 @@ # Import all models so Base.metadata knows every table -import app.models.domain # noqa: F401 # pylint: disable=unused-import -import app.models.report # noqa: F401 # pylint: disable=unused-import -import app.models.user # noqa: F401 # pylint: disable=unused-import import pytest -from app.core.database import Base, get_db -from app.main import create_app -from app.services.report_store import ReportStore from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +import app.models.domain # noqa: F401 # pylint: disable=unused-import +import app.models.report # noqa: F401 # pylint: disable=unused-import +import app.models.user # noqa: F401 # pylint: disable=unused-import +from app.core.database import Base, get_db +from app.main import create_app +from app.services.report_store import ReportStore + @pytest.fixture() def test_app() -> FastAPI: diff --git a/backend/app/tests/test_dmarc_parser.py b/backend/app/tests/test_dmarc_parser.py index 72a3934..8361d52 100644 --- a/backend/app/tests/test_dmarc_parser.py +++ b/backend/app/tests/test_dmarc_parser.py @@ -2,6 +2,7 @@ import io import zipfile import pytest + from app.services.dmarc_parser import DMARCParser from app.tests.test_data import SAMPLE_XML diff --git a/backend/app/tests/test_models.py b/backend/app/tests/test_models.py index 43703b4..a6df613 100644 --- a/backend/app/tests/test_models.py +++ b/backend/app/tests/test_models.py @@ -1,6 +1,7 @@ +from sqlalchemy.orm import Session + from app.models.domain import Domain from app.models.report import DMARCReport, ReportRecord -from sqlalchemy.orm import Session class TestDomainModel: diff --git a/backend/app/tests/test_reports_api.py b/backend/app/tests/test_reports_api.py index f7a3a3a..fb77ccf 100644 --- a/backend/app/tests/test_reports_api.py +++ b/backend/app/tests/test_reports_api.py @@ -1,9 +1,10 @@ import io import zipfile -from app.tests.test_data import SAMPLE_XML from fastapi.testclient import TestClient +from app.tests.test_data import SAMPLE_XML + def _make_zip(xml_content: str) -> bytes: """Create a ZIP file containing the given XML content.""" diff --git a/backend/app/tests/test_security.py b/backend/app/tests/test_security.py index d99bebe..f83b477 100644 --- a/backend/app/tests/test_security.py +++ b/backend/app/tests/test_security.py @@ -4,8 +4,9 @@ Security-focused tests for DMARQ application. Covers API key management, domain validation, file upload limits, and XML parsing security. """ -import app.services.dmarc_parser as parser_module import pytest + +import app.services.dmarc_parser as parser_module from app.core.security import add_api_key, generate_api_key, verify_api_key from app.services.dmarc_parser import DMARCParser from app.utils.domain_validator import validate_domain, validate_domain_config diff --git a/pyproject.toml b/pyproject.toml index ddb31ed..e131f85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,24 @@ include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true +known_first_party = ["app"] +skip = ["venv", ".venv", "migrations"] + +[tool.pylint.main] +# Run from repo root: pylint backend/app +max-line-length = 100 + +[tool.pylint."messages control"] +disable = [ + "C0111", # missing-docstring + "C0103", # invalid-name (e.g. SessionLocal, TestingSessionLocal) + "R0903", # too-few-public-methods + "R0913", # too-many-arguments + "W0212", # protected-access +] + +[tool.pylint.basic] +good-names = ["i", "j", "k", "ex", "_", "id", "db"] [tool.pytest.ini_options] testpaths = ["backend/app/tests"] diff --git a/setup.cfg b/setup.cfg index 92fe295..ef8919e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,59 +31,3 @@ skip_covered = False [coverage:html] directory = htmlcov - -[flake8] -max-line-length = 100 -exclude = - .git, - __pycache__, - .venv, - venv, - build, - dist, - *.egg-info, - migrations -extend-ignore = E203, W503, E501 -per-file-ignores = - __init__.py:F401 -max-complexity = 10 - -[mypy] -python_version = 3.10 -warn_return_any = True -warn_unused_configs = True -disallow_untyped_defs = False -disallow_incomplete_defs = False -check_untyped_defs = True -disallow_untyped_calls = False -disallow_any_generics = False -ignore_missing_imports = True -no_implicit_optional = True -warn_redundant_casts = True -warn_unused_ignores = True -warn_no_return = True -strict_optional = True - -[isort] -profile = black -line_length = 100 -multi_line_output = 3 -include_trailing_comma = True -force_grid_wrap = 0 -use_parentheses = True -ensure_newline_before_comments = True -skip = venv,.venv,migrations - -[pylint] -max-line-length = 100 -disable = - C0111, # missing-docstring - C0103, # invalid-name - R0903, # too-few-public-methods - R0913, # too-many-arguments - W0212, # protected-access -good-names = i,j,k,ex,_,id,db - -[bandit] -exclude_dirs = /tests/,/venv/,.venv/ -skips = B101,B601 From a90c9b393e49c01a1bbba3bef472aab0a756434c Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sun, 29 Mar 2026 13:35:32 +0200 Subject: [PATCH 5/5] Delete .github/workflows/test.yml --- .github/workflows/test.yml | 132 ------------------------------------- 1 file changed, 132 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 390698b..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Tests - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - -jobs: - test: - name: Test - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Cache pip packages - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - cd backend - pip install -r requirements.txt - - - name: Run tests with coverage - run: | - cd backend - pytest --cov=app --cov-report=xml --cov-report=term-missing - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - file: ./backend/coverage.xml - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false - - lint: - name: Lint and Format Check - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install linting tools - run: | - python -m pip install --upgrade pip - pip install pylint black flake8 isort mypy - cd backend && pip install -r requirements.txt - - - name: Run Black (format check) - run: | - black --check backend/app - - - name: Run isort (import order check) - run: | - isort --check-only backend/app - - - name: Run Flake8 - run: | - flake8 backend/app - - - name: Run Pylint - run: | - pylint backend/app - continue-on-error: true - - docker: - name: Docker Build & Publish - runs-on: ubuntu-latest - needs: [test, lint] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - permissions: - contents: read - packages: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=ref,event=branch - type=sha,prefix= - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: ./backend - file: ./backend/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max