Skip to content

Commit 389e228

Browse files
authored
feat: associate gitlab accounts to pypi accounts (#19298)
1 parent c243e42 commit 389e228

File tree

15 files changed

+1213
-150
lines changed

15 files changed

+1213
-150
lines changed

dev/environment

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ GITHUB_OAUTH_BACKEND=warehouse.accounts.oauth.NullGitHubOAuthClient
5656
# For real GitHub App integration, uncomment and configure:
5757
# GITHUB_OAUTH_BACKEND=warehouse.accounts.oauth.GitHubAppClient client_id=Iv2.your_client_id client_secret=your_client_secret
5858

59+
# Use NullGitLabOAuthClient for local development (no GitLab OAuth needed)
60+
GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.NullGitLabOAuthClient
61+
# For real GitLab OAuth integration, uncomment and configure:
62+
# GITLAB_OAUTH_BACKEND=warehouse.accounts.oauth.GitLabOAuthClient client_id=your_app_id client_secret=your_app_secret
63+
5964
METRICS_BACKEND=warehouse.metrics.DataDogMetrics host=notdatadog
6065

6166
STATUSPAGE_URL=https://2p66nmmycsj3.statuspage.io

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ def get_app_config(database, nondefaults=None):
354354
"warehouse.oidc.audience": "pypi",
355355
"oidc.backend": "warehouse.oidc.services.NullOIDCPublisherService",
356356
"github.oauth.backend": "warehouse.accounts.oauth.NullGitHubOAuthClient",
357+
"gitlab.oauth.backend": "warehouse.accounts.oauth.NullGitLabOAuthClient",
357358
"captcha.backend": "warehouse.captcha.hcaptcha.Service",
358359
}
359360

tests/functional/manage/test_account_associations.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,157 @@ def test_github_oauth_missing_code(self, webtest):
338338
)
339339
assert error_message is not None
340340
assert "No authorization code received from GitHub" in error_message.text
341+
342+
def test_connect_gitlab_account(self, webtest):
343+
"""A user can connect a GitLab account via OAuth."""
344+
user = UserFactory.create(
345+
with_verified_primary_email=True,
346+
with_terms_of_service_agreement=True,
347+
clear_pwd="password",
348+
)
349+
350+
self._login_user(webtest, user)
351+
352+
# Click "Connect GitLab" button (initiates OAuth flow)
353+
connect_response = webtest.get(
354+
"/manage/account/associations/gitlab/connect",
355+
status=HTTPStatus.SEE_OTHER,
356+
)
357+
358+
# Follow the redirect to the callback URL
359+
parsed_url = parse_url(connect_response.location)
360+
callback_response = webtest.get(
361+
parsed_url.path,
362+
params=parsed_url.query,
363+
status=HTTPStatus.SEE_OTHER,
364+
)
365+
366+
# Follow redirect back to account page
367+
account_page = callback_response.follow(status=HTTPStatus.OK)
368+
369+
# Verify association was created
370+
assert "Account associations" in account_page.text
371+
assert "mockuser_" in account_page.text
372+
373+
def test_connect_gitlab_account_invalid_state(self, webtest):
374+
"""GitLab OAuth flow rejects requests with invalid state tokens."""
375+
user = UserFactory.create(
376+
with_verified_primary_email=True,
377+
with_terms_of_service_agreement=True,
378+
clear_pwd="password",
379+
)
380+
381+
self._login_user(webtest, user)
382+
383+
# Try to access callback directly with invalid state
384+
callback_response = webtest.get(
385+
"/manage/account/associations/gitlab/callback",
386+
params={"code": "test", "state": "invalid"},
387+
status=HTTPStatus.SEE_OTHER,
388+
)
389+
390+
# Should redirect to account page with error
391+
account_page = callback_response.follow(status=HTTPStatus.OK)
392+
# Verify no association was created
393+
assert "You have not connected" in account_page.text
394+
395+
def test_gitlab_oauth_error_response(self, webtest):
396+
"""GitLab OAuth flow handles error responses from GitLab."""
397+
user = UserFactory.create(
398+
with_verified_primary_email=True,
399+
with_terms_of_service_agreement=True,
400+
clear_pwd="password",
401+
)
402+
403+
self._login_user(webtest, user)
404+
405+
# Start OAuth flow to get a valid state token
406+
connect_response = webtest.get(
407+
"/manage/account/associations/gitlab/connect",
408+
status=HTTPStatus.SEE_OTHER,
409+
)
410+
parsed_url = parse_url(connect_response.location)
411+
state = parsed_url.query.split("state=")[1].split("&")[0]
412+
413+
# Simulate OAuth error response with valid state
414+
callback_response = webtest.get(
415+
"/manage/account/associations/gitlab/callback",
416+
params={
417+
"state": state,
418+
"error": "access_denied",
419+
"error_description": "User declined",
420+
},
421+
status=HTTPStatus.SEE_OTHER,
422+
)
423+
callback_response.follow(status=HTTPStatus.OK)
424+
425+
# Check flash messages for the error
426+
flash_messages = webtest.get(
427+
"/_includes/unauthed/flash-messages/", status=HTTPStatus.OK
428+
)
429+
error_message = flash_messages.html.find(
430+
"span", {"class": "notification-bar__message"}
431+
)
432+
assert error_message is not None
433+
assert "GitLab OAuth failed" in error_message.text
434+
assert "User declined" in error_message.text
435+
436+
def test_gitlab_oauth_missing_code(self, webtest):
437+
"""GitLab OAuth flow handles missing authorization code."""
438+
user = UserFactory.create(
439+
with_verified_primary_email=True,
440+
with_terms_of_service_agreement=True,
441+
clear_pwd="password",
442+
)
443+
444+
self._login_user(webtest, user)
445+
446+
# Start OAuth flow to get a valid state token
447+
connect_response = webtest.get(
448+
"/manage/account/associations/gitlab/connect",
449+
status=HTTPStatus.SEE_OTHER,
450+
)
451+
parsed_url = parse_url(connect_response.location)
452+
state = parsed_url.query.split("state=")[1].split("&")[0]
453+
454+
# Call callback with valid state but no code
455+
callback_response = webtest.get(
456+
"/manage/account/associations/gitlab/callback",
457+
params={"state": state},
458+
status=HTTPStatus.SEE_OTHER,
459+
)
460+
callback_response.follow(status=HTTPStatus.OK)
461+
462+
# Check flash messages for the error
463+
flash_messages = webtest.get(
464+
"/_includes/unauthed/flash-messages/",
465+
status=HTTPStatus.OK,
466+
)
467+
error_message = flash_messages.html.find(
468+
"span", {"class": "notification-bar__message"}
469+
)
470+
assert error_message is not None
471+
assert "No authorization code received from GitLab" in error_message.text
472+
473+
def test_view_account_with_gitlab_association(self, webtest):
474+
"""A user can view a GitLab association on the account page."""
475+
user = UserFactory.create(
476+
with_verified_primary_email=True,
477+
with_terms_of_service_agreement=True,
478+
clear_pwd="password",
479+
)
480+
OAuthAccountAssociationFactory.create(
481+
user=user,
482+
service="gitlab",
483+
external_username="gitlabuser",
484+
)
485+
486+
self._login_user(webtest, user)
487+
488+
# Visit account settings page
489+
account_page = webtest.get("/manage/account/", status=HTTPStatus.OK)
490+
491+
# Verify GitLab association is present
492+
assert "Account associations" in account_page.text
493+
assert "gitlabuser" in account_page.text
494+
assert "gitlab" in account_page.text.lower()

