Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 13 additions & 5 deletions scripts/mutation_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
182 changes: 182 additions & 0 deletions tests/unit/test_masking_mutation.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions tests/unit/test_mutmut_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading