Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ ignore = [
"PLR0913"
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = [
"A002",
"ARG005",
"EM101",
"EM102",
"PLC0415",
"PLR2004",
"PT018",
"S105",
"S106",
"SLF001",
]

[tool.mypy]
strict = true
pretty = true
Expand Down Expand Up @@ -57,5 +71,14 @@ dev = [
"fastapi[all]>=0.137.2",
"mypy>=2.1.0",
"pydantic-settings>=2.14.2",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"ruff>=0.15.18",
]

[tool.pytest.ini_options]
addopts = "--cov=fastid_client --cov-branch --cov-report=term-missing --cov-fail-under=100"
asyncio_mode = "auto"
pythonpath = ["."]
testpaths = ["tests"]
Empty file added tests/__init__.py
Empty file.
290 changes: 290 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import base64
import builtins
import importlib.util
from collections.abc import AsyncIterator, Callable
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

import httpx
import pytest

from fastid_client.base import OAuth2Client
from fastid_client.client import FastIDClient
from fastid_client.exceptions import (
AuthorizationError,
DiscoveryError,
FastAPINotFoundError,
RedirectURIError,
StateError,
UserinfoError,
)
from fastid_client.schemas import (
DiscoveryDocument,
OAuth2Callback,
ProviderMeta,
TokenResponse,
UserData,
)

DISCOVERY = DiscoveryDocument(
issuer="https://id.test",
authorization_endpoint="https://id.test/authorize",
token_endpoint="https://id.test/token",
userinfo_endpoint="https://id.test/userinfo",
)


def make_client(
handler: Callable[[httpx.Request], httpx.Response],
*,
use_state: bool = True,
redirect_uri: str | None = "https://client.test/callback",
) -> tuple[OAuth2Client, httpx.AsyncClient]:
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
meta = ProviderMeta(
discovery=DISCOVERY,
scope=["openid", "profile"],
use_state=use_state,
)
return (
OAuth2Client(
"client-id",
"client-secret",
redirect_uri,
meta=meta,
client=http_client,
),
http_client,
)


def unused_handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"Unexpected request: {request.method} {request.url}")


@pytest.mark.asyncio
async def test_initialization_discovery_property_and_fastid_client() -> None:
class DefaultClient(OAuth2Client):
default_meta = ProviderMeta(discovery=DISCOVERY, scope=["openid"])

default_http = httpx.AsyncClient()
client = DefaultClient("id", "secret", client=default_http)
assert client.discovery is DISCOVERY
assert client.scope == ["openid"]

fastid_http = httpx.AsyncClient()
fastid = FastIDClient(
"https://fastid.test/",
"id",
"secret",
scope=[],
client=fastid_http,
)
assert fastid.discovery_url == (
"https://fastid.test//.well-known/openid-configuration"
)
assert fastid.scope == []

missing_http = httpx.AsyncClient()
missing = OAuth2Client(
"id",
"secret",
scope=[],
meta=ProviderMeta(discovery_url="https://id.test/discovery"),
client=missing_http,
)
with pytest.raises(DiscoveryError, match="Please discover first"):
_ = missing.discovery

await default_http.aclose()
await fastid_http.aclose()
await missing_http.aclose()


@pytest.mark.asyncio
async def test_discover_conversion_context_manager_call_and_close() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url == "https://id.test/discovery"
return httpx.Response(200, json=DISCOVERY.model_dump())

http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = OAuth2Client(
"id",
"secret",
scope=[],
meta=ProviderMeta(discovery_url="https://id.test/discovery"),
client=http_client,
)
assert (await client.discover()).issuer == "https://id.test"
assert (await client.convert_token({"access_token": "access"})).access_token == (
"access"
)
assert (await client.convert_userinfo({"id": "user"})).id == "user"

async with client as entered:
assert entered is client
assert client.discovery.authorization_endpoint == "https://id.test/authorize"

dependency: AsyncIterator[OAuth2Client] = client()
assert await anext(dependency) is client
with pytest.raises(StopAsyncIteration):
await anext(dependency)

await client.close()
assert http_client.is_closed


def test_authorization_url_variants(monkeypatch: pytest.MonkeyPatch) -> None:
client, _ = make_client(unused_handler)
monkeypatch.setattr("fastid_client.base.generate_random_state", lambda: "random")

