- 盘面图 XSS 防护:装配层全面转义用户可控字段 + 模板严格 CSP(default-src 'none',无 script-src)+ 6 项注入向量回归测试 - 自更新签名信任锚:--apply 双源 manifest 交叉验证 → ed25519 验签(纯 Python 实现,缺 sig 即拒)→ 逐文件 SHA256,全 fail-closed - sync 工具同步后自动重建 manifest + 签名 + 独立验签,私钥缺失 fail-closed - 测试 170 项全绿
268 lines
13 KiB
Python
268 lines
13 KiB
Python
"""HTML 盘面渲染测试(render/html.py + cast_chart --html)。"""
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
|
||
|
||
|
||
def _chart(name="张三", place="四川省成都市"):
|
||
"""用真实排盘管线造一份 ChartData(成都 1990 男盘)。"""
|
||
from datetime import datetime
|
||
from astro.solar_time import to_true_solar_time
|
||
from astro.timezone_resolve import to_utc
|
||
from astro.day_boundary import resolve_calendar_day
|
||
from chart.tyme_adapter import cast
|
||
from chart.luck import (childhood_luck, compute_luck, decade_pillars,
|
||
flow_months, flow_years, is_forward,
|
||
luck_label_for_year)
|
||
from chart.model import build_chart_data
|
||
from geo.cities import resolve_location
|
||
|
||
loc = resolve_location("四川省成都市")
|
||
clock = datetime(1990, 5, 14, 8, 30)
|
||
utc, tz_audit = to_utc(clock, loc)
|
||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||
day, bd_audit = resolve_calendar_day(tst)
|
||
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
|
||
raw = cast(cast_dt, "男")
|
||
forward = is_forward("男", raw["year"]["gan"])
|
||
luck = compute_luck(birth_tst=tst, prev_jie_time=raw["prev_jie"]["time"],
|
||
next_jie_time=raw["next_jie"]["time"], forward=forward)
|
||
this_year = datetime.now().year
|
||
decade = decade_pillars(raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||
luck["start_age_years"], cast_dt.year)
|
||
years = [(y, gz, luck_label_for_year(y, decade))
|
||
for y, gz in flow_years(cast_dt.year, this_year, 10)]
|
||
return build_chart_data(
|
||
name=name, gender="男", place=place,
|
||
clock="1990-05-14 08:30", loc=loc, tz_audit=tz_audit,
|
||
st_audit=st_audit, bd_audit=bd_audit, raw=raw, luck=luck,
|
||
luck_pillars=decade, flow_years=years,
|
||
flow_months=flow_months(this_year),
|
||
childhood_luck=childhood_luck(raw["hour"]["gan"], raw["hour"]["zhi"],
|
||
forward, luck["start_age_years"],
|
||
cast_dt.year),
|
||
)
|
||
|
||
|
||
class HtmlRenderTest(unittest.TestCase):
|
||
def test_render_complete(self):
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
self.assertFalse(re.findall(r"\{\{[A-Z_]+\}\}", html), "存在未填充占位符")
|
||
for ch in ("庚", "午", "辛", "巳", "己", "卯", "丁"):
|
||
self.assertIn(ch, html)
|
||
for sect in ("排盘", "大运", "流年", "流月", "空亡", "神煞", "纳音"):
|
||
self.assertIn(sect, html)
|
||
|
||
def test_brand_head_embedded(self):
|
||
"""顶部品牌区:logo 与 LogoFont 均 base64 内嵌,口号在。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
self.assertIn("灵爻妙解", html)
|
||
self.assertIn("传承易学智慧", html)
|
||
self.assertIn('class="brand-head"', html)
|
||
self.assertIn("data:image/png;base64,", html)
|
||
self.assertIn("@font-face", html)
|
||
self.assertIn("data:font/ttf;base64,", html)
|
||
|
||
def test_qrcode_card(self):
|
||
"""尾部交流群卡:二维码 jpeg base64 内嵌,标题与正文文案各出现一次。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
self.assertIn('class="card qrcard"', html)
|
||
self.assertIn("data:image/jpeg;base64,", html)
|
||
self.assertEqual(html.count("灵爻妙解 · 传统文化交流群"), 1)
|
||
self.assertEqual(
|
||
html.count("扫码加入微信群:反馈解读偏差、交流文化心得、"
|
||
"参与档案共建,版本更新动态第一时间同步。"), 1)
|
||
|
||
def test_audit_versions_only_in_comment(self):
|
||
"""技术信息并入用户信息卡:展示行删版本段,完整原文留在 HTML 注释。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
self.assertEqual(html.count("tyme4py"), 1)
|
||
self.assertEqual(html.count("schema"), 1)
|
||
m = re.search(r"<!--[^>]*tyme4py[^>]*-->", html)
|
||
self.assertIsNotNone(m, "注释中应保留含 tyme4py/schema 的完整审计原文")
|
||
self.assertIn("schema", m.group(0))
|
||
self.assertIn("时区", m.group(0))
|
||
self.assertIn("月令", html)
|
||
self.assertIn("日界 子初换日(23:00)", html)
|
||
|
||
def test_shensha_notes_stripped(self):
|
||
"""神煞括号注释在装配层剔除(全角/半角),Markdown 侧不受影响。"""
|
||
from render.html import _strip_sha_note, build_html
|
||
self.assertEqual(_strip_sha_note("将星(以年支子查,见于月支子)"), "将星")
|
||
self.assertEqual(_strip_sha_note("天乙贵人(以日干查)"), "天乙贵人")
|
||
self.assertEqual(_strip_sha_note("驿马"), "驿马")
|
||
html = build_html(_chart())
|
||
self.assertNotIn("(以", html)
|
||
|
||
def test_luck_flow_card_removed(self):
|
||
"""「大运 · 流年」对照卡已整张删除;排盘卡仍只含四柱。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
self.assertNotIn("大运 · 流年", html)
|
||
self.assertNotIn("当前一步大运与本年流年对照", html)
|
||
self.assertNotIn("LUCK_FLOW_TABLE", html)
|
||
pan_seg = html[html.find("排盘<span"):html.find('class="sect">大运')]
|
||
self.assertIn("年柱", pan_seg)
|
||
self.assertNotIn("大运", pan_seg)
|
||
self.assertNotIn("流年", pan_seg)
|
||
|
||
def test_luck_strip_enriched(self):
|
||
"""大运格:起运年+干支之外带十神·星运/空亡/纳音/神煞(2018 甲申运对拍)。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
seg = html[html.find('class="sect">大运'):html.find('class="sect">流年')]
|
||
self.assertIn("2018 · 28 岁起", seg)
|
||
self.assertIn("正官 · 沐浴", seg) # 甲对日干己 / 己对申
|
||
self.assertIn("午未", seg) # 甲申旬空
|
||
self.assertIn("泉中水", seg) # 甲申纳音
|
||
self.assertIn("天乙贵人 · 金舆 · 驿马 · 劫煞 · 孤辰", seg) # 甲申岁运神煞
|
||
self.assertIn('class="cell cur"', seg) # 当前一步高亮
|
||
|
||
def test_flow_year_strip_enriched(self):
|
||
"""流年格:年份+干支之外带主星/星运/空亡/纳音/神煞(2026 丙午对拍)。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
seg = html[html.find('class="sect">流年'):html.find('class="sect">流月')]
|
||
self.assertIn("正印 · 临官", seg) # 丙对日干己 / 己对午
|
||
self.assertIn("寅卯", seg) # 丙午旬空(甲辰旬)
|
||
self.assertIn("天河水", seg) # 丙午纳音
|
||
self.assertIn("天德合 · 禄神 · 将星", seg) # 丙午岁运神煞
|
||
self.assertIn('class="cell cur"', seg) # 本年高亮
|
||
|
||
def test_strips_have_no_field_labels(self):
|
||
"""条带小卡只排值:不出现「主星/星运/空亡/纳音/神煞」字段标签。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
segs = [html[html.find('class="sect">大运'):html.find('class="sect">流年')],
|
||
html[html.find('class="sect">流年'):html.find('class="sect">流月')]]
|
||
for seg in segs:
|
||
for label in ("主星", "星运", "空亡", "纳音", "神煞"):
|
||
self.assertNotIn(label, seg)
|
||
|
||
def test_ten_god_table(self):
|
||
from chart.ganzhi import ten_god
|
||
self.assertEqual(ten_god("庚", "丁"), "正官")
|
||
self.assertEqual(ten_god("庚", "丙"), "七杀")
|
||
self.assertEqual(ten_god("庚", "辛"), "劫财")
|
||
self.assertEqual(ten_god("庚", "壬"), "食神")
|
||
|
||
def test_void_and_terrain(self):
|
||
from chart.ganzhi import gz_void, terrain
|
||
self.assertEqual(gz_void("庚子"), "辰巳")
|
||
self.assertEqual(gz_void("甲子"), "戌亥")
|
||
self.assertEqual(terrain("庚", "巳"), "长生")
|
||
self.assertEqual(terrain("庚", "子"), "死")
|
||
|
||
def test_nayin_shared_with_shensha_module(self):
|
||
"""纳音表集中于 chart/ganzhi.py,chart/shensha.py 复用同一份。"""
|
||
from chart.ganzhi import NAYIN
|
||
from chart.shensha import NAYIN as SHA_NAYIN
|
||
self.assertIs(NAYIN, SHA_NAYIN)
|
||
self.assertEqual(NAYIN["甲辰"], "覆灯火")
|
||
self.assertEqual(NAYIN["丙午"], "天河水")
|
||
|
||
|
||
class HtmlXssTest(unittest.TestCase):
|
||
"""XSS 防护:用户可控字段(姓名/出生地)一律转义,模板带 CSP,无脚本。"""
|
||
|
||
PAYLOADS = (
|
||
"<script>alert(1)</script>",
|
||
"<img src=x onerror=alert(1)>",
|
||
'" autofocus onfocus=alert(1) x="',
|
||
"javascript:alert(1)",
|
||
'山西晋城 "><svg onload=alert(1)>',
|
||
)
|
||
|
||
# 活跃标签(真实 < 起首、未被 < 转义)携带事件属性或 javascript: 协议
|
||
_LIVE_HANDLER = re.compile(r"<[a-zA-Z][^<>]*\son\w+\s*=")
|
||
_LIVE_JS_URL = re.compile(r"<[a-zA-Z][^<>]*javascript:")
|
||
|
||
def _render(self, name, place):
|
||
from render.html import build_html
|
||
return build_html(_chart(name=name, place=place))
|
||
|
||
def test_payloads_escaped(self):
|
||
"""注入向量在输出中只以转义形态存在,裸形态消失。"""
|
||
from html import escape
|
||
for payload in self.PAYLOADS:
|
||
for field in ("name", "place"):
|
||
kw = {"name": "张三", "place": "四川省成都市", field: payload}
|
||
html = self._render(**kw)
|
||
esc = escape(payload, quote=True)
|
||
if esc != payload: # 含 & < > " ' 的载荷:转义形态在、裸形态不在
|
||
self.assertIn(esc, html, f"{field}={payload!r} 未转义")
|
||
self.assertNotIn(payload, html, f"{field}={payload!r} 裸注入")
|
||
|
||
def test_no_live_markup_in_document(self):
|
||
"""注入打到 name/place 后检查整份文档(含结构性占位符区域):
|
||
无 <script> 标签、无活跃事件处理属性、无 javascript: 属性值。"""
|
||
html = self._render("<img src=x onerror=alert(1)>",
|
||
'" autofocus onfocus=alert(1) x="')
|
||
self.assertNotIn("<script", html.lower())
|
||
self.assertIsNone(self._LIVE_HANDLER.search(html),
|
||
"存在活跃 on* 事件处理属性")
|
||
self.assertIsNone(self._LIVE_JS_URL.search(html),
|
||
"存在 javascript: 属性值")
|
||
|
||
def test_javascript_scheme_inert(self):
|
||
"""javascript:alert(1) 无特殊字符、转义前后同形——只能作为纯文本出现。"""
|
||
html = self._render("javascript:alert(1)", "javascript:alert(1)")
|
||
self.assertIsNone(self._LIVE_JS_URL.search(html))
|
||
|
||
def test_csp_meta_present(self):
|
||
"""CSP meta 存在且按模板实际资源类型收紧:default-src 'none',
|
||
仅放行 data: 图片/字体与 inline 样式。"""
|
||
from render.html import build_html
|
||
html = build_html(_chart())
|
||
m = re.search(
|
||
r'<meta http-equiv="Content-Security-Policy" content="([^"]+)"', html)
|
||
self.assertIsNotNone(m, "缺少 CSP meta")
|
||
csp = m.group(1)
|
||
self.assertIn("default-src 'none'", csp)
|
||
self.assertIn("img-src data:", csp)
|
||
self.assertIn("style-src 'unsafe-inline'", csp)
|
||
self.assertIn("font-src data:", csp)
|
||
|
||
def test_no_script_tag(self):
|
||
"""盘面为纯静态页:输出不含任何 <script>。"""
|
||
from render.html import build_html
|
||
self.assertNotIn("<script", build_html(_chart()).lower())
|
||
|
||
def test_structural_placeholders_intact(self):
|
||
"""转义不误伤结构性占位符:表格/条带 HTML 片段原样保留。"""
|
||
html = self._render("<b>x</b>", '<i>y</i>')
|
||
self.assertIn('<table class="pan">', html)
|
||
self.assertIn('<div class="strip">', html)
|
||
self.assertIn('data:image/png;base64,', html)
|
||
|
||
|
||
class CastHtmlE2ETest(unittest.TestCase):
|
||
def test_html_flag_writes_file(self):
|
||
with tempfile.TemporaryDirectory() as td:
|
||
out = Path(td) / "p.html"
|
||
r = subprocess.run(
|
||
[sys.executable, str(SKILL_ROOT / "scripts" / "cast_chart.py"),
|
||
"--name", "张三", "--gender", "男", "--birth", "1990-05-14 08:30",
|
||
"--place", "四川省成都市", "--html", str(out)],
|
||
capture_output=True, text=True, timeout=120,
|
||
cwd=str(SKILL_ROOT / "scripts"))
|
||
self.assertEqual(r.returncode, 0, r.stderr)
|
||
self.assertTrue(r.stdout.startswith("# 命盘:张三"))
|
||
self.assertIn("HTML 盘面:", r.stderr)
|
||
html = out.read_text(encoding="utf-8")
|
||
self.assertIn("<table", html)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|