tests/test_parser.py (5993B)
1 """Tests for ap_scanner.parser."""
2
3 import pytest
4
5 from scapy.layers.dot11 import (
6 Dot11,
7 Dot11Beacon,
8 Dot11Elt,
9 Dot11ProbeReq,
10 Dot11ProbeResp,
11 RadioTap,
12 )
13
14 from ap_scanner.parser import (
15 _extract_ssid,
16 _collect_ies,
17 _parse_wps_version,
18 parse_packet,
19 parse_probe_request,
20 )
21
22
23 def _make_beacon(ssid: str = "TestSSID", bssid: str = "AA:BB:CC:DD:EE:FF",
24 channel: int = 6, freq: int = 2437) -> object:
25 """Create a minimal beacon packet for testing."""
26 pkt = (
27 RadioTap(dBm_AntSignal=-42, ChannelFrequency=freq)
28 / Dot11(addr3=bssid)
29 / Dot11Beacon(cap="ESS+privacy")
30 / Dot11Elt(ID=0, info=ssid.encode())
31 / Dot11Elt(ID=3, info=bytes([channel]))
32 )
33 return pkt
34
35
36 def _make_probe_request(ssid: str = "TargetNet",
37 client_mac: str = "11:22:33:44:55:66") -> object:
38 """Create a minimal probe request packet."""
39 pkt = (
40 RadioTap()
41 / Dot11(addr2=client_mac)
42 / Dot11ProbeReq()
43 / Dot11Elt(ID=0, info=ssid.encode())
44 )
45 return pkt
46
47
48 # --- parse_packet ---
49
50 def test_parse_beacon_basic():
51 pkt = _make_beacon(ssid="HomeNet", channel=6, freq=2437)
52 ap = parse_packet(pkt)
53 assert ap is not None
54 assert ap.ssid == "HomeNet"
55 assert ap.bssid == "AA:BB:CC:DD:EE:FF"
56 assert ap.channel == 6
57 # Note: RSSI may be None in mock packets depending on Scapy version
58 # The important thing is that the AP was parsed successfully
59
60
61 def test_parse_hidden_ssid():
62 pkt = _make_beacon(ssid="", channel=1, freq=2412)
63 ap = parse_packet(pkt)
64 assert ap is not None
65 assert ap.is_hidden is True
66 assert ap.display_ssid == "[Hidden]"
67
68
69 def test_parse_5ghz_beacon():
70 pkt = _make_beacon(ssid="Office5G", channel=36, freq=5180,
71 bssid="22:33:44:55:66:77")
72 ap = parse_packet(pkt)
73 assert ap is not None
74 assert ap.band == "5GHz"
75 assert ap.channel == 36
76
77
78 def test_parse_non_beacon_returns_none():
79 # Data frame (not beacon/probe response)
80 pkt = RadioTap() / Dot11(type=2, addr3="AA:BB:CC:DD:EE:FF")
81 result = parse_packet(pkt)
82 assert result is None
83
84
85 def test_parse_no_bssid_returns_none():
86 pkt = RadioTap() / Dot11(addr3=None) / Dot11Beacon() / Dot11Elt(ID=0, info=b"Test")
87 result = parse_packet(pkt)
88 assert result is None
89
90
91 # --- parse_probe_request ---
92
93 def test_parse_probe_request_basic():
94 pkt = _make_probe_request(ssid="TargetNet", client_mac="AA:BB:CC:DD:EE:01")
95 result = parse_probe_request(pkt)
96 assert result is not None
97 ssid, client = result
98 assert ssid == "TargetNet"
99 assert client == "AA:BB:CC:DD:EE:01"
100
101
102 def test_parse_probe_request_empty_ssid():
103 pkt = _make_probe_request(ssid="")
104 result = parse_probe_request(pkt)
105 assert result is None
106
107
108 def test_parse_probe_request_non_probe():
109 pkt = _make_beacon()
110 result = parse_probe_request(pkt)
111 assert result is None
112
113
114 # --- _extract_ssid ---
115
116 def test_extract_ssid_basic():
117 ies = {0: [b"MyNetwork"]}
118 assert _extract_ssid(ies) == "MyNetwork"
119
120
121 def test_extract_ssid_empty():
122 ies = {0: [b""]}
123 assert _extract_ssid(ies) == ""
124
125
126 def test_extract_ssid_missing():
127 ies = {}
128 assert _extract_ssid(ies) == ""
129
130
131 def test_extract_ssid_capped_at_32_bytes():
132 long_ssid = b"A" * 50
133 ies = {0: [long_ssid]}
134 result = _extract_ssid(ies)
135 assert len(result) == 32
136
137
138 # --- _parse_wps_version ---
139
140 def test_parse_wps_version_basic():
141 # Version attribute: type=0x104A, length=1, value=0x10 (v1.0)
142 data = b"\x10\x4A\x00\x01\x10"
143 assert _parse_wps_version(data) == "1.0"
144
145
146 def test_parse_wps_version_2():
147 # Version attribute: type=0x104A, length=1, value=0x20 (v2.0)
148 data = b"\x10\x4A\x00\x01\x20"
149 assert _parse_wps_version(data) == "2.0"
150
151
152 def test_parse_wps_version_empty():
153 assert _parse_wps_version(b"") == ""
154
155
156 def test_parse_wps_version_truncated():
157 # Truncated data should not crash
158 data = b"\x10\x4A\x00"
159 assert _parse_wps_version(data) == ""
160
161
162 def test_parse_wps_version_malformed_vendor_ext():
163 # Vendor Extension (0x1049) with truncated sub-element
164 data = b"\x10\x49\x00\x04\x00\x37\x2A\x01" # WFA OUI + 1 byte
165 result = _parse_wps_version(data)
166 assert isinstance(result, str) # Should not crash
167
168
169 # --- parse_packet edge cases ---
170
171 def test_parse_beacon_channel_0_rejected():
172 """Beacons with channel 0 should be rejected (unreliable data)."""
173 pkt = (
174 RadioTap()
175 / Dot11(addr3="AA:BB:CC:DD:EE:FF")
176 / Dot11Beacon(cap="ESS+privacy")
177 / Dot11Elt(ID=0, info=b"TestSSID")
178 # No DS Parameter Set IE and no RadioTap frequency -> channel 0
179 )
180 ap = parse_packet(pkt)
181 assert ap is None
182
183
184 def test_parse_probe_response():
185 """Probe Response frames should be parsed just like beacons."""
186 pkt = (
187 RadioTap(dBm_AntSignal=-55, ChannelFrequency=2437)
188 / Dot11(addr3="11:22:33:44:55:66")
189 / Dot11ProbeResp(cap="ESS+privacy")
190 / Dot11Elt(ID=0, info=b"ProbeNet")
191 / Dot11Elt(ID=3, info=bytes([6]))
192 )
193 ap = parse_packet(pkt)
194 assert ap is not None
195 assert ap.ssid == "ProbeNet"
196 assert ap.bssid == "11:22:33:44:55:66"
197
198
199 def test_parse_ssid_utf8_decode():
200 """Non-ASCII SSIDs should be decoded with errors='replace'."""
201 pkt = _make_beacon(ssid="X", channel=1, freq=2412)
202 # Manually replace SSID with invalid UTF-8
203 elt = pkt.getlayer(Dot11Elt)
204 elt.info = b"\xc0\xc1\xff" # invalid UTF-8 bytes
205 ap = parse_packet(pkt)
206 assert ap is not None
207 # Should contain replacement characters, not crash
208 assert "\ufffd" in ap.ssid
209
210
211 def test_parse_probe_request_null_addr2():
212 """Probe request with no source address should return None."""
213 pkt = (
214 RadioTap()
215 / Dot11(addr2=None)
216 / Dot11ProbeReq()
217 / Dot11Elt(ID=0, info=b"TestNet")
218 )
219 result = parse_probe_request(pkt)
220 assert result is None