---
phase: 01-unify-duplicated-discovery-loops
plan: 05
type: execute
wave: 3
depends_on:
  - 01-02
files_modified:
  - tests/test_network/test_discovery_errors.py
autonomous: true
requirements:
  - D-10
  - D-11
user_setup: []

must_haves:
  truths:
    - "The TestParseDeviceStateServiceErrors class (which tested the deleted _parse_device_state_service directly) is removed, and the import of _parse_device_state_service is gone (D-10)"
    - "The behaviour the deleted tests proved (malformed/too-short StateService payloads raise/are handled) stays covered via the shared _discover_with_packet path — a malformed payload is handled by the LifxProtocolError branch without yielding a device (D-10)"
    - "New tests prove serial validation at the _discover_with_packet / discover_devices level: a broadcast-bit (multicast) serial is rejected and an all-0xff serial is rejected (D-11)"
    - "A new test proves first-wins dedup: two valid responses with the same serial yield exactly one device (D-11)"
    - "An emulator-backed test asserts discover_devices yields no duplicate serials (D-11, emulator-first)"
    - "The full tests/test_network/ suite passes after the discovery refactor (no references to retired internals remain)"
  artifacts:
    - path: "tests/test_network/test_discovery_errors.py"
      provides: "Rewritten discovery error/DoS tests targeting the shared path; no _parse_device_state_service references"
      contains: "def test_"
  key_links:
    - from: "tests/test_network/test_discovery_errors.py"
      to: "lifx.network.discovery.discover_devices / _discover_with_packet"
      via: "mock UdpTransport feeding crafted StateService packets"
      pattern: "lifx.network.discovery.UdpTransport"
---

<objective>
Rewrite `tests/test_network/test_discovery_errors.py` to match the unified discovery code from
Plan 02 (D-10): delete the tests pinned to the now-removed `_parse_device_state_service`, re-prove
the behaviour they covered (malformed payload handling) through the shared `_discover_with_packet`
path, and add the new tests that prove serial validation and first-wins dedup AT the shared
generator level — the protection must be proven where it now lives, not just transitively (D-11).
Keep the emulator-first dedup test.

Purpose: Close the test gap created by the refactor — the hoisted DoS validation and dedup must
have direct coverage at `_discover_with_packet`, and no test may reference deleted internals.

