Files
ykxmz/_build/test_response_headers.py
T
xmz7891 fe3b9ada12 修管理页 Content-Type(浏览器显示成一屏源码)
用户截图:打开 relay.xxmxxs.top/zhongzhuanzhan 看到的**不是页面,
而是整页 HTML 源码**。

根因:_send() 的 content_type 参数默认是 application/json,
我重做管理页时调它没传 text/html —— 浏览器按 JSON/纯文本处理,
就把 HTML 原样显示了。

⚠ 更该反省的是:**我当时没发现。**
   验证脚本只看了 HTTP 200 和字节数,两个都对,就以为没问题。
   200 不等于页面能看 —— 响应头也得看。

顺手修的第二个问题:
   HEAD 请求一律返回 501 Unsupported method(没实现 do_HEAD)。
   浏览器用 GET 所以页面照开,但 Cloudflare 健康检查、
   很多监控/代理探测都用 HEAD,curl -I 也是。
   补了 do_HEAD:按路径算出类型和长度,只回头不回体。

新增测试 _build/test_response_headers.py(9 项):
  * HEAD 各路径不能是 501
  * 官网/管理页 Content-Type 必须是 text/html,不能是 json
  * favicon、产品截图必须是 image/*
并加进全量套件(阶段 3b6)。

验证:该测试 9/9 通过;全量套件 33 个阶段全部通过。
2026-10-07 09:53:43 +08:00

179 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r'''加一个"响应头"检查,并把这次的坑记下来。
踩的坑:
重做管理页时,_send() 的 content_type 默认是 application/json,
我调它的时候没传 text/html —— 结果浏览器把整页 HTML
当纯文本显示,用户看到的是一屏源码。
更糟的是**我当时没发现**:验证脚本只看了 HTTP 200 和字节数,
两个都对,就以为没问题了。**200 不等于页面能看。**
所以这个检查必须看响应头,不能只看状态码。
顺便也发现 HEAD 请求返回 501(没实现 do_HEAD),一起补了。
'''
from __future__ import annotations
import ast
import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
PORT = 8944
PASS = "@REVAedu120830"
def wait_port(seconds: float = 12.0) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
try:
with urllib.request.urlopen(
"http://127.0.0.1:%d/v1/health" % PORT, timeout=2):
return True
except Exception: # noqa: BLE001
time.sleep(0.5)
return False
def head(path: str, cookie: str = "") -> dict:
"""发一个 HEAD,取回响应头。"""
req = urllib.request.Request("http://127.0.0.1:%d%s" % (PORT, path),
method="HEAD")
if cookie:
req.add_header("Cookie", cookie)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
return {"status": resp.status,
"ctype": resp.headers.get("Content-Type", ""),
"len": resp.headers.get("Content-Length", "")}
except urllib.error.HTTPError as exc:
return {"status": exc.code,
"ctype": exc.headers.get("Content-Type", ""), "len": ""}
except Exception as exc: # noqa: BLE001
return {"status": 0, "ctype": "", "len": str(exc)}
def login() -> str:
"""登录拿 cookie。
用 http.cookiejar 而不是手工读 Set-Cookie ——
/admin/login 会 302 跳转,urllib 默认跟着跳,
Set-Cookie 头在第一个响应上就被丢掉了,手工读永远是空。
cookiejar 会把中间那次的 cookie 存下来。
"""
import http.cookiejar
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar))
body = ("username=xmz7891&password=" + PASS).encode()
req = urllib.request.Request(
"http://127.0.0.1:%d/admin/login" % PORT, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
opener.open(req, timeout=8).read()
except Exception: # noqa: BLE001
pass
for cookie in jar:
if cookie.name == "lr_admin":
return "%s=%s" % (cookie.name, cookie.value)
return ""
def main() -> int:
env = dict(__import__("os").environ)
env["RELAY_PORT"] = str(PORT)
env["RELAY_ADMIN_PASSWORD"] = PASS
data = ROOT / "_build" / "_hdr_data"
data.mkdir(parents=True, exist_ok=True)
env["RELAY_DATA"] = str(data)
# 中转把管理密码放在 DATA_DIR/data/admin_password.txt,
# 没有这个文件就用内置默认值。为了让测试不依赖默认值,
# 这里显式写一份,保证登录能成。
auth_dir = data / "data"
auth_dir.mkdir(parents=True, exist_ok=True)
(auth_dir / "admin_password.txt").write_text(PASS, encoding="utf-8")
# /shot/ 路由是从 relay.py 所在目录找 site_shots/。
# 仓库里那张图在 _build/site_shots/,这里拷一份过去,
# 否则那条断言会因为"文件不存在"而误报。
shot_src = ROOT / "_build" / "site_shots" / "pc-main.png"
shot_dst = ROOT / "relay" / "site_shots" / "pc-main.png"
if shot_src.is_file():
shot_dst.parent.mkdir(parents=True, exist_ok=True)
if not shot_dst.is_file():
shot_dst.write_bytes(shot_src.read_bytes())
print(" 启动中转(端口 %d)" % PORT)
proc = subprocess.Popen(
[sys.executable, "relay.py"], cwd=str(ROOT / "relay"), env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
passed = failed = 0
def check(name, ok, detail=""):
nonlocal passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + " " + str(detail))
try:
if not wait_port():
print(" [失败] 中转没起来")
return 1
print()
print(" 一、HEAD 请求要能用(原来一律 501)")
for path in ("/", "/v1/health", "/favicon.ico"):
info = head(path)
check("HEAD %s 不是 501" % path, info["status"] != 501,
info["status"])
print()
print(" 二、Content-Type 必须是 text/html(否则浏览器显示源码)")
site = head("/")
check("官网 Content-Type 是 text/html",
"text/html" in site["ctype"], site["ctype"])
cookie = login()
check("登录拿到 cookie", bool(cookie), cookie or "(没拿到)")
admin = head("/zhongzhuanzhan", cookie)
check("管理页 Content-Type 是 text/html",
"text/html" in admin["ctype"],
admin["ctype"] or "(空)")
check("管理页不是 application/json",
"json" not in admin["ctype"].lower(), admin["ctype"])
print()
print(" 三、其它资源的类型")
icon = head("/favicon.ico")
check("favicon 是 image/*", "image" in icon["ctype"], icon["ctype"])
if shot_dst.is_file():
shot = head("/shot/pc-main.png")
check("截图是 image/*", "image" in shot["ctype"], shot["ctype"])
else:
print(" [跳过] 截图类型(本地没有 site_shots/pc-main.png)")
finally:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
print()
print("=" * 54)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())