157 lines
6.0 KiB
Python
157 lines
6.0 KiB
Python
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
|
|
import api.oauth as oauth
|
|
|
|
|
|
def setup_function():
|
|
with oauth._OAUTH_FLOWS_LOCK:
|
|
oauth._OAUTH_FLOWS.clear()
|
|
with oauth._OAUTH_START_LOCKS_LOCK:
|
|
oauth._OAUTH_START_LOCKS.clear()
|
|
|
|
|
|
def teardown_function():
|
|
with oauth._OAUTH_FLOWS_LOCK:
|
|
oauth._OAUTH_FLOWS.clear()
|
|
with oauth._OAUTH_START_LOCKS_LOCK:
|
|
oauth._OAUTH_START_LOCKS.clear()
|
|
|
|
|
|
def _run_two_concurrent(fn):
|
|
ready = threading.Barrier(2)
|
|
|
|
def wrapped():
|
|
ready.wait(timeout=5)
|
|
return fn()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
futures = [executor.submit(wrapped), executor.submit(wrapped)]
|
|
return [future.result(timeout=5) for future in futures]
|
|
|
|
|
|
def test_anthropic_onboarding_oauth_start_reuses_pending_flow(monkeypatch, tmp_path):
|
|
"""Repeated unauthenticated starts must not spawn unbounded pending workers."""
|
|
spawned = []
|
|
hermes_home = tmp_path / "hermes-home"
|
|
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: None)
|
|
monkeypatch.setattr(oauth, "_spawn_anthropic_credential_worker", lambda flow_id: spawned.append(flow_id))
|
|
|
|
first = oauth._start_anthropic_flow(hermes_home)
|
|
second = oauth._start_anthropic_flow(hermes_home)
|
|
|
|
assert first["status"] == "pending"
|
|
assert second["status"] == "pending"
|
|
assert second["flow_id"] == first["flow_id"]
|
|
assert spawned == [first["flow_id"]]
|
|
with oauth._OAUTH_FLOWS_LOCK:
|
|
pending = [flow for flow in oauth._OAUTH_FLOWS.values() if flow.get("status") == "pending"]
|
|
assert len(pending) == 1
|
|
|
|
|
|
def test_anthropic_onboarding_oauth_start_single_flight_is_thread_safe(monkeypatch, tmp_path):
|
|
"""Concurrent unauthenticated Anthropic starts must reserve one pending worker atomically."""
|
|
spawned = []
|
|
hermes_home = tmp_path / "hermes-home"
|
|
credential_reads = threading.Barrier(2)
|
|
|
|
def no_credentials():
|
|
credential_reads.wait(timeout=5)
|
|
return None
|
|
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", no_credentials)
|
|
monkeypatch.setattr(oauth, "_spawn_anthropic_credential_worker", lambda flow_id: spawned.append(flow_id))
|
|
|
|
first, second = _run_two_concurrent(lambda: oauth._start_anthropic_flow(hermes_home))
|
|
|
|
assert first["status"] == "pending"
|
|
assert second["status"] == "pending"
|
|
assert second["flow_id"] == first["flow_id"]
|
|
assert spawned == [first["flow_id"]]
|
|
with oauth._OAUTH_FLOWS_LOCK:
|
|
pending = [flow for flow in oauth._OAUTH_FLOWS.values() if flow.get("status") == "pending"]
|
|
assert len(pending) == 1
|
|
|
|
|
|
def test_codex_onboarding_oauth_start_reuses_pending_flow_before_requesting_code(monkeypatch, tmp_path):
|
|
"""A second Codex start should reuse the live flow before creating a new device code or worker."""
|
|
spawned = []
|
|
requested = []
|
|
hermes_home = tmp_path / "hermes-home"
|
|
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: Path(hermes_home))
|
|
|
|
def fake_request_code():
|
|
requested.append(True)
|
|
return {"user_code": "ABCD-EFGH", "device_auth_id": "device-1", "interval": 5, "expires_in": 900}
|
|
|
|
monkeypatch.setattr(oauth, "_request_codex_user_code", fake_request_code)
|
|
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: spawned.append(flow_id))
|
|
|
|
first = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
|
|
second = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
|
|
|
|
assert first["status"] == "pending"
|
|
assert second["flow_id"] == first["flow_id"]
|
|
assert second["user_code"] == "ABCD-EFGH"
|
|
assert requested == [True]
|
|
assert spawned == [first["flow_id"]]
|
|
with oauth._OAUTH_FLOWS_LOCK:
|
|
pending = [flow for flow in oauth._OAUTH_FLOWS.values() if flow.get("status") == "pending"]
|
|
assert len(pending) == 1
|
|
|
|
|
|
def test_codex_onboarding_oauth_start_does_not_hold_lock_while_requesting_code(monkeypatch, tmp_path):
|
|
"""The slow Codex device-code request must not block unrelated flow operations."""
|
|
lock_available_during_request = []
|
|
hermes_home = tmp_path / "hermes-home"
|
|
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: Path(hermes_home))
|
|
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: None)
|
|
|
|
def fake_request_code():
|
|
acquired = oauth._OAUTH_FLOWS_LOCK.acquire(blocking=False)
|
|
lock_available_during_request.append(acquired)
|
|
if acquired:
|
|
oauth._OAUTH_FLOWS_LOCK.release()
|
|
return {"user_code": "ABCD-EFGH", "device_auth_id": "device-1", "interval": 5, "expires_in": 900}
|
|
|
|
monkeypatch.setattr(oauth, "_request_codex_user_code", fake_request_code)
|
|
|
|
result = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
|
|
|
|
assert result["status"] == "pending"
|
|
assert lock_available_during_request == [True]
|
|
|
|
|
|
def test_codex_onboarding_oauth_start_single_flight_is_thread_safe(monkeypatch, tmp_path):
|
|
"""Concurrent Codex starts must request one device code and spawn one worker."""
|
|
spawned = []
|
|
requested = []
|
|
hermes_home = tmp_path / "hermes-home"
|
|
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: Path(hermes_home))
|
|
|
|
def fake_request_code():
|
|
requested.append(True)
|
|
time.sleep(0.05)
|
|
return {"user_code": "ABCD-EFGH", "device_auth_id": "device-1", "interval": 5, "expires_in": 900}
|
|
|
|
monkeypatch.setattr(oauth, "_request_codex_user_code", fake_request_code)
|
|
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: spawned.append(flow_id))
|
|
|
|
first, second = _run_two_concurrent(lambda: oauth.start_onboarding_oauth_flow({"provider": "openai-codex"}))
|
|
|
|
assert first["status"] == "pending"
|
|
assert second["status"] == "pending"
|
|
assert second["flow_id"] == first["flow_id"]
|
|
assert second["user_code"] == "ABCD-EFGH"
|
|
assert requested == [True]
|
|
assert spawned == [first["flow_id"]]
|
|
with oauth._OAUTH_FLOWS_LOCK:
|
|
pending = [flow for flow in oauth._OAUTH_FLOWS.values() if flow.get("status") == "pending"]
|
|
assert len(pending) == 1
|