This commit is contained in:
2026-03-09 14:05:00 +08:00
commit 754e720ba7
105 changed files with 5890 additions and 0 deletions

View File

Binary file not shown.

86
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Shared test fixtures for Weibo-HotSign backend tests.
Uses SQLite in-memory for database tests and a simple dict-based
fake Redis for refresh-token tests, so no external services are needed.
"""
import asyncio
import sys
import os
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
# Ensure backend/ is on sys.path so `shared` and `app` imports work
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# --------------- override shared settings BEFORE any app import ---------------
os.environ["DATABASE_URL"] = "sqlite+aiosqlite://"
os.environ["REDIS_URL"] = "redis://localhost:6379/0"
os.environ["JWT_SECRET_KEY"] = "test-secret-key"
os.environ["COOKIE_ENCRYPTION_KEY"] = "test-cookie-key"
# Create the test engine BEFORE importing shared.models so we can swap it in
TEST_ENGINE = create_async_engine("sqlite+aiosqlite://", echo=False)
TestSessionLocal = sessionmaker(TEST_ENGINE, class_=AsyncSession, expire_on_commit=False)
# Now patch shared.models.base module-level objects before they get used
import shared.models.base as _base_mod # noqa: E402
_base_mod.engine = TEST_ENGINE
_base_mod.AsyncSessionLocal = TestSessionLocal
from shared.models.base import Base # noqa: E402
from shared.models import User # noqa: E402
@pytest.fixture(scope="session")
def event_loop():
"""Create a single event loop for the whole test session."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(autouse=True)
async def setup_db():
"""Create all tables before each test, drop after."""
async with TEST_ENGINE.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with TEST_ENGINE.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Yield a fresh async DB session."""
async with TestSessionLocal() as session:
yield session
# --------------- Fake Redis for refresh-token tests ---------------
class FakeRedis:
"""Minimal async Redis stand-in backed by a plain dict."""
def __init__(self):
self._store: dict[str, str] = {}
async def setex(self, key: str, ttl: int, value: str):
self._store[key] = value
async def get(self, key: str):
return self._store.get(key)
async def delete(self, key: str):
self._store.pop(key, None)
@pytest.fixture
def fake_redis():
return FakeRedis()

View File

@@ -0,0 +1,214 @@
"""
Tests for api_service account CRUD endpoints.
Validates tasks 4.1 and 4.2.
"""
import pytest
import pytest_asyncio
from unittest.mock import patch
from httpx import AsyncClient, ASGITransport
from shared.models import get_db
from tests.conftest import TEST_ENGINE, TestSessionLocal, Base, FakeRedis
@pytest_asyncio.fixture
async def client():
"""
Provide an httpx AsyncClient wired to the api_service app,
with DB overridden to test SQLite and a fake Redis for auth tokens.
"""
fake_redis = FakeRedis()
async with TEST_ENGINE.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Import apps after DB is ready
from api_service.app.main import app as api_app
from auth_service.app.main import app as auth_app
async def override_get_db():
async with TestSessionLocal() as session:
yield session
async def _fake_get_redis():
return fake_redis
api_app.dependency_overrides[get_db] = override_get_db
auth_app.dependency_overrides[get_db] = override_get_db
with patch(
"auth_service.app.utils.security.get_redis",
new=_fake_get_redis,
):
# We need both clients: auth for getting tokens, api for account ops
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://auth"
) as auth_client, AsyncClient(
transport=ASGITransport(app=api_app), base_url="http://api"
) as api_client:
yield auth_client, api_client
api_app.dependency_overrides.clear()
auth_app.dependency_overrides.clear()
async def _register_and_login(auth_client: AsyncClient, suffix: str = "1") -> str:
"""Helper: register a user and return an access token."""
reg = await auth_client.post("/auth/register", json={
"username": f"acctuser{suffix}",
"email": f"acct{suffix}@example.com",
"password": "Str0ng!Pass1",
})
assert reg.status_code == 201, f"Register failed: {reg.json()}"
resp = await auth_client.post("/auth/login", json={
"email": f"acct{suffix}@example.com",
"password": "Str0ng!Pass1",
})
login_body = resp.json()
assert resp.status_code == 200, f"Login failed: {login_body}"
# Handle both wrapped (success_response) and unwrapped token formats
if "data" in login_body:
return login_body["data"]["access_token"]
return login_body["access_token"]
def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ===================== Basic structure tests =====================
class TestAPIServiceBase:
@pytest.mark.asyncio
async def test_health(self, client):
_, api = client
resp = await api.get("/health")
assert resp.status_code == 200
assert resp.json()["success"] is True
@pytest.mark.asyncio
async def test_root(self, client):
_, api = client
resp = await api.get("/")
assert resp.status_code == 200
assert "API Service" in resp.json()["data"]["service"]
# ===================== Account CRUD tests =====================
class TestAccountCRUD:
@pytest.mark.asyncio
async def test_create_account(self, client):
auth, api = client
token = await _register_and_login(auth)
resp = await api.post("/api/v1/accounts", json={
"weibo_user_id": "12345",
"cookie": "SUB=abc; SUBP=xyz;",
"remark": "test account",
}, headers=_auth_header(token))
assert resp.status_code == 201
body = resp.json()
assert body["success"] is True
assert body["data"]["weibo_user_id"] == "12345"
assert body["data"]["status"] == "pending"
assert body["data"]["remark"] == "test account"
# Cookie plaintext must NOT appear in response
assert "SUB=abc" not in str(body)
@pytest.mark.asyncio
async def test_list_accounts(self, client):
auth, api = client
token = await _register_and_login(auth, "list")
# Create two accounts
for i in range(2):
await api.post("/api/v1/accounts", json={
"weibo_user_id": f"uid{i}",
"cookie": f"cookie{i}",
}, headers=_auth_header(token))
resp = await api.get("/api/v1/accounts", headers=_auth_header(token))
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data) == 2
@pytest.mark.asyncio
async def test_get_account_detail(self, client):
auth, api = client
token = await _register_and_login(auth, "detail")
create_resp = await api.post("/api/v1/accounts", json={
"weibo_user_id": "99",
"cookie": "c=1",
"remark": "my remark",
}, headers=_auth_header(token))
account_id = create_resp.json()["data"]["id"]
resp = await api.get(f"/api/v1/accounts/{account_id}", headers=_auth_header(token))
assert resp.status_code == 200
assert resp.json()["data"]["remark"] == "my remark"
@pytest.mark.asyncio
async def test_update_account_remark(self, client):
auth, api = client
token = await _register_and_login(auth, "upd")
create_resp = await api.post("/api/v1/accounts", json={
"weibo_user_id": "55",
"cookie": "c=old",
}, headers=_auth_header(token))
account_id = create_resp.json()["data"]["id"]
resp = await api.put(f"/api/v1/accounts/{account_id}", json={
"remark": "updated remark",
}, headers=_auth_header(token))
assert resp.status_code == 200
assert resp.json()["data"]["remark"] == "updated remark"
@pytest.mark.asyncio
async def test_delete_account(self, client):
auth, api = client
token = await _register_and_login(auth, "del")
create_resp = await api.post("/api/v1/accounts", json={
"weibo_user_id": "77",
"cookie": "c=del",
}, headers=_auth_header(token))
account_id = create_resp.json()["data"]["id"]
resp = await api.delete(f"/api/v1/accounts/{account_id}", headers=_auth_header(token))
assert resp.status_code == 200
# Verify it's gone
resp2 = await api.get(f"/api/v1/accounts/{account_id}", headers=_auth_header(token))
assert resp2.status_code == 404
@pytest.mark.asyncio
async def test_access_other_users_account_forbidden(self, client):
auth, api = client
token_a = await _register_and_login(auth, "ownerA")
token_b = await _register_and_login(auth, "ownerB")
# User A creates an account
create_resp = await api.post("/api/v1/accounts", json={
"weibo_user_id": "111",
"cookie": "c=a",
}, headers=_auth_header(token_a))
account_id = create_resp.json()["data"]["id"]
# User B tries to access it
resp = await api.get(f"/api/v1/accounts/{account_id}", headers=_auth_header(token_b))
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_request_rejected(self, client):
_, api = client
resp = await api.get("/api/v1/accounts")
assert resp.status_code in (401, 403)

View File

@@ -0,0 +1,238 @@
"""
Tests for api_service signin log query endpoints.
Validates task 6.1.
"""
import pytest
import pytest_asyncio
from datetime import datetime, timedelta
from unittest.mock import patch
from httpx import AsyncClient, ASGITransport
from shared.models import get_db, Account, SigninLog
from tests.conftest import TEST_ENGINE, TestSessionLocal, Base, FakeRedis
@pytest_asyncio.fixture
async def client():
"""
Provide an httpx AsyncClient wired to the api_service app,
with DB overridden to test SQLite and a fake Redis for auth tokens.
"""
fake_redis = FakeRedis()
async with TEST_ENGINE.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Import apps after DB is ready
from api_service.app.main import app as api_app
from auth_service.app.main import app as auth_app
async def override_get_db():
async with TestSessionLocal() as session:
yield session
async def _fake_get_redis():
return fake_redis
api_app.dependency_overrides[get_db] = override_get_db
auth_app.dependency_overrides[get_db] = override_get_db
with patch(
"auth_service.app.utils.security.get_redis",
new=_fake_get_redis,
):
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://auth"
) as auth_client, AsyncClient(
transport=ASGITransport(app=api_app), base_url="http://api"
) as api_client:
yield auth_client, api_client
api_app.dependency_overrides.clear()
auth_app.dependency_overrides.clear()
async def _register_and_login(auth_client: AsyncClient, suffix: str = "1") -> str:
"""Helper: register a user and return an access token."""
reg = await auth_client.post("/auth/register", json={
"username": f"loguser{suffix}",
"email": f"log{suffix}@example.com",
"password": "Str0ng!Pass1",
})
assert reg.status_code == 201
resp = await auth_client.post("/auth/login", json={
"email": f"log{suffix}@example.com",
"password": "Str0ng!Pass1",
})
login_body = resp.json()
assert resp.status_code == 200
if "data" in login_body:
return login_body["data"]["access_token"]
return login_body["access_token"]
def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
async def _create_account(api: AsyncClient, token: str, weibo_id: str) -> str:
"""Helper: create an account and return its ID."""
resp = await api.post("/api/v1/accounts", json={
"weibo_user_id": weibo_id,
"cookie": f"cookie_{weibo_id}",
}, headers=_auth_header(token))
assert resp.status_code == 201
return resp.json()["data"]["id"]
async def _create_signin_logs(db, account_id: str, count: int, statuses: list = None):
"""Helper: create signin logs for testing."""
if statuses is None:
statuses = ["success"] * count
base_time = datetime.utcnow()
for i in range(count):
log = SigninLog(
account_id=account_id,
topic_title=f"Topic {i}",
status=statuses[i] if i < len(statuses) else "success",
signed_at=base_time - timedelta(hours=i), # Descending order
)
db.add(log)
await db.commit()
# ===================== Signin Log Query Tests =====================
class TestSigninLogQuery:
@pytest.mark.asyncio
async def test_get_signin_logs_empty(self, client):
"""Test querying logs for an account with no logs."""
auth, api = client
token = await _register_and_login(auth, "empty")
account_id = await _create_account(api, token, "empty_acc")
resp = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs",
headers=_auth_header(token)
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["total"] == 0
assert len(data["items"]) == 0
@pytest.mark.asyncio
async def test_get_signin_logs_with_data(self, client):
"""Test querying logs returns data in descending order."""
auth, api = client
token = await _register_and_login(auth, "data")
account_id = await _create_account(api, token, "data_acc")
# Create logs directly in DB
async with TestSessionLocal() as db:
await _create_signin_logs(db, account_id, 5)
resp = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs",
headers=_auth_header(token)
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["total"] == 5
assert len(data["items"]) == 5
# Verify descending order by signed_at
items = data["items"]
for i in range(len(items) - 1):
assert items[i]["signed_at"] >= items[i + 1]["signed_at"]
@pytest.mark.asyncio
async def test_signin_logs_pagination(self, client):
"""Test pagination works correctly."""
auth, api = client
token = await _register_and_login(auth, "page")
account_id = await _create_account(api, token, "page_acc")
# Create 10 logs
async with TestSessionLocal() as db:
await _create_signin_logs(db, account_id, 10)
# Page 1, size 3
resp = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs?page=1&size=3",
headers=_auth_header(token)
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["total"] == 10
assert len(data["items"]) == 3
assert data["page"] == 1
assert data["size"] == 3
assert data["total_pages"] == 4
# Page 2, size 3
resp2 = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs?page=2&size=3",
headers=_auth_header(token)
)
data2 = resp2.json()["data"]
assert len(data2["items"]) == 3
assert data2["page"] == 2
@pytest.mark.asyncio
async def test_signin_logs_status_filter(self, client):
"""Test status filtering works correctly."""
auth, api = client
token = await _register_and_login(auth, "filter")
account_id = await _create_account(api, token, "filter_acc")
# Create logs with different statuses
async with TestSessionLocal() as db:
statuses = ["success", "success", "failed_network", "success", "failed_already_signed"]
await _create_signin_logs(db, account_id, 5, statuses)
# Filter by success
resp = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs?status=success",
headers=_auth_header(token)
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["total"] == 3
assert all(item["status"] == "success" for item in data["items"])
# Filter by failed_network
resp2 = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs?status=failed_network",
headers=_auth_header(token)
)
data2 = resp2.json()["data"]
assert data2["total"] == 1
assert data2["items"][0]["status"] == "failed_network"
@pytest.mark.asyncio
async def test_access_other_users_logs_forbidden(self, client):
"""Test that users cannot access other users' signin logs."""
auth, api = client
token_a = await _register_and_login(auth, "logA")
token_b = await _register_and_login(auth, "logB")
# User A creates an account
account_id = await _create_account(api, token_a, "logA_acc")
# User B tries to access logs
resp = await api.get(
f"/api/v1/accounts/{account_id}/signin-logs",
headers=_auth_header(token_b)
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_logs_request_rejected(self, client):
"""Test that unauthenticated requests are rejected."""
_, api = client
resp = await api.get("/api/v1/accounts/fake-id/signin-logs")
assert resp.status_code in (401, 403)

View File

@@ -0,0 +1,226 @@
"""
Tests for API_Service task management endpoints.
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models import User, Account, Task
from auth_service.app.utils.security import create_access_token
from shared.crypto import encrypt_cookie, derive_key
@pytest_asyncio.fixture
async def test_user(db_session: AsyncSession) -> User:
"""Create a test user."""
user = User(
username="testuser",
email="test@example.com",
hashed_password="hashed_password",
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def test_account(db_session: AsyncSession, test_user: User) -> Account:
"""Create a test account."""
key = derive_key("test-cookie-key")
ciphertext, iv = encrypt_cookie("test_cookie_data", key)
account = Account(
user_id=test_user.id,
weibo_user_id="123456",
remark="Test Account",
encrypted_cookies=ciphertext,
iv=iv,
status="pending",
)
db_session.add(account)
await db_session.commit()
await db_session.refresh(account)
return account
@pytest_asyncio.fixture
async def auth_headers(test_user: User) -> dict:
"""Generate JWT auth headers for test user."""
token = create_access_token({"sub": test_user.id})
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_create_task_valid_cron(
db_session: AsyncSession,
test_user: User,
test_account: Account,
auth_headers: dict,
):
"""Test creating a task with valid cron expression."""
from api_service.app.main import app
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post(
f"/api/v1/accounts/{test_account.id}/tasks",
json={"cron_expression": "0 9 * * *"},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["success"] is True
assert data["data"]["cron_expression"] == "0 9 * * *"
assert data["data"]["is_enabled"] is True
assert data["data"]["account_id"] == test_account.id
@pytest.mark.asyncio
async def test_create_task_invalid_cron(
db_session: AsyncSession,
test_user: User,
test_account: Account,
auth_headers: dict,
):
"""Test creating a task with invalid cron expression."""
from api_service.app.main import app
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post(
f"/api/v1/accounts/{test_account.id}/tasks",
json={"cron_expression": "invalid cron"},
headers=auth_headers,
)
assert response.status_code == 400
data = response.json()
assert data["success"] is False
@pytest.mark.asyncio
async def test_list_tasks(
db_session: AsyncSession,
test_user: User,
test_account: Account,
auth_headers: dict,
):
"""Test listing tasks for an account."""
# Create two tasks
task1 = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True)
task2 = Task(account_id=test_account.id, cron_expression="0 18 * * *", is_enabled=False)
db_session.add_all([task1, task2])
await db_session.commit()
from api_service.app.main import app
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get(
f"/api/v1/accounts/{test_account.id}/tasks",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
@pytest.mark.asyncio
async def test_update_task(
db_session: AsyncSession,
test_user: User,
test_account: Account,
auth_headers: dict,
):
"""Test updating a task (enable/disable)."""
task = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True)
db_session.add(task)
await db_session.commit()
await db_session.refresh(task)
from api_service.app.main import app
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.put(
f"/api/v1/tasks/{task.id}",
json={"is_enabled": False},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["is_enabled"] is False
@pytest.mark.asyncio
async def test_delete_task(
db_session: AsyncSession,
test_user: User,
test_account: Account,
auth_headers: dict,
):
"""Test deleting a task."""
task = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True)
db_session.add(task)
await db_session.commit()
await db_session.refresh(task)
from api_service.app.main import app
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.delete(
f"/api/v1/tasks/{task.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify task is deleted
from sqlalchemy import select
result = await db_session.execute(select(Task).where(Task.id == task.id))
deleted_task = result.scalar_one_or_none()
assert deleted_task is None
@pytest.mark.asyncio
async def test_access_other_user_task_forbidden(
db_session: AsyncSession,
test_account: Account,
):
"""Test that users cannot access tasks from other users' accounts."""
# Create another user
other_user = User(
username="otheruser",
email="other@example.com",
hashed_password="hashed_password",
)
db_session.add(other_user)
await db_session.commit()
await db_session.refresh(other_user)
# Create a task for test_account
task = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True)
db_session.add(task)
await db_session.commit()
await db_session.refresh(task)
# Try to access with other_user's token
other_token = create_access_token({"sub": other_user.id})
other_headers = {"Authorization": f"Bearer {other_token}"}
from api_service.app.main import app
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.put(
f"/api/v1/tasks/{task.id}",
json={"is_enabled": False},
headers=other_headers,
)
assert response.status_code == 403

