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 5a6a9253..84a9238c 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -1,15 +1,14 @@ +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 .cloud_provider_interface import CloudProviderInterface from .exceptions import AuthenticationException from .models import ( + ApplicationCSPPayload, + ApplicationCSPResult, BillingInstructionCSPPayload, BillingInstructionCSPResult, BillingProfileCreationCSPPayload, @@ -18,6 +17,8 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + KeyVaultCredentials, + ManagementGroupCSPResponse, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -26,6 +27,7 @@ from .models import ( TenantCSPResult, ) from .policy import AzurePolicyManager +from atst.utils import sha256_hex AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI @@ -47,6 +49,7 @@ 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 import adal @@ -85,7 +88,7 @@ class AzureCloudProvider(CloudProviderInterface): def set_secret(self, secret_key, secret_value): credential = self._get_client_secret_credential_obj({}) - secret_client = self.secrets.SecretClient( + secret_client = self.sdk.secrets.SecretClient( vault_url=self.vault_url, credential=credential, ) try: @@ -98,7 +101,7 @@ class AzureCloudProvider(CloudProviderInterface): def get_secret(self, secret_key): credential = self._get_client_secret_credential_obj({}) - secret_client = self.secrets.SecretClient( + secret_client = self.sdk.secrets.SecretClient( vault_url=self.vault_url, credential=credential, ) try: @@ -109,9 +112,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: @@ -128,7 +129,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 @@ -167,16 +168,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=AZURE_MANAGEMENT_API, ) + 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, ): @@ -198,6 +209,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( @@ -290,6 +304,7 @@ class AzureCloudProvider(CloudProviderInterface): sp_token = self._get_sp_token(payload.creds) 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) @@ -626,3 +641,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 a6c338b5..10d62e15 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -17,6 +17,9 @@ from .exceptions import ( UnknownServerException, ) from .models import ( + AZURE_MGMNT_PATH, + ApplicationCSPPayload, + ApplicationCSPResult, BillingInstructionCSPPayload, BillingInstructionCSPResult, BillingProfileCreationCSPPayload, @@ -340,3 +343,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 c6bf0ede..b4ff9232 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 @@ -232,3 +234,110 @@ class BillingInstructionCSPResult(AliasModel): fields = { "reported_clin_name": "name", } + + +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/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/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/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..bd407607 100644 --- a/terraform/secrets-tool/README.md +++ b/terraform/secrets-tool/README.md @@ -15,7 +15,7 @@ With both usernames and passwords generated, the application only needs to make Ex. ``` { - 'postgres_root_user': 'EzTEzSNLKQPHuJyPdPloIDCAlcibbl', + 'postgres_root_user': 'EzTEzSNLKQPHuJyPdPloIDCAlcibbl', 'postgres_root_password': "2+[A@E4:C=ubb/#R#'n 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 7fa67667..4f37848e 100644 --- a/tests/mock_azure.py +++ b/tests/mock_azure.py @@ -72,6 +72,12 @@ def mock_secrets(): return Mock(spec=secrets) +def mock_identity(): + import azure.identity as identity + + return Mock(spec=identity) + + class MockAzureSDK(object): def __init__(self): from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD @@ -88,6 +94,7 @@ class MockAzureSDK(object): self.requests = mock_requests() # may change to a JEDI cloud self.cloud = AZURE_PUBLIC_CLOUD + 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 4855ad83..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 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 b3caebc7..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. @@ -558,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