mirror of
https://github.com/Rarebuffalo/securelens-backend.git
synced 2026-06-19 07:00:30 +00:00
updated the architecture
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
70
tests/conftest.py
Normal file
70
tests/conftest.py
Normal 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
106
tests/test_auth.py
Normal 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
65
tests/test_cookies.py
Normal 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
94
tests/test_headers.py
Normal 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
19
tests/test_health.py
Normal 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
90
tests/test_history.py
Normal 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
54
tests/test_scan.py
Normal 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
63
tests/test_scoring.py
Normal 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
86
tests/test_ssl_checker.py
Normal 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
75
tests/test_transport.py
Normal 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
50
tests/test_validators.py
Normal 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
|
||||
Reference in New Issue
Block a user