parsed = urlparse(client.get_authorization_url(params={"prompt": "login"}))
assert parse_qs(parsed.query) == {
"response_type": ["code"],
"client_id": ["client-id"],
"scope": ["openid profile"],
"redirect_uri": ["https://client.test/callback"],
"state": ["random"],
"prompt": ["login"],
}
assert parse_qs(
urlparse(
client.get_authorization_url(
"https://other.test/callback", state="explicit"
)
).query
)["state"] == ["explicit"]

stateless, _ = make_client(unused_handler, use_state=False)
assert "state" not in parse_qs(
urlparse(stateless.get_authorization_url(params={})).query
)

no_redirect, _ = make_client(unused_handler, redirect_uri=None)
with pytest.raises(RedirectURIError, match="redirect_uri must be provided"):
no_redirect.get_authorization_url()


@pytest.mark.asyncio
async def test_process_callback_success_and_request_contents() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.headers["content-type"].startswith(
"application/x-www-form-urlencoded"
)
expected_auth = base64.b64encode(b"client-id:client-secret").decode()
assert request.headers["authorization"] == f"Basic {expected_auth}"
body = parse_qs(request.content.decode())
assert body == {
"grant_type": ["authorization_code"],
"code": ["auth-code"],
"redirect_uri": ["https://override.test/callback"],
"client_id": ["client-id"],
"client_secret": ["client-secret"],
"state": ["state"],
"audience": ["api"],
}
return httpx.Response(200, json={"access_token": "access"})

client, http_client = make_client(handler)
token = await client.process_callback(
OAuth2Callback(
code="auth-code",
state="state",
redirect_uri="https://override.test/callback",
),
body={"audience": "api"},
headers={"X-Test": "value"},
)
assert token.access_token == "access"
await http_client.aclose()


@pytest.mark.asyncio
async def test_process_callback_errors_and_stateless_request() -> None:
client, http_client = make_client(
lambda request: httpx.Response(400, json={"error": "invalid_grant"})
)
with pytest.raises(StateError, match="State was not found"):
await client.process_callback(OAuth2Callback(code="code"))
with pytest.raises(AuthorizationError) as error:
await client.process_callback(OAuth2Callback(code="code", state="state"))
assert error.value.args[1] == {"error": "invalid_grant"}
await http_client.aclose()

def stateless_handler(request: httpx.Request) -> httpx.Response:
assert "state" not in parse_qs(request.content.decode())
return httpx.Response(299, json={"access_token": "edge"})

stateless, stateless_http = make_client(stateless_handler, use_state=False)
assert (
await stateless.process_callback(OAuth2Callback(code="code"))
).access_token == "edge"
await stateless_http.aclose()


@pytest.mark.asyncio
async def test_get_userinfo_success_and_failure() -> None:
responses = iter(
[
httpx.Response(200, json={"id": "user", "email": "u@example.com"}),
httpx.Response(500, json={"error": "server_error"}),
]
)

def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["authorization"] == "Bearer access"
return next(responses)

client, http_client = make_client(handler)
user = await client.get_userinfo(TokenResponse(access_token="access"))
assert user == UserData(id="user", email="u@example.com")
with pytest.raises(UserinfoError) as error:
await client.get_userinfo(TokenResponse(access_token="access"))
assert error.value.args[1] == {"error": "server_error"}
await http_client.aclose()


@pytest.mark.asyncio
async def test_fastapi_redirect_and_missing_optional_dependency(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from fastid_client.fastapi.client import FastIDClient as FastAPIFastIDClient

http_client = httpx.AsyncClient()
client = FastAPIFastIDClient(
"https://fastid.test",
"id",
"secret",
"https://client.test/callback",
client=http_client,
)
client._discovery = DISCOVERY
response = client.get_authorization_redirect(state="known")
assert response.status_code == 307
assert "state=known" in response.headers["location"]
await http_client.aclose()

import fastid_client.fastapi.client as module

real_import = builtins.__import__

def import_without_fastapi(
name: str,
globals: dict[str, Any] | None = None,
locals: dict[str, Any] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> Any:
if name == "fastapi":
raise ImportError("simulated missing dependency")
return real_import(name, globals, locals, fromlist, level)

monkeypatch.setattr(builtins, "__import__", import_without_fastapi)
path = Path(module.__file__)
spec = importlib.util.spec_from_file_location("missing_fastapi_client", path)
assert spec is not None and spec.loader is not None
missing_module = importlib.util.module_from_spec(spec)
with pytest.raises(FastAPINotFoundError, match="fastapi is not installed"):
spec.loader.exec_module(missing_module)
Loading
Loading