diff --git a/Makefile b/Makefile index e255be409..1aafc9d82 100644 --- a/Makefile +++ b/Makefile @@ -19,15 +19,15 @@ BUILD_URL ?= DOCKER_CONTAINER_PREFIX = ${USER}-${BUILD_TAG} -CODEDEPLOY_PREFIX ?= notifications-admin -CODEDEPLOY_APP_NAME ?= notify-admin - CF_API ?= api.cloud.service.gov.uk CF_ORG ?= govuk-notify CF_SPACE ?= ${DEPLOY_ENV} CF_HOME ?= ${HOME} $(eval export CF_HOME) +CF_MANIFEST_FILE ?= manifest-${CF_SPACE}.yml +NOTIFY_CREDENTIALS ?= ~/.notify-credentials + .PHONY: help help: @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -43,8 +43,6 @@ venv/bin/activate: check-env-vars: ## Check mandatory environment variables $(if ${DEPLOY_ENV},,$(error Must specify DEPLOY_ENV)) $(if ${DNS_NAME},,$(error Must specify DNS_NAME)) - $(if ${AWS_ACCESS_KEY_ID},,$(error Must specify AWS_ACCESS_KEY_ID)) - $(if ${AWS_SECRET_ACCESS_KEY},,$(error Must specify AWS_SECRET_ACCESS_KEY)) .PHONY: sandbox sandbox: ## Set environment to sandbox @@ -87,55 +85,22 @@ build: dependencies generate-version-file ## Build project npm run build . venv/bin/activate && PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} pip-accel install -r requirements.txt -.PHONY: cf-build -cf-build: dependencies generate-version-file ## Build project - npm run build - -.PHONY: build-codedeploy-artifact -build-codedeploy-artifact: ## Build the deploy artifact for CodeDeploy +.PHONY: build-paas-artifact +build-paas-artifact: ## Build the deploy artifact for PaaS rm -rf target mkdir -p target zip -y -q -r -x@deploy-exclude.lst target/notifications-admin.zip ./ -.PHONY: upload-codedeploy-artifact ## Upload the deploy artifact for CodeDeploy -upload-codedeploy-artifact: check-env-vars - $(if ${DEPLOY_BUILD_NUMBER},,$(error Must specify DEPLOY_BUILD_NUMBER)) - aws s3 cp --region eu-west-1 --sse AES256 target/notifications-admin.zip s3://${DNS_NAME}-codedeploy/${CODEDEPLOY_PREFIX}-${DEPLOY_BUILD_NUMBER}.zip - -.PHONY: build-paas-artifact -build-paas-artifact: build-codedeploy-artifact ## Build the deploy artifact for PaaS - .PHONY: upload-paas-artifact ## Upload the deploy artifact for PaaS upload-paas-artifact: $(if ${DEPLOY_BUILD_NUMBER},,$(error Must specify DEPLOY_BUILD_NUMBER)) $(if ${JENKINS_S3_BUCKET},,$(error Must specify JENKINS_S3_BUCKET)) - aws s3 cp --region eu-west-1 --sse AES256 target/notifications-admin.zip s3://${JENKINS_S3_BUCKET}/build/${CODEDEPLOY_PREFIX}/${DEPLOY_BUILD_NUMBER}.zip + aws s3 cp --region eu-west-1 --sse AES256 target/notifications-admin.zip s3://${JENKINS_S3_BUCKET}/build/notifications-admin/${DEPLOY_BUILD_NUMBER}.zip .PHONY: test test: venv ## Run tests ./scripts/run_tests.sh -.PHONY: deploy -deploy: check-env-vars ## Upload deploy artifacts to S3 and trigger CodeDeploy - aws deploy create-deployment --application-name ${CODEDEPLOY_APP_NAME} --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name ${CODEDEPLOY_APP_NAME} --s3-location bucket=${DNS_NAME}-codedeploy,key=${CODEDEPLOY_PREFIX}-${DEPLOY_BUILD_NUMBER}.zip,bundleType=zip --region eu-west-1 - -.PHONY: check-aws-vars -check-aws-vars: ## Check if AWS access keys are set - $(if ${AWS_ACCESS_KEY_ID},,$(error Must specify AWS_ACCESS_KEY_ID)) - $(if ${AWS_SECRET_ACCESS_KEY},,$(error Must specify AWS_SECRET_ACCESS_KEY)) - -.PHONY: deploy-suspend-autoscaling-processes -deploy-suspend-autoscaling-processes: check-aws-vars ## Suspend launch and terminate processes for the auto-scaling group - aws autoscaling suspend-processes --region eu-west-1 --auto-scaling-group-name ${CODEDEPLOY_APP_NAME} --scaling-processes "Launch" "Terminate" - -.PHONY: deploy-resume-autoscaling-processes -deploy-resume-autoscaling-processes: check-aws-vars ## Resume launch and terminate processes for the auto-scaling group - aws autoscaling resume-processes --region eu-west-1 --auto-scaling-group-name ${CODEDEPLOY_APP_NAME} --scaling-processes "Launch" "Terminate" - -.PHONY: deploy-check-autoscaling-processes -deploy-check-autoscaling-processes: check-aws-vars ## Returns with the number of instances with active autoscaling events - @aws autoscaling describe-auto-scaling-groups --region eu-west-1 --auto-scaling-group-names ${CODEDEPLOY_APP_NAME} | jq '.AutoScalingGroups[0].Instances|map(select(.LifecycleState != "InService"))|length' - .PHONY: coverage coverage: venv ## Create coverage report . venv/bin/activate && coveralls @@ -180,10 +145,6 @@ endef build-with-docker: prepare-docker-build-image ## Build inside a Docker container $(call run_docker_container,build,gosu hostuser make build) -.PHONY: cf-build-with-docker -cf-build-with-docker: prepare-docker-build-image ## Build inside a Docker container - $(call run_docker_container,build,gosu hostuser make cf-build) - .PHONY: test-with-docker test-with-docker: prepare-docker-build-image ## Run tests inside a Docker container $(call run_docker_container,test,gosu hostuser make test) @@ -209,21 +170,29 @@ cf-login: ## Log in to Cloud Foundry @echo "Logging in to Cloud Foundry on ${CF_API}" @cf login -a "${CF_API}" -u ${CF_USERNAME} -p "${CF_PASSWORD}" -o "${CF_ORG}" -s "${CF_SPACE}" +.PHONY: generate-manifest +generate-manifest: + $(if ${CF_SPACE},,$(error Must specify CF_SPACE)) + $(if $(shell which gpg2), $(eval export GPG=gpg2), $(eval export GPG=gpg)) + $(if ${GPG_PASSPHRASE_TXT}, $(eval export DECRYPT_CMD=echo -n $$$${GPG_PASSPHRASE_TXT} | ${GPG} --quiet --batch --passphrase-fd 0 --pinentry-mode loopback -d), $(eval export DECRYPT_CMD=${GPG} --quiet --batch -d)) + + @./scripts/generate_manifest.py ${CF_MANIFEST_FILE} \ + <(${DECRYPT_CMD} ${NOTIFY_CREDENTIALS}/credentials/${CF_SPACE}/paas/environment-variables.gpg) + .PHONY: cf-deploy cf-deploy: ## Deploys the app to Cloud Foundry $(if ${CF_SPACE},,$(error Must specify CF_SPACE)) @cf app --guid notify-admin || exit 1 cf rename notify-admin notify-admin-rollback - cf push -f manifest-${CF_SPACE}.yml + cf push -f <(make -s generate-manifest) cf scale -i $$(cf curl /v2/apps/$$(cf app --guid notify-admin-rollback) | jq -r ".entity.instances" 2>/dev/null || echo "1") notify-admin cf stop notify-admin-rollback cf delete -f notify-admin-rollback .PHONY: cf-deploy-prototype -cf-deploy-prototype: ## Deploys the app to Cloud Foundry +cf-deploy-prototype: cf-target ## Deploys the app to Cloud Foundry $(if ${CF_SPACE},,$(error Must specify CF_SPACE)) - cf target -s ${CF_SPACE} - cf push -f manifest-prototype-${CF_SPACE}.yml + cf push -f <(make -s CF_MANIFEST_FILE=manifest-prototype-${CF_SPACE}.yml generate-manifest) .PHONY: cf-rollback cf-rollback: ## Rollbacks the app to the previous release @@ -234,4 +203,24 @@ cf-rollback: ## Rollbacks the app to the previous release .PHONY: cf-push cf-push: - cf push -f manifest-${CF_SPACE}.yml + cf push -f <(make -s generate-manifest) + +.PHONY: cf-target +cf-target: check-env-vars + @cf target -o ${CF_ORG} -s ${CF_SPACE} + +.PHONY: cf-failwhale-deployed +cf-failwhale-deployed: + @cf app notify-admin-failwhale --guid || (echo "notify-admin-failwhale is not deployed on ${CF_SPACE}" && exit 1) + +.PHONY: enable-failwhale +enable-failwhale: cf-target cf-failwhale-deployed ## Enable the failwhale app and disable admin + @cf map-route notify-admin-failwhale ${DNS_NAME} --hostname www + @cf unmap-route notify-admin ${DNS_NAME} --hostname www + @echo "Failwhale is enabled" + +.PHONY: disable-failwhale +disable-failwhale: cf-target cf-failwhale-deployed ## Disable the failwhale app and enable admin + @cf map-route notify-admin ${DNS_NAME} --hostname www + @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www + @echo "Failwhale is disabled" diff --git a/app/__init__.py b/app/__init__.py index 7669fbc73..48a664431 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -38,7 +38,6 @@ from werkzeug.local import LocalProxy from app import proxy_fix from app.config import configs from app.asset_fingerprinter import AssetFingerprinter -from app.its_dangerous_session import ItsdangerousSessionInterface from app.notify_client.service_api_client import ServiceAPIClient from app.notify_client.api_key_api_client import ApiKeyApiClient from app.notify_client.invite_api_client import InviteApiClient @@ -124,8 +123,6 @@ def create_app(application): proxy_fix.init_app(application) - application.session_interface = ItsdangerousSessionInterface() - add_template_filters(application) register_errorhandlers(application) @@ -139,6 +136,16 @@ def init_app(application): application.before_request(load_service_before_request) application.before_request(request_helper.check_proxy_header_before_request) + @application.before_request + def make_session_permanent(): + # this is dumb. You'd think, given that there's `config['PERMANENT_SESSION_LIFETIME']`, that you'd enable + # permanent sessions in the config too - but no, you have to declare it for each request. + # https://stackoverflow.com/questions/34118093/flask-permanent-session-where-to-define-them + # session.permanent is also, helpfully, a way of saying that the session isn't permanent - in that, it will + # expire on its own, as opposed to being controlled by the browser's session. Because session is a proxy, it's + # only accessible from within a request context, so we need to set this before every request :rolls_eyes: + session.permanent = True + @application.context_processor def _attach_current_service(): return {'current_service': current_service} diff --git a/app/assets/stylesheets/components/vendor/previous-next-navigation.scss b/app/assets/stylesheets/components/vendor/previous-next-navigation.scss index a1cf5e0ea..5f8f9f4d9 100644 --- a/app/assets/stylesheets/components/vendor/previous-next-navigation.scss +++ b/app/assets/stylesheets/components/vendor/previous-next-navigation.scss @@ -1,6 +1,6 @@ /* Taken from the GOV.UK component at -https://github.com/alphagov/static/blob/a9d462e71709d2ff6348bcce7e8c625af2b86114/app/assets/stylesheets/govuk-component/_previous-and-next-navigation.scss +https://github.com/alphagov/static/blob/3d93a762b9d7af54615c77ae3e479131c03b8175/app/assets/stylesheets/govuk-component/_previous-and-next-navigation.scss and https://github.com/alphagov/static/blob/da8aeeaa749093eab30286d7fc9f965533b66f47/app/assets/stylesheets/styleguide/_conditionals2.scss */ @@ -31,12 +31,11 @@ $is-ie: false !default; } .govuk-previous-and-next-navigation { - - @include media-down(mobile) { - margin: 2em 0 0 0; - } - display: block; + margin-top: $gutter; + margin-bottom: $gutter; + margin-left: -$gutter-half; + margin-right: -$gutter-half; ul { margin: 0; @@ -50,18 +49,17 @@ $is-ie: false !default; text-align: right; margin: 0; padding: 0; - width: 49%; + width: 50%; a { - - @include ie-lte(7) { - height: 4.5em; - } - display: block; - color: $link-colour; + padding: $gutter-half; text-decoration: none; + &:visited { + color: $link-colour; + } + &:hover, &:active { background-color: $canvas-colour; @@ -69,61 +67,47 @@ $is-ie: false !default; .pagination-part-title { @include core-27($line-height: (33.75 / 27)); - margin-bottom: 0.1em; display: block; } - - } - - &.next-page { - float: right; - text-align: right; - } - - &.next-page a:before { - background: transparent file-url("arrow-sprite.png") no-repeat -102px -11px; - margin: -4px -32px 0 0; - display: block; - float: right; - width: 30px; - height: 38px; - content: " "; - } - - &.previous-page a:before { - background: transparent file-url("arrow-sprite.png") no-repeat -20px -11px; - margin: -4px 0 0 -32px; - display: block; - float: left; - width: 30px; - height: 38px; - content: " "; - } - - &.previous-page { - float: left; - text-align: left; - } - - &.previous-page a { - padding: 0.75em 0 0.75em 3em; - } - - &.next-page a { - padding: 0.75em 3em 0.75em 0; - } - - @include media-down(mobile) { - &.previous-page, - &.next-page { - float: none; - width: 100%; - } - - &.next-page a { - text-align: right; - } } } -} \ No newline at end of file + .previous-page { + float: left; + text-align: left; + } + + .next-page { + float: right; + text-align: right; + } + + @include media-down(mobile) { + .previous-page, + .next-page { + float: none; + width: 100%; + } + + .next-page a { + text-align: right; + } + } + + .pagination-icon { + display: inline-block; + margin-bottom: 1px; + height: .482em; + width: .63em; + } + + .pagination-label { + display: inline-block; + margin-top: 0.1em; + text-decoration: underline; + + &:empty { + display: none; + } + } +} diff --git a/app/cloudfoundry_config.py b/app/cloudfoundry_config.py index 6d55acf37..97004b6ad 100644 --- a/app/cloudfoundry_config.py +++ b/app/cloudfoundry_config.py @@ -8,53 +8,6 @@ import json def extract_cloudfoundry_config(): - vcap_services = json.loads(os.environ['VCAP_SERVICES']) - - set_config_env_vars(vcap_services) - - -def set_config_env_vars(vcap_services): vcap_application = json.loads(os.environ.get('VCAP_APPLICATION')) os.environ['NOTIFY_ENVIRONMENT'] = vcap_application['space_name'] os.environ['NOTIFY_LOG_PATH'] = '/home/vcap/logs/app.log' - - for s in vcap_services['user-provided']: - if s['name'] == 'notify-config': - extract_notify_config(s) - elif s['name'] == 'notify-aws': - extract_notify_aws_config(s) - elif s['name'] == 'hosted-graphite': - extract_hosted_graphite_config(s) - elif s['name'] == 'deskpro': - extract_deskpro_config(s) - elif s['name'] == 'notify-template-preview': - extract_template_preview_config(s) - - -def extract_notify_config(notify_config): - os.environ['ADMIN_CLIENT_SECRET'] = notify_config['credentials']['admin_client_secret'] - os.environ['API_HOST_NAME'] = notify_config['credentials']['api_host_name'] - os.environ['ADMIN_BASE_URL'] = notify_config['credentials']['admin_base_url'] - os.environ['SECRET_KEY'] = notify_config['credentials']['secret_key'] - os.environ['DANGEROUS_SALT'] = notify_config['credentials']['dangerous_salt'] - os.environ['ROUTE_SECRET_KEY_1'] = notify_config['credentials']['route_secret_key_1'] - os.environ['ROUTE_SECRET_KEY_2'] = notify_config['credentials']['route_secret_key_2'] - - -def extract_notify_aws_config(aws_config): - os.environ['AWS_ACCESS_KEY_ID'] = aws_config['credentials']['aws_access_key_id'] - os.environ['AWS_SECRET_ACCESS_KEY'] = aws_config['credentials']['aws_secret_access_key'] - - -def extract_hosted_graphite_config(hosted_graphite_config): - os.environ['STATSD_PREFIX'] = hosted_graphite_config['credentials']['statsd_prefix'] - - -def extract_deskpro_config(deskpro_config): - os.environ['DESKPRO_API_HOST'] = deskpro_config['credentials']['api_host'] - os.environ['DESKPRO_API_KEY'] = deskpro_config['credentials']['api_key'] - - -def extract_template_preview_config(template_preview_config): - os.environ['TEMPLATE_PREVIEW_API_HOST'] = template_preview_config['credentials']['api_host'] - os.environ['TEMPLATE_PREVIEW_API_KEY'] = template_preview_config['credentials']['api_key'] diff --git a/app/commands.py b/app/commands.py index c13dfee18..026be777a 100644 --- a/app/commands.py +++ b/app/commands.py @@ -4,7 +4,7 @@ from flask import current_app def list_routes(): """List URLs of all application routes.""" for rule in sorted(current_app.url_map.iter_rules(), key=lambda r: r.rule): - print("{:10} {}".format(", ".join(rule.methods - set(['OPTIONS', 'HEAD'])), rule.rule)) + print("{:10} {}".format(", ".join(rule.methods - set(['OPTIONS', 'HEAD'])), rule.rule)) # noqa def setup_commands(application): diff --git a/app/config.py b/app/config.py index 04b0bbc59..547821b47 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,8 @@ import os -if os.environ.get('VCAP_SERVICES'): - # on cloudfoundry, config is a json blob in VCAP_SERVICES - unpack it, and populate +if os.environ.get('VCAP_APPLICATION'): + # on cloudfoundry, config is a json blob in VCAP_APPLICATION - unpack it, and populate # standard environment variables from it from app.cloudfoundry_config import extract_cloudfoundry_config extract_cloudfoundry_config() @@ -97,6 +97,8 @@ class Config(object): r"gov\.wales", r"biglotteryfund\.org\.uk", r"marinemanagement\.org\.uk", + r"britishmuseum\.org", + r"derrystrabane\.com", ] LOGO_UPLOAD_BUCKET_NAME = 'public-logos-local' diff --git a/app/its_dangerous_session.py b/app/its_dangerous_session.py deleted file mode 100644 index e16cecc55..000000000 --- a/app/its_dangerous_session.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import timedelta, datetime - -from werkzeug.datastructures import CallbackDict -from flask.sessions import SessionInterface, SessionMixin -from itsdangerous import URLSafeTimedSerializer, BadSignature - - -class ItsdangerousSession(CallbackDict, SessionMixin): - def __init__(self, initial=None): - def on_update(self): - self.modified = True - - CallbackDict.__init__(self, initial, on_update) - self.modified = False - - -class ItsdangerousSessionInterface(SessionInterface): - session_class = ItsdangerousSession - - def get_serializer(self, app): - salt = app.config.get('DANGEROUS_SALT') - if not app.secret_key: - return None - return URLSafeTimedSerializer(app.secret_key, - salt=salt) - - def open_session(self, app, request): - s = self.get_serializer(app) - if s is None: - return None - val = request.cookies.get(app.session_cookie_name) - if not val: - return self.session_class() - max_age = app.permanent_session_lifetime.total_seconds() - try: - data = s.loads(val, max_age=max_age) - return self.session_class(data) - except BadSignature: - return self.session_class() - - def save_session(self, app, session, response): - domain = self.get_cookie_domain(app) - if not session: - if session.modified: - response.delete_cookie(app.session_cookie_name, - domain=domain) - return - session.permanent = True - expires = datetime.utcnow() + timedelta(seconds=app.config.get('PERMANENT_SESSION_LIFETIME')) - val = self.get_serializer(app).dumps(dict(session)) - response.set_cookie(app.session_cookie_name, val, - expires=expires, httponly=True, - domain=domain, secure=app.config.get('SESSION_COOKIE_SECURE')) diff --git a/app/main/forms.py b/app/main/forms.py index ea89af61d..9901cb89a 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -839,3 +839,13 @@ class SetSenderForm(StripWhitespaceForm): self.sender.label.text = kwargs['sender_label'] sender = RadioField() + + +class SetTemplateSenderForm(StripWhitespaceForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sender.choices = kwargs['sender_choices'] + self.sender.label.text = 'Select your sender' + + sender = RadioField() diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py index 1482c68e4..438ad5d50 100644 --- a/app/main/views/notifications.py +++ b/app/main/views/notifications.py @@ -16,7 +16,7 @@ from app import ( current_service, format_date_numeric) from app.main import main -from app.template_previews import TemplatePreview +from app.template_previews import TemplatePreview, get_page_count_for_letter from app.utils import ( user_has_permissions, get_help_argument, @@ -33,6 +33,8 @@ from app.utils import ( @user_has_permissions('view_activity', admin_override=True) def view_notification(service_id, notification_id): notification = notification_api_client.get_notification(service_id, str(notification_id)) + notification['template'].update({'reply_to_text': notification['reply_to_text']}) + template = get_template( notification['template'], current_service, @@ -42,6 +44,7 @@ def view_notification(service_id, notification_id): notification_id=notification_id, filetype='png', ), + page_count=get_page_count_for_letter(notification['template']), show_recipient=True, redact_missing_personalisation=True, ) @@ -83,6 +86,7 @@ def view_letter_notification_as_preview(service_id, notification_id, filetype): abort(404) notification = notification_api_client.get_notification(service_id, notification_id) + notification['template'].update({'reply_to_text': notification['reply_to_text']}) template = get_template( notification['template'], diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 7631b0f39..e1e11373b 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -553,6 +553,10 @@ def service_add_letter_contact(service_id): contact_block=form.letter_contact_block.data.replace('\r', '') or None, is_default=first_contact_block if first_contact_block else form.is_default.data ) + if request.args.get('from_template'): + return redirect( + url_for('.set_template_sender', service_id=service_id, template_id=request.args.get('from_template')) + ) return redirect(url_for('.service_letter_contact_details', service_id=service_id)) return render_template( 'views/service-settings/letter-contact/add.html', diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 840483f0f..90f7d6b82 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -12,7 +12,9 @@ from flask import ( ) from flask_login import login_required, current_user from dateutil.parser import parse +from markupsafe import Markup +from notifications_utils.formatters import nl2br from notifications_utils.recipients import first_column_headings from notifications_utils.template import LetterDVLATemplate from notifications_python_client.errors import HTTPError @@ -26,8 +28,9 @@ from app.main.forms import ( EmailTemplateForm, LetterTemplateForm, SearchTemplatesForm, + SetTemplateSenderForm, ) -from app.main.views.send import get_example_csv_rows +from app.main.views.send import get_example_csv_rows, get_sender_details from app import service_api_client, current_service, template_statistics_client @@ -181,6 +184,7 @@ def view_letter_template_preview(service_id, template_id, filetype): abort(404) db_template = service_api_client.get_service_template(service_id, template_id)['data'] + return TemplatePreview.from_database_object(db_template, filetype, page=request.args.get('page')) @@ -373,7 +377,8 @@ def edit_service_template(service_id, template_id): 'subject': subject, 'template_type': template['template_type'], 'id': template['id'], - 'process_type': form.process_type.data + 'process_type': form.process_type.data, + 'reply_to_text': template['reply_to_text'] }, current_service) template_change = get_template(template, current_service).compare_to(new_template) if template_change.placeholders_added and not request.form.get('confirm'): @@ -564,6 +569,65 @@ def view_template_versions(service_id, template_id): ) +@main.route('/services//templates//set-template-sender', methods=['GET', 'POST']) +@login_required +@user_has_permissions('manage_templates', admin_override=True) +def set_template_sender(service_id, template_id): + template = service_api_client.get_service_template(service_id, template_id)['data'] + sender_details = get_template_sender_form_dict(service_id, template) + no_senders = sender_details.get('no_senders', False) + + form = SetTemplateSenderForm( + sender=sender_details['current_choice'], + sender_choices=sender_details['value_and_label'], + ) + option_hints = {sender_details['default_sender']: '(Default)'} + + if form.validate_on_submit(): + service_api_client.update_service_template_sender( + service_id, + template_id, + form.sender.data if form.sender.data else None, + ) + return redirect(url_for('.view_template', service_id=service_id, template_id=template_id)) + + return render_template( + 'views/templates/set-template-sender.html', + form=form, + template_id=template_id, + no_senders=no_senders, + option_hints=option_hints + ) + + +def get_template_sender_form_dict(service_id, template): + context = { + 'email': { + 'field_name': 'email_address' + }, + 'letter': { + 'field_name': 'contact_block' + }, + 'sms': { + 'field_name': 'sms_sender' + } + }[template['template_type']] + + sender_format = context['field_name'] + service_senders = get_sender_details(service_id, template['template_type']) + context['default_sender'] = next( + (x['id'] for x in service_senders if x['is_default']), "Not set" + ) + if not service_senders: + context['no_senders'] = True + + context['value_and_label'] = [(sender['id'], Markup(nl2br(sender[sender_format]))) for sender in service_senders] + context['value_and_label'].insert(0, ('', 'Blank')) # Add blank option to start of list + + context['current_choice'] = template['service_letter_contact'] if template['service_letter_contact'] else '' + return context + + def get_last_use_message(template_name, template_statistics): try: most_recent_use = max( diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 2b7b6ead4..848da40b4 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -187,6 +187,16 @@ class ServiceAPIClient(NotifyAdminAPIClient): ), ) + def update_service_template_sender(self, service_id, template_id, reply_to): + data = { + 'reply_to': reply_to, + } + data = _attach_current_user(data) + return self.post( + "/service/{0}/template/{1}".format(service_id, template_id), + data + ) + def get_service_template(self, service_id, template_id, version=None, *params): """ Retrieve a service template. diff --git a/app/template_previews.py b/app/template_previews.py index a3f5970e8..e4493f3a4 100644 --- a/app/template_previews.py +++ b/app/template_previews.py @@ -8,7 +8,7 @@ class TemplatePreview: @classmethod def from_database_object(cls, template, filetype, values=None, page=None): data = { - 'letter_contact_block': current_service['letter_contact_block'], + 'letter_contact_block': template.get('reply_to_text', ''), 'template': template, 'values': values, 'dvla_org_id': current_service['dvla_organisation'], diff --git a/app/templates/components/previous-next-navigation.html b/app/templates/components/previous-next-navigation.html index 525555396..e7102d4a0 100644 --- a/app/templates/components/previous-next-navigation.html +++ b/app/templates/components/previous-next-navigation.html @@ -4,7 +4,12 @@ {% if previous_page %}
  • - {{previous_page['title']}} + + + + + {{previous_page['title']}} + {{previous_page['label']}}
  • @@ -12,7 +17,12 @@ {% if next_page %}
  • diff --git a/app/templates/views/service-settings/request-to-go-live.html b/app/templates/views/service-settings/request-to-go-live.html index 0a7f087ca..48cb4f3d6 100644 --- a/app/templates/views/service-settings/request-to-go-live.html +++ b/app/templates/views/service-settings/request-to-go-live.html @@ -24,10 +24,10 @@ settings page
  • added the templates you want to start with, making sure they follow our - design patterns, - style guide + design patterns, + style guide and - information security guidelines + information security guidelines
  • diff --git a/app/templates/views/signedout.html b/app/templates/views/signedout.html index 051ea14f7..5c5968a0d 100644 --- a/app/templates/views/signedout.html +++ b/app/templates/views/signedout.html @@ -120,12 +120,12 @@

    Services

    -
    128
    +
    131
    services

    Organisations

    -
    53
    +
    55
    organisations
    diff --git a/app/templates/views/templates/_template.html b/app/templates/views/templates/_template.html index 1dd6a41ab..70bce8a26 100644 --- a/app/templates/views/templates/_template.html +++ b/app/templates/views/templates/_template.html @@ -35,11 +35,7 @@
    {% if current_user.has_permissions(permissions=['manage_templates'], admin_override=True) and template.template_type == 'letter' %} Edit - {% if default_letter_contact_block_id %} - Edit - {% else %} - Edit - {% endif %} + Edit {% endif %} {{ template|string }}
    diff --git a/app/templates/views/templates/set-template-sender.html b/app/templates/views/templates/set-template-sender.html new file mode 100644 index 000000000..c25ea1789 --- /dev/null +++ b/app/templates/views/templates/set-template-sender.html @@ -0,0 +1,32 @@ +{% extends "withnav_template.html" %} +{% from "components/radios.html" import radios, branding_radios %} +{% from "components/page-footer.html" import page_footer %} + +{% block service_page_title %} + Set letter contact block +{% endblock %} + +{% block maincolumn_content %} + +

    Set letter contact block

    +
    +
    +
    + {{ radios( + form.sender, + option_hints=option_hints, + hide_legend=True + ) }} + {{ page_footer( + 'Continue', + back_link=url_for('.view_template', service_id=current_service.id, template_id=template_id), + back_link_text='Back to template' + ) }} + {% if no_senders %} + Add new sender + {% endif %} +
    +
    +
    + +{% endblock %} diff --git a/app/templates/views/terms-of-use.html b/app/templates/views/terms-of-use.html index 9add85681..8956b9cfb 100644 --- a/app/templates/views/terms-of-use.html +++ b/app/templates/views/terms-of-use.html @@ -39,7 +39,7 @@ Terms of use
  • get the right levels of consent (to send messages and to use data)
  • not send unsolicited messages, only ones related to a transaction or something the user has subscribed to be updated about (check the Service Manual if you’re not sure)
  • - send messages that meet the GOV.UK design patterns, style guide and information security guidelines
  • + send messages that meet the GOV.UK design patterns, style guide and information security guidelines
  • not send messages containing any personally or commercially sensitive information
  • check that the data you add to Notify is accurate and complies with Data Protection Act principles
  • diff --git a/app/templates/views/using-notify.html b/app/templates/views/using-notify.html index d0d8d834c..d23f89520 100644 --- a/app/templates/views/using-notify.html +++ b/app/templates/views/using-notify.html @@ -16,10 +16,10 @@

    Using Notify

    Writing and sending text messages, emails and letters

    -

    Check the Service Manual for guidance on how to:

    +

    Check the Service Manual for guidance on how to:

    Trial mode

    diff --git a/app/utils.py b/app/utils.py index 3812a936f..95e881249 100644 --- a/app/utils.py +++ b/app/utils.py @@ -318,11 +318,12 @@ def get_template( template, image_url=letter_preview_url, page_count=int(page_count), + contact_block=template['reply_to_text'] ) else: return LetterPreviewTemplate( template, - contact_block=service['letter_contact_block'], + contact_block=template['reply_to_text'], admin_base_url=current_app.config['ADMIN_BASE_URL'], redact_missing_personalisation=redact_missing_personalisation, ) diff --git a/appspec.yml b/appspec.yml deleted file mode 100644 index 0a55dcfa1..000000000 --- a/appspec.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -files: - - - destination: /home/notify-app/notifications-admin - source: / -hooks: - BeforeInstall: - - location: scripts/aws_clear_instance.sh - runas: root - timeout: 1000 - AfterInstall: - - - location: scripts/aws_install_dependencies.sh - runas: root - timeout: 300 - - - location: scripts/aws_change_ownership.sh - runas: root - timeout: 300 - ApplicationStart: - - - location: scripts/aws_start_app.sh - runas: root - timeout: 300 - - - location: scripts/register_with_elb.sh - runas: ubuntu - timeout: 300 - ApplicationStop: - - - location: scripts/deregister_from_elb.sh - runas: ubuntu - timeout: 300 - - - location: scripts/aws_stop_app.sh - runas: root - timeout: 300 -os: linux -version: 0.0 diff --git a/manifest-base.yml b/manifest-base.yml index 9f6d0d60f..26a167743 100644 --- a/manifest-base.yml +++ b/manifest-base.yml @@ -2,16 +2,30 @@ buildpack: python_buildpack command: scripts/run_app_paas.sh gunicorn -c /home/vcap/app/gunicorn_config.py --error-logfile /home/vcap/logs/gunicorn_error.log -w 5 -b 0.0.0.0:$PORT application -services: - - notify-aws - - notify-config - - notify-template-preview - - hosted-graphite - - deskpro instances: 1 memory: 1G env: NOTIFY_APP_NAME: admin + # Credentials variables + ADMIN_CLIENT_SECRET: null + ADMIN_BASE_URL: null + API_HOST_NAME: null + DANGEROUS_SALT: null + SECRET_KEY: null + ROUTE_SECRET_KEY_1: null + ROUTE_SECRET_KEY_2: null + + AWS_ACCESS_KEY_ID: null + AWS_SECRET_ACCESS_KEY: null + + STATSD_PREFIX: null + + DESKPRO_API_HOST: null + DESKPRO_API_KEY: null + + TEMPLATE_PREVIEW_API_HOST: null + TEMPLATE_PREVIEW_API_KEY: null + applications: - name: notify-admin diff --git a/manifest-preview.yml b/manifest-preview.yml index 34f46f22c..8573b785b 100644 --- a/manifest-preview.yml +++ b/manifest-preview.yml @@ -3,11 +3,6 @@ inherit: manifest-base.yml services: - - notify-aws - - notify-config - - notify-template-preview - - hosted-graphite - - deskpro - logit-ssl-syslog-drain routes: diff --git a/manifest-prototype-base.yml b/manifest-prototype-base.yml index cf28cbb6a..6003594b7 100644 --- a/manifest-prototype-base.yml +++ b/manifest-prototype-base.yml @@ -2,16 +2,30 @@ buildpack: python_buildpack command: scripts/run_app_paas.sh gunicorn -w 5 -b 0.0.0.0:$PORT application -services: - - notify-aws - - notify-config - - notify-template-preview - - hosted-graphite - - deskpro instances: 1 memory: 1G env: NOTIFY_APP_NAME: admin + # Credentials variables + ADMIN_CLIENT_SECRET: null + ADMIN_BASE_URL: null + API_HOST_NAME: null + DANGEROUS_SALT: null + SECRET_KEY: null + ROUTE_SECRET_KEY_1: null + ROUTE_SECRET_KEY_2: null + + AWS_ACCESS_KEY_ID: null + AWS_SECRET_ACCESS_KEY: null + + STATSD_PREFIX: null + + DESKPRO_API_HOST: null + DESKPRO_API_KEY: null + + TEMPLATE_PREVIEW_API_HOST: null + TEMPLATE_PREVIEW_API_KEY: null + applications: - name: notify-admin-prototype diff --git a/manifest-staging.yml b/manifest-staging.yml index c0f1d862f..ed4119be7 100644 --- a/manifest-staging.yml +++ b/manifest-staging.yml @@ -7,11 +7,6 @@ routes: - route: www.staging-notify.works services: - - notify-aws - - notify-config - - notify-template-preview - - hosted-graphite - - deskpro - logit-ssl-syslog-drain instances: 2 diff --git a/paas-failwhale/README.md b/paas-failwhale/README.md index 25e10f6f4..95e0ea107 100644 --- a/paas-failwhale/README.md +++ b/paas-failwhale/README.md @@ -10,21 +10,17 @@ It should already be deployed, but if not you can deploy it by running cf push notify-admin-failwhale -To direct traffic to it you need to update the routes by running +To enable it you need to run - cf map-route notify-admin-failwhale [ENVIRONMENT-URL] --hostname www - cf unmap-route notify-admin [ENVIRONMENT-URL] --hostname www + make enable-failwhale -To remove admin failwhale: +and to disable it - cf map-route notify-admin [ENVIRONMENT-URL] --hostname www - cf unmap-route notify-admin-failwhale [ENVIRONMENT-URL] --hostname www + make disable-failwhale -Where [ENVIRONMENT-URL] would be one of: +Where `` is any of -- notify.works for preview -- notify-staging.works for staging -- notifications.service.gov.uk for production - -**Make sure you are on the correct environment!** +- preview +- staging +- production diff --git a/requirements_for_test.txt b/requirements_for_test.txt index b3196465e..7e0d0651e 100644 --- a/requirements_for_test.txt +++ b/requirements_for_test.txt @@ -1,5 +1,5 @@ -r requirements.txt -pytest==3.3.1 +pytest==3.3.2 pytest-mock==1.6.3 pytest-cov==2.5.1 pytest-xdist==1.21.0 @@ -8,3 +8,4 @@ httpretty==0.8.14 beautifulsoup4==4.6.0 freezegun==0.3.9 flake8==3.5.0 +flake8-print==3.0.1 diff --git a/scripts/aws_change_ownership.sh b/scripts/aws_change_ownership.sh deleted file mode 100755 index e800956d3..000000000 --- a/scripts/aws_change_ownership.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -echo "Chown application to be owned by ubuntu" -cd /home/notify-app/; -chown -R notify-app:govuk-notify-applications notifications-admin \ No newline at end of file diff --git a/scripts/aws_clear_instance.sh b/scripts/aws_clear_instance.sh deleted file mode 100755 index 1b159ddb3..000000000 --- a/scripts/aws_clear_instance.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -echo "Removing application and dependencies" - -if [ -d "/home/notify-app/notifications-admin" ]; then - # Remove and re-create the directory - rm -rf /home/notify-app/notifications-admin - mkdir -p /home/notify-app/notifications-admin -fi - diff --git a/scripts/aws_install_dependencies.sh b/scripts/aws_install_dependencies.sh deleted file mode 100755 index ccf5a6578..000000000 --- a/scripts/aws_install_dependencies.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -echo "Install dependencies" -cd /home/notify-app/notifications-admin; -pip3 install --find-links=vendor -r /home/notify-app/notifications-admin/requirements.txt diff --git a/scripts/aws_start_app.sh b/scripts/aws_start_app.sh deleted file mode 100755 index b4082672a..000000000 --- a/scripts/aws_start_app.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -echo "Starting application" -sudo service notifications-admin start \ No newline at end of file diff --git a/scripts/aws_stop_app.sh b/scripts/aws_stop_app.sh deleted file mode 100755 index 2b90414a1..000000000 --- a/scripts/aws_stop_app.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - - -function error_exit -{ - echo "$1" 1>&2 - exit 0 -} - -echo "Stopping application" -if sudo service notifications-admin stop; then - exit 0 -else - error_exit "Could not stop application" -fi \ No newline at end of file diff --git a/scripts/common_functions.sh b/scripts/common_functions.sh deleted file mode 100644 index d9fa96b3b..000000000 --- a/scripts/common_functions.sh +++ /dev/null @@ -1,384 +0,0 @@ -#!/bin/bash -# -# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://aws.amazon.com/apache2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. - -# ELB_LIST defines which Elastic Load Balancers this instance should be part of. -# The elements in ELB_LIST should be seperated by space. -ELB_LIST="" - -# Under normal circumstances, you shouldn't need to change anything below this line. -# ----------------------------------------------------------------------------- - -export PATH="$PATH:/usr/bin:/usr/local/bin" - -# If true, all messages will be printed. If false, only fatal errors are printed. -DEBUG=true - -# Number of times to check for a resouce to be in the desired state. -WAITER_ATTEMPTS=60 - -# Number of seconds to wait between attempts for resource to be in a state. -WAITER_INTERVAL=1 - -# AutoScaling Standby features at minimum require this version to work. -MIN_CLI_VERSION='1.3.25' - -# Usage: get_instance_region -# -# Writes to STDOUT the AWS region as known by the local instance. -get_instance_region() { - if [ -z "$AWS_REGION" ]; then - AWS_REGION=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \ - | grep -i region \ - | awk -F\" '{print $4}') - fi - - echo $AWS_REGION -} - -AWS_CLI="aws --region $(get_instance_region)" - - -# Usage: get_instance_state_asg -# -# Gets the state of the given as known by the AutoScaling group it's a part of. -# Health is printed to STDOUT and the function returns 0. Otherwise, no output and return is -# non-zero. -get_instance_state_asg() { - local instance_id=$1 - - local state=$($AWS_CLI autoscaling describe-auto-scaling-instances \ - --instance-ids $instance_id \ - --query "AutoScalingInstances[?InstanceId == \`$instance_id\`].LifecycleState | [0]" \ - --output text) - if [ $? != 0 ]; then - return 1 - else - echo $state - return 0 - fi -} - -reset_waiter_timeout() { - local elb=$1 - local state_name=$2 - - if [ "$state_name" == "InService" ]; then - - # Wait for a health check to succeed - local timeout=$($AWS_CLI elb describe-load-balancers \ - --load-balancer-name $elb \ - --query 'LoadBalancerDescriptions[0].HealthCheck.Timeout') - - elif [ "$state_name" == "OutOfService" ]; then - - # If connection draining is enabled, wait for connections to drain - local draining_values=$($AWS_CLI elb describe-load-balancer-attributes \ - --load-balancer-name $elb \ - --query 'LoadBalancerAttributes.ConnectionDraining.[Enabled,Timeout]' \ - --output text) - local draining_enabled=$(echo $draining_values | awk '{print $1}') - local timeout=$(echo $draining_values | awk '{print $2}') - - if [ "$draining_enabled" != "True" ]; then - timeout=0 - fi - - else - msg "Unknown state name, '$state_name'"; - return 1; - fi - - # Base register/deregister action may take up to about 30 seconds - timeout=$((timeout + 60)) - - WAITER_ATTEMPTS=$((timeout / WAITER_INTERVAL)) -} - -# Usage: wait_for_state [ELB name] -# -# Waits for the state of to be in as seen by . Returns 0 if -# it successfully made it to that state; non-zero if not. By default, checks $WAITER_ATTEMPTS -# times, every $WAITER_INTERVAL seconds. If giving an [ELB name] to check under, these are reset -# to that ELB's timeout values. -wait_for_state() { - local service=$1 - local instance_id=$2 - local state_name=$3 - local elb=$4 - - local instance_state_cmd - if [ "$service" == "elb" ]; then - instance_state_cmd="get_instance_health_elb $instance_id $elb" - reset_waiter_timeout $elb $state_name - if [ $? != 0 ]; then - error_exit "Failed resetting waiter timeout for $elb" - fi - elif [ "$service" == "autoscaling" ]; then - instance_state_cmd="get_instance_state_asg $instance_id" - else - msg "Cannot wait for instance state; unknown service type, '$service'" - return 1 - fi - - msg "Checking $WAITER_ATTEMPTS times, every $WAITER_INTERVAL seconds, for instance $instance_id to be in state $state_name" - - local instance_state=$($instance_state_cmd) - local count=1 - - msg "Instance is currently in state: $instance_state" - while [ "$instance_state" != "$state_name" ]; do - if [ $count -ge $WAITER_ATTEMPTS ]; then - local timeout=$(($WAITER_ATTEMPTS * $WAITER_INTERVAL)) - msg "Instance failed to reach state, $state_name within $timeout seconds" - return 1 - fi - - sleep $WAITER_INTERVAL - - instance_state=$($instance_state_cmd) - count=$(($count + 1)) - msg "Instance is currently in state: $instance_state" - done - - return 0 -} - -# Usage: get_instance_health_elb -# -# Gets the health of the given as known by . If it's a valid health -# status (one of InService|OutOfService|Unknown), then the health is printed to STDOUT and the -# function returns 0. Otherwise, no output and return is non-zero. -get_instance_health_elb() { - local instance_id=$1 - local elb_name=$2 - - msg "Checking status of instance '$instance_id' in load balancer '$elb_name'" - - # If describe-instance-health for this instance returns an error, then it's not part of - # this ELB. But, if the call was successful let's still double check that the status is - # valid. - local instance_status=$($AWS_CLI elb describe-instance-health \ - --load-balancer-name $elb_name \ - --instances $instance_id \ - --query 'InstanceStates[].State' \ - --output text 2>/dev/null) - - if [ $? == 0 ]; then - case "$instance_status" in - InService|OutOfService|Unknown) - echo -n $instance_status - return 0 - ;; - *) - msg "Instance '$instance_id' not part of ELB '$elb_name'" - return 1 - esac - fi -} - -# Usage: validate_elb -# -# Validates that the Elastic Load Balancer with name exists, is describable, and -# contains as one of its instances. -# -# If any of these checks are false, the function returns non-zero. -validate_elb() { - local instance_id=$1 - local elb_name=$2 - - # Get the list of active instances for this LB. - local elb_instances=$($AWS_CLI elb describe-load-balancers \ - --load-balancer-name $elb_name \ - --query 'LoadBalancerDescriptions[*].Instances[*].InstanceId' \ - --output text) - if [ $? != 0 ]; then - msg "Couldn't describe ELB instance named '$elb_name'" - return 1 - fi - - msg "Checking health of '$instance_id' as known by ELB '$elb_name'" - local instance_health=$(get_instance_health_elb $instance_id $elb_name) - if [ $? != 0 ]; then - return 1 - fi - - return 0 -} - -# Usage: get_elb_list -# -# Ensures that this instance is related to the named ELB. After execution, the variable -# "ELB_LIST" will contain the list of load balancers for the given instance. -# -# If the given instance ID isn't found registered to any ELBs, the function returns non-zero -get_elb_list() { - local instance_id=$1 - local required_elb=$2 - local elb_list="" - - msg "Looking up from ELB list" - local all_balancers=$($AWS_CLI elb describe-load-balancers \ - --query LoadBalancerDescriptions[*].LoadBalancerName \ - --output text | sed -e $'s/\t/ /g') - - if [[ $all_balancers =~ $required_elb ]] - then - local instance_health - instance_health=$(get_instance_health_elb $instance_id $required_elb) - if [ $? == 0 ]; then - elb_list="$elb_list $required_elb" - fi - fi - - if [ -z "$elb_list" ]; then - return 1 - else - msg "Got load balancer list of: $elb_list" - ELB_LIST=$elb_list - return 0 - fi -} - -# Usage: deregister_instance -# -# Deregisters from . -deregister_instance() { - local instance_id=$1 - local elb_name=$2 - - $AWS_CLI elb deregister-instances-from-load-balancer \ - --load-balancer-name $elb_name \ - --instances $instance_id 1> /dev/null - - return $? -} - -# Usage: register_instance -# -# Registers to . -register_instance() { - local instance_id=$1 - local elb_name=$2 - - $AWS_CLI elb register-instances-with-load-balancer \ - --load-balancer-name $elb_name \ - --instances $instance_id 1> /dev/null - - return $? -} - -# Usage: check_cli_version [version-to-check] [desired version] -# -# Without any arguments, checks that the installed version of the AWS CLI is at least at version -# $MIN_CLI_VERSION. Returns non-zero if the version is not high enough. -check_cli_version() { - if [ -z $1 ]; then - version=$($AWS_CLI --version 2>&1 | cut -f1 -d' ' | cut -f2 -d/) - else - version=$1 - fi - - if [ -z "$2" ]; then - min_version=$MIN_CLI_VERSION - else - min_version=$2 - fi - - x=$(echo $version | cut -f1 -d.) - y=$(echo $version | cut -f2 -d.) - z=$(echo $version | cut -f3 -d.) - - min_x=$(echo $min_version | cut -f1 -d.) - min_y=$(echo $min_version | cut -f2 -d.) - min_z=$(echo $min_version | cut -f3 -d.) - - msg "Checking minimum required CLI version (${min_version}) against installed version ($version)" - - if [ $x -lt $min_x ]; then - return 1 - elif [ $y -lt $min_y ]; then - return 1 - elif [ $y -gt $min_y ]; then - return 0 - elif [ $z -ge $min_z ]; then - return 0 - else - return 1 - fi -} - -# Usage: msg -# -# Writes to STDERR only if $DEBUG is true, otherwise has no effect. -msg() { - local message=$1 - $DEBUG && echo $message 1>&2 -} - -# Usage: error_exit -# -# Writes to STDERR as a "fatal" and immediately exits the currently running script. -error_exit() { - local message=$1 - - echo "[FATAL] $message" 1>&2 - exit 1 -} - -# Usage: get_instance_id -# -# Writes to STDOUT the EC2 instance ID for the local instance. Returns non-zero if the local -# instance metadata URL is inaccessible. -get_instance_id() { - curl -s http://169.254.169.254/latest/meta-data/instance-id - return $? -} - -# Usage: get_instance_name_from_tags -# -# Looks up tags for the given instance, extracting the 'name' -# returns or error_exit -get_instance_name_from_tags() { - local instance_id=$1 - - local instance_name=$($AWS_CLI ec2 describe-tags \ - --filters "Name=resource-id,Values=${instance_id}" \ - --query Tags[0].Value \ - --output text) - if [ $? != 0 ]; then - error_exit "Couldn't get instance name for '$instance_id'" - fi - echo $instance_name - return $? -} - -ELB_NAME="" - -get_elb_name_for_instance_name() { - local instance_name=$1 - - declare -A elb_to_instance_mapping - - elb_to_instance_mapping['notify-admin']='notify-admin' - - elb_to_instance_mapping['notify-admin-ags']='notify-admin-ags' - - local elb_name=${elb_to_instance_mapping[${instance_name}]} - if [ -z $elb_name ]; then - msg "No ELB for instance ${instance_name}" - else - ELB_NAME=$elb_name - fi -} diff --git a/scripts/deregister_from_elb.sh b/scripts/deregister_from_elb.sh deleted file mode 100755 index 62bab1612..000000000 --- a/scripts/deregister_from_elb.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/bash -# -# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://aws.amazon.com/apache2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. - -. $(dirname $0)/common_functions.sh - -msg "Running AWS CLI with region: $(get_instance_region)" - -# get this instance's ID -INSTANCE_ID=$(get_instance_id) -if [ $? != 0 -o -z "$INSTANCE_ID" ]; then - error_exit "Unable to get this instance's ID; cannot continue." -fi - -# Get current time -msg "Started $(basename $0) at $(/bin/date "+%F %T")" -start_sec=$(/bin/date +%s.%N) - -msg "Getting relevant load balancer" -INSTANCE_NAME=$(get_instance_name_from_tags $INSTANCE_ID) -get_elb_name_for_instance_name $INSTANCE_NAME -get_elb_list $INSTANCE_ID $ELB_NAME - -msg "Checking that user set at least one load balancer" -if test -z "$ELB_LIST"; then - error_exit "Must have at least one load balancer to deregister from" -fi - -# Loop through all LBs the user set, and attempt to deregister this instance from them. -for elb in $ELB_LIST; do - msg "Checking validity of load balancer named '$elb'" - validate_elb $INSTANCE_ID $elb - if [ $? != 0 ]; then - msg "Error validating $elb; cannot continue with this LB" - continue - fi - - msg "Deregistering $INSTANCE_ID from $elb" - deregister_instance $INSTANCE_ID $elb - - if [ $? != 0 ]; then - error_exit "Failed to deregister instance $INSTANCE_ID from ELB $elb" - fi -done - -# Wait for all Deregistrations to finish -msg "Waiting for instance to de-register from its load balancers" -for elb in $ELB_LIST; do - wait_for_state "elb" $INSTANCE_ID "OutOfService" $elb - if [ $? != 0 ]; then - error_exit "Failed waiting for $INSTANCE_ID to leave $elb" - fi -done - -msg "Finished $(basename $0) at $(/bin/date "+%F %T")" - -end_sec=$(/bin/date +%s.%N) -elapsed_seconds=$(echo "$end_sec - $start_sec" | /usr/bin/bc) - -msg "Elapsed time: $elapsed_seconds" diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py new file mode 100755 index 000000000..ce7982763 --- /dev/null +++ b/scripts/generate_manifest.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import sys + +import json +import yaml + + +def merge_dicts(a, b): + if not (isinstance(a, dict) and isinstance(b, dict)): + raise ValueError("Error merging variables: '{}' and '{}'".format( + type(a).__name__, type(b).__name__ + )) + + result = a.copy() + for key, val in b.items(): + if isinstance(result.get(key), dict): + result[key] = merge_dicts(a[key], b[key]) + else: + result[key] = val + + return result + + +def load_manifest(manifest_file): + with open(manifest_file) as f: + manifest = yaml.load(f) + + if 'inherit' in manifest: + inherit_file = os.path.join(os.path.dirname(manifest_file), manifest.pop('inherit')) + manifest = merge_dicts(load_manifest(inherit_file), manifest) + + return manifest + + +def load_variables(vars_files): + variables = {} + for vars_file in vars_files: + with open(vars_file) as f: + variables = merge_dicts(variables, yaml.load(f)) + + return { + k.upper(): json.dumps(v) if isinstance(v, (dict, list)) else v + for k, v in variables.items() + } + + +def paas_manifest(manifest_file, *vars_files): + manifest = load_manifest(manifest_file) + variables = load_variables(vars_files) + + for key in manifest.get('env', {}): + if key in variables: + manifest['env'][key] = variables[key] + + return yaml.dump(manifest, default_flow_style=False, allow_unicode=True) + + +if __name__ == "__main__": + print('---') # noqa + print(paas_manifest(*sys.argv[1:])) # noqa diff --git a/scripts/register_with_elb.sh b/scripts/register_with_elb.sh deleted file mode 100755 index 7cc78006c..000000000 --- a/scripts/register_with_elb.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash -# -# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://aws.amazon.com/apache2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. - -. $(dirname $0)/common_functions.sh - -msg "Running AWS CLI with region: $(get_instance_region)" - -# get this instance's ID -INSTANCE_ID=$(get_instance_id) -if [ $? != 0 -o -z "$INSTANCE_ID" ]; then - error_exit "Unable to get this instance's ID; cannot continue." -fi - -# Get current time -msg "Started $(basename $0) at $(/bin/date "+%F %T")" -start_sec=$(/bin/date +%s.%N) - -msg "Getting relevant load balancer" -INSTANCE_NAME=$(get_instance_name_from_tags $INSTANCE_ID) -get_elb_name_for_instance_name $INSTANCE_NAME -ELB_LIST=$ELB_NAME -get_elb_list $INSTANCE_ID $ELB_NAME - -msg "Checking that user set at least one load balancer" -if test -z "$ELB_LIST"; then - error_exit "Must have at least one load balancer to register to" -fi - -# Loop through all LBs the user set, and attempt to register this instance to them. -for elb in $ELB_LIST; do - msg "Checking validity of load balancer named '$elb'" - validate_elb $INSTANCE_ID $elb - if [ $? != 0 ]; then - msg "Error validating $elb; cannot continue with this LB" - continue - fi - - msg "Registering $INSTANCE_ID to $elb" - register_instance $INSTANCE_ID $elb - - if [ $? != 0 ]; then - error_exit "Failed to register instance $INSTANCE_ID from ELB $elb" - fi -done - -# Wait for all Registrations to finish -msg "Waiting for instance to register to its load balancers" -for elb in $ELB_LIST; do - wait_for_state "elb" $INSTANCE_ID "InService" $elb - if [ $? != 0 ]; then - error_exit "Failed waiting for $INSTANCE_ID to return to $elb" - fi -done - -msg "Finished $(basename $0) at $(/bin/date "+%F %T")" - -end_sec=$(/bin/date +%s.%N) -elapsed_seconds=$(echo "$end_sec - $start_sec" | /usr/bin/bc) - -msg "Elapsed time: $elapsed_seconds" diff --git a/scripts/run_app_paas.sh b/scripts/run_app_paas.sh index 499cfeb2b..9abb9fcae 100755 --- a/scripts/run_app_paas.sh +++ b/scripts/run_app_paas.sh @@ -18,9 +18,6 @@ function check_params { function configure_aws_logs { aws configure set plugins.cwlogs cwlogs - export AWS_ACCESS_KEY_ID=$(echo ${VCAP_SERVICES} | jq -r '.["user-provided"][]|select(.name=="notify-aws")|.credentials.aws_access_key_id') - export AWS_SECRET_ACCESS_KEY=$(echo ${VCAP_SERVICES} | jq -r '.["user-provided"][]|select(.name=="notify-aws")|.credentials.aws_secret_access_key') - cat > /home/vcap/app/awslogs.conf << EOF [general] state_file = /home/vcap/logs/awslogs-state diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index d8d6b5a39..2b5da0565 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -28,7 +28,7 @@ if [[ -z "$VIRTUAL_ENV" ]] && [[ -d venv ]]; then source ./venv/bin/activate fi -flake8 . +flake8 --enable=T . display_result $? 1 "Code style check" npm test diff --git a/tests/__init__.py b/tests/__init__.py index 262b008e5..6fef78218 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -100,6 +100,9 @@ def template_json(service_id, archived=False, process_type='normal', redact_personalisation=None, + service_letter_contact=None, + reply_to=None, + reply_to_text=None, ): template = { 'id': id_, @@ -111,6 +114,9 @@ def template_json(service_id, 'updated_at': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f'), 'archived': archived, 'process_type': process_type, + 'service_letter_contact': service_letter_contact, + 'reply_to': reply_to, + 'reply_to_text': reply_to_text, } if content is None: template['content'] = "template content" @@ -236,6 +242,7 @@ def notification_json( rows=5, personalisation=None, template_type=None, + reply_to_text=None ): if template is None: template = template_json(service_id, str(generate_uuid()), type_=template_type) @@ -283,6 +290,7 @@ def notification_json( 'template_version': template['version'], 'personalisation': personalisation or {}, 'notification_type': template_type, + 'reply_to_text': reply_to_text, } for i in range(rows)], 'total': rows, 'page_size': 50, @@ -368,7 +376,6 @@ def validate_route_permission(mocker, else: pytest.fail("Invalid method call {}".format(method)) if resp.status_code != response_code: - print(resp.status_code) pytest.fail("Invalid permissions set for endpoint {}".format(route)) return resp @@ -403,6 +410,5 @@ def validate_route_permission_with_client(mocker, else: pytest.fail("Invalid method call {}".format(method)) if resp.status_code != response_code: - print(resp.status_code) pytest.fail("Invalid permissions set for endpoint {}".format(route)) return resp diff --git a/tests/app/main/test_validators.py b/tests/app/main/test_validators.py index f16e71a2c..43f094cbc 100644 --- a/tests/app/main/test_validators.py +++ b/tests/app/main/test_validators.py @@ -100,6 +100,8 @@ def _gen_mock_field(x): 'test@acas.org.uk', 'test@biglotteryfund.org.uk', 'test@marinemanagement.org.uk', + 'test@britishmuseum.org', + 'test@derrystrabane.com', ]) def test_valid_list_of_white_list_email_domains( client, diff --git a/tests/app/main/views/test_api_integration.py b/tests/app/main/views/test_api_integration.py new file mode 100644 index 000000000..aa2ec3e46 --- /dev/null +++ b/tests/app/main/views/test_api_integration.py @@ -0,0 +1,639 @@ +import uuid +from collections import OrderedDict + +import pytest +from flask import url_for +from bs4 import BeautifulSoup +from unittest.mock import call + +from tests import validate_route_permission +from tests.conftest import ( + mock_get_service, + mock_get_live_service, + mock_get_service_with_letters, + normalize_spaces, + SERVICE_ONE_ID, + mock_get_valid_service_callback_api, + mock_get_valid_service_inbound_api, +) + + +def test_should_show_api_page( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions, + mock_get_notifications +): + response = logged_in_client.get(url_for('main.api_integration', service_id=str(uuid.uuid4()))) + assert response.status_code == 200 + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + assert page.h1.string.strip() == 'API integration' + rows = page.find_all('details') + assert len(rows) == 5 + for index, row in enumerate(rows): + assert row.find('h3').string.strip() == '07123456789' + + +def test_should_show_api_page_with_lots_of_notifications( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions, + mock_get_notifications_with_previous_next +): + response = logged_in_client.get(url_for('main.api_integration', service_id=str(uuid.uuid4()))) + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + rows = page.find_all('div', {'class': 'api-notifications-item'}) + assert ' '.join(rows[len(rows) - 1].text.split()) == ( + 'Only showing the first 50 messages. Notify deletes messages after 7 days.' + ) + + +def test_should_show_api_page_with_no_notifications( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions, + mock_get_notifications_with_no_notifications +): + response = logged_in_client.get(url_for('main.api_integration', service_id=str(uuid.uuid4()))) + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + rows = page.find_all('div', {'class': 'api-notifications-item'}) + assert 'When you send messages via the API they’ll appear here.' in rows[len(rows) - 1].text.strip() + + +def test_should_show_api_page_for_live_service( + logged_in_client, + mock_login, + api_user_active, + mock_get_live_service, + mock_has_permissions +): + response = logged_in_client.get(url_for('main.api_integration', service_id=str(uuid.uuid4()))) + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + assert 'Your service is in trial mode' not in page.find('main').text + + +def test_api_documentation_page_should_redirect( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions +): + response = logged_in_client.get(url_for('main.api_documentation', service_id=str(uuid.uuid4()))) + assert response.status_code == 301 + assert response.location == url_for( + 'main.documentation', + _external=True + ) + + +def test_should_show_empty_api_keys_page( + client, + api_user_pending, + mock_login, + mock_get_no_api_keys, + mock_get_service, + mock_has_permissions, +): + client.login(api_user_pending) + service_id = str(uuid.uuid4()) + response = client.get(url_for('main.api_keys', service_id=service_id)) + + assert response.status_code == 200 + assert 'You haven’t created any API keys yet' in response.get_data(as_text=True) + assert 'Create an API key' in response.get_data(as_text=True) + mock_get_no_api_keys.assert_called_once_with(service_id=service_id) + + +def test_should_show_api_keys_page( + logged_in_client, + api_user_active, + mock_login, + mock_get_api_keys, + mock_get_service, + mock_has_permissions, + fake_uuid, +): + response = logged_in_client.get(url_for('main.api_keys', service_id=fake_uuid)) + + assert response.status_code == 200 + resp_data = response.get_data(as_text=True) + assert 'some key name' in resp_data + assert 'another key name' in resp_data + assert 'Revoked 1 January at 1:00am' in resp_data + mock_get_api_keys.assert_called_once_with(service_id=fake_uuid) + + +@pytest.mark.parametrize('service_mock, expected_options', [ + (mock_get_service, [ + ( + 'Live – sends to anyone ' + 'Not available because your service is in trial mode' + ), + 'Team and whitelist – limits who you can send to', + 'Test – pretends to send messages', + ]), + (mock_get_live_service, [ + 'Live – sends to anyone', + 'Team and whitelist – limits who you can send to', + 'Test – pretends to send messages', + ]), + (mock_get_service_with_letters, [ + 'Live – sends to anyone', + ( + 'Team and whitelist – limits who you can send to ' + 'Can’t be used to send letters' + ), + 'Test – pretends to send messages', + ]), +]) +def test_should_show_create_api_key_page( + client_request, + mocker, + api_user_active, + mock_get_api_keys, + service_mock, + expected_options, +): + service_mock(mocker, api_user_active) + + page = client_request.get('main.create_api_key', service_id=SERVICE_ONE_ID) + + for index, option in enumerate(expected_options): + assert normalize_spaces(page.select('.block-label')[index].text) == option + + +def test_should_create_api_key_with_type_normal( + logged_in_client, + api_user_active, + mock_login, + mock_get_api_keys, + mock_get_live_service, + mock_has_permissions, + fake_uuid, + mocker, +): + post = mocker.patch('app.notify_client.api_key_api_client.ApiKeyApiClient.post', return_value={'data': fake_uuid}) + service_id = str(uuid.uuid4()) + + response = logged_in_client.post( + url_for('main.create_api_key', service_id=service_id), + data={ + 'key_name': 'Some default key name 1/2', + 'key_type': 'normal' + } + ) + + assert response.status_code == 200 + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + keys = page.find_all('span', {'class': 'api-key-key'}) + for index, key in enumerate([ + 'some_default_key_name_12-{}-{}'.format(service_id, fake_uuid), + service_id, + fake_uuid + ]): + assert keys[index].text.strip() == key + + post.assert_called_once_with(url='/service/{}/api-key'.format(service_id), data={ + 'name': 'Some default key name 1/2', + 'key_type': 'normal', + 'created_by': api_user_active.id + }) + + +def test_cant_create_normal_api_key_in_trial_mode( + logged_in_client, + api_user_active, + mock_login, + mock_get_api_keys, + mock_get_service, + mock_has_permissions, + fake_uuid, + mocker, +): + mock_post = mocker.patch('app.notify_client.api_key_api_client.ApiKeyApiClient.post') + + response = logged_in_client.post( + url_for('main.create_api_key', service_id=uuid.uuid4()), + data={ + 'key_name': 'some default key name', + 'key_type': 'normal' + } + ) + assert response.status_code == 400 + mock_post.assert_not_called() + + +def test_should_show_confirm_revoke_api_key( + client_request, + mock_get_api_keys, + fake_uuid, +): + page = client_request.get( + 'main.revoke_api_key', service_id=SERVICE_ONE_ID, key_id=fake_uuid, + _test_page_title=False, + ) + assert normalize_spaces(page.select('.banner-dangerous')[0].text) == ( + 'Are you sure you want to revoke this API key? ' + '‘some key name’ will no longer let you connect to GOV.UK Notify.' + ) + assert mock_get_api_keys.call_args_list == [ + call( + key_id=fake_uuid, + service_id='596364a0-858e-42c8-9062-a8fe822260eb', + ), + call( + service_id='596364a0-858e-42c8-9062-a8fe822260eb' + ), + ] + + +def test_should_redirect_after_revoking_api_key( + logged_in_client, + api_user_active, + mock_login, + mock_revoke_api_key, + mock_get_api_keys, + mock_get_service, + mock_has_permissions, + fake_uuid, +): + response = logged_in_client.post(url_for('main.revoke_api_key', service_id=fake_uuid, key_id=fake_uuid)) + + assert response.status_code == 302 + assert response.location == url_for('.api_keys', service_id=fake_uuid, _external=True) + mock_revoke_api_key.assert_called_once_with(service_id=fake_uuid, key_id=fake_uuid) + mock_get_api_keys.assert_called_once_with(service_id=fake_uuid, key_id=fake_uuid) + + +@pytest.mark.parametrize('route', [ + 'main.api_keys', + 'main.create_api_key', + 'main.revoke_api_key' +]) +def test_route_permissions( + mocker, + app_, + api_user_active, + service_one, + mock_get_api_keys, + route, +): + with app_.test_request_context(): + validate_route_permission( + mocker, + app_, + "GET", + 200, + url_for(route, service_id=service_one['id'], key_id=123), + ['manage_api_keys'], + api_user_active, + service_one) + + +@pytest.mark.parametrize('route', [ + 'main.api_keys', + 'main.create_api_key', + 'main.revoke_api_key' +]) +def test_route_invalid_permissions( + mocker, + app_, + api_user_active, + service_one, + mock_get_api_keys, + route, +): + with app_.test_request_context(): + validate_route_permission( + mocker, + app_, + "GET", + 403, + url_for(route, service_id=service_one['id'], key_id=123), + ['view_activity'], + api_user_active, + service_one) + + +def test_should_show_whitelist_page( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions, + mock_get_whitelist, +): + response = logged_in_client.get(url_for('main.whitelist', service_id=str(uuid.uuid4()))) + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + textboxes = page.find_all('input', {'type': 'text'}) + for index, value in enumerate( + ['test@example.com'] + [''] * 4 + ['07900900000'] + [''] * 4 + ): + assert textboxes[index]['value'] == value + + +def test_should_update_whitelist( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions, + mock_update_whitelist +): + service_id = str(uuid.uuid4()) + data = OrderedDict([ + ('email_addresses-1', 'test@example.com'), + ('email_addresses-3', 'test@example.com'), + ('phone_numbers-0', '07900900000'), + ('phone_numbers-2', '+1800-555-555'), + ]) + + logged_in_client.post( + url_for('main.whitelist', service_id=service_id), + data=data + ) + + mock_update_whitelist.assert_called_once_with(service_id, { + 'email_addresses': ['test@example.com', 'test@example.com'], + 'phone_numbers': ['07900900000', '+1800-555-555']}) + + +def test_should_validate_whitelist_items( + logged_in_client, + mock_login, + api_user_active, + mock_get_service, + mock_has_permissions, + mock_update_whitelist +): + + response = logged_in_client.post( + url_for('main.whitelist', service_id=str(uuid.uuid4())), + data=OrderedDict([ + ('email_addresses-1', 'abc'), + ('phone_numbers-0', '123') + ]) + ) + + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + assert page.h1.string.strip() == 'There was a problem with your whitelist' + jump_links = page.select('.banner-dangerous a') + + assert jump_links[0].string.strip() == 'Enter valid email addresses' + assert jump_links[0]['href'] == '#email_addresses' + + assert jump_links[1].string.strip() == 'Enter valid phone numbers' + assert jump_links[1]['href'] == '#phone_numbers' + + mock_update_whitelist.assert_not_called() + + +@pytest.mark.parametrize('endpoint', [ + ('main.delivery_status_callback'), + ('main.received_text_messages_callback'), +]) +@pytest.mark.parametrize('url, bearer_token, expected_errors', [ + ("", "", "Can’t be empty Can’t be empty"), + ("http://not_https.com", "1234567890", "Must be a valid https URL"), + ("https://test.com", "123456789", "Must be at least 10 characters"), +]) +def test_callback_forms_validation( + client_request, + service_one, + endpoint, + url, + bearer_token, + expected_errors +): + if endpoint == 'main.received_text_messages_callback': + service_one['permissions'] = ['inbound_sms'] + + data = { + "url": url, + "bearer_token": bearer_token, + } + + response = client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + _expected_status=200 + ) + error_msgs = ' '.join(msg.text.strip() for msg in response.select(".error-message")) + + assert error_msgs == expected_errors + + +@pytest.mark.parametrize('has_inbound_sms, expected_link', [ + (True, 'main.api_callbacks'), + (False, 'main.delivery_status_callback'), +]) +def test_callbacks_button_links_straight_to_delivery_status_if_service_has_no_inbound_sms( + client_request, + service_one, + mocker, + mock_get_notifications, + has_inbound_sms, + expected_link +): + if has_inbound_sms: + service_one['permissions'] = ['inbound_sms'] + + page = client_request.get( + 'main.api_integration', + service_id=service_one['id'], + ) + + assert page.select('.pill-separate-item')[2]['href'] == url_for( + expected_link, service_id=service_one['id'] + ) + + +def test_callbacks_page_redirects_to_delivery_status_if_service_has_no_inbound_sms( + client_request, + service_one, + mocker +): + page = client_request.get( + 'main.api_callbacks', + service_id=service_one['id'], + _follow_redirects=True, + ) + + assert normalize_spaces(page.select_one('h1').text) == "Callbacks for delivery receipts" + + +@pytest.mark.parametrize('has_inbound_sms, expected_link', [ + (True, 'main.api_callbacks'), + (False, 'main.api_integration'), +]) +def test_back_link_directs_to_api_integration_from_delivery_callback_if_no_inbound_sms( + client_request, + service_one, + mocker, + has_inbound_sms, + expected_link +): + if has_inbound_sms: + service_one['permissions'] = ['inbound_sms'] + + page = client_request.get( + 'main.delivery_status_callback', + service_id=service_one['id'], + _follow_redirects=True, + ) + + assert page.select_one('.page-footer-back-link')['href'] == url_for( + expected_link, service_id=service_one['id'] + ) + + +@pytest.mark.parametrize('endpoint', [ + ('main.delivery_status_callback'), + ('main.received_text_messages_callback'), +]) +def test_create_delivery_status_and_receive_text_message_callbacks( + client_request, + service_one, + mocker, + mock_get_notifications, + mock_create_service_inbound_api, + mock_create_service_callback_api, + endpoint, + fake_uuid, +): + if endpoint == 'main.received_text_messages_callback': + service_one['permissions'] = ['inbound_sms'] + + data = { + 'url': "https://test.url.com/", + 'bearer_token': '1234567890', + 'user_id': fake_uuid + } + + client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + ) + + if endpoint == 'main.received_text_messages_callback': + mock_create_service_inbound_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + ) + else: + mock_create_service_callback_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + ) + + +@pytest.mark.parametrize('endpoint, fixture', [ + ('main.delivery_status_callback', mock_get_valid_service_callback_api), + ('main.received_text_messages_callback', mock_get_valid_service_inbound_api), +]) +def test_update_delivery_status_and_receive_text_message_callbacks( + client_request, + service_one, + mocker, + mock_get_notifications, + mock_update_service_inbound_api, + mock_update_service_callback_api, + endpoint, + fixture, + fake_uuid, +): + if endpoint == 'main.received_text_messages_callback': + service_one['inbound_api'] = [fake_uuid] + service_one['permissions'] = ['inbound_sms'] + else: + service_one['service_callback_api'] = [fake_uuid] + + fixture(mocker) + + data = { + 'url': "https://test.url.com/", + 'bearer_token': '1234567890', + 'user_id': fake_uuid + } + + client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + ) + + if endpoint == 'main.received_text_messages_callback': + mock_update_service_inbound_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + inbound_api_id=fake_uuid, + ) + else: + mock_update_service_callback_api.assert_called_once_with( + service_one['id'], + url="https://test.url.com/", + bearer_token="1234567890", + user_id=fake_uuid, + callback_api_id=fake_uuid + ) + + +@pytest.mark.parametrize('endpoint, data, fixture', [ + ( + 'main.delivery_status_callback', + {"url": "https://hello2.gov.uk", "bearer_token": "bearer_token_set"}, + mock_get_valid_service_callback_api + ), + ( + 'main.received_text_messages_callback', + {"url": "https://hello3.gov.uk", "bearer_token": "bearer_token_set"}, + mock_get_valid_service_inbound_api + ), +]) +def test_update_delivery_status_and_receive_text_message_callbacks_without_changes_do_not_update( + client_request, + service_one, + mocker, + mock_get_notifications, + mock_update_service_callback_api, + mock_update_service_inbound_api, + data, + fixture, + endpoint, + fake_uuid, +): + if endpoint == 'main.received_text_messages_callback': + service_one['inbound_api'] = [fake_uuid] + service_one['permissions'] = ['inbound_sms'] + else: + service_one['service_callback_api'] = [fake_uuid] + + fixture(mocker) + + data['user_id'] = fake_uuid + + client_request.post( + endpoint, + service_id=service_one['id'], + _data=data, + ) + + if endpoint == 'main.received_text_messages_callback': + assert mock_update_service_inbound_api.called is False + else: + assert mock_update_service_callback_api.called is False diff --git a/tests/app/main/views/test_notifications.py b/tests/app/main/views/test_notifications.py index ff2e94890..2ef218748 100644 --- a/tests/app/main/views/test_notifications.py +++ b/tests/app/main/views/test_notifications.py @@ -109,13 +109,19 @@ def test_notification_page_doesnt_link_to_template_in_tour( @freeze_time("2016-01-01 01:01") -def test_notification_page_shows_status_of_letter_notification( +def test_notification_page_shows_page_for_letter_notification( client_request, mocker, fake_uuid, ): + count_of_pages = 3 + mock_get_notification(mocker, fake_uuid, template_type='letter') + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=count_of_pages + ) page = client_request.get( 'main.view_notification', @@ -131,6 +137,15 @@ def test_notification_page_shows_status_of_letter_notification( ) assert page.select('p.notification-status') == [] + letter_images = page.select('main img') + + assert len(letter_images) == count_of_pages + + for index in range(1, count_of_pages + 1): + assert page.select('img')[index]['src'].endswith( + '.png?page={}'.format(index) + ) + @pytest.mark.parametrize('filetype', [ 'pdf', 'png' @@ -193,6 +208,10 @@ def test_notification_page_has_link_to_send_another_for_sms( service_one['permissions'] = service_permissions mock_get_notification(mocker, fake_uuid, template_type=template_type) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) page = client_request.get( 'main.view_notification', @@ -237,6 +256,10 @@ def test_notification_page_has_link_to_download_letter( ): mock_get_notification(mocker, fake_uuid, template_type=template_type) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) page = client_request.get( 'main.view_notification', diff --git a/tests/app/main/views/test_service_settings.py b/tests/app/main/views/test_service_settings.py index 02d22db84..fb8cb7d76 100644 --- a/tests/app/main/views/test_service_settings.py +++ b/tests/app/main/views/test_service_settings.py @@ -908,6 +908,34 @@ def test_add_letter_contact( ) +def test_add_letter_contact_when_coming_from_template( + no_letter_contact_blocks, + client_request, + mock_add_letter_contact, + fake_uuid, + mock_get_service_letter_template, +): + data = { + 'letter_contact_block': "1 Example Street" + } + + page = client_request.post( + 'main.service_add_letter_contact', + service_id=SERVICE_ONE_ID, + _data=data, + from_template=fake_uuid, + _follow_redirects=True + ) + + mock_add_letter_contact.assert_called_once_with( + SERVICE_ONE_ID, + contact_block="1 Example Street", + is_default=True + ) + + assert page.find('h1').text == 'Set letter contact block' + + @pytest.mark.parametrize('fixture, data, api_default_args', [ (no_sms_senders, {}, True), (multiple_sms_senders, {}, False), diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index eb5ae48e0..d33982af2 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -11,6 +11,8 @@ from tests.conftest import ( mock_get_service_email_template, mock_get_service_letter_template, mock_get_service_template, + no_letter_contact_blocks, + single_letter_contact_block, normalize_spaces, SERVICE_ONE_ID, active_user_with_permissions, @@ -198,31 +200,7 @@ def test_should_show_sms_template_with_downgraded_unicode_characters( assert rendered_msg in response.get_data(as_text=True) -def test_should_let_letter_contact_block_be_edited_if_a_letter_contact_block_exists( - mocker, - mock_get_service_letter_template, - single_letter_contact_block, - client_request, - service_one, - fake_uuid, -): - service_one['permissions'].append('letter') - mocker.patch('app.main.views.templates.get_page_count_for_letter', return_value=1) - - page = client_request.get( - 'main.view_template', - service_id=SERVICE_ONE_ID, - template_id=fake_uuid - ) - - assert page.find('a', {'class': 'edit-template-link-letter-contact'})['href'] == url_for( - '.service_edit_letter_contact', - service_id=service_one['id'], - letter_contact_id='1234', - from_template=fake_uuid) - - -def test_should_let_letter_contact_block_be_added_if_no_letter_contact_blocks_exist( +def test_should_let_letter_contact_block_be_changed_for_the_template( mocker, mock_get_service_letter_template, no_letter_contact_blocks, @@ -240,9 +218,10 @@ def test_should_let_letter_contact_block_be_added_if_no_letter_contact_blocks_ex ) assert page.find('a', {'class': 'edit-template-link-letter-contact'})['href'] == url_for( - '.service_add_letter_contact', - service_id=service_one['id'], - from_template=fake_uuid) + 'main.set_template_sender', + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + ) def test_should_show_page_template_with_priority_select_if_platform_admin( @@ -1286,3 +1265,51 @@ def test_should_show_letter_template_as_dvla_markup( )) assert response.status_code == expected_response_code + + +def test_set_template_sender( + client_request, + fake_uuid, + mock_update_service_template_sender, + mock_get_service_letter_template, + single_letter_contact_block +): + data = { + 'sender': '1234', + } + + client_request.post( + 'main.set_template_sender', + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + _data=data, + ) + + mock_update_service_template_sender.assert_called_once_with( + SERVICE_ONE_ID, + fake_uuid, + '1234', + ) + + +@pytest.mark.parametrize('fixture, add_button_is_on_page', [ + (no_letter_contact_blocks, True), + (single_letter_contact_block, False), +]) +def test_add_sender_link_only_appears_on_services_with_no_senders( + client_request, + fake_uuid, + mocker, + fixture, + add_button_is_on_page, + mock_get_service_letter_template, + no_letter_contact_blocks +): + fixture(mocker) + page = client_request.get( + 'main.set_template_sender', + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + ) + + assert (page.select_one('.column-three-quarters form > a') is not None) == add_button_is_on_page diff --git a/tests/app/test_cloudfoundry_config.py b/tests/app/test_cloudfoundry_config.py index 22b8f93c0..dfd2ccdf7 100644 --- a/tests/app/test_cloudfoundry_config.py +++ b/tests/app/test_cloudfoundry_config.py @@ -1,92 +1,12 @@ import os -import json import pytest -from app.cloudfoundry_config import extract_cloudfoundry_config, set_config_env_vars +from app.cloudfoundry_config import extract_cloudfoundry_config @pytest.fixture -def notify_config(): - return { - 'name': 'notify-config', - 'credentials': { - 'api_host_name': 'api host name', - 'admin_base_url': 'admin base url', - 'admin_client_secret': 'admin client secret', - 'secret_key': 'secret key', - 'dangerous_salt': 'dangerous salt', - 'route_secret_key_1': 'key 1', - 'route_secret_key_2': 'key 2', - } - } - - -@pytest.fixture -def aws_config(): - return { - 'name': 'notify-aws', - 'credentials': { - 'aws_access_key_id': 'aws access key id', - 'aws_secret_access_key': 'aws secret access key', - } - } - - -@pytest.fixture -def hosted_graphite_config(): - return { - 'name': 'hosted-graphite', - 'credentials': { - 'statsd_prefix': 'statsd prefix' - } - } - - -@pytest.fixture -def deskpro_config(): - return { - 'name': 'deskpro', - 'credentials': { - 'api_host': 'deskpro api host', - 'api_key': 'deskpro api key' - } - } - - -@pytest.fixture -def template_preview_config(): - return { - 'name': 'notify-template-preview', - 'credentials': { - 'api_host': 'template-preview api host', - 'api_key': 'template-preview api key' - } - } - - -@pytest.fixture -def cloudfoundry_config( - notify_config, - aws_config, - hosted_graphite_config, - deskpro_config, - template_preview_config, -): - return { - 'user-provided': [ - notify_config, - aws_config, - hosted_graphite_config, - deskpro_config, - template_preview_config, - ] - } - - -@pytest.fixture -def cloudfoundry_environ(monkeypatch, cloudfoundry_config): - monkeypatch.setenv('VCAP_SERVICES', json.dumps(cloudfoundry_config)) +def cloudfoundry_environ(monkeypatch): monkeypatch.setenv('VCAP_APPLICATION', '{"space_name":"🚀🌌"}') @@ -96,60 +16,3 @@ def test_extract_cloudfoundry_config_populates_other_vars(): assert os.environ['NOTIFY_ENVIRONMENT'] == '🚀🌌' assert os.environ['NOTIFY_LOG_PATH'] == '/home/vcap/logs/app.log' - - -@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') -def test_set_config_env_vars_ignores_unknown_configs(cloudfoundry_config): - cloudfoundry_config['foo'] = {'credentials': {'foo': 'foo'}} - cloudfoundry_config['user-provided'].append({ - 'name': 'bar', 'credentials': {'bar': 'bar'} - }) - - set_config_env_vars(cloudfoundry_config) - - assert 'foo' not in os.environ - assert 'bar' not in os.environ - - -@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') -def test_notify_config(): - extract_cloudfoundry_config() - - assert os.environ['API_HOST_NAME'] == 'api host name' - assert os.environ['ADMIN_BASE_URL'] == 'admin base url' - assert os.environ['ADMIN_CLIENT_SECRET'] == 'admin client secret' - assert os.environ['SECRET_KEY'] == 'secret key' - assert os.environ['DANGEROUS_SALT'] == 'dangerous salt' - assert os.environ['ROUTE_SECRET_KEY_1'] == 'key 1' - assert os.environ['ROUTE_SECRET_KEY_2'] == 'key 2' - - -@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') -def test_aws_config(): - extract_cloudfoundry_config() - - assert os.environ['AWS_ACCESS_KEY_ID'] == 'aws access key id' - assert os.environ['AWS_SECRET_ACCESS_KEY'] == 'aws secret access key' - - -@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') -def test_hosted_graphite_config(): - extract_cloudfoundry_config() - - assert os.environ['STATSD_PREFIX'] == 'statsd prefix' - - -@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') -def test_deskpro_config(): - extract_cloudfoundry_config() - - assert os.environ['DESKPRO_API_HOST'] == 'deskpro api host' - assert os.environ['DESKPRO_API_KEY'] == 'deskpro api key' - - -@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') -def test_template_preview_config(): - extract_cloudfoundry_config() - - assert os.environ['TEMPLATE_PREVIEW_API_HOST'] == 'template-preview api host' - assert os.environ['TEMPLATE_PREVIEW_API_KEY'] == 'template-preview api key' diff --git a/tests/app/test_config.py b/tests/app/test_config.py index 87cc10aed..9ea0f3c72 100644 --- a/tests/app/test_config.py +++ b/tests/app/test_config.py @@ -26,7 +26,7 @@ def reload_config(): def test_load_cloudfoundry_config_if_available(monkeypatch, reload_config): os.environ['API_HOST_NAME'] = 'env' - monkeypatch.setenv('VCAP_SERVICES', 'some json blob') + monkeypatch.setenv('VCAP_APPLICATION', 'some json blob') with mock.patch('app.cloudfoundry_config.extract_cloudfoundry_config', side_effect=cf_conf) as cf_config: # reload config so that its module level code (ie: all of it) is re-instantiated @@ -41,7 +41,7 @@ def test_load_cloudfoundry_config_if_available(monkeypatch, reload_config): def test_load_config_if_cloudfoundry_not_available(monkeypatch, reload_config): os.environ['API_HOST_NAME'] = 'env' - monkeypatch.delenv('VCAP_SERVICES', raising=False) + monkeypatch.delenv('VCAP_APPLICATION', raising=False) with mock.patch('app.cloudfoundry_config.extract_cloudfoundry_config') as cf_config: # reload config so that its module level code (ie: all of it) is re-instantiated diff --git a/tests/app/test_template_previews.py b/tests/app/test_template_previews.py index e1088bbdf..933371a95 100644 --- a/tests/app/test_template_previews.py +++ b/tests/app/test_template_previews.py @@ -45,20 +45,22 @@ def test_from_database_object_makes_request( client, partial_call, expected_url, + mock_get_service_letter_template ): resp = Mock(content='a', status_code='b', headers={'c': 'd'}) request_mock = mocker.patch('app.template_previews.requests.post', return_value=resp) mocker.patch('app.template_previews.current_service', __getitem__=Mock(return_value='123')) + template = mock_get_service_letter_template('123', '456')['data'] - ret = partial_call(template='foo') + ret = partial_call(template=template) assert ret[0] == 'a' assert ret[1] == 'b' assert list(ret[2]) == [('c', 'd')] data = { - 'letter_contact_block': '123', - 'template': 'foo', + 'letter_contact_block': None, + 'template': template, 'values': None, 'dvla_org_id': '123', } diff --git a/tests/conftest.py b/tests/conftest.py index 3e9c5c466..b6bd4030b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1029,6 +1029,17 @@ def mock_redact_template(mocker): return mocker.patch('app.service_api_client.redact_service_template') +@pytest.fixture(scope='function') +def mock_update_service_template_sender(mocker): + def _update(service_id, template_id, reply_to): + return + + return mocker.patch( + 'app.service_api_client.update_service_template_sender', + side_effect=_update + ) + + @pytest.fixture(scope='function') def api_user_pending(fake_uuid): from app.notify_client.user_api_client import User