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]