tests/unit/accounts/test_core.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,55 @@ def test_includeme(monkeypatch):
227227
pretend.call(crontab(minute="*/20"), compute_user_metrics)
228228
in config.add_periodic_task.calls
229229
)
230+
231+
# Verify GitLab OAuth is NOT registered when setting is absent
232+
assert (
233+
pretend.call(
234+
accounts.NullGitLabOAuthClient.create_service,
235+
accounts.IOAuthProviderService,
236+
name="gitlab",
237+
)
238+
not in config.register_service_factory.calls
239+
)
240+
241+
242+
def test_includeme_with_gitlab_oauth():
243+
"""Verify GitLab OAuth service is registered when configured."""
244+
register_service_factory = pretend.call_recorder(
245+
lambda factory, iface, name=None: None
246+
)
247+
config = pretend.stub(
248+
registry=pretend.stub(
249+
settings={
250+
"warehouse.account.user_login_ratelimit_string": "10 per 5 minutes",
251+
"warehouse.account.ip_login_ratelimit_string": "10 per 5 minutes",
252+
"warehouse.account.global_login_ratelimit_string": "1000 per 5 minutes",
253+
"warehouse.account.2fa_user_ratelimit_string": "5 per 5 minutes, 20 per hour, 50 per day", # noqa: E501
254+
"warehouse.account.2fa_ip_ratelimit_string": "10 per 5 minutes, 50 per hour", # noqa: E501
255+
"warehouse.account.email_add_ratelimit_string": "2 per day",
256+
"warehouse.account.verify_email_ratelimit_string": "3 per 6 hours",
257+
"warehouse.account.password_reset_ratelimit_string": "5 per day",
258+
"warehouse.account.accounts_search_ratelimit_string": "100 per hour",
259+
"github.oauth.backend": accounts.NullGitHubOAuthClient,
260+
"gitlab.oauth.backend": accounts.NullGitLabOAuthClient,
261+
}
262+
),
263+
register_service_factory=register_service_factory,
264+
register_rate_limiter=pretend.call_recorder(lambda limit_string, name: None),
265+
add_request_method=pretend.call_recorder(lambda f, name, reify=False: None),
266+
set_security_policy=pretend.call_recorder(lambda p: None),
267+
maybe_dotted=pretend.call_recorder(lambda path: path),
268+
add_route_predicate=pretend.call_recorder(lambda name, cls: None),
269+
add_periodic_task=pretend.call_recorder(lambda *a, **kw: None),
270+
)
271+
272+
accounts.includeme(config)
273+
274+
assert (
275+
pretend.call(
276+
accounts.NullGitLabOAuthClient.create_service,
277+
accounts.IOAuthProviderService,
278+
name="gitlab",
279+
)
280+
in register_service_factory.calls
281+
)

0 commit comments

Comments
 (0)