Files
infra-docker-configs/servers/tools-pve/asr-pipeline/tests/test_z_hardening.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

376 lines
16 KiB
Python

"""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('<form class="q-item"')
self.assertGreaterEqual(len(forms), 3)
for form in forms[1:]:
head = form.split("</form>", 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("<!-- intake:"))
for i in range(3):
from asrpipe import lexicon
lexicon.learn_alias(f"别名{i}", f"规范{i}", origin="manual")
from asrpipe import lexicon
self.assertGreaterEqual(lexicon.count_aliases(q="别名"), 3)
page = lexicon.list_aliases(q="别名", limit=2, offset=0)
self.assertEqual(len(page), 2)
env = os.environ.copy()
env.pop("ASR_GIT_SYNC", None)
env.pop("ASR_NOTIFY", None)
env["ASR_STATE_DIR"] = tempfile.mkdtemp()
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
out = subprocess.check_output(
[sys.executable, "-c",
"from asrpipe.config import Config; c=Config(); print(int(c.git_sync), int(c.notify))"],
env=env, text=True,
).strip()
self.assertEqual(out, "0 0")
if __name__ == "__main__":
unittest.main()