diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9063a1f42..581766f1b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,11 @@ FROM benefits_client:latest +# install Azure CLI +# https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt +USER root +RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash +USER $USER + # install devcontainer requirements RUN pip install -e .[dev,test] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db78bfbcb..7cb87f035 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "service": "dev", "runServices": ["dev", "docs", "server"], "workspaceFolder": "/home/calitp/app", - "postStartCommand": ["/bin/bash", "bin/init.sh"], + "postStartCommand": ["/bin/bash", "bin/reset_db.sh"], "postAttachCommand": ["/bin/bash", ".devcontainer/postAttach.sh"], "customizations": { "vscode": { diff --git a/.env.sample b/.env.sample new file mode 100644 index 000000000..b4c167aee --- /dev/null +++ b/.env.sample @@ -0,0 +1,25 @@ +DJANGO_SUPERUSER_USERNAME=benefits-admin +DJANGO_SUPERUSER_EMAIL=benefits-admin@calitp.org +DJANGO_SUPERUSER_PASSWORD=superuser12345! + +DJANGO_DB_RESET=true +DJANGO_DB_DIR=. +DJANGO_DB_FILE=django.db +DJANGO_DB_FIXTURES="benefits/core/migrations/local_fixtures.json" + +auth_provider_client_id=benefits-oauth-client-id +courtesy_card_verifier_api_auth_key=server-auth-token +mobility_pass_verifier_api_auth_key=server-auth-token +client_private_key='-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1pt0ZoOuPEVPJJS+5r884zcjZLkZZ2GcPwr79XOLDbOi46on\nCa79kjRnhS0VUK96SwUPS0z9J5mDA5LSNL2RoxFb5QGaevnJY828NupzTNdUd0sY\nJK3kRjKUggHWuB55hwJcH/Dx7I3DNH4NL68UAlK+VjwJkfYPrhq/bl5z8ZiurvBa\n5C1mDxhFpcTZlCfxQoas7D1d+uPACF6mEMbQNd3RaIaSREO50NvNywXIIt/OmCiR\nqI7JtOcn4eyh1I4j9WtlbMhRJLfwPMAgY5epTsWcURmhVofF2wVoFbib3JGCfA7t\nz/gmP5YoEKnf/cumKmF3e9LrZb8zwm7bTHUViwIDAQABAoIBAQCIv0XMjNvZS9DC\nXoXGQtVpcxj6dXfaiDgnc7hZDubsNCr3JtT5NqgdIYdVNQUABNDIPNEiCkzFjuwM\nuuF2+dRzM/x6UCs/cSsCjXYBCCOwMwV/fjpEJQnwMQqwTLulVsXZYYeSUtXVBf/8\n0tVULRty34apLFhsyX30UtboXQdESfpmm5ZsqsZJlYljw+M7JxRMneQclI19y/ya\nhPWlfhLB9OffVEJXGaWx1NSYnKoCMKqE/+4krROr6V62xXaNyX6WtU6XiT7C6R5A\nPBxfhmoeFdVCF6a+Qq0v2fKThYoZnV4sn2q2An9YPfynFYnlgzdfnAFSejsqxQd0\nfxYLOtMBAoGBAP1jxjHDJngZ1N+ymw9MIpRgr3HeuMP5phiSTbY2tu9lPzQd+TMX\nfhr1bQh2Fd/vU0u7X0yPnTWtUrLlCdGnWPpXivx95GNGgUUIk2HStFdrRx+f2Qvk\nG8vtLgmSbjQ26UiHzxi9Wa0a41PWIA3TixkcFrS2X29Qc4yd6pVHmicfAoGBANjR\nZ8aaDkSKLkq5Nk1T7I0E1+mtPoH1tPV/FJClXjJrvfDuYHBeOyUpipZddnZuPGWA\nIW2tFIsMgJQtgpvgs52NFI7pQGJRUPK/fTG+Ycocxo78TkLr/RIj8Kj5brXsbZ9P\n3/WBX5GAISTSp1ab8xVgK/Tm07hGupKVqnY2lCAVAoGAIql0YjhE2ecGtLcU+Qm8\nLTnwpg4GjmBnNTNGSCfB7IuYEsQK489R49Qw3xhwM5rkdRajmbCHm+Eiz+/+4NwY\nkt5I1/NMu7vYUR40MwyEuPSm3Q+bvEGu/71pL8wFIUVlshNJ5CN60fA8qqo+5kVK\n4Ntzy7Kq6WpC9Dhh75vE3ZcCgYEAty99uXtxsJD6+aEwcvcENkUwUztPQ6ggAwci\nje9Z/cmwCj6s9mN3HzfQ4qgGrZsHpk4ycCK655xhilBFOIQJ3YRUKUaDYk4H0YDe\nOsf6gTP8wtQDH2GZSNlavLk5w7UFDYQD2b47y4fw+NaOEYvjPl0p5lmb6ebAPZb8\nFbKZRd0CgYBC1HTbA+zMEqDdY4MWJJLC6jZsjdxOGhzjrCtWcIWEGMDF7oDDEoix\nW3j2hwm4C6vaNkH9XX1dr5+q6gq8vJQdbYoExl22BGMiNbfI3+sLRk0zBYL//W6c\ntSREgR4EjosqQfbkceLJ2JT1wuNjInI0eR9H3cRugvlDTeWtbdJ5qA==\n-----END RSA PRIVATE KEY-----' +client_public_key='-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1pt0ZoOuPEVPJJS+5r88\n4zcjZLkZZ2GcPwr79XOLDbOi46onCa79kjRnhS0VUK96SwUPS0z9J5mDA5LSNL2R\noxFb5QGaevnJY828NupzTNdUd0sYJK3kRjKUggHWuB55hwJcH/Dx7I3DNH4NL68U\nAlK+VjwJkfYPrhq/bl5z8ZiurvBa5C1mDxhFpcTZlCfxQoas7D1d+uPACF6mEMbQ\nNd3RaIaSREO50NvNywXIIt/OmCiRqI7JtOcn4eyh1I4j9WtlbMhRJLfwPMAgY5ep\nTsWcURmhVofF2wVoFbib3JGCfA7tz/gmP5YoEKnf/cumKmF3e9LrZb8zwm7bTHUV\niwIDAQAB\n-----END PUBLIC KEY-----' +mst_payment_processor_client_cert='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' +mst_payment_processor_client_cert_private_key='-----BEGIN RSA PRIVATE KEY-----\nPEM DATA\n-----END RSA PRIVATE KEY-----' +mst_payment_processor_client_cert_root_ca='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' +sacrt_payment_processor_client_cert='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' +sacrt_payment_processor_client_cert_private_key='-----BEGIN RSA PRIVATE KEY-----\nPEM DATA\n-----END RSA PRIVATE KEY-----' +sacrt_payment_processor_client_cert_root_ca='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' +sbmtd_payment_processor_client_cert='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' +sbmtd_payment_processor_client_cert_private_key='-----BEGIN RSA PRIVATE KEY-----\nPEM DATA\n-----END RSA PRIVATE KEY-----' +sbmtd_payment_processor_client_cert_root_ca='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' + +testsecret="Hello from the local environment!" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 96c61c651..20ede63b0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,15 @@ updates: include: "scope" labels: - "dependencies" + - package-ecosystem: "pip" + directory: "/docs" # requirements.txt + schedule: + interval: "daily" + commit-message: + prefix: "chore" + include: "scope" + labels: + - "dependencies" - package-ecosystem: "github-actions" # Workflow files stored in the # default location of `.github/workflows` diff --git a/.github/workflows/check-api.yml b/.github/workflows/check-api.yml index a5361e6fd..5487bb8a4 100644 --- a/.github/workflows/check-api.yml +++ b/.github/workflows/check-api.yml @@ -2,81 +2,39 @@ name: Check access to API on: workflow_dispatch: - inputs: - environment: - type: choice - description: Select the API environment - options: [all, prod, qa] schedule: - cron: "0 12 * * *" jobs: check-api: runs-on: ubuntu-latest - env: - SHOULD_RUN: | - ${{ github.event_name == 'schedule' - || github.event.inputs.environment == 'all' - || github.event.inputs.environment == matrix.name - }} strategy: fail-fast: false matrix: - include: - - name: prod - cert: API_CHECK_PROD_CERT - key: API_CHECK_PROD_KEY - ca-cert: API_CHECK_PROD_CA_CERT - url: API_CHECK_PROD_URL - data: API_CHECK_PROD_DATA - - - name: qa - cert: API_CHECK_QA_CERT - key: API_CHECK_QA_KEY - ca-cert: API_CHECK_QA_CA_CERT - url: API_CHECK_QA_URL - data: API_CHECK_QA_DATA - - name: Check API endpoint (${{ matrix.name }}) + participant: [mst, sacrt, sbmtd] + env: [qa, prod] steps: - - name: Echo workflow run information - run: | - echo "Triggering event name: ${{ github.event_name }}, \ - APIs to check: ${{ github.event.inputs.environment }}" + - uses: actions/checkout@v4 + with: + repository: "cal-itp/littlepay" - - name: Decode cert files - if: contains(env.SHOULD_RUN, 'true') + - name: Install the littlepay library run: | - mkdir $RUNNER_TEMP/${{ matrix.name }} - temp_dir=$RUNNER_TEMP/${{ matrix.name }} - - cat > $temp_dir/cert.pem <<- EOM - ${{ secrets[matrix.cert] }} - EOM - - cat > $temp_dir/key.pem <<- EOM - ${{ secrets[matrix.key] }} - EOM + python3 -m pip install --upgrade pip + pip install -e . - cat > $temp_dir/cacert.ca <<- EOM - ${{ secrets[matrix.ca-cert] }} + - name: Create config file and set config + run: | + cat > config.yaml <<- EOM + ${{ secrets.API_CHECK_CONFIG }} EOM + littlepay config config.yaml - - name: Call API endpoint - if: contains(env.SHOULD_RUN, 'true') + - name: Run littlepay to get access token run: | - temp_dir=$RUNNER_TEMP/${{ matrix.name }} - curl -i --url ${{ secrets[matrix.url] }} \ - --header 'Accept: application/json' \ - --header 'Content-type: application/json' \ - --data '${{ secrets[matrix.data] }}' \ - --cert $temp_dir/cert.pem \ - --key $temp_dir/key.pem \ - --cacert $temp_dir/cacert.ca > $temp_dir/payload.txt - - test $(head -n 1 $temp_dir/payload.txt | grep -o 201) + littlepay switch env ${{ matrix.env }} + littlepay switch participant ${{ matrix.participant }} - # https://www.ravsam.in/blog/send-slack-notification-when-github-actions-fails/#using-notify-slack-action - name: Report failure to Slack if: always() uses: ravsamhq/notify-slack-action@v2 diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 248332173..f08636847 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -59,8 +59,17 @@ jobs: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PREVIEW_APP_SITE_ID }} + - name: Find existing comment + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.number }} + comment-author: "github-actions[bot]" + body-includes: "Preview url: https://" + - name: Add Netlify link PR comment uses: actions/github-script@v7 + if: steps.find-comment.outputs.comment-id == '' with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/tests-cypress.yml b/.github/workflows/tests-cypress.yml index 17420a0c0..ee8bfb7ba 100644 --- a/.github/workflows/tests-cypress.yml +++ b/.github/workflows/tests-cypress.yml @@ -15,8 +15,9 @@ jobs: - name: Start app run: | - touch .env - docker compose up --detach client server + cp .env.sample .env + docker compose up --detach server + docker compose run --detach --service-ports client bin/test_start.sh - name: Run Cypress tests uses: cypress-io/github-action@v6 diff --git a/.github/workflows/tests-ui.yml b/.github/workflows/tests-ui.yml index 98bcf58c5..f7020e1be 100644 --- a/.github/workflows/tests-ui.yml +++ b/.github/workflows/tests-ui.yml @@ -18,11 +18,11 @@ jobs: - name: Start app run: | - touch .env + cp .env.sample .env docker compose up --detach client - name: Run Lighthouse tests for a11y - uses: treosh/lighthouse-ci-action@10.1.0 + uses: treosh/lighthouse-ci-action@11.4.0 with: urls: | http://localhost:8000 diff --git a/.gitignore b/.gitignore index 46b0f2ec3..228504a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.db *.env +*fixtures.json +!benefits/core/migrations/local_fixtures.json *.mo *.tfbackend *.tmp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 054f7687d..dd5fe4de9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: args: ["--maxkb=1500"] - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.2.0 hooks: - id: black types: diff --git a/benefits/core/admin.py b/benefits/core/admin.py index c6744e380..cb468b629 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -2,22 +2,47 @@ The core application: Admin interface configuration. """ +import logging +import requests + from django.conf import settings +from django.contrib import admin +from . import models + +logger = logging.getLogger(__name__) + + +GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + +for model in [ + models.EligibilityType, + models.EligibilityVerifier, + models.PaymentProcessor, + models.PemData, + models.TransitAgency, +]: + logger.debug(f"Register {model.__name__}") + admin.site.register(model) -if settings.ADMIN: - import logging - from django.contrib import admin - from . import models +def pre_login_user(user, request): + logger.debug(f"Running pre-login callback for user: {user.username}") + token = request.session.get("google_sso_access_token") + if token: + headers = { + "Authorization": f"Bearer {token}", + } - logger = logging.getLogger(__name__) + # Request Google user info to get name and email + response = requests.get(GOOGLE_USER_INFO_URL, headers=headers, timeout=settings.REQUESTS_TIMEOUT) + user_data = response.json() + logger.debug(f"Updating user data from Google for user with email: {user_data['email']}") - for model in [ - models.EligibilityType, - models.EligibilityVerifier, - models.PaymentProcessor, - models.PemData, - models.TransitAgency, - ]: - logger.debug(f"Register {model.__name__}") - admin.site.register(model) + user.first_name = user_data["given_name"] + user.last_name = user_data["family_name"] + user.username = user_data["email"] + user.email = user_data["email"] + user.save() + else: + logger.warning("google_sso_access_token not found in session.") diff --git a/benefits/core/migrations/0001_initial.py b/benefits/core/migrations/0001_initial.py index ad482b829..1d7ec7edc 100644 --- a/benefits/core/migrations/0001_initial.py +++ b/benefits/core/migrations/0001_initial.py @@ -1,10 +1,13 @@ -# Generated by Django 4.2.4 on 2023-08-16 15:06 +# Generated by Django 5.0.1 on 2024-02-06 18:09 -from django.db import migrations, models +import benefits.core.models +import benefits.secrets import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [] @@ -17,7 +20,10 @@ class Migration(migrations.Migration): ("sign_out_button_template", models.TextField(null=True)), ("sign_out_link_template", models.TextField(null=True)), ("client_name", models.TextField()), - ("client_id", models.TextField()), + ( + "client_id_secret_name", + benefits.core.models.SecretNameField(max_length=127, validators=[benefits.secrets.SecretNameValidator()]), + ), ("authority", models.TextField()), ("scope", models.TextField(null=True)), ("claim", models.TextField(null=True)), @@ -41,7 +47,12 @@ class Migration(migrations.Migration): ("active", models.BooleanField(default=False)), ("api_url", models.TextField(null=True)), ("api_auth_header", models.TextField(null=True)), - ("api_auth_key", models.TextField(null=True)), + ( + "api_auth_key_secret_name", + benefits.core.models.SecretNameField( + max_length=127, null=True, validators=[benefits.secrets.SecretNameValidator()] + ), + ), ("jwe_cek_enc", models.TextField(null=True)), ("jwe_encryption_alg", models.TextField(null=True)), ("jws_signing_alg", models.TextField(null=True)), @@ -80,8 +91,13 @@ class Migration(migrations.Migration): fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), ("label", models.TextField()), - ("text", models.TextField(null=True)), ("remote_url", models.TextField(null=True)), + ( + "text_secret_name", + benefits.core.models.SecretNameField( + max_length=127, null=True, validators=[benefits.secrets.SecretNameValidator()] + ), + ), ], ), migrations.CreateModel( diff --git a/benefits/core/migrations/0002_data.py b/benefits/core/migrations/0002_data.py deleted file mode 100644 index c1807c2ea..000000000 --- a/benefits/core/migrations/0002_data.py +++ /dev/null @@ -1,383 +0,0 @@ -"""Data migration which loads configuration data for Benefits. -""" - -import json -import os - -from django.db import migrations - - -def load_data(app, *args, **kwargs): - EligibilityType = app.get_model("core", "EligibilityType") - - mst_senior_type = EligibilityType.objects.create( - name="senior", label="Senior Discount (MST)", group_id=os.environ.get("MST_SENIOR_GROUP_ID", "group1") - ) - mst_veteran_type = EligibilityType.objects.create( - name="veteran", - label="Veteran Discount (MST)", - group_id=os.environ.get("MST_VETERAN_GROUP_ID", "group3"), - ) - mst_courtesy_card_type = EligibilityType.objects.create( - name="courtesy_card", - label="Courtesy Card Discount (MST)", - group_id=os.environ.get("MST_COURTESY_CARD_GROUP_ID", "group2"), - ) - sacrt_senior_type = EligibilityType.objects.create( - name="senior", label="Senior Discount (SacRT)", group_id=os.environ.get("SACRT_SENIOR_GROUP_ID", "group3") - ) - sbmtd_senior_type = EligibilityType.objects.create( - name="senior", label="Senior Discount (SBMTD)", group_id=os.environ.get("SBMTD_SENIOR_GROUP_ID", "group4") - ) - sbmtd_mobility_pass_type = EligibilityType.objects.create( - name="mobility_pass", - label="Mobility Pass Discount (SBMTD)", - group_id=os.environ.get("SBMTD_MOBILITY_PASS_GROUP_ID", "group5"), - ) - - PemData = app.get_model("core", "PemData") - - mst_server_public_key = PemData.objects.create( - label="Eligibility server public key", - remote_url=os.environ.get( - "MST_SERVER_PUBLIC_KEY_URL", "https://raw.githubusercontent.com/cal-itp/eligibility-server/main/keys/server.pub" - ), - ) - - sbmtd_server_public_key = PemData.objects.create( - label="Eligibility server public key", - remote_url=os.environ.get( - "SBMTD_SERVER_PUBLIC_KEY_URL", "https://raw.githubusercontent.com/cal-itp/eligibility-server/main/keys/server.pub" - ), - ) - - default_client_private_key = """ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA1pt0ZoOuPEVPJJS+5r884zcjZLkZZ2GcPwr79XOLDbOi46on -Ca79kjRnhS0VUK96SwUPS0z9J5mDA5LSNL2RoxFb5QGaevnJY828NupzTNdUd0sY -JK3kRjKUggHWuB55hwJcH/Dx7I3DNH4NL68UAlK+VjwJkfYPrhq/bl5z8ZiurvBa -5C1mDxhFpcTZlCfxQoas7D1d+uPACF6mEMbQNd3RaIaSREO50NvNywXIIt/OmCiR -qI7JtOcn4eyh1I4j9WtlbMhRJLfwPMAgY5epTsWcURmhVofF2wVoFbib3JGCfA7t -z/gmP5YoEKnf/cumKmF3e9LrZb8zwm7bTHUViwIDAQABAoIBAQCIv0XMjNvZS9DC -XoXGQtVpcxj6dXfaiDgnc7hZDubsNCr3JtT5NqgdIYdVNQUABNDIPNEiCkzFjuwM -uuF2+dRzM/x6UCs/cSsCjXYBCCOwMwV/fjpEJQnwMQqwTLulVsXZYYeSUtXVBf/8 -0tVULRty34apLFhsyX30UtboXQdESfpmm5ZsqsZJlYljw+M7JxRMneQclI19y/ya -hPWlfhLB9OffVEJXGaWx1NSYnKoCMKqE/+4krROr6V62xXaNyX6WtU6XiT7C6R5A -PBxfhmoeFdVCF6a+Qq0v2fKThYoZnV4sn2q2An9YPfynFYnlgzdfnAFSejsqxQd0 -fxYLOtMBAoGBAP1jxjHDJngZ1N+ymw9MIpRgr3HeuMP5phiSTbY2tu9lPzQd+TMX -fhr1bQh2Fd/vU0u7X0yPnTWtUrLlCdGnWPpXivx95GNGgUUIk2HStFdrRx+f2Qvk -G8vtLgmSbjQ26UiHzxi9Wa0a41PWIA3TixkcFrS2X29Qc4yd6pVHmicfAoGBANjR -Z8aaDkSKLkq5Nk1T7I0E1+mtPoH1tPV/FJClXjJrvfDuYHBeOyUpipZddnZuPGWA -IW2tFIsMgJQtgpvgs52NFI7pQGJRUPK/fTG+Ycocxo78TkLr/RIj8Kj5brXsbZ9P -3/WBX5GAISTSp1ab8xVgK/Tm07hGupKVqnY2lCAVAoGAIql0YjhE2ecGtLcU+Qm8 -LTnwpg4GjmBnNTNGSCfB7IuYEsQK489R49Qw3xhwM5rkdRajmbCHm+Eiz+/+4NwY -kt5I1/NMu7vYUR40MwyEuPSm3Q+bvEGu/71pL8wFIUVlshNJ5CN60fA8qqo+5kVK -4Ntzy7Kq6WpC9Dhh75vE3ZcCgYEAty99uXtxsJD6+aEwcvcENkUwUztPQ6ggAwci -je9Z/cmwCj6s9mN3HzfQ4qgGrZsHpk4ycCK655xhilBFOIQJ3YRUKUaDYk4H0YDe -Osf6gTP8wtQDH2GZSNlavLk5w7UFDYQD2b47y4fw+NaOEYvjPl0p5lmb6ebAPZb8 -FbKZRd0CgYBC1HTbA+zMEqDdY4MWJJLC6jZsjdxOGhzjrCtWcIWEGMDF7oDDEoix -W3j2hwm4C6vaNkH9XX1dr5+q6gq8vJQdbYoExl22BGMiNbfI3+sLRk0zBYL//W6c -tSREgR4EjosqQfbkceLJ2JT1wuNjInI0eR9H3cRugvlDTeWtbdJ5qA== ------END RSA PRIVATE KEY----- -""" - - client_private_key = PemData.objects.create( - text=os.environ.get("CLIENT_PRIVATE_KEY", default_client_private_key), - label="Benefits client private key", - ) - - default_client_public_key = """ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1pt0ZoOuPEVPJJS+5r88 -4zcjZLkZZ2GcPwr79XOLDbOi46onCa79kjRnhS0VUK96SwUPS0z9J5mDA5LSNL2R -oxFb5QGaevnJY828NupzTNdUd0sYJK3kRjKUggHWuB55hwJcH/Dx7I3DNH4NL68U -AlK+VjwJkfYPrhq/bl5z8ZiurvBa5C1mDxhFpcTZlCfxQoas7D1d+uPACF6mEMbQ -Nd3RaIaSREO50NvNywXIIt/OmCiRqI7JtOcn4eyh1I4j9WtlbMhRJLfwPMAgY5ep -TsWcURmhVofF2wVoFbib3JGCfA7tz/gmP5YoEKnf/cumKmF3e9LrZb8zwm7bTHUV -iwIDAQAB ------END PUBLIC KEY----- -""" - - client_public_key = PemData.objects.create( - text=os.environ.get("CLIENT_PUBLIC_KEY", default_client_public_key), - label="Benefits client public key", - ) - - dummy_cert_text = """ ------BEGIN CERTIFICATE----- -PEM DATA ------END CERTIFICATE----- -""" - - mst_payment_processor_client_cert = PemData.objects.create( - text=os.environ.get("MST_PAYMENT_PROCESSOR_CLIENT_CERT", dummy_cert_text), - label="MST payment processor client certificate", - ) - - mst_payment_processor_client_cert_private_key = PemData.objects.create( - text=os.environ.get("MST_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY", client_private_key.text), - label="MST payment processor client certificate private key", - ) - - mst_payment_processor_client_cert_root_ca = PemData.objects.create( - text=os.environ.get("MST_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA", dummy_cert_text), - label="MST payment processor client certificate root CA", - ) - - sacrt_payment_processor_client_cert = PemData.objects.create( - text=os.environ.get("SACRT_PAYMENT_PROCESSOR_CLIENT_CERT", dummy_cert_text), - label="SacRT payment processor client certificate", - ) - - sacrt_payment_processor_client_cert_private_key = PemData.objects.create( - text=os.environ.get("SACRT_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY", client_private_key.text), - label="SacRT payment processor client certificate private key", - ) - - sacrt_payment_processor_client_cert_root_ca = PemData.objects.create( - text=os.environ.get("SACRT_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA", dummy_cert_text), - label="SacRT payment processor client certificate root CA", - ) - - sbmtd_payment_processor_client_cert = PemData.objects.create( - text=os.environ.get("SBMTD_PAYMENT_PROCESSOR_CLIENT_CERT", dummy_cert_text), - label="SBMTD payment processor client certificate", - ) - - sbmtd_payment_processor_client_cert_private_key = PemData.objects.create( - text=os.environ.get("SBMTD_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY", client_private_key.text), - label="SBMTD payment processor client certificate private key", - ) - - sbmtd_payment_processor_client_cert_root_ca = PemData.objects.create( - text=os.environ.get("SBMTD_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA", dummy_cert_text), - label="SBMTD payment processor client certificate root CA", - ) - - AuthProvider = app.get_model("core", "AuthProvider") - - senior_auth_provider = AuthProvider.objects.create( - sign_out_button_template="core/includes/button--sign-out--login-gov.html", - sign_out_link_template="core/includes/link--sign-out--login-gov.html", - client_name=os.environ.get("SENIOR_AUTH_PROVIDER_CLIENT_NAME", "senior-benefits-oauth-client-name"), - client_id=os.environ.get("AUTH_PROVIDER_CLIENT_ID", "benefits-oauth-client-id"), - authority=os.environ.get("AUTH_PROVIDER_AUTHORITY", "https://example.com"), - scope=os.environ.get("SENIOR_AUTH_PROVIDER_SCOPE", "verify:senior"), - claim=os.environ.get("SENIOR_AUTH_PROVIDER_CLAIM", "senior"), - scheme=os.environ.get("SENIOR_AUTH_PROVIDER_SCHEME", "dev-cal-itp_benefits"), - ) - - veteran_auth_provider = AuthProvider.objects.create( - sign_out_button_template="core/includes/button--sign-out--login-gov.html", - sign_out_link_template="core/includes/link--sign-out--login-gov.html", - client_name=os.environ.get("VETERAN_AUTH_PROVIDER_CLIENT_NAME", "veteran-benefits-oauth-client-name"), - client_id=os.environ.get("AUTH_PROVIDER_CLIENT_ID", "benefits-oauth-client-id"), - authority=os.environ.get("AUTH_PROVIDER_AUTHORITY", "https://example.com"), - scope=os.environ.get("VETERAN_AUTH_PROVIDER_SCOPE", "verify:veteran"), - claim=os.environ.get("VETERAN_AUTH_PROVIDER_CLAIM", "veteran"), - scheme=os.environ.get("VETERAN_AUTH_PROVIDER_SCHEME", "vagov"), - ) - - EligibilityVerifier = app.get_model("core", "EligibilityVerifier") - - mst_senior_verifier = EligibilityVerifier.objects.create( - name=os.environ.get("MST_SENIOR_VERIFIER_NAME", "OAuth claims via Login.gov (MST)"), - active=os.environ.get("MST_SENIOR_VERIFIER_ACTIVE", "True").lower() == "true", - eligibility_type=mst_senior_type, - auth_provider=senior_auth_provider, - selection_label_template="eligibility/includes/selection-label--senior.html", - start_template="eligibility/start--senior.html", - ) - - mst_veteran_verifier = EligibilityVerifier.objects.create( - name=os.environ.get("MST_VETERAN_VERIFIER_NAME", "VA.gov - Veteran (MST)"), - active=os.environ.get("MST_VETERAN_VERIFIER_ACTIVE", "True").lower() == "true", - eligibility_type=mst_veteran_type, - auth_provider=veteran_auth_provider, - selection_label_template="eligibility/includes/selection-label--veteran.html", - start_template="eligibility/start--veteran.html", - ) - - mst_courtesy_card_verifier = EligibilityVerifier.objects.create( - name=os.environ.get("COURTESY_CARD_VERIFIER_NAME", "Eligibility Server Verifier"), - active=os.environ.get("COURTESY_CARD_VERIFIER_ACTIVE", "True").lower() == "true", - api_url=os.environ.get("COURTESY_CARD_VERIFIER_API_URL", "http://server:8000/verify"), - api_auth_header=os.environ.get("COURTESY_CARD_VERIFIER_API_AUTH_HEADER", "X-Server-API-Key"), - api_auth_key=os.environ.get("COURTESY_CARD_VERIFIER_API_AUTH_KEY", "server-auth-token"), - eligibility_type=mst_courtesy_card_type, - public_key=mst_server_public_key, - jwe_cek_enc=os.environ.get("COURTESY_CARD_VERIFIER_JWE_CEK_ENC", "A256CBC-HS512"), - jwe_encryption_alg=os.environ.get("COURTESY_CARD_VERIFIER_JWE_ENCRYPTION_ALG", "RSA-OAEP"), - jws_signing_alg=os.environ.get("COURTESY_CARD_VERIFIER_JWS_SIGNING_ALG", "RS256"), - auth_provider=None, - selection_label_template="eligibility/includes/selection-label--mst-courtesy-card.html", - start_template="eligibility/start--mst-courtesy-card.html", - form_class="benefits.eligibility.forms.MSTCourtesyCard", - ) - - sacrt_senior_verifier = EligibilityVerifier.objects.create( - name=os.environ.get("SACRT_SENIOR_VERIFIER_NAME", "OAuth claims via Login.gov (SacRT)"), - active=os.environ.get("SACRT_SENIOR_VERIFIER_ACTIVE", "False").lower() == "true", - eligibility_type=sacrt_senior_type, - auth_provider=senior_auth_provider, - selection_label_template="eligibility/includes/selection-label--senior.html", - start_template="eligibility/start--senior.html", - ) - - sbmtd_senior_verifier = EligibilityVerifier.objects.create( - name=os.environ.get("SBMTD_SENIOR_VERIFIER_NAME", "OAuth claims via Login.gov (SBMTD)"), - active=os.environ.get("SBMTD_SENIOR_VERIFIER_ACTIVE", "False").lower() == "true", - eligibility_type=sbmtd_senior_type, - auth_provider=senior_auth_provider, - selection_label_template="eligibility/includes/selection-label--senior.html", - start_template="eligibility/start--senior.html", - ) - - sbmtd_mobility_pass_verifier = EligibilityVerifier.objects.create( - name=os.environ.get("MOBILITY_PASS_VERIFIER_NAME", "Eligibility Server Verifier"), - active=os.environ.get("MOBILITY_PASS_VERIFIER_ACTIVE", "True").lower() == "true", - api_url=os.environ.get("MOBILITY_PASS_VERIFIER_API_URL", "http://server:8000/verify"), - api_auth_header=os.environ.get("MOBILITY_PASS_VERIFIER_API_AUTH_HEADER", "X-Server-API-Key"), - api_auth_key=os.environ.get("MOBILITY_PASS_VERIFIER_API_AUTH_KEY", "server-auth-token"), - eligibility_type=sbmtd_mobility_pass_type, - public_key=sbmtd_server_public_key, - jwe_cek_enc=os.environ.get("MOBILITY_PASS_VERIFIER_JWE_CEK_ENC", "A256CBC-HS512"), - jwe_encryption_alg=os.environ.get("MOBILITY_PASS_VERIFIER_JWE_ENCRYPTION_ALG", "RSA-OAEP"), - jws_signing_alg=os.environ.get("MOBILITY_PASS_VERIFIER_JWS_SIGNING_ALG", "RS256"), - auth_provider=None, - selection_label_template="eligibility/includes/selection-label--sbmtd-mobility-pass.html", - start_template="eligibility/start--sbmtd-mobility-pass.html", - form_class="benefits.eligibility.forms.SBMTDMobilityPass", - ) - - PaymentProcessor = app.get_model("core", "PaymentProcessor") - - mst_payment_processor = PaymentProcessor.objects.create( - name=os.environ.get("MST_PAYMENT_PROCESSOR_NAME", "Test Payment Processor"), - api_base_url=os.environ.get("MST_PAYMENT_PROCESSOR_API_BASE_URL", "http://server:8000"), - api_access_token_endpoint=os.environ.get("MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT", "access-token"), - api_access_token_request_key=os.environ.get("MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY", "request_access"), - api_access_token_request_val=os.environ.get("MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL", "REQUEST_ACCESS"), - card_tokenize_url=os.environ.get("MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL", "http://server:8000/static/tokenize.js"), - card_tokenize_func=os.environ.get("MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC", "tokenize"), - card_tokenize_env=os.environ.get("MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV", "test"), - client_cert=mst_payment_processor_client_cert, - client_cert_private_key=mst_payment_processor_client_cert_private_key, - client_cert_root_ca=mst_payment_processor_client_cert_root_ca, - customer_endpoint="customer", - customers_endpoint="customers", - group_endpoint="group", - ) - - sacrt_payment_processor = PaymentProcessor.objects.create( - name=os.environ.get("SACRT_PAYMENT_PROCESSOR_NAME", "Test Payment Processor"), - api_base_url=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_BASE_URL", "http://server:8000"), - api_access_token_endpoint=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT", "access-token"), - api_access_token_request_key=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY", "request_access"), - api_access_token_request_val=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL", "REQUEST_ACCESS"), - card_tokenize_url=os.environ.get("SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL", "http://server:8000/static/tokenize.js"), - card_tokenize_func=os.environ.get("SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC", "tokenize"), - card_tokenize_env=os.environ.get("SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV", "test"), - client_cert=sacrt_payment_processor_client_cert, - client_cert_private_key=sacrt_payment_processor_client_cert_private_key, - client_cert_root_ca=sacrt_payment_processor_client_cert_root_ca, - customer_endpoint="customer", - customers_endpoint="customers", - group_endpoint="group", - ) - - sbmtd_payment_processor = PaymentProcessor.objects.create( - name=os.environ.get("SBMTD_PAYMENT_PROCESSOR_NAME", "Test Payment Processor"), - api_base_url=os.environ.get("SBMTD_PAYMENT_PROCESSOR_API_BASE_URL", "http://server:8000"), - api_access_token_endpoint=os.environ.get("SBMTD_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT", "access-token"), - api_access_token_request_key=os.environ.get("SBMTD_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY", "request_access"), - api_access_token_request_val=os.environ.get("SBMTD_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL", "REQUEST_ACCESS"), - card_tokenize_url=os.environ.get("SBMTD_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL", "http://server:8000/static/tokenize.js"), - card_tokenize_func=os.environ.get("SBMTD_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC", "tokenize"), - card_tokenize_env=os.environ.get("SBMTD_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV", "test"), - client_cert=sbmtd_payment_processor_client_cert, - client_cert_private_key=sbmtd_payment_processor_client_cert_private_key, - client_cert_root_ca=sbmtd_payment_processor_client_cert_root_ca, - customer_endpoint="customer", - customers_endpoint="customers", - group_endpoint="group", - ) - - TransitAgency = app.get_model("core", "TransitAgency") - - # load the sample data from a JSON file so that it can be accessed by Cypress as well - sample_agency_data = os.path.join(os.path.dirname(__file__), "sample_agency.json") - with open(sample_agency_data) as f: - sample_agency = json.load(f) - - mst_agency = TransitAgency.objects.create( - slug=sample_agency["slug"], - short_name=os.environ.get("MST_AGENCY_SHORT_NAME", sample_agency["short_name"]), - long_name=os.environ.get("MST_AGENCY_LONG_NAME", sample_agency["long_name"]), - agency_id=sample_agency["agency_id"], - merchant_id=sample_agency["merchant_id"], - info_url=sample_agency["info_url"], - phone=sample_agency["phone"], - active=True, - private_key=client_private_key, - public_key=client_public_key, - jws_signing_alg=os.environ.get("MST_AGENCY_JWS_SIGNING_ALG", "RS256"), - payment_processor=mst_payment_processor, - index_template="core/index--mst.html", - eligibility_index_template="eligibility/index--mst.html", - enrollment_success_template="enrollment/success--mst.html", - help_template="core/includes/help--mst.html", - ) - mst_agency.eligibility_types.set([mst_senior_type, mst_veteran_type, mst_courtesy_card_type]) - mst_agency.eligibility_verifiers.set([mst_senior_verifier, mst_veteran_verifier, mst_courtesy_card_verifier]) - - sacrt_agency = TransitAgency.objects.create( - slug="sacrt", - short_name=os.environ.get("SACRT_AGENCY_SHORT_NAME", "SacRT (sample)"), - long_name=os.environ.get("SACRT_AGENCY_LONG_NAME", "Sacramento Regional Transit (sample)"), - agency_id="sacrt", - merchant_id=os.environ.get("SACRT_AGENCY_MERCHANT_ID", "sacrt"), - info_url="https://sacrt.com/", - phone="916-321-2877", - active=os.environ.get("SACRT_AGENCY_ACTIVE", "True").lower() == "true", - private_key=client_private_key, - public_key=client_public_key, - jws_signing_alg=os.environ.get("SACRT_AGENCY_JWS_SIGNING_ALG", "RS256"), - payment_processor=sacrt_payment_processor, - index_template="core/index--sacrt.html", - eligibility_index_template="eligibility/index--sacrt.html", - enrollment_success_template="enrollment/success--sacrt.html", - ) - sacrt_agency.eligibility_types.set([sacrt_senior_type]) - sacrt_agency.eligibility_verifiers.set([sacrt_senior_verifier]) - - sbmtd_agency = TransitAgency.objects.create( - slug="sbmtd", - short_name=os.environ.get("SBMTD_AGENCY_SHORT_NAME", "SBMTD (sample)"), - long_name=os.environ.get("SBMTD_AGENCY_LONG_NAME", "Santa Barbara MTD (sample)"), - agency_id="sbmtd", - merchant_id=os.environ.get("SBMTD_AGENCY_MERCHANT_ID", "sbmtd"), - info_url="https://sbmtd.gov/taptoride/", - phone="805-963-3366", - active=os.environ.get("SBMTD_AGENCY_ACTIVE", "True").lower() == "true", - private_key=client_private_key, - public_key=client_public_key, - jws_signing_alg=os.environ.get("SBMTD_AGENCY_JWS_SIGNING_ALG", "RS256"), - payment_processor=sbmtd_payment_processor, - index_template="core/index--sbmtd.html", - eligibility_index_template="eligibility/index--sbmtd.html", - enrollment_success_template="enrollment/success--sbmtd.html", - help_template="core/includes/help--sbmtd.html", - ) - sbmtd_agency.eligibility_types.set([sbmtd_senior_type, sbmtd_mobility_pass_type]) - sbmtd_agency.eligibility_verifiers.set([sbmtd_senior_verifier, sbmtd_mobility_pass_verifier]) - - -class Migration(migrations.Migration): - dependencies = [ - ("core", "0001_initial"), - ] - - operations = [ - migrations.RunPython(load_data), - ] diff --git a/benefits/core/migrations/local_fixtures.json b/benefits/core/migrations/local_fixtures.json new file mode 100644 index 000000000..0aaf9a808 --- /dev/null +++ b/benefits/core/migrations/local_fixtures.json @@ -0,0 +1,453 @@ +[ + { + "model": "core.pemdata", + "pk": 1, + "fields": { + "label": "(MST) eligibility server public key", + "text_secret_name": null, + "remote_url": "https://raw.githubusercontent.com/cal-itp/eligibility-server/main/keys/server.pub" + } + }, + { + "model": "core.pemdata", + "pk": 2, + "fields": { + "label": "(SBMTD) eligibility server public key", + "text_secret_name": null, + "remote_url": "https://raw.githubusercontent.com/cal-itp/eligibility-server/main/keys/server.pub" + } + }, + { + "model": "core.pemdata", + "pk": 3, + "fields": { + "label": "Benefits client private key", + "text_secret_name": "client-private-key", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 4, + "fields": { + "label": "Benefits client public key", + "text_secret_name": "client-public-key", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 5, + "fields": { + "label": "(MST) payment processor client certificate", + "text_secret_name": "mst-payment-processor-client-cert", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 6, + "fields": { + "label": "(MST) payment processor client certificate private key", + "text_secret_name": "mst-payment-processor-client-cert-private-key", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 7, + "fields": { + "label": "(MST) payment processor client certificate root CA", + "text_secret_name": "mst-payment-processor-client-cert-root-ca", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 8, + "fields": { + "label": "(SacRT) payment processor client certificate", + "text_secret_name": "sacrt-payment-processor-client-cert", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 9, + "fields": { + "label": "(SacRT) payment processor client certificate private key", + "text_secret_name": "sacrt-payment-processor-client-cert-private-key", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 10, + "fields": { + "label": "(SacRT) payment processor client certificate root CA", + "text_secret_name": "sacrt-payment-processor-client-cert-root-ca", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 11, + "fields": { + "label": "(SBMTD) payment processor client certificate", + "text_secret_name": "sbmtd-payment-processor-client-cert", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 12, + "fields": { + "label": "(SBMTD) payment processor client certificate private key", + "text_secret_name": "sbmtd-payment-processor-client-cert-private-key", + "remote_url": null + } + }, + { + "model": "core.pemdata", + "pk": 13, + "fields": { + "label": "(SBMTD) payment processor client certificate root CA", + "text_secret_name": "sbmtd-payment-processor-client-cert-root-ca", + "remote_url": null + } + }, + { + "model": "core.authprovider", + "pk": 1, + "fields": { + "sign_out_button_template": "core/includes/button--sign-out--login-gov.html", + "sign_out_link_template": "core/includes/link--sign-out--login-gov.html", + "client_name": "senior-benefits-oauth-client-name", + "client_id_secret_name": "auth-provider-client-id", + "authority": "https://example.com", + "scope": "verify:senior", + "claim": "senior", + "scheme": "dev-cal-itp_benefits" + } + }, + { + "model": "core.authprovider", + "pk": 2, + "fields": { + "sign_out_button_template": "core/includes/button--sign-out--login-gov.html", + "sign_out_link_template": "core/includes/link--sign-out--login-gov.html", + "client_name": "veteran-benefits-oauth-client-name", + "client_id_secret_name": "auth-provider-client-id", + "authority": "https://example.com", + "scope": "verify:veteran", + "claim": "veteran", + "scheme": "vagov" + } + }, + { + "model": "core.eligibilitytype", + "pk": 1, + "fields": { + "name": "senior", + "label": "(MST) Senior Discount", + "group_id": "group123" + } + }, + { + "model": "core.eligibilitytype", + "pk": 2, + "fields": { + "name": "veteran", + "label": "(MST) Veteran Discount", + "group_id": "group123" + } + }, + { + "model": "core.eligibilitytype", + "pk": 3, + "fields": { + "name": "courtesy_card", + "label": "(MST) Courtesy Card Discount", + "group_id": "group123" + } + }, + { + "model": "core.eligibilitytype", + "pk": 4, + "fields": { + "name": "senior", + "label": "(SacRT) Senior Discount", + "group_id": "group123" + } + }, + { + "model": "core.eligibilitytype", + "pk": 5, + "fields": { + "name": "senior", + "label": "(SBMTD) Senior Discount", + "group_id": "group123" + } + }, + { + "model": "core.eligibilitytype", + "pk": 6, + "fields": { + "name": "mobility_pass", + "label": "(SBMTD) Mobility Pass Discount", + "group_id": "group123" + } + }, + { + "model": "core.eligibilityverifier", + "pk": 1, + "fields": { + "name": "(MST) oauth claims via Login.gov", + "active": true, + "api_url": null, + "api_auth_header": null, + "api_auth_key_secret_name": null, + "eligibility_type": 1, + "public_key": null, + "jwe_cek_enc": null, + "jwe_encryption_alg": null, + "jws_signing_alg": null, + "auth_provider": 1, + "selection_label_template": "eligibility/includes/selection-label--senior.html", + "start_template": "eligibility/start--senior.html", + "form_class": null + } + }, + { + "model": "core.eligibilityverifier", + "pk": 2, + "fields": { + "name": "(MST) VA.gov - veteran", + "active": true, + "api_url": null, + "api_auth_header": null, + "api_auth_key_secret_name": null, + "eligibility_type": 2, + "public_key": null, + "jwe_cek_enc": null, + "jwe_encryption_alg": null, + "jws_signing_alg": null, + "auth_provider": 2, + "selection_label_template": "eligibility/includes/selection-label--veteran.html", + "start_template": "eligibility/start--veteran.html", + "form_class": null + } + }, + { + "model": "core.eligibilityverifier", + "pk": 3, + "fields": { + "name": "(MST) eligibility server verifier", + "active": true, + "api_url": "http://server:8000/verify", + "api_auth_header": "X-Server-API-Key", + "api_auth_key_secret_name": "courtesy-card-verifier-api-auth-key", + "eligibility_type": 3, + "public_key": 1, + "jwe_cek_enc": "A256CBC-HS512", + "jwe_encryption_alg": "RSA-OAEP", + "jws_signing_alg": "RS256", + "auth_provider": null, + "selection_label_template": "eligibility/includes/selection-label--mst-courtesy-card.html", + "start_template": "eligibility/start--mst-courtesy-card.html", + "form_class": "benefits.eligibility.forms.MSTCourtesyCard" + } + }, + { + "model": "core.eligibilityverifier", + "pk": 4, + "fields": { + "name": "(SacRT) oauth claims via Login.gov", + "active": false, + "api_url": null, + "api_auth_header": null, + "api_auth_key_secret_name": null, + "eligibility_type": 4, + "public_key": null, + "jwe_cek_enc": null, + "jwe_encryption_alg": null, + "jws_signing_alg": null, + "auth_provider": 1, + "selection_label_template": "eligibility/includes/selection-label--senior.html", + "start_template": "eligibility/start--senior.html", + "form_class": null + } + }, + { + "model": "core.eligibilityverifier", + "pk": 5, + "fields": { + "name": "(SBMTD) oauth claims via Login.gov", + "active": false, + "api_url": null, + "api_auth_header": null, + "api_auth_key_secret_name": null, + "eligibility_type": 5, + "public_key": null, + "jwe_cek_enc": null, + "jwe_encryption_alg": null, + "jws_signing_alg": null, + "auth_provider": 1, + "selection_label_template": "eligibility/includes/selection-label--senior.html", + "start_template": "eligibility/start--senior.html", + "form_class": null + } + }, + { + "model": "core.eligibilityverifier", + "pk": 6, + "fields": { + "name": "(SBMTD) eligibility server verifier", + "active": true, + "api_url": "http://server:8000/verify", + "api_auth_header": "X-Server-API-Key", + "api_auth_key_secret_name": "mobility-pass-verifier-api-auth-key", + "eligibility_type": 6, + "public_key": 2, + "jwe_cek_enc": "A256CBC-HS512", + "jwe_encryption_alg": "RSA-OAEP", + "jws_signing_alg": "RS256", + "auth_provider": null, + "selection_label_template": "eligibility/includes/selection-label--sbmtd-mobility-pass.html", + "start_template": "eligibility/start--sbmtd-mobility-pass.html", + "form_class": "benefits.eligibility.forms.SBMTDMobilityPass" + } + }, + { + "model": "core.paymentprocessor", + "pk": 1, + "fields": { + "name": "(MST) test payment processor", + "api_base_url": "http://server:8000", + "api_access_token_endpoint": "access-token", + "api_access_token_request_key": "request_access", + "api_access_token_request_val": "REQUEST_ACCESS", + "card_tokenize_url": "http://server:8000/static/tokenize.js", + "card_tokenize_func": "tokenize", + "card_tokenize_env": "test", + "client_cert": 5, + "client_cert_private_key": 6, + "client_cert_root_ca": 7, + "customer_endpoint": "customer", + "customers_endpoint": "customers", + "group_endpoint": "group" + } + }, + { + "model": "core.paymentprocessor", + "pk": 2, + "fields": { + "name": "(SacRT) test payment processor", + "api_base_url": "http://server:8000", + "api_access_token_endpoint": "access-token", + "api_access_token_request_key": "request_access", + "api_access_token_request_val": "REQUEST_ACCESS", + "card_tokenize_url": "http://server:8000/static/tokenize.js", + "card_tokenize_func": "tokenize", + "card_tokenize_env": "test", + "client_cert": 8, + "client_cert_private_key": 9, + "client_cert_root_ca": 10, + "customer_endpoint": "customer", + "customers_endpoint": "customers", + "group_endpoint": "group" + } + }, + { + "model": "core.paymentprocessor", + "pk": 3, + "fields": { + "name": "(SBMTD) test payment processor", + "api_base_url": "http://server:8000", + "api_access_token_endpoint": "access-token", + "api_access_token_request_key": "request_access", + "api_access_token_request_val": "REQUEST_ACCESS", + "card_tokenize_url": "http://server:8000/static/tokenize.js", + "card_tokenize_func": "tokenize", + "card_tokenize_env": "test", + "client_cert": 11, + "client_cert_private_key": 12, + "client_cert_root_ca": 13, + "customer_endpoint": "customer", + "customers_endpoint": "customers", + "group_endpoint": "group" + } + }, + { + "model": "core.transitagency", + "pk": 1, + "fields": { + "slug": "mst", + "short_name": "MST (local)", + "long_name": "Monterey-Salinas Transit (local)", + "agency_id": "mst", + "merchant_id": "mst", + "info_url": "https://mst.org/benefits", + "phone": "888-678-2871", + "active": true, + "payment_processor": 1, + "private_key": 3, + "public_key": 4, + "jws_signing_alg": "RS256", + "index_template": "core/index--mst.html", + "eligibility_index_template": "eligibility/index--mst.html", + "enrollment_success_template": "enrollment/success--mst.html", + "help_template": "core/includes/help--mst.html", + "eligibility_types": [1, 2, 3], + "eligibility_verifiers": [1, 2, 3] + } + }, + { + "model": "core.transitagency", + "pk": 2, + "fields": { + "slug": "sacrt", + "short_name": "SacRT (local)", + "long_name": "Sacramento Regional Transit (local)", + "agency_id": "sacrt", + "merchant_id": "sacrt", + "info_url": "https://sacrt.com/", + "phone": "916-321-2877", + "active": true, + "payment_processor": 2, + "private_key": 3, + "public_key": 4, + "jws_signing_alg": "RS256", + "index_template": "core/index--sacrt.html", + "eligibility_index_template": "eligibility/index--sacrt.html", + "enrollment_success_template": "enrollment/success--sacrt.html", + "help_template": null, + "eligibility_types": [4], + "eligibility_verifiers": [4] + } + }, + { + "model": "core.transitagency", + "pk": 3, + "fields": { + "slug": "sbmtd", + "short_name": "SBMTD (local)", + "long_name": "Santa Barbara MTD (local)", + "agency_id": "sbmtd", + "merchant_id": "sbmtd", + "info_url": "https://sbmtd.gov/taptoride/", + "phone": "805-963-3366", + "active": true, + "payment_processor": 3, + "private_key": 3, + "public_key": 4, + "jws_signing_alg": "RS256", + "index_template": "core/index--sbmtd.html", + "eligibility_index_template": "eligibility/index--sbmtd.html", + "enrollment_success_template": "enrollment/success--sbmtd.html", + "help_template": "core/includes/help--sbmtd.html", + "eligibility_types": [5, 6], + "eligibility_verifiers": [5, 6] + } + } +] diff --git a/benefits/core/migrations/sample_agency.json b/benefits/core/migrations/sample_agency.json deleted file mode 100644 index 6060e4980..000000000 --- a/benefits/core/migrations/sample_agency.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "slug": "mst", - "short_name": "MST (sample)", - "long_name": "Monterey-Salinas Transit (sample)", - "agency_id": "mst", - "merchant_id": "mst", - "info_url": "https://mst.org/benefits", - "phone": "888-678-2871" -} diff --git a/benefits/core/models.py b/benefits/core/models.py index 8de6ec1fe..8473fc2cc 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -2,6 +2,7 @@ The core application: Common model definitions. """ +from functools import cached_property import importlib import logging @@ -11,33 +12,68 @@ import requests +from benefits.secrets import NAME_VALIDATOR, get_secret_by_name + logger = logging.getLogger(__name__) +class SecretNameField(models.SlugField): + """Field that stores the name of a secret held in a secret store. + + The secret value itself MUST NEVER be stored in this field. + """ + + description = """Field that stores the name of a secret held in a secret store. + + Secret names must be between 1-127 alphanumeric ASCII characters or hyphen characters. + + The secret value itself MUST NEVER be stored in this field. + """ + + def __init__(self, *args, **kwargs): + kwargs["validators"] = [NAME_VALIDATOR] + # although the validator also checks for a max length of 127 + # this setting enforces the length at the database column level as well + kwargs["max_length"] = 127 + # similar to max_length, enforce at the field (form) validation level to not allow blanks + kwargs["blank"] = False + # the default is False, but this is more explicit + kwargs["allow_unicode"] = False + super().__init__(*args, **kwargs) + + class PemData(models.Model): """API Certificate or Key in PEM format.""" id = models.AutoField(primary_key=True) # Human description of the PEM data label = models.TextField() - # The data in utf-8 encoded PEM text format - text = models.TextField(null=True) + # The name of a secret with data in utf-8 encoded PEM text format + text_secret_name = SecretNameField(null=True) # Public URL hosting the utf-8 encoded PEM text remote_url = models.TextField(null=True) def __str__(self): return self.label - @property + @cached_property def data(self): - if self.text: - return self.text - elif self.remote_url: - self.text = requests.get(self.remote_url, timeout=settings.REQUESTS_TIMEOUT).text + """ + Attempts to get data from `remote_url` or `text_secret_name`, with the latter taking precendence if both are defined. + """ + remote_data = None + secret_data = None + + if self.remote_url: + remote_data = requests.get(self.remote_url, timeout=settings.REQUESTS_TIMEOUT).text + if self.text_secret_name: + try: + secret_data = get_secret_by_name(self.text_secret_name) + except Exception: + secret_data = None - self.save() - return self.text + return secret_data if secret_data is not None else remote_data class AuthProvider(models.Model): @@ -47,7 +83,7 @@ class AuthProvider(models.Model): sign_out_button_template = models.TextField(null=True) sign_out_link_template = models.TextField(null=True) client_name = models.TextField() - client_id = models.TextField() + client_id_secret_name = SecretNameField() authority = models.TextField() scope = models.TextField(null=True) claim = models.TextField(null=True) @@ -61,6 +97,10 @@ def supports_claims_verification(self): def supports_sign_out(self): return bool(self.sign_out_button_template) or bool(self.sign_out_link_template) + @property + def client_id(self): + return get_secret_by_name(self.client_id_secret_name) + class EligibilityType(models.Model): """A single conditional eligibility type.""" @@ -101,7 +141,7 @@ class EligibilityVerifier(models.Model): active = models.BooleanField(default=False) api_url = models.TextField(null=True) api_auth_header = models.TextField(null=True) - api_auth_key = models.TextField(null=True) + api_auth_key_secret_name = SecretNameField(null=True) eligibility_type = models.ForeignKey(EligibilityType, on_delete=models.PROTECT) # public key is used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True) @@ -120,6 +160,13 @@ class EligibilityVerifier(models.Model): def __str__(self): return self.name + @property + def api_auth_key(self): + if self.api_auth_key_secret_name is not None: + return get_secret_by_name(self.api_auth_key_secret_name) + else: + return None + @property def public_key_data(self): """This Verifier's public key as a string.""" diff --git a/benefits/secrets.py b/benefits/secrets.py new file mode 100644 index 000000000..7a8f650a6 --- /dev/null +++ b/benefits/secrets.py @@ -0,0 +1,99 @@ +import logging +import os +import re +import sys + +from azure.core.exceptions import ClientAuthenticationError +from azure.identity import DefaultAzureCredential +from azure.keyvault.secrets import SecretClient +from django.conf import settings +from django.core.validators import RegexValidator + +logger = logging.getLogger(__name__) + + +KEY_VAULT_URL = "https://kv-cdt-pub-calitp-{env}-001.vault.azure.net/" + + +class SecretNameValidator(RegexValidator): + """RegexValidator that validates a secret name. + + Azure KeyVault currently enforces the following rules: + + * The value must be between 1 and 127 characters long. + * Secret names can only contain alphanumeric characters and dashes. + + Read more about Azure KeyVault naming rules: + https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftkeyvault + + Read more about Django validators: + https://docs.djangoproject.com/en/5.0/ref/validators/#module-django.core.validators + """ + + def __init__(self, *args, **kwargs): + kwargs["regex"] = re.compile(r"^[-a-zA-Z0-9]{1,127}$", re.ASCII) + kwargs["message"] = ( + "Enter a valid secret name of between 1-127 alphanumeric ASCII characters and the hyphen character only." + ) + super().__init__(*args, **kwargs) + + +NAME_VALIDATOR = SecretNameValidator() + + +def get_secret_by_name(secret_name, client=None): + """Read a value from the secret store, currently Azure KeyVault. + + When `settings.RUNTIME_ENVIRONMENT() == "local"`, reads from the environment instead. + """ + NAME_VALIDATOR(secret_name) + + runtime_env = settings.RUNTIME_ENVIRONMENT() + + if runtime_env == "local": + logger.debug("Runtime environment is local, reading from environment instead of Azure KeyVault.") + # environment variable names cannot contain the hyphen character + # assume the variable name is the same but with underscores instead + env_secret_name = secret_name.replace("-", "_") + secret_value = os.environ.get(env_secret_name) + # we have to replace literal newlines here with the actual newline character + # to support local environment variables values that span multiple lines (e.g. PEM keys/certs) + # because the VS Code Python extension doesn't support multiline environment variables + # https://code.visualstudio.com/docs/python/environments#_environment-variables + return secret_value.replace("\\n", "\n") + + elif client is None: + # construct the KeyVault URL from the runtime environment + # see https://docs.calitp.org/benefits/deployment/infrastructure/#environments + # and https://github.com/cal-itp/benefits/blob/dev/terraform/key_vault.tf + vault_url = KEY_VAULT_URL.format(env=runtime_env[0]) + logger.debug(f"Configuring Azure KeyVault secrets client for: {vault_url}") + + credential = DefaultAzureCredential() + client = SecretClient(vault_url=vault_url, credential=credential) + + secret_value = None + + if client is not None: + try: + secret = client.get_secret(secret_name) + secret_value = secret.value + except ClientAuthenticationError: + logger.error("Could not authenticate to Azure KeyVault") + else: + logger.error("Azure KeyVault SecretClient was not configured") + + return secret_value + + +if __name__ == "__main__": + args = sys.argv[1:] + if len(args) < 1: + print("Provide the name of the secret to read") + exit(1) + + secret_name = args[0] + secret_value = get_secret_by_name(secret_name) + + print(f"[{settings.RUNTIME_ENVIRONMENT()}] {secret_name}: {secret_value}") + exit(0) diff --git a/benefits/sentry.py b/benefits/sentry.py index 1362d6c62..86585e58b 100644 --- a/benefits/sentry.py +++ b/benefits/sentry.py @@ -3,6 +3,7 @@ import os import subprocess +from django.conf import settings import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST @@ -11,7 +12,6 @@ logger = logging.getLogger(__name__) -SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT", "local") SENTRY_CSP_REPORT_URI = None @@ -80,19 +80,21 @@ def get_traces_sample_rate(): def configure(): - SENTRY_DSN = os.environ.get("SENTRY_DSN") - if SENTRY_DSN: + sentry_dsn = os.environ.get("SENTRY_DSN") + sentry_environment = os.environ.get("SENTRY_ENVIRONMENT", settings.RUNTIME_ENVIRONMENT()) + + if sentry_dsn: release = get_release() - logger.info(f"Enabling Sentry for environment '{SENTRY_ENVIRONMENT}', release '{release}'...") + logger.info(f"Enabling Sentry for environment '{sentry_environment}', release '{release}'...") # https://docs.sentry.io/platforms/python/configuration/ sentry_sdk.init( - dsn=SENTRY_DSN, + dsn=sentry_dsn, integrations=[ DjangoIntegration(), ], traces_sample_rate=get_traces_sample_rate(), - environment=SENTRY_ENVIRONMENT, + environment=sentry_environment, release=release, in_app_include=["benefits"], # send_default_pii must be False (the default) for a custom EventScrubber/denylist diff --git a/benefits/settings.py b/benefits/settings.py index 222312904..35163a339 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -4,6 +4,8 @@ import os +from django.conf import settings + from benefits import sentry @@ -20,30 +22,54 @@ def _filter_empty(ls): # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true" -ADMIN = os.environ.get("DJANGO_ADMIN", "False").lower() == "true" +ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")) + + +def RUNTIME_ENVIRONMENT(): + """Helper calculates the current runtime environment from ALLOWED_HOSTS.""" + + # usage of django.conf.settings.ALLOWED_HOSTS here (rather than the module variable directly) + # is to ensure dynamic calculation, e.g. for unit tests and elsewhere this setting is needed + env = "local" + if "dev-benefits.calitp.org" in settings.ALLOWED_HOSTS: + env = "dev" + elif "test-benefits.calitp.org" in settings.ALLOWED_HOSTS: + env = "test" + elif "benefits.calitp.org" in settings.ALLOWED_HOSTS: + env = "prod" + return env -ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")) # Application definition INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", "django.contrib.messages", "django.contrib.sessions", "django.contrib.staticfiles", + "django_google_sso", "benefits.core", "benefits.enrollment", "benefits.eligibility", "benefits.oauth", ] -if ADMIN: - INSTALLED_APPS.extend( - [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - ] - ) +GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret") +GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin") +GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret") +GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(",")) +GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(",")) +GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(",")) +GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg" +GOOGLE_SSO_SAVE_ACCESS_TOKEN = True +GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user" +GOOGLE_SSO_SCOPES = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -57,16 +83,10 @@ def _filter_empty(ls): "django.middleware.clickjacking.XFrameOptionsMiddleware", "csp.middleware.CSPMiddleware", "benefits.core.middleware.ChangedLanguageEvent", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", ] -if ADMIN: - MIDDLEWARE.extend( - [ - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - ] - ) - if DEBUG: MIDDLEWARE.append("benefits.core.middleware.DebugSession") @@ -104,7 +124,7 @@ def _filter_empty(ls): # SSL terminates before getting to Django, and NGINX adds this header to indicate # if the original request was secure or not # -# See https://docs.djangoproject.com/en/4.0/ref/settings/#secure-proxy-ssl-header +# See https://docs.djangoproject.com/en/5.0/ref/settings/#secure-proxy-ssl-header if not DEBUG: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") @@ -112,6 +132,7 @@ def _filter_empty(ls): template_ctx_processors = [ "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "benefits.core.context_processors.agency", "benefits.core.context_processors.active_agencies", @@ -128,14 +149,6 @@ def _filter_empty(ls): ] ) -if ADMIN: - template_ctx_processors.extend( - [ - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - ) - TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -153,31 +166,27 @@ def _filter_empty(ls): DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(DATABASE_DIR, "django.db"), + "NAME": os.path.join(DATABASE_DIR, os.environ.get("DJANGO_DB_FILE", "django.db")), } } # Password validation -AUTH_PASSWORD_VALIDATORS = [] +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] -if ADMIN: - AUTH_PASSWORD_VALIDATORS.extend( - [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, - ] - ) # Internationalization @@ -282,7 +291,11 @@ def _filter_empty(ls): if len(env_frame_src) > 0: CSP_FRAME_SRC = env_frame_src -CSP_IMG_SRC = ["'self'", "data:"] +CSP_IMG_SRC = [ + "'self'", + "data:", + "*.googleusercontent.com", +] # Configuring strict Content Security Policy # https://django-csp.readthedocs.io/en/latest/nonce.html @@ -294,9 +307,11 @@ def _filter_empty(ls): CSP_REPORT_URI = [sentry.SENTRY_CSP_REPORT_URI] CSP_SCRIPT_SRC = [ + "'self'", "https://cdn.amplitude.com/libs/", "https://cdn.jsdelivr.net/", "*.littlepay.com", + "https://code.jquery.com/jquery-3.6.0.min.js", ] env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(",")) CSP_SCRIPT_SRC.extend(env_script_src) diff --git a/benefits/static/img/icon/google_sso_logo.svg b/benefits/static/img/icon/google_sso_logo.svg new file mode 100644 index 000000000..21ec49090 --- /dev/null +++ b/benefits/static/img/icon/google_sso_logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/benefits/urls.py b/benefits/urls.py index 57d7931ec..30d95f018 100644 --- a/benefits/urls.py +++ b/benefits/urls.py @@ -2,12 +2,14 @@ benefits URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.0/topics/http/urls/ + https://docs.djangoproject.com/en/5.0/topics/http/urls/ """ import logging from django.conf import settings +from django.contrib import admin +from django.http import HttpResponse from django.urls import include, path logger = logging.getLogger(__name__) @@ -34,10 +36,17 @@ def trigger_error(request): urlpatterns.append(path("error/", trigger_error)) -if settings.ADMIN: - from django.contrib import admin + # simple route to read a pre-defined "secret" + # this "secret" does not contain sensitive information + # and is only configured in the dev environment for testing/debugging - logger.debug("Register admin urls") - urlpatterns.append(path("admin/", admin.site.urls)) -else: - logger.debug("Skip url registrations for admin") + def test_secret(request): + from benefits.secrets import get_secret_by_name + + return HttpResponse(get_secret_by_name("testsecret")) + + urlpatterns.append(path("testsecret/", test_secret)) + +logger.debug("Register admin urls") +urlpatterns.append(path("admin/", admin.site.urls)) +urlpatterns.append(path("google_sso/", include("django_google_sso.urls", namespace="django_google_sso"))) diff --git a/bin/init.sh b/bin/init.sh index 8a11d863e..c2a283c2a 100755 --- a/bin/init.sh +++ b/bin/init.sh @@ -1,33 +1,10 @@ #!/usr/bin/env bash set -eux -# make the path to the database file from environment or default -DB_DIR="${DJANGO_DB_DIR:-.}" -DB_FILE="${DB_DIR}/django.db" -DB_RESET="${DJANGO_DB_RESET:-true}" - -# remove existing (old) database file -if [[ $DB_RESET = true && -f $DB_FILE ]]; then - # rename then delete the new file - # trying to avoid a file lock on the existing file - # after marking it for deletion - mv "${DB_FILE}" "${DB_FILE}.old" - rm "${DB_FILE}.old" -fi - # run database migrations python manage.py migrate -# create a superuser account for backend admin access -# check DJANGO_ADMIN = true, default to false if empty or unset - -if [[ ${DJANGO_ADMIN:-false} = true ]]; then - python manage.py createsuperuser --no-input -else - echo "superuser: Django not configured for Admin access" -fi - # generate language *.mo files for use by Django python manage.py compilemessages diff --git a/bin/makemigrations.sh b/bin/makemigrations.sh index 4fc8508f0..89d755f0b 100755 --- a/bin/makemigrations.sh +++ b/bin/makemigrations.sh @@ -1,27 +1,10 @@ #!/usr/bin/env bash set -eux -# create temporary directory (if it doesn't already exist) - -mkdir -p benefits/core/old_migrations - -# move old migrations to temporary directory, but keep init file - -mv benefits/core/migrations/* benefits/core/old_migrations -cp benefits/core/old_migrations/__init__.py benefits/core/migrations - -# regenerate +# generate python manage.py makemigrations -# copy over migrations that don't exist - -cp benefits/core/old_migrations/* benefits/core/migrations --no-clobber --recursive - -# clean up temporary directory - -rm -rf benefits/core/old_migrations - # reformat with black python -m black benefits/core/migrations/*.py diff --git a/bin/reset_db.sh b/bin/reset_db.sh new file mode 100755 index 000000000..1b3ea0fd8 --- /dev/null +++ b/bin/reset_db.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -ex + +# whether to reset database file, defaults to true +DB_RESET="${DJANGO_DB_RESET:-true}" + +if [[ $DB_RESET = true ]]; then + # construct the path to the database file from environment or default + DB_DIR="${DJANGO_DB_DIR:-.}" + DB_FILE="${DJANGO_DB_FILE:-django.db}" + DB_PATH="${DB_DIR}/${DB_FILE}" + + rm -f "${DB_PATH}" + + # run database migrations and other initialization + bin/init.sh + + # create a superuser account for backend admin access + # set username, email, and password using environment variables + # DJANGO_SUPERUSER_USERNAME, DJANGO_SUPERUSER_EMAIL, and DJANGO_SUPERUSER_PASSWORD + python manage.py createsuperuser --no-input +else + echo "DB_RESET is false, skipping" +fi + +valid_fixtures=$(echo "$DJANGO_DB_FIXTURES" | grep -e fixtures\.json$) + +if [[ -n "$valid_fixtures" ]]; then + # load data fixtures + python manage.py loaddata "$DJANGO_DB_FIXTURES" +else + echo "No JSON fixtures to load" +fi diff --git a/bin/test_start.sh b/bin/test_start.sh new file mode 100755 index 000000000..12c0ebeef --- /dev/null +++ b/bin/test_start.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -eux + +# container startup script specifically for running Cypress tests +# needs to reset the DB with sample data and then start the app normally + +bin/reset_db.sh + +bin/start.sh diff --git a/docs/README.md b/docs/README.md index 5005b2f69..9941df732 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ The following California transit agencies have launched Cal-ITP Benefits for the | Transit agency | Older adults | Agency card | Veterans | Initial agency launch | | ----------------------------------------------- | ------------ | ----------- | -------- | --------------------- | | **Monterey-Salinas Transit** | Live | Live | Live | 05/2021 | -| **Santa Barbara Metropolitan Transit District** | Live | In test |   | 10/2023 | +| **Santa Barbara Metropolitan Transit District** | Live | Live |   | 10/2023 | | **Sacramento Regional Transit District** | In test |   |   |   | ## Supported enrollment pathways @@ -32,6 +32,7 @@ The Cal-ITP Benefits app supports the following enrollment pathways that use the | [**Older adults**](/benefits/enrollment-pathways/older-adults) | [Login.gov ID Proofed](https://developers.login.gov/attributes/) | Live | [08/2022](https://github.com/cal-itp/benefits/releases/tag/2022.08.1) | | [**Agency cards**](/benefits/enrollment-pathways/agency-cards) | [Eligibility API](https://docs.calitp.org/eligibility-api/specification/) | Live | [11/2022](https://github.com/cal-itp/benefits/releases/tag/2022.11.1) | | [**Veterans**](/benefits/enrollment-pathways/veterans) | [Veteran Confirmation API](https://developer.va.gov/explore/api/veteran-confirmation) | Live | [09/2023](https://github.com/cal-itp/benefits/releases/tag/2023.09.1) | +| [**Low-income**](/benefits/enrollment-pathways/low-income) | CalFresh Confirm API | In development | | Read more about each [enrollment pathway](/benefits/enrollment-pathways/). @@ -96,4 +97,4 @@ All code changes are reviewed by at least one other member of the engineering te [interconnections]: deployment/infrastructure/#system-interconnections [hosting]: deployment/ [littlepay]: https://littlepay.com/ -[i18n]: https://docs.djangoproject.com/en/4.0/topics/i18n/ +[i18n]: https://docs.djangoproject.com/en/5.0/topics/i18n/ diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 9e0a29e96..9ed629e16 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -13,7 +13,7 @@ startup. The model objects defined in the data migration file are also loaded into and seed Django's database at application startup time. - See the [Setting secrets](../deployment/secrets) section for how to set secret values for a deployment. +See the [Setting secrets](../deployment/secrets) section for how to set secret values for a deployment. ## Django settings @@ -55,10 +55,10 @@ from django.config import settings # ... -if settings.ADMIN: - # do something when admin is enabled +if settings.DEBUG: + # do something when debug is enabled else: - # do something else when admin is disabled + # do something else when debug is disabled ``` Through the [Django model][django-model] framework, `benefits.core.models` instances are used to access the configuration data: @@ -77,9 +77,9 @@ else: [benefits-manage]: https://github.com/cal-itp/benefits/blob/dev/manage.py [benefits-settings]: https://github.com/cal-itp/benefits/blob/dev/benefits/settings.py [benefits-wsgi]: https://github.com/cal-itp/benefits/blob/dev/benefits/wsgi.py -[django-model]: https://docs.djangoproject.com/en/4.0/topics/db/models/ -[django-settings]: https://docs.djangoproject.com/en/4.0/topics/settings/ -[django-using-settings]: https://docs.djangoproject.com/en/4.0/topics/settings/#using-settings-in-python-code +[django-model]: https://docs.djangoproject.com/en/5.0/topics/db/models/ +[django-settings]: https://docs.djangoproject.com/en/5.0/topics/settings/ +[django-using-settings]: https://docs.djangoproject.com/en/5.0/topics/settings/#using-settings-in-python-code [env-vars]: environment-variables.md [data]: data.md [getting-started]: ../getting-started/README.md diff --git a/docs/configuration/data.md b/docs/configuration/data.md index 6e52de0b8..b87c79dea 100644 --- a/docs/configuration/data.md +++ b/docs/configuration/data.md @@ -1,8 +1,8 @@ # Configuration data -!!! example "Data migration file" +!!! example "Sample data fixtures" - [`benefits/core/migrations/0002_data.py`][data-migration] + [`benefits/core/migrations/local_fixtures.json`][sample-fixtures] !!! tldr "Django docs" @@ -10,14 +10,15 @@ ## Introduction -Django [data migrations](https://docs.djangoproject.com/en/4.0/topics/migrations/#data-migrations) are used to load the database with instances of the app's model classes, defined in [`benefits/core/models.py`][core-models]. +The app's model classes are defined in [`benefits/core/models.py`][core-models]. Migrations are run as the application starts up. See the [`bin/init.sh`][init] script. The sample values provided in the repository are sufficient to run the app locally and interact with e.g. the sample Transit -Agencies. +Agencies. [Django fixtures][django-fixtures] are used to load the database with sample data when running locally. -During the [deployment](../deployment/README.md) process, environment-specific values are set in environment variables and are read by the data migration file to build that environment's configuration database. See the [data migration file][data-migration] for the environment variable names. +During the [deployment](../deployment/README.md) process, some environment-specific values are set in environment variables and +read dynamically at runtime. Most configuration values are managed directly in the Django Admin interface at the `/admin` endpoint. ## Sample data @@ -37,32 +38,24 @@ Some configuration data is not available with the samples in the repository: - Payment processor configuration for the enrollment phase - Amplitude configuration for capturing analytics events -### Sample transit agency: `ABC` +## Rebuilding the configuration database locally -- Presents the user a choice between two different eligibility pathways -- One eligibility verifier requires authentication -- One eligibility verifier does not require authentication +A local Django database will be initialized upon first startup of the devcontainer. -### Sample transit agency: `DefTL` - -- Single eligibility pathway, no choice presented to the user -- Eligibility verifier does not require authentication - -## Building the configuration database - -When the data migration changes, the configuration database needs to be rebuilt. - -The file is called `django.db` and the following commands will rebuild it. - -Run these commands from within the repository root, inside the devcontainer: +To rebuild the local Django database, run the [`bin/reset_db.sh`][reset-db] script from within the repository root, +inside the devcontainer: ```bash -bin/init.sh +bin/reset_db.sh ``` +See the [Django Environment Variables](environment-variables.md#django) section for details about how to configure the local +database rebuild. + [core-models]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/models.py -[django-load-initial-data]: https://docs.djangoproject.com/en/4.0/howto/initial-data/ +[django-fixtures]: https://docs.djangoproject.com/en/5.0/topics/db/fixtures/ +[django-load-initial-data]: https://docs.djangoproject.com/en/5.0/howto/initial-data/ [eligibility-server]: https://docs.calitp.org/eligibility-server -[data-migration]: https://github.com/cal-itp/benefits/tree/dev/benefits/core/migrations/0002_data.py -[helper-migration]: https://github.com/cal-itp/benefits/tree/dev/benefits/core/migrations/0003_data_migration_order.py [init]: https://github.com/cal-itp/benefits/blob/dev/bin/init.sh +[reset-db]: https://github.com/cal-itp/benefits/blob/dev/bin/reset_db.sh +[sample-fixtures]: https://github.com/cal-itp/benefits/tree/dev/benefits/core/migrations/local_fixtures.json diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 574ca3f4b..cd150c283 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -2,10 +2,35 @@ The first steps of the Getting Started guide mention [creating an `.env` file][getting-started_create-env]. -The sections below outline in more detail the application environment variables that you may want to override, and their purpose. In App Service, this is more generally called the ["configuration"][app-service-config]. +The sections below outline in more detail the application environment variables that you may want to override, and their purpose. +In Azure App Services, this is more generally called the ["configuration"][app-service-config]. See other topic pages in this section for more specific environment variable configurations. +!!! warning "Multiline environment variables" + + Although Docker, bash, etc. support multiline values directly in e.g. an .env file: + + ```bash + multi_line_value='first line + second line + third line' + ``` + + The VS Code Python extension does not parse multiline values: https://code.visualstudio.com/docs/python/environments#_environment-variables + + When specifying multiline values for local usage, use the literal newline character `\n` but maintain the single quote wrapper: + + ```bash + multi_line_value='first line\nsecond line\third line' + ``` + + A quick bash script to convert direct multiline values to their literal newline character equivalent is: + + ```bash + echo "${multi_line_value//$'\n'/\\n}" + ``` + ## Amplitude !!! tldr "Amplitude API docs" @@ -24,13 +49,6 @@ If blank or an invalid key, analytics events aren't captured (though may still b ## Django -### `DJANGO_ADMIN` - -Boolean: - -- `True`: activates Django's built-in admin interface for content authoring. -- `False` (default): skips this activation. - ### `DJANGO_ALLOWED_HOSTS` !!! warning "Deployment configuration" @@ -39,7 +57,7 @@ Boolean: !!! tldr "Django docs" - [Settings: `ALLOWS_HOSTS`](https://docs.djangoproject.com/en/4.0/ref/settings/#allowed-hosts) + [Settings: `ALLOWS_HOSTS`](https://docs.djangoproject.com/en/5.0/ref/settings/#allowed-hosts) A list of strings representing the host/domain names that this Django site can serve. @@ -54,11 +72,33 @@ writable by the Django process._ By default, the base project directory (i.e. the root of the repository). +### `DJANGO_DB_FILE` + +!!! info "Local configuration" + + This setting only affects the app running on localhost + +The name of the Django database file to use locally (during both normal app startup and for resetting the database). + +By default, `django.db`. + +### `DJANGO_DB_FIXTURES` + +!!! info "Local configuration" + + This setting only affects the app running on localhost + +A path, relative to the repository root, of Django data fixtures to load when resetting the database. + +The file must end in `fixtures.json` for the script to process it correctly. + +By default, `benefits/core/migrations/local_fixtures.json`. + ### `DJANGO_DB_RESET` -!!! warning "Deployment configuration" +!!! info "Local configuration" - You may change this setting when deploying the app to a non-localhost domain + This setting only affects the app running on localhost Boolean: @@ -73,7 +113,7 @@ Boolean: !!! tldr "Django docs" - [Settings: `DEBUG`](https://docs.djangoproject.com/en/4.0/ref/settings/#debug) + [Settings: `DEBUG`](https://docs.djangoproject.com/en/5.0/ref/settings/#debug) Boolean: @@ -102,7 +142,7 @@ From inside the container, the app is always listening on port `8000`. !!! tldr "Django docs" - [Settings: `LOGGING_CONFIG`](https://docs.djangoproject.com/en/4.0/ref/settings/#logging-config) + [Settings: `LOGGING_CONFIG`](https://docs.djangoproject.com/en/5.0/ref/settings/#logging-config) The log level used in the application's logging configuration. @@ -116,45 +156,33 @@ By default the application sends logs to `stdout`. !!! tldr "Django docs" - [Settings: `SECRET_KEY`](https://docs.djangoproject.com/en/4.0/ref/settings/#secret-key) + [Settings: `SECRET_KEY`](https://docs.djangoproject.com/en/5.0/ref/settings/#secret-key) Django's primary secret, keep this safe! ### `DJANGO_SUPERUSER_EMAIL` -!!! warning "Deployment configuration" - - You may change this setting when deploying the app to a non-localhost domain - -!!! danger "Required configuration" +!!! info "Local configuration" - This setting is required when `DJANGO_ADMIN` is `true` + This setting only affects the app running on localhost -The email address of the Django Admin superuser created during initialization. +The email address of the Django Admin superuser created when resetting the database. ### `DJANGO_SUPERUSER_PASSWORD` -!!! warning "Deployment configuration" - - You may change this setting when deploying the app to a non-localhost domain - -!!! danger "Required configuration" +!!! info "Local configuration" - This setting is required when `DJANGO_ADMIN` is `true` + This setting only affects the app running on localhost -The password of the Django Admin superuser created during initialization. +The password of the Django Admin superuser created when resetting the database. ### `DJANGO_SUPERUSER_USERNAME` -!!! warning "Deployment configuration" - - You may change this setting when deploying the app to a non-localhost domain - -!!! danger "Required configuration" +!!! info "Local configuration" - This setting is required when `DJANGO_ADMIN` is `true` + This setting only affects the app running on localhost -The username of the Django Admin superuser created during initialization. +The username of the Django Admin superuser created when resetting the database. ### `DJANGO_TRUSTED_ORIGINS` @@ -164,7 +192,7 @@ The username of the Django Admin superuser created during initialization. !!! tldr "Django docs" - [Settings: `CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#csrf-trusted-origins) + [Settings: `CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/5.0/ref/settings/#csrf-trusted-origins) Comma-separated list of hosts which are trusted origins for unsafe requests (e.g. POST) @@ -219,7 +247,9 @@ Enables [sending events to Sentry](../../deployment/troubleshooting/#error-monit [`environment` config value](https://docs.sentry.io/platforms/python/configuration/options/#environment) -Segments errors by which deployment they occur in. This defaults to `local`, and can be set to match one of the [environment names](../../deployment/infrastructure/#environments). +Segments errors by which deployment they occur in. This defaults to `dev`, and can be set to match one of the [environment names](../../deployment/infrastructure/#environments). + +`local` may also be used for local testing of the Sentry integration. ### `SENTRY_REPORT_URI` diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 697325fae..c3e7046cc 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -1,6 +1,6 @@ # Overview -[dev-benefits.calitp.org][dev-benefits] is currently deployed into a Microsoft Azure account provided by [California Department of Technology (CDT)'s Office of Enterprise Technology (OET)][oet], a.k.a. the "DevSecOps" team. More specifically, it uses [custom containers][app-service-containers] on [Azure App Service][app-service]. [More about the infrastructure.](infrastructure.md) +The Benefits app is currently deployed into a Microsoft Azure account provided by [California Department of Technology (CDT)'s Office of Enterprise Technology (OET)][oet], a.k.a. the "DevSecOps" team. More specifically, it uses [custom containers][app-service-containers] on [Azure App Service][app-service]. [More about the infrastructure.](infrastructure.md) ## Deployment process @@ -20,18 +20,18 @@ You can view what Git commit is deployed for a given environment by visitng the ## Configuration -[Configuration settings](../configuration/README.md) are stored as Application Configuration variables in Azure. -[Data](../configuration/data.md) is loaded via Django data migrations. +Sensitive [configuration settings](../configuration/README.md) are maintained as Application Configuration variables in Azure, +referencing [Azure Key Vault secrets](https://azure.microsoft.com/en-us/products/key-vault/). Other non-sensitive configuration +is maintained directly in the configuration database via the [Django Admin](https://docs.djangoproject.com/en/5.0/ref/contrib/admin/). ## Docker images Docker images for each of the deploy branches are available from GitHub Container Registry (GHCR): -* [Repository Package page](https://github.com/cal-itp/benefits/pkgs/container/benefits) -* Image path: `ghcr.io/cal-itp/benefits` -* Image tags: `dev`, `test`, `prod` +- [Repository Package page](https://github.com/cal-itp/benefits/pkgs/container/benefits) +- Image path: `ghcr.io/cal-itp/benefits` +- Image tags: `dev`, `test`, `prod` -[dev-benefits]: https://dev-benefits.calitp.org [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 diff --git a/docs/deployment/infrastructure.md b/docs/deployment/infrastructure.md index 97afea83e..14047ae23 100644 --- a/docs/deployment/infrastructure.md +++ b/docs/deployment/infrastructure.md @@ -131,6 +131,7 @@ Terraform is [`plan`](https://www.terraform.io/cli/commands/plan)'d when code is ./init.sh ``` +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. 1. Make changes to Terraform files. 1. Preview the changes, as necessary. diff --git a/docs/development/i18n.md b/docs/development/i18n.md index 093a7e102..7261011bd 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -2,9 +2,9 @@ !!! tldr "Django docs" - [Internationalization and localization](https://docs.djangoproject.com/en/4.0/topics/i18n/) + [Internationalization and localization](https://docs.djangoproject.com/en/5.0/topics/i18n/) - [Translation](https://docs.djangoproject.com/en/4.0/topics/i18n/translation/) + [Translation](https://docs.djangoproject.com/en/5.0/topics/i18n/translation/) !!! example "Message files" @@ -12,7 +12,7 @@ The Cal-ITP Benefits application is fully internationalized and available in both English and Spanish. -It uses Django's built-in support for translation using [message files](https://docs.djangoproject.com/en/4.0/topics/i18n/#term-message-file), which contain entries of `msgid`/`msgstr` pairs. The `msgid` is referenced in source code so that Django takes care of showing the `msgstr` for the user's language. +It uses Django's built-in support for translation using [message files](https://docs.djangoproject.com/en/5.0/topics/i18n/#term-message-file), which contain entries of `msgid`/`msgstr` pairs. The `msgid` is referenced in source code so that Django takes care of showing the `msgstr` for the user's language. ## Updating message files @@ -42,7 +42,7 @@ When templates have different copy per agency, create a new template for that ag ### Fuzzy strings -From [Django docs](https://docs.djangoproject.com/en/4.0/topics/i18n/translation/#message-files): +From [Django docs](https://docs.djangoproject.com/en/5.0/topics/i18n/translation/#message-files): > `makemessages` sometimes generates translation entries marked as fuzzy, e.g. when translations are inferred from previously translated strings. diff --git a/docs/development/models-migrations.md b/docs/development/models-migrations.md index 413289b9c..631fcc33d 100644 --- a/docs/development/models-migrations.md +++ b/docs/development/models-migrations.md @@ -6,24 +6,17 @@ [`benefits/core/migrations/0001_initial.py`][core-migrations] - [`benefits/core/migrations/0002_data.py`][data-migrations] - Cal-ITP Benefits defines a number of [models][core-models] in the core application, used throughout the codebase to configure different parts of the UI and logic. -The Cal-ITP Benefits database is a simple read-only Sqlite database, initialized from the [data migration](../configuration/data.md) files. - -## Migrations - -The database is rebuilt from scratch each time the container starts. We maintain a few [migration][migrations] files that set up the schema and load initial data. - -These files always represent the current schema and data for the database and match the current structure of the model classes. +The Cal-ITP Benefits database is a simple Sqlite database that mostly acts as a read-only configuration store. +Runtime configuration changes can be persisted via [Django's Admin interface](https://docs.djangoproject.com/en/5.0/ref/contrib/admin/). ## Updating models -When models are updated, the migration should be updated as well. +When models are updated, new migrations must be generated to reflect those changes into the configuration database. -A simple helper script exists to regenerate the migration file based on the current state of models in the local directory: +A simple helper script exists to generate migrations based on the current state of models in the local directory: [`bin/makemigrations.sh`][makemigrations] @@ -33,15 +26,11 @@ bin/makemigrations.sh This script: -1. Copies the existing migration files to a temporary directory 1. Runs the django `makemigrations` command -1. Copies back any migration files that are missing (data migration file) -1. Formats the newly regenerated schema migration file with `black` +1. Formats the newly regenerated migration file with `black` -This will result in a simple diff of changes on the schema migration file. Commit these changes (including the timestamp!) along with the model changes. +Commit the new migration file along with the model changes. [core-models]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/models.py [core-migrations]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/migrations/0001_initial.py -[data-migrations]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/migrations/0002_data.py [makemigrations]: https://github.com/cal-itp/benefits/blob/dev/bin/makemigrations.sh -[migrations]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/migrations diff --git a/docs/enrollment-pathways/README.md b/docs/enrollment-pathways/README.md index a0464a970..2d46283f7 100644 --- a/docs/enrollment-pathways/README.md +++ b/docs/enrollment-pathways/README.md @@ -14,17 +14,9 @@ See our [Milestones][milestones] for current work tracked against specific featu ## Product roadmap -See our [Product Roadmap][roadmap] for more information on planned feature development and prioritization. - - +See our Product Roadmap for more information on planned feature development and prioritization. + +![Cal-ITP Benefits Product Roadmap](img/roadmap.png) [board]: https://github.com/orgs/cal-itp/projects/8/views/1 [milestones]: https://github.com/cal-itp/benefits/milestones diff --git a/docs/enrollment-pathways/img/roadmap.png b/docs/enrollment-pathways/img/roadmap.png new file mode 100644 index 000000000..df980309e Binary files /dev/null and b/docs/enrollment-pathways/img/roadmap.png differ diff --git a/docs/enrollment-pathways/low-income.md b/docs/enrollment-pathways/low-income.md new file mode 100644 index 000000000..ced5deada --- /dev/null +++ b/docs/enrollment-pathways/low-income.md @@ -0,0 +1,115 @@ +# Low-income + +## Overview + +This use case describes a feature in the Cal-ITP Benefits app that allows Californians to verify their active participation in the CalFresh Program—as a proxy for low-income status—to receive reduced fares for transit when paying by contactless debit or credit card at participating transit operators in California. + +**Actor:** A person who uses public transit in California. For benefit eligibility, a “low-income rider” is a person who has received [CalFresh benefits](https://www.cdss.ca.gov/food-nutrition/calfresh) in any of the previous three months. + +**Goal:** To verify a transit rider’s financial need so they receive reduced fares when paying by contactless debit or credit card. + +**Precondition:** The California transit operator offers fixed route service, has installed and tested validator hardware necessary to collect fares using contactless payment on bus or rail lines, and the operator has a policy in place to offer a transit discount to low-income riders. + +## Basic Flow + +```mermaid +sequenceDiagram +autonumber +%% Low-income Rider Enrollment Pathway + actor Transit Rider + participant Benefits as Benefits app + participant IdG as Identity Gateway + participant Login.gov + participant CDSS + participant Littlepay +Transit Rider->>Benefits: visits benefits.calitp.org + activate Benefits +Benefits-->>IdG: eligibility verification + activate IdG +Transit Rider->>Login.gov: account authentication + activate Login.gov +IdG-->>Login.gov: requests required PII + activate Login.gov + Note right of Login.gov: first name
last name
Social Security number
date of birth +Login.gov-->>IdG: returns required PII + deactivate Login.gov +IdG-->>CDSS: check Calfresh enrollment status + activate CDSS +CDSS-->>IdG: return Calfresh enrollment status + deactivate CDSS +IdG-->>Benefits: eligibility response + deactivate IdG + deactivate Login.gov +Benefits-->>Littlepay: payment enrollment start + activate Littlepay +Transit Rider->>Littlepay: provides debit or credit card details +Littlepay-->>Benefits: payment method enrollment confirmation + deactivate Littlepay + deactivate Benefits +``` + +1. The transit rider visits the web application at `benefits.calitp.org` in a browser on their desktop computer. + +1. The transit rider chooses the transit operator that serves their area. + +1. The transit rider chooses to verify their eligibility as a participant in the [CalFresh Program](https://www.cdss.ca.gov/food-nutrition/calfresh). + +1. The transit rider authenticates with their existing [Login.gov](Login.gov) account or, if they don’t have one, creates a [Login.gov](Login.gov) account. + +1. The Cal-ITP Benefits app interfaces with the [California Department of Technology Identity Gateway](https://digitalidstrategy.cdt.ca.gov/primary-elements.html) (IdG) to verify benefit eligibility. The IdG uses personal information shared by [Login.gov](Login.gov) to verify CalFresh participation status. + +1. The IdG uses the response provided by the California Department of Social Services (CDSS) to determine the rider’s eligibility for a transit benefit. + +1. The IdG then passes the response from CDSS as low-income status = TRUE to the Cal-ITP Benefits app to indicate the person is eligible for a benefit. + +1. The transit rider provides the debit or credit card details they use to pay for transit to Littlepay, the payment processor that facilitates transit fare collection. + +1. The app registers the low-income benefit with the transit rider’s debit or credit card. + +## Alternative Flows + +- Suppose the transit rider does not have a desktop computer. In this case, they open the web application at `benefits.calitp.org` in a mobile browser on their iOS or Android tablet or mobile device to complete enrollment using the basic flow. + +- Suppose the transit rider cannot authenticate with [Login.gov](Login.gov), or will not create an account. In either case, the app cannot determine their CalFresh Program participation status and they cannot enroll their contactless debit or credit card for a reduced fare. + +- Suppose the IdG returns a status of `FALSE` for CalFresh Program participation status. In that case, the Cal-ITP Benefits app will not allow the transit rider to enroll their contactless debit or credit card for a reduced fare. + +- Suppose the debit or credit card expires or is canceled by the issuer. In that case, the transit rider must repeat the basic flow to register a new debit or credit card. + +- When the initial transit benefit enrollment period ends after one year from the date of enrollment, the transit rider must repeat the basic flow to re-enroll. + +- Suppose the transit rider attempts to re-enroll for a transit benefit as a CalFresh Program participant three months after their enrollment period started. The app will inform them they must wait re-enroll within 14 days of the benefit expiration. + +- Suppose the transit rider doesn’t re-enroll for a transit benefit after one year, but continues paying for transit using the card they registered. The transit operator will charge the rider full fare. + +- If the transit rider uses more than one debit or credit card to pay for transit, they repeat the basic flow for each card. + +## Postcondition + +The transit rider receives a fare reduction each time they use the debit or credit card they registered to pay for transit rides. The number of times they can use the card to pay for transit is unlimited, but the benefit expires one year after enrollment. + +## Benefits + +- The transit rider no longer needs cash to pay for transit rides. + +- The transit rider doesn’t have to lock up funds on a closed-loop card offered by the transit agency. + +- The transit rider pays for transit rides with their debit or credit card, just as they do for groceries, a cup of coffee, or any other good or service. + +- The transit rider can enroll in a transit benefit from home when convenient; they do not have to visit a transit agency in person. + +- The transit rider does not have to prove income eligibility with the transit agency. The app simply uses their participation in the CalFresh program to confirm eligibility for a transit benefit. + +- The transit agency doesn't have to craft and policy for a low-icome discount; they simply use the approach implemented in the Cal-ITP Benefits application. As more agencies adopt the application, they also adopt a standard policy for transit benefits. + +- Secure state and federal solutions manage the transit rider’s personal identifiable information (PII): [Login.gov](Login.gov) and the California Department of Technology Identity Gateway (IdG). Transit riders do not have to share personal information with local transit operators. + +- Benefit enrollment takes minutes rather than days or weeks. + +- Benefit enrollment doesn’t require online accounts with private companies. + +## Example Scenario + +A CalFresh Program participant uses public transit regularly. They don’t have a car and depend on buses to get to appointments and do errands that take too long to use their bicycle. Even though this person already qualifies for benefits from the California Department of Social Services, they had to navigate another extensive, in-person eligibility process with different requirements to qualify for reduced fares from their local transit agency. They now receive a 50% fare reduction but have to pay for transit rides using the closed loop card provided by the operator to receive the reduced fare. It’s frustrating and inconvenient to reload this closed loop card in $10 payments every week, especially because they sometimes they could use the money tied up on the card to make ends meet. In summary, this person pays for daily expenses using three forms of payment: their Electronic Benefits Transfer (EBT) card for eligible items, their agency card for transportation, and their bank card (or cash) for everything else. + +The transit operator serving their region of California implements contactless payments on fixed bus routes throughout the service area. This rider uses `benefits.calitp.org` on their mobile device to confirm their participation in the CalFresh Program offered by CDSS and registers their debit card for reduced fares. They tap to pay when boarding buses in their area and are automatically charged the reduced fare. While they still need to manage funds on their EBT card *and* their bank card, they no longer need to use their transit operator card to pay for transit. Best of all, they have complete access to all funds in their weekly budget. If other expenses are higher one week, they can allocate additional funds to those areas and ride transit less. diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 77a1e22ff..ac2b5475c 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -12,10 +12,10 @@ git clone https://github.com/cal-itp/benefits ## Create an environment file -The application is configured with defaults to run locally, but an `.env` file is required to run with Docker Compose. This file can be empty, or environment overrides can be added as needed: +The application is configured with defaults to run locally, but an `.env` file is required to run with Docker Compose. Start from the existing sample: ```bash -touch .env +cp .env.sample .env ``` E.g. to change the localhost port from the default `8000` to `9000`, add the following line to your `.env` file: @@ -56,8 +56,7 @@ docker compose up client After initialization, the client is running running on `http://localhost:8000` by default. -If `DJANGO_ADMIN=true`, the backend administrative interface can be accessed at the `/admin` route using the superuser account -you setup as part of initialization. +The backend administrative interface can be accessed at the `/admin` route using the superuser account you setup as part of initialization. By default, sample values are used to initialize Django. Alternatively you may: diff --git a/docs/product-and-design/.pages b/docs/product-and-design/.pages index 6fbcf6eb0..2b0f7392a 100644 --- a/docs/product-and-design/.pages +++ b/docs/product-and-design/.pages @@ -4,4 +4,3 @@ nav: - analytics.md - copy-delivery.md - copy-style.md -- Design style guide: https://www.figma.com/proto/SeSd3LaLd6WkbEYhmtKpO3/Benefits-(IAL2-Login.gov)?node-id=4942%3A17385&scaling=scale-down&page-id=4890%3A17182 diff --git a/docs/product-and-design/copy-delivery.md b/docs/product-and-design/copy-delivery.md index 443ead508..ef920fc19 100644 --- a/docs/product-and-design/copy-delivery.md +++ b/docs/product-and-design/copy-delivery.md @@ -10,9 +10,9 @@ Translation strings include all application copy, including: - In-line link URLs - Error messages (like [no script](https://github.com/cal-itp/benefits/blob/dev/benefits/core/templates/core/includes/noscript.html), [no cookies](https://github.com/cal-itp/benefits/blob/dev/benefits/core/templates/core/includes/nocookies.html) warnings) -## Cal-ITP Benefits Application Copy (Configurable Strings) +## Cal-ITP Benefits Application Copy -The human-readable version of the English and Spanish translation strings for the application are delivered to Design and Engineering by Product, and live at this link: [Cal-ITP Benefits Application Copy (Configurable Strings)](https://docs.google.com/spreadsheets/d/1_Gi_YbJr4ZuXCOsnOWaewvHqUO1nC1nKqiVDHvw0118/edit#gid=0). +The human-readable version of the English and Spanish translation strings for the application are delivered to Design and Engineering by Product, and live at this link: [Cal-ITP Benefits Application Copy](https://docs.google.com/spreadsheets/d/1_Gi_YbJr4ZuXCOsnOWaewvHqUO1nC1nKqiVDHvw0118/edit#gid=0). By tabs: @@ -27,14 +27,14 @@ By tabs: - Engage with copy writers to get the English language copy drafted, proofed and ready for design. - Engage with client editorial/communications team to ensure English language and Spanish language copy are edited according to client style guides. - Engage all necessary stakeholders to get English language copy approved and ready for design. -- Compile copy in [Cal-ITP Benefits Application Copy (Configurable Strings)](https://docs.google.com/spreadsheets/d/1_Gi_YbJr4ZuXCOsnOWaewvHqUO1nC1nKqiVDHvw0118/edit#gid=0), ready to be used by Design, so Design can sync the spreadsheet to Figma. +- Compile copy in [Cal-ITP Benefits Application Copy](https://docs.google.com/spreadsheets/d/1_Gi_YbJr4ZuXCOsnOWaewvHqUO1nC1nKqiVDHvw0118/edit#gid=0), ready to be used by Design, so Design can sync the spreadsheet to Figma. - Engage with the translation agency, [iBabbleOn](https://ibabbleon.com/), to get Spanish translations ready for Engineering. - Transfer translations from iBabbleOn to the spreadsheet, in proper format. - Ensure English and Spanish copy is ready for Engineering. ### Design -- Sync copy from [Cal-ITP Benefits Application Copy (Configurable Strings)](https://docs.google.com/spreadsheets/d/1_Gi_YbJr4ZuXCOsnOWaewvHqUO1nC1nKqiVDHvw0118/edit#gid=0) into Figma. +- Sync copy from [Cal-ITP Benefits Application Copy](https://docs.google.com/spreadsheets/d/1_Gi_YbJr4ZuXCOsnOWaewvHqUO1nC1nKqiVDHvw0118/edit#gid=0) into Figma. - Ensure the string is in the appropriate column (e.g. `Subtitle`, `ButtonLabel`) ### Engineering diff --git a/docs/requirements.txt b/docs/requirements.txt index 028622c74..0211c4936 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ mdx_truly_sane_lists -mkdocs +mkdocs==1.5.3 mkdocs-awesome-pages-plugin mkdocs-macros-plugin -mkdocs-material +mkdocs-material==9.5.12 mkdocs-redirects diff --git a/docs/tests/automated-tests.md b/docs/tests/automated-tests.md index f8062b93d..1b4e6e894 100644 --- a/docs/tests/automated-tests.md +++ b/docs/tests/automated-tests.md @@ -21,25 +21,31 @@ will install `cypress` and its dependencies on your machine. Make sure to run th If not, [install Node.js](https://nodejs.org/en/download/) locally. -2. Start the the application container: +1. Start the local eligibility verification server: ```bash - docker compose up -d client + docker compose up --detach server ``` -3. Change into the `cypress` directory: +1. Start the the application: + + ```bash + docker compose run --detach --service-ports client bin/test_start.sh + ``` + +1. Change into the `cypress` directory: ```bash cd tests/cypress ``` -4. Install all packages and `cypress`. Verify `cypress` installation succeeds: +1. Install all packages and `cypress`. Verify `cypress` installation succeeds: ```bash npm install ``` -5. Run `cypress` with test environment variables and configuration variables: +1. Run `cypress` with test environment variables and configuration variables: ```bash CYPRESS_baseUrl=http://localhost:8000 npm run cypress:open diff --git a/mkdocs.yml b/mkdocs.yml index 2e6881842..2ec8dd6ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,8 +50,8 @@ markdown_extensions: linenums: true - mdx_truly_sane_lists - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.inlinehilite - pymdownx.tasklist: custom_checkbox: true diff --git a/pyproject.toml b/pyproject.toml index f38537d97..18c61600b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "benefits" -version = "2024.01.1" +version = "2024.03.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" } @@ -8,11 +8,14 @@ classifiers = ["Programming Language :: Python :: 3 :: Only"] requires-python = ">=3.9" dependencies = [ "Authlib==1.3.0", - "Django==5.0.1", - "django-csp==3.7", + "azure-keyvault-secrets==4.8.0", + "azure-identity==1.15.0", + "Django==5.0.2", + "django-csp==3.8", + "django-google-sso==5.0.0", "eligibility-api==2023.9.1", "requests==2.31.0", - "sentry-sdk==1.40.0", + "sentry-sdk==1.40.6", "six==1.16.0", ] diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index ac67d9d3f..515d05263 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/azurerm" { version = "3.37.0" constraints = "~> 3.0" hashes = [ + "h1:83XTgyPKUKt706IjTLHo9HL0KN5m+DwmSKuVQv6dNb4=", "h1:tD9TmGFgYV/oxZQu0pXuA46H+ML9nALCDwFqoaETjGg=", "h1:yBkhudX4uTCZAUU85SVr10C40bVlK6kAVGU6IiTUWSU=", "zh:2a7bda0b7679d1c791c762103a22f333b544b6e6776c4177f33bafc9cc28c919", diff --git a/terraform/app_service.tf b/terraform/app_service.tf index 37db1d5ac..1b3679022 100644 --- a/terraform/app_service.tf +++ b/terraform/app_service.tf @@ -66,15 +66,10 @@ resource "azurerm_linux_web_app" "main" { "REQUESTS_READ_TIMEOUT" = "${local.secret_prefix}requests-read-timeout)", # Django settings - "DJANGO_ADMIN" = "${local.secret_prefix}django-admin)", - "DJANGO_ALLOWED_HOSTS" = "${local.secret_prefix}django-allowed-hosts)", - "DJANGO_DB_DIR" = "${local.secret_prefix}django-db-dir)", - "DJANGO_DB_RESET" = "${local.secret_prefix}django-db-reset)", - "DJANGO_DEBUG" = local.is_prod ? null : "${local.secret_prefix}django-debug)", - "DJANGO_LOG_LEVEL" = "${local.secret_prefix}django-log-level)", - "DJANGO_SUPERUSER_EMAIL" = "${local.secret_prefix}django-superuser-email)", - "DJANGO_SUPERUSER_PASSWORD" = "${local.secret_prefix}django-superuser-password)", - "DJANGO_SUPERUSER_USERNAME" = "${local.secret_prefix}django-superuser-username)", + "DJANGO_ALLOWED_HOSTS" = "${local.secret_prefix}django-allowed-hosts)", + "DJANGO_DB_DIR" = "${local.secret_prefix}django-db-dir)", + "DJANGO_DEBUG" = local.is_prod ? null : "${local.secret_prefix}django-debug)", + "DJANGO_LOG_LEVEL" = "${local.secret_prefix}django-log-level)", "DJANGO_RECAPTCHA_SECRET_KEY" = local.is_dev ? null : "${local.secret_prefix}django-recaptcha-secret-key)", "DJANGO_RECAPTCHA_SITE_KEY" = local.is_dev ? null : "${local.secret_prefix}django-recaptcha-site-key)", @@ -84,103 +79,20 @@ resource "azurerm_linux_web_app" "main" { "HEALTHCHECK_USER_AGENTS" = local.is_dev ? null : "${local.secret_prefix}healthcheck-user-agents)", + # Google SSO for Admin + + "GOOGLE_SSO_CLIENT_ID" = "${local.secret_prefix}google-sso-client-id)", + "GOOGLE_SSO_PROJECT_ID" = "${local.secret_prefix}google-sso-project-id)", + "GOOGLE_SSO_CLIENT_SECRET" = "${local.secret_prefix}google-sso-client-secret)", + "GOOGLE_SSO_ALLOWABLE_DOMAINS" = "${local.secret_prefix}google-sso-allowable-domains)", + "GOOGLE_SSO_STAFF_LIST" = "${local.secret_prefix}google-sso-staff-list)", + "GOOGLE_SSO_SUPERUSER_LIST" = "${local.secret_prefix}google-sso-superuser-list)" + # Sentry "SENTRY_DSN" = "${local.secret_prefix}sentry-dsn)", "SENTRY_ENVIRONMENT" = local.env_name, "SENTRY_REPORT_URI" = "${local.secret_prefix}sentry-report-uri)", "SENTRY_TRACES_SAMPLE_RATE" = "${local.secret_prefix}sentry-traces-sample-rate)", - - # Environment variables for data migration - "MST_SENIOR_GROUP_ID" = "${local.secret_prefix}mst-senior-group-id)", - "MST_VETERAN_GROUP_ID" = "${local.secret_prefix}mst-veteran-group-id)", - "MST_COURTESY_CARD_GROUP_ID" = "${local.secret_prefix}mst-courtesy-card-group-id)" - "SACRT_SENIOR_GROUP_ID" = "${local.secret_prefix}sacrt-senior-group-id)" - "SBMTD_SENIOR_GROUP_ID" = "${local.secret_prefix}sbmtd-senior-group-id)", - "SBMTD_MOBILITY_PASS_GROUP_ID" = "${local.secret_prefix}sbmtd-mobility-pass-group-id)" - "CLIENT_PRIVATE_KEY" = "${local.secret_prefix}client-private-key)" - "CLIENT_PUBLIC_KEY" = "${local.secret_prefix}client-public-key)" - "MST_SERVER_PUBLIC_KEY_URL" = "${local.secret_prefix}mst-server-public-key-url)" - "SBMTD_SERVER_PUBLIC_KEY_URL" = "${local.secret_prefix}sbmtd-server-public-key-url)" - "MST_PAYMENT_PROCESSOR_CLIENT_CERT" = "${local.secret_prefix}mst-payment-processor-client-cert)" - "MST_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY" = "${local.secret_prefix}mst-payment-processor-client-cert-private-key)" - "MST_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA" = "${local.secret_prefix}mst-payment-processor-client-cert-root-ca)" - "SACRT_PAYMENT_PROCESSOR_CLIENT_CERT" = "${local.secret_prefix}sacrt-payment-processor-client-cert)" - "SACRT_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY" = "${local.secret_prefix}sacrt-payment-processor-client-cert-private-key)" - "SACRT_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA" = "${local.secret_prefix}sacrt-payment-processor-client-cert-root-ca)" - "SBMTD_PAYMENT_PROCESSOR_CLIENT_CERT" = "${local.secret_prefix}sbmtd-payment-processor-client-cert)" - "SBMTD_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY" = "${local.secret_prefix}sbmtd-payment-processor-client-cert-private-key)" - "SBMTD_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA" = "${local.secret_prefix}sbmtd-payment-processor-client-cert-root-ca)" - "AUTH_PROVIDER_CLIENT_ID" = "${local.secret_prefix}auth-provider-client-id)" - "AUTH_PROVIDER_AUTHORITY" = "${local.secret_prefix}auth-provider-authority)" - "SENIOR_AUTH_PROVIDER_CLIENT_NAME" = "${local.secret_prefix}senior-auth-provider-client-name)" - "SENIOR_AUTH_PROVIDER_SCOPE" = "${local.secret_prefix}senior-auth-provider-scope)" - "SENIOR_AUTH_PROVIDER_CLAIM" = "${local.secret_prefix}senior-auth-provider-claim)" - "SENIOR_AUTH_PROVIDER_SCHEME" = "${local.secret_prefix}senior-auth-provider-scheme)" - "VETERAN_AUTH_PROVIDER_CLIENT_NAME" = "${local.secret_prefix}veteran-auth-provider-client-name)" - "VETERAN_AUTH_PROVIDER_SCOPE" = "${local.secret_prefix}veteran-auth-provider-scope)" - "VETERAN_AUTH_PROVIDER_CLAIM" = "${local.secret_prefix}veteran-auth-provider-claim)" - "VETERAN_AUTH_PROVIDER_SCHEME" = "${local.secret_prefix}veteran-auth-provider-scheme)" - "MST_SENIOR_VERIFIER_NAME" = "${local.secret_prefix}mst-senior-verifier-name)" - "MST_SENIOR_VERIFIER_ACTIVE" = "${local.secret_prefix}mst-senior-verifier-active)" - "MST_VETERAN_VERIFIER_NAME" = "${local.secret_prefix}mst-veteran-verifier-name)" - "MST_VETERAN_VERIFIER_ACTIVE" = "${local.secret_prefix}mst-veteran-verifier-active)" - "COURTESY_CARD_VERIFIER_NAME" = "${local.secret_prefix}courtesy-card-verifier-name)" - "COURTESY_CARD_VERIFIER_ACTIVE" = "${local.secret_prefix}courtesy-card-verifier-active)" - "COURTESY_CARD_VERIFIER_API_URL" = "${local.secret_prefix}courtesy-card-verifier-api-url)" - "COURTESY_CARD_VERIFIER_API_AUTH_HEADER" = "${local.secret_prefix}courtesy-card-verifier-api-auth-header)" - "COURTESY_CARD_VERIFIER_API_AUTH_KEY" = "${local.secret_prefix}courtesy-card-verifier-api-auth-key)" - "COURTESY_CARD_VERIFIER_JWE_CEK_ENC" = "${local.secret_prefix}courtesy-card-verifier-jwe-cek-enc)" - "COURTESY_CARD_VERIFIER_JWE_ENCRYPTION_ALG" = "${local.secret_prefix}courtesy-card-verifier-jwe-encryption-alg)" - "COURTESY_CARD_VERIFIER_JWS_SIGNING_ALG" = "${local.secret_prefix}courtesy-card-verifier-jws-signing-alg)" - "SACRT_SENIOR_VERIFIER_NAME" = "${local.secret_prefix}sacrt-senior-verifier-name)" - "SACRT_SENIOR_VERIFIER_ACTIVE" = "${local.secret_prefix}sacrt-senior-verifier-active)" - "SBMTD_SENIOR_VERIFIER_NAME" = "${local.secret_prefix}sbmtd-senior-verifier-name)" - "SBMTD_SENIOR_VERIFIER_ACTIVE" = "${local.secret_prefix}sbmtd-senior-verifier-active)" - "MST_PAYMENT_PROCESSOR_NAME" = "${local.secret_prefix}mst-payment-processor-name)" - "MST_PAYMENT_PROCESSOR_API_BASE_URL" = "${local.secret_prefix}mst-payment-processor-api-base-url)" - "MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT" = "${local.secret_prefix}mst-payment-processor-api-access-token-endpoint)" - "MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY" = "${local.secret_prefix}mst-payment-processor-api-access-token-request-key)" - "MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL" = "${local.secret_prefix}mst-payment-processor-api-access-token-request-val)" - "MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL" = "${local.secret_prefix}mst-payment-processor-card-tokenize-url)" - "MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC" = "${local.secret_prefix}mst-payment-processor-card-tokenize-func)" - "MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV" = "${local.secret_prefix}mst-payment-processor-card-tokenize-env)" - "SACRT_PAYMENT_PROCESSOR_NAME" = "${local.secret_prefix}sacrt-payment-processor-name)" - "SACRT_PAYMENT_PROCESSOR_API_BASE_URL" = "${local.secret_prefix}sacrt-payment-processor-api-base-url)" - "SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT" = "${local.secret_prefix}sacrt-payment-processor-api-access-token-endpoint)" - "SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY" = "${local.secret_prefix}sacrt-payment-processor-api-access-token-request-key)" - "SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL" = "${local.secret_prefix}sacrt-payment-processor-api-access-token-request-val)" - "SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL" = "${local.secret_prefix}sacrt-payment-processor-card-tokenize-url)" - "SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC" = "${local.secret_prefix}sacrt-payment-processor-card-tokenize-func)" - "SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV" = "${local.secret_prefix}sacrt-payment-processor-card-tokenize-env)" - "SBMTD_PAYMENT_PROCESSOR_NAME" = "${local.secret_prefix}sbmtd-payment-processor-name)" - "SBMTD_PAYMENT_PROCESSOR_API_BASE_URL" = "${local.secret_prefix}sbmtd-payment-processor-api-base-url)" - "SBMTD_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT" = "${local.secret_prefix}sbmtd-payment-processor-api-access-token-endpoint)" - "SBMTD_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY" = "${local.secret_prefix}sbmtd-payment-processor-api-access-token-request-key)" - "SBMTD_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL" = "${local.secret_prefix}sbmtd-payment-processor-api-access-token-request-val)" - "SBMTD_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL" = "${local.secret_prefix}sbmtd-payment-processor-card-tokenize-url)" - "SBMTD_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC" = "${local.secret_prefix}sbmtd-payment-processor-card-tokenize-func)" - "SBMTD_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV" = "${local.secret_prefix}sbmtd-payment-processor-card-tokenize-env)" - "MOBILITY_PASS_VERIFIER_NAME" = "${local.secret_prefix}mobility-pass-verifier-name)" - "MOBILITY_PASS_VERIFIER_ACTIVE" = "${local.secret_prefix}mobility-pass-verifier-active)" - "MOBILITY_PASS_VERIFIER_API_URL" = "${local.secret_prefix}mobility-pass-verifier-api-url)" - "MOBILITY_PASS_VERIFIER_API_AUTH_HEADER" = "${local.secret_prefix}mobility-pass-verifier-api-auth-header)" - "MOBILITY_PASS_VERIFIER_API_AUTH_KEY" = "${local.secret_prefix}mobility-pass-verifier-api-auth-key)" - "MOBILITY_PASS_VERIFIER_JWE_CEK_ENC" = "${local.secret_prefix}mobility-pass-verifier-jwe-cek-enc)" - "MOBILITY_PASS_VERIFIER_JWE_ENCRYPTION_ALG" = "${local.secret_prefix}mobility-pass-verifier-jwe-encryption-alg)" - "MOBILITY_PASS_VERIFIER_JWS_SIGNING_ALG" = "${local.secret_prefix}mobility-pass-verifier-jws-signing-alg)" - "MST_AGENCY_SHORT_NAME" = "${local.secret_prefix}mst-agency-short-name)" - "MST_AGENCY_LONG_NAME" = "${local.secret_prefix}mst-agency-long-name)" - "MST_AGENCY_JWS_SIGNING_ALG" = "${local.secret_prefix}mst-agency-jws-signing-alg)" - "SACRT_AGENCY_SHORT_NAME" = "${local.secret_prefix}sacrt-agency-short-name)" - "SACRT_AGENCY_LONG_NAME" = "${local.secret_prefix}sacrt-agency-long-name)" - "SACRT_AGENCY_MERCHANT_ID" = "${local.secret_prefix}sacrt-agency-merchant-id)" - "SACRT_AGENCY_ACTIVE" = "${local.secret_prefix}sacrt-agency-active)" - "SACRT_AGENCY_JWS_SIGNING_ALG" = "${local.secret_prefix}sacrt-agency-jws-signing-alg)" - "SBMTD_AGENCY_SHORT_NAME" = "${local.secret_prefix}sbmtd-agency-short-name)" - "SBMTD_AGENCY_LONG_NAME" = "${local.secret_prefix}sbmtd-agency-long-name)" - "SBMTD_AGENCY_MERCHANT_ID" = "${local.secret_prefix}sbmtd-agency-merchant-id)" - "SBMTD_AGENCY_ACTIVE" = "${local.secret_prefix}sbmtd-agency-active)" - "SBMTD_AGENCY_JWS_SIGNING_ALG" = "${local.secret_prefix}sbmtd-agency-jws-signing-alg)" } storage_account { diff --git a/terraform/storage.tf b/terraform/storage.tf index b69c44159..891187e04 100644 --- a/terraform/storage.tf +++ b/terraform/storage.tf @@ -23,6 +23,45 @@ resource "azurerm_storage_account" "main" { } } +resource "azurerm_recovery_services_vault" "main" { + name = "rsvcdtcalitp${lower(local.env_letter)}001" + location = data.azurerm_resource_group.main.location + resource_group_name = data.azurerm_resource_group.main.name + sku = "Standard" + soft_delete_enabled = true + + lifecycle { + ignore_changes = [tags] + } +} + +resource "azurerm_backup_container_storage_account" "main" { + resource_group_name = data.azurerm_resource_group.main.name + recovery_vault_name = azurerm_recovery_services_vault.main.name + storage_account_id = azurerm_storage_account.main.id +} + +resource "azurerm_backup_policy_file_share" "policy" { + name = "${azurerm_storage_account.main.name}-backup-policy" + resource_group_name = data.azurerm_resource_group.main.name + recovery_vault_name = azurerm_recovery_services_vault.main.name + timezone = "UTC" + + backup { + frequency = "Daily" + time = "14:00" + } + + retention_daily { + count = 1 + } + + retention_weekly { + count = 5 + weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + } +} + resource "azurerm_storage_share" "data" { name = "benefits-data" storage_account_name = azurerm_storage_account.main.name diff --git a/terraform/terraform.tfvars.sample b/terraform/terraform.tfvars.sample new file mode 100644 index 000000000..7413007e5 --- /dev/null +++ b/terraform/terraform.tfvars.sample @@ -0,0 +1,2 @@ +DEVSECOPS_OBJECT_ID = "object-id" +ENGINEERING_GROUP_OBJECT_ID = "object-id" diff --git a/tests/cypress/fixtures/README.md b/tests/cypress/fixtures/README.md index 5fc857fec..023e67a56 100644 --- a/tests/cypress/fixtures/README.md +++ b/tests/cypress/fixtures/README.md @@ -1 +1 @@ -The [user data](users.json) corresponds to [the sample data for the eligibility server](https://github.com/cal-itp/eligibility-server/blob/dev/data/server.json). +The [user data](users.json) corresponds to [the sample data for the eligibility server](https://github.com/cal-itp/eligibility-server/blob/main/data/server.csv). diff --git a/tests/cypress/fixtures/transit-agencies.js b/tests/cypress/fixtures/transit-agencies.js index 6a9843f2e..7f30e7cfc 100644 --- a/tests/cypress/fixtures/transit-agencies.js +++ b/tests/cypress/fixtures/transit-agencies.js @@ -1,4 +1,10 @@ -const agency = require("../../../benefits/core/migrations/sample_agency.json"); -const agencies = [{ fields: agency }]; +// extract the "fields" object from the first TransitAgency model fixture + +const local_fixtures = require("../../../benefits/core/migrations/local_fixtures.json"); +const local_agencies = local_fixtures.filter( + (fixture) => fixture.model == "core.transitagency", +); +const first_agency_model = local_agencies[0]; +const agencies = [{ fields: first_agency_model.fields }]; export default agencies; diff --git a/tests/cypress/package-lock.json b/tests/cypress/package-lock.json index 122161e64..91d5a3c2e 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.6.4" + "cypress": "^13.6.6" } }, "node_modules/@colors/colors": { @@ -537,9 +537,9 @@ } }, "node_modules/cypress": { - "version": "13.6.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.4.tgz", - "integrity": "sha512-pYJjCfDYB+hoOoZuhysbbYhEmNW7DEDsqn+ToCLwuVowxUXppIWRr7qk4TVRIU471ksfzyZcH+mkoF0CQUKnpw==", + "version": "13.6.6", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.6.tgz", + "integrity": "sha512-S+2S9S94611hXimH9a3EAYt81QM913ZVA03pUmGDfLTFa5gyp85NJ8dJGSlEAEmyRsYkioS1TtnWtbv/Fzt11A==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -550,7 +550,7 @@ "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", @@ -568,7 +568,7 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.0", + "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -2358,9 +2358,9 @@ } }, "cypress": { - "version": "13.6.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.4.tgz", - "integrity": "sha512-pYJjCfDYB+hoOoZuhysbbYhEmNW7DEDsqn+ToCLwuVowxUXppIWRr7qk4TVRIU471ksfzyZcH+mkoF0CQUKnpw==", + "version": "13.6.6", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.6.tgz", + "integrity": "sha512-S+2S9S94611hXimH9a3EAYt81QM913ZVA03pUmGDfLTFa5gyp85NJ8dJGSlEAEmyRsYkioS1TtnWtbv/Fzt11A==", "dev": true, "requires": { "@cypress/request": "^3.0.0", @@ -2370,7 +2370,7 @@ "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", @@ -2388,7 +2388,7 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.0", + "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", diff --git a/tests/cypress/package.json b/tests/cypress/package.json index de9cbf6ba..8c42e2c6c 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.6.4" + "cypress": "^13.6.6" } } diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index d3c78c512..cc62540bc 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -32,11 +32,15 @@ def app_request(rf): return app_request +# autouse this fixture so we never call out to the real secret store +@pytest.fixture(autouse=True) +def mock_models_get_secret_by_name(mocker): + return mocker.patch("benefits.core.models.get_secret_by_name", return_value="secret value!") + + @pytest.fixture def model_PemData(): - data = PemData.objects.create( - text="-----BEGIN PUBLIC KEY-----\nPEM DATA\n-----END PUBLIC KEY-----\n", label="Test public key" - ) + data = PemData.objects.create(text_secret_name="pem-secret-data", label="Test public key") return data @@ -47,7 +51,7 @@ def model_AuthProvider(): sign_out_button_template="core/includes/button--sign-out--senior.html", sign_out_link_template="core/includes/link--sign-out--senior.html", client_name="Client", - client_id="1234", + client_id_secret_name="1234", authority="https://example.com", ) @@ -108,7 +112,7 @@ def model_EligibilityVerifier(model_PemData, model_EligibilityType): active=True, api_url="https://example.com/verify", api_auth_header="X-API-AUTH", - api_auth_key="secret-key", + api_auth_key_secret_name="secret-key", eligibility_type=model_EligibilityType, public_key=model_PemData, selection_label_template="eligibility/includes/selection-label.html", diff --git a/tests/pytest/core/test_admin.py b/tests/pytest/core/test_admin.py new file mode 100644 index 000000000..36bfa3434 --- /dev/null +++ b/tests/pytest/core/test_admin.py @@ -0,0 +1,64 @@ +import pytest +from django.contrib.auth.models import User +import benefits.core.admin +from benefits.core.admin import GOOGLE_USER_INFO_URL, pre_login_user + + +@pytest.fixture +def model_AdminUser(): + return User.objects.create(email="user@calitp.org", first_name="", last_name="", username="") + + +@pytest.mark.django_db +def test_admin_registered(client): + response = client.get("/admin", follow=True) + + assert response.status_code == 200 + assert ("/admin/", 301) in response.redirect_chain + assert ("/admin/login/?next=/admin/", 302) in response.redirect_chain + assert response.request["PATH_INFO"] == "/admin/login/" + assert "google_sso/login.html" in response.template_name + + +@pytest.mark.django_db +def test_pre_login_user(mocker, model_AdminUser): + assert model_AdminUser.email == "user@calitp.org" + assert model_AdminUser.first_name == "" + assert model_AdminUser.last_name == "" + assert model_AdminUser.username == "" + + response_from_google = { + "username": "admin@calitp.org", + "given_name": "Admin", + "family_name": "User", + "email": "admin@calitp.org", + } + + mocked_request = mocker.Mock() + mocked_response = mocker.Mock() + mocked_response.json.return_value = response_from_google + requests_spy = mocker.patch("benefits.core.admin.requests.get", return_value=mocked_response) + + pre_login_user(model_AdminUser, mocked_request) + + requests_spy.assert_called_once() + assert GOOGLE_USER_INFO_URL in requests_spy.call_args.args + assert model_AdminUser.email == response_from_google["email"] + assert model_AdminUser.first_name == response_from_google["given_name"] + assert model_AdminUser.last_name == response_from_google["family_name"] + assert model_AdminUser.username == response_from_google["username"] + + +@pytest.mark.django_db +def test_pre_login_user_no_session_token(mocker, model_AdminUser): + mocked_request = mocker.Mock() + mocked_request.session.get.return_value = None + logger_spy = mocker.spy(benefits.core.admin, "logger") + + pre_login_user(model_AdminUser, mocked_request) + + assert model_AdminUser.email == "user@calitp.org" + assert model_AdminUser.first_name == "" + assert model_AdminUser.last_name == "" + assert model_AdminUser.username == "" + logger_spy.warning.assert_called_once() diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py index 6df589ce0..2069b4d22 100644 --- a/tests/pytest/core/test_models.py +++ b/tests/pytest/core/test_models.py @@ -1,7 +1,27 @@ from django.conf import settings + import pytest -from benefits.core.models import EligibilityType, EligibilityVerifier, TransitAgency +from benefits.core.models import SecretNameField, EligibilityType, EligibilityVerifier, TransitAgency +import benefits.secrets + + +@pytest.fixture +def mock_requests_get_pem_data(mocker): + # intercept and spy on the GET request + return mocker.patch("benefits.core.models.requests.get", return_value=mocker.Mock(text="PEM text")) + + +def test_SecretNameField_init(): + field = SecretNameField() + + assert benefits.secrets.NAME_VALIDATOR in field.validators + assert field.max_length == 127 + assert field.blank is False + assert field.null is False + assert field.allow_unicode is False + assert field.description is not None + assert field.description != "" @pytest.mark.django_db @@ -10,27 +30,58 @@ def test_PemData_str(model_PemData): @pytest.mark.django_db -def test_PemData_data_text(model_PemData): - assert model_PemData.text - assert model_PemData.data == model_PemData.text +def test_PemData_data_text_secret_name(model_PemData, mock_models_get_secret_by_name): + # a secret name and not remote URL, should use secret value + + data = model_PemData.data + + mock_models_get_secret_by_name.assert_called_once_with(model_PemData.text_secret_name) + assert data == mock_models_get_secret_by_name.return_value @pytest.mark.django_db -def test_PemData_data_remote(model_PemData, mocker): - model_PemData.text = None +def test_PemData_data_remote(model_PemData, mock_requests_get_pem_data): + # a remote URL and no secret name, should use remote value + + model_PemData.text_secret_name = None model_PemData.remote_url = "http://localhost/publickey" - # intercept and spy on the GET request - requests_spy = mocker.patch("benefits.core.models.requests.get", return_value=mocker.Mock(text="PEM text")) + assert not model_PemData.text_secret_name + + data = model_PemData.data + + mock_requests_get_pem_data.assert_called_once_with(model_PemData.remote_url, timeout=settings.REQUESTS_TIMEOUT) + assert data == mock_requests_get_pem_data.return_value.text + + +@pytest.mark.django_db +def test_PemData_data_text_secret_name_and_remote__uses_text_secret( + model_PemData, mock_models_get_secret_by_name, mock_requests_get_pem_data +): + # a remote URL and the secret value is not None, should use the secret value + + model_PemData.remote_url = "http://localhost/publickey" + + data = model_PemData.data - assert not model_PemData.text + mock_models_get_secret_by_name.assert_called_once_with(model_PemData.text_secret_name) + mock_requests_get_pem_data.assert_called_once_with(model_PemData.remote_url, timeout=settings.REQUESTS_TIMEOUT) + assert data == mock_models_get_secret_by_name.return_value + + +@pytest.mark.django_db +def test_PemData_data_text_secret_name_and_remote__uses_remote( + model_PemData, mock_models_get_secret_by_name, mock_requests_get_pem_data +): + # a remote URL and the secret value is None, should use remote value + model_PemData.remote_url = "http://localhost/publickey" + mock_models_get_secret_by_name.return_value = None data = model_PemData.data - assert model_PemData.text - assert data == "PEM text" - assert data == model_PemData.text - requests_spy.assert_called_once_with(model_PemData.remote_url, timeout=settings.REQUESTS_TIMEOUT) + mock_models_get_secret_by_name.assert_called_once_with(model_PemData.text_secret_name) + mock_requests_get_pem_data.assert_called_once_with(model_PemData.remote_url, timeout=settings.REQUESTS_TIMEOUT) + assert data == mock_requests_get_pem_data.return_value.text @pytest.mark.django_db @@ -39,6 +90,14 @@ def test_model_AuthProvider(model_AuthProvider): assert model_AuthProvider.supports_sign_out +@pytest.mark.django_db +def test_model_AuthProvider_client_id(model_AuthProvider, mock_models_get_secret_by_name): + secret_value = model_AuthProvider.client_id + + mock_models_get_secret_by_name.assert_called_once_with(model_AuthProvider.client_id_secret_name) + assert secret_value == mock_models_get_secret_by_name.return_value + + @pytest.mark.django_db def test_model_AuthProvider_with_verification(model_AuthProvider_with_verification): assert model_AuthProvider_with_verification.supports_claims_verification @@ -204,6 +263,14 @@ def test_EligibilityVerifier_without_AuthProvider(model_EligibilityVerifier): assert not model_EligibilityVerifier.uses_auth_verification +@pytest.mark.django_db +def test_EligiblityVerifier_api_auth_key(model_EligibilityVerifier, mock_models_get_secret_by_name): + secret_value = model_EligibilityVerifier.api_auth_key + + mock_models_get_secret_by_name.assert_called_once_with(model_EligibilityVerifier.api_auth_key_secret_name) + assert secret_value == mock_models_get_secret_by_name.return_value + + @pytest.mark.django_db def test_PaymentProcessor_str(model_PaymentProcessor): assert str(model_PaymentProcessor) == model_PaymentProcessor.name diff --git a/tests/pytest/core/test_settings.py b/tests/pytest/core/test_settings.py deleted file mode 100644 index 1359d43ae..000000000 --- a/tests/pytest/core/test_settings.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from django.conf import settings - - -@pytest.mark.django_db -def test_admin_not_registered(client): - response = client.get("/admin") - - assert settings.ADMIN is False - assert response.status_code == 404 diff --git a/tests/pytest/test_secrets.py b/tests/pytest/test_secrets.py new file mode 100644 index 000000000..8cba4d5c4 --- /dev/null +++ b/tests/pytest/test_secrets.py @@ -0,0 +1,157 @@ +import pytest +from azure.core.exceptions import ClientAuthenticationError +from django.core.exceptions import ValidationError + +from benefits.secrets import KEY_VAULT_URL, NAME_VALIDATOR, SecretNameValidator, get_secret_by_name + + +@pytest.fixture(autouse=True) +def mock_DefaultAzureCredential(mocker): + # patching the class to ensure new instances always return the same mock + credential_cls = mocker.patch("benefits.secrets.DefaultAzureCredential") + credential_cls.return_value = mocker.Mock() + return credential_cls + + +@pytest.fixture +def secret_name(): + return "the-secret-name" + + +@pytest.fixture +def secret_value(): + return "the secret value" + + +@pytest.mark.parametrize( + "secret_name", + [ + "a", + "1", + "one", + "one-two-three", + "1-2-3", + "this-is-a-really-long-secret-name-in-fact-it-is-the-absolute-maximum-length-of-127-characters-to-be-exact-and-now-it-has-enough", # noqa: E501 + ], +) +def test_SecretNameValidator_valid(secret_name): + validator = SecretNameValidator() + + # a successful validation does not raise an Exception and returns None + assert validator(secret_name) is None + assert NAME_VALIDATOR(secret_name) is None + + +@pytest.mark.parametrize( + "secret_name", + [ + "", + "!", + "underscores_not_allowed", + "this-is-a-really-long-secret-name-in-fact-it-much-much-longer-than-the-absolute-maximum-length-of-127-characters-and-now-it-has-enough-to-be-too-long", # noqa: E501 + ], +) +def test_SecretNameValidator_invalid(secret_name): + validator = SecretNameValidator() + + # an unsuccessful validation raises django.core.exceptions.ValidationError + with pytest.raises(ValidationError): + validator(secret_name) + + with pytest.raises(ValidationError): + NAME_VALIDATOR(secret_name) + + +@pytest.mark.parametrize( + "secret_name", + [ + "", + "!", + "underscores_not_allowed", + "this-is-a-really-long-secret-name-in-fact-it-much-much-longer-than-the-absolute-maximum-length-of-127-characters-and-now-it-has-enough-to-be-too-long", # noqa: E501 + ], +) +def test_get_secret_by_name__invalid_name(secret_name): + with pytest.raises(ValidationError): + get_secret_by_name(secret_name) + + +@pytest.mark.parametrize("runtime_env", ["dev", "test", "prod"]) +def test_get_secret_by_name__with_client__returns_secret_value(mocker, runtime_env, settings, secret_name, secret_value): + settings.RUNTIME_ENVIRONMENT = lambda: runtime_env + + client = mocker.patch("benefits.secrets.SecretClient") + client.get_secret.return_value = mocker.Mock(value=secret_value) + + actual_value = get_secret_by_name(secret_name, client) + + client.get_secret.assert_called_once_with(secret_name) + assert actual_value == secret_value + + +@pytest.mark.parametrize("runtime_env", ["dev", "test", "prod"]) +def test_get_secret_by_name__None_client__returns_secret_value( + mocker, runtime_env, settings, mock_DefaultAzureCredential, secret_name, secret_value +): + settings.RUNTIME_ENVIRONMENT = lambda: runtime_env + expected_keyvault_url = KEY_VAULT_URL.format(env=runtime_env[0]) + + # this test does not pass in a known client, instead checking that a client is constructed as expected + mock_credential = mock_DefaultAzureCredential.return_value + client_cls = mocker.patch("benefits.secrets.SecretClient") + client = client_cls.return_value + client.get_secret.return_value = mocker.Mock(value=secret_value) + + actual_value = get_secret_by_name(secret_name) + + client_cls.assert_called_once_with(vault_url=expected_keyvault_url, credential=mock_credential) + client.get_secret.assert_called_once_with(secret_name) + assert actual_value == secret_value + + +@pytest.mark.parametrize("runtime_env", ["dev", "test", "prod"]) +def test_get_secret_by_name__None_client__returns_None(mocker, runtime_env, settings, secret_name): + settings.RUNTIME_ENVIRONMENT = lambda: runtime_env + + # this test forces construction of a new client to None + client_cls = mocker.patch("benefits.secrets.SecretClient", return_value=None) + + actual_value = get_secret_by_name(secret_name) + + client_cls.assert_called_once() + assert actual_value is None + + +@pytest.mark.parametrize("runtime_env", ["dev", "test", "prod"]) +def test_get_secret_by_name__unauthenticated_client__returns_None(mocker, runtime_env, settings, secret_name): + settings.RUNTIME_ENVIRONMENT = lambda: runtime_env + + # this test forces client.get_secret to throw an exception + client_cls = mocker.patch("benefits.secrets.SecretClient") + client = client_cls.return_value + client.get_secret.side_effect = ClientAuthenticationError + + actual_value = get_secret_by_name(secret_name) + + client_cls.assert_called_once() + client.get_secret.assert_called_once_with(secret_name) + assert actual_value is None + + +def test_get_secret_by_name__local__returns_environment_variable(mocker, settings, secret_name): + settings.RUNTIME_ENVIRONMENT = lambda: "local" + + secret_value_literal_newlines = "the\\nsecret\\nvalue" + expected_secret_value = secret_value_literal_newlines.replace("\\n", "\n") + + env_spy = mocker.patch("benefits.secrets.os.environ.get", return_value=secret_value_literal_newlines) + env_secret_name = secret_name.replace("-", "_") + client_cls = mocker.patch("benefits.secrets.SecretClient") + client = client_cls.return_value + + actual_value = get_secret_by_name(secret_name) + + client_cls.assert_not_called() + client.get_secret.assert_not_called() + env_spy.assert_called_once_with(env_secret_name) + assert actual_value == expected_secret_value diff --git a/tests/pytest/test_settings.py b/tests/pytest/test_settings.py new file mode 100644 index 000000000..b7146c0c4 --- /dev/null +++ b/tests/pytest/test_settings.py @@ -0,0 +1,58 @@ +def test_runtime_environment__default(settings): + assert settings.RUNTIME_ENVIRONMENT() == "local" + + +def test_runtime_environment__dev(settings): + settings.ALLOWED_HOSTS = ["dev-benefits.calitp.org"] + assert settings.RUNTIME_ENVIRONMENT() == "dev" + + +def test_runtime_environment__dev_and_test(settings): + # if both dev and test are specified (edge case/error in configuration), assume dev + settings.ALLOWED_HOSTS = ["test-benefits.calitp.org", "dev-benefits.calitp.org"] + assert settings.RUNTIME_ENVIRONMENT() == "dev" + + +def test_runtime_environment__dev_and_test_and_prod(settings): + # if all 3 of dev and test and prod are specified (edge case/error in configuration), assume dev + settings.ALLOWED_HOSTS = ["benefits.calitp.org", "test-benefits.calitp.org", "dev-benefits.calitp.org"] + assert settings.RUNTIME_ENVIRONMENT() == "dev" + + +def test_runtime_environment__local(settings): + settings.ALLOWED_HOSTS = ["localhost", "127.0.0.1"] + assert settings.RUNTIME_ENVIRONMENT() == "local" + + +def test_runtime_environment__nonmatching(settings): + # with only nonmatching hosts, return local + settings.ALLOWED_HOSTS = ["example.com", "example2.org"] + assert settings.RUNTIME_ENVIRONMENT() == "local" + + +def test_runtime_environment__test(settings): + settings.ALLOWED_HOSTS = ["test-benefits.calitp.org"] + assert settings.RUNTIME_ENVIRONMENT() == "test" + + +def test_runtime_environment__test_and_nonmatching(settings): + # when test is specified with other nonmatching hosts, assume test + settings.ALLOWED_HOSTS = ["test-benefits.calitp.org", "example.com"] + assert settings.RUNTIME_ENVIRONMENT() == "test" + + +def test_runtime_environment__test_and_prod(settings): + # if both test and prod are specified (edge case/error in configuration), assume test + settings.ALLOWED_HOSTS = ["benefits.calitp.org", "test-benefits.calitp.org"] + assert settings.RUNTIME_ENVIRONMENT() == "test" + + +def test_runtime_environment__prod(settings): + settings.ALLOWED_HOSTS = ["benefits.calitp.org"] + assert settings.RUNTIME_ENVIRONMENT() == "prod" + + +def test_runtime_environment__prod_and_nonmatching(settings): + # when prod is specified with other nonmatching hosts, assume prod + settings.ALLOWED_HOSTS = ["benefits.calitp.org", "https://example.com"] + assert settings.RUNTIME_ENVIRONMENT() == "prod"