Output: Rewritten `test_discovery_errors.py`; full `tests/test_network/` suite green. This plan is
Wave 3 because it depends on the refactored discovery.py from Plan 02 (Wave 2).
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-CONTEXT.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-02-SUMMARY.md
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Retire _parse_device_state_service tests and re-prove malformed-payload handling via the shared path</name>
  <files>tests/test_network/test_discovery_errors.py</files>
  <read_first>
    - tests/test_network/test_discovery_errors.py (full file — the TestParseDeviceStateServiceErrors class at 19–49 and the `_parse_device_state_service` import at line 13; the `_build_state_service_packet` helper at 131–155; the mock-transport pattern at 186–199; the mock_receive helpers at 176–184 / 220–227)
    - src/lifx/network/discovery.py (post-Plan-02 — _discover_with_packet's LifxProtocolError handling and serial validation; discover_devices thin wrapper; confirm _parse_device_state_service no longer exists)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (Tests: What Changes table lines 555–565; New tests required table lines 568–575)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (test_discovery_errors.py section lines 390–467 — packet builder, mock transport, emulator patterns)
  </read_first>
  <behavior>
    - The TestParseDeviceStateServiceErrors class no longer exists and `_parse_device_state_service` is no longer imported.
    - A test feeds a too-short/malformed StateService payload through a mocked UdpTransport and asserts discover_devices (or _discover_with_packet) yields no device and does not raise out of the generator — proving the LifxProtocolError branch handles it (replaces the old direct unit coverage, D-10).
    - The existing TestDiscoverySourceValidation tests (wrong source, all-0xff serial, multicast serial) still pass against the unified code (they already drive discover_devices, which now shares the validated path).
  </behavior>
  <action>
    Delete the `TestParseDeviceStateServiceErrors` class (lines 19–49) and remove
    `_parse_device_state_service` from the import at line 13 (the import must reference only symbols
    that still exist — `discover_devices`, and add `_discover_with_packet` for the new tests). Keep
    the `struct` import (still used by the `_build_state_service_packet` helper to craft test
    payloads).

    Re-prove the malformed-payload behaviour the deleted class covered (D-10): add a test that
    crafts a StateService packet whose payload is too short (fewer than 5 bytes — e.g. build a
    header with pkt_type=3 plus a 2-byte payload) and feeds it via the existing mocked-UdpTransport
    pattern (patch `lifx.network.discovery.UdpTransport` and `lifx.network.discovery.allocate_source`),
    then assert `discover_devices` yields zero devices and the generator completes without
    propagating an exception (the unified `LifxProtocolError`/exception handling in
    `_discover_with_packet` swallows it and continues). Reuse the `_build_state_service_packet`
    helper, parameterising the payload length, or add a small variant that emits a truncated
    payload.

    Verify the existing `TestDiscoverySourceValidation` and `TestDiscoveryWithEmulatorErrors`
    classes still pass unchanged against the unified code; adjust only if an assertion depended on
    a retired internal (they should not). Australian-English spelling in any new docstrings.
  </action>
  <verify>
    <automated>uv run --frozen pytest tests/test_network/test_discovery_errors.py -v</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "_parse_device_state_service" tests/test_network/test_discovery_errors.py` returns 0.
    - `grep -c "class TestParseDeviceStateServiceErrors" tests/test_network/test_discovery_errors.py` returns 0.
    - A test exists that feeds a truncated StateService payload and asserts zero devices yielded (malformed-payload coverage re-proven via the shared path).
    - `uv run --frozen pytest tests/test_network/test_discovery_errors.py -v` passes and collects the existing source/serial validation tests.
    - `uv run ruff check tests/test_network/test_discovery_errors.py` reports no errors (no unused imports).
  </acceptance_criteria>
  <done>_parse_device_state_service tests/import removed; malformed-payload handling re-proven via mocked UdpTransport through discover_devices; existing validation tests still green.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add generator-level serial-validation and dedup tests (D-11)</name>
  <files>tests/test_network/test_discovery_errors.py</files>
  <read_first>
    - tests/test_network/test_discovery_errors.py (post-Task-1 state — the `_build_state_service_packet` helper, the mocked-UdpTransport pattern, the emulator dedup class TestDiscoveryDeduplication at 290–306)
    - src/lifx/network/discovery.py (post-Plan-02 — _discover_with_packet performs serial validation + first-wins dedup; STATE_TYPE on DevicePackets.GetService is 3; the DiscoveryResponse contract)
    - src/lifx/protocol/packets.py (Device.GetService / Device.StateService — STATE_TYPE / PKT_TYPE values for crafting valid packets)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (New tests required table lines 568–575; Phase Requirements → Test Map lines 640–650 — naming hints: "broadcast", "all_ff", "dedup")
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (AsyncMock+patch lines 511–513; emulator dedup pattern lines 442–458)
  </read_first>
  <behavior>
    - test (broadcast-bit serial rejected at _discover_with_packet): feeding a StateService packet with a multicast (target[0] & 0x01) serial into _discover_with_packet via mocked UdpTransport yields zero DiscoveryResponse objects.
    - test (all-0xff serial rejected at _discover_with_packet): feeding an all-0xff target yields zero DiscoveryResponse objects.
    - test (first-wins dedup at _discover_with_packet): feeding two packets with the same valid serial yields exactly one DiscoveryResponse.
    - test (emulator dedup via discover_devices): against the embedded emulator, discover_devices yields no duplicate serials (kept/confirmed from existing TestDiscoveryDeduplication).
  </behavior>
  <action>
    Add direct `_discover_with_packet`-level tests proving the hoisted protections (D-11) — these
    must drive `_discover_with_packet(DevicePackets.GetService(), ...)` (or import and iterate it)
    rather than only `discover_devices`, so the protection is proven where it now lives:

    1. Broadcast-bit/multicast serial rejection: craft a StateService packet via
       `_build_state_service_packet(source=known_source, target=<multicast serial e.g.
       b"\x01\x02\x03\x04\x05\x06\x00\x00">)`, patch `lifx.network.discovery.UdpTransport` +
       `allocate_source` with the established mock pattern, iterate `_discover_with_packet`, and
       assert zero responses. Name it with "broadcast" or "multicast" so
       `-k "broadcast"` selects it (per the Test Map).
    2. All-0xff serial rejection: same pattern with `target=b"\xff"*8`; name it with "all_ff".
    3. First-wins dedup: mock_receive returns two packets with the SAME valid serial then raises
       LifxTimeoutError; iterate `_discover_with_packet` and assert exactly one DiscoveryResponse;
       name it with "dedup".
    4. Keep the existing `@pytest.mark.emulator TestDiscoveryDeduplication.test_devices_deduplicated_by_serial`
       (D-11 emulator-first) — confirm it still passes; do not weaken it.

    Reuse `_build_state_service_packet` and the AsyncMock/patch transport pattern already in the
    file. Use `@pytest.mark.asyncio`. These are unit (mock) tests because hostile broadcast/multicast
    serials cannot be produced by the emulator (per D-11: direct generator-level tests are
    acceptable where hostile packets cannot be emulated). Australian-English spelling in docstrings.
  </action>
  <verify>
    <automated>uv run --frozen pytest tests/test_network/test_discovery_errors.py -v -k "broadcast or all_ff or dedup or multicast"</automated>
  </verify>
  <acceptance_criteria>
    - New tests iterate `_discover_with_packet` directly (not only `discover_devices`): `grep -c "_discover_with_packet" tests/test_network/test_discovery_errors.py` returns >= 1.
    - `uv run --frozen pytest tests/test_network/test_discovery_errors.py -v -k "broadcast"` selects and passes a broadcast/multicast-serial rejection test.
    - `uv run --frozen pytest tests/test_network/test_discovery_errors.py -v -k "all_ff"` selects and passes an all-0xff rejection test.
    - `uv run --frozen pytest tests/test_network/test_discovery_errors.py -v -k "dedup"` selects and passes a first-wins dedup test at the generator level.
    - The emulator dedup test (`test_devices_deduplicated_by_serial`) is still present and passes (or skips cleanly without the emulator).
    - `uv run --frozen pytest tests/test_network/ -q` passes in full (whole network suite green after the refactor) and `uv run pyright` reports 0 errors.
  </acceptance_criteria>
  <done>Generator-level tests prove broadcast/multicast + all-0xff serial rejection and first-wins dedup directly against _discover_with_packet; emulator dedup test retained; full network suite + pyright green.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| crafted-packet test fixtures → _discover_with_packet | Tests simulate untrusted UDP responses (spoofed serials) crossing into the discovery generator to prove the DoS guard |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-09 | Spoofing / Denial of Service (verification) | Hoisted serial validation in _discover_with_packet | mitigate | This plan provides the direct generator-level test coverage that proves T-01-02 (broadcast/multicast and 0xff rejection) and T-01-05 (first-wins dedup) are enforced where they now live, not just transitively via discover_devices. Without these tests the hoisted protection would be unverified at its new boundary. |

ASVS L1: V5 Input Validation — these tests are the verification evidence for the L1 serial-guard
control. No HIGH-severity items. No package installs (Package Legitimacy Gate N/A — stdlib only).
</threat_model>

<verification>
- `uv run --frozen pytest tests/test_network/ -q` passes (full network suite, no retired-internal references).
- `uv run pyright` reports 0 errors (strict).
- `uv run ruff check .` reports no errors.
</verification>

<success_criteria>
- `_parse_device_state_service` tests and import removed; malformed-payload coverage re-proven via the shared path (D-10).
- Generator-level tests prove broadcast/multicast + all-0xff serial rejection and first-wins dedup (D-11).
- Emulator dedup test retained (D-11 emulator-first).
- Full `tests/test_network/` suite green after the discovery refactor.
</success_criteria>

<artifacts_produced>
## Artifacts this phase produces (Plan 05)

- Modified `tests/test_network/test_discovery_errors.py`:
  - Deleted class: `TestParseDeviceStateServiceErrors` (and the `_parse_device_state_service` import).
  - New test: malformed/truncated StateService payload handled via the shared path (zero devices yielded, no exception escapes).
  - New tests: broadcast/multicast serial rejection, all-0xff serial rejection, first-wins dedup — driving `_discover_with_packet` directly (D-11).
  - Retained: `_build_state_service_packet` helper, `TestDiscoverySourceValidation`, `TestDiscoveryWithEmulatorErrors`, `TestDiscoveryDeduplication` (emulator).
  - New import: `_discover_with_packet` from `lifx.network.discovery`.
- No production-code symbols produced (test-only plan).
</artifacts_produced>

<output>
Create `.planning/phases/01-unify-duplicated-discovery-loops/01-05-SUMMARY.md` when done.
</output>
