updated the architecture

This commit is contained in:
rarebuffalo
2026-04-07 18:13:43 +05:30
parent 087d8ffaee
commit 8330060e86
66 changed files with 3484 additions and 130 deletions

0
tests/__init__.py Normal file
View File

70
tests/conftest.py Normal file
View File

@@ -0,0 +1,70 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base, get_db
from app.main import app
from app.models.user import User
from app.utils.auth import create_access_token, hash_password
TEST_DB_URL = "sqlite+aiosqlite://"
test_engine = create_async_engine(TEST_DB_URL, echo=False)
TestSessionLocal = async_sessionmaker(
bind=test_engine, class_=AsyncSession, expire_on_commit=False
)
async def override_get_db():
async with TestSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(autouse=True)
async def setup_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
def client():
from fastapi.testclient import TestClient
return TestClient(app)
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def test_user():
async with TestSessionLocal() as session:
user = User(
email="test@example.com",
username="testuser",
hashed_password=hash_password("testpassword123"),
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest.fixture
async def auth_headers(test_user):
token = create_access_token(test_user.id)
return {"Authorization": f"Bearer {token}"}

106
tests/test_auth.py Normal file
View File

@@ -0,0 +1,106 @@
import pytest
@pytest.mark.asyncio
async def test_register(async_client):
response = await async_client.post("/auth/register", json={
"email": "new@example.com",
"username": "newuser",
"password": "securepass123",
})
assert response.status_code == 201
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_register_duplicate_email(async_client, test_user):
response = await async_client.post("/auth/register", json={
"email": "test@example.com",
"username": "different",
"password": "securepass123",
})
assert response.status_code == 409
@pytest.mark.asyncio
async def test_register_duplicate_username(async_client, test_user):
response = await async_client.post("/auth/register", json={
"email": "different@example.com",
"username": "testuser",
"password": "securepass123",
})
assert response.status_code == 409
@pytest.mark.asyncio
async def test_register_short_password(async_client):
response = await async_client.post("/auth/register", json={
"email": "new@example.com",
"username": "newuser",
"password": "short",
})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_invalid_email(async_client):
response = await async_client.post("/auth/register", json={
"email": "not-an-email",
"username": "newuser",
"password": "securepass123",
})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login(async_client, test_user):
response = await async_client.post("/auth/login", json={
"email": "test@example.com",
"password": "testpassword123",
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
@pytest.mark.asyncio
async def test_login_wrong_password(async_client, test_user):
response = await async_client.post("/auth/login", json={
"email": "test@example.com",
"password": "wrongpassword",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_email(async_client):
response = await async_client.post("/auth/login", json={
"email": "nobody@example.com",
"password": "testpassword123",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me(async_client, test_user, auth_headers):
response = await async_client.get("/auth/me", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["email"] == "test@example.com"
assert data["username"] == "testuser"
assert "id" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_get_me_unauthorized(async_client):
response = await async_client.get("/auth/me")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me_invalid_token(async_client):
response = await async_client.get("/auth/me", headers={"Authorization": "Bearer invalid"})
assert response.status_code == 401

65
tests/test_cookies.py Normal file
View File

@@ -0,0 +1,65 @@
from unittest.mock import MagicMock
import pytest
from app.services.scanner.cookies import CookieScanner
scanner = CookieScanner()
def _make_response(set_cookie_headers: list[str]) -> MagicMock:
items = [("content-type", "text/html")]
for cookie in set_cookie_headers:
items.append(("set-cookie", cookie))
response = MagicMock()
response.headers.multi_items.return_value = items
return response
@pytest.mark.asyncio
async def test_no_cookies_returns_empty():
response = _make_response([])
issues = await scanner.scan("https://example.com", response)
assert issues == []
@pytest.mark.asyncio
async def test_detects_missing_httponly():
response = _make_response(["session=abc123; Path=/; Secure; SameSite=Lax"])
issues = await scanner.scan("https://example.com", response)
assert any("HttpOnly" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_missing_secure():
response = _make_response(["session=abc123; Path=/; HttpOnly; SameSite=Lax"])
issues = await scanner.scan("https://example.com", response)
assert any("Secure" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_missing_samesite():
response = _make_response(["session=abc123; Path=/; HttpOnly; Secure"])
issues = await scanner.scan("https://example.com", response)
assert any("SameSite" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_samesite_none_without_secure():
response = _make_response(["session=abc123; Path=/; HttpOnly; SameSite=None"])
issues = await scanner.scan("https://example.com", response)
assert any("SameSite=None" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_secure_cookie_passes():
response = _make_response(["session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax"])
issues = await scanner.scan("https://example.com", response)
assert len(issues) == 0
@pytest.mark.asyncio
async def test_skips_secure_check_for_http():
response = _make_response(["session=abc123; Path=/; HttpOnly; SameSite=Lax"])
issues = await scanner.scan("http://example.com", response)
assert not any("Secure flag" in i.issue for i in issues)

94
tests/test_headers.py Normal file
View File

@@ -0,0 +1,94 @@
from unittest.mock import MagicMock
import pytest
from app.services.scanner.headers import HeaderScanner
scanner = HeaderScanner()
def _make_response(headers: dict) -> MagicMock:
response = MagicMock()
response.headers = headers
return response
@pytest.mark.asyncio
async def test_detects_all_missing_headers():
response = _make_response({})
issues = await scanner.scan("https://example.com", response)
issue_texts = [i.issue for i in issues]
assert any("Content-Security-Policy" in t for t in issue_texts)
assert any("X-Frame-Options" in t for t in issue_texts)
assert any("X-Content-Type-Options" in t for t in issue_texts)
assert any("Referrer-Policy" in t for t in issue_texts)
assert any("Permissions-Policy" in t for t in issue_texts)
assert any("Cache-Control" in t for t in issue_texts)
assert any("COOP" in t for t in issue_texts)
assert any("CORP" in t for t in issue_texts)
assert any("COEP" in t for t in issue_texts)
@pytest.mark.asyncio
async def test_detects_unsafe_inline_csp():
response = _make_response({
"Content-Security-Policy": "default-src 'self' 'unsafe-inline'",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin",
"Permissions-Policy": "camera=()",
"Cache-Control": "no-store",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
})
issues = await scanner.scan("https://example.com", response)
assert any("unsafe-inline" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_unsafe_eval_csp():
response = _make_response({
"Content-Security-Policy": "default-src 'self' 'unsafe-eval'",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin",
"Permissions-Policy": "camera=()",
"Cache-Control": "no-store",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
})
issues = await scanner.scan("https://example.com", response)
assert any("unsafe-eval" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_server_disclosure():
response = _make_response({"Server": "Apache/2.4.41"})
issues = await scanner.scan("https://example.com", response)
assert any("Server header" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_x_powered_by():
response = _make_response({"X-Powered-By": "Express"})
issues = await scanner.scan("https://example.com", response)
assert any("X-Powered-By" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_no_issues_with_all_headers():
response = _make_response({
"Content-Security-Policy": "default-src 'self'",
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=()",
"Cache-Control": "no-store",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
})
issues = await scanner.scan("https://example.com", response)
assert len(issues) == 0

19
tests/test_health.py Normal file
View File

@@ -0,0 +1,19 @@
import pytest
@pytest.mark.asyncio
async def test_root(async_client):
response = await async_client.get("/")
assert response.status_code == 200
data = response.json()
assert "running" in data["message"]
@pytest.mark.asyncio
async def test_health(async_client):
response = await async_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "app" in data
assert "version" in data

90
tests/test_history.py Normal file
View File

@@ -0,0 +1,90 @@
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.scan import ScanResult
async def _create_scan(user_id: str) -> None:
from tests.conftest import TestSessionLocal
async with TestSessionLocal() as session:
scan = ScanResult(
user_id=user_id,
url="https://example.com",
security_score=85,
layers={"Transport Layer": {"issues": 1, "status": "yellow"}},
issues=[{"issue": "Missing HSTS", "severity": "Warning", "layer": "Transport Layer", "fix": "Add HSTS"}],
)
session.add(scan)
await session.commit()
await session.refresh(scan)
return scan
@pytest.mark.asyncio
async def test_list_scans_empty(async_client, test_user, auth_headers):
response = await async_client.get("/scans", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["scans"] == []
assert data["total"] == 0
@pytest.mark.asyncio
async def test_list_scans_with_results(async_client, test_user, auth_headers):
scan = await _create_scan(test_user.id)
response = await async_client.get("/scans", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["scans"]) == 1
assert data["scans"][0]["url"] == "https://example.com"
assert data["scans"][0]["security_score"] == 85
@pytest.mark.asyncio
async def test_list_scans_pagination(async_client, test_user, auth_headers):
for _ in range(5):
await _create_scan(test_user.id)
response = await async_client.get("/scans?page=1&per_page=2", headers=auth_headers)
data = response.json()
assert data["total"] == 5
assert len(data["scans"]) == 2
assert data["page"] == 1
assert data["per_page"] == 2
@pytest.mark.asyncio
async def test_get_scan_by_id(async_client, test_user, auth_headers):
scan = await _create_scan(test_user.id)
response = await async_client.get(f"/scans/{scan.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["url"] == "https://example.com"
assert data["security_score"] == 85
assert len(data["issues"]) == 1
@pytest.mark.asyncio
async def test_get_scan_not_found(async_client, test_user, auth_headers):
response = await async_client.get("/scans/nonexistent", headers=auth_headers)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_scan(async_client, test_user, auth_headers):
scan = await _create_scan(test_user.id)
response = await async_client.delete(f"/scans/{scan.id}", headers=auth_headers)
assert response.status_code == 204
response = await async_client.get(f"/scans/{scan.id}", headers=auth_headers)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_list_scans_unauthorized(async_client):
response = await async_client.get("/scans")
assert response.status_code == 401

54
tests/test_scan.py Normal file
View File

@@ -0,0 +1,54 @@
import pytest
@pytest.mark.asyncio
async def test_scan_rejects_invalid_url(async_client):
response = await async_client.post("/scan", json={"url": "not-a-url"})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_scan_rejects_localhost(async_client):
response = await async_client.post("/scan", json={"url": "http://localhost:8000"})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_scan_rejects_private_ip(async_client):
response = await async_client.post("/scan", json={"url": "http://192.168.1.1"})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_scan_valid_url(async_client):
response = await async_client.post("/scan", json={"url": "https://example.com"})
assert response.status_code in (200, 502)
data = response.json()
assert "security_score" in data or "error" in data
@pytest.mark.asyncio
async def test_scan_missing_url(async_client):
response = await async_client.post("/scan", json={})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_scan_saves_when_authenticated(async_client, test_user, auth_headers):
response = await async_client.post(
"/scan",
json={"url": "https://example.com"},
headers=auth_headers,
)
if response.status_code == 200:
data = response.json()
assert data["id"] is not None
assert data["created_at"] is not None
@pytest.mark.asyncio
async def test_scan_no_save_when_anonymous(async_client):
response = await async_client.post("/scan", json={"url": "https://example.com"})
if response.status_code == 200:
data = response.json()
assert data["id"] is None

63
tests/test_scoring.py Normal file
View File

@@ -0,0 +1,63 @@
from app.schemas.scan import Issue
from app.services.scoring import calculate_layer_statuses, calculate_score
def test_perfect_score_no_issues():
assert calculate_score([]) == 100
def test_critical_deduction():
issues = [Issue(issue="Test", severity="Critical", layer="Transport Layer", fix="Fix")]
assert calculate_score(issues) == 85
def test_warning_deduction():
issues = [Issue(issue="Test", severity="Warning", layer="Transport Layer", fix="Fix")]
assert calculate_score(issues) == 95
def test_info_deduction():
issues = [Issue(issue="Test", severity="Info", layer="Transport Layer", fix="Fix")]
assert calculate_score(issues) == 98
def test_score_cannot_go_below_zero():
issues = [Issue(issue=f"Test {i}", severity="Critical", layer="Transport Layer", fix="Fix") for i in range(10)]
assert calculate_score(issues) == 0
def test_all_layers_present():
statuses = calculate_layer_statuses([])
assert "Transport Layer" in statuses
assert "SSL/TLS Layer" in statuses
assert "Server Config Layer" in statuses
assert "Cookie Security" in statuses
assert "Exposure Layer" in statuses
def test_layer_status_green_when_no_issues():
statuses = calculate_layer_statuses([])
for layer in statuses.values():
assert layer.status == "green"
assert layer.issues == 0
def test_layer_status_yellow_for_few_issues():
issues = [
Issue(issue="Test 1", severity="Warning", layer="SSL/TLS Layer", fix="Fix"),
Issue(issue="Test 2", severity="Warning", layer="SSL/TLS Layer", fix="Fix"),
]
statuses = calculate_layer_statuses(issues)
assert statuses["SSL/TLS Layer"].status == "yellow"
assert statuses["SSL/TLS Layer"].issues == 2
def test_layer_status_red_for_many_issues():
issues = [
Issue(issue="Test 1", severity="Warning", layer="Cookie Security", fix="Fix"),
Issue(issue="Test 2", severity="Warning", layer="Cookie Security", fix="Fix"),
Issue(issue="Test 3", severity="Critical", layer="Cookie Security", fix="Fix"),
]
statuses = calculate_layer_statuses(issues)
assert statuses["Cookie Security"].status == "red"
assert statuses["Cookie Security"].issues == 3

86
tests/test_ssl_checker.py Normal file
View File

@@ -0,0 +1,86 @@
import datetime
from unittest.mock import MagicMock, patch
import pytest
from app.services.scanner.ssl_checker import SSLScanner, _check_ssl
scanner = SSLScanner()
@pytest.mark.asyncio
async def test_skips_http_urls():
response = MagicMock()
issues = await scanner.scan("http://example.com", response)
assert issues == []
@pytest.mark.asyncio
async def test_detects_self_signed():
response = MagicMock()
mock_result = {
"error": "self-signed certificate",
"cert": None,
"tls_version": "TLSv1.3",
"self_signed": True,
}
with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
issues = await scanner.scan("https://self-signed.example.com", response)
assert any("self-signed" in i.issue.lower() for i in issues)
@pytest.mark.asyncio
async def test_detects_weak_tls():
response = MagicMock()
future_date = (datetime.datetime.utcnow() + datetime.timedelta(days=365)).strftime("%b %d %H:%M:%S %Y GMT")
mock_result = {
"error": None,
"cert": {
"notAfter": future_date,
"subject": ((('commonName', 'example.com'),),),
"issuer": ((('commonName', 'CA'),),),
},
"tls_version": "TLSv1.1",
"self_signed": False,
}
with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
issues = await scanner.scan("https://example.com", response)
assert any("weak TLS" in i.issue.lower() or "tls" in i.issue.lower() for i in issues)
@pytest.mark.asyncio
async def test_detects_expiring_soon():
response = MagicMock()
soon_date = (datetime.datetime.utcnow() + datetime.timedelta(days=15)).strftime("%b %d %H:%M:%S %Y GMT")
mock_result = {
"error": None,
"cert": {
"notAfter": soon_date,
"subject": ((('commonName', 'example.com'),),),
"issuer": ((('commonName', 'CA'),),),
},
"tls_version": "TLSv1.3",
"self_signed": False,
}
with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
issues = await scanner.scan("https://example.com", response)
assert any("expires in" in i.issue.lower() for i in issues)
@pytest.mark.asyncio
async def test_no_issues_for_valid_cert():
response = MagicMock()
future_date = (datetime.datetime.utcnow() + datetime.timedelta(days=365)).strftime("%b %d %H:%M:%S %Y GMT")
mock_result = {
"error": None,
"cert": {
"notAfter": future_date,
"subject": ((('commonName', 'example.com'),),),
"issuer": ((('commonName', 'Let\'s Encrypt'),),),
},
"tls_version": "TLSv1.3",
"self_signed": False,
}
with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
issues = await scanner.scan("https://example.com", response)
assert len(issues) == 0

75
tests/test_transport.py Normal file
View File

@@ -0,0 +1,75 @@
from unittest.mock import MagicMock
import pytest
from app.services.scanner.transport import TransportScanner
scanner = TransportScanner()
def _make_response(headers: dict) -> MagicMock:
response = MagicMock()
response.headers = headers
return response
@pytest.mark.asyncio
async def test_detects_no_https():
response = _make_response({})
issues = await scanner.scan("http://example.com", response)
assert any("HTTPS" in i.issue for i in issues)
assert len(issues) == 1
@pytest.mark.asyncio
async def test_detects_missing_hsts():
response = _make_response({})
issues = await scanner.scan("https://example.com", response)
assert any("HSTS" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_short_hsts_max_age():
response = _make_response({
"Strict-Transport-Security": "max-age=3600; includeSubDomains; preload"
})
issues = await scanner.scan("https://example.com", response)
assert any("max-age" in i.issue.lower() for i in issues)
@pytest.mark.asyncio
async def test_detects_missing_includesubdomains():
response = _make_response({
"Strict-Transport-Security": "max-age=31536000; preload"
})
issues = await scanner.scan("https://example.com", response)
assert any("includeSubDomains" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_missing_preload():
response = _make_response({
"Strict-Transport-Security": "max-age=31536000; includeSubDomains"
})
issues = await scanner.scan("https://example.com", response)
assert any("preload" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_detects_missing_upgrade_insecure_requests():
response = _make_response({
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Content-Security-Policy": "default-src 'self'",
})
issues = await scanner.scan("https://example.com", response)
assert any("upgrade-insecure-requests" in i.issue for i in issues)
@pytest.mark.asyncio
async def test_good_hsts_no_transport_issues():
response = _make_response({
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Content-Security-Policy": "default-src 'self'; upgrade-insecure-requests",
})
issues = await scanner.scan("https://example.com", response)
assert len(issues) == 0

50
tests/test_validators.py Normal file
View File

@@ -0,0 +1,50 @@
import pytest
from fastapi import HTTPException
from app.utils.validators import validate_url
def test_valid_https_url():
result = validate_url("https://example.com")
assert result == "https://example.com"
def test_valid_http_url():
result = validate_url("http://example.com")
assert result == "http://example.com"
def test_rejects_ftp_scheme():
with pytest.raises(HTTPException) as exc_info:
validate_url("ftp://example.com")
assert exc_info.value.status_code == 400
def test_rejects_no_scheme():
with pytest.raises(HTTPException) as exc_info:
validate_url("example.com")
assert exc_info.value.status_code == 400
def test_rejects_localhost():
with pytest.raises(HTTPException) as exc_info:
validate_url("http://localhost")
assert exc_info.value.status_code == 400
def test_rejects_private_ip():
with pytest.raises(HTTPException) as exc_info:
validate_url("http://192.168.1.1")
assert exc_info.value.status_code == 400
def test_rejects_loopback():
with pytest.raises(HTTPException) as exc_info:
validate_url("http://127.0.0.1")
assert exc_info.value.status_code == 400
def test_rejects_unresolvable_host():
with pytest.raises(HTTPException) as exc_info:
validate_url("http://this-domain-does-not-exist-xyz123.com")
assert exc_info.value.status_code == 400