View File

@@ -0,0 +1,317 @@
"""
Tests for auth_service: security utils, AuthService logic, and API endpoints.
Validates tasks 2.1 2.3.
"""
import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock
from fastapi import HTTPException
from shared.models import User
from tests.conftest import TestSessionLocal, FakeRedis
# Import security utilities
from auth_service.app.utils.security import (
hash_password,
verify_password,
validate_password_strength,
create_access_token,
decode_access_token,
)
from auth_service.app.services.auth_service import AuthService
from auth_service.app.schemas.user import UserCreate, UserLogin
# ===================== Password utilities =====================
class TestPasswordUtils:
def test_hash_and_verify(self):
raw = "MyP@ssw0rd"
hashed = hash_password(raw)
assert verify_password(raw, hashed)
def test_wrong_password_rejected(self):
hashed = hash_password("Correct1!")
assert not verify_password("Wrong1!", hashed)
@pytest.mark.parametrize(
"pwd, expected_valid",
[
("Ab1!abcd", True), # meets all criteria
("short1A!", True), # 8 chars, has upper/lower/digit/special valid
("alllower1!", False), # no uppercase
("ALLUPPER1!", False), # no lowercase
("NoDigits!Aa", False), # no digit
("NoSpecial1a", False), # no special char
],
)
def test_password_strength(self, pwd, expected_valid):
is_valid, _ = validate_password_strength(pwd)
assert is_valid == expected_valid
def test_password_too_short(self):
is_valid, msg = validate_password_strength("Ab1!")
assert not is_valid
assert "8 characters" in msg
# ===================== JWT utilities =====================
class TestJWT:
def test_create_and_decode(self):
token = create_access_token({"sub": "user-123", "username": "alice"})
payload = decode_access_token(token)
assert payload is not None
assert payload["sub"] == "user-123"
def test_invalid_token_returns_none(self):
assert decode_access_token("not.a.valid.token") is None
# ===================== Refresh token helpers (with fake Redis) =====================
class TestRefreshToken:
@pytest.mark.asyncio
async def test_create_verify_revoke(self, fake_redis):
"""Full lifecycle: create → verify → revoke → verify again returns None."""
async def _fake_get_redis():
return fake_redis
with patch(
"auth_service.app.utils.security.get_redis",
new=_fake_get_redis,
):
from auth_service.app.utils.security import (
create_refresh_token,
verify_refresh_token,
revoke_refresh_token,
)
token = await create_refresh_token("user-42")
assert isinstance(token, str) and len(token) > 0
uid = await verify_refresh_token(token)
assert uid == "user-42"
await revoke_refresh_token(token)
assert await verify_refresh_token(token) is None
# ===================== AuthService business logic =====================
class TestAuthServiceLogic:
@pytest_asyncio.fixture
async def auth_svc(self, db_session):
return AuthService(db_session)
@pytest.mark.asyncio
async def test_create_user_success(self, auth_svc, db_session):
data = UserCreate(username="newuser", email="new@example.com", password="Str0ng!Pass")
user = await auth_svc.create_user(data)
assert user.username == "newuser"
assert user.email == "new@example.com"
assert user.hashed_password != "Str0ng!Pass"
@pytest.mark.asyncio
async def test_create_user_weak_password_rejected(self, auth_svc):
# Use a password that passes Pydantic min_length=8 but fails strength check
data = UserCreate(username="weakuser", email="weak@example.com", password="weakpassword")
with pytest.raises(HTTPException) as exc_info:
await auth_svc.create_user(data)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_get_user_by_email(self, auth_svc, db_session):
data = UserCreate(username="findme", email="find@example.com", password="Str0ng!Pass")
await auth_svc.create_user(data)
found = await auth_svc.get_user_by_email("find@example.com")
assert found is not None
assert found.username == "findme"
@pytest.mark.asyncio
async def test_check_user_exists(self, auth_svc, db_session):
data = UserCreate(username="exists", email="exists@example.com", password="Str0ng!Pass")
await auth_svc.create_user(data)
email_u, username_u = await auth_svc.check_user_exists("exists@example.com", "other")
assert email_u is not None
assert username_u is None
# ===================== Auth API endpoint tests =====================
class TestAuthAPI:
"""Integration tests hitting the FastAPI app via httpx."""
@pytest_asyncio.fixture
async def client(self, fake_redis):
"""
Provide an httpx AsyncClient wired to the auth_service app,
with DB session overridden to use the test SQLite engine.
"""
from shared.models import get_db
from auth_service.app.main import app
from httpx import AsyncClient, ASGITransport
from tests.conftest import TEST_ENGINE, TestSessionLocal, Base
async with TEST_ENGINE.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def override_get_db():
async with TestSessionLocal() as session:
yield session
async def _fake_get_redis():
return fake_redis
app.dependency_overrides[get_db] = override_get_db
with patch(
"auth_service.app.utils.security.get_redis",
new=_fake_get_redis,
):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_register_and_login(self, client):
# Register
resp = await client.post("/auth/register", json={
"username": "apiuser",
"email": "api@example.com",
"password": "Str0ng!Pass1",
})
assert resp.status_code == 201
body = resp.json()
assert body["success"] is True
assert body["data"]["username"] == "apiuser"
# Login
resp = await client.post("/auth/login", json={
"email": "api@example.com",
"password": "Str0ng!Pass1",
})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert "access_token" in body["data"]
assert "refresh_token" in body["data"]
@pytest.mark.asyncio
async def test_login_wrong_password(self, client):
await client.post("/auth/register", json={
"username": "wrongpw",
"email": "wrongpw@example.com",
"password": "Str0ng!Pass1",
})
resp = await client.post("/auth/login", json={
"email": "wrongpw@example.com",
"password": "WrongPassword1!",
})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_register_duplicate_email(self, client):
await client.post("/auth/register", json={
"username": "dup1",
"email": "dup@example.com",
"password": "Str0ng!Pass1",
})
resp = await client.post("/auth/register", json={
"username": "dup2",
"email": "dup@example.com",
"password": "Str0ng!Pass1",
})
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_register_weak_password(self, client):
resp = await client.post("/auth/register", json={
"username": "weakpwd",
"email": "weakpwd@example.com",
"password": "weakpassword",
})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_me_endpoint(self, client):
await client.post("/auth/register", json={
"username": "meuser",
"email": "me@example.com",
"password": "Str0ng!Pass1",
})
login_resp = await client.post("/auth/login", json={
"email": "me@example.com",
"password": "Str0ng!Pass1",
})
token = login_resp.json()["data"]["access_token"]
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
assert body["data"]["username"] == "meuser"
assert body["data"]["email"] == "me@example.com"
@pytest.mark.asyncio
async def test_refresh_endpoint(self, client):
await client.post("/auth/register", json={
"username": "refreshuser",
"email": "refresh@example.com",
"password": "Str0ng!Pass1",
})
login_resp = await client.post("/auth/login", json={
"email": "refresh@example.com",
"password": "Str0ng!Pass1",
})
refresh_token = login_resp.json()["data"]["refresh_token"]
# Refresh
resp = await client.post("/auth/refresh", json={
"refresh_token": refresh_token,
})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert "access_token" in body["data"]
new_refresh = body["data"]["refresh_token"]
assert new_refresh != refresh_token # rotation
# Old token should be revoked
resp2 = await client.post("/auth/refresh", json={
"refresh_token": refresh_token,
})
assert resp2.status_code == 401
@pytest.mark.asyncio
async def test_me_without_token(self, client):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_unified_error_format(self, client):
"""Verify error responses follow the unified format."""
resp = await client.post("/auth/login", json={
"email": "nobody@example.com",
"password": "Whatever1!",
})
body = resp.json()
assert body["success"] is False
assert body["data"] is None
assert "error" in body

