diff --git a/.github/actions/deploy-hyp3/action.yml b/.github/actions/deploy-hyp3/action.yml index 6d5fa269d..52986fd3e 100644 --- a/.github/actions/deploy-hyp3/action.yml +++ b/.github/actions/deploy-hyp3/action.yml @@ -38,8 +38,8 @@ inputs: DEFAULT_CREDITS_PER_USER: description: "The default number of credits given to a new user" required: true - RESET_CREDITS_MONTHLY: - description: "Whether to reset each user's remaining credits each month" + DEFAULT_APPLICATION_STATUS: + description: "The default status for new user applications." required: true COST_PROFILE: description: "Job spec cost profile" @@ -126,7 +126,7 @@ runs: $ORIGIN_ACCESS_IDENTITY_ID \ $DISTRIBUTION_URL \ DefaultCreditsPerUser='${{ inputs.DEFAULT_CREDITS_PER_USER }}' \ - ResetCreditsMonthly='${{ inputs.RESET_CREDITS_MONTHLY }}' \ + DefaultApplicationStatus='${{ inputs.DEFAULT_APPLICATION_STATUS }}' \ DefaultMaxvCpus='${{ inputs.DEFAULT_MAX_VCPUS }}' \ ExpandedMaxvCpus='${{ inputs.EXPANDED_MAX_VCPUS }}' \ MonthlyBudget='${{ inputs.MONTHLY_BUDGET }}' \ diff --git a/.github/workflows/deploy-daac.yml b/.github/workflows/deploy-daac.yml index ee30d6f56..be75bc6ed 100644 --- a/.github/workflows/deploy-daac.yml +++ b/.github/workflows/deploy-daac.yml @@ -22,7 +22,7 @@ jobs: image_tag: latest product_lifetime_in_days: 14 default_credits_per_user: 10000 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: EDC deploy_ref: refs/heads/main job_files: job_spec/AUTORIFT.yml job_spec/INSAR_GAMMA.yml job_spec/RTC_GAMMA.yml job_spec/INSAR_ISCE_BURST.yml @@ -41,7 +41,7 @@ jobs: image_tag: test product_lifetime_in_days: 14 default_credits_per_user: 10000 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: EDC deploy_ref: refs/heads/develop job_files: >- @@ -62,7 +62,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: aws-actions/configure-aws-credentials@v4 with: @@ -90,7 +90,7 @@ jobs: SECRET_ARN: ${{ secrets.SECRET_ARN }} CLOUDFORMATION_ROLE_ARN: ${{ secrets.CLOUDFORMATION_ROLE_ARN }} DEFAULT_CREDITS_PER_USER: ${{ matrix.default_credits_per_user }} - RESET_CREDITS_MONTHLY: ${{ matrix.reset_credits_monthly }} + DEFAULT_APPLICATION_STATUS: ${{ matrix.default_application_status }} COST_PROFILE: ${{ matrix.cost_profile }} JOB_FILES: ${{ matrix.job_files }} DEFAULT_MAX_VCPUS: ${{ matrix.default_max_vcpus }} diff --git a/.github/workflows/deploy-enterprise-test.yml b/.github/workflows/deploy-enterprise-test.yml index ec505a65c..e60494da3 100644 --- a/.github/workflows/deploy-enterprise-test.yml +++ b/.github/workflows/deploy-enterprise-test.yml @@ -20,7 +20,7 @@ jobs: image_tag: test product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: false + default_application_status: APPROVED cost_profile: DEFAULT deploy_ref: refs/heads/develop job_files: >- @@ -44,7 +44,7 @@ jobs: image_tag: test product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: >- job_spec/ARIA_RAIDER.yml @@ -63,7 +63,7 @@ jobs: image_tag: test product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: >- job_spec/AUTORIFT_ITS_LIVE.yml @@ -82,7 +82,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: aws-actions/configure-aws-credentials@v4 with: @@ -109,7 +109,7 @@ jobs: SECRET_ARN: ${{ secrets.SECRET_ARN }} CLOUDFORMATION_ROLE_ARN: ${{ secrets.CLOUDFORMATION_ROLE_ARN }} DEFAULT_CREDITS_PER_USER: ${{ matrix.default_credits_per_user }} - RESET_CREDITS_MONTHLY: ${{ matrix.reset_credits_monthly }} + DEFAULT_APPLICATION_STATUS: ${{ matrix.default_application_status }} COST_PROFILE: ${{ matrix.cost_profile }} JOB_FILES: ${{ matrix.job_files }} DEFAULT_MAX_VCPUS: ${{ matrix.default_max_vcpus }} diff --git a/.github/workflows/deploy-enterprise.yml b/.github/workflows/deploy-enterprise.yml index b3ef70364..09a58aaf8 100644 --- a/.github/workflows/deploy-enterprise.yml +++ b/.github/workflows/deploy-enterprise.yml @@ -20,7 +20,7 @@ jobs: image_tag: latest product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: >- job_spec/AUTORIFT_ITS_LIVE.yml @@ -39,7 +39,7 @@ jobs: image_tag: latest product_lifetime_in_days: 180 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: >- job_spec/ARIA_RAIDER.yml @@ -58,7 +58,7 @@ jobs: image_tag: latest product_lifetime_in_days: 30 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_ISCE.yml instance_types: c6id.xlarge,c6id.2xlarge,c6id.4xlarge,c6id.8xlarge @@ -75,7 +75,7 @@ jobs: image_tag: latest product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_ISCE.yml instance_types: c6id.xlarge,c6id.2xlarge,c6id.4xlarge,c6id.8xlarge @@ -92,7 +92,7 @@ jobs: image_tag: latest product_lifetime_in_days: 365000 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_GAMMA.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -109,7 +109,7 @@ jobs: image_tag: latest product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/RTC_GAMMA.yml job_spec/WATER_MAP.yml job_spec/WATER_MAP_EQ.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -126,7 +126,7 @@ jobs: image_tag: latest product_lifetime_in_days: 90 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/RTC_GAMMA.yml job_spec/WATER_MAP.yml job_spec/WATER_MAP_EQ.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -143,7 +143,7 @@ jobs: image_tag: latest product_lifetime_in_days: 30 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_GAMMA.yml job_spec/INSAR_ISCE_BURST.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -160,7 +160,7 @@ jobs: image_tag: latest product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_GAMMA.yml job_spec/RTC_GAMMA.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -177,7 +177,7 @@ jobs: image_tag: latest product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_GAMMA.yml job_spec/RTC_GAMMA.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -194,7 +194,7 @@ jobs: image_tag: latest product_lifetime_in_days: 30 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/INSAR_GAMMA.yml job_spec/RTC_GAMMA.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -213,7 +213,7 @@ jobs: # S3 bucket, but maybe we want to allow for a backlog of products-to-be-transferred? product_lifetime_in_days: 14 default_credits_per_user: 0 - reset_credits_monthly: true + default_application_status: APPROVED cost_profile: DEFAULT job_files: job_spec/WATER_MAP.yml instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge @@ -229,7 +229,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: aws-actions/configure-aws-credentials@v4 with: @@ -256,7 +256,7 @@ jobs: SECRET_ARN: ${{ secrets.SECRET_ARN }} CLOUDFORMATION_ROLE_ARN: ${{ secrets.CLOUDFORMATION_ROLE_ARN }} DEFAULT_CREDITS_PER_USER: ${{ matrix.default_credits_per_user }} - RESET_CREDITS_MONTHLY: ${{ matrix.reset_credits_monthly }} + DEFAULT_APPLICATION_STATUS: ${{ matrix.default_application_status }} COST_PROFILE: ${{ matrix.cost_profile }} JOB_FILES: ${{ matrix.job_files }} DEFAULT_MAX_VCPUS: ${{ matrix.default_max_vcpus }} diff --git a/.github/workflows/deploy-whitelisting-sandbox.yml b/.github/workflows/deploy-whitelisting-sandbox.yml new file mode 100644 index 000000000..3001fbdc1 --- /dev/null +++ b/.github/workflows/deploy-whitelisting-sandbox.yml @@ -0,0 +1,83 @@ +name: Deploy Whitelisting Sandbox Stack to AWS + +on: + push: + branches: + - whitelisting-sandbox + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + deploy: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - environment: hyp3-whitelisting-sandbox + domain: hyp3-whitelisting-sandbox.asf.alaska.edu + template_bucket: cf-templates-1hz9ldhhl4ahu-us-west-2 + image_tag: test + product_lifetime_in_days: 14 + default_credits_per_user: 10 + default_application_status: NOT_STARTED + cost_profile: EDC + deploy_ref: refs/heads/whitelisting-sandbox + job_files: >- + job_spec/AUTORIFT.yml + job_spec/INSAR_GAMMA.yml + job_spec/RTC_GAMMA.yml + job_spec/INSAR_ISCE_BURST.yml + instance_types: r6id.xlarge,r6id.2xlarge,r6id.4xlarge,r6id.8xlarge,r6idn.xlarge,r6idn.2xlarge,r6idn.4xlarge,r6idn.8xlarge + default_max_vcpus: 640 + expanded_max_vcpus: 640 + required_surplus: 0 + security_environment: ASF + ami_id: /aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id + distribution_url: '' + + environment: + name: ${{ matrix.environment }} + url: https://${{ matrix.domain }} + + steps: + - uses: actions/checkout@v4.1.2 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.V2_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.V2_AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.V2_AWS_SESSION_TOKEN }} + aws-region: ${{ secrets.AWS_REGION }} + + - uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - uses: ./.github/actions/deploy-hyp3 + with: + TEMPLATE_BUCKET: ${{ matrix.template_bucket }} + STACK_NAME: ${{ matrix.environment }} + DOMAIN_NAME: ${{ matrix.domain }} + API_NAME: ${{ matrix.environment }} + CERTIFICATE_ARN: ${{ secrets.CERTIFICATE_ARN }} + IMAGE_TAG: ${{ matrix.image_tag }} + PRODUCT_LIFETIME: ${{ matrix.product_lifetime_in_days }} + VPC_ID: ${{ secrets.VPC_ID }} + SUBNET_IDS: ${{ secrets.SUBNET_IDS }} + SECRET_ARN: ${{ secrets.SECRET_ARN }} + CLOUDFORMATION_ROLE_ARN: ${{ secrets.CLOUDFORMATION_ROLE_ARN }} + DEFAULT_CREDITS_PER_USER: ${{ matrix.default_credits_per_user }} + DEFAULT_APPLICATION_STATUS: ${{ matrix.default_application_status }} + COST_PROFILE: ${{ matrix.cost_profile }} + JOB_FILES: ${{ matrix.job_files }} + DEFAULT_MAX_VCPUS: ${{ matrix.default_max_vcpus }} + EXPANDED_MAX_VCPUS: ${{ matrix.expanded_max_vcpus }} + MONTHLY_BUDGET: ${{ secrets.MONTHLY_BUDGET }} + REQUIRED_SURPLUS: ${{ matrix.required_surplus }} + ORIGIN_ACCESS_IDENTITY_ID: ${{ secrets.ORIGIN_ACCESS_IDENTITY_ID }} + SECURITY_ENVIRONMENT: ${{ matrix.security_environment }} + AMI_ID: ${{ matrix.ami_id }} + INSTANCE_TYPES: ${{ matrix.instance_types }} + DISTRIBUTION_URL: ${{ matrix.distribution_url }} + AUTH_PUBLIC_KEY: ${{ secrets.AUTH_PUBLIC_KEY }} diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 3852a2b1c..ee35bf19f 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -6,7 +6,7 @@ jobs: flake8: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: actions/setup-python@v5 with: python-version: 3.9 @@ -23,7 +23,7 @@ jobs: matrix: security_environment: [ASF, EDC, JPL, JPL-public] steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: actions/setup-python@v5 with: python-version: 3.9 @@ -37,7 +37,7 @@ jobs: openapi-spec-validator: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: actions/setup-python@v5 with: python-version: 3.9 @@ -50,7 +50,7 @@ jobs: statelint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: ruby/setup-ruby@v1 with: ruby-version: 2.7 @@ -70,7 +70,7 @@ jobs: snyk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: snyk/actions/setup@0.4.0 - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3e11a6a7f..a48a05477 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.5 - uses: actions/setup-python@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ce81604..cd9467e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.2.0] + +This release includes changes to support an upcoming user whitelisting feature. A new user will be required to apply for HyP3 access and will not be able to submit jobs until an operator has manually reviewed and approved the application. As of this release, all new and existing users are automatically approved without being required to submit an application, but this will change in the near future. + +⚠️ Important notes for HyP3 deployment operators: +- Changing a user's application status (e.g. to approve or reject a new user) requires manually updating the value of the `application_status` field in the Users table. +- The response for both `/user` endpoints now automatically includes all Users table fields except those prefixed by an underscore (`_`). +- The following manual updates must be made to the Users table upon deployment of this release: + - Add field `application_status` with the appropriate value for each user. + - Rename field `month_of_last_credits_reset` to `_month_of_last_credit_reset`. + - Rename field `notes` to `_notes`. + +### Added +- A new `PATCH /user` endpoint with a single `use_case` parameter allows the user to submit an application or update a pending application. The structure for a successful response is the same as for `GET /user`. +- A new `default_application_status` deployment parameter specifies the default status for new user applications. The parameter has been set to `APPROVED` for all deployments. + +### Changed +- The `POST /jobs` endpoint now returns a `403` response if the user has not been approved. +- The response schema for the `GET /user` endpoint now includes: + - A required `application_status` field representing the status of the user's application: `NOT_STARTED`, `PENDING`, `APPROVED`, or `REJECTED`. + - An optional `use_case` field containing the use case submitted with the user's application. + - An optional `credits_per_month` field representing the user's monthly credit allotment, if different from the deployment default. + +### Removed +- The `reset_credits_monthly` deployment parameter has been removed. Credits now reset monthly in all deployments. This only changes the behavior of the `hyp3-enterprise-test` deployment. + ## [7.1.1] ### Changed - Reduced `start_execution_manager` batch size from 600 jobs to 500 jobs. Fixes [#2241](https://github.com/ASFHyP3/hyp3/issues/2241). diff --git a/apps/api/api-cf.yml.j2 b/apps/api/api-cf.yml.j2 index 94f28cc8f..975bbf3be 100644 --- a/apps/api/api-cf.yml.j2 +++ b/apps/api/api-cf.yml.j2 @@ -15,7 +15,7 @@ Parameters: DefaultCreditsPerUser: Type: Number - ResetCreditsMonthly: + DefaultApplicationStatus: Type: String SystemAvailable: @@ -182,7 +182,7 @@ Resources: AUTH_PUBLIC_KEY: !Ref AuthPublicKey AUTH_ALGORITHM: !Ref AuthAlgorithm DEFAULT_CREDITS_PER_USER: !Ref DefaultCreditsPerUser - RESET_CREDITS_MONTHLY: !Ref ResetCreditsMonthly + DEFAULT_APPLICATION_STATUS: !Ref DefaultApplicationStatus SYSTEM_AVAILABLE: !Ref SystemAvailable Code: src/ Handler: hyp3_api.lambda_handler.handler diff --git a/apps/api/src/hyp3_api/api-spec/openapi-spec.yml.j2 b/apps/api/src/hyp3_api/api-spec/openapi-spec.yml.j2 index 309b2d699..98d5ea13f 100644 --- a/apps/api/src/hyp3_api/api-spec/openapi-spec.yml.j2 +++ b/apps/api/src/hyp3_api/api-spec/openapi-spec.yml.j2 @@ -104,6 +104,22 @@ paths: $ref: "#/components/schemas/job" /user: + patch: + description: Submit or update an application for processing approval. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/patch_user_body" + required: true + responses: + "200": + description: 200 response + content: + application/json: + schema: + $ref: "#/components/schemas/user" + get: description: Get information about the logged in user. responses: @@ -172,20 +188,48 @@ components: next: $ref: "#/components/schemas/next_url" + patch_user_body: + description: Application for processing approval. + type: object + required: + - use_case + additionalProperties: false + properties: + use_case: + $ref: "#/components/schemas/use_case" + user: description: Information about a user type: object required: - - user_id + - application_status + - job_names - remaining_credits + - user_id additionalProperties: false properties: - user_id: - $ref: "#/components/schemas/user_id" - remaining_credits: + application_status: + $ref: "#/components/schemas/application_status" + credits_per_month: $ref: "#/components/schemas/credits" job_names: $ref: "#/components/schemas/job_names_list" + remaining_credits: + $ref: "#/components/schemas/credits" + use_case: + $ref: "#/components/schemas/use_case" + user_id: + $ref: "#/components/schemas/user_id" + + application_status: + description: Status of an application for processing approval. + type: string + enum: + - NOT_STARTED + - PENDING + - APPROVED + - REJECTED + example: PENDING credits: description: Processing credits for running jobs. @@ -266,6 +310,11 @@ components: format: uuid example: 27836b79-e5b2-4d8f-932f-659724ea02c3 + use_case: + description: Reason for wanting to use HyP3. + type: string + example: I want to process data. + user_id: description: Username from Earthdata Login. type: string diff --git a/apps/api/src/hyp3_api/auth.py b/apps/api/src/hyp3_api/auth.py index 6927798ec..ff75ae6ff 100644 --- a/apps/api/src/hyp3_api/auth.py +++ b/apps/api/src/hyp3_api/auth.py @@ -1,23 +1,21 @@ import time from os import environ +from typing import Optional import jwt -def decode_token(token): +def decode_token(token) -> Optional[dict]: try: - payload = jwt.decode(token, environ['AUTH_PUBLIC_KEY'], algorithms=environ['AUTH_ALGORITHM']) - return { - 'active': True, - 'sub': payload['urs-user-id'], - } + return jwt.decode(token, environ['AUTH_PUBLIC_KEY'], algorithms=environ['AUTH_ALGORITHM']) except (jwt.ExpiredSignatureError, jwt.DecodeError): return None -def get_mock_jwt_cookie(user: str, lifetime_in_seconds: int): +def get_mock_jwt_cookie(user: str, lifetime_in_seconds: int, access_token: str) -> str: payload = { 'urs-user-id': user, + 'urs-access-token': access_token, 'exp': int(time.time()) + lifetime_in_seconds, } value = jwt.encode( diff --git a/apps/api/src/hyp3_api/handlers.py b/apps/api/src/hyp3_api/handlers.py index 44911aa11..81c3afd13 100644 --- a/apps/api/src/hyp3_api/handlers.py +++ b/apps/api/src/hyp3_api/handlers.py @@ -4,6 +4,7 @@ from flask import abort, jsonify, request import dynamo +from dynamo.exceptions import InsufficientCreditsError, UnexpectedApplicationStatusError from hyp3_api import util from hyp3_api.validation import GranuleValidationError, validate_jobs @@ -32,7 +33,9 @@ def post_jobs(body, user): try: body['jobs'] = dynamo.jobs.put_jobs(user, body['jobs'], dry_run=body.get('validate_only')) - except dynamo.jobs.InsufficientCreditsError as e: + except UnexpectedApplicationStatusError as e: + abort(problem_format(403, str(e))) + except InsufficientCreditsError as e: abort(problem_format(400, str(e))) return body @@ -58,20 +61,32 @@ def get_job_by_id(job_id): return job -def get_names_for_user(user): +def patch_user(body: dict, user: str, edl_access_token: str) -> dict: + print(body) + try: + user_record = dynamo.user.update_user(user, edl_access_token, body) + except UnexpectedApplicationStatusError as e: + abort(problem_format(403, str(e))) + return _user_response(user_record) + + +def get_user(user): + user_record = dynamo.user.get_or_create_user(user) + return _user_response(user_record) + + +def _user_response(user_record: dict) -> dict: + # TODO: count this as jobs are submitted, not every time `/user` is queried + job_names = _get_names_for_user(user_record['user_id']) + payload = {key: user_record[key] for key in user_record if not key.startswith('_')} + payload['job_names'] = job_names + return payload + + +def _get_names_for_user(user): jobs, next_key = dynamo.jobs.query_jobs(user) while next_key is not None: new_jobs, next_key = dynamo.jobs.query_jobs(user, start_key=next_key) jobs.extend(new_jobs) names = {job['name'] for job in jobs if 'name' in job} return sorted(list(names)) - - -def get_user(user): - user_record = dynamo.user.get_or_create_user(user) - return { - 'user_id': user, - 'remaining_credits': user_record['remaining_credits'], - # TODO: count this as jobs are submitted, not every time `/user` is queried - 'job_names': get_names_for_user(user) - } diff --git a/apps/api/src/hyp3_api/routes.py b/apps/api/src/hyp3_api/routes.py index 23ace2bad..e3c820d05 100644 --- a/apps/api/src/hyp3_api/routes.py +++ b/apps/api/src/hyp3_api/routes.py @@ -39,9 +39,10 @@ def check_system_available(): @app.before_request def authenticate_user(): cookie = request.cookies.get('asf-urs') - auth_info = auth.decode_token(cookie) - if auth_info is not None: - g.user = auth_info['sub'] + payload = auth.decode_token(cookie) + if payload is not None: + g.user = payload['urs-user-id'] + g.edl_access_token = payload['urs-access-token'] else: if any([request.path.startswith(route) for route in AUTHENTICATED_ROUTES]) and request.method != 'OPTIONS': abort(handlers.problem_format(401, 'No authorization token provided')) @@ -148,6 +149,12 @@ def jobs_get_by_job_id(job_id): return jsonify(handlers.get_job_by_id(job_id)) +@app.route('/user', methods=['PATCH']) +@openapi +def user_patch(): + return jsonify(handlers.patch_user(request.get_json(), g.user, g.edl_access_token)) + + @app.route('/user', methods=['GET']) @openapi def user_get(): diff --git a/apps/main-cf.yml.j2 b/apps/main-cf.yml.j2 index df7d00fd5..46a17191b 100644 --- a/apps/main-cf.yml.j2 +++ b/apps/main-cf.yml.j2 @@ -36,12 +36,12 @@ Parameters: Type: Number MinValue: 0 - ResetCreditsMonthly: - Description: "Whether to reset each user's remaining credits each month" + DefaultApplicationStatus: + Description: The default status for new user applications. Type: String AllowedValues: - - false - - true + - NOT_STARTED + - APPROVED SystemAvailable: Description: Set to false to shutdown system, API will run and provide errors to users, but will not accept jobs. @@ -123,7 +123,7 @@ Resources: AuthPublicKey: !Ref AuthPublicKey AuthAlgorithm: !Ref AuthAlgorithm DefaultCreditsPerUser: !Ref DefaultCreditsPerUser - ResetCreditsMonthly: !Ref ResetCreditsMonthly + DefaultApplicationStatus: !Ref DefaultApplicationStatus SystemAvailable: !Ref SystemAvailable {% if security_environment == 'EDC' %} VpcId: !Ref VpcId diff --git a/docs/deployments/ASF-deployment.md b/docs/deployments/ASF-deployment.md index 11c74e6e4..b96e7be77 100644 --- a/docs/deployments/ASF-deployment.md +++ b/docs/deployments/ASF-deployment.md @@ -94,5 +94,5 @@ aws cloudformation deploy \ CertificateArn= \ SecretArn= \ DefaultCreditsPerUser=0 \ - ResetCreditsMonthly=true + DefaultApplicationStatus=APPROVED ``` diff --git a/docs/deployments/JPL-deployment.md b/docs/deployments/JPL-deployment.md index eca192a3b..830c8800f 100644 --- a/docs/deployments/JPL-deployment.md +++ b/docs/deployments/JPL-deployment.md @@ -94,7 +94,7 @@ aws cloudformation deploy \ CertificateArn= \ SecretArn= \ DefaultCreditsPerUser=0 \ - ResetCreditsMonthly=true + DefaultApplicationStatus=APPROVED ``` ## 5. Post deployment diff --git a/lib/dynamo/dynamo/exceptions.py b/lib/dynamo/dynamo/exceptions.py new file mode 100644 index 000000000..29f386ec7 --- /dev/null +++ b/lib/dynamo/dynamo/exceptions.py @@ -0,0 +1,49 @@ +"""Custom exceptions for the dynamo library.""" + + +class DatabaseConditionException(Exception): + """Raised when a DynamoDB condition expression check fails.""" + + +class InsufficientCreditsError(Exception): + """Raised when trying to submit jobs whose total cost exceeds the user's remaining credits.""" + + +class InvalidApplicationStatusError(Exception): + """Raised for an invalid user application status.""" + + def __init__(self, user_id: str, application_status: str): + super().__init__(f'User {user_id} has an invalid application status: {application_status}') + + +class UnexpectedApplicationStatusError(Exception): + """Raised for an unexpected user application status.""" + help_url = 'https://hyp3-docs.asf.alaska.edu/using/requesting_access' + + +class NotStartedApplicationError(UnexpectedApplicationStatusError): + def __init__(self, user_id: str): + super().__init__( + f'{user_id} must request access before submitting jobs. Visit {self.help_url}' + ) + + +class PendingApplicationError(UnexpectedApplicationStatusError): + def __init__(self, user_id: str): + super().__init__( + f"{user_id}'s request for access is pending review. For more information, visit {self.help_url}" + ) + + +class ApprovedApplicationError(UnexpectedApplicationStatusError): + def __init__(self, user_id: str): + super().__init__( + f"{user_id}'s request for access is already approved. For more information, visit {self.help_url}" + ) + + +class RejectedApplicationError(UnexpectedApplicationStatusError): + def __init__(self, user_id: str): + super().__init__( + f"{user_id}'s request for access has been rejected. For more information, visit {self.help_url}" + ) diff --git a/lib/dynamo/dynamo/jobs.py b/lib/dynamo/dynamo/jobs.py index 5048df66f..f95e26aca 100644 --- a/lib/dynamo/dynamo/jobs.py +++ b/lib/dynamo/dynamo/jobs.py @@ -9,6 +9,14 @@ from boto3.dynamodb.conditions import Attr, Key import dynamo.user +from dynamo.exceptions import ( + InsufficientCreditsError, + InvalidApplicationStatusError, + NotStartedApplicationError, + PendingApplicationError, + RejectedApplicationError, +) +from dynamo.user import APPLICATION_APPROVED, APPLICATION_NOT_STARTED, APPLICATION_PENDING, APPLICATION_REJECTED from dynamo.util import DYNAMODB_RESOURCE, convert_floats_to_decimals, format_time, get_request_time_expression costs_file = Path(__file__).parent / 'costs.json' @@ -22,15 +30,14 @@ DEFAULT_PARAMS_BY_JOB_TYPE = {} -class InsufficientCreditsError(Exception): - """Raised when trying to submit jobs whose total cost exceeds the user's remaining credits.""" - - def put_jobs(user_id: str, jobs: List[dict], dry_run=False) -> List[dict]: table = DYNAMODB_RESOURCE.Table(environ['JOBS_TABLE_NAME']) request_time = format_time(datetime.now(timezone.utc)) user_record = dynamo.user.get_or_create_user(user_id) + + _raise_for_application_status(user_record['application_status'], user_record['user_id']) + remaining_credits = user_record['remaining_credits'] priority_override = user_record.get('priority_override') @@ -64,6 +71,17 @@ def put_jobs(user_id: str, jobs: List[dict], dry_run=False) -> List[dict]: return prepared_jobs +def _raise_for_application_status(application_status: str, user_id: str) -> None: + if application_status == APPLICATION_NOT_STARTED: + raise NotStartedApplicationError(user_id) + if application_status == APPLICATION_PENDING: + raise PendingApplicationError(user_id) + if application_status == APPLICATION_REJECTED: + raise RejectedApplicationError(user_id) + if application_status != APPLICATION_APPROVED: + raise InvalidApplicationStatusError(user_id, application_status) + + def _prepare_job_for_database( job: dict, user_id: str, diff --git a/lib/dynamo/dynamo/user.py b/lib/dynamo/dynamo/user.py index ee16b9eea..bfe5fcfea 100644 --- a/lib/dynamo/dynamo/user.py +++ b/lib/dynamo/dynamo/user.py @@ -4,12 +4,56 @@ from os import environ import botocore.exceptions +import requests +from dynamo.exceptions import ( + ApprovedApplicationError, DatabaseConditionException, InvalidApplicationStatusError, RejectedApplicationError +) from dynamo.util import DYNAMODB_RESOURCE +APPLICATION_NOT_STARTED = 'NOT_STARTED' +APPLICATION_PENDING = 'PENDING' +APPLICATION_APPROVED = 'APPROVED' +APPLICATION_REJECTED = 'REJECTED' -class DatabaseConditionException(Exception): - """Raised when a DynamoDB condition expression check fails.""" + +def update_user(user_id: str, edl_access_token: str, body: dict) -> dict: + user = get_or_create_user(user_id) + application_status = user['application_status'] + if application_status in (APPLICATION_NOT_STARTED, APPLICATION_PENDING): + edl_profile = _get_edl_profile(user_id, edl_access_token) + users_table = DYNAMODB_RESOURCE.Table(environ['USERS_TABLE_NAME']) + try: + user = users_table.update_item( + Key={'user_id': user_id}, + UpdateExpression='SET #edl_profile = :edl_profile, use_case = :use_case, application_status = :pending', + ConditionExpression='application_status IN (:not_started, :pending)', + ExpressionAttributeNames={'#edl_profile': '_edl_profile'}, + ExpressionAttributeValues={ + ':edl_profile': edl_profile, + ':use_case': body['use_case'], + ':not_started': APPLICATION_NOT_STARTED, + ':pending': APPLICATION_PENDING + }, + ReturnValues='ALL_NEW', + )['Attributes'] + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + raise DatabaseConditionException(f'Failed to update record for user {user_id}') + raise + return user + if application_status == APPLICATION_REJECTED: + raise RejectedApplicationError(user_id) + if application_status == APPLICATION_APPROVED: + raise ApprovedApplicationError(user_id) + raise InvalidApplicationStatusError(user_id, application_status) + + +def _get_edl_profile(user_id: str, edl_access_token: str) -> dict: + url = f'https://urs.earthdata.nasa.gov/api/users/{user_id}' + response = requests.get(url, headers={'Authorization': f'Bearer {edl_access_token}'}) + response.raise_for_status() + return response.json() def get_or_create_user(user_id: str) -> dict: @@ -19,29 +63,27 @@ def get_or_create_user(user_id: str) -> dict: users_table = DYNAMODB_RESOURCE.Table(environ['USERS_TABLE_NAME']) user = users_table.get_item(Key={'user_id': user_id}).get('Item') - if user is not None: - user = _reset_credits_if_needed( - user=user, - default_credits=default_credits, - current_month=current_month, - users_table=users_table, - ) - else: - user = _create_user( - user_id=user_id, - default_credits=default_credits, - current_month=current_month, - users_table=users_table, - ) - return user + if user is None: + user = _create_user(user_id, users_table) + + return _reset_credits_if_needed( + user=user, + default_credits=default_credits, + current_month=current_month, + users_table=users_table, + ) def _get_current_month() -> str: return datetime.now(tz=timezone.utc).strftime('%Y-%m') -def _create_user(user_id: str, default_credits: Decimal, current_month: str, users_table) -> dict: - user = {'user_id': user_id, 'remaining_credits': default_credits, 'month_of_last_credits_reset': current_month} +def _create_user(user_id: str, users_table) -> dict: + user = { + 'user_id': user_id, + 'remaining_credits': Decimal(0), + 'application_status': os.environ['DEFAULT_APPLICATION_STATUS'], + } try: users_table.put_item(Item=user, ConditionExpression='attribute_not_exists(user_id)') except botocore.exceptions.ClientError as e: @@ -53,25 +95,50 @@ def _create_user(user_id: str, default_credits: Decimal, current_month: str, use def _reset_credits_if_needed(user: dict, default_credits: Decimal, current_month: str, users_table) -> dict: if ( - os.environ['RESET_CREDITS_MONTHLY'] == 'true' - and user['month_of_last_credits_reset'] < current_month # noqa: W503 + user['application_status'] == APPLICATION_APPROVED + and user.get('_month_of_last_credit_reset', '0') < current_month # noqa: W503 and user['remaining_credits'] is not None # noqa: W503 ): - user['month_of_last_credits_reset'] = current_month - user['remaining_credits'] = user.get('credits_per_month', default_credits) try: - users_table.put_item( - Item=user, - ConditionExpression='month_of_last_credits_reset < :current_month' - ' AND attribute_type(remaining_credits, :number)', + user = users_table.update_item( + Key={'user_id': user['user_id']}, + UpdateExpression='SET remaining_credits = :credits, #month_of_last_credit_reset = :current_month', + ConditionExpression=( + 'application_status = :approved' + ' AND (attribute_not_exists(#month_of_last_credit_reset)' + ' OR #month_of_last_credit_reset < :current_month)' + ' AND attribute_type(remaining_credits, :number)' + ), + ExpressionAttributeNames={'#month_of_last_credit_reset': '_month_of_last_credit_reset'}, ExpressionAttributeValues={ + ':approved': APPLICATION_APPROVED, + ':credits': user.get('credits_per_month', default_credits), ':current_month': current_month, ':number': 'N', }, - ) + ReturnValues='ALL_NEW', + )['Attributes'] + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + raise DatabaseConditionException( + f'Failed to perform monthly credit reset for approved user {user["user_id"]}' + ) + raise + elif user['application_status'] != APPLICATION_APPROVED and user['remaining_credits'] != Decimal(0): + try: + user = users_table.update_item( + Key={'user_id': user['user_id']}, + UpdateExpression='SET remaining_credits = :zero REMOVE #month_of_last_credit_reset, credits_per_month', + ConditionExpression='application_status <> :approved AND remaining_credits <> :zero', + ExpressionAttributeNames={'#month_of_last_credit_reset': '_month_of_last_credit_reset'}, + ExpressionAttributeValues={':approved': APPLICATION_APPROVED, ':zero': Decimal(0)}, + ReturnValues='ALL_NEW', + )['Attributes'] except botocore.exceptions.ClientError as e: if e.response['Error']['Code'] == 'ConditionalCheckFailedException': - raise DatabaseConditionException(f'Failed to perform monthly credits reset for user {user["user_id"]}') + raise DatabaseConditionException( + f'Failed to perform monthly credit reset for unapproved user {user["user_id"]}' + ) raise return user diff --git a/requirements-all.txt b/requirements-all.txt index 6bb9a7f51..f0f028a19 100644 --- a/requirements-all.txt +++ b/requirements-all.txt @@ -5,10 +5,10 @@ -r requirements-apps-start-execution-worker.txt -r requirements-apps-disable-private-dns.txt -r requirements-apps-update-db.txt -boto3==1.34.87 -jinja2==3.1.3 -moto[dynamodb]==5.0.5 -pytest==8.1.1 +boto3==1.34.100 +jinja2==3.1.4 +moto[dynamodb]==5.0.6 +pytest==8.2.0 PyYAML==6.0.1 responses==0.25.0 flake8==7.0.0 @@ -17,4 +17,4 @@ flake8-blind-except==0.2.1 flake8-builtins==2.5.0 setuptools==69.5.1 openapi-spec-validator==0.7.1 -cfn-lint==0.86.3 +cfn-lint==0.87.1 diff --git a/requirements-apps-api.txt b/requirements-apps-api.txt index ef089808a..b67720ab1 100644 --- a/requirements-apps-api.txt +++ b/requirements-apps-api.txt @@ -1,6 +1,6 @@ flask==2.2.5 -Flask-Cors==4.0.0 -jsonschema==4.21.1 +Flask-Cors==4.0.1 +jsonschema==4.22.0 openapi-core==0.19.1 prance==23.6.21.0 PyJWT==2.8.0 @@ -8,5 +8,4 @@ requests==2.31.0 serverless_wsgi==3.0.3 shapely==2.0.4 strict-rfc3339==0.7 -Werkzeug==3.0.2 ./lib/dynamo/ diff --git a/requirements-apps-disable-private-dns.txt b/requirements-apps-disable-private-dns.txt index 650714373..46c415b8b 100644 --- a/requirements-apps-disable-private-dns.txt +++ b/requirements-apps-disable-private-dns.txt @@ -1 +1 @@ -boto3==1.34.87 +boto3==1.34.100 diff --git a/requirements-apps-start-execution-manager.txt b/requirements-apps-start-execution-manager.txt index a67e4cc80..ff011fc59 100644 --- a/requirements-apps-start-execution-manager.txt +++ b/requirements-apps-start-execution-manager.txt @@ -1,3 +1,3 @@ -boto3==1.34.87 +boto3==1.34.100 ./lib/dynamo/ ./lib/lambda_logging/ diff --git a/requirements-apps-start-execution-worker.txt b/requirements-apps-start-execution-worker.txt index a553e2729..8ff916d39 100644 --- a/requirements-apps-start-execution-worker.txt +++ b/requirements-apps-start-execution-worker.txt @@ -1,2 +1,2 @@ -boto3==1.34.87 +boto3==1.34.100 ./lib/lambda_logging/ diff --git a/tests/cfg.env b/tests/cfg.env index 42d4b557b..31bf731a5 100644 --- a/tests/cfg.env +++ b/tests/cfg.env @@ -4,7 +4,7 @@ USERS_TABLE_NAME=hyp3-db-table-user AUTH_PUBLIC_KEY=123456789 AUTH_ALGORITHM=HS256 DEFAULT_CREDITS_PER_USER=25 -RESET_CREDITS_MONTHLY=false +DEFAULT_APPLICATION_STATUS=NOT_STARTED SYSTEM_AVAILABLE=true AWS_DEFAULT_REGION=us-west-2 AWS_ACCESS_KEY_ID=testing diff --git a/tests/conftest.py b/tests/conftest.py index 3ba79fe42..555c08616 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ +from decimal import Decimal from os import environ, path import pytest import yaml from moto import mock_aws +from dynamo.user import APPLICATION_APPROVED + @pytest.fixture def table_properties(): @@ -41,5 +44,16 @@ class Tables: yield tables +@pytest.fixture +def approved_user(tables) -> str: + user = { + 'user_id': 'approved_user', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_APPROVED, + } + tables.users_table.put_item(Item=user) + return user['user_id'] + + def list_have_same_elements(l1, l2): return [item for item in l1 if item not in l2] == [] == [item for item in l2 if item not in l1] diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 202a57a26..a70e0b332 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -14,6 +14,7 @@ DEFAULT_JOB_ID = 'myJobId' DEFAULT_USERNAME = 'test_username' +DEFAULT_ACCESS_TOKEN = 'test_access_token' CMR_URL_RE = re.compile(f'{CMR_URL}.*') @@ -24,11 +25,11 @@ def client(): yield test_client -def login(client, username=DEFAULT_USERNAME): +def login(client, username=DEFAULT_USERNAME, access_token=DEFAULT_ACCESS_TOKEN): client.set_cookie( domain='localhost', key=AUTH_COOKIE, - value=auth.get_mock_jwt_cookie(username, lifetime_in_seconds=10_000) + value=auth.get_mock_jwt_cookie(username, lifetime_in_seconds=10_000, access_token=access_token) ) diff --git a/tests/test_api/test_api_spec.py b/tests/test_api/test_api_spec.py index 027b7e60e..b9937f28a 100644 --- a/tests/test_api/test_api_spec.py +++ b/tests/test_api/test_api_spec.py @@ -7,7 +7,7 @@ ENDPOINTS = { JOBS_URI: {'GET', 'HEAD', 'OPTIONS', 'POST'}, JOBS_URI + '/foo': {'GET', 'HEAD', 'OPTIONS'}, - USER_URI: {'GET', 'HEAD', 'OPTIONS'}, + USER_URI: {'GET', 'HEAD', 'OPTIONS', 'PATCH'}, } @@ -52,7 +52,7 @@ def test_expired_cookie(client): client.set_cookie( domain='localhost', key=AUTH_COOKIE, - value=auth.get_mock_jwt_cookie('user', lifetime_in_seconds=-1) + value=auth.get_mock_jwt_cookie('user', lifetime_in_seconds=-1, access_token='token') ) response = client.get(uri) assert response.status_code == HTTPStatus.UNAUTHORIZED diff --git a/tests/test_api/test_get_user.py b/tests/test_api/test_get_user.py index af54b05f1..fe21ea5ed 100644 --- a/tests/test_api/test_get_user.py +++ b/tests/test_api/test_get_user.py @@ -3,24 +3,24 @@ from test_api.conftest import USER_URI, login, make_db_record +from dynamo.user import APPLICATION_APPROVED, APPLICATION_NOT_STARTED, APPLICATION_REJECTED from dynamo.util import format_time def test_get_new_user(client, tables, monkeypatch): - monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '25') - login(client, 'user') response = client.get(USER_URI) assert response.status_code == HTTPStatus.OK assert response.json == { 'user_id': 'user', - 'remaining_credits': 25, + 'application_status': APPLICATION_NOT_STARTED, + 'remaining_credits': 0, 'job_names': [], } -def test_get_existing_user(client, tables): - user = {'user_id': 'user', 'remaining_credits': None} +def test_get_rejected_user(client, tables): + user = {'user_id': 'user', 'remaining_credits': 100, 'application_status': APPLICATION_REJECTED} tables.users_table.put_item(Item=user) login(client, 'user') @@ -28,14 +28,22 @@ def test_get_existing_user(client, tables): assert response.status_code == HTTPStatus.OK assert response.json == { 'user_id': 'user', - 'remaining_credits': None, + 'application_status': APPLICATION_REJECTED, + 'remaining_credits': 0, 'job_names': [], } def test_get_user_with_jobs(client, tables): user_id = 'user_with_jobs' - user = {'user_id': user_id, 'remaining_credits': 20, 'foo': 'bar'} + user = { + 'user_id': user_id, + 'remaining_credits': 20, + 'application_status': APPLICATION_APPROVED, + 'credits_per_month': 50, + '_month_of_last_credit_reset': '2024-01-01', + '_foo': 'bar', + } tables.users_table.put_item(Item=user) request_time = format_time(datetime.now(timezone.utc)) @@ -53,7 +61,9 @@ def test_get_user_with_jobs(client, tables): assert response.status_code == HTTPStatus.OK assert response.json == { 'user_id': 'user_with_jobs', - 'remaining_credits': 20, + 'application_status': APPLICATION_APPROVED, + 'credits_per_month': 50, + 'remaining_credits': 50, 'job_names': [ 'job1', 'job2', diff --git a/tests/test_api/test_patch_user.py b/tests/test_api/test_patch_user.py new file mode 100644 index 000000000..90d1c8d2d --- /dev/null +++ b/tests/test_api/test_patch_user.py @@ -0,0 +1,68 @@ +import unittest.mock +from decimal import Decimal +from http import HTTPStatus + +from test_api.conftest import DEFAULT_ACCESS_TOKEN, USER_URI, login + +from dynamo.user import APPLICATION_PENDING, APPLICATION_REJECTED + + +def test_patch_new_user(client, tables): + login(client, 'foo') + with unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + mock_get_edl_profile.return_value = {} + response = client.patch(USER_URI, json={'use_case': 'I want data.'}) + mock_get_edl_profile.assert_called_once_with('foo', DEFAULT_ACCESS_TOKEN) + + assert response.status_code == HTTPStatus.OK + assert response.json == { + 'user_id': 'foo', + 'application_status': APPLICATION_PENDING, + 'remaining_credits': Decimal(0), + 'job_names': [], + 'use_case': 'I want data.', + } + + +def test_patch_pending_user(client, tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(5), + 'application_status': APPLICATION_PENDING, + 'use_case': 'Old use case.', + '_edl_profile': {}, + '_foo': 'bar', + } + ) + + login(client, 'foo') + with unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + mock_get_edl_profile.return_value = {} + response = client.patch(USER_URI, json={'use_case': 'New use case.'}) + mock_get_edl_profile.assert_called_once_with('foo', DEFAULT_ACCESS_TOKEN) + + assert response.status_code == HTTPStatus.OK + assert response.json == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_PENDING, + 'use_case': 'New use case.', + 'job_names': [], + } + + +def test_patch_rejected_user(client, tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_REJECTED, + } + ) + + login(client, 'foo') + response = client.patch(USER_URI, json={'use_case': 'I want data.'}) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert 'has been rejected' in response.json['detail'] diff --git a/tests/test_api/test_submit_job.py b/tests/test_api/test_submit_job.py index 1a0a80f05..962b544f6 100644 --- a/tests/test_api/test_submit_job.py +++ b/tests/test_api/test_submit_job.py @@ -1,13 +1,15 @@ from datetime import datetime, timezone +from decimal import Decimal from http import HTTPStatus -from test_api.conftest import DEFAULT_USERNAME, login, make_job, setup_requests_mock, submit_batch +from test_api.conftest import login, make_job, setup_requests_mock, submit_batch +from dynamo.user import APPLICATION_PENDING from dynamo.util import format_time -def test_submit_one_job(client, tables): - login(client) +def test_submit_one_job(client, approved_user): + login(client, username=approved_user) batch = [make_job()] setup_requests_mock(batch) response = submit_batch(client, batch) @@ -16,11 +18,11 @@ def test_submit_one_job(client, tables): assert len(jobs) == 1 assert jobs[0]['status_code'] == 'PENDING' assert jobs[0]['request_time'] <= format_time(datetime.now(timezone.utc)) - assert jobs[0]['user_id'] == DEFAULT_USERNAME + assert jobs[0]['user_id'] == approved_user -def test_submit_insar_gamma(client, tables): - login(client) +def test_submit_insar_gamma(client, approved_user): + login(client, username=approved_user) granules = [ 'S1A_IW_SLC__1SDV_20200720T172109_20200720T172128_033541_03E2FB_341F', 'S1A_IW_SLC__1SDV_20200813T172110_20200813T172129_033891_03EE3F_2C3E', @@ -55,8 +57,8 @@ def test_submit_insar_gamma(client, tables): assert response.status_code == HTTPStatus.OK -def test_submit_autorift(client, tables): - login(client) +def test_submit_autorift(client, approved_user): + login(client, username=approved_user) job = make_job( [ 'S1A_IW_SLC__1SDV_20200720T172109_20200720T172128_033541_03E2FB_341F', @@ -70,8 +72,8 @@ def test_submit_autorift(client, tables): assert response.status_code == HTTPStatus.OK -def test_submit_multiple_job_types(client, tables): - login(client) +def test_submit_multiple_job_types(client, approved_user): + login(client, username=approved_user) rtc_gamma_job = make_job() insar_gamma_job = make_job( [ @@ -93,9 +95,9 @@ def test_submit_multiple_job_types(client, tables): assert response.status_code == HTTPStatus.OK -def test_submit_many_jobs(client, tables): +def test_submit_many_jobs(client, approved_user): max_jobs = 25 - login(client) + login(client, username=approved_user) batch = [make_job() for ii in range(max_jobs)] setup_requests_mock(batch) @@ -112,8 +114,8 @@ def test_submit_many_jobs(client, tables): assert response.status_code == HTTPStatus.BAD_REQUEST -def test_submit_exceeds_remaining_credits(client, tables, monkeypatch): - login(client) +def test_submit_exceeds_remaining_credits(client, approved_user, monkeypatch): + login(client, username=approved_user) monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '25') batch1 = [make_job() for _ in range(20)] @@ -130,6 +132,24 @@ def test_submit_exceeds_remaining_credits(client, tables, monkeypatch): assert response2.json['detail'] == 'These jobs would cost 10.0 credits, but you have only 5.0 remaining.' +def test_submit_unapproved_user(client, tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_PENDING, + } + ) + login(client, username='foo') + + batch = [make_job()] + setup_requests_mock(batch) + + response = submit_batch(client, batch) + assert response.status_code == HTTPStatus.FORBIDDEN + assert 'request for access is pending review' in response.json['detail'] + + def test_submit_without_jobs(client): login(client) batch = [] @@ -137,8 +157,8 @@ def test_submit_without_jobs(client): assert response.status_code == HTTPStatus.BAD_REQUEST -def test_submit_job_without_name(client, tables): - login(client) +def test_submit_job_without_name(client, approved_user): + login(client, username=approved_user) batch = [ make_job(name=None) ] @@ -198,8 +218,8 @@ def test_submit_job_granule_does_not_exist(client, tables): 'S1A_IW_SLC__1SDV_20200610T173646_20200610T173704_032958_03D14C_5F2A' -def test_submit_good_rtc_granule_names(client, tables): - login(client) +def test_submit_good_rtc_granule_names(client, approved_user): + login(client, username=approved_user) good_granule_names = [ 'S1B_IW_SLC__1SDV_20200604T082207_20200604T082234_021881_029874_5E38', 'S1A_IW_SLC__1SSH_20150608T205059_20150608T205126_006287_0083E8_C4F0', @@ -252,8 +272,8 @@ def test_submit_bad_rtc_granule_names(client): assert response.status_code == HTTPStatus.BAD_REQUEST -def test_submit_good_autorift_granule_names(client, tables): - login(client) +def test_submit_good_autorift_granule_names(client, approved_user): + login(client, username=approved_user) good_granule_names = [ 'S1B_IW_SLC__1SDV_20200604T082207_20200604T082234_021881_029874_5E38', 'S1A_IW_SLC__1SSH_20150608T205059_20150608T205126_006287_0083E8_C4F0', @@ -318,8 +338,8 @@ def test_submit_bad_autorift_granule_names(client): assert response.status_code == HTTPStatus.BAD_REQUEST -def test_submit_mixed_job_parameters(client, tables): - login(client) +def test_submit_mixed_job_parameters(client, approved_user): + login(client, username=approved_user) rtc_parameters = { 'resolution': 30.0, @@ -375,8 +395,8 @@ def test_submit_mixed_job_parameters(client, tables): assert response.status_code == HTTPStatus.BAD_REQUEST -def test_float_input(client, tables): - login(client) +def test_float_input(client, approved_user): + login(client, username=approved_user) batch = [make_job(parameters={'resolution': 30.0})] setup_requests_mock(batch) response = submit_batch(client, batch) @@ -390,8 +410,8 @@ def test_float_input(client, tables): assert isinstance(response.json['jobs'][0]['job_parameters']['resolution'], int) -def test_submit_validate_only(client, tables): - login(client) +def test_submit_validate_only(client, tables, approved_user): + login(client, username=approved_user) batch = [make_job()] setup_requests_mock(batch) diff --git a/tests/test_dynamo/test_jobs.py b/tests/test_dynamo/test_jobs.py index b19ea7ed0..2b5d1b748 100644 --- a/tests/test_dynamo/test_jobs.py +++ b/tests/test_dynamo/test_jobs.py @@ -6,6 +6,14 @@ from conftest import list_have_same_elements import dynamo +from dynamo.exceptions import ( + InsufficientCreditsError, + InvalidApplicationStatusError, + NotStartedApplicationError, + PendingApplicationError, + RejectedApplicationError, +) +from dynamo.user import APPLICATION_APPROVED def test_query_jobs_by_user(tables): @@ -257,14 +265,14 @@ def test_get_credit_cost_validate_keys(): dynamo.jobs._get_credit_cost({'job_type': 'JOB_TYPE_F'}, costs) -def test_put_jobs(tables, monkeypatch): +def test_put_jobs(tables, monkeypatch, approved_user): monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '10') payload = [{'name': 'name1'}, {'name': 'name1'}, {'name': 'name2'}] with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month: mock_get_current_month.return_value = '2024-02' - jobs = dynamo.jobs.put_jobs('user1', payload) + jobs = dynamo.jobs.put_jobs(approved_user, payload) mock_get_current_month.assert_called_once_with() @@ -274,19 +282,70 @@ def test_put_jobs(tables, monkeypatch): 'name', 'job_id', 'user_id', 'status_code', 'execution_started', 'request_time', 'priority', 'credit_cost' } assert job['request_time'] <= dynamo.util.format_time(datetime.now(timezone.utc)) - assert job['user_id'] == 'user1' + assert job['user_id'] == approved_user assert job['status_code'] == 'PENDING' assert job['execution_started'] is False assert job['credit_cost'] == 1 assert tables.jobs_table.scan()['Items'] == sorted(jobs, key=lambda item: item['job_id']) - assert tables.users_table.scan()['Items'] == [ - {'user_id': 'user1', 'remaining_credits': 7, 'month_of_last_credits_reset': '2024-02'} - ] + assert tables.users_table.scan()['Items'] == [{ + 'user_id': approved_user, + 'remaining_credits': Decimal(7), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + }] + + +def test_put_jobs_application_status(tables): + payload = [{'name': 'name1'}, {'name': 'name1'}, {'name': 'name2'}] + + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': dynamo.user.APPLICATION_NOT_STARTED, + } + ) + with pytest.raises(NotStartedApplicationError): + dynamo.jobs.put_jobs('foo', payload) + assert tables.jobs_table.scan()['Items'] == [] + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': dynamo.user.APPLICATION_PENDING, + } + ) + with pytest.raises(PendingApplicationError): + dynamo.jobs.put_jobs('foo', payload) + assert tables.jobs_table.scan()['Items'] == [] -def test_put_jobs_default_params(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': dynamo.user.APPLICATION_REJECTED, + } + ) + with pytest.raises(RejectedApplicationError): + dynamo.jobs.put_jobs('foo', payload) + assert tables.jobs_table.scan()['Items'] == [] + + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': 'bar', + } + ) + with pytest.raises(InvalidApplicationStatusError): + dynamo.jobs.put_jobs('foo', payload) + assert tables.jobs_table.scan()['Items'] == [] + + +def test_put_jobs_default_params(tables, approved_user): default_params = { 'JOB_TYPE_A': {'a1': 'a1_default', 'a2': 'a2_default'}, 'JOB_TYPE_B': {'b1': 'b1_default'}, @@ -313,7 +372,7 @@ def test_put_jobs_default_params(tables): ] with unittest.mock.patch('dynamo.jobs.DEFAULT_PARAMS_BY_JOB_TYPE', default_params), \ unittest.mock.patch('dynamo.jobs.COSTS', costs): - jobs = dynamo.jobs.put_jobs('user1', payload) + jobs = dynamo.jobs.put_jobs(approved_user, payload) assert 'job_parameters' not in jobs[0] assert jobs[1]['job_parameters'] == {'a1': 'a1_default', 'a2': 'a2_default'} @@ -331,8 +390,8 @@ def test_put_jobs_default_params(tables): assert tables.jobs_table.scan()['Items'] == sorted(jobs, key=lambda item: item['job_id']) -def test_put_jobs_costs(tables): - tables.users_table.put_item(Item={'user_id': 'user1', 'remaining_credits': Decimal(100)}) +def test_put_jobs_costs(tables, monkeypatch, approved_user): + monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '100') costs = [ { @@ -390,7 +449,7 @@ def test_put_jobs_costs(tables): ] with unittest.mock.patch('dynamo.jobs.COSTS', costs), \ unittest.mock.patch('dynamo.jobs.DEFAULT_PARAMS_BY_JOB_TYPE', default_params): - jobs = dynamo.jobs.put_jobs('user1', payload) + jobs = dynamo.jobs.put_jobs(approved_user, payload) assert len(jobs) == 8 @@ -413,39 +472,27 @@ def test_put_jobs_costs(tables): assert jobs[7]['credit_cost'] == Decimal('0.4') assert tables.jobs_table.scan()['Items'] == sorted(jobs, key=lambda item: item['job_id']) - assert tables.users_table.scan()['Items'] == [{'user_id': 'user1', 'remaining_credits': Decimal('11.7')}] - - -def test_put_jobs_user_exists(tables): - tables.users_table.put_item(Item={'user_id': 'user1', 'remaining_credits': 5}) + assert tables.users_table.scan()['Items'][0]['remaining_credits'] == Decimal('11.7') - jobs = dynamo.jobs.put_jobs('user1', [{}, {}]) - - assert len(jobs) == 2 - assert tables.jobs_table.scan()['Items'] == sorted(jobs, key=lambda item: item['job_id']) - assert tables.users_table.scan()['Items'] == [{'user_id': 'user1', 'remaining_credits': 3}] - -def test_put_jobs_insufficient_credits(tables, monkeypatch): +def test_put_jobs_insufficient_credits(tables, monkeypatch, approved_user): monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '1') payload = [{'name': 'name1'}, {'name': 'name2'}] - with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month: - mock_get_current_month.return_value = '2024-02' - with pytest.raises(dynamo.jobs.InsufficientCreditsError): - dynamo.jobs.put_jobs('user1', payload) + with pytest.raises(InsufficientCreditsError): + dynamo.jobs.put_jobs(approved_user, payload) assert tables.jobs_table.scan()['Items'] == [] - assert tables.users_table.scan()['Items'] == [ - {'user_id': 'user1', 'remaining_credits': 1, 'month_of_last_credits_reset': '2024-02'} - ] + assert tables.users_table.scan()['Items'][0]['remaining_credits'] == 1 def test_put_jobs_infinite_credits(tables, monkeypatch): monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '1') payload = [{'name': 'name1'}, {'name': 'name2'}] - tables.users_table.put_item(Item={'user_id': 'user1', 'remaining_credits': None}) + tables.users_table.put_item( + Item={'user_id': 'user1', 'remaining_credits': None, 'application_status': APPLICATION_APPROVED} + ) jobs = dynamo.jobs.put_jobs('user1', payload) @@ -456,7 +503,10 @@ def test_put_jobs_infinite_credits(tables, monkeypatch): def test_put_jobs_priority_override(tables): payload = [{'name': 'name1'}, {'name': 'name2'}] - tables.users_table.put_item(Item={'user_id': 'user1', 'priority_override': 100, 'remaining_credits': 3}) + user = { + 'user_id': 'user1', 'priority_override': 100, 'remaining_credits': 3, 'application_status': APPLICATION_APPROVED + } + tables.users_table.put_item(Item=user) jobs = dynamo.jobs.put_jobs('user1', payload) @@ -464,7 +514,13 @@ def test_put_jobs_priority_override(tables): for job in jobs: assert job['priority'] == 100 - tables.users_table.put_item(Item={'user_id': 'user1', 'priority_override': 550, 'remaining_credits': None}) + user = { + 'user_id': 'user1', + 'priority_override': 550, + 'remaining_credits': None, + 'application_status': APPLICATION_APPROVED + } + tables.users_table.put_item(Item=user) jobs = dynamo.jobs.put_jobs('user1', payload) @@ -473,31 +529,31 @@ def test_put_jobs_priority_override(tables): assert job['priority'] == 550 -def test_put_jobs_priority(tables): - tables.users_table.put_item(Item={'user_id': 'user1', 'remaining_credits': 7}) +def test_put_jobs_priority(tables, monkeypatch, approved_user): + monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '7') - jobs = dynamo.jobs.put_jobs(user_id='user1', jobs=[{}, {}, {}]) + jobs = dynamo.jobs.put_jobs(user_id=approved_user, jobs=[{}, {}, {}]) assert jobs[0]['priority'] == 7 assert jobs[1]['priority'] == 6 assert jobs[2]['priority'] == 5 - jobs.extend(dynamo.jobs.put_jobs(user_id='user1', jobs=[{}, {}, {}, {}])) + jobs.extend(dynamo.jobs.put_jobs(user_id=approved_user, jobs=[{}, {}, {}, {}])) assert jobs[3]['priority'] == 4 assert jobs[4]['priority'] == 3 assert jobs[5]['priority'] == 2 assert jobs[6]['priority'] == 1 -def test_put_jobs_priority_extra_credits(tables): - tables.users_table.put_item(Item={'user_id': 'user1', 'remaining_credits': 10_003}) +def test_put_jobs_priority_extra_credits(tables, monkeypatch, approved_user): + monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '10003') - jobs = dynamo.jobs.put_jobs(user_id='user1', jobs=[{}]) + jobs = dynamo.jobs.put_jobs(user_id=approved_user, jobs=[{}]) assert jobs[0]['priority'] == 9999 - jobs.extend(dynamo.jobs.put_jobs(user_id='user1', jobs=[{}])) + jobs.extend(dynamo.jobs.put_jobs(user_id=approved_user, jobs=[{}])) assert jobs[1]['priority'] == 9999 - jobs.extend(dynamo.jobs.put_jobs(user_id='user1', jobs=[{}] * 6)) + jobs.extend(dynamo.jobs.put_jobs(user_id=approved_user, jobs=[{}] * 6)) assert jobs[2]['priority'] == 9999 assert jobs[3]['priority'] == 9999 assert jobs[4]['priority'] == 9999 @@ -506,11 +562,11 @@ def test_put_jobs_priority_extra_credits(tables): assert jobs[7]['priority'] == 9996 -def test_put_jobs_decrement_credits_failure(tables): +def test_put_jobs_decrement_credits_failure(tables, approved_user): with unittest.mock.patch('dynamo.user.decrement_credits') as mock_decrement_credits: mock_decrement_credits.side_effect = Exception('test error') with pytest.raises(Exception, match=r'^test error$'): - dynamo.jobs.put_jobs('user1', [{'name': 'job1'}]) + dynamo.jobs.put_jobs(approved_user, [{'name': 'job1'}]) assert tables.jobs_table.scan()['Items'] == [] diff --git a/tests/test_dynamo/test_user.py b/tests/test_dynamo/test_user.py index 0dbab7679..ee12c0299 100644 --- a/tests/test_dynamo/test_user.py +++ b/tests/test_dynamo/test_user.py @@ -5,82 +5,254 @@ import pytest import dynamo.user +from dynamo.exceptions import ( + ApprovedApplicationError, + DatabaseConditionException, + InvalidApplicationStatusError, + RejectedApplicationError, +) +from dynamo.user import APPLICATION_APPROVED, APPLICATION_NOT_STARTED, APPLICATION_PENDING, APPLICATION_REJECTED + + +def test_update_user(tables): + with unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + mock_get_edl_profile.return_value = {'key': 'value'} + user = dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.'}, + ) + mock_get_edl_profile.assert_called_once_with('foo', 'test-edl-access-token') + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_PENDING, + '_edl_profile': {'key': 'value'}, + 'use_case': 'I want data.', + } + assert tables.users_table.scan()['Items'] == [user] -def test_get_or_create_user_reset(tables, monkeypatch): - monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '25') - tables.users_table.put_item(Item={'user_id': 'foo'}) - with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month, \ - unittest.mock.patch('dynamo.user._reset_credits_if_needed') as mock_reset_credits_if_needed: - mock_get_current_month.return_value = '2024-02' - mock_reset_credits_if_needed.return_value = 'reset_credits_return_value' +def test_update_user_not_started(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(5), + 'application_status': APPLICATION_NOT_STARTED, + } + ) + with unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + mock_get_edl_profile.return_value = {'key': 'value'} + user = dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.'}, + ) + mock_get_edl_profile.assert_called_once_with('foo', 'test-edl-access-token') - user = dynamo.user.get_or_create_user('foo') + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_PENDING, + '_edl_profile': {'key': 'value'}, + 'use_case': 'I want data.', + } + assert tables.users_table.scan()['Items'] == [user] - mock_get_current_month.assert_called_once_with() - mock_reset_credits_if_needed.assert_called_once_with( - user={'user_id': 'foo'}, - default_credits=Decimal(25), - current_month='2024-02', - users_table=tables.users_table, + +def test_update_user_pending(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(5), + 'application_status': APPLICATION_PENDING, + '_edl_profile': {'key': 'old_value'}, + 'use_case': 'Old use case.', + } + ) + with unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + mock_get_edl_profile.return_value = {'key': 'new_value'} + user = dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'New use case.'}, ) + mock_get_edl_profile.assert_called_once_with('foo', 'test-edl-access-token') - assert user == 'reset_credits_return_value' + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_PENDING, + '_edl_profile': {'key': 'new_value'}, + 'use_case': 'New use case.', + } + assert tables.users_table.scan()['Items'] == [user] -def test_get_or_create_user_create(tables, monkeypatch): - monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '25') +def test_update_user_rejected(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(5), + 'application_status': APPLICATION_REJECTED, + } + ) + with pytest.raises(RejectedApplicationError): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.'}, + ) + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_REJECTED, + }] + - with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month, \ - unittest.mock.patch('dynamo.user._create_user') as mock_create_user: +def test_update_user_approved(tables, monkeypatch): + monkeypatch.setenv('DEFAULT_CREDITS_PER_USER', '25') + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'application_status': APPLICATION_APPROVED, + } + ) + with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month: mock_get_current_month.return_value = '2024-02' - mock_create_user.return_value = 'create_user_return_value' + with pytest.raises(ApprovedApplicationError): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.'}, + ) + mock_get_current_month.assert_called_once_with() - user = dynamo.user.get_or_create_user('foo') + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(25), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + }] - mock_get_current_month.assert_called_once_with() - mock_create_user.assert_called_once_with( - user_id='foo', - default_credits=Decimal(25), - current_month='2024-02', - users_table=tables.users_table, + +def test_update_user_invalid_status(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(5), + 'application_status': 'bar', + } + ) + with pytest.raises(InvalidApplicationStatusError): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.'}, ) + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': 'bar', + }] - assert user == 'create_user_return_value' +def test_update_user_failed_application_status(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'application_status': 'bar', + } + ) + with unittest.mock.patch('dynamo.user.get_or_create_user') as mock_get_or_create_user, \ + unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + mock_get_or_create_user.return_value = { + 'user_id': 'foo', + 'application_status': APPLICATION_NOT_STARTED, + } + mock_get_edl_profile.return_value = {'key': 'new_value'} + with pytest.raises(DatabaseConditionException): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'New use case.'}, + ) + mock_get_or_create_user.assert_called_once_with('foo') + mock_get_edl_profile.assert_called_once_with('foo', 'test-edl-access-token') + + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'application_status': 'bar', + }] -def test_create_user(tables): - user = dynamo.user._create_user( - user_id='foo', - default_credits=Decimal(25), - current_month='2024-02', - users_table=tables.users_table + +def test_get_or_create_user_existing_user(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_APPROVED, + } ) - assert user == {'user_id': 'foo', 'remaining_credits': Decimal(25), 'month_of_last_credits_reset': '2024-02'} + with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month: + mock_get_current_month.return_value = '2024-02' + user = dynamo.user.get_or_create_user(user_id='foo') + mock_get_current_month.assert_called_once_with() + + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(25), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } + assert tables.users_table.scan()['Items'] == [user] + + +def test_get_or_create_user_new_user(tables): + user = dynamo.user.get_or_create_user(user_id='foo') + + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': APPLICATION_NOT_STARTED, + } + assert tables.users_table.scan()['Items'] == [user] + + +def test_get_or_create_user_default_application_status_approved(tables, monkeypatch): + monkeypatch.setenv('DEFAULT_APPLICATION_STATUS', APPLICATION_APPROVED) + + with unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month: + mock_get_current_month.return_value = '2024-02' + user = dynamo.user.get_or_create_user(user_id='foo') + mock_get_current_month.assert_called_once_with() + + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(25), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } assert tables.users_table.scan()['Items'] == [user] -def test_create_user_already_exists(tables): +def test_create_user_failed_already_exists(tables): tables.users_table.put_item(Item={'user_id': 'foo'}) - with pytest.raises(dynamo.user.DatabaseConditionException): - dynamo.user._create_user( - user_id='foo', - default_credits=Decimal(25), - current_month='2024-02', - users_table=tables.users_table - ) + with pytest.raises(DatabaseConditionException): + dynamo.user._create_user(user_id='foo', users_table=tables.users_table) assert tables.users_table.scan()['Items'] == [{'user_id': 'foo'}] -def test_reset_credits(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'true') - +def test_reset_credits(tables): original_user_record = { - 'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-01' + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'application_status': APPLICATION_APPROVED, } tables.users_table.put_item(Item=original_user_record) @@ -91,18 +263,46 @@ def test_reset_credits(tables, monkeypatch): users_table=tables.users_table, ) - assert user == {'user_id': 'foo', 'remaining_credits': Decimal(25), 'month_of_last_credits_reset': '2024-02'} + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(25), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } assert tables.users_table.scan()['Items'] == [user] -def test_reset_credits_override(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'true') +def test_reset_credits_month_exists(tables): + original_user_record = { + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-01', + 'application_status': APPLICATION_APPROVED, + } + tables.users_table.put_item(Item=original_user_record) + + user = dynamo.user._reset_credits_if_needed( + user=original_user_record, + default_credits=Decimal(25), + current_month='2024-02', + users_table=tables.users_table, + ) + + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(25), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } + assert tables.users_table.scan()['Items'] == [user] + +def test_reset_credits_override(tables): original_user_record = { 'user_id': 'foo', 'remaining_credits': Decimal(10), 'credits_per_month': Decimal(50), - 'month_of_last_credits_reset': '2024-01', + 'application_status': APPLICATION_APPROVED, } tables.users_table.put_item(Item=original_user_record) @@ -117,16 +317,18 @@ def test_reset_credits_override(tables, monkeypatch): 'user_id': 'foo', 'remaining_credits': Decimal(50), 'credits_per_month': Decimal(50), - 'month_of_last_credits_reset': '2024-02', + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, } assert tables.users_table.scan()['Items'] == [user] -def test_reset_credits_no_reset(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'false') - +def test_reset_credits_same_month(tables): original_user_record = { - 'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-01' + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, } tables.users_table.put_item(Item=original_user_record) @@ -137,15 +339,20 @@ def test_reset_credits_no_reset(tables, monkeypatch): users_table=tables.users_table, ) - assert user == {'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-01'} + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } assert tables.users_table.scan()['Items'] == [user] -def test_reset_credits_same_month(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'true') - +def test_reset_credits_infinite_credits(tables): original_user_record = { - 'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-02' + 'user_id': 'foo', + 'remaining_credits': None, + 'application_status': APPLICATION_APPROVED, } tables.users_table.put_item(Item=original_user_record) @@ -156,15 +363,45 @@ def test_reset_credits_same_month(tables, monkeypatch): users_table=tables.users_table, ) - assert user == {'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-02'} + assert user == { + 'user_id': 'foo', + 'remaining_credits': None, + 'application_status': APPLICATION_APPROVED, + } assert tables.users_table.scan()['Items'] == [user] -def test_reset_credits_infinite_credits(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'true') +def test_reset_credits_to_zero(tables): + original_user_record = { + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'credits_per_month': Decimal(50), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', + } + tables.users_table.put_item(Item=original_user_record) + + user = dynamo.user._reset_credits_if_needed( + user=original_user_record, + default_credits=Decimal(25), + current_month='2024-02', + users_table=tables.users_table, + ) + + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + 'application_status': 'bar', + } + assert tables.users_table.scan()['Items'] == [user] + +def test_reset_credits_already_at_zero(tables): original_user_record = { - 'user_id': 'foo', 'remaining_credits': None, 'month_of_last_credits_reset': '2024-01' + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', } tables.users_table.put_item(Item=original_user_record) @@ -175,46 +412,162 @@ def test_reset_credits_infinite_credits(tables, monkeypatch): users_table=tables.users_table, ) - assert user == {'user_id': 'foo', 'remaining_credits': None, 'month_of_last_credits_reset': '2024-01'} + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', + } assert tables.users_table.scan()['Items'] == [user] -def test_reset_credits_failed_same_month(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'true') +def test_reset_credits_failed_not_approved(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'application_status': 'bar', + } + ) + + with pytest.raises(DatabaseConditionException): + dynamo.user._reset_credits_if_needed( + user={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'application_status': APPLICATION_APPROVED, + }, + default_credits=Decimal(25), + current_month='2024-02', + users_table=tables.users_table, + ) + + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'application_status': 'bar', + }] + + +def test_reset_credits_failed_same_month(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } + ) + + with pytest.raises(DatabaseConditionException): + dynamo.user._reset_credits_if_needed( + user={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-01', + 'application_status': APPLICATION_APPROVED, + }, + default_credits=Decimal(25), + current_month='2024-02', + users_table=tables.users_table, + ) + + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + }] + + +def test_reset_credits_failed_infinite_credits(tables): + tables.users_table.put_item( + Item={ + 'user_id': 'foo', + 'remaining_credits': None, + 'application_status': APPLICATION_APPROVED, + } + ) + + with pytest.raises(DatabaseConditionException): + dynamo.user._reset_credits_if_needed( + user={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + 'application_status': APPLICATION_APPROVED, + }, + default_credits=Decimal(25), + current_month='2024-02', + users_table=tables.users_table, + ) + + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': None, + 'application_status': APPLICATION_APPROVED, + }] + + +def test_reset_credits_failed_approved(tables): tables.users_table.put_item( - Item={'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-02'} + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + } ) - with pytest.raises(dynamo.user.DatabaseConditionException): + with pytest.raises(DatabaseConditionException): dynamo.user._reset_credits_if_needed( - user={'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-01'}, + user={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', + }, default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) - assert tables.users_table.scan()['Items'] == [ - {'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-02'} - ] + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': APPLICATION_APPROVED, + }] -def test_reset_credits_failed_infinite_credits(tables, monkeypatch): - monkeypatch.setenv('RESET_CREDITS_MONTHLY', 'true') +def test_reset_credits_failed_zero_credits(tables): tables.users_table.put_item( - Item={'user_id': 'foo', 'remaining_credits': None, 'month_of_last_credits_reset': '2024-01'} + Item={ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', + } ) - with pytest.raises(dynamo.user.DatabaseConditionException): + with pytest.raises(DatabaseConditionException): dynamo.user._reset_credits_if_needed( - user={'user_id': 'foo', 'remaining_credits': Decimal(10), 'month_of_last_credits_reset': '2024-01'}, + user={ + 'user_id': 'foo', + 'remaining_credits': Decimal(10), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', + }, default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) - assert tables.users_table.scan()['Items'] == [ - {'user_id': 'foo', 'remaining_credits': None, 'month_of_last_credits_reset': '2024-01'} - ] + assert tables.users_table.scan()['Items'] == [{ + 'user_id': 'foo', + 'remaining_credits': Decimal(0), + '_month_of_last_credit_reset': '2024-02', + 'application_status': 'bar', + }] def test_decrement_credits(tables): @@ -245,7 +598,7 @@ def test_decrement_credits_invalid_cost(tables): def test_decrement_credits_cost_too_high(tables): tables.users_table.put_item(Item={'user_id': 'foo', 'remaining_credits': Decimal(1)}) - with pytest.raises(dynamo.user.DatabaseConditionException): + with pytest.raises(DatabaseConditionException): dynamo.user.decrement_credits('foo', 2) assert tables.users_table.scan()['Items'] == [{'user_id': 'foo', 'remaining_credits': Decimal(1)}] @@ -254,7 +607,7 @@ def test_decrement_credits_cost_too_high(tables): assert tables.users_table.scan()['Items'] == [{'user_id': 'foo', 'remaining_credits': Decimal(0)}] - with pytest.raises(dynamo.user.DatabaseConditionException): + with pytest.raises(DatabaseConditionException): dynamo.user.decrement_credits('foo', 1) assert tables.users_table.scan()['Items'] == [{'user_id': 'foo', 'remaining_credits': Decimal(0)}] @@ -274,7 +627,7 @@ def test_decrement_credits_infinite_credits(tables): def test_decrement_credits_user_does_not_exist(tables): - with pytest.raises(dynamo.user.DatabaseConditionException): + with pytest.raises(DatabaseConditionException): dynamo.user.decrement_credits('foo', 1) assert tables.users_table.scan()['Items'] == []