Merge pull request #106 from ZHOUKAILIAN/fix/issue-80-master

fix: 修复导入邮箱和现有账号场景下重复命中旧验证码的问题
This commit is contained in:
演变
2026-03-27 20:57:38 +08:00
committed by GitHub
12 changed files with 1285 additions and 9 deletions

View File

@@ -0,0 +1,156 @@
import asyncio
from contextlib import contextmanager
from pathlib import Path
from src.config.constants import EmailServiceType
from src.core.register import RegistrationEngine, RegistrationResult
from src.database.models import Account, Base
from src.database.session import DatabaseSessionManager
from src.services.base import BaseEmailService
from src.web.routes import accounts as accounts_routes
class DummySettings:
openai_client_id = "client-1"
openai_auth_url = "https://auth.openai.test/authorize"
openai_token_url = "https://auth.openai.test/token"
openai_redirect_uri = "https://localhost/callback"
openai_scope = "openid profile email offline_access"
tempmail_base_url = "https://api.tempmail.test"
tempmail_timeout = 30
tempmail_max_retries = 3
class FakeStatefulTempmailService(BaseEmailService):
def __init__(self, config=None, name=None):
super().__init__(EmailServiceType.TEMPMAIL, name)
self.messages = [
("id:msg-1", "111111"),
("id:msg-2", "222222"),
]
def create_email(self, config=None):
return {"email": "tester@example.com", "service_id": "token-1"}
def get_verification_code(
self,
email: str,
email_id: str = None,
timeout: int = 120,
pattern: str = r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
for marker, code in self.messages:
if self._accept_verification_code(email, code, marker):
return code
return None
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id: str) -> bool:
return True
def check_health(self) -> bool:
return True
def _build_test_db(name: str) -> DatabaseSessionManager:
runtime_dir = Path("tests_runtime")
runtime_dir.mkdir(exist_ok=True)
db_path = runtime_dir / name
if db_path.exists():
db_path.unlink()
manager = DatabaseSessionManager(f"sqlite:///{db_path}")
Base.metadata.create_all(bind=manager.engine)
return manager
def test_account_inbox_code_persists_verification_state_across_requests(monkeypatch):
manager = _build_test_db("account_inbox_code_state.db")
with manager.session_scope() as session:
account = Account(
email="tester@example.com",
email_service="tempmail",
email_service_id="token-1",
status="active",
extra_data={},
)
session.add(account)
session.commit()
session.refresh(account)
account_id = account.id
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr(accounts_routes, "get_db", fake_get_db)
monkeypatch.setattr(accounts_routes, "get_settings", lambda: DummySettings())
monkeypatch.setattr(
"src.services.base.EmailServiceFactory.create",
lambda service_type, config, name=None: FakeStatefulTempmailService(config, name),
)
first = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
second = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
assert first["success"] is True
assert first["code"] == "111111"
assert second["success"] is True
assert second["code"] == "222222"
with manager.session_scope() as session:
saved = session.query(Account).filter(Account.id == account_id).first()
verification_state = (saved.extra_data or {}).get("verification_state") or {}
assert verification_state["used_codes"] == ["111111", "222222"]
assert verification_state["seen_messages"] == ["id:msg-1", "id:msg-2"]
def test_save_to_database_persists_verification_state(monkeypatch):
manager = _build_test_db("registration_verification_state.db")
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr("src.core.register.get_db", fake_get_db)
monkeypatch.setattr("src.core.register.get_settings", lambda: DummySettings())
email_service = FakeStatefulTempmailService()
email_service._accept_verification_code("tester@example.com", "111111", "id:msg-1")
engine = RegistrationEngine(email_service=email_service, proxy_url="http://proxy.test")
engine.email_info = {"service_id": "token-1"}
result = RegistrationResult(
success=True,
email="tester@example.com",
password="secret",
account_id="acct-1",
workspace_id="ws-1",
access_token="access-token",
refresh_token="refresh-token",
id_token="id-token",
session_token="session-token",
metadata={"registered_at": "2026-03-26T00:00:00"},
source="register",
)
assert engine.save_to_database(result) is True
with manager.session_scope() as session:
saved = session.query(Account).filter(Account.email == "tester@example.com").first()
verification_state = (saved.extra_data or {}).get("verification_state") or {}
assert verification_state["used_codes"] == ["111111"]
assert verification_state["seen_messages"] == ["id:msg-1"]

