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 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
 
@@ -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
- data = self._request(
96
- "GET",
97
- f"/backend-api/accounts/{self.account_id}/invites?offset=0&limit=100",
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
- data = self._request(
104
- "GET",
105
- f"/backend-api/accounts/{self.account_id}/users?offset=0&limit=100",
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
- return data.get("items", data.get("users", []))
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 _decode_jwt_payload(token: str) -> dict[str, Any]:
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 = _decode_jwt_payload(token)
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 = _decode_jwt_payload(token)
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
- rows = self._compose_rows(self._load_accounts())
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
@@ -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:%S")
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
- 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})
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
- 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
- )
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
- 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
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": "app_EMoamEEZ73f0CkXaXp7hrann",
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": "app_EMoamEEZ73f0CkXaXp7hrann",
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 = _decode_jwt_payload(id_token or access_token)
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")
@@ -80,7 +80,9 @@ class RPCServer:
80
80
  if not value:
81
81
  return ""
82
82
  if "KEY" in key:
83
- return value[:4] + "***" + value[-4:] if len(value) > 12 else "***"
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:
@@ -277,7 +277,7 @@ class UIFacade:
277
277
  log("Слот создан без OAuth-токена — нужен перелогин")
278
278
  except Exception as e:
279
279
  log(f"Ошибка: {e}")
280
- manager._close_admin_page()
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
- mail = create_provider_for_mailbox(Mailbox(email=worker.email, password=worker.password))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "izteamslots",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "ChatGPT Team slot management — automated invite, register, OAuth login & codex token pipeline",
5
5
  "bin": {
6
6
  "izteamslots": "bin/izteamslots.mjs"
package/requirements.txt CHANGED
@@ -1,3 +1,3 @@
1
- python-dotenv~=1.0
1
+ python-dotenv~=1.2
2
2
  requests~=2.31
3
- seleniumbase~=4.32
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()
@@ -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()