mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
318 lines
10 KiB
Python
318 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from backend.models import Base, User
|
|
from backend.services.email_service import EmailService
|
|
from backend.services.email_templates import (
|
|
EmailTemplate,
|
|
EmailTemplateRenderError,
|
|
SafeHtml,
|
|
render_email_template,
|
|
)
|
|
|
|
|
|
def test_registration_template_escapes_dynamic_values_and_uses_shared_layout() -> None:
|
|
html = render_email_template(
|
|
EmailTemplate.NEW_USER_REGISTRATION,
|
|
{
|
|
"user_alias": "<Alice>",
|
|
"user_id": "42",
|
|
"created_time": "2026-05-05 10:00:00",
|
|
"admin_url": "https://example.test/admin/users?x=1&y=2",
|
|
},
|
|
)
|
|
|
|
assert '<div class="email-shell"' in html
|
|
assert "新用户注册通知" in html
|
|
assert "<Alice>" in html
|
|
assert "<Alice>" not in html
|
|
assert "https://example.test/admin/users?x=1&y=2" in html
|
|
|
|
|
|
def test_template_renderer_fails_clearly_when_context_is_missing() -> None:
|
|
with pytest.raises(EmailTemplateRenderError, match="created_time"):
|
|
render_email_template(
|
|
EmailTemplate.NEW_USER_REGISTRATION,
|
|
{
|
|
"user_alias": "Alice",
|
|
"user_id": "42",
|
|
"admin_url": "https://example.test/admin/users",
|
|
},
|
|
)
|
|
|
|
|
|
def test_template_renderer_allows_trusted_html_snippets() -> None:
|
|
html = render_email_template(
|
|
EmailTemplate.USER_REJECTED,
|
|
{
|
|
"user_alias": "Alice",
|
|
"processed_time": "2026-05-05 10:00:00",
|
|
"reason_section": SafeHtml("<p><strong>拒绝原因:</strong>资料不完整</p>"),
|
|
},
|
|
)
|
|
|
|
assert "<strong>拒绝原因:</strong>资料不完整" in html
|
|
|
|
|
|
def test_template_renderer_defaults_footer_year_to_current_year() -> None:
|
|
html = render_email_template(
|
|
EmailTemplate.TOKEN_EXPIRED,
|
|
{
|
|
"user_alias": "Alice",
|
|
"login_url": "https://example.test/login",
|
|
},
|
|
)
|
|
|
|
assert str(datetime.now().year) in html
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "context", "expected"),
|
|
[
|
|
(
|
|
EmailTemplate.USER_APPROVED,
|
|
{
|
|
"user_alias": "Alice",
|
|
"user_role": "user",
|
|
"created_time": "2026-05-01 09:00:00",
|
|
"approved_time": "2026-05-05 10:00:00",
|
|
"login_url": "https://example.test/login",
|
|
},
|
|
"账户审批通过",
|
|
),
|
|
(
|
|
EmailTemplate.USER_REJECTED,
|
|
{
|
|
"user_alias": "Alice",
|
|
"processed_time": "2026-05-05 10:00:00",
|
|
"reason_section": "<p><strong>拒绝原因:</strong>资料不完整</p>",
|
|
},
|
|
"未通过",
|
|
),
|
|
(
|
|
EmailTemplate.TOKEN_EXPIRING,
|
|
{
|
|
"user_alias": "Alice",
|
|
"minutes_left": "25",
|
|
},
|
|
"25 分钟",
|
|
),
|
|
(
|
|
EmailTemplate.TOKEN_EXPIRED,
|
|
{
|
|
"user_alias": "Alice",
|
|
"login_url": "https://example.test/login",
|
|
},
|
|
"登录凭证已过期",
|
|
),
|
|
(
|
|
EmailTemplate.CHECK_IN_RESULT,
|
|
{
|
|
"user_alias": "Alice",
|
|
"executed_time": "2026-05-05 10:00:00",
|
|
"thread_id": "thread-1",
|
|
"status_text": "失败",
|
|
"status_color": "#b91c1c",
|
|
"message_row": "<tr><td>失败原因</td><td>Token 失效</td></tr>",
|
|
"guidance_section": "<p>请刷新 Token。</p>",
|
|
},
|
|
"thread-1",
|
|
),
|
|
],
|
|
)
|
|
def test_supported_notification_templates_render_shared_layout(
|
|
template: EmailTemplate, context: dict[str, str], expected: str
|
|
) -> None:
|
|
html = render_email_template(template, context)
|
|
|
|
assert '<div class="email-shell"' in html
|
|
assert expected in html
|
|
assert "接龙自动打卡系统" in html
|
|
|
|
|
|
@pytest.fixture()
|
|
def db_session():
|
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
|
Base.metadata.create_all(bind=engine)
|
|
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
session = Session()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
def add_user(db_session, alias: str, email: str | None = None, role: str = "user") -> User:
|
|
user = User(alias=alias, email=email, role=role)
|
|
db_session.add(user)
|
|
db_session.commit()
|
|
db_session.refresh(user)
|
|
return user
|
|
|
|
|
|
def test_new_user_registration_notification_uses_template_and_admin_recipients(
|
|
db_session, monkeypatch
|
|
) -> None:
|
|
admin = add_user(db_session, "admin", "admin@example.test", "admin")
|
|
user = add_user(db_session, "<Alice>", "alice@example.test")
|
|
sent: dict[str, object] = {}
|
|
|
|
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
|
|
sent["to_emails"] = to_emails
|
|
sent["subject"] = subject
|
|
sent["body_html"] = body_html
|
|
return True
|
|
|
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
|
|
|
assert EmailService.notify_new_user_registration(user, db_session) is True
|
|
|
|
assert sent["to_emails"] == [admin.email]
|
|
assert "新用户注册通知" in str(sent["subject"])
|
|
assert "<Alice>" in str(sent["body_html"])
|
|
assert "<Alice>" not in str(sent["body_html"])
|
|
assert "/admin/users" in str(sent["body_html"])
|
|
|
|
|
|
def test_user_rejection_notification_escapes_reason_and_skips_missing_email(monkeypatch) -> None:
|
|
sent: dict[str, object] = {}
|
|
|
|
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
|
|
sent["to_emails"] = to_emails
|
|
sent["subject"] = subject
|
|
sent["body_html"] = body_html
|
|
return True
|
|
|
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
|
|
|
assert EmailService.notify_user_rejected(User(alias="NoMail"), "ignored") is False
|
|
assert sent == {}
|
|
|
|
user = User(alias="Bob", email="bob@example.test")
|
|
assert EmailService.notify_user_rejected(user, "<script>alert(1)</script>") is True
|
|
|
|
assert sent["to_emails"] == ["bob@example.test"]
|
|
assert "账户审批结果" in str(sent["subject"])
|
|
assert "<script>alert(1)</script>" in str(sent["body_html"])
|
|
assert "<script>" not in str(sent["body_html"])
|
|
|
|
|
|
def test_user_approval_notification_uses_template_login_link(monkeypatch) -> None:
|
|
sent: dict[str, object] = {}
|
|
|
|
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
|
|
sent["to_emails"] = to_emails
|
|
sent["subject"] = subject
|
|
sent["body_html"] = body_html
|
|
return True
|
|
|
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
|
|
|
user = User(alias="Alice", email="alice@example.test")
|
|
user.email_verified_at = datetime.now(timezone.utc)
|
|
assert EmailService.notify_user_approved(user) is True
|
|
|
|
assert sent["to_emails"] == ["alice@example.test"]
|
|
assert "账户审批通过" in str(sent["subject"])
|
|
assert "立即登录" in str(sent["body_html"])
|
|
assert "/login" in str(sent["body_html"])
|
|
|
|
|
|
def test_token_notifications_use_template_links(monkeypatch) -> None:
|
|
sent_messages: list[dict[str, object]] = []
|
|
|
|
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
|
|
sent_messages.append(
|
|
{
|
|
"to_emails": to_emails,
|
|
"subject": subject,
|
|
"body_html": body_html,
|
|
}
|
|
)
|
|
return True
|
|
|
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
|
monkeypatch.setattr(
|
|
"backend.services.email_service.parse_jwt_exp",
|
|
lambda jwt_exp: 1000,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"backend.services.email_service.minutes_until_expiry",
|
|
lambda exp_timestamp: 25,
|
|
raising=False,
|
|
)
|
|
|
|
user = User(alias="Alice", email="alice@example.test")
|
|
assert EmailService.notify_token_expiring(user, "1000") is True
|
|
assert EmailService.notify_token_expired(user) is True
|
|
|
|
assert len(sent_messages) == 2
|
|
assert "登录凭证即将过期" in str(sent_messages[0]["subject"])
|
|
assert "25 分钟" in str(sent_messages[0]["body_html"])
|
|
assert "立即登录刷新" not in str(sent_messages[0]["body_html"])
|
|
assert "登录凭证已过期" in str(sent_messages[1]["subject"])
|
|
assert "立即登录刷新" in str(sent_messages[1]["body_html"])
|
|
|
|
|
|
def test_check_in_result_notification_preserves_token_error_guidance(monkeypatch) -> None:
|
|
sent: dict[str, object] = {}
|
|
|
|
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
|
|
sent["to_emails"] = to_emails
|
|
sent["subject"] = subject
|
|
sent["body_html"] = body_html
|
|
return True
|
|
|
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
|
|
|
user = User(alias="Alice", email="alice@example.test")
|
|
assert (
|
|
EmailService.notify_check_in_result(
|
|
user,
|
|
{"thread_id": "thread-1<script>"},
|
|
False,
|
|
"Token <expired>",
|
|
)
|
|
is True
|
|
)
|
|
|
|
assert sent["to_emails"] == ["alice@example.test"]
|
|
assert "打卡❌ 失败" in str(sent["subject"])
|
|
assert "thread-1<script>" in str(sent["body_html"])
|
|
assert "Token <expired>" in str(sent["body_html"])
|
|
assert "打卡凭证已过期" in str(sent["body_html"])
|
|
assert "立即登录刷新" in str(sent["body_html"])
|
|
assert "/dashboard" in str(sent["body_html"])
|
|
|
|
|
|
def test_failed_check_in_notification_includes_dashboard_button(monkeypatch) -> None:
|
|
sent: dict[str, object] = {}
|
|
|
|
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
|
|
sent["to_emails"] = to_emails
|
|
sent["subject"] = subject
|
|
sent["body_html"] = body_html
|
|
return True
|
|
|
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
|
|
|
assert (
|
|
EmailService.notify_check_in_result(
|
|
User(alias="Alice", email="alice@example.test"),
|
|
{"thread_id": "thread-2"},
|
|
False,
|
|
"网络错误",
|
|
)
|
|
is True
|
|
)
|
|
|
|
assert "打卡❌ 失败" in str(sent["subject"])
|
|
assert "立即登录刷新" in str(sent["body_html"])
|
|
assert "/dashboard" in str(sent["body_html"])
|