View File

@@ -0,0 +1,523 @@
from src.services.duck_mail import DuckMailService
from src.services.freemail import FreemailService
from src.services.moe_mail import MeoMailEmailService
from src.services.temp_mail import TempMailService
from src.services.tempmail import TempmailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeRequestHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
class FakeGetHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append({
"method": "GET",
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: GET {url}")
return self.responses.pop(0)
def test_tempmail_service_skips_code_returned_by_previous_fetch():
service = TempmailService({"base_url": "https://api.tempmail.test"})
service.http_client = FakeGetHTTPClient([
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
}
]
}
),
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
},
{
"date": 1003,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
otp_sent_at=1000,
)
second_code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
otp_sent_at=1002,
)
assert first_code == "111111"
assert second_code == "654321"
def test_temp_mail_service_skips_code_returned_by_previous_fetch():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_temp_mail_service_accepts_same_code_from_newer_message():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "111111"
def test_freemail_service_skips_code_returned_by_previous_fetch():
service = FreemailService({
"base_url": "https://mail.example.com",
"admin_token": "jwt-token",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
}
]
),
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 654321",
"verification_code": "654321",
"created_at": "2026-03-19T10:00:03Z",
},
]
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_duck_mail_service_skips_previously_used_code_even_with_small_timestamp_gap():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:01Z",
}
]
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:01Z",
},
{
"id": "msg-2",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),
])
service._accounts_by_email["tester@duckmail.sbs"] = {
"email": "tester@duckmail.sbs",
"service_id": "account-1",
"account_id": "account-1",
"token": "token-123",
}
first_code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
otp_sent_at=1742378401,
)
second_code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_moe_mail_service_filters_old_messages_with_millisecond_timestamps():
service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
def fake_make_request(method, endpoint, **kwargs):
if endpoint == "/api/emails/email-1":
return {
"messages": [
{
"id": "msg-old",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-new",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
]
}
if endpoint == "/api/emails/email-1/msg-old":
return {
"message": {
"content": "Your OpenAI verification code is 111111",
}
}
if endpoint == "/api/emails/email-1/msg-new":
return {
"message": {
"content": "Your OpenAI verification code is 654321",
}
}
raise AssertionError(f"未准备响应: {method} {endpoint}")
service._make_request = fake_make_request
code = service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
otp_sent_at=1742378402,
)
assert code == "654321"
def test_moe_mail_service_cross_request_state_prefers_latest_of_three_messages():
first_service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
first_responses = [
{
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
]
},
{
"message": {
"content": "Your OpenAI verification code is 111111",
}
},
]
def fake_make_request_first(method, endpoint, **kwargs):
if not first_responses:
raise AssertionError(f"未准备响应: {method} {endpoint}")
return first_responses.pop(0)
first_service._make_request = fake_make_request_first
first_code = first_service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
state = first_service.export_verification_state("tester@example.com")
second_service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
second_service.load_verification_state("tester@example.com", **state)
second_calls = []
second_responses = {
"/api/emails/email-1": {
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-2",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
{
"id": "msg-3",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378406000,
},
]
},
"/api/emails/email-1/msg-3": {
"message": {
"content": "Your OpenAI verification code is 333333",
}
},
"/api/emails/email-1/msg-2": {
"message": {
"content": "Your OpenAI verification code is 222222",
}
},
"/api/emails/email-1/msg-1": {
"message": {
"content": "Your OpenAI verification code is 111111",
}
},
}
def fake_make_request_second(method, endpoint, **kwargs):
second_calls.append(endpoint)
if endpoint not in second_responses:
raise AssertionError(f"未准备响应: {method} {endpoint}")
return second_responses[endpoint]
second_service._make_request = fake_make_request_second
second_code = second_service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
assert first_code == "111111"
assert state == {
"used_codes": ["111111"],
"seen_messages": ["id:msg-1"],
}
assert second_code == "333333"
assert second_calls == [
"/api/emails/email-1",
"/api/emails/email-1/msg-3",
]

View File

