feat: add secure contract import CLI
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Offline command workflows for contract management."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.contracts.cli import parser, run
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
|
||||
|
||||
|
||||
async def test_validate_command_reports_source_counts(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
arguments = argparse.Namespace(command="validate", store=tmp_path, file=FIXTURE)
|
||||
|
||||
assert await run(arguments) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert json.loads(output) == {"nodes": 1, "warnings": 0}
|
||||
|
||||
|
||||
async def test_local_import_list_and_show(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
import_arguments = argparse.Namespace(
|
||||
command="import",
|
||||
store=tmp_path,
|
||||
file=FIXTURE,
|
||||
url=None,
|
||||
version="9.2.3",
|
||||
)
|
||||
assert await run(import_arguments) == 0
|
||||
revision = Path(capsys.readouterr().out.strip()).name
|
||||
|
||||
assert await run(argparse.Namespace(command="list", store=tmp_path)) == 0
|
||||
assert capsys.readouterr().out.strip() == revision
|
||||
|
||||
assert await run(argparse.Namespace(command="show", store=tmp_path, revision=revision)) == 0
|
||||
manifest = json.loads(capsys.readouterr().out)
|
||||
assert manifest["source_version"] == "9.2.3"
|
||||
assert manifest["snapshot_sha256"] == revision
|
||||
|
||||
|
||||
def test_cli_parser_accepts_local_import() -> None:
|
||||
arguments = parser().parse_args(
|
||||
["--store", "saved", "import", "--file", str(FIXTURE), "--version", "9.2.3"]
|
||||
)
|
||||
|
||||
assert arguments.command == "import"
|
||||
assert arguments.store == Path("saved")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Security and idempotency tests for contract imports."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.contracts.importer import RemoteSourceImporter, validate_remote_url
|
||||
from app.contracts.normalize import normalize_snapshot
|
||||
from app.contracts.source import ApiViewerParser, SourceError
|
||||
from app.contracts.store import RevisionStore
|
||||
|
||||
|
||||
async def public_resolver(_host: str) -> tuple[str, ...]:
|
||||
return ("93.184.216.34",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://pve.proxmox.com/apidoc.js",
|
||||
"https://evil.example/apidoc.js",
|
||||
"https://pve.proxmox.com.evil.example/apidoc.js",
|
||||
"https://user@pve.proxmox.com/apidoc.js",
|
||||
"https://pve.proxmox.com:444/apidoc.js",
|
||||
],
|
||||
)
|
||||
def test_remote_url_policy_rejects_unsafe_urls(url: str) -> None:
|
||||
with pytest.raises(SourceError):
|
||||
validate_remote_url(url, frozenset({"pve.proxmox.com"}))
|
||||
|
||||
|
||||
async def test_remote_import_rejects_private_resolution() -> None:
|
||||
async def private_resolver(_host: str) -> tuple[str, ...]:
|
||||
return ("127.0.0.1",)
|
||||
|
||||
importer = RemoteSourceImporter(
|
||||
"https://pve.proxmox.com/apidoc.js",
|
||||
resolver=private_resolver,
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"[]")),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceError, match="non-public"):
|
||||
await importer.load()
|
||||
|
||||
|
||||
async def test_redirect_is_revalidated() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(302, headers={"location": "https://evil.example/private"})
|
||||
|
||||
importer = RemoteSourceImporter(
|
||||
"https://pve.proxmox.com/apidoc.js",
|
||||
resolver=public_resolver,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceError, match="allowlist"):
|
||||
await importer.load()
|
||||
|
||||
|
||||
async def test_remote_import_enforces_size_limit() -> None:
|
||||
importer = RemoteSourceImporter(
|
||||
"https://pve.proxmox.com/apidoc.js",
|
||||
max_bytes=2,
|
||||
resolver=public_resolver,
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"[]\n")),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceError, match="size"):
|
||||
await importer.load()
|
||||
|
||||
|
||||
def test_revision_store_is_idempotent(tmp_path: Path) -> None:
|
||||
raw = b'[{"path":"/version","info":{}}]'
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
snapshot, manifest = normalize_snapshot(
|
||||
parsed,
|
||||
raw=raw,
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
store = RevisionStore(tmp_path)
|
||||
|
||||
first = store.save(raw, snapshot, manifest)
|
||||
second = store.save(raw, snapshot, manifest)
|
||||
|
||||
assert first == second
|
||||
assert store.list() == (manifest.snapshot_sha256,)
|
||||
assert store.manifest(manifest.snapshot_sha256) == manifest
|
||||
Reference in New Issue
Block a user