diff --git a/alembic/versions/014e4bceb947_add_soft_delete_to_application_and_.py b/alembic/versions/014e4bceb947_add_soft_delete_to_application_and_.py new file mode 100644 index 00000000..2b0a87b0 --- /dev/null +++ b/alembic/versions/014e4bceb947_add_soft_delete_to_application_and_.py @@ -0,0 +1,35 @@ +"""add soft delete to application and environment resources and roles + +Revision ID: 014e4bceb947 +Revises: 32438a35cfb5 +Create Date: 2019-04-10 09:40:37.688157 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import expression + + +# revision identifiers, used by Alembic. +revision = '014e4bceb947' +down_revision = '32438a35cfb5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('application_roles', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False)) + op.add_column('applications', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False)) + op.add_column('environment_roles', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False)) + op.add_column('environments', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('environments', 'deleted') + op.drop_column('environment_roles', 'deleted') + op.drop_column('applications', 'deleted') + op.drop_column('application_roles', 'deleted') + # ### end Alembic commands ### diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 30884c59..ebb37606 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -1,3 +1,5 @@ +from sqlalchemy.orm.exc import NoResultFound + from atst.database import db from atst.domain.environments import Environments from atst.domain.exceptions import NotFoundError @@ -23,7 +25,9 @@ class Applications(object): def get(cls, application_id): try: application = ( - db.session.query(Application).filter_by(id=application_id).one() + db.session.query(Application) + .filter_by(id=application_id, deleted=False) + .one() ) except NoResultFound: raise NotFoundError("application") @@ -63,3 +67,17 @@ class Applications(object): db.session.commit() return application + + @classmethod + def delete(cls, application): + for env in application.environments: + Environments.delete(env) + + application.deleted = True + + for role in application.roles: + role.deleted = True + db.session.add(role) + + db.session.add(application) + db.session.commit() diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index 529897ad..c498a1fe 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -8,6 +8,12 @@ class CloudProviderInterface: """ raise NotImplementedError() + def delete_application(self, cloud_id): # pragma: no cover + """Delete an application in the cloud with the provided cloud_id. Returns + True for success or raises an error. + """ + raise NotImplementedError() + def create_user(self, user): # pragma: no cover """Create an account in the CSP for specified user. Returns the ID of the created user. @@ -49,6 +55,11 @@ class MockCloudProvider(CloudProviderInterface): cloud.""" return uuid4().hex + def delete_application(self, name): + """Returns an id that represents what would be an application in the + cloud.""" + return True + def create_user(self, user): """Returns an id that represents what would be an user in the cloud.""" return uuid4().hex diff --git a/atst/domain/environments.py b/atst/domain/environments.py index 9b0fa9b5..5e7102b1 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -51,7 +51,11 @@ class Environments(object): @classmethod def get(cls, environment_id): try: - env = db.session.query(Environment).filter_by(id=environment_id).one() + env = ( + db.session.query(Environment) + .filter_by(id=environment_id, deleted=False) + .one() + ) except NoResultFound: raise NotFoundError("environment") @@ -94,3 +98,19 @@ class Environments(object): @classmethod def revoke_access(cls, environment, target_user): EnvironmentRoles.delete(environment.id, target_user.id) + + @classmethod + def delete(cls, environment, commit=False): + environment.deleted = True + db.session.add(environment) + + for role in environment.roles: + role.deleted = True + db.session.add(role) + + if commit: + db.session.commit() + + app.csp.cloud.delete_application(environment.cloud_id) + + return environment diff --git a/atst/domain/permission_sets.py b/atst/domain/permission_sets.py index 4d02682a..52131a20 100644 --- a/atst/domain/permission_sets.py +++ b/atst/domain/permission_sets.py @@ -86,6 +86,7 @@ _PORTFOLIO_APP_MGMT_PERMISSION_SETS = [ "permissions": [ Permissions.EDIT_APPLICATION, Permissions.CREATE_APPLICATION, + Permissions.DELETE_APPLICATION, Permissions.EDIT_APPLICATION_MEMBER, Permissions.CREATE_APPLICATION_MEMBER, Permissions.EDIT_ENVIRONMENT, diff --git a/atst/models/application.py b/atst/models/application.py index d10bd773..d06f20b7 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -6,7 +6,9 @@ from atst.models.types import Id from atst.models import mixins -class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin): +class Application( + Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin +): __tablename__ = "applications" id = Id() @@ -15,7 +17,11 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin): portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False) portfolio = relationship("Portfolio") - environments = relationship("Environment", back_populates="application") + environments = relationship( + "Environment", + back_populates="application", + primaryjoin="and_(Environment.application_id==Application.id, Environment.deleted==False)", + ) roles = relationship("ApplicationRole") @property @@ -34,3 +40,7 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin): return "".format( self.name, self.description, self.portfolio.name, self.id ) + + @property + def history(self): + return self.get_changes() diff --git a/atst/models/application_role.py b/atst/models/application_role.py index 054006cc..9b8fe05d 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -26,7 +26,11 @@ application_roles_permission_sets = Table( class ApplicationRole( - Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin + Base, + mixins.TimestampsMixin, + mixins.AuditableMixin, + mixins.PermissionsMixin, + mixins.DeletableMixin, ): __tablename__ = "application_roles" @@ -51,6 +55,10 @@ class ApplicationRole( self.application.name, self.user_id, self.id, self.permissions ) + @property + def history(self): + return self.get_changes() + Index( "application_role_user_application", diff --git a/atst/models/environment.py b/atst/models/environment.py index a520e787..410d9e91 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -6,7 +6,9 @@ from atst.models.types import Id from atst.models import mixins -class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin): +class Environment( + Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin +): __tablename__ = "environments" id = Id() @@ -44,3 +46,7 @@ class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin): self.application.portfolio.name, self.id, ) + + @property + def history(self): + return self.get_changes() diff --git a/atst/models/environment_role.py b/atst/models/environment_role.py index 37207e59..55cf742e 100644 --- a/atst/models/environment_role.py +++ b/atst/models/environment_role.py @@ -13,7 +13,9 @@ class CSPRole(Enum): TECHNICAL_READ = "Technical Read-only" -class EnvironmentRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): +class EnvironmentRole( + Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin +): __tablename__ = "environment_roles" id = types.Id() diff --git a/atst/models/mixins/__init__.py b/atst/models/mixins/__init__.py index ebc2b362..54c85d61 100644 --- a/atst/models/mixins/__init__.py +++ b/atst/models/mixins/__init__.py @@ -1,3 +1,4 @@ from .timestamps import TimestampsMixin from .auditable import AuditableMixin from .permissions import PermissionsMixin +from .deletable import DeletableMixin diff --git a/atst/models/mixins/deletable.py b/atst/models/mixins/deletable.py new file mode 100644 index 00000000..a8e430ab --- /dev/null +++ b/atst/models/mixins/deletable.py @@ -0,0 +1,6 @@ +from sqlalchemy import Column, Boolean +from sqlalchemy.sql import expression + + +class DeletableMixin(object): + deleted = Column(Boolean, nullable=False, server_default=expression.false()) diff --git a/atst/models/permissions.py b/atst/models/permissions.py index cb03020d..98f25b36 100644 --- a/atst/models/permissions.py +++ b/atst/models/permissions.py @@ -8,6 +8,7 @@ class Permissions(object): VIEW_APPLICATION = "view_application" EDIT_APPLICATION = "edit_application" CREATE_APPLICATION = "create_application" + DELETE_APPLICATION = "delete_application" VIEW_APPLICATION_MEMBER = "view_application_member" EDIT_APPLICATION_MEMBER = "edit_application_member" CREATE_APPLICATION_MEMBER = "create_application_member" diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 599de3e6..23c19482 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -16,7 +16,11 @@ class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin): name = Column(String) defense_component = Column(String) # Department of Defense Component - applications = relationship("Application", back_populates="portfolio") + applications = relationship( + "Application", + back_populates="portfolio", + primaryjoin="and_(Application.portfolio_id==Portfolio.id, Application.deleted==False)", + ) roles = relationship("PortfolioRole") task_orders = relationship("TaskOrder") diff --git a/atst/models/user.py b/atst/models/user.py index 971f0a94..a5379698 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -25,7 +25,11 @@ class User( permission_sets = relationship("PermissionSet", secondary=users_permission_sets) portfolio_roles = relationship("PortfolioRole", backref="user") - application_roles = relationship("ApplicationRole", backref="user") + application_roles = relationship( + "ApplicationRole", + backref="user", + primaryjoin="and_(ApplicationRole.user_id==User.id, ApplicationRole.deleted==False)", + ) email = Column(String, unique=True) dod_id = Column(String, unique=True, nullable=False) diff --git a/atst/routes/portfolios/applications.py b/atst/routes/portfolios/applications.py index 20603b3c..955683dd 100644 --- a/atst/routes/portfolios/applications.py +++ b/atst/routes/portfolios/applications.py @@ -15,6 +15,7 @@ from atst.domain.portfolios import Portfolios from atst.forms.application import NewApplicationForm, ApplicationForm from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.models.permissions import Permissions +from atst.utils.flash import formatted_flash as flash @portfolios_bp.route("/portfolios//applications") @@ -118,3 +119,18 @@ def access_environment(portfolio_id, environment_id): token = app.csp.cloud.get_access_token(env_role) return redirect(url_for("atst.csp_environment_access", token=token)) + + +@portfolios_bp.route( + "/portfolios//applications//delete", methods=["POST"] +) +@user_can(Permissions.DELETE_APPLICATION, message="delete application") +def delete_application(portfolio_id, application_id): + application = Applications.get(application_id) + Applications.delete(application) + + flash("application_deleted", application_name=application.name) + + return redirect( + url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) + ) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index d2b42979..7970cc03 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -148,6 +148,14 @@ MESSAGES = { """, "category": "success", }, + "application_deleted": { + "title_template": translate("flash.success"), + "message_template": """ + {{ "flash.application.deleted" | translate({"application_name": application_name}) }} + Undo. + """, + "category": "success", + }, } diff --git a/deploy/kubernetes/test/atst-envvars-configmap.yml b/deploy/kubernetes/test/atst-envvars-configmap.yml index bc82c333..1ac4cb03 100644 --- a/deploy/kubernetes/test/atst-envvars-configmap.yml +++ b/deploy/kubernetes/test/atst-envvars-configmap.yml @@ -12,3 +12,4 @@ data: RQ_QUEUES: atat-test CRL_STORAGE_PROVIDER: CLOUDFILES LOG_JSON: "true" + DEBUG: "false" diff --git a/js/components/delete_confirmation.js b/js/components/delete_confirmation.js new file mode 100644 index 00000000..cd710de3 --- /dev/null +++ b/js/components/delete_confirmation.js @@ -0,0 +1,15 @@ +export default { + name: 'delete-confirmation', + + data: function() { + return { + deleteText: '', + } + }, + + computed: { + valid: function() { + return this.deleteText.toLowerCase() === 'delete' + }, + }, +} diff --git a/js/index.js b/js/index.js index dee29320..44461fc2 100644 --- a/js/index.js +++ b/js/index.js @@ -35,6 +35,7 @@ import DateSelector from './components/date_selector' import SidenavToggler from './components/sidenav_toggler' import KoReview from './components/forms/ko_review' import BaseForm from './components/forms/base_form' +import DeleteConfirmation from './components/delete_confirmation' Vue.config.productionTip = false @@ -72,6 +73,7 @@ const app = new Vue({ SidenavToggler, KoReview, BaseForm, + DeleteConfirmation, }, mounted: function() { diff --git a/styles/components/_modal.scss b/styles/components/_modal.scss index dd68fd64..b2c31798 100644 --- a/styles/components/_modal.scss +++ b/styles/components/_modal.scss @@ -8,7 +8,7 @@ body { .modal { position: fixed; - z-index: 3; + z-index: 6; left: 0; right: 0; top: 0; diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 906e14a6..06b9f416 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -213,12 +213,6 @@ border-top: 0; padding: 3 * $gap 2 * $gap; - .usa-button-secondary { - color: $color-red; - background-color: $color-red-lightest; - box-shadow: inset 0 0 0 1px $color-red; - } - .usa-button-disabled { color: $color-gray-medium; background-color: $color-gray-lightest; @@ -271,10 +265,6 @@ height: 4rem; } - .usa-button-danger { - background: $color-red; - } - select { padding-left: 1.2rem } diff --git a/styles/elements/_buttons.scss b/styles/elements/_buttons.scss index 59a6481e..af167ade 100644 --- a/styles/elements/_buttons.scss +++ b/styles/elements/_buttons.scss @@ -11,3 +11,23 @@ button, opacity: 0.75; } } + +.button-danger { + background: $color-red; + + &:hover { + background-color: $color-red-darkest; + } +} + +.button-danger-outline, input[type="button"].button-danger-outline { + color: $color-red; + background-color: $color-red-lightest; + box-shadow: inset 0 0 0 1px $color-red; + + &:hover { + color: white; + background-color: $color-red-darkest; + box-shadow: inset 0 0 0 1px $color-red-darkest; + } +} diff --git a/templates/components/alert.html b/templates/components/alert.html index b510457e..d6834f0d 100644 --- a/templates/components/alert.html +++ b/templates/components/alert.html @@ -21,26 +21,25 @@ } } %} -
- {{ Icon(levels.get(level).get('icon'), classes='alert__icon icon--large') }} +
-
+
{% if vue_template %} -

