diff --git a/.dockerignore b/.dockerignore index 7b9644ad..5674e27e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,11 +21,8 @@ LICENSE # Skip envrc .envrc -# Skip ansible-container stuff -ansible* -container.yml -meta.yml -requirements.yml +# Skip terraform +terraform # Skip kubernetes and Docker config stuff deploy diff --git a/README.md b/README.md index 2681346e..d846d486 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ To generate coverage reports for the Javascript tests: - `SESSION_COOKIE_DOMAIN`: String value specifying the name to use for the session cookie. This should be set to the root domain so that it is valid for both the main site and the authentication subdomain. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_DOMAIN - `SESSION_KEY_PREFIX`: A prefix that is added before all session keys: https://pythonhosted.org/Flask-Session/#configuration - `SESSION_TYPE`: String value specifying the cookie storage backend. https://pythonhosted.org/Flask-Session/ +- `SESSION_COOKIE_SECURE`: https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_SECURE - `SESSION_USE_SIGNER`: Boolean value specifying if the cookie sid should be signed. - `SQLALCHEMY_ECHO`: Boolean value specifying if SQLAlchemy should log queries to stdout. - `STATIC_URL`: URL specifying where static assets are hosted. diff --git a/alembic/versions/07e0598199f6_add_applications_claimed_until.py b/alembic/versions/07e0598199f6_add_applications_claimed_until.py new file mode 100644 index 00000000..9c5d3abc --- /dev/null +++ b/alembic/versions/07e0598199f6_add_applications_claimed_until.py @@ -0,0 +1,29 @@ +"""add applications.claimed_until + +Revision ID: 07e0598199f6 +Revises: 26319c44a8d5 +Create Date: 2020-01-25 13:33:17.711548 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '07e0598199f6' # pragma: allowlist secret +down_revision = '26319c44a8d5' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('applications', sa.Column('claimed_until', sa.TIMESTAMP(timezone=True), nullable=True)) + op.add_column('applications', sa.Column('cloud_id', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('applications', 'claimed_until') + op.drop_column('applications', 'cloud_id') + # ### end Alembic commands ### diff --git a/alembic/versions/508957112ed6_combine_job_failures.py b/alembic/versions/508957112ed6_combine_job_failures.py new file mode 100644 index 00000000..9d40bb12 --- /dev/null +++ b/alembic/versions/508957112ed6_combine_job_failures.py @@ -0,0 +1,60 @@ +"""combine job failures + +Revision ID: 508957112ed6 +Revises: 07e0598199f6 +Create Date: 2020-01-25 15:03:06.377442 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '508957112ed6' # pragma: allowlist secret +down_revision = '07e0598199f6' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('job_failures', + sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.String(), nullable=False), + sa.Column('entity', sa.String(), nullable=False), + sa.Column('entity_id', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('environment_job_failures') + op.drop_table('environment_role_job_failures') + op.drop_table('portfolio_job_failures') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('portfolio_job_failures', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('portfolio_id', postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['portfolio_id'], ['portfolios.id'], name='portfolio_job_failures_portfolio_id_fkey'), + sa.PrimaryKeyConstraint('id', name='portfolio_job_failures_pkey') + ) + op.create_table('environment_role_job_failures', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('environment_role_id', postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['environment_role_id'], ['environment_roles.id'], name='environment_role_job_failures_environment_role_id_fkey'), + sa.PrimaryKeyConstraint('id', name='environment_role_job_failures_pkey') + ) + op.create_table('environment_job_failures', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('environment_id', postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['environment_id'], ['environments.id'], name='environment_job_failures_environment_id_fkey'), + sa.PrimaryKeyConstraint('id', name='environment_job_failures_pkey') + ) + op.drop_table('job_failures') + # ### end Alembic commands ### diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 3dbb9953..b9df260e 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -1,5 +1,9 @@ -from . import BaseDomainClass from flask import g +from sqlalchemy import func, or_ +from typing import List +from uuid import UUID + +from . import BaseDomainClass from atst.database import db from atst.domain.application_roles import ApplicationRoles from atst.domain.environments import Environments @@ -10,7 +14,10 @@ from atst.models import ( ApplicationRole, ApplicationRoleStatus, EnvironmentRole, + Portfolio, + PortfolioStateMachine, ) +from atst.models.mixins.state_machines import FSMStates from atst.utils import first_or_none, commit_or_raise_already_exists_error @@ -118,3 +125,21 @@ class Applications(BaseDomainClass): db.session.commit() return invitation + + @classmethod + def get_applications_pending_creation(cls) -> List[UUID]: + results = ( + db.session.query(Application.id) + .join(Portfolio) + .join(PortfolioStateMachine) + .filter(PortfolioStateMachine.state == FSMStates.COMPLETED) + .filter(Application.deleted == False) + .filter(Application.cloud_id.is_(None)) + .filter( + or_( + Application.claimed_until.is_(None), + Application.claimed_until <= func.now(), + ) + ) + ).all() + return [id_ for id_, in results] diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 73a99738..735bf53a 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -1,17 +1,18 @@ +import json import re from secrets import token_urlsafe from typing import Dict from uuid import uuid4 -from atst.models.application import Application -from atst.models.environment import Environment -from atst.models.user import User +from atst.utils import sha256_hex from .cloud_provider_interface import CloudProviderInterface from .exceptions import AuthenticationException from .models import ( AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPResult, + ApplicationCSPPayload, + ApplicationCSPResult, BillingInstructionCSPPayload, BillingInstructionCSPResult, BillingProfileCreationCSPPayload, @@ -20,6 +21,8 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + KeyVaultCredentials, + ManagementGroupCSPResponse, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, TaskOrderBillingCreationCSPPayload, @@ -60,6 +63,10 @@ class AzureSDKProvider(object): import azure.common.credentials as credentials import azure.identity as identity from azure.keyvault import secrets + from azure.core import exceptions + from msrestazure.azure_cloud import ( + AZURE_PUBLIC_CLOUD, + ) # TODO: choose cloud type from config import adal import requests @@ -74,10 +81,6 @@ class AzureSDKProvider(object): self.exceptions = exceptions self.secrets = secrets self.requests = requests - - # TODO: choose cloud type from config - from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD - self.cloud = AZURE_PUBLIC_CLOUD @@ -126,9 +129,7 @@ class AzureCloudProvider(CloudProviderInterface): exc_info=1, ) - def create_environment( - self, auth_credentials: Dict, user: User, environment: Environment - ): + def create_environment(self, auth_credentials: Dict, user, environment): # since this operation would only occur within a tenant, should we source the tenant # via lookup from environment once we've created the portfolio csp data schema # something like this: @@ -145,7 +146,7 @@ class AzureCloudProvider(CloudProviderInterface): credentials, management_group_id, display_name, parent_id, ) - return management_group + return ManagementGroupCSPResponse(**management_group) def create_atat_admin_user( self, auth_credentials: Dict, csp_environment_id: str @@ -184,16 +185,26 @@ class AzureCloudProvider(CloudProviderInterface): "role_name": role_assignment_id, } - def _create_application(self, auth_credentials: Dict, application: Application): - management_group_name = str(uuid4()) # can be anything, not just uuid - display_name = application.name # Does this need to be unique? - credentials = self._get_credential_obj(auth_credentials) - parent_id = "?" # application.portfolio.csp_details.management_group_id - - return self._create_management_group( - credentials, management_group_name, display_name, parent_id, + def create_application(self, payload: ApplicationCSPPayload): + creds = self._source_creds(payload.tenant_id) + credentials = self._get_credential_obj( + { + "client_id": creds.root_sp_client_id, + "secret_key": creds.root_sp_key, + "tenant_id": creds.root_tenant_id, + }, + resource=self.sdk.cloud.endpoints.resource_manager, ) + response = self._create_management_group( + credentials, + payload.management_group_name, + payload.display_name, + payload.parent_id, + ) + + return ApplicationCSPResult(**response) + def _create_management_group( self, credentials, management_group_id, display_name, parent_id=None, ): @@ -215,6 +226,9 @@ class AzureCloudProvider(CloudProviderInterface): # result is a synchronous wait, might need to do a poll instead to handle first mgmt group create # since we were told it could take 10+ minutes to complete, unless this handles that polling internally + # TODO: what to do is status is not 'Succeeded' on the + # response object? Will it always raise its own error + # instead? return create_request.result() def _create_subscription( @@ -307,6 +321,7 @@ class AzureCloudProvider(CloudProviderInterface): sp_token = self.get_root_provisioning_token() if sp_token is None: raise AuthenticationException("Could not resolve token for tenant creation") + payload.password = token_urlsafe(16) create_tenant_body = payload.dict(by_alias=True) @@ -862,3 +877,24 @@ class AzureCloudProvider(CloudProviderInterface): "secret_key": self.secret_key, "tenant_id": self.tenant_id, } + + def _source_creds(self, tenant_id=None) -> KeyVaultCredentials: + if tenant_id: + return self._source_tenant_creds(tenant_id) + else: + return KeyVaultCredentials( + root_tenant_id=self._root_creds.get("tenant_id"), + root_sp_client_id=self._root_creds.get("client_id"), + root_sp_key=self._root_creds.get("secret_key"), + ) + + def update_tenant_creds(self, tenant_id, secret): + hashed = sha256_hex(tenant_id) + self.set_secret(hashed, json.dumps(secret)) + + return secret + + def _source_tenant_creds(self, tenant_id): + hashed = sha256_hex(tenant_id) + raw_creds = self.get_secret(hashed) + return KeyVaultCredentials(**json.loads(raw_creds)) diff --git a/atst/domain/csp/cloud/cloud_provider_interface.py b/atst/domain/csp/cloud/cloud_provider_interface.py index 7f975c07..5f4b9ab5 100644 --- a/atst/domain/csp/cloud/cloud_provider_interface.py +++ b/atst/domain/csp/cloud/cloud_provider_interface.py @@ -1,9 +1,5 @@ from typing import Dict -from atst.models.user import User -from atst.models.environment import Environment -from atst.models.environment_role import EnvironmentRole - class CloudProviderInterface: def set_secret(self, secret_key: str, secret_value: str): @@ -15,9 +11,7 @@ class CloudProviderInterface: def root_creds(self) -> Dict: raise NotImplementedError() - def create_environment( - self, auth_credentials: Dict, user: User, environment: Environment - ) -> str: + def create_environment(self, auth_credentials: Dict, user, environment) -> str: """Create a new environment in the CSP. Arguments: @@ -65,7 +59,7 @@ class CloudProviderInterface: raise NotImplementedError() def create_or_update_user( - self, auth_credentials: Dict, user_info: EnvironmentRole, csp_role_id: str + self, auth_credentials: Dict, user_info, csp_role_id: str ) -> str: """Creates a user or updates an existing user's role. diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index 81147885..52e68f08 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -1,6 +1,5 @@ from uuid import uuid4 - from .cloud_provider_interface import CloudProviderInterface from .exceptions import ( AuthenticationException, @@ -14,10 +13,11 @@ from .exceptions import ( UserRemovalException, ) from .models import ( - PrincipalAdminRoleCSPResult, + AZURE_MGMNT_PATH, + AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPResult, - TenantAdminOwnershipCSPResult, - TenantPrincipalCSPResult, + ApplicationCSPPayload, + ApplicationCSPResult, BillingInstructionCSPPayload, BillingInstructionCSPResult, BillingProfileCreationCSPPayload, @@ -26,12 +26,13 @@ from .models import ( BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, PrincipalAdminRoleCSPPayload, - AdminRoleDefinitionCSPPayload, + PrincipalAdminRoleCSPResult, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, TaskOrderBillingVerificationCSPResult, TenantAdminOwnershipCSPPayload, + TenantAdminOwnershipCSPResult, TenantCSPPayload, TenantCSPResult, TenantPrincipalAppCSPPayload, @@ -39,6 +40,7 @@ from .models import ( TenantPrincipalCredentialCSPPayload, TenantPrincipalCredentialCSPResult, TenantPrincipalCSPPayload, + TenantPrincipalCSPResult, TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPResult, ) @@ -416,3 +418,16 @@ class MockCloudProvider(CloudProviderInterface): self._delay(1, 5) if self._with_authorization and credentials != self._auth_credentials: raise self.AUTHENTICATION_EXCEPTION + + def create_application(self, payload: ApplicationCSPPayload): + self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) + + return ApplicationCSPResult( + id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}" + ) + + def get_credentials(self, scope="portfolio", tenant_id=None): + return self.root_creds() + + def update_tenant_creds(self, tenant_id, secret): + return secret diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 9aa16883..ce7769a1 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -1,6 +1,8 @@ from typing import Dict, List, Optional +import re +from uuid import uuid4 -from pydantic import BaseModel, validator +from pydantic import BaseModel, validator, root_validator from atst.utils import snake_to_camel @@ -300,3 +302,110 @@ class PrincipalAdminRoleCSPResult(AliasModel): class Config: fields = {"principal_assignment_id": "id"} + + +AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/" + +MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$" + + +class ManagementGroupCSPPayload(AliasModel): + """ + :param: management_group_name: Just pass a UUID for this. + :param: display_name: This can contain any character and + spaces, but should be 90 characters or fewer long. + :param: parent_id: This should be the fully qualified Azure ID, + i.e. /providers/Microsoft.Management/managementGroups/[management group ID] + """ + + tenant_id: str + management_group_name: Optional[str] + display_name: str + parent_id: str + + @validator("management_group_name", pre=True, always=True) + def supply_management_group_name_default(cls, name): + if name: + if re.match(MANAGEMENT_GROUP_NAME_REGEX, name) is None: + raise ValueError( + f"Management group name must match {MANAGEMENT_GROUP_NAME_REGEX}" + ) + + return name[0:90] + else: + return str(uuid4()) + + @validator("display_name", pre=True, always=True) + def enforce_display_name_length(cls, name): + return name[0:90] + + @validator("parent_id", pre=True, always=True) + def enforce_parent_id_pattern(cls, id_): + if AZURE_MGMNT_PATH not in id_: + return f"{AZURE_MGMNT_PATH}{id_}" + else: + return id_ + + +class ManagementGroupCSPResponse(AliasModel): + id: str + + +class ApplicationCSPPayload(ManagementGroupCSPPayload): + pass + + +class ApplicationCSPResult(ManagementGroupCSPResponse): + pass + + +class KeyVaultCredentials(BaseModel): + root_sp_client_id: Optional[str] + root_sp_key: Optional[str] + root_tenant_id: Optional[str] + + tenant_id: Optional[str] + + tenant_admin_username: Optional[str] + tenant_admin_password: Optional[str] + + tenant_sp_client_id: Optional[str] + tenant_sp_key: Optional[str] + + @root_validator(pre=True) + def enforce_admin_creds(cls, values): + tenant_id = values.get("tenant_id") + username = values.get("tenant_admin_username") + password = values.get("tenant_admin_password") + if any([username, password]) and not all([tenant_id, username, password]): + raise ValueError( + "tenant_id, tenant_admin_username, and tenant_admin_password must all be set if any one is" + ) + + return values + + @root_validator(pre=True) + def enforce_sp_creds(cls, values): + tenant_id = values.get("tenant_id") + client_id = values.get("tenant_sp_client_id") + key = values.get("tenant_sp_key") + if any([client_id, key]) and not all([tenant_id, client_id, key]): + raise ValueError( + "tenant_id, tenant_sp_client_id, and tenant_sp_key must all be set if any one is" + ) + + return values + + @root_validator(pre=True) + def enforce_root_creds(cls, values): + sp_creds = [ + values.get("root_tenant_id"), + values.get("root_sp_client_id"), + values.get("root_sp_key"), + ] + if any(sp_creds) and not all(sp_creds): + raise ValueError( + "root_tenant_id, root_sp_client_id, and root_sp_key must all be set if any one is" + ) + + return values diff --git a/atst/domain/users.py b/atst/domain/users.py index 5e09ce22..e5fdbad7 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -93,10 +93,13 @@ class Users(object): return user @classmethod - def give_ccpo_perms(cls, user): + def give_ccpo_perms(cls, user, commit=True): user.permission_sets = PermissionSets.get_all() db.session.add(user) - db.session.commit() + + if commit: + db.session.commit() + return user @classmethod diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 6b209bf8..8d40c015 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -10,11 +10,13 @@ from wtforms.fields.html5 import DateField from wtforms.validators import ( Required, Length, + Optional, NumberRange, ValidationError, ) from flask_wtf import FlaskForm import numbers + from atst.forms.validators import Number, AlphaNumeric from .data import JEDI_CLIN_TYPES @@ -60,6 +62,14 @@ def validate_date_in_range(form, field): ) +def remove_dashes(value): + return value.replace("-", "") if value else None + + +def coerce_upper(value): + return value.upper() if value else None + + class CLINForm(FlaskForm): jedi_clin_type = SelectField( translate("task_orders.form.clin_type_label"), @@ -149,8 +159,8 @@ class AttachmentForm(BaseForm): class TaskOrderForm(BaseForm): number = StringField( label=translate("forms.task_order.number_description"), - filters=[remove_empty_string], - validators=[Number(), Length(max=13)], + filters=[remove_empty_string, remove_dashes, coerce_upper], + validators=[AlphaNumeric(), Length(min=13, max=17), Optional()], ) pdf = FormField( AttachmentForm, diff --git a/atst/jobs.py b/atst/jobs.py index 7172343b..14256336 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -3,47 +3,38 @@ import pendulum from atst.database import db from atst.queue import celery -from atst.models import ( - EnvironmentJobFailure, - EnvironmentRoleJobFailure, - EnvironmentRole, - PortfolioJobFailure, -) +from atst.models import EnvironmentRole, JobFailure from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.csp.cloud import CloudProviderInterface +from atst.domain.applications import Applications from atst.domain.environments import Environments from atst.domain.portfolios import Portfolios from atst.domain.environment_roles import EnvironmentRoles from atst.models.utils import claim_for_update from atst.utils.localization import translate +from atst.domain.csp.cloud.models import ApplicationCSPPayload -class RecordPortfolioFailure(celery.Task): +class RecordFailure(celery.Task): + _ENTITIES = [ + "portfolio_id", + "application_id", + "environment_id", + "environment_role_id", + ] + + def _derive_entity_info(self, kwargs): + matches = [e for e in self._ENTITIES if e in kwargs.keys()] + if matches: + match = matches[0] + return {"entity": match.replace("_id", ""), "entity_id": kwargs[match]} + else: + return None + def on_failure(self, exc, task_id, args, kwargs, einfo): - if "portfolio_id" in kwargs: - failure = PortfolioJobFailure( - portfolio_id=kwargs["portfolio_id"], task_id=task_id - ) - db.session.add(failure) - db.session.commit() - - -class RecordEnvironmentFailure(celery.Task): - def on_failure(self, exc, task_id, args, kwargs, einfo): - if "environment_id" in kwargs: - failure = EnvironmentJobFailure( - environment_id=kwargs["environment_id"], task_id=task_id - ) - db.session.add(failure) - db.session.commit() - - -class RecordEnvironmentRoleFailure(celery.Task): - def on_failure(self, exc, task_id, args, kwargs, einfo): - if "environment_role_id" in kwargs: - failure = EnvironmentRoleJobFailure( - environment_role_id=kwargs["environment_role_id"], task_id=task_id - ) + info = self._derive_entity_info(kwargs) + if info: + failure = JobFailure(**info, task_id=task_id) db.session.add(failure) db.session.commit() @@ -63,6 +54,27 @@ def send_notification_mail(recipients, subject, body): app.mailer.send(recipients, subject, body) +def do_create_application(csp: CloudProviderInterface, application_id=None): + application = Applications.get(application_id) + + with claim_for_update(application) as application: + + if application.cloud_id: + return + + csp_details = application.portfolio.csp_data + parent_id = csp_details.get("root_management_group_id") + tenant_id = csp_details.get("tenant_id") + payload = ApplicationCSPPayload( + tenant_id=tenant_id, display_name=application.name, parent_id=parent_id + ) + + app_result = csp.create_application(payload) + application.cloud_id = app_result.id + db.session.add(application) + db.session.commit() + + def do_create_environment(csp: CloudProviderInterface, environment_id=None): environment = Environments.get(environment_id) @@ -144,17 +156,22 @@ def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None): fsm.trigger_next_transition() -@celery.task(bind=True, base=RecordPortfolioFailure) +@celery.task(bind=True, base=RecordFailure) def provision_portfolio(self, portfolio_id=None): do_work(do_provision_portfolio, self, app.csp.cloud, portfolio_id=portfolio_id) -@celery.task(bind=True, base=RecordEnvironmentFailure) +@celery.task(bind=True, base=RecordFailure) +def create_application(self, application_id=None): + do_work(do_create_application, self, app.csp.cloud, application_id=application_id) + + +@celery.task(bind=True, base=RecordFailure) def create_environment(self, environment_id=None): do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id) -@celery.task(bind=True, base=RecordEnvironmentFailure) +@celery.task(bind=True, base=RecordFailure) def create_atat_admin_user(self, environment_id=None): do_work( do_create_atat_admin_user, self, app.csp.cloud, environment_id=environment_id @@ -177,6 +194,12 @@ def dispatch_provision_portfolio(self): provision_portfolio.delay(portfolio_id=portfolio_id) +@celery.task(bind=True) +def dispatch_create_application(self): + for application_id in Applications.get_applications_pending_creation(): + create_application.delay(application_id=application_id) + + @celery.task(bind=True) def dispatch_create_environment(self): for environment_id in Environments.get_environments_pending_creation( diff --git a/atst/models/__init__.py b/atst/models/__init__.py index f6c48306..dfb1c19d 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -7,11 +7,7 @@ from .audit_event import AuditEvent from .clin import CLIN, JEDICLINType from .environment import Environment from .environment_role import EnvironmentRole, CSPRole -from .job_failure import ( - EnvironmentJobFailure, - EnvironmentRoleJobFailure, - PortfolioJobFailure, -) +from .job_failure import JobFailure from .notification_recipient import NotificationRecipient from .permissions import Permissions from .permission_set import PermissionSet diff --git a/atst/models/application.py b/atst/models/application.py index a7bdadba..1af9e39f 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -1,4 +1,4 @@ -from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint +from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint, TIMESTAMP from sqlalchemy.orm import relationship, synonym from atst.models.base import Base @@ -40,6 +40,9 @@ class Application( ), ) + cloud_id = Column(String) + claimed_until = Column(TIMESTAMP(timezone=True)) + @property def users(self): return set(role.user for role in self.members) diff --git a/atst/models/environment.py b/atst/models/environment.py index 115f3ed7..a0713c63 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -30,8 +30,6 @@ class Environment( claimed_until = Column(TIMESTAMP(timezone=True)) - job_failures = relationship("EnvironmentJobFailure") - roles = relationship( "EnvironmentRole", back_populates="environment", diff --git a/atst/models/environment_role.py b/atst/models/environment_role.py index 21f033e0..24aaeb7e 100644 --- a/atst/models/environment_role.py +++ b/atst/models/environment_role.py @@ -32,8 +32,6 @@ class EnvironmentRole( ) application_role = relationship("ApplicationRole") - job_failures = relationship("EnvironmentRoleJobFailure") - csp_user_id = Column(String()) claimed_until = Column(TIMESTAMP(timezone=True)) diff --git a/atst/models/job_failure.py b/atst/models/job_failure.py index 7a7f010a..5f9eee6c 100644 --- a/atst/models/job_failure.py +++ b/atst/models/job_failure.py @@ -1,22 +1,21 @@ -from sqlalchemy import Column, ForeignKey +from celery.result import AsyncResult +from sqlalchemy import Column, String, Integer from atst.models.base import Base import atst.models.mixins as mixins -class EnvironmentJobFailure(Base, mixins.JobFailureMixin): - __tablename__ = "environment_job_failures" +class JobFailure(Base, mixins.TimestampsMixin): + __tablename__ = "job_failures" - environment_id = Column(ForeignKey("environments.id"), nullable=False) + id = Column(Integer(), primary_key=True) + task_id = Column(String(), nullable=False) + entity = Column(String(), nullable=False) + entity_id = Column(String(), nullable=False) + @property + def task(self): + if not hasattr(self, "_task"): + self._task = AsyncResult(self.task_id) -class EnvironmentRoleJobFailure(Base, mixins.JobFailureMixin): - __tablename__ = "environment_role_job_failures" - - environment_role_id = Column(ForeignKey("environment_roles.id"), nullable=False) - - -class PortfolioJobFailure(Base, mixins.JobFailureMixin): - __tablename__ = "portfolio_job_failures" - - portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False) + return self._task diff --git a/atst/models/mixins/__init__.py b/atst/models/mixins/__init__.py index 955171ab..e95b2516 100644 --- a/atst/models/mixins/__init__.py +++ b/atst/models/mixins/__init__.py @@ -3,5 +3,4 @@ from .auditable import AuditableMixin from .permissions import PermissionsMixin from .deletable import DeletableMixin from .invites import InvitesMixin -from .job_failure import JobFailureMixin from .state_machines import FSMMixin diff --git a/atst/models/mixins/job_failure.py b/atst/models/mixins/job_failure.py deleted file mode 100644 index c4f4cfa4..00000000 --- a/atst/models/mixins/job_failure.py +++ /dev/null @@ -1,14 +0,0 @@ -from celery.result import AsyncResult -from sqlalchemy import Column, String, Integer - - -class JobFailureMixin(object): - id = Column(Integer(), primary_key=True) - task_id = Column(String(), nullable=False) - - @property - def task(self): - if not hasattr(self, "_task"): - self._task = AsyncResult(self.task_id) - - return self._task diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index cf42710b..be9324b1 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -175,7 +175,7 @@ class PortfolioStateMachine( tenant_id = new_creds.get("tenant_id") secret = self.csp.get_secret(tenant_id, new_creds) secret.update(new_creds) - self.csp.set_secret(tenant_id, secret) + self.csp.update_tenant_creds(tenant_id, secret) except PydanticValidationError as exc: app.logger.error( f"Failed to cast response to valid result class {self.__repr__()}:", diff --git a/atst/queue.py b/atst/queue.py index 1dce690c..70718150 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -11,6 +11,10 @@ def update_celery(celery, app): "task": "atst.jobs.dispatch_provision_portfolio", "schedule": 60, }, + "beat-dispatch_create_application": { + "task": "atst.jobs.dispatch_create_application", + "schedule": 60, + }, "beat-dispatch_create_environment": { "task": "atst.jobs.dispatch_create_environment", "schedule": 60, diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index f6e53b75..eaf6f2b2 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -70,7 +70,12 @@ def update_task_order(form, portfolio_id=None, task_order_id=None, flash_invalid def update_and_render_next( - form_data, next_page, current_template, portfolio_id=None, task_order_id=None + form_data, + next_page, + current_template, + portfolio_id=None, + task_order_id=None, + previous=False, ): form = None if task_order_id: @@ -80,8 +85,9 @@ def update_and_render_next( form = TaskOrderForm(form_data) task_order = update_task_order(form, portfolio_id, task_order_id) - if task_order: - return redirect(url_for(next_page, task_order_id=task_order.id)) + if task_order or previous: + to_id = task_order.id if task_order else task_order_id + return redirect(url_for(next_page, task_order_id=to_id)) else: return ( render_task_orders_edit( @@ -210,12 +216,21 @@ def form_step_two_add_number(task_order_id): @task_orders_bp.route("/task_orders//form/step_2", methods=["POST"]) @user_can(Permissions.CREATE_TASK_ORDER, message="update task order form") def submit_form_step_two_add_number(task_order_id): + previous = http_request.args.get("previous", "False").lower() == "true" form_data = {**http_request.form} - next_page = "task_orders.form_step_three_add_clins" + next_page = ( + "task_orders.form_step_three_add_clins" + if not previous + else "task_orders.form_step_one_add_pdf" + ) current_template = "task_orders/step_2.html" return update_and_render_next( - form_data, next_page, current_template, task_order_id=task_order_id + form_data, + next_page, + current_template, + task_order_id=task_order_id, + previous=previous, ) @@ -230,12 +245,21 @@ def form_step_three_add_clins(task_order_id): @task_orders_bp.route("/task_orders//form/step_3", methods=["POST"]) @user_can(Permissions.CREATE_TASK_ORDER, message="update task order form") def submit_form_step_three_add_clins(task_order_id): + previous = http_request.args.get("previous", "False").lower() == "true" form_data = {**http_request.form} - next_page = "task_orders.form_step_four_review" + next_page = ( + "task_orders.form_step_four_review" + if not previous + else "task_orders.form_step_two_add_number" + ) current_template = "task_orders/step_3.html" return update_and_render_next( - form_data, next_page, current_template, task_order_id=task_order_id + form_data, + next_page, + current_template, + task_order_id=task_order_id, + previous=previous, ) diff --git a/atst/utils/__init__.py b/atst/utils/__init__.py index 09c63dea..79d5362a 100644 --- a/atst/utils/__init__.py +++ b/atst/utils/__init__.py @@ -1,3 +1,4 @@ +import hashlib import re from sqlalchemy.exc import IntegrityError @@ -41,3 +42,8 @@ def commit_or_raise_already_exists_error(message): except IntegrityError: db.session.rollback() raise AlreadyExistsError(message) + + +def sha256_hex(string): + hsh = hashlib.sha256(string.encode()) + return hsh.digest().hex() diff --git a/config/base.ini b/config/base.ini index 6fbcce73..3504e3cd 100644 --- a/config/base.ini +++ b/config/base.ini @@ -43,6 +43,7 @@ SERVER_NAME SESSION_COOKIE_NAME=atat SESSION_COOKIE_DOMAIN SESSION_KEY_PREFIX=session: +SESSION_COOKIE_SECURE=false SESSION_TYPE = redis SESSION_USE_SIGNER = True SQLALCHEMY_ECHO = False diff --git a/deploy/azure/atst-envvars-configmap.yml b/deploy/azure/atst-envvars-configmap.yml index edd049a7..0d3e5312 100644 --- a/deploy/azure/atst-envvars-configmap.yml +++ b/deploy/azure/atst-envvars-configmap.yml @@ -32,6 +32,7 @@ data: REDIS_HOST: atat.redis.cache.windows.net:6380 REDIS_TLS: "true" SESSION_COOKIE_DOMAIN: atat.code.mil + SESSION_COOKIE_SECURE: "true" STATIC_URL: https://atat-cdn.azureedge.net/static/ TZ: UTC UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini diff --git a/deploy/azure/azure.yml b/deploy/azure/azure.yml index f988d5fc..d58f77a7 100644 --- a/deploy/azure/azure.yml +++ b/deploy/azure/azure.yml @@ -29,6 +29,8 @@ spec: containers: - name: atst image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false env: - name: UWSGI_PROCESSES value: "2" @@ -64,6 +66,8 @@ spec: cpu: 940m - name: nginx image: nginx:alpine + securityContext: + allowPrivilegeEscalation: false ports: - containerPort: 8342 name: main-upgrade @@ -189,6 +193,8 @@ spec: containers: - name: atst-worker image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false args: [ "/opt/atat/atst/.venv/bin/python", @@ -261,6 +267,8 @@ spec: containers: - name: atst-beat image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false args: [ "/opt/atat/atst/.venv/bin/python", diff --git a/deploy/azure/crls-sync.yaml b/deploy/azure/crls-sync.yaml index 5fdcd7b8..221a0d8c 100644 --- a/deploy/azure/crls-sync.yaml +++ b/deploy/azure/crls-sync.yaml @@ -20,6 +20,8 @@ spec: containers: - name: crls image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false command: [ "/bin/sh", "-c" ] diff --git a/deploy/shared/migration.yaml b/deploy/shared/migration.yaml index b5161114..4944aa0c 100644 --- a/deploy/shared/migration.yaml +++ b/deploy/shared/migration.yaml @@ -16,6 +16,8 @@ spec: containers: - name: migration image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false command: [ "/bin/sh", "-c" ] diff --git a/js/components/__tests__/text_input.test.js b/js/components/__tests__/text_input.test.js new file mode 100644 index 00000000..c290b23a --- /dev/null +++ b/js/components/__tests__/text_input.test.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils' + +import textinput from '../text_input' + +import { makeTestWrapper } from '../../test_utils/component_test_helpers' + +const ToNumberWrapperComponent = makeTestWrapper({ + components: { + textinput, + }, + templatePath: 'text_input_to_number.html', + data: function() { + const { validation, initialValue } = this.initialData + return { validation, initialValue } + }, +}) + +describe('TextInput Validates Correctly', () => { + describe('taskOrderNumber validator', () => { + it('Should initialize with the validator and no validation icon', () => { + const wrapper = mount(ToNumberWrapperComponent, { + propsData: { + name: 'testTextInput', + initialData: { + validation: 'taskOrderNumber', + }, + }, + }) + expect(wrapper.contains('.usa-input--success')).toBe(false) + expect(wrapper.contains('.usa-input--error')).toBe(false) + expect(wrapper.contains('.usa-input--validation--taskOrderNumber')).toBe( + true + ) + }) + + it('Should allow valid TO numbers', () => { + const wrapper = mount(ToNumberWrapperComponent, { + propsData: { + name: 'testTextInput', + initialData: { + validation: 'taskOrderNumber', + }, + }, + }) + + var textInputField = wrapper.find('input[id="number"]') + var hiddenField = wrapper.find('input[name="number"]') + const validToNumbers = [ + '12345678901234567', + '1234567890123', + 'abc1234567890', // pragma: allowlist secret + 'abc-1234567890', + 'DC12-123-1234567890', + 'fg34-987-1234567890', + ] + + for (const number of validToNumbers) { + // set value to be a valid TO number + textInputField.setValue(number) + // manually trigger change event in hidden fields + hiddenField.trigger('change') + // check for validation classes + expect(wrapper.contains('.usa-input--success')).toBe(true) + expect(wrapper.contains('.usa-input--error')).toBe(false) + } + }) + + it('Should not allow invalid TO numbers', () => { + const wrapper = mount(ToNumberWrapperComponent, { + propsData: { + name: 'testTextInput', + initialData: { + validation: 'taskOrderNumber', + }, + }, + }) + + var textInputField = wrapper.find('input[id="number"]') + var hiddenField = wrapper.find('input[name="number"]') + const invalidToNumbers = [ + '1234567890', + '12345678901234567890', // pragma: allowlist secret + '123:4567890123', + '123_1234567890', + ] + + for (const number of invalidToNumbers) { + // set value to be a valid TO number + textInputField.setValue(number) + // manually trigger change event in hidden fields + hiddenField.trigger('change') + // check for validation classes + expect(wrapper.contains('.usa-input--success')).toBe(false) + expect(wrapper.contains('.usa-input--error')).toBe(true) + } + }) + }) +}) diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index e2dc03b7..9f113aa6 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -106,9 +106,9 @@ export default { }, taskOrderNumber: { mask: false, - match: /^.{13}$/, - unmask: [], - validationError: 'TO number must be 13 digits', + match: /(^[0-9a-zA-Z]{13,17}$)/, + unmask: ['-'], + validationError: 'TO number must be between 13 and 17 characters', }, usPhone: { mask: [ diff --git a/script/create_database.py b/script/create_database.py new file mode 100644 index 00000000..f21a857e --- /dev/null +++ b/script/create_database.py @@ -0,0 +1,41 @@ +# Add root application dir to the python path +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(parent_dir) + +import sqlalchemy + +from atst.app import make_config + + +def _root_connection(config, root_db): + # Assemble DATABASE_URI value + database_uri = "postgresql://{}:{}@{}:{}/{}".format( # pragma: allowlist secret + config.get("PGUSER"), + config.get("PGPASSWORD"), + config.get("PGHOST"), + config.get("PGPORT"), + root_db, + ) + engine = sqlalchemy.create_engine(database_uri) + return engine.connect() + + +def create_database(conn, dbname): + conn.execute("commit") + conn.execute(f"CREATE DATABASE {dbname};") + conn.close() + + return True + + +if __name__ == "__main__": + dbname = sys.argv[1] + config = make_config() + + conn = _root_connection(config, "postgres") + + print(f"Creating database {dbname}") + create_database(conn, dbname) diff --git a/script/database_setup.py b/script/database_setup.py new file mode 100644 index 00000000..7784be05 --- /dev/null +++ b/script/database_setup.py @@ -0,0 +1,76 @@ +# Add root application dir to the python path +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(parent_dir) + +import sqlalchemy +import yaml + +from atst.app import make_config, make_app +from atst.database import db +from atst.domain.users import Users +from atst.models import User +from reset_database import reset_database + + +def database_setup(username, password, dbname, ccpo_users): + print( + f"Creating Postgres user role for '{username}' and granting all privileges to database '{dbname}'." + ) + try: + _create_database_user(username, password, dbname) + except sqlalchemy.exc.ProgrammingError as err: + print(f"Postgres user role '{username}' already exists.") + + print("Applying schema and seeding roles and permissions.") + reset_database() + print("Creating initial set of CCPO users.") + _add_ccpo_users(ccpo_users) + + +def _create_database_user(username, password, dbname): + conn = db.engine.connect() + + meta = sqlalchemy.MetaData(bind=conn) + meta.reflect() + + trans = conn.begin() + engine = trans.connection.engine + + engine.execute( + f"CREATE ROLE {username} WITH LOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD '{password}';\n" + f"GRANT ALL PRIVILEGES ON DATABASE {dbname} TO {username};\n" + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO {username}; \n" + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO {username}; \n" + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO {username}; \n" + ) + + trans.commit() + + +def _add_ccpo_users(ccpo_users): + for user_data in ccpo_users: + user = User(**user_data) + Users.give_ccpo_perms(user, commit=False) + db.session.add(user) + + db.session.commit() + + +def _load_yaml(file_): + with open(file_) as f: + return yaml.safe_load(f) + + +if __name__ == "__main__": + config = make_config({"DISABLE_CRL_CHECK": True, "DEBUG": False}) + app = make_app(config) + with app.app_context(): + dbname = config.get("PGDATABASE", "atat") + username = sys.argv[1] + password = sys.argv[2] + ccpo_user_file = sys.argv[3] + ccpo_users = _load_yaml(ccpo_user_file) + database_setup(username, password, dbname, ccpo_users) diff --git a/script/reset_database.py b/script/reset_database.py index cfa63298..dda1c1ba 100644 --- a/script/reset_database.py +++ b/script/reset_database.py @@ -16,7 +16,9 @@ from atst.app import make_config, make_app def reset_database(): conn = db.engine.connect() - meta = sqlalchemy.MetaData(bind=conn, reflect=True) + meta = sqlalchemy.MetaData(bind=conn) + meta.reflect() + trans = conn.begin() # drop all tables diff --git a/static/icons/clock.svg b/static/icons/clock.svg new file mode 100644 index 00000000..ef1d84a1 --- /dev/null +++ b/static/icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/icons/user.svg b/static/icons/user.svg new file mode 100644 index 00000000..a0da4770 --- /dev/null +++ b/static/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/styles/components/_empty_state.scss b/styles/components/_empty_state.scss index b0b73b16..e18a2faf 100644 --- a/styles/components/_empty_state.scss +++ b/styles/components/_empty_state.scss @@ -1,8 +1,6 @@ .empty-state { - padding: $gap * 3; - max-width: 100%; + max-width: $max-panel-width; background-color: $color-gray-lightest; - margin-top: $gap * 5; &--white { background-color: $color-white; @@ -18,17 +16,28 @@ margin-top: 3rem; } + h3 { + margin: 0 0 1rem; + padding: 3.2rem 2.4rem 0; + } + + p { + margin: 0; + padding: 0 $gap * 3; + } + hr { - margin-left: -$gap * 3; - margin-right: -$gap * 3; + margin: $gap * 4 0 0; } &__footer { text-align: center; + background-color: $color-gray-lightest; + padding: $gap * 3; a.usa-button { width: 60%; - display: inline-block; + margin: 0 auto; } } } diff --git a/styles/components/_footer.scss b/styles/components/_footer.scss index bb248e4d..881ab9a7 100644 --- a/styles/components/_footer.scss +++ b/styles/components/_footer.scss @@ -3,9 +3,7 @@ background-color: $color-white; border-top: 1px solid $color-gray-lightest; display: flex; - flex-direction: row-reverse; align-items: center; - padding: $gap * 1.5; position: fixed; left: 0; bottom: 0; @@ -13,8 +11,11 @@ height: $footer-height; color: $color-gray-dark; font-size: 1.5rem; + padding: 0 $gap * 1.5; &__login { - padding-left: 0.8rem; + width: 100%; + max-width: 1175px; + text-align: right; } } diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss index 5ad748d1..9b21b41f 100644 --- a/styles/components/_global_layout.scss +++ b/styles/components/_global_layout.scss @@ -22,15 +22,18 @@ body { padding-bottom: $footer-height * 2.5; .global-panel-container { - margin: $gap; flex-grow: 1; -ms-flex-negative: 1; top: $usa-banner-height + $topbar-height; position: relative; + padding: 0 $large-spacing; @include media($medium-screen) { - margin: $gap * 2; top: $usa-banner-height + $topbar-height; } + + .user-edit { + max-width: $max-panel-width; + } } } diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index d01c1427..4a45cc5f 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -3,26 +3,34 @@ @include grid-row; min-height: 500px; } +} - margin-left: 2 * $gap; +.portfolio-header-new .portfolio-header__name { + padding: 1.6rem 0; } .portfolio-header { flex-direction: column; + margin: $gap * 2 0; + max-width: $max-panel-width; + @include media($small-screen) { flex-direction: row; } - margin-bottom: $gap * 1; - .col--grow { overflow: inherit; + display: table; + min-height: 10rem; } &__name { @include h1; + display: table-cell; + vertical-align: middle; + h1 { - margin: 0 $gap ($gap * 2) 0; + margin: 0; font-size: 3.5rem; } @@ -30,6 +38,7 @@ font-size: $small-font-size; margin: 0 0 (-$gap * 0.5); color: $color-gray-medium; + max-width: 100%; } } @@ -38,9 +47,15 @@ font-size: $small-font-size; .icon-link { - padding: $gap; + padding: 0; border-radius: 0; color: $color-blue-darkest; + min-width: 10rem; + min-height: 10rem; + + .col { + margin: 0 auto; + } &:hover { background-color: $color-aqua-lightest; @@ -53,6 +68,7 @@ &.active { color: $color-blue; background-color: $color-gray-lightest; + text-decoration: none; &:hover { background-color: $color-aqua-lightest; @@ -82,11 +98,19 @@ margin-bottom: 3 * $gap; } -.portfolio-content { - margin: (4 * $gap) $gap 0 $gap; +.portfolio-admin { + margin: $large-spacing 0; + max-width: $max-panel-width; +} +.portfolio-content { .panel { padding-bottom: 2rem; + max-width: $max-panel-width; + } + + hr { + max-width: $max-panel-width; } a.add-new-button { @@ -251,6 +275,7 @@ .portfolio-applications { margin-top: $gap * 5; + max-width: $max-panel-width; &__header { &--title { @@ -296,8 +321,8 @@ } .portfolio-funding { - padding: 2 * $gap; - padding-top: 0; + max-width: $max-panel-width; + margin: $large-spacing 0; .panel { @include shadow-panel; @@ -366,6 +391,8 @@ } .portfolio-reports { + max-width: $max-panel-width; + &__header { margin-bottom: 4 * $gap; diff --git a/styles/components/_sticky_cta.scss b/styles/components/_sticky_cta.scss index a62dc326..c3a7b0ea 100644 --- a/styles/components/_sticky_cta.scss +++ b/styles/components/_sticky_cta.scss @@ -20,12 +20,10 @@ .sticky-cta-container { display: flex; align-items: center; + max-width: 90rem; .usa-button { - margin: $gap $gap * 1.5 $gap 0; - width: 20rem; - height: 3.2rem; - font-size: $small-font-size; + margin: 0; } } @@ -42,6 +40,10 @@ &-buttons { display: flex; + a { + font-size: 1.5rem; + } + .action-group { margin: 0; diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index a64a1344..6d84f426 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -4,14 +4,15 @@ height: $topbar-height; position: fixed; top: $usa-banner-height; - width: 100%; z-index: 10; + width: 100%; &__navigation { display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; + max-width: 1190px; a { color: $color-white; @@ -64,3 +65,11 @@ justify-content: flex-end; } } + +.login-topbar .topbar__navigation { + max-width: 100%; +} + +.login-topbar .topbar__context .topbar__link-icon { + margin: 0 0 0 0.8rem; +} diff --git a/styles/core/_grid.scss b/styles/core/_grid.scss index d060198d..d22a866f 100644 --- a/styles/core/_grid.scss +++ b/styles/core/_grid.scss @@ -41,7 +41,6 @@ &.col--grow { flex: 1 auto; - padding-right: $spacing-small; } &.col--half { diff --git a/styles/core/_util.scss b/styles/core/_util.scss index 5203da45..0790a121 100644 --- a/styles/core/_util.scss +++ b/styles/core/_util.scss @@ -94,3 +94,7 @@ hr { margin: ($gap * 3) ($site-margins * -4); } } + +.usa-section { + padding: 0; +} diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 122739c4..12657ca4 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -16,8 +16,9 @@ $footer-height: 5rem; $usa-banner-height: 2.8rem; $sidenav-expanded-width: 25rem; $sidenav-collapsed-width: 10rem; -$max-panel-width: 80rem; +$max-panel-width: 90rem; $home-pg-icon-width: 6rem; +$large-spacing: 4rem; /* * USWDS Variables @@ -189,4 +190,4 @@ $spacing-x-small: 0.5rem; $spacing-small: 1rem; $spacing-md-small: 1.5rem; $spacing-medium: 2rem; -$spacing-large: 3rem; +$spacing-large: 4rem; diff --git a/styles/elements/_labels.scss b/styles/elements/_labels.scss index 8c044f8d..1e431957 100644 --- a/styles/elements/_labels.scss +++ b/styles/elements/_labels.scss @@ -21,7 +21,7 @@ text-transform: uppercase; &--default { - background-color: $color-gray-dark; + background-color: $color-gray; } &--info { diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss index 8ecd36c6..df681767 100644 --- a/styles/elements/_panels.scss +++ b/styles/elements/_panels.scss @@ -19,10 +19,7 @@ } @mixin panel-margin { - margin-top: 0; - margin-left: 0; - margin-right: 0; - margin-bottom: $site-margins-mobile * 6; + margin: $spacing-large 0; @include media($medium-screen) { margin-bottom: $site-margins * 8; @@ -56,9 +53,10 @@ @include panel-theme-default; @include panel-margin; @include shadow-panel; + max-width: $max-panel-width; &__content { - padding: $gap * 2; + padding: 3.2rem 2.4rem; } &__body { @@ -66,7 +64,7 @@ } &__heading { - padding: $gap * 2; + padding: 3.2rem 2.4rem; @include media($medium-screen) { padding: $gap * 4; diff --git a/styles/elements/_sidenav.scss b/styles/elements/_sidenav.scss index 23a4f05f..81a55b4e 100644 --- a/styles/elements/_sidenav.scss +++ b/styles/elements/_sidenav.scss @@ -113,8 +113,8 @@ text-overflow: ellipsis; &--active { - @include h4; - + font-size: $base-font-size; + font-weight: $font-bold; background-color: $color-aqua-lightest !important; color: $color-primary-darker !important; box-shadow: inset ($gap / 2) 0 0 0 $color-primary-darker; diff --git a/styles/sections/_home.scss b/styles/sections/_home.scss index b0d715d2..52636a61 100644 --- a/styles/sections/_home.scss +++ b/styles/sections/_home.scss @@ -1,12 +1,11 @@ .home { - margin: $gap * 3; .sticky-cta { margin: -1.6rem -1.6rem 0 -1.6rem; } &__content { - margin: 4rem; - max-width: 900px; + margin: $large-spacing 0; + max-width: $max-panel-width; &--descriptions { .col { @@ -29,7 +28,7 @@ background-color: $color-white; .home-container { - max-width: 90rem; + max-width: $max-panel-width; margin-left: auto; margin-right: auto; margin-bottom: 8rem; diff --git a/templates/applications/fragments/environments.html b/templates/applications/fragments/environments.html index fa4e5959..d0934268 100644 --- a/templates/applications/fragments/environments.html +++ b/templates/applications/fragments/environments.html @@ -1,3 +1,4 @@ +{% from "components/alert.html" import Alert %} {% from "components/icon.html" import Icon %} {% from "components/label.html" import Label %} {% from 'components/save_button.html' import SaveButton %} @@ -10,10 +11,13 @@ new_env_form) %}

