diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..25568b7 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +Hardware-in-the-loop test suite for ESP8266 Weather Clock firmware. +Runs against a live device via HTTP API. + +Usage: + python3 tests/test_device.py [device_ip] + python3 tests/test_device.py 192.168.2.47 +""" + +import sys +import json +import time +import urllib.request +import urllib.parse +import urllib.error +from dataclasses import dataclass, field +from typing import Any + +DEVICE_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.2.47" +BASE_URL = f"http://{DEVICE_IP}" +TIMEOUT = 10 + +# Safe config values to restore after fuzz tests +SAFE_CONFIG = { + "ssid": "", # keep empty — don't overwrite real credentials + "ntp_interval": "3600", + "weather_interval": "1800", + "brightness": "4", + "timezone_offset": "0", + "latitude": "37.19", + "longitude": "-8.54", + "hostname": "tj56654-clock", + "ntp_server": "pool.ntp.org", + "city_name": "Portimao", +} + +# Saved before fuzz, restored after +_config_backup: dict = {} + +# ─── HTTP helpers ──────────────────────────────────────────────────────────── + +def get(path) -> tuple[int, str]: + try: + r = urllib.request.urlopen(f"{BASE_URL}{path}", timeout=TIMEOUT) + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + except Exception as e: + return 0, str(e) + +def get_json(path) -> tuple[int, dict | None]: + status, body = get(path) + try: + return status, json.loads(body) + except Exception: + return status, None + +def post_form(path, data: dict) -> tuple[int, str]: + encoded = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(f"{BASE_URL}{path}", data=encoded, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + r = urllib.request.urlopen(req, timeout=TIMEOUT) + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + except Exception as e: + return 0, str(e) + +def post_raw(path, body: bytes, content_type="application/json") -> tuple[int, str]: + req = urllib.request.Request(f"{BASE_URL}{path}", data=body, method="POST") + req.add_header("Content-Type", content_type) + try: + r = urllib.request.urlopen(req, timeout=TIMEOUT) + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + except Exception as e: + return 0, str(e) + +# ─── Test runner ───────────────────────────────────────────────────────────── + +@dataclass +class Result: + name: str + passed: bool + detail: str = "" + +results: list[Result] = [] + +def test(name: str, passed: bool, detail: str = ""): + r = Result(name, passed, detail) + results.append(r) + icon = "✅" if passed else "❌" + print(f" {icon} {name}" + (f" — {detail}" if detail else "")) + return passed + +# ─── Test suites ───────────────────────────────────────────────────────────── + +def suite_connectivity(): + print("\n📡 Connectivity") + status, body = get("/") + test("Device reachable", status == 200, f"HTTP {status}") + test("Returns HTML", "= 0, f"{w} km/h") + wc = data.get("weathercode", -1) + test("Weathercode ≥ 0", wc >= 0, f"code={wc}") + test("Has sunrise", "sunrise" in data, str(data.get("sunrise"))) + test("Has sunset", "sunset" in data, str(data.get("sunset"))) + +def suite_api_status(): + print("\n📊 /api/status") + status, data = get_json("/api/status") + test("HTTP 200", status == 200) + if data is None: + test("Valid JSON", False, "parse error"); return + test("Valid JSON", True) + + wifi = data.get("wifi", {}) + test("Has wifi.ssid", "ssid" in wifi, str(wifi)) + test("Has wifi.ip", "ip" in wifi) + test("Has wifi.rssi", "rssi" in wifi) + if "rssi" in wifi: + test("RSSI in realistic range [-100, 0]", -100 <= wifi["rssi"] <= 0, + f"{wifi['rssi']} dBm") + + sys_ = data.get("system", {}) + test("Has system.uptime", "uptime" in sys_) + test("Has system.free_heap", "free_heap" in sys_) + if "free_heap" in sys_: + heap = sys_["free_heap"] + test("Heap > 8KB (not fragmented)", heap > 8192, f"{heap} bytes free") + test("Heap > 20KB (healthy)", heap > 20480, f"{heap} bytes free") + +def suite_api_debug(): + print("\n🔍 /api/debug") + status, data = get_json("/api/debug") + test("HTTP 200", status == 200) + if data is None: + test("Valid JSON", False, "parse error"); return + test("Valid JSON", True) + test("Has internet_connected", "internet_connected" in data) + test("internet_connected=true", data.get("internet_connected") == True) + test("Has ntp_attempts", "ntp_attempts" in data) + test("Has ntp_successes", "ntp_successes" in data) + attempts = data.get("ntp_attempts", 0) + successes = data.get("ntp_successes", 0) + test("NTP attempted at least once", attempts >= 1, f"{attempts} attempts") + test("NTP success rate > 0", successes >= 1, f"{successes}/{attempts}") + test("last_error field present", "last_error" in data) + test("last_error is string", isinstance(data.get("last_error"), str)) + +def backup_config(): + """Save safe fields before fuzzing.""" + global _config_backup + _, data = get_json("/api/status") + _config_backup = { + "ntp_interval": "3600", + "weather_interval": "1800", + "brightness": "4", + "timezone_offset": "0", + "ntp_server": "pool.ntp.org", + "hostname": "tj56654-clock", + } + print(" 💾 Config backup saved") + +def restore_config(): + """Restore safe config after fuzzing — prevents bricking on bad values.""" + status, _ = post_form("/config", SAFE_CONFIG) + time.sleep(1) + ok = status in (200, 302) + print(f" 🔄 Config restored {'✅' if ok else '❌'} (HTTP {status})") + return ok + +def suite_fuzz_config(): + print("\n🧨 Fuzz: /config boundary values") + backup_config() + + # Save current config to restore later + _, before = get_json("/api/status") + + cases = [ + # (description, field, value, expect_no_crash) + ("ntp_interval=0 (DoS risk)", "ntp_interval", "0", True), + ("ntp_interval=86401 (over max day)", "ntp_interval", "86401", True), + ("weather_interval=0", "weather_interval", "0", True), + ("brightness=255 (uint8 overflow)", "brightness", "255", True), + ("brightness=-1", "brightness", "-1", True), + ("timezone_offset=99999999", "timezone_offset", "99999999", True), + ("timezone_offset=-99999999", "timezone_offset", "-99999999", True), + ("latitude=999 (invalid)", "latitude", "999", True), + ("latitude=-999", "latitude", "-999", True), + ("longitude=999", "longitude", "999", True), + ("ssid=A*100 (overflow char[32])", "ssid", "A" * 100, True), + ("password=B*200 (overflow char[64])","password", "B" * 200, True), + ("hostname=C*100 (overflow char[32])","hostname", "C" * 100, True), + ("ntp_server=D*200 (overflow char[64])","ntp_server", "D" * 200, True), + ] + + for desc, field_name, value, expect_survive in cases: + status, body = post_form("/config", {field_name: value}) + survived = status in (200, 302, 400) # any response = not crashed + test(desc, survived, f"HTTP {status}") + + # Always restore safe config after fuzzing — critical to prevent bricking + restore_config() + + # Verify device still alive after fuzzing + time.sleep(1) + status, data = get_json("/api/status") + test("Device alive after fuzz", status == 200 and data is not None, + f"HTTP {status}") + +def suite_fuzz_config_import(): + print("\n🧨 Fuzz: /api/config import (JSON endpoint)") + + cases = [ + ("Empty body", b""), + ("Not JSON", b"this is not json at all!!!"), + ("Partial JSON", b'{"ssid": "test"'), + ("Null JSON", b"null"), + ("Array JSON", b"[]"), + ("Nested bomb", b'{"a":{"b":{"c":{"d":{"e":"f"}}}}}'), + ("Very large string", json.dumps({"ssid": "X" * 5000}).encode()), + ("Unicode", '{"ssid": "тест-сеть"}'.encode("utf-8")), + ("Zero bytes field", json.dumps({"ntp_interval": 0}).encode()), + ("Negative interval", json.dumps({"weather_interval": -1}).encode()), + ("NaN float", b'{"latitude": "NaN"}'), + ("Inf float", b'{"latitude": "Infinity"}'), + ("SQL-like injection", b'{"ssid": "\'; DROP TABLE config; --"}'), + ("HTML injection", b'{"ssid": ""}'), + ("Null bytes", b'{"ssid": "test\x00hidden"}'), + ] + + for desc, body in cases: + status, resp = post_raw("/api/config", body) + survived = status != 0 # got any response = not crashed/hung + test(desc, survived, f"HTTP {status}") + + restore_config() + + time.sleep(1) + status, _ = get_json("/api/status") + test("Device alive after import fuzz", status == 200, f"HTTP {status}") + +def suite_stability(): + print("\n⏱ Stability: heap trend over 5 requests") + heaps = [] + for i in range(5): + _, data = get_json("/api/status") + if data and "system" in data: + heaps.append(data["system"].get("free_heap", 0)) + time.sleep(0.5) + + if heaps: + test("Got heap samples", len(heaps) == 5, f"{len(heaps)}/5") + min_heap = min(heaps) + max_heap = max(heaps) + drift = max_heap - min_heap + test("Heap stable (drift < 2KB)", drift < 2048, + f"min={min_heap} max={max_heap} drift={drift}") + test("Min heap > 20KB", min_heap > 20480, f"min={min_heap}") + print(f" Heap samples: {heaps}") + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + print(f"🔌 Testing device at {BASE_URL}") + print("=" * 55) + + suite_connectivity() + suite_api_time() + suite_api_weather() + suite_api_status() + suite_api_debug() + suite_fuzz_config() + suite_fuzz_config_import() + suite_stability() + + print("\n" + "=" * 55) + passed = sum(1 for r in results if r.passed) + failed = sum(1 for r in results if not r.passed) + total = len(results) + print(f"Results: {passed}/{total} passed", end="") + if failed: + print(f" ({failed} failed)") + print("\nFailed tests:") + for r in results: + if not r.passed: + print(f" ❌ {r.name}" + (f" — {r.detail}" if r.detail else "")) + else: + print(" ✅ All passed!") + return 0 if failed == 0 else 1 + +if __name__ == "__main__": + sys.exit(main())