Files
EventLens-Public/backend/tests/test_api_privacy.py
T

172 lines
6.5 KiB
Python

import asyncio
from datetime import UTC, datetime
import httpx
import pytest
from app import main
from app.models import NormalizedEvent, ParseIssue
from app.parser import ParseResult
from app.version import VERSION
def request(method: str, url: str, **kwargs) -> httpx.Response:
async def send() -> httpx.Response:
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await client.request(method, url, **kwargs)
return asyncio.run(send())
def _fake_parse(_data: bytes) -> ParseResult:
event = NormalizedEvent(id="1",timestamp=datetime(2026,9,9,tzinfo=UTC),provider="Test Provider",event_id=1000,level="error",channel="Application",computer="LAB-PC",category="crash",summary="Synthetic crash",fields={})
return ParseResult(events=[event], issues=[], truncated=False, duration_ms=1)
def test_api_sets_no_store_headers(monkeypatch):
monkeypatch.setattr(main, "parse_evtx_bytes", _fake_parse)
response = request("POST", "/api/analyze", files={"file": ("sample.evtx", b"synthetic", "application/octet-stream")})
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.headers["pragma"] == "no-cache"
def test_api_sets_response_security_headers():
response = request("GET", "/health")
assert response.status_code == 200
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["referrer-policy"] == "no-referrer"
assert response.headers["x-frame-options"] == "DENY"
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
def test_api_exposes_semantic_partial_parse_metrics(monkeypatch):
def partial_parse(_data: bytes) -> ParseResult:
event = _fake_parse(_data).events[0]
return ParseResult(
events=[event],
issues=[ParseIssue(code="event_limit_reached", message="later records were not analyzed", skipped_record=True)],
truncated=True,
duration_ms=12,
status="partial",
truncation_reason="event_limit_reached",
records_seen=4,
skipped_records=1,
)
monkeypatch.setattr(main, "parse_evtx_bytes", partial_parse)
response = request("POST", "/api/analyze", files={"file": ("sample.evtx", b"synthetic", "application/octet-stream")})
assert response.status_code == 200
parse = response.json()["parse"]
assert parse["status"] == "partial"
assert parse["parsed_events"] == 1
assert parse["records_seen"] == 4
assert parse["skipped_records"] == 1
assert parse["issue_count"] == 1
assert parse["issues"][0]["code"] == "event_limit_reached"
def test_health_sets_no_store_headers():
response = request("GET", "/health")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.headers["pragma"] == "no-cache"
def test_health_uses_the_repository_version_source():
response = request("GET", "/health")
assert response.json()["version"] == VERSION
def test_health_exposes_exact_build_revision_and_preserves_existing_fields(monkeypatch):
revision = "0123456789abcdef0123456789abcdef01234567"
monkeypatch.setenv("EVENTLENS_REVISION", revision)
response = request("GET", "/health")
assert response.status_code == 200
assert response.json() == {
"status": "ok",
"evtx_ingestion": True,
"version": VERSION,
"revision": revision,
}
@pytest.mark.parametrize("revision", [None, "", "unknown", "A" * 40, "a" * 39, "a" * 64])
def test_health_uses_deterministic_local_build_revision_fallback(monkeypatch, revision):
if revision is None:
monkeypatch.delenv("EVENTLENS_REVISION", raising=False)
else:
monkeypatch.setenv("EVENTLENS_REVISION", revision)
response = request("GET", "/health")
assert response.status_code == 200
assert response.json()["revision"] == "local"
def test_analysis_does_not_persist_upload_in_working_directory(monkeypatch, tmp_path):
monkeypatch.setattr(main, "parse_evtx_bytes", _fake_parse)
monkeypatch.chdir(tmp_path)
before = list(tmp_path.iterdir())
response = request("POST", "/api/analyze", files={"file": ("sample.evtx", b"synthetic", "application/octet-stream")})
after = list(tmp_path.iterdir())
assert response.status_code == 200
assert before == after == []
def test_api_sanitizes_filename_in_analysis_source(monkeypatch):
monkeypatch.setattr(main, "parse_evtx_bytes", _fake_parse)
response = request(
"POST",
"/api/analyze",
files={"file": (r"..\private\LAB-PC.evtx", b"synthetic", "application/octet-stream")},
)
assert response.status_code == 200
assert response.json()["source"] == "LAB-PC.evtx"
def test_api_rejects_oversized_upload_before_parser(monkeypatch):
monkeypatch.setattr(main, "MAX_UPLOAD_BYTES", 8)
response = request(
"POST", "/api/analyze", files={"file": ("sample.evtx", b"123456789", "application/octet-stream")}
)
assert response.status_code == 413
def test_request_limit_response_is_uncacheable_and_nosniff():
response = request(
"POST",
"/api/analyze",
headers={"Content-Length": str(main.MAX_REQUEST_BYTES + 1)},
content=b"oversized",
)
assert response.status_code == 413
assert response.headers["cache-control"] == "no-store"
assert response.headers["pragma"] == "no-cache"
assert response.headers["x-content-type-options"] == "nosniff"
def test_api_rejects_when_parse_capacity_is_full(monkeypatch):
import time
def slow_parse(_data: bytes) -> ParseResult:
time.sleep(0.35)
return _fake_parse(_data)
async def send_two():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await asyncio.gather(
client.post("/api/analyze", files={"file": ("one.evtx", b"synthetic", "application/octet-stream")}),
client.post("/api/analyze", files={"file": ("two.evtx", b"synthetic", "application/octet-stream")}),
)
monkeypatch.setattr(main, "parse_evtx_bytes", slow_parse)
monkeypatch.setattr(main, "PARSE_QUEUE_LIMIT", 0)
monkeypatch.setattr(main, "_parse_inflight", 0)
responses = asyncio.run(send_two())
assert sorted(response.status_code for response in responses) == [200, 503]