{{ "portfolios.applications.settings.environments" | translate }}

+ {% if portfolio.num_task_orders == 0 -%} + {{ Alert(message="portfolios.applications.environments.funding_alert"|translate({'name': portfolio.name})) }} + {%- endif %} + {% if g.matchesPath("application-environments") -%} + {% include "fragments/flash.html" %} + {%- endif %}
- {% if g.matchesPath("application-environments") -%} - {% include "fragments/flash.html" %} - {%- endif %} {% if 0 == environments_obj | length -%}

@@ -30,14 +34,21 @@

  • - - - {{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }} - - + {% if not env["pending"] -%} + + + {{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }} + + + {% else -%} + + {{ env['name'] }} + + {{ Label(type="pending_creation", classes='label--below')}} + {%- endif %} {% if user_can(permissions.EDIT_ENVIRONMENT) -%} {{ ToggleButton( @@ -57,10 +68,6 @@ classes="environment-list__item__members" ) }} -
    - {% if env['pending'] -%} - {{ Label(type="changes_pending", classes='label--below')}} - {%- endif %}
    diff --git a/templates/applications/index.html b/templates/applications/index.html index 00a9b0e8..fd5a5fa7 100644 --- a/templates/applications/index.html +++ b/templates/applications/index.html @@ -24,11 +24,8 @@ {% if not portfolio.applications %} {{ EmptyState( - header="portfolios.applications.empty_state.header"|translate, - message="portfolios.applications.empty_state.message"|translate, - button_text="portfolios.applications.empty_state.button_text"|translate, + resource='applications', button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id), - view_only_text="portfolios.applications.empty_state.view_only_text"|translate, user_can_create=can_create_applications, ) }} diff --git a/templates/base_public.html b/templates/base_public.html index 7b872471..07447633 100644 --- a/templates/base_public.html +++ b/templates/base_public.html @@ -17,7 +17,7 @@
    {% include 'components/usa_header.html' %} - {% include 'navigation/topbar.html' %} + {% block content %}{% endblock %} diff --git a/templates/components/empty_state.html b/templates/components/empty_state.html index 9989e4f8..8357252e 100644 --- a/templates/components/empty_state.html +++ b/templates/components/empty_state.html @@ -1,14 +1,22 @@ -{% macro EmptyState(header, message, button_text, button_link, view_only_text, user_can_create=True) %} +{% macro EmptyState(resource, button_link, user_can_create=False) %} + {% if user_can_create %} + {% set perms = 'edit' %} + {% else %} + {% set perms = 'view' %} + {% endif %} + + {% set header = "empty_state.{}.header.{}".format(resource, perms) | translate | safe %} + {% set message = "empty_state.{}.message.{}".format(resource, perms) | translate | safe %} + {% set button_text = "empty_state.{}.button_text".format(resource) | translate | safe %} +

    {{ header }}

    {{ message }}

    -
    - + {%- endif %}
    {% endmacro %} diff --git a/templates/components/label.html b/templates/components/label.html index 27f1c1b1..4d2679f1 100644 --- a/templates/components/label.html +++ b/templates/components/label.html @@ -9,6 +9,11 @@ "text": "changes pending", "color": "default", }, + "pending_creation": { + "icon": "clock", + "text": "pending creation", + "color": "default", + }, "ppoc": {"text": "primary point of contact"} } %} diff --git a/templates/navigation/topbar.html b/templates/navigation/topbar.html index af5e87bf..005ef6d6 100644 --- a/templates/navigation/topbar.html +++ b/templates/navigation/topbar.html @@ -11,7 +11,7 @@
    {% if g.current_user %} - {{ Icon('avatar', classes='topbar__link-icon') }} + {{ Icon('user', classes='topbar__link-icon') }} {{ g.current_user.first_name + " " + g.current_user.last_name }} diff --git a/templates/portfolios/admin.html b/templates/portfolios/admin.html index 044d669d..9eed77d1 100644 --- a/templates/portfolios/admin.html +++ b/templates/portfolios/admin.html @@ -22,7 +22,7 @@ {{ TextInput(portfolio_form.name, validation="portfolioName", optional=False) }} {{ TextInput(portfolio_form.description, validation="defaultTextAreaField", paragraph=True) }}
    - {{ SaveButton(text='Save Changes', additional_classes='usa-button-big') }} + {{ SaveButton(text='Save Changes') }}
    diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html index 64c11533..3305d924 100644 --- a/templates/portfolios/new/step_1.html +++ b/templates/portfolios/new/step_1.html @@ -10,10 +10,11 @@
    {% include "fragments/flash.html" %} -
    -

    {{ "portfolios.header" | translate }}

    -

    {{ "portfolios.new.title" | translate }}

    -
    +
    +
    +

    {{ "portfolios.header" | translate }}

    +

    {{ 'portfolios.new.title' | translate }}

    +
    {{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
    diff --git a/templates/portfolios/reports/application_and_env_spending.html b/templates/portfolios/reports/application_and_env_spending.html index 783b29ac..44efd76e 100644 --- a/templates/portfolios/reports/application_and_env_spending.html +++ b/templates/portfolios/reports/application_and_env_spending.html @@ -6,17 +6,10 @@ {% if not portfolio.applications %} {% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %} - {% set message = ('portfolios.reports.empty_state.sub_message.can_create_applications' | translate) - if can_create_applications - else ('portfolios.reports.empty_state.sub_message.cannot_create_applications' | translate) - %} {{ EmptyState( - header='portfolios.reports.empty_state.message' | translate, - message=message, - button_text="portfolios.applications.empty_state.button_text"|translate, + resource='applications_reporting', button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id), - view_only_text="portfolios.applications.empty_state.view_only_text"|translate, user_can_create=can_create_applications, ) }} diff --git a/templates/task_orders/builder_base.html b/templates/task_orders/builder_base.html index f5b88c3a..9ee8dd0c 100644 --- a/templates/task_orders/builder_base.html +++ b/templates/task_orders/builder_base.html @@ -14,10 +14,14 @@ {% call Modal(name='cancel', dismissable=True) %}
    -

    Do you want to save this draft?

    +

    {{ 'task_orders.form.builder_base.cancel_modal' | translate }}

    - - + +
    {% endcall %} @@ -39,9 +43,20 @@ {% endblock %} {% if step != "1" %} -
    - Previous - + {% if step == "2" or step == "3" -%} + + {% else -%} + + {{ "common.previous" | translate }} + + {%- endif %} {% endif %} - Task Order Number: {{ to_number }} + {{ "task_orders.form.builder_base.to_number" | translate({ "number": to_number }) | safe }}

    {% endif %} {% if description %} diff --git a/templates/task_orders/index.html b/templates/task_orders/index.html index a720c789..8caeeab2 100644 --- a/templates/task_orders/index.html +++ b/templates/task_orders/index.html @@ -85,11 +85,8 @@ {% endcall %} {% else %} {{ EmptyState( - header="task_orders.empty_state.header"|translate, - message="task_orders.empty_state.message"|translate, + resource="task_orders", button_link=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id), - button_text="task_orders.empty_state.button_text"|translate, - view_only_text="task_orders.empty_state.view_only_text"|translate, user_can_create=user_can(permissions.CREATE_TASK_ORDER), ) }} {% endif %} diff --git a/templates/task_orders/step_1.html b/templates/task_orders/step_1.html index 177a41a8..1e2911e5 100644 --- a/templates/task_orders/step_1.html +++ b/templates/task_orders/step_1.html @@ -10,7 +10,7 @@ {% set action = url_for("task_orders.submit_form_step_one_add_pdf", portfolio_id=portfolio.id) %} {% endif %} -{% set next_button_text = "Next: Add TO Number" %} +{% set next_button_text = "task_orders.form.step_1.next_button" | translate %} {% set step = "1" %} {% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %} diff --git a/templates/task_orders/step_2.html b/templates/task_orders/step_2.html index 1a5b0c42..6be0dec7 100644 --- a/templates/task_orders/step_2.html +++ b/templates/task_orders/step_2.html @@ -4,8 +4,8 @@ {% from "task_orders/form_header.html" import TOFormStepHeader %} {% set action = url_for("task_orders.submit_form_step_two_add_number", task_order_id=task_order_id) %} -{% set next_button_text = "Next: Add Base CLIN" %} -{% set previous_button_link = url_for("task_orders.form_step_one_add_pdf", task_order_id=task_order_id) %} +{% set next_button_text = "task_orders.form.step_2.next_button" | translate %} +{% set previous_button_link = url_for("task_orders.submit_form_step_two_add_number", task_order_id=task_order_id, previous=True) %} {% set step = "2" %} {% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %} diff --git a/templates/task_orders/step_3.html b/templates/task_orders/step_3.html index b1788b51..7782c7bd 100644 --- a/templates/task_orders/step_3.html +++ b/templates/task_orders/step_3.html @@ -6,7 +6,7 @@ {% set action = url_for("task_orders.submit_form_step_three_add_clins", task_order_id=task_order_id) %} {% set next_button_text = "task_orders.form.step_3.next_button" | translate %} -{% set previous_button_link = url_for("task_orders.form_step_two_add_number", task_order_id=task_order_id) %} +{% set previous_button_link = url_for("task_orders.submit_form_step_three_add_clins", task_order_id=task_order_id, previous=True) %} {% set step = "3" %} {% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %} diff --git a/templates/task_orders/step_4.html b/templates/task_orders/step_4.html index 770a40c4..bc17ac01 100644 --- a/templates/task_orders/step_4.html +++ b/templates/task_orders/step_4.html @@ -12,7 +12,7 @@
    - Next: Confirm + {{ "task_orders.form.step_4.next_button" | translate }} {% endblock %} diff --git a/templates/user/edit.html b/templates/user/edit.html index fc4ac64f..226dc167 100644 --- a/templates/user/edit.html +++ b/templates/user/edit.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} -
    +
    {% include "fragments/flash.html" %} diff --git a/terraform/README.md b/terraform/README.md index ec0fbdeb..8c8e7beb 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -1,11 +1,11 @@ # ATAT Terraform Welcome! You've found the ATAT IaC configurations. -ATAT is configured using terraform and a wrapper script called `secrets-tool`. With `terraform` we can configure infrastructure in a programatic way and ensure consistency across environments. +ATAT is configured using terraform and a wrapper script called `secrets-tool`. With `terraform` we can configure infrastructure in a programatic way and ensure consistency across environments. ## Directory Structure -**modules/** - Terraform modules. These are modules that can be re-used for multiple environments. +**modules/** - Terraform modules. These are modules that can be re-used for multiple environments. **providers/** - Specific environment configurations. (dev,production, etc) @@ -92,7 +92,7 @@ Check the output for errors. Sometimes the syntax is valid, but some of the conf # After running TF (Manual Steps) -## VM Scale Set +## VM Scale Set After running terraform, we need to make a manual change to the VM Scale Set that is used in the kubernetes. Terraform has a bug that is not applying this as of `v1.40` of the `azurerm` provider. In order to get the `SystemAssigned` identity to be set, it needs to be set manually in the console. @@ -253,7 +253,7 @@ Uncomment the `backend {}` section in the `provider.tf` file. Once uncommented, *Say `yes` to the question* -Now we need to update the Update `variables.tf` with the principals for the users in `admin_users` variable map. If these are not defined yet, just leave it as an empty set. +Now we need to update the Update `variables.tf` with the principals for the users in `admin_users` variable map. If these are not defined yet, just leave it as an empty set. Next, we'll create the operator keyvault. @@ -281,4 +281,25 @@ secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ cr `terraform apply` -*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)* \ No newline at end of file +*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)* + +*Then we need an instance of the container* + +Change directories to the repo root. Ensure that you've checked out the staging or master branch: + +`docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:latest` + +*Create secrets for ATAT database user* + +Change directories back to terraform/secrets-tool. There is a sample file there. Make sure you know the URL for the aplication Key Vault (distinct from the operator Key Vault). Run: + +`secrets-tool secrets --keyvault [application key vault URL] load -f ./postgres-user.yaml + +*Create the database, database user, schema, and initial data set* + + +This is discussed in more detail [here](https://github.com/dod-ccpo/atst/tree/staging/terraform/secrets-tool#setting-up-the-initial-atat-database). Be sure to read the requirements section. + +``` +secrets-tool database --keyvault [operator key vault URL] provision --app-keyvault [application key vault URL] --dbname jedidev-atat --dbhost [database host name] --ccpo-users /full/path/to/users.yml +``` diff --git a/terraform/modules/postgres/main.tf b/terraform/modules/postgres/main.tf index c3252264..29b6cc53 100644 --- a/terraform/modules/postgres/main.tf +++ b/terraform/modules/postgres/main.tf @@ -35,11 +35,3 @@ resource "azurerm_postgresql_virtual_network_rule" "sql" { subnet_id = var.subnet_id ignore_missing_vnet_service_endpoint = true } - -resource "azurerm_postgresql_database" "db" { - name = "${var.name}-${var.environment}-atat" - resource_group_name = azurerm_resource_group.sql.name - server_name = azurerm_postgresql_server.sql.name - charset = "UTF8" - collation = "en-US" -} diff --git a/terraform/modules/postgres/outputs.tf b/terraform/modules/postgres/outputs.tf index 1ff1dd65..e69de29b 100644 --- a/terraform/modules/postgres/outputs.tf +++ b/terraform/modules/postgres/outputs.tf @@ -1,3 +0,0 @@ -output "db_name" { - value = azurerm_postgresql_database.db.name -} diff --git a/terraform/secrets-tool/README.md b/terraform/secrets-tool/README.md index 28b44817..268ecd7d 100644 --- a/terraform/secrets-tool/README.md +++ b/terraform/secrets-tool/README.md @@ -15,8 +15,8 @@ With both usernames and passwords generated, the application only needs to make Ex. ``` { - 'postgres_root_user': 'EzTEzSNLKQPHuJyPdPloIDCAlcibbl', - 'postgres_root_password': "2+[A@E4:C=ubb/#R#'n } ``` @@ -30,6 +30,51 @@ Terraform typically expects user defined secrets to be stored in either a file, This provides a number of security benefits. First, secrets are not on disk. Secondly, users/operators never see the secrets fly by (passerbys or voyeurs that like to look over your shoulder when deploying to production) +## Setting up the initial ATAT database + +This handles bootstrapping the ATAT database with a user, schema, and initial data. + +It does the following: + +- Sources the Postgres root user credentials +- Source the Postgres ATAT user password +- Runs a script inside an ATAT docker container to set up the initial database user, schema, and seed data in the database + +Requirements: + +- docker +- A copy of the ATAT docker image. This can be built in the repo root with: `docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:latest` +- You need to know the hostname for the Postgres database. Your IP must either be whitelisted in its firewall rules or you must be behind the VPN. +- You will need a YAML file listing all the CCPO users to be added to the database, with the format: + +``` +- dod_id: "2323232323" + first_name: "Luke" + last_name: "Skywalker" +- dod_id: "5656565656" + first_name: "Han" + last_name: "Solo" +``` + +- There should be a password for the ATAT database user in the application Key Vault, preferably named `PGPASSWORD`. You can load this by running `secrets-tool --keyvault [operator key vault url] load -f postgres-user.yml` and supplying YAML like: + +``` +--- +- PGPASSWORD: + type: 'password' + length: 30 +``` + +This command takes a lot of arguments. Run `secrets-tool database --keyvault [operator key vault url] provision -- help` to see the full list of available options. + +The command supplies some defaults by assuming you've followed the patterns in sample-secrets.yml and elsewhere. + +An example would be: + +``` +secrets-tool database --keyvault [operator key vault URL] provision --app-keyvault [application key vault URL] --dbname jedidev-atat --dbhost [database host name] --ccpo-users /full/path/to/users.yml +``` + # Setup *Requirements* @@ -76,4 +121,4 @@ secrets-tool secrets --keyvault https://operator-dev-keyvault.vault.azure.net/ l This will fetch all secrets from the keyvault specified. `secrets-tool` then converts the keys to a variable name that terraform will look for. Essentially it prepends the keys found in KeyVault with `TF_VAR` and then executes terraform as a subprocess with the injected environment variables. ``` secrets-tool terraform --keyvault https://operator-dev-keyvault.vault.azure.net/ plan -``` \ No newline at end of file +``` diff --git a/terraform/secrets-tool/commands/database.py b/terraform/secrets-tool/commands/database.py new file mode 100644 index 00000000..144ceee6 --- /dev/null +++ b/terraform/secrets-tool/commands/database.py @@ -0,0 +1,143 @@ +import os +import click +import logging +import subprocess + +from utils.keyvault.secrets import SecretsClient + +logger = logging.getLogger(__name__) + + +def _run_cmd(command): + try: + env = os.environ.copy() + with subprocess.Popen( + command, env=env, stdout=subprocess.PIPE, shell=True + ) as proc: + for line in proc.stdout: + logging.info(line.decode("utf-8")) + except Exception as e: + print(e) + + +@click.group() +@click.option("--keyvault", required=True, help="Specify the keyvault to operate on") +@click.pass_context +def database(ctx, keyvault): + ctx.ensure_object(dict) + ctx.obj["keyvault"] = keyvault + + +# root password, root username +@click.command("provision") +@click.option( + "--app-keyvault", + "app_keyvault", + required=True, + help="The username for the new Postgres user.", +) +@click.option( + "--user-username", + "user_username", + default="atat", + required=True, + help="The username for the new Postgres user.", +) +@click.option( + "--user-password-key", + "user_password_key", + default="PGPASSWORD", + required=True, + help="The name of the user's password key in the specified vault.", +) +@click.option( + "--root-username-key", + "root_username_key", + default="postgres-root-user", + required=True, + help="The name of the user's password key in the specified vault.", +) +@click.option( + "--root-password-key", + "root_password_key", + default="postgres-root-password", + required=True, + help="The name of the user's password key in the specified vault.", +) +@click.option( + "--dbname", + "dbname", + required=True, + help="The name of the database the user will be given full access to.", +) +@click.option( + "--dbhost", + "dbhost", + required=True, + help="The name of the database the user will be given full access to.", +) +@click.option( + "--container", + "container", + default="atat:latest", + required=True, + help="The container to run the provisioning command in.", +) +@click.option( + "--ccpo-users", + "ccpo_users", + required=True, + help="The full path to a YAML file listing CCPO users to be seeded to the database.", +) +@click.pass_context +def provision( + ctx, + app_keyvault, + user_username, + user_password_key, + root_username_key, + root_password_key, + dbname, + dbhost, + container, + ccpo_users, +): + """ + Set up the initial ATAT database. + """ + logger.info("obtaining postgres root user credentials") + operator_keyvault = SecretsClient(vault_url=ctx.obj["keyvault"]) + root_password = operator_keyvault.get_secret(root_password_key) + root_name = operator_keyvault.get_secret(root_username_key) + + logger.info("obtaining postgres database user password") + app_keyvault = SecretsClient(vault_url=app_keyvault) + user_password = app_keyvault.get_secret(user_password_key) + + logger.info("starting docker process") + + create_database_cmd = ( + f"docker run -e PGHOST='{dbhost}'" + f" -e PGPASSWORD='{root_password}'" + f" -e PGUSER='{root_name}@{dbhost}'" + f" -e PGDATABASE='{dbname}'" + f" -e PGSSLMODE=require" + f" {container}" + f" .venv/bin/python script/create_database.py {dbname}" + ) + _run_cmd(create_database_cmd) + + seed_database_cmd = ( + f"docker run -e PGHOST='{dbhost}'" + f" -e PGPASSWORD='{root_password}'" + f" -e PGUSER='{root_name}@{dbhost}'" + f" -e PGDATABASE='{dbname}'" + f" -e PGSSLMODE=require" + f" -v {ccpo_users}:/opt/atat/atst/users.yml" + f" {container}" + f" .venv/bin/python script/database_setup.py {user_username} '{user_password}' users.yml" + ) + _run_cmd(seed_database_cmd) + + +database.add_command(provision) diff --git a/terraform/secrets-tool/postgres-user.yaml b/terraform/secrets-tool/postgres-user.yaml new file mode 100644 index 00000000..ffca46ea --- /dev/null +++ b/terraform/secrets-tool/postgres-user.yaml @@ -0,0 +1,4 @@ +--- +- PGPASSWORD: + type: 'password' + length: 30 diff --git a/terraform/secrets-tool/secrets-tool b/terraform/secrets-tool/secrets-tool index 58079e3e..04270813 100755 --- a/terraform/secrets-tool/secrets-tool +++ b/terraform/secrets-tool/secrets-tool @@ -7,6 +7,7 @@ import logging from commands.secrets import secrets from commands.terraform import terraform +from commands.database import database config.setup_logging() logger = logging.getLogger(__name__) @@ -21,6 +22,7 @@ def cli(): # Add additional command groups cli.add_command(secrets) cli.add_command(terraform) +cli.add_command(database) if __name__ == "__main__": @@ -41,12 +43,12 @@ if __name__ == "__main__": val = keyvault.get_secret(secret) #print(val) os.environ[name] = val - env = os.environ.copy() + env = os.environ.copy() command = "{} {}".format(PROCESS, sys.argv[1]) with subprocess.Popen(command, env=env, stdout=subprocess.PIPE, shell=True) as proc: for line in proc.stdout: logging.info(line.decode("utf-8") ) - + except Exception as e: print(e, traceback.print_stack) -''' \ No newline at end of file +''' diff --git a/terraform/secrets-tool/utils/keyvault/secrets.py b/terraform/secrets-tool/utils/keyvault/secrets.py index af8b5201..40a6120d 100644 --- a/terraform/secrets-tool/utils/keyvault/secrets.py +++ b/terraform/secrets-tool/utils/keyvault/secrets.py @@ -36,7 +36,7 @@ class SecretsLoader(): load the secrets in to keyvault """ def __init__(self, yaml_file: str, keyvault: object): - assert Path(yaml_file).exists() + assert Path(yaml_file).exists() self.yaml_file = yaml_file self.keyvault = keyvault self.config = dict() @@ -47,7 +47,7 @@ class SecretsLoader(): def _load_yaml(self): with open(self.yaml_file) as handle: self.config = yaml.load(handle, Loader=yaml.FullLoader) - + def _generate_secrets(self): secrets = GenerateSecrets(self.config).process_definition() self.secrets = secrets @@ -60,12 +60,14 @@ class SecretsLoader(): class GenerateSecrets(): """ - Read the secrets definition and generate requiesite + Read the secrets definition and generate requiesite secrets based on the type of secret and arguments provided """ def __init__(self, definitions: dict): self.definitions = definitions + most_punctuation = string.punctuation.replace("'", "").replace('"', "") + self.password_characters = string.ascii_letters + string.digits + most_punctuation def process_definition(self): """ @@ -101,9 +103,8 @@ class GenerateSecrets(): # Types. Can be usernames, passwords, or in the future things like salted # tokens, uuid, or other specialized types def _generate_password(self, length: int): - self.password_characters = string.ascii_letters + string.digits + string.punctuation return ''.join(secrets.choice(self.password_characters) for i in range(length)) - + def _generate_username(self, length: int): self.username_characters = string.ascii_letters return ''.join(secrets.choice(self.username_characters) for i in range(length)) diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index da40634d..ac5f62a2 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -1,6 +1,8 @@ +import json from unittest.mock import Mock, patch from uuid import uuid4 +import pytest from tests.factories import ApplicationFactory, EnvironmentFactory from tests.mock_azure import AUTH_CREDENTIALS, mock_azure @@ -8,6 +10,8 @@ from atst.domain.csp.cloud import AzureCloudProvider from atst.domain.csp.cloud.models import ( AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPResult, + ApplicationCSPPayload, + ApplicationCSPResult, BaseCSPPayload, BillingInstructionCSPPayload, BillingInstructionCSPResult, @@ -73,8 +77,8 @@ def test_create_subscription_succeeds(mock_azure: AzureCloudProvider): def mock_management_group_create(mock_azure, spec_dict): - mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = Mock( - **spec_dict + mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = ( + spec_dict ) @@ -90,12 +94,30 @@ def test_create_environment_succeeds(mock_azure: AzureCloudProvider): assert result.id == "Test Id" +# mock the get_secret so it returns a JSON string +MOCK_CREDS = { + "tenant_id": str(uuid4()), + "tenant_sp_client_id": str(uuid4()), + "tenant_sp_key": "1234", +} + + +def mock_get_secret(azure, func): + azure.get_secret = func + + return azure + + def test_create_application_succeeds(mock_azure: AzureCloudProvider): application = ApplicationFactory.create() - mock_management_group_create(mock_azure, {"id": "Test Id"}) - result = mock_azure._create_application(AUTH_CREDENTIALS, application) + mock_azure = mock_get_secret(mock_azure, lambda *a, **k: json.dumps(MOCK_CREDS)) + + payload = ApplicationCSPPayload( + tenant_id="1234", display_name=application.name, parent_id=str(uuid4()) + ) + result = mock_azure.create_application(payload) assert result.id == "Test Id" diff --git a/tests/domain/cloud/test_models.py b/tests/domain/cloud/test_models.py new file mode 100644 index 00000000..d9fc963d --- /dev/null +++ b/tests/domain/cloud/test_models.py @@ -0,0 +1,99 @@ +import pytest + +from pydantic import ValidationError + +from atst.domain.csp.cloud.models import ( + AZURE_MGMNT_PATH, + KeyVaultCredentials, + ManagementGroupCSPPayload, + ManagementGroupCSPResponse, +) + + +def test_ManagementGroupCSPPayload_management_group_name(): + # supplies management_group_name when absent + payload = ManagementGroupCSPPayload( + tenant_id="any-old-id", + display_name="Council of Naboo", + parent_id="Galactic_Senate", + ) + assert payload.management_group_name + # validates management_group_name + with pytest.raises(ValidationError): + payload = ManagementGroupCSPPayload( + tenant_id="any-old-id", + management_group_name="council of Naboo 1%^&", + display_name="Council of Naboo", + parent_id="Galactic_Senate", + ) + # shortens management_group_name to fit + name = "council_of_naboo".ljust(95, "1") + + assert len(name) > 90 + payload = ManagementGroupCSPPayload( + tenant_id="any-old-id", + management_group_name=name, + display_name="Council of Naboo", + parent_id="Galactic_Senate", + ) + assert len(payload.management_group_name) == 90 + + +def test_ManagementGroupCSPPayload_display_name(): + # shortens display_name to fit + name = "Council of Naboo".ljust(95, "1") + assert len(name) > 90 + payload = ManagementGroupCSPPayload( + tenant_id="any-old-id", display_name=name, parent_id="Galactic_Senate" + ) + assert len(payload.display_name) == 90 + + +def test_ManagementGroupCSPPayload_parent_id(): + full_path = f"{AZURE_MGMNT_PATH}Galactic_Senate" + # adds full path + payload = ManagementGroupCSPPayload( + tenant_id="any-old-id", + display_name="Council of Naboo", + parent_id="Galactic_Senate", + ) + assert payload.parent_id == full_path + # keeps full path + payload = ManagementGroupCSPPayload( + tenant_id="any-old-id", display_name="Council of Naboo", parent_id=full_path + ) + assert payload.parent_id == full_path + + +def test_ManagementGroupCSPResponse_id(): + full_id = "/path/to/naboo-123" + response = ManagementGroupCSPResponse( + **{"id": "/path/to/naboo-123", "other": "stuff"} + ) + assert response.id == full_id + + +def test_KeyVaultCredentials_enforce_admin_creds(): + with pytest.raises(ValidationError): + KeyVaultCredentials(tenant_id="an id", tenant_admin_username="C3PO") + assert KeyVaultCredentials( + tenant_id="an id", + tenant_admin_username="C3PO", + tenant_admin_password="beep boop", + ) + + +def test_KeyVaultCredentials_enforce_sp_creds(): + with pytest.raises(ValidationError): + KeyVaultCredentials(tenant_id="an id", tenant_sp_client_id="C3PO") + assert KeyVaultCredentials( + tenant_id="an id", tenant_sp_client_id="C3PO", tenant_sp_key="beep boop" + ) + + +def test_KeyVaultCredentials_enforce_root_creds(): + with pytest.raises(ValidationError): + KeyVaultCredentials(root_tenant_id="an id", root_sp_client_id="C3PO") + assert KeyVaultCredentials( + root_tenant_id="an id", root_sp_client_id="C3PO", root_sp_key="beep boop" + ) diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index 9fda3114..02dd3124 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta import pytest from uuid import uuid4 @@ -196,3 +197,20 @@ def test_update_does_not_duplicate_names_within_portfolio(): with pytest.raises(AlreadyExistsError): Applications.update(dupe_application, {"name": name}) + + +def test_get_applications_pending_creation(): + now = datetime.now() + later = now + timedelta(minutes=30) + + portfolio1 = PortfolioFactory.create(state="COMPLETED") + app_ready = ApplicationFactory.create(portfolio=portfolio1) + + app_done = ApplicationFactory.create(portfolio=portfolio1, cloud_id="123456") + + portfolio2 = PortfolioFactory.create(state="UNSTARTED") + app_not_ready = ApplicationFactory.create(portfolio=portfolio2) + + uuids = Applications.get_applications_pending_creation() + + assert [app_ready.id] == uuids diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 49b9bae6..42da74e6 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -71,7 +71,7 @@ def test_update_adds_clins(): def test_update_does_not_duplicate_clins(): task_order = TaskOrderFactory.create( - number="3453453456", create_clins=[{"number": "123"}, {"number": "456"}] + number="3453453456123", create_clins=[{"number": "123"}, {"number": "456"}] ) clins = [ { @@ -93,7 +93,7 @@ def test_update_does_not_duplicate_clins(): ] task_order = TaskOrders.update( task_order_id=task_order.id, - number="0000000000", + number="0000000000000", clins=clins, pdf={"filename": "sample.pdf", "object_name": "1234567"}, ) @@ -170,3 +170,11 @@ def test_update_enforces_unique_number(): dupe_task_order = TaskOrderFactory.create() with pytest.raises(AlreadyExistsError): TaskOrders.update(dupe_task_order.id, task_order.number, [], None) + + +def test_allows_alphanumeric_number(): + portfolio = PortfolioFactory.create() + valid_to_numbers = ["1234567890123", "ABC1234567890"] + + for number in valid_to_numbers: + assert TaskOrders.create(portfolio.id, number, [], None) diff --git a/tests/factories.py b/tests/factories.py index d9af7c40..b7a63243 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -7,6 +7,7 @@ import datetime from atst.forms import data from atst.models import * +from atst.models.mixins.state_machines import FSMStates from atst.domain.invitations import PortfolioInvitations from atst.domain.permission_sets import PermissionSets @@ -121,6 +122,7 @@ class PortfolioFactory(Base): owner = kwargs.pop("owner", UserFactory.create()) members = kwargs.pop("members", []) with_task_orders = kwargs.pop("task_orders", []) + state = kwargs.pop("state", None) portfolio = super()._create(model_class, *args, **kwargs) @@ -161,6 +163,12 @@ class PortfolioFactory(Base): permission_sets=perms_set, ) + if state: + state = getattr(FSMStates, state) + fsm = PortfolioStateMachineFactory.create(state=state, portfolio=portfolio) + # setting it in the factory is not working for some reason + fsm.state = state + portfolio.applications = applications portfolio.task_orders = task_orders return portfolio diff --git a/tests/forms/test_task_order.py b/tests/forms/test_task_order.py index 97759c81..ae4fd3c6 100644 --- a/tests/forms/test_task_order.py +++ b/tests/forms/test_task_order.py @@ -112,3 +112,37 @@ def test_no_number(): http_request_form_data = {} form = TaskOrderForm(http_request_form_data) assert form.data["number"] is None + + +def test_number_allows_alphanumeric(): + valid_to_numbers = ["1234567890123", "ABC1234567890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + + +def test_number_allows_between_13_and_17_characters(): + valid_to_numbers = ["123456789012345", "ABCDEFG1234567890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + + +def test_number_strips_dashes(): + valid_to_numbers = ["123-456789-012345", "ABCD-EFG12345-67890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + assert not "-" in form.number.data + + +def test_number_case_coerces_all_caps(): + valid_to_numbers = ["12345678012345", "AbcEFg1234567890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + assert form.number.data == number.upper() diff --git a/tests/mock_azure.py b/tests/mock_azure.py index 6818925e..438ae855 100644 --- a/tests/mock_azure.py +++ b/tests/mock_azure.py @@ -102,6 +102,7 @@ class MockAzureSDK(object): self.secrets = mock_secrets() self.requests = mock_requests() self.cloud = mock_cloud_details() + self.identity = mock_identity() @pytest.fixture(scope="function") diff --git a/tests/render_vue_component.py b/tests/render_vue_component.py index 62106a67..49fb6ee9 100644 --- a/tests/render_vue_component.py +++ b/tests/render_vue_component.py @@ -35,6 +35,7 @@ class TaskOrderPdfForm(Form): class TaskOrderForm(Form): pdf = FormField(TaskOrderPdfForm, label="task_order_pdf") + number = StringField(label="task_order_number", default="number") @pytest.fixture @@ -63,6 +64,12 @@ def multi_checkbox_input_macro(env): return getattr(multi_checkbox_template.module, "MultiCheckboxInput") +@pytest.fixture +def text_input_macro(env): + text_input_template = env.get_template("components/text_input.html") + return getattr(text_input_template.module, "TextInput") + + @pytest.fixture def initial_value_form(scope="function"): return InitialValueForm() @@ -170,3 +177,10 @@ def test_make_pop_date_range(env, app): index=1, ) write_template(pop_date_range, "pop_date_range.html") + + +def test_make_text_input_template(text_input_macro, task_order_form): + text_input_to_number = text_input_macro( + task_order_form.number, validation="taskOrderNumber" + ) + write_template(text_input_to_number, "text_input_to_number.html") diff --git a/tests/routes/task_orders/test_new.py b/tests/routes/task_orders/test_new.py index 0aef88ed..9929a992 100644 --- a/tests/routes/task_orders/test_new.py +++ b/tests/routes/task_orders/test_new.py @@ -158,7 +158,7 @@ def test_task_orders_form_step_two_add_number(client, user_session, task_order): def test_task_orders_submit_form_step_two_add_number(client, user_session, task_order): user_session(task_order.portfolio.owner) - form_data = {"number": "1234567890"} + form_data = {"number": "abc-1234567890"} response = client.post( url_for( "task_orders.submit_form_step_two_add_number", task_order_id=task_order.id @@ -167,7 +167,7 @@ def test_task_orders_submit_form_step_two_add_number(client, user_session, task_ ) assert response.status_code == 302 - assert task_order.number == "1234567890" + assert task_order.number == "ABC1234567890" # pragma: allowlist secret def test_task_orders_submit_form_step_two_enforces_unique_number( @@ -194,7 +194,7 @@ def test_task_orders_submit_form_step_two_add_number_existing_to( client, user_session, task_order ): user_session(task_order.portfolio.owner) - form_data = {"number": "0000000000"} + form_data = {"number": "0000000000000"} original_number = task_order.number response = client.post( url_for( @@ -203,7 +203,7 @@ def test_task_orders_submit_form_step_two_add_number_existing_to( data=form_data, ) assert response.status_code == 302 - assert task_order.number == "0000000000" + assert task_order.number == "0000000000000" assert task_order.number != original_number @@ -458,3 +458,61 @@ def test_task_order_form_shows_errors(client, user_session, task_order): body = response.data.decode() assert "There were some errors" in body assert "Not a valid decimal" in body + + +def test_update_and_render_next_handles_previous_valid_data( + client, user_session, task_order +): + user_session(task_order.portfolio.owner) + form_data = {"number": "0000000000000"} + original_number = task_order.number + response = client.post( + url_for( + "task_orders.submit_form_step_two_add_number", + task_order_id=task_order.id, + previous=True, + ), + data=form_data, + ) + assert response.status_code == 302 + assert task_order.number == "0000000000000" + assert task_order.number != original_number + + +def test_update_and_render_next_handles_previous_invalid_data( + client, user_session, task_order +): + clin_list = [ + { + "jedi_clin_type": "JEDI_CLIN_1", + "number": "12312", + "start_date": "01/01/2020", + "end_date": "01/01/2021", + "obligated_amount": "5000", + "total_amount": "10000", + }, + ] + TaskOrders.create_clins(task_order.id, clin_list) + assert len(task_order.clins) == 2 + + user_session(task_order.portfolio.owner) + form_data = { + "clins-0-jedi_clin_type": "JEDI_CLIN_1", + "clins-0-number": "12312", + "clins-0-start_date": "01/01/2020", + "clins-0-end_date": "01/01/2021", + "clins-0-obligated_amount": "5000", + "clins-0-total_amount": "10000", + "clins-1-jedi_clin_type": "JEDI_CLIN_1", + "clins-1-number": "1212", + } + response = client.post( + url_for( + "task_orders.submit_form_step_three_add_clins", + task_order_id=task_order.id, + previous=True, + ), + data=form_data, + ) + + assert len(task_order.clins) == 2 diff --git a/tests/test_access.py b/tests/test_access.py index b0dac527..f8879024 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -663,7 +663,7 @@ def test_task_orders_new_get_routes(get_url_assert_status): def test_task_orders_new_post_routes(post_url_assert_status): post_routes = [ ("task_orders.submit_form_step_one_add_pdf", {"pdf": ""}), - ("task_orders.submit_form_step_two_add_number", {"number": "1234567890"}), + ("task_orders.submit_form_step_two_add_number", {"number": "1234567890123"}), ( "task_orders.submit_form_step_three_add_clins", { diff --git a/tests/test_jobs.py b/tests/test_jobs.py index ff8e4602..2ac5f408 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -8,9 +8,9 @@ from atst.domain.csp.cloud import MockCloudProvider from atst.domain.portfolios import Portfolios from atst.jobs import ( - RecordEnvironmentFailure, - RecordEnvironmentRoleFailure, + RecordFailure, dispatch_create_environment, + dispatch_create_application, dispatch_create_atat_admin_user, dispatch_provision_portfolio, dispatch_provision_user, @@ -18,6 +18,7 @@ from atst.jobs import ( do_provision_user, do_provision_portfolio, do_create_environment, + do_create_application, do_create_atat_admin_user, ) from atst.models.utils import claim_for_update @@ -27,9 +28,10 @@ from tests.factories import ( EnvironmentRoleFactory, PortfolioFactory, PortfolioStateMachineFactory, + ApplicationFactory, ApplicationRoleFactory, ) -from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus +from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure @pytest.fixture(autouse=True, scope="function") @@ -43,8 +45,17 @@ def portfolio(): return portfolio -def test_environment_job_failure(celery_app, celery_worker): - @celery_app.task(bind=True, base=RecordEnvironmentFailure) +def _find_failure(session, entity, id_): + return ( + session.query(JobFailure) + .filter(JobFailure.entity == entity) + .filter(JobFailure.entity_id == id_) + .one() + ) + + +def test_environment_job_failure(session, celery_app, celery_worker): + @celery_app.task(bind=True, base=RecordFailure) def _fail_hard(self, environment_id=None): raise ValueError("something bad happened") @@ -56,13 +67,12 @@ def test_environment_job_failure(celery_app, celery_worker): with pytest.raises(ValueError): task.get() - assert environment.job_failures - job_failure = environment.job_failures[0] + job_failure = _find_failure(session, "environment", str(environment.id)) assert job_failure.task == task -def test_environment_role_job_failure(celery_app, celery_worker): - @celery_app.task(bind=True, base=RecordEnvironmentRoleFailure) +def test_environment_role_job_failure(session, celery_app, celery_worker): + @celery_app.task(bind=True, base=RecordFailure) def _fail_hard(self, environment_role_id=None): raise ValueError("something bad happened") @@ -74,8 +84,7 @@ def test_environment_role_job_failure(celery_app, celery_worker): with pytest.raises(ValueError): task.get() - assert role.job_failures - job_failure = role.job_failures[0] + job_failure = _find_failure(session, "environment_role", str(role.id)) assert job_failure.task == task @@ -99,6 +108,24 @@ def test_create_environment_job_is_idempotent(csp, session): csp.create_environment.assert_not_called() +def test_create_application_job(session, csp): + portfolio = PortfolioFactory.create( + csp_data={"tenant_id": str(uuid4()), "root_management_group_id": str(uuid4())} + ) + application = ApplicationFactory.create(portfolio=portfolio, cloud_id=None) + do_create_application(csp, application.id) + session.refresh(application) + + assert application.cloud_id + + +def test_create_application_job_is_idempotent(csp): + application = ApplicationFactory.create(cloud_id=uuid4()) + do_create_application(csp, application.id) + + csp.create_application.assert_not_called() + + def test_create_atat_admin_user(csp, session): environment = EnvironmentFactory.create(cloud_id="something") do_create_atat_admin_user(csp, environment.id) @@ -139,6 +166,21 @@ def test_dispatch_create_environment(session, monkeypatch): mock.delay.assert_called_once_with(environment_id=e1.id) +def test_dispatch_create_application(monkeypatch): + portfolio = PortfolioFactory.create(state="COMPLETED") + app = ApplicationFactory.create(portfolio=portfolio) + + mock = Mock() + monkeypatch.setattr("atst.jobs.create_application", mock) + + # When dispatch_create_application is called + dispatch_create_application.run() + + # It should cause the create_application task to be called once + # with the application id + mock.delay.assert_called_once_with(application_id=app.id) + + def test_dispatch_create_atat_admin_user(session, monkeypatch): portfolio = PortfolioFactory.create( applications=[ diff --git a/tests/utils/test_hash.py b/tests/utils/test_hash.py new file mode 100644 index 00000000..5cfb8489 --- /dev/null +++ b/tests/utils/test_hash.py @@ -0,0 +1,16 @@ +import random +import re +import string + +from atst.utils import sha256_hex + + +def test_sha256_hex(): + sample = "".join( + random.choices(string.ascii_uppercase + string.digits, k=random.randrange(200)) + ) + hashed = sha256_hex(sample) + assert re.match("^[a-zA-Z0-9]+$", hashed) + assert len(hashed) == 64 + hashed_again = sha256_hex(sample) + assert hashed == hashed_again diff --git a/translations.yaml b/translations.yaml index d9615c4d..df583059 100644 --- a/translations.yaml +++ b/translations.yaml @@ -84,6 +84,31 @@ email: application_invite: "{inviter_name} has invited you to a JEDI cloud application" portfolio_invite: "{inviter_name} has invited you to a JEDI cloud portfolio" environment_ready: JEDI cloud environment ready +empty_state: + applications: + header: + edit: You don’t have any Applications yet + view: This portfolio has no Applications + message: + edit: You can manage multiple Applications within a single Portfolio as long as the funding sources are the same. + view: A Portfolio member with Edit Application permissions can add Applications to this Portfolio. + button_text: Create Your First Application + applications_reporting: + header: + edit: Nothing to report. + view: Nothing to report. + message: + edit: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started. + view: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments. + button_text: Add a new application + task_orders: + header: + edit: Add approved task orders + view: This Portfolio has no Task Orders + message: + edit: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future. + view: A Portfolio member with Edit Funding permissions can fund this Portfolio with approved Task Orders. + button_text: Add Task Order flash: application: created: @@ -370,11 +395,6 @@ portfolios: add_member: Add Team Member add_another_environment: Add another environment create_button: Create Application - empty_state: - header: You don't have any Applications yet - message: You can manage multiple Applications within a single Portfolio as long as the funding sources are the same. - button_text: Create Your First Application - view_only_text: Contact your portfolio administrator to add an application. new: step_1_header: Name and Describe New Application step_1_button_text: "Next: Add Environments" @@ -417,6 +437,7 @@ portfolios: add_subscription: Add new subscription blank_slate: This Application has no environments disabled: ": Access Suspended" + funding_alert: "Application environments will not be created until the {name} portfolio is funded." environments_heading: Application Environments existing_application_title: "{application_name} Application Settings" member_count: "{count} Members" @@ -482,12 +503,6 @@ portfolios: header: Funding Duration tooltip: Funding duration is the period of time that there is a valid task order funding the portfolio. estimate_warning: Reports displayed in JEDI are estimates and not a system of record. - empty_state: - message: Nothing to report. - sub_message: - can_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started. - cannot_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments. - action_label: "Add a new application" total_value: header: Total Portfolio Value tooltip: Total portfolio value is all obligated and projected funds for all task orders in this portfolio. @@ -509,11 +524,16 @@ task_orders: tooltip: obligated_funds: Funds committed to fund your portfolio. This may represent 100% of your total Task Order value, or a portion of it. total_value: All obligated and projected funds for the Task Order’s Base and Option CLINs. - expended_funds: All funds spend from the Task Order so far. + expended_funds: All funds spent from the Task Order so far. form: add_clin: Add Another CLIN add_to_header: Enter the Task Order number add_to_description: Please input your 13-digit Task Order number. This number may be listed under "Order Number" if your Contracting Officer used form 1149, or "Delivery Order/Call No." if form 1155 was used. Moving forward, this portion of funding will be referenced by the recorded Task Order number. + builder_base: + cancel_modal: Do you want to save this draft? + delete_draft: No, delete it + save_draft: Yes, save for later + to_number: "Task Order Number: {number}" clin_title: Enter Contract Line Items clin_description: "Refer to your task order to locate your Contract Line Item Numbers (CLINs)." clin_details: CLIN Details @@ -536,12 +556,16 @@ task_orders: step_1: title: Upload your approved Task Order (TO) description: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future. + next_button: "Next: Add TO Number" + step_2: + next_button: "Next: Add Base CLIN" step_3: next_button: "Next: Review Task Order" percent_obligated: "% of Funds Obligated" step_4: documents: Documents clins: CLIN Summary + next_button: "Next: Confirm" step_5: cta_text: Verify Your Information description: Prior to submitting the Task Order, you must acknowledge, by marking the appropriate box below, that the uploaded Task Order is signed by an appropriate, duly warranted Contracting Officer who has the authority to execute the uploaded Task Order on your Agency’s behalf and has authorized you to upload the Task Order in accordance with Agency policy and procedures. You must further acknowledge, by marking the appropriate box below, that all information entered herein matches that of the submitted Task Order. @@ -549,11 +573,6 @@ task_orders: sticky_header_text: "Add a Task Order" sticky_header_review_text: Review Changes sticky_header_context: "Step {step} of 5" - empty_state: - header: Add approved task orders - message: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future. - button_text: Add Task Order - view_only_text: Contact your portfolio administrator to add a Task Order. sign: digital_signature_description: I confirm the uploaded Task Order is signed by the appropriate, duly warranted Agency Contracting Officer who authorized me to upload the Task Order. confirmation_description: I confirm that the information entered here in matches that of the submitted Task Order. diff --git a/uitests/Application_Index_with_App.html b/uitests/Application_Index_with_App.html index 2c2c120c..c7efd48a 100644 --- a/uitests/Application_Index_with_App.html +++ b/uitests/Application_Index_with_App.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/Create_New_Application.html b/uitests/Create_New_Application.html index 24ee7094..564c1886 100644 --- a/uitests/Create_New_Application.html +++ b/uitests/Create_New_Application.html @@ -160,7 +160,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -413,28 +413,13 @@ Imported from: AT-AT CI - login--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -449,7 +434,7 @@ Imported from: AT-AT CI - login--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/Create_New_TO.html b/uitests/Create_New_TO.html index 460026cd..1d85c2bc 100644 --- a/uitests/Create_New_TO.html +++ b/uitests/Create_New_TO.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -189,12 +189,12 @@ Imported from: AT-AT CI - login--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Edit_App_Member.html b/uitests/Edit_App_Member.html index b3af2f7e..e4e92fa8 100644 --- a/uitests/Edit_App_Member.html +++ b/uitests/Edit_App_Member.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -656,28 +640,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - -waitForElementPresent css=.environment_roles.environment-roles-new > .form-row:nth-of-type(1) > .form-col.form-col--third > fieldset.usa-input__choices > select[name="environment_roles-2-role"] type css=.environment_roles.environment-roles-new > .form-row:nth-of-type(1) > .form-col.form-col--third > fieldset.usa-input__choices > select[name="environment_roles-2-role"] -Business Read-only +CONTRIBUTOR waitForPageToLoad @@ -761,13 +730,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(3) > .env-role__role +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(3) > .env-role__role assertText -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(3) > .env-role__role -*Business Read-only* +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(3) > .env-role__role +*Contributor* diff --git a/uitests/Edit_Portfolio_Member.html b/uitests/Edit_Portfolio_Member.html index 71d82e8f..3fd58cee 100644 --- a/uitests/Edit_Portfolio_Member.html +++ b/uitests/Edit_Portfolio_Member.html @@ -16,7 +16,7 @@ - + @@ -174,7 +174,7 @@ Imported from: AT-AT CI - New Portfolio--> - + @@ -192,7 +192,7 @@ Imported from: AT-AT CI - New Portfolio--> - + @@ -291,29 +291,12 @@ Imported from: AT-AT CI - Portfolio Settings--> Imported from: AT-AT CI - Portfolio Settings--> - + - - - - - - - - - - - - - - - - - + @@ -331,41 +314,7 @@ Imported from: AT-AT CI - Portfolio Settings--> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -375,12 +324,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -391,13 +340,13 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - - + + @@ -487,13 +436,13 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - - + + @@ -503,12 +452,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -519,12 +468,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -535,12 +484,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -551,60 +500,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -615,22 +516,6 @@ Imported from: AT-AT CI - Portfolio Settings--> - - - - - - - - - - - - - - - - @@ -647,12 +532,75 @@ Imported from: AT-AT CI - Portfolio Settings--> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -660,105 +608,59 @@ Imported from: AT-AT CI - Portfolio Settings--> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -768,13 +670,13 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - - + +
    assertText css=.sticky-cta-text > h3*Create New Portfolio**Name and Describe Portfolio*
    waitForPageToLoad
    type css=#nameTatooine Energy Maintenance SystemsTatooine Energy Maintenance Systems ${alphanumeric}
    waitForPageToLoad
    waitForElementPresentcss=.panel__content > p:nth-of-type(2)css=th.table-cell--third
    assertElementPresentcss=.panel__content > p:nth-of-type(2)
    waitForPageToLoad
    waitForElementPresentcss=td.name
    assertElementPresentcss=td.namecss=th.table-cell--third
    assertText css=button.usa-button.usa-button-primary.usa-button-bigSave
    waitForPageToLoad
    waitForElementPresentcss=button.usa-button.usa-button-primary
    assertTextcss=button.usa-button.usa-button-primary*Update*
    waitForPageToLoad
    waitForElementPresentcss=input.usa-button.usa-button-primary
    assertTextcss=input.usa-button.usa-button-primarySaveSave Changes
    waitForPageToLoad
    waitForElementPresentcss=a.icon-link.modal-linkcss=a.usa-button.usa-button-secondary.add-new-button
    clickcss=a.icon-link.modal-linkcss=a.usa-button.usa-button-secondary.add-new-button
    waitForElementPresentcss=#add-port-mem > div > div:nth-of-type(1) > h1css=#add-portfolio-manager > div > div > div.member-form > h2
    assertTextcss=#add-port-mem > div > div:nth-of-type(1) > h1*Invite new portfolio member*css=#add-portfolio-manager > div > div > div.member-form > h2*Add Manager*
    waitForPageToLoad
    waitForElementPresentcss=#add-port-mem > div > div:nth-of-type(2) > h1css=#add-portfolio-manager > div > div > div.member-form > h2
    assertTextcss=#add-port-mem > div > div:nth-of-type(2) > h1*Assign member permissions*css=#add-portfolio-manager > div > div > div.member-form > h2*Set Portfolio Permissions*
    waitForPageToLoad
    waitForElementPresentcss=#permission_sets-perms_app_mgmtcss=#perms_app_mgmt-None
    clickcss=#permission_sets-perms_app_mgmtcss=#perms_app_mgmt-None
    waitForElementPresentcss=#permission_sets-perms_app_mgmt > option:nth-of-type(1)css=#perms_funding-None
    clickcss=#permission_sets-perms_app_mgmt > option:nth-of-type(1)css=#perms_funding-None
    waitForElementPresentcss=#permission_sets-perms_fundingcss=#perms_reporting-None
    clickcss=#permission_sets-perms_fundingcss=#perms_reporting-None
    waitForElementPresentcss=#permission_sets-perms_funding > option:nth-of-type(1)
    clickcss=#permission_sets-perms_funding > option:nth-of-type(1)
    waitForPageToLoad
    waitForElementPresentcss=#permission_sets-perms_reporting
    clickcss=#permission_sets-perms_reporting
    waitForPageToLoad
    waitForElementPresentcss=#permission_sets-perms_reporting > option:nth-of-type(1)
    clickcss=#permission_sets-perms_reporting > option:nth-of-type(1)
    waitForPageToLoad
    waitForElementPresentcss=#permission_sets-perms_portfolio_mgmtcss=#perms_portfolio_mgmt-None
    typecss=#permission_sets-perms_portfolio_mgmtcss=#perms_portfolio_mgmt-None edit_portfolio_admin
    waitForElementPresentcss=#permission_sets-perms_portfolio_mgmt > option:nth-of-type(2)
    clickcss=#permission_sets-perms_portfolio_mgmt > option:nth-of-type(2)
    waitForPageToLoad
    waitForElementPresent css=input[type="submit"].action-group__action
    waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.namecss=table.atat-table > tbody > tr > td > span.label.label--success.label--below
    assertTextcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below*invite pending*
    waitForPageToLoad
    waitForElementPresentcss=.usa-alert-body
    assertTextcss=.usa-alert-body*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.*
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
    clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1)
    clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1)
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-content > div:nth-of-type(3) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1
    assertElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.namecss=.portfolio-content > div:nth-of-type(3) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1
    waitForElementPresentcss=.usa-alert-body > p:nth-of-type(2)css=.portfolio-perms > div:nth-of-type(2) > .usa-input.input__inline-fields.checked > fieldset.usa-input__choices > legend > label
    clickcss=.portfolio-perms > div:nth-of-type(2) > .usa-input.input__inline-fields.checked > fieldset.usa-input__choices > legend > label
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-perms > div:nth-of-type(4) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label
    clickcss=.portfolio-perms > div:nth-of-type(4) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label
    waitForPageToLoad
    waitForElementPresentcss=.action-group__action.usa-button
    clickcss=.action-group__action.usa-button
    waitForPageToLoad
    waitForElementPresentcss=h3.usa-alert-heading
    assertTextcss=.usa-alert-body > p:nth-of-type(2)*You have successfully invited Brandon Buchannan to the portfolio.*
    waitForPageToLoad
    waitForElementPresentcss=select[name="members_permissions-1-perms_app_mgmt"]
    typecss=select[name="members_permissions-1-perms_app_mgmt"]edit_portfolio_application_management
    waitForPageToLoad
    waitForElementPresentcss=select[name="members_permissions-1-perms_app_mgmt"] > option:nth-of-type(2)
    clickcss=select[name="members_permissions-1-perms_app_mgmt"] > option:nth-of-type(2)
    waitForPageToLoad
    waitForElementPresentcss=select[name="members_permissions-1-perms_reporting"]
    typecss=select[name="members_permissions-1-perms_reporting"]edit_portfolio_reports
    waitForPageToLoad
    waitForElementPresentcss=select[name="members_permissions-1-perms_reporting"] > option:nth-of-type(2)
    clickcss=select[name="members_permissions-1-perms_reporting"] > option:nth-of-type(2)
    waitForPageToLoad
    waitForElementPresentcss=input[type="submit"]
    clickcss=input[type="submit"]
    waitForPageToLoad
    waitForElementPresentcss=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading
    assertTextcss=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-headingcss=h3.usa-alert-heading *Success!*
    waitForElementPresentcss=.usa-alert-body > p:nth-of-type(2)css=.usa-alert-text
    assertTextcss=.usa-alert-body > p:nth-of-type(2)*You have successfully updated access permissions for members of Tatooine Energy Maintenance Systems.*css=.usa-alert-text*You have successfully updated access permissions for*
    diff --git a/uitests/New_App_Step_1.html b/uitests/New_App_Step_1.html index 05f25f84..83f70ab6 100644 --- a/uitests/New_App_Step_1.html +++ b/uitests/New_App_Step_1.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_App_Step_2.html b/uitests/New_App_Step_2.html index 3deecee8..50338788 100644 --- a/uitests/New_App_Step_2.html +++ b/uitests/New_App_Step_2.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_App_Step_2_-_Add_Env.html b/uitests/New_App_Step_2_-_Add_Env.html index a2ed4f8d..7450d2e3 100644 --- a/uitests/New_App_Step_2_-_Add_Env.html +++ b/uitests/New_App_Step_2_-_Add_Env.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_App_Step_3.html b/uitests/New_App_Step_3.html index 2371a8a4..f8e4948c 100644 --- a/uitests/New_App_Step_3.html +++ b/uitests/New_App_Step_3.html @@ -170,7 +170,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -640,28 +640,13 @@ Imported from: AT-AT CI - New App Step 1--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - -waitForElementPresent css=[name=environment_roles-0-role] type css=[name=environment_roles-0-role] -Basic Access +ADMIN waitForPageToLoad @@ -676,7 +661,7 @@ Imported from: AT-AT CI - New App Step 1--> type css=[name=environment_roles-1-role] -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/New_Portfolio.html b/uitests/New_Portfolio.html index ea3aee7d..cb3f3c26 100644 --- a/uitests/New_Portfolio.html +++ b/uitests/New_Portfolio.html @@ -96,7 +96,7 @@ assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_Portfolio_Member.html b/uitests/New_Portfolio_Member.html index 35c052df..ab4480ee 100644 --- a/uitests/New_Portfolio_Member.html +++ b/uitests/New_Portfolio_Member.html @@ -165,7 +165,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -275,22 +275,6 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.panel__content > p:nth-of-type(2) - - - -assertElementPresent -css=.panel__content > p:nth-of-type(2) - - - -waitForPageToLoad - - - - - -waitForElementPresent css=th.table-cell--third @@ -320,22 +304,6 @@ Imported from: AT-AT CI - New Portfolio--> - - -waitForElementPresent -css=button.usa-button.usa-button-primary - - - -assertText -css=button.usa-button.usa-button-primary -*Update* - - -waitForPageToLoad - - - waitForElementPresent css=a.usa-button.usa-button-secondary.add-new-button @@ -554,7 +522,9 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.usa-alert-body -*You have successfully invited Brandon Buchannan to the portfolio.* +*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.* diff --git a/uitests/Portfolio_Settings.html b/uitests/Portfolio_Settings.html index 4996fdbb..4c3ea61d 100644 --- a/uitests/Portfolio_Settings.html +++ b/uitests/Portfolio_Settings.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -204,21 +204,6 @@ Imported from: AT-AT CI - login--> waitForElementPresent -css=.panel__content > p:nth-of-type(2) - - - -assertElementPresent -css=.panel__content > p:nth-of-type(2) - - - -waitForPageToLoad - - - - -waitForElementPresent css=th.table-cell--third @@ -242,21 +227,6 @@ Imported from: AT-AT CI - login--> css=button.usa-button.usa-button-primary.usa-button-big Save Changes - -waitForPageToLoad - - - - -waitForElementPresent -css=button.usa-button.usa-button-primary - - - -assertText -css=button.usa-button.usa-button-primary -*Update* - diff --git a/uitests/Reports_-_Basics.html b/uitests/Reports_-_Basics.html index 84d65469..ecb8e962 100644 --- a/uitests/Reports_-_Basics.html +++ b/uitests/Reports_-_Basics.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Reports_-_Empty_State.html b/uitests/Reports_-_Empty_State.html index d0ae8235..448a3cb0 100644 --- a/uitests/Reports_-_Empty_State.html +++ b/uitests/Reports_-_Empty_State.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/Reports_-_Follow_Add_App_Button.html b/uitests/Reports_-_Follow_Add_App_Button.html index 1b4658bd..c1607b2f 100644 --- a/uitests/Reports_-_Follow_Add_App_Button.html +++ b/uitests/Reports_-_Follow_Add_App_Button.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -211,12 +211,12 @@ Imported from: AT-AT CI - Create New TO--> Imported from: AT-AT CI - Create New TO--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Reports_-_Follow_TO_link.html b/uitests/Reports_-_Follow_TO_link.html index 627838b2..7c3d2e6d 100644 --- a/uitests/Reports_-_Follow_TO_link.html +++ b/uitests/Reports_-_Follow_TO_link.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -211,12 +211,12 @@ Imported from: AT-AT CI - Create New TO--> Imported from: AT-AT CI - Create New TO--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary @@ -841,7 +841,7 @@ Imported from: AT-AT CI - Create New TO--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(1) > .summary-item__value--large -*$100,000.00* +*$800,000.00* waitForPageToLoad @@ -856,7 +856,7 @@ Imported from: AT-AT CI - Create New TO--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(2) > .summary-item__value--large -*$800,000.00* +*$100,000.00* diff --git a/uitests/Reports_-_with_TO,_App,_and_Environments.html b/uitests/Reports_-_with_TO,_App,_and_Environments.html index 9bb67a36..6fbc96e2 100644 --- a/uitests/Reports_-_with_TO,_App,_and_Environments.html +++ b/uitests/Reports_-_with_TO,_App,_and_Environments.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -583,12 +567,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Reports_-_with_expired_TO.html b/uitests/Reports_-_with_expired_TO.html index 8970b8b8..df76aa00 100644 --- a/uitests/Reports_-_with_expired_TO.html +++ b/uitests/Reports_-_with_expired_TO.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -583,12 +567,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Resend_App_Member_Invite.html b/uitests/Resend_App_Member_Invite.html index 2fb72573..def438b5 100644 --- a/uitests/Resend_App_Member_Invite.html +++ b/uitests/Resend_App_Member_Invite.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -626,12 +610,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.user-info > .usa-input.usa-input--validation--requiredField:nth-of-type(1) > input[id="first_name"][type="text"] +css=.user-info > .usa-input.usa-input--validation--name:nth-of-type(1) > input[id="first_name"][type="text"] assertText -css=.user-info > .usa-input.usa-input--validation--requiredField:nth-of-type(1) > input[id="first_name"][type="text"] +css=.user-info > .usa-input.usa-input--validation--name:nth-of-type(1) > input[id="first_name"][type="text"] *Brandon* @@ -641,12 +625,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] assertText -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] *Resend Invite* @@ -656,12 +640,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] click -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] @@ -671,28 +655,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading - - - -assertText -css=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading -*Application invitation resent* - - -waitForPageToLoad - - - - -waitForElementPresent css=.usa-alert.usa-alert-success > .usa-alert-body > .usa-alert-text assertText css=.usa-alert.usa-alert-success > .usa-alert-body > .usa-alert-text -*You have successfully resent the invite for Brandon Buchannan* +*jay+brandon@promptworks.com has been sent an invitation to access this Application* diff --git a/uitests/Resend_Portfolio_Member_Invite.html b/uitests/Resend_Portfolio_Member_Invite.html new file mode 100644 index 00000000..b369438f --- /dev/null +++ b/uitests/Resend_Portfolio_Member_Invite.html @@ -0,0 +1,639 @@ + + + + + + +Resend Portfolio Member Invite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Resend Portfolio Member Invite
    waitForPageToLoad
    open/login-dev?username=brandon
    waitForPageToLoad
    waitForElementPresentcss=a[href="/user"] > .topbar__link-label
    assertTextcss=a[href="/user"] > .topbar__link-label*Brandon Buchannan*
    waitForPageToLoad
    waitForElementPresentcss=a[href="/logout"] > .topbar__link-label
    clickcss=a[href="/logout"] > .topbar__link-label
    waitForPageToLoad
    waitForElementPresentcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading
    assertTextcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading*Logged out*
    waitForPageToLoad
    open/login-dev
    waitForPageToLoad
    waitForElementPresentcss=.home__content > h1
    assertTextcss=.home__content > h1JEDI Cloud Services
    waitForPageToLoad
    waitForElementPresentcss=a[href="/portfolios/new"]
    clickcss=a[href="/portfolios/new"]
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-header__name > h1
    assertTextcss=.portfolio-header__name > h1*New Portfolio*
    waitForPageToLoad
    waitForElementPresentcss=.sticky-cta-text > h3
    assertTextcss=.sticky-cta-text > h3*Name and Describe Portfolio*
    waitForPageToLoad
    waitForElementPresentcss=#name
    typecss=#nameTatooine Energy Maintenance Systems ${alphanumeric}
    waitForPageToLoad
    waitForElementPresentcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
    clickcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
    waitForPageToLoad
    waitForElementPresentcss=input[type="submit"]
    clickcss=input[type="submit"]
    waitForPageToLoad
    waitForElementPresentcss=.empty-state > h3
    assertTextcss=.empty-state > h3*You don't have any Applications yet*
    waitForPageToLoad
    waitForElementPresentcss=.icon.icon--cog > svg
    clickcss=.icon.icon--cog > svg
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-header__name > h1
    assertTextcss=.portfolio-header__name > h1*Tatooine Energy Maintenance Systems*
    waitForPageToLoad
    waitForElementPresentcss=th.table-cell--third
    assertElementPresentcss=th.table-cell--third
    waitForPageToLoad
    waitForElementPresentcss=button.usa-button.usa-button-primary.usa-button-big
    assertTextcss=button.usa-button.usa-button-primary.usa-button-bigSave Changes
    waitForPageToLoad
    waitForElementPresentcss=a.usa-button.usa-button-secondary.add-new-button
    clickcss=a.usa-button.usa-button-secondary.add-new-button
    waitForPageToLoad
    waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
    assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Add Manager*
    waitForPageToLoad
    waitForElementPresentcss=#user_data-first_name
    typecss=#user_data-first_nameBrandon
    waitForPageToLoad
    waitForElementPresentcss=#user_data-last_name
    typecss=#user_data-last_nameBuchannan
    waitForPageToLoad
    waitForElementPresentcss=#user_data-email
    typecss=#user_data-emailjay+brandon@promptworks.com
    waitForPageToLoad
    waitForElementPresentcss=#user_data-dod_id
    typecss=#user_data-dod_id3456789012
    waitForPageToLoad
    waitForElementPresentcss=input[type="button"]
    clickcss=input[type="button"]
    waitForPageToLoad
    waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
    assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Set Portfolio Permissions*
    waitForPageToLoad
    waitForElementPresentcss=#perms_app_mgmt-None
    clickcss=#perms_app_mgmt-None
    waitForPageToLoad
    waitForElementPresentcss=#perms_funding-None
    clickcss=#perms_funding-None
    waitForPageToLoad
    waitForElementPresentcss=#perms_reporting-None
    clickcss=#perms_reporting-None
    waitForPageToLoad
    waitForElementPresentcss=#perms_portfolio_mgmt-None
    typecss=#perms_portfolio_mgmt-Noneedit_portfolio_admin
    waitForPageToLoad
    waitForElementPresentcss=input[type="submit"].action-group__action
    clickcss=input[type="submit"].action-group__action
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below
    assertTextcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below*invite pending*
    waitForPageToLoad
    waitForElementPresentcss=.usa-alert-body
    assertTextcss=.usa-alert-body*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.*
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16 > path
    clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16 > path
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(2)
    clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(2)
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-content > div:nth-of-type(4) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1
    assertTextcss=.portfolio-content > div:nth-of-type(4) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1*Verify Member Information*
    waitForPageToLoad
    waitForElementPresentcss=.action-group__action.usa-button
    clickcss=.action-group__action.usa-button
    waitForPageToLoad
    waitForElementPresentcss=.usa-alert-text
    assertTextcss=.usa-alert-text*jay+brandon@promptworks.com has been sent an invitation to access this Portfolio*
    + + \ No newline at end of file diff --git a/uitests/Revoke_App_Member_Invite.html b/uitests/Revoke_App_Member_Invite.html index c0055261..847afe97 100644 --- a/uitests/Revoke_App_Member_Invite.html +++ b/uitests/Revoke_App_Member_Invite.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/Revoke_Environment_Access.html b/uitests/Revoke_Environment_Access.html index abb4d1dc..6506733b 100644 --- a/uitests/Revoke_Environment_Access.html +++ b/uitests/Revoke_Environment_Access.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -581,12 +565,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.accordion-table__item-toggle-content.app-member-menu__toggle > a:nth-of-type(1) +css=.accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1) click -css=.accordion-table__item-toggle-content.app-member-menu__toggle > a:nth-of-type(1) +css=.accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1) @@ -671,12 +655,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.action-group > input[type="submit"].usa-button.usa-button-primary.action-group__action +css=.action-group > input[type="submit"].usa-button.action-group__action click -css=.action-group > input[type="submit"].usa-button.usa-button-primary.action-group__action +css=.action-group > input[type="submit"].usa-button.action-group__action @@ -686,12 +670,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(1) > .env-role__role +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(1) > .env-role__role assertText -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(1) > .env-role__role +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(1) > .env-role__role *None* diff --git a/uitests/Revoke_Portfolio_Member_Invite.html b/uitests/Revoke_Portfolio_Member_Invite.html new file mode 100644 index 00000000..947bf526 --- /dev/null +++ b/uitests/Revoke_Portfolio_Member_Invite.html @@ -0,0 +1,624 @@ + + + + + + +Revoke Portfolio Member Invite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Revoke Portfolio Member Invite
    waitForPageToLoad
    open/login-dev?username=brandon
    waitForPageToLoad
    waitForElementPresentcss=a[href="/user"] > .topbar__link-label
    assertTextcss=a[href="/user"] > .topbar__link-label*Brandon Buchannan*
    waitForPageToLoad
    waitForElementPresentcss=a[href="/logout"] > .topbar__link-label
    clickcss=a[href="/logout"] > .topbar__link-label
    waitForPageToLoad
    waitForElementPresentcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading
    assertTextcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading*Logged out*
    waitForPageToLoad
    open/login-dev
    waitForPageToLoad
    waitForElementPresentcss=.home__content > h1
    assertTextcss=.home__content > h1JEDI Cloud Services
    waitForPageToLoad
    waitForElementPresentcss=a[href="/portfolios/new"]
    clickcss=a[href="/portfolios/new"]
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-header__name > h1
    assertTextcss=.portfolio-header__name > h1*New Portfolio*
    waitForPageToLoad
    waitForElementPresentcss=.sticky-cta-text > h3
    assertTextcss=.sticky-cta-text > h3*Name and Describe Portfolio*
    waitForPageToLoad
    waitForElementPresentcss=#name
    typecss=#nameTatooine Energy Maintenance Systems ${alphanumeric}
    waitForPageToLoad
    waitForElementPresentcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
    clickcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
    waitForPageToLoad
    waitForElementPresentcss=input[type="submit"]
    clickcss=input[type="submit"]
    waitForPageToLoad
    waitForElementPresentcss=.empty-state > h3
    assertTextcss=.empty-state > h3*You don't have any Applications yet*
    waitForPageToLoad
    waitForElementPresentcss=.icon.icon--cog > svg
    clickcss=.icon.icon--cog > svg
    waitForPageToLoad
    waitForElementPresentcss=.portfolio-header__name > h1
    assertTextcss=.portfolio-header__name > h1*Tatooine Energy Maintenance Systems*
    waitForPageToLoad
    waitForElementPresentcss=th.table-cell--third
    assertElementPresentcss=th.table-cell--third
    waitForPageToLoad
    waitForElementPresentcss=button.usa-button.usa-button-primary.usa-button-big
    assertTextcss=button.usa-button.usa-button-primary.usa-button-bigSave Changes
    waitForPageToLoad
    waitForElementPresentcss=a.usa-button.usa-button-secondary.add-new-button
    clickcss=a.usa-button.usa-button-secondary.add-new-button
    waitForPageToLoad
    waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
    assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Add Manager*
    waitForPageToLoad
    waitForElementPresentcss=#user_data-first_name
    typecss=#user_data-first_nameBrandon
    waitForPageToLoad
    waitForElementPresentcss=#user_data-last_name
    typecss=#user_data-last_nameBuchannan
    waitForPageToLoad
    waitForElementPresentcss=#user_data-email
    typecss=#user_data-emailjay+brandon@promptworks.com
    waitForPageToLoad
    waitForElementPresentcss=#user_data-dod_id
    typecss=#user_data-dod_id3456789012
    waitForPageToLoad
    waitForElementPresentcss=input[type="button"]
    clickcss=input[type="button"]
    waitForPageToLoad
    waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
    assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Set Portfolio Permissions*
    waitForPageToLoad
    waitForElementPresentcss=#perms_app_mgmt-None
    clickcss=#perms_app_mgmt-None
    waitForPageToLoad
    waitForElementPresentcss=#perms_funding-None
    clickcss=#perms_funding-None
    waitForPageToLoad
    waitForElementPresentcss=#perms_reporting-None
    clickcss=#perms_reporting-None
    waitForPageToLoad
    waitForElementPresentcss=#perms_portfolio_mgmt-None
    typecss=#perms_portfolio_mgmt-Noneedit_portfolio_admin
    waitForPageToLoad
    waitForElementPresentcss=input[type="submit"].action-group__action
    clickcss=input[type="submit"].action-group__action
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below
    assertTextcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below*invite pending*
    waitForPageToLoad
    waitForElementPresentcss=.usa-alert-body
    assertTextcss=.usa-alert-body*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.*
    waitForPageToLoad
    waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
    clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
    waitForPageToLoad
    waitForElementPresentcss=.accordion-table__item-toggle-content > a:nth-of-type(3)
    clickcss=.accordion-table__item-toggle-content > a:nth-of-type(3)
    waitForPageToLoad
    waitForElementPresentcss=form[action] > h1
    assertTextcss=form[action] > h1*Revoke Invite*
    waitForPageToLoad
    waitForElementPresentcss=button[type="submit"].action-group__action
    clickcss=button[type="submit"].action-group__action
    + + \ No newline at end of file diff --git a/uitests/TO_Index_(Landing)_Page_-_Empty_State.html b/uitests/TO_Index_(Landing)_Page_-_Empty_State.html index 15dae03e..bc0e7d5a 100644 --- a/uitests/TO_Index_(Landing)_Page_-_Empty_State.html +++ b/uitests/TO_Index_(Landing)_Page_-_Empty_State.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -237,21 +237,6 @@ Imported from: AT-AT CI - login--> css=.empty-state__footer > a.usa-button.usa-button-primary - -waitForPageToLoad - - - - -waitForElementPresent -css=.sticky-cta-buttons > a.usa-button.usa-button-primary - - - -assertElementPresent -css=.sticky-cta-buttons > a.usa-button.usa-button-primary - - diff --git a/uitests/TO_Index_with_Draft_TO.html b/uitests/TO_Index_with_Draft_TO.html index 77a6e543..6e34eb31 100644 --- a/uitests/TO_Index_with_Draft_TO.html +++ b/uitests/TO_Index_with_Draft_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_TO.html b/uitests/TO_Index_with_TO.html index 8997f6b0..8065848c 100644 --- a/uitests/TO_Index_with_TO.html +++ b/uitests/TO_Index_with_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_Unsigned_TO.html b/uitests/TO_Index_with_Unsigned_TO.html index 3b3f1786..49998dbe 100644 --- a/uitests/TO_Index_with_Unsigned_TO.html +++ b/uitests/TO_Index_with_Unsigned_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_expired_TO.html b/uitests/TO_Index_with_expired_TO.html index 9bccba7e..47459209 100644 --- a/uitests/TO_Index_with_expired_TO.html +++ b/uitests/TO_Index_with_expired_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_future_TO.html b/uitests/TO_Index_with_future_TO.html index 3401bd3e..d04b0530 100644 --- a/uitests/TO_Index_with_future_TO.html +++ b/uitests/TO_Index_with_future_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Step_1.html b/uitests/TO_Step_1.html index 53e0c1b2..ac031608 100644 --- a/uitests/TO_Step_1.html +++ b/uitests/TO_Step_1.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_2.html b/uitests/TO_Step_2.html index 1bd56535..464d0f51 100644 --- a/uitests/TO_Step_2.html +++ b/uitests/TO_Step_2.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_3.html b/uitests/TO_Step_3.html index 84ba584d..ffdf0ae4 100644 --- a/uitests/TO_Step_3.html +++ b/uitests/TO_Step_3.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_3_-_Add_CLIN.html b/uitests/TO_Step_3_-_Add_CLIN.html index c5ab24a1..4617ebdd 100644 --- a/uitests/TO_Step_3_-_Add_CLIN.html +++ b/uitests/TO_Step_3_-_Add_CLIN.html @@ -116,7 +116,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_4.html b/uitests/TO_Step_4.html index 1968c48d..8bf2f648 100644 --- a/uitests/TO_Step_4.html +++ b/uitests/TO_Step_4.html @@ -116,7 +116,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -748,7 +748,7 @@ Imported from: AT-AT CI - TO Step 2--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(1) > .summary-item__value--large -*$100,000.00* +*$800,000.00* waitForPageToLoad @@ -763,7 +763,7 @@ Imported from: AT-AT CI - TO Step 2--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(2) > .summary-item__value--large -*$800,000.00* +*$100,000.00* waitForPageToLoad diff --git a/uitests/TO_Step_5.html b/uitests/TO_Step_5.html index d5d5d7ee..4eb53ab9 100644 --- a/uitests/TO_Step_5.html +++ b/uitests/TO_Step_5.html @@ -121,7 +121,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -791,7 +791,7 @@ Imported from: AT-AT CI - TO Step 3--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(1) > .summary-item__value--large -*$100,000.00* +*$800,000.00* waitForPageToLoad @@ -807,7 +807,7 @@ Imported from: AT-AT CI - TO Step 3--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(2) > .summary-item__value--large -*$800,000.00* +*$100,000.00* waitForPageToLoad