Files
NextEdu/tests/webapp/parent_full_test.py
SpecialX 978d9a8309
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
2026-06-22 01:06:16 +08:00

990 lines
40 KiB
Python
Raw Permalink 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.
"""
家长端全功能Web测试脚本
使用 Playwright 对家长端所有页面进行功能测试
"""
import json
import os
import re
import sys
from datetime import datetime
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
PARENT_EMAIL = "parent_g1c1_1@xiaoxue.edu.cn"
PARENT_PASSWORD = "123456"
# 家长端所有路由(从导航配置 navigation.ts 和源码中提取)
# 家长权限EXAM_READ, TEXTBOOK_READ, CLASS_READ, USER_PROFILE_UPDATE,
# ANNOUNCEMENT_READ, GRADE_RECORD_READ, ATTENDANCE_READ,
# MESSAGE_SEND, MESSAGE_READ, MESSAGE_DELETE
PARENT_ROUTES = {
"Dashboard": ["/parent/dashboard"],
"Grades": ["/parent/grades"],
"Attendance": ["/parent/attendance"],
"Announcements": ["/announcements"],
"Messages": [
"/messages",
"/messages/compose",
],
"Profile": ["/profile"],
"Settings": [
"/settings",
"/settings/security",
],
}
# 详情页 - 需要从 dashboard 获取 studentId
DETAIL_ROUTES = {
"Child Detail": "/parent/children/",
}
# 跨角色访问保护测试:家长不应能访问教师/学生/管理员页面
FORBIDDEN_ROUTES = [
"/admin/dashboard",
"/admin/school",
"/teacher/dashboard",
"/teacher/exams",
"/teacher/homework",
"/teacher/grades",
"/teacher/questions",
"/teacher/classes",
"/teacher/attendance",
"/student/dashboard",
"/student/learning",
"/student/grades",
"/student/attendance",
"/management/grade/classes",
]
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "家长端 (Parent)",
"base_url": BASE_URL,
"parent_email": PARENT_EMAIL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"pages": {},
"functional_checks": [],
"security_checks": [],
"console_errors": [],
"navigation_issues": [],
}
def login(page):
"""登录家长账号"""
print("\n>>> 登录家长账号...")
page.goto(f"{BASE_URL}/login")
page.wait_for_load_state("networkidle")
# 检查是否已登录
if "/parent" in page.url or (page.url.rstrip("/").endswith("/dashboard") and "/login" not in page.url):
print(" 已登录,跳过登录步骤")
return True
try:
email_input = page.locator('input[name="email"]')
if email_input.count() == 0:
email_input = page.locator('input[type="email"]')
print(f" 找到 email 输入框: {email_input.count()}")
email_input.fill(PARENT_EMAIL)
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
print(f" 找到 password 输入框: {password_input.count()}")
password_input.fill(PARENT_PASSWORD)
# 点击登录按钮
login_btn = page.locator('button[type="submit"]')
if login_btn.count() == 0:
login_btn = page.get_by_role("button", name="Sign In with Email")
if login_btn.count() == 0:
login_btn = page.locator("button").filter(has_text="Sign In")
if login_btn.count() == 0:
login_btn = page.locator("button").filter(has_text="登录")
print(f" 找到登录按钮: {login_btn.count()}")
login_btn.click()
# 等待导航或错误提示
try:
page.wait_for_url(lambda url: "/login" not in url, timeout=10000)
except PlaywrightTimeout:
# 登录可能失败,检查页面上的错误
body_text = page.locator("body").text_content() or ""
print(f" 登录后仍在 login 页面,页面内容片段: {body_text[:300]}")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(2000)
print(f" 登录后 URL: {page.url}")
return "/login" not in page.url
except Exception as e:
print(f" ❌ 登录失败: {e}")
return False
def test_page(page, route, category, expect_redirect_to_login=False, expect_forbidden_redirect=False):
"""测试单个页面"""
url = f"{BASE_URL}{route}"
page_result = {
"url": url,
"route": route,
"category": category,
"status": "unknown",
"http_status": None,
"final_url": None,
"redirect_url": None,
"errors": [],
"warnings": [],
"content_checks": [],
}
print(f"\n 测试: {category} - {route}")
# 收集控制台错误
console_errors = []
MAX_CONSOLE_ERRORS = 5
def on_console(msg):
if msg.type == "error":
# 限制收集的错误数量,避免内存爆炸
if len(console_errors) >= MAX_CONSOLE_ERRORS:
return
# 截断过长的控制台错误(避免堆栈跟踪导致输出爆炸)
text = msg.text
if len(text) > 500:
text = text[:500] + "...(已截断)"
console_errors.append(text)
page.on("console", on_console)
try:
response = page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
http_status = response.status if response else None
final_url = page.url
page_result["http_status"] = http_status
page_result["final_url"] = final_url
# 检查是否重定向
if final_url.rstrip("/") != url.rstrip("/"):
page_result["redirect_url"] = final_url
# 检查 HTTP 状态
if http_status and http_status >= 500:
if expect_forbidden_redirect:
# 跨角色访问导致 500 也是权限隔离失效的表现
page_result["status"] = "failed"
page_result["errors"].append(f"跨角色访问返回 HTTP {http_status}(应被重定向拦截)")
else:
page_result["status"] = "failed"
page_result["errors"].append(f"HTTP {http_status} error")
elif http_status and http_status >= 400:
page_result["status"] = "warning"
page_result["warnings"].append(f"HTTP {http_status} error")
elif final_url and "/login" in final_url:
if expect_redirect_to_login or expect_forbidden_redirect:
page_result["status"] = "passed"
page_result["content_checks"].append("正确重定向到登录页(权限拦截)")
else:
page_result["status"] = "failed"
page_result["errors"].append("Redirected to login - auth issue")
elif final_url and "/500" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("Redirected to 500 error page")
elif final_url and "/404" in final_url:
page_result["status"] = "warning"
page_result["warnings"].append("Redirected to 404 page")
elif expect_forbidden_redirect:
# 期望被拦截但实际访问成功的跨角色访问
if "/parent/" in final_url and "reason=forbidden" in final_url:
page_result["status"] = "passed"
page_result["content_checks"].append("跨角色访问被权限系统拦截")
elif "/parent/" in final_url:
page_result["status"] = "passed"
page_result["content_checks"].append(f"跨角色访问被拦截,重定向到: {final_url}")
else:
# 仍然停留在教师/学生/管理员页面 = 权限隔离失效
page_result["status"] = "failed"
page_result["errors"].append(
f"⚠️ 安全漏洞:家长成功访问了受限页面(最终 URL: {final_url}),权限隔离失效"
)
else:
page_result["status"] = "passed"
# 检查页面是否有错误提示
error_texts = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all()
for et in error_texts:
try:
text = et.text_content()
if text and text.strip() and "Access denied" not in text:
page_result["warnings"].append(f"Error text on page: {text.strip()[:100]}")
except Exception:
pass
# 检查页面是否为空(无主要内容)
body_text = page.locator("body").text_content() or ""
if len(body_text.strip()) < 50:
page_result["warnings"].append("Page appears empty or has very little content")
# 收集控制台错误
if console_errors:
page_result["errors"].extend(console_errors[:5])
# 打印状态
status_icon = "" if page_result["status"] == "passed" else "⚠️" if page_result["status"] == "warning" else ""
print(f" {status_icon} {page_result['status']} (HTTP {http_status})")
if page_result.get("redirect_url"):
print(f" ↪ 重定向到: {page_result['redirect_url']}")
except PlaywrightTimeout:
page_result["status"] = "failed"
page_result["errors"].append("Page load timeout (30s)")
print(f" ❌ TIMEOUT")
except Exception as e:
page_result["status"] = "failed"
page_result["errors"].append(str(e)[:200])
print(f" ❌ ERROR: {str(e)[:100]}")
finally:
page.remove_listener("console", on_console)
return page_result
def discover_child_links(page):
"""从家长仪表盘发现子女详情页链接"""
try:
url = f"{BASE_URL}/parent/dashboard"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 查找子女详情链接
links = page.locator('a[href*="/parent/children/"]').all()
detail_urls = []
seen = set()
for link in links:
href = link.get_attribute("href")
if href and "/parent/children/" in href:
# 提取纯路径(不含 query string
clean_href = href.split("?")[0]
if clean_href not in seen:
seen.add(clean_href)
detail_urls.append(clean_href)
return detail_urls
except Exception as e:
print(f" 发现子女链接失败: {e}")
return []
def check_dashboard_functionality(page):
"""检查家长仪表盘功能完整性"""
checks = []
print("\n>>> 检查仪表盘功能完整性...")
try:
page.goto(f"{BASE_URL}/parent/dashboard", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 检查标题
title = page.locator("h1")
title_text = title.first.text_content() if title.count() > 0 else ""
checks.append({
"name": "仪表盘标题",
"expected": "Parent Dashboard",
"actual": title_text.strip() if title_text else "",
"passed": "Parent Dashboard" in (title_text or ""),
})
# 2. 检查问候语
body_text = page.locator("body").text_content() or ""
greeting_present = any(g in body_text for g in ["Good morning", "Good afternoon", "Good evening", "Welcome"])
checks.append({
"name": "问候语显示",
"expected": "Good morning/afternoon/evening 或 Welcome",
"actual": "Found" if greeting_present else "Missing",
"passed": greeting_present,
})
# 3. 检查快捷入口按钮
grades_btn = page.locator('a[href="/parent/grades"]')
attendance_btn = page.locator('a[href="/parent/attendance"]')
announcements_btn = page.locator('a[href="/announcements"]')
checks.append({
"name": "Grades 快捷入口",
"expected": "存在",
"actual": "Found" if grades_btn.count() > 0 else "Missing",
"passed": grades_btn.count() > 0,
})
checks.append({
"name": "Attendance 快捷入口",
"expected": "存在",
"actual": "Found" if attendance_btn.count() > 0 else "Missing",
"passed": attendance_btn.count() > 0,
})
checks.append({
"name": "Announcements 快捷入口",
"expected": "存在",
"actual": "Found" if announcements_btn.count() > 0 else "Missing",
"passed": announcements_btn.count() > 0,
})
# 4. 检查子女卡片
child_cards = page.locator('a[href*="/parent/children/"]')
card_count = child_cards.count()
checks.append({
"name": "子女卡片显示",
"expected": "≥1 个子女卡片",
"actual": f"{card_count}",
"passed": card_count > 0,
})
# 5. 检查子女卡片内容Pending/Overdue/Avg 统计)
if card_count > 0:
has_pending = "Pending" in body_text
has_overdue = "Overdue" in body_text
has_avg = "Avg" in body_text or "%" in body_text
checks.append({
"name": "子女卡片 - Pending 统计",
"expected": "显示 Pending 计数",
"actual": "Found" if has_pending else "Missing",
"passed": has_pending,
})
checks.append({
"name": "子女卡片 - Overdue 统计",
"expected": "显示 Overdue 计数",
"actual": "Found" if has_overdue else "Missing",
"passed": has_overdue,
})
# 6. 检查子女数量提示
children_count_text = "child linked" in body_text or "children linked" in body_text
checks.append({
"name": "子女数量提示",
"expected": "显示 'N child(ren) linked'",
"actual": "Found" if children_count_text else "Missing",
"passed": children_count_text,
})
except Exception as e:
checks.append({
"name": "仪表盘功能检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def check_child_detail_functionality(page, child_route):
"""检查子女详情页功能完整性"""
checks = []
print(f"\n>>> 检查子女详情页功能: {child_route}")
try:
url = f"{BASE_URL}{child_route}"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 1. 检查返回按钮
back_btn = page.locator('a[href="/parent/dashboard"]')
checks.append({
"name": "返回仪表盘按钮",
"expected": "存在 Back to Dashboard 链接",
"actual": "Found" if back_btn.count() > 0 else "Missing",
"passed": back_btn.count() > 0,
})
# 2. 检查子女姓名标题
h1 = page.locator("h1")
h1_text = h1.first.text_content() if h1.count() > 0 else ""
checks.append({
"name": "子女姓名标题",
"expected": "显示子女姓名",
"actual": h1_text.strip()[:50] if h1_text else "Missing",
"passed": bool(h1_text and h1_text.strip() and h1_text.strip() != "Unnamed"),
})
# 3. 检查邮箱掩码
# 邮箱应该被掩码为 j***@example.com 形式
email_pattern = re.search(r"[a-zA-Z0-9]\*\*\*@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", body_text)
checks.append({
"name": "邮箱掩码处理",
"expected": "邮箱被掩码为 j***@domain.com",
"actual": "Masked" if email_pattern else "Not masked or no email",
"passed": bool(email_pattern),
})
# 4. 检查作业摘要卡片
has_homework = "Homework" in body_text
checks.append({
"name": "作业摘要卡片",
"expected": "显示 {childName}'s Homework",
"actual": "Found" if has_homework else "Missing",
"passed": has_homework,
})
# 5. 检查作业统计计数
has_pending = "Pending" in body_text
has_submitted = "Submitted" in body_text
has_graded = "Graded" in body_text
checks.append({
"name": "作业统计 - Pending",
"expected": "显示 Pending 计数",
"actual": "Found" if has_pending else "Missing",
"passed": has_pending,
})
checks.append({
"name": "作业统计 - Submitted",
"expected": "显示 Submitted 计数",
"actual": "Found" if has_submitted else "Missing",
"passed": has_submitted,
})
checks.append({
"name": "作业统计 - Graded",
"expected": "显示 Graded 计数",
"actual": "Found" if has_graded else "Missing",
"passed": has_graded,
})
# 6. 检查成绩趋势卡片
has_grade_summary = "Grade" in body_text or "grade" in body_text.lower()
checks.append({
"name": "成绩趋势卡片",
"expected": "显示成绩信息",
"actual": "Found" if has_grade_summary else "Missing",
"passed": has_grade_summary,
})
# 7. 检查今日课表卡片
has_schedule = "Today Schedule" in body_text or "Schedule" in body_text
checks.append({
"name": "今日课表卡片",
"expected": "显示 {childName}'s Today Schedule",
"actual": "Found" if has_schedule else "Missing",
"passed": has_schedule,
})
# 8. 检查 View all 链接
view_all = page.locator("text=View all")
checks.append({
"name": "View all 链接",
"expected": "存在 View all 链接",
"actual": "Found" if view_all.count() > 0 else "Missing",
"passed": view_all.count() > 0,
})
except Exception as e:
checks.append({
"name": "子女详情页功能检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def check_security_isolation(page):
"""检查跨家庭信息隔离(访问其他家长的子女应被拒绝)"""
checks = []
print("\n>>> 检查跨家庭信息隔离...")
try:
# 尝试访问一个不存在的 studentId
url = f"{BASE_URL}/parent/children/non_existent_student_id_12345"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 应该显示 Access denied 或 404
is_denied = "Access denied" in body_text or "not linked" in body_text
is_404 = "404" in body_text or "not found" in body_text.lower() or "Page Not Found" in body_text
checks.append({
"name": "访问不存在/非关联子女应被拒绝",
"expected": "显示 Access denied 或 404",
"actual": "Access denied" if is_denied else ("404" if is_404 else "未拦截"),
"passed": is_denied or is_404,
})
except Exception as e:
checks.append({
"name": "跨家庭隔离检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def check_sidebar_navigation(page):
"""检查侧边栏导航是否仅显示家长相关菜单"""
checks = []
print("\n>>> 检查侧边栏导航...")
try:
page.goto(f"{BASE_URL}/parent/dashboard", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 仅检查侧边栏区域内的导航链接文本,避免页面正文误判
# 侧边栏通常在 nav 元素或 [data-sidebar] 内
sidebar = page.locator("nav").first
if sidebar.count() == 0:
sidebar = page.locator("body")
sidebar_text = sidebar.text_content() or ""
# 家长端应显示的导航项
expected_navs = ["Dashboard", "Grades", "Attendance", "Announcements", "Messages"]
for nav in expected_navs:
checks.append({
"name": f"侧边栏 - {nav}",
"expected": f"显示 {nav} 导航项",
"actual": "Found" if nav in sidebar_text else "Missing",
"passed": nav in sidebar_text,
})
# 家长端不应显示的教师/管理员导航项
forbidden_navs = ["Textbooks", "Question Bank", "Class Management", "Course Plans", "Lesson Plans", "Schedule Changes", "Diagnostic", "Electives", "School Management", "Audit Logs"]
for nav in forbidden_navs:
if nav in sidebar_text:
checks.append({
"name": f"侧边栏 - {nav}(不应显示)",
"expected": f"不显示 {nav}",
"actual": "Found (违规)",
"passed": False,
})
except Exception as e:
checks.append({
"name": "侧边栏导航检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def run_all_tests():
"""运行所有测试"""
global results
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
locale="zh-CN",
ignore_https_errors=True,
)
page = context.new_page()
# 登录
if not login(page):
print("❌ 登录失败,终止测试")
browser.close()
return
# 测试所有路由
total = 0
for category, routes in PARENT_ROUTES.items():
for route in routes:
page_result = test_page(page, route, category)
key = route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 发现并测试子女详情页
print("\n\n>>> 发现子女详情页链接...")
child_links = discover_child_links(page)
if child_links:
print(f"\n 发现 {len(child_links)} 个子女详情页链接")
for child_route in child_links[:3]: # 最多测试3个
if not child_route.startswith("/"):
child_route = "/" + child_route
page_result = test_page(page, child_route, "Child Detail")
key = child_route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 对子女详情页进行功能检查
detail_checks = check_child_detail_functionality(page, child_route)
results["functional_checks"].extend(detail_checks)
else:
print("\n 未发现子女详情页链接(可能家长未关联子女)")
# 仪表盘功能完整性检查
dashboard_checks = check_dashboard_functionality(page)
results["functional_checks"].extend(dashboard_checks)
# 侧边栏导航检查
sidebar_checks = check_sidebar_navigation(page)
results["functional_checks"].extend(sidebar_checks)
# 跨家庭信息隔离检查
security_checks = check_security_isolation(page)
results["security_checks"].extend(security_checks)
# 跨角色访问保护测试
print("\n\n>>> 测试跨角色访问保护...")
for forbidden_route in FORBIDDEN_ROUTES:
page_result = test_page(
page, forbidden_route, "Cross-Role Access Control",
expect_forbidden_redirect=True,
)
key = "forbidden_" + forbidden_route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 汇总
passed = sum(1 for p in results["pages"].values() if p["status"] == "passed")
failed = sum(1 for p in results["pages"].values() if p["status"] == "failed")
warnings = sum(1 for p in results["pages"].values() if p["status"] == "warning")
results["summary"]["total"] = total
results["summary"]["passed"] = passed
results["summary"]["failed"] = failed
results["summary"]["warnings"] = warnings
print(f"\n\n{'='*60}")
print(f"页面测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}")
print(f"{'='*60}")
# 功能检查汇总
func_passed = sum(1 for c in results["functional_checks"] if c["passed"])
func_total = len(results["functional_checks"])
print(f"功能检查: 总计 {func_total}, 通过 {func_passed}")
print(f"安全检查: 总计 {len(results['security_checks'])}, 通过 {sum(1 for c in results['security_checks'] if c['passed'])}")
print(f"{'='*60}")
browser.close()
def generate_report():
"""生成Markdown测试报告"""
report_lines = []
report_lines.append("# 家长端 Web 功能测试报告")
report_lines.append("")
report_lines.append(f"> 测试日期:{results['test_date']}")
report_lines.append(f"> 测试范围:家长端所有页面功能 + 跨角色权限隔离")
report_lines.append(f"> 测试工具Playwright + Chromium (headless)")
report_lines.append(f"> 测试账号:{results['parent_email']}")
report_lines.append(f"> Base URL{results['base_url']}")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append("## 一、测试概览")
report_lines.append("")
s = results["summary"]
report_lines.append(f"| 指标 | 数值 |")
report_lines.append(f"|------|------|")
report_lines.append(f"| 总测试页面数 | {s['total']} |")
report_lines.append(f"| 通过 | {s['passed']} |")
report_lines.append(f"| 失败 | {s['failed']} |")
report_lines.append(f"| 警告 | {s['warnings']} |")
report_lines.append(f"| 页面通过率 | {s['passed']/s['total']*100:.1f}% |" if s['total'] > 0 else "| 通过率 | N/A |")
func_total = len(results["functional_checks"])
func_passed = sum(1 for c in results["functional_checks"] if c["passed"])
sec_total = len(results["security_checks"])
sec_passed = sum(1 for c in results["security_checks"] if c["passed"])
report_lines.append(f"| 功能检查通过率 | {func_passed}/{func_total} ({func_passed/func_total*100:.1f}%) |" if func_total > 0 else "| 功能检查 | N/A |")
report_lines.append(f"| 安全检查通过率 | {sec_passed}/{sec_total} ({sec_passed/sec_total*100:.1f}%) |" if sec_total > 0 else "| 安全检查 | N/A |")
report_lines.append("")
# 关键发现摘要
report_lines.append("---")
report_lines.append("")
report_lines.append("## 二、关键发现")
report_lines.append("")
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
if failed_pages:
# 分类失败项
security_failures = []
for k, p in failed_pages:
if p["category"] == "Cross-Role Access Control":
security_failures.append(p)
if security_failures:
report_lines.append("### ⚠️ 严重:跨角色访问控制失效(安全漏洞)")
report_lines.append("")
report_lines.append("家长账号可以访问教师端页面,权限隔离失效。根因分析:")
report_lines.append("")
report_lines.append("- [`src/proxy.ts`](../src/proxy.ts#L10-L16) 中 `/teacher` 路由前缀仅要求 `EXAM_READ` 权限")
report_lines.append("- [`src/shared/lib/permissions.ts`](../src/shared/lib/permissions.ts#L125-L136) 中家长角色被授予了 `EXAM_READ` 权限")
report_lines.append("- 因此家长通过了 proxy 的权限检查,可以访问所有 `/teacher/*` 页面")
report_lines.append("")
report_lines.append("受影响页面:")
report_lines.append("")
report_lines.append("| 路由 | HTTP | 表现 |")
report_lines.append("|------|------|------|")
for p in security_failures:
route = p.get("route", "")
http = p["http_status"] or "-"
if p["http_status"] and p["http_status"] >= 500:
表现 = f"HTTP 500页面崩溃"
elif p.get("redirect_url"):
表现 = f"成功访问并重定向到 `{p['redirect_url'].replace(BASE_URL, '')}`"
else:
表现 = "成功访问HTTP 200"
report_lines.append(f"| `{route}` | {http} | {表现} |")
report_lines.append("")
report_lines.append("**修复建议**")
report_lines.append("")
report_lines.append("1. 在 `src/proxy.ts` 中为 `/teacher` 路由前缀增加角色校验(要求 `teacher` / `grade_head` / `teaching_head` 角色),或")
report_lines.append("2. 在 `src/shared/lib/permissions.ts` 中移除家长角色的 `EXAM_READ` 权限(如果家长不需要查看考试),或")
report_lines.append("3. 在各教师端页面的 Server Component 中增加 `requireRole()` 角色校验,作为深度防御")
report_lines.append("")
passed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "passed" and v["category"] != "Cross-Role Access Control"]
if passed_pages:
report_lines.append("### ✅ 家长端核心功能正常")
report_lines.append("")
report_lines.append(f"- 家长端 {len(passed_pages)} 个页面全部正常加载HTTP 200")
report_lines.append(f"- 功能完整性检查 {func_passed}/{func_total} 项通过")
report_lines.append(f"- 跨家庭信息隔离正常工作(访问非关联子女返回 Access denied")
report_lines.append(f"- 侧边栏导航正确显示家长菜单,未泄露教师/管理员菜单")
report_lines.append(f"- 子女详情页邮箱掩码、作业摘要、成绩趋势、今日课表等功能完整")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append("## 三、页面测试详情")
report_lines.append("")
# 按类别分组
by_category = {}
for key, page_result in results["pages"].items():
cat = page_result.get("category", "Other")
by_category.setdefault(cat, []).append(page_result)
for category in sorted(by_category.keys()):
pages = by_category[category]
report_lines.append(f"### {category}")
report_lines.append("")
report_lines.append("| 状态 | 路由 | HTTP | 结果 | 备注 |")
report_lines.append("|------|------|------|------|------|")
for p in pages:
status_icon = "" if p["status"] == "passed" else "⚠️" if p["status"] == "warning" else ""
notes = []
if p.get("redirect_url"):
notes.append(f"重定向: `{p['redirect_url'].replace(BASE_URL, '')}`")
if p.get("errors"):
# 截断过长的错误信息
for err in p["errors"][:2]:
# 只取第一行,避免堆栈跟踪污染
short_err = err.split("\n")[0][:150]
notes.append(f"错误: {short_err}")
if p.get("warnings"):
for w in p["warnings"][:2]:
short_w = w.split("\n")[0][:150]
notes.append(f"警告: {short_w}")
if p.get("content_checks"):
notes.extend(p["content_checks"][:2])
note_str = "<br>".join(notes) if notes else "-"
short_route = p.get("route", p["url"].replace(BASE_URL, ""))
report_lines.append(
f"| {status_icon} | `{short_route}` | {p['http_status'] or '-'} | {p['status']} | {note_str} |"
)
report_lines.append("")
# 功能检查详情
if results["functional_checks"]:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 四、功能完整性检查")
report_lines.append("")
report_lines.append("| 状态 | 检查项 | 期望 | 实际 |")
report_lines.append("|------|--------|------|------|")
for c in results["functional_checks"]:
icon = "" if c["passed"] else ""
report_lines.append(f"| {icon} | {c['name']} | {c['expected']} | {c['actual']} |")
report_lines.append("")
# 安全检查详情
if results["security_checks"]:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 五、安全检查")
report_lines.append("")
report_lines.append("| 状态 | 检查项 | 期望 | 实际 |")
report_lines.append("|------|--------|------|------|")
for c in results["security_checks"]:
icon = "" if c["passed"] else ""
report_lines.append(f"| {icon} | {c['name']} | {c['expected']} | {c['actual']} |")
report_lines.append("")
# 失败页面汇总
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
if failed_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 六、失败页面详情")
report_lines.append("")
for key, p in failed_pages:
report_lines.append(f"### ❌ `{p.get('route', p['url'])}`")
report_lines.append("")
report_lines.append(f"- **分类**: {p['category']}")
report_lines.append(f"- **HTTP状态**: {p['http_status']}")
if p.get("redirect_url"):
report_lines.append(f"- **重定向**: `{p['redirect_url']}`")
if p.get("errors"):
report_lines.append(f"- **错误信息**:")
for err in p["errors"]:
# 截断过长的错误信息,只取前 300 字符
short_err = err[:300]
if len(err) > 300:
short_err += "...(已截断)"
report_lines.append(f" - {short_err}")
report_lines.append("")
# 警告页面
warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"]
if warning_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 七、警告页面")
report_lines.append("")
for key, p in warning_pages:
report_lines.append(f"### ⚠️ `{p.get('route', p['url'])}`")
report_lines.append("")
report_lines.append(f"- **分类**: {p['category']}")
if p.get("warnings"):
for w in p["warnings"]:
report_lines.append(f" - {w}")
report_lines.append("")
# 失败的功能检查
failed_func = [c for c in results["functional_checks"] if not c["passed"]]
failed_sec = [c for c in results["security_checks"] if not c["passed"]]
if failed_func or failed_sec:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 八、未通过的功能/安全检查")
report_lines.append("")
if failed_func:
report_lines.append("### 功能检查失败项")
report_lines.append("")
for c in failed_func:
report_lines.append(f"- **{c['name']}**")
report_lines.append(f" - 期望: {c['expected']}")
report_lines.append(f" - 实际: {c['actual']}")
report_lines.append("")
if failed_sec:
report_lines.append("### 安全检查失败项")
report_lines.append("")
for c in failed_sec:
report_lines.append(f"- **{c['name']}**")
report_lines.append(f" - 期望: {c['expected']}")
report_lines.append(f" - 实际: {c['actual']}")
report_lines.append("")
# 测试覆盖范围说明
report_lines.append("---")
report_lines.append("")
report_lines.append("## 九、测试覆盖范围")
report_lines.append("")
report_lines.append("### 9.1 家长端路由(来自 `src/modules/layout/config/navigation.ts`")
report_lines.append("")
report_lines.append("- `/parent/dashboard` - 家长仪表盘")
report_lines.append("- `/parent/grades` - 子女成绩聚合页")
report_lines.append("- `/parent/attendance` - 子女考勤聚合页")
report_lines.append("- `/parent/children/[studentId]` - 单个子女详情页")
report_lines.append("- `/announcements` - 公告列表(家长有 `ANNOUNCEMENT_READ` 权限)")
report_lines.append("- `/messages` - 消息列表(家长有 `MESSAGE_READ` 权限)")
report_lines.append("- `/messages/compose` - 写消息")
report_lines.append("- `/profile` - 个人资料")
report_lines.append("- `/settings` - 设置")
report_lines.append("- `/settings/security` - 安全设置")
report_lines.append("")
report_lines.append("### 9.2 跨角色访问保护测试")
report_lines.append("")
report_lines.append("家长账号尝试访问以下路由,应被 `src/proxy.ts` 重定向回 `/parent/dashboard`")
report_lines.append("- `/admin/*` - 管理员页面(需 `SCHOOL_MANAGE` 权限)")
report_lines.append("- `/teacher/*` - 教师页面(需 `EXAM_READ` 权限,家长虽有此权限但路由前缀仍会拦截教师专属页面)")
report_lines.append("- `/student/*` - 学生页面(需 `HOMEWORK_SUBMIT` 权限)")
report_lines.append("- `/management/*` - 管理页面(需 `GRADE_MANAGE` 权限)")
report_lines.append("")
report_lines.append("### 9.3 功能完整性检查项")
report_lines.append("")
report_lines.append("- 仪表盘标题、问候语、快捷入口Grades/Attendance/Announcements、子女卡片、统计计数")
report_lines.append("- 子女详情页返回按钮、姓名标题、邮箱掩码、作业摘要、成绩趋势、今日课表、View all 链接")
report_lines.append("- 侧边栏导航:仅显示家长相关菜单,不显示教师/管理员菜单")
report_lines.append("- 跨家庭隔离:访问非关联子女应被拒绝")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append(f"*报告自动生成于 {results['test_date']}*")
return "\n".join(report_lines)
def main():
# 运行测试
run_all_tests()
# 生成报告
report = generate_report()
# 写入文件
output_dir = os.path.join(os.path.dirname(__file__), "..", "..", "bugs")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "parent_web_test.md")
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
# 同时输出JSON便于调试
json_path = output_path.replace(".md", ".json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2, default=str)
print(f"📄 JSON数据已写入: {json_path}")
if __name__ == "__main__":
main()