# Story 7.11: Add Redactor Branch Coverage Tests

**Status:** Ready
**Priority:** P1
**Depends on:** None
**Effort:** 2 days

---

## Problem Statement

The Code Coverage Report identified redactor modules as having significant branch coverage gaps:

- `field_mask.py`: 81.72% (16 stmts, 18 branches missing)
- `regex_mask.py`: 85.25% (13 stmts, 5 branches missing)
- `url_credentials.py`: 84.27% (9 stmts, 5 branches missing)

These are **security-critical** components. Untested branches in redaction logic could mean:

1. Sensitive data leaking through edge cases
2. Silent failures when `block_on_unredactable=True` is configured
3. Traversal bugs with deeply nested or malformed structures
4. Guardrail bypasses (max_depth, max_keys_scanned)

Current tests cover basic masking but miss:

- Wildcard traversal paths (`*`, `[*]`, `key[*]`)
- Numeric index handling in dicts vs lists
- `block_on_unredactable` diagnostic emission paths
- Intermediate vs terminal field handling
- Guardrail limit behaviors
- Exception containment in assignment failures

---

## Goals

1. **Verify wildcard traversal** - All wildcard patterns (`*`, `[*]`, `key[*]`) work correctly
2. **Verify guardrail enforcement** - `max_depth` and `max_keys_scanned` limits trigger diagnostics
3. **Verify block_on_unredactable** - Diagnostic paths fire correctly
4. **Verify exception containment** - Assignment failures don't crash redaction
5. **Verify URL credential stripping** - Edge cases in URL parsing are handled

---

## Acceptance Criteria

### AC1: FieldMaskRedactor Wildcard Paths

- [ ] Test verifies `*` wildcard masks all dict keys at that level
- [ ] Test verifies `[*]` wildcard masks all list elements at that level
- [ ] Test verifies `key[*]` masks all elements of list at `key`
- [ ] Test verifies terminal wildcard masks values, not keys
- [ ] Test verifies non-terminal wildcard descends into nested structures

### AC2: FieldMaskRedactor Guardrails

