Files
team-tryouts/tests/test_discord_oauth.py
GGThedandClaude Opus 5 7cec18c139 style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun
changement de comportement, aucune ligne de logique touchee. 72 fichiers,
4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur
les commits voisins reste lisible.

`quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui
evite le brassage guillemets simples / doubles : le diff porte sur les
retours a la ligne, l indentation des appels longs et les virgules
finales, pas sur le style de chaine.

Verification : 263 tests passent avant et apres, ruff check propre.

L activation en CI arrive dans le commit suivant, separement, pour que ce
diff-ci ne contienne rien d autre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:53:10 -04:00

126 lines
4.9 KiB
Python

"""Discord OAuth2 flow.
SEC-AUTH-004. The authorization URL carried client_id, redirect_uri,
response_type and scope — and nothing else. With no `state`, the callback
accepted any authorization code presented to it, so an attacker could have
a victim's browser consume a code issued for the attacker's own Discord
account (RFC 6749 §10.12, "login CSRF").
"""
from urllib.parse import parse_qs, urlparse
import pytest
from app.routes import auth as auth_module
@pytest.fixture
def discord_configured(monkeypatch):
"""Pretend the Discord application credentials are present.
They are read into module-level constants at import time, so they are
patched on the module rather than in the environment.
"""
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
monkeypatch.setattr(
auth_module,
'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback',
)
def _authorize_params(response):
return parse_qs(urlparse(response.headers['Location']).query)
class TestAuthorizationRequest:
def test_the_request_carries_a_state(self, client, discord_configured):
response = client.get('/auth/discord/login', follow_redirects=False)
assert response.status_code in (301, 302)
params = _authorize_params(response)
assert 'state' in params
assert len(params['state'][0]) >= 32
def test_the_state_is_stored_in_the_session(self, client, discord_configured):
response = client.get('/auth/discord/login', follow_redirects=False)
sent = _authorize_params(response)['state'][0]
with client.session_transaction() as sess:
assert sess[auth_module.DISCORD_STATE_KEY] == sent
def test_two_requests_get_different_states(self, client, discord_configured):
first = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
'state'
][0]
second = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
'state'
][0]
assert first != second
def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
params = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))
assert params['scope'][0] == 'identify connections'
assert params['response_type'][0] == 'code'
assert params['redirect_uri'][0] == 'https://example.test/auth/discord/callback'
def test_a_missing_redirect_uri_is_reported_not_raised(self, client, monkeypatch):
"""Quoting an unset redirect_uri used to raise inside the query
builder instead of reporting a configuration problem."""
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_REDIRECT_URI', None)
response = client.get('/auth/discord/login', follow_redirects=False)
assert response.status_code in (301, 302)
assert '/auth/register' in response.headers['Location']
class TestCallbackStateValidation:
"""The callback must reject anything it did not itself initiate.
None of these reach Discord: the state check happens before the token
exchange, so a rejected callback performs no network call.
"""
def test_a_callback_without_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False)
response = client.get('/auth/discord/callback?code=attacker-code', follow_redirects=False)
assert '/auth/register' in response.headers['Location']
def test_a_callback_with_a_wrong_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False)
response = client.get(
'/auth/discord/callback?code=attacker-code&state=forged', follow_redirects=False
)
assert '/auth/register' in response.headers['Location']
with client.session_transaction() as sess:
assert 'discord_oauth' not in sess
def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured):
"""No /discord/login beforehand: nothing to match against."""
response = client.get(
'/auth/discord/callback?code=x&state=anything', follow_redirects=False
)
assert '/auth/register' in response.headers['Location']
def test_the_state_is_single_use(self, client, discord_configured):
"""Consumed on the first callback, valid or not, so it cannot be
replayed."""
state = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
'state'
][0]
client.get(f'/auth/discord/callback?code=x&state={state}', follow_redirects=False)
with client.session_transaction() as sess:
assert auth_module.DISCORD_STATE_KEY not in sess