From 4802880cdb675b4025950337e43035e08e25e373 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 11 Feb 2020 13:21:49 -0500 Subject: [PATCH 1/4] Prevent double submitting in multi-step forms by updating the submitted property in handleSubmit --- js/components/forms/multi_step_modal_form.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/components/forms/multi_step_modal_form.js b/js/components/forms/multi_step_modal_form.js index 5cc5f4df..2cf20bba 100644 --- a/js/components/forms/multi_step_modal_form.js +++ b/js/components/forms/multi_step_modal_form.js @@ -55,6 +55,10 @@ export default { return this.step === this.steps - 1 }, handleSubmit: function(e) { + if (this._onLastPage) { + this.submitted = true + } + if (!this.validateFields() || !this._onLastPage()) { e.preventDefault() this.next() From c8991a95bf352c06ce1acb9e80df75ec839ab440 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 16:03:56 -0500 Subject: [PATCH 2/4] Add copy for "portfolio is ready" email --- translations.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/translations.yaml b/translations.yaml index a147dc13..4229545a 100644 --- a/translations.yaml +++ b/translations.yaml @@ -83,7 +83,9 @@ errors: 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 + portfolio_ready: + subject: Portfolio Provisioned + body: "Your portfolio has been provisioned.\nVisit {password_reset_address}, and use your username, {username}, to create a password." task_order_sent: subject: "Task Order {to_number}" body: "Task Order number {to_number} updated." From 8f52443b5d17add51c9a4257855f759462f1405d Mon Sep 17 00:00:00 2001 From: graham-dds Date: Wed, 12 Feb 2020 13:54:24 -0500 Subject: [PATCH 3/4] Send email to PPOC when portfolio is provisioned When a portfolio state machine transitions to the COMPLETED state, an email is sent to the PPOC letting them know it's ready, and provides them with their username needed to create a password. --- .secrets.baseline | 4 ++-- README.md | 1 + atst/jobs.py | 21 +++++++++++++++++ config/base.ini | 1 + tests/test_jobs.py | 59 +++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4e393738..45f10336 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2020-02-10T21:40:38Z", + "generated_at": "2020-02-12T18:51:01Z", "plugins_used": [ { "base64_limit": 4.5, @@ -82,7 +82,7 @@ "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_secret": false, "is_verified": false, - "line_number": 43, + "line_number": 44, "type": "Secret Keyword" } ], diff --git a/README.md b/README.md index b8530135..accecd38 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ To generate coverage reports for the Javascript tests: - `ASSETS_URL`: URL to host which serves static assets (such as a CDN). - `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account +- `AZURE_LOGIN_URL`: The URL used to login for an Azure instance. - `AZURE_STORAGE_KEY`: A valid secret key for the Azure blob storage account - `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads - `BLOB_STORAGE_URL`: URL to Azure blob storage container. diff --git a/atst/jobs.py b/atst/jobs.py index 0d462f0f..0094a6dd 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -18,6 +18,7 @@ from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.domain.portfolios import Portfolios from atst.models import CSPRole, JobFailure +from atst.models.mixins.state_machines import FSMStates from atst.domain.task_orders import TaskOrders from atst.models.utils import claim_for_update, claim_many_for_update from atst.queue import celery @@ -177,10 +178,30 @@ def do_work(fn, task, csp, **kwargs): raise task.retry(exc=e) +def send_PPOC_email(portfolio_dict): + ppoc_email = portfolio_dict.get("password_recovery_email_address") + user_id = portfolio_dict.get("user_id") + domain_name = portfolio_dict.get("domain_name") + + send_mail( + recipients=[ppoc_email], + subject=translate("email.portfolio_ready.subject"), + body=translate( + "email.portfolio_ready.body", + { + "password_reset_address": app.config.get("AZURE_LOGIN_URL"), + "username": f"{user_id}@{domain_name}.onmicrosoft.com", + }, + ), + ) + + def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None): portfolio = Portfolios.get_for_update(portfolio_id) fsm = Portfolios.get_or_create_state_machine(portfolio) fsm.trigger_next_transition(csp_data=portfolio.to_dictionary()) + if fsm.current_state == FSMStates.COMPLETED: + send_PPOC_email(portfolio.to_dictionary()) @celery.task(bind=True, base=RecordFailure) diff --git a/config/base.ini b/config/base.ini index 727172d8..3aa9ad86 100644 --- a/config/base.ini +++ b/config/base.ini @@ -4,6 +4,7 @@ AZURE_AADP_QTY=5 AZURE_ACCOUNT_NAME AZURE_CLIENT_ID AZURE_GRAPH_RESOURCE="https://graph.microsoft.com/" +AZURE_LOGIN_URL="https://portal.azure.com/" AZURE_POLICY_LOCATION=policies AZURE_POWERSHELL_CLIENT_ID AZURE_ROLE_DEF_ID_BILLING_READER="fa23ad8b-c56e-40d8-ac0c-ce449e1d2c64" diff --git a/tests/test_jobs.py b/tests/test_jobs.py index c14b626c..3edfd769 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -7,8 +7,7 @@ from azure.core.exceptions import AzureError from atst.domain.csp.cloud import MockCloudProvider from atst.domain.csp.cloud.models import UserRoleCSPResult -from atst.domain.portfolios import Portfolios -from atst.models import ApplicationRoleStatus +from atst.models import ApplicationRoleStatus, Portfolio, FSMStates from atst.jobs import ( RecordFailure, @@ -24,6 +23,7 @@ from atst.jobs import ( do_create_environment, do_create_environment_role, do_create_application, + send_PPOC_email, ) from tests.factories import ( ApplicationFactory, @@ -281,10 +281,57 @@ def test_dispatch_provision_portfolio(csp, monkeypatch): mock.delay.assert_called_once_with(portfolio_id=portfolio.id) -def test_do_provision_portfolio(csp, session, portfolio): - do_provision_portfolio(csp=csp, portfolio_id=portfolio.id) - session.refresh(portfolio) - assert portfolio.state_machine +class TestDoProvisionPortfolio: + def test_portfolio_has_state_machine(self, csp, session, portfolio): + do_provision_portfolio(csp=csp, portfolio_id=portfolio.id) + session.refresh(portfolio) + assert portfolio.state_machine + + def test_sends_email_to_PPOC_on_completion( + self, monkeypatch, csp, portfolio: Portfolio + ): + mock = Mock() + monkeypatch.setattr("atst.jobs.send_PPOC_email", mock) + + csp._authorize.return_value = None + csp._maybe_raise.return_value = None + sm: PortfolioStateMachine = PortfolioStateMachineFactory.create( + portfolio=portfolio + ) + # The stage before "COMPLETED" + sm.state = FSMStates.BILLING_OWNER_CREATED + do_provision_portfolio(csp=csp, portfolio_id=portfolio.id) + + # send_PPOC_email was called + assert mock.assert_called_once + + +def test_send_ppoc_email(monkeypatch, app): + mock = Mock() + monkeypatch.setattr("atst.jobs.send_mail", mock) + + ppoc_email = "example@example.com" + user_id = "user_id" + domain_name = "domain" + + send_PPOC_email( + { + "password_recovery_email_address": ppoc_email, + "user_id": user_id, + "domain_name": domain_name, + } + ) + mock.assert_called_once_with( + recipients=[ppoc_email], + subject=translate("email.portfolio_ready.subject"), + body=translate( + "email.portfolio_ready.body", + { + "password_reset_address": app.config.get("AZURE_LOGIN_URL"), + "username": f"{user_id}@{domain_name}.onmicrosoft.com", + }, + ), + ) def test_provision_portfolio_create_tenant( From 9b25a4b907e9029a9dabde7e331fcb9771651c26 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Wed, 12 Feb 2020 18:26:42 -0500 Subject: [PATCH 4/4] Move 'onmicrosoft.com' to an app constant --- atst/app.py | 1 + atst/domain/csp/cloud/azure_cloud_provider.py | 4 +--- atst/domain/csp/cloud/models.py | 3 ++- atst/jobs.py | 2 +- tests/domain/cloud/test_models.py | 11 +++++++---- tests/test_jobs.py | 6 +++--- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/atst/app.py b/atst/app.py index 04aed44d..1499671c 100644 --- a/atst/app.py +++ b/atst/app.py @@ -186,6 +186,7 @@ def map_config(config): # with a Beat job once a day) "CELERY_RESULT_EXPIRES": 0, "CELERY_RESULT_EXTENDED": True, + "OFFICE_365_DOMAIN": "onmicrosoft.com", "CONTRACT_START_DATE": datetime.strptime( config.get("default", "CONTRACT_START_DATE"), "%Y-%m-%d" ).date(), diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 7c976b8d..b85bed09 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -327,9 +327,7 @@ class AzureCloudProvider(CloudProviderInterface): if result.status_code == 200: result_dict = result.json() tenant_id = result_dict.get("tenantId") - tenant_admin_username = ( - f"{payload.user_id}@{payload.domain_name}.onmicrosoft.com" - ) + tenant_admin_username = f"{payload.user_id}@{payload.domain_name}.{self.config.get('OFFICE_365_DOMAIN')}" self.update_tenant_creds( tenant_id, KeyVaultCredentials( diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 859158a0..27f2c9c7 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -4,6 +4,7 @@ from typing import Dict, List, Optional from uuid import uuid4 import re +from flask import current_app as app from pydantic import BaseModel, validator, root_validator from atst.utils import snake_to_camel @@ -526,7 +527,7 @@ class UserMixin(BaseModel): @property def user_principal_name(self): - return f"{self.mail_nickname}@{self.tenant_host_name}.onmicrosoft.com" + return f"{self.mail_nickname}@{self.tenant_host_name}.{app.config.get('OFFICE_365_DOMAIN')}" @property def mail_nickname(self): diff --git a/atst/jobs.py b/atst/jobs.py index 0094a6dd..77a8c2f6 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -190,7 +190,7 @@ def send_PPOC_email(portfolio_dict): "email.portfolio_ready.body", { "password_reset_address": app.config.get("AZURE_LOGIN_URL"), - "username": f"{user_id}@{domain_name}.onmicrosoft.com", + "username": f"{user_id}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}", }, ), ) diff --git a/tests/domain/cloud/test_models.py b/tests/domain/cloud/test_models.py index e9951c9d..68bcd351 100644 --- a/tests/domain/cloud/test_models.py +++ b/tests/domain/cloud/test_models.py @@ -134,9 +134,12 @@ def test_UserCSPPayload_mail_nickname(): assert payload.mail_nickname == f"han.solo" -def test_UserCSPPayload_user_principal_name(): +def test_UserCSPPayload_user_principal_name(app): payload = UserCSPPayload(**user_payload) - assert payload.user_principal_name == f"han.solo@rebelalliance.onmicrosoft.com" + assert ( + payload.user_principal_name + == f"han.solo@rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}" + ) def test_UserCSPPayload_password(): @@ -167,11 +170,11 @@ class TestBillingOwnerCSPPayload: payload = BillingOwnerCSPPayload(**self.user_payload) assert payload.password - def test_user_principal_name(self): + def test_user_principal_name(self, app): payload = BillingOwnerCSPPayload(**self.user_payload) assert ( payload.user_principal_name - == f"billing_admin@rebelalliance.onmicrosoft.com" + == f"billing_admin@rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}" ) def test_email(self): diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 3edfd769..9df83264 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -135,11 +135,11 @@ def test_create_application_job_is_idempotent(csp): csp.create_application.assert_not_called() -def test_create_user_job(session, csp): +def test_create_user_job(session, csp, app): portfolio = PortfolioFactory.create( csp_data={ "tenant_id": str(uuid4()), - "domain_name": "rebelalliance.onmicrosoft.com", + "domain_name": f"rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}", } ) application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321") @@ -328,7 +328,7 @@ def test_send_ppoc_email(monkeypatch, app): "email.portfolio_ready.body", { "password_reset_address": app.config.get("AZURE_LOGIN_URL"), - "username": f"{user_id}@{domain_name}.onmicrosoft.com", + "username": f"{user_id}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}", }, ), )