diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7131477 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: + push: + branches: [main, dev] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Prepare configuration + run: cp config.example.toml config.toml + + - name: Run tests + run: pytest -v diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..facde79 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = tests +pythonpath = . diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0e19f67 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.5 +pytest-asyncio==0.25.3 +httpx==0.28.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3474dea --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,95 @@ +"""Shared pytest fixtures for API tests.""" + +import shutil +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from httpx import ASGITransport, AsyncClient + +WORKSPACE = Path(__file__).resolve().parents[1] +CONFIG_PATH = WORKSPACE / "config.toml" + +if not CONFIG_PATH.exists(): + shutil.copy(WORKSPACE / "config.example.toml", CONFIG_PATH) + + +@pytest.fixture +def mock_captcha_provider(): + """Minimal captcha provider for template rendering tests.""" + from src.captcha.base import CaptchaVerificationResult + + provider = MagicMock() + provider.provider_name = "hcaptcha" + provider.get_frontend_config.return_value = { + "provider": "hcaptcha", + "site_key": "test-site-key", + } + provider.verify = AsyncMock( + return_value=CaptchaVerificationResult(success=True) + ) + return provider + + +@pytest.fixture +async def client(monkeypatch, mock_captcha_provider): + """HTTP client against the FastAPI app without a real database.""" + monkeypatch.setattr("src.api.main.init_database", AsyncMock()) + monkeypatch.setattr("src.api.main.close_database", AsyncMock()) + monkeypatch.setattr( + "src.captcha.factory._captcha_provider", + mock_captcha_provider, + ) + + from src.api.main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +def make_verification_session( + *, + token: str = "test-token", + user_id: int = 123456, + chat_id: int = -100123456, + captcha_completed: bool = False, + expired: bool = False, +): + """Build a VerificationSession instance for route tests.""" + from src.database.models import VerificationSession + + expires_at = datetime.utcnow() - timedelta(hours=1) + if not expired: + expires_at = datetime.utcnow() + timedelta(hours=1) + + return VerificationSession( + token=token, + user_id=user_id, + chat_id=chat_id, + captcha_completed=captcha_completed, + created_time=datetime.utcnow(), + expires_at=expires_at, + ) + + +def make_join_request( + *, + token: str = "test-token", + user_id: int = 123456, + chat_id: int = -100123456, + request_type: str = "telegram", + status: str = "pending", +): + """Build a JoinRequest instance for route tests.""" + from src.database.models import JoinRequest + + return JoinRequest( + user_id=user_id, + chat_id=chat_id, + first_name="Test", + verification_token=token, + request_type=request_type, + status=status, + ) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..17ec254 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,12 @@ +"""Tests for health check endpoints.""" + +import pytest + + +@pytest.mark.asyncio +async def test_health_check(client): + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["service"] == "TGuard API" diff --git a/tests/test_static_pages.py b/tests/test_static_pages.py new file mode 100644 index 0000000..75dfec3 --- /dev/null +++ b/tests/test_static_pages.py @@ -0,0 +1,100 @@ +"""Tests for HTML template routes (Mini Web App pages).""" + +from unittest.mock import AsyncMock + +import pytest + +from tests.conftest import make_verification_session + + +@pytest.mark.asyncio +async def test_index_page_returns_html(client): + response = await client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "TGuard" in response.text + + +@pytest.mark.asyncio +async def test_success_page_returns_html(client): + response = await client.get("/success") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +@pytest.mark.asyncio +async def test_verify_page_with_valid_session(client, monkeypatch): + session = make_verification_session() + monkeypatch.setattr( + "src.api.routes.static_files.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.get("/verify", params={"token": "test-token"}) + + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "test-token" in response.text + assert "123456" in response.text + + +@pytest.mark.asyncio +async def test_verify_page_invalid_token_returns_404(client, monkeypatch): + monkeypatch.setattr( + "src.api.routes.static_files.get_verification_session", + AsyncMock(return_value=None), + ) + + response = await client.get("/verify", params={"token": "invalid"}) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_verify_page_expired_session(client, monkeypatch): + session = make_verification_session(expired=True) + monkeypatch.setattr( + "src.api.routes.static_files.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.get("/verify", params={"token": "test-token"}) + + assert response.status_code == 200 + assert "验证链接已过期" in response.text + + +@pytest.mark.asyncio +async def test_verify_page_completed_session(client, monkeypatch): + session = make_verification_session(captcha_completed=True) + monkeypatch.setattr( + "src.api.routes.static_files.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.get("/verify", params={"token": "test-token"}) + + assert response.status_code == 200 + assert "您已完成验证" in response.text + + +@pytest.mark.asyncio +async def test_template_response_signature(): + """Guard against Starlette TemplateResponse API regressions.""" + from starlette.requests import Request + + from fastapi.templating import Jinja2Templates + + templates = Jinja2Templates(directory="templates") + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + } + request = Request(scope) + + response = templates.TemplateResponse(request, "index.html") + + assert response.status_code == 200 + assert response.media_type.startswith("text/html") diff --git a/tests/test_verification_api.py b/tests/test_verification_api.py new file mode 100644 index 0000000..78af6c6 --- /dev/null +++ b/tests/test_verification_api.py @@ -0,0 +1,269 @@ +"""Tests for core verification API flow (POST /api/v1/verify, status, captcha config).""" + +from unittest.mock import AsyncMock + +import pytest + +from src.api.services.approval import ApprovalResult +from src.captcha.base import CaptchaVerificationResult +from tests.conftest import make_join_request, make_verification_session + +VERIFY_URL = "/api/v1/verify" +VERIFY_PAYLOAD = { + "token": "test-token", + "captcha_response": "captcha-token-from-client", +} + + +@pytest.mark.asyncio +async def test_verify_success_telegram_join_request(client, monkeypatch): + session = make_verification_session() + join_request = make_join_request() + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + monkeypatch.setattr( + "src.api.routes.verification.complete_verification", + AsyncMock(return_value=True), + ) + monkeypatch.setattr( + "src.api.routes.verification.get_join_request_by_token", + AsyncMock(return_value=join_request), + ) + monkeypatch.setattr( + "src.api.routes.verification.auto_approve_user", + AsyncMock(return_value=ApprovalResult(True)), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["redirect_url"] == "tg://" + + +@pytest.mark.asyncio +async def test_verify_success_when_auto_approve_fails(client, monkeypatch): + """Captcha success should still return success even if Telegram approval fails.""" + session = make_verification_session() + join_request = make_join_request() + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + monkeypatch.setattr( + "src.api.routes.verification.complete_verification", + AsyncMock(return_value=True), + ) + monkeypatch.setattr( + "src.api.routes.verification.get_join_request_by_token", + AsyncMock(return_value=join_request), + ) + monkeypatch.setattr( + "src.api.routes.verification.auto_approve_user", + AsyncMock(return_value=ApprovalResult(False, "Bot 无权限")), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data.get("redirect_url") is None + + +@pytest.mark.asyncio +async def test_verify_api_request_with_valid_chat_auto_approves(client, monkeypatch): + session = make_verification_session(chat_id=0) + join_request = make_join_request(request_type="api", chat_id=-100999) + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + monkeypatch.setattr( + "src.api.routes.verification.complete_verification", + AsyncMock(return_value=True), + ) + monkeypatch.setattr( + "src.api.routes.verification.get_join_request_by_token", + AsyncMock(return_value=join_request), + ) + approve_mock = AsyncMock(return_value=ApprovalResult(True)) + monkeypatch.setattr( + "src.api.routes.verification.auto_approve_user", + approve_mock, + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 200 + approve_mock.assert_awaited_once_with("test-token") + + +@pytest.mark.asyncio +async def test_verify_api_request_without_chat_skips_approval(client, monkeypatch): + session = make_verification_session(chat_id=0) + join_request = make_join_request(request_type="api", chat_id=0) + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + monkeypatch.setattr( + "src.api.routes.verification.complete_verification", + AsyncMock(return_value=True), + ) + monkeypatch.setattr( + "src.api.routes.verification.get_join_request_by_token", + AsyncMock(return_value=join_request), + ) + approve_mock = AsyncMock(return_value=ApprovalResult(True)) + monkeypatch.setattr( + "src.api.routes.verification.auto_approve_user", + approve_mock, + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 200 + approve_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_verify_session_not_found(client, monkeypatch): + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=None), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_verify_session_expired(client, monkeypatch): + session = make_verification_session(expired=True) + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 400 + assert "过期" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_verify_already_completed(client, monkeypatch): + session = make_verification_session(captcha_completed=True) + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 400 + assert "已完成" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_verify_user_id_mismatch(client, monkeypatch): + session = make_verification_session(user_id=111) + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.post( + VERIFY_URL, + json={**VERIFY_PAYLOAD, "user_id": 999}, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_verify_captcha_failure(client, monkeypatch, mock_captcha_provider): + session = make_verification_session() + mock_captcha_provider.verify = AsyncMock( + return_value=CaptchaVerificationResult( + success=False, + error_code="invalid-input-response", + error_message="验证码无效", + ) + ) + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 400 + assert "验证码无效" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_verify_complete_verification_db_failure(client, monkeypatch): + session = make_verification_session() + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + monkeypatch.setattr( + "src.api.routes.verification.complete_verification", + AsyncMock(return_value=False), + ) + + response = await client.post(VERIFY_URL, json=VERIFY_PAYLOAD) + + assert response.status_code == 500 + + +@pytest.mark.asyncio +async def test_verification_status(client, monkeypatch): + session = make_verification_session(captcha_completed=True) + join_request = make_join_request(status="pending") + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=session), + ) + monkeypatch.setattr( + "src.api.routes.verification.get_join_request_by_token", + AsyncMock(return_value=join_request), + ) + + response = await client.get("/api/v1/verification-status/test-token") + + assert response.status_code == 200 + data = response.json() + assert data["token"] == "test-token" + assert data["completed"] is True + assert data["expired"] is False + assert data["status"] == "pending" + + +@pytest.mark.asyncio +async def test_verification_status_not_found(client, monkeypatch): + monkeypatch.setattr( + "src.api.routes.verification.get_verification_session", + AsyncMock(return_value=None), + ) + + response = await client.get("/api/v1/verification-status/missing") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_captcha_config(client): + response = await client.get("/api/v1/captcha-config") + + assert response.status_code == 200 + data = response.json() + assert data["provider"] == "hcaptcha" + assert data["captcha"]["site_key"] == "test-site-key" diff --git a/tests/test_verification_helpers.py b/tests/test_verification_helpers.py new file mode 100644 index 0000000..87d44f4 --- /dev/null +++ b/tests/test_verification_helpers.py @@ -0,0 +1,29 @@ +"""Unit tests for verification route helpers.""" + +from unittest.mock import MagicMock + +from src.api.routes.verification import get_client_ip + + +def _request_with_headers(headers: dict, client_host: str = "1.2.3.4"): + request = MagicMock() + request.headers.get.side_effect = lambda key, default=None: headers.get(key, default) + request.client.host = client_host + return request + + +def test_get_client_ip_from_x_forwarded_for(): + request = _request_with_headers( + {"X-Forwarded-For": "203.0.113.1, 10.0.0.1"}, + ) + assert get_client_ip(request) == "203.0.113.1" + + +def test_get_client_ip_from_x_real_ip(): + request = _request_with_headers({"X-Real-IP": "198.51.100.2"}) + assert get_client_ip(request) == "198.51.100.2" + + +def test_get_client_ip_fallback_to_client_host(): + request = _request_with_headers({}) + assert get_client_ip(request) == "1.2.3.4"