mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(auth): require verified email for approval
This commit is contained in:
@@ -17,6 +17,9 @@ from backend.migration_steps.email_notification_settings import (
|
||||
apply as apply_email_notification_settings,
|
||||
)
|
||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
||||
from backend.migration_steps.user_email_verification import (
|
||||
apply as apply_user_email_verification,
|
||||
)
|
||||
|
||||
|
||||
def test_pending_migration_is_recorded_and_skipped_on_next_run() -> None:
|
||||
@@ -79,11 +82,13 @@ def test_existing_migrations_are_registered_in_order() -> None:
|
||||
"2026050401_add_account_lockout",
|
||||
"2026050402_add_task_thread_id",
|
||||
"2026050501_add_email_notification_settings",
|
||||
"2026050601_add_user_email_verification",
|
||||
]
|
||||
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
||||
"backend.migration_steps.account_lockout",
|
||||
"backend.migration_steps.task_thread_id",
|
||||
"backend.migration_steps.email_notification_settings",
|
||||
"backend.migration_steps.user_email_verification",
|
||||
]
|
||||
|
||||
|
||||
@@ -105,9 +110,45 @@ def test_email_notification_settings_migration_creates_settings_table() -> None:
|
||||
"smtp_use_ssl",
|
||||
"notify_token_expiring",
|
||||
"notify_check_in_success",
|
||||
"require_admin_approval_for_registration",
|
||||
"warn_unverified_email_before_approval",
|
||||
} <= columns
|
||||
|
||||
|
||||
def test_user_email_verification_migration_adds_user_fields_and_policy_flags() -> None:
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("CREATE TABLE users (id INTEGER PRIMARY KEY, alias VARCHAR(50))"))
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE email_notification_settings ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"notify_token_expiring BOOLEAN NOT NULL DEFAULT 1, "
|
||||
"notify_check_in_success BOOLEAN NOT NULL DEFAULT 1"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
apply_user_email_verification(conn)
|
||||
|
||||
user_columns = {row[1] for row in conn.execute(text("PRAGMA table_info(users)"))}
|
||||
settings_columns = {
|
||||
row[1] for row in conn.execute(text("PRAGMA table_info(email_notification_settings)"))
|
||||
}
|
||||
|
||||
assert {
|
||||
"email_verified_at",
|
||||
"email_verification_code_hash",
|
||||
"email_verification_expires_at",
|
||||
} <= user_columns
|
||||
assert {
|
||||
"require_admin_approval_for_registration",
|
||||
"warn_unverified_email_before_approval",
|
||||
} <= settings_columns
|
||||
|
||||
|
||||
def test_account_lockout_migration_adds_missing_user_fields() -> None:
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
||||
assert updated.smtp_sender_password == "new-secret"
|
||||
assert updated.notify_token_expiring is False
|
||||
assert updated.notify_check_in_success is False
|
||||
assert updated.require_admin_approval_for_registration is True
|
||||
assert updated.warn_unverified_email_before_approval is True
|
||||
|
||||
EmailSettingsService.update_settings(
|
||||
session,
|
||||
@@ -84,6 +86,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
||||
smtp_use_ssl=True,
|
||||
notify_token_expiring=False,
|
||||
notify_check_in_success=False,
|
||||
require_admin_approval_for_registration=False,
|
||||
warn_unverified_email_before_approval=False,
|
||||
clear_smtp_sender_password=True,
|
||||
),
|
||||
)
|
||||
@@ -91,6 +95,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
||||
cleared = EmailSettingsService.get_snapshot(session)
|
||||
assert cleared.smtp_sender_password in ("", None)
|
||||
assert cleared.has_smtp_sender_password is False
|
||||
assert cleared.require_admin_approval_for_registration is False
|
||||
assert cleared.warn_unverified_email_before_approval is False
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
@@ -195,3 +201,14 @@ def test_email_settings_update_validates_sender_email() -> None:
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
)
|
||||
|
||||
|
||||
def test_registration_approval_policy_defaults_enabled() -> None:
|
||||
engine, _, session = make_session()
|
||||
|
||||
snapshot = EmailSettingsService.get_snapshot(session)
|
||||
|
||||
assert snapshot.require_admin_approval_for_registration is True
|
||||
assert snapshot.warn_unverified_email_before_approval is True
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -213,9 +213,9 @@ def test_user_approval_notification_uses_template_login_link(monkeypatch) -> Non
|
||||
|
||||
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
||||
|
||||
assert (
|
||||
EmailService.notify_user_approved(User(alias="Alice", email="alice@example.test")) is True
|
||||
)
|
||||
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"])
|
||||
|
||||
@@ -65,6 +65,28 @@ def test_frontend_admin_api_covers_email_settings() -> None:
|
||||
assert "/api/admin/email_settings" in api
|
||||
|
||||
|
||||
def test_frontend_covers_pending_email_verification_flow() -> None:
|
||||
api = (SRC_ROOT / "api" / "index.ts").read_text(encoding="utf-8")
|
||||
pending = (SRC_ROOT / "views" / "PendingApprovalView.vue").read_text(encoding="utf-8")
|
||||
|
||||
assert "/api/users/me/email" in api
|
||||
assert "/api/users/me/email/verify" in api
|
||||
assert "自动吊销" in pending
|
||||
assert "验证码" in pending
|
||||
|
||||
|
||||
def test_frontend_admin_approval_policy_warnings_are_visible() -> None:
|
||||
admin_users = (SRC_ROOT / "views" / "admin" / "AdminUsersView.vue").read_text(encoding="utf-8")
|
||||
email_settings = (SRC_ROOT / "views" / "admin" / "AdminEmailSettingsView.vue").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "allow_unverified_email" in admin_users
|
||||
assert "邮箱未验证" in admin_users
|
||||
assert "require_admin_approval_for_registration" in email_settings
|
||||
assert "关闭管理员审批" in email_settings
|
||||
|
||||
|
||||
def test_frontend_replaces_starter_component() -> None:
|
||||
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from backend.api import users as users_api
|
||||
from backend.dependencies import get_current_user, get_db
|
||||
from backend.models import Base, User
|
||||
from backend.schemas.email_settings import EmailNotificationSettingsUpdate
|
||||
from backend.schemas.user import UserUpdate
|
||||
from backend.services import email_settings_service
|
||||
from backend.services.admin_service import AdminService
|
||||
from backend.services.email_service import EmailService
|
||||
from backend.services.email_settings_service import EmailSettingsService
|
||||
from backend.services.user_service import UserService
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
session = session_factory()
|
||||
return engine, session_factory, session
|
||||
|
||||
|
||||
def add_user(session, alias: str = "Alice", email: str | None = None) -> User:
|
||||
user = User(alias=alias, email=email, role="user", is_approved=False, jwt_exp="0")
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def test_current_user_can_set_and_verify_email(monkeypatch) -> None:
|
||||
engine, _, session = make_session()
|
||||
user = add_user(session)
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
def fake_send_verification_code(to_email: str, alias: str, code: str) -> bool:
|
||||
sent["to_email"] = to_email
|
||||
sent["alias"] = alias
|
||||
sent["code"] = code
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(EmailService, "send_email_verification_code", fake_send_verification_code)
|
||||
|
||||
result = UserService.set_email_for_verification(user.id, " Alice@Example.COM ", session)
|
||||
|
||||
assert result.email == "Alice@example.com"
|
||||
assert result.email_verified is False
|
||||
assert sent["to_email"] == "Alice@example.com"
|
||||
assert isinstance(sent["code"], str)
|
||||
assert sent["code"] != ""
|
||||
|
||||
verified = UserService.verify_email_code(user.id, str(sent["code"]), session)
|
||||
|
||||
assert verified.email_verified is True
|
||||
assert verified.email_verified_at is not None
|
||||
refreshed = session.get(User, user.id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.email_verification_code_hash is None
|
||||
assert refreshed.email_verification_expires_at is None
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_email_verification_rejects_wrong_or_expired_code(monkeypatch) -> None:
|
||||
engine, _, session = make_session()
|
||||
user = add_user(session)
|
||||
sent: dict[str, str] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
EmailService,
|
||||
"send_email_verification_code",
|
||||
lambda to_email, alias, code: sent.setdefault("code", code) or True,
|
||||
)
|
||||
|
||||
UserService.set_email_for_verification(user.id, "alice@example.com", session)
|
||||
|
||||
with pytest.raises(ValueError, match="验证码"):
|
||||
UserService.verify_email_code(user.id, "000000", session)
|
||||
|
||||
refreshed = session.get(User, user.id)
|
||||
assert refreshed is not None
|
||||
refreshed.email_verification_expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="验证码"):
|
||||
UserService.verify_email_code(user.id, sent["code"], session)
|
||||
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_changing_email_clears_verification_state(monkeypatch) -> None:
|
||||
engine, _, session = make_session()
|
||||
user = add_user(session, email="old@example.com")
|
||||
user.email_verified_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
monkeypatch.setattr(EmailService, "send_email_verification_code", lambda *args: True)
|
||||
|
||||
result = UserService.set_email_for_verification(user.id, "new@example.com", session)
|
||||
|
||||
assert result.email == "new@example.com"
|
||||
assert result.email_verified is False
|
||||
refreshed = session.get(User, user.id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.email_verified_at is None
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_approval_requires_warning_then_allows_override(monkeypatch) -> None:
|
||||
engine, session_factory, session = make_session()
|
||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||
user = add_user(session)
|
||||
|
||||
first = AdminService.approve_user(user.id, session)
|
||||
assert first["success"] is False
|
||||
assert first["requires_override"] is True
|
||||
assert first["warning_code"] == "UNVERIFIED_EMAIL"
|
||||
|
||||
second = AdminService.approve_user(user.id, session, allow_unverified_email=True)
|
||||
assert second["success"] is True
|
||||
assert second["warning_code"] == "UNVERIFIED_EMAIL"
|
||||
assert session.get(User, user.id).is_approved is True
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_approval_warning_can_be_disabled(monkeypatch) -> None:
|
||||
engine, session_factory, session = make_session()
|
||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||
EmailSettingsService.update_settings(
|
||||
session,
|
||||
EmailNotificationSettingsUpdate(
|
||||
smtp_server="smtp.example.test",
|
||||
smtp_port=465,
|
||||
smtp_sender_email="mailer@example.com",
|
||||
smtp_use_ssl=True,
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
require_admin_approval_for_registration=True,
|
||||
warn_unverified_email_before_approval=False,
|
||||
),
|
||||
)
|
||||
user = add_user(session)
|
||||
|
||||
result = AdminService.approve_user(user.id, session)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "requires_override" not in result
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_approval_notification_requires_verified_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
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
||||
|
||||
unverified = User(alias="Alice", email="alice@example.com")
|
||||
assert EmailService.notify_user_approved(unverified) is False
|
||||
assert sent == {}
|
||||
|
||||
verified = User(alias="Bob", email="bob@example.com")
|
||||
verified.email_verified_at = datetime.now(timezone.utc)
|
||||
assert EmailService.notify_user_approved(verified) is True
|
||||
assert sent["to_emails"] == ["bob@example.com"]
|
||||
|
||||
|
||||
def test_registration_approval_policy_controls_new_user_approval(monkeypatch) -> None:
|
||||
engine, session_factory, session = make_session()
|
||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||
EmailSettingsService.update_settings(
|
||||
session,
|
||||
EmailNotificationSettingsUpdate(
|
||||
smtp_server="smtp.example.test",
|
||||
smtp_port=465,
|
||||
smtp_sender_email="mailer@example.com",
|
||||
smtp_use_ssl=True,
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
require_admin_approval_for_registration=False,
|
||||
warn_unverified_email_before_approval=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert EmailSettingsService.is_registration_approval_required() is False
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_admin_update_warns_when_approval_changes_email_before_approval(monkeypatch) -> None:
|
||||
engine, session_factory, session = make_session()
|
||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||
user = add_user(session, email="old@example.com")
|
||||
user.email_verified_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
warning = AdminService.approval_warning_for_user(
|
||||
user,
|
||||
allow_unverified_email=False,
|
||||
next_email="new@example.com",
|
||||
)
|
||||
|
||||
assert warning is not None
|
||||
assert warning["requires_override"] is True
|
||||
|
||||
UserService.update_user(
|
||||
user.id,
|
||||
UserUpdate(email="new@example.com", is_approved=True, allow_unverified_email=True),
|
||||
session,
|
||||
)
|
||||
|
||||
refreshed = session.get(User, user.id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.email == "new@example.com"
|
||||
assert refreshed.email_verified is False
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_admin_update_route_returns_unverified_email_warning(monkeypatch) -> None:
|
||||
engine, session_factory, session = make_session()
|
||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||
admin = User(alias="Admin", role="admin", is_approved=True, jwt_exp="0")
|
||||
user = User(alias="Alice", role="user", is_approved=False, jwt_exp="0")
|
||||
session.add_all([admin, user])
|
||||
session.commit()
|
||||
session.refresh(admin)
|
||||
session.refresh(user)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(users_api.router, prefix="/api/users")
|
||||
|
||||
def override_get_db():
|
||||
yield session
|
||||
|
||||
async def override_get_current_user() -> User:
|
||||
return admin
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.put(f"/api/users/{user.id}", json={"is_approved": True})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"success": False,
|
||||
"message": "用户邮箱未验证,确认后仍可继续审批",
|
||||
"user_id": None,
|
||||
"requires_override": True,
|
||||
"warning_code": "UNVERIFIED_EMAIL",
|
||||
}
|
||||
assert session.get(User, user.id).is_approved is False
|
||||
session.close()
|
||||
engine.dispose()
|
||||
Reference in New Issue
Block a user