open-swe/tests/auth/test_github_oauth_refresh.py
Adam Moussa ae1f883b4c
refactor: move tests into tests/<domain>/ layout
Applies the plan's C5 step: git mv every test per the domain-reorg
move-map (movemap-m50.txt) into tests/{agent,analyzer,auth,dashboard,
github,middleware,models,reviewer,sandbox,slack,tools,webhooks}/, plus
the 13 fork-only placements from the scoping report §2c (Atlassian
webhook tests -> tests/webhooks/, test_atlassian_connect.py and
test_auth_error_leak.py -> tests/auth/, jira/confluence util tests ->
tests/tools/, test_repo_binding_isolation.py -> tests/sandbox/,
bot-identity/autofix tests -> tests/github/).

Path-only move: the only content edits are parents[1] -> parents[2]
fixes in test_e2b_integration.py and test_daytona_integration.py,
required because their __file__-relative ROOT path gained one more
directory level in the move.

Monkeypatch retargets for these files were already completed in C4;
none remained outstanding here.
2026-07-17 14:42:45 -04:00

212 lines
7.5 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from agent.dashboard.oauth import (
GithubOAuthError,
expires_at_from_github_response,
is_unrecoverable_refresh_error,
)
from agent.dashboard.profiles import _token_expired, get_valid_access_token
def test_expires_at_from_github_response() -> None:
data = {"expires_in": 3600}
expires = expires_at_from_github_response(data, field="expires_in")
assert expires is not None
exp = datetime.fromisoformat(expires)
assert exp > datetime.now(UTC)
def test_token_expired_with_skew() -> None:
past = (datetime.now(UTC) - timedelta(minutes=1)).isoformat()
assert _token_expired(past, skew_seconds=300) is True
future = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
assert _token_expired(future, skew_seconds=300) is False
assert _token_expired(None) is False
@pytest.mark.asyncio
async def test_get_valid_access_token_refreshes_when_near_expiry() -> None:
soon = (datetime.now(UTC) + timedelta(minutes=1)).isoformat()
record = {
"email": "u@example.com",
"encrypted_gh_token": "enc-access",
"encrypted_gh_refresh_token": "enc-refresh",
"token_expires_at": soon,
}
with (
patch(
"agent.dashboard.profiles._get_value",
new_callable=AsyncMock,
return_value=record,
),
patch("agent.dashboard.profiles._decrypt_access_token", return_value="old-access"),
patch("agent.dashboard.profiles._decrypt_refresh_token", return_value="ghr_test"),
patch(
"agent.dashboard.profiles.refresh_user_access_token",
new_callable=AsyncMock,
return_value={
"access_token": "new-access",
"refresh_token": "ghr_new",
"expires_in": 28800,
"refresh_token_expires_in": 15897600,
},
),
patch(
"agent.dashboard.profiles.upsert_access_token_from_github_response",
new_callable=AsyncMock,
) as mock_upsert,
):
token = await get_valid_access_token("octo")
assert token == "new-access"
mock_upsert.assert_awaited_once()
def test_is_unrecoverable_refresh_error() -> None:
assert is_unrecoverable_refresh_error(
GithubOAuthError(400, "x", error_code="bad_refresh_token")
)
assert is_unrecoverable_refresh_error(
GithubOAuthError(400, "x", error_code="unauthorized_client")
)
assert not is_unrecoverable_refresh_error(GithubOAuthError(400, "x", error_code="slow_down"))
assert not is_unrecoverable_refresh_error(GithubOAuthError(400, "x"))
assert not is_unrecoverable_refresh_error(RuntimeError("boom"))
@pytest.mark.asyncio
async def test_get_valid_access_token_drops_record_on_dead_refresh_token() -> None:
soon = (datetime.now(UTC) + timedelta(minutes=1)).isoformat()
record = {
"email": "u@example.com",
"encrypted_gh_token": "enc-access",
"encrypted_gh_refresh_token": "enc-refresh",
"token_expires_at": soon,
}
with (
patch(
"agent.dashboard.profiles._get_value",
new_callable=AsyncMock,
return_value=record,
),
patch("agent.dashboard.profiles._decrypt_access_token", return_value="stale-access"),
patch("agent.dashboard.profiles._decrypt_refresh_token", return_value="ghr_dead"),
patch(
"agent.dashboard.profiles.refresh_user_access_token",
new_callable=AsyncMock,
side_effect=GithubOAuthError(
400, "github oauth error: bad refresh token", error_code="bad_refresh_token"
),
),
patch(
"agent.dashboard.profiles.delete_access_token",
new_callable=AsyncMock,
) as mock_delete,
):
token = await get_valid_access_token("octo")
assert token is None
mock_delete.assert_awaited_once_with("octo")
@pytest.mark.asyncio
async def test_get_valid_access_token_keeps_fresh_reauth_on_dead_refresh_token() -> None:
soon = (datetime.now(UTC) + timedelta(minutes=1)).isoformat()
stale = {
"email": "u@example.com",
"encrypted_gh_token": "enc-access",
"encrypted_gh_refresh_token": "enc-refresh-dead",
"token_expires_at": soon,
}
reauthed = {
"email": "u@example.com",
"encrypted_gh_token": "enc-access-new",
"encrypted_gh_refresh_token": "enc-refresh-new",
"token_expires_at": (datetime.now(UTC) + timedelta(hours=8)).isoformat(),
}
with (
patch(
"agent.dashboard.profiles._get_value",
new_callable=AsyncMock,
side_effect=[stale, stale, reauthed],
),
patch(
"agent.dashboard.profiles._decrypt_access_token",
side_effect=lambda r: "fresh-access" if r is reauthed else "stale-access",
),
patch("agent.dashboard.profiles._decrypt_refresh_token", return_value="ghr_dead"),
patch(
"agent.dashboard.profiles.refresh_user_access_token",
new_callable=AsyncMock,
side_effect=GithubOAuthError(
400, "github oauth error: bad refresh token", error_code="bad_refresh_token"
),
),
patch(
"agent.dashboard.profiles.delete_access_token",
new_callable=AsyncMock,
) as mock_delete,
):
token = await get_valid_access_token("octo")
assert token == "fresh-access"
mock_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_valid_access_token_keeps_record_on_transient_refresh_failure() -> None:
soon = (datetime.now(UTC) + timedelta(minutes=1)).isoformat()
record = {
"email": "u@example.com",
"encrypted_gh_token": "enc-access",
"encrypted_gh_refresh_token": "enc-refresh",
"token_expires_at": soon,
}
with (
patch(
"agent.dashboard.profiles._get_value",
new_callable=AsyncMock,
return_value=record,
),
patch("agent.dashboard.profiles._decrypt_access_token", return_value="still-usable"),
patch("agent.dashboard.profiles._decrypt_refresh_token", return_value="ghr_ok"),
patch(
"agent.dashboard.profiles.refresh_user_access_token",
new_callable=AsyncMock,
side_effect=GithubOAuthError(503, "github oauth temporarily unavailable"),
),
patch(
"agent.dashboard.profiles.delete_access_token",
new_callable=AsyncMock,
) as mock_delete,
):
token = await get_valid_access_token("octo")
assert token == "still-usable"
mock_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_valid_access_token_returns_stored_when_not_expiring() -> None:
future = (datetime.now(UTC) + timedelta(hours=5)).isoformat()
record = {
"encrypted_gh_token": "enc-access",
"encrypted_gh_refresh_token": "enc-refresh",
"token_expires_at": future,
}
with (
patch(
"agent.dashboard.profiles._get_value",
new_callable=AsyncMock,
return_value=record,
),
patch("agent.dashboard.profiles._decrypt_access_token", return_value="still-good"),
patch(
"agent.dashboard.profiles.refresh_user_access_token",
new_callable=AsyncMock,
) as mock_refresh,
):
token = await get_valid_access_token("octo")
assert token == "still-good"
mock_refresh.assert_not_called()