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:
Mathias Gesbert 2026-04-09 18:40:46 +02:00 committed by GitHub
parent 90763daf81
commit e9a9217cc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 7202 additions and 541 deletions

View 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

View 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

View 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