diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7cb87f035..7cda99910 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerComposeFile": ["../compose.yml"], "service": "dev", "runServices": ["dev", "docs", "server"], - "workspaceFolder": "/home/calitp/app", + "workspaceFolder": "/calitp/app", "postStartCommand": ["/bin/bash", "bin/reset_db.sh"], "postAttachCommand": ["/bin/bash", ".devcontainer/postAttach.sh"], "customizations": { diff --git a/.devcontainer/postAttach.sh b/.devcontainer/postAttach.sh index 29495649d..b5c5440ca 100755 --- a/.devcontainer/postAttach.sh +++ b/.devcontainer/postAttach.sh @@ -3,5 +3,5 @@ set -eu # initialize pre-commit -git config --global --add safe.directory /home/calitp/app +git config --global --add safe.directory /calitp/app pre-commit install --overwrite diff --git a/.github/workflows/add-to-project-dependabot.yml b/.github/workflows/add-to-project-dependabot.yml index ae42b5f6c..0f02b6cac 100644 --- a/.github/workflows/add-to-project-dependabot.yml +++ b/.github/workflows/add-to-project-dependabot.yml @@ -8,7 +8,7 @@ jobs: add-to-project-dependabot: runs-on: ubuntu-latest # see https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#responding-to-events - if: github.actor == 'dependabot[bot]' + if: github.actor == 'dependabot[bot]' || github.actor == 'pre-commit-ci[bot]' steps: - uses: actions/add-to-project@main with: @@ -18,7 +18,7 @@ jobs: - uses: EndBug/project-fields@v2 with: operation: set - fields: Effort - values: 1 + fields: Effort,Status + values: 1,In review project_url: https://github.com/orgs/cal-itp/projects/${{ secrets.GH_PROJECT }} github_token: ${{ secrets.GH_PROJECTS_TOKEN }} diff --git a/.github/workflows/check-migrations-and-messages.yml b/.github/workflows/check-migrations-and-messages.yml new file mode 100644 index 000000000..b8cdac094 --- /dev/null +++ b/.github/workflows/check-migrations-and-messages.yml @@ -0,0 +1,48 @@ +name: Check for up-to-date Django migrations and messages +on: [push, pull_request] + +jobs: + check-migrations-and-messages: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install system packages + run: | + sudo apt-get update -y + sudo apt-get install -y gettext + + - uses: actions/setup-python@v5 + with: + python-version-file: .github/workflows/.python-version + cache: pip + cache-dependency-path: "**/pyproject.toml" + + - name: Install Python dependencies + run: pip install -e .[dev,test] + + - name: Run ./bin/makemigrations.sh + run: | + if ./bin/makemigrations.sh | grep -q 'No changes detected'; + then + exit 0; + else + exit 1; + fi + + - name: Run ./bin/makemessages.sh + run: | + ./bin/makemessages.sh + + set -x # show commands + + git add benefits + + # message files are up-to-date if the only differences are from the updated timestamp + if echo $(git diff --cached --shortstat) | grep -q '2 files changed, 2 insertions(+), 2 deletions(-)'; + then + exit 0; + else + exit 1; + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d3365e533..91b63e312 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,6 +37,7 @@ jobs: name: CodeQL Analyze runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.languages != '[]' }} permissions: actions: read contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5dadca811..790c295a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -61,6 +61,11 @@ jobs: context: . file: appcontainer/Dockerfile push: true - tags: | - ghcr.io/${{ github.repository }}:${{ github.ref_name }} - ghcr.io/${{ github.repository }}:${{ github.sha }} + tags: ghcr.io/${{ github.repository }}:${{ github.sha }} + + - name: Deploy to Azure Web App + uses: azure/webapps-deploy@v2 + with: + app-name: ${{ vars.AZURE_WEBAPP_NAME }} + images: ghcr.io/${{ github.repository }}:${{ github.sha }} + publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 226870d5d..49306225b 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -92,7 +92,7 @@ jobs: uses: actions/checkout@v4 - name: Download coverage report - uses: dawidd6/action-download-artifact@v3 + uses: dawidd6/action-download-artifact@v6 with: workflow: tests-pytest.yml branch: dev diff --git a/appcontainer/Dockerfile b/appcontainer/Dockerfile index 4aa3cf9df..9e6cb3b98 100644 --- a/appcontainer/Dockerfile +++ b/appcontainer/Dockerfile @@ -5,7 +5,7 @@ RUN python -m pip install --upgrade pip # overwrite default nginx.conf COPY appcontainer/nginx.conf /etc/nginx/nginx.conf -COPY appcontainer/proxy.conf /home/calitp/run/proxy.conf +COPY appcontainer/proxy.conf /calitp/run/proxy.conf # copy source files COPY manage.py manage.py diff --git a/appcontainer/nginx.conf b/appcontainer/nginx.conf index 27f322248..b0fe492bd 100644 --- a/appcontainer/nginx.conf +++ b/appcontainer/nginx.conf @@ -22,7 +22,7 @@ http { upstream app_server { # fail_timeout=0 means we always retry an upstream even if it failed # to return a good HTTP response - server unix:/home/calitp/run/gunicorn.sock fail_timeout=0; + server unix:/calitp/run/gunicorn.sock fail_timeout=0; } # maps $binary_ip_address to $limit variable if request is of type POST @@ -67,7 +67,7 @@ http { # path for static files location /static/ { - alias /home/calitp/app/static/; + alias /calitp/app/static/; expires 1y; add_header Cache-Control public; } @@ -81,12 +81,12 @@ http { # case-insensitive regex matches path location ~* ^/(eligibility/confirm)$ { limit_req zone=rate_limit; - include /home/calitp/run/proxy.conf; + include /calitp/run/proxy.conf; } # app path location @proxy_to_app { - include /home/calitp/run/proxy.conf; + include /calitp/run/proxy.conf; } } } diff --git a/benefits/core/context_processors.py b/benefits/core/context_processors.py index d010ab284..0d54ae5f1 100644 --- a/benefits/core/context_processors.py +++ b/benefits/core/context_processors.py @@ -15,7 +15,7 @@ def unique_values(original_list): def _agency_context(agency): return { "eligibility_index_url": agency.eligibility_index_url, - "help_templates": unique_values([v.help_template for v in agency.active_verifiers if v.help_template is not None]), + "help_templates": unique_values([v.help_template for v in agency.active_verifiers if v.help_template]), "info_url": agency.info_url, "long_name": agency.long_name, "phone": agency.phone, diff --git a/benefits/core/migrations/0011_move_enrollment_success_template_field.py b/benefits/core/migrations/0011_move_enrollment_success_template_field.py new file mode 100644 index 000000000..9c00fbb0f --- /dev/null +++ b/benefits/core/migrations/0011_move_enrollment_success_template_field.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.3 on 2024-05-17 19:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0010_alter_secret_name_field_blank"), + ] + + operations = [ + migrations.RemoveField( + model_name="transitagency", + name="enrollment_success_template", + ), + migrations.AddField( + model_name="eligibilitytype", + name="enrollment_success_template", + field=models.TextField(default="enrollment/success.html"), + ), + ] diff --git a/benefits/core/migrations/local_fixtures.json b/benefits/core/migrations/local_fixtures.json index 05abb266e..c66dfd8b2 100644 --- a/benefits/core/migrations/local_fixtures.json +++ b/benefits/core/migrations/local_fixtures.json @@ -69,7 +69,8 @@ "fields": { "name": "senior", "label": "(MST) Senior Discount", - "group_id": "group123" + "group_id": "group123", + "enrollment_success_template": "enrollment/success--mst.html" } }, { @@ -78,7 +79,8 @@ "fields": { "name": "veteran", "label": "(MST) Veteran Discount", - "group_id": "group123" + "group_id": "group123", + "enrollment_success_template": "enrollment/success--mst.html" } }, { @@ -88,7 +90,8 @@ "name": "courtesy_card", "label": "(MST) Courtesy Card Discount", "group_id": "group123", - "enrollment_index_template": "enrollment/index--agency-card.html" + "enrollment_index_template": "enrollment/index--agency-card.html", + "enrollment_success_template": "enrollment/success--mst-courtesy-card.html" } }, { @@ -97,7 +100,8 @@ "fields": { "name": "senior", "label": "(SacRT) Senior Discount", - "group_id": "group123" + "group_id": "group123", + "enrollment_success_template": "enrollment/success--sacrt.html" } }, { @@ -106,7 +110,8 @@ "fields": { "name": "senior", "label": "(SBMTD) Senior Discount", - "group_id": "group123" + "group_id": "group123", + "enrollment_success_template": "enrollment/success--sbmtd.html" } }, { @@ -116,7 +121,8 @@ "name": "mobility_pass", "label": "(SBMTD) Mobility Pass Discount", "group_id": "group123", - "enrollment_index_template": "enrollment/index--agency-card.html" + "enrollment_index_template": "enrollment/index--agency-card.html", + "enrollment_success_template": "enrollment/success--sbmtd-mobility-pass.html" } }, { @@ -340,7 +346,6 @@ "jws_signing_alg": "RS256", "index_template": "core/index--mst.html", "eligibility_index_template": "eligibility/index--mst.html", - "enrollment_success_template": "enrollment/success--mst.html", "eligibility_types": [1, 7, 2, 3], "eligibility_verifiers": [1, 7, 2, 3] } @@ -363,7 +368,6 @@ "jws_signing_alg": "RS256", "index_template": "core/index--sacrt.html", "eligibility_index_template": "eligibility/index--sacrt.html", - "enrollment_success_template": "enrollment/success--sacrt.html", "eligibility_types": [4], "eligibility_verifiers": [4] } @@ -386,7 +390,6 @@ "jws_signing_alg": "RS256", "index_template": "core/index--sbmtd.html", "eligibility_index_template": "eligibility/index--sbmtd.html", - "enrollment_success_template": "enrollment/success--sbmtd.html", "eligibility_types": [5, 6], "eligibility_verifiers": [5, 6] } diff --git a/benefits/core/models.py b/benefits/core/models.py index 22abac492..b0c378448 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -116,6 +116,7 @@ class EligibilityType(models.Model): expiration_reenrollment_days = models.PositiveSmallIntegerField(null=True, blank=True) enrollment_index_template = models.TextField(default="enrollment/index.html") reenrollment_error_template = models.TextField(null=True, blank=True) + enrollment_success_template = models.TextField(default="enrollment/success.html") def __str__(self): return self.label @@ -276,7 +277,6 @@ class TransitAgency(models.Model): jws_signing_alg = models.TextField() index_template = models.TextField() eligibility_index_template = models.TextField() - enrollment_success_template = models.TextField() def __str__(self): return self.long_name diff --git a/benefits/core/urls.py b/benefits/core/urls.py index b8c32a639..2b7157ecc 100644 --- a/benefits/core/urls.py +++ b/benefits/core/urls.py @@ -50,4 +50,5 @@ def to_url(self, agency): path("", views.agency_index, name="agency_index"), path("/publickey", views.agency_public_key, name="agency_public_key"), path("logged_out", views.logged_out, name="logged_out"), + path("error", views.server_error, name="server-error"), ] diff --git a/benefits/core/views.py b/benefits/core/views.py index 9bd16880c..359cf19cb 100644 --- a/benefits/core/views.py +++ b/benefits/core/views.py @@ -12,6 +12,7 @@ ROUTE_ELIGIBILITY = "eligibility:index" ROUTE_HELP = "core:help" ROUTE_LOGGED_OUT = "core:logged_out" +ROUTE_SERVER_ERROR = "core:server-error" TEMPLATE_INDEX = "core/index.html" TEMPLATE_AGENCY = "core/agency-index.html" diff --git a/benefits/enrollment/analytics.py b/benefits/enrollment/analytics.py index 3fc60b4ab..b92e4d349 100644 --- a/benefits/enrollment/analytics.py +++ b/benefits/enrollment/analytics.py @@ -16,6 +16,15 @@ def __init__(self, request, status, error=None, payment_group=None): self.update_event_properties(payment_group=payment_group) +class FailedAccessTokenRequestEvent(core.Event): + """Analytics event representing a failure to acquire an access token for card tokenization.""" + + def __init__(self, request, status_code=None): + super().__init__(request, "failed access token request") + if status_code is not None: + self.update_event_properties(status_code=status_code) + + def returned_error(request, error): """Send the "returned enrollment" analytics event with an error status and message.""" core.send_event(ReturnedEnrollmentEvent(request, status="error", error=error)) @@ -29,3 +38,8 @@ def returned_retry(request): def returned_success(request, payment_group): """Send the "returned enrollment" analytics event with a success status.""" core.send_event(ReturnedEnrollmentEvent(request, status="success", payment_group=payment_group)) + + +def failed_access_token_request(request, status_code=None): + """Send the "failed access token request" analytics event with the response status code.""" + core.send_event(FailedAccessTokenRequestEvent(request, status_code=status_code)) diff --git a/benefits/enrollment/forms.py b/benefits/enrollment/forms.py index 9606d7085..d9f72e146 100644 --- a/benefits/enrollment/forms.py +++ b/benefits/enrollment/forms.py @@ -19,12 +19,12 @@ class CardTokenizeSuccessForm(forms.Form): class CardTokenizeFailForm(forms.Form): """Form to indicate card tokenization failure to server.""" - id = "form-card-tokenize-fail" method = "POST" - def __init__(self, action_url, *args, **kwargs): + def __init__(self, action_url, id, *args, **kwargs): # init super with an empty data dict # binds and makes immutable this form's data # since there are no form fields, the form is also marked as valid super().__init__({}, *args, **kwargs) + self.id = id self.action_url = action_url diff --git a/benefits/enrollment/templates/enrollment/index.html b/benefits/enrollment/templates/enrollment/index.html index f75e42303..e0575f2e7 100644 --- a/benefits/enrollment/templates/enrollment/index.html +++ b/benefits/enrollment/templates/enrollment/index.html @@ -41,6 +41,13 @@

