diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index c73e032..42dfc88 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -5,19 +5,18 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v4
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
+ - name: Set up Python
+ uses: actions/setup-python@v5
with:
- python-version: ${{ matrix.python-version }}
+ python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint
+ cd backend && pip install -r requirements.txt
- name: Analysing the code with pylint
run: |
- pylint $(git ls-files '*.py')
+ pylint $(git ls-files '*.py') --disable=C0111,R0903
+ continue-on-error: true
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 38f119c..80522dc 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: '3.10'
@@ -47,7 +47,7 @@ jobs:
continue-on-error: true
- name: Upload Bandit Report
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: always()
with:
name: bandit-security-report
@@ -71,16 +71,16 @@ jobs:
uses: actions/checkout@v4
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
@@ -94,6 +94,6 @@ jobs:
uses: actions/checkout@v4
- name: Dependency Review
- uses: actions/dependency-review-action@v3
+ uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index dbed291..8d42973 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,123 +8,125 @@ on:
jobs:
test:
- name: Test Python ${{ matrix.python-version }}
+ name: Test
runs-on: ubuntu-latest
-
- strategy:
- matrix:
- python-version: ["3.10", "3.11", "3.12"]
-
- services:
- postgres:
- image: postgres:14-alpine
- env:
- POSTGRES_PASSWORD: test_password
- POSTGRES_USER: test_user
- POSTGRES_DB: test_db
- ports:
- - 5432:5432
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
-
+ permissions:
+ contents: read
+
steps:
- name: Checkout code
uses: actions/checkout@v4
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
with:
- python-version: ${{ matrix.python-version }}
-
+ python-version: '3.10'
+
- name: Cache pip packages
- uses: actions/cache@v3
+ 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
- pip install pytest pytest-cov pytest-asyncio
-
+
- name: Run tests with coverage
- env:
- DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
- SECRET_KEY: test_secret_key_for_ci
run: |
cd backend
pytest --cov=app --cov-report=xml --cov-report=term-missing
-
+
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v3
+ 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@v4
+ 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 --max-line-length=100 --extend-ignore=E203,W503
-
+ flake8 backend/app --max-line-length=100 --extend-ignore=E203,W503,E501
+
- name: Run Pylint
run: |
pylint backend/app --max-line-length=100 --disable=C0111,R0903
continue-on-error: true
-
- docker-build:
- name: Docker Build Test
+
+ 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@v2
-
- - name: Build Docker image
- run: |
- docker compose build
-
- - name: Test Docker image
- run: |
- docker compose up -d
- sleep 10
- docker compose ps
- docker compose logs
- docker compose down
+ 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
diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py
index ad76bf4..451019a 100644
--- a/backend/app/api/api_v1/endpoints/domains.py
+++ b/backend/app/api/api_v1/endpoints/domains.py
@@ -1,6 +1,6 @@
+import random # Used for mock data generation - TODO: Replace with actual historical data
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
-import random # Used for mock data generation - TODO: Replace with actual historical data
from app.services.report_store import ReportStore
from fastapi import APIRouter, HTTPException, Path, Query, status
diff --git a/backend/app/api/api_v1/endpoints/reports.py b/backend/app/api/api_v1/endpoints/reports.py
index cc30885..70f5cf5 100644
--- a/backend/app/api/api_v1/endpoints/reports.py
+++ b/backend/app/api/api_v1/endpoints/reports.py
@@ -35,6 +35,77 @@ ALLOWED_MIME_TYPES = {
ALLOWED_EXTENSIONS = {".xml", ".zip", ".gz", ".gzip"}
+def _validate_mime_type(file_content: bytes) -> None:
+ """Validate the MIME type of the uploaded file using python-magic.
+
+ No-ops silently when python-magic is unavailable.
+ Raises HTTPException on a disallowed MIME type.
+ """
+ if not HAS_MAGIC:
+ logger.debug("MIME type validation skipped (python-magic not available)")
+ return
+ 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}")
+ 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:
+ # If magic fails, log but continue (fallback to extension check)
+ logger.warning(f"MIME type detection failed: {str(e)}")
+
+
+def _validate_upload_file(file: UploadFile, file_content: bytes) -> None:
+ """Run all pre-parse validation checks on an uploaded file.
+
+ Raises HTTPException for any validation failure.
+ """
+ # Security: Validate filename is provided
+ if not file.filename:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required"
+ )
+
+ # Security: Validate file extension
+ file_ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
+ if file_ext not in ALLOWED_EXTENSIONS:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}",
+ )
+
+ # Security: Validate file is not empty
+ if len(file_content) == 0:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")
+
+ # Security: Validate MIME type (if python-magic is available)
+ _validate_mime_type(file_content)
+
+
+def _handle_upload_value_error(filename: str, error_message: str) -> None:
+ """Translate a parser ValueError into a sanitized HTTPException.
+
+ Always raises — never returns.
+ """
+ logger.error(f"ValueError processing report {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():
+ 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"
+ )
+
+
class UploadResponse(BaseModel):
"""Response model for report upload"""
@@ -89,42 +160,9 @@ async def upload_report(file: UploadFile = File(...)):
- Sanitized error messages
"""
try:
- # Security: Validate filename is provided
- if not file.filename:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required"
- )
-
- # Security: Validate file extension
- file_ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
- if file_ext not in ALLOWED_EXTENSIONS:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}",
- )
-
- # Read the file content
+ # Read content first so validators can inspect it
file_content = await file.read()
-
- # Security: Validate file is not empty
- if len(file_content) == 0:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")
-
- # Security: Validate MIME type using python-magic (if available)
- if HAS_MAGIC:
- 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}")
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Invalid file type. File must be XML, ZIP, or GZIP format.",
- )
- except Exception as e:
- # If magic fails, log but continue (fallback to extension check)
- logger.warning(f"MIME type detection failed: {str(e)}")
- else:
- logger.debug("MIME type validation skipped (python-magic not available)")
+ _validate_upload_file(file, file_content)
# Parse the report
parser = DMARCParser()
@@ -141,7 +179,6 @@ async def upload_report(file: UploadFile = File(...)):
# Validate domain format (not DNS resolution to avoid external calls)
is_valid, error_msg, error_code = validate_domain(domain, check_dns=False)
if not is_valid and error_code != DomainValidationError.DNS_RESOLUTION_FAILED:
- # Allow domains that fail DNS resolution but have valid format
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid domain in report: {error_msg}",
@@ -161,26 +198,10 @@ async def upload_report(file: UploadFile = File(...)):
)
except HTTPException:
- # Re-raise HTTP exceptions as-is
raise
except ValueError as e:
# Security: Sanitize error messages from parser
- error_message = str(e)
- # Log full error for debugging
- logger.error(f"ValueError processing report {file.filename}: {error_message}")
- # Return sanitized 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():
- 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"
- )
+ _handle_upload_value_error(file.filename, str(e))
except Exception as e:
# Security: Don't expose internal errors to client
logger.error(f"Unexpected error processing report {file.filename}: {str(e)}")
diff --git a/backend/app/main.py b/backend/app/main.py
index c71f364..fdb50a8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -148,7 +148,6 @@ def create_app() -> FastAPI:
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up background tasks on application shutdown"""
- global background_task
if background_task:
logger.info("Cancelling IMAP polling background task")
background_task.cancel()
diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py
index b58d2f6..aba4c3a 100644
--- a/backend/app/middleware/security.py
+++ b/backend/app/middleware/security.py
@@ -52,29 +52,29 @@ 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:
- #
+ #
# For script-src 'unsafe-inline':
# 1. Move all inline "}
- result = validate_domain_config(malicious_config)
+ def test_domain_config_xss_description(self):
+ result = validate_domain_config(
+ {"name": "example.com", "description": ""}
+ )
assert not result["valid"]
assert "description" in result["errors"]
class TestFileUploadSecurity:
- """Test file upload security features."""
+ """Test file upload size limits."""
def test_file_size_limit(self):
- """Test file size limit enforcement."""
- parser = DMARCParser()
-
- # Create a file that's too large (> 10 MB)
large_content = b"x" * (11 * 1024 * 1024)
-
- with pytest.raises(ValueError) as exc_info:
- parser.parse_file(large_content, "test.xml")
-
- assert "too large" in str(exc_info.value).lower()
+ with pytest.raises(ValueError, match="too large"):
+ DMARCParser.parse_file(large_content, "test.xml")
class TestXMLParsingSecurity:
- """Test XML parsing security features."""
+ """Test XML parsing security (defusedxml, XXE protection)."""
- def test_defusedxml_import(self):
- """Test that defusedxml is being used."""
+ def test_defusedxml_is_used(self):
import app.services.dmarc_parser as parser_module
- # Check that the module uses defusedxml
assert hasattr(parser_module, "ET")
- # The module name should contain 'defusedxml'
- assert (
- "defusedxml" in str(parser_module.ET.__name__).lower()
- or "defusedxml" in str(parser_module.ET.__module__).lower()
+ module_info = str(getattr(parser_module.ET, "__name__", "")) + str(
+ getattr(parser_module.ET, "__module__", "")
)
+ assert "defusedxml" in module_info.lower()
- def test_xml_entity_expansion_protection(self):
- """Test protection against XML entity expansion attacks."""
- parser = DMARCParser()
-
- # XXE attack payload
- xxe_payload = b"""
+ def test_xxe_protection(self):
+ """defusedxml should prevent XXE entity expansion."""
+ xxe_payload = b"""\
+
]>
@@ -195,23 +144,10 @@ class TestXMLParsingSecurity:
"""
-
- # Should either fail parsing or not expand the entity
- # defusedxml should prevent this
+ # defusedxml should raise an error or not expand the entity
try:
- result = parser.parse_file(xxe_payload, "test.xml")
- # If it doesn't raise an error, the entity should not be expanded
+ result = DMARCParser.parse_file(xxe_payload, "test.xml")
org_name = result.get("org_name", "")
- assert not org_name.startswith("root:") and "/bin" not in org_name
+ assert "root:" not in org_name and "/bin" not in org_name
except Exception:
- # Expected - defusedxml should prevent parsing
- pass
-
-
-# Note: TestSecurityHeaders and TestErrorHandling tests are not implemented
-# because they require proper async client setup. These will be added in a future PR
-# with proper integration test infrastructure.
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
+ pass # Expected – defusedxml blocks DTD processing
diff --git a/backend/app/utils/domain_validator.py b/backend/app/utils/domain_validator.py
index 952dd03..a329249 100644
--- a/backend/app/utils/domain_validator.py
+++ b/backend/app/utils/domain_validator.py
@@ -17,6 +17,45 @@ class DomainValidationError:
DNS_RESOLUTION_FAILED = "dns_resolution_failed"
+def _validate_domain_characters(
+ domain_name: str,
+) -> Tuple[bool, Optional[str], Optional[str]]:
+ """Check a domain name for whitespace and suspicious characters."""
+ if " " in domain_name or "\t" in domain_name or "\n" in domain_name:
+ return (
+ False,
+ "Domain name cannot contain whitespace",
+ DomainValidationError.INVALID_CHARACTERS,
+ )
+ if any(char in domain_name for char in ["<", ">", '"', "'", "\\", "|", ";", "&", "$", "`"]):
+ return (
+ False,
+ "Domain name contains invalid characters",
+ DomainValidationError.INVALID_CHARACTERS,
+ )
+ return True, None, None
+
+
+def _validate_domain_labels(
+ labels: list,
+) -> Tuple[bool, Optional[str], Optional[str]]:
+ """Check each DNS label for length and hyphen-placement rules."""
+ for label in labels:
+ if len(label) > 63:
+ return (
+ False,
+ f"Domain label too long: '{label}' (max 63 characters per label)",
+ DomainValidationError.LABEL_TOO_LONG,
+ )
+ if label.startswith("-") or label.endswith("-"):
+ return (
+ False,
+ f"Domain label cannot start or end with hyphen: '{label}'",
+ DomainValidationError.INVALID_LABEL,
+ )
+ return True, None, None
+
+
def validate_domain(
domain_name: str, check_dns: bool = True
) -> Tuple[bool, Optional[str], Optional[str]]:
@@ -41,21 +80,10 @@ def validate_domain(
if len(domain_name) > 253:
return False, "Domain name too long (max 253 characters)", DomainValidationError.TOO_LONG
- # Security: Check for whitespace
- if " " in domain_name or "\t" in domain_name or "\n" in domain_name:
- return (
- False,
- "Domain name cannot contain whitespace",
- DomainValidationError.INVALID_CHARACTERS,
- )
-
- # Security: Check for suspicious characters
- if any(char in domain_name for char in ["<", ">", '"', "'", "\\", "|", ";", "&", "$", "`"]):
- return (
- False,
- "Domain name contains invalid characters",
- DomainValidationError.INVALID_CHARACTERS,
- )
+ # Security: Check for whitespace and suspicious characters
+ char_ok, char_msg, char_code = _validate_domain_characters(domain_name)
+ if not char_ok:
+ return False, char_msg, char_code
# Check domain format with regex
# This regex allows domain names with alphanumeric characters, hyphens,
@@ -67,19 +95,9 @@ def validate_domain(
# Security: Check each label length (max 63 characters per label)
labels = domain_name.split(".")
- for label in labels:
- if len(label) > 63:
- return (
- False,
- f"Domain label too long: '{label}' (max 63 characters per label)",
- DomainValidationError.LABEL_TOO_LONG,
- )
- if label.startswith("-") or label.endswith("-"):
- return (
- False,
- f"Domain label cannot start or end with hyphen: '{label}'",
- DomainValidationError.INVALID_LABEL,
- )
+ label_ok, label_msg, label_code = _validate_domain_labels(labels)
+ if not label_ok:
+ return False, label_msg, label_code
# Check if domain exists by attempting to resolve DNS (optional)
if check_dns:
diff --git a/docs/development/agents.md b/docs/development/agents.md
index 8aba023..da3b1bc 100644
--- a/docs/development/agents.md
+++ b/docs/development/agents.md
@@ -91,6 +91,46 @@ async def list_domains():
return domains
```
+## Mandatory Pre-Commit Checks
+
+Before committing any code to the repository, **always** run the following checks and ensure they pass:
+
+### Linting (Required)
+
+```bash
+# Format check – must pass with zero reformatted files
+black --check backend/app
+
+# Import order check
+isort --check-only backend/app
+
+# Flake8 lint
+flake8 backend/app --max-line-length=100 --extend-ignore=E203,W503,E501
+```
+
+If Black or isort report issues, fix them automatically:
+
+```bash
+black backend/app
+isort backend/app
+```
+
+### Test Coverage (Required)
+
+All changes must include passing tests. Run the test suite with coverage:
+
+```bash
+cd backend
+pytest --cov=app --cov-report=term-missing
+```
+
+Coverage goals:
+- Overall coverage: **80%+**
+- Core modules (`core/`, `services/`, `utils/`): **90%+**
+- New code: **100%** of new functions and branches should be covered
+
+When adding new features, always add corresponding tests in `backend/app/tests/`.
+
## Best Practices
### 1. Start Small
diff --git a/docs/development/testing.md b/docs/development/testing.md
index 614f5d1..04347c2 100644
--- a/docs/development/testing.md
+++ b/docs/development/testing.md
@@ -1,318 +1,149 @@
# Testing
-This guide covers the testing methodology for DMARQ, including unit tests, integration tests, and end-to-end testing.
+This guide covers the testing methodology for DMARQ, including unit tests, integration tests, and how to run them.
## Testing Philosophy
-DMARQ follows a comprehensive testing approach to ensure reliability:
+DMARQ follows a practical testing approach:
- **Unit Tests**: Test individual functions and classes in isolation
-- **Integration Tests**: Test components working together
-- **End-to-End Tests**: Test the complete application flow
-- **Performance Tests**: Ensure the system can handle expected load
+- **Integration Tests**: Test API endpoints with the full FastAPI stack
+- **Security Tests**: Verify security controls (input validation, XXE protection, API keys)
## Test Structure
-The test directory structure follows the application structure:
-
```
backend/app/tests/
-├── conftest.py # Pytest fixtures and configuration
-├── test_api.py # API endpoint tests
-├── test_dmarc_parser.py # DMARC parser tests
-├── test_models.py # Database model tests
-├── test_reports_api.py # Reports API tests
-├── unit/ # Unit tests
-│ ├── test_domain_validator.py
-│ ├── test_utils.py
-│ └── ...
-├── integration/ # Integration tests
-│ ├── test_database.py
-│ ├── test_imap.py
-│ └── ...
-└── e2e/ # End-to-end tests
- ├── test_report_flow.py
- └── ...
+├── conftest.py # Pytest fixtures (DB session, TestClient, ReportStore reset)
+├── test_api.py # API endpoint tests (health, domains, upload validation)
+├── test_dmarc_parser.py # DMARC XML/ZIP parser tests
+├── test_models.py # SQLAlchemy ORM model tests
+├── test_report_store.py # In-memory ReportStore tests
+├── test_reports_api.py # Reports upload and retrieval API tests
+└── test_security.py # Security: API keys, domain validation, XML security
```
## Setting Up the Test Environment
### Prerequisites
-- Python 3.9+
-- pytest and required plugins
+- Python 3.10+
+- Dependencies from `backend/requirements.txt`
### Installation
```bash
cd backend
-pip install -r requirements-dev.txt
+pip install -r requirements.txt
```
-This will install:
-- pytest
-- pytest-cov (for coverage reports)
-- pytest-mock (for mocking)
-- pytest-asyncio (for async tests)
-
## Running Tests
### All Tests
-To run all tests:
-
```bash
cd backend
pytest
```
-### Specific Tests
-
-To run specific test files:
+### With Coverage
```bash
-pytest tests/test_dmarc_parser.py
+pytest --cov=app --cov-report=term-missing
```
-To run tests matching a pattern:
+### Specific Test File
```bash
-pytest -k "parser" # Runs tests with "parser" in the name
+pytest app/tests/test_dmarc_parser.py
```
-### Test Coverage
-
-To generate a coverage report:
+### Tests Matching a Pattern
```bash
-pytest --cov=app
+pytest -k "parser"
```
-For an HTML coverage report:
+### HTML Coverage Report
```bash
pytest --cov=app --cov-report=html
+# Open htmlcov/index.html
```
-Then open `htmlcov/index.html` to view the report.
+## Key Fixtures (conftest.py)
+
+| Fixture | Scope | Description |
+|---------|-------|-------------|
+| `test_app` | function | Fresh FastAPI application instance |
+| `db_session` | function | In-memory SQLite session, tables created/dropped per test |
+| `client` | function | `TestClient` wired to test DB |
+| `_reset_report_store` | function (autouse) | Clears the `ReportStore` singleton between tests |
+
+The `db_session` fixture uses `sqlite://` (true in-memory) so each test gets a clean database. All ORM models are imported in `conftest.py` to ensure `Base.metadata.create_all()` knows every table.
## Writing Tests
-### Fixtures
-
-We use pytest fixtures for test setup and teardown. Common fixtures are defined in `conftest.py`:
+### Unit Tests (no fixtures needed)
```python
-import pytest
-from sqlalchemy import create_engine
-from sqlalchemy.orm import sessionmaker
-from app.models.base import Base
-from app.core.database import get_db
+from app.utils.domain_validator import validate_domain
-@pytest.fixture
-def db_engine():
- engine = create_engine("sqlite:///:memory:")
- Base.metadata.create_all(engine)
- return engine
-
-@pytest.fixture
-def db_session(db_engine):
- Session = sessionmaker(bind=db_engine)
- session = Session()
- yield session
- session.close()
-
-@pytest.fixture
-def test_app(db_session):
- from app.main import app
- app.dependency_overrides[get_db] = lambda: db_session
- return app
+def test_valid_domain():
+ is_valid, error, _ = validate_domain("example.com", check_dns=False)
+ assert is_valid
```
-### Unit Tests
-
-Unit tests should focus on testing a single function or class in isolation, using mocks for dependencies:
+### Model Tests (use `db_session`)
```python
-from app.utils.domain_validator import is_valid_domain
-import pytest
+from app.models.domain import Domain
-def test_is_valid_domain():
- # Valid domains
- assert is_valid_domain("example.com") is True
- assert is_valid_domain("sub.example.com") is True
-
- # Invalid domains
- assert is_valid_domain("invalid..com") is False
- assert is_valid_domain("a" * 300 + ".com") is False
-```
-
-### API Tests
-
-API tests use the FastAPI TestClient:
-
-```python
-from fastapi.testclient import TestClient
-
-def test_get_domains(test_app, db_session):
- # Add test data to db_session
- # ...
-
- client = TestClient(test_app)
- response = client.get("/api/v1/domains")
- assert response.status_code == 200
- data = response.json()
- assert len(data["domains"]) == 2 # Assuming 2 domains were added
-```
-
-### Mocking
-
-We use pytest-mock for mocking:
-
-```python
-def test_imap_client(mocker):
- # Mock the imaplib.IMAP4_SSL class
- mock_imap = mocker.patch("imaplib.IMAP4_SSL")
- mock_imap.return_value.login.return_value = ("OK", [])
- mock_imap.return_value.select.return_value = ("OK", [b"10"])
-
- from app.services.imap_client import IMAPClient
- client = IMAPClient("imap.example.com", "user", "pass")
- result = client.connect()
-
- assert result is True
- mock_imap.return_value.login.assert_called_once()
-```
-
-### Testing Async Code
-
-For async functions, use pytest-asyncio:
-
-```python
-import pytest
-
-@pytest.mark.asyncio
-async def test_async_function():
- from app.services.report_processor import process_report_async
- result = await process_report_async("test_data")
- assert result is not None
-```
-
-## Testing Database Models
-
-When testing database models, use an in-memory SQLite database:
-
-```python
-def test_domain_model(db_session):
- from app.models.domain import Domain
-
- domain = Domain(name="example.com")
+def test_create_domain(db_session):
+ domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
-
- fetched = db_session.query(Domain).filter_by(name="example.com").first()
- assert fetched is not None
- assert fetched.name == "example.com"
+ assert domain.id is not None
```
-## Test Data
-
-### Sample Files
-
-Sample DMARC report files for testing are stored in:
-```
-backend/app/tests/data/
-```
-
-These include:
-- Sample XML reports
-- Compressed reports (ZIP, GZ)
-- Invalid reports for error testing
-
-### Factories
-
-For generating test data, we use factory_boy:
+### API Tests (use `client`)
```python
-import factory
-from app.models.domain import Domain
-from app.models.report import Report
-
-class DomainFactory(factory.Factory):
- class Meta:
- model = Domain
-
- name = factory.Sequence(lambda n: f"domain-{n}.com")
- active = True
-
-class ReportFactory(factory.Factory):
- class Meta:
- model = Report
-
- domain = factory.SubFactory(DomainFactory)
- report_id = factory.Sequence(lambda n: f"report-{n}")
- begin_date = factory.LazyFunction(lambda: datetime.now() - timedelta(days=1))
- end_date = factory.LazyFunction(lambda: datetime.now())
- org_name = "test-org"
+def test_health_check(client):
+ response = client.get("/api/v1/health")
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
```
-## Continuous Integration
+## Linting Before Committing
-Tests are automatically run on every pull request using GitHub Actions.
-
-The CI workflow:
-1. Sets up the test environment
-2. Runs linting checks
-3. Runs the test suite
-4. Generates coverage reports
-5. Reports test results
-
-## Performance Testing
-
-For performance testing, we use Locust:
+Always run linting before committing:
```bash
-cd backend/performance_tests
-locust -f locustfile.py
+black --check backend/app
+isort --check-only backend/app
+flake8 backend/app --max-line-length=100 --extend-ignore=E203,W503
```
-This starts a web interface at http://localhost:8089 to configure and run performance tests.
-
-## Debugging Tests
-
-When tests fail, you can use pytest's verbose mode for more details:
+Auto-fix formatting:
```bash
-pytest -vv
+black backend/app
+isort backend/app
```
-For even more information, add the `-s` flag to show print statements:
-
-```bash
-pytest -vvs
-```
-
-## Writing Testable Code
-
-To make testing easier:
-
-1. **Dependency Injection**: Pass dependencies rather than creating them inside functions
-2. **Single Responsibility**: Keep functions focused on a single task
-3. **Pure Functions**: When possible, write pure functions that don't modify state
-4. **Testable Units**: Structure code in small, testable units
-5. **Configuration**: Make configuration injectable for tests
-
## Code Coverage Goals
-Our coverage goals are:
-- Overall coverage: 80%+
-- Core modules: 90%+
-- API endpoints: 100%
+- Overall coverage: **80%+**
+- Core modules: **90%+**
+- New code should have **100%** branch coverage
-## Reporting Bugs
+## Continuous Integration
-If you find a bug:
-1. Write a failing test that reproduces the issue
-2. File an issue describing the bug
-3. Link the failing test in the issue
-4. If possible, submit a PR with a fix
\ No newline at end of file
+Tests run automatically on every push and PR via GitHub Actions (`.github/workflows/test.yml`).
+
+The CI workflow:
+1. Installs dependencies (Python 3.10)
+2. Runs `pytest` with coverage
+3. Runs linting checks (Black, isort, Flake8, Pylint)
+4. Uploads coverage to Codecov
\ No newline at end of file