#!/usr/bin/env python3 """whoami 设备心跳自测。 **全程只连本地 stub HTTP 服务(127.0.0.1:随机端口),不碰外网**: * 抓包的 24 个字段一个不少、顺序一致、可配置项生效; * 真实 POST 的形状(方法/path/query/Content-Type/UA/空 body)与 `next_interval` 解析; * 失败(连不上 / 5xx / 非 JSON)**只记状态不抛异常**; * 后台线程:启动即打一次、`whoami_enabled=False` 时一次都不打、间隔被夹在 [60s, 24h]; * 管理接口 `GET /api/whoami`、`POST /api/whoami/send`。 python3 -m unittest tests.test_whoami -v """ import json import os import sys import tempfile import threading import unittest import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from ktv import admin, whoami from ktv.config import Config from ktv.store import Store from ktv.web import Request # 2026-10-11 抓包(hbeat.ktvsky.com/whoami)里的设备相关取值 HAR_TS_MS = 1791702330016 HAR_KEYS = ("_src", "_src_at", "_m", "_mac", "_vc", "_vn", "_pkn", "_player", "_boot", "_ts", "_api", "ktv_id", "androidid", "_manuf", "_cid", "d_name", "d_device", "d_board", "d_hardware", "d_fingerprint", "d_incremental", "d_release", "d_utc", "d_user") class _StubServer: """本地 stub:记录每一次请求,按固定内容作答(默认 200 + `next_interval=7`)。""" def __init__(self, body: bytes = b'{"msg":"","code":200,"next_interval":7}', status: int = 200, content_type: str = "application/json"): self.body, self.status, self.content_type = body, status, content_type self.requests: list[dict] = [] self.hit = threading.Event() outer = self class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *args): # 测试里不要往 stderr 刷日志 pass def do_POST(self): parsed = urllib.parse.urlsplit(self.path) length = int(self.headers.get("Content-Length") or 0) payload = self.rfile.read(length) if length else b"" outer.requests.append({ "method": self.command, "path": parsed.path, "raw_query": parsed.query, "query": {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query, keep_blank_values=True).items()}, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": payload}) outer.hit.set() self.send_response(outer.status) self.send_header("Content-Type", outer.content_type) self.send_header("Content-Length", str(len(outer.body))) self.end_headers() self.wfile.write(outer.body) self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) self.httpd.daemon_threads = True self.port = self.httpd.server_address[1] self.url = f"http://127.0.0.1:{self.port}/whoami" threading.Thread(target=self.httpd.serve_forever, daemon=True).start() def close(self) -> None: try: self.httpd.shutdown() self.httpd.server_close() except Exception: pass def _free_port() -> int: """占一个端口再释放 —— 得到一个"确定连不上"的端口号(不会被别的服务占用)。""" import socket s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port class TestWhoamiQuery(unittest.TestCase): """query 构造:字段全量、顺序一致、可配置项生效、编码与抓包一致。""" def setUp(self): self.tmp = tempfile.mkdtemp() self.cfg = Config(os.path.join(self.tmp, "c.json")) def test_fields_and_order_match_har(self): params = whoami.build_query(self.cfg, boot=True, now_ms=HAR_TS_MS) self.assertEqual([k for k, _ in params], list(HAR_KEYS)) self.assertEqual([k for k, _ in params], list(whoami.PARAM_ORDER)) d = dict(params) self.assertEqual(len(params), 24) # 抓包里的固定值 self.assertEqual(d["_src"], "600007") self.assertEqual(d["_src_at"], "600001") self.assertEqual(d["_vc"], "4") self.assertEqual(d["_vn"], "4.1.3.03281430") self.assertEqual(d["_pkn"], "com.origjoy.local.ktv") self.assertEqual(d["_player"], "0") self.assertEqual(d["_api"], "31") self.assertEqual(d["ktv_id"], "10006789") self.assertEqual(d["d_user"], "dpi") self.assertEqual(d["d_release"], "12") # _api=31 → Android 12 # 设备身份:抓包时 _mac / androidid / _cid 三者同源(_cid 带 A_ 前缀) self.assertEqual(d["androidid"], d["_mac"]) self.assertEqual(d["_cid"], f"A_{d['_mac']}") # 时间戳:_ts 与 d_utc 同值(抓包如此) self.assertEqual(d["_ts"], str(HAR_TS_MS)) self.assertEqual(d["d_utc"], str(HAR_TS_MS)) # 平台真实可读的字段必须非空 for k in ("_m", "_manuf", "d_name", "d_device", "d_board", "d_hardware", "d_incremental"): self.assertTrue(d[k], k) self.assertTrue(d["d_fingerprint"].startswith(d["_manuf"] + "/")) def test_boot_flag(self): self.assertEqual(dict(whoami.build_query(self.cfg, boot=True))["_boot"], "1") self.assertEqual(dict(whoami.build_query(self.cfg, boot=False))["_boot"], "0") def test_config_overrides_are_used(self): self.cfg.update({"whoami_pkn": "com.example.custom", "whoami_model": "SM-TEST", "whoami_manufacturer": "Acme", "whoami_cid": "C_123", "whoami_api": 33, "whoami_ktv_id": "42", "whoami_src": "1", "whoami_src_at": "2", "upstream_mac": "aabbccddeeff"}) d = dict(whoami.build_query(self.cfg, boot=False)) self.assertEqual(d["_pkn"], "com.example.custom") self.assertEqual(d["_m"], "SM-TEST") self.assertEqual(d["_manuf"], "Acme") self.assertEqual(d["_cid"], "C_123") self.assertEqual(d["_api"], "33") self.assertEqual(d["d_release"], "13") # 33 → Android 13 self.assertEqual(d["ktv_id"], "42") self.assertEqual(d["_src"], "1") self.assertEqual(d["_src_at"], "2") self.assertEqual(d["_mac"], "aabbccddeeff") def test_url_uses_config_url_and_percent_encoding(self): self.cfg.update({"whoami_url": "http://127.0.0.1:9/whoami"}) url = whoami.build_url(self.cfg, boot=True, now_ms=HAR_TS_MS) self.assertTrue(url.startswith("http://127.0.0.1:9/whoami?"), url) raw = urllib.parse.urlsplit(url).query # 抓包里 fingerprint 的 `/` 与 `:` 都是百分号编码 self.assertIn("%2F", raw) self.assertIn("%3A", raw) self.assertEqual(dict(urllib.parse.parse_qsl(raw))["_pkn"], "com.origjoy.local.ktv") def test_user_agent_matches_dalvik_shape(self): ua = whoami.user_agent(self.cfg) self.assertTrue(ua.startswith("Dalvik/2.1.0 (Linux; U; Android 12; "), ua) self.assertIn(" Build/", ua) class TestWhoamiHttp(unittest.TestCase): """真实 POST 到本地 stub:形状、`next_interval` 解析、失败路径。""" def setUp(self): self.tmp = tempfile.mkdtemp() self.cfg = Config(os.path.join(self.tmp, "c.json")) self.stub = _StubServer() self.cfg.update({"whoami_url": self.stub.url, "whoami_interval_sec": 1234}) self.addCleanup(self.stub.close) def test_send_once_posts_expected_request(self): res = whoami.send_once(self.cfg, boot=True) self.assertTrue(res["ok"], res) self.assertEqual(res["http"], 200) self.assertEqual(res["next_interval"], 7) # 来自响应,而不是配置的 1234 self.assertEqual(self.stub.requests.__len__(), 1) req = self.stub.requests[0] self.assertEqual(req["method"], "POST") self.assertEqual(req["path"], "/whoami") self.assertEqual(req["body"], b"") # 抓包就是空 body self.assertEqual(req["headers"].get("content-type"), "text/plain; charset=utf-8") self.assertTrue(req["headers"].get("user-agent", "").startswith("Dalvik/2.1.0")) # 24 个字段全量送出 self.assertEqual(set(req["query"]), set(HAR_KEYS)) self.assertEqual(req["query"]["_boot"], "1") self.assertEqual(req["query"]["_pkn"], "com.origjoy.local.ktv") self.assertTrue(req["query"]["d_fingerprint"]) self.assertEqual(req["raw_query"].count("&"), 23) # 24 个参数 = 23 个 & def test_next_interval_falls_back_when_body_not_json(self): stub = _StubServer(body=b"502") self.cfg.update({"whoami_url": stub.url}) try: res = whoami.send_once(self.cfg) finally: stub.close() self.assertTrue(res["ok"]) # HTTP 200 就算成功 self.assertEqual(res["next_interval"], 1234) # 解析不到 → 用配置值 def test_http_error_is_recorded_not_raised(self): stub = _StubServer(body=b'{"code":500}', status=500) self.cfg.update({"whoami_url": stub.url}) try: res = whoami.send_once(self.cfg) finally: stub.close() self.assertFalse(res["ok"]) self.assertEqual(res["http"], 500) self.assertIn("HTTP 500", res["error"]) def test_connection_refused_is_swallowed(self): self.cfg.update({"whoami_url": f"http://127.0.0.1:{_free_port()}/whoami"}) res = whoami.send_once(self.cfg) # 不能抛异常 self.assertFalse(res["ok"]) self.assertEqual(res["http"], 0) self.assertTrue(res["error"]) self.assertGreaterEqual(res["elapsed_ms"], 0) def test_reporter_sends_on_start_then_waits(self): rep = whoami.WhoamiReporter(self.cfg) self.addCleanup(rep.stop) rep.start() self.assertTrue(self.stub.hit.wait(5), "启动后应立即打一次") for _ in range(50): # 等状态落定 if rep.status()["sent"]: break threading.Event().wait(0.05) st = rep.status() self.assertTrue(st["running"]) self.assertEqual(st["sent"], 1) self.assertEqual(st["failed"], 0) self.assertTrue(st["last_ok"]) self.assertEqual(st["last"]["next_interval"], 7) self.assertEqual(st["last_http"], 200) self.assertEqual(self.stub.requests[0]["query"]["_boot"], "1") # 启动第一次是 boot self.assertEqual(rep._interval({"next_interval": 7}), 60) # 夹到下限 self.assertEqual(rep._interval({"next_interval": 10 ** 9}), 24 * 3600) self.assertEqual(rep._interval({}), 1234) # 回落配置值 def test_reporter_disabled_sends_nothing(self): self.cfg.update({"whoami_enabled": False}) rep = whoami.WhoamiReporter(self.cfg) self.addCleanup(rep.stop) rep.start() self.assertFalse(self.stub.hit.wait(0.4), "关闭时不应发心跳") self.assertEqual(rep.status()["sent"], 0) def test_send_now_works_even_when_disabled(self): self.cfg.update({"whoami_enabled": False}) rep = whoami.WhoamiReporter(self.cfg) res = rep.send_now() self.assertTrue(res["ok"]) self.assertTrue(self.stub.hit.wait(1)) def test_reporter_never_blocks_start(self): """`start()` 必须在毫秒级返回(请求在子线程里发)—— 防止启动期阻塞。""" import time self.cfg.update({"whoami_url": f"http://127.0.0.1:{_free_port()}/whoami"}) rep = whoami.WhoamiReporter(self.cfg) self.addCleanup(rep.stop) t0 = time.time() rep.start() self.assertLess(time.time() - t0, 0.5) class TestWhoamiApi(unittest.TestCase): """管理接口:GET /api/whoami(状态)与 POST /api/whoami/send(立刻打一次)。""" def setUp(self): self.tmp = tempfile.mkdtemp() self.cfg = Config(os.path.join(self.tmp, "c.json")) self.stub = _StubServer() self.cfg.update({"whoami_url": self.stub.url, "data_dir": self.tmp, "media_dir": os.path.join(self.tmp, "media")}) self.store = Store(os.path.join(self.tmp, "t.db")) self.addCleanup(self.stub.close) self.addCleanup(self.store.conn.close) @staticmethod def _req(method, path): return Request(method, path, {}, {}, b"") def test_status_and_manual_send(self): rep = whoami.WhoamiReporter(self.cfg, self.store) router = admin.build_router(self.cfg, self.store, None, whoami=rep) st = json.loads(router.dispatch(self._req("GET", "/api/whoami")).body) self.assertTrue(st["enabled"]) self.assertEqual(st["url"], self.stub.url) self.assertFalse(st["last_ok"]) # 还没打过 # 立刻打一次 → 状态里应能看到时间/耗时/响应/成功 res = json.loads(router.dispatch(self._req("POST", "/api/whoami/send")).body) self.assertTrue(res["ok"], res) self.assertEqual(res["http"], 200) self.assertIn("next_interval", res["response"]) st2 = json.loads(router.dispatch(self._req("GET", "/api/whoami")).body) self.assertTrue(st2["last_ok"]) self.assertEqual(st2["sent"], 1) self.assertGreater(st2["last_at"], 0) self.assertGreaterEqual(st2["last_elapsed_ms"], 0) self.assertEqual(st2["last_http"], 200) self.assertEqual(st2["last_next_interval"], 7) def test_manual_send_records_failure(self): self.cfg.update({"whoami_url": f"http://127.0.0.1:{_free_port()}/whoami"}) rep = whoami.WhoamiReporter(self.cfg, self.store) router = admin.build_router(self.cfg, self.store, None, whoami=rep) res = json.loads(router.dispatch(self._req("POST", "/api/whoami/send")).body) self.assertFalse(res["ok"]) self.assertTrue(res["error"]) st = json.loads(router.dispatch(self._req("GET", "/api/whoami")).body) self.assertEqual(st["failed"], 1) self.assertFalse(st["last_ok"]) def test_endpoints_are_safe_without_reporter(self): router = admin.build_router(self.cfg, self.store, None) st = router.dispatch(self._req("GET", "/api/whoami")) self.assertEqual(st.status, 200) self.assertFalse(json.loads(st.body)["enabled"]) send = router.dispatch(self._req("POST", "/api/whoami/send")) self.assertEqual(send.status, 503) def test_events_are_written(self): rep = whoami.WhoamiReporter(self.cfg, self.store) rep.send_now() texts = " ".join(e["text"] for e in self.store.events(20)) self.assertIn("whoami 心跳成功", texts) if __name__ == "__main__": unittest.main(verbosity=2)