v2.7.4 (#579)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
90763daf81
commit
e9a9217cc8
113 changed files with 7202 additions and 541 deletions
113
tests/browser_sign_in/stubs.py
Normal file
113
tests/browser_sign_in/stubs.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from vibe.setup.auth import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInGateway,
|
||||
BrowserSignInPollResult,
|
||||
BrowserSignInProcess,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExchangeRequestPayload:
|
||||
process_id: str
|
||||
exchange_token: str
|
||||
code_verifier: str
|
||||
|
||||
|
||||
class StubBrowserSignInGateway(BrowserSignInGateway):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
process: BrowserSignInProcess | None = None,
|
||||
processes: list[BrowserSignInProcess] | None = None,
|
||||
poll_results: list[BrowserSignInPollResult | BrowserSignInError] | None = None,
|
||||
exchange_result: str = "sk-browser-key",
|
||||
exchange_error: BrowserSignInError | None = None,
|
||||
) -> None:
|
||||
if process is not None and processes is not None:
|
||||
msg = "StubBrowserSignInGateway accepts either process or processes."
|
||||
raise AssertionError(msg)
|
||||
|
||||
self._processes = list(processes or ([] if process is None else [process]))
|
||||
self._poll_results = list(poll_results or [])
|
||||
self.exchange_result = exchange_result
|
||||
self.exchange_error = exchange_error
|
||||
self.code_challenges: list[str] = []
|
||||
self.polled_urls: list[str] = []
|
||||
self.exchange_requests: list[ExchangeRequestPayload] = []
|
||||
self.closed = False
|
||||
self.process_number = 0
|
||||
|
||||
async def create_process(self, code_challenge: str) -> BrowserSignInProcess:
|
||||
self.code_challenges.append(code_challenge)
|
||||
if not self._processes:
|
||||
msg = "StubBrowserSignInGateway requires at least one scripted process."
|
||||
raise AssertionError(msg)
|
||||
|
||||
self.process_number += 1
|
||||
return self._processes.pop(0)
|
||||
|
||||
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
|
||||
self.polled_urls.append(poll_url)
|
||||
if not self._poll_results:
|
||||
msg = "StubBrowserSignInGateway requires scripted poll results."
|
||||
raise AssertionError(msg)
|
||||
|
||||
result = self._poll_results.pop(0)
|
||||
if isinstance(result, BrowserSignInError):
|
||||
raise result
|
||||
return result
|
||||
|
||||
async def exchange(
|
||||
self, process_id: str, exchange_token: str, code_verifier: str
|
||||
) -> str:
|
||||
self.exchange_requests.append(
|
||||
ExchangeRequestPayload(
|
||||
process_id=process_id,
|
||||
exchange_token=exchange_token,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
)
|
||||
if self.exchange_error is not None:
|
||||
raise self.exchange_error
|
||||
return self.exchange_result
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def build_sign_in_process(
|
||||
now: datetime, process_id: str = "process-1"
|
||||
) -> BrowserSignInProcess:
|
||||
fragment = urlencode({
|
||||
"process_id": process_id,
|
||||
"complete_token": f"complete-token-{process_id}",
|
||||
"state": f"state-{process_id}",
|
||||
})
|
||||
return BrowserSignInProcess(
|
||||
process_id=process_id,
|
||||
sign_in_url=(
|
||||
f"https://console.mistral.ai/codestral/cli/authenticate#{fragment}"
|
||||
),
|
||||
poll_url=(
|
||||
f"https://api.mistral.ai/api/vibe/sign-in/poll/poll-token-{process_id}"
|
||||
),
|
||||
expires_at=now + timedelta(minutes=5),
|
||||
)
|
||||
|
||||
|
||||
def build_poll_failed_error() -> BrowserSignInError:
|
||||
return BrowserSignInError(
|
||||
"Browser sign-in status could not be retrieved.",
|
||||
code=BrowserSignInErrorCode.POLL_FAILED,
|
||||
)
|
||||
|
||||
|
||||
async def noop_sleep(_: float) -> None:
|
||||
return None
|
||||
325
tests/browser_sign_in/test_browser_sign_in.py
Normal file
325
tests/browser_sign_in/test_browser_sign_in.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.browser_sign_in.stubs import (
|
||||
StubBrowserSignInGateway,
|
||||
build_poll_failed_error,
|
||||
build_sign_in_process,
|
||||
noop_sleep,
|
||||
)
|
||||
from vibe.setup.auth import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInPollResult,
|
||||
BrowserSignInProcess,
|
||||
BrowserSignInService,
|
||||
)
|
||||
|
||||
TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
TEST_PROCESS_ID = "process-1"
|
||||
TEST_SIGN_IN_URL = "https://console.mistral.ai/codestral/cli/authenticate#" + urlencode({
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"complete_token": f"complete-token-{TEST_PROCESS_ID}",
|
||||
"state": f"state-{TEST_PROCESS_ID}",
|
||||
})
|
||||
TEST_POLL_URL = "https://api.mistral.ai/api/vibe/sign-in/poll/poll-token-process-1"
|
||||
|
||||
|
||||
def build_code_challenge(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def build_test_service(
|
||||
*,
|
||||
poll_results: list[BrowserSignInPollResult | BrowserSignInError],
|
||||
exchange_error: BrowserSignInError | None = None,
|
||||
open_browser: Callable[[str], bool] | None = None,
|
||||
sleep: Callable[[float], Awaitable[None]] = noop_sleep,
|
||||
now: Callable[[], datetime] | None = None,
|
||||
process_time: datetime = TEST_NOW,
|
||||
) -> tuple[StubBrowserSignInGateway, BrowserSignInService]:
|
||||
gateway = StubBrowserSignInGateway(
|
||||
process=build_sign_in_process(process_time),
|
||||
poll_results=poll_results,
|
||||
exchange_error=exchange_error,
|
||||
)
|
||||
service = BrowserSignInService(
|
||||
gateway,
|
||||
open_browser=open_browser or (lambda _: True),
|
||||
sleep=sleep,
|
||||
now=now or (lambda: process_time),
|
||||
poll_interval=0,
|
||||
)
|
||||
return gateway, service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_returns_api_key_after_pending_poll() -> None:
|
||||
opened_urls: list[str] = []
|
||||
statuses: list[str] = []
|
||||
gateway, service = build_test_service(
|
||||
poll_results=[
|
||||
BrowserSignInPollResult(status="pending"),
|
||||
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
|
||||
],
|
||||
open_browser=lambda url: opened_urls.append(url) or True,
|
||||
)
|
||||
|
||||
api_key = await service.authenticate(status_callback=statuses.append)
|
||||
|
||||
code_verifier = gateway.exchange_requests[0].code_verifier
|
||||
assert gateway.code_challenges == [build_code_challenge(code_verifier)]
|
||||
assert api_key == "sk-browser-key"
|
||||
assert opened_urls == [TEST_SIGN_IN_URL]
|
||||
assert statuses == [
|
||||
"opening_browser",
|
||||
"waiting_for_browser_sign_in",
|
||||
"exchanging",
|
||||
"completed",
|
||||
]
|
||||
assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL]
|
||||
assert gateway.exchange_requests[0].exchange_token == "exchange-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_raises_when_polling_expires() -> None:
|
||||
opened_urls: list[str] = []
|
||||
_, service = build_test_service(
|
||||
poll_results=[BrowserSignInPollResult(status="expired")],
|
||||
open_browser=lambda url: opened_urls.append(url) or True,
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserSignInError, match="expired"):
|
||||
await service.authenticate()
|
||||
|
||||
assert opened_urls == [TEST_SIGN_IN_URL]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_retries_after_transient_poll_failure() -> None:
|
||||
gateway, service = build_test_service(
|
||||
poll_results=[
|
||||
build_poll_failed_error(),
|
||||
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
|
||||
]
|
||||
)
|
||||
|
||||
api_key = await service.authenticate()
|
||||
|
||||
assert api_key == "sk-browser-key"
|
||||
assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_fails_after_three_consecutive_poll_failures() -> None:
|
||||
_, service = build_test_service(
|
||||
poll_results=[
|
||||
build_poll_failed_error(),
|
||||
build_poll_failed_error(),
|
||||
build_poll_failed_error(),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await service.authenticate()
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_resets_poll_failure_streak_after_successful_poll() -> None:
|
||||
gateway, service = build_test_service(
|
||||
poll_results=[
|
||||
build_poll_failed_error(),
|
||||
BrowserSignInPollResult(status="pending"),
|
||||
build_poll_failed_error(),
|
||||
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
|
||||
]
|
||||
)
|
||||
|
||||
api_key = await service.authenticate()
|
||||
|
||||
assert api_key == "sk-browser-key"
|
||||
assert len(gateway.polled_urls) == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_raises_on_unknown_poll_state() -> None:
|
||||
class UnknownStateGateway:
|
||||
def __init__(self) -> None:
|
||||
self.process = build_sign_in_process(TEST_NOW)
|
||||
|
||||
async def create_process(self, code_challenge: str):
|
||||
return self.process
|
||||
|
||||
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
|
||||
return cast(
|
||||
BrowserSignInPollResult,
|
||||
SimpleNamespace(status="unexpected", exchange_token=None, message=None),
|
||||
)
|
||||
|
||||
async def exchange(
|
||||
self, process_id: str, exchange_token: str, code_verifier: str
|
||||
) -> str:
|
||||
return "sk-browser-key"
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
service = BrowserSignInService(
|
||||
UnknownStateGateway(),
|
||||
open_browser=lambda _: True,
|
||||
sleep=noop_sleep,
|
||||
now=lambda: TEST_NOW,
|
||||
poll_interval=0,
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserSignInError, match="unknown state") as err:
|
||||
await service.authenticate()
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.UNKNOWN_STATE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_raises_when_browser_cannot_be_opened() -> None:
|
||||
_, service = build_test_service(poll_results=[], open_browser=lambda _: False)
|
||||
|
||||
with pytest.raises(BrowserSignInError, match="open browser"):
|
||||
await service.authenticate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_raises_when_exchange_fails() -> None:
|
||||
_, service = build_test_service(
|
||||
poll_results=[
|
||||
BrowserSignInPollResult(status="completed", exchange_token="exchange-1")
|
||||
],
|
||||
exchange_error=BrowserSignInError(
|
||||
"Failed to exchange browser sign-in for an API key."
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserSignInError, match="exchange"):
|
||||
await service.authenticate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_can_be_cancelled_before_start() -> None:
|
||||
gateway, service = build_test_service(poll_results=[])
|
||||
task = asyncio.create_task(service.authenticate())
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert gateway.code_challenges == []
|
||||
assert gateway.exchange_requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_can_be_cancelled_while_waiting_for_sign_in() -> None:
|
||||
blocker = asyncio.Event()
|
||||
|
||||
async def wait_forever(_: float) -> None:
|
||||
await blocker.wait()
|
||||
|
||||
gateway, service = build_test_service(
|
||||
poll_results=[BrowserSignInPollResult(status="pending")], sleep=wait_forever
|
||||
)
|
||||
task = asyncio.create_task(service.authenticate())
|
||||
|
||||
while not gateway.polled_urls:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert gateway.exchange_requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_times_out_when_process_never_completes() -> None:
|
||||
current_time = TEST_NOW
|
||||
|
||||
async def advance_time(_: float) -> None:
|
||||
nonlocal current_time
|
||||
current_time += timedelta(minutes=3)
|
||||
|
||||
_, service = build_test_service(
|
||||
poll_results=[
|
||||
BrowserSignInPollResult(status="pending"),
|
||||
BrowserSignInPollResult(status="pending"),
|
||||
],
|
||||
sleep=advance_time,
|
||||
now=lambda: current_time,
|
||||
process_time=current_time,
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserSignInError, match="timed out"):
|
||||
await service.authenticate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"first_poll_result",
|
||||
[BrowserSignInPollResult(status="pending"), build_poll_failed_error()],
|
||||
ids=["pending", "transient_poll_failure"],
|
||||
)
|
||||
async def test_authenticate_caps_sleep_to_remaining_sign_in_lifetime(
|
||||
first_poll_result: BrowserSignInPollResult | BrowserSignInError,
|
||||
) -> None:
|
||||
current_time = TEST_NOW
|
||||
sleep_durations: list[float] = []
|
||||
|
||||
async def advance_time(duration: float) -> None:
|
||||
nonlocal current_time
|
||||
sleep_durations.append(duration)
|
||||
current_time += timedelta(seconds=duration)
|
||||
|
||||
gateway = StubBrowserSignInGateway(
|
||||
process=BrowserSignInProcess(
|
||||
process_id=TEST_PROCESS_ID,
|
||||
sign_in_url=TEST_SIGN_IN_URL,
|
||||
poll_url=TEST_POLL_URL,
|
||||
expires_at=TEST_NOW + timedelta(seconds=1),
|
||||
),
|
||||
poll_results=[first_poll_result],
|
||||
)
|
||||
service = BrowserSignInService(
|
||||
gateway,
|
||||
open_browser=lambda _: True,
|
||||
sleep=advance_time,
|
||||
now=lambda: current_time,
|
||||
poll_interval=3.0,
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserSignInError, match="timed out") as err:
|
||||
await service.authenticate()
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.TIMED_OUT
|
||||
assert sleep_durations == [1.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_closes_underlying_api() -> None:
|
||||
gateway, service = build_test_service(poll_results=[])
|
||||
|
||||
await service.aclose()
|
||||
|
||||
assert gateway.closed is True
|
||||
654
tests/browser_sign_in/test_browser_sign_in_http.py
Normal file
654
tests/browser_sign_in/test_browser_sign_in_http.py
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vibe.setup.auth import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
HttpBrowserSignInGateway,
|
||||
)
|
||||
|
||||
AUTH_ORIGIN = "https://api.mistral.ai"
|
||||
AUTH_BROWSER_BASE_URL = "https://console.mistral.ai"
|
||||
AUTH_API_BASE_URL = AUTH_ORIGIN
|
||||
TEST_PROCESS_ID = "process-1"
|
||||
TEST_COMPLETE_TOKEN = "complete-token-1"
|
||||
TEST_STATE = "state-1"
|
||||
TEST_POLL_TOKEN = "poll-token-1"
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def build_sign_in_url(
|
||||
*,
|
||||
process_id: str = TEST_PROCESS_ID,
|
||||
base_url: str = AUTH_BROWSER_BASE_URL,
|
||||
complete_token: str = TEST_COMPLETE_TOKEN,
|
||||
state: str = TEST_STATE,
|
||||
) -> str:
|
||||
fragment = urlencode({
|
||||
"process_id": process_id,
|
||||
"complete_token": complete_token,
|
||||
"state": state,
|
||||
})
|
||||
return f"{base_url}/codestral/cli/authenticate#{fragment}"
|
||||
|
||||
|
||||
def build_poll_url(
|
||||
*, poll_token: str = TEST_POLL_TOKEN, api_base_url: str = AUTH_API_BASE_URL
|
||||
) -> str:
|
||||
return f"{api_base_url}/api/vibe/sign-in/poll/{poll_token}"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def build_gateway(
|
||||
handler: Callable[[httpx.Request], httpx.Response],
|
||||
*,
|
||||
origin: str = AUTH_ORIGIN,
|
||||
browser_base_url: str = AUTH_BROWSER_BASE_URL,
|
||||
api_base_url: str = AUTH_API_BASE_URL,
|
||||
):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler), base_url=origin
|
||||
) as client:
|
||||
yield HttpBrowserSignInGateway(
|
||||
browser_base_url=browser_base_url, api_base_url=api_base_url, client=client
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_creates_process_with_pkce_payload() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
captured_body: str | None = None
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal captured_body
|
||||
assert request.url.path == "/vibe/sign-in"
|
||||
captured_body = request.content.decode("utf-8")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": build_poll_url(),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
process = await gateway.create_process("challenge-123")
|
||||
|
||||
assert process.process_id == TEST_PROCESS_ID
|
||||
assert process.sign_in_url == build_sign_in_url()
|
||||
assert process.poll_url == build_poll_url()
|
||||
assert captured_body is not None
|
||||
assert '"code_challenge":"challenge-123"' in captured_body
|
||||
assert '"code_challenge_method":"S256"' in captured_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_polls_process_state() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/vibe/sign-in/poll/poll-token-1"
|
||||
return httpx.Response(
|
||||
200, json={"status": "completed", "exchange_token": "exchange-1"}
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
result = await gateway.poll(build_poll_url())
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.exchange_token == "exchange-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_maps_410_poll_response_to_expired_status() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/vibe/sign-in/poll/poll-token-1"
|
||||
return httpx.Response(410)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
result = await gateway.poll(build_poll_url())
|
||||
|
||||
assert result.status == "expired"
|
||||
assert result.exchange_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_exchanges_token_for_api_key() -> None:
|
||||
captured_body: str | None = None
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal captured_body
|
||||
assert request.url.path == "/vibe/sign-in/process-1/exchange"
|
||||
captured_body = request.content.decode("utf-8")
|
||||
return httpx.Response(200, json={"api_key": "sk-browser-key"})
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
api_key = await gateway.exchange("process-1", "exchange-1", "verifier-1")
|
||||
|
||||
assert api_key == "sk-browser-key"
|
||||
assert captured_body is not None
|
||||
assert '"exchange_token":"exchange-1"' in captured_body
|
||||
assert '"code_verifier":"verifier-1"' in captured_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_translates_transport_errors() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom", request=request)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_assigns_poll_failed_code_on_transport_error() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom", request=request)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await gateway.poll(build_poll_url())
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_assigns_poll_failed_code_on_invalid_poll_url() -> None:
|
||||
async with build_gateway(lambda _: httpx.Response(200)) as gateway:
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await gateway.poll("https://evil.example/poll/secret-1")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_translates_non_json_start_response() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text="<html>not json</html>")
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_translates_missing_start_fields() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": build_poll_url(),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_accepts_poll_url_under_configured_api_base_url() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": build_poll_url(),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
process = await gateway.create_process("challenge-123")
|
||||
|
||||
assert process.poll_url == build_poll_url()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_accepts_poll_url_under_configured_api_base_path() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/vibe/sign-in"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": build_poll_url(
|
||||
poll_token=TEST_POLL_TOKEN, api_base_url="https://api.mistral.ai/v1"
|
||||
),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(
|
||||
handler,
|
||||
origin="https://api.mistral.ai",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
) as gateway:
|
||||
process = await gateway.create_process("challenge-123")
|
||||
|
||||
assert process.poll_url == build_poll_url(api_base_url="https://api.mistral.ai/v1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_accepts_same_origin_urls_with_explicit_default_https_ports() -> (
|
||||
None
|
||||
):
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(
|
||||
base_url="https://console.mistral.ai:443"
|
||||
),
|
||||
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai:443"),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
process = await gateway.create_process("challenge-123")
|
||||
|
||||
assert process.sign_in_url == build_sign_in_url(
|
||||
base_url="https://console.mistral.ai:443"
|
||||
)
|
||||
assert process.poll_url == build_poll_url(api_base_url="https://api.mistral.ai:443")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_accepts_poll_url_without_explicit_default_https_port_when_base_has_one() -> (
|
||||
None
|
||||
):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/vibe/sign-in/poll/poll-token-1"
|
||||
return httpx.Response(
|
||||
200, json={"status": "completed", "exchange_token": "exchange-1"}
|
||||
)
|
||||
|
||||
async with build_gateway(
|
||||
handler,
|
||||
origin="https://api.mistral.ai:443",
|
||||
api_base_url="https://api.mistral.ai:443",
|
||||
) as gateway:
|
||||
result = await gateway.poll(build_poll_url())
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.exchange_token == "exchange-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_accepts_sign_in_url_under_configured_browser_base_url() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": build_poll_url(),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
process = await gateway.create_process("challenge-123")
|
||||
|
||||
assert process.sign_in_url == build_sign_in_url()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_sign_in_url_outside_browser_base_url() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(base_url="https://evil.example"),
|
||||
"poll_url": build_poll_url(),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_sign_in_url_outside_browser_base_path_after_normalization() -> (
|
||||
None
|
||||
):
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(
|
||||
base_url="https://console.mistral.ai/v1/.."
|
||||
),
|
||||
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai/v1"),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(
|
||||
handler,
|
||||
browser_base_url="https://console.mistral.ai/v1",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_sign_in_url_with_encoded_dot_segments_outside_browser_base_path() -> (
|
||||
None
|
||||
):
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(
|
||||
base_url="https://console.mistral.ai/v1/%2e%2e"
|
||||
),
|
||||
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai/v1"),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(
|
||||
handler,
|
||||
browser_base_url="https://console.mistral.ai/v1",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_poll_url_outside_api_base_url() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": "https://evil.example/api/vibe/sign-in/poll/poll-token-1",
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_returned_poll_url_outside_api_base_path_after_normalization() -> (
|
||||
None
|
||||
):
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(
|
||||
base_url="https://console.mistral.ai/v1"
|
||||
),
|
||||
"poll_url": build_poll_url(
|
||||
poll_token=TEST_POLL_TOKEN,
|
||||
api_base_url="https://api.mistral.ai/v1/..",
|
||||
),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(
|
||||
handler,
|
||||
origin="https://api.mistral.ai",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
browser_base_url="https://console.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_returned_poll_url_with_encoded_dot_segments_outside_api_base_path() -> (
|
||||
None
|
||||
):
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(
|
||||
base_url="https://console.mistral.ai/v1"
|
||||
),
|
||||
"poll_url": build_poll_url(
|
||||
poll_token=TEST_POLL_TOKEN,
|
||||
api_base_url="https://api.mistral.ai/v1/%2e%2e",
|
||||
),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(
|
||||
handler,
|
||||
origin="https://api.mistral.ai",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
browser_base_url="https://console.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_poll_url_outside_api_base_path() -> None:
|
||||
async with build_gateway(
|
||||
lambda _: httpx.Response(200),
|
||||
origin="https://api.mistral.ai",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await gateway.poll(
|
||||
"https://api.mistral.ai/v1evil/api/vibe/sign-in/poll/poll-token-1"
|
||||
)
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_rejects_poll_url_outside_api_base_path_after_normalization() -> (
|
||||
None
|
||||
):
|
||||
async with build_gateway(
|
||||
lambda _: httpx.Response(200),
|
||||
origin="https://api.mistral.ai",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await gateway.poll(
|
||||
"https://api.mistral.ai/v1/../api/vibe/sign-in/poll/poll-token-1"
|
||||
)
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_translates_invalid_returned_poll_url_port() -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(),
|
||||
"poll_url": "https://api.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1",
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.START_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_assigns_poll_failed_code_on_invalid_poll_url_port() -> None:
|
||||
async with build_gateway(lambda _: httpx.Response(200)) as gateway:
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await gateway.poll(
|
||||
"https://api.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1"
|
||||
)
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_translates_non_json_poll_response() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text="<html>not json</html>")
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
) as err:
|
||||
await gateway.poll(build_poll_url())
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_translates_non_json_exchange_response() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text="<html>not json</html>")
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with pytest.raises(BrowserSignInError, match="exchange browser sign-in") as err:
|
||||
await gateway.exchange("process-1", "exchange-1", "verifier-1")
|
||||
|
||||
assert err.value.code is BrowserSignInErrorCode.EXCHANGE_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_does_not_log_sign_in_or_poll_secrets_on_start_validation_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
now = datetime(2026, 3, 16, tzinfo=UTC)
|
||||
complete_token = "complete-token-secret"
|
||||
state = "state-secret"
|
||||
poll_token = "poll-token-secret"
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"process_id": TEST_PROCESS_ID,
|
||||
"sign_in_url": build_sign_in_url(
|
||||
base_url="https://evil.example",
|
||||
complete_token=complete_token,
|
||||
state=state,
|
||||
),
|
||||
"poll_url": build_poll_url(poll_token=poll_token),
|
||||
"expires_at": _iso(now + timedelta(minutes=5)),
|
||||
},
|
||||
)
|
||||
|
||||
async with build_gateway(handler) as gateway:
|
||||
with caplog.at_level(logging.WARNING, logger="vibe"):
|
||||
with pytest.raises(BrowserSignInError, match="start browser sign-in"):
|
||||
await gateway.create_process("challenge-123")
|
||||
|
||||
assert complete_token not in caplog.text
|
||||
assert state not in caplog.text
|
||||
assert poll_token not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_api_does_not_log_poll_secret_on_poll_url_validation_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
poll_token = "poll-token-secret"
|
||||
|
||||
async with build_gateway(
|
||||
lambda _: httpx.Response(200),
|
||||
origin="https://api.mistral.ai",
|
||||
api_base_url="https://api.mistral.ai/v1",
|
||||
) as gateway:
|
||||
with caplog.at_level(logging.WARNING, logger="vibe"):
|
||||
with pytest.raises(
|
||||
BrowserSignInError, match="status could not be retrieved"
|
||||
):
|
||||
await gateway.poll(
|
||||
f"https://api.mistral.ai/v1evil/api/vibe/sign-in/poll/{poll_token}"
|
||||
)
|
||||
|
||||
assert poll_token not in caplog.text
|
||||
Loading…
Add table
Add a link
Reference in a new issue