test_rotate.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. """Tests for ops.rotate API-only single-pool logic."""
  2. from __future__ import annotations
  3. import json
  4. from pathlib import Path
  5. from datetime import datetime, timedelta
  6. import pytest
  7. from ops.rotate import RotateResult, rotate_once
  8. from ops.rotate_probe import classify_status_message
  9. class FakeClient:
  10. def __init__(self, files: list[dict[str, object]], *, healthy: bool = True):
  11. self.files = [dict(item) for item in files]
  12. self.healthy = healthy
  13. self.deleted: list[str] = []
  14. def health_check(self) -> bool:
  15. return self.healthy
  16. def list_auth_files(self) -> list[dict[str, object]]:
  17. return [dict(item) for item in self.files]
  18. def delete_auth_file(self, name: str) -> bool:
  19. self.deleted.append(name)
  20. self.files = [item for item in self.files if str(item.get("name")) != name]
  21. return True
  22. @pytest.fixture(autouse=True)
  23. def _stub_runtime_reconcile(monkeypatch):
  24. monkeypatch.setattr("ops.rotate._maybe_reconcile_cpa_runtime", lambda **kwargs: None)
  25. def test_classify_unauthorized_returns_401() -> None:
  26. assert classify_status_message("unauthorized") == 401
  27. assert classify_status_message("Token invalidated by provider") == 401
  28. def test_classify_quota_returns_429() -> None:
  29. msg = json.dumps({"error": {"type": "usage_limit_reached", "message": "weekly quota exceeded"}})
  30. assert classify_status_message(msg) == 429
  31. def test_classify_empty_returns_200() -> None:
  32. assert classify_status_message("") == 200
  33. assert classify_status_message(None) == 200 # type: ignore[arg-type]
  34. def test_rotate_deletes_401(tmp_path: Path) -> None:
  35. pool_dir = tmp_path / "pool"
  36. pool_dir.mkdir()
  37. target = pool_dir / "bad@example.com.json"
  38. target.write_text('{"email": "bad@example.com"}', encoding="utf-8")
  39. client = FakeClient([{"name": "bad@example.com.json", "status_message": "unauthorized", "status": "error"}])
  40. result = rotate_once(pool_dir=pool_dir, client=client)
  41. assert result.deleted_401 == 1
  42. assert result.deleted_429 == 0
  43. assert result.main_pool_before == 1
  44. assert result.main_pool_after == 0
  45. assert client.deleted == ["bad@example.com.json"]
  46. assert not target.exists()
  47. def test_rotate_keeps_429(tmp_path: Path) -> None:
  48. pool_dir = tmp_path / "pool"
  49. pool_dir.mkdir()
  50. target = pool_dir / "quota@example.com.json"
  51. target.write_text('{"email": "quota@example.com"}', encoding="utf-8")
  52. client = FakeClient(
  53. [
  54. {
  55. "name": "quota@example.com.json",
  56. "status_message": json.dumps({"error": {"type": "usage_limit_reached", "message": "quota"}}),
  57. "status": "error",
  58. }
  59. ]
  60. )
  61. result = rotate_once(pool_dir=pool_dir, client=client)
  62. assert result.deleted_401 == 0
  63. assert result.deleted_429 == 0
  64. assert client.deleted == []
  65. assert target.exists()
  66. def test_rotate_deletes_deactivated(tmp_path: Path) -> None:
  67. pool_dir = tmp_path / "pool"
  68. pool_dir.mkdir()
  69. target = pool_dir / "dead@example.com.json"
  70. target.write_text('{"email": "dead@example.com"}', encoding="utf-8")
  71. client = FakeClient(
  72. [
  73. {
  74. "name": "dead@example.com.json",
  75. "status_message": json.dumps({"error": {"type": "account_deactivated", "message": "has been deactivated"}}),
  76. "status": "error",
  77. }
  78. ]
  79. )
  80. result = rotate_once(pool_dir=pool_dir, client=client)
  81. assert result.deleted_401 == 1
  82. assert client.deleted == ["dead@example.com.json"]
  83. assert not target.exists()
  84. def test_rotate_keeps_transport_error(tmp_path: Path) -> None:
  85. pool_dir = tmp_path / "pool"
  86. pool_dir.mkdir()
  87. target = pool_dir / "retry@example.com.json"
  88. target.write_text('{"email": "retry@example.com"}', encoding="utf-8")
  89. client = FakeClient(
  90. [{"name": "retry@example.com.json", "status_message": 'Post "https://chatgpt.com/backend-api/codex/responses": EOF', "status": "error"}]
  91. )
  92. result = rotate_once(pool_dir=pool_dir, client=client)
  93. assert result.deleted_401 == 0
  94. assert result.deleted_429 == 0
  95. assert target.exists()
  96. def test_rotate_keeps_healthy(tmp_path: Path) -> None:
  97. pool_dir = tmp_path / "pool"
  98. pool_dir.mkdir()
  99. target = pool_dir / "ok@example.com.json"
  100. target.write_text('{"email": "ok@example.com"}', encoding="utf-8")
  101. client = FakeClient([{"name": "ok@example.com.json", "status_message": "", "status": "active"}])
  102. result = rotate_once(pool_dir=pool_dir, client=client)
  103. assert result.deleted_401 == 0
  104. assert result.deleted_429 == 0
  105. assert result.quota_probed == 0
  106. assert target.exists()
  107. def test_rotate_quota_probe_detects_401(monkeypatch, tmp_path: Path) -> None:
  108. pool_dir = tmp_path / "pool"
  109. pool_dir.mkdir()
  110. target = pool_dir / "probe401@example.com.json"
  111. target.write_text('{"email": "probe401@example.com"}', encoding="utf-8")
  112. class FakeCpaClient(FakeClient):
  113. def _resolve_key(self): # noqa: ANN202
  114. return "test-key"
  115. client = FakeCpaClient(
  116. [
  117. {
  118. "name": "probe401@example.com.json",
  119. "status_message": 'Post "https://chatgpt.com/backend-api/codex/responses": EOF',
  120. "status": "error",
  121. "provider": "codex",
  122. "auth_index": "auth-1",
  123. "id_token": {"chatgpt_account_id": "acct-1"},
  124. }
  125. ]
  126. )
  127. monkeypatch.setattr(
  128. "ops.rotate._collect_quota_probe_results",
  129. lambda entries, **kwargs: (
  130. {"probe401@example.com.json": (401, "invalidated", False)},
  131. {"probed": 1, "probe_401": 1, "probe_429": 0, "probe_skipped": 0},
  132. ),
  133. )
  134. result = rotate_once(pool_dir=pool_dir, client=client)
  135. assert result.quota_probed == 1
  136. assert result.deleted_401 == 1
  137. assert not target.exists()
  138. def test_rotate_cpa_unreachable_returns_empty(tmp_path: Path) -> None:
  139. client = FakeClient([], healthy=False)
  140. result = rotate_once(pool_dir=tmp_path / "pool", client=client)
  141. assert result == RotateResult()
  142. def test_rotate_result_fields_correct(tmp_path: Path) -> None:
  143. pool_dir = tmp_path / "pool"
  144. pool_dir.mkdir()
  145. for name in ["bad@example.com.json", "quota@example.com.json", "ok@example.com.json"]:
  146. (pool_dir / name).write_text("{}", encoding="utf-8")
  147. client = FakeClient(
  148. [
  149. {"name": "bad@example.com.json", "status_message": "unauthorized", "status": "error"},
  150. {"name": "quota@example.com.json", "status_message": json.dumps({"error": {"type": "usage_limit_reached", "message": "quota"}}), "status": "error"},
  151. {"name": "ok@example.com.json", "status_message": "", "status": "active"},
  152. ]
  153. )
  154. result = rotate_once(pool_dir=pool_dir, client=client)
  155. assert result.main_pool_before == 3
  156. assert result.deleted_401 == 1
  157. assert result.deleted_429 == 0
  158. assert result.main_pool_after == 2
  159. def test_rotate_skips_fresh_accounts_during_grace_period(monkeypatch, tmp_path: Path) -> None:
  160. pool_dir = tmp_path / "pool"
  161. pool_dir.mkdir()
  162. target = pool_dir / "fresh@example.com.json"
  163. target.write_text(
  164. json.dumps(
  165. {
  166. "email": "fresh@example.com",
  167. "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
  168. }
  169. ),
  170. encoding="utf-8",
  171. )
  172. class FakeCpaClient(FakeClient):
  173. def _resolve_key(self): # noqa: ANN202
  174. return "test-key"
  175. client = FakeCpaClient(
  176. [
  177. {
  178. "name": "fresh@example.com.json",
  179. "status_message": "",
  180. "status": "active",
  181. "provider": "codex",
  182. "auth_index": "auth-1",
  183. "id_token": {"chatgpt_account_id": "acct-1"},
  184. }
  185. ]
  186. )
  187. monkeypatch.setattr(
  188. "ops.rotate._collect_quota_probe_results",
  189. lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("fresh accounts should skip rotate probing during grace period")),
  190. )
  191. result = rotate_once(
  192. pool_dir=pool_dir,
  193. client=client,
  194. fresh_grace_seconds=600,
  195. )
  196. assert result.deleted_401 == 0
  197. assert result.quota_probed == 0
  198. assert result.quota_probe_skipped == 1
  199. assert target.exists()