test_setup_wizard.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. from __future__ import annotations
  2. from dataclasses import dataclass
  3. import core.setup_wizard as setup_wizard
  4. from core.setup_wizard import run_setup_wizard
  5. class _Elapsed:
  6. def __init__(self, seconds: float) -> None:
  7. self._seconds = seconds
  8. def total_seconds(self) -> float:
  9. return self._seconds
  10. class _Response:
  11. def __init__(self, *, elapsed: float = 0.085, status_code: int = 200) -> None:
  12. self.elapsed = _Elapsed(elapsed)
  13. self.status_code = status_code
  14. class _HttpxStub:
  15. def __init__(self) -> None:
  16. self.calls: list[tuple[str, dict]] = []
  17. def get(self, url: str, **kwargs): # type: ignore[no-untyped-def]
  18. self.calls.append((url, kwargs))
  19. if "api.openai.com" in url:
  20. return _Response(elapsed=0.085, status_code=200)
  21. raise AssertionError(f"unexpected url: {url}")
  22. @dataclass(frozen=True)
  23. class _PreparedCfmail:
  24. api_token: str
  25. account_id: str
  26. zone_id: str
  27. worker_name: str
  28. worker_domain: str
  29. zone_name: str
  30. email_domain: str
  31. admin_password: str
  32. d1_name: str
  33. d1_database_id: str
  34. def test_run_setup_wizard_writes_lite_cfmail_minimal_flow(tmp_path, monkeypatch):
  35. env_file = tmp_path / ".env"
  36. env_file.write_text(
  37. "\n".join(
  38. [
  39. "ZHUCE6_HOST=0.0.0.0",
  40. "ZHUCE6_PORT=9000",
  41. "ZHUCE6_REGISTER_MAIL_PROVIDER=cfmail",
  42. f"ZHUCE6_CFMAIL_CONFIG_PATH={tmp_path / 'config' / 'cfmail_accounts.json'}",
  43. f"ZHUCE6_CFMAIL_ENV_FILE={tmp_path / 'config' / 'cfmail_provision.env'}",
  44. ]
  45. )
  46. + "\n",
  47. encoding="utf-8",
  48. )
  49. httpx_stub = _HttpxStub()
  50. monkeypatch.setattr(setup_wizard, "httpx", httpx_stub)
  51. monkeypatch.setattr(
  52. setup_wizard,
  53. "_validate_cloudflare_credentials",
  54. lambda print_fn, **_kwargs: print_fn(" ✅ Cloudflare 凭据有效"),
  55. )
  56. def fake_prepare_runtime_cfmail_config(**kwargs): # type: ignore[no-untyped-def]
  57. accounts_path = kwargs["accounts_path"]
  58. env_path = kwargs["provision_env_path"]
  59. accounts_path.parent.mkdir(parents=True, exist_ok=True)
  60. accounts_path.write_text('[{"name":"worker-one","worker_domain":"worker-one.demo.workers.dev","email_domain":"mail.example.com","admin_password":"super-secret","enabled":true}]\n', encoding="utf-8")
  61. env_path.parent.mkdir(parents=True, exist_ok=True)
  62. env_path.write_text(
  63. '\n'.join(
  64. [
  65. 'export ZHUCE6_CFMAIL_API_TOKEN="cf-token"',
  66. 'export ZHUCE6_CFMAIL_CF_ACCOUNT_ID="account-1"',
  67. 'export ZHUCE6_CFMAIL_CF_ZONE_ID="zone-1"',
  68. 'export ZHUCE6_CFMAIL_WORKER_NAME="worker-one"',
  69. 'export ZHUCE6_CFMAIL_ZONE_NAME="example.com"',
  70. "",
  71. ]
  72. ),
  73. encoding="utf-8",
  74. )
  75. return _PreparedCfmail(
  76. api_token="cf-token",
  77. account_id="account-1",
  78. zone_id="zone-1",
  79. worker_name="worker-one",
  80. worker_domain="worker-one.demo.workers.dev",
  81. zone_name="example.com",
  82. email_domain="mail.example.com",
  83. admin_password="super-secret",
  84. d1_name="zhuce6-cfmail-db",
  85. d1_database_id="db-1",
  86. )
  87. monkeypatch.setattr(setup_wizard.setup_cfmail, "prepare_runtime_cfmail_config", fake_prepare_runtime_cfmail_config)
  88. answers = iter(
  89. [
  90. "", # mode -> lite
  91. "", # host -> existing default
  92. "9100",
  93. "cfmail",
  94. "y",
  95. "1",
  96. "socks5://10.0.0.1:1080",
  97. "cf-token",
  98. "example.com",
  99. "worker-one",
  100. "mail.example.com",
  101. "super-secret",
  102. "y",
  103. ]
  104. )
  105. captured: list[str] = []
  106. result = run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  107. env_content = env_file.read_text(encoding="utf-8")
  108. assert "ZHUCE6_RUN_MODE=lite" in env_content
  109. assert "ZHUCE6_BACKEND=cpa" in env_content
  110. assert "ZHUCE6_PORT=9100" in env_content
  111. assert "ZHUCE6_PROXY_POOL_DIRECT_URLS=socks5://10.0.0.1:1080" in env_content
  112. assert "ZHUCE6_CFMAIL_API_TOKEN=cf-token" in env_content
  113. assert "ZHUCE6_CFMAIL_CF_ACCOUNT_ID=account-1" in env_content
  114. assert "ZHUCE6_CFMAIL_WORKER_NAME=worker-one" in env_content
  115. assert "ZHUCE6_D1_DATABASE_ID=db-1" in env_content
  116. assert "ZHUCE6_MAIN_POOL_TARGET" not in env_content
  117. assert result.cfmail_accounts_path == tmp_path / "config" / "cfmail_accounts.json"
  118. assert result.cfmail_env_path == tmp_path / "config" / "cfmail_provision.env"
  119. assert any("[1/5] 运行模式与后端" in line for line in captured)
  120. assert any("下一步:" in line for line in captured)
  121. assert any("doctor --fix" in line for line in captured)
  122. assert [url for url, _kwargs in httpx_stub.calls] == ["https://api.openai.com"]
  123. def test_run_setup_wizard_full_cpa_writes_backend_config(tmp_path, monkeypatch):
  124. env_file = tmp_path / ".env"
  125. env_file.write_text("", encoding="utf-8")
  126. monkeypatch.setattr(setup_wizard, "httpx", _HttpxStub())
  127. answers = iter(
  128. [
  129. "full",
  130. "cpa",
  131. "127.0.0.1",
  132. "8000",
  133. "mailtm",
  134. "y",
  135. "1",
  136. "http://127.0.0.1:7899",
  137. "http://127.0.0.1:8317/v0/management",
  138. "mgmt-key",
  139. "y",
  140. ]
  141. )
  142. captured: list[str] = []
  143. run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  144. env_content = env_file.read_text(encoding="utf-8")
  145. assert "ZHUCE6_RUN_MODE=full" in env_content
  146. assert "ZHUCE6_BACKEND=cpa" in env_content
  147. assert "ZHUCE6_CPA_MANAGEMENT_BASE_URL=http://127.0.0.1:8317/v0/management" in env_content
  148. assert "ZHUCE6_CPA_MANAGEMENT_KEY=mgmt-key" in env_content
  149. assert "ZHUCE6_SUB2API_BASE_URL" not in env_content
  150. assert any("后端:" in line and "cpa" in line for line in captured)
  151. def test_run_setup_wizard_full_sub2api_writes_backend_config(tmp_path, monkeypatch):
  152. env_file = tmp_path / ".env"
  153. env_file.write_text("", encoding="utf-8")
  154. monkeypatch.setattr(setup_wizard, "httpx", _HttpxStub())
  155. answers = iter(
  156. [
  157. "full",
  158. "sub2api",
  159. "127.0.0.1",
  160. "8000",
  161. "mailtm",
  162. "n",
  163. "http://127.0.0.1:7899",
  164. "http://127.0.0.1:8080",
  165. "password",
  166. "admin@sub2api.local",
  167. "secret",
  168. "y",
  169. ]
  170. )
  171. captured: list[str] = []
  172. run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  173. env_content = env_file.read_text(encoding="utf-8")
  174. assert "ZHUCE6_RUN_MODE=full" in env_content
  175. assert "ZHUCE6_BACKEND=sub2api" in env_content
  176. assert "ZHUCE6_SUB2API_BASE_URL=http://127.0.0.1:8080" in env_content
  177. assert "ZHUCE6_SUB2API_ADMIN_EMAIL=admin@sub2api.local" in env_content
  178. assert "ZHUCE6_SUB2API_ADMIN_PASSWORD=secret" in env_content
  179. assert "ZHUCE6_CPA_MANAGEMENT_BASE_URL" not in env_content
  180. assert any("后端:" in line and "sub2api" in line for line in captured)
  181. def test_run_setup_wizard_reuses_existing_cfmail_config(tmp_path, monkeypatch):
  182. env_file = tmp_path / ".env"
  183. config_dir = tmp_path / "config"
  184. config_dir.mkdir()
  185. (config_dir / "cfmail_accounts.json").write_text(
  186. '[{"name":"existing-worker","worker_domain":"existing.workers.dev","email_domain":"mail.example.com","admin_password":"keep-me","enabled":true}]\n',
  187. encoding="utf-8",
  188. )
  189. (config_dir / "cfmail_provision.env").write_text(
  190. '\n'.join(
  191. [
  192. 'export ZHUCE6_CFMAIL_API_TOKEN="old-token"',
  193. 'export ZHUCE6_CFMAIL_CF_ACCOUNT_ID="account-old"',
  194. 'export ZHUCE6_CFMAIL_CF_ZONE_ID="zone-old"',
  195. 'export ZHUCE6_CFMAIL_WORKER_NAME="existing-worker"',
  196. 'export ZHUCE6_CFMAIL_ZONE_NAME="example.com"',
  197. 'export ZHUCE6_D1_DATABASE_ID="db-old"',
  198. "",
  199. ]
  200. ),
  201. encoding="utf-8",
  202. )
  203. env_file.write_text(
  204. "\n".join(
  205. [
  206. "ZHUCE6_REGISTER_MAIL_PROVIDER=cfmail",
  207. f"ZHUCE6_CFMAIL_CONFIG_PATH={config_dir / 'cfmail_accounts.json'}",
  208. f"ZHUCE6_CFMAIL_ENV_FILE={config_dir / 'cfmail_provision.env'}",
  209. ]
  210. )
  211. + "\n",
  212. encoding="utf-8",
  213. )
  214. monkeypatch.setattr(setup_wizard, "httpx", _HttpxStub())
  215. monkeypatch.setattr(
  216. setup_wizard.setup_cfmail,
  217. "prepare_runtime_cfmail_config",
  218. lambda **_kwargs: (_ for _ in ()).throw(AssertionError("should not regenerate cfmail config")),
  219. )
  220. answers = iter(
  221. [
  222. "lite",
  223. "127.0.0.1",
  224. "8000",
  225. "cfmail",
  226. "n",
  227. "http://127.0.0.1:7899",
  228. "y",
  229. "y",
  230. ]
  231. )
  232. captured: list[str] = []
  233. run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  234. env_content = env_file.read_text(encoding="utf-8")
  235. assert "ZHUCE6_CFMAIL_API_TOKEN=old-token" in env_content
  236. assert "ZHUCE6_CFMAIL_CF_ACCOUNT_ID=account-old" in env_content
  237. assert "ZHUCE6_D1_DATABASE_ID=db-old" in env_content
  238. assert any("复用现有 worker" in line for line in captured)
  239. def test_run_setup_wizard_accepts_legacy_cfmail_global_key_for_fresh_init(tmp_path, monkeypatch):
  240. env_file = tmp_path / ".env"
  241. env_file.write_text("", encoding="utf-8")
  242. httpx_stub = _HttpxStub()
  243. monkeypatch.setattr(setup_wizard, "httpx", httpx_stub)
  244. monkeypatch.setattr(
  245. setup_wizard,
  246. "_validate_cloudflare_credentials",
  247. lambda print_fn, **_kwargs: print_fn(" ✅ Cloudflare 凭据有效"),
  248. )
  249. captured_prepare: dict[str, object] = {}
  250. def fake_prepare_runtime_cfmail_config(**kwargs): # type: ignore[no-untyped-def]
  251. captured_prepare.update(kwargs)
  252. accounts_path = kwargs["accounts_path"]
  253. env_path = kwargs["provision_env_path"]
  254. accounts_path.parent.mkdir(parents=True, exist_ok=True)
  255. accounts_path.write_text(
  256. '[{"name":"worker-one","worker_domain":"worker-one.demo.workers.dev","email_domain":"mail.example.com","admin_password":"super-secret","enabled":true}]\n',
  257. encoding="utf-8",
  258. )
  259. env_path.parent.mkdir(parents=True, exist_ok=True)
  260. env_path.write_text(
  261. '\n'.join(
  262. [
  263. 'export ZHUCE6_CFMAIL_API_TOKEN=""',
  264. 'export ZHUCE6_CFMAIL_CF_AUTH_EMAIL="cf@example.com"',
  265. 'export ZHUCE6_CFMAIL_CF_AUTH_KEY="global-key"',
  266. 'export ZHUCE6_CFMAIL_CF_ACCOUNT_ID="account-1"',
  267. 'export ZHUCE6_CFMAIL_CF_ZONE_ID="zone-1"',
  268. 'export ZHUCE6_CFMAIL_WORKER_NAME="worker-one"',
  269. 'export ZHUCE6_CFMAIL_ZONE_NAME="example.com"',
  270. 'export ZHUCE6_D1_DATABASE_ID="db-1"',
  271. "",
  272. ]
  273. ),
  274. encoding="utf-8",
  275. )
  276. return _PreparedCfmail(
  277. api_token="",
  278. account_id="account-1",
  279. zone_id="zone-1",
  280. worker_name="worker-one",
  281. worker_domain="worker-one.demo.workers.dev",
  282. zone_name="example.com",
  283. email_domain="mail.example.com",
  284. admin_password="super-secret",
  285. d1_name="zhuce6-cfmail-db",
  286. d1_database_id="db-1",
  287. )
  288. monkeypatch.setattr(setup_wizard.setup_cfmail, "prepare_runtime_cfmail_config", fake_prepare_runtime_cfmail_config)
  289. answers = iter(
  290. [
  291. "lite",
  292. "127.0.0.1",
  293. "9100",
  294. "cfmail",
  295. "n",
  296. "http://127.0.0.1:7899",
  297. "", # token left blank
  298. "cf@example.com",
  299. "global-key",
  300. "email-api.example.com",
  301. "example.com",
  302. "worker-one",
  303. "mail.example.com",
  304. "super-secret",
  305. "y",
  306. ]
  307. )
  308. captured: list[str] = []
  309. run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  310. env_content = env_file.read_text(encoding="utf-8")
  311. assert "ZHUCE6_CFMAIL_API_TOKEN=" in env_content
  312. assert "ZHUCE6_CFMAIL_CF_AUTH_EMAIL=cf@example.com" in env_content
  313. assert "ZHUCE6_CFMAIL_CF_AUTH_KEY=global-key" in env_content
  314. assert "ZHUCE6_D1_DATABASE_ID=db-1" in env_content
  315. assert captured_prepare["api_token"] == ""
  316. assert captured_prepare["auth_email"] == "cf@example.com"
  317. assert captured_prepare["auth_key"] == "global-key"
  318. assert captured_prepare["worker_domain"] == "email-api.example.com"
  319. assert any("Cloudflare 全局 Key" in line for line in captured)
  320. def test_run_setup_wizard_clash_mode_prints_sslocal_guidance_when_missing(monkeypatch, tmp_path):
  321. env_file = tmp_path / ".env"
  322. env_file.write_text("", encoding="utf-8")
  323. monkeypatch.setattr(setup_wizard, "httpx", _HttpxStub())
  324. monkeypatch.setattr(setup_wizard.shutil, "which", lambda _name: None)
  325. answers = iter(
  326. [
  327. "full",
  328. "cpa",
  329. "127.0.0.1",
  330. "8000",
  331. "mailtm",
  332. "y",
  333. "2",
  334. str(tmp_path / "clash.yaml"),
  335. "http://127.0.0.1:8317/v0/management",
  336. "",
  337. "y",
  338. ]
  339. )
  340. captured: list[str] = []
  341. run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  342. env_content = env_file.read_text(encoding="utf-8")
  343. assert f"ZHUCE6_PROXY_POOL_CONFIG={tmp_path / 'clash.yaml'}" in env_content
  344. assert any("shadowsocks-rust" in line for line in captured)
  345. def test_run_setup_wizard_decline_save_does_not_write_env_or_cfmail_files(tmp_path, monkeypatch):
  346. env_file = tmp_path / ".env"
  347. env_file.write_text("ORIGINAL=1\n", encoding="utf-8")
  348. monkeypatch.setattr(setup_wizard, "httpx", _HttpxStub())
  349. monkeypatch.setattr(
  350. setup_wizard,
  351. "_validate_cloudflare_credentials",
  352. lambda print_fn, **_kwargs: print_fn(" ✅ Cloudflare 凭据有效"),
  353. )
  354. monkeypatch.setattr(
  355. setup_wizard.setup_cfmail,
  356. "prepare_runtime_cfmail_config",
  357. lambda **_kwargs: _PreparedCfmail(
  358. api_token="cf-token",
  359. account_id="account-1",
  360. zone_id="zone-1",
  361. worker_name="worker-one",
  362. worker_domain="worker-one.demo.workers.dev",
  363. zone_name="example.com",
  364. email_domain="mail.example.com",
  365. admin_password="secret",
  366. d1_name="db",
  367. d1_database_id="db-1",
  368. ),
  369. )
  370. answers = iter(
  371. [
  372. "lite",
  373. "127.0.0.1",
  374. "8000",
  375. "cfmail",
  376. "n",
  377. "http://127.0.0.1:7899",
  378. "cf-token",
  379. "example.com",
  380. "worker-one",
  381. "mail.example.com",
  382. "secret",
  383. "n",
  384. ]
  385. )
  386. captured: list[str] = []
  387. result = run_setup_wizard(env_file=env_file, input_fn=lambda _prompt: next(answers), print_fn=captured.append)
  388. assert env_file.read_text(encoding="utf-8") == "ORIGINAL=1\n"
  389. assert not (tmp_path / "config" / "cfmail_accounts.json").exists()
  390. assert not (tmp_path / "config" / "cfmail_provision.env").exists()
  391. assert result.cfmail_accounts_path is None
  392. assert result.cfmail_env_path is None
  393. assert any("已取消保存" in line for line in captured)