7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""High-value tests for security/data_sanitizer.py - Data Sanitization."""
|
|
|
|
import json
|
|
|
|
|
|
from local_deep_research.security.data_sanitizer import (
|
|
DataSanitizer,
|
|
sanitize_data,
|
|
redact_data,
|
|
filter_research_metadata,
|
|
strip_settings_snapshot,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DataSanitizer.sanitize()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSanitizeBasic:
|
|
"""Basic sanitize behavior."""
|
|
|
|
def test_removes_api_key(self):
|
|
result = DataSanitizer.sanitize(
|
|
{"username": "bob", "api_key": "secret"}
|
|
)
|
|
assert result == {"username": "bob"}
|
|
|
|
def test_removes_password(self):
|
|
result = DataSanitizer.sanitize({"password": "pass123", "name": "x"})
|
|
assert result == {"name": "x"}
|
|
|
|
def test_removes_multiple_sensitive_keys(self):
|
|
data = {"api_key": "k", "secret": "s", "safe": "v"}
|
|
result = DataSanitizer.sanitize(data)
|
|
assert result == {"safe": "v"}
|
|
|
|
def test_case_insensitive_removal(self):
|
|
data = {"API_KEY": "k", "Password": "p", "name": "n"}
|
|
result = DataSanitizer.sanitize(data)
|
|
assert result == {"name": "n"}
|
|
|
|
def test_primitive_passthrough_string(self):
|
|
assert DataSanitizer.sanitize("hello") == "hello"
|
|
|
|
def test_primitive_passthrough_int(self):
|
|
assert DataSanitizer.sanitize(42) == 42
|
|
|
|
def test_primitive_passthrough_none(self):
|
|
assert DataSanitizer.sanitize(None) is None
|
|
|
|
def test_empty_dict(self):
|
|
assert DataSanitizer.sanitize({}) == {}
|
|
|
|
def test_empty_list(self):
|
|
assert DataSanitizer.sanitize([]) == []
|
|
|
|
|
|
class TestSanitizeRecursive:
|
|
"""Recursive sanitization through nested structures."""
|
|
|
|
def test_nested_dict(self):
|
|
data = {"outer": {"api_key": "secret", "value": 1}}
|
|
result = DataSanitizer.sanitize(data)
|
|
assert result == {"outer": {"value": 1}}
|
|
|
|
def test_deeply_nested(self):
|
|
data = {"a": {"b": {"c": {"password": "p", "data": "d"}}}}
|
|
result = DataSanitizer.sanitize(data)
|
|
assert result == {"a": {"b": {"c": {"data": "d"}}}}
|
|
|
|
def test_list_of_dicts(self):
|
|
data = [{"api_key": "k1", "name": "a"}, {"api_key": "k2", "name": "b"}]
|
|
result = DataSanitizer.sanitize(data)
|
|
assert result == [{"name": "a"}, {"name": "b"}]
|
|
|
|
def test_dict_with_list_of_dicts(self):
|
|
data = {"items": [{"secret": "s", "id": 1}]}
|
|
result = DataSanitizer.sanitize(data)
|
|
assert result == {"items": [{"id": 1}]}
|
|
|
|
def test_list_of_primitives_unchanged(self):
|
|
assert DataSanitizer.sanitize([1, 2, 3]) == [1, 2, 3]
|
|
|
|
|
|
class TestSanitizeCustomKeys:
|
|
"""Custom sensitive_keys parameter."""
|
|
|
|
def test_custom_keys_only(self):
|
|
data = {"api_key": "keep", "custom_secret": "remove", "name": "n"}
|
|
result = DataSanitizer.sanitize(data, sensitive_keys={"custom_secret"})
|
|
assert result == {"api_key": "keep", "name": "n"}
|
|
|
|
def test_custom_keys_case_insensitive(self):
|
|
data = {"MY_TOKEN": "t", "name": "n"}
|
|
result = DataSanitizer.sanitize(data, sensitive_keys={"my_token"})
|
|
assert result == {"name": "n"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DataSanitizer.redact()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRedactBasic:
|
|
"""Basic redact behavior."""
|
|
|
|
def test_replaces_with_redacted(self):
|
|
data = {"api_key": "secret", "name": "bob"}
|
|
result = DataSanitizer.redact(data)
|
|
assert result == {"api_key": "[REDACTED]", "name": "bob"}
|
|
|
|
def test_case_insensitive(self):
|
|
result = DataSanitizer.redact({"PASSWORD": "p", "x": 1})
|
|
assert result == {"PASSWORD": "[REDACTED]", "x": 1}
|
|
|
|
def test_custom_redaction_text(self):
|
|
result = DataSanitizer.redact({"api_key": "k"}, redaction_text="***")
|
|
assert result == {"api_key": "***"}
|
|
|
|
def test_primitive_passthrough(self):
|
|
assert DataSanitizer.redact("hello") == "hello"
|
|
assert DataSanitizer.redact(42) == 42
|
|
assert DataSanitizer.redact(None) is None
|
|
|
|
|
|
class TestRedactRecursive:
|
|
"""Recursive redaction."""
|
|
|
|
def test_nested_dict_redaction(self):
|
|
data = {"outer": {"password": "secret", "value": 1}}
|
|
result = DataSanitizer.redact(data)
|
|
assert result == {"outer": {"password": "[REDACTED]", "value": 1}}
|
|
|
|
def test_list_of_dicts_redaction(self):
|
|
data = [{"secret": "s1", "id": 1}, {"secret": "s2", "id": 2}]
|
|
result = DataSanitizer.redact(data)
|
|
assert result == [
|
|
{"secret": "[REDACTED]", "id": 1},
|
|
{"secret": "[REDACTED]", "id": 2},
|
|
]
|
|
|
|
def test_list_passthrough(self):
|
|
assert DataSanitizer.redact([1, "two", 3]) == [1, "two", 3]
|
|
|
|
|
|
class TestRedactNonPrimitiveValue:
|
|
"""Redact replaces the value wholesale, even if it's a nested structure."""
|
|
|
|
def test_nested_dict_value_replaced_not_recursed(self):
|
|
data = {"api_key": {"nested": "data"}}
|
|
result = DataSanitizer.redact(data)
|
|
assert result == {"api_key": "[REDACTED]"}
|
|
|
|
|
|
class TestRedactCustomKeys:
|
|
"""Custom sensitive_keys for redact."""
|
|
|
|
def test_custom_keys(self):
|
|
data = {"api_key": "keep", "my_field": "redact"}
|
|
result = DataSanitizer.redact(data, sensitive_keys={"my_field"})
|
|
assert result == {"api_key": "keep", "my_field": "[REDACTED]"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Convenience functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConvenienceFunctions:
|
|
def test_sanitize_data_delegates(self):
|
|
data = {"api_key": "k", "name": "n"}
|
|
assert sanitize_data(data) == {"name": "n"}
|
|
|
|
def test_redact_data_delegates(self):
|
|
data = {"password": "p", "name": "n"}
|
|
result = redact_data(data)
|
|
assert result == {"password": "[REDACTED]", "name": "n"}
|
|
|
|
def test_redact_data_custom_text(self):
|
|
result = redact_data({"api_key": "k"}, redaction_text="HIDDEN")
|
|
assert result == {"api_key": "HIDDEN"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# filter_research_metadata()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFilterResearchMetadata:
|
|
"""filter_research_metadata extracts only safe fields."""
|
|
|
|
def test_dict_with_is_news_search_true(self):
|
|
result = filter_research_metadata(
|
|
{"is_news_search": True, "settings_snapshot": {"api_key": "x"}}
|
|
)
|
|
assert result == {"is_news_search": True}
|
|
|
|
def test_dict_with_is_news_search_false(self):
|
|
result = filter_research_metadata({"is_news_search": False})
|
|
assert result == {"is_news_search": False}
|
|
|
|
def test_dict_missing_is_news_search(self):
|
|
result = filter_research_metadata({"other": "stuff"})
|
|
assert result == {"is_news_search": False}
|
|
|
|
def test_json_string_input(self):
|
|
result = filter_research_metadata('{"is_news_search": true}')
|
|
assert result == {"is_news_search": True}
|
|
|
|
def test_none_input(self):
|
|
result = filter_research_metadata(None)
|
|
assert result == {"is_news_search": False}
|
|
|
|
def test_non_dict_input(self):
|
|
result = filter_research_metadata([1, 2, 3])
|
|
assert result == {"is_news_search": False}
|
|
|
|
def test_invalid_json_string(self):
|
|
result = filter_research_metadata("{bad json")
|
|
assert result == {"is_news_search": False}
|
|
|
|
def test_empty_dict(self):
|
|
result = filter_research_metadata({})
|
|
assert result == {"is_news_search": False}
|
|
|
|
def test_int_truthy_coerced_to_bool(self):
|
|
result = filter_research_metadata({"is_news_search": 1})
|
|
assert result == {"is_news_search": True}
|
|
assert type(result["is_news_search"]) is bool
|
|
|
|
def test_string_truthy_coerced_to_bool(self):
|
|
result = filter_research_metadata({"is_news_search": "yes"})
|
|
assert result == {"is_news_search": True}
|
|
|
|
def test_json_string_non_dict_returns_default(self):
|
|
"""JSON string that parses to a list exercises the not-isinstance(meta, dict) branch."""
|
|
result = filter_research_metadata("[1, 2]")
|
|
assert result == {"is_news_search": False}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# strip_settings_snapshot()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStripSettingsSnapshot:
|
|
"""strip_settings_snapshot removes settings_snapshot key."""
|
|
|
|
def test_removes_settings_snapshot(self):
|
|
data = {
|
|
"phase": "done",
|
|
"settings_snapshot": {"api_key": "x"},
|
|
"duration": 5,
|
|
}
|
|
result = strip_settings_snapshot(data)
|
|
assert result == {"phase": "done", "duration": 5}
|
|
|
|
def test_preserves_other_keys(self):
|
|
data = {"phase": "done", "error_type": None, "mode": "quick"}
|
|
result = strip_settings_snapshot(data)
|
|
assert result == data
|
|
|
|
def test_json_string_input(self):
|
|
data = json.dumps({"settings_snapshot": {}, "phase": "done"})
|
|
result = strip_settings_snapshot(data)
|
|
assert result == {"phase": "done"}
|
|
|
|
def test_none_input(self):
|
|
assert strip_settings_snapshot(None) == {}
|
|
|
|
def test_non_dict_input(self):
|
|
assert strip_settings_snapshot(42) == {}
|
|
|
|
def test_invalid_json(self):
|
|
assert strip_settings_snapshot("{invalid") == {}
|
|
|
|
def test_nested_settings_snapshot_survives(self):
|
|
data = {
|
|
"results": {"settings_snapshot": {"key": "val"}},
|
|
"phase": "done",
|
|
}
|
|
result = strip_settings_snapshot(data)
|
|
assert result == {
|
|
"results": {"settings_snapshot": {"key": "val"}},
|
|
"phase": "done",
|
|
}
|
|
|
|
def test_json_string_non_dict_returns_empty(self):
|
|
"""JSON string that parses to a list exercises the not-isinstance branch."""
|
|
assert strip_settings_snapshot("[1, 2]") == {}
|
|
|
|
def test_empty_dict(self):
|
|
assert strip_settings_snapshot({}) == {}
|