feat: initial release

This commit is contained in:
Heiko
2025-12-18 19:16:04 +01:00
commit f038d6a3fc
38 changed files with 6765 additions and 0 deletions

0
tests/__init__.py Normal file
View File

410
tests/conftest.py Normal file
View File

@@ -0,0 +1,410 @@
"""Pytest configuration and shared fixtures."""
import sqlite3
from pathlib import Path
from typing import Any
import pytest
@pytest.fixture
def mock_scan_metadata() -> dict[str, Any]:
"""Provide mock scan metadata."""
return {
"scan_id": 5,
"hostname": "example.com",
"fqdn": "example.com",
"ipv4": "192.168.1.1",
"ipv6": "2001:db8::1",
"timestamp": "2025-01-08T10:30:00.123456",
"duration": 12.34,
"ports": ["443", "636"],
}
@pytest.fixture
def mock_scan_data(mock_scan_metadata: dict[str, Any]) -> dict[str, Any]:
"""Provide complete mock scan data structure."""
return {
"metadata": mock_scan_metadata,
"summary": {
"total_ports": 2,
"successful_ports": 2,
"total_cipher_suites": 50,
"compliant_cipher_suites": 45,
"cipher_suite_percentage": 90,
"total_groups": 10,
"compliant_groups": 8,
"group_percentage": 80,
"critical_vulnerabilities": 0,
},
"ports_data": {
443: {
"port": 443,
"status": "completed",
"tls_version": "1.3",
"cipher_suites": {
"1.2": {
"accepted": [
{
"name": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"iana_recommended": "Y",
"bsi_approved": True,
"bsi_valid_until": "2029",
"compliant": True,
},
],
"rejected": [],
"rejected_total": 0,
},
"1.3": {
"accepted": [
{
"name": "TLS_AES_128_GCM_SHA256",
"iana_recommended": "Y",
"bsi_approved": True,
"bsi_valid_until": "2031",
"compliant": True,
},
],
"rejected": [
{
"name": "TLS_AES_128_CCM_SHA256",
"iana_recommended": "Y",
"bsi_approved": True,
"bsi_valid_until": "2031",
},
],
"rejected_total": 1,
},
},
"supported_groups": [
{
"name": "x25519",
"iana_recommended": "Y",
"bsi_approved": False,
"bsi_valid_until": None,
"compliant": True,
},
],
"missing_recommended_groups": {
"bsi_approved": [
{
"name": "brainpoolP256r1",
"tls_versions": ["1.2"],
"valid_until": "2031",
},
],
"iana_recommended": [],
},
"certificates": [
{
"position": 0,
"subject": "CN=example.com",
"issuer": "CN=Test CA",
"not_before": "2024-01-01",
"not_after": "2025-12-31",
"key_type": "RSA",
"key_bits": 2048,
},
],
"vulnerabilities": [
{
"type": "Heartbleed",
"vulnerable": False,
"details": "Not vulnerable",
},
],
"protocol_features": [
{
"name": "TLS Compression",
"supported": False,
"details": "Disabled",
},
],
"session_features": [
{
"type": "Session Resumption",
"client_initiated": True,
"secure": True,
"session_id_supported": True,
"ticket_supported": True,
"details": "Supported",
},
],
"http_headers": [
{
"name": "Strict-Transport-Security",
"is_present": True,
"value": "max-age=31536000",
},
],
"compliance": {
"cipher_suites_checked": 45,
"cipher_suites_passed": 40,
"cipher_suite_percentage": 88.89,
"groups_checked": 5,
"groups_passed": 4,
"group_percentage": 80.0,
},
},
636: {
"port": 636,
"status": "completed",
"tls_version": "1.2",
"cipher_suites": {
"1.2": {
"accepted": [
{
"name": "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384",
"iana_recommended": "Y",
"bsi_approved": True,
"bsi_valid_until": "2029",
"compliant": True,
},
],
"rejected": [],
"rejected_total": 0,
},
},
"supported_groups": [],
"missing_recommended_groups": {
"bsi_approved": [],
"iana_recommended": [],
},
"certificates": [],
"vulnerabilities": [],
"protocol_features": [],
"session_features": [],
"http_headers": [],
"compliance": {
"cipher_suites_checked": 5,
"cipher_suites_passed": 5,
"cipher_suite_percentage": 100.0,
"groups_checked": 0,
"groups_passed": 0,
"group_percentage": 0.0,
},
},
},
}
@pytest.fixture
def temp_output_dir(tmp_path: Path) -> Path:
"""Provide temporary output directory."""
output_dir = tmp_path / "output"
output_dir.mkdir()
return output_dir
# SQL for database views
VIEWS_SQL = """
-- View: Cipher suites with compliance information
CREATE VIEW IF NOT EXISTS v_cipher_suites_with_compliance AS
SELECT
scs.scan_id,
scs.port,
scs.tls_version,
scs.cipher_suite_name,
scs.accepted,
scs.iana_value,
scs.key_size,
scs.is_anonymous,
sc.iana_recommended,
sc.bsi_approved,
sc.bsi_valid_until,
sc.passed as compliant,
CASE
WHEN scs.accepted = 1 THEN sc.iana_recommended
ELSE iana.recommended
END as iana_recommended_final,
CASE
WHEN scs.accepted = 1 THEN sc.bsi_approved
ELSE (bsi.name IS NOT NULL)
END as bsi_approved_final,
CASE
WHEN scs.accepted = 1 THEN sc.bsi_valid_until
ELSE bsi.valid_until
END as bsi_valid_until_final
FROM scan_cipher_suites scs
LEFT JOIN scan_compliance_status sc
ON scs.scan_id = sc.scan_id
AND scs.port = sc.port
AND sc.check_type = 'cipher_suite'
AND scs.cipher_suite_name = sc.item_name
LEFT JOIN iana_tls_cipher_suites iana
ON scs.cipher_suite_name = iana.description
LEFT JOIN bsi_tr_02102_2_tls bsi
ON scs.cipher_suite_name = bsi.name
AND scs.tls_version = bsi.tls_version
AND bsi.category = 'cipher_suite';
-- View: Supported groups with compliance information
CREATE VIEW IF NOT EXISTS v_supported_groups_with_compliance AS
SELECT
ssg.scan_id,
ssg.port,
ssg.group_name,
ssg.iana_value,
ssg.openssl_nid,
sc.iana_recommended,
sc.bsi_approved,
sc.bsi_valid_until,
sc.passed as compliant
FROM scan_supported_groups ssg
LEFT JOIN scan_compliance_status sc
ON ssg.scan_id = sc.scan_id
AND ssg.port = sc.port
AND sc.check_type = 'supported_group'
AND ssg.group_name = sc.item_name;
-- View: Certificates with compliance information
CREATE VIEW IF NOT EXISTS v_certificates_with_compliance AS
SELECT
c.scan_id,
c.port,
c.position,
c.subject,
c.issuer,
c.serial_number,
c.not_before,
c.not_after,
c.key_type,
c.key_bits,
c.signature_algorithm,
c.fingerprint_sha256,
MAX(cs.passed) as compliant,
MAX(cs.details) as compliance_details
FROM scan_certificates c
LEFT JOIN scan_compliance_status cs
ON c.scan_id = cs.scan_id
AND c.port = cs.port
AND cs.check_type = 'certificate'
AND cs.item_name = (c.key_type || ' ' || c.key_bits || ' Bit')
GROUP BY c.scan_id, c.port, c.position, c.subject, c.issuer, c.serial_number,
c.not_before, c.not_after, c.key_type, c.key_bits,
c.signature_algorithm, c.fingerprint_sha256;
-- View: Port compliance summary
CREATE VIEW IF NOT EXISTS v_port_compliance_summary AS
SELECT
scan_id,
port,
check_type,
COUNT(*) as total,
SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) as passed,
ROUND(CAST(SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100, 1) as percentage
FROM scan_compliance_status
GROUP BY scan_id, port, check_type;
-- View: Missing BSI-approved groups
CREATE VIEW IF NOT EXISTS v_missing_bsi_groups AS
SELECT
s.scan_id,
s.ports,
bsi.name as group_name,
bsi.tls_version,
bsi.valid_until
FROM scans s
CROSS JOIN (
SELECT DISTINCT name, tls_version, valid_until
FROM bsi_tr_02102_2_tls
WHERE category = 'dh_group'
) bsi
WHERE NOT EXISTS (
SELECT 1
FROM scan_supported_groups ssg
WHERE ssg.scan_id = s.scan_id
AND LOWER(ssg.group_name) = LOWER(bsi.name)
);
-- View: Missing IANA-recommended groups
CREATE VIEW IF NOT EXISTS v_missing_iana_groups AS
SELECT
s.scan_id,
s.ports,
iana.description as group_name,
iana.value as iana_value
FROM scans s
CROSS JOIN (
SELECT description, value
FROM iana_tls_supported_groups
WHERE recommended = 'Y'
) iana
WHERE NOT EXISTS (
SELECT 1
FROM scan_supported_groups ssg
WHERE ssg.scan_id = s.scan_id
AND LOWER(ssg.group_name) = LOWER(iana.description)
)
AND NOT EXISTS (
SELECT 1
FROM bsi_tr_02102_2_tls bsi
WHERE LOWER(bsi.name) = LOWER(iana.description)
AND bsi.category = 'dh_group'
);
"""
@pytest.fixture
def test_db() -> sqlite3.Connection:
"""Provide in-memory test database with crypto standards and scan data."""
conn = sqlite3.connect(":memory:")
# 1. Copy crypto_standards.db to memory
standards_path = (
Path(__file__).parent.parent / "src/sslysze_scan/data/crypto_standards.db"
)
if standards_path.exists():
with sqlite3.connect(str(standards_path)) as src_conn:
for line in src_conn.iterdump():
conn.execute(line)
# 2. Copy test_scan.db data to memory (skip CREATE and csv_export_metadata)
fixtures_dir = Path(__file__).parent / "fixtures"
test_scan_path = fixtures_dir / "test_scan.db"
if test_scan_path.exists():
with sqlite3.connect(str(test_scan_path)) as src_conn:
for line in src_conn.iterdump():
if not line.startswith("CREATE ") and "csv_export_metadata" not in line:
conn.execute(line)
# 3. Create views
conn.executescript(VIEWS_SQL)
conn.commit()
yield conn
conn.close()
@pytest.fixture
def test_db_path(tmp_path: Path) -> str:
"""Provide test database as file path for functions expecting a path."""
db_path = tmp_path / "test.db"
conn = sqlite3.connect(str(db_path))
# 1. Copy crypto_standards.db to file
standards_path = (
Path(__file__).parent.parent / "src/sslysze_scan/data/crypto_standards.db"
)
if standards_path.exists():
with sqlite3.connect(str(standards_path)) as src_conn:
for line in src_conn.iterdump():
conn.execute(line)
# 2. Copy test_scan.db data to file (skip CREATE and csv_export_metadata)
fixtures_dir = Path(__file__).parent / "fixtures"
test_scan_path = fixtures_dir / "test_scan.db"
if test_scan_path.exists():
with sqlite3.connect(str(test_scan_path)) as src_conn:
for line in src_conn.iterdump():
if not line.startswith("CREATE ") and "csv_export_metadata" not in line:
conn.execute(line)
# 3. Create views
conn.executescript(VIEWS_SQL)
conn.commit()
conn.close()
return str(db_path)

