Ctrl K
ComunidadGuías…

app/tests/test_phase1_privacy_invariants.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

import base64
import re
from copy import deepcopy

import pytest

from app.masking.anonymizer import (
    MappingItem,
    MaskingSession,
    ReservedTokenInjection,
    VaultCapacityExceeded,
)
from app.masking.detector import RegexDetector
from app.masking.rehydrator import rehydrate
from app.masking.surrogate_generator import SurrogateGenerator
from app.policies.policy_engine import PolicyEngine
from app.privacy.output_guard import OutputPrivacyGuard
from app.privacy.vault import InMemoryVault, VaultBudget
from app.privacy.wire import FinalWirePrivacyGuard, WirePrivacyViolation


def test_reversible_surrogates_never_wrap_or_collide() -> None:
    generator = SurrogateGenerator()

    generated = [generator.generate("EMAIL") for _ in range(100)]

    assert len(generated) == len(set(generated))


def test_default_placeholder_is_opaque_and_collision_resistant(settings) -> None:
    policy = PolicyEngine(settings.policy_file, block_api_keys=False)
    session = MaskingSession("placeholder", policy)
    detector = RegexDetector()
    text = "Contact alice@example.com or bob@example.com"

    masked = session.mask_text(text, detector.detect(text))
    tokens = [item.replacement for item in session.items]

    assert all(re.fullmatch(r"<MG:[A-Z2-7]{26}>", token) for token in tokens)
    assert len(tokens) == len(set(tokens)) == 2
    assert "EMAIL" not in masked
    assert "_1" not in masked


def test_structural_rehydration_restores_dictionary_keys() -> None:
    mapping = [MappingItem("EMAIL", "owner@example.com", "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>")]

    restored = rehydrate(
        {"<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>": {"value": "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>"}},
        mapping,
    )

    assert restored == {"owner@example.com": {"value": "owner@example.com"}}


def test_vault_deepcopy_has_independent_lock_and_indexes() -> None:
    vault = InMemoryVault(VaultBudget())
    vault.add(MappingItem("EMAIL", "first@example.com", "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>"))

    cloned = deepcopy(vault)
    cloned.add(MappingItem("EMAIL", "second@example.com", "<MG:BBBBBBBBBBBBBBBBBBBBBBBBBB>"))

    assert len(vault.items) == 1
    assert len(cloned.items) == 2


def test_final_wire_guard_checks_exact_serialized_body() -> None:
    guard = FinalWirePrivacyGuard(RegexDetector())

    checked = guard.check(
        provider="openai-chat",
        payload={
            "messages": [
                {"role": "user", "content": "Hello <MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>"}
            ]
        },
        approved_tokens={"<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>"},
    )

    assert b"<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>" in checked.body
    assert checked.provider == "openai-chat"


def test_final_wire_guard_rejects_raw_pii_and_injected_tokens() -> None:
    guard = FinalWirePrivacyGuard(RegexDetector())

    with pytest.raises(WirePrivacyViolation, match="EMAIL"):
        guard.check(
            provider="openai-chat",
            payload={"messages": [{"role": "user", "content": "alice@example.com"}]},
        )


def test_final_wire_guard_rejects_reversibly_encoded_content() -> None:
    guard = FinalWirePrivacyGuard(RegexDetector())
    encoded = base64.b64encode(b"alice@example.com").decode("ascii")

    with pytest.raises(WirePrivacyViolation, match="unsupported encoded content"):
        guard.check(
            provider="openai-chat",
            payload={"metadata": {"opaque": encoded}},
        )


def test_final_wire_guard_rejects_unclassified_numeric_data() -> None:
    guard = FinalWirePrivacyGuard(RegexDetector())

    with pytest.raises(WirePrivacyViolation, match="unclassified numeric"):
        guard.check(
            provider="openai-chat",
            payload={"metadata": {"phone_as_number": 7_916_123_4567}},
        )

    checked = guard.check(
        provider="openai-chat",
        payload={"temperature": 0.2, "max_tokens": 100, "messages": []},
    )
    assert checked.payload["temperature"] == 0.2

    with pytest.raises(WirePrivacyViolation, match="encoded EMAIL"):
        guard.check(
            provider="openai-chat",
            payload={"metadata": {"escaped": "alice%40example%2Ecom"}},
        )

    with pytest.raises(WirePrivacyViolation, match="unrecognized MaskGate token"):
        guard.check(
            provider="openai-chat",
            payload={"messages": [{"role": "user", "content": "<MG:BBBBBBBBBBBBBBBBBBBBBBBBBB>"}]},
        )


def test_output_guard_redacts_new_provider_pii_before_authorized_rehydration() -> None:
    guard = OutputPrivacyGuard(RegexDetector())
    mapping = [MappingItem("EMAIL", "owner@example.com", "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>")]
    provider_response = {
        "choices": [
            {
                "message": {
                    "content": (
                        "Known <MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>; generated stranger@example.net"
                    )
                }
            }
        ]
    }

    guarded = guard.process(provider_response, mapping)

    content = guarded["choices"][0]["message"]["content"]
    assert "owner@example.com" in content
    assert "stranger@example.net" not in content
    assert "[REDACTED_PROVIDER_EMAIL]" in content


def test_output_guard_never_restores_tokens_in_provider_metadata() -> None:
    guard = OutputPrivacyGuard(RegexDetector())
    mapping = [MappingItem("EMAIL", "owner@example.com", "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>")]

    guarded = guard.process(
        {
            "id": "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>",
            "choices": [{"message": {"content": "<MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>"}}],
        },
        mapping,
    )

    assert guarded["id"] == "[REDACTED_PROVIDER_TOKEN]"
    assert guarded["choices"][0]["message"]["content"] == "owner@example.com"


def test_vault_mapping_budget_fails_closed(settings) -> None:
    policy = PolicyEngine(settings.policy_file)
    session = MaskingSession("placeholder", policy, max_mappings=1)
    detector = RegexDetector()
    session.mask_text("first@example.com", detector.detect("first@example.com"))

    with pytest.raises(VaultCapacityExceeded):
        session.mask_text("second@example.com", detector.detect("second@example.com"))


def test_user_supplied_reserved_token_is_rejected(settings) -> None:
    session = MaskingSession("placeholder", PolicyEngine(settings.policy_file))
    text = "Replay <MG:AAAAAAAAAAAAAAAAAAAAAAAAAA>"

    with pytest.raises(ReservedTokenInjection):
        session.mask_text(text, RegexDetector().detect(text))