"""Comprehensive coverage tests for data_sanitizer and log_sanitizer modules. Module 1 -- data_sanitizer: DataSanitizer.sanitize, DataSanitizer.redact, sanitize_data, redact_data, filter_research_metadata, strip_settings_snapshot Module 2 -- log_sanitizer: strip_control_chars, sanitize_for_log """ import json import pytest from local_deep_research.security.data_sanitizer import ( DataSanitizer, filter_research_metadata, redact_data, sanitize_data, strip_settings_snapshot, ) from local_deep_research.security.log_sanitizer import ( sanitize_for_log, strip_control_chars, ) # =================================================================== # DataSanitizer.sanitize # =================================================================== class TestSanitizeNestedDicts: """sanitize removes sensitive keys inside deeply nested dicts.""" def test_two_levels_deep(self): data = {"outer": {"api_key": "abc", "safe": 1}} result = DataSanitizer.sanitize(data) assert result == {"outer": {"safe": 1}} def test_three_levels_deep(self): data = {"a": {"b": {"password": "pw", "x": 9}}} result = DataSanitizer.sanitize(data) assert result == {"a": {"b": {"x": 9}}} def test_multiple_sensitive_keys_at_different_depths(self): data = { "secret": "top", "level1": { "refresh_token": "tok", "level2": {"private_key": "pk", "ok": True}, }, } result = DataSanitizer.sanitize(data) assert result == {"level1": {"level2": {"ok": True}}} class TestSanitizeLists: """sanitize recurses into lists and sanitizes dicts inside them.""" def test_list_of_dicts(self): data = [{"api_key": "k", "id": 1}, {"password": "p", "id": 2}] result = DataSanitizer.sanitize(data) assert result == [{"id": 1}, {"id": 2}] def test_empty_list(self): assert DataSanitizer.sanitize([]) == [] def test_list_of_primitives_unchanged(self): data = [1, "hello", None, True] assert DataSanitizer.sanitize(data) == [1, "hello", None, True] class TestSanitizeSensitiveKeyRemoval: """Verify all default sensitive keys are removed.""" @pytest.mark.parametrize( "key", sorted(DataSanitizer.DEFAULT_SENSITIVE_KEYS) ) def test_each_default_key_removed(self, key): data = {key: "value", "keep_me": 42} result = DataSanitizer.sanitize(data) assert key not in result assert result["keep_me"] == 42 class TestSanitizeCaseInsensitive: """sanitize matches keys case-insensitively.""" def test_uppercase_key(self): data = {"API_KEY": "secret", "name": "ok"} result = DataSanitizer.sanitize(data) assert "API_KEY" not in result assert result == {"name": "ok"} def test_mixed_case_key(self): data = {"PaSsWoRd": "secret", "name": "ok"} result = DataSanitizer.sanitize(data) assert "PaSsWoRd" not in result def test_title_case_key(self): data = {"Access_Token": "tok", "status": "active"} result = DataSanitizer.sanitize(data) assert "Access_Token" not in result assert result == {"status": "active"} class TestSanitizeJsonStringInput: """sanitize treats a JSON string as a primitive -- it is NOT parsed.""" def test_json_string_returned_as_is(self): json_str = json.dumps({"api_key": "secret"}) result = DataSanitizer.sanitize(json_str) assert result == json_str def test_json_array_string_returned_as_is(self): json_str = json.dumps([{"password": "pw"}]) assert DataSanitizer.sanitize(json_str) == json_str class TestSanitizeCustomKeys: """sanitize accepts a custom set of sensitive keys.""" def test_custom_keys_only(self): data = {"my_token": "t", "api_key": "k", "name": "n"} result = DataSanitizer.sanitize(data, sensitive_keys={"my_token"}) assert "my_token" not in result assert "api_key" in result # not in custom set def test_empty_custom_keys_removes_nothing(self): data = {"api_key": "k", "password": "p"} result = DataSanitizer.sanitize(data, sensitive_keys=set()) assert result == data # =================================================================== # DataSanitizer.redact # =================================================================== class TestRedactBasic: """redact replaces sensitive values with placeholder text.""" def test_default_redaction_text(self): data = {"api_key": "secret123", "name": "ok"} result = DataSanitizer.redact(data) assert result["api_key"] == "[REDACTED]" assert result["name"] == "ok" def test_key_preserved_value_replaced(self): data = {"password": "mypass"} result = DataSanitizer.redact(data) assert "password" in result assert result["password"] == "[REDACTED]" class TestRedactCustomText: """redact accepts custom redaction placeholder text.""" def test_custom_redaction_text(self): data = {"api_key": "secret", "name": "ok"} result = DataSanitizer.redact(data, redaction_text="***") assert result["api_key"] == "***" assert result["name"] == "ok" def test_empty_string_redaction_text(self): data = {"secret": "val"} result = DataSanitizer.redact(data, redaction_text="") assert result["secret"] == "" def test_long_redaction_text(self): text = "SENSITIVE DATA REMOVED BY SECURITY FILTER" data = {"password": "pw"} result = DataSanitizer.redact(data, redaction_text=text) assert result["password"] == text class TestRedactNestedStructures: """redact recurses into nested dicts and lists.""" def test_nested_dict(self): data = {"config": {"auth_token": "tok", "host": "localhost"}} result = DataSanitizer.redact(data) assert result["config"]["auth_token"] == "[REDACTED]" assert result["config"]["host"] == "localhost" def test_list_of_dicts(self): data = [{"session_token": "s1"}, {"csrf_token": "c1"}] result = DataSanitizer.redact(data) assert result[0]["session_token"] == "[REDACTED]" assert result[1]["csrf_token"] == "[REDACTED]" def test_dict_in_list_in_dict(self): data = {"items": [{"private_key": "pk", "id": 1}]} result = DataSanitizer.redact(data) assert result["items"][0]["private_key"] == "[REDACTED]" assert result["items"][0]["id"] == 1 def test_original_data_not_mutated(self): data = {"api_key": "original_value", "name": "ok"} DataSanitizer.redact(data) assert data["api_key"] == "original_value" class TestRedactCaseInsensitive: """redact matches keys case-insensitively.""" def test_uppercase_key_redacted(self): data = {"SECRET": "val"} result = DataSanitizer.redact(data) assert result["SECRET"] == "[REDACTED]" def test_mixed_case_key_redacted(self): data = {"Apikey": "val", "name": "ok"} result = DataSanitizer.redact(data) assert result["Apikey"] == "[REDACTED]" assert result["name"] == "ok" # =================================================================== # sanitize_data wrapper # =================================================================== class TestSanitizeDataWrapper: """sanitize_data convenience function delegates to DataSanitizer.sanitize.""" def test_removes_default_sensitive_keys(self): data = {"api_key": "k", "name": "n"} result = sanitize_data(data) assert result == {"name": "n"} def test_accepts_custom_sensitive_keys(self): data = {"custom_field": "v", "api_key": "k"} result = sanitize_data(data, sensitive_keys={"custom_field"}) assert "custom_field" not in result assert "api_key" in result def test_primitive_passthrough(self): assert sanitize_data(99) == 99 assert sanitize_data(None) is None # =================================================================== # redact_data wrapper # =================================================================== class TestRedactDataWrapper: """redact_data convenience function delegates to DataSanitizer.redact.""" def test_redacts_default_sensitive_keys(self): data = {"password": "pw", "name": "n"} result = redact_data(data) assert result["password"] == "[REDACTED]" assert result["name"] == "n" def test_accepts_custom_keys_and_text(self): data = {"my_key": "v", "password": "pw"} result = redact_data( data, sensitive_keys={"my_key"}, redaction_text="XXX" ) assert result["my_key"] == "XXX" assert result["password"] == "pw" def test_primitive_passthrough(self): assert redact_data("hello") == "hello" assert redact_data(None) is None # =================================================================== # filter_research_metadata # =================================================================== class TestFilterResearchMetadata: """filter_research_metadata extracts only safe allowlisted fields.""" def test_extracts_is_news_search_true(self): result = filter_research_metadata( {"is_news_search": True, "extra": "x"} ) assert result == {"is_news_search": True} assert "extra" not in result def test_extracts_is_news_search_false(self): result = filter_research_metadata({"is_news_search": False}) assert result == {"is_news_search": False} def test_missing_is_news_search_defaults_false(self): result = filter_research_metadata({"some_other_key": 123}) assert result == {"is_news_search": False} def test_none_input(self): result = filter_research_metadata(None) assert result == {"is_news_search": False} def test_json_string_input_dict(self): meta_json = json.dumps( {"is_news_search": True, "settings_snapshot": {}} ) result = filter_research_metadata(meta_json) assert result == {"is_news_search": True} assert "settings_snapshot" not in result def test_json_string_input_false(self): meta_json = json.dumps({"is_news_search": False}) result = filter_research_metadata(meta_json) 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_integer_input_returns_default(self): result = filter_research_metadata(42) assert result == {"is_news_search": False} def test_is_news_search_truthy_int_coerced(self): """Non-zero int is truthy, coerced to True via bool().""" result = filter_research_metadata({"is_news_search": 1}) assert result["is_news_search"] is True assert type(result["is_news_search"]) is bool def test_settings_snapshot_never_leaks(self): meta = { "is_news_search": False, "settings_snapshot": {"api_key": "SUPER_SECRET"}, } result = filter_research_metadata(meta) assert "settings_snapshot" not in result # =================================================================== # strip_settings_snapshot # =================================================================== class TestStripSettingsSnapshot: """strip_settings_snapshot removes settings_snapshot, preserves the rest.""" def test_removes_settings_snapshot(self): meta = {"phase": "done", "settings_snapshot": {"api_key": "k"}} result = strip_settings_snapshot(meta) assert "settings_snapshot" not in result assert result["phase"] == "done" def test_preserves_all_other_keys(self): meta = { "mode": "quick", "duration": 5.2, "is_news_search": True, "settings_snapshot": {}, } result = strip_settings_snapshot(meta) assert result == { "mode": "quick", "duration": 5.2, "is_news_search": True, } def test_none_input(self): result = strip_settings_snapshot(None) assert result == {} def test_string_metadata_json(self): meta_json = json.dumps( {"phase": "complete", "settings_snapshot": {"k": "v"}} ) result = strip_settings_snapshot(meta_json) assert result == {"phase": "complete"} assert "settings_snapshot" not in result def test_no_settings_snapshot_key_noop(self): meta = {"phase": "done", "duration": 3.0} result = strip_settings_snapshot(meta) assert result == meta def test_invalid_json_string_returns_empty(self): result = strip_settings_snapshot("not valid json {{{") assert result == {} def test_non_dict_type_returns_empty(self): assert strip_settings_snapshot(123) == {} assert strip_settings_snapshot([1, 2]) == {} # =================================================================== # strip_control_chars (log_sanitizer) # =================================================================== class TestStripControlCharsComprehensive: """strip_control_chars removes C0/C1 control, zero-width, and format chars.""" def test_c0_null_and_bell(self): assert strip_control_chars("a\x00b\x07c") == "abc" def test_c0_tab_newline_carriage_return(self): assert strip_control_chars("a\tb\nc\rd") == "abcd" def test_c1_range(self): """C1 control characters (0x80-0x9F) are stripped.""" assert strip_control_chars("a\x80b\x8fc\x9fd") == "abcd" def test_del_character(self): """DEL (0x7F) is stripped.""" assert strip_control_chars("hello\x7fworld") == "helloworld" def test_zero_width_space(self): assert strip_control_chars("hello\u200bworld") == "helloworld" def test_zero_width_non_joiner(self): assert strip_control_chars("a\u200cb") == "ab" def test_zero_width_joiner(self): assert strip_control_chars("a\u200db") == "ab" def test_ltr_rtl_marks(self): assert strip_control_chars("a\u200eb\u200fc") == "abc" def test_unicode_format_chars_rlo(self): """Right-to-left override (RLO) is stripped.""" assert strip_control_chars("hello\u202eworld") == "helloworld" def test_unicode_embedding_chars(self): """LRE, RLE, PDF, LRO are stripped.""" assert strip_control_chars("\u202a\u202b\u202c\u202dtext") == "text" def test_bom_stripped(self): assert strip_control_chars("\ufeffhello") == "hello" def test_word_joiner_stripped(self): assert strip_control_chars("a\u2060b") == "ab" def test_arabic_letter_mark_stripped(self): assert strip_control_chars("a\u061cb") == "ab" def test_isolate_chars_stripped(self): """LRI, RLI, FSI, PDI are stripped.""" assert strip_control_chars("\u2066\u2067\u2068\u2069text") == "text" def test_preserves_valid_unicode(self): assert ( strip_control_chars("cafe\u0301") == "cafe\u0301" ) # e-acute combining def test_preserves_emoji(self): result = strip_control_chars("hello world") assert result == "hello world" def test_preserves_cjk(self): assert strip_control_chars("test") == "test" def test_empty_string(self): assert strip_control_chars("") == "" def test_all_control_chars_yields_empty(self): assert strip_control_chars("\x00\x01\x02\x7f\x80") == "" # =================================================================== # sanitize_for_log (log_sanitizer) # =================================================================== class TestSanitizeForLogTruncation: """sanitize_for_log truncates with '...' suffix when exceeding max_length.""" def test_long_string_truncated_with_ellipsis(self): result = sanitize_for_log("a" * 100, max_length=50) assert result == "a" * 47 + "..." assert len(result) == 50 def test_default_max_length_50(self): result = sanitize_for_log("b" * 200) assert len(result) == 50 assert result.endswith("...") def test_exact_max_length_no_truncation(self): result = sanitize_for_log("c" * 50, max_length=50) assert result == "c" * 50 assert "..." not in result def test_shorter_than_max_no_truncation(self): result = sanitize_for_log("short", max_length=50) assert result == "short" class TestSanitizeForLogMaxLengthEdgeCases: """Edge-case max_length values: 0, 1, 3, 50.""" def test_max_length_zero(self): """max_length=0 returns empty string (no room for anything).""" result = sanitize_for_log("hello", max_length=0) assert result == "" def test_max_length_one(self): """max_length=1 -- too short for '...' so just truncates.""" result = sanitize_for_log("hello", max_length=1) assert len(result) <= 1 assert result == "h" def test_max_length_three(self): """max_length=3 -- exactly the length of '...', uses raw truncation.""" result = sanitize_for_log("hello", max_length=3) assert len(result) <= 3 assert result == "hel" def test_max_length_four_uses_ellipsis(self): """max_length=4 -- enough room for 1 char + '...'.""" result = sanitize_for_log("hello world", max_length=4) assert result == "h..." assert len(result) == 4 def test_max_length_50_default(self): result = sanitize_for_log("x" * 60, max_length=50) assert len(result) == 50 assert result == "x" * 47 + "..." class TestSanitizeForLogEmptyString: """sanitize_for_log on empty input.""" def test_empty_string_returns_empty(self): assert sanitize_for_log("") == "" def test_empty_string_with_explicit_max_length(self): assert sanitize_for_log("", max_length=10) == "" class TestSanitizeForLogControlCharsAndTruncation: """Control chars are stripped BEFORE truncation is applied.""" def test_control_chars_removed_then_truncated(self): # 10 visible chars + control chars = still only 10 visible after strip raw = "abcde\x00\x01\x02fghij" result = sanitize_for_log(raw, max_length=50) assert result == "abcdefghij" def test_control_chars_reduce_length_below_max(self): """After stripping, string may become shorter than max_length.""" raw = "\x00\x01\x02abc" result = sanitize_for_log(raw, max_length=50) assert result == "abc" def test_control_chars_stripped_still_needs_truncation(self): """Even after stripping, result may still exceed max_length.""" raw = "a" * 40 + "\x00" * 5 + "b" * 40 result = sanitize_for_log(raw, max_length=50) # After strip: 80 visible chars, needs truncation to 50 assert len(result) == 50 assert result.endswith("...") def test_all_control_chars_yields_empty(self): result = sanitize_for_log("\x00\x01\x7f", max_length=50) assert result == "" def test_unicode_preserved_controls_stripped(self): raw = "cafe\u0301\x00\x07 world" result = sanitize_for_log(raw, max_length=50) assert result == "cafe\u0301 world"