defmodule Aprsme.PacketSanitizerTest do use ExUnit.Case, async: true use ExUnitProperties alias Aprsme.PacketSanitizer describe "sanitize_packet/1" do test "leaves short strings alone" do packet = %{sender: "K5ABC", base_callsign: "K5ABC"} assert PacketSanitizer.sanitize_packet(packet) == packet end test "truncates oversized sender to 20 bytes" do long = String.duplicate("A", 50) result = PacketSanitizer.sanitize_packet(%{sender: long}) assert byte_size(result.sender) == 20 end test "truncates oversized base_callsign to 20 bytes" do long = String.duplicate("B", 100) result = PacketSanitizer.sanitize_packet(%{base_callsign: long}) assert byte_size(result.base_callsign) == 20 end test "truncates raw_packet to 5000 bytes" do long = String.duplicate("x", 6000) result = PacketSanitizer.sanitize_packet(%{raw_packet: long}) assert byte_size(result.raw_packet) == 5000 end test "leaves non-binary values unchanged" do packet = %{lat: 30.5, lon: -95.0, has_position: true, altitude: 100} assert PacketSanitizer.sanitize_packet(packet) == packet end test "leaves keys without length limits alone" do long = String.duplicate("y", 10_000) result = PacketSanitizer.sanitize_packet(%{some_field_nobody_limits: long}) assert result.some_field_nobody_limits == long end test "strips null bytes from values inside the data map" do packet = %{data: %{"information_field" => "helloworld"}} result = PacketSanitizer.sanitize_packet(packet) assert result.data["information_field"] == "helloworld" end test "truncates and strips null bytes in the data map" do long = "a" <> <<0>> <> String.duplicate("b", 6000) result = PacketSanitizer.sanitize_packet(%{data: %{"information_field" => long}}) sanitized = result.data["information_field"] refute String.contains?(sanitized, <<0>>) assert byte_size(sanitized) <= 5000 end test "leaves non-binary data values alone" do packet = %{data: %{"count" => 7, "enabled" => true}} assert PacketSanitizer.sanitize_packet(packet) == packet end test "leaves :data key alone when it's not a map" do packet = %{data: "not a map"} assert PacketSanitizer.sanitize_packet(packet) == packet end end describe "UTF-8 boundary safety" do test "truncation never produces invalid UTF-8" do # Use a 3-byte character repeated so any naive byte-slice hits mid-char. s = String.duplicate("中", 50) result = PacketSanitizer.sanitize_packet(%{sender: s}) assert String.valid?(result.sender) assert byte_size(result.sender) <= 20 end test "strings exactly at the limit are kept intact" do exact = String.duplicate("a", 20) result = PacketSanitizer.sanitize_packet(%{sender: exact}) assert result.sender == exact end test "emoji mid-truncation produces valid UTF-8" do # Emoji are 4 bytes in UTF-8; 20 byte limit will cut mid-character. s = String.duplicate("😀", 10) result = PacketSanitizer.sanitize_packet(%{sender: s}) assert String.valid?(result.sender) assert byte_size(result.sender) <= 20 end end describe "property: sanitization respects declared limits" do # Mirrors the @max_lengths module attribute so changes there get caught. @limited_fields %{ sender: 20, base_callsign: 20, destination: 20, ssid: 10, symbol_code: 5, symbol_table_id: 5, path: 500, raw_packet: 5000, comment: 2000, message_text: 2000 } property "sanitized values never exceed their declared byte limit" do check all( {field, limit} <- member_of(Map.to_list(@limited_fields)), value <- string(:printable) ) do result = PacketSanitizer.sanitize_packet(%{field => value}) assert byte_size(result[field]) <= limit end end property "sanitized values always remain valid UTF-8" do check all( {field, _limit} <- member_of(Map.to_list(@limited_fields)), value <- string(:utf8) ) do result = PacketSanitizer.sanitize_packet(%{field => value}) assert String.valid?(result[field]) end end property "sanitize_packet is idempotent" do check all( {field, _limit} <- member_of(Map.to_list(@limited_fields)), value <- string(:printable) ) do once = PacketSanitizer.sanitize_packet(%{field => value}) twice = PacketSanitizer.sanitize_packet(once) assert once == twice end end property "values under the limit are passed through unchanged" do check all( {field, limit} <- member_of(Map.to_list(@limited_fields)), value <- string(:alphanumeric, max_length: 5), byte_size(value) <= limit ) do result = PacketSanitizer.sanitize_packet(%{field => value}) assert result[field] == value end end property "null bytes are always stripped from data map string values" do check all(parts <- list_of(string(:printable), max_length: 10)) do value = Enum.join(parts, <<0>>) result = PacketSanitizer.sanitize_packet(%{data: %{"random_key" => value}}) refute String.contains?(result.data["random_key"], <<0>>) end end end end