diff --git a/pyproject.toml b/pyproject.toml index c3ccbb2..7efb37c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,15 +205,15 @@ asyncio_mode = "auto" # tests/unit/test_mutmut_policy.py (paths exist, define real logic, cover the # security-critical surfaces). It is a superset of what the CI gate actually # mutates: scripts/mutation_report.py drives the gate from its own -# MODULE_TARGETS, which now mutates sdk/agentflow/retry.py AND -# src/serving/semantic_layer/sql_guard.py live. sql_guard is mutated as a -# top-level `serving` package against a duckdb-free narrow test: mutmut's -# trampoline rejects a module name starting with `src.`, which (not duckdb) was -# the real blocker. The remaining serving modules below stay declared-only for -# now -- their unit tests pull the duckdb-backed query engine, so mutating them -# in isolation needs a duckdb-free test per module (the pattern sql_guard now -# uses); the blocker is the test import chain, not the module. See -# scripts/mutation_report.py. +# MODULE_TARGETS, which now mutates sdk/agentflow/retry.py plus +# src/serving/semantic_layer/sql_guard.py and src/serving/masking.py live. The +# serving modules are mutated as top-level `serving` packages against duckdb-free +# narrow tests: mutmut's trampoline rejects a module name starting with `src.`, +# which (not duckdb) was the real blocker. The remaining serving modules below +# stay declared-only for now -- their unit tests pull the duckdb-backed query +# engine, so mutating them in isolation needs a duckdb-free test per module (the +# pattern sql_guard and masking now use); the blocker is the test import chain, +# not the module. See scripts/mutation_report.py. paths_to_mutate = [ "src/serving/api/auth/manager.py", "src/serving/api/auth/key_rotation.py", diff --git a/scripts/mutation_report.py b/scripts/mutation_report.py index 3e03363..9533d1f 100644 --- a/scripts/mutation_report.py +++ b/scripts/mutation_report.py @@ -29,11 +29,11 @@ class ModuleTarget: # that, not duckdb, was the real blocker for the serving modules. The fix is to # (a) copy the module so it imports as a top-level package and (b) pair it with a # NARROW test that does not pull the duckdb-backed engine import chain. So -# retry.py mutates as agentflow.retry (from sdk/agentflow), and sql_guard mutates -# as serving.semantic_layer.sql_guard (from src/serving) against a duckdb-free -# test. Serving modules whose tests still need the duckdb engine (the -# query/masking/auth surfaces) remain harder to isolate and stay declared-only in -# the [tool.mutmut] policy until they get duckdb-free unit tests of their own. +# retry.py mutates as agentflow.retry (from sdk/agentflow), and sql_guard and +# masking mutate as serving.* packages (from src/serving) against duckdb-free +# tests. Serving modules whose tests still need the duckdb engine (the query and +# auth surfaces) remain harder to isolate and stay declared-only in the +# [tool.mutmut] policy until they get duckdb-free unit tests of their own. MODULE_TARGETS = { Path("agentflow/retry.py"): ModuleTarget( threshold=0.75, @@ -43,6 +43,14 @@ class ModuleTarget: threshold=0.90, tests=("tests/unit/test_sql_guard_mutation.py",), ), + Path("serving/masking.py"): ModuleTarget( + # Lower than sql_guard's 0.90: masking is a much larger surface (config + # loading + every redaction strategy), and the narrow duckdb-free test + # scores ~0.84 -- enough to catch redaction-logic regressions without + # chasing equivalent config-init mutants. + threshold=0.80, + tests=("tests/unit/test_masking_mutation.py",), + ), } STATUS_BY_EXIT_CODE = { diff --git a/tests/unit/test_masking_mutation.py b/tests/unit/test_masking_mutation.py new file mode 100644 index 0000000..d5c3c24 --- /dev/null +++ b/tests/unit/test_masking_mutation.py @@ -0,0 +1,182 @@ +"""Narrow, duckdb-free mutation test for the PII masker. + +This is the test the mutation gate runs against src/serving/masking.py (see +scripts/mutation_report.py MODULE_TARGETS). masking imports only hashlib / +pathlib / sqlglot / yaml -- no duckdb -- so, like sql_guard, it is mutated as a +top-level ``serving`` package against a test that stays off the duckdb-backed +engine import chain. + +A surviving mutant in PII redaction is a cleartext-PII leak, so this exercises +every strategy branch (full / hash / partial / passthrough / None) and every +``_partial_mask`` shape (email / phone / address / multi-word / single word), +pinning the exact masked output so value-level mutants die. +""" + +import hashlib + +import pytest + +try: # mutation-harness workspace exposes it as a top-level package + from serving.masking import PiiMasker +except ImportError: # ordinary pytest sees it under the src package + from src.serving.masking import PiiMasker + +CONFIG_YAML = """\ +masking: + default_strategy: partial + entity_fields: + user: + - field: email + strategy: partial + - field: ip_address + strategy: hash + - field: ssn + strategy: full + - field: untouched + strategy: passthrough + - field: nickname + order: + - field: shipping_address + strategy: partial + pii_exempt_tenants: + - internal-analytics +""" + + +@pytest.fixture +def masker(tmp_path): + cfg = tmp_path / "pii_fields.yaml" + cfg.write_text(CONFIG_YAML, encoding="utf-8") + return PiiMasker(config_path=cfg) + + +def test_exempt_tenant_returns_unmasked_copy(masker): + data = {"email": "alice@example.com"} + out = masker.mask("user", data, "internal-analytics") + assert out == {"email": "alice@example.com"} + assert out is not data # a copy, not the original + + +def test_non_exempt_tenant_masks_email(masker): + out = masker.mask("user", {"email": "alice@example.com"}, "acme") + assert out["email"] == "a***@example.com" + + +def test_hash_strategy(masker): + out = masker.mask("user", {"ip_address": "10.0.0.1"}, "acme") + assert out["ip_address"] == hashlib.sha256(b"10.0.0.1").hexdigest()[:12] + + +def test_full_strategy(masker): + assert masker.mask("user", {"ssn": "123-45-6789"}, "acme")["ssn"] == "***" + + +def test_unknown_strategy_passes_value_through(masker): + out = masker.mask("user", {"untouched": "keep-me"}, "acme") + assert out["untouched"] == "keep-me" + + +def test_rule_without_strategy_uses_default(masker): + # nickname has no strategy -> default_strategy (partial) applies. + assert masker.mask("user", {"nickname": "Bobby"}, "acme")["nickname"] == "B***" + + +def test_field_absent_is_skipped(masker): + out = masker.mask("user", {"other": "x"}, "acme") + assert out == {"other": "x"} + + +def test_unknown_entity_returns_copy(masker): + out = masker.mask("widget", {"email": "a@b.com"}, "acme") + assert out == {"email": "a@b.com"} + + +def test_mask_query_results_masks_matched_entity(masker): + rows = [{"email": "alice@example.com"}] + masked, changed = masker.mask_query_results( + "SELECT email FROM users_v2", rows, "acme", {"users_v2": "user"} + ) + assert masked[0]["email"] == "a***@example.com" + assert changed is True + + +def test_mask_query_results_no_entity_passthrough(masker): + rows = [{"email": "alice@example.com"}] + masked, changed = masker.mask_query_results( + "SELECT email FROM unknown_t", rows, "acme", {"users_v2": "user"} + ) + assert masked[0]["email"] == "alice@example.com" + assert changed is False + + +def test_mask_query_results_unparseable_sql_passes_through(masker): + masked, changed = masker.mask_query_results( + "NOT SQL (((", [{"email": "a@b.com"}], "acme", {"users_v2": "user"} + ) + assert changed is False + + +def test_mask_query_results_multi_entity_join_masks_both(masker): + rows = [{"email": "alice@example.com", "shipping_address": "123 Main St, Town"}] + masked, changed = masker.mask_query_results( + "SELECT * FROM users_v2 JOIN orders_v2 ON 1=1", + rows, + "acme", + {"users_v2": "user", "orders_v2": "order"}, + ) + assert masked[0]["email"] == "a***@example.com" + assert masked[0]["shipping_address"] != "123 Main St, Town" + assert changed is True + + +# --- _partial_mask shapes (via _apply_strategy partial) ---------------------- + + +def test_partial_email_empty_local(masker): + assert masker._mask_email("@example.com") == "***@example.com" + + +def test_partial_phone(masker): + assert masker._partial_mask("+1 (234) 567-8900") == "***-***-8900" + + +def test_phone_without_digits_is_starred(masker): + assert masker._mask_phone("no-digits-here-xxxxxxx") == "***" + + +def test_partial_address_with_number(masker): + assert masker._partial_mask("123 Main St, Springfield") == "123 *** St, ***" + + +def test_address_street_without_number(masker): + assert masker._mask_address("Main Street") == "M*** S***" + + +def test_partial_multiword_name(masker): + assert masker._partial_mask("John Ronald Doe") == "J*** R*** D***" + + +def test_partial_single_word(masker): + assert masker._partial_mask("Madonna") == "M***" + + +def test_partial_empty_string(masker): + assert masker._partial_mask("") == "" + + +def test_apply_strategy_none_stays_none(masker): + assert masker._apply_strategy(None, "full") is None + + +def test_mask_word_empty(masker): + assert masker._mask_word("") == "" + + +def test_looks_like_phone_true_false(masker): + assert masker._looks_like_phone("+1 234 567 8900") is True + assert masker._looks_like_phone("hello world") is False + + +def test_looks_like_address_true_false(masker): + assert masker._looks_like_address("12 Main") is True + assert masker._looks_like_address("nodigits") is False diff --git a/tests/unit/test_mutmut_policy.py b/tests/unit/test_mutmut_policy.py index 11217ef..bbef864 100644 --- a/tests/unit/test_mutmut_policy.py +++ b/tests/unit/test_mutmut_policy.py @@ -17,11 +17,12 @@ # - sql_builder: every entity/metric SQL string the engine executes is # assembled here. # NOTE: these are the *declared* targets (intent). Actual mutation execution is -# gated by scripts/mutation_report.py (MODULE_TARGETS), which now runs retry.py -# AND sql_guard.py live (sql_guard via a duckdb-free narrow test, mutated as a -# top-level `serving` package so mutmut's trampoline accepts it). The other -# serving modules below stay declared-only until they get duckdb-free unit tests -# of their own. These assertions guard the declared policy, not live coverage. +# gated by scripts/mutation_report.py (MODULE_TARGETS), which now runs retry.py, +# sql_guard.py AND masking.py live (the serving modules via duckdb-free narrow +# tests, mutated as top-level `serving` packages so mutmut's trampoline accepts +# them). The other serving modules below stay declared-only until they get +# duckdb-free unit tests of their own. These guard the declared policy, not live +# coverage. REQUIRED_MUTATION_TARGETS = { "src/serving/semantic_layer/sql_guard.py", "src/serving/api/auth/manager.py",