diff --git a/.github/workflows/deploy-daac.yml b/.github/workflows/deploy-daac.yml index be75bc6ed..73aaf2ba9 100644 --- a/.github/workflows/deploy-daac.yml +++ b/.github/workflows/deploy-daac.yml @@ -62,7 +62,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.5 + - uses: actions/checkout@v4.1.6 - uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/.github/workflows/deploy-enterprise-test.yml b/.github/workflows/deploy-enterprise-test.yml index e60494da3..1e9bdce3d 100644 --- a/.github/workflows/deploy-enterprise-test.yml +++ b/.github/workflows/deploy-enterprise-test.yml @@ -82,7 +82,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.5 + - uses: actions/checkout@v4.1.6 - uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/.github/workflows/deploy-enterprise.yml b/.github/workflows/deploy-enterprise.yml index 09a58aaf8..23669e144 100644 --- a/.github/workflows/deploy-enterprise.yml +++ b/.github/workflows/deploy-enterprise.yml @@ -37,7 +37,7 @@ jobs: domain: hyp3-a19-jpl.asf.alaska.edu template_bucket: cf-templates-v4pvone059de-us-west-2 image_tag: latest - product_lifetime_in_days: 180 + product_lifetime_in_days: 14 default_credits_per_user: 0 default_application_status: APPROVED cost_profile: DEFAULT @@ -45,8 +45,8 @@ jobs: job_spec/ARIA_RAIDER.yml job_spec/INSAR_ISCE.yml instance_types: c6id.xlarge,c6id.2xlarge,c6id.4xlarge,c6id.8xlarge - default_max_vcpus: 4000 - expanded_max_vcpus: 4000 + default_max_vcpus: 0 + expanded_max_vcpus: 0 required_surplus: 0 security_environment: JPL-public ami_id: /aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id @@ -229,7 +229,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.5 + - uses: actions/checkout@v4.1.6 - uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/.github/workflows/deploy-whitelisting-sandbox.yml b/.github/workflows/deploy-whitelisting-sandbox.yml index 3001fbdc1..7ffe281bf 100644 --- a/.github/workflows/deploy-whitelisting-sandbox.yml +++ b/.github/workflows/deploy-whitelisting-sandbox.yml @@ -41,7 +41,7 @@ jobs: url: https://${{ matrix.domain }} steps: - - uses: actions/checkout@v4.1.2 + - uses: actions/checkout@v4.1.6 - uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index ee35bf19f..c986d1feb 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.5 + - uses: actions/checkout@v4.1.6 - 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.5 + - uses: actions/checkout@v4.1.6 - 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.5 + - uses: actions/checkout@v4.1.6 - 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.5 + - uses: actions/checkout@v4.1.6 - 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.5 + - uses: actions/checkout@v4.1.6 - 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 a48a05477..dd480b0a9 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.5 + - uses: actions/checkout@v4.1.6 - uses: actions/setup-python@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b3f5205..c260abd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.3.0] + +This release adds support for access codes. If a user specifies an active access code when they apply for HyP3 access, they will be granted automatic approval without the need for a HyP3 operator to review their application. + +If you operate a HyP3 deployment, you can create a new access code by adding an item to the `AccessCodesTable` DynamoDB table for your deployment, with any string for the `access_code` attribute and an ISO-formatted UTC timestamp for the `start_date` and `end_date` attributes, e.g. `2024-06-01T00:00:00+00:00` and `2024-06-02T00:00:00+00:00` for an access code that becomes active on June 1, 2024 and expires on June 2, 2024. + +### Added +- The `PATCH /user` endpoint now includes an optional `access_code` parameter and returns a `403` response if given an invalid or inactive access code. + +### Changed +- Turn off hyp3 ACCESS spend by zeroing the max VCPUs in the associated deployment. +- Reduce product lifetime in hyp3 ACCESS deployment to 14 days. + ## [7.2.1] ### Fixed diff --git a/apps/api/api-cf.yml.j2 b/apps/api/api-cf.yml.j2 index 975bbf3be..338ff01ec 100644 --- a/apps/api/api-cf.yml.j2 +++ b/apps/api/api-cf.yml.j2 @@ -6,6 +6,9 @@ Parameters: UsersTable: Type: String + AccessCodesTable: + Type: String + AuthPublicKey: Type: String @@ -171,6 +174,10 @@ Resources: - dynamodb:PutItem - dynamodb:UpdateItem Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${UsersTable}*" + - Effect: Allow + Action: + - dynamodb:GetItem + Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${AccessCodesTable}*" Lambda: Type: AWS::Lambda::Function @@ -179,6 +186,7 @@ Resources: Variables: JOBS_TABLE_NAME: !Ref JobsTable USERS_TABLE_NAME: !Ref UsersTable + ACCESS_CODES_TABLE_NAME: !Ref AccessCodesTable AUTH_PUBLIC_KEY: !Ref AuthPublicKey AUTH_ALGORITHM: !Ref AuthAlgorithm DEFAULT_CREDITS_PER_USER: !Ref DefaultCreditsPerUser 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 98d5ea13f..16c014488 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 @@ -197,6 +197,8 @@ components: properties: use_case: $ref: "#/components/schemas/use_case" + access_code: + $ref: "#/components/schemas/access_code" user: description: Information about a user @@ -315,6 +317,11 @@ components: type: string example: I want to process data. + access_code: + description: Grants automatic user approval while the code remains active. + type: string + example: 123 + user_id: description: Username from Earthdata Login. type: string diff --git a/apps/api/src/hyp3_api/handlers.py b/apps/api/src/hyp3_api/handlers.py index 81c3afd13..393fc4ac6 100644 --- a/apps/api/src/hyp3_api/handlers.py +++ b/apps/api/src/hyp3_api/handlers.py @@ -4,7 +4,7 @@ from flask import abort, jsonify, request import dynamo -from dynamo.exceptions import InsufficientCreditsError, UnexpectedApplicationStatusError +from dynamo.exceptions import AccessCodeError, InsufficientCreditsError, UnexpectedApplicationStatusError from hyp3_api import util from hyp3_api.validation import GranuleValidationError, validate_jobs @@ -65,6 +65,8 @@ 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 AccessCodeError as e: + abort(problem_format(403, str(e))) except UnexpectedApplicationStatusError as e: abort(problem_format(403, str(e))) return _user_response(user_record) diff --git a/apps/main-cf.yml.j2 b/apps/main-cf.yml.j2 index 46a17191b..bc5ad6135 100644 --- a/apps/main-cf.yml.j2 +++ b/apps/main-cf.yml.j2 @@ -120,6 +120,7 @@ Resources: Parameters: JobsTable: !Ref JobsTable UsersTable: !Ref UsersTable + AccessCodesTable: !Ref AccessCodesTable AuthPublicKey: !Ref AuthPublicKey AuthAlgorithm: !Ref AuthAlgorithm DefaultCreditsPerUser: !Ref DefaultCreditsPerUser @@ -356,6 +357,17 @@ Resources: - AttributeName: user_id KeyType: HASH + AccessCodesTable: + Type: AWS::DynamoDB::Table + Properties: + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: access_code + AttributeType: S + KeySchema: + - AttributeName: access_code + KeyType: HASH + {% if security_environment == 'EDC' %} DisablePrivateDNS: Type: AWS::CloudFormation::Stack diff --git a/lib/dynamo/dynamo/exceptions.py b/lib/dynamo/dynamo/exceptions.py index 29f386ec7..6460d21f8 100644 --- a/lib/dynamo/dynamo/exceptions.py +++ b/lib/dynamo/dynamo/exceptions.py @@ -5,6 +5,10 @@ class DatabaseConditionException(Exception): """Raised when a DynamoDB condition expression check fails.""" +class AccessCodeError(Exception): + """Raised when a user application includes an invalid or expired access code.""" + + class InsufficientCreditsError(Exception): """Raised when trying to submit jobs whose total cost exceeds the user's remaining credits.""" diff --git a/lib/dynamo/dynamo/jobs.py b/lib/dynamo/dynamo/jobs.py index f95e26aca..ddeb579dd 100644 --- a/lib/dynamo/dynamo/jobs.py +++ b/lib/dynamo/dynamo/jobs.py @@ -1,5 +1,4 @@ import json -from datetime import datetime, timezone from decimal import Decimal from os import environ from pathlib import Path @@ -17,7 +16,7 @@ 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 +from dynamo.util import DYNAMODB_RESOURCE, convert_floats_to_decimals, current_utc_time, get_request_time_expression costs_file = Path(__file__).parent / 'costs.json' COSTS = convert_floats_to_decimals(json.loads(costs_file.read_text())) @@ -32,7 +31,7 @@ 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)) + request_time = current_utc_time() user_record = dynamo.user.get_or_create_user(user_id) diff --git a/lib/dynamo/dynamo/user.py b/lib/dynamo/dynamo/user.py index bfe5fcfea..df25c70d6 100644 --- a/lib/dynamo/dynamo/user.py +++ b/lib/dynamo/dynamo/user.py @@ -6,8 +6,13 @@ import botocore.exceptions import requests +import dynamo.util from dynamo.exceptions import ( - ApprovedApplicationError, DatabaseConditionException, InvalidApplicationStatusError, RejectedApplicationError + AccessCodeError, + ApprovedApplicationError, + DatabaseConditionException, + InvalidApplicationStatusError, + RejectedApplicationError, ) from dynamo.util import DYNAMODB_RESOURCE @@ -21,19 +26,36 @@ 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): + access_code = body.get('access_code') + if access_code: + _validate_access_code(access_code) + updated_application_status = APPLICATION_APPROVED + access_code_expression = ', access_code = :access_code' + access_code_value = {':access_code': access_code} + else: + updated_application_status = APPLICATION_PENDING + access_code_expression = '' + access_code_value = {} 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', + UpdateExpression=( + 'SET #edl_profile = :edl_profile,' + ' use_case = :use_case,' + ' application_status = :updated_application_status' + f'{access_code_expression}' + ), 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 + ':pending': APPLICATION_PENDING, + ':updated_application_status': updated_application_status, + **access_code_value }, ReturnValues='ALL_NEW', )['Attributes'] @@ -41,6 +63,7 @@ def update_user(user_id: str, edl_access_token: str, body: dict) -> dict: if e.response['Error']['Code'] == 'ConditionalCheckFailedException': raise DatabaseConditionException(f'Failed to update record for user {user_id}') raise + user = _reset_credits_if_needed(user=user, current_month=_get_current_month(), users_table=users_table) return user if application_status == APPLICATION_REJECTED: raise RejectedApplicationError(user_id) @@ -49,6 +72,21 @@ def update_user(user_id: str, edl_access_token: str, body: dict) -> dict: raise InvalidApplicationStatusError(user_id, application_status) +def _validate_access_code(access_code: str) -> None: + access_codes_table = DYNAMODB_RESOURCE.Table(environ['ACCESS_CODES_TABLE_NAME']) + item = access_codes_table.get_item(Key={'access_code': access_code}).get('Item') + + if item is None: + raise AccessCodeError(f'{access_code} is not a valid access code') + + now = dynamo.util.current_utc_time() + if now < item['start_date']: + raise AccessCodeError(f'Access code {access_code} will become active on {item["start_date"]}') + + if now >= item['end_date']: + raise AccessCodeError(f'Access code {access_code} expired on {item["end_date"]}') + + 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}'}) @@ -57,21 +95,13 @@ def _get_edl_profile(user_id: str, edl_access_token: str) -> dict: def get_or_create_user(user_id: str) -> dict: - current_month = _get_current_month() - default_credits = Decimal(os.environ['DEFAULT_CREDITS_PER_USER']) - users_table = DYNAMODB_RESOURCE.Table(environ['USERS_TABLE_NAME']) user = users_table.get_item(Key={'user_id': user_id}).get('Item') 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, - ) + return _reset_credits_if_needed(user=user, current_month=_get_current_month(), users_table=users_table) def _get_current_month() -> str: @@ -93,7 +123,7 @@ def _create_user(user_id: str, users_table) -> dict: return user -def _reset_credits_if_needed(user: dict, default_credits: Decimal, current_month: str, users_table) -> dict: +def _reset_credits_if_needed(user: dict, current_month: str, users_table) -> dict: if ( user['application_status'] == APPLICATION_APPROVED and user.get('_month_of_last_credit_reset', '0') < current_month # noqa: W503 @@ -112,7 +142,7 @@ def _reset_credits_if_needed(user: dict, default_credits: Decimal, current_month ExpressionAttributeNames={'#month_of_last_credit_reset': '_month_of_last_credit_reset'}, ExpressionAttributeValues={ ':approved': APPLICATION_APPROVED, - ':credits': user.get('credits_per_month', default_credits), + ':credits': user.get('credits_per_month', Decimal(os.environ['DEFAULT_CREDITS_PER_USER'])), ':current_month': current_month, ':number': 'N', }, diff --git a/lib/dynamo/dynamo/util.py b/lib/dynamo/dynamo/util.py index 4d2561a2b..37a2f7f0e 100644 --- a/lib/dynamo/dynamo/util.py +++ b/lib/dynamo/dynamo/util.py @@ -21,13 +21,17 @@ def get_request_time_expression(start, end): return key.lte(formatted_end) -def format_time(time: datetime): +def format_time(time: datetime) -> str: if time.tzinfo is None: raise ValueError(f'missing tzinfo for datetime {time}') utc_time = time.astimezone(timezone.utc) return utc_time.isoformat(timespec='seconds') +def current_utc_time() -> str: + return format_time(datetime.now(timezone.utc)) + + def convert_floats_to_decimals(element): if type(element) is float: return Decimal(str(element)) diff --git a/requirements-all.txt b/requirements-all.txt index f0f028a19..3bd625237 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.100 +boto3==1.34.109 jinja2==3.1.4 -moto[dynamodb]==5.0.6 -pytest==8.2.0 +moto[dynamodb]==5.0.7 +pytest==8.2.1 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.87.1 +cfn-lint==0.87.3 diff --git a/requirements-apps-api-binary.txt b/requirements-apps-api-binary.txt index 3beaa30e8..ca19e20b4 100644 --- a/requirements-apps-api-binary.txt +++ b/requirements-apps-api-binary.txt @@ -1 +1 @@ -cryptography==42.0.5 +cryptography==42.0.7 diff --git a/requirements-apps-api.txt b/requirements-apps-api.txt index b67720ab1..750fcfffb 100644 --- a/requirements-apps-api.txt +++ b/requirements-apps-api.txt @@ -4,8 +4,8 @@ jsonschema==4.22.0 openapi-core==0.19.1 prance==23.6.21.0 PyJWT==2.8.0 -requests==2.31.0 -serverless_wsgi==3.0.3 +requests==2.32.1 +serverless_wsgi==3.0.4 shapely==2.0.4 strict-rfc3339==0.7 ./lib/dynamo/ diff --git a/requirements-apps-disable-private-dns.txt b/requirements-apps-disable-private-dns.txt index 46c415b8b..e69671eed 100644 --- a/requirements-apps-disable-private-dns.txt +++ b/requirements-apps-disable-private-dns.txt @@ -1 +1 @@ -boto3==1.34.100 +boto3==1.34.109 diff --git a/requirements-apps-start-execution-manager.txt b/requirements-apps-start-execution-manager.txt index ff011fc59..ab2543465 100644 --- a/requirements-apps-start-execution-manager.txt +++ b/requirements-apps-start-execution-manager.txt @@ -1,3 +1,3 @@ -boto3==1.34.100 +boto3==1.34.109 ./lib/dynamo/ ./lib/lambda_logging/ diff --git a/requirements-apps-start-execution-worker.txt b/requirements-apps-start-execution-worker.txt index 8ff916d39..639ea1469 100644 --- a/requirements-apps-start-execution-worker.txt +++ b/requirements-apps-start-execution-worker.txt @@ -1,2 +1,2 @@ -boto3==1.34.100 +boto3==1.34.109 ./lib/lambda_logging/ diff --git a/tests/cfg.env b/tests/cfg.env index 31bf731a5..a8e781711 100644 --- a/tests/cfg.env +++ b/tests/cfg.env @@ -1,6 +1,7 @@ FLASK_DEBUG=true JOBS_TABLE_NAME=hyp3-db-table-job USERS_TABLE_NAME=hyp3-db-table-user +ACCESS_CODES_TABLE_NAME=hyp3-db-table-access-codes AUTH_PUBLIC_KEY=123456789 AUTH_ALGORITHM=HS256 DEFAULT_CREDITS_PER_USER=25 diff --git a/tests/conftest.py b/tests/conftest.py index 555c08616..be67e315b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ def table_properties(): class TableProperties: jobs_table = get_table_properties_from_template('JobsTable') users_table = get_table_properties_from_template('UsersTable') + access_codes_table = get_table_properties_from_template('AccessCodesTable') return TableProperties() @@ -40,6 +41,10 @@ class Tables: TableName=environ['USERS_TABLE_NAME'], **table_properties.users_table, ) + access_codes_table = DYNAMODB_RESOURCE.create_table( + TableName=environ['ACCESS_CODES_TABLE_NAME'], + **table_properties.access_codes_table, + ) tables = Tables() yield tables diff --git a/tests/test_api/test_get_user.py b/tests/test_api/test_get_user.py index fe21ea5ed..23acef7be 100644 --- a/tests/test_api/test_get_user.py +++ b/tests/test_api/test_get_user.py @@ -1,10 +1,9 @@ -from datetime import datetime, timezone from http import HTTPStatus 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 +from dynamo.util import current_utc_time def test_get_new_user(client, tables, monkeypatch): @@ -46,7 +45,7 @@ def test_get_user_with_jobs(client, tables): } tables.users_table.put_item(Item=user) - request_time = format_time(datetime.now(timezone.utc)) + request_time = current_utc_time() items = [ make_db_record('job1', user_id=user_id, request_time=request_time, status_code='PENDING', name='job1'), make_db_record('job2', user_id=user_id, request_time=request_time, status_code='RUNNING', name='job1'), diff --git a/tests/test_api/test_patch_user.py b/tests/test_api/test_patch_user.py index 90d1c8d2d..a3086a63d 100644 --- a/tests/test_api/test_patch_user.py +++ b/tests/test_api/test_patch_user.py @@ -4,7 +4,7 @@ from test_api.conftest import DEFAULT_ACCESS_TOKEN, USER_URI, login -from dynamo.user import APPLICATION_PENDING, APPLICATION_REJECTED +from dynamo.user import APPLICATION_APPROVED, APPLICATION_PENDING, APPLICATION_REJECTED def test_patch_new_user(client, tables): @@ -66,3 +66,85 @@ def test_patch_rejected_user(client, tables): assert response.status_code == HTTPStatus.FORBIDDEN assert 'has been rejected' in response.json['detail'] + + +def test_patch_user_access_code(client, tables): + tables.access_codes_table.put_item( + Item={'access_code': '123', 'start_date': '2024-05-21T20:01:03+00:00', 'end_date': '2024-05-21T20:01:04+00:00'} + ) + login(client, 'foo') + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time, \ + unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + + mock_current_utc_time.return_value = '2024-05-21T20:01:03+00:00' + mock_get_edl_profile.return_value = {} + + response = client.patch( + USER_URI, + json={'use_case': 'I want data.', 'access_code': '123'} + ) + + mock_current_utc_time.assert_called_once_with() + 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_APPROVED, + 'remaining_credits': Decimal(25), + 'job_names': [], + 'use_case': 'I want data.', + 'access_code': '123', + } + + +def test_patch_user_access_code_start_date(client, tables): + tables.access_codes_table.put_item( + Item={'access_code': '123', 'start_date': '2024-05-21T20:01:03+00:00'} + ) + login(client, 'foo') + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time: + mock_current_utc_time.return_value = '2024-05-21T20:01:02+00:00' + response = client.patch( + USER_URI, + json={'use_case': 'I want data.', 'access_code': '123'} + ) + mock_current_utc_time.assert_called_once_with() + + assert response.status_code == HTTPStatus.FORBIDDEN + assert 'will become active' in response.json['detail'] + + +def test_patch_user_access_code_end_date(client, tables): + tables.access_codes_table.put_item( + Item={'access_code': '123', 'start_date': '2024-05-21T20:01:03+00:00', 'end_date': '2024-05-21T20:01:04+00:00'} + ) + login(client, 'foo') + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time: + mock_current_utc_time.return_value = '2024-05-21T20:01:04+00:00' + response = client.patch( + USER_URI, + json={'use_case': 'I want data.', 'access_code': '123'} + ) + mock_current_utc_time.assert_called_once_with() + + assert response.status_code == HTTPStatus.FORBIDDEN + assert 'expired' in response.json['detail'] + + +def test_patch_user_access_code_invalid(client, tables): + tables.access_codes_table.put_item( + Item={'access_code': '123'} + ) + login(client, 'foo') + + response = client.patch( + USER_URI, + json={'use_case': 'I want data.', 'access_code': '456'} + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert 'not a valid access code' in response.json['detail'] diff --git a/tests/test_api/test_submit_job.py b/tests/test_api/test_submit_job.py index 962b544f6..292b7e26e 100644 --- a/tests/test_api/test_submit_job.py +++ b/tests/test_api/test_submit_job.py @@ -1,11 +1,10 @@ -from datetime import datetime, timezone from decimal import Decimal from http import HTTPStatus 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 +from dynamo.util import current_utc_time def test_submit_one_job(client, approved_user): @@ -17,7 +16,7 @@ def test_submit_one_job(client, approved_user): jobs = response.json['jobs'] assert len(jobs) == 1 assert jobs[0]['status_code'] == 'PENDING' - assert jobs[0]['request_time'] <= format_time(datetime.now(timezone.utc)) + assert jobs[0]['request_time'] <= current_utc_time() assert jobs[0]['user_id'] == approved_user diff --git a/tests/test_dynamo/test_jobs.py b/tests/test_dynamo/test_jobs.py index 2b5d1b748..b4bafa4d5 100644 --- a/tests/test_dynamo/test_jobs.py +++ b/tests/test_dynamo/test_jobs.py @@ -1,5 +1,4 @@ import unittest.mock -from datetime import datetime, timezone from decimal import Decimal import pytest @@ -14,6 +13,7 @@ RejectedApplicationError, ) from dynamo.user import APPLICATION_APPROVED +from dynamo.util import current_utc_time def test_query_jobs_by_user(tables): @@ -281,7 +281,7 @@ def test_put_jobs(tables, monkeypatch, approved_user): assert set(job.keys()) == { '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['request_time'] <= current_utc_time() assert job['user_id'] == approved_user assert job['status_code'] == 'PENDING' assert job['execution_started'] is False diff --git a/tests/test_dynamo/test_user.py b/tests/test_dynamo/test_user.py index ee12c0299..6b1fed030 100644 --- a/tests/test_dynamo/test_user.py +++ b/tests/test_dynamo/test_user.py @@ -6,6 +6,7 @@ import dynamo.user from dynamo.exceptions import ( + AccessCodeError, ApprovedApplicationError, DatabaseConditionException, InvalidApplicationStatusError, @@ -188,6 +189,108 @@ def test_update_user_failed_application_status(tables): }] +def test_update_user_access_code(tables): + tables.access_codes_table.put_item( + Item={'access_code': '123', 'start_date': '2024-05-21T20:01:03+00:00', 'end_date': '2024-05-21T20:01:04+00:00'} + ) + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time, \ + unittest.mock.patch('dynamo.user._get_current_month') as mock_get_current_month, \ + unittest.mock.patch('dynamo.user._get_edl_profile') as mock_get_edl_profile: + + mock_current_utc_time.return_value = '2024-05-21T20:01:03+00:00' + mock_get_current_month.return_value = '2024-05' + mock_get_edl_profile.return_value = {'key': 'value'} + + user = dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.', 'access_code': '123'} + ) + + mock_current_utc_time.assert_called_once_with() + assert mock_get_current_month.mock_calls == [unittest.mock.call()] * 2 + mock_get_edl_profile.assert_called_once_with('foo', 'test-edl-access-token') + + assert user == { + 'user_id': 'foo', + 'remaining_credits': Decimal(25), + '_month_of_last_credit_reset': '2024-05', + 'application_status': APPLICATION_APPROVED, + '_edl_profile': {'key': 'value'}, + 'use_case': 'I want data.', + 'access_code': '123', + } + assert tables.users_table.scan()['Items'] == [user] + + +def test_update_user_access_code_start_date(tables): + tables.access_codes_table.put_item(Item={'access_code': '123', 'start_date': '2024-05-21T20:01:03+00:00'}) + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time: + mock_current_utc_time.return_value = '2024-05-21T20:01:02+00:00' + with pytest.raises(AccessCodeError, match=r'.*will become active.*'): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.', 'access_code': '123'} + ) + mock_current_utc_time.assert_called_once_with() + + assert tables.users_table.scan()['Items'] == [ + {'user_id': 'foo', 'remaining_credits': Decimal(0), 'application_status': APPLICATION_NOT_STARTED} + ] + + +def test_update_user_access_code_end_date(tables): + tables.access_codes_table.put_item( + Item={'access_code': '123', 'start_date': '2024-05-21T20:01:03+00:00', 'end_date': '2024-05-21T20:01:04+00:00'} + ) + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time: + mock_current_utc_time.return_value = '2024-05-21T20:01:05+00:00' + with pytest.raises(AccessCodeError, match=r'.*expired.*'): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.', 'access_code': '123'} + ) + mock_current_utc_time.assert_called_once_with() + + assert tables.users_table.scan()['Items'] == [ + {'user_id': 'foo', 'remaining_credits': Decimal(0), 'application_status': APPLICATION_NOT_STARTED} + ] + + with unittest.mock.patch('dynamo.util.current_utc_time') as mock_current_utc_time: + mock_current_utc_time.return_value = '2024-05-21T20:01:04+00:00' + with pytest.raises(AccessCodeError, match=r'.*expired.*'): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.', 'access_code': '123'} + ) + mock_current_utc_time.assert_called_once_with() + + assert tables.users_table.scan()['Items'] == [ + {'user_id': 'foo', 'remaining_credits': Decimal(0), 'application_status': APPLICATION_NOT_STARTED} + ] + + +def test_update_user_access_code_invalid(tables): + tables.access_codes_table.put_item(Item={'access_code': '123'}) + + with pytest.raises(AccessCodeError, match=r'.*not a valid access code.*'): + dynamo.user.update_user( + 'foo', + 'test-edl-access-token', + {'use_case': 'I want data.', 'access_code': '456'} + ) + + assert tables.users_table.scan()['Items'] == [ + {'user_id': 'foo', 'remaining_credits': Decimal(0), 'application_status': APPLICATION_NOT_STARTED} + ] + + def test_get_or_create_user_existing_user(tables): tables.users_table.put_item( Item={ @@ -258,7 +361,6 @@ def test_reset_credits(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -283,7 +385,6 @@ def test_reset_credits_month_exists(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -308,7 +409,6 @@ def test_reset_credits_override(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -334,7 +434,6 @@ def test_reset_credits_same_month(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -358,7 +457,6 @@ def test_reset_credits_infinite_credits(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -383,7 +481,6 @@ def test_reset_credits_to_zero(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -407,7 +504,6 @@ def test_reset_credits_already_at_zero(tables): user = dynamo.user._reset_credits_if_needed( user=original_user_record, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -437,7 +533,6 @@ def test_reset_credits_failed_not_approved(tables): 'remaining_credits': Decimal(10), 'application_status': APPLICATION_APPROVED, }, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -467,7 +562,6 @@ def test_reset_credits_failed_same_month(tables): '_month_of_last_credit_reset': '2024-01', 'application_status': APPLICATION_APPROVED, }, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -496,7 +590,6 @@ def test_reset_credits_failed_infinite_credits(tables): 'remaining_credits': Decimal(10), 'application_status': APPLICATION_APPROVED, }, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -526,7 +619,6 @@ def test_reset_credits_failed_approved(tables): '_month_of_last_credit_reset': '2024-02', 'application_status': 'bar', }, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, ) @@ -557,7 +649,6 @@ def test_reset_credits_failed_zero_credits(tables): '_month_of_last_credit_reset': '2024-02', 'application_status': 'bar', }, - default_credits=Decimal(25), current_month='2024-02', users_table=tables.users_table, )