| import asyncio |
| import importlib |
| import sys |
| import types |
|
|
| from sqlalchemy import create_engine |
| from sqlalchemy.orm import sessionmaker |
| from starlette.requests import Request |
|
|
|
|
| def _create_db(): |
| from landppt.database.models import ( |
| Base, |
| CreditTransaction, |
| InviteCode, |
| InviteCodeUsage, |
| UserConfig, |
| User, |
| ) |
|
|
| engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) |
| Base.metadata.create_all( |
| engine, |
| tables=[ |
| User.__table__, |
| CreditTransaction.__table__, |
| InviteCode.__table__, |
| InviteCodeUsage.__table__, |
| UserConfig.__table__, |
| ], |
| ) |
| SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) |
| return SessionLocal() |
|
|
|
|
| def _create_invite(db, *, code: str, channel: str, credits_amount: int = 0, max_uses: int = 1): |
| from landppt.database.models import InviteCode |
|
|
| invite = InviteCode( |
| code=code, |
| channel=channel, |
| credits_amount=credits_amount, |
| max_uses=max_uses, |
| used_count=0, |
| is_active=True, |
| created_by=1, |
| ) |
| db.add(invite) |
| db.commit() |
| db.refresh(invite) |
| return invite |
|
|
|
|
| def _create_user(db, username: str, email: str): |
| from landppt.database.models import User |
|
|
| user = User(username=username, email=email, is_admin=False, is_active=True, credits_balance=100) |
| user.set_password("pw") |
| db.add(user) |
| db.commit() |
| db.refresh(user) |
| return user |
|
|
|
|
| def _set_community_setting(db, key: str, value: str, value_type: str = "boolean"): |
| from landppt.database.models import UserConfig |
|
|
| item = UserConfig( |
| user_id=None, |
| config_key=key, |
| config_value=value, |
| config_type=value_type, |
| category="community_ops", |
| ) |
| db.add(item) |
| db.commit() |
| db.refresh(item) |
| return item |
|
|
|
|
| def _make_request(path: str = "/auth/api/send-code"): |
| return Request( |
| { |
| "type": "http", |
| "method": "POST", |
| "path": path, |
| "headers": [], |
| "client": ("127.0.0.1", 12345), |
| } |
| ) |
|
|
|
|
| def _import_auth_routes(monkeypatch): |
| import fastapi.templating |
|
|
| class _DummyTemplates: |
| def __init__(self, *args, **kwargs): |
| self.env = types.SimpleNamespace(globals={}) |
|
|
| def TemplateResponse(self, *args, **kwargs): |
| raise AssertionError("TemplateResponse should not be used in this test") |
|
|
| monkeypatch.setattr(fastapi.templating, "Jinja2Templates", _DummyTemplates) |
| sys.modules.pop("landppt.auth.routes", None) |
| return importlib.import_module("landppt.auth.routes") |
|
|
|
|
| def test_apply_invite_code_records_reward_usage_and_channel(): |
| from landppt.database.models import CreditTransaction, InviteCodeUsage |
| from landppt.services.community_service import community_service |
|
|
| db = _create_db() |
| try: |
| user = _create_user(db, "mail_user", "mail@example.com") |
| invite = _create_invite(db, code="MAILCODE1", channel="mail", credits_amount=25, max_uses=2) |
|
|
| community_service.apply_invite_code_to_user(db, user, invite, "mail") |
| db.commit() |
| db.refresh(user) |
| db.refresh(invite) |
|
|
| usage = db.query(InviteCodeUsage).filter(InviteCodeUsage.user_id == user.id).one() |
| tx = db.query(CreditTransaction).filter(CreditTransaction.user_id == user.id).one() |
|
|
| assert user.registration_channel == "mail" |
| assert user.invite_code_id == invite.id |
| assert user.credits_balance == 125 |
| assert invite.used_count == 1 |
| assert usage.invite_code_id == invite.id |
| assert usage.credits_granted == 25 |
| assert tx.transaction_type == "invite_reward" |
| assert tx.amount == 25 |
| finally: |
| db.close() |
|
|
|
|
| def test_validate_invite_code_rejects_wrong_channel(): |
| from landppt.services.community_service import community_service |
|
|
| db = _create_db() |
| try: |
| _create_user(db, "admin", "admin@example.com") |
| _create_invite(db, code="GHCODE1", channel="github", credits_amount=10, max_uses=1) |
|
|
| try: |
| community_service.validate_invite_code(db, "GHCODE1", "mail") |
| except ValueError as exc: |
| assert "仅限" in str(exc) |
| else: |
| raise AssertionError("Expected validate_invite_code to reject mismatched channel") |
| finally: |
| db.close() |
|
|
|
|
| def test_validate_invite_code_accepts_universal_channel_for_multiple_registration_methods(): |
| from landppt.services.community_service import community_service |
|
|
| db = _create_db() |
| try: |
| _create_user(db, "admin", "admin@example.com") |
| invite = _create_invite(db, code="UNIVCODE1", channel="universal", credits_amount=10, max_uses=2) |
|
|
| mail_invite = community_service.validate_invite_code(db, invite.code, "mail") |
| github_invite = community_service.validate_invite_code(db, invite.code, "github") |
| linuxdo_invite = community_service.validate_invite_code(db, invite.code, "linuxdo") |
|
|
| assert mail_invite.id == invite.id |
| assert github_invite.id == invite.id |
| assert linuxdo_invite.id == invite.id |
| finally: |
| db.close() |
|
|
|
|
| def test_apply_universal_invite_code_records_actual_registration_channel(): |
| from landppt.database.models import InviteCodeUsage |
| from landppt.services.community_service import community_service |
|
|
| db = _create_db() |
| try: |
| user = _create_user(db, "universal_user", "universal@example.com") |
| invite = _create_invite(db, code="UNIVMAIL1", channel="universal", credits_amount=18, max_uses=3) |
|
|
| community_service.apply_invite_code_to_user(db, user, invite, "mail") |
| db.commit() |
| db.refresh(user) |
| db.refresh(invite) |
|
|
| usage = db.query(InviteCodeUsage).filter(InviteCodeUsage.user_id == user.id).one() |
|
|
| assert user.registration_channel == "mail" |
| assert usage.channel == "mail" |
| assert user.invite_code_id == invite.id |
| assert invite.used_count == 1 |
| finally: |
| db.close() |
|
|
|
|
| def test_api_send_code_register_rejects_invalid_invite_before_email_send(monkeypatch): |
| from landppt.services import email_service, turnstile_service |
| from landppt.utils import rate_limiter |
|
|
| auth_routes = _import_auth_routes(monkeypatch) |
|
|
| db = _create_db() |
| sent = [] |
|
|
| async def fake_send_verification_email(email, code_type): |
| sent.append((email, code_type)) |
| return True, "ok" |
|
|
| async def fake_rate_limit_hit(**kwargs): |
| return True, None, None |
|
|
| try: |
| monkeypatch.setattr(email_service, "send_verification_email", fake_send_verification_email) |
| monkeypatch.setattr(turnstile_service, "is_turnstile_active", lambda: False) |
| monkeypatch.setattr(rate_limiter, "hit", fake_rate_limit_hit) |
|
|
| result = asyncio.run( |
| auth_routes.api_send_code( |
| _make_request(), |
| auth_routes.SendCodeRequest( |
| email="new@example.com", |
| code_type="register", |
| invite_code="BADCODE", |
| ), |
| db, |
| ) |
| ) |
|
|
| assert result["success"] is False |
| assert sent == [] |
| finally: |
| db.close() |
|
|
|
|
| def test_api_send_code_register_sends_email_after_valid_invite_check(monkeypatch): |
| from landppt.services import email_service, turnstile_service |
| from landppt.utils import rate_limiter |
|
|
| auth_routes = _import_auth_routes(monkeypatch) |
|
|
| db = _create_db() |
| sent = [] |
|
|
| async def fake_send_verification_email(email, code_type): |
| sent.append((email, code_type)) |
| return True, "sent" |
|
|
| async def fake_rate_limit_hit(**kwargs): |
| return True, None, None |
|
|
| try: |
| _create_user(db, "admin", "admin@example.com") |
| invite = _create_invite(db, code="MAILSEND1", channel="mail", credits_amount=0, max_uses=2) |
|
|
| monkeypatch.setattr(email_service, "send_verification_email", fake_send_verification_email) |
| monkeypatch.setattr(turnstile_service, "is_turnstile_active", lambda: False) |
| monkeypatch.setattr(rate_limiter, "hit", fake_rate_limit_hit) |
|
|
| result = asyncio.run( |
| auth_routes.api_send_code( |
| _make_request(), |
| auth_routes.SendCodeRequest( |
| email="valid@example.com", |
| code_type="register", |
| invite_code=invite.code, |
| ), |
| db, |
| ) |
| ) |
|
|
| assert result["success"] is True |
| assert sent == [("valid@example.com", "register")] |
| finally: |
| db.close() |
|
|
|
|
| def test_api_send_code_reset_rejects_when_turnstile_verification_fails(monkeypatch): |
| from landppt.services import email_service, turnstile_service |
|
|
| auth_routes = _import_auth_routes(monkeypatch) |
|
|
| db = _create_db() |
| sent = [] |
|
|
| async def fake_send_verification_email(email, code_type): |
| sent.append((email, code_type)) |
| return True, "sent" |
|
|
| async def fake_verify_turnstile(token, remote_ip): |
| return False, "请先完成人机验证" |
|
|
| try: |
| _create_user(db, "reset_user", "reset@example.com") |
|
|
| monkeypatch.setattr(email_service, "send_verification_email", fake_send_verification_email) |
| monkeypatch.setattr(turnstile_service, "is_turnstile_active", lambda: True) |
| monkeypatch.setattr(turnstile_service, "verify_turnstile", fake_verify_turnstile) |
|
|
| result = asyncio.run( |
| auth_routes.api_send_code( |
| _make_request(), |
| auth_routes.SendCodeRequest( |
| email="reset@example.com", |
| code_type="reset", |
| ), |
| db, |
| ) |
| ) |
|
|
| assert result["success"] is False |
| assert "人机验证" in result["message"] |
| assert sent == [] |
| finally: |
| db.close() |
|
|
|
|
| def test_api_send_code_reset_sends_email_after_turnstile_check(monkeypatch): |
| from landppt.services import email_service, turnstile_service |
|
|
| auth_routes = _import_auth_routes(monkeypatch) |
|
|
| db = _create_db() |
| sent = [] |
| verify_calls = [] |
|
|
| async def fake_send_verification_email(email, code_type): |
| sent.append((email, code_type)) |
| return True, "sent" |
|
|
| async def fake_verify_turnstile(token, remote_ip): |
| verify_calls.append((token, remote_ip)) |
| return True, "ok" |
|
|
| try: |
| _create_user(db, "reset_user_ok", "reset-ok@example.com") |
|
|
| monkeypatch.setattr(email_service, "send_verification_email", fake_send_verification_email) |
| monkeypatch.setattr(turnstile_service, "is_turnstile_active", lambda: True) |
| monkeypatch.setattr(turnstile_service, "verify_turnstile", fake_verify_turnstile) |
|
|
| result = asyncio.run( |
| auth_routes.api_send_code( |
| _make_request(), |
| auth_routes.SendCodeRequest( |
| email="reset-ok@example.com", |
| code_type="reset", |
| turnstile_token="turnstile-token-ok", |
| ), |
| db, |
| ) |
| ) |
|
|
| assert result["success"] is True |
| assert verify_calls == [("turnstile-token-ok", "127.0.0.1")] |
| assert sent == [("reset-ok@example.com", "reset")] |
| finally: |
| db.close() |
|
|
|
|
| def test_resolve_registration_invite_allows_blank_when_switch_disabled(): |
| from landppt.services.community_service import community_service |
|
|
| db = _create_db() |
| try: |
| _set_community_setting(db, "invite_code_required_for_registration", "false") |
| invite = community_service.resolve_registration_invite(db, "", "mail") |
| assert invite is None |
| assert community_service.is_invite_code_required_for_registration(db) is False |
| finally: |
| db.close() |
|
|
|
|
| def test_github_oauth_new_user_requires_and_consumes_invite_code(): |
| from landppt.auth.github_oauth_service import get_or_create_user_by_github |
| from landppt.core.config import app_config |
| from landppt.database.models import InviteCodeUsage |
|
|
| db = _create_db() |
| try: |
| _create_user(db, "admin", "admin@example.com") |
| invite = _create_invite(db, code="GITHUB01", channel="github", credits_amount=15, max_uses=1) |
|
|
| user, created, error = get_or_create_user_by_github( |
| db=db, |
| github_id="gh-001", |
| github_login="octocat", |
| email="octocat@example.com", |
| name="Octo Cat", |
| avatar_url=None, |
| invite_code=None, |
| ) |
| assert user is None |
| assert created is False |
| assert "邀请码" in (error or "") |
|
|
| user, created, error = get_or_create_user_by_github( |
| db=db, |
| github_id="gh-001", |
| github_login="octocat", |
| email="octocat@example.com", |
| name="Octo Cat", |
| avatar_url=None, |
| invite_code=invite.code, |
| ) |
| assert error is None |
| assert created is True |
| assert user is not None |
| assert user.registration_channel == "github" |
| assert user.credits_balance == app_config.default_credits_for_new_users + 15 |
| assert db.query(InviteCodeUsage).filter(InviteCodeUsage.user_id == user.id).count() == 1 |
| finally: |
| db.close() |
|
|
|
|
| def test_github_oauth_new_user_can_register_without_invite_when_switch_disabled(): |
| from landppt.auth.github_oauth_service import get_or_create_user_by_github |
| from landppt.core.config import app_config |
| from landppt.database.models import InviteCodeUsage |
|
|
| db = _create_db() |
| try: |
| _create_user(db, "admin", "admin@example.com") |
| _set_community_setting(db, "invite_code_required_for_registration", "false") |
|
|
| user, created, error = get_or_create_user_by_github( |
| db=db, |
| github_id="gh-002", |
| github_login="hubot", |
| email="hubot@example.com", |
| name="Hubot", |
| avatar_url=None, |
| invite_code=None, |
| ) |
|
|
| assert error is None |
| assert created is True |
| assert user is not None |
| assert user.registration_channel == "github" |
| assert user.invite_code_id is None |
| assert user.credits_balance == app_config.default_credits_for_new_users |
| assert db.query(InviteCodeUsage).filter(InviteCodeUsage.user_id == user.id).count() == 0 |
| finally: |
| db.close() |
|
|