"""Regression checks for the review and the 8788 black-box report.""" from __future__ import annotations import json import os import socket import subprocess import sys import tempfile import threading import unittest import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import mock if "asrservice.config" not in sys.modules: _boot = tempfile.mkdtemp(prefix="asr-harden-") os.environ["ASR_DATA_DIR"] = str(Path(_boot) / "data") os.environ["ASR_STATE_DIR"] = str(Path(_boot) / "state") os.environ["ASR_VAULT"] = str(Path(_boot) / "vault") os.environ["ASR_NOTIFY"] = "0" os.environ["ASR_GIT_SYNC"] = "0" os.environ["ASR_AGENT_TOKEN"] = "test-agent-token" from asrpipe import locking, refine, review, webauth # noqa: E402 from asrpipe.config import Config # noqa: E402 from asrpipe import providers # noqa: E402 from asrservice import config as svc_cfg # noqa: E402 from asrservice import db as svc_db # noqa: E402 from asrservice import web as svc_web # noqa: E402 from asrservice import worker # noqa: E402 class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp) def _opener(follow=True): if follow: return urllib.request.build_opener(urllib.request.HTTPCookieProcessor()) return urllib.request.build_opener(_NoRedirect) def _start_server(): server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return server, server.server_address[1] def _raw(port: int, payload: bytes) -> bytes: sock = socket.create_connection(("127.0.0.1", port), timeout=5) try: sock.sendall(payload) sock.shutdown(socket.SHUT_WR) chunks = [] while True: part = sock.recv(65536) if not part: break chunks.append(part) return b"".join(chunks) finally: sock.close() class HardeningTests(unittest.TestCase): def setUp(self): os.environ.pop("ASR_WEB_ALLOW_ANON", None) os.environ["ASR_WEB_PASSWORD"] = "test-web-password" os.environ["ASR_NOTIFY"] = "0" os.environ["ASR_GIT_SYNC"] = "0" svc_cfg.ensure_dirs() svc_db.connect().close() self._hindsight = Config.use_hindsight.__get__(svc_web.CFG, type(svc_web.CFG)) svc_web.CFG.use_hindsight = False def tearDown(self): svc_web.CFG.use_hindsight = self._hindsight def test_closed_without_password_and_csrf_and_bad_length(self): os.environ["ASR_WEB_PASSWORD"] = "" server, port = _start_server() try: opener = _opener(False) with self.assertRaises(urllib.error.HTTPError) as caught: opener.open(f"http://127.0.0.1:{port}/review/", timeout=5) self.assertEqual(caught.exception.code, 303) self.assertIn("/login", caught.exception.headers.get("Location", "")) page = urllib.request.urlopen(f"http://127.0.0.1:{port}/login", timeout=5).read().decode() self.assertIn("ASR_WEB_PASSWORD", page) raw = _raw(port, b"POST /login HTTP/1.0\r\nContent-Length: abc\r\n\r\n") self.assertIn(b" 400 ", raw) self.assertNotIn(b"Empty", raw) finally: server.shutdown() server.server_close() os.environ["ASR_WEB_PASSWORD"] = "test-web-password" def test_pages_hide_secrets_escape_titles_and_reject_bad_edits(self): svc_db.save_settings({ "litellm_api_key": "sk-SECRET-abcd-wxyz", "diarize_url": "http://192.168.50.100:10099/asr", }) jid = "abc123abc123abc123abc123" title = "X');window.__xss=1;//" svc_db.create_job(jid, "录音.wav", "sample.wav", title=title, size=100, source="web") audio = svc_cfg.PROCESSED / "sample.wav" audio.write_bytes(b"0123456789" * 10) draft = svc_cfg.DRAFTS / f"{jid}.md" draft.write_text("原文一行\n", encoding="utf-8") result = svc_cfg.RESULTS / "2026-纪要.md" result.write_text("# 纪要\n", encoding="utf-8") svc_db.update_job(jid, "complete", "完成", draft=str(draft), result=str(result)) q1 = review.enqueue("rec-a", "第一题?", {"kind": "draft_question", "candidates": ["甲"]}) q2 = review.enqueue("rec-b", "第二题?", {"kind": "draft_question", "candidates": ["乙"]}) server, port = _start_server() try: opener = _opener(True) opener.open(urllib.request.Request( f"http://127.0.0.1:{port}/login", data=urllib.parse.urlencode({"password": "test-web-password"}).encode(), method="POST", ), timeout=5) home = opener.open(f"http://127.0.0.1:{port}/", timeout=5).read().decode() self.assertNotIn('onclick="delOne(', home) self.assertIn("data-del", home) settings = opener.open(f"http://127.0.0.1:{port}/settings", timeout=5).read().decode() self.assertNotIn("192.168.50.100", settings) self.assertNotIn("sk-SECRET", settings) self.assertIn("已配置", settings) review_html = opener.open(f"http://127.0.0.1:{port}/review/", timeout=5).read().decode() forms = review_html.split('
", 1)[0] self.assertLess(head.find('name="single_custom"'), head.find("custom_")) missing = _raw( port, ( "POST /jobs/%s/meta HTTP/1.0\r\nHost: 127.0.0.1\r\n" "Content-Type: application/x-www-form-urlencoded\r\n" "Cookie: asr_session=%s\r\nContent-Length: 10\r\n\r\ntitle=nope" % (jid, webauth.session_token()) ).encode(), ) self.assertIn(b" 403 ", missing) edit = urllib.parse.urlencode({ "csrf": webauth.csrf_token(), "target": "../../x", "content": "x", }).encode() req = urllib.request.Request( f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=edit, method="POST", ) body = opener.open(req, timeout=5).read().decode() self.assertIn("未保存", body) self.assertEqual(draft.read_text(encoding="utf-8"), "原文一行\n") empty = urllib.parse.urlencode({ "csrf": webauth.csrf_token(), "target": "draft", "content": " ", }).encode() body = opener.open(urllib.request.Request( f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=empty, method="POST", ), timeout=5).read().decode() self.assertIn("内容为空", body) header = ( f"POST /jobs/{jid}/edit HTTP/1.0\r\nCookie: asr_session={webauth.session_token()}\r\n" "Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3000000\r\n\r\n" ).encode() huge = _raw(port, header) self.assertIn(b" 413 ", huge) art = urllib.request.Request(f"http://127.0.0.1:{port}/jobs/{jid}/artifact/result") with opener.open(art, timeout=5) as resp: disp = resp.headers.get("Content-Disposition", "") text = resp.read().decode() disp.encode("latin-1") self.assertIn("filename*=UTF-8''", disp) self.assertIn("filename=\"download.md\"", disp) self.assertIn("纪要", text) ranged = urllib.request.Request( f"http://127.0.0.1:{port}/jobs/{jid}/audio", headers={"Range": "bytes=80-10"}, ) try: opener.open(ranged, timeout=5) self.fail("reverse range should be rejected") except urllib.error.HTTPError as exc: self.assertEqual(exc.code, 416) self.assertIn("bytes */100", exc.headers.get("Content-Range", "")) suffix = urllib.request.Request( f"http://127.0.0.1:{port}/jobs/{jid}/audio", headers={"Range": "bytes=-10"}, ) with opener.open(suffix, timeout=5) as resp: self.assertEqual(resp.status, 206) self.assertEqual(resp.headers.get("Content-Range"), "bytes 90-99/100") self.assertEqual(len(resp.read()), 10) try: opener.open(f"http://127.0.0.1:{port}/no-such-page", timeout=5) self.fail("missing page should be 404") except urllib.error.HTTPError as exc: self.assertEqual(exc.code, 404) self.assertIn("页面不存在", exc.read().decode()) finally: server.shutdown() server.server_close() review.delete_for_job(transcript_path="rec-a") review.delete_for_job(transcript_path="rec-b") self.assertIsNotNone(q1) self.assertIsNotNone(q2) def test_busy_retry_does_not_move_processing_audio(self): jid = "fedcba9876543210fedcba98" name = "busy.wav" svc_db.create_job(jid, name, name, title="进行中", size=4, source="web") audio = svc_cfg.PROCESSING / name audio.write_bytes(b"wave") svc_db.update_job(jid, "transcribing", "正在转写") server, port = _start_server() try: opener = _opener(True) opener.open(urllib.request.Request( f"http://127.0.0.1:{port}/login", data=urllib.parse.urlencode({"password": "test-web-password"}).encode(), method="POST", ), timeout=5) body = urllib.parse.urlencode({ "csrf": webauth.csrf_token(), "title": "进行中", "provider": "not-a-real-engine", "next_action": "retry", }).encode() page = opener.open(urllib.request.Request( f"http://127.0.0.1:{port}/jobs/{jid}/meta", data=body, method="POST", ), timeout=5).read().decode() self.assertIn("未重新排队", page) self.assertIn("未知引擎", page) self.assertTrue(audio.is_file()) self.assertFalse((svc_cfg.INBOX / name).exists()) self.assertEqual(svc_db.get_job(jid)["status"], "transcribing") finally: server.shutdown() server.server_close() def test_revision_failure_keeps_marker_and_answers(self): jid = "a1b2c3d4e5f60718293a4b5c" transcript = svc_cfg.TRANSCRIPTS / f"{jid}.txt" transcript.write_text("【speaker_0】原文\n", encoding="utf-8") draft = svc_cfg.DRAFTS / f"{jid}.md" draft.write_text("---\ntype: minutes\n---\n# 稿\n", encoding="utf-8") svc_db.create_job(jid, "a.wav", "a.wav", title="重精修", size=1, source="web") svc_db.update_job(jid, "review", "待确认", transcript=str(transcript), draft=str(draft)) qid = review.enqueue(str(transcript), "已确认的问题?", { "kind": "draft_question", "draft_path": str(draft), "candidates": ["保留"], }) review.answer(qid, "保留") pending_id = review.enqueue(str(transcript), "仍待确认?", { "kind": "draft_question", "draft_path": str(draft), "candidates": ["甲"], }) with mock.patch("asrservice.worker.refine.refine", side_effect=RuntimeError("llm down")): with self.assertRaises(RuntimeError): worker.rerefine_job(svc_db.get_job(jid)) answered = review.recent_answered(20) self.assertIn(qid, [row["id"] for row in answered]) self.assertIn(pending_id, [row["id"] for row in review.pending(50)]) marker = svc_cfg.REVISION_QUEUE / f"revise-{jid}.json" marker.write_text(json.dumps({ "action": "revise", "job_id": jid, "draft": "/missing/draft.md", }), encoding="utf-8") worker.process_revisions() self.assertTrue(marker.is_file()) payload = json.loads(marker.read_text(encoding="utf-8")) self.assertEqual(payload["attempts"], 1) self.assertEqual(svc_db.get_job(jid)["status"], "review") def test_recover_processing_and_shared_lock_and_stream(self): jid = "00112233445566778899aabb" name = f"stuck-{jid[:8]}.wav" audio = svc_cfg.PROCESSING / name audio.write_bytes(b"stuck") svc_db.create_job(jid, name, name, title="卡住", size=5, source="web") svc_db.update_job(jid, "transcribing", "中断") worker.recover() self.assertFalse(audio.exists()) parked = svc_cfg.INBOX / name self.assertTrue(parked.is_file()) self.assertEqual(svc_db.get_job(jid)["status"], "queued") worker.beat() self.assertTrue((svc_cfg.STATE / "worker.heartbeat").is_file()) lock_path = Path(tempfile.mkdtemp()) / "consumer.lock" handle = locking.acquire_recording_lock(lock_path) try: with self.assertRaises(SystemExit): locking.acquire_recording_lock(lock_path) finally: handle.close() blob = b"STREAMTEST" + b"q" * (256 * 1024) audio_path = Path(tempfile.mkdtemp()) / "stream.wav" audio_path.write_bytes(blob) seen = {} class Capture(BaseHTTPRequestHandler): def do_POST(self): n = int(self.headers["Content-Length"]) body = self.rfile.read(n) seen["n"] = len(body) seen["head"] = b"STREAMTEST" in body seen["tail"] = body.endswith(b"q" + b"\r\n--" + body.split(b"----ASRBoundary", 1)[0][:0] or b"q") self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"ok": true}') def log_message(self, *_): pass cap = ThreadingHTTPServer(("127.0.0.1", 0), Capture) threading.Thread(target=cap.serve_forever, daemon=True).start() try: out = providers._http_multipart( f"http://127.0.0.1:{cap.server_address[1]}/up", fields={"model": "unit"}, file_field="file", file_path=audio_path, timeout=10, retries=1, ) finally: cap.shutdown() cap.server_close() self.assertTrue(out["ok"]) self.assertGreater(seen["n"], len(blob)) self.assertTrue(seen["head"]) def test_wording_timezone_git_default_and_lexicon_page(self): self.assertEqual( refine.scrub_internal_wording("卡片未提供职务,卡片提及张三"), "记录未写明职务,录音中提到张三", ) stamp = datetime(2026, 11, 2, 13, 8, 26, tzinfo=timezone.utc).timestamp() self.assertEqual(svc_web.fmt_ts(stamp), "2026-11-02 21:08:26") header = worker.intake_header({"context": "a-->b } c"}) self.assertEqual(header.count("-->"), 1) self.assertTrue(header.endswith("-->")) self.assertNotIn("a-->", header) self.assertTrue(header.startswith("