801 lines
30 KiB
Python
801 lines
30 KiB
Python
import importlib
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from docx import Document
|
|
from fastapi.testclient import TestClient
|
|
from PIL import Image
|
|
from pypdf import PdfWriter
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
def load_app_module(
|
|
monkeypatch,
|
|
*,
|
|
skip_model_load=True,
|
|
ollama_model=None,
|
|
service_token=None,
|
|
external_ai_enabled=False,
|
|
routing_mode="local_first",
|
|
circuit_threshold=3,
|
|
external_prompt_limit=24000,
|
|
):
|
|
if skip_model_load:
|
|
monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1")
|
|
else:
|
|
monkeypatch.delenv("AI_SERVICE_SKIP_MODEL_LOAD", raising=False)
|
|
monkeypatch.delenv("AI_SERVICE_EAGER_MODEL_LOAD", raising=False)
|
|
# Default to keyless so the existing suite is unaffected by a token in the dev shell.
|
|
if service_token is None:
|
|
monkeypatch.delenv("AI_SERVICE_TOKEN", raising=False)
|
|
else:
|
|
monkeypatch.setenv("AI_SERVICE_TOKEN", service_token)
|
|
if ollama_model is None:
|
|
monkeypatch.delenv("OLLAMA_MODEL", raising=False)
|
|
else:
|
|
monkeypatch.setenv("OLLAMA_MODEL", ollama_model)
|
|
if external_ai_enabled:
|
|
monkeypatch.setenv("EXTERNAL_AI_ENABLED", "true")
|
|
else:
|
|
monkeypatch.delenv("EXTERNAL_AI_ENABLED", raising=False)
|
|
monkeypatch.setenv("AI_ROUTING_MODE", routing_mode)
|
|
monkeypatch.setenv("LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD", str(circuit_threshold))
|
|
monkeypatch.setenv("EXTERNAL_AI_MAX_PROMPT_CHARS", str(external_prompt_limit))
|
|
monkeypatch.delenv("EXTERNAL_AI_ALLOWED_TASKS", raising=False)
|
|
if "app" in sys.modules:
|
|
del sys.modules["app"]
|
|
module = importlib.import_module("app")
|
|
return importlib.reload(module)
|
|
|
|
|
|
def test_health_reports_runtime_without_ollama_and_without_forcing_model_load(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["ok"] is True
|
|
assert payload["device"] == "cpu"
|
|
assert payload["model_loaded"] is False
|
|
assert payload["model_disabled"] is True
|
|
assert payload["summarize_available"] is False
|
|
assert payload["parser_isolated"] is True
|
|
assert payload["parser_concurrency"] == 1
|
|
assert payload["parser_timeout_seconds"] == 25
|
|
assert payload["parser_memory_mib"] == 512
|
|
assert "disabled" in payload["model_load_error"].lower()
|
|
assert payload["ollama_configured"] is False
|
|
assert payload["ollama_model"] is None
|
|
assert payload["ollama_installed_models"] == []
|
|
assert payload["ollama_loaded_models"] == []
|
|
|
|
|
|
def test_summarize_returns_503_with_explicit_reason_when_model_loading_is_disabled(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/summarize", json={"text": "Platform engineering role with APIs and Python experience."})
|
|
|
|
assert response.status_code == 503
|
|
payload = response.json()
|
|
assert "disabled" in payload["detail"].lower()
|
|
|
|
|
|
def test_health_reports_ollama_unreachable_when_configured_but_not_available(monkeypatch):
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
|
|
|
def boom(path: str):
|
|
raise OSError("connection refused")
|
|
|
|
monkeypatch.setattr(module, "_ollama_json", boom)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["ollama_configured"] is True
|
|
assert payload["ollama_reachable"] is False
|
|
assert payload["ollama_model"] == "qwen2.5:7b"
|
|
assert payload["ollama_model_available"] is False
|
|
|
|
|
|
def test_rewrite_cv_returns_plain_rewritten_text(monkeypatch):
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
|
monkeypatch.setattr(module, "_ollama_generate_text", lambda prompt: "# Professional Summary\nBuilt resilient backend systems.\n\n# Skills\n- C#\n- .NET")
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/cv/rewrite", json={
|
|
"instruction": "Rewrite this CV into a cleaner master CV.",
|
|
"text": "Professional Summary\nBuilt backend systems.",
|
|
"max_length": 220,
|
|
"min_length": 80,
|
|
})
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["rewritten_text"].startswith("# Professional Summary")
|
|
assert "Role summary:" not in payload["rewritten_text"]
|
|
|
|
|
|
def test_classify_block_returns_structured_json(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
|
|
def fake_generate_json(prompt: str):
|
|
assert "Senior Platform Engineer" in prompt
|
|
return {
|
|
"section": "Work Experience",
|
|
"confidence": 0.91,
|
|
"reason": "job block",
|
|
"title": "Senior Platform Engineer",
|
|
"company": "Atlas Systems",
|
|
"location": "Oslo",
|
|
"start": "2019",
|
|
"end": "Present",
|
|
"bullets": ["Built event-driven APIs and migration tooling."],
|
|
"summary": [],
|
|
"skills": ["Python", "SQL"],
|
|
}
|
|
|
|
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/cv/classify-block", json={"block": "Senior Platform Engineer at Atlas Systems, Oslo, 2019 - Present. Built event-driven APIs and migration tooling."})
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["section"] == "Work Experience"
|
|
assert payload["title"] == "Senior Platform Engineer"
|
|
assert payload["company"] == "Atlas Systems"
|
|
assert payload["bullets"] == ["Built event-driven APIs and migration tooling."]
|
|
assert payload["summary"] == []
|
|
assert payload["skills"] == ["Python", "SQL"]
|
|
|
|
|
|
def test_classify_block_supports_projects_section(monkeypatch):
|
|
# Phase 2.1-b: Projects and Certifications are now valid classified sections so project blocks
|
|
# (e.g. the benchmark CV's JobTrack/InboxIntel) are no longer dropped into "Other".
|
|
module = load_app_module(monkeypatch)
|
|
|
|
def fake_generate_json(prompt: str):
|
|
assert "Projects" in prompt # the enum now advertises Projects to the model
|
|
return {
|
|
"section": "Projects",
|
|
"confidence": 0.83,
|
|
"reason": "project block",
|
|
"title": "JobTrack",
|
|
"company": None,
|
|
"location": None,
|
|
"start": None,
|
|
"end": None,
|
|
"bullets": ["Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."],
|
|
"summary": [],
|
|
"skills": [],
|
|
}
|
|
|
|
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/cv/classify-block", json={"block": "JobTrack - Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."})
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["section"] == "Projects"
|
|
assert payload["title"] == "JobTrack"
|
|
|
|
|
|
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []})
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/cv/classify-block", json={"block": "Miscellaneous profile text"})
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["section"] == "Other"
|
|
assert payload["bullets"] == []
|
|
assert payload["summary"] == []
|
|
assert payload["skills"] == []
|
|
|
|
|
|
# --- AI provider router -------------------------------------------------------
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload):
|
|
self._data = json.dumps(payload).encode("utf-8")
|
|
|
|
def read(self):
|
|
return self._data
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
|
|
def _install_fake_urlopen(monkeypatch, module, response_payload, captured):
|
|
def fake_urlopen(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
|
captured["body"] = json.loads(req.data.decode("utf-8"))
|
|
return _FakeResponse(response_payload)
|
|
|
|
monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen)
|
|
|
|
|
|
def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch):
|
|
monkeypatch.delenv("AI_PROVIDER", raising=False)
|
|
monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama-host:11434")
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
|
assert module.AI_PROVIDER == "ollama"
|
|
|
|
captured = {}
|
|
_install_fake_urlopen(monkeypatch, module, {"response": '{"score": 7}'}, captured)
|
|
|
|
assert module._ollama_generate_json("hi") == {"score": 7}
|
|
assert captured["url"] == "http://ollama-host:11434/api/generate"
|
|
assert captured["body"]["model"] == "qwen2.5:7b"
|
|
assert captured["body"]["format"] == "json"
|
|
assert captured["body"]["options"]["temperature"] == 0.1
|
|
|
|
|
|
def test_local_success_wins_even_when_external_fallback_is_permitted(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash")
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
|
module._external_ai_allowed.set(True)
|
|
module._ai_task_type.set("cv-normalize")
|
|
|
|
calls = []
|
|
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or '{"score": 7}')
|
|
monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: calls.append("gemini") or '{"score": 9}')
|
|
|
|
assert module._ollama_generate_json("hi") == {"score": 7}
|
|
assert calls == ["ollama"]
|
|
|
|
|
|
def test_local_failure_uses_permitted_groq_fallback_sequentially(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "groq")
|
|
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
|
module._external_ai_allowed.set(True)
|
|
module._ai_task_type.set("cv-rewrite")
|
|
|
|
calls = []
|
|
|
|
def local_failure(*args, **kwargs):
|
|
calls.append("ollama")
|
|
raise module.URLError("synthetic local outage")
|
|
|
|
monkeypatch.setattr(module, "_ollama_generate", local_failure)
|
|
monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "rewritten CV text")
|
|
|
|
assert module._ollama_generate_text("rewrite this") == "rewritten CV text"
|
|
assert calls == ["ollama", "groq"]
|
|
assert module._route_state.get()["provider"] == "groq"
|
|
assert module._route_state.get()["fallback_reason"] == "local_provider_unavailable"
|
|
|
|
|
|
def test_missing_external_key_never_bypasses_local_failure(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
|
module._external_ai_allowed.set(True)
|
|
module._ai_task_type.set("cv-normalize")
|
|
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: (_ for _ in ()).throw(module.URLError("synthetic outage")))
|
|
monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not call Gemini")))
|
|
|
|
from fastapi import HTTPException
|
|
|
|
try:
|
|
module._ollama_generate_json("hi")
|
|
except module.HTTPException as ex:
|
|
assert ex.status_code == 503
|
|
assert "Ollama" in ex.detail
|
|
else:
|
|
raise AssertionError("expected the local failure")
|
|
|
|
|
|
def test_health_reports_active_provider(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
payload = client.get("/health").json()
|
|
|
|
assert payload["ai_provider"] == "gemini"
|
|
assert payload["ai_provider_configured"] is True
|
|
assert payload["ai_routing_mode"] == "local_first"
|
|
assert payload["local_circuit_open"] is False
|
|
|
|
|
|
def test_external_fallback_requires_admin_gate_and_backend_consent_header(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
|
calls = []
|
|
|
|
def fake_generate(provider, *args, **kwargs):
|
|
calls.append(provider)
|
|
if provider == "ollama":
|
|
raise module._ProviderFailure("ollama", "provider_unavailable", 503)
|
|
return "rewritten externally"
|
|
|
|
monkeypatch.setattr(module, "_generate_from_provider", fake_generate)
|
|
client = TestClient(module.app)
|
|
|
|
local_response = client.post("/cv/rewrite", json={"instruction": "Rewrite", "text": "Synthetic CV"})
|
|
assert local_response.status_code == 503
|
|
assert calls == ["ollama"]
|
|
assert local_response.headers["X-Ai-Provider"] == "ollama"
|
|
|
|
calls.clear()
|
|
external_response = client.post(
|
|
"/cv/rewrite",
|
|
json={"instruction": "Rewrite", "text": "Synthetic CV"},
|
|
headers={"X-Ai-External-Allowed": "true"},
|
|
)
|
|
assert external_response.status_code == 200
|
|
assert calls == ["ollama", "gemini"]
|
|
assert external_response.headers["X-Ai-Provider"] == "gemini"
|
|
assert external_response.headers["X-Ai-Model"] == "gemini-2.0-flash"
|
|
assert external_response.headers["X-Ai-Fallback-Reason"] == "local_provider_unavailable"
|
|
assert external_response.headers["X-Ai-Route-Reason"] == "external_fallback"
|
|
|
|
|
|
def test_invalid_local_json_can_fallback_but_prompt_cost_cap_cannot(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
module = load_app_module(
|
|
monkeypatch,
|
|
ollama_model="qwen2.5:7b",
|
|
external_ai_enabled=True,
|
|
external_prompt_limit=1000,
|
|
)
|
|
module._external_ai_allowed.set(True)
|
|
module._ai_task_type.set("cv-normalize")
|
|
calls = []
|
|
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or "not json")
|
|
monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: calls.append("gemini") or '{"score": 9}')
|
|
|
|
assert module._ollama_generate_json("short") == {"score": 9}
|
|
assert calls == ["ollama", "gemini"]
|
|
assert module._route_state.get()["fallback_reason"] == "local_schema_invalid"
|
|
|
|
calls.clear()
|
|
try:
|
|
module._ollama_generate_json("x" * 1001)
|
|
except module.HTTPException as ex:
|
|
assert ex.status_code == 502
|
|
else:
|
|
raise AssertionError("expected local schema failure above the external prompt cap")
|
|
assert calls == ["ollama"]
|
|
assert "external_prompt_limit" in module._route_state.get()["route_reason"]
|
|
|
|
|
|
def test_open_local_circuit_skips_local_only_when_fallback_is_permitted(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "groq")
|
|
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
|
module = load_app_module(
|
|
monkeypatch,
|
|
ollama_model="qwen2.5:7b",
|
|
external_ai_enabled=True,
|
|
circuit_threshold=1,
|
|
)
|
|
module._external_ai_allowed.set(True)
|
|
module._ai_task_type.set("cv-rewrite")
|
|
calls = []
|
|
|
|
def local_failure(*args, **kwargs):
|
|
calls.append("ollama")
|
|
raise module.URLError("synthetic local outage")
|
|
|
|
monkeypatch.setattr(module, "_ollama_generate", local_failure)
|
|
monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "external")
|
|
|
|
assert module._ollama_generate_text("first") == "external"
|
|
assert module._ollama_generate_text("second") == "external"
|
|
assert calls == ["ollama", "groq", "groq"]
|
|
assert module._route_state.get()["fallback_reason"] == "local_circuit_open"
|
|
|
|
|
|
def test_external_failure_is_clear_and_unapproved_background_task_stays_local(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
|
module._external_ai_allowed.set(True)
|
|
calls = []
|
|
|
|
def provider_failure(provider, *args, **kwargs):
|
|
calls.append(provider)
|
|
raise module._ProviderFailure(provider, "provider_unavailable", 503)
|
|
|
|
monkeypatch.setattr(module, "_generate_from_provider", provider_failure)
|
|
module._ai_task_type.set("cv-rewrite")
|
|
try:
|
|
module._ollama_generate_text("allowed")
|
|
except module.HTTPException as ex:
|
|
assert ex.status_code == 503
|
|
assert "Gemini" in ex.detail
|
|
else:
|
|
raise AssertionError("expected external provider failure")
|
|
assert calls == ["ollama", "gemini"]
|
|
assert module._route_state.get()["fallback_reason"] == "local_provider_unavailable"
|
|
|
|
calls.clear()
|
|
module._ai_task_type.set("strategy.snapshot")
|
|
try:
|
|
module._ollama_generate_text("not task-approved")
|
|
except module.HTTPException as ex:
|
|
assert ex.status_code == 503
|
|
assert "Ollama" in ex.detail
|
|
else:
|
|
raise AssertionError("expected local provider failure")
|
|
assert calls == ["ollama"]
|
|
assert "task_not_allowed_external" in module._route_state.get()["route_reason"]
|
|
|
|
|
|
def test_external_only_mode_still_requires_explicit_permission_and_task_allowlist(monkeypatch):
|
|
monkeypatch.setenv("AI_PROVIDER", "groq")
|
|
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
|
module = load_app_module(
|
|
monkeypatch,
|
|
ollama_model="qwen2.5:7b",
|
|
external_ai_enabled=True,
|
|
routing_mode="external_only",
|
|
)
|
|
module._ai_task_type.set("cv-rewrite")
|
|
calls = []
|
|
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or "local")
|
|
monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "external")
|
|
|
|
try:
|
|
module._ollama_generate_text("without consent")
|
|
except module.HTTPException as ex:
|
|
assert ex.status_code == 403
|
|
else:
|
|
raise AssertionError("expected external permission denial")
|
|
assert calls == []
|
|
|
|
module._external_ai_allowed.set(True)
|
|
assert module._ollama_generate_text("with consent") == "external"
|
|
assert calls == ["groq"]
|
|
|
|
|
|
def test_service_token_rejects_calls_without_the_header(monkeypatch):
|
|
module = load_app_module(monkeypatch, service_token="s3cret")
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/summarize", json={"text": "Platform engineering role."})
|
|
|
|
assert response.status_code == 401
|
|
assert "token" in response.json()["detail"].lower()
|
|
|
|
|
|
def test_service_token_rejects_a_wrong_header(monkeypatch):
|
|
module = load_app_module(monkeypatch, service_token="s3cret")
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post(
|
|
"/summarize",
|
|
json={"text": "Platform engineering role."},
|
|
headers={"X-Ai-Service-Token": "wrong"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_service_token_allows_the_correct_header(monkeypatch):
|
|
module = load_app_module(monkeypatch, service_token="s3cret")
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post(
|
|
"/summarize",
|
|
json={"text": "Platform engineering role."},
|
|
headers={"X-Ai-Service-Token": "s3cret"},
|
|
)
|
|
|
|
# 503 = passed the token gate and reached the handler, which is model-disabled here.
|
|
assert response.status_code == 503
|
|
|
|
|
|
def test_health_stays_open_so_probes_and_healthchecks_work(monkeypatch):
|
|
module = load_app_module(monkeypatch, service_token="s3cret")
|
|
client = TestClient(module.app)
|
|
|
|
assert client.get("/health").status_code == 200
|
|
|
|
|
|
def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
monkeypatch.setattr(module, "MAX_EXTRACT_FILE_BYTES", 32)
|
|
monkeypatch.setattr(module, "_run_parser_child", lambda extension, data: (_ for _ in ()).throw(AssertionError("parser must not run")))
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/extract-text", files={"file": ("large.txt", b"x" * 64, "text/plain")})
|
|
|
|
assert response.status_code == 400
|
|
assert "too large" in response.json()["detail"].lower()
|
|
|
|
|
|
def test_extract_text_rejects_extension_signature_mismatch(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/extract-text", files={"file": ("resume.pdf", b"not a pdf", "application/pdf")})
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "The uploaded file does not match its PDF extension."
|
|
|
|
|
|
def test_extract_text_rejects_binary_plain_text(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/extract-text", files={"file": ("resume.txt", b"Name\x00binary", "text/plain")})
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "The uploaded text file contains binary data."
|
|
|
|
|
|
def test_extract_text_rejects_pdf_over_page_limit(monkeypatch):
|
|
load_app_module(monkeypatch)
|
|
import parser_child
|
|
|
|
writer = PdfWriter()
|
|
writer.add_blank_page(width=100, height=100)
|
|
writer.add_blank_page(width=100, height=100)
|
|
payload = io.BytesIO()
|
|
writer.write(payload)
|
|
try:
|
|
parser_child.extract_pdf_text(payload.getvalue(), page_limit=1)
|
|
except parser_child.ParserError as exc:
|
|
assert exc.code == "pdf_page_limit"
|
|
else:
|
|
raise AssertionError("Expected the PDF page limit to reject the document")
|
|
|
|
|
|
def test_extract_text_rejects_image_pixel_limit_before_ocr(monkeypatch):
|
|
load_app_module(monkeypatch)
|
|
import parser_child
|
|
|
|
monkeypatch.setattr(parser_child, "_ocr_image", lambda image: (_ for _ in ()).throw(AssertionError("OCR must not run")))
|
|
payload = io.BytesIO()
|
|
Image.new("RGB", (10, 10), "white").save(payload, format="PNG")
|
|
|
|
try:
|
|
parser_child.extract_image_text(payload.getvalue(), pixel_limit=50)
|
|
except parser_child.ParserError as exc:
|
|
assert exc.code == "image_pixel_limit"
|
|
else:
|
|
raise AssertionError("Expected the image pixel limit to reject the document")
|
|
|
|
|
|
def test_docx_container_rejects_excessive_entries(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
monkeypatch.setattr(module, "MAX_DOCX_ENTRIES", 2)
|
|
payload = io.BytesIO()
|
|
with zipfile.ZipFile(payload, "w") as archive:
|
|
archive.writestr("[Content_Types].xml", "types")
|
|
archive.writestr("word/document.xml", "document")
|
|
archive.writestr("word/styles.xml", "styles")
|
|
|
|
try:
|
|
module._validate_docx_container(payload.getvalue())
|
|
except module.HTTPException as exc:
|
|
assert exc.status_code == 422
|
|
assert exc.detail == "The DOCX contains too many files."
|
|
else:
|
|
raise AssertionError("Expected the DOCX entry limit to reject the container")
|
|
|
|
|
|
def test_extract_text_does_not_leak_parser_exception(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
monkeypatch.setattr(module, "_run_parser_child", lambda extension, data: module._raise_parser_error("parser_failed"))
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post("/extract-text", files={"file": ("resume.pdf", b"%PDF-invalid", "application/pdf")})
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"] == "Document extraction failed."
|
|
assert "secret" not in response.text
|
|
|
|
|
|
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
|
|
load_app_module(monkeypatch)
|
|
import parser_child
|
|
|
|
normalized = parser_child.normalize_text("PROFILE\n\nEngineer\n• Built APIs\n• Shipped services")
|
|
|
|
assert normalized.splitlines() == ["PROFILE", "", "Engineer", "• Built APIs", "• Shipped services"]
|
|
|
|
|
|
def test_docx_extraction_preserves_paragraph_and_table_boundaries(monkeypatch):
|
|
load_app_module(monkeypatch)
|
|
import parser_child
|
|
|
|
document = Document()
|
|
document.add_heading("Technical Skills", level=1)
|
|
table = document.add_table(rows=2, cols=2)
|
|
table.cell(0, 0).text = "Backend"
|
|
table.cell(0, 1).text = "C#, .NET"
|
|
table.cell(1, 0).text = "DevOps"
|
|
table.cell(1, 1).text = "Docker, Linux"
|
|
payload = io.BytesIO()
|
|
document.save(payload)
|
|
|
|
extracted = parser_child.extract_docx_text(payload.getvalue())
|
|
|
|
assert "Technical Skills" in extracted
|
|
assert "Backend | C#, .NET" in extracted
|
|
assert "DevOps | Docker, Linux" in extracted
|
|
|
|
|
|
def test_extract_text_runs_in_isolated_child(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
response = client.post(
|
|
"/extract-text",
|
|
files={"file": ("resume.md", b"# Ada Lovelace\n\n## Skills\nC#", "text/markdown")},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["text"] == "# Ada Lovelace\n\n## Skills\nC#"
|
|
|
|
|
|
def test_parser_child_receives_no_provider_secrets(monkeypatch, tmp_path):
|
|
module = load_app_module(monkeypatch)
|
|
monkeypatch.setenv("GEMINI_API_KEY", "must-not-leak")
|
|
monkeypatch.setenv("GROQ_API_KEY", "must-not-leak")
|
|
child = tmp_path / "environment_probe.py"
|
|
child.write_text(textwrap.dedent("""
|
|
import argparse, json, os
|
|
from pathlib import Path
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--input')
|
|
parser.add_argument('--output')
|
|
parser.add_argument('--extension')
|
|
parser.add_argument('--cpu-seconds')
|
|
parser.add_argument('--memory-bytes')
|
|
args = parser.parse_args()
|
|
leaked = any(os.environ.get(key) for key in ('GEMINI_API_KEY', 'GROQ_API_KEY', 'AI_SERVICE_TOKEN'))
|
|
Path(args.output).write_text(json.dumps({'ok': not leaked, 'text': 'safe', 'ocr_used': False, 'page_count': None} if not leaked else {'ok': False, 'code': 'parser_failed'}), encoding='utf-8')
|
|
"""), encoding="utf-8")
|
|
monkeypatch.setattr(module, "PARSER_CHILD_PATH", child)
|
|
|
|
assert module._run_parser_child(".txt", b"safe")["text"] == "safe"
|
|
|
|
|
|
def test_parser_child_timeout_is_stable_and_releases_capacity(monkeypatch, tmp_path):
|
|
module = load_app_module(monkeypatch)
|
|
child = tmp_path / "sleeping_parser.py"
|
|
child.write_text("import time\ntime.sleep(30)\n", encoding="utf-8")
|
|
monkeypatch.setattr(module, "PARSER_CHILD_PATH", child)
|
|
monkeypatch.setattr(module, "PARSER_TIMEOUT_SECONDS", 0.1)
|
|
|
|
try:
|
|
module._run_parser_child(".txt", b"safe")
|
|
except module.HTTPException as exc:
|
|
assert exc.status_code == 504
|
|
assert exc.detail == "Document extraction timed out."
|
|
else:
|
|
raise AssertionError("Expected the parser deadline to terminate the child")
|
|
|
|
assert module._parser_capacity.acquire(blocking=False)
|
|
module._parser_capacity.release()
|
|
|
|
|
|
def test_parser_timeout_terminates_descendants(monkeypatch, tmp_path):
|
|
module = load_app_module(monkeypatch)
|
|
pid_path = tmp_path / "descendant.pid"
|
|
child = tmp_path / "parser_with_descendant.py"
|
|
child.write_text(textwrap.dedent(f"""
|
|
import subprocess, sys, time
|
|
from pathlib import Path
|
|
descendant = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'])
|
|
Path({str(pid_path)!r}).write_text(str(descendant.pid), encoding='ascii')
|
|
time.sleep(30)
|
|
"""), encoding="utf-8")
|
|
monkeypatch.setattr(module, "PARSER_CHILD_PATH", child)
|
|
monkeypatch.setattr(module, "PARSER_TIMEOUT_SECONDS", 0.2)
|
|
|
|
try:
|
|
module._run_parser_child(".txt", b"safe")
|
|
except module.HTTPException as exc:
|
|
assert exc.status_code == 504
|
|
else:
|
|
raise AssertionError("Expected the parser deadline to terminate the process tree")
|
|
|
|
descendant_pid = int(pid_path.read_text(encoding="ascii"))
|
|
if os.name == "nt":
|
|
listing = subprocess.run(
|
|
["tasklist", "/FI", f"PID eq {descendant_pid}", "/FO", "CSV", "/NH"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
).stdout
|
|
assert f'"{descendant_pid}"' not in listing
|
|
else:
|
|
try:
|
|
os.kill(descendant_pid, 0)
|
|
except ProcessLookupError:
|
|
pass
|
|
else:
|
|
# Minimal CI containers may not run an init process that promptly
|
|
# reaps orphaned children. A zombie has already been terminated and
|
|
# cannot execute or retain parser resources, even though kill(pid, 0)
|
|
# continues to see its process-table entry.
|
|
stat_path = Path(f"/proc/{descendant_pid}/stat")
|
|
try:
|
|
state = stat_path.read_text(encoding="ascii").split()[2]
|
|
except (FileNotFoundError, IndexError, OSError):
|
|
state = None
|
|
if state == "Z":
|
|
return
|
|
os.kill(descendant_pid, 9)
|
|
raise AssertionError("Parser descendant survived the parent deadline")
|
|
|
|
|
|
def test_parser_temp_cleanup_removes_only_stale_work_directories(monkeypatch, tmp_path):
|
|
module = load_app_module(monkeypatch)
|
|
parser_root = tmp_path / "parser-root"
|
|
stale = parser_root / "work-stale"
|
|
recent = parser_root / "work-recent"
|
|
unrelated = parser_root / "keep-me"
|
|
stale.mkdir(parents=True)
|
|
recent.mkdir()
|
|
unrelated.mkdir()
|
|
old = 1_000_000
|
|
os.utime(stale, (old, old))
|
|
monkeypatch.setattr(module, "PARSER_TEMP_ROOT", parser_root.resolve())
|
|
monkeypatch.setattr(module, "PARSER_STALE_WORK_SECONDS", 300)
|
|
monkeypatch.setattr(module, "_parser_temp_ready", False)
|
|
|
|
assert module._prepare_parser_temp_root() == parser_root.resolve()
|
|
assert not stale.exists()
|
|
assert recent.exists()
|
|
assert unrelated.exists()
|
|
|
|
|
|
def test_cache_purge_requires_service_token_and_clears_content(monkeypatch):
|
|
module = load_app_module(monkeypatch, service_token="s3cret")
|
|
module.cache["synthetic-key"] = "synthetic-summary"
|
|
client = TestClient(module.app)
|
|
|
|
assert client.delete("/maintenance/cache").status_code == 401
|
|
response = client.delete(
|
|
"/maintenance/cache",
|
|
headers={"X-Ai-Service-Token": "s3cret"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"cleared": 1}
|
|
assert len(module.cache) == 0
|
|
|
|
|
|
def test_endpoints_stay_open_when_no_token_is_configured(monkeypatch):
|
|
module = load_app_module(monkeypatch)
|
|
client = TestClient(module.app)
|
|
|
|
# Keyless local dev: reaches the handler (503 model-disabled), not a 401.
|
|
response = client.post("/summarize", json={"text": "Platform engineering role."})
|
|
|
|
assert response.status_code == 503
|