feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
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
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
主要变更: - 新增 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)
This commit is contained in:
989
tests/webapp/parent_full_test.py
Normal file
989
tests/webapp/parent_full_test.py
Normal file
@@ -0,0 +1,989 @@
|
||||
"""
|
||||
家长端全功能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()
|
||||
Reference in New Issue
Block a user