Files
cleveragents-core/features/steps/auth_refresh_steps.py
HAL9000 e5c818292d
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m45s
CI / lint (pull_request) Failing after 1m56s
CI / quality (pull_request) Successful in 2m0s
CI / typecheck (pull_request) Successful in 2m15s
CI / security (pull_request) Successful in 2m25s
CI / push-validation (pull_request) Successful in 23s
CI / helm (pull_request) Successful in 25s
CI / integration_tests (pull_request) Successful in 4m17s
CI / e2e_tests (pull_request) Successful in 5m28s
CI / unit_tests (pull_request) Failing after 5m35s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s
fix(auth): resolve all reviewer blockers on JWT token refresh endpoint
- Strip 11+ unrelated features bundled in original PR (scope violation)
- Remove # type: ignore[arg-type] from jwt_service.py (zero-tolerance policy)
- Replace hardcoded JWT_SECRET_KEY default with mandatory env var validation
- Fix HS256 docstring: "asymmetric" -> "symmetric (HMAC-SHA256)"
- Add 1 MiB body size limit in _read_body() to prevent OOM DoS
- Fix 405 Allow header: POST paths now advertise POST, not GET
- Fix Scenario Outline -> Scenario in auth_refresh.feature (no Examples table)
- Remove duplicate step definitions conflicting with asgi_app_steps.py
- Add auth/refresh ASGI-level scenarios to asgi_app.feature
- Remove duplicate langchain-anthropic dependency from pyproject.toml
- Set JWT_SECRET_KEY test secret in environment.py before_all hook
2026-05-04 21:02:03 +00:00

179 lines
6.3 KiB
Python

"""Step definitions for JWT refresh endpoint ASGI-level BDD scenarios.
These steps complement ``asgi_app_steps.py`` with auth-specific request
helpers and response assertions for the ``/auth/refresh`` endpoint.
The ``@given("the ASGI app module is loaded")`` step is defined in
``asgi_app_steps.py`` and shared across all ASGI feature tests.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from behave import then, when
from behave.runner import Context
from cleveragents.a2a.asgi import app
from cleveragents.core.jwt_service import (
create_access_token,
create_refresh_token,
decode_token,
)
SendMessage = dict[str, Any]
def _make_asgi_post_request(
path: str,
body: bytes = b"",
content_type: str = "application/json",
) -> list[SendMessage]:
"""Simulate an ASGI HTTP POST request.
Args:
path: Request path.
body: Request body bytes.
content_type: Content-Type header value.
Returns:
List of messages sent via the ASGI send callable.
"""
sent_messages: list[SendMessage] = []
async def receive() -> dict[str, Any]:
if body:
return {"type": "http.request", "body": body, "more_body": False}
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message: SendMessage) -> None:
sent_messages.append(message)
headers: list[tuple[bytes, bytes]] = [
(b"content-type", content_type.encode("utf-8")),
]
scope: dict[str, Any] = {
"type": "http",
"method": "POST",
"path": path,
"headers": headers,
}
asyncio.run(app(scope, receive, send))
return sent_messages
def _extract_json_body(sent_messages: list[SendMessage]) -> dict[str, Any]:
"""Extract and parse the JSON body from ASGI sent messages.
Args:
sent_messages: ASGI messages captured during request handling.
Returns:
Parsed JSON dictionary.
Raises:
AssertionError: If no response body was sent.
"""
body_msg = next(
(msg for msg in sent_messages if msg.get("type") == "http.response.body"),
None,
)
if body_msg is None:
raise AssertionError("No response body sent")
return json.loads(body_msg["body"].decode("utf-8"))
@when('I send an HTTP POST request to "{path}" with empty JSON body')
def step_post_empty_json(context: Context, path: str) -> None:
"""Send a POST with an empty object as JSON body."""
context.asgi_sent_messages = _make_asgi_post_request(path, body=b"{}")
@when('I send an HTTP POST request to "{path}" with invalid JSON')
def step_post_invalid_json(context: Context, path: str) -> None:
"""Send a POST with an invalid JSON string."""
context.asgi_sent_messages = _make_asgi_post_request(path, body=b"{invalid}")
@when('I send an HTTP POST request to "{path}" with malformed token')
def step_post_malformed_token(context: Context, path: str) -> None:
"""Send a POST with a string that is not a valid JWT."""
payload = json.dumps({"refresh_token": "not-a-valid-jwt-token"}).encode("utf-8")
context.asgi_sent_messages = _make_asgi_post_request(path, body=payload)
@when('I send an HTTP POST request to "{path}" with an access token')
def step_post_access_token(context: Context, path: str) -> None:
"""Send a POST with a valid access token where a refresh token is expected."""
access_tok = create_access_token("test-user")
payload = json.dumps({"refresh_token": access_tok}).encode("utf-8")
context.asgi_sent_messages = _make_asgi_post_request(path, body=payload)
@when('I send an HTTP POST request to "{path}" with a valid refresh token')
def step_post_valid_refresh_token(context: Context, path: str) -> None:
"""Send a POST with a valid refresh token."""
refresh_tok = create_refresh_token("test-user")
payload = json.dumps({"refresh_token": refresh_tok}).encode("utf-8")
context.asgi_sent_messages = _make_asgi_post_request(path, body=payload)
context._refresh_token_value = refresh_tok
@then("the response contains valid new tokens")
def step_response_has_new_tokens(context: Context) -> None:
"""Verify response contains decodable access and refresh tokens."""
data = _extract_json_body(context.asgi_sent_messages)
assert "access_token" in data, "Response missing access_token"
assert "refresh_token" in data, "Response missing refresh_token"
decode_token(data["access_token"])
decode_token(data["refresh_token"])
@then("the token_type is bearer")
def step_token_type_is_bearer(context: Context) -> None:
"""Verify the response token_type is 'bearer'."""
data = _extract_json_body(context.asgi_sent_messages)
assert data.get("token_type") == "bearer", (
f"Expected token_type 'bearer', got {data.get('token_type')!r}"
)
@then("the returned access token can be decoded")
def step_access_token_decodable(context: Context) -> None:
"""Verify the returned access token decodes correctly."""
data = _extract_json_body(context.asgi_sent_messages)
payload = decode_token(data["access_token"])
assert payload.get("type") == "access", (
f"Expected type 'access', got {payload.get('type')!r}"
)
assert payload.get("sub") == "test-user", (
f"Expected sub 'test-user', got {payload.get('sub')!r}"
)
@then("the decoded access token has correct subject")
def step_access_token_subject(context: Context) -> None:
"""Verify the access token subject matches the original."""
data = _extract_json_body(context.asgi_sent_messages)
payload = decode_token(data["access_token"])
assert payload.get("sub") == "test-user"
@then("the returned refresh token can be decoded")
def step_refresh_token_decodable(context: Context) -> None:
"""Verify the returned refresh token decodes correctly."""
data = _extract_json_body(context.asgi_sent_messages)
payload = decode_token(data["refresh_token"])
assert payload.get("type") == "refresh", (
f"Expected type 'refresh', got {payload.get('type')!r}"
)
@then("the decode type field is refresh")
def step_decode_type_is_refresh(context: Context) -> None:
"""Verify the decode type field indicates a refresh token."""
data = _extract_json_body(context.asgi_sent_messages)
payload = decode_token(data["refresh_token"])
assert payload.get("type") == "refresh"