@@ -0,0 +1,258 @@
from src.services.duck_mail import DuckMailService
from src.services.freemail import FreemailService
from src.services.moe_mail import MeoMailEmailService
from src.services.temp_mail import TempMailService
from src.services.tempmail import TempmailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeRequestHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
class FakeGetHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append({
"method": "GET",
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: GET {url}")
return self.responses.pop(0)
def test_tempmail_service_prefers_latest_matching_message_without_otp_timestamp():
service = TempmailService({"base_url": "https://api.tempmail.test"})
service.http_client = FakeGetHTTPClient([
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
},
{
"date": 1003,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
},
]
}
),
])
code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
)
assert code == "654321"
def test_temp_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
)
assert code == "654321"
def test_moe_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
def fake_make_request(method, endpoint, **kwargs):
if endpoint == "/api/emails/email-1":
return {
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-2",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
]
}
if endpoint == "/api/emails/email-1/msg-1":
return {
"message": {
"content": "Your OpenAI verification code is 111111",
}
}
if endpoint == "/api/emails/email-1/msg-2":
return {
"message": {
"content": "Your OpenAI verification code is 654321",
}
}
raise AssertionError(f"未准备响应: {method} {endpoint}")
service._make_request = fake_make_request
code = service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
assert code == "654321"
def test_freemail_service_prefers_latest_matching_message_without_otp_timestamp():
service = FreemailService({
"base_url": "https://mail.example.com",
"admin_token": "jwt-token",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 654321",
"verification_code": "654321",
"created_at": "2026-03-19T10:00:03Z",
},
]
),
])
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
)
assert code == "654321"
def test_duck_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
])
service._accounts_by_email["tester@duckmail.sbs"] = {
"email": "tester@duckmail.sbs",
"service_id": "account-1",
"account_id": "account-1",
"token": "token-123",
}
code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
)
assert code == "654321"

View File

@@ -0,0 +1,92 @@
import json
from types import SimpleNamespace
from src.core.register import RegistrationEngine
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeSession:
def __init__(self):
self.get_calls = []
self.post_calls = []
self.cookies = SimpleNamespace(get=lambda name: None)
def get(self, url, **kwargs):
self.get_calls.append({
"url": url,
"kwargs": kwargs,
})
return FakeResponse(status_code=200)
def post(self, url, **kwargs):
self.post_calls.append({
"url": url,
"kwargs": kwargs,
})
if url.endswith("/email-otp/validate"):
code = json.loads(kwargs["data"])["code"]
return FakeResponse(status_code=200 if code == "654321" else 401)
return FakeResponse(status_code=200)
class FakeEmailService:
def __init__(self):
self.calls = []
def get_verification_code(self, **kwargs):
self.calls.append(kwargs)
if len(self.calls) == 1:
return None
return "654321"
def test_registration_engine_resend_flow_propagates_new_otp_timestamp_and_validates_code(monkeypatch):
engine = RegistrationEngine.__new__(RegistrationEngine)
engine.logs = []
engine._log = lambda message, level="info": None
engine.email = "tester@example.com"
engine.email_info = {"service_id": "email-1"}
engine.email_service = FakeEmailService()
engine.session = FakeSession()
engine._otp_sent_at = None
engine.phase_history = []
issued_timestamps = iter([1000.0, 1000.0, 1000.0, 1005.0, 1005.0, 1005.0])
monkeypatch.setattr("src.core.register.time.time", lambda: next(issued_timestamps))
assert engine._send_verification_code() is True
first_otp_sent_at = engine._otp_sent_at
first_code = engine._get_verification_code()
assert first_otp_sent_at == 1000.0
assert first_code is None
assert engine._send_verification_code() is True
second_otp_sent_at = engine._otp_sent_at
second_code = engine._get_verification_code()
assert second_otp_sent_at == 1005.0
assert second_code == "654321"
assert engine._validate_verification_code(second_code) is True
assert len(engine.email_service.calls) == 2
assert engine.email_service.calls[0]["otp_sent_at"] == 1000.0
assert engine.email_service.calls[1]["otp_sent_at"] == 1005.0
assert engine.email_service.calls[0]["email_id"] == "email-1"
assert engine.email_service.calls[1]["email_id"] == "email-1"
validate_call = engine.session.post_calls[-1]
assert validate_call["url"].endswith("/email-otp/validate")
assert json.loads(validate_call["kwargs"]["data"]) == {"code": "654321"}