$.ajax({ dataType: "script", attrs: { nonce: "{{ request.csp_nonce }}"}, url: "{{ card_tokenize_url }}" }) .done(function() { $.get("{{ access_token_url }}", function(data) { + if (data.redirect) { + // https://stackoverflow.com/a/42469170 + // use 'assign' because 'replace' was giving strange Back button behavior + window.location.assign(data.redirect); + return; // exit early so the rest of this function doesn't execute + } + $(".loading").remove(); // remove invisible and add back visible, so we aren't left with // a div with an empty class attribute @@ -87,7 +94,11 @@

/* 400 or 500 will return. */ amplitude.getInstance().logEvent(closedEvent, {status: "error", error: response}); - var form = $("form#{{ form_retry }}"); + if (response.status >= 500) { + var form = $("form#{{ form_system_error }}"); + } else { + var form = $("form#{{ form_server_error }}"); + } form.submit(); }, onCancel: function () { diff --git a/benefits/enrollment/templates/enrollment/success--mst-courtesy-card.html b/benefits/enrollment/templates/enrollment/success--mst-courtesy-card.html new file mode 100644 index 000000000..1fffece99 --- /dev/null +++ b/benefits/enrollment/templates/enrollment/success--mst-courtesy-card.html @@ -0,0 +1,21 @@ +{% extends "enrollment/success.html" %} +{% load i18n %} + +{% block headline-message %} + {% blocktranslate trimmed %} + You can now use your contactless card to tap to ride with a reduced fare! + {% endblocktranslate %} +{% endblock headline-message %} + +{% block success-message %} + {% blocktranslate trimmed %} + Your contactless card is now enrolled in an MST Courtesy Card transit benefit. When boarding an MST bus, tap this card and you will be + charged a reduced fare. You will need to re-enroll if you choose to change the card you use to pay for transit service. + {% endblocktranslate %} +{% endblock success-message %} + +{% block thank-you-message %} + {% blocktranslate trimmed %} + You were not charged anything today. Thank you for using Cal-ITP Benefits! + {% endblocktranslate %} +{% endblock thank-you-message %} diff --git a/benefits/enrollment/templates/enrollment/success--mst.html b/benefits/enrollment/templates/enrollment/success--mst.html index b6ea0e1a8..83ce6f92e 100644 --- a/benefits/enrollment/templates/enrollment/success--mst.html +++ b/benefits/enrollment/templates/enrollment/success--mst.html @@ -3,7 +3,7 @@ {% block success-message %} {% blocktranslate trimmed %} - You were not charged anything today. When boarding an MST fixed route bus, tap this card when you board and you will be + You were not charged anything today. When boarding an MST bus, tap this card and you will be charged a reduced fare. You will need to re-enroll if you choose to change the card you use to pay for transit service. {% endblocktranslate %} {% endblock success-message %} diff --git a/benefits/enrollment/templates/enrollment/success--sacrt.html b/benefits/enrollment/templates/enrollment/success--sacrt.html index 6dfdb13c5..79fc21f38 100644 --- a/benefits/enrollment/templates/enrollment/success--sacrt.html +++ b/benefits/enrollment/templates/enrollment/success--sacrt.html @@ -3,7 +3,7 @@ {% block success-message %} {% blocktranslate trimmed %} - You were not charged anything today. When boarding SacRT light rail, tap this card when you board and you will be charged - a reduced fare. You will need to re-enroll if you choose to change the card you use to pay for transit service. + You were not charged anything today. When boarding SacRT light rail, tap this card and you will be + charged a reduced fare. You will need to re-enroll if you choose to change the card you use to pay for transit service. {% endblocktranslate %} {% endblock success-message %} diff --git a/benefits/enrollment/templates/enrollment/success--sbmtd-mobility-pass.html b/benefits/enrollment/templates/enrollment/success--sbmtd-mobility-pass.html new file mode 100644 index 000000000..111c3dfb1 --- /dev/null +++ b/benefits/enrollment/templates/enrollment/success--sbmtd-mobility-pass.html @@ -0,0 +1,21 @@ +{% extends "enrollment/success.html" %} +{% load i18n %} + +{% block headline-message %} + {% blocktranslate trimmed %} + You can now use your contactless card to tap to ride with a reduced fare! + {% endblocktranslate %} +{% endblock headline-message %} + +{% block success-message %} + {% blocktranslate trimmed %} + Your contactless card is now enrolled in an SBMTD Reduced Fare Mobility ID transit benefit. When boarding an SBMTD bus, tap this card and you will be + charged a reduced fare. You will need to re-enroll if you choose to change the card you use to pay for transit service. + {% endblocktranslate %} +{% endblock success-message %} + +{% block thank-you-message %} + {% blocktranslate trimmed %} + You were not charged anything today. Thank you for using Cal-ITP Benefits! + {% endblocktranslate %} +{% endblock thank-you-message %} diff --git a/benefits/enrollment/templates/enrollment/success--sbmtd.html b/benefits/enrollment/templates/enrollment/success--sbmtd.html index 08559b21f..1902838d7 100644 --- a/benefits/enrollment/templates/enrollment/success--sbmtd.html +++ b/benefits/enrollment/templates/enrollment/success--sbmtd.html @@ -3,7 +3,7 @@ {% block success-message %} {% blocktranslate trimmed %} - You were not charged anything today. When boarding an SBMTD bus, tap this card when you board and you will be - charged a reduced fare. If you change the card you use to pay for transit service, you will need to re-enroll. + You were not charged anything today. When boarding an SBMTD bus, tap this card and you will be + charged a reduced fare. You will need to re-enroll if you choose to change the card you use to pay for transit service. {% endblocktranslate %} {% endblock success-message %} diff --git a/benefits/enrollment/templates/enrollment/success.html b/benefits/enrollment/templates/enrollment/success.html index f4b161840..36931292c 100644 --- a/benefits/enrollment/templates/enrollment/success.html +++ b/benefits/enrollment/templates/enrollment/success.html @@ -13,26 +13,34 @@ {% block headline %}
-

{% translate "Success! Your transit benefit is now connected to your card." %}

+

+ {% block headline-message %} + {% translate "Success! Your transit benefit is now connected to your card." %} + {% endblock headline-message %} +

{% endblock headline %} {% block inner-content %}
-
+
{# djlint:off #} {% if enrollment.supports_expiration %}

{% translate "Your benefit will expire on" %} {{ enrollment.expires|date }}.

{% else %} -

+

{% endif %} {% block success-message %} {% endblock success-message %}

{# djlint:on #} -

{% translate "Thank you for using Cal-ITP Benefits!" %}

+

+ {% block thank-you-message %} + {% translate "Thank you for using Cal-ITP Benefits!" %} + {% endblock thank-you-message %} +

+

+ {% include "core/includes/icon.html" with name="bankcardquestion" %} + {% translate "Our enrollment system is not working right now." %} +

+ +
+
+

+ {% translate "We’re working to solve the problem. Please wait 48 hours and try to enroll again, or contact your transit agency for help." %} +

+
+
+ +
+
{% include "core/includes/agency-links.html" %}
+
+ +
+
+ {% if authentication and authentication.sign_out_link_template %} + {% url "oauth:logout" as sign_out_url %} + {% translate "Sign out of" as button_text %} + + {% else %} + {% include "core/includes/button--origin.html" %} + {% endif %} +
+
+
+{% endblock main-content %} diff --git a/benefits/enrollment/urls.py b/benefits/enrollment/urls.py index f74eb2452..fb3ddd5c1 100644 --- a/benefits/enrollment/urls.py +++ b/benefits/enrollment/urls.py @@ -15,4 +15,5 @@ path("reenrollment-error", views.reenrollment_error, name="reenrollment-error"), path("retry", views.retry, name="retry"), path("success", views.success, name="success"), + path("error", views.system_error, name="system-error"), ] diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index b6191e811..916d942fb 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -10,10 +10,11 @@ from django.utils.decorators import decorator_from_middleware from littlepay.api.client import Client from requests.exceptions import HTTPError +import sentry_sdk from benefits.core import session from benefits.core.middleware import EligibleSessionRequired, VerifierSessionRequired, pageview_decorator -from benefits.core.views import ROUTE_LOGGED_OUT +from benefits.core.views import ROUTE_LOGGED_OUT, ROUTE_SERVER_ERROR from . import analytics, forms @@ -21,10 +22,11 @@ ROUTE_REENROLLMENT_ERROR = "enrollment:reenrollment-error" ROUTE_RETRY = "enrollment:retry" ROUTE_SUCCESS = "enrollment:success" +ROUTE_SYSTEM_ERROR = "enrollment:system-error" ROUTE_TOKEN = "enrollment:token" TEMPLATE_RETRY = "enrollment/retry.html" -TEMPLATE_SUCCESS = "enrollment/success.html" +TEMPLATE_SYSTEM_ERROR = "enrollment/system_error.html" logger = logging.getLogger(__name__) @@ -36,15 +38,39 @@ def token(request): if not session.enrollment_token_valid(request): agency = session.agency(request) payment_processor = agency.payment_processor - client = Client( - base_url=payment_processor.api_base_url, - client_id=payment_processor.client_id, - client_secret=payment_processor.client_secret, - audience=payment_processor.audience, - ) - client.oauth.ensure_active_token(client.token) - response = client.request_card_tokenization_access() - session.update(request, enrollment_token=response.get("access_token"), enrollment_token_exp=response.get("expires_at")) + + try: + client = Client( + base_url=payment_processor.api_base_url, + client_id=payment_processor.client_id, + client_secret=payment_processor.client_secret, + audience=payment_processor.audience, + ) + client.oauth.ensure_active_token(client.token) + response = client.request_card_tokenization_access() + except Exception as e: + logger.debug("Error occurred while requesting access token", exc_info=e) + sentry_sdk.capture_exception(e) + + if isinstance(e, HTTPError): + status_code = e.response.status_code + + if status_code >= 500: + redirect = reverse(ROUTE_SYSTEM_ERROR) + else: + redirect = reverse(ROUTE_SERVER_ERROR) + else: + status_code = None + redirect = reverse(ROUTE_SERVER_ERROR) + + analytics.failed_access_token_request(request, status_code) + + data = {"redirect": redirect} + return JsonResponse(data) + else: + session.update( + request, enrollment_token=response.get("access_token"), enrollment_token_exp=response.get("expires_at") + ) data = {"token": session.enrollment_token(request)} @@ -88,6 +114,11 @@ def index(request): if e.response.status_code == 409: analytics.returned_success(request, eligibility.group_id) return success(request) + elif e.response.status_code >= 500: + analytics.returned_error(request, str(e)) + sentry_sdk.capture_exception(e) + + return system_error(request) else: analytics.returned_error(request, str(e)) raise Exception(f"{e}: {e.response.json()}") @@ -100,18 +131,22 @@ def index(request): # GET enrollment index else: - tokenize_retry_form = forms.CardTokenizeFailForm(ROUTE_RETRY) + tokenize_retry_form = forms.CardTokenizeFailForm(ROUTE_RETRY, "form-card-tokenize-fail-retry") + tokenize_server_error_form = forms.CardTokenizeFailForm(ROUTE_SERVER_ERROR, "form-card-tokenize-fail-server-error") + tokenize_system_error_form = forms.CardTokenizeFailForm(ROUTE_SYSTEM_ERROR, "form-card-tokenize-fail-system-error") tokenize_success_form = forms.CardTokenizeSuccessForm(auto_id=True, label_suffix="") context = { - "forms": [tokenize_retry_form, tokenize_success_form], + "forms": [tokenize_retry_form, tokenize_server_error_form, tokenize_system_error_form, tokenize_success_form], "cta_button": "tokenize_card", "card_tokenize_env": agency.payment_processor.card_tokenize_env, "card_tokenize_func": agency.payment_processor.card_tokenize_func, "card_tokenize_url": agency.payment_processor.card_tokenize_url, "token_field": "card_token", "form_retry": tokenize_retry_form.id, + "form_server_error": tokenize_server_error_form.id, "form_success": tokenize_success_form.id, + "form_system_error": tokenize_system_error_form.id, } logger.debug(f'card_tokenize_url: {context["card_tokenize_url"]}') @@ -145,6 +180,17 @@ def retry(request): return TemplateResponse(request, TEMPLATE_RETRY) +@decorator_from_middleware(EligibleSessionRequired) +def system_error(request): + """View handler for an enrollment system error.""" + + # overwrite origin so that CTA takes user to agency index + agency = session.agency(request) + session.update(request, origin=agency.index_url) + + return TemplateResponse(request, TEMPLATE_SYSTEM_ERROR) + + @pageview_decorator @decorator_from_middleware(VerifierSessionRequired) def success(request): @@ -152,7 +198,7 @@ def success(request): request.path = "/enrollment/success" session.update(request, origin=reverse(ROUTE_SUCCESS)) - agency = session.agency(request) + eligibility = session.eligibility(request) verifier = session.verifier(request) if session.logged_in(request) and verifier.auth_provider.supports_sign_out: @@ -160,4 +206,4 @@ def success(request): # if they click the logout button, they are taken to the new route session.update(request, origin=reverse(ROUTE_LOGGED_OUT)) - return TemplateResponse(request, agency.enrollment_success_template) + return TemplateResponse(request, eligibility.enrollment_success_template) diff --git a/benefits/locale/en/LC_MESSAGES/django.po b/benefits/locale/en/LC_MESSAGES/django.po index 9cf9c9aff..b6c6ec264 100644 --- a/benefits/locale/en/LC_MESSAGES/django.po +++ b/benefits/locale/en/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: https://github.com/cal-itp/benefits/issues \n" -"POT-Creation-Date: 2024-05-15 16:02+0000\n" +"POT-Creation-Date: 2024-06-07 16:22+0000\n" "Language: English\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -645,23 +645,43 @@ msgid "" msgstr "" msgid "" -"You were not charged anything today. When boarding an MST fixed route bus, " -"tap this card when you board and you will be charged a reduced fare. You " -"will need to re-enroll if you choose to change the card you use to pay for " -"transit service." +"You can now use your contactless card to tap to ride with a reduced fare!" +msgstr "" + +msgid "" +"Your contactless card is now enrolled in an MST Courtesy Card transit " +"benefit. When boarding an MST bus, tap this card and you will be charged a " +"reduced fare. You will need to re-enroll if you choose to change the card " +"you use to pay for transit service." +msgstr "" + +msgid "" +"You were not charged anything today. Thank you for using Cal-ITP Benefits!" +msgstr "" + +msgid "" +"You were not charged anything today. When boarding an MST bus, tap this card " +"and you will be charged a reduced fare. You will need to re-enroll if you " +"choose to change the card you use to pay for transit service." msgstr "" msgid "" "You were not charged anything today. When boarding SacRT light rail, tap " -"this card when you board and you will be charged a reduced fare. You will " -"need to re-enroll if you choose to change the card you use to pay for " -"transit service." +"this card and you will be charged a reduced fare. You will need to re-enroll " +"if you choose to change the card you use to pay for transit service." +msgstr "" + +msgid "" +"Your contactless card is now enrolled in an SBMTD Reduced Fare Mobility ID " +"transit benefit. When boarding an SBMTD bus, tap this card and you will be " +"charged a reduced fare. You will need to re-enroll if you choose to change " +"the card you use to pay for transit service." msgstr "" msgid "" "You were not charged anything today. When boarding an SBMTD bus, tap this " -"card when you board and you will be charged a reduced fare. If you change " -"the card you use to pay for transit service, you will need to re-enroll." +"card and you will be charged a reduced fare. You will need to re-enroll if " +"you choose to change the card you use to pay for transit service." msgstr "" msgid "Success" @@ -679,6 +699,20 @@ msgstr "" msgid "If you are on a public or shared computer, don’t forget to sign out of " msgstr "" +msgid "Enrollment system down" +msgstr "" + +msgid "Our enrollment system is not working right now." +msgstr "" + +msgid "" +"We’re working to solve the problem. Please wait 48 hours and try to enroll " +"again, or contact your transit agency for help." +msgstr "" + +msgid "Sign out of" +msgstr "" + msgid "Start over" msgstr "" diff --git a/benefits/locale/es/LC_MESSAGES/django.po b/benefits/locale/es/LC_MESSAGES/django.po index 1fb37ac4f..098c2858b 100644 --- a/benefits/locale/es/LC_MESSAGES/django.po +++ b/benefits/locale/es/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: https://github.com/cal-itp/benefits/issues \n" -"POT-Creation-Date: 2024-05-15 16:02+0000\n" +"POT-Creation-Date: 2024-06-07 16:22+0000\n" "Language: Español\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -95,7 +95,8 @@ msgstr "" msgid "The contactless symbol is four curved lines, like this:" msgstr "" -"El símbolo sin contacto está compuesto por cuatro líneas curvas, como este:" +"El símbolo de 'sin contacto' está compuesto por cuatro líneas curvas, como " +"este:" msgctxt "image alt text" msgid "Four curved lines on contactless-enabled cards" @@ -123,7 +124,7 @@ msgid "" "noreferrer\">contact %(short_name)s." msgstr "" "También puede conseguir su beneficio de tránsito pasando por el proceso de " -"solicitud de su %(short_name)s. Para recibir actualizaciones sobre opciones " +"solicitud de %(short_name)s. Para recibir actualizaciones sobre opciones " "adicionales, vuelva a consultar este sitio web o contacte a %(short_name)s." @@ -138,7 +139,7 @@ msgid "" msgstr "" "Login.gov es un servicio que ofrece acceso seguro y privado a programas " "gubernamentales como beneficios, servicios y aplicaciones federales. Con una " -"cuenta de Login.gov puede iniciar sesión en varios sitios web " +"cuenta de Login.gov puede iniciar una sesión en varios sitios web " "gubernamentales con la misma dirección de correo electrónico y contraseña." msgid "" @@ -194,7 +195,7 @@ msgid "" msgstr "" "Login.gov también necesitará verificar su identidad llamando o enviando un " "mensaje de texto a su teléfono. Si Login.gov no puede verificar su número de " -"teléfono, usted puede verificarlo por correo." +"teléfono, usted puede verificar su identidad por correo." msgid "" "Please visit the Login.gov help center for ." msgid "Questions?" -msgstr "¿Tienes preguntas?" +msgstr "¿Tiene preguntas?" msgid "" "If you need assistance with this website, please reach out to the customer " @@ -806,34 +807,68 @@ msgstr "" "agencia de tránsito para obtener ayuda." msgid "" -"You were not charged anything today. When boarding an MST fixed route bus, " -"tap this card when you board and you will be charged a reduced fare. You " -"will need to re-enroll if you choose to change the card you use to pay for " -"transit service." +"You can now use your contactless card to tap to ride with a reduced fare!" msgstr "" -"No se le cobró nada hoy. Al abordar un autobús de ruta fija de MST, toque " -"esta tarjeta cuando suba y se le cobrará una tarifa reducida. Deberá volver " -"a inscribirse si elige cambiar la tarjeta que usa para pagar el servicio de " +"¡Ahora puede usar su tarjeta sin contacto para tocar y viajar con una tarifa " +"reducida!" + +msgid "" +"Your contactless card is now enrolled in an MST Courtesy Card transit " +"benefit. When boarding an MST bus, tap this card and you will be charged a " +"reduced fare. You will need to re-enroll if you choose to change the card " +"you use to pay for transit service." +msgstr "" +"Su tarjeta sin contacto ahora está inscrita en el beneficio de tránsito de " +"la tarjeta de cortesía de MST. Cuando suba a un autobús de MST, toque el " +"lector con esta tarjeta y se le cobrará una tarifa reducida. Deberá volver a " +"inscribirse si elige cambiar la tarjeta que usa para pagar el servicio de " +"tránsito." + +msgid "" +"You were not charged anything today. Thank you for using Cal-ITP Benefits!" +msgstr "No se le cobró nada hoy. ¡Gracias por usar Cal-ITP Benefits!" + +msgid "" +"You were not charged anything today. When boarding an MST bus, tap this card " +"and you will be charged a reduced fare. You will need to re-enroll if you " +"choose to change the card you use to pay for transit service." +msgstr "" +"No se le cobró nada hoy. Al abordar un autobús de CST, toque el lector con " +"esta tarjeta y se le cobrará una tarifa reducida. Deberá volver a " +"inscribirse si elige cambiar la tarjeta que usa para pagar el servicio de " "tránsito." msgid "" "You were not charged anything today. When boarding SacRT light rail, tap " -"this card when you board and you will be charged a reduced fare. You will " -"need to re-enroll if you choose to change the card you use to pay for " -"transit service." +"this card and you will be charged a reduced fare. You will need to re-enroll " +"if you choose to change the card you use to pay for transit service." +msgstr "" +"No se le cobró nada hoy. Al abordar un tren ligero de SaCRT, toque el lector " +"con esta tarjeta y se le cobrará una tarifa reducida. Deberá volver a " +"inscribirse si elige cambiar la tarjeta que usa para pagar el servicio de " +"tránsito." + +msgid "" +"Your contactless card is now enrolled in an SBMTD Reduced Fare Mobility ID " +"transit benefit. When boarding an SBMTD bus, tap this card and you will be " +"charged a reduced fare. You will need to re-enroll if you choose to change " +"the card you use to pay for transit service." msgstr "" -"No se le cobró nada hoy. Al abordar un tren ligero SacRT, toque esta tarjeta " -"cuando suba y se le cobrará una tarifa reducida. Deberá volver a inscribirse " -"si elige cambiar la tarjeta que usa para pagar el servicio de tránsito." +"Su tarjeta sin contacto ahora está inscrita en el beneficio de tránsito de " +"Reduced Fare Mobility ID de SBMTD. Cuando suba a un autobús de SBMTD, toque " +"el lector con esta tarjeta y se le cobrará una tarifa reducida. Deberá " +"volver a inscribirse si elige cambiar la tarjeta que usa para pagar el " +"servicio de tránsito." msgid "" "You were not charged anything today. When boarding an SBMTD bus, tap this " -"card when you board and you will be charged a reduced fare. If you change " -"the card you use to pay for transit service, you will need to re-enroll." +"card and you will be charged a reduced fare. You will need to re-enroll if " +"you choose to change the card you use to pay for transit service." msgstr "" -"No se le cobró nada hoy. Al abordar un autobús de SBMTD, toque esta tarjeta " -"cuando suba y se le cobrará una tarifa reducida. Deberá volver a inscribirse " -"si elige cambiar la tarjeta que usa para pagar el servicio de tránsito." +"No se le cobró nada hoy. Al abordar un autobús de SBMTD, toque el lector con " +"esta tarjeta y se le cobrará una tarifa reducida. Deberá volver a " +"inscribirse si elige cambiar la tarjeta que usa para pagar el servicio de " +"tránsito." msgid "Success" msgstr "Éxito" @@ -851,6 +886,23 @@ msgid "If you are on a public or shared computer, don’t forget to sign out of msgstr "" "Si está en una computadora pública o compartida, no olvide cerrar sesión en " +msgid "Enrollment system down" +msgstr "El sistema de inscripción no funciona" + +msgid "Our enrollment system is not working right now." +msgstr "Nuestro sistema de inscripción no está funcionando en este momento." + +msgid "" +"We’re working to solve the problem. Please wait 48 hours and try to enroll " +"again, or contact your transit agency for help." +msgstr "" +"Estamos trabajando para resolver el problema. Espere 48 horas e intente " +"inscribirse nuevamente, o contacte a su agencia de tránsito para obtener " +"ayuda." + +msgid "Sign out of" +msgstr "Cierre sesión de" + msgid "Start over" msgstr "Comenzar de nuevo" diff --git a/benefits/oauth/analytics.py b/benefits/oauth/analytics.py index 4565d9fa3..5f1b60ddf 100644 --- a/benefits/oauth/analytics.py +++ b/benefits/oauth/analytics.py @@ -32,8 +32,10 @@ def __init__(self, request): class FinishedSignInEvent(OAuthEvent): """Analytics event representing the end of the OAuth sign in flow.""" - def __init__(self, request): + def __init__(self, request, error=None): super().__init__(request, "finished sign in") + if error is not None: + self.update_event_properties(error_code=error) class StartedSignOutEvent(OAuthEvent): @@ -61,9 +63,9 @@ def canceled_sign_in(request): core.send_event(CanceledSignInEvent(request)) -def finished_sign_in(request): +def finished_sign_in(request, error=None): """Send the "finished sign in" analytics event.""" - core.send_event(FinishedSignInEvent(request)) + core.send_event(FinishedSignInEvent(request, error)) def started_sign_out(request): diff --git a/benefits/oauth/apps.py b/benefits/oauth/apps.py index 8c9acf873..68d7be30f 100644 --- a/benefits/oauth/apps.py +++ b/benefits/oauth/apps.py @@ -9,15 +9,3 @@ class OAuthAppConfig(AppConfig): name = "benefits.oauth" label = "oauth" verbose_name = "Benefits OAuth" - - def ready(self): - # delay import until the ready() function is called, signaling that - # Django has loaded all the apps and models - from .client import oauth, register_providers - - # wrap registration in try/catch - # even though we are in a ready() function, sometimes it's called early? - try: - register_providers(oauth) - except Exception: - pass diff --git a/benefits/oauth/client.py b/benefits/oauth/client.py index 3be706194..923b29ab6 100644 --- a/benefits/oauth/client.py +++ b/benefits/oauth/client.py @@ -6,9 +6,6 @@ from authlib.integrations.django_client import OAuth -from benefits.core.models import AuthProvider - - logger = logging.getLogger(__name__) oauth = OAuth() @@ -42,23 +39,32 @@ def _authorize_params(scheme): return params -def register_providers(oauth_registry): +def _register_provider(oauth_registry, provider): """ - Register OAuth clients into the given registry, using configuration from AuthProvider models. + Register OAuth clients into the given registry, using configuration from AuthProvider model. Adapted from https://stackoverflow.com/a/64174413. """ - logger.info("Registering OAuth clients") + logger.debug(f"Registering OAuth client: {provider.client_name}") + + client = oauth_registry.register( + provider.client_name, + client_id=provider.client_id, + server_metadata_url=_server_metadata_url(provider.authority), + client_kwargs=_client_kwargs(provider.scope), + authorize_params=_authorize_params(provider.scheme), + ) + + return client - providers = AuthProvider.objects.all() - for provider in providers: - logger.debug(f"Registering OAuth client: {provider.client_name}") +def create_client(oauth_registry, provider): + """ + Returns an OAuth client, registering it if needed. + """ + client = oauth_registry.create_client(provider.client_name) + + if client is None: + client = _register_provider(oauth_registry, provider) - oauth_registry.register( - provider.client_name, - client_id=provider.client_id, - server_metadata_url=_server_metadata_url(provider.authority), - client_kwargs=_client_kwargs(provider.scope), - authorize_params=_authorize_params(provider.scheme), - ) + return client diff --git a/benefits/oauth/views.py b/benefits/oauth/views.py index 86db149fe..9ce8297bf 100644 --- a/benefits/oauth/views.py +++ b/benefits/oauth/views.py @@ -6,7 +6,7 @@ from benefits.core import session from . import analytics, redirects -from .client import oauth +from .client import oauth, create_client from .middleware import VerifierUsesAuthVerificationSessionRequired @@ -24,7 +24,8 @@ def login(request): """View implementing OIDC authorize_redirect.""" verifier = session.verifier(request) - oauth_client = oauth.create_client(verifier.auth_provider.client_name) + + oauth_client = create_client(oauth, verifier.auth_provider) if not oauth_client: raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}") @@ -43,7 +44,7 @@ def login(request): def authorize(request): """View implementing OIDC token authorization.""" verifier = session.verifier(request) - oauth_client = oauth.create_client(verifier.auth_provider.client_name) + oauth_client = create_client(oauth, verifier.auth_provider) if not oauth_client: raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}") @@ -64,21 +65,25 @@ def authorize(request): verifier_claim = verifier.auth_provider.claim stored_claim = None + error_claim = None + if verifier_claim: userinfo = token.get("userinfo") if userinfo: claim_value = userinfo.get(verifier_claim) - # the claim comes back in userinfo like { "claim": "True" | "False" } + # the claim comes back in userinfo like { "claim": "1" | "0" } + claim_value = int(claim_value) if claim_value else None if claim_value is None: logger.warning(f"userinfo did not contain: {verifier_claim}") - elif claim_value.lower() == "true": - # if userinfo contains our claim and the flag is true, store the *claim* + elif claim_value == 1: + # if userinfo contains our claim and the flag is 1 (true), store the *claim* stored_claim = verifier_claim + elif claim_value >= 10: + error_claim = claim_value session.update(request, oauth_token=id_token, oauth_claim=stored_claim) - - analytics.finished_sign_in(request) + analytics.finished_sign_in(request, error=error_claim) return redirect(ROUTE_CONFIRM) diff --git a/benefits/static/css/styles.css b/benefits/static/css/styles.css index 6927754d9..91b71c0b3 100644 --- a/benefits/static/css/styles.css +++ b/benefits/static/css/styles.css @@ -470,9 +470,10 @@ footer .footer-links li a.footer-link:visited { } } -/* Sign in with Login.gov (white logo) on Eligibility Start */ +/* Sign in with Login.gov (white logo) on Eligibility Start, System Enrollment Error */ -.eligibility-start .btn.btn-lg.btn-primary.login { +.eligibility-start .btn.btn-lg.btn-primary.login, +.system-enrollment-error .btn.btn-lg.btn-primary.login { padding: 10px 0; } diff --git a/benefits/templates/error.html b/benefits/templates/error.html index 4ec55f37b..677c9dda1 100644 --- a/benefits/templates/error.html +++ b/benefits/templates/error.html @@ -8,7 +8,7 @@ {% block main-content %}
-

+

{% include "core/includes/icon.html" with name="sadbus" %} {% block headline %} {% endblock headline %} diff --git a/compose.yml b/compose.yml index b39a881f3..c1a400b30 100644 --- a/compose.yml +++ b/compose.yml @@ -1,5 +1,4 @@ name: benefits -version: "3.8" services: client: @@ -24,7 +23,7 @@ services: ports: - "${DJANGO_LOCAL_PORT:-8000}:8000" volumes: - - ./:/home/calitp/app + - ./:/calitp/app docs: image: benefits_client:dev @@ -33,7 +32,7 @@ services: ports: - "8001" volumes: - - ./:/home/calitp/app + - ./:/calitp/app server: image: ghcr.io/cal-itp/eligibility-server:dev diff --git a/docs/configuration/oauth.md b/docs/configuration/oauth.md index 6966a5d10..b8f33ba66 100644 --- a/docs/configuration/oauth.md +++ b/docs/configuration/oauth.md @@ -31,14 +31,9 @@ The [data migration file](./data.md) contains sample values for an `AuthProvider The [`benefits.oauth.client`][oauth-client] module defines helpers for registering OAuth clients, and creating instances for use in e.g. views. -- `register_providers(oauth_registry)` uses data from `AuthProvider` instances to register clients into the given registry - `oauth` is an `authlib.integrations.django_client.OAuth` instance -Providers are registered into this instance once in the [`OAuthAppConfig.ready()`][oauth-app-ready] function at application -startup. +Consumers call `benefits.oauth.client.create_client(oauth, provider)` with the name of a client to obtain an Authlib client +instance. If that client name has not been registered yet, `_register_provider(oauth_registry, provider)` uses data from the given `AuthProvider` instance to register the client into this instance and returns the client object. -Consumers call `oauth.create_client(client_name)` with the name of a previously registered client to obtain an Authlib client -instance. - -[oauth-app-ready]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/__init__.py [oauth-client]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/client.py diff --git a/docs/deployment/README.md b/docs/deployment/README.md index c3e7046cc..066173080 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -10,11 +10,8 @@ The Django application gets built into a [Docker image][dockerfile] with [NGINX] The application is deployed to an [Azure Web App Container][az-webapp] using three separate environments for `dev`, `test`, and `prod`. -A [GitHub Action][gh-actions] per environment is responsible for building that branch's image and pushing to [GitHub Container -Registry (GHCR)][ghcr]. - -GitHub POSTs a [webhook][gh-webhooks] to the Azure Web App when an [image is published to GHCR][gh-webhook-event], telling -Azure to restart the app and pull the latest image. +The [Deploy](deploy-workflow) workflow is responsible for building that branch's image and pushing to [GitHub Container +Registry (GHCR)][ghcr]. It also deploys to the Azure Web App, telling Azure to restart the app and pull the latest image. You can view what Git commit is deployed for a given environment by visitng the URL path `/static/sha.txt`. @@ -30,14 +27,11 @@ Docker images for each of the deploy branches are available from GitHub Containe - [Repository Package page](https://github.com/cal-itp/benefits/pkgs/container/benefits) - Image path: `ghcr.io/cal-itp/benefits` -- Image tags: `dev`, `test`, `prod` [oet]: https://techblog.cdt.ca.gov/2020/06/cdt-taking-the-lead-in-digital-transformation/ [app-service-containers]: https://docs.microsoft.com/en-us/azure/app-service/configure-custom-container [app-service]: https://docs.microsoft.com/en-us/azure/app-service/overview +[deploy-workflow]: https://github.com/cal-itp/benefits/blob/dev/.github/workflows/deploy.yml [dockerfile]: https://github.com/cal-itp/benefits/blob/dev/Dockerfile [az-webapp]: https://azure.microsoft.com/en-us/services/app-service/containers/ -[gh-actions]: https://docs.github.com/en/actions -[gh-webhook-event]: https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#package -[gh-webhooks]: https://docs.github.com/en/github-ae@latest/developers/webhooks-and-events/webhooks [ghcr]: https://github.com/features/packages diff --git a/docs/deployment/infrastructure.md b/docs/deployment/infrastructure.md index 14047ae23..e6512a8a0 100644 --- a/docs/deployment/infrastructure.md +++ b/docs/deployment/infrastructure.md @@ -2,8 +2,75 @@ The infrastructure is configured as code via [Terraform](https://www.terraform.io/), for [various reasons](https://techcommunity.microsoft.com/t5/fasttrack-for-azure/the-benefits-of-infrastructure-as-code/ba-p/2069350). +## Getting started + +Since the Benefits app is deployed into a Microsoft Azure account provided by the California Department of Technology (CDT)'s Office of Enterprise Technology (OET) team, you'll need to request access from them to the `CDT Digital CA` directory so you can get into the [Azure portal](https://portal.azure.com), and to the `California Department of Technology` directory so you can access [Azure DevOps](https://calenterprise.visualstudio.com/CDT.OET.CAL-ITP). + +The Azure portal is where you can view the infrastructure resources for Benefits. Azure DevOps is where our [infrastructure pipeline](https://github.com/cal-itp/benefits/blob/dev/terraform/azure-pipelines.yml) is run to build and deploy those infrastructure resources. + +## Environments + +Within the `CDT Digital CA` directory, there are two [Subscriptions](https://learn.microsoft.com/en-us/microsoft-365/enterprise/subscriptions-licenses-accounts-and-tenants-for-microsoft-cloud-offerings?view=o365-worldwide#subscriptions), with Resource Groups under each. (Refer to Azure's documentation for [switching directories](https://learn.microsoft.com/en-us/azure/devtest/offer/how-to-change-directory-tenants-visual-studio-azure).) + +Each of our environments corresponds to a single Resource Group, [Terraform Workspace](https://developer.hashicorp.com/terraform/language/state/workspaces), and branch. + +| Environment | Subscription | Resource Group | Workspace | Branch | +| ----------- | --------------------- | ----------------------------- | --------- | ------ | +| Dev | `CDT/ODI Development` | `RG-CDT-PUB-VIP-CALITP-D-001` | `dev` | `dev` | +| Test | `CDT/ODI Development` | `RG-CDT-PUB-VIP-CALITP-T-001` | `test` | `test` | +| Prod | `CDT/ODI Production` | `RG-CDT-PUB-VIP-CALITP-P-001` | `default` | `prod` | + +All resources in these Resource Groups should be reflected in Terraform in this repository. The exceptions are: + +- Secrets, such as values under [Key Vault](https://azure.microsoft.com/en-us/services/key-vault/). [`prevent_destroy`](https://developer.hashicorp.com/terraform/tutorials/state/resource-lifecycle#prevent-resource-deletion) is used on these Resources. +- [Things managed by DevSecOps](#ownership) + +### Ownership + +The following things in Azure are managed by the California Department of Technology (CDT)'s DevSecOps (OET) team: + +- Subcriptions +- [Resource Groups](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-portal) +- Networking +- Front Door + - Web Application Firewall (WAF) + - Distributed denial-of-service (DDoS) protection +- IAM +- Service connections + +You'll see these referenced in Terraform as [data sources](https://developer.hashicorp.com/terraform/language/data-sources), meaning they are managed outside of Terraform. + ## Architecture +These diagrams show a high-level view of the architecture per environment, including some external systems (e.g. analytics, error monitoring, eligibility servers). + +### Benefits application + +```mermaid +flowchart LR + internet[Public internet] + frontdoor[Front Door] + django[Django application] + interconnections[Other system interconnections] + + internet --> Cloudflare + Cloudflare --> frontdoor + django <--> interconnections + + subgraph Azure + frontdoor --> NGINX + + subgraph App Service + subgraph Custom container + direction TB + NGINX --> django + end + end + end +``` + +[Front Door](https://docs.microsoft.com/en-us/azure/frontdoor/front-door-overview) also includes the [Web Application Firewall (WAF)](https://docs.microsoft.com/en-us/azure/web-application-firewall/afds/afds-overview) and handles TLS termination. Front Door is managed by the DevSecOps team. + ### System interconnections ```mermaid @@ -42,77 +109,40 @@ flowchart LR idg -->|User attributes| benefits ``` -### Benefits application - -```mermaid -flowchart LR - internet[Public internet] - frontdoor[Front Door] - django[Django application] - interconnections[Other system interconnections] +## Naming conventions - internet --> Cloudflare - Cloudflare --> frontdoor - django <--> interconnections - - subgraph Azure - frontdoor --> NGINX +The DevSecOps team sets the following naming convention for Resources: - subgraph App Service - subgraph Custom container - direction TB - NGINX --> django - end - end - end +``` +<>-<>-<>-<>-<>-<><>-<>-<> ``` -[Front Door](https://docs.microsoft.com/en-us/azure/frontdoor/front-door-overview) also includes the [Web Application Firewall (WAF)](https://docs.microsoft.com/en-us/azure/web-application-firewall/afds/afds-overview) and handles TLS termination. Front Door is managed by the DevSecOps team. - -## Ownership - -The following things in Azure are managed by the California Department of Technology (CDT)'s DevSecOps (OET) team: - -- Subcriptions -- [Resource Groups](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-portal) -- Networking -- Front Door - - Web Application Firewall (WAF) - - Distributed denial-of-service (DDoS) protection -- IAM -- Service connections - -## Environments - -Within the `CDT Digital CA` directory ([how to switch](https://learn.microsoft.com/en-us/azure/devtest/offer/how-to-change-directory-tenants-visual-studio-azure)), there are two [Subscriptions](https://learn.microsoft.com/en-us/microsoft-365/enterprise/subscriptions-licenses-accounts-and-tenants-for-microsoft-cloud-offerings?view=o365-worldwide#subscriptions), with Resource Groups under each. Each environment corresponds to a single Resource Group, [Terraform Workspace](https://developer.hashicorp.com/terraform/language/state/workspaces), and branch. - -| Environment | Subscription | Resource Group | Workspace | Branch | -| ----------- | --------------------- | ----------------------------- | --------- | ------ | -| Dev | `CDT/ODI Development` | `RG-CDT-PUB-VIP-CALITP-D-001` | `dev` | `dev` | -| Test | `CDT/ODI Development` | `RG-CDT-PUB-VIP-CALITP-T-001` | `test` | `test` | -| Prod | `CDT/ODI Production` | `RG-CDT-PUB-VIP-CALITP-P-001` | `default` | `prod` | +### Sample Names -All resources in these Resource Groups should be reflected in Terraform in this repository. The exceptions are: +- `RG-CDT-PUB-VIP-BNSCN-E-D-001` +- `ASP-CDT-PUB-VIP-BNSCN-EL-P-001` +- `AS-CDT-PUB-VIP-BNSCN-EL-D-001` -- Secrets, such as values under [Key Vault](https://azure.microsoft.com/en-us/services/key-vault/). [`prevent_destroy`](https://developer.hashicorp.com/terraform/tutorials/state/resource-lifecycle#prevent-resource-deletion) is used on these Resources. -- [Things managed by DevSecOps](#ownership) +### Resource Types -You'll see these referenced in Terraform as [data sources](https://developer.hashicorp.com/terraform/language/data-sources). +Use the following shorthand for conveying the Resource Type as part of the Resource Name: -For browsing the [Azure portal](https://portal.azure.com), you can [switch your `Default subscription filter`](https://docs.microsoft.com/en-us/azure/azure-portal/set-preferences). +| Resource | Convention | +| ---------------- | ---------- | +| App Service | `AS` | +| App Service Plan | `ASP` | +| Virtual Network | `VNET` | +| Resource Group | `RG` | +| Virtual Machine | `VM` | +| Database | `DB` | +| Subnet | `SNET` | +| Front Door | `FD` | ## Making changes -[![Build Status](https://calenterprise.visualstudio.com/CDT.OET.CAL-ITP/_apis/build/status/cal-itp.benefits%20Infra?branchName=dev)](https://calenterprise.visualstudio.com/CDT.OET.CAL-ITP/_build/latest?definitionId=828&branchName=dev) - -Terraform is [`plan`](https://www.terraform.io/cli/commands/plan)'d when code is pushed to any branch on GitHub, then [`apply`](https://www.terraform.io/cli/commands/apply)'d when merged to `dev`. While other automation for this project is done through GitHub Actions, we use an Azure Pipeline (above) for a couple of reasons: - -- Easier authentication with the Azure API using a service connnection -- Log output is hidden, avoiding accidentally leaking secrets - -### Local development +### Set up for local development -1. Get access to the Azure account through the DevSecOps team. +1. [Get access to the Azure account through the DevSecOps team.](#getting-started) 1. Install dependencies: - [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) @@ -132,6 +162,11 @@ Terraform is [`plan`](https://www.terraform.io/cli/commands/plan)'d when code is ``` 1. Create a local `terraform.tfvars` file (ignored by git) from the sample; fill in the `*_OBJECT_ID` variables with values from the Azure Pipeline definition. + +### Development process + +When configuration changes to infrastructure resources are needed, they should be made to the resource definitions in Terraform and submitted via pull request. + 1. Make changes to Terraform files. 1. Preview the changes, as necessary. @@ -139,52 +174,35 @@ Terraform is [`plan`](https://www.terraform.io/cli/commands/plan)'d when code is terraform plan ``` -1. [Submit the changes via pull request.](../development/commits-branches-merging/) - -For Azure resources, you need to [ignore changes](https://www.terraform.io/language/meta-arguments/lifecycle#ignore_changes) to tags, since they are [automatically created by Azure Policy](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/tag-policies). +1. [Submit the changes via pull request.](../../development/commits-branches-merging) -```hcl -lifecycle { - ignore_changes = [tags] -} -``` +!!! info "Azure tags" + For Azure resources, you need to [ignore changes](https://www.terraform.io/language/meta-arguments/lifecycle#ignore_changes) to tags, since they are [automatically created by an Azure Policy managed by CDT](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/tag-policies). -### Naming conventions + ```hcl + lifecycle { + ignore_changes = [tags] + } + ``` -The DevSecOps team sets the following naming convention for Resources: +### Infrastructure pipeline -``` -<>-<>-<>-<>-<>-<><>-<>-<> -``` - -#### Sample Names - -- `RG-CDT-PUB-VIP-BNSCN-E-D-001` -- `ASP-CDT-PUB-VIP-BNSCN-EL-P-001` -- `AS-CDT-PUB-VIP-BNSCN-EL-D-001` +[![Build Status](https://calenterprise.visualstudio.com/CDT.OET.CAL-ITP/_apis/build/status/cal-itp.benefits%20Infra?branchName=dev)](https://calenterprise.visualstudio.com/CDT.OET.CAL-ITP/_build/latest?definitionId=828&branchName=dev) -#### Resource Types +When code is pushed to any branch on GitHub, our infrastructure pipeline in Azure DevOps runs [`terraform plan`](https://www.terraform.io/cli/commands/plan). When the pull request is merged into `dev`, the pipeline runs [`terraform apply`](https://www.terraform.io/cli/commands/apply). -Use the following shorthand for conveying the Resource Type as part of the Resource Name: +While other automation for this project is done through GitHub Actions, we use an Azure Pipeline for a couple of reasons: -| Resource | Convention | -| ---------------- | ---------- | -| App Service | `AS` | -| App Service Plan | `ASP` | -| Virtual Network | `VNET` | -| Resource Group | `RG` | -| Virtual Machine | `VM` | -| Database | `DB` | -| Subnet | `SNET` | -| Front Door | `FD` | +- Easier authentication with the Azure API using a service connnection +- Log output is hidden, avoiding accidentally leaking secrets ## Azure environment setup -The following steps are required to set up the environment, with linked issues to automate them: +These steps were followed when setting up our Azure deployment for the first time: +- CDT team creates the [resources that they own](#ownership) - `terraform apply` -- Set up Slack notifications by [creating a Slack email](https://slack.com/help/articles/206819278-Send-emails-to-Slack) for the [#benefits-notify](https://cal-itp.slack.com/archives/C022HHSEE3F) channel, then [setting it as a Secret in the Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/secrets/quick-create-portal#add-a-secret-to-key-vault) named `slack-benefits-notify-email` +- Set up Slack notifications by [creating a Slack email](https://slack.com/help/articles/206819278-Send-emails-to-Slack) for the [#notify-benefits](https://cal-itp.slack.com/archives/C022HHSEE3F) channel, then [setting it as a Secret in the Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/secrets/quick-create-portal#add-a-secret-to-key-vault) named `slack-benefits-notify-email` - Set required [App Service configuration](../configuration/environment-variables.md) and [configuration](../configuration/data.md) by setting values in Key Vault (the mapping is defined in [app_service.tf](https://github.com/cal-itp/benefits/blob/dev/terraform/app_service.tf)) -- [Set up webhook from GitHub](https://github.com/cal-itp/benefits/settings/hooks) to [App Service Deployment Center](https://learn.microsoft.com/en-us/azure/app-service/deploy-ci-cd-custom-container?tabs=acr&pivots=container-linux) for the `Packages` event This is not a complete step-by-step guide; more a list of things to remember. This may be useful as part of [incident response](https://docs.google.com/document/d/1qtev8qItPiTB4Tp9FQ87XsLtWZ4HlNXqoe9vF2VuGcY/edit#). diff --git a/docs/deployment/release.md b/docs/deployment/release.md index dcd2a0764..3fa542cad 100644 --- a/docs/deployment/release.md +++ b/docs/deployment/release.md @@ -87,8 +87,12 @@ git checkout prod git reset --hard origin/prod -git tag YYYY.0M.R +git tag -a YYYY.0M.R +``` + +Git will open your default text editor and prompt you for the tag annotation. For the tag annotation, use the title of the `release`-tagged Issue that kicked off the release. Finally, after closing the text editor: +```bash git push origin YYYY.0M.R ``` diff --git a/docs/deployment/workflows.md b/docs/deployment/workflows.md index 2c7d7f47c..ff5b21f42 100644 --- a/docs/deployment/workflows.md +++ b/docs/deployment/workflows.md @@ -23,16 +23,15 @@ Using the `github.actor` and built-in `GITHUB_TOKEN` secret ### 3. Build and push image to GitHub Container Registry (GHCR) -Build the root [`Dockerfile`][dockerfile], tagging with both the branch name (e.g. `dev`) and the SHA from the HEAD commit. +Build the root [`Dockerfile`][dockerfile], tagging with the SHA from the HEAD commit. Push this image:tag into [GHCR][ghcr]. ### 4. App Service deploy -Each Azure App Service instance is configured to [listen to a webhook from GitHub, then deploy the image][webhook]. +Push the new image:tag to the Azure App Service instance. [deploy]: https://github.com/cal-itp/benefits/blob/dev/.github/workflows/deploy.yml [dockerfile]: https://github.com/cal-itp/benefits/blob/dev/Dockerfile [ghcr]: https://github.com/features/packages [gh-actions-trigger]: https://docs.github.com/en/actions/reference/events-that-trigger-workflows -[webhook]: https://docs.microsoft.com/en-us/azure/app-service/deploy-ci-cd-custom-container diff --git a/docs/development/.pages b/docs/development/.pages index 8b2801411..f3088a77b 100644 --- a/docs/development/.pages +++ b/docs/development/.pages @@ -3,6 +3,7 @@ nav: - commits-branches-merging.md - docker-dynamic-ports.md - linting-pre-commit.md + - application-logic.md - models-migrations.md - i18n.md - test-server.md diff --git a/docs/development/application-logic.md b/docs/development/application-logic.md new file mode 100644 index 000000000..1c49dbf58 --- /dev/null +++ b/docs/development/application-logic.md @@ -0,0 +1,301 @@ +# Application logic + +!!! info "See also" + + More specific user flow diagrams: [Enrollment pathways](../enrollment-pathways/README.md) + +This page describes how Cal-ITP Benefits defines user flows through the following high-level _phases_: + +1. [Initial setup](#initial-setup) +1. [Identity proofing](#identity-proofing) +1. [Eligibility verification](#eligibility-verification) +1. [Enrollment](#enrollment) + +```mermaid +flowchart LR + start((Start)) + entry[Initial setup] + identity[Identity proofing] + eligibility[Eligibility verification] + enrollment[Enrollment] + complete((End)) + style complete stroke-width:2px + + start --> entry + entry --> identity + identity --> eligibility + eligibility --> enrollment + enrollment --> complete +``` + +The structure of the source code in [`benefits/`](https://github.com/cal-itp/benefits/tree/dev/benefits) +generally follows from these phases: + +- [`benefits/core/`](https://github.com/cal-itp/benefits/tree/dev/benefits/core) implements shared logic and models, and + defines the user's entrypoint into the app +- [`benefits/oauth/`](https://github.com/cal-itp/benefits/tree/dev/benefits/oauth) implements identity proofing +- [`benefits/eligibility/`](https://github.com/cal-itp/benefits/tree/dev/benefits/eligibility) implements eligibility + verification +- [`benefits/enrollment/`](https://github.com/cal-itp/benefits/tree/dev/benefits/enrollment) implements enrollment + +Each of these directories contains a standalone Django app registered in the [settings](../configuration/README.md#django-settings). + +All of the common logic and [database models and migrations](./models-migrations.md) are defined in `benefits.core`, and this +app is imported by the other apps. + +## Django request pipeline + +Each request to the Benefits app is ultimately a [Django request](https://docs.djangoproject.com/en/5.0/ref/request-response/) +and goes through the [Django HTTP request pipeline](https://docs.djangoproject.com/en/5.0/topics/http/). + +Benefits uses middleware to pre- and post-process requests for (view) access control, session configuration, and analytics. +Benefits also uses context processors to enrich the Django template context with data needed for rendering on the front-end. + +!!! example "Key supporting files" + + [`benefits/core/context_processors.py`][core-context-processors] + + [`benefits/core/middleware.py`][core-middleware] + +In general, the flow of a Django request looks like: + +```mermaid +flowchart LR + user((User)) + style user stroke-width:2px + + pre_middleware[Request middleware] + view_middleware[View-specific middleware] + context[Context processors] + view[View function] + post_middleware[Response middleware] + + user -- Request --> pre_middleware + pre_middleware -- Request --> view_middleware + view_middleware -- Request --> context + context -- Request --> view + view -- Response --> post_middleware + post_middleware -- Response --> user +``` + +## Initial setup + +In this phase, the user makes the initial selections that will configure the rest of their journey. + +!!! example "Entrypoint" + + [`benefits/core/views.py`][core-views] + +!!! example "Key supporting files" + + [`benefits/core/models.py`][core-models] + + [`benefits/core/session.py`][core-session] + +```mermaid +flowchart LR + session[(session)] + analytics[[analytics]] + + start((Start)) + pick_agency["`Agency picker + modal`"] + agency(("`Agency + selected`")) + eligibility(("`Eligibility type + selected`")) + next>"`_Next phase_`"] + style next stroke-width:2px + + start -- "1a. Lands on index" --> pick_agency + start -- "1b. Lands on agency index" --> agency + %% invisible links help with diagram layout + start ~~~ session + start ~~~ agency + + pick_agency -- 2. Chooses agency --> agency + agency -- 3. Chooses enrollment pathway --> eligibility + + eligibility -- 4. continue --> next + + agency -. update -.-o session + eligibility -. update -.-o session + eligibility -. selected eligibility verifier -.-o analytics +``` + +Depending upon the choice of enrollment pathway, the _Next phase_ above may be: + +- [Identity proofing](#identity-proofing), for all flows that require user PII (such as for verifying age). +- [Eligibility verification](#eligibility-verification), for Agency card flows that require a physical card from the transit + agency. + +## Identity proofing + +In this phase, Cal-ITP Benefits takes the user through an [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) +flow as a Client (the Relying Party or RP) of the CDT Identity Gateway (the Identity Provider or IDP), via Login.gov. + +The CDT Identity Gateway transforms PII from Login.gov into anonymized boolean claims that are later used in +[eligibility verification](#eligibility-verification). + +!!! example "Entrypoint" + + [`benefits/oauth/views.py`][oauth-views] + +!!! example "Key supporting files" + + [`benefits/oauth/client.py`][oauth-client] + + [`benefits/oauth/redirects.py`][oauth-redirects] + +```mermaid +flowchart LR + session[(session)] + analytics[[analytics]] + + start((Initial setup)) + style start stroke-dasharray: 5 5 + + benefits[Benefits app] + idg[["`CDT + Identity Gateway`"]] + logingov[[Login.gov]] + claims((Claims received)) + + next>"`_Eligibility + verification_`"] + style next stroke-width:2px + + start -- 1. Clicks login button --> benefits + %% invisible links help with diagram layout + start ~~~ session + + benefits -- 2. OIDC authorize_redirect --> idg + benefits -. started sign in -.-o analytics + + idg <-. "3. PII exchange" .-> logingov + idg -- 4. OIDC token authorization --> claims + + claims -- 5. continue --> next + claims -. update .-o session + claims -. finished sign in -.-o analytics +``` + +## Eligibility verification + +In this phase, Cal-ITP Benefits verifies the user's claims using one of two methods: + +- Claims validation, using claims previously stored in the user's session during [Identity proofing](#identity-proofing) +- Eligibility API verification, using non-PII claims provided by the user in an HTML form submission + +!!! example "Entrypoint" + + [`benefits/eligibility/views.py`][eligibility-views] + +!!! example "Key supporting files" + + [`benefits/eligibility/verify.py`][eligibility-verify] + +```mermaid +flowchart LR + session[(session)] + analytics[[analytics]] + + start(("`Previous + phase`")) + style start stroke-dasharray: 5 5 + + claims[Session claims check] + form[HTTP form POST] + server[[Eligibility Verification server]] + eligible{Eligible?} + + next>"`_Enrollment_`"] + style next stroke-width:2px + + stop{{Stop}} + + start -- Eligibility API verification --> form + form -- Eligibility API call --> server + form -. started eligibility -.-o analytics + server --> eligible + + start -- Claims validation --> claims + session -.-o claims + claims --> eligible + claims -. started eligibility -.-o analytics + + eligible -- Yes --> next + eligible -- No --> stop + eligible -. update .-o session + eligible -. returned eligibility -.-o analytics +``` + +## Enrollment + +In this final phase, the user registers their contactless payment card with a concession group configured within the +payment processor (Littlepay). + +**_Cal-ITP Benefits never processes, transmits, nor stores the user's payment card details._** + +!!! example "Entrypoint" + + [`benefits/enrollment/views.py`][enrollment-views] + +!!! example "Supporting packages" + + [`cal-itp/littlepay`][littlepay] + +```mermaid +sequenceDiagram +autonumber +%% Enrollment phase + actor user as User + participant benefits as Benefits app + participant littlepay as Littlepay + participant analytics as Analytics + +user->>benefits: starts enrollment phase + activate user +benefits-->>user: display enrollment index +user->>littlepay: GET tokenization lib (AJAX) +littlepay-->>user: tokenization lib .js +user->>benefits: GET card tokenization access token (AJAX) + deactivate user + activate benefits +benefits->>littlepay: GET API access token +littlepay-->>benefits: access token +benefits->>littlepay: GET card tokenization access token +littlepay-->>benefits: access token +benefits-->>user: access token + deactivate benefits + activate user +user->>user: click to initiate payment card collection +user-->>user: display Littlepay overlay +user-->>analytics: started payment connection +user->>littlepay: provides debit or credit card details +littlepay-->>user: card token +user-->>analytics: closed payment connection +user->>benefits: POST back card token + deactivate user + activate benefits +benefits->>littlepay: GET API access token +littlepay-->>benefits: access token +benefits->>littlepay: GET funding source from card token +littlepay-->>benefits: funding source +benefits->>littlepay: enroll funding source in group +benefits-->>analytics: returned enrollment + deactivate benefits +``` + +[core-context-processors]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/context_processors.py +[core-middleware]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/middleware.py +[core-models]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/models.py +[core-session]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/session.py +[core-views]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/views.py +[eligibility-verify]: https://github.com/cal-itp/benefits/blob/dev/benefits/eligibility/verify.py +[eligibility-views]: https://github.com/cal-itp/benefits/blob/dev/benefits/eligibility/views.py +[enrollment-views]: https://github.com/cal-itp/benefits/blob/dev/benefits/enrollment/views.py +[littlepay]: https://github.com/cal-itp/littlepay +[oauth-client]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/client.py +[oauth-redirects]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/redirects.py +[oauth-views]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/views.py diff --git a/docs/enrollment-pathways/README.md b/docs/enrollment-pathways/README.md index 29fd0b295..a1aed2fe6 100644 --- a/docs/enrollment-pathways/README.md +++ b/docs/enrollment-pathways/README.md @@ -30,16 +30,17 @@ timeline Q2
Now : Deploy low-income riders enrollment pathway : Support for expiring benefits (low-income) - : Benefits admin tool (Agency configuration) : Improved UX for agency card enrollment + : Improved UX for application error states %% : Release enhancements to Veterans pathway Q3
Next + : Benefits admin tool (Agency configuration) : Benefits admin tool (Agency users) : Benefits admin tool (In-person eligibility verification) - : Release Medicare cardholder enrollment pathway Q4
Planned + : Release Medicare cardholder enrollment pathway : Release riders with disabilities enrollment pathway %% Cal-ITP Benefits Epics (2025) @@ -50,8 +51,8 @@ timeline : Implement evolved organizing principles for app UX Q2 - : Support for multiple payment processors - : Integration with all MSA payment processors + : Support for multiple transit processors + : Integration with all MSA transit processors %%{ init: { diff --git a/docs/requirements.txt b/docs/requirements.txt index 655941a89..67c855732 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,5 +2,5 @@ mdx_truly_sane_lists mkdocs==1.6.0 mkdocs-awesome-pages-plugin mkdocs-macros-plugin -mkdocs-material==9.5.22 +mkdocs-material==9.5.26 mkdocs-redirects diff --git a/pyproject.toml b/pyproject.toml index f7470832f..e8bff0969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,23 @@ [project] name = "benefits" -version = "2024.05.1" +version = "2024.06.1" description = "Cal-ITP Benefits is an application that enables automated eligibility verification and enrollment for transit benefits onto customers’ existing contactless bank (credit/debit) cards." readme = "README.md" license = { file = "LICENSE" } classifiers = ["Programming Language :: Python :: 3 :: Only"] requires-python = ">=3.9" dependencies = [ - "Authlib==1.3.0", + "Authlib==1.3.1", "azure-keyvault-secrets==4.8.0", - "azure-identity==1.16.0", + "azure-identity==1.16.1", "Django==5.0.6", "django-csp==3.8", - "django-admin-sortable2==2.1.10", - "django-google-sso==6.2.0", + "django-admin-sortable2==2.2.1", + "django-google-sso==6.2.1", "eligibility-api==2023.9.1", - "calitp-littlepay==2024.4.1", - "requests==2.31.0", - "sentry-sdk==2.1.1", + "calitp-littlepay==2024.6.1", + "requests==2.32.3", + "sentry-sdk==2.5.1", "six==1.16.0", ] diff --git a/terraform/app_service.tf b/terraform/app_service.tf index 1b3679022..029ee3797 100644 --- a/terraform/app_service.tf +++ b/terraform/app_service.tf @@ -11,7 +11,7 @@ resource "azurerm_service_plan" "main" { } locals { - data_mount = "/home/calitp/app/data" + data_mount = "/calitp/app/data" } resource "azurerm_linux_web_app" "main" { @@ -25,10 +25,6 @@ resource "azurerm_linux_web_app" "main" { site_config { ftps_state = "Disabled" vnet_route_all_enabled = true - application_stack { - docker_image = "ghcr.io/cal-itp/benefits" - docker_image_tag = local.env_name - } } identity { @@ -49,14 +45,9 @@ resource "azurerm_linux_web_app" "main" { } app_settings = { - # app setting used solely for refreshing secrets - see https://github.com/MicrosoftDocs/azure-docs/issues/79855#issuecomment-1265664801 - "change_me_to_refresh_secrets" = "change me in the portal to refresh all secrets", - - "DOCKER_ENABLE_CI" = "true", - "DOCKER_REGISTRY_SERVER_URL" = "https://ghcr.io/", - "WEBSITE_HTTPLOGGING_RETENTION_DAYS" = "99999", + "DOCKER_REGISTRY_SERVER_URL" = "https://ghcr.io/" "WEBSITE_TIME_ZONE" = "America/Los_Angeles", - "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = "false", + "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = "true", "WEBSITES_PORT" = "8000", "ANALYTICS_KEY" = local.is_dev ? null : "${local.secret_prefix}analytics-key)", diff --git a/terraform/pipeline/deploy.yml b/terraform/pipeline/deploy.yml index dc1fc1e9e..5e7c34b26 100644 --- a/terraform/pipeline/deploy.yml +++ b/terraform/pipeline/deploy.yml @@ -9,7 +9,7 @@ steps: - task: TerraformInstaller@0 displayName: Install Terraform inputs: - terraformVersion: 1.3.7 + terraformVersion: 1.8.5 # https://github.com/microsoft/azure-pipelines-terraform/tree/main/Tasks/TerraformTask/TerraformTaskV3#readme - task: TerraformTaskV3@3 displayName: Terraform init diff --git a/tests/cypress/package-lock.json b/tests/cypress/package-lock.json index 2ce820a19..90de5d31c 100644 --- a/tests/cypress/package-lock.json +++ b/tests/cypress/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "AGPL-3.0-or-later", "devDependencies": { - "cypress": "^13.9.0" + "cypress": "^13.11.0" } }, "node_modules/@colors/colors": { @@ -537,9 +537,9 @@ } }, "node_modules/cypress": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz", - "integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.11.0.tgz", + "integrity": "sha512-NXXogbAxVlVje4XHX+Cx5eMFZv4Dho/2rIcdBHg9CNPFUGZdM4cRdgIgM7USmNYsC12XY0bZENEQ+KBk72fl+A==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -2358,9 +2358,9 @@ } }, "cypress": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz", - "integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.11.0.tgz", + "integrity": "sha512-NXXogbAxVlVje4XHX+Cx5eMFZv4Dho/2rIcdBHg9CNPFUGZdM4cRdgIgM7USmNYsC12XY0bZENEQ+KBk72fl+A==", "dev": true, "requires": { "@cypress/request": "^3.0.0", diff --git a/tests/cypress/package.json b/tests/cypress/package.json index 64cf2d147..254c8c948 100644 --- a/tests/cypress/package.json +++ b/tests/cypress/package.json @@ -12,6 +12,6 @@ "license": "AGPL-3.0-or-later", "private": true, "devDependencies": { - "cypress": "^13.9.0" + "cypress": "^13.11.0" } } diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index 2dfb4aa48..60e12ef7c 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -100,7 +100,12 @@ def model_AuthProvider_without_verification_no_sign_out(model_AuthProvider): @pytest.fixture def model_EligibilityType(): - eligibility = EligibilityType.objects.create(name="test", label="Test Eligibility Type", group_id="1234") + eligibility = EligibilityType.objects.create( + name="test", + label="Test Eligibility Type", + group_id="1234", + enrollment_success_template="enrollment/success.html", + ) return eligibility @@ -202,7 +207,6 @@ def model_TransitAgency(model_PemData, model_EligibilityType, model_EligibilityV jws_signing_alg="alg", index_template="core/agency-index.html", eligibility_index_template="eligibility/index.html", - enrollment_success_template="enrollment/success.html", ) # add many-to-many relationships after creation, need ID on both sides @@ -270,12 +274,11 @@ def mocked_session_verifier_oauth(mocker, model_EligibilityVerifier_AuthProvider @pytest.fixture -def mocked_session_verifier_auth_required( - mocker, model_EligibilityVerifier_AuthProvider_with_verification, mocked_session_verifier_oauth +def mocked_session_verifier_uses_auth_verification( + model_EligibilityVerifier_AuthProvider_with_verification, mocked_session_verifier_oauth ): - mock_verifier = mocker.Mock(spec=model_EligibilityVerifier_AuthProvider_with_verification) + mock_verifier = model_EligibilityVerifier_AuthProvider_with_verification mock_verifier.name = model_EligibilityVerifier_AuthProvider_with_verification.name - mock_verifier.is_auth_required = True mock_verifier.auth_provider.sign_out_button_template = ( model_EligibilityVerifier_AuthProvider_with_verification.auth_provider.sign_out_button_template ) @@ -287,11 +290,12 @@ def mocked_session_verifier_auth_required( @pytest.fixture -def mocked_session_verifier_auth_not_required(mocked_session_verifier_auth_required): - # mocked_session_verifier_auth_required.return_value is the Mock(spec=model_EligibilityVerifier) from that fixture - mocked_session_verifier_auth_required.return_value.is_auth_required = False - mocked_session_verifier_auth_required.return_value.uses_auth_verification = False - return mocked_session_verifier_auth_required +def mocked_session_verifier_does_not_use_auth_verification( + mocked_session_verifier_uses_auth_verification, model_AuthProvider_without_verification +): + mocked_verifier = mocked_session_verifier_uses_auth_verification + mocked_verifier.auth_provider = model_AuthProvider_without_verification + return mocked_verifier @pytest.fixture diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py index 0d2b8cf6d..63c6ae3e7 100644 --- a/tests/pytest/core/test_models.py +++ b/tests/pytest/core/test_models.py @@ -238,13 +238,22 @@ def test_EligibilityType_supports_expiration(model_EligibilityType_supports_expi @pytest.mark.django_db -def test_EligibilityType_enrollment_index_template(model_EligibilityType): - assert model_EligibilityType.enrollment_index_template == "enrollment/index.html" +def test_EligibilityType_enrollment_index_template(): + new_eligibility_type = EligibilityType.objects.create() - model_EligibilityType.enrollment_index_template = "test/enrollment.html" - model_EligibilityType.save() + assert new_eligibility_type.enrollment_index_template == "enrollment/index.html" - assert model_EligibilityType.enrollment_index_template == "test/enrollment.html" + new_eligibility_type.enrollment_index_template = "test/enrollment.html" + new_eligibility_type.save() + + assert new_eligibility_type.enrollment_index_template == "test/enrollment.html" + + +@pytest.mark.django_db +def test_EligibilityType_enrollment_success_template(): + new_eligibility_type = EligibilityType.objects.create() + + assert new_eligibility_type.enrollment_success_template == "enrollment/success.html" class SampleFormClass: diff --git a/tests/pytest/eligibility/test_verify.py b/tests/pytest/eligibility/test_verify.py index 2e73c496d..37086ea1b 100644 --- a/tests/pytest/eligibility/test_verify.py +++ b/tests/pytest/eligibility/test_verify.py @@ -52,10 +52,12 @@ def test_eligibility_from_api_no_verified_types( @pytest.mark.django_db -def test_eligibility_from_oauth_auth_not_required(mocked_session_verifier_auth_not_required, model_TransitAgency): - # mocked_session_verifier_auth_not_required is Mocked version of the session.verifier() function +def test_eligibility_from_oauth_does_not_use_auth_verification( + mocked_session_verifier_does_not_use_auth_verification, model_TransitAgency +): + # mocked_session_verifier_does_not_use_auth_verification is Mocked version of the session.verifier() function # call it (with a None request) to return a verifier object - verifier = mocked_session_verifier_auth_not_required(None) + verifier = mocked_session_verifier_does_not_use_auth_verification(None) types = eligibility_from_oauth(verifier, "claim", model_TransitAgency) @@ -63,10 +65,10 @@ def test_eligibility_from_oauth_auth_not_required(mocked_session_verifier_auth_n @pytest.mark.django_db -def test_eligibility_from_oauth_auth_claim_mismatch(mocked_session_verifier_auth_required, model_TransitAgency): - # mocked_session_verifier_auth_required is Mocked version of the session.verifier() function +def test_eligibility_from_oauth_auth_claim_mismatch(mocked_session_verifier_uses_auth_verification, model_TransitAgency): + # mocked_session_verifier_uses_auth_verification is Mocked version of the session.verifier() function # call it (with a None request) to return a verifier object - verifier = mocked_session_verifier_auth_required(None) + verifier = mocked_session_verifier_uses_auth_verification(None) verifier.auth_claim = "claim" types = eligibility_from_oauth(verifier, "some_other_claim", model_TransitAgency) @@ -76,11 +78,11 @@ def test_eligibility_from_oauth_auth_claim_mismatch(mocked_session_verifier_auth @pytest.mark.django_db def test_eligibility_from_oauth_auth_claim_match( - mocked_session_verifier_auth_required, model_EligibilityType, model_TransitAgency + mocked_session_verifier_uses_auth_verification, model_EligibilityType, model_TransitAgency ): - # mocked_session_verifier_auth_required is Mocked version of the session.verifier() function + # mocked_session_verifier_uses_auth_verification is Mocked version of the session.verifier() function # call it (with a None request) to return a verifier object - verifier = mocked_session_verifier_auth_required.return_value + verifier = mocked_session_verifier_uses_auth_verification.return_value verifier.auth_provider.claim = "claim" verifier.eligibility_type = model_EligibilityType diff --git a/tests/pytest/eligibility/test_views.py b/tests/pytest/eligibility/test_views.py index 4cc08407e..6c05e2158 100644 --- a/tests/pytest/eligibility/test_views.py +++ b/tests/pytest/eligibility/test_views.py @@ -171,7 +171,9 @@ def test_index_calls_session_logout(client, session_logout_spy): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_agency", "mocked_verifier_selection_form", "mocked_session_verifier_auth_required") +@pytest.mark.usefixtures( + "mocked_session_agency", "mocked_verifier_selection_form", "mocked_session_verifier_uses_auth_verification" +) def test_start_verifier_auth_required_logged_in(mocker, client): mock_session = mocker.patch("benefits.eligibility.views.session") mock_session.logged_in.return_value = True @@ -183,7 +185,9 @@ def test_start_verifier_auth_required_logged_in(mocker, client): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_agency", "mocked_verifier_selection_form", "mocked_session_verifier_auth_required") +@pytest.mark.usefixtures( + "mocked_session_agency", "mocked_verifier_selection_form", "mocked_session_verifier_uses_auth_verification" +) def test_start_verifier_auth_required_not_logged_in(mocker, client): mock_session = mocker.patch("benefits.eligibility.views.session") mock_session.logged_in.return_value = False @@ -196,7 +200,7 @@ def test_start_verifier_auth_required_not_logged_in(mocker, client): @pytest.mark.django_db @pytest.mark.usefixtures( - "mocked_session_agency", "mocked_verifier_selection_form", "mocked_session_verifier_auth_not_required" + "mocked_session_agency", "mocked_verifier_selection_form", "mocked_session_verifier_does_not_use_auth_verification" ) def test_start_verifier_auth_not_required(client): path = reverse(ROUTE_START) @@ -226,7 +230,7 @@ def test_confirm_get_unverified(mocker, client): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility", "mocked_session_verifier_auth_not_required") +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility", "mocked_session_verifier") def test_confirm_get_verified(client, mocked_session_update): path = reverse(ROUTE_CONFIRM) response = client.get(path) diff --git a/tests/pytest/enrollment/test_analytics.py b/tests/pytest/enrollment/test_analytics.py new file mode 100644 index 000000000..d774586a9 --- /dev/null +++ b/tests/pytest/enrollment/test_analytics.py @@ -0,0 +1,10 @@ +import pytest + +from benefits.enrollment.analytics import FailedAccessTokenRequestEvent + + +@pytest.mark.django_db +def test_FailedAccessTokenRequestEvent_sets_status_code(app_request): + event = FailedAccessTokenRequestEvent(app_request, 500) + + assert event.event_properties["status_code"] == 500 diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index dfec565db..625fb5d40 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -1,6 +1,7 @@ import time import pytest +from authlib.integrations.base_client.errors import UnsupportedTokenTypeError from django.urls import reverse from littlepay.api.funding_sources import FundingSourceResponse from requests import HTTPError @@ -12,9 +13,11 @@ ROUTE_INDEX, ROUTE_REENROLLMENT_ERROR, ROUTE_RETRY, + ROUTE_SERVER_ERROR, ROUTE_SUCCESS, + ROUTE_SYSTEM_ERROR, ROUTE_TOKEN, - TEMPLATE_SUCCESS, + TEMPLATE_SYSTEM_ERROR, TEMPLATE_RETRY, ) @@ -34,6 +37,11 @@ def mocked_analytics_module(mocked_analytics_module): return mocked_analytics_module(benefits.enrollment.views) +@pytest.fixture +def mocked_sentry_sdk_module(mocker): + return mocker.patch.object(benefits.enrollment.views, "sentry_sdk") + + @pytest.fixture def mocked_funding_source(): return FundingSourceResponse( @@ -97,6 +105,106 @@ def test_token_valid(mocker, client): assert data["token"] == "enrollment_token" +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +def test_token_http_error_500(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module): + mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) + + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + mock_error = {"message": "Mock error message"} + mock_error_response = mocker.Mock(status_code=500, **mock_error) + mock_error_response.json.return_value = mock_error + mock_client.request_card_tokenization_access.side_effect = HTTPError( + response=mock_error_response, + ) + + path = reverse(ROUTE_TOKEN) + response = client.get(path) + + assert response.status_code == 200 + data = response.json() + assert "token" not in data + assert "redirect" in data + assert data["redirect"] == reverse(ROUTE_SYSTEM_ERROR) + mocked_analytics_module.failed_access_token_request.assert_called_once() + assert 500 in mocked_analytics_module.failed_access_token_request.call_args.args + mocked_sentry_sdk_module.capture_exception.assert_called_once() + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +def test_token_http_error_400(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module): + mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) + + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + mock_error = {"message": "Mock error message"} + mock_error_response = mocker.Mock(status_code=400, **mock_error) + mock_error_response.json.return_value = mock_error + mock_client.request_card_tokenization_access.side_effect = HTTPError( + response=mock_error_response, + ) + + path = reverse(ROUTE_TOKEN) + response = client.get(path) + + assert response.status_code == 200 + data = response.json() + assert "token" not in data + assert "redirect" in data + assert data["redirect"] == reverse(ROUTE_SERVER_ERROR) + mocked_analytics_module.failed_access_token_request.assert_called_once() + assert 400 in mocked_analytics_module.failed_access_token_request.call_args.args + mocked_sentry_sdk_module.capture_exception.assert_called_once() + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +def test_token_misconfigured_client_id(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module): + mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) + + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + mock_client.request_card_tokenization_access.side_effect = UnsupportedTokenTypeError() + + path = reverse(ROUTE_TOKEN) + response = client.get(path) + + assert response.status_code == 200 + data = response.json() + assert "token" not in data + assert "redirect" in data + assert data["redirect"] == reverse(ROUTE_SERVER_ERROR) + mocked_analytics_module.failed_access_token_request.assert_called_once() + mocked_sentry_sdk_module.capture_exception.assert_called_once() + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +def test_token_connection_error(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module): + mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) + + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + mock_client.oauth.ensure_active_token.side_effect = ConnectionError() + + path = reverse(ROUTE_TOKEN) + response = client.get(path) + + assert response.status_code == 200 + data = response.json() + assert "token" not in data + assert "redirect" in data + assert data["redirect"] == reverse(ROUTE_SERVER_ERROR) + mocked_analytics_module.failed_access_token_request.assert_called_once() + mocked_sentry_sdk_module.capture_exception.assert_called_once() + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_get(client, model_EligibilityType): @@ -124,13 +232,48 @@ def test_index_eligible_post_invalid_form(client, invalid_form_data): client.post(path, invalid_form_data) +@pytest.mark.django_db +@pytest.mark.parametrize("status_code", [500, 501, 502, 503, 504]) +@pytest.mark.usefixtures("mocked_session_eligibility") +def test_index_eligible_post_valid_form_http_error_500( + mocker, + client, + mocked_session_agency, + mocked_analytics_module, + mocked_sentry_sdk_module, + card_tokenize_form_data, + status_code, +): + mock_session = mocker.patch("benefits.enrollment.views.session") + mock_session.agency.return_value = mocked_session_agency.return_value + + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + mock_error = {"message": "Mock error message"} + mock_error_response = mocker.Mock(status_code=status_code, **mock_error) + mock_error_response.json.return_value = mock_error + mock_client.link_concession_group_funding_source.side_effect = HTTPError( + response=mock_error_response, + ) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SYSTEM_ERROR + assert {"origin": mocked_session_agency.return_value.index_url} in mock_session.update.call_args + mocked_analytics_module.returned_error.assert_called_once() + mocked_sentry_sdk_module.capture_exception.assert_called_once() + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_http_error(mocker, client, card_tokenize_form_data): +def test_index_eligible_post_valid_form_http_error_400(mocker, client, card_tokenize_form_data): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value - # any status_code that isn't 409 is considered an error + # any 400 level status_code that isn't 409 is considered an error mock_error = {"message": "Mock error message"} mock_error_response = mocker.Mock(status_code=400, **mock_error) mock_error_response.json.return_value = mock_error @@ -174,7 +317,7 @@ def test_index_eligible_post_valid_form_customer_already_enrolled( funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id ) assert response.status_code == 200 - assert response.template_name == TEMPLATE_SUCCESS + assert response.template_name == model_EligibilityType.enrollment_success_template mocked_analytics_module.returned_success.assert_called_once() assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args @@ -195,7 +338,7 @@ def test_index_eligible_post_valid_form_success( funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id ) assert response.status_code == 200 - assert response.template_name == TEMPLATE_SUCCESS + assert response.template_name == model_EligibilityType.enrollment_success_template mocked_analytics_module.returned_success.assert_called_once() assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args @@ -285,39 +428,43 @@ def test_success_no_verifier(client): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") -def test_success_authentication_logged_in(mocker, client, model_TransitAgency): +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") +def test_success_authentication_logged_in(mocker, client, model_TransitAgency, model_EligibilityType): mock_session = mocker.patch("benefits.enrollment.views.session") mock_session.logged_in.return_value = True mock_session.agency.return_value = model_TransitAgency + mock_session.eligibility.return_value = model_EligibilityType path = reverse(ROUTE_SUCCESS) response = client.get(path) assert response.status_code == 200 - assert response.template_name == TEMPLATE_SUCCESS + assert response.template_name == model_EligibilityType.enrollment_success_template assert {"origin": reverse(ROUTE_LOGGED_OUT)} in mock_session.update.call_args @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") -def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency): +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") +def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency, model_EligibilityType): mock_session = mocker.patch("benefits.enrollment.views.session") mock_session.logged_in.return_value = False mock_session.agency.return_value = model_TransitAgency + mock_session.eligibility.return_value = model_EligibilityType path = reverse(ROUTE_SUCCESS) response = client.get(path) assert response.status_code == 200 - assert response.template_name == TEMPLATE_SUCCESS + assert response.template_name == model_EligibilityType.enrollment_success_template @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_auth_not_required") -def test_success_no_authentication(client): +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_does_not_use_auth_verification") +def test_success_no_authentication(mocker, client, model_EligibilityType): + mock_session = mocker.patch("benefits.enrollment.views.session") + mock_session.eligibility.return_value = model_EligibilityType path = reverse(ROUTE_SUCCESS) response = client.get(path) assert response.status_code == 200 - assert response.template_name == TEMPLATE_SUCCESS + assert response.template_name == model_EligibilityType.enrollment_success_template diff --git a/tests/pytest/oauth/test_analytics.py b/tests/pytest/oauth/test_analytics.py index 1d1149282..26f80a1b6 100644 --- a/tests/pytest/oauth/test_analytics.py +++ b/tests/pytest/oauth/test_analytics.py @@ -1,20 +1,13 @@ import pytest -from benefits.oauth.analytics import OAuthEvent +from benefits.oauth.analytics import OAuthEvent, FinishedSignInEvent @pytest.mark.django_db -def test_OAuthEvent_checks_verifier_uses_auth_verification(app_request, mocked_session_verifier_auth_required): - mocked_verifier = mocked_session_verifier_auth_required(app_request) - - OAuthEvent(app_request, "event type") - - mocked_verifier.uses_auth_verification.assert_called_once - - -@pytest.mark.django_db -def test_OAuthEvent_verifier_client_name_when_uses_auth_verification(app_request, mocked_session_verifier_auth_required): - mocked_verifier = mocked_session_verifier_auth_required(app_request) +def test_OAuthEvent_verifier_client_name_when_uses_auth_verification( + app_request, mocked_session_verifier_uses_auth_verification +): + mocked_verifier = mocked_session_verifier_uses_auth_verification(app_request) mocked_verifier.auth_provider.client_name = "ClientName" event = OAuthEvent(app_request, "event type") @@ -24,8 +17,20 @@ def test_OAuthEvent_verifier_client_name_when_uses_auth_verification(app_request @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_not_required") +@pytest.mark.usefixtures("mocked_session_verifier_does_not_use_auth_verification") def test_OAuthEvent_verifier_no_client_name_when_does_not_use_auth_verification(app_request): event = OAuthEvent(app_request, "event type") assert "auth_provider" not in event.event_properties + + +@pytest.mark.django_db +def test_FinishedSignInEvent_with_error(app_request): + event = FinishedSignInEvent(app_request, error=10) + assert event.event_properties["error_code"] == 10 + + +@pytest.mark.django_db +def test_FinishedSignInEvent_without_error(app_request): + event = FinishedSignInEvent(app_request) + assert "error_code" not in event.event_properties diff --git a/tests/pytest/oauth/test_app.py b/tests/pytest/oauth/test_app.py deleted file mode 100644 index 93b1aaeb6..000000000 --- a/tests/pytest/oauth/test_app.py +++ /dev/null @@ -1,23 +0,0 @@ -import benefits -from benefits.oauth.apps import OAuthAppConfig - - -def test_ready_registers_clients(mocker): - mock_registry = mocker.patch("benefits.oauth.client.oauth") - mock_register_providers = mocker.patch("benefits.oauth.client.register_providers") - - app = OAuthAppConfig("oauth", benefits) - app.ready() - - mock_register_providers.assert_called_once_with(mock_registry) - - -def test_ready_register_exception(mocker): - mocker.patch("benefits.oauth.client.oauth") - mocker.patch("benefits.oauth.client.register_providers", side_effect=Exception) - - app = OAuthAppConfig("oauth", benefits) - app.ready() - - # we expect no Exception to be raised - assert app diff --git a/tests/pytest/oauth/test_client.py b/tests/pytest/oauth/test_client.py index 4c6286cd6..3bfe63df8 100644 --- a/tests/pytest/oauth/test_client.py +++ b/tests/pytest/oauth/test_client.py @@ -1,7 +1,7 @@ import pytest from benefits.core.models import AuthProvider -from benefits.oauth.client import _client_kwargs, _server_metadata_url, _authorize_params, register_providers +from benefits.oauth.client import _client_kwargs, _server_metadata_url, _authorize_params, _register_provider, create_client def test_client_kwargs(): @@ -39,33 +39,57 @@ def test_authorize_params_no_scheme(): @pytest.mark.django_db -def test_register_providers(mocker, mocked_oauth_registry): - mock_providers = [] +def test_register_provider(mocker, mocked_oauth_registry): + mocked_client_provider = mocker.Mock(spec=AuthProvider) + mocked_client_provider.client_name = "client_name_1" + mocked_client_provider.client_id = "client_id_1" - for i in range(3): - p = mocker.Mock(spec=AuthProvider) - p.client_name = f"client_name_{i}" - p.client_id = f"client_id_{i}" - mock_providers.append(p) + mocker.patch("benefits.oauth.client._client_kwargs", return_value={"client": "kwargs"}) + mocker.patch("benefits.oauth.client._server_metadata_url", return_value="https://metadata.url") + mocker.patch("benefits.oauth.client._authorize_params", return_value={"scheme": "test_scheme"}) + + _register_provider(mocked_oauth_registry, mocked_client_provider) - mocked_client_provider = mocker.patch("benefits.oauth.client.AuthProvider") - mocked_client_provider.objects.all.return_value = mock_providers + mocked_oauth_registry.register.assert_any_call( + "client_name_1", + client_id="client_id_1", + server_metadata_url="https://metadata.url", + client_kwargs={"client": "kwargs"}, + authorize_params={"scheme": "test_scheme"}, + ) + + +@pytest.mark.django_db +def test_create_client_already_registered(mocker, mocked_oauth_registry): + mocked_client_provider = mocker.Mock(spec=AuthProvider) + mocked_client_provider.client_name = "client_name_1" + mocked_client_provider.client_id = "client_id_1" + + create_client(mocked_oauth_registry, mocked_client_provider) + + mocked_oauth_registry.create_client.assert_any_call("client_name_1") + mocked_oauth_registry.register.assert_not_called() + + +@pytest.mark.django_db +def test_create_client_already_not_registered_yet(mocker, mocked_oauth_registry): + mocked_client_provider = mocker.Mock(spec=AuthProvider) + mocked_client_provider.client_name = "client_name_1" + mocked_client_provider.client_id = "client_id_1" mocker.patch("benefits.oauth.client._client_kwargs", return_value={"client": "kwargs"}) mocker.patch("benefits.oauth.client._server_metadata_url", return_value="https://metadata.url") mocker.patch("benefits.oauth.client._authorize_params", return_value={"scheme": "test_scheme"}) - register_providers(mocked_oauth_registry) - - mocked_client_provider.objects.all.assert_called_once() + mocked_oauth_registry.create_client.return_value = None - for provider in mock_providers: - i = mock_providers.index(provider) + create_client(mocked_oauth_registry, mocked_client_provider) - mocked_oauth_registry.register.assert_any_call( - f"client_name_{i}", - client_id=f"client_id_{i}", - server_metadata_url="https://metadata.url", - client_kwargs={"client": "kwargs"}, - authorize_params={"scheme": "test_scheme"}, - ) + mocked_oauth_registry.create_client.assert_any_call("client_name_1") + mocked_oauth_registry.register.assert_any_call( + "client_name_1", + client_id="client_id_1", + server_metadata_url="https://metadata.url", + client_kwargs={"client": "kwargs"}, + authorize_params={"scheme": "test_scheme"}, + ) diff --git a/tests/pytest/oauth/test_middleware_authverifier_required.py b/tests/pytest/oauth/test_middleware_authverifier_required.py index 5d1440279..6fc479574 100644 --- a/tests/pytest/oauth/test_middleware_authverifier_required.py +++ b/tests/pytest/oauth/test_middleware_authverifier_required.py @@ -21,7 +21,7 @@ def test_authverifier_required_no_verifier(app_request, mocked_view, decorated_v @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_not_required") +@pytest.mark.usefixtures("mocked_session_verifier_does_not_use_auth_verification") def test_authverifier_required_no_authverifier(app_request, mocked_view, decorated_view): response = decorated_view(app_request) diff --git a/tests/pytest/oauth/test_views.py b/tests/pytest/oauth/test_views.py index 0f7493fc0..2aa4b7900 100644 --- a/tests/pytest/oauth/test_views.py +++ b/tests/pytest/oauth/test_views.py @@ -16,7 +16,7 @@ def mocked_analytics_module(mocked_analytics_module): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_login_no_oauth_client(mocked_oauth_create_client, app_request): mocked_oauth_create_client.return_value = None @@ -33,7 +33,9 @@ def test_login_no_session_verifier(app_request): @pytest.mark.django_db -def test_login(mocked_oauth_create_client, mocked_session_verifier_auth_required, mocked_analytics_module, app_request): +def test_login( + mocked_oauth_create_client, mocked_session_verifier_uses_auth_verification, mocked_analytics_module, app_request +): assert not session.logged_in(app_request) mocked_oauth_client = mocked_oauth_create_client.return_value @@ -41,7 +43,7 @@ def test_login(mocked_oauth_create_client, mocked_session_verifier_auth_required login(app_request) - mocked_verifier = mocked_session_verifier_auth_required.return_value + mocked_verifier = mocked_session_verifier_uses_auth_verification.return_value mocked_oauth_create_client.assert_called_once_with(mocked_verifier.auth_provider.client_name) mocked_oauth_client.authorize_redirect.assert_called_with(app_request, "https://testserver/oauth/authorize") mocked_analytics_module.started_sign_in.assert_called_once() @@ -49,7 +51,7 @@ def test_login(mocked_oauth_create_client, mocked_session_verifier_auth_required @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_authorize_no_oauth_client(mocked_oauth_create_client, app_request): mocked_oauth_create_client.return_value = None @@ -66,7 +68,7 @@ def test_authorize_no_session_verifier(app_request): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_authorize_fail(mocked_oauth_create_client, app_request): mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_access_token.return_value = None @@ -82,7 +84,7 @@ def test_authorize_fail(mocked_oauth_create_client, app_request): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_authorize_success(mocked_oauth_create_client, mocked_analytics_module, app_request): mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token"} @@ -99,14 +101,13 @@ def test_authorize_success(mocked_oauth_create_client, mocked_analytics_module, @pytest.mark.django_db @pytest.mark.usefixtures("mocked_analytics_module") -@pytest.mark.parametrize("flag", ["true", "True", "tRuE"]) def test_authorize_success_with_claim_true( - mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request, flag + mocked_session_verifier_uses_auth_verification, mocked_oauth_create_client, app_request ): - verifier = mocked_session_verifier_auth_required.return_value + verifier = mocked_session_verifier_uses_auth_verification.return_value verifier.auth_provider.claim = "claim" mocked_oauth_client = mocked_oauth_create_client.return_value - mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": flag}} + mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": "1"}} result = authorize(app_request) @@ -118,14 +119,15 @@ def test_authorize_success_with_claim_true( @pytest.mark.django_db @pytest.mark.usefixtures("mocked_analytics_module") -@pytest.mark.parametrize("flag", ["false", "False", "fAlSe"]) def test_authorize_success_with_claim_false( - mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request, flag + mocked_session_verifier_uses_auth_verification, + mocked_oauth_create_client, + app_request, ): - verifier = mocked_session_verifier_auth_required.return_value + verifier = mocked_session_verifier_uses_auth_verification.return_value verifier.auth_provider.claim = "claim" mocked_oauth_client = mocked_oauth_create_client.return_value - mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": flag}} + mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": "0"}} result = authorize(app_request) @@ -135,22 +137,42 @@ def test_authorize_success_with_claim_false( assert result.url == reverse(ROUTE_CONFIRM) +@pytest.mark.django_db +def test_authorize_success_with_claim_error( + mocked_session_verifier_uses_auth_verification, + mocked_oauth_create_client, + mocked_analytics_module, + app_request, +): + verifier = mocked_session_verifier_uses_auth_verification.return_value + verifier.auth_provider.claim = "claim" + mocked_oauth_client = mocked_oauth_create_client.return_value + mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": "10"}} + + result = authorize(app_request) + + mocked_oauth_client.authorize_access_token.assert_called_with(app_request) + mocked_analytics_module.finished_sign_in.assert_called_with(app_request, error=10) + assert session.oauth_claim(app_request) is None + assert result.status_code == 302 + assert result.url == reverse(ROUTE_CONFIRM) + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_analytics_module") def test_authorize_success_without_verifier_claim( - mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request + mocked_session_verifier_uses_auth_verification, mocked_oauth_create_client, app_request ): - verifier = mocked_session_verifier_auth_required.return_value + verifier = mocked_session_verifier_uses_auth_verification.return_value verifier.auth_provider.claim = "" mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": "True"}} result = authorize(app_request) - mocked_oauth_client.authorize_access_token.assert_called_with(app_request) assert session.oauth_claim(app_request) is None - assert result.status_code == 302 - assert result.url == reverse(ROUTE_CONFIRM) + assert result.status_code == 200 + assert result.template_name == TEMPLATE_USER_ERROR @pytest.mark.django_db @@ -163,9 +185,9 @@ def test_authorize_success_without_verifier_claim( ], ) def test_authorize_success_without_claim_in_response( - mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request, access_token_response + mocked_session_verifier_uses_auth_verification, mocked_oauth_create_client, app_request, access_token_response ): - verifier = mocked_session_verifier_auth_required.return_value + verifier = mocked_session_verifier_uses_auth_verification.return_value verifier.auth_provider.claim = "claim" mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_access_token.return_value = access_token_response @@ -179,7 +201,7 @@ def test_authorize_success_without_claim_in_response( @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_cancel(mocked_analytics_module, app_request): unverified_route = reverse(ROUTE_UNVERIFIED) @@ -199,7 +221,7 @@ def test_cancel_no_session_verifier(app_request): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_logout_no_oauth_client(mocked_oauth_create_client, app_request): mocked_oauth_create_client.return_value = None @@ -216,7 +238,7 @@ def test_logout_no_session_verifier(app_request): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_logout(mocker, mocked_oauth_create_client, mocked_analytics_module, app_request): # logout internally calls deauthorize_redirect # this mocks that function and a success response @@ -243,7 +265,7 @@ def test_logout(mocker, mocked_oauth_create_client, mocked_analytics_module, app @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +@pytest.mark.usefixtures("mocked_session_verifier_uses_auth_verification") def test_post_logout(app_request, mocked_analytics_module): origin = reverse(ROUTE_INDEX) session.update(app_request, origin=origin)