1
tests/fixtures/__init__.py vendored Normal file
View File

@@ -0,0 +1 @@
"""Test fixtures package."""

BIN
tests/fixtures/test_scan.db vendored Normal file

Binary file not shown.

26
tests/test_cli.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for CLI argument parsing."""
import pytest
from sslysze_scan.cli import parse_host_ports
class TestParseHostPorts:
"""Tests for parse_host_ports function."""
def test_parse_host_ports_multiple_ports(self) -> None:
"""Test parsing hostname with multiple ports."""
hostname, ports = parse_host_ports("example.com:443,636,993")
assert hostname == "example.com"
assert ports == [443, 636, 993]
def test_parse_host_ports_ipv6_multiple(self) -> None:
"""Test parsing IPv6 address with multiple ports."""
hostname, ports = parse_host_ports("[2001:db8::1]:443,636")
assert hostname == "2001:db8::1"
assert ports == [443, 636]
def test_parse_host_ports_invalid_port_range(self) -> None:
"""Test error when port number out of range."""
with pytest.raises(ValueError, match="Invalid port number.*Must be between"):
parse_host_ports("example.com:99999")

73
tests/test_compliance.py Normal file
View File

@@ -0,0 +1,73 @@
"""Tests for compliance checking functionality."""
from datetime import datetime
class TestComplianceChecks:
"""Tests for compliance validation logic."""
def test_check_bsi_validity(self) -> None:
"""Test BSI cipher suite validity checking."""
# Valid BSI-approved cipher suite (not expired)
cipher_suite_valid = {
"name": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"iana_recommended": "N",
"bsi_approved": True,
"bsi_valid_until": "2029",
}
# Check that current year is before 2029
current_year = datetime.now().year
assert current_year < 2029, "Test assumes current year < 2029"
# BSI-approved and valid should be compliant
assert cipher_suite_valid["bsi_approved"] is True
assert int(cipher_suite_valid["bsi_valid_until"]) > current_year
# Expired BSI-approved cipher suite
cipher_suite_expired = {
"name": "TLS_OLD_CIPHER",
"iana_recommended": "N",
"bsi_approved": True,
"bsi_valid_until": "2020",
}
# BSI-approved but expired should not be compliant
assert cipher_suite_expired["bsi_approved"] is True
assert int(cipher_suite_expired["bsi_valid_until"]) < current_year
# No BSI data
cipher_suite_no_bsi = {
"name": "TLS_CHACHA20_POLY1305_SHA256",
"iana_recommended": "Y",
"bsi_approved": False,
"bsi_valid_until": None,
}
# Without BSI approval, compliance depends on IANA
assert cipher_suite_no_bsi["bsi_approved"] is False
def test_check_iana_recommendation(self) -> None:
"""Test IANA recommendation checking."""
# IANA recommended cipher suite
cipher_suite_recommended = {
"name": "TLS_AES_256_GCM_SHA384",
"iana_recommended": "Y",
"bsi_approved": True,
"bsi_valid_until": "2031",
}
assert cipher_suite_recommended["iana_recommended"] == "Y"
# IANA not recommended cipher suite
cipher_suite_not_recommended = {
"name": "TLS_RSA_WITH_AES_128_CBC_SHA",
"iana_recommended": "N",
"bsi_approved": False,
"bsi_valid_until": None,
}
assert cipher_suite_not_recommended["iana_recommended"] == "N"
# No IANA data (should default to non-compliant)
cipher_suite_no_iana = {
"name": "TLS_UNKNOWN_CIPHER",
"iana_recommended": None,
"bsi_approved": False,
"bsi_valid_until": None,
}
assert cipher_suite_no_iana["iana_recommended"] is None

297
tests/test_csv_export.py Normal file
View File

@@ -0,0 +1,297 @@
"""Tests for CSV export functionality."""
import csv
from pathlib import Path
from sslysze_scan.reporter.csv_export import generate_csv_reports
class TestCsvExport:
"""Tests for CSV file generation."""
def test_export_summary(self, test_db_path: str, tmp_path: Path) -> None:
"""Test summary CSV export with aggregated statistics."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
summary_file = output_dir / "summary.csv"
assert summary_file.exists()
assert str(summary_file) in files
with open(summary_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Metric", "Value"]
assert len(rows) >= 7
metrics = {row[0]: row[1] for row in rows[1:]}
assert "Scanned Ports" in metrics
assert "Ports with TLS Support" in metrics
assert "Cipher Suites Checked" in metrics
def test_export_cipher_suites_port_443(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test cipher suites export for port 443."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
accepted_files = [
f for f in files if "443_cipher_suites" in f and "accepted" in f
]
assert len(accepted_files) > 0
accepted_file = Path(accepted_files[0])
assert accepted_file.exists()
with open(accepted_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Cipher Suite", "IANA", "BSI", "Valid Until", "Compliant"]
assert len(rows) > 1
for row in rows[1:]:
assert len(row) == 5
assert row[4] in ["Yes", "No", "-"]
def test_export_supported_groups_port_636(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test supported groups export for port 636."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
groups_files = [f for f in files if "636_supported_groups.csv" in f]
if groups_files:
groups_file = Path(groups_files[0])
assert groups_file.exists()
with open(groups_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Group", "IANA", "BSI", "Valid Until", "Compliant"]
for row in rows[1:]:
assert len(row) == 5
assert row[4] in ["Yes", "No", "-"]
def test_export_missing_groups_port_443(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test missing groups export for port 443."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
bsi_files = [f for f in files if "443_missing_groups_bsi.csv" in f]
if bsi_files:
bsi_file = Path(bsi_files[0])
assert bsi_file.exists()
with open(bsi_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Group", "TLS Versions", "Valid Until"]
for row in rows[1:]:
assert len(row) == 3
def test_export_certificates_port_636(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test certificates export for port 636."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
cert_files = [f for f in files if "636_certificates.csv" in f]
if cert_files:
cert_file = Path(cert_files[0])
assert cert_file.exists()
with open(cert_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
expected_headers = [
"Position",
"Subject",
"Issuer",
"Valid From",
"Valid Until",
"Key Type",
"Key Size",
"Compliant",
]
assert rows[0] == expected_headers
for row in rows[1:]:
assert len(row) == 8
assert row[7] in ["Yes", "No", "-"]
def test_export_vulnerabilities_port_443(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test vulnerabilities export for port 443."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
vuln_files = [f for f in files if "443_vulnerabilities.csv" in f]
if vuln_files:
vuln_file = Path(vuln_files[0])
assert vuln_file.exists()
with open(vuln_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Type", "Vulnerable", "Details"]
for row in rows[1:]:
assert len(row) == 3
assert row[1] in ["Yes", "No", "-"]
def test_export_protocol_features_port_636(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test protocol features export for port 636."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
protocol_files = [f for f in files if "636_protocol_features.csv" in f]
if protocol_files:
protocol_file = Path(protocol_files[0])
assert protocol_file.exists()
with open(protocol_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Feature", "Supported", "Details"]
for row in rows[1:]:
assert len(row) == 3
assert row[1] in ["Yes", "No", "-"]
def test_export_session_features_port_443(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test session features export for port 443."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
session_files = [f for f in files if "443_session_features.csv" in f]
if session_files:
session_file = Path(session_files[0])
assert session_file.exists()
with open(session_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
expected_headers = [
"Feature",
"Client Initiated",
"Secure",
"Session ID",
"TLS Ticket",
"Details",
]
assert rows[0] == expected_headers
for row in rows[1:]:
assert len(row) == 6
for i in range(1, 5):
assert row[i] in ["Yes", "No", "-"]
def test_export_http_headers_port_636(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test HTTP headers export for port 636."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
header_files = [f for f in files if "636_http_headers.csv" in f]
if header_files:
header_file = Path(header_files[0])
assert header_file.exists()
with open(header_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Header", "Present", "Value"]
for row in rows[1:]:
assert len(row) == 3
assert row[1] in ["Yes", "No", "-"]
def test_export_compliance_status_port_443(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test compliance status export for port 443."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
compliance_files = [f for f in files if "443_compliance_status.csv" in f]
if compliance_files:
compliance_file = Path(compliance_files[0])
assert compliance_file.exists()
with open(compliance_file, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
assert rows[0] == ["Category", "Checked", "Compliant", "Percentage"]
for row in rows[1:]:
assert len(row) == 4
assert "%" in row[3]
def test_generate_csv_reports_all_files(
self, test_db_path: str, tmp_path: Path
) -> None:
"""Test that generate_csv_reports creates expected files."""
output_dir = tmp_path / "output"
output_dir.mkdir()
files = generate_csv_reports(test_db_path, 1, str(output_dir))
assert len(files) > 0
assert any("summary.csv" in f for f in files)
assert any("443_" in f for f in files)
assert any("636_" in f for f in files)
for file_path in files:
assert Path(file_path).exists()
assert Path(file_path).suffix == ".csv"

View File

@@ -0,0 +1,67 @@
"""Tests for template utilities."""
from datetime import datetime
from typing import Any
from sslysze_scan.reporter.template_utils import (
build_template_context,
format_tls_version,
generate_report_id,
)
class TestFormatTlsVersion:
"""Tests for format_tls_version function."""
def test_format_tls_version_all_versions(self) -> None:
"""Test formatting all known TLS versions."""
versions = ["1.0", "1.1", "1.2", "1.3", "ssl_3.0", "unknown"]
expected = ["TLS 1.0", "TLS 1.1", "TLS 1.2", "TLS 1.3", "SSL 3.0", "unknown"]
assert [format_tls_version(v) for v in versions] == expected
class TestGenerateReportId:
"""Tests for generate_report_id function."""
def test_generate_report_id_valid_and_invalid(self) -> None:
"""Test report ID generation with valid and invalid timestamps."""
# Valid timestamp
metadata = {"timestamp": "2025-01-08T10:30:00.123456", "scan_id": 5}
result = generate_report_id(metadata)
assert result == "20250108_5"
# Invalid timestamp falls back to current date
metadata = {"timestamp": "invalid", "scan_id": 5}
result = generate_report_id(metadata)
today = datetime.now().strftime("%Y%m%d")
assert result == f"{today}_5"
class TestBuildTemplateContext:
"""Tests for build_template_context function."""
def test_build_template_context_complete_and_partial(
self, mock_scan_data: dict[str, Any]
) -> None:
"""Test context building with complete and partial data."""
# Complete data
context = build_template_context(mock_scan_data)
assert context["scan_id"] == 5
assert context["hostname"] == "example.com"
assert context["fqdn"] == "example.com"
assert context["ipv4"] == "192.168.1.1"
assert context["ipv6"] == "2001:db8::1"
assert context["timestamp"] == "08.01.2025 10:30"
assert context["duration"] == "12.34"
assert context["ports"] == "443, 636"
assert "summary" in context
assert "ports_data" in context
# Verify ports_data sorted by port
ports = [p["port"] for p in context["ports_data"]]
assert ports == sorted(ports)
# Missing duration
mock_scan_data["metadata"]["duration"] = None
context = build_template_context(mock_scan_data)
assert context["duration"] == "N/A"