+

{% else %} -

{{title}}

+

{{title}}

{% endif %} {% if message %} -
{{ message | safe }}
+
{{ message | safe }}
{% endif %} {% if caller %} -
{{ caller() }}
+
{{ caller() }}
{% endif %} {% if fragment %} -
+
{% include fragment %}
{% endif %} diff --git a/templates/fragments/admin/members_edit.html b/templates/fragments/admin/members_edit.html index d2f9889f..8f5f8fd2 100644 --- a/templates/fragments/admin/members_edit.html +++ b/templates/fragments/admin/members_edit.html @@ -12,7 +12,7 @@ {% elif ppoc %} {% set archive_button_class = 'usa-button-disabled' %} {% else %} - {% set archive_button_class = 'usa-button-secondary' %} + {% set archive_button_class = 'button-danger-outline' %} {% endif %} diff --git a/templates/portfolios/applications/edit.html b/templates/portfolios/applications/edit.html index db842f80..602a42f5 100644 --- a/templates/portfolios/applications/edit.html +++ b/templates/portfolios/applications/edit.html @@ -1,7 +1,9 @@ {% extends "portfolios/applications/base.html" %} +{% from "components/alert.html" import Alert %} {% from "components/text_input.html" import TextInput %} {% from "components/icon.html" import Icon %} +{% from "components/modal.html" import Modal %} {% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %} @@ -14,6 +16,58 @@
{% include "fragments/applications/edit_application_form.html" %} + {{ form.csrf_token }} +

