- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
(переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
(обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
296 lines
12 KiB
Python
296 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""Интеграционные сценарии сессии против mock-модуля (mock_ac.py).
|
||
|
||
usage: test_session_mock.py <session_runner> <mock_ac.py>
|
||
Сценарии: normal, rekey, fail_push, slot_503, no_poll, bad_ke, set_get.
|
||
"""
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
|
||
RUNNER = sys.argv[1]
|
||
MOCK = sys.argv[2]
|
||
|
||
# Синтетический ключ (НЕ боевой).
|
||
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
|
||
KEY_ID = 64201
|
||
DSN = "AC000W00MOCK0001"
|
||
|
||
|
||
def free_port():
|
||
s = socket.socket()
|
||
s.bind(("127.0.0.1", 0))
|
||
port = s.getsockname()[1]
|
||
s.close()
|
||
return port
|
||
|
||
|
||
class Proc:
|
||
"""Процесс с построчным логом stdout."""
|
||
|
||
def __init__(self, argv, env=None):
|
||
self.lines = []
|
||
self.lock = threading.Lock()
|
||
self.ev = threading.Event()
|
||
self.proc = subprocess.Popen(
|
||
argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True)
|
||
threading.Thread(target=self._reader, daemon=True).start()
|
||
|
||
def _reader(self):
|
||
for line in self.proc.stdout:
|
||
with self.lock:
|
||
self.lines.append(line.rstrip("\n"))
|
||
self.ev.set()
|
||
|
||
def wait_line(self, prefix, timeout, exclude_prefix=None):
|
||
"""Ждёт строку, начинающуюся с prefix (или содержащую, если prefix
|
||
начинается с '~')."""
|
||
contains = prefix.startswith("~")
|
||
needle = prefix[1:] if contains else prefix
|
||
deadline = time.time() + timeout
|
||
seen = 0
|
||
while time.time() < deadline:
|
||
with self.lock:
|
||
for i, line in enumerate(self.lines):
|
||
if i < seen:
|
||
continue
|
||
hit = (needle in line) if contains else line.startswith(prefix)
|
||
if hit:
|
||
if exclude_prefix and line.startswith(exclude_prefix):
|
||
continue
|
||
return line
|
||
seen = i + 1
|
||
self.ev.wait(0.2)
|
||
return None
|
||
|
||
def all_lines(self):
|
||
with self.lock:
|
||
return list(self.lines)
|
||
|
||
def stop(self, timeout=10):
|
||
self.proc.terminate()
|
||
try:
|
||
self.proc.wait(timeout)
|
||
except subprocess.TimeoutExpired:
|
||
self.proc.kill()
|
||
|
||
|
||
def run_mock(extra, port):
|
||
p = Proc([sys.executable, MOCK, "--port", str(port), "--lanip-key", LANIP_KEY,
|
||
"--key-id", str(KEY_ID)] + extra)
|
||
if p.wait_line("READY", 10) is None:
|
||
raise RuntimeError("mock не стартовал: " + "\n".join(p.all_lines()))
|
||
return p
|
||
|
||
|
||
def run_runner(mock_port, listen_port, props, duration, env_extra=None,
|
||
timeout=None):
|
||
env = dict(os.environ)
|
||
if env_extra:
|
||
env.update(env_extra)
|
||
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen_port), DSN,
|
||
LANIP_KEY, str(KEY_ID), str(duration)] + props
|
||
return Proc(argv, env=env), (timeout or duration + 25)
|
||
|
||
|
||
FAILURES = []
|
||
|
||
|
||
def check(cond, what):
|
||
tag = "ok " if cond else "FAIL"
|
||
print(f" [{tag}] {what}")
|
||
if not cond:
|
||
FAILURES.append(what)
|
||
|
||
|
||
def scenario_normal():
|
||
print("== scenario: normal (установка, батч GET, push, delete)")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock([], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen,
|
||
["operation_mode", "fan_speed"], 3)
|
||
line = runner.wait_line("STATE online", 10)
|
||
check(line is not None, "переход в online")
|
||
p1 = runner.wait_line("PROP operation_mode", 10)
|
||
p2 = runner.wait_line("PROP fan_speed", 10)
|
||
check(p1 is not None and "cid=" not in p1 and " i:6" in p1, f"push #1: {p1}")
|
||
check(p2 is not None and " i:4" in p2, f"push #2: {p2}")
|
||
check(runner.wait_line("DELETED", timeout) is not None, "delete_session")
|
||
check(runner.wait_line("STATS", 5) is not None, "STATS")
|
||
check("rekeys=1" in " ".join(runner.all_lines()), "ровно 1 key exchange")
|
||
check("CMD GET operation_mode" in "\n".join(mock.all_lines()),
|
||
"mock исполнил GET")
|
||
check("DELETE" in "\n".join(mock.all_lines()), "mock получил DELETE")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_rekey():
|
||
print("== scenario: rekey по local_reg (chain непрерывна)")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--rekey-every", "2", "--push-every", "1"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, [], 6,
|
||
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
|
||
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||
t1 = runner.wait_line("PROP tick", 15)
|
||
t2 = runner.wait_line("PROP tick", 15)
|
||
check(t1 is not None and t2 is not None, "спонтанные push после re-key")
|
||
runner.wait_line("DELETED", timeout)
|
||
stats = "\n".join(runner.all_lines())
|
||
check("rekeys=" in stats and "pushes_bad=0" in stats,
|
||
f"без потерянных push: {stats.splitlines()[-1] if stats else '?'}")
|
||
check("CMD bad-sign" not in "\n".join(mock.all_lines()),
|
||
"mock проверял подписи наших команд")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_fail_push():
|
||
print("== scenario: push с битой подписью -> recovering -> online")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--push-every", "1", "--fail-pushes", "1"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, [], 8,
|
||
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
|
||
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||
rec = runner.wait_line("STATE recovering 6", 15)
|
||
check(rec is not None, f"recovering после битой подписи: {rec}")
|
||
# Следующий push расшифровывается (цепочка в синке) -> online.
|
||
check(runner.wait_line("STATE online 0", 15) is not None,
|
||
"online восстановлен следующим валидным push")
|
||
check(runner.wait_line("STATS", timeout) is not None, "STATS получены")
|
||
stats = "\n".join(runner.all_lines())
|
||
stats_line = [l for l in stats.splitlines() if l.startswith("STATS")]
|
||
check("pushes_bad=1" in stats, f"учтён 1 битый push: {stats_line}")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_slot_503():
|
||
print("== scenario: 503 — нет слотов")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--503"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, [], 4,
|
||
env_extra={"RUNNER_NOSLOT_RETRY_MS": "2000"})
|
||
line = runner.wait_line("STATE offline 1", 10)
|
||
check(line is not None, "offline с ошибкой NoSlot")
|
||
regs = [l for l in mock.all_lines() if l.startswith("REG")]
|
||
check(len(regs) >= 1, "local_reg доходил до модуля")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_no_poll():
|
||
print("== scenario: KE без poll — активация не наступила, тихая пауза")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--no-poll"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, [], 5,
|
||
env_extra={"RUNNER_ACTIVATION_MS": "1500",
|
||
"RUNNER_QUIET_MS": "3000",
|
||
"RUNNER_KEEPALIVE_MS": "800"})
|
||
line = runner.wait_line("STATE recovering 5", 15)
|
||
check(line is not None, "recovering с ActivationTimeout")
|
||
time.sleep(1.0)
|
||
regs = [l for l in mock.all_lines() if l.startswith("REG")]
|
||
# Первый reg + возможно второй до таймаута активации; после — тишина.
|
||
check(len(regs) <= 3, f"нет спама local_reg (получено {len(regs)})")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_bad_ke():
|
||
print("== scenario: key_exchange ver=2 -> key_error")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--fail-first-ke"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, [], 3)
|
||
line = runner.wait_line("STATE key_error 4", 10)
|
||
check(line is not None, "key_error с BadKeyExchange")
|
||
check(mock.wait_line("~-> 426", 10) is not None, "mock получил 426")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_chain_divergence_heals():
|
||
print("== scenario: входящая цепочка расходится на 1 сообщение и сходится")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--push-every", "1", "--garbage-pushes", "1"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, [], 8,
|
||
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
|
||
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||
check(runner.wait_line("STATE recovering 6", 15) is not None,
|
||
"recovering после урезанного push")
|
||
# CBC-состояние = последний шифроблок: следующий полный push сходится.
|
||
check(runner.wait_line("STATE online 0", 15) is not None,
|
||
"online восстановлен следующим push (цепочка сошлась)")
|
||
stats = runner.wait_line("STATS", timeout)
|
||
# Урезанный push + один residual (первый блок следующего) = 2 потери,
|
||
# далее цепочка сошлась (состояние CBC = последний шифроблок).
|
||
check(stats is not None and "pushes_bad=2" in stats,
|
||
f"ровно 2 потери: {stats}")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_outbound_desync_rekey():
|
||
print("== scenario: исходящий десинк -> модуль ре-кает на local_reg")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock(["--break-outbound", "1"], mock_port)
|
||
runner, timeout = run_runner(mock_port, listen, ["operation_mode"], 12,
|
||
env_extra={"RUNNER_KEEPALIVE_MS": "1000",
|
||
"RUNNER_SET_NAME": "fan_speed",
|
||
"RUNNER_SET_VALUE": "2",
|
||
"RUNNER_SET_AT": "6"})
|
||
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||
check(mock.wait_line("~CMD skipped", 10) is not None,
|
||
"мок пропустил ответ (десинк)")
|
||
check(mock.wait_line("~CMD bad-sign", 15) is not None,
|
||
"подпись следующей команды не сошлась")
|
||
# Реальный модуль ре-кает на следующем local_reg (как в probe1-3);
|
||
# после re-key цепочки свежие — SET доходит.
|
||
check(mock.wait_line("~CMD SET fan_speed=2", 20) is not None,
|
||
"SET доставлена после re-key")
|
||
check(len([l for l in mock.all_lines() if l.startswith("KE ")]) >= 2,
|
||
"второй key exchange был")
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def scenario_set_get():
|
||
print("== scenario: SET + подтверждение GET (эха нет)")
|
||
mock_port, listen = free_port(), free_port()
|
||
mock = run_mock([], mock_port)
|
||
env = {"RUNNER_SET_NAME": "fan_speed", "RUNNER_SET_VALUE": "3",
|
||
"RUNNER_SET_AT": "1"}
|
||
runner, timeout = run_runner(mock_port, listen, [], 5, env_extra=env)
|
||
line = runner.wait_line("SET_DONE fan_speed=3", 10)
|
||
check(line is not None, "SET отправлена")
|
||
check(mock.wait_line("CMD SET fan_speed=3", 10) is not None,
|
||
"mock применил SET")
|
||
runner.wait_line("DELETED", timeout)
|
||
runner.stop()
|
||
mock.stop()
|
||
|
||
|
||
def main():
|
||
scenarios = [scenario_normal, scenario_rekey, scenario_fail_push,
|
||
scenario_slot_503, scenario_no_poll, scenario_bad_ke,
|
||
scenario_chain_divergence_heals,
|
||
scenario_outbound_desync_rekey, scenario_set_get]
|
||
for sc in scenarios:
|
||
sc()
|
||
print()
|
||
if FAILURES:
|
||
print("ПРОВАЛЕНО:", len(FAILURES))
|
||
for f in FAILURES:
|
||
print(" -", f)
|
||
sys.exit(1)
|
||
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|