|
| 1 | +# Copyright 2025, 2026 Query Farm LLC - https://query.farm |
| 2 | + |
| 3 | +"""Tests for vgi.client.Client's OAuth wiring (oauth=/oauth_refresh_token=/oauth_flow=). |
| 4 | +
|
| 5 | +The device-code/PKCE flow logic itself is tested exhaustively in |
| 6 | +vgi-rpc-python's tests/test_oauth_client.py (mock IdP, no network). These |
| 7 | +tests cover the integration seam: does `Client` build a `VgiOAuthAuth` with |
| 8 | +the right configuration, does construction-time validation reject bad |
| 9 | +combinations, and does a full round trip actually work when driven through |
| 10 | +`Client`'s real request path (via the `httpx_client=` escape hatch, wired to |
| 11 | +a mock IdP the same way vgi-rpc-python's own tests are). |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import httpx2 |
| 17 | +import pytest |
| 18 | +from vgi_rpc.http import VgiOAuthAuth |
| 19 | + |
| 20 | +from vgi.client.client import Client |
| 21 | + |
| 22 | +RESOURCE_METADATA_URL = "https://api.example.com/.well-known/oauth-protected-resource" |
| 23 | +DEVICE_AUTH_URL = "https://auth.example.com/device/code" |
| 24 | +TOKEN_URL = "https://auth.example.com/token" |
| 25 | + |
| 26 | + |
| 27 | +class _MockIdp: |
| 28 | + """Minimal mock IdP + protected resource for one full device-code round trip.""" |
| 29 | + |
| 30 | + def __init__(self) -> None: |
| 31 | + self.device_auth_calls = 0 |
| 32 | + self.granted_token = "mock-access-token" |
| 33 | + |
| 34 | + def handler(self, request: httpx2.Request) -> httpx2.Response: |
| 35 | + """Route a request to the matching mock endpoint by exact URL.""" |
| 36 | + url = str(request.url) |
| 37 | + if url == RESOURCE_METADATA_URL: |
| 38 | + return httpx2.Response( |
| 39 | + 200, |
| 40 | + json={ |
| 41 | + "resource": "https://api.example.com", |
| 42 | + "authorization_servers": ["https://auth.example.com"], |
| 43 | + "device_code_client_id": "device-client", |
| 44 | + "device_code_client_secret": "device-secret", |
| 45 | + }, |
| 46 | + ) |
| 47 | + if url == "https://auth.example.com/.well-known/openid-configuration": |
| 48 | + return httpx2.Response( |
| 49 | + 200, |
| 50 | + json={ |
| 51 | + "token_endpoint": TOKEN_URL, |
| 52 | + "device_authorization_endpoint": DEVICE_AUTH_URL, |
| 53 | + "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code"], |
| 54 | + }, |
| 55 | + ) |
| 56 | + if url == DEVICE_AUTH_URL: |
| 57 | + self.device_auth_calls += 1 |
| 58 | + return httpx2.Response( |
| 59 | + 200, |
| 60 | + json={ |
| 61 | + "device_code": "devcode-123", |
| 62 | + "user_code": "ABCD-EFGH", |
| 63 | + "verification_uri": "https://auth.example.com/activate", |
| 64 | + "expires_in": 600, |
| 65 | + "interval": 0, |
| 66 | + }, |
| 67 | + ) |
| 68 | + if url == TOKEN_URL: |
| 69 | + return httpx2.Response(200, json={"access_token": self.granted_token, "token_type": "Bearer"}) |
| 70 | + if url.endswith("/vgi/catalogs"): |
| 71 | + token = request.headers.get("authorization", "") |
| 72 | + if token: |
| 73 | + return httpx2.Response(200, json={"authorization": token}) |
| 74 | + return httpx2.Response( |
| 75 | + 401, |
| 76 | + headers={"www-authenticate": f'Bearer resource_metadata="{RESOURCE_METADATA_URL}"'}, |
| 77 | + ) |
| 78 | + return httpx2.Response(404, json={"error": "not_found", "url": url}) # pragma: no cover |
| 79 | + |
| 80 | + |
| 81 | +class TestConstructorValidation: |
| 82 | + """Tests for the mutually-exclusive oauth/bearer_token/httpx_client combinations.""" |
| 83 | + |
| 84 | + def test_oauth_and_bearer_token_raises(self) -> None: |
| 85 | + """Passing both oauth=True and bearer_token= raises ValueError.""" |
| 86 | + with pytest.raises(ValueError, match="mutually exclusive"): |
| 87 | + Client(transport="http", base_url="https://example.com", oauth=True, bearer_token="tok") |
| 88 | + |
| 89 | + def test_oauth_refresh_token_and_bearer_token_raises(self) -> None: |
| 90 | + """oauth_refresh_token= (which implies oauth=True) also conflicts with bearer_token=.""" |
| 91 | + with pytest.raises(ValueError, match="mutually exclusive"): |
| 92 | + Client( |
| 93 | + transport="http", |
| 94 | + base_url="https://example.com", |
| 95 | + oauth_refresh_token="seed", |
| 96 | + bearer_token="tok", |
| 97 | + ) |
| 98 | + |
| 99 | + def test_oauth_and_httpx_client_raises(self) -> None: |
| 100 | + """Passing both oauth=True and httpx_client= raises ValueError.""" |
| 101 | + with pytest.raises(ValueError, match="httpx_client"): |
| 102 | + Client( |
| 103 | + transport="http", |
| 104 | + base_url="https://example.com", |
| 105 | + oauth=True, |
| 106 | + httpx_client=httpx2.Client(), |
| 107 | + ) |
| 108 | + |
| 109 | + def test_invalid_oauth_flow_raises(self) -> None: |
| 110 | + """An unrecognized oauth_flow value raises ValueError, not a silent no-op.""" |
| 111 | + with pytest.raises(ValueError, match="oauth_flow"): |
| 112 | + Client(transport="http", base_url="https://example.com", oauth=True, oauth_flow="carrier-pigeon") # type: ignore[arg-type] |
| 113 | + |
| 114 | + def test_invalid_oauth_prompt_raises(self) -> None: |
| 115 | + """An unrecognized oauth_prompt value raises ValueError, not a silent no-op.""" |
| 116 | + with pytest.raises(ValueError, match="oauth_prompt"): |
| 117 | + Client(transport="http", base_url="https://example.com", oauth=True, oauth_prompt="shout") # type: ignore[arg-type] |
| 118 | + |
| 119 | + def test_bearer_token_alone_still_works(self) -> None: |
| 120 | + """The existing bearer_token= path is unaffected by the new oauth params.""" |
| 121 | + client = Client(transport="http", base_url="https://example.com", bearer_token="tok") |
| 122 | + assert client._bearer_token == "tok" |
| 123 | + assert not client._use_oauth |
| 124 | + |
| 125 | + |
| 126 | +class TestOAuthWiring: |
| 127 | + """Tests that Client(oauth=...) builds a correctly-configured VgiOAuthAuth.""" |
| 128 | + |
| 129 | + def test_oauth_true_builds_vgi_oauth_auth(self) -> None: |
| 130 | + """oauth=True lazily builds a VgiOAuthAuth with the client's own oauth_* settings.""" |
| 131 | + client = Client( |
| 132 | + transport="http", |
| 133 | + base_url="https://example.com", |
| 134 | + oauth=True, |
| 135 | + oauth_flow="device_code", |
| 136 | + oauth_timeout_seconds=42.0, |
| 137 | + oauth_prompt="consent", |
| 138 | + ) |
| 139 | + assert client.oauth_identity() is None # nothing has authenticated yet |
| 140 | + client._get_or_create_httpx_client() |
| 141 | + assert isinstance(client._oauth_auth, VgiOAuthAuth) |
| 142 | + assert client._oauth_auth._flow == "device_code" # noqa: SLF001 -- white-box wiring check |
| 143 | + assert client._oauth_auth._timeout_seconds == 42.0 # noqa: SLF001 |
| 144 | + assert client._oauth_auth._prompt == "consent" # noqa: SLF001 |
| 145 | + client._oauth_auth.close() |
| 146 | + |
| 147 | + def test_oauth_refresh_token_seeds_the_auth_object(self) -> None: |
| 148 | + """oauth_refresh_token= implies oauth=True and seeds the refresh token.""" |
| 149 | + client = Client( |
| 150 | + transport="http", |
| 151 | + base_url="https://example.com", |
| 152 | + oauth_refresh_token="pre-seeded-token", |
| 153 | + ) |
| 154 | + assert client._use_oauth |
| 155 | + client._get_or_create_httpx_client() |
| 156 | + assert client._oauth_auth is not None |
| 157 | + assert client._oauth_auth._token.refresh_token == "pre-seeded-token" # noqa: SLF001 |
| 158 | + client._oauth_auth.close() |
| 159 | + |
| 160 | + |
| 161 | +class TestOAuthEndToEnd: |
| 162 | + """A full device-code login round trip through the exact client Client would use. |
| 163 | +
|
| 164 | + Uses the httpx_client= escape hatch to wire a VgiOAuthAuth against a mock |
| 165 | + IdP (httpx2.MockTransport) -- the same technique vgi-rpc-python's own |
| 166 | + tests use. The device-code/PKCE flow mechanics are covered exhaustively |
| 167 | + there (mock IdP, no network); this test's job is narrower: prove |
| 168 | + ``Client`` actually issues its RPCs through the httpx2.Client it was |
| 169 | + handed (so a real attach would carry the OAuth-obtained token), and that |
| 170 | + a 401-triggered login on that client completes end to end. |
| 171 | + """ |
| 172 | + |
| 173 | + def test_client_issues_requests_through_the_oauth_authed_httpx_client(self) -> None: |
| 174 | + """A 401 on the client Client._get_or_create_httpx_client() returns triggers a real login.""" |
| 175 | + idp = _MockIdp() |
| 176 | + auth = VgiOAuthAuth( |
| 177 | + base_url="https://example.com", |
| 178 | + flow="device_code", |
| 179 | + timeout_seconds=5.0, |
| 180 | + transport=httpx2.MockTransport(idp.handler), |
| 181 | + ) |
| 182 | + httpx_client = httpx2.Client( |
| 183 | + transport=httpx2.MockTransport(idp.handler), auth=auth, base_url="https://example.com" |
| 184 | + ) |
| 185 | + # Not started (no worker spawned) -- this test only exercises the HTTP/OAuth |
| 186 | + # plumbing, not a full RPC call, so client.stop() (which requires start()) |
| 187 | + # doesn't apply; the httpx_client/auth are closed directly instead. |
| 188 | + client = Client(transport="http", base_url="https://example.com", httpx_client=httpx_client) |
| 189 | + try: |
| 190 | + # This is the exact client object Client's own RPC machinery sends |
| 191 | + # every request through -- not a parallel one built just for the test. |
| 192 | + assert client._get_or_create_httpx_client() is httpx_client |
| 193 | + |
| 194 | + resp = httpx_client.get("/vgi/catalogs") |
| 195 | + assert resp.status_code == 200 |
| 196 | + assert resp.json()["authorization"] == f"Bearer {idp.granted_token}" |
| 197 | + assert idp.device_auth_calls == 1 |
| 198 | + |
| 199 | + # A second request reuses the cached token -- no second login. |
| 200 | + httpx_client.get("/vgi/catalogs") |
| 201 | + assert idp.device_auth_calls == 1 |
| 202 | + finally: |
| 203 | + httpx_client.close() |
| 204 | + auth.close() |
0 commit comments