You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
132 lines
5.6 KiB
132 lines
5.6 KiB
"""Synthetic command-boundary tests; lifecycle persistence has separate coverage.""" |
|
import copy |
|
import importlib |
|
import json |
|
from io import StringIO |
|
from types import SimpleNamespace |
|
from uuid import UUID |
|
|
|
import pytest |
|
from django.core.management import call_command |
|
from django.core.management.base import CommandError |
|
|
|
|
|
BATCH = "11111111-1111-4111-8111-111111111111" |
|
ACTOR = ["--actor-kind", "HUMAN", "--actor-identifier", "synthetic-owner"] |
|
|
|
|
|
@pytest.fixture |
|
def command(tmp_path, settings, monkeypatch): |
|
settings.IMPORT_ROOT = tmp_path |
|
module = importlib.import_module("ingestion.management.commands.import_review") |
|
review = { |
|
"batch_id": BATCH, "revision": 1, "state": "PREVIEW", |
|
"profile": "BANK_CSV_V1", "encoding": "utf-8", |
|
"summary": {"items": 1}, |
|
"items": [{"locator": "line:2", "candidate": {"description": "SYNTHETIC PRIVATE LABEL"}, |
|
"issues": [], "duplicates": [], "decision": None}], |
|
"decisions": [{"locator": "line:2", "action": "CREATE"}], |
|
} |
|
batch = SimpleNamespace(pk=UUID(BATCH), state="PREVIEW", revision=1) |
|
monkeypatch.setattr(module.services, "export_review", lambda *a, **kw: copy.deepcopy(review)) |
|
monkeypatch.setattr(module.services, "apply_decisions", lambda *a, **kw: batch) |
|
return module, review |
|
|
|
|
|
def run(command, operation, *args): |
|
output = StringIO() |
|
call_command(command[0].Command(), operation, *args, *ACTOR, stdout=output) |
|
return output.getvalue() |
|
|
|
|
|
def test_export_keeps_candidate_text_in_local_file_only(command, tmp_path): |
|
output = run(command, "export", "--batch", BATCH, "--output", "review.json") |
|
assert "SYNTHETIC PRIVATE LABEL" not in output |
|
assert json.loads((tmp_path / "review.json").read_text())["items"][0]["candidate"]["description"] == "SYNTHETIC PRIVATE LABEL" |
|
assert json.loads(output)["batch_id"] == BATCH |
|
|
|
|
|
@pytest.mark.parametrize("path", ["../outside.json", "nested/../../outside.json"]) |
|
def test_output_rejects_traversal(command, path): |
|
with pytest.raises(CommandError, match="INVALID_PATH"): |
|
run(command, "export", "--batch", BATCH, "--output", path) |
|
|
|
|
|
def test_output_never_overwrites_input_or_existing_review(command, tmp_path): |
|
destination = tmp_path / "source.json" |
|
destination.write_text("synthetic original") |
|
with pytest.raises(CommandError): |
|
run(command, "export", "--batch", BATCH, "--output", "source.json") |
|
assert destination.read_text() == "synthetic original" |
|
|
|
|
|
def test_output_rejects_symlink_escape(command, tmp_path): |
|
link = tmp_path / "escape" |
|
try: |
|
link.symlink_to(tmp_path.parent, target_is_directory=True) |
|
except OSError: |
|
pytest.skip("Symlink creation unavailable") |
|
with pytest.raises(CommandError, match="INVALID_PATH"): |
|
run(command, "export", "--batch", BATCH, "--output", "escape/review.json") |
|
|
|
|
|
@pytest.mark.parametrize("payload", [ |
|
'{"batch_id":"' + BATCH + '","revision":1,"revision":2,"decisions":[]}', |
|
'{"batch_id":"' + BATCH + '","revision":1,"decisions":[{"locator":"2","action":"CREATE","action":"SKIP"}]}', |
|
'{"batch_id":"' + BATCH + '","revision":NaN,"decisions":[]}', |
|
]) |
|
def test_apply_rejects_ambiguous_json(command, tmp_path, payload): |
|
(tmp_path / "decisions.json").write_text(payload) |
|
with pytest.raises(CommandError, match="INVALID_JSON"): |
|
run(command, "apply", "--batch", BATCH, "--input", "decisions.json") |
|
|
|
|
|
@pytest.mark.parametrize("change", ["candidate", "metadata", "unknown", "revision", "batch"]) |
|
def test_apply_rejects_modified_evidence_or_wrong_envelope(command, tmp_path, change): |
|
payload = copy.deepcopy(command[1]) |
|
if change == "candidate": |
|
payload["items"][0]["candidate"]["description"] = "tampered" |
|
elif change == "metadata": |
|
payload["encoding"] = "latin-1" |
|
elif change == "unknown": |
|
payload["extra"] = True |
|
elif change == "revision": |
|
payload["revision"] = True |
|
else: |
|
payload["batch_id"] = "22222222-2222-4222-8222-222222222222" |
|
(tmp_path / "decisions.json").write_text(json.dumps(payload)) |
|
with pytest.raises(CommandError): |
|
run(command, "apply", "--batch", BATCH, "--input", "decisions.json") |
|
|
|
|
|
@pytest.mark.parametrize("full", [True, False]) |
|
def test_apply_accepts_decisions_only_or_unchanged_full_export(command, tmp_path, full): |
|
payload = copy.deepcopy(command[1]) |
|
payload["decisions"] = [{"locator": "line:2", "action": "SKIP", "reason": "USER_EXCLUDED"}] |
|
if not full: |
|
payload = {key: payload[key] for key in ("batch_id", "revision", "decisions")} |
|
(tmp_path / "decisions.json").write_text(json.dumps(payload)) |
|
output = run(command, "apply", "--batch", BATCH, "--input", "decisions.json") |
|
assert "SYNTHETIC PRIVATE LABEL" not in output |
|
assert json.loads(output)["state"] == "PREVIEW" |
|
|
|
|
|
def test_file_read_is_bounded(command, tmp_path): |
|
with (tmp_path / "large.json").open("wb") as stream: |
|
stream.truncate(10 * 1024 * 1024 + 1) |
|
with pytest.raises(CommandError, match="INPUT_TOO_LARGE"): |
|
run(command, "apply", "--batch", BATCH, "--input", "large.json") |
|
|
|
|
|
def test_service_exception_does_not_disclose_source(command, monkeypatch): |
|
def fail(*args, **kwargs): |
|
raise RuntimeError("SYNTHETIC PRIVATE LABEL") |
|
monkeypatch.setattr(command[0].services, "export_review", fail) |
|
with pytest.raises(CommandError) as caught: |
|
run(command, "export", "--batch", BATCH, "--output", "review.json") |
|
assert "SYNTHETIC" not in str(caught.value) |
|
|
|
|
|
def test_all_operations_require_explicit_actor(command): |
|
with pytest.raises(CommandError): |
|
call_command(command[0].Command(), "export", "--batch", BATCH, "--output", "review.json")
|
|
|