+ {{ "fragments.edit_application_form.explain" | translate }} +

+
+
+ {{ TextInput(form.name) }} +
+
+ {% if user_can(permissions.DELETE_APPLICATION) %} +
+ + +
+ {% endif %} +
+
+
+
+ {{ TextInput(form.description, paragraph=True) }} +
+
+ +
+
+

{{ 'portfolios.applications.environments_heading' | translate }}

+

+ {{ 'portfolios.applications.environments_description' | translate }} +

+
+ +
    + {% for environment in application.environments %} +
  • +
    + + +
    +
  • + {% endfor %} +
+
+ {% if user_can(permissions.DELETE_APPLICATION) %} + {% call Modal(name="delete-application") %} +

{{ "portfolios.applications.delete.header" | translate }}

+ + {{ + Alert( + title="portfolios.applications.delete.alert.title" | translate, + message="portfolios.applications.delete.alert.message" | translate, + level="warning" + ) + }} + + +
+
+ + +
+
+
+ {{ form.csrf_token }} + +
+ +
+
+
+ {% endcall %} + {% endif %} {% endblock %} diff --git a/templates/portfolios/applications/index.html b/templates/portfolios/applications/index.html index 0ecddde4..dab1a214 100644 --- a/templates/portfolios/applications/index.html +++ b/templates/portfolios/applications/index.html @@ -8,6 +8,7 @@ {% block portfolio_content %}
+ {% include "fragments/flash.html" %}
Applications
diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index 98dde0e2..e955ed4e 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -1,6 +1,15 @@ +import pytest + from atst.domain.applications import Applications -from tests.factories import UserFactory, PortfolioFactory -from atst.domain.portfolios import Portfolios +from atst.domain.exceptions import NotFoundError + +from tests.factories import ( + ApplicationFactory, + ApplicationRoleFactory, + UserFactory, + PortfolioFactory, + EnvironmentFactory, +) def test_create_application_with_multiple_environments(): @@ -53,3 +62,30 @@ def test_can_only_update_name_and_description(): assert application.description == "a new application" assert len(application.environments) == 1 assert application.environments[0].name == env_name + + +def test_get_excludes_deleted(): + app = ApplicationFactory.create(deleted=True) + with pytest.raises(NotFoundError): + Applications.get(app.id) + + +def test_delete_application(session): + app = ApplicationFactory.create() + app_role = ApplicationRoleFactory.create(user=UserFactory.create(), application=app) + env1 = EnvironmentFactory.create(application=app) + env2 = EnvironmentFactory.create(application=app) + assert not app.deleted + assert not env1.deleted + assert not env2.deleted + assert not app_role.deleted + + Applications.delete(app) + + assert app.deleted + assert env1.deleted + assert env2.deleted + assert app_role.deleted + + # changes are flushed + assert not session.dirty diff --git a/tests/domain/test_environments.py b/tests/domain/test_environments.py index dee03fb6..b572a7e5 100644 --- a/tests/domain/test_environments.py +++ b/tests/domain/test_environments.py @@ -1,8 +1,17 @@ +import pytest + from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.domain.portfolio_roles import PortfolioRoles +from atst.domain.exceptions import NotFoundError -from tests.factories import ApplicationFactory, UserFactory, PortfolioFactory +from tests.factories import ( + ApplicationFactory, + UserFactory, + PortfolioFactory, + EnvironmentFactory, + EnvironmentRoleFactory, +) def test_create_environments(): @@ -186,3 +195,29 @@ def test_get_scoped_environments(db): application2_envs = Environments.for_user(developer, portfolio.applications[1]) assert [env.name for env in application2_envs] == ["application2 staging"] + + +def test_get_excludes_deleted(): + env = EnvironmentFactory.create( + deleted=True, application=ApplicationFactory.create() + ) + with pytest.raises(NotFoundError): + Environments.get(env.id) + + +def test_delete_environment(session): + env = EnvironmentFactory.create(application=ApplicationFactory.create()) + env_role = EnvironmentRoleFactory.create(user=UserFactory.create(), environment=env) + assert not env.deleted + assert not env_role.deleted + Environments.delete(env) + assert env.deleted + assert env_role.deleted + # did not flush + assert session.dirty + + Environments.delete(env, commit=True) + assert env.deleted + assert env_role.deleted + # flushed the change + assert not session.dirty diff --git a/tests/factories.py b/tests/factories.py index f9b5af17..29dc7fa0 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -105,7 +105,7 @@ class PortfolioFactory(Base): class Meta: model = Portfolio - name = factory.Faker("name") + name = factory.Faker("domain_word") defense_component = factory.LazyFunction(random_service_branch) @classmethod @@ -157,7 +157,7 @@ class ApplicationFactory(Base): model = Application portfolio = factory.SubFactory(PortfolioFactory) - name = factory.Faker("name") + name = factory.Faker("domain_word") description = "A test application" @classmethod @@ -192,6 +192,8 @@ class EnvironmentFactory(Base): class Meta: model = Environment + name = factory.Faker("domain_word") + @classmethod def _create(cls, model_class, *args, **kwargs): with_members = kwargs.pop("members", []) diff --git a/tests/models/test_application.py b/tests/models/test_application.py index d7bf564b..d90e4cd6 100644 --- a/tests/models/test_application.py +++ b/tests/models/test_application.py @@ -1,4 +1,10 @@ -from tests.factories import ApplicationFactory, ApplicationRoleFactory +from atst.models import AuditEvent + +from tests.factories import ( + ApplicationFactory, + ApplicationRoleFactory, + EnvironmentFactory, +) def test_application_num_users(): @@ -9,3 +15,26 @@ def test_application_num_users(): ApplicationRoleFactory.create(application=application) assert application.num_users == 1 + + +def test_application_environments_excludes_deleted(): + app = ApplicationFactory.create() + env = EnvironmentFactory.create(application=app) + EnvironmentFactory.create(application=app, deleted=True) + assert len(app.environments) == 1 + assert app.environments[0].id == env.id + + +def test_audit_event_for_application_deletion(session): + app = ApplicationFactory.create() + app.deleted = True + session.add(app) + session.commit() + + update_event = ( + session.query(AuditEvent) + .filter(AuditEvent.resource_id == app.id, AuditEvent.action == "update") + .one() + ) + assert update_event.changed_state.get("deleted") + assert update_event.changed_state["deleted"] == [False, True] diff --git a/tests/models/test_environments.py b/tests/models/test_environments.py index 17a32ec8..bccfdb94 100644 --- a/tests/models/test_environments.py +++ b/tests/models/test_environments.py @@ -1,6 +1,13 @@ +from atst.models import AuditEvent from atst.domain.environments import Environments from atst.domain.applications import Applications -from tests.factories import PortfolioFactory, UserFactory + +from tests.factories import ( + PortfolioFactory, + UserFactory, + EnvironmentFactory, + ApplicationFactory, +) def test_add_user_to_environment(): @@ -15,3 +22,20 @@ def test_add_user_to_environment(): dev_environment = Environments.add_member(dev_environment, developer, "developer") assert developer in dev_environment.users + + +def test_audit_event_for_environment_deletion(session): + env = EnvironmentFactory.create(application=ApplicationFactory.create()) + env.deleted = True + session.add(env) + session.commit() + + update_event = ( + session.query(AuditEvent) + .filter(AuditEvent.resource_id == env.id, AuditEvent.action == "update") + .one() + ) + assert update_event.changed_state.get("deleted") + before, after = update_event.changed_state["deleted"] + assert not before + assert after diff --git a/tests/models/test_portfolio.py b/tests/models/test_portfolio.py new file mode 100644 index 00000000..613b6435 --- /dev/null +++ b/tests/models/test_portfolio.py @@ -0,0 +1,9 @@ +from tests.factories import ApplicationFactory, PortfolioFactory + + +def test_portfolio_applications_excludes_deleted(): + portfolio = PortfolioFactory.create() + app = ApplicationFactory.create(portfolio=portfolio) + ApplicationFactory.create(portfolio=portfolio, deleted=True) + assert len(portfolio.applications) == 1 + assert portfolio.applications[0].id == app.id diff --git a/tests/models/test_user.py b/tests/models/test_user.py index ca3dc83d..2a3ae51e 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -3,7 +3,7 @@ from sqlalchemy.exc import InternalError from atst.models.user import User -from tests.factories import UserFactory +from tests.factories import UserFactory, ApplicationFactory, ApplicationRoleFactory def test_profile_complete_with_all_info(): @@ -24,3 +24,16 @@ def test_cannot_update_dod_id(session): session.add(user) with pytest.raises(InternalError): session.commit() + + +def test_deleted_application_roles_are_ignored(session): + user = UserFactory.create() + app = ApplicationFactory.create() + app_role = ApplicationRoleFactory.create(user=user, application=app) + assert len(user.application_roles) == 1 + + app_role.deleted = True + session.add(app_role) + session.commit() + + assert len(user.application_roles) == 0 diff --git a/tests/routes/portfolios/test_applications.py b/tests/routes/portfolios/test_applications.py index ab958f8a..8df582e3 100644 --- a/tests/routes/portfolios/test_applications.py +++ b/tests/routes/portfolios/test_applications.py @@ -1,4 +1,4 @@ -from flask import url_for +from flask import url_for, get_flashed_messages from tests.factories import ( UserFactory, @@ -290,3 +290,42 @@ def test_environment_access_with_no_role(client, user_session): ) ) assert response.status_code == 404 + + +def test_delete_application(client, user_session): + user = UserFactory.create() + port = PortfolioFactory.create( + owner=user, + applications=[ + { + "name": "mos eisley", + "environments": [ + {"name": "bar"}, + {"name": "booth"}, + {"name": "band stage"}, + ], + } + ], + ) + application = port.applications[0] + user_session(user) + + response = client.post( + url_for( + "portfolios.delete_application", + portfolio_id=port.id, + application_id=application.id, + ) + ) + # appropriate response and redirect + assert response.status_code == 302 + assert response.location == url_for( + "portfolios.portfolio_applications", portfolio_id=port.id, _external=True + ) + # appropriate flash message + message = get_flashed_messages()[0] + assert "deleted" in message["message"] + assert application.name in message["message"] + # app and envs are soft deleted + assert len(port.applications) == 0 + assert len(application.environments) == 0 diff --git a/tests/test_access.py b/tests/test_access.py index c79fd64d..0208887b 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -203,6 +203,44 @@ def test_portfolios_create_member_access(post_url_assert_status): post_url_assert_status(rando, url, 404) +# portfolios.delete_application +def test_portfolios_delete_application_access(post_url_assert_status, monkeypatch): + ccpo = UserFactory.create_ccpo() + owner = user_with() + app_admin = user_with() + rando = user_with() + + portfolio = PortfolioFactory.create( + owner=owner, applications=[{"name": "mos eisley"}] + ) + application = portfolio.applications[0] + + ApplicationRoleFactory.create( + user=app_admin, + application=application, + permission_sets=PermissionSets.get_many( + [ + PermissionSets.VIEW_APPLICATION, + PermissionSets.EDIT_APPLICATION_ENVIRONMENTS, + PermissionSets.EDIT_APPLICATION_TEAM, + PermissionSets.DELETE_APPLICATION_ENVIRONMENTS, + ] + ), + ) + + monkeypatch.setattr("atst.domain.applications.Applications.delete", lambda *a: True) + + url = url_for( + "portfolios.delete_application", + portfolio_id=portfolio.id, + application_id=application.id, + ) + post_url_assert_status(app_admin, url, 404) + post_url_assert_status(rando, url, 404) + post_url_assert_status(owner, url, 302) + post_url_assert_status(ccpo, url, 302) + + # portfolios.edit_application def test_portfolios_edit_application_access(get_url_assert_status): ccpo = user_with(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT) diff --git a/translations.yaml b/translations.yaml index a9711883..cf3a3fdb 100644 --- a/translations.yaml +++ b/translations.yaml @@ -28,6 +28,8 @@ flash: delete_member_success: You have successfully deleted {member_name} from the portfolio. new_ppoc_title: Primary point of contact updated new_ppoc_message: You have successfully added {ppoc_name} as the primary point of contact. You are no longer the PPoC. + application: + deleted: You have successfully deleted the {application_name} application. To view the retained activity log, visit the portfolio administration page. common: back: Back cancel: Cancel @@ -43,6 +45,7 @@ common: contracting_officer: Contracting Officer security_officer: Security Officer contracting_officer_representative: Contracting Officer Representative + delete_confirm: "Please type the word DELETE to confirm:" components: modal: close: Close @@ -599,6 +602,12 @@ portfolios: name: Name members: Members add_environment: Add New Environment + delete: + button: Delete Application + header: Are you sure you want to delete this application? + alert: + title: Warning! This action is permanent. + message: You will lose access to this application and all of its reporting and metrics tools. The activity log will be retained. admin: portfolio_members_title: Portfolio members portfolio_members_subheading: These members have different levels of access to the portfolio.