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
563 lines
19 KiB
Python
563 lines
19 KiB
Python
"""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"
|