diff --git a/src/core/login.py b/src/core/login.py index af8473f..4d5bc11 100644 --- a/src/core/login.py +++ b/src/core/login.py @@ -345,13 +345,16 @@ class LoginEngine(RegistrationEngine): return result self._log("10. 等待验证码...") - code = self._get_verification_code() + code, otp_phase = self._await_verification_code_with_resends( + self._send_verification_code, + timeout_retry_log_template="10. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template="10. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + ) if not code: - self._log("10. 验证码超时,重新发送...") - if self._send_verification_code(): - code = self._get_verification_code() - if not code: - result.error_message = "获取验证码失败" + result.error_message = ( + otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败" + ) + result.error_code = otp_phase.error_code if otp_phase else "" return result self._log("11. 验证验证码...") @@ -380,13 +383,16 @@ class LoginEngine(RegistrationEngine): return result self._log("15. 等待验证码...") - code = self._get_verification_code() + code, otp_phase = self._await_verification_code_with_resends( + self._send_verification_code_passwordless, + timeout_retry_log_template="15. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template="15. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + ) if not code: - self._log("15. 验证码超时,重新发送...") - if self._send_verification_code_passwordless(): - code = self._get_verification_code() - if not code: - result.error_message = "获取验证码失败" + result.error_message = ( + otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败" + ) + result.error_code = otp_phase.error_code if otp_phase else "" return result self._log("16. 验证验证码...") diff --git a/src/core/codex_auth.py b/src/core/openai/codex_auth.py similarity index 97% rename from src/core/codex_auth.py rename to src/core/openai/codex_auth.py index 276ee3b..b3e9368 100644 --- a/src/core/codex_auth.py +++ b/src/core/openai/codex_auth.py @@ -7,15 +7,15 @@ import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional -from .openai.oauth import OAuthManager -from .register import PhaseContext, RegistrationEngine -from ..config.constants import ( +from core.openai.oauth import OAuthManager +from core.register import PhaseContext, RegistrationEngine +from config.constants import ( CODEX_OAUTH_ORIGINATOR, CODEX_OAUTH_REDIRECT_URI, CODEX_OAUTH_SCOPE, ) -from ..config.settings import get_settings -from ..services.base import BaseEmailService +from config.settings import get_settings +from services.base import BaseEmailService @dataclass diff --git a/src/core/register.py b/src/core/register.py index 62b2f76..cd8b27e 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -4,28 +4,20 @@ """ import base64 +import json +import logging import math import re -import json -import time -import logging import secrets -from typing import Optional, Dict, Any, Tuple, Callable +import time from dataclasses import dataclass, field from datetime import datetime +from typing import Optional, Dict, Any, Tuple, Callable from curl_cffi import requests as cffi_requests +from .http_client import OpenAIHTTPClient from .openai.oauth import OAuthManager, OAuthStart -from .http_client import OpenAIHTTPClient, HTTPClientError -from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType -from ..services.base import ( - EmailProviderBackoffState, - OTP_NO_OPENAI_SENDER_ERROR, - OTPNoOpenAISenderEmailServiceError, -) -from ..database import crud -from ..database.session import get_db from ..config.constants import ( OPENAI_API_ENDPOINTS, OPENAI_PAGE_TYPES, @@ -33,11 +25,16 @@ from ..config.constants import ( OTP_CODE_PATTERN, DEFAULT_PASSWORD_LENGTH, PASSWORD_CHARSET, - AccountStatus, - TaskStatus, ) from ..config.settings import get_settings - +from ..database import crud +from ..database.session import get_db +from ..services import BaseEmailService +from ..services.base import ( + EmailProviderBackoffState, + OTP_NO_OPENAI_SENDER_ERROR, + OTPNoOpenAISenderEmailServiceError, +) logger = logging.getLogger(__name__) @@ -639,6 +636,75 @@ class RegistrationEngine: ) return code + def _await_verification_code_with_resends( + self, + resend_callback: Callable[[], bool], + *, + timeout_retry_log_template: str = "收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template: str = "检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + timeout_retry_status_template: Optional[str] = None, + non_openai_retry_status_template: Optional[str] = None, + step_index: Optional[int] = None, + ) -> Tuple[Optional[str], Optional[PhaseResult]]: + """等待验证码,并在可重试场景下按独立预算触发重发。""" + settings = get_settings() + timeout_resend_max = settings.email_code_resend_max_retries + non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries + timeout_resend_used = 0 + non_openai_sender_resend_used = 0 + otp_phase_started_at = time.time() + otp_phase: Optional[PhaseResult] = None + + while True: + code, otp_phase = self._phase_otp_secondary( + PhaseContext(otp_sent_at=self._otp_sent_at), + started_at=otp_phase_started_at, + ) + if code: + return code, otp_phase + + if otp_phase and not otp_phase.retryable: + return None, otp_phase + + retry_error_code = otp_phase.error_code if otp_phase else "" + retry_reason = ( + "non_openai_sender" + if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR + else "timeout" + ) + + if retry_reason == "non_openai_sender": + if non_openai_sender_resend_used >= non_openai_sender_resend_max: + return None, otp_phase + non_openai_sender_resend_used += 1 + resend_attempt = non_openai_sender_resend_used + self._log(non_openai_retry_log_template.format(attempt=resend_attempt)) + status_template = non_openai_retry_status_template + else: + if timeout_resend_used >= timeout_resend_max: + return None, otp_phase + timeout_resend_used += 1 + resend_attempt = timeout_resend_used + self._log(timeout_retry_log_template.format(attempt=resend_attempt)) + status_template = timeout_retry_status_template + + if status_template: + emit_kwargs = {} + if step_index is not None: + emit_kwargs["step_index"] = step_index + self._emit_status( + "otp_resend", + status_template.format(attempt=resend_attempt), + **emit_kwargs, + ) + + if not resend_callback(): + self._log("重新发送验证码失败,跳过本次重试", "warning") + + otp_phase_started_at = time.time() + + return None, otp_phase + def _phase_otp_secondary( self, context: PhaseContext, @@ -1330,9 +1396,18 @@ class RegistrationEngine: if not self._submit_login_password_step(): return None, None - code = self._get_verification_code() + code, otp_phase = self._await_verification_code_with_resends( + self._submit_login_password_step, + timeout_retry_log_template="登录流程未找到验证码,第 {attempt} 次重新触发登录验证码...", + non_openai_retry_log_template="登录流程检测到非 OpenAI 发件人干扰,第 {attempt} 次重新触发登录验证码...", + timeout_retry_status_template="重新触发登录验证码(第 {attempt} 次)", + non_openai_retry_status_template="重新触发登录验证码(非 OpenAI 发件人,第 {attempt} 次)", + ) if not code: - self._log("登录流程获取验证码失败", "warning") + self._log( + otp_phase.error_message if otp_phase and otp_phase.error_message else "登录流程获取验证码失败", + "warning", + ) return None, None valid, consent_url = self._validate_verification_code_and_get_continue_url(code) @@ -1559,57 +1634,14 @@ class RegistrationEngine: # 10. 获取验证码(支持重发重试) self._log("10. 等待验证码...") self._emit_status("otp_secondary", "等待验证码邮件", step_index=10) - otp_phase_started_at = time.time() - settings = get_settings() - timeout_resend_max = settings.email_code_resend_max_retries - non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries - timeout_resend_used = 0 - non_openai_sender_resend_used = 0 - code, otp_phase = None, None - while True: - code, otp_phase = self._phase_otp_secondary( - PhaseContext(otp_sent_at=self._otp_sent_at), - started_at=otp_phase_started_at, - ) - if code: - break - - retry_error_code = otp_phase.error_code if otp_phase else "" - retry_reason = ( - "non_openai_sender" - if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR - else "timeout" - ) - - if retry_reason == "non_openai_sender": - if non_openai_sender_resend_used >= non_openai_sender_resend_max: - break - non_openai_sender_resend_used += 1 - resend_attempt = non_openai_sender_resend_used - self._log( - f"10. 检测到非 OpenAI 发件人干扰,第 {resend_attempt} 次重新发送验证码..." - ) - self._emit_status( - "otp_resend", - f"重新发送验证码(非 OpenAI 发件人,第 {resend_attempt} 次)", - step_index=10, - ) - else: - if timeout_resend_used >= timeout_resend_max: - break - timeout_resend_used += 1 - resend_attempt = timeout_resend_used - self._log(f"10. 收件箱未找到验证码,第 {resend_attempt} 次重新发送验证码...") - self._emit_status( - "otp_resend", - f"重新发送验证码(第 {resend_attempt} 次)", - step_index=10, - ) - - if not self._send_verification_code(): - self._log("重新发送验证码失败,跳过本次重试", "warning") - - otp_phase_started_at = time.time() + code, otp_phase = self._await_verification_code_with_resends( + self._send_verification_code, + timeout_retry_log_template="10. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template="10. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + timeout_retry_status_template="重新发送验证码(第 {attempt} 次)", + non_openai_retry_status_template="重新发送验证码(非 OpenAI 发件人,第 {attempt} 次)", + step_index=10, + ) if not code: result.error_message = ( otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败" diff --git a/src/database/crud.py b/src/database/crud.py index 3a19d46..84f3fa6 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -532,6 +532,42 @@ def delete_proxy(db: Session, proxy_id: int) -> bool: db.commit() return True + +def delete_proxies_by_ids(db: Session, proxy_ids: Iterable[int]) -> Dict[str, Any]: + """按 ID 批量删除代理配置。""" + normalized_ids = [] + seen_ids: Set[int] = set() + for proxy_id in proxy_ids: + current_id = int(proxy_id) + if current_id <= 0 or current_id in seen_ids: + continue + seen_ids.add(current_id) + normalized_ids.append(current_id) + + if not normalized_ids: + return { + "requested_count": 0, + "deleted_count": 0, + "not_found_ids": [], + } + + existing_ids = { + proxy_id + for (proxy_id,) in db.query(Proxy.id).filter(Proxy.id.in_(normalized_ids)).all() + } + not_found_ids = [proxy_id for proxy_id in normalized_ids if proxy_id not in existing_ids] + + deleted_count = 0 + if existing_ids: + deleted_count = db.query(Proxy).filter(Proxy.id.in_(existing_ids)).delete(synchronize_session=False) + db.commit() + + return { + "requested_count": len(normalized_ids), + "deleted_count": deleted_count, + "not_found_ids": not_found_ids, + } + def delete_disabled_proxies(db: Session) -> int: """删除所有已禁用代理""" deleted = db.query(Proxy).filter(Proxy.enabled == False).delete(synchronize_session=False) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index a0a20f4..9dc6040 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -759,7 +759,7 @@ async def codex_auth_login(request: CodexAuthLoginRequest): log_queue.put(("log", msg)) def run_login(): - from ...core.codex_auth import CodexAuthEngine + from core.openai.codex_auth import CodexAuthEngine try: engine = CodexAuthEngine( email=email, @@ -876,7 +876,7 @@ async def codex_auth_login_batch(request: CodexAuthBatchRequest): log_queue = queue.Queue() def run_batch(): - from ...core.codex_auth import CodexAuthEngine + from core.openai.codex_auth import CodexAuthEngine results = [] for i, acc_data in enumerate(accounts_data): diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 3186812..574089f 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -470,6 +470,11 @@ class ProxyCreateRequest(BaseModel): priority: int = 0 +class ProxyBatchDeleteRequest(BaseModel): + """批量删除代理请求""" + ids: List[int] + + class ProxyUpdateRequest(BaseModel): """更新代理请求""" name: Optional[str] = None @@ -563,6 +568,35 @@ async def create_proxy_item(request: ProxyCreateRequest): return {"success": True, "proxy": proxy.to_dict()} +@router.post("/proxies/batch-delete") +async def batch_delete_proxies(request: ProxyBatchDeleteRequest): + """批量删除代理。""" + if not request.ids: + raise HTTPException(status_code=400, detail="请至少选择一个代理") + + with get_db() as db: + result = crud.delete_proxies_by_ids(db, request.ids) + return { + "success": True, + "requested_count": result["requested_count"], + "deleted_count": result["deleted_count"], + "not_found_ids": result["not_found_ids"], + "message": f"已删除 {result['deleted_count']} 个代理", + } + + +@router.post("/proxies/delete-disabled") +async def delete_disabled_proxy_items(): + """删除所有已禁用代理。""" + with get_db() as db: + deleted_count = crud.delete_disabled_proxies(db) + return { + "success": True, + "deleted_count": deleted_count, + "message": f"已删除 {deleted_count} 个禁用代理", + } + + @router.get("/proxies/{proxy_id}") async def get_proxy_item(proxy_id: int): """获取单个代理""" diff --git a/static/js/settings.js b/static/js/settings.js index 0902f24..20eac53 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -29,7 +29,9 @@ const elements = { selectAllServices: document.getElementById('select-all-services'), // 代理列表 proxiesTable: document.getElementById('proxies-table'), + selectAllProxies: document.getElementById('select-all-proxies'), addProxyBtn: document.getElementById('add-proxy-btn'), + batchDeleteProxiesBtn: document.getElementById('batch-delete-proxies-btn'), testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), deleteDisabledProxiesBtn: document.getElementById('delete-disabled-proxies-btn'), batchImportProxyBtn: document.getElementById('batch-import-proxy-btn'), @@ -89,6 +91,8 @@ const elements = { // 选中的服务 ID let selectedServiceIds = new Set(); +let selectedProxyIds = new Set(); +let disabledProxyCount = 0; // 初始化 document.addEventListener('DOMContentLoaded', () => { @@ -252,6 +256,20 @@ function initEventListeners() { elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); } + if (elements.selectAllProxies) { + elements.selectAllProxies.addEventListener('change', (e) => { + toggleSelectAllProxies(e.target.checked); + }); + } + + if (elements.batchDeleteProxiesBtn) { + elements.batchDeleteProxiesBtn.addEventListener('click', handleBatchDeleteProxies); + } + + if (elements.deleteDisabledProxiesBtn) { + elements.deleteDisabledProxiesBtn.addEventListener('click', handleDeleteDisabledProxies); + } + if (elements.closeProxyModal) { elements.closeProxyModal.addEventListener('click', closeProxyModal); } @@ -845,12 +863,16 @@ function escapeHtml(text) { async function loadProxies() { try { const data = await api.get('/settings/proxies'); + syncSelectedProxyIds(data.proxies || []); renderProxies(data.proxies); } catch (error) { console.error('加载代理列表失败:', error); + selectedProxyIds = new Set(); + disabledProxyCount = 0; + updateProxyBatchActions(); elements.proxiesTable.innerHTML = ` - +
加载失败
@@ -864,9 +886,12 @@ async function loadProxies() { // 渲染代理列表 function renderProxies(proxies) { if (!proxies || proxies.length === 0) { + selectedProxyIds = new Set(); + disabledProxyCount = 0; + updateProxyBatchActions(); elements.proxiesTable.innerHTML = ` - +
🌐
暂无代理
@@ -878,8 +903,20 @@ function renderProxies(proxies) { return; } + disabledProxyCount = proxies.filter((proxy) => proxy && proxy.enabled === false).length; + elements.proxiesTable.innerHTML = proxies.map(proxy => ` + + + ${proxy.id} ${escapeHtml(proxy.name)} ${proxy.type.toUpperCase()} @@ -911,6 +948,56 @@ function renderProxies(proxies) { `).join(''); + + updateProxyBatchActions(); +} + +function syncSelectedProxyIds(proxies) { + const validIds = new Set((proxies || []).map(proxy => proxy.id)); + selectedProxyIds = new Set([...selectedProxyIds].filter(id => validIds.has(id))); +} + +function toggleSelectAllProxies(checked) { + document.querySelectorAll('.proxy-checkbox').forEach((checkbox) => { + checkbox.checked = checked; + }); + updateSelectedProxies(); +} + +function updateSelectedProxies() { + selectedProxyIds = new Set( + [...document.querySelectorAll('.proxy-checkbox:checked')] + .map((checkbox) => parseInt(checkbox.dataset.id, 10)) + .filter((id) => Number.isInteger(id) && id > 0) + ); + updateProxyBatchActions(); +} + +function updateProxyBatchActions() { + const proxyCheckboxes = [...document.querySelectorAll('.proxy-checkbox')]; + const checkedCount = selectedProxyIds.size; + + if (elements.selectAllProxies) { + const totalCount = proxyCheckboxes.length; + const allChecked = totalCount > 0 && checkedCount === totalCount; + elements.selectAllProxies.checked = allChecked; + elements.selectAllProxies.indeterminate = checkedCount > 0 && checkedCount < totalCount; + elements.selectAllProxies.disabled = totalCount === 0; + } + + if (elements.batchDeleteProxiesBtn) { + elements.batchDeleteProxiesBtn.disabled = checkedCount === 0; + elements.batchDeleteProxiesBtn.textContent = checkedCount > 0 + ? `🗑️ 批量删除 (${checkedCount})` + : '🗑️ 批量删除'; + } + + if (elements.deleteDisabledProxiesBtn) { + elements.deleteDisabledProxiesBtn.disabled = disabledProxyCount === 0; + elements.deleteDisabledProxiesBtn.textContent = disabledProxyCount > 0 + ? `🧹 删除禁用项 (${disabledProxyCount})` + : '🧹 删除禁用项'; + } } function toggleSettingsMoreMenu(btn) { @@ -1071,6 +1158,49 @@ async function deleteProxyItem(id) { } } +async function handleBatchDeleteProxies() { + const ids = [...selectedProxyIds]; + if (ids.length === 0) { + toast.error('请先选择要删除的代理'); + return; + } + + const confirmed = await confirm(`确定要批量删除选中的 ${ids.length} 个代理吗?`); + if (!confirmed) return; + + try { + const result = await api.post('/settings/proxies/batch-delete', { ids }); + const missingCount = Array.isArray(result.not_found_ids) ? result.not_found_ids.length : 0; + const summary = missingCount > 0 + ? `${result.message},其中 ${missingCount} 个代理不存在或已被删除` + : result.message; + toast.success(summary || `已删除 ${ids.length} 个代理`); + selectedProxyIds = new Set(); + await loadProxies(); + } catch (error) { + toast.error('批量删除失败: ' + error.message); + } +} + +async function handleDeleteDisabledProxies() { + if (disabledProxyCount === 0) { + toast.error('当前没有可删除的禁用代理'); + return; + } + + const confirmed = await confirm(`确定要删除全部 ${disabledProxyCount} 个禁用代理吗?`); + if (!confirmed) return; + + try { + const result = await api.post('/settings/proxies/delete-disabled'); + selectedProxyIds = new Set(); + toast.success(result.message || `已删除 ${result.deleted_count || 0} 个禁用代理`); + await loadProxies(); + } catch (error) { + toast.error('删除禁用代理失败: ' + error.message); + } +} + // 测试所有代理 async function handleTestAllProxies() { elements.testAllProxiesBtn.disabled = true; diff --git a/templates/settings.html b/templates/settings.html index 4708103..bcdde03 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -94,6 +94,7 @@

代理列表

+ @@ -104,6 +105,9 @@ + @@ -116,7 +120,7 @@ -
+ + ID 名称 类型
+
🌐
暂无代理
diff --git a/tests/test_codex_auth_flow.py b/tests/test_codex_auth_flow.py index 599ee9c..5fae698 100644 --- a/tests/test_codex_auth_flow.py +++ b/tests/test_codex_auth_flow.py @@ -1,7 +1,7 @@ from types import SimpleNamespace -import src.core.codex_auth as codex_auth_module -from src.core.codex_auth import CodexAuthEngine +import core.openai.codex_auth as codex_auth_module +from core.openai.codex_auth import CodexAuthEngine from src.core.register import PhaseResult, RegistrationEngine from src.services import EmailServiceType diff --git a/tests/test_login_engine_otp_retry.py b/tests/test_login_engine_otp_retry.py new file mode 100644 index 0000000..4de0f38 --- /dev/null +++ b/tests/test_login_engine_otp_retry.py @@ -0,0 +1,153 @@ +import src.core.register as register_module +from src.core.login import LoginEngine +from src.core.register import PhaseResult +from src.services import EmailServiceType + + +class DummySettings: + openai_client_id = "client-id" + openai_auth_url = "https://auth.example.test" + openai_token_url = "https://token.example.test" + openai_redirect_uri = "https://callback.example.test" + openai_scope = "openid profile email" + email_code_timeout = 120 + email_code_poll_interval = 3 + email_code_resend_max_retries = 2 + email_code_non_openai_sender_resend_max_retries = 1 + + +class FakeEmailService: + service_type = EmailServiceType.TEMPMAIL + + def get_verification_code(self, **kwargs): + raise AssertionError("unexpected direct email_service.get_verification_code call") + + +def _success_phase() -> PhaseResult: + return PhaseResult( + phase=register_module.PHASE_OTP_SECONDARY, + success=True, + ) + + +def _failed_phase(error_code: str, error_message: str) -> PhaseResult: + return PhaseResult( + phase=register_module.PHASE_OTP_SECONDARY, + success=False, + error_code=error_code, + error_message=error_message, + retryable=True, + next_action="resend_otp", + ) + + +def _build_login_engine(monkeypatch) -> LoginEngine: + monkeypatch.setattr(register_module, "get_settings", lambda: DummySettings()) + engine = LoginEngine(email_service=FakeEmailService()) + engine.session = type("FakeSession", (), {"cookies": type("FakeCookies", (), {"get": staticmethod(lambda _name: None)})()})() + return engine + + +def _stub_login_success_path(monkeypatch, engine: LoginEngine): + monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US")) + + def fake_create_email(): + engine.email = "tester@example.com" + engine.email_info = {"service_id": "svc-1"} + return True + + def fake_start_oauth(): + engine.oauth_start = type("OAuthStart", (), {"auth_url": "https://auth.example.test/authorize"})() + return True + + monkeypatch.setattr(engine, "_create_email", fake_create_email) + monkeypatch.setattr(engine, "_init_session", lambda: True) + monkeypatch.setattr(engine, "_start_oauth", fake_start_oauth) + monkeypatch.setattr(engine, "_get_device_id", lambda: "did-1") + monkeypatch.setattr(engine, "_check_sentinel", lambda _did: None) + monkeypatch.setattr( + engine, + "_submit_signup_form", + lambda _did, _sen_token: type("SignupResult", (), {"success": True, "error_message": ""})(), + ) + monkeypatch.setattr(engine, "_register_password", lambda: (True, "pass-123")) + monkeypatch.setattr(engine, "_validate_verification_code", lambda _code: True) + monkeypatch.setattr(engine, "_create_user_account", lambda: True) + monkeypatch.setattr(engine, "_follow_login_redirects", lambda _url: True) + monkeypatch.setattr(engine, "_submit_login_form", lambda _did, _sen_token: True) + monkeypatch.setattr(engine, "_get_workspace_id", lambda: "ws-1") + monkeypatch.setattr(engine, "_select_workspace", lambda _workspace_id: "https://auth.example.test/continue") + monkeypatch.setattr(engine, "_follow_redirects", lambda _url: "https://callback.example.test?code=abc&state=xyz") + monkeypatch.setattr( + engine, + "_handle_oauth_callback", + lambda _url: { + "account_id": "acct-1", + "access_token": "access-token", + "refresh_token": "refresh-token", + "id_token": "id-token", + }, + ) + monkeypatch.setattr(engine, "close", lambda: None) + + +def test_login_engine_run_uses_retryable_waiter_for_both_otp_steps(monkeypatch): + engine = _build_login_engine(monkeypatch) + _stub_login_success_path(monkeypatch, engine) + + send_calls = [] + wait_callbacks = [] + + def send_signup_code(): + send_calls.append("signup") + return True + + def send_passwordless_code(): + send_calls.append("passwordless") + return True + + monkeypatch.setattr(engine, "_send_verification_code", send_signup_code) + monkeypatch.setattr(engine, "_send_verification_code_passwordless", send_passwordless_code) + + wait_results = iter([ + ("111111", _success_phase()), + ("222222", _success_phase()), + ]) + + def fake_wait_for_code(resend_callback, **_kwargs): + wait_callbacks.append(resend_callback.__name__) + return next(wait_results) + + monkeypatch.setattr(engine, "_await_verification_code_with_resends", fake_wait_for_code) + + result = engine.run() + + assert result.success is True + assert send_calls == ["signup", "passwordless"] + assert wait_callbacks == ["send_signup_code", "send_passwordless_code"] + assert result.workspace_id == "ws-1" + assert result.account_id == "acct-1" + + +def test_login_engine_run_preserves_non_openai_sender_error(monkeypatch): + engine = _build_login_engine(monkeypatch) + _stub_login_success_path(monkeypatch, engine) + + monkeypatch.setattr(engine, "_send_verification_code", lambda: True) + monkeypatch.setattr( + engine, + "_await_verification_code_with_resends", + lambda _resend_callback, **_kwargs: ( + None, + _failed_phase("OTP_NO_OPENAI_SENDER", "当前邮件批次未发现 OpenAI 发件人"), + ), + ) + monkeypatch.setattr(engine, "close", lambda: None) + + result = engine.run() + + assert result.success is False + assert result.error_code == "OTP_NO_OPENAI_SENDER" + assert result.error_message == "当前邮件批次未发现 OpenAI 发件人" + + diff --git a/tests/test_proxy_batch_delete.py b/tests/test_proxy_batch_delete.py new file mode 100644 index 0000000..5edf6f4 --- /dev/null +++ b/tests/test_proxy_batch_delete.py @@ -0,0 +1,152 @@ +import asyncio +from contextlib import contextmanager + +import pytest +from fastapi import HTTPException + +from src.database import crud +from src.database.session import DatabaseSessionManager +from src.web.routes import settings as settings_routes + + +def _build_fake_get_db(manager): + @contextmanager + def fake_get_db(): + with manager.session_scope() as session: + yield session + + return fake_get_db + + +def _create_proxy_ids(manager, total: int) -> list[int]: + created_ids = [] + with manager.session_scope() as session: + for index in range(total): + proxy = crud.create_proxy( + session, + name=f"proxy-{index}", + type="http", + host=f"127.0.0.{index + 1}", + port=8000 + index, + ) + created_ids.append(proxy.id) + return created_ids + + +def test_batch_delete_proxies_removes_selected_ids(tmp_path, monkeypatch): + manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-batch-delete.db") + manager.create_tables() + manager.migrate_tables() + monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager)) + + proxy_ids = _create_proxy_ids(manager, 3) + + result = asyncio.run( + settings_routes.batch_delete_proxies( + settings_routes.ProxyBatchDeleteRequest(ids=[proxy_ids[0], proxy_ids[2]]) + ) + ) + + assert result["success"] is True + assert result["requested_count"] == 2 + assert result["deleted_count"] == 2 + assert result["not_found_ids"] == [] + + with manager.session_scope() as session: + remaining_ids = [proxy.id for proxy in crud.get_proxies(session)] + + assert remaining_ids == [proxy_ids[1]] + + +def test_batch_delete_proxies_reports_missing_ids(tmp_path, monkeypatch): + manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-batch-delete-missing.db") + manager.create_tables() + manager.migrate_tables() + monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager)) + + proxy_ids = _create_proxy_ids(manager, 1) + + result = asyncio.run( + settings_routes.batch_delete_proxies( + settings_routes.ProxyBatchDeleteRequest(ids=[proxy_ids[0], 99999, proxy_ids[0]]) + ) + ) + + assert result["requested_count"] == 2 + assert result["deleted_count"] == 1 + assert result["not_found_ids"] == [99999] + + +def test_batch_delete_proxies_requires_non_empty_selection(): + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + settings_routes.batch_delete_proxies( + settings_routes.ProxyBatchDeleteRequest(ids=[]) + ) + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "请至少选择一个代理" + + +def test_delete_disabled_proxy_items_only_removes_disabled_records(tmp_path, monkeypatch): + manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-delete-disabled.db") + manager.create_tables() + manager.migrate_tables() + monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager)) + + with manager.session_scope() as session: + enabled_proxy = crud.create_proxy( + session, + name="enabled-proxy", + type="http", + host="127.0.0.10", + port=8010, + enabled=True, + ) + crud.create_proxy( + session, + name="disabled-proxy-1", + type="http", + host="127.0.0.11", + port=8011, + enabled=False, + ) + crud.create_proxy( + session, + name="disabled-proxy-2", + type="http", + host="127.0.0.12", + port=8012, + enabled=False, + ) + enabled_proxy_id = enabled_proxy.id + + result = asyncio.run(settings_routes.delete_disabled_proxy_items()) + + assert result["success"] is True + assert result["deleted_count"] == 2 + + with manager.session_scope() as session: + remaining = [(proxy.id, proxy.enabled) for proxy in crud.get_proxies(session)] + + assert remaining == [(enabled_proxy_id, True)] + + +def test_delete_disabled_proxy_items_is_noop_when_none_exist(tmp_path, monkeypatch): + manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-delete-disabled-empty.db") + manager.create_tables() + manager.migrate_tables() + monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager)) + + _create_proxy_ids(manager, 2) + + result = asyncio.run(settings_routes.delete_disabled_proxy_items()) + + assert result["success"] is True + assert result["deleted_count"] == 0 + assert result["message"] == "已删除 0 个禁用代理" + + + + diff --git a/tests/test_registration_otp_phase.py b/tests/test_registration_otp_phase.py index a5a3678..49faf45 100644 --- a/tests/test_registration_otp_phase.py +++ b/tests/test_registration_otp_phase.py @@ -244,7 +244,7 @@ def test_run_keeps_timeout_budget_after_non_openai_sender_resend(monkeypatch): assert len(send_calls) == 4 -def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monkeypatch): +def test_advance_login_authorization_sets_otp_anchor_before_retryable_wait(monkeypatch): email_service = FakeEmailService(code=None) engine = _build_engine(monkeypatch, email_service) engine.oauth_start = object() @@ -264,11 +264,12 @@ def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monk monkeypatch.setattr(engine, "_submit_login_password_step", fake_submit_login_password_step) - def fake_get_verification_code(): + def fake_wait_for_verification_code(resend_callback, **_kwargs): seen_anchors.append(engine._otp_sent_at) - return None + assert resend_callback is engine._submit_login_password_step + return None, _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender") - monkeypatch.setattr(engine, "_get_verification_code", fake_get_verification_code) + monkeypatch.setattr(engine, "_await_verification_code_with_resends", fake_wait_for_verification_code) workspace_id, callback_url = engine._advance_login_authorization() diff --git a/tests/test_settings_email_code.py b/tests/test_settings_email_code.py new file mode 100644 index 0000000..ec8bab2 --- /dev/null +++ b/tests/test_settings_email_code.py @@ -0,0 +1,112 @@ +import asyncio +from types import SimpleNamespace + +from fastapi import HTTPException + +from src.web.routes import settings as settings_routes + + +class DummySecret: + def __init__(self, value: str = ""): + self._value = value + + def get_secret_value(self) -> str: + return self._value + + +def _build_settings(**overrides): + data = { + "proxy_enabled": False, + "proxy_type": "http", + "proxy_host": "127.0.0.1", + "proxy_port": 7890, + "proxy_username": "", + "proxy_password": DummySecret(""), + "proxy_dynamic_enabled": False, + "proxy_dynamic_api_url": "", + "proxy_dynamic_api_key_header": "Authorization", + "proxy_dynamic_result_field": "data.proxy", + "proxy_dynamic_api_key": DummySecret(""), + "registration_max_retries": 3, + "registration_timeout": 120, + "registration_default_password_length": 12, + "registration_sleep_min": 5, + "registration_sleep_max": 30, + "webui_host": "127.0.0.1", + "webui_port": 15555, + "debug": False, + "webui_access_password": DummySecret(""), + "tempmail_base_url": "https://mail.example.test", + "tempmail_timeout": 30, + "tempmail_max_retries": 3, + "email_code_timeout": 120, + "email_code_poll_interval": 3, + "email_code_resend_max_retries": 2, + "email_code_non_openai_sender_resend_max_retries": 1, + } + data.update(overrides) + return SimpleNamespace(**data) + + +def test_get_all_settings_includes_non_openai_sender_resend_budget(monkeypatch): + monkeypatch.setattr( + settings_routes, + "get_settings", + lambda: _build_settings(email_code_non_openai_sender_resend_max_retries=4), + ) + + result = asyncio.run(settings_routes.get_all_settings()) + + assert result["email_code"]["timeout"] == 120 + assert result["email_code"]["resend_max_retries"] == 2 + assert result["email_code"]["non_openai_sender_resend_max_retries"] == 4 + + +def test_update_email_code_settings_persists_non_openai_sender_budget(monkeypatch): + captured = {} + + monkeypatch.setattr( + settings_routes, + "update_settings", + lambda **kwargs: captured.update(kwargs), + ) + + response = asyncio.run( + settings_routes.update_email_code_settings( + settings_routes.EmailCodeSettings( + timeout=180, + poll_interval=5, + resend_max_retries=3, + non_openai_sender_resend_max_retries=2, + ) + ) + ) + + assert response["success"] is True + assert captured == { + "email_code_timeout": 180, + "email_code_poll_interval": 5, + "email_code_resend_max_retries": 3, + "email_code_non_openai_sender_resend_max_retries": 2, + } + + +def test_update_email_code_settings_rejects_invalid_non_openai_sender_budget(): + try: + asyncio.run( + settings_routes.update_email_code_settings( + settings_routes.EmailCodeSettings( + timeout=120, + poll_interval=3, + resend_max_retries=2, + non_openai_sender_resend_max_retries=11, + ) + ) + ) + except HTTPException as exc: + assert exc.status_code == 400 + assert exc.detail == "非 OpenAI 发件人重发次数必须在 0-10 之间" + return + + raise AssertionError("expected HTTPException for invalid non_openai_sender_resend_max_retries") + diff --git a/tests/test_settings_proxy_batch_delete.cjs b/tests/test_settings_proxy_batch_delete.cjs new file mode 100644 index 0000000..90e6ccc --- /dev/null +++ b/tests/test_settings_proxy_batch_delete.cjs @@ -0,0 +1,239 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SETTINGS_JS_PATH = path.join(__dirname, '..', 'static', 'js', 'settings.js'); + +function createClassList() { + const values = new Set(); + return { + add(...items) { + items.forEach((item) => values.add(item)); + }, + remove(...items) { + items.forEach((item) => values.delete(item)); + }, + contains(item) { + return values.has(item); + }, + }; +} + +function createElementStub(overrides = {}) { + return { + value: '', + checked: false, + disabled: false, + indeterminate: false, + innerHTML: '', + textContent: '', + style: {}, + dataset: {}, + classList: createClassList(), + addEventListener() {}, + removeEventListener() {}, + querySelectorAll() { + return []; + }, + querySelector() { + return null; + }, + reset() {}, + ...overrides, + }; +} + +function createSandbox() { + const elements = new Map(); + const proxyCheckboxes = []; + const toastCalls = []; + const apiCalls = []; + + function getElement(id) { + if (!elements.has(id)) { + elements.set(id, createElementStub({ id })); + } + return elements.get(id); + } + + const sandbox = { + console, + setTimeout, + clearTimeout, + __proxyCheckboxes: proxyCheckboxes, + __toastCalls: toastCalls, + __apiCalls: apiCalls, + document: { + getElementById(id) { + return getElement(id); + }, + querySelectorAll(selector) { + if (selector === '.proxy-checkbox') { + return proxyCheckboxes; + } + if (selector === '.proxy-checkbox:checked') { + return proxyCheckboxes.filter((checkbox) => checkbox.checked); + } + return []; + }, + addEventListener() {}, + }, + window: null, + api: { + get: async () => ({ proxies: [] }), + post: async (url, payload) => { + apiCalls.push({ url, payload }); + if (url === '/settings/proxies/delete-disabled') { + return { success: true, deleted_count: 2, message: '已删除 2 个禁用代理' }; + } + return { success: true, message: `已删除 ${payload.ids.length} 个代理`, not_found_ids: [] }; + }, + patch: async () => ({ success: true }), + delete: async () => ({ success: true }), + }, + toast: { + success(message) { + toastCalls.push({ type: 'success', message }); + }, + error(message) { + toastCalls.push({ type: 'error', message }); + }, + info(message) { + toastCalls.push({ type: 'info', message }); + }, + }, + confirm: async () => true, + format: { + date(value) { + return value || '-'; + }, + number(value) { + return String(value ?? 0); + }, + }, + }; + + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(SETTINGS_JS_PATH, 'utf8'), sandbox, { filename: 'settings.js' }); + return sandbox; +} + +test('updateSelectedProxies updates batch delete button state', () => { + const sandbox = createSandbox(); + sandbox.__proxyCheckboxes.push( + createElementStub({ checked: true, dataset: { id: '1' } }), + createElementStub({ checked: false, dataset: { id: '2' } }), + createElementStub({ checked: true, dataset: { id: '3' } }), + ); + + vm.runInContext('updateSelectedProxies()', sandbox); + + const batchDeleteBtn = sandbox.document.getElementById('batch-delete-proxies-btn'); + const selectAllProxies = sandbox.document.getElementById('select-all-proxies'); + + assert.equal(batchDeleteBtn.disabled, false); + assert.equal(batchDeleteBtn.textContent, '🗑️ 批量删除 (2)'); + assert.equal(selectAllProxies.checked, false); + assert.equal(selectAllProxies.indeterminate, true); +}); + +test('handleBatchDeleteProxies posts selected proxy ids and reloads list', async () => { + const sandbox = createSandbox(); + sandbox.__proxyCheckboxes.push( + createElementStub({ checked: true, dataset: { id: '11' } }), + createElementStub({ checked: true, dataset: { id: '12' } }), + ); + + vm.runInContext('updateSelectedProxies()', sandbox); + vm.runInContext('loadProxies = async () => { globalThis.__reloaded = true; }', sandbox); + + await vm.runInContext('handleBatchDeleteProxies()', sandbox); + + assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), [ + { + url: '/settings/proxies/batch-delete', + payload: { ids: [11, 12] }, + }, + ]); + assert.equal(sandbox.__reloaded, true); + assert.equal(sandbox.__toastCalls.at(-1).type, 'success'); +}); + +test('handleBatchDeleteProxies rejects empty selection without api call', async () => { + const sandbox = createSandbox(); + + await vm.runInContext('handleBatchDeleteProxies()', sandbox); + + assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []); + assert.equal(sandbox.__toastCalls.at(-1).type, 'error'); + assert.equal(sandbox.__toastCalls.at(-1).message, '请先选择要删除的代理'); +}); + +test('handleBatchDeleteProxies stops when user cancels confirmation', async () => { + const sandbox = createSandbox(); + sandbox.confirm = async () => false; + sandbox.__proxyCheckboxes.push( + createElementStub({ checked: true, dataset: { id: '21' } }), + ); + + vm.runInContext('updateSelectedProxies()', sandbox); + await vm.runInContext('handleBatchDeleteProxies()', sandbox); + + assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []); + assert.equal(sandbox.__toastCalls.length, 0); +}); + +test('updateProxyBatchActions enables delete-disabled button based on disabled count', () => { + const sandbox = createSandbox(); + + vm.runInContext('disabledProxyCount = 3; updateProxyBatchActions()', sandbox); + + const deleteDisabledBtn = sandbox.document.getElementById('delete-disabled-proxies-btn'); + assert.equal(deleteDisabledBtn.disabled, false); + assert.equal(deleteDisabledBtn.textContent, '🧹 删除禁用项 (3)'); +}); + +test('handleDeleteDisabledProxies posts dedicated route and reloads list', async () => { + const sandbox = createSandbox(); + vm.runInContext('disabledProxyCount = 2', sandbox); + vm.runInContext('loadProxies = async () => { globalThis.__reloadedDisabled = true; }', sandbox); + + await vm.runInContext('handleDeleteDisabledProxies()', sandbox); + + assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), [ + { + url: '/settings/proxies/delete-disabled', + }, + ]); + assert.equal(sandbox.__reloadedDisabled, true); + assert.equal(sandbox.__toastCalls.at(-1).message, '已删除 2 个禁用代理'); +}); + +test('handleDeleteDisabledProxies rejects when there are no disabled proxies', async () => { + const sandbox = createSandbox(); + + await vm.runInContext('handleDeleteDisabledProxies()', sandbox); + + assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []); + assert.equal(sandbox.__toastCalls.at(-1).message, '当前没有可删除的禁用代理'); +}); + +test('handleDeleteDisabledProxies stops when user cancels confirmation', async () => { + const sandbox = createSandbox(); + sandbox.confirm = async () => false; + vm.runInContext('disabledProxyCount = 2', sandbox); + + await vm.runInContext('handleDeleteDisabledProxies()', sandbox); + + assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []); + assert.equal(sandbox.__toastCalls.length, 0); +}); + + + + +