Add initial MVP documentation for DMARQ platform, detailing backend architecture, frontend implementation, and deployment structure

This commit is contained in:
Christian Krakau-Louis
2025-04-17 15:20:42 +02:00
parent 363a31c02d
commit f910cb0ba4
33 changed files with 4176 additions and 14 deletions
+104
View File
@@ -0,0 +1,104 @@
import asyncio
import os
from typing import AsyncGenerator, Generator
import pytest
import pytest_asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.core.security import get_password_hash
from app.models.user import User
# Use in-memory SQLite database for tests
TEST_DATABASE_URL = "sqlite:///./test.db"
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for each test case."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
def test_app() -> FastAPI:
# Avoid circular import
from app.main import create_app
app = create_app()
return app
@pytest.fixture(scope="function")
def db_session():
# Create the SQLite database engine
engine = create_engine(TEST_DATABASE_URL)
# Create all tables
Base.metadata.create_all(engine)
# Create a new session
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# Drop all tables after the test
Base.metadata.drop_all(engine)
@pytest.fixture(scope="function")
def client(test_app: FastAPI, db_session):
# Override the get_db dependency to use the test database
def override_get_db():
try:
yield db_session
finally:
pass
test_app.dependency_overrides[get_db] = override_get_db
# Use the FastAPI TestClient
with TestClient(test_app) as test_client:
yield test_client
@pytest_asyncio.fixture(scope="function")
async def async_client(test_app: FastAPI, db_session):
# Override the get_db dependency to use the test database
def override_get_db():
try:
yield db_session
finally:
pass
test_app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=test_app, base_url="http://testserver") as ac:
yield ac
@pytest.fixture(scope="function")
def test_user(db_session):
"""Create a test user in the database."""
user = User(
email="test@example.com",
hashed_password=get_password_hash("password"),
is_active=True,
is_superuser=False,
is_verified=True
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
+61
View File
@@ -0,0 +1,61 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
def test_read_health(client: TestClient):
"""Test health check endpoint"""
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "version" in data
def test_read_domains_empty(client: TestClient):
"""Test reading domains when none exist"""
response = client.get("/api/v1/domains")
assert response.status_code == 200
data = response.json()
assert data == []
def test_read_domains(client: TestClient, db_session: Session):
"""Test reading domains"""
# Create some test domains
domain1 = Domain(name="example.com", description="Example Domain", active=True)
domain2 = Domain(name="test.com", description="Test Domain", active=True)
db_session.add_all([domain1, domain2])
db_session.commit()
response = client.get("/api/v1/domains")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert {"name": "example.com", "description": "Example Domain"}.items() <= data[0].items()
assert {"name": "test.com", "description": "Test Domain"}.items() <= data[1].items()
def test_create_domain(client: TestClient):
"""Test creating a new domain"""
response = client.post(
"/api/v1/domains",
json={"name": "newdomain.com", "description": "New Domain", "active": True}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "newdomain.com"
assert data["description"] == "New Domain"
assert data["active"] is True
assert "id" in data
# Check that the domain was actually created
response = client.get("/api/v1/domains")
assert response.status_code == 200
domains = response.json()
assert any(d["name"] == "newdomain.com" for d in domains)
+124
View File
@@ -0,0 +1,124 @@
import os
import pytest
from unittest.mock import patch, MagicMock
from xml.etree import ElementTree as ET
from app.services.dmarc_parser import (
DMARCParser,
parse_aggregate_report_xml,
parse_aggregate_report_zip,
)
class TestDMARCParser:
def setup_method(self):
"""Set up test fixtures"""
self.parser = DMARCParser()
# Sample XML string for testing
self.sample_xml = """<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
def test_parse_aggregate_report_xml(self):
"""Test parsing an XML aggregate report"""
result = parse_aggregate_report_xml(self.sample_xml)
# Verify report metadata
assert result['report_metadata']['org_name'] == 'google.com'
assert result['report_metadata']['email'] == 'noreply-dmarc-support@google.com'
assert result['report_metadata']['report_id'] == '123456789'
assert result['report_metadata']['begin_date'] == 1597449600
assert result['report_metadata']['end_date'] == 1597535999
# Verify policy published
assert result['policy_published']['domain'] == 'example.com'
assert result['policy_published']['policy'] == 'none'
# Verify record data
assert len(result['records']) == 1
record = result['records'][0]
assert record['source_ip'] == '203.0.113.1'
assert record['count'] == 2
assert record['policy_evaluated']['disposition'] == 'none'
assert record['policy_evaluated']['dkim'] == 'pass'
assert record['policy_evaluated']['spf'] == 'fail'
assert record['identifiers']['header_from'] == 'example.com'
@patch('app.services.dmarc_parser.zipfile.ZipFile')
def test_parse_aggregate_report_zip(self, mock_zipfile):
"""Test parsing a zipped aggregate report"""
# Setup mock zipfile extraction
mock_zip_instance = MagicMock()
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
mock_zip_instance.namelist.return_value = ['report.xml']
mock_zip_instance.read.return_value = self.sample_xml.encode('utf-8')
result = parse_aggregate_report_zip('/fake/path/report.zip')
# Assertions similar to test_parse_aggregate_report_xml
assert result['report_metadata']['org_name'] == 'google.com'
assert len(result['records']) == 1
def test_extract_authentication_results(self):
"""Test extracting authentication results from report"""
# Parse the sample XML
root = ET.fromstring(self.sample_xml)
record_elem = root.find('./record')
auth_results = self.parser._extract_authentication_results(record_elem)
# Verify DKIM results
assert len(auth_results['dkim']) == 1
assert auth_results['dkim'][0]['domain'] == 'example.com'
assert auth_results['dkim'][0]['result'] == 'pass'
assert auth_results['dkim'][0]['selector'] == 'default'
# Verify SPF results
assert len(auth_results['spf']) == 1
assert auth_results['spf'][0]['domain'] == 'example.com'
assert auth_results['spf'][0]['result'] == 'fail'
+151
View File
@@ -0,0 +1,151 @@
import pytest
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
class TestDomainModel:
"""Tests for the Domain model"""
def test_create_domain(self, db_session: Session):
"""Test creating a domain in the database"""
domain = Domain(
name="example.com",
description="Test domain",
active=True,
dmarc_policy="quarantine"
)
db_session.add(domain)
db_session.commit()
db_session.refresh(domain)
assert domain.id is not None
assert domain.name == "example.com"
assert domain.description == "Test domain"
assert domain.active is True
assert domain.dmarc_policy == "quarantine"
def test_domain_reports_relationship(self, db_session: Session):
"""Test the relationship between domains and DMARC reports"""
# Create a domain
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
# Create reports for the domain
report1 = DMARCReport(
domain_id=domain.id,
report_id="report1",
org_name="Google",
begin_date=1597449600,
end_date=1597535999,
source_email="noreply-dmarc-support@google.com"
)
report2 = DMARCReport(
domain_id=domain.id,
report_id="report2",
org_name="Microsoft",
begin_date=1597536000,
end_date=1597622399,
source_email="dmarc@microsoft.com"
)
db_session.add_all([report1, report2])
db_session.commit()
# Query the domain and check its reports
domain = db_session.query(Domain).filter_by(name="example.com").first()
assert domain is not None
assert len(domain.reports) == 2
assert domain.reports[0].report_id in ["report1", "report2"]
assert domain.reports[1].report_id in ["report1", "report2"]
class TestDMARCReportModel:
"""Tests for the DMARCReport model"""
def test_create_report(self, db_session: Session):
"""Test creating a DMARC report in the database"""
# Create a domain first
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
# Create a report
report = DMARCReport(
domain_id=domain.id,
report_id="123456789",
org_name="Google",
begin_date=1597449600,
end_date=1597535999,
source_email="noreply-dmarc-support@google.com",
policy="none",
adkim="r",
aspf="r",
percentage=100
)
db_session.add(report)
db_session.commit()
db_session.refresh(report)
assert report.id is not None
assert report.domain_id == domain.id
assert report.report_id == "123456789"
assert report.org_name == "Google"
assert report.begin_date == 1597449600
assert report.policy == "none"
def test_report_records_relationship(self, db_session: Session):
"""Test the relationship between reports and records"""
# Create domain and report
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
report = DMARCReport(
domain_id=domain.id,
report_id="123456789",
org_name="Google",
begin_date=1597449600,
end_date=1597535999,
source_email="noreply-dmarc-support@google.com"
)
db_session.add(report)
db_session.commit()
# Create records for the report
record1 = ReportRecord(
report_id=report.id,
source_ip="203.0.113.1",
count=2,
disposition="none",
dkim="pass",
spf="fail",
header_from="example.com",
envelope_from=None
)
record2 = ReportRecord(
report_id=report.id,
source_ip="203.0.113.2",
count=5,
disposition="none",
dkim="pass",
spf="pass",
header_from="example.com",
envelope_from=None
)
db_session.add_all([record1, record2])
db_session.commit()
# Query the report and check its records
report = db_session.query(DMARCReport).filter_by(report_id="123456789").first()
assert report is not None
assert len(report.records) == 2
assert report.records[0].source_ip in ["203.0.113.1", "203.0.113.2"]
assert report.records[1].source_ip in ["203.0.113.1", "203.0.113.2"]
+166
View File
@@ -0,0 +1,166 @@
import pytest
import io
import zipfile
import os
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
def test_read_reports_empty(client: TestClient):
"""Test reading reports when none exist"""
response = client.get("/api/v1/reports")
assert response.status_code == 200
data = response.json()
assert data == []
def test_upload_report_no_domain(client: TestClient):
"""Test uploading a report when domain doesn't exist"""
# Create a simple XML report
xml_content = """<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>nonexistentdomain.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>nonexistentdomain.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>nonexistentdomain.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>nonexistentdomain.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
# Create an in-memory zip file
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
zip_file.writestr('report.xml', xml_content)
zip_buffer.seek(0)
# Upload the zip file
response = client.post(
"/api/v1/reports/upload",
files={"file": ("report.zip", zip_buffer, "application/zip")}
)
# Should return an error since domain doesn't exist
assert response.status_code == 404
data = response.json()
assert "domain not found" in data["detail"].lower()
def test_upload_report_success(client: TestClient, db_session: Session):
"""Test successfully uploading a report"""
# Create a domain first
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
# Create a simple XML report
xml_content = """<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
# Create an in-memory zip file
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
zip_file.writestr('report.xml', xml_content)
zip_buffer.seek(0)
# Upload the zip file
response = client.post(
"/api/v1/reports/upload",
files={"file": ("report.zip", zip_buffer, "application/zip")}
)
# Should be successful
assert response.status_code == 201
data = response.json()
assert data["success"] is True
assert "report_id" in data
# Check that the report was actually created
response = client.get("/api/v1/reports")
assert response.status_code == 200
reports = response.json()
assert len(reports) == 1
assert reports[0]["report_id"] == "123456789"
assert reports[0]["org_name"] == "google.com"