diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fa37c..86ca289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [CLI] NewsDOM JSON 출력물이 스키마(`ParseResponse`)를 준수하는지 검증하는 `tools/validate_dom.py` 도구를 추가했습니다. + ### Security - 전역 500 에러 응답에도 표준 보안 헤더를 적용하여 예외 경로에서 header 누락을 방지 - MinerU subprocess argv 생성 시 `-`로 시작하는 option-like 인자를 거부하여 argument injection 위험을 낮춤 diff --git a/tests/test_tools_validate_dom.py b/tests/test_tools_validate_dom.py new file mode 100644 index 0000000..0a998ff --- /dev/null +++ b/tests/test_tools_validate_dom.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +from pathlib import Path +import pytest +import runpy +import sys +import warnings + +from tools import validate_dom + + +@pytest.fixture +def mock_valid_json_file(tmp_path: Path) -> Path: + json_path = tmp_path / "valid.json" + data = { + "document_id": "doc_123", + "pages": [], + "quality": {"status": "success", "parser": "mineru", "warnings": []}, + } + json_path.write_text(json.dumps(data), encoding="utf-8") + return json_path + + +@pytest.fixture +def mock_invalid_schema_json_file(tmp_path: Path) -> Path: + json_path = tmp_path / "invalid_schema.json" + # Missing document_id which is required in ParseResponse + data = {"pages": []} + json_path.write_text(json.dumps(data), encoding="utf-8") + return json_path + + +@pytest.fixture +def mock_invalid_format_json_file(tmp_path: Path) -> Path: + json_path = tmp_path / "invalid_format.json" + json_path.write_text("{ this is not a valid json ", encoding="utf-8") + return json_path + + +def test_validate_dom_success(mock_valid_json_file, capsys): + validate_dom.main([str(mock_valid_json_file)]) + out = capsys.readouterr().out + assert "Validation successful" in out + + +def test_validate_dom_invalid_schema(mock_invalid_schema_json_file, capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main([str(mock_invalid_schema_json_file)]) + assert e.value.code == 1 + assert "Schema validation failed" in capsys.readouterr().err + + +def test_validate_dom_invalid_format(mock_invalid_format_json_file, capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main([str(mock_invalid_format_json_file)]) + assert e.value.code == 1 + assert "Invalid JSON format" in capsys.readouterr().err + + +def test_validate_dom_not_found(tmp_path, capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main([str(tmp_path / "missing.json")]) + assert e.value.code == 1 + assert "File not found" in capsys.readouterr().err + + +def test_validate_dom_wrong_ext(tmp_path, capsys): + txt = tmp_path / "wrong.txt" + txt.write_text("test", encoding="utf-8") + with pytest.raises(SystemExit) as e: + validate_dom.main([str(txt)]) + assert e.value.code == 1 + assert "must be a .json file" in capsys.readouterr().err + + +def test_validate_dom_main_execution(monkeypatch, mock_valid_json_file): + monkeypatch.setattr(sys, "argv", ["validate_dom.py", str(mock_valid_json_file)]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + runpy.run_module("tools.validate_dom", run_name="__main__") + + +def test_validate_dom_help_exit(capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main(["--help"]) + assert e.value.code == 0 + assert "Validate a NewsDOM JSON output" in capsys.readouterr().out + + +def test_validate_dom_interrupt(monkeypatch, mock_valid_json_file): + def mock_validate(*args, **kwargs): + raise KeyboardInterrupt() + + monkeypatch.setattr(validate_dom, "validate_dom", mock_validate) + + with pytest.raises(SystemExit) as e: + validate_dom.main([str(mock_valid_json_file)]) + assert e.value.code == 130 diff --git a/tools/validate_dom.py b/tools/validate_dom.py new file mode 100644 index 0000000..52429c7 --- /dev/null +++ b/tools/validate_dom.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +# We inject sys.path inside main() to avoid global side-effects during test imports. + + +def validate_dom(json_path: Path) -> Any: + """Validate DOM JSON against ParseResponse schema.""" + if not json_path.is_file(): + raise FileNotFoundError(f"File not found or is not a file: {json_path}") + if json_path.suffix.lower() != ".json": + raise ValueError("File must be a .json file.") + + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON format: {e}") from e + + from newsdom_api.schemas import ParseResponse + + try: + validated_model = ParseResponse.model_validate(data) + except ValidationError as e: + raise ValueError(f"Schema validation failed:\n{e}") from e + + return validated_model + + +def main(argv: list[str] | None = None) -> None: + """Run validate_dom main entry point.""" + _REPO_ROOT = Path(__file__).resolve().parents[1] + _SRC_ROOT = _REPO_ROOT / "src" + if str(_SRC_ROOT) not in sys.path: # pragma: no cover + sys.path.insert(0, str(_SRC_ROOT)) + + parser = argparse.ArgumentParser( + description="Validate a NewsDOM JSON output against the ParseResponse schema." + ) + parser.add_argument("input", type=Path, help="Path to the JSON DOM file.") + + args = parser.parse_args(argv) + + try: + validate_dom(args.input) + print( + "Validation successful: The JSON file strictly matches the ParseResponse schema." + ) + except KeyboardInterrupt: + sys.exit(130) + except (FileNotFoundError, ValueError, OSError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": # pragma: no cover + main()