427 lines
15 KiB
Python
427 lines
15 KiB
Python
|
|
"""Tests for sensitive-data boundary and AI provider compliance (B-PRIV-TEST)."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.core.sensitive_data import (
|
||
|
|
DATA_EXPOSURE_POLICY,
|
||
|
|
KNOWN_DATA_CLASSES,
|
||
|
|
SENSITIVE_FIELDS,
|
||
|
|
check_provider_compliance,
|
||
|
|
filter_for_embeddings,
|
||
|
|
filter_for_export,
|
||
|
|
filter_for_llm_context,
|
||
|
|
filter_for_search,
|
||
|
|
get_data_class_for_field,
|
||
|
|
get_exposure_policy,
|
||
|
|
get_sensitive_fields,
|
||
|
|
is_sensitive,
|
||
|
|
register_sensitive_fields,
|
||
|
|
sanitize_dict,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ── is_sensitive ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestIsSensitive:
|
||
|
|
def test_known_sensitive_contact_field(self):
|
||
|
|
assert is_sensitive("contact", "password_hash") is True
|
||
|
|
|
||
|
|
def test_known_sensitive_mail_account_field(self):
|
||
|
|
assert is_sensitive("mail_account", "smtp_password") is True
|
||
|
|
|
||
|
|
def test_known_sensitive_user_field(self):
|
||
|
|
assert is_sensitive("user", "api_key") is True
|
||
|
|
|
||
|
|
def test_non_sensitive_contact_field(self):
|
||
|
|
assert is_sensitive("contact", "firstname") is False
|
||
|
|
|
||
|
|
def test_unknown_entity_type(self):
|
||
|
|
assert is_sensitive("nonexistent", "password_hash") is False
|
||
|
|
|
||
|
|
def test_unknown_field(self):
|
||
|
|
assert is_sensitive("contact", "unknown_field") is False
|
||
|
|
|
||
|
|
|
||
|
|
# ── get_sensitive_fields ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetSensitiveFields:
|
||
|
|
def test_contact_fields(self):
|
||
|
|
fields = get_sensitive_fields("contact")
|
||
|
|
assert "password_hash" in fields
|
||
|
|
assert "smtp_password" in fields
|
||
|
|
assert "api_key" in fields
|
||
|
|
|
||
|
|
def test_user_fields(self):
|
||
|
|
fields = get_sensitive_fields("user")
|
||
|
|
assert "password_hash" in fields
|
||
|
|
assert "session_token" in fields
|
||
|
|
|
||
|
|
def test_unknown_entity_returns_empty(self):
|
||
|
|
assert get_sensitive_fields("nonexistent") == set()
|
||
|
|
|
||
|
|
def test_returns_copy(self):
|
||
|
|
fields = get_sensitive_fields("contact")
|
||
|
|
fields.add("temp_field")
|
||
|
|
# Original should not be modified
|
||
|
|
assert "temp_field" not in SENSITIVE_FIELDS["contact"]
|
||
|
|
|
||
|
|
|
||
|
|
# ── sanitize_dict ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestSanitizeDict:
|
||
|
|
def test_redacts_sensitive_fields(self):
|
||
|
|
data = {"firstname": "John", "password_hash": "secret123", "email_1": "john@example.com"}
|
||
|
|
result = sanitize_dict(data, "contact")
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
assert result["password_hash"] == "***REDACTED***"
|
||
|
|
assert result["email_1"] == "john@example.com"
|
||
|
|
|
||
|
|
def test_preserves_non_sensitive_fields(self):
|
||
|
|
data = {"firstname": "Jane", "surname": "Doe"}
|
||
|
|
result = sanitize_dict(data, "contact")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
def test_does_not_mutate_original(self):
|
||
|
|
data = {"password_hash": "secret"}
|
||
|
|
sanitize_dict(data, "contact")
|
||
|
|
assert data["password_hash"] == "secret"
|
||
|
|
|
||
|
|
def test_unknown_entity_type_preserves_all(self):
|
||
|
|
data = {"password_hash": "secret", "name": "test"}
|
||
|
|
result = sanitize_dict(data, "nonexistent")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
def test_nested_dict_redaction(self):
|
||
|
|
data = {"meta": {"password_hash": "secret", "info": "ok"}}
|
||
|
|
result = sanitize_dict(data, "contact")
|
||
|
|
assert result["meta"]["password_hash"] == "***REDACTED***"
|
||
|
|
assert result["meta"]["info"] == "ok"
|
||
|
|
|
||
|
|
def test_user_entity_redaction(self):
|
||
|
|
data = {"name": "admin", "password_hash": "hashed", "api_key": "key123"}
|
||
|
|
result = sanitize_dict(data, "user")
|
||
|
|
assert result["name"] == "admin"
|
||
|
|
assert result["password_hash"] == "***REDACTED***"
|
||
|
|
assert result["api_key"] == "***REDACTED***"
|
||
|
|
|
||
|
|
|
||
|
|
# ── register_sensitive_fields ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestRegisterSensitiveFields:
|
||
|
|
def test_register_new_entity_type(self):
|
||
|
|
register_sensitive_fields("custom_plugin_entity", {"secret_field"})
|
||
|
|
assert is_sensitive("custom_plugin_entity", "secret_field") is True
|
||
|
|
# Cleanup
|
||
|
|
SENSITIVE_FIELDS.pop("custom_plugin_entity", None)
|
||
|
|
|
||
|
|
def test_register_merges_with_existing(self):
|
||
|
|
original = set(SENSITIVE_FIELDS.get("contact", set()))
|
||
|
|
register_sensitive_fields("contact", {"new_secret_field"})
|
||
|
|
assert is_sensitive("contact", "new_secret_field") is True
|
||
|
|
assert is_sensitive("contact", "password_hash") is True
|
||
|
|
# Cleanup
|
||
|
|
SENSITIVE_FIELDS["contact"] = original
|
||
|
|
|
||
|
|
def test_register_multiple_fields(self):
|
||
|
|
register_sensitive_fields("test_multi", {"field1", "field2", "field3"})
|
||
|
|
fields = get_sensitive_fields("test_multi")
|
||
|
|
assert fields == {"field1", "field2", "field3"}
|
||
|
|
# Cleanup
|
||
|
|
SENSITIVE_FIELDS.pop("test_multi", None)
|
||
|
|
|
||
|
|
|
||
|
|
# ── get_exposure_policy ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetExposurePolicy:
|
||
|
|
def test_sensitive_field_always_blocked(self):
|
||
|
|
policy = get_exposure_policy("contact", "password_hash")
|
||
|
|
assert all(v is False for v in policy.values())
|
||
|
|
|
||
|
|
def test_normal_field_all_allowed(self):
|
||
|
|
policy = get_exposure_policy("contact", "firstname")
|
||
|
|
assert all(v is True for v in policy.values())
|
||
|
|
|
||
|
|
def test_sensitive_financial_field_export_only(self):
|
||
|
|
policy = get_exposure_policy("contact", "vat_code")
|
||
|
|
assert policy["export"] is True
|
||
|
|
assert policy["llm_context"] is False
|
||
|
|
assert policy["embeddings"] is False
|
||
|
|
assert policy["search"] is False
|
||
|
|
|
||
|
|
def test_unknown_field_defaults_to_all_allowed(self):
|
||
|
|
policy = get_exposure_policy("contact", "totally_unknown_field")
|
||
|
|
assert all(v is True for v in policy.values())
|
||
|
|
|
||
|
|
def test_policy_has_all_systems(self):
|
||
|
|
policy = get_exposure_policy("contact", "firstname")
|
||
|
|
for system in ("llm_context", "search", "embeddings", "rag", "agent_memory", "export"):
|
||
|
|
assert system in policy
|
||
|
|
|
||
|
|
|
||
|
|
# ── filter_for_llm_context ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestFilterForLlmContext:
|
||
|
|
def test_removes_sensitive_fields(self):
|
||
|
|
data = {"firstname": "John", "password_hash": "secret", "email_1": "john@example.com"}
|
||
|
|
result = filter_for_llm_context(data, "contact")
|
||
|
|
assert "password_hash" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
|
||
|
|
def test_removes_export_only_fields(self):
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123", "notes": "private"}
|
||
|
|
result = filter_for_llm_context(data, "contact")
|
||
|
|
assert "vat_code" not in result
|
||
|
|
assert "notes" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
|
||
|
|
def test_keeps_normal_fields(self):
|
||
|
|
data = {"firstname": "John", "surname": "Doe", "email_1": "john@example.com"}
|
||
|
|
result = filter_for_llm_context(data, "contact")
|
||
|
|
assert result == data
|
||
|
|
|
||
|
|
|
||
|
|
# ── filter_for_search ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestFilterForSearch:
|
||
|
|
def test_removes_sensitive_fields(self):
|
||
|
|
data = {"firstname": "John", "password_hash": "secret"}
|
||
|
|
result = filter_for_search(data, "contact")
|
||
|
|
assert "password_hash" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
|
||
|
|
def test_removes_export_only_fields(self):
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123"}
|
||
|
|
result = filter_for_search(data, "contact")
|
||
|
|
assert "vat_code" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
|
||
|
|
|
||
|
|
# ── filter_for_export ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestFilterForExport:
|
||
|
|
def test_removes_sensitive_fields(self):
|
||
|
|
data = {"firstname": "John", "password_hash": "secret", "vat_code": "DE123"}
|
||
|
|
result = filter_for_export(data, "contact")
|
||
|
|
assert "password_hash" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
assert result["vat_code"] == "DE123"
|
||
|
|
|
||
|
|
def test_keeps_export_allowed_fields(self):
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123", "bic": "ABCDEF"}
|
||
|
|
result = filter_for_export(data, "contact")
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
assert result["vat_code"] == "DE123"
|
||
|
|
assert result["bic"] == "ABCDEF"
|
||
|
|
|
||
|
|
|
||
|
|
# ── filter_for_embeddings ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestFilterForEmbeddings:
|
||
|
|
def test_removes_sensitive_fields(self):
|
||
|
|
data = {"firstname": "John", "password_hash": "secret"}
|
||
|
|
result = filter_for_embeddings(data, "contact")
|
||
|
|
assert "password_hash" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
|
||
|
|
def test_removes_export_only_fields(self):
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123"}
|
||
|
|
result = filter_for_embeddings(data, "contact")
|
||
|
|
assert "vat_code" not in result
|
||
|
|
assert result["firstname"] == "John"
|
||
|
|
|
||
|
|
|
||
|
|
# ── Secrets always blocked ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestSecretsAlwaysBlocked:
|
||
|
|
"""Ensure that known secret fields are blocked in all filters."""
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("field_name", [
|
||
|
|
"password_hash",
|
||
|
|
"smtp_password",
|
||
|
|
"imap_password",
|
||
|
|
"api_key",
|
||
|
|
"oauth_token",
|
||
|
|
"session_token",
|
||
|
|
"encryption_key",
|
||
|
|
])
|
||
|
|
def test_secret_blocked_in_llm_context(self, field_name):
|
||
|
|
data = {field_name: "secret_value", "firstname": "John"}
|
||
|
|
result = filter_for_llm_context(data, "contact")
|
||
|
|
assert field_name not in result
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("field_name", [
|
||
|
|
"password_hash",
|
||
|
|
"smtp_password",
|
||
|
|
"imap_password",
|
||
|
|
"api_key",
|
||
|
|
"oauth_token",
|
||
|
|
"session_token",
|
||
|
|
"encryption_key",
|
||
|
|
])
|
||
|
|
def test_secret_blocked_in_search(self, field_name):
|
||
|
|
data = {field_name: "secret_value", "firstname": "John"}
|
||
|
|
result = filter_for_search(data, "contact")
|
||
|
|
assert field_name not in result
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("field_name", [
|
||
|
|
"password_hash",
|
||
|
|
"smtp_password",
|
||
|
|
"imap_password",
|
||
|
|
"api_key",
|
||
|
|
"oauth_token",
|
||
|
|
"session_token",
|
||
|
|
"encryption_key",
|
||
|
|
])
|
||
|
|
def test_secret_blocked_in_embeddings(self, field_name):
|
||
|
|
data = {field_name: "secret_value", "firstname": "John"}
|
||
|
|
result = filter_for_embeddings(data, "contact")
|
||
|
|
assert field_name not in result
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("field_name", [
|
||
|
|
"password_hash",
|
||
|
|
"smtp_password",
|
||
|
|
"imap_password",
|
||
|
|
"api_key",
|
||
|
|
"oauth_token",
|
||
|
|
"session_token",
|
||
|
|
"encryption_key",
|
||
|
|
])
|
||
|
|
def test_secret_blocked_in_export(self, field_name):
|
||
|
|
data = {field_name: "secret_value", "firstname": "John"}
|
||
|
|
result = filter_for_export(data, "contact")
|
||
|
|
assert field_name not in result
|
||
|
|
|
||
|
|
|
||
|
|
# ── Exposure policy enforcement ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestExposurePolicyEnforcement:
|
||
|
|
def test_sensitive_field_not_in_llm_context(self):
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123"}
|
||
|
|
result = filter_for_llm_context(data, "contact")
|
||
|
|
assert "vat_code" not in result
|
||
|
|
assert "firstname" in result
|
||
|
|
|
||
|
|
def test_sensitive_field_not_in_embeddings(self):
|
||
|
|
data = {"firstname": "John", "notes": "private notes"}
|
||
|
|
result = filter_for_embeddings(data, "contact")
|
||
|
|
assert "notes" not in result
|
||
|
|
assert "firstname" in result
|
||
|
|
|
||
|
|
def test_sensitive_field_in_export(self):
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123"}
|
||
|
|
result = filter_for_export(data, "contact")
|
||
|
|
assert "vat_code" in result
|
||
|
|
assert result["vat_code"] == "DE123"
|
||
|
|
|
||
|
|
|
||
|
|
# ── AI Provider compliance ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestProviderCompliance:
|
||
|
|
def test_allowed_data_class(self):
|
||
|
|
assert check_provider_compliance(["public", "internal"], "public") is True
|
||
|
|
|
||
|
|
def test_disallowed_data_class(self):
|
||
|
|
assert check_provider_compliance(["public"], "sensitive") is False
|
||
|
|
|
||
|
|
def test_none_allowed_classes_fails_open(self):
|
||
|
|
assert check_provider_compliance(None, "sensitive") is True
|
||
|
|
|
||
|
|
def test_empty_allowed_classes_fails_open(self):
|
||
|
|
assert check_provider_compliance([], "sensitive") is True
|
||
|
|
|
||
|
|
def test_critical_data_class_blocked(self):
|
||
|
|
assert check_provider_compliance(["public", "sensitive"], "critical") is False
|
||
|
|
|
||
|
|
def test_all_known_data_classes_exist(self):
|
||
|
|
for dc in ("public", "internal", "sensitive", "critical"):
|
||
|
|
assert dc in KNOWN_DATA_CLASSES
|
||
|
|
|
||
|
|
|
||
|
|
# ── get_data_class_for_field ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetDataClassForField:
|
||
|
|
def test_sensitive_field_is_critical(self):
|
||
|
|
assert get_data_class_for_field("contact", "password_hash") == "critical"
|
||
|
|
|
||
|
|
def test_normal_field_is_public(self):
|
||
|
|
assert get_data_class_for_field("contact", "firstname") == "public"
|
||
|
|
|
||
|
|
def test_export_only_field_is_sensitive(self):
|
||
|
|
assert get_data_class_for_field("contact", "vat_code") == "sensitive"
|
||
|
|
|
||
|
|
|
||
|
|
# ── Integration: non-approved provider receives no sensitive data ──
|
||
|
|
|
||
|
|
|
||
|
|
class TestProviderDataFiltering:
|
||
|
|
"""Verify that a non-approved provider does not receive sensitive data."""
|
||
|
|
|
||
|
|
def test_filter_removes_data_before_provider_check(self):
|
||
|
|
"""Simulate the flow: filter data → check provider compliance."""
|
||
|
|
data = {
|
||
|
|
"firstname": "John",
|
||
|
|
"password_hash": "secret",
|
||
|
|
"vat_code": "DE123",
|
||
|
|
"notes": "private",
|
||
|
|
}
|
||
|
|
|
||
|
|
# Step 1: Filter for LLM context
|
||
|
|
filtered = filter_for_llm_context(data, "contact")
|
||
|
|
|
||
|
|
# Step 2: Check provider compliance for remaining fields
|
||
|
|
for field_name in filtered:
|
||
|
|
data_class = get_data_class_for_field("contact", field_name)
|
||
|
|
# Provider only allows public data
|
||
|
|
assert check_provider_compliance(["public"], data_class) is True, \
|
||
|
|
f"Field {field_name} with data_class={data_class} should be allowed"
|
||
|
|
|
||
|
|
# Sensitive fields should have been removed
|
||
|
|
assert "password_hash" not in filtered
|
||
|
|
assert "vat_code" not in filtered
|
||
|
|
assert "notes" not in filtered
|
||
|
|
|
||
|
|
def test_provider_without_sensitive_data_approval(self):
|
||
|
|
"""A provider that only allows 'public' should not receive 'sensitive' data."""
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123"}
|
||
|
|
filtered = filter_for_llm_context(data, "contact")
|
||
|
|
|
||
|
|
# vat_code should be filtered out (export-only → not in llm_context)
|
||
|
|
assert "vat_code" not in filtered
|
||
|
|
|
||
|
|
# Even if it weren't filtered, compliance check would block it
|
||
|
|
data_class = get_data_class_for_field("contact", "vat_code")
|
||
|
|
assert check_provider_compliance(["public"], data_class) is False
|
||
|
|
|
||
|
|
def test_provider_with_full_approval(self):
|
||
|
|
"""A provider that allows all data classes should receive all non-sensitive data."""
|
||
|
|
data = {"firstname": "John", "vat_code": "DE123", "password_hash": "secret"}
|
||
|
|
filtered = filter_for_llm_context(data, "contact")
|
||
|
|
|
||
|
|
# password_hash always blocked
|
||
|
|
assert "password_hash" not in filtered
|
||
|
|
|
||
|
|
# vat_code is export-only, not allowed in llm_context
|
||
|
|
assert "vat_code" not in filtered
|
||
|
|
|
||
|
|
# firstname is normal, always allowed
|
||
|
|
assert "firstname" in filtered
|
||
|
|
|
||
|
|
# Compliance check for firstname should pass
|
||
|
|
data_class = get_data_class_for_field("contact", "firstname")
|
||
|
|
assert check_provider_compliance(["public", "sensitive", "critical"], data_class) is True
|