test_setup_cfmail.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. from __future__ import annotations
  2. import json
  3. import sys
  4. import tomllib
  5. from core.cfmail import build_cfmail_accounts, load_cfmail_accounts_from_file
  6. from scripts import setup_cfmail
  7. def test_write_cfmail_accounts_json_is_runtime_compatible(tmp_path):
  8. output_path = tmp_path / "cfmail_accounts.json"
  9. setup_cfmail.write_cfmail_accounts_json(
  10. output_path,
  11. worker_domain="zhuce6-cfmail.demo-subdomain.workers.dev",
  12. email_domain="example.com",
  13. worker_name="zhuce6-cfmail",
  14. admin_password="secret-admin-password",
  15. )
  16. raw_accounts = load_cfmail_accounts_from_file(output_path)
  17. normalized = build_cfmail_accounts(raw_accounts)
  18. assert json.loads(output_path.read_text(encoding="utf-8")) == [
  19. {
  20. "name": "zhuce6-cfmail",
  21. "worker_domain": "zhuce6-cfmail.demo-subdomain.workers.dev",
  22. "email_domain": "example.com",
  23. "admin_password": "secret-admin-password",
  24. "enabled": True,
  25. }
  26. ]
  27. assert len(normalized) == 1
  28. assert normalized[0].name == "zhuce6-cfmail"
  29. assert normalized[0].worker_domain == "zhuce6-cfmail.demo-subdomain.workers.dev"
  30. assert normalized[0].email_domain == "example.com"
  31. assert normalized[0].admin_password == "secret-admin-password"
  32. def test_write_cfmail_provision_env_contains_required_exports(tmp_path):
  33. output_path = tmp_path / "cfmail_provision.env"
  34. setup_cfmail.write_cfmail_provision_env(
  35. output_path,
  36. api_token="cf-token",
  37. account_id="account-123",
  38. zone_id="zone-456",
  39. worker_name="zhuce6-cfmail",
  40. zone_name="example.com",
  41. d1_database_id="db-789",
  42. )
  43. content = output_path.read_text(encoding="utf-8")
  44. assert 'export ZHUCE6_CFMAIL_API_TOKEN="cf-token"' in content
  45. assert 'export ZHUCE6_CFMAIL_CF_AUTH_EMAIL=""' in content
  46. assert 'export ZHUCE6_CFMAIL_CF_AUTH_KEY=""' in content
  47. assert 'export ZHUCE6_CFMAIL_CF_ACCOUNT_ID="account-123"' in content
  48. assert 'export ZHUCE6_CFMAIL_CF_ZONE_ID="zone-456"' in content
  49. assert 'export ZHUCE6_CFMAIL_WORKER_NAME="zhuce6-cfmail"' in content
  50. assert 'export ZHUCE6_CFMAIL_ZONE_NAME="example.com"' in content
  51. assert 'export ZHUCE6_D1_DATABASE_ID="db-789"' in content
  52. def test_write_worker_wrangler_emits_runtime_required_vars(tmp_path):
  53. worker_dir = tmp_path / "worker"
  54. worker_dir.mkdir()
  55. wrangler_path = setup_cfmail.write_worker_wrangler(
  56. worker_dir=worker_dir,
  57. worker_name="zhuce6-cfmail",
  58. account_id="account-123",
  59. database_id="db-456",
  60. database_name="zhuce6-cfmail-db",
  61. email_domain="example.com",
  62. admin_password="secret-admin-password",
  63. jwt_secret="secret-jwt",
  64. compatibility_date="2025-04-01",
  65. )
  66. document = tomllib.loads(wrangler_path.read_text(encoding="utf-8"))
  67. assert document["name"] == "zhuce6-cfmail"
  68. assert document["account_id"] == "account-123"
  69. assert document["main"] == "src/worker.ts"
  70. assert document["compatibility_date"] == "2025-04-01"
  71. assert document["vars"]["DOMAINS"] == ["example.com"]
  72. assert document["vars"]["DEFAULT_DOMAINS"] == ["example.com"]
  73. assert document["vars"]["ADMIN_PASSWORDS"] == ["secret-admin-password"]
  74. assert document["vars"]["JWT_SECRET"] == "secret-jwt"
  75. assert document["d1_databases"][0]["binding"] == "DB"
  76. assert document["d1_databases"][0]["database_id"] == "db-456"
  77. assert document["d1_databases"][0]["database_name"] == "zhuce6-cfmail-db"
  78. def test_build_parser_exposes_expected_defaults():
  79. parser = setup_cfmail.build_parser()
  80. args = parser.parse_args(["--api-token", "cfpat_xxx", "--zone-name", "example.com"])
  81. assert args.worker_name == "zhuce6-cfmail"
  82. assert args.d1_name == "zhuce6-cfmail-db"
  83. assert args.mail_domain is None
  84. assert args.skip_clone is False
  85. def test_build_parser_supports_legacy_cloudflare_global_key_args():
  86. parser = setup_cfmail.build_parser()
  87. args = parser.parse_args(
  88. [
  89. "--auth-email",
  90. "cf@example.com",
  91. "--auth-key",
  92. "global-key",
  93. "--zone-name",
  94. "example.com",
  95. ]
  96. )
  97. assert args.api_token == ""
  98. assert args.auth_email == "cf@example.com"
  99. assert args.auth_key == "global-key"
  100. assert args.zone_name == "example.com"
  101. def test_cloudflare_client_supports_legacy_global_key_auth(monkeypatch) -> None:
  102. captured: dict[str, object] = {}
  103. class FakeClient:
  104. def __init__(self, **kwargs): # type: ignore[no-untyped-def]
  105. captured.update(kwargs)
  106. def close(self) -> None:
  107. return
  108. class FakeHttpxModule:
  109. Client = FakeClient
  110. HTTPError = RuntimeError
  111. monkeypatch.setitem(sys.modules, "httpx", FakeHttpxModule())
  112. client = setup_cfmail.CloudflareClient("", auth_email="cf@example.com", auth_key="global-key")
  113. client.close()
  114. headers = captured["headers"]
  115. assert headers["X-Auth-Email"] == "cf@example.com"
  116. assert headers["X-Auth-Key"] == "global-key"
  117. assert "Authorization" not in headers
  118. def test_cloudflare_client_verify_token_uses_user_endpoint_for_legacy_global_key(monkeypatch) -> None:
  119. requested: list[tuple[str, str]] = []
  120. class FakeResponse:
  121. status_code = 200
  122. is_success = True
  123. text = ""
  124. @staticmethod
  125. def json() -> dict[str, object]:
  126. return {
  127. "success": True,
  128. "result": {
  129. "id": "user-1",
  130. "email": "cf@example.com",
  131. },
  132. }
  133. class FakeClient:
  134. def __init__(self, **_kwargs): # type: ignore[no-untyped-def]
  135. return
  136. def request(self, method, path, params=None, json=None): # type: ignore[no-untyped-def]
  137. requested.append((method, path))
  138. return FakeResponse()
  139. def close(self) -> None:
  140. return
  141. class FakeHttpxModule:
  142. Client = FakeClient
  143. HTTPError = RuntimeError
  144. monkeypatch.setitem(sys.modules, "httpx", FakeHttpxModule())
  145. with setup_cfmail.CloudflareClient("", auth_email="cf@example.com", auth_key="global-key") as client:
  146. result = client.verify_token()
  147. assert requested == [("GET", "/user")]
  148. assert result["status"] == "active"
  149. assert result["email"] == "cf@example.com"
  150. def test_prepare_runtime_cfmail_config_accepts_existing_worker_domain_override(monkeypatch, tmp_path) -> None:
  151. requested: list[tuple[str, str]] = []
  152. class FakeCloudflareClient:
  153. def __init__(self, api_token, *, auth_email="", auth_key="", timeout=30.0): # type: ignore[no-untyped-def]
  154. assert api_token == ""
  155. assert auth_email == "cf@example.com"
  156. assert auth_key == "global-key"
  157. def __enter__(self): # type: ignore[no-untyped-def]
  158. return self
  159. def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
  160. return
  161. def verify_token(self): # type: ignore[no-untyped-def]
  162. requested.append(("GET", "/user"))
  163. return {"status": "active"}
  164. def resolve_zone(self, zone_name): # type: ignore[no-untyped-def]
  165. assert zone_name == "example.com"
  166. return {"id": "zone-1", "account": {"id": "account-1"}}
  167. def ensure_d1_database(self, account_id, database_name): # type: ignore[no-untyped-def]
  168. assert account_id == "account-1"
  169. assert database_name == "zhuce6-cfmail-db"
  170. return {"uuid": "db-1"}
  171. def get_workers_subdomain(self, account_id): # type: ignore[no-untyped-def]
  172. raise AssertionError("worker_domain override path must not call get_workers_subdomain")
  173. class FakeProvisioner:
  174. def __init__(self, *, config_path, settings, proxy_url=None): # type: ignore[no-untyped-def]
  175. self.smoke_calls = []
  176. def smoke_test(self, worker_domain, admin_password, email_domain): # type: ignore[no-untyped-def]
  177. self.smoke_calls.append((worker_domain, admin_password, email_domain))
  178. return
  179. def rotate_active_domain(self): # type: ignore[no-untyped-def]
  180. raise AssertionError("valid override path must not rotate")
  181. monkeypatch.setattr(setup_cfmail, "CloudflareClient", FakeCloudflareClient)
  182. monkeypatch.setattr(setup_cfmail, "CfmailProvisioner", FakeProvisioner)
  183. accounts_path = tmp_path / "config" / "cfmail_accounts.json"
  184. provision_env_path = tmp_path / "config" / "cfmail_provision.env"
  185. result = setup_cfmail.prepare_runtime_cfmail_config(
  186. api_token="",
  187. auth_email="cf@example.com",
  188. auth_key="global-key",
  189. worker_domain="email-api.example.com",
  190. zone_name="example.com",
  191. worker_name="worker-one",
  192. mail_domain="mail.example.com",
  193. admin_password="super-secret",
  194. accounts_path=accounts_path,
  195. provision_env_path=provision_env_path,
  196. )
  197. assert requested == [("GET", "/user")]
  198. assert result.worker_domain == "email-api.example.com"
  199. assert '"worker_domain": "email-api.example.com"' in accounts_path.read_text(encoding="utf-8")
  200. def test_prepare_runtime_cfmail_config_rotates_when_existing_domain_is_invalid(monkeypatch, tmp_path) -> None:
  201. class FakeCloudflareClient:
  202. def __init__(self, api_token, *, auth_email="", auth_key="", timeout=30.0): # type: ignore[no-untyped-def]
  203. assert api_token == ""
  204. assert auth_email == "cf@example.com"
  205. assert auth_key == "global-key"
  206. def __enter__(self): # type: ignore[no-untyped-def]
  207. return self
  208. def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
  209. return
  210. def verify_token(self): # type: ignore[no-untyped-def]
  211. return {"status": "active"}
  212. def resolve_zone(self, zone_name): # type: ignore[no-untyped-def]
  213. return {"id": "zone-1", "account": {"id": "account-1"}}
  214. def ensure_d1_database(self, account_id, database_name): # type: ignore[no-untyped-def]
  215. return {"uuid": "db-1"}
  216. def get_workers_subdomain(self, account_id): # type: ignore[no-untyped-def]
  217. raise AssertionError("worker_domain override path must not call get_workers_subdomain")
  218. class FakeProvisionResult:
  219. def __init__(self, success: bool, new_domain: str, error: str = "") -> None:
  220. self.success = success
  221. self.new_domain = new_domain
  222. self.error = error
  223. class FakeProvisioner:
  224. last_instance = None
  225. def __init__(self, *, config_path, settings, proxy_url=None): # type: ignore[no-untyped-def]
  226. self.config_path = config_path
  227. self.settings = settings
  228. self.proxy_url = proxy_url
  229. self.smoke_calls = []
  230. self.rotate_calls = 0
  231. FakeProvisioner.last_instance = self
  232. def smoke_test(self, worker_domain, admin_password, email_domain): # type: ignore[no-untyped-def]
  233. self.smoke_calls.append((worker_domain, admin_password, email_domain))
  234. raise RuntimeError("HTTP 400 创建邮箱地址失败: 无效的域名")
  235. def rotate_active_domain(self): # type: ignore[no-untyped-def]
  236. self.rotate_calls += 1
  237. self.config_path.write_text(
  238. '[{\"name\":\"worker-one\",\"worker_domain\":\"email-api.example.com\",\"email_domain\":\"auto-fresh.example.com\",\"admin_password\":\"super-secret\",\"enabled\":true}]\\n',
  239. encoding="utf-8",
  240. )
  241. return FakeProvisionResult(True, "auto-fresh.example.com")
  242. monkeypatch.setattr(setup_cfmail, "CloudflareClient", FakeCloudflareClient)
  243. monkeypatch.setattr(setup_cfmail, "CfmailProvisioner", FakeProvisioner)
  244. accounts_path = tmp_path / "config" / "cfmail_accounts.json"
  245. provision_env_path = tmp_path / "config" / "cfmail_provision.env"
  246. result = setup_cfmail.prepare_runtime_cfmail_config(
  247. api_token="",
  248. auth_email="cf@example.com",
  249. auth_key="global-key",
  250. worker_domain="email-api.example.com",
  251. zone_name="example.com",
  252. worker_name="worker-one",
  253. mail_domain="stale.example.com",
  254. admin_password="super-secret",
  255. accounts_path=accounts_path,
  256. provision_env_path=provision_env_path,
  257. )
  258. fake = FakeProvisioner.last_instance
  259. assert fake is not None
  260. assert fake.smoke_calls == [("email-api.example.com", "super-secret", "stale.example.com")]
  261. assert fake.rotate_calls == 1
  262. assert result.worker_domain == "email-api.example.com"
  263. assert result.email_domain == "auto-fresh.example.com"
  264. assert '"email_domain":"auto-fresh.example.com"' in accounts_path.read_text(encoding="utf-8")