- [ ] Test verifies `max_depth` exceeded emits diagnostic and stops traversal
- [ ] Test verifies `max_keys_scanned` exceeded emits diagnostic and stops traversal
- [ ] Test verifies redaction completes (doesn't crash) when guardrails hit

### AC3: FieldMaskRedactor block_on_unredactable

- [ ] Test verifies diagnostic emitted when terminal field assignment fails
- [ ] Test verifies diagnostic emitted when intermediate field is not dict/list
- [ ] Test verifies diagnostic emitted when container is primitive mid-path
- [ ] Test verifies no diagnostic when `block_on_unredactable=False`

### AC4: FieldMaskRedactor Edge Cases

- [ ] Test verifies numeric index in path is ignored for dicts
- [ ] Test verifies numeric index in path works for lists
- [ ] Test verifies absent paths are silently ignored
- [ ] Test verifies idempotent masking (already-masked values unchanged)

### AC5: RegexMaskRedactor Branch Paths

- [ ] Test verifies pattern compilation errors recorded in `_pattern_errors`
- [ ] Test verifies `health_check()` returns False with pattern errors
- [ ] Test verifies broken pattern at runtime doesn't crash traversal
- [ ] Test verifies `block_on_unredactable` diagnostic on assignment failure
- [ ] Test verifies guardrails emit diagnostics when exceeded

### AC6: UrlCredentialsRedactor Edge Cases

- [ ] Test verifies strings exceeding `max_string_length` are unchanged
- [ ] Test verifies empty strings are unchanged
- [ ] Test verifies unparseable URLs are unchanged
- [ ] Test verifies depth/scan guardrails stop traversal
- [ ] Test verifies exception in `_strip_credentials` emits diagnostic

---

## Technical Approach

### Test Files

Create new test file: `tests/unit/test_redactor_branches.py`

### Test 1: FieldMask Wildcard Traversal

```python
@pytest.mark.asyncio
async def test_field_mask_wildcard_masks_all_keys():
    """Wildcard '*' should mask all keys at that level."""
    redactor = FieldMaskRedactor(
        config={"fields_to_mask": ["data.*"], "mask_string": "MASKED"}
    )

    event = {
        "message": "test",
        "data": {
            "field1": "secret1",
            "field2": "secret2",
            "field3": "secret3",
        }
    }

    result = await redactor.redact(event)

    assert result["data"]["field1"] == "MASKED"
    assert result["data"]["field2"] == "MASKED"
    assert result["data"]["field3"] == "MASKED"
    assert result["message"] == "test"  # Unchanged


@pytest.mark.asyncio
async def test_field_mask_bracket_wildcard_on_list():
    """Wildcard '[*]' should mask all list elements."""
    redactor = FieldMaskRedactor(
        config={"fields_to_mask": ["tokens[*]"], "mask_string": "MASKED"}
    )

    event = {
        "message": "test",
        "tokens": ["token1", "token2", "token3"],
    }

    result = await redactor.redact(event)

    assert result["tokens"] == ["MASKED", "MASKED", "MASKED"]


@pytest.mark.asyncio
async def test_field_mask_key_bracket_wildcard():
    """Pattern 'key[*]' should mask all elements in list at key."""
    redactor = FieldMaskRedactor(
        config={"fields_to_mask": ["users[*].password"], "mask_string": "***"}
    )

    event = {
        "users": [
            {"name": "alice", "password": "secret1"},
            {"name": "bob", "password": "secret2"},
        ]
    }

    result = await redactor.redact(event)

    assert result["users"][0]["password"] == "***"
    assert result["users"][1]["password"] == "***"
    assert result["users"][0]["name"] == "alice"  # Unchanged
```

### Test 2: FieldMask Guardrails

```python
@pytest.mark.asyncio
async def test_field_mask_max_depth_exceeded():
    """max_depth exceeded should emit diagnostic and stop."""
    diagnostics: list[dict] = []

    def capture_warn(component: str, message: str, **fields):
        diagnostics.append({"component": component, "message": message, **fields})

    redactor = FieldMaskRedactor(
        config={
            "fields_to_mask": ["level1.level2.level3.level4.level5.secret"],
            "max_depth": 3,
        }
    )

    # Create deeply nested structure
    event = {
        "level1": {
            "level2": {
                "level3": {
                    "level4": {
                        "level5": {"secret": "value"}
                    }
                }
            }
        }
    }

    with patch("fapilog.core.diagnostics.warn", side_effect=capture_warn):
        result = await redactor.redact(event)

    # Should have depth exceeded diagnostic
    depth_diagnostics = [d for d in diagnostics if "depth" in d["message"]]
    assert len(depth_diagnostics) > 0

    # Original value should be unchanged (traversal stopped)
    assert result["level1"]["level2"]["level3"]["level4"]["level5"]["secret"] == "value"


@pytest.mark.asyncio
async def test_field_mask_max_keys_exceeded():
    """max_keys_scanned exceeded should emit diagnostic and stop."""
    diagnostics: list[dict] = []

    def capture_warn(component: str, message: str, **fields):
        diagnostics.append({"component": component, "message": message, **fields})

    redactor = FieldMaskRedactor(
        config={
            "fields_to_mask": ["*"],  # Scan all keys
            "max_keys_scanned": 5,
        }
    )

    # Create structure with many keys
    event = {f"key{i}": f"value{i}" for i in range(20)}

    with patch("fapilog.core.diagnostics.warn", side_effect=capture_warn):
        result = await redactor.redact(event)

    # Should have keys exceeded diagnostic
    keys_diagnostics = [d for d in diagnostics if "keys" in d["message"]]
    assert len(keys_diagnostics) > 0
```

### Test 3: FieldMask block_on_unredactable

```python
@pytest.mark.asyncio
async def test_field_mask_block_emits_diagnostic_on_intermediate_primitive():
    """block_on_unredactable should emit diagnostic for primitive mid-path."""
    diagnostics: list[dict] = []

    def capture_warn(component: str, message: str, **fields):
        diagnostics.append({"component": component, "message": message, **fields})

    redactor = FieldMaskRedactor(
        config={
            "fields_to_mask": ["data.nested.secret"],
            "block_on_unredactable": True,
        }
    )

    # data.nested is a primitive, not a dict
    event = {"data": {"nested": "primitive_value"}}

    with patch("fapilog.core.diagnostics.warn", side_effect=capture_warn):
        result = await redactor.redact(event)

    # Should have unredactable diagnostic
    unredactable = [d for d in diagnostics if "unredactable" in d["message"]]
    assert len(unredactable) > 0
    assert "not dict or list" in str(unredactable[0])
```

### Test 4: RegexMask Pattern Errors

```python
def test_regex_mask_records_pattern_errors():
    """Invalid regex patterns should be recorded, not crash."""
    redactor = RegexMaskRedactor(
        config={
            "patterns": [r"valid\.path", r"[invalid(regex", r"another\.valid"],
        }
    )

    # Should have recorded the error
    assert len(redactor._pattern_errors) == 1
    assert "[invalid(regex" in redactor._pattern_errors[0]


@pytest.mark.asyncio
async def test_regex_mask_health_check_fails_with_pattern_errors():
    """health_check should return False if patterns failed to compile."""
    redactor = RegexMaskRedactor(
        config={"patterns": [r"[bad"]}
    )

    result = await redactor.health_check()
    assert result is False
```

### Test 5: UrlCredentials Edge Cases

```python
@pytest.mark.asyncio
async def test_url_credentials_ignores_long_strings():
    """Strings exceeding max_string_length should be unchanged."""
    redactor = UrlCredentialsRedactor(
        config={"max_string_length": 50}
    )

    long_url = "https://user:pass@" + "x" * 100 + ".com/path"
    event = {"url": long_url}

    result = await redactor.redact(event)

    # Long string should be unchanged
    assert result["url"] == long_url


@pytest.mark.asyncio
async def test_url_credentials_handles_unparseable():
    """Unparseable URLs should be left unchanged."""
    redactor = UrlCredentialsRedactor()

    event = {
        "data": "not://a[valid]url",
        "also": "just plain text",
    }

    result = await redactor.redact(event)

    assert result["data"] == "not://a[valid]url"
    assert result["also"] == "just plain text"


@pytest.mark.asyncio
async def test_url_credentials_depth_limit():
    """Deeply nested structures should stop at depth limit."""
    redactor = UrlCredentialsRedactor()

    # Create structure deeper than 16 levels
    event: dict = {}
    current = event
    for i in range(20):
        current[f"level{i}"] = {}
        current = current[f"level{i}"]
    current["url"] = "https://user:pass@example.com"

    result = await redactor.redact(event)

    # Deep URL should be unchanged (depth limit reached)
    deep = result
    for i in range(20):
        deep = deep[f"level{i}"]
    assert deep["url"] == "https://user:pass@example.com"
```

---

## Files Changed

| File | Action |
|------|--------|
| `tests/unit/test_redactor_branches.py` | New file |

**Lines added:** ~350-400

---

## Risk Assessment

**Risk Level: Low**

- Tests only, no production code changes
- Security-critical area benefits from additional test coverage
- May reveal edge cases in redaction logic (intended)

---

## Rollback Plan

Delete `tests/unit/test_redactor_branches.py`; no other files affected.

---

## Definition of Done

- [ ] All acceptance criteria tests implemented
- [ ] Tests verify wildcard traversal paths
- [ ] Tests verify guardrail enforcement
- [ ] Tests verify block_on_unredactable diagnostics
- [ ] Tests verify exception containment
- [ ] Tests verify URL credential edge cases
- [ ] All tests pass
- [ ] Coverage of `field_mask.py` improved toward 90%
- [ ] Coverage of `regex_mask.py` improved toward 90%
- [ ] Coverage of `url_credentials.py` improved toward 90%
