izteamslots 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +17 -0
- package/backend/account_store.py +3 -0
- package/backend/chatgpt_workspace_api.py +32 -10
- package/backend/codex_switcher.py +4 -5
- package/backend/file_logger.py +3 -3
- package/backend/jobs.py +28 -28
- package/backend/openai_web_auth.py +4 -15
- package/backend/rpc_server.py +3 -1
- package/backend/slot_orchestrator.py +4 -0
- package/backend/ui_facade.py +3 -3
- package/package.json +1 -1
- package/requirements.txt +2 -2
- package/tests/test_account_store.py +26 -0
- package/tests/test_codex_switcher.py +66 -0
- package/tests/test_jobs.py +32 -0
- package/tests/test_rpc_protocol.py +23 -0
- package/tests/test_workspace_api.py +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [1.7.1](https://github.com/izzzzzi/izTeamSlots/compare/v1.7.0...v1.7.1) (2026-04-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **codex-switcher:** eliminate redundant _load_accounts calls in pick_first_ready ([1024e95](https://github.com/izzzzzi/izTeamSlots/commit/1024e9582137aeaaea43484c94002455160ed37d))
|
|
7
|
+
* deduplicate _decode_jwt_payload — single canonical implementation in codex_switcher ([5e579fe](https://github.com/izzzzzi/izTeamSlots/commit/5e579fe44caccf381d47d43484c56b4242589f87))
|
|
8
|
+
* **jobs:** move thread assignment inside lock to prevent race condition ([40a3be2](https://github.com/izzzzzi/izTeamSlots/commit/40a3be2ce0937ae6eeef72c508c820d5de2d1029))
|
|
9
|
+
* **logger:** use UTC timestamps consistent with rest of codebase ([302dfa1](https://github.com/izzzzzi/izTeamSlots/commit/302dfa1c2c5348fcab11c5606c5eb916ff0b5d26))
|
|
10
|
+
* remove redundant Mailbox creation in relogin_worker_email ([883af9b](https://github.com/izzzzzi/izTeamSlots/commit/883af9bd7650c887056885514329b38d6bd5acab))
|
|
11
|
+
* **security:** set chmod 0600 on meta.json and index.json files ([08c27af](https://github.com/izzzzzi/izTeamSlots/commit/08c27af9b330fae9f9a6316fd0fc7c6d38813506))
|
|
12
|
+
* **security:** strengthen API key masking — show at most 4 chars for long keys ([545ac15](https://github.com/izzzzzi/izTeamSlots/commit/545ac15c4505fa94aff59ef5f1ede2b11a8819c2))
|
|
13
|
+
* **workspace-api:** add pagination to get_members and get_pending_invites ([48229d2](https://github.com/izzzzzi/izTeamSlots/commit/48229d209a20593be9693df18f504dba9ee18ddc))
|
|
14
|
+
|
|
1
15
|
# [1.7.0](https://github.com/izzzzzi/izTeamSlots/compare/v1.6.1...v1.7.0) (2026-03-07)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ izteamslots
|
|
|
38
38
|
- Сохранение `codex-<email>-Team.json`.
|
|
39
39
|
- Логи, локальные browser profiles и doctor-проверка.
|
|
40
40
|
- Синхронизация workspace с локальными слотами.
|
|
41
|
+
- Свитч Codex-аккаунтов: мониторинг usage, авто-ротация auth.json при достижении лимита.
|
|
41
42
|
|
|
42
43
|
## Ограничения
|
|
43
44
|
|
|
@@ -83,6 +84,22 @@ echo "BOOMLIFY_API_KEY=your_api_key" > "$env:USERPROFILE\.izteamslots\.env"
|
|
|
83
84
|
| `BOOMLIFY_TIME` | `permanent` | Время жизни ящика |
|
|
84
85
|
| `SLOT_MAIL_PROVIDER` | `boomlify` | Провайдер почты для слотов |
|
|
85
86
|
| `MAIL_PROVIDER` | `trickads` | Провайдер почты для админов |
|
|
87
|
+
| `CODEX_SWITCHER_ENABLED` | `false` | Включить автосвитч Codex-аккаунтов |
|
|
88
|
+
| `CODEX_SWITCHER_INTERVAL_MINUTES` | `15` | Интервал фоновой проверки usage (минуты) |
|
|
89
|
+
|
|
90
|
+
## Свитч Codex-аккаунтов
|
|
91
|
+
|
|
92
|
+
Встроенный механизм ротации Codex-аккаунтов. Все codex-файлы из пула (`<DATA_ROOT>/codex`) отображаются в разделе **Свитч аккаунтов** главного меню.
|
|
93
|
+
|
|
94
|
+
Что доступно:
|
|
95
|
+
- **Таблица аккаунтов** — active-статус, primary usage %, reset time, состояние токена.
|
|
96
|
+
- **Ручное обновление** — запросить usage по всем аккаунтам.
|
|
97
|
+
- **Ручное переключение** — выбрать аккаунт и записать его в `auth.json`.
|
|
98
|
+
- **Первый готовый** — автоматически выбрать первый аккаунт без near-limit.
|
|
99
|
+
- **Автосвитч** — фоновый шедулер проверяет usage и переключает `auth.json`, если `primary_used_percent >= 90%`. Включается через настройку `CODEX_SWITCHER_ENABLED`.
|
|
100
|
+
- **Авто-рефреш токенов** — если access token истекает, обновляется через OAuth.
|
|
101
|
+
|
|
102
|
+
Путь к `auth.json` определяется через `CODEX_HOME` или `~/.codex/auth.json`.
|
|
86
103
|
|
|
87
104
|
## Почтовые провайдеры
|
|
88
105
|
|
package/backend/account_store.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import threading
|
|
6
7
|
import uuid
|
|
@@ -77,6 +78,8 @@ class AccountStore:
|
|
|
77
78
|
tmp = path.with_suffix(".tmp")
|
|
78
79
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
79
80
|
tmp.replace(path)
|
|
81
|
+
if os.name != "nt":
|
|
82
|
+
os.chmod(path, 0o600)
|
|
80
83
|
|
|
81
84
|
# --- Admin CRUD ---
|
|
82
85
|
|
|
@@ -91,20 +91,42 @@ class ChatGPTWorkspaceAPI:
|
|
|
91
91
|
)
|
|
92
92
|
|
|
93
93
|
def get_pending_invites(self) -> list[dict]:
|
|
94
|
-
"""Получить список ожидающих
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
|
|
94
|
+
"""Получить список ожидающих инвайтов (с пагинацией)."""
|
|
95
|
+
return self._paginate(
|
|
96
|
+
f"/backend-api/accounts/{self.account_id}/invites",
|
|
97
|
+
items_key="invites",
|
|
98
98
|
)
|
|
99
|
-
return data.get("invites", [])
|
|
100
99
|
|
|
101
100
|
def get_members(self) -> list[dict]:
|
|
102
|
-
"""Получить список участников workspace."""
|
|
103
|
-
|
|
104
|
-
"
|
|
105
|
-
|
|
101
|
+
"""Получить список участников workspace (с пагинацией)."""
|
|
102
|
+
return self._paginate(
|
|
103
|
+
f"/backend-api/accounts/{self.account_id}/users",
|
|
104
|
+
items_key="items",
|
|
105
|
+
fallback_key="users",
|
|
106
106
|
)
|
|
107
|
-
|
|
107
|
+
|
|
108
|
+
def _paginate(
|
|
109
|
+
self,
|
|
110
|
+
path: str,
|
|
111
|
+
items_key: str,
|
|
112
|
+
fallback_key: str | None = None,
|
|
113
|
+
page_size: int = 100,
|
|
114
|
+
max_pages: int = 20,
|
|
115
|
+
) -> list[dict]:
|
|
116
|
+
"""Fetch all pages from a paginated endpoint."""
|
|
117
|
+
all_items: list[dict] = []
|
|
118
|
+
for page_num in range(max_pages):
|
|
119
|
+
offset = page_num * page_size
|
|
120
|
+
data = self._request("GET", f"{path}?offset={offset}&limit={page_size}")
|
|
121
|
+
items = data.get(items_key) or (data.get(fallback_key, []) if fallback_key else [])
|
|
122
|
+
all_items.extend(items)
|
|
123
|
+
if not data.get("has_more") and len(items) >= page_size:
|
|
124
|
+
continue
|
|
125
|
+
if len(items) < page_size:
|
|
126
|
+
break
|
|
127
|
+
if data.get("has_more") is False:
|
|
128
|
+
break
|
|
129
|
+
return all_items
|
|
108
130
|
|
|
109
131
|
def delete_member(self, user_id: str) -> dict:
|
|
110
132
|
"""Удалить участника из workspace по user_id."""
|
|
@@ -45,7 +45,7 @@ def _parse_int(value: str | None, default: int) -> int:
|
|
|
45
45
|
return parsed if parsed > 0 else default
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def
|
|
48
|
+
def decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
49
49
|
if not token or "." not in token:
|
|
50
50
|
return {}
|
|
51
51
|
parts = token.split(".")
|
|
@@ -66,7 +66,7 @@ def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def _parse_jwt_exp(token: str) -> datetime | None:
|
|
69
|
-
payload =
|
|
69
|
+
payload = decode_jwt_payload(token)
|
|
70
70
|
exp = payload.get("exp")
|
|
71
71
|
if not isinstance(exp, (int, float)):
|
|
72
72
|
return None
|
|
@@ -74,7 +74,7 @@ def _parse_jwt_exp(token: str) -> datetime | None:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def _parse_id_token_claims(token: str) -> tuple[str | None, str | None]:
|
|
77
|
-
payload =
|
|
77
|
+
payload = decode_jwt_payload(token)
|
|
78
78
|
email = payload.get("email") if isinstance(payload.get("email"), str) else None
|
|
79
79
|
auth_claims = payload.get("https://api.openai.com/auth")
|
|
80
80
|
account_id = None
|
|
@@ -210,8 +210,7 @@ class CodexSwitcherService:
|
|
|
210
210
|
if not target:
|
|
211
211
|
return {"active_email": active, "switched": False}
|
|
212
212
|
self._activate_account(target)
|
|
213
|
-
|
|
214
|
-
active = self._detect_active_account(self._load_accounts())
|
|
213
|
+
active = self._detect_active_account(accounts)
|
|
215
214
|
for row in rows:
|
|
216
215
|
row["is_active"] = row["email"] == active
|
|
217
216
|
self._status["active_email"] = active
|
package/backend/file_logger.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import threading
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ from . import DATA_ROOT
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _timestamp() -> str:
|
|
14
|
-
return datetime.now().strftime("%Y-%m-%d %H:%M:%
|
|
14
|
+
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%SZ")
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def _safe_title(value: str) -> str:
|
|
@@ -46,7 +46,7 @@ class FileLogger:
|
|
|
46
46
|
self._append(self.app_log, lines)
|
|
47
47
|
|
|
48
48
|
def create_job_logger(self, job_id: str, title: str) -> "JobFileLogger":
|
|
49
|
-
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
49
|
+
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
50
50
|
filename = f"{stamp}-{job_id[:8]}-{_safe_title(title)}.log"
|
|
51
51
|
path = self.jobs_dir / filename
|
|
52
52
|
rel_path = path.relative_to(DATA_ROOT).as_posix()
|
package/backend/jobs.py
CHANGED
|
@@ -57,36 +57,36 @@ class JobManager:
|
|
|
57
57
|
if self.busy:
|
|
58
58
|
raise RuntimeError(f"Задача уже выполняется: {self._active_job_id}")
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
job_id = uuid.uuid4().hex
|
|
61
|
+
job_logger = self._file_logger.create_job_logger(job_id, title)
|
|
62
|
+
self._emit("job.started", {"job_id": job_id, "title": title, "log_path": job_logger.rel_path})
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
64
|
+
def runner() -> None:
|
|
65
|
+
ctx = JobContext(job_id=job_id, _emit=self._emit, _logger=job_logger)
|
|
66
|
+
try:
|
|
67
|
+
result = handler(ctx)
|
|
68
|
+
job_logger.done(result)
|
|
69
|
+
self._emit("job.done", {"job_id": job_id, "result": result, "log_path": job_logger.rel_path})
|
|
70
|
+
except Exception as e:
|
|
71
|
+
message = str(e)
|
|
72
|
+
if len(message) > 1200:
|
|
73
|
+
message = message[:1200] + "…"
|
|
74
|
+
tb = traceback.format_exc()
|
|
75
|
+
job_logger.error(message, traceback_text=tb)
|
|
76
|
+
self._emit(
|
|
77
|
+
"job.error",
|
|
78
|
+
{
|
|
79
|
+
"job_id": job_id,
|
|
80
|
+
"error": message,
|
|
81
|
+
"log_path": job_logger.rel_path,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
thread = threading.Thread(target=runner, daemon=True)
|
|
86
|
+
thread.start()
|
|
87
|
+
self._active_thread = thread
|
|
88
|
+
self._active_job_id = job_id
|
|
89
|
+
return job_id
|
|
90
90
|
|
|
91
91
|
def wait_all(self, timeout: float = 30) -> None:
|
|
92
92
|
"""Wait for the active job to finish."""
|
|
@@ -25,6 +25,7 @@ from selenium.webdriver.support.ui import WebDriverWait
|
|
|
25
25
|
from seleniumbase import Driver as create_driver
|
|
26
26
|
|
|
27
27
|
from . import PROJECT_ROOT as _PROJECT_ROOT
|
|
28
|
+
from .codex_switcher import CLIENT_ID, decode_jwt_payload
|
|
28
29
|
from .mail import Mailbox, MailError, MailProvider
|
|
29
30
|
|
|
30
31
|
LOGIN_URL = "https://chatgpt.com/auth/login_with"
|
|
@@ -706,18 +707,6 @@ def wait_for_browser_close(context: BrowserContext, log: Callable[[str], Any] |
|
|
|
706
707
|
_log("Браузер закрыт")
|
|
707
708
|
|
|
708
709
|
|
|
709
|
-
def _decode_jwt_payload(token: str) -> dict | None:
|
|
710
|
-
try:
|
|
711
|
-
parts = token.split(".")
|
|
712
|
-
if len(parts) < 2:
|
|
713
|
-
return None
|
|
714
|
-
payload = parts[1]
|
|
715
|
-
payload += "=" * (4 - len(payload) % 4)
|
|
716
|
-
decoded = base64.urlsafe_b64decode(payload)
|
|
717
|
-
return json.loads(decoded)
|
|
718
|
-
except Exception:
|
|
719
|
-
return None
|
|
720
|
-
|
|
721
710
|
|
|
722
711
|
def _activate_best_tab(driver: Any, preferred_url_parts: list[str] | None = None) -> None:
|
|
723
712
|
preferred = [part for part in (preferred_url_parts or []) if part]
|
|
@@ -834,7 +823,7 @@ def _prepare_oauth_authorize_url() -> tuple[str, HTTPServer, dict[str, str | Non
|
|
|
834
823
|
|
|
835
824
|
server, redirect_uri, holder = _start_callback_server(state)
|
|
836
825
|
params = urllib.parse.urlencode({
|
|
837
|
-
"client_id":
|
|
826
|
+
"client_id": CLIENT_ID,
|
|
838
827
|
"response_type": "code",
|
|
839
828
|
"redirect_uri": redirect_uri,
|
|
840
829
|
"scope": "openid email profile offline_access",
|
|
@@ -852,7 +841,7 @@ def _prepare_oauth_authorize_url() -> tuple[str, HTTPServer, dict[str, str | Non
|
|
|
852
841
|
def _exchange_oauth_code(auth_code: str, redirect_uri: str, code_verifier: str) -> dict[str, Any]:
|
|
853
842
|
token_data = {
|
|
854
843
|
"grant_type": "authorization_code",
|
|
855
|
-
"client_id":
|
|
844
|
+
"client_id": CLIENT_ID,
|
|
856
845
|
"code": auth_code,
|
|
857
846
|
"redirect_uri": redirect_uri,
|
|
858
847
|
"code_verifier": code_verifier,
|
|
@@ -878,7 +867,7 @@ def _exchange_oauth_code(auth_code: str, redirect_uri: str, code_verifier: str)
|
|
|
878
867
|
"refresh_token": refresh_token,
|
|
879
868
|
}
|
|
880
869
|
|
|
881
|
-
jwt_data =
|
|
870
|
+
jwt_data = decode_jwt_payload(id_token or access_token)
|
|
882
871
|
if jwt_data:
|
|
883
872
|
auth_info = jwt_data.get("https://api.openai.com/auth", {})
|
|
884
873
|
session_result["account_id"] = auth_info.get("chatgpt_account_id")
|
package/backend/rpc_server.py
CHANGED
|
@@ -80,7 +80,9 @@ class RPCServer:
|
|
|
80
80
|
if not value:
|
|
81
81
|
return ""
|
|
82
82
|
if "KEY" in key:
|
|
83
|
-
|
|
83
|
+
if len(value) <= 16:
|
|
84
|
+
return "***"
|
|
85
|
+
return value[:2] + "***" + value[-2:]
|
|
84
86
|
return value
|
|
85
87
|
|
|
86
88
|
def _set_setting(self, key: str, value: str) -> None:
|
|
@@ -95,6 +95,10 @@ class SlotManager:
|
|
|
95
95
|
pass
|
|
96
96
|
self._admin_page = None
|
|
97
97
|
|
|
98
|
+
def close_admin_page(self) -> None:
|
|
99
|
+
"""Закрыть браузер админа (публичный интерфейс)."""
|
|
100
|
+
self._close_admin_page()
|
|
101
|
+
|
|
98
102
|
def _get_api(self, page: Page) -> ChatGPTWorkspaceAPI:
|
|
99
103
|
"""Создать ChatGPTWorkspaceAPI с привязкой к странице браузера."""
|
|
100
104
|
if not self.account_id or not self.access_token:
|
package/backend/ui_facade.py
CHANGED
|
@@ -277,7 +277,7 @@ class UIFacade:
|
|
|
277
277
|
log("Слот создан без OAuth-токена — нужен перелогин")
|
|
278
278
|
except Exception as e:
|
|
279
279
|
log(f"Ошибка: {e}")
|
|
280
|
-
manager.
|
|
280
|
+
manager.close_admin_page()
|
|
281
281
|
log(f"Готово: {ok}/{count} слотов")
|
|
282
282
|
self.sync_codex_files()
|
|
283
283
|
return {"ok": ok, "total": count}
|
|
@@ -291,9 +291,9 @@ class UIFacade:
|
|
|
291
291
|
log(f"{email} — нет openai_password")
|
|
292
292
|
return False
|
|
293
293
|
|
|
294
|
-
|
|
294
|
+
mailbox = Mailbox(email=worker.email, password=worker.password)
|
|
295
|
+
mail = create_provider_for_mailbox(mailbox)
|
|
295
296
|
try:
|
|
296
|
-
mailbox = Mailbox(email=worker.email, password=worker.password)
|
|
297
297
|
profile_dir = self.store.get_worker_profile_dir(worker)
|
|
298
298
|
|
|
299
299
|
page, session = oauth_login(
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
python-dotenv~=1.
|
|
1
|
+
python-dotenv~=1.2
|
|
2
2
|
requests~=2.31
|
|
3
|
-
seleniumbase~=4.
|
|
3
|
+
seleniumbase~=4.47
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
4
6
|
import tempfile
|
|
5
7
|
import unittest
|
|
6
8
|
from pathlib import Path
|
|
@@ -84,3 +86,27 @@ class TestAccountStore(unittest.TestCase):
|
|
|
84
86
|
self.assertEqual(rebuilt["email"], "slot@example.com")
|
|
85
87
|
self.assertEqual(rebuilt["status"], "created")
|
|
86
88
|
self.assertTrue(any("meta.json отсутствовал" in fix for fix in fixes))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestAccountStorePermissions(unittest.TestCase):
|
|
92
|
+
def setUp(self) -> None:
|
|
93
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
94
|
+
self.base_dir = Path(self.temp_dir.name) / "accounts"
|
|
95
|
+
self.store = AccountStore(self.base_dir)
|
|
96
|
+
|
|
97
|
+
def tearDown(self) -> None:
|
|
98
|
+
self.temp_dir.cleanup()
|
|
99
|
+
|
|
100
|
+
@unittest.skipIf(os.name == "nt", "chmod not meaningful on Windows")
|
|
101
|
+
def test_meta_json_has_restrictive_permissions(self) -> None:
|
|
102
|
+
admin = self.store.add_admin("admin@example.com", "secret-password")
|
|
103
|
+
meta_path = self.store.admin_dir / admin.id / "meta.json"
|
|
104
|
+
mode = stat.S_IMODE(meta_path.stat().st_mode)
|
|
105
|
+
self.assertEqual(mode, 0o600, f"Expected 0600, got {oct(mode)}")
|
|
106
|
+
|
|
107
|
+
@unittest.skipIf(os.name == "nt", "chmod not meaningful on Windows")
|
|
108
|
+
def test_index_json_has_restrictive_permissions(self) -> None:
|
|
109
|
+
self.store.add_admin("admin@example.com", "pw")
|
|
110
|
+
index_path = self.store.admin_dir / "index.json"
|
|
111
|
+
mode = stat.S_IMODE(index_path.stat().st_mode)
|
|
112
|
+
self.assertEqual(mode, 0o600, f"Expected 0600, got {oct(mode)}")
|
|
@@ -375,6 +375,43 @@ class CodexSwitcherServiceTests(unittest.TestCase):
|
|
|
375
375
|
codex_data = json.loads((self.codex_dir / "codex-retry.json").read_text(encoding="utf-8"))
|
|
376
376
|
self.assertEqual(codex_data["access_token"], new_access)
|
|
377
377
|
|
|
378
|
+
def test_pick_first_ready_returns_consistent_state_after_switch(self) -> None:
|
|
379
|
+
"""After switching, returned active_email must match the auth.json on disk."""
|
|
380
|
+
token_a = make_jwt({"exp": 4102444800})
|
|
381
|
+
token_b = make_jwt({"exp": 4102444800})
|
|
382
|
+
write_codex(self.codex_dir / "codex-a.json", "a@example.com", "acc-a", token_a, "rt-a")
|
|
383
|
+
write_codex(self.codex_dir / "codex-b.json", "b@example.com", "acc-b", token_b, "rt-b")
|
|
384
|
+
write_auth(self.auth_path, "acc-a", token_a, "a@example.com")
|
|
385
|
+
|
|
386
|
+
usage = {
|
|
387
|
+
"acc-a": {"plan_type": "team", "rate_limit": {
|
|
388
|
+
"primary_window": {"used_percent": 10, "reset_at": "2026-03-07T05:00:00Z"},
|
|
389
|
+
"secondary_window": {"used_percent": 5, "reset_at": "2026-03-08T05:00:00Z"},
|
|
390
|
+
}},
|
|
391
|
+
"acc-b": {"plan_type": "team", "rate_limit": {
|
|
392
|
+
"primary_window": {"used_percent": 20, "reset_at": "2026-03-07T05:00:00Z"},
|
|
393
|
+
"secondary_window": {"used_percent": 5, "reset_at": "2026-03-08T05:00:00Z"},
|
|
394
|
+
}},
|
|
395
|
+
}
|
|
396
|
+
service = CodexSwitcherService(
|
|
397
|
+
codex_dir=self.codex_dir,
|
|
398
|
+
auth_path=self.auth_path,
|
|
399
|
+
session_factory=lambda: FakeSession(usage),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
result = service.pick_first_ready()
|
|
403
|
+
|
|
404
|
+
self.assertTrue(result["switched"])
|
|
405
|
+
auth_data = json.loads(self.auth_path.read_text(encoding="utf-8"))
|
|
406
|
+
auth_account_id = auth_data["tokens"]["account_id"]
|
|
407
|
+
for path in self.codex_dir.glob("codex-*.json"):
|
|
408
|
+
codex = json.loads(path.read_text(encoding="utf-8"))
|
|
409
|
+
if codex.get("account_id") == auth_account_id:
|
|
410
|
+
self.assertEqual(result["active_email"], codex["email"])
|
|
411
|
+
break
|
|
412
|
+
else:
|
|
413
|
+
self.fail(f"No codex file found with account_id={auth_account_id}")
|
|
414
|
+
|
|
378
415
|
def test_no_switch_when_active_below_threshold(self) -> None:
|
|
379
416
|
token_a = make_jwt({"exp": 4102444800})
|
|
380
417
|
token_b = make_jwt({"exp": 4102444800})
|
|
@@ -405,5 +442,34 @@ class CodexSwitcherServiceTests(unittest.TestCase):
|
|
|
405
442
|
self.assertEqual(auth_data["tokens"]["account_id"], "acc-a")
|
|
406
443
|
|
|
407
444
|
|
|
445
|
+
class TestDecodeJwtPayload(unittest.TestCase):
|
|
446
|
+
def test_valid_jwt_returns_payload(self) -> None:
|
|
447
|
+
from backend.codex_switcher import decode_jwt_payload
|
|
448
|
+
|
|
449
|
+
token = make_jwt({"sub": "user123", "exp": 4102444800})
|
|
450
|
+
result = decode_jwt_payload(token)
|
|
451
|
+
self.assertEqual(result["sub"], "user123")
|
|
452
|
+
|
|
453
|
+
def test_empty_string_returns_empty_dict(self) -> None:
|
|
454
|
+
from backend.codex_switcher import decode_jwt_payload
|
|
455
|
+
|
|
456
|
+
self.assertEqual(decode_jwt_payload(""), {})
|
|
457
|
+
|
|
458
|
+
def test_malformed_token_returns_empty_dict(self) -> None:
|
|
459
|
+
from backend.codex_switcher import decode_jwt_payload
|
|
460
|
+
|
|
461
|
+
self.assertEqual(decode_jwt_payload("not.a.jwt"), {})
|
|
462
|
+
self.assertEqual(decode_jwt_payload("only-one-part"), {})
|
|
463
|
+
|
|
464
|
+
def test_non_dict_payload_returns_empty_dict(self) -> None:
|
|
465
|
+
from backend.codex_switcher import decode_jwt_payload
|
|
466
|
+
|
|
467
|
+
array_payload = base64.urlsafe_b64encode(b'[1,2,3]').decode().rstrip("=")
|
|
468
|
+
header = base64.urlsafe_b64encode(b'{"alg":"none"}').decode().rstrip("=")
|
|
469
|
+
sig = base64.urlsafe_b64encode(b'sig').decode().rstrip("=")
|
|
470
|
+
token = f"{header}.{array_payload}.{sig}"
|
|
471
|
+
self.assertEqual(decode_jwt_payload(token), {})
|
|
472
|
+
|
|
473
|
+
|
|
408
474
|
if __name__ == "__main__":
|
|
409
475
|
unittest.main()
|
package/tests/test_jobs.py
CHANGED
|
@@ -74,3 +74,35 @@ class TestJobManager(unittest.TestCase):
|
|
|
74
74
|
finally:
|
|
75
75
|
release.set()
|
|
76
76
|
manager.wait_all()
|
|
77
|
+
|
|
78
|
+
def test_start_is_thread_safe_under_contention(self) -> None:
|
|
79
|
+
"""Ensure only one job starts even with concurrent start() calls."""
|
|
80
|
+
manager = JobManager(self.emit, file_logger=self.logger)
|
|
81
|
+
barrier = threading.Barrier(10)
|
|
82
|
+
release = threading.Event()
|
|
83
|
+
results: list[str | None] = [None] * 10
|
|
84
|
+
errors: list[str | None] = [None] * 10
|
|
85
|
+
|
|
86
|
+
def handler(_ctx):
|
|
87
|
+
release.wait(timeout=5)
|
|
88
|
+
|
|
89
|
+
def try_start(index: int) -> None:
|
|
90
|
+
barrier.wait()
|
|
91
|
+
try:
|
|
92
|
+
job_id = manager.start(f"job-{index}", handler)
|
|
93
|
+
results[index] = job_id
|
|
94
|
+
except RuntimeError as e:
|
|
95
|
+
errors[index] = str(e)
|
|
96
|
+
|
|
97
|
+
threads = [threading.Thread(target=try_start, args=(i,)) for i in range(10)]
|
|
98
|
+
for t in threads:
|
|
99
|
+
t.start()
|
|
100
|
+
for t in threads:
|
|
101
|
+
t.join(timeout=5)
|
|
102
|
+
release.set()
|
|
103
|
+
manager.wait_all()
|
|
104
|
+
|
|
105
|
+
started = [r for r in results if r is not None]
|
|
106
|
+
failed = [e for e in errors if e is not None]
|
|
107
|
+
self.assertEqual(len(started), 1, f"Expected exactly 1 job to start, got {len(started)}")
|
|
108
|
+
self.assertEqual(len(failed), 9, f"Expected 9 rejections, got {len(failed)}")
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import unittest
|
|
4
4
|
|
|
5
5
|
from backend.rpc_protocol import RPCError, make_error_response, make_event, make_success_response, parse_request
|
|
6
|
+
from backend.rpc_server import RPCServer
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class TestRPCProtocol(unittest.TestCase):
|
|
@@ -31,3 +32,25 @@ class TestRPCProtocol(unittest.TestCase):
|
|
|
31
32
|
self.assertEqual(make_success_response("1", {"ok": True})["ok"], True)
|
|
32
33
|
self.assertEqual(make_error_response("1", err)["error"]["message"], "boom")
|
|
33
34
|
self.assertEqual(make_event("job.done", {"id": "1"})["type"], "event")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestMaskSettingValue(unittest.TestCase):
|
|
38
|
+
def test_empty_value_returns_empty(self) -> None:
|
|
39
|
+
self.assertEqual(RPCServer._mask_setting_value("BOOMLIFY_API_KEY", ""), "")
|
|
40
|
+
|
|
41
|
+
def test_short_key_fully_masked(self) -> None:
|
|
42
|
+
self.assertEqual(RPCServer._mask_setting_value("BOOMLIFY_API_KEY", "abc123"), "***")
|
|
43
|
+
|
|
44
|
+
def test_long_key_shows_only_first_and_last_two(self) -> None:
|
|
45
|
+
key = "sk-abcdefghijklmnop" # 18 chars
|
|
46
|
+
masked = RPCServer._mask_setting_value("BOOMLIFY_API_KEY", key)
|
|
47
|
+
self.assertEqual(masked, "sk***op")
|
|
48
|
+
self.assertNotIn("abcdef", masked)
|
|
49
|
+
|
|
50
|
+
def test_medium_key_fully_masked(self) -> None:
|
|
51
|
+
key = "1234567890123" # 13 chars
|
|
52
|
+
masked = RPCServer._mask_setting_value("BOOMLIFY_API_KEY", key)
|
|
53
|
+
self.assertEqual(masked, "***")
|
|
54
|
+
|
|
55
|
+
def test_non_key_setting_not_masked(self) -> None:
|
|
56
|
+
self.assertEqual(RPCServer._mask_setting_value("BOOMLIFY_DOMAIN", "example.com"), "example.com")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakePage:
|
|
9
|
+
"""Minimal Page stub that returns canned responses from page.evaluate."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, responses: list[dict[str, Any]]) -> None:
|
|
12
|
+
self._responses = list(responses)
|
|
13
|
+
self._call_index = 0
|
|
14
|
+
|
|
15
|
+
def evaluate(self, script: str, args: Any = None) -> Any:
|
|
16
|
+
if self._call_index >= len(self._responses):
|
|
17
|
+
raise RuntimeError("No more canned responses")
|
|
18
|
+
resp = self._responses[self._call_index]
|
|
19
|
+
self._call_index += 1
|
|
20
|
+
return resp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestChatGPTWorkspaceAPIPagination(unittest.TestCase):
|
|
24
|
+
def _make_api(self, page: FakePage):
|
|
25
|
+
from backend.chatgpt_workspace_api import ChatGPTWorkspaceAPI
|
|
26
|
+
return ChatGPTWorkspaceAPI(page, "acc-123", "token-123")
|
|
27
|
+
|
|
28
|
+
def test_get_members_fetches_all_pages(self) -> None:
|
|
29
|
+
page1_body = json.dumps({
|
|
30
|
+
"items": [{"id": f"u{i}", "email": f"u{i}@x.com"} for i in range(100)],
|
|
31
|
+
"has_more": True,
|
|
32
|
+
})
|
|
33
|
+
page2_body = json.dumps({
|
|
34
|
+
"items": [{"id": f"u{i}", "email": f"u{i}@x.com"} for i in range(100, 130)],
|
|
35
|
+
"has_more": False,
|
|
36
|
+
})
|
|
37
|
+
page = FakePage([
|
|
38
|
+
{"status": 200, "body": page1_body},
|
|
39
|
+
{"status": 200, "body": page2_body},
|
|
40
|
+
])
|
|
41
|
+
api = self._make_api(page)
|
|
42
|
+
|
|
43
|
+
members = api.get_members()
|
|
44
|
+
|
|
45
|
+
self.assertEqual(len(members), 130)
|
|
46
|
+
|
|
47
|
+
def test_get_members_single_page(self) -> None:
|
|
48
|
+
body = json.dumps({
|
|
49
|
+
"items": [{"id": "u1", "email": "u1@x.com"}],
|
|
50
|
+
})
|
|
51
|
+
page = FakePage([{"status": 200, "body": body}])
|
|
52
|
+
api = self._make_api(page)
|
|
53
|
+
|
|
54
|
+
members = api.get_members()
|
|
55
|
+
|
|
56
|
+
self.assertEqual(len(members), 1)
|
|
57
|
+
|
|
58
|
+
def test_get_pending_invites_fetches_all_pages(self) -> None:
|
|
59
|
+
page1_body = json.dumps({
|
|
60
|
+
"invites": [{"email": f"i{i}@x.com"} for i in range(100)],
|
|
61
|
+
"has_more": True,
|
|
62
|
+
})
|
|
63
|
+
page2_body = json.dumps({
|
|
64
|
+
"invites": [{"email": f"i{i}@x.com"} for i in range(100, 110)],
|
|
65
|
+
"has_more": False,
|
|
66
|
+
})
|
|
67
|
+
page = FakePage([
|
|
68
|
+
{"status": 200, "body": page1_body},
|
|
69
|
+
{"status": 200, "body": page2_body},
|
|
70
|
+
])
|
|
71
|
+
api = self._make_api(page)
|
|
72
|
+
|
|
73
|
+
invites = api.get_pending_invites()
|
|
74
|
+
|
|
75
|
+
self.assertEqual(len(invites), 110)
|
|
76
|
+
|
|
77
|
+
def test_get_pending_invites_single_page(self) -> None:
|
|
78
|
+
body = json.dumps({
|
|
79
|
+
"invites": [{"email": "i1@x.com"}],
|
|
80
|
+
})
|
|
81
|
+
page = FakePage([{"status": 200, "body": body}])
|
|
82
|
+
api = self._make_api(page)
|
|
83
|
+
|
|
84
|
+
invites = api.get_pending_invites()
|
|
85
|
+
|
|
86
|
+
self.assertEqual(len(invites), 1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
unittest.main()
|