Repository: cal-itp/benefits
Pull request #2819: Refactor: use cdt_identity `logout` view
Author: login: lalver1
Labels: name: deployment-dev
description: [auto] Changes that will trigger a deploy if merged to dev
name: tests
description: Related to automated testing (unit, UI, integration, etc.)
name: back-end
description: Django views, sessions, middleware, models, migrations etc.
Created: 2025-04-03T18:00:03Z | Merged: 2025-04-04T18:09:45Z

Closes #2787

As part of the oauth refactor, this PR replaces `benefits.oauth.views.logout` with `cdt_identity.views.logout`.

## Reviewing

Run through a login.gov flow and ensure you can confirm your eligibility. On the eligibility confirmation page, click on `Sign out of Login.gov ` and check that the `cdt_identity` `logout` view is called.  In addition, a `started sign out` analytics event will be created as part of the `pre_logout` hook; and a `finished sign out` analytics event will be created as part of the `post_logout` hook.


Linked issue #2787: OAuth refactor: use `cdt_identity.views.logout`
## Acceptance Criteria

- [ ] Wire up the `logout` URL path to the `cdt_identity.views.logout` view (see notes on #2724)
- [ ] Delete unused code (see notes on #2725)


Diff:
diff --git a/benefits/oauth/urls.py b/benefits/oauth/urls.py
index 95c07b7c5e..ad858975b2 100644
--- a/benefits/oauth/urls.py
+++ b/benefits/oauth/urls.py
@@ -28,7 +28,12 @@
         {"hooks": hooks.OAuthHooks},
         name=routes.name(routes.OAUTH_CANCEL),
     ),
-    path("logout", views.logout, name=routes.name(routes.OAUTH_LOGOUT)),
+    path(
+        "logout",
+        decorator_from_middleware(FlowUsesClaimsVerificationSessionRequired)(cdt_identity_views.logout),
+        {"hooks": hooks.OAuthHooks},
+        name=routes.name(routes.OAUTH_LOGOUT),
+    ),
     path(
         "post_logout",
         decorator_from_middleware(FlowUsesClaimsVerificationSessionRequired)(cdt_identity_views.post_logout),
diff --git a/benefits/oauth/views.py b/benefits/oauth/views.py
index 0fa9f2bf1e..28b99e4ac3 100644
--- a/benefits/oauth/views.py
+++ b/benefits/oauth/views.py
@@ -2,16 +2,14 @@
 
 from django.shortcuts import redirect
 from django.template.response import TemplateResponse
-from django.urls import reverse
 from django.utils.decorators import decorator_from_middleware
 import sentry_sdk
 
 from benefits.routes import routes
 from benefits.core import models, session
 from benefits.core.middleware import AgencySessionRequired
-from . import analytics, redirects
+from . import analytics
 from .client import oauth, create_client
-from .middleware import FlowUsesClaimsVerificationSessionRequired
 
 
 logger = logging.getLogger(__name__)
@@ -44,36 +42,6 @@ def _oauth_client_or_error_redirect(request, flow: models.EnrollmentFlow):
     return oauth_client
 
 
-@decorator_from_middleware(FlowUsesClaimsVerificationSessionRequired)
-def logout(request):
-    """View implementing OIDC and application sign out."""
-    flow = session.flow(request)
-
-    oauth_client_result = _oauth_client_or_error_redirect(request, flow)
-
-    if hasattr(oauth_client_result, "load_server_metadata"):
-        # this looks like an oauth_client since it has the method we need
-        # (called in redirects.deauthorize_redirect)
-        oauth_client = oauth_client_result
-    else:
-        # this does not look like an oauth_client, it's an error redirect
-        return oauth_client_result
-
-    analytics.started_sign_out(request)
-
-    # the user is signed out of the app
-    session.logout(request)
-
-    route = reverse(routes.OAUTH_POST_LOGOUT)
-    redirect_uri = redirects.generate_redirect_uri(request, route)
-
-    logger.debug(f"OAuth end_session_endpoint with redirect_uri: {redirect_uri}")
-
-    # send the user through the end_session_endpoint, redirecting back to
-    # the post_logout route
-    return redirects.deauthorize_redirect(request, oauth_client, redirect_uri)
-
-
 @decorator_from_middleware(AgencySessionRequired)
 def system_error(request):
     """View handler for an oauth system error."""
diff --git a/tests/pytest/oauth/test_hooks.py b/tests/pytest/oauth/test_hooks.py
index 3e003d8b74..744853290f 100644
--- a/tests/pytest/oauth/test_hooks.py
+++ b/tests/pytest/oauth/test_hooks.py
@@ -1,6 +1,7 @@
 from cdt_identity.claims import ClaimsResult
 from cdt_identity.hooks import Operation
 from cdt_identity.models import ClaimsVerificationRequest
+from cdt_identity.session import Session as OAuthSession
 from django.urls import reverse
 import pytest
 
@@ -49,6 +50,9 @@ def test_pre_logout(app_request, mocked_oauth_analytics_module):
 
     mocked_oauth_analytics_module.started_sign_out.assert_called_once_with(app_request)
     assert not session.logged_in(app_request)
+    assert session.enrollment_token(app_request) is False
+    assert session.logged_in(app_request) is False
+    assert OAuthSession(app_request).claims_result == ClaimsResult()
 
 
 @pytest.mark.parametrize("origin", [routes.ELIGIBILITY_START, routes.INDEX])
diff --git a/tests/pytest/oauth/test_views.py b/tests/pytest/oauth/test_views.py
index 1626938ddd..38a35787d6 100644
--- a/tests/pytest/oauth/test_views.py
+++ b/tests/pytest/oauth/test_views.py
@@ -1,5 +1,3 @@
-from cdt_identity.claims import ClaimsResult
-from cdt_identity.session import Session as OAuthSession
 from django.http import HttpResponse
 from django.urls import reverse
 
@@ -11,7 +9,6 @@
 from benefits.oauth.views import (
     TEMPLATE_SYSTEM_ERROR,
     _oauth_client_or_error_redirect,
-    logout,
     system_error,
 )
 import benefits.oauth.views
@@ -109,8 +106,12 @@ def test_login_oauth_client_init_error(client, mocked_hook_analytics_module, moc
 
 
 @pytest.mark.django_db
-def test_login_no_session_flow(client):
-    path = reverse(routes.OAUTH_LOGIN)
+@pytest.mark.parametrize(
+    "route",
+    [routes.OAUTH_LOGIN, routes.OAUTH_AUTHORIZE, routes.OAUTH_CANCEL, routes.OAUTH_LOGOUT, routes.OAUTH_POST_LOGOUT],
+)
+def test_url_path_no_session_flow(client, route):
+    path = reverse(route)
     response = client.get(path)
 
     assert response.status_code == 200
@@ -129,15 +130,6 @@ def test_authorize_oauth_client_init_error(client, mocked_hook_analytics_module,
     mocked_hook_sentry_sdk_module.capture_exception.assert_called_once()
 
 
-@pytest.mark.django_db
-def test_authorize_no_session_flow(client):
-    path = reverse(routes.OAUTH_AUTHORIZE)
-    response = client.get(path)
-
-    assert response.status_code == 200
-    assert response.template_name == TEMPLATE_USER_ERROR
-
-
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification")
 def test_cancel(client, mocked_hook_analytics_module):
@@ -151,50 +143,6 @@ def test_cancel(client, mocked_hook_analytics_module):
     assert response.url == unverified_route
 
 
-@pytest.mark.django_db
-def test_cancel_no_session_flow(client):
-    path = reverse(routes.OAUTH_CANCEL)
-    response = client.get(path)
-
-    assert response.status_code == 200
-    assert response.template_name == TEMPLATE_USER_ERROR
-
-
-@pytest.mark.django_db
-def test_logout_no_session_flow(app_request):
-    result = logout(app_request)
-
-    assert result.status_code == 200
-    assert result.template_name == TEMPLATE_USER_ERROR
-
-
-@pytest.mark.django_db
-@pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification")
-def test_logout(app_request, mocker, mocked_oauth_client_or_error_redirect__client, mocked_view_analytics_module):
-    # logout internally calls deauthorize_redirect
-    # this mocks that function and a success response
-    # and returns a spy object we can use to validate calls
-    message = "logout successful"
-    mocked_oauth_client = mocked_oauth_client_or_error_redirect__client.return_value
-    mocked_redirect = mocker.patch("benefits.oauth.views.redirects.deauthorize_redirect", return_value=HttpResponse(message))
-
-    oauth_token_authorized = True
-    session.update(app_request, logged_in=oauth_token_authorized)
-    assert session.logged_in(app_request) == oauth_token_authorized
-
-    result = logout(app_request)
-
-    mocked_redirect.assert_called_with(app_request, mocked_oauth_client, "https://testserver/oauth/post_logout")
-    mocked_view_analytics_module.started_sign_out.assert_called_once()
-    assert result.status_code == 200
-    assert message in str(result.content)
-
-    assert not session.logged_in(app_request)
-    assert session.enrollment_token(app_request) is False
-    assert session.logged_in(app_request) is False
-    assert OAuthSession(app_request).claims_result == ClaimsResult()
-
-
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification")
 def test_post_logout(mocker, client, mocked_hook_analytics_module):
@@ -209,15 +157,6 @@ def test_post_logout(mocker, client, mocked_hook_analytics_module):
     mocked_hook_analytics_module.finished_sign_out.assert_called_once()
 
 
-@pytest.mark.django_db
-def test_post_logout_no_session_flow(client):
-    path = reverse(routes.OAUTH_POST_LOGOUT)
-    response = client.get(path)
-
-    assert response.status_code == 200
-    assert response.template_name == TEMPLATE_USER_ERROR
-
-
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_agency")
 def test_system_error(app_request, model_TransitAgency):


Reviews:
user: login: angela-tran
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T20:36:41Z
user: login: lalver1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T22:12:45Z
user: login: angela-tran
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-04T17:39:01Z
user: login: angela-tran
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T18:08:29Z

Review comments:
diff_hunk: @@ -161,38 +158,12 @@ def test_cancel_no_session_flow(client):
 
 
 @pytest.mark.django_db
-def test_logout_no_session_flow(app_request):
-    result = logout(app_request)
-
-    assert result.status_code == 200
-    assert result.template_name == TEMPLATE_USER_ERROR
-
-
-@pytest.mark.django_db
-@pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification")
-def test_logout(app_request, mocker, mocked_oauth_client_or_error_redirect__client, mocked_view_analytics_module):
-    # logout internally calls deauthorize_redirect
-    # this mocks that function and a success response
-    # and returns a spy object we can use to validate calls
-    message = "logout successful"
-    mocked_oauth_client = mocked_oauth_client_or_error_redirect__client.return_value
-    mocked_redirect = mocker.patch("benefits.oauth.views.redirects.deauthorize_redirect", return_value=HttpResponse(message))
-
-    oauth_token_authorized = True
-    session.update(app_request, logged_in=oauth_token_authorized)
-    assert session.logged_in(app_request) == oauth_token_authorized
-
-    result = logout(app_request)
-
-    mocked_redirect.assert_called_with(app_request, mocked_oauth_client, "https://testserver/oauth/post_logout")
-    mocked_view_analytics_module.started_sign_out.assert_called_once()
-    assert result.status_code == 200
-    assert message in str(result.content)
+def test_logout_no_session_flow(client):
+    path = reverse(routes.OAUTH_LOGOUT)
+    response = client.get(path)
 
-    assert not session.logged_in(app_request)
-    assert session.enrollment_token(app_request) is False
-    assert session.logged_in(app_request) is False
-    assert OAuthSession(app_request).claims_result == ClaimsResult()
path: tests/pytest/oauth/test_views.py
user: login: angela-tran
body: _(sorry, I know this PR is still WIP but just thought I'd go ahead and post this suggestion :sweat_smile:)_

Can we add these assertions to [`test_hooks.test_pre_logout`](https://github.com/cal-itp/benefits/blob/main/tests/pytest/oauth/test_hooks.py#L52)?

created_at: 2025-04-03T20:14:17Z
author_association: MEMBER
diff_hunk: @@ -161,38 +158,12 @@ def test_cancel_no_session_flow(client):
 
 
 @pytest.mark.django_db
-def test_logout_no_session_flow(app_request):
-    result = logout(app_request)
-
-    assert result.status_code == 200
-    assert result.template_name == TEMPLATE_USER_ERROR
-
-
-@pytest.mark.django_db
-@pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification")
-def test_logout(app_request, mocker, mocked_oauth_client_or_error_redirect__client, mocked_view_analytics_module):
-    # logout internally calls deauthorize_redirect
-    # this mocks that function and a success response
-    # and returns a spy object we can use to validate calls
-    message = "logout successful"
-    mocked_oauth_client = mocked_oauth_client_or_error_redirect__client.return_value
-    mocked_redirect = mocker.patch("benefits.oauth.views.redirects.deauthorize_redirect", return_value=HttpResponse(message))
-
-    oauth_token_authorized = True
-    session.update(app_request, logged_in=oauth_token_authorized)
-    assert session.logged_in(app_request) == oauth_token_authorized
-
-    result = logout(app_request)
-
-    mocked_redirect.assert_called_with(app_request, mocked_oauth_client, "https://testserver/oauth/post_logout")
-    mocked_view_analytics_module.started_sign_out.assert_called_once()
-    assert result.status_code == 200
-    assert message in str(result.content)
+def test_logout_no_session_flow(client):
+    path = reverse(routes.OAUTH_LOGOUT)
+    response = client.get(path)
 
-    assert not session.logged_in(app_request)
-    assert session.enrollment_token(app_request) is False
-    assert session.logged_in(app_request) is False
-    assert OAuthSession(app_request).claims_result == ClaimsResult()
path: tests/pytest/oauth/test_views.py
user: login: lalver1
body: Not at all! This is great, I just pushed a small refactor of the `test_xxx_no_session_flow` tests but I'll look at this next 👍 
created_at: 2025-04-03T22:12:45Z
author_association: MEMBER
diff_hunk: @@ -109,8 +106,12 @@ def test_login_oauth_client_init_error(client, mocked_hook_analytics_module, moc
 
 
 @pytest.mark.django_db
-def test_login_no_session_flow(client):
-    path = reverse(routes.OAUTH_LOGIN)
+@pytest.mark.parametrize(
+    "route",
+    [routes.OAUTH_LOGIN, routes.OAUTH_AUTHORIZE, routes.OAUTH_CANCEL, routes.OAUTH_LOGOUT, routes.OAUTH_POST_LOGOUT],
+)
path: tests/pytest/oauth/test_views.py
user: login: angela-tran
body: :raised_hands: 
created_at: 2025-04-04T17:38:50Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-03T18:01:17Z
body: ## Coverage report


<img title="Coverage for the whole project went from 93.47% to 93.5%" src="https://img.shields.io/badge/Coverage%20evolution-93%25%20%3E%2093%25-brightgreen.svg"> <img title="100% of the statement lines added by this PR are covered" src="https://img.shields.io/badge/PR%20Coverage-100%25-brightgreen.svg"><details><summary>Click to see where and how coverage changed</summary><table><thead>
  <tr><th>File</th><th>Statements</th><th>Missing</th><th>Coverage</th><th>Coverage<br>(new stmts)</th><th>Lines missing</th></tr>
</thead>
<tbody><tr>
<td colspan="6">&nbsp;&nbsp;<b>benefits/oauth</b></td><tr>
<td>&nbsp;&nbsp;<a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-046f7cf43c214ae0ab405c72f331b032dd2c52f707e95f6fb9e33f35f07f7170">views.py</a></td>

<td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-046f7cf43c214ae0ab405c72f331b032dd2c52f707e95f6fb9e33f35f07f7170"><img title="This PR removes 15 from the number of statements in benefits/oauth/views.py, taking it from 46 to 31." src="https://img.shields.io/badge/31-%28--15%29-49c2ee.svg"></a></td><td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-046f7cf43c214ae0ab405c72f331b032dd2c52f707e95f6fb9e33f35f07f7170"><img title="This PR removes 1 from the number of statements missing coverage in benefits/oauth/views.py, taking it from 1 to 0." src="https://img.shields.io/badge/0-%28--1%29-brightgreen.svg"></a></td><td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-046f7cf43c214ae0ab405c72f331b032dd2c52f707e95f6fb9e33f35f07f7170"><img title="This PR adds 3.85 percentage points to the coverage rate in benefits/oauth/views.py, taking it from 96.15% (45/46) to 100% (31/31)." src="https://img.shields.io/badge/100%25-%2845/46%20%3E%2031/31%29-brightgreen.svg"></a></td><td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-046f7cf43c214ae0ab405c72f331b032dd2c52f707e95f6fb9e33f35f07f7170"><img title="In this PR, 1 new statements are added to benefits/oauth/views.py, 1 of which are covered (100%)." src="https://img.shields.io/badge/100%25-%281/1%29-brightgreen.svg"></a></td><td></td></tbody>
<tfoot>
<tr>
<td><b>Project Total</b></td>

<td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-4b0bf2efa3367c0072ac2bf1e234e703dc46b47aaa4fe9d3b01737b1a15752b1"><img title="This PR removes 15 from the number of statements in the whole project, taking it from 2385 to 2370." src="https://img.shields.io/badge/2.4k-%28--15%29-49c2ee.svg"></a></td><td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-4b0bf2efa3367c0072ac2bf1e234e703dc46b47aaa4fe9d3b01737b1a15752b1"><img title="This PR removes 1 from the number of statements missing coverage in the whole project, taking it from 112 to 111." src="https://img.shields.io/badge/111-%28--1%29-brightgreen.svg"></a></td><td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-4b0bf2efa3367c0072ac2bf1e234e703dc46b47aaa4fe9d3b01737b1a15752b1"><img title="This PR adds 0.03 percentage points to the coverage rate in the whole project, taking it from 93.47% (2273/2385) to 93.5% (2259/2370)." src="https://img.shields.io/badge/93%25-%282.3k/2.4k%20%3E%202.3k/2.4k%29-brightgreen.svg"></a></td><td align="center"><a href="https://github.com/cal-itp/benefits/pull/2819/files#diff-4b0bf2efa3367c0072ac2bf1e234e703dc46b47aaa4fe9d3b01737b1a15752b1"><img title="In this PR, 1 new statements are added to the whole project, 1 of which are covered (100%)." src="https://img.shields.io/badge/100%25-%281/1%29-brightgreen.svg"></a></td><td>&nbsp;</td>
</tr>
</tfoot>
</table>

<sub>

This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action)

</sub>
</details>



<!-- This comment was produced by python-coverage-comment-action -->
author_association: NONE
user: login: angela-tran
created_at: 2025-04-04T17:39:29Z
body: Just realized this is still in draft. @lalver1 are there changes you're still wanting to make on this PR?
author_association: MEMBER
user: login: lalver1
created_at: 2025-04-04T17:59:11Z
body: It's ready to go @angela-tran! I only wanted to clarify if the few lines I added in https://github.com/cal-itp/benefits/pull/2819/files#diff-c4e66e5bbdbde74f5062e2a19cb1b0374dc4180b1de7f0b932e169f60ef25e02R53 were what you had in mind (but that's part of the review 😅 ). I think the reason to add them is because we removed `test_logout` yet we still want to have these assertions.
author_association: MEMBER
user: login: angela-tran
created_at: 2025-04-04T18:05:48Z
body: > It's ready to go @angela-tran! I only wanted to clarify if the few lines I added in https://github.com/cal-itp/benefits/pull/2819/files#diff-c4e66e5bbdbde74f5062e2a19cb1b0374dc4180b1de7f0b932e169f60ef25e02R53 were what you had in mind (but that's part of the review 😅 ). 

Yep, it looks great!

> I think the reason to add them is because we removed `test_logout` yet we still want to have these assertions.

Yep, that was my thinking. Ideally those assertions would've been a part of the [test](https://github.com/cal-itp/benefits/pull/2769/files#diff-c4e66e5bbdbde74f5062e2a19cb1b0374dc4180b1de7f0b932e169f60ef25e02R35-R42) introduced in #2769, but you know, hindsight is 20/20.
author_association: MEMBER