feat(proxy): add batch delete functionality for proxies and enhance verification code handling

This commit is contained in:
cnlimiter
2026-03-28 04:13:15 +08:00
parent 51922ef2a6
commit 8b8ef7c6c0
14 changed files with 996 additions and 97 deletions

View File

@@ -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

View File

@@ -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 发件人"

View File

@@ -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 个禁用代理"

View File

@@ -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()

View File

@@ -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")

View File

@@ -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);
});