View File

@@ -0,0 +1,171 @@
"""
Tests for the shared module: crypto, response format, and ORM models.
Validates tasks 1.1 1.5 (excluding optional PBT task 1.4).
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from shared.crypto import derive_key, encrypt_cookie, decrypt_cookie
from shared.response import success_response, error_response
from shared.models import User, Account, Task, SigninLog
from tests.conftest import TestSessionLocal
# ===================== Crypto tests =====================
class TestCrypto:
"""Verify AES-256-GCM encrypt/decrypt round-trip and error handling."""
def setup_method(self):
self.key = derive_key("test-encryption-key")
def test_encrypt_decrypt_roundtrip(self):
original = "SUB=abc123; SUBP=xyz789;"
ct, iv = encrypt_cookie(original, self.key)
assert decrypt_cookie(ct, iv, self.key) == original
def test_different_plaintexts_produce_different_ciphertexts(self):
ct1, _ = encrypt_cookie("cookie_a", self.key)
ct2, _ = encrypt_cookie("cookie_b", self.key)
assert ct1 != ct2
def test_wrong_key_raises(self):
ct, iv = encrypt_cookie("secret", self.key)
wrong_key = derive_key("wrong-key")
with pytest.raises(Exception):
decrypt_cookie(ct, iv, wrong_key)
def test_empty_string_roundtrip(self):
ct, iv = encrypt_cookie("", self.key)
assert decrypt_cookie(ct, iv, self.key) == ""
def test_unicode_roundtrip(self):
original = "微博Cookie=值; 中文=测试"
ct, iv = encrypt_cookie(original, self.key)
assert decrypt_cookie(ct, iv, self.key) == original
# ===================== Response format tests =====================
class TestResponseFormat:
"""Verify unified response helpers."""
def test_success_response_structure(self):
resp = success_response({"id": 1}, "ok")
assert resp["success"] is True
assert resp["data"] == {"id": 1}
assert resp["message"] == "ok"
def test_success_response_defaults(self):
resp = success_response()
assert resp["success"] is True
assert resp["data"] is None
assert "Operation successful" in resp["message"]
def test_error_response_structure(self):
resp = error_response("bad", "VALIDATION_ERROR", [{"field": "email"}], 400)
assert resp.status_code == 400
import json
body = json.loads(resp.body)
assert body["success"] is False
assert body["data"] is None
assert body["error"]["code"] == "VALIDATION_ERROR"
assert len(body["error"]["details"]) == 1
# ===================== ORM model smoke tests =====================
class TestORMModels:
"""Verify ORM models can be created and queried with SQLite."""
@pytest.mark.asyncio
async def test_create_user(self, db_session):
user = User(
username="testuser",
email="test@example.com",
hashed_password="hashed",
)
db_session.add(user)
await db_session.commit()
result = await db_session.execute(select(User).where(User.username == "testuser"))
fetched = result.scalar_one()
assert fetched.email == "test@example.com"
assert fetched.is_active is True
@pytest.mark.asyncio
async def test_create_account_linked_to_user(self, db_session):
user = User(username="u1", email="u1@x.com", hashed_password="h")
db_session.add(user)
await db_session.commit()
acct = Account(
user_id=user.id,
weibo_user_id="12345",
remark="test",
encrypted_cookies="enc",
iv="iv123",
)
db_session.add(acct)
await db_session.commit()
result = await db_session.execute(select(Account).where(Account.user_id == user.id))
fetched = result.scalar_one()
assert fetched.weibo_user_id == "12345"
assert fetched.status == "pending"
@pytest.mark.asyncio
async def test_create_task_linked_to_account(self, db_session):
user = User(username="u2", email="u2@x.com", hashed_password="h")
db_session.add(user)
await db_session.commit()
acct = Account(
user_id=user.id, weibo_user_id="99", remark="r",
encrypted_cookies="e", iv="i",
)
db_session.add(acct)
await db_session.commit()
task = Task(account_id=acct.id, cron_expression="0 8 * * *")
db_session.add(task)
await db_session.commit()
result = await db_session.execute(select(Task).where(Task.account_id == acct.id))
fetched = result.scalar_one()
assert fetched.cron_expression == "0 8 * * *"
assert fetched.is_enabled is True
@pytest.mark.asyncio
async def test_create_signin_log(self, db_session):
user = User(username="u3", email="u3@x.com", hashed_password="h")
db_session.add(user)
await db_session.commit()
acct = Account(
user_id=user.id, weibo_user_id="77", remark="r",
encrypted_cookies="e", iv="i",
)
db_session.add(acct)
await db_session.commit()
log = SigninLog(
account_id=acct.id,
topic_title="超话A",
status="success",
)
db_session.add(log)
await db_session.commit()
result = await db_session.execute(
select(SigninLog).where(SigninLog.account_id == acct.id)
)
fetched = result.scalar_one()
assert fetched.status == "success"
assert fetched.topic_title == "超话A"