diff --git a/app/__init__.py b/app/__init__.py index ecf6aea52..2cd412af4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -52,6 +52,7 @@ from app.notify_client.provider_client import ProviderClient from app.notify_client.email_branding_client import EmailBrandingClient from app.notify_client.models import AnonymousUser from app.notify_client.organisations_api_client import OrganisationsClient +from app.notify_client.org_invite_api_client import OrgInviteApiClient from app.notify_client.letter_jobs_client import LetterJobsClient from app.notify_client.inbound_number_client import InboundNumberClient from app.notify_client.billing_api_client import BillingAPIClient @@ -74,6 +75,7 @@ events_api_client = EventsApiClient() provider_client = ProviderClient() email_branding_client = EmailBrandingClient() organisations_client = OrganisationsClient() +org_invite_api_client = OrgInviteApiClient() asset_fingerprinter = AssetFingerprinter() statsd_client = StatsdClient() deskpro_client = DeskproClient() @@ -109,6 +111,7 @@ def create_app(application): notification_api_client.init_app(application) status_api_client.init_app(application) invite_api_client.init_app(application) + org_invite_api_client.init_app(application) template_statistics_client.init_app(application) events_api_client.init_app(application) provider_client.init_app(application) diff --git a/app/config.py b/app/config.py index fdb87bad0..c2b0185ea 100644 --- a/app/config.py +++ b/app/config.py @@ -48,7 +48,7 @@ class Config(object): HTTP_PROTOCOL = 'http' MAX_FAILED_LOGIN_COUNT = 10 NOTIFY_APP_NAME = 'admin' - NOTIFY_LOG_LEVEL = 'DEBUG' + NOTIFY_LOG_LEVEL = 'ERROR' PERMANENT_SESSION_LIFETIME = 20 * 60 * 60 # 20 hours SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year SESSION_COOKIE_HTTPONLY = True diff --git a/app/main/forms.py b/app/main/forms.py index 442809940..4d7d7a9e5 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -237,6 +237,25 @@ class RegisterUserFromInviteForm(StripWhitespaceForm): raise ValidationError('Can’t be empty') +class RegisterUserFromOrgInviteForm(StripWhitespaceForm): + def __init__(self, invited_org_user): + super().__init__( + organisation=invited_org_user['organisation'], + email_address=invited_org_user['email_address'], + ) + + name = StringField( + 'Full name', + validators=[DataRequired(message='Can’t be empty')] + ) + + mobile_number = InternationalPhoneNumber('Mobile number', validators=[DataRequired(message='Can’t be empty')]) + password = password() + organisation = HiddenField('organisation') + email_address = HiddenField('email_address') + auth_type = HiddenField('auth_type', validators=[DataRequired()]) + + class PermissionsForm(StripWhitespaceForm): send_messages = BooleanField("Send messages from existing templates") manage_templates = BooleanField("Add and edit templates") @@ -264,6 +283,18 @@ class InviteUserForm(PermissionsForm): raise ValidationError("You can’t send an invitation to yourself") +class InviteOrgUserForm(StripWhitespaceForm): + email_address = email_address(gov_user=False) + + def __init__(self, invalid_email_address, *args, **kwargs): + super(InviteOrgUserForm, self).__init__(*args, **kwargs) + self.invalid_email_address = invalid_email_address.lower() + + def validate_email_address(self, field): + if field.data.lower() == self.invalid_email_address: + raise ValidationError("You can’t send an invitation to yourself") + + class TwoFactorForm(StripWhitespaceForm): def __init__(self, validate_code_func, *args, **kwargs): ''' @@ -419,7 +450,7 @@ class ChangeEmailForm(StripWhitespaceForm): def validate_email_address(self, field): is_valid = self.validate_email_func(field.data) - if not is_valid: + if is_valid: raise ValidationError("The email address is already in use") diff --git a/app/main/views/invites.py b/app/main/views/invites.py index 2da96a154..8575eb02e 100644 --- a/app/main/views/invites.py +++ b/app/main/views/invites.py @@ -4,38 +4,23 @@ from flask import ( session, flash, render_template, - abort, - current_app + abort ) -from itsdangerous import SignatureExpired from markupsafe import Markup -from notifications_utils.url_safe_token import check_token from flask_login import current_user from app.main import main from app import ( invite_api_client, + org_invite_api_client, user_api_client, + organisations_client, service_api_client ) @main.route("/invitation/") def accept_invite(token): - try: - check_token( - token, - current_app.config['SECRET_KEY'], - current_app.config['DANGEROUS_SALT'], - current_app.config['INVITATION_EXPIRY_SECONDS'] - ) - except SignatureExpired: - errors = [ - 'Your invitation to GOV.UK Notify has expired. ' - 'Please ask the person that invited you to send you another one' - ] - return render_template("error/400.html", message=errors), 400 - invited_user = invite_api_client.check_token(token) if not current_user.is_anonymous and current_user.email_address.lower() != invited_user.email_address.lower(): @@ -88,3 +73,44 @@ def accept_invite(token): return redirect(url_for('main.service_dashboard', service_id=invited_user.service)) else: return redirect(url_for('main.register_from_invite')) + + +@main.route("/organisation-invitation/") +def accept_org_invite(token): + invited_org_user = org_invite_api_client.check_token(token) + if not current_user.is_anonymous and current_user.email_address.lower() != invited_org_user.email_address.lower(): + message = Markup(""" + You’re signed in as {}. + This invite is for another email address. + Sign out and click the link again to accept this invite. + """.format( + current_user.email_address, + url_for("main.sign_out", _external=True))) + + flash(message=message) + + abort(403) + + if invited_org_user.status == 'cancelled': + invited_by = user_api_client.get_user(invited_org_user.invited_by) + organisation = organisations_client.get_organisation(invited_org_user.organisation) + return render_template('views/cancelled-invitation.html', + from_user=invited_by.name, + organisation_name=organisation['name']) + + if invited_org_user.status == 'accepted': + session.pop('invited_org_user', None) + return redirect(url_for('main.organisation_dashboard', org_id=invited_org_user.organisation)) + + session['invited_org_user'] = invited_org_user.serialize() + + existing_user = user_api_client.get_user_by_email_or_none(invited_org_user.email_address) + organisation_users = user_api_client.get_users_for_organisation(invited_org_user.organisation) + + if existing_user: + org_invite_api_client.accept_invite(invited_org_user.organisation, invited_org_user.id) + if existing_user not in organisation_users: + user_api_client.add_user_to_organisation(invited_org_user.organisation, existing_user.id) + return redirect(url_for('main.organisation_dashboard', org_id=invited_org_user.organisation)) + else: + return redirect(url_for('main.register_from_org_invite')) diff --git a/app/main/views/organisations.py b/app/main/views/organisations.py index 54ac461cb..c210b6639 100644 --- a/app/main/views/organisations.py +++ b/app/main/views/organisations.py @@ -1,11 +1,31 @@ -from flask import redirect, render_template, url_for -from flask_login import login_required +from flask import ( + redirect, + render_template, + url_for, + flash, + request +) +from flask_login import ( + login_required, + current_user +) -from app import organisations_client +from app import ( + organisations_client, + org_invite_api_client, + user_api_client, +) +from app.main.forms import ( + SearchUsersForm, + InviteOrgUserForm, +) from app.main import main from app.main.forms import CreateOrUpdateOrganisation from app.utils import user_has_permissions +from notifications_python_client.errors import HTTPError +from werkzeug.exceptions import abort + @main.route("/organisations", methods=['GET']) @login_required @@ -38,7 +58,7 @@ def add_organisation(): ) -@main.route("/organisation/", methods=['GET']) +@main.route("/organisations/", methods=['GET']) @login_required @user_has_permissions(admin_override=True) def organisation_dashboard(org_id): @@ -50,7 +70,7 @@ def organisation_dashboard(org_id): ) -@main.route("/organisation//edit", methods=['GET', 'POST']) +@main.route("/organisations//edit", methods=['GET', 'POST']) @login_required @user_has_permissions(admin_override=True) def update_organisation(org_id): @@ -75,11 +95,96 @@ def update_organisation(org_id): ) -@main.route("/organisation//users", methods=['GET']) +@main.route("/organisations//users", methods=['GET']) @login_required @user_has_permissions(admin_override=True) -def organisation_users(org_id): +def manage_org_users(org_id): + users = sorted( + user_api_client.get_users_for_organisation(org_id=org_id) + [ + invite for invite in org_invite_api_client.get_invites_for_organisation(org_id=org_id) + if invite.status != 'accepted' + ], + key=lambda user: user.email_address, + ) return render_template( 'views/organisations/organisation/users/index.html', + users=users, + show_search_box=(len(users) > 7), + form=SearchUsersForm(), ) + + +@main.route("/organisations//users/invite", methods=['GET', 'POST']) +@login_required +@user_has_permissions(admin_override=True) +def invite_org_user(org_id): + form = InviteOrgUserForm( + invalid_email_address=current_user.email_address + ) + if form.validate_on_submit(): + email_address = form.email_address.data + invited_org_user = org_invite_api_client.create_invite( + current_user.id, + org_id, + email_address + ) + + flash('Invite sent to {}'.format(invited_org_user.email_address), 'default_with_tick') + return redirect(url_for('.manage_org_users', org_id=org_id)) + + return render_template( + 'views/organisations/organisation/users/invite-org-user.html', + form=form + ) + + +@main.route("/organisations//users/", methods=['GET', 'POST']) +@login_required +@user_has_permissions(admin_override=True) +def edit_user_org_permissions(org_id, user_id): + user = user_api_client.get_user(user_id) + + return render_template( + 'views/organisations/organisation/users/user/index.html', + user=user + ) + + +@main.route("/organisations//users//delete", methods=['GET', 'POST']) +@login_required +@user_has_permissions(admin_override=True) +def remove_user_from_organisation(org_id, user_id): + user = user_api_client.get_user(user_id) + if request.method == 'POST': + try: + organisations_client.remove_user_from_organisation(org_id, user_id) + except HTTPError as e: + msg = "You cannot remove the only user for a service" + if e.status_code == 400 and msg in e.message: + flash(msg, 'info') + return redirect(url_for( + '.manage_org_users', + org_id=org_id)) + else: + abort(500, e) + + return redirect(url_for( + '.manage_org_users', + org_id=org_id + )) + + flash('Are you sure you want to remove {}?'.format(user.name), 'remove') + return render_template( + 'views/organisations/organisation/users/user/index.html', + user=user, + ) + + +@main.route("/organisations//cancel-invited-user/", methods=['GET']) +@login_required +@user_has_permissions(admin_override=True) +def cancel_invited_org_user(org_id, invited_user_id): + org_invite_api_client.cancel_invited_user(org_id=org_id, invited_user_id=invited_user_id) + + return redirect(url_for('main.manage_org_users', org_id=org_id)) diff --git a/app/main/views/register.py b/app/main/views/register.py index 0bdcf3ca4..c3e4754d4 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -17,13 +17,15 @@ from app.main import main from app.main.forms import ( RegisterUserForm, - RegisterUserFromInviteForm + RegisterUserFromInviteForm, + RegisterUserFromOrgInviteForm ) from app.main.views.verify import activate_user from app import ( user_api_client, - invite_api_client + invite_api_client, + org_invite_api_client ) @@ -65,20 +67,42 @@ def register_from_invite(): return render_template('views/register-from-invite.html', invited_user=invited_user, form=form) -def _do_registration(form, send_sms=True, send_email=True): - if user_api_client.is_email_unique(form.email_address.data): +@main.route('/register-from-org-invite', methods=['GET', 'POST']) +def register_from_org_invite(): + invited_org_user = session.get('invited_org_user') + if not invited_org_user: + abort(404) + + form = RegisterUserFromOrgInviteForm( + invited_org_user, + ) + form.auth_type.data = 'sms_auth' + + if form.validate_on_submit(): + if (form.organisation.data != invited_org_user['organisation'] or + form.email_address.data != invited_org_user['email_address']): + abort(400) + _do_registration(form, send_email=False, send_sms=True, organisation_id=invited_org_user['organisation']) + org_invite_api_client.accept_invite(invited_org_user['organisation'], invited_org_user['id']) + user_api_client.add_user_to_organisation(invited_org_user['organisation'], session['user_details']['id']) + + return redirect(url_for('main.verify')) + return render_template('views/register-from-org-invite.html', invited_org_user=invited_org_user, form=form) + + +def _do_registration(form, send_sms=True, send_email=True, organisation_id=None): + if user_api_client.is_email_already_in_use(form.email_address.data): + user = user_api_client.get_user_by_email(form.email_address.data) + if send_email: + user_api_client.send_already_registered_email(user.id, user.email_address) + session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1)) + session['user_details'] = {"email": user.email_address, "id": user.id} + else: user = user_api_client.register_user(form.name.data, form.email_address.data, form.mobile_number.data or None, form.password.data, form.auth_type.data) - - # TODO possibly there should be some exception handling - # for sending sms and email codes. - # How do we report to the user there is a problem with - # sending codes apart from service unavailable? - # at the moment i believe http 500 is fine. - if send_email: user_api_client.send_verify_email(user.id, user.email_address) @@ -86,12 +110,8 @@ def _do_registration(form, send_sms=True, send_email=True): user_api_client.send_verify_code(user.id, 'sms', user.mobile_number) session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1)) session['user_details'] = {"email": user.email_address, "id": user.id} - else: - user = user_api_client.get_user_by_email(form.email_address.data) - if send_email: - user_api_client.send_already_registered_email(user.id, user.email_address) - session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1)) - session['user_details'] = {"email": user.email_address, "id": user.id} + if organisation_id: + session['organisation_id'] = organisation_id @main.route('/registration-continue') diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py index 0d583e4b2..b0c84b5fa 100644 --- a/app/main/views/user_profile.py +++ b/app/main/views/user_profile.py @@ -64,9 +64,9 @@ def user_profile_email(): if not is_gov_user(current_user.email_address): abort(403) - def _is_email_unique(email): - return user_api_client.is_email_unique(email) - form = ChangeEmailForm(_is_email_unique, + def _is_email_already_in_use(email): + return user_api_client.is_email_already_in_use(email) + form = ChangeEmailForm(_is_email_already_in_use, email_address=current_user.email_address) if form.validate_on_submit(): diff --git a/app/main/views/verify.py b/app/main/views/verify.py index 1b3bd70fb..f656c061b 100644 --- a/app/main/views/verify.py +++ b/app/main/views/verify.py @@ -74,6 +74,10 @@ def activate_user(user_id): user = user_api_client.get_user(user_id) # the user will have a new current_session_id set by the API - store it in the cookie for future requests session['current_session_id'] = user.current_session_id + organisation_id = session.get('organisation_id', None) activated_user = user_api_client.activate_user(user) login_user(activated_user) - return redirect(url_for('main.add_service', first='first')) + if organisation_id: + return redirect(url_for('main.organisation_dashboard', org_id=organisation_id)) + else: + return redirect(url_for('main.add_service', first='first')) diff --git a/app/notify_client/models.py b/app/notify_client/models.py index 394402154..4cc23f04b 100644 --- a/app/notify_client/models.py +++ b/app/notify_client/models.py @@ -27,6 +27,7 @@ class User(UserMixin): self.max_failed_login_count = max_failed_login_count self.platform_admin = fields.get('platform_admin') self.current_session_id = fields.get('current_session_id') + self.organisations = fields.get('organisations', []) def get_id(self): return self.id @@ -155,6 +156,7 @@ class User(UserMixin): "state": self.state, "failed_login_count": self.failed_login_count, "permissions": [x for x in self._permissions], + "organisations": self.organisations, "current_session_id": self.current_session_id } if hasattr(self, '_password'): @@ -164,6 +166,14 @@ class User(UserMixin): def set_password(self, pwd): self._password = pwd + @property + def organisations(self): + return self._organisations + + @organisations.setter + def organisations(self, organisations): + self._organisations = organisations + class InvitedUser(object): @@ -217,6 +227,38 @@ class InvitedUser(object): return data +class InvitedOrgUser(object): + + def __init__(self, id, organisation, invited_by, email_address, status, created_at): + self.id = id + self.organisation = str(organisation) + self.invited_by = invited_by + self.email_address = email_address + self.status = status + self.created_at = created_at + + def __eq__(self, other): + return ((self.id, + self.organisation, + self.invited_by, + self.email_address, + self.status) == (other.id, + other.organisation, + other.invited_by, + other.email_address, + other.status)) + + def serialize(self, permissions_as_string=False): + data = {'id': self.id, + 'organisation': self.organisation, + 'invited_by': self.invited_by, + 'email_address': self.email_address, + 'status': self.status, + 'created_at': str(self.created_at) + } + return data + + class AnonymousUser(AnonymousUserMixin): # set the anonymous user so that if a new browser hits us we don't error http://stackoverflow.com/a/19275188 def logged_in_elsewhere(self): diff --git a/app/notify_client/org_invite_api_client.py b/app/notify_client/org_invite_api_client.py new file mode 100644 index 000000000..71f3f955b --- /dev/null +++ b/app/notify_client/org_invite_api_client.py @@ -0,0 +1,52 @@ +from app.notify_client import _attach_current_user, NotifyAdminAPIClient +from app.notify_client.models import InvitedOrgUser + + +class OrgInviteApiClient(NotifyAdminAPIClient): + def __init__(self): + super().__init__("a" * 73, "b") + + def init_app(self, app): + self.base_url = app.config['API_HOST_NAME'] + self.admin_url = app.config['ADMIN_BASE_URL'] + self.service_id = app.config['ADMIN_CLIENT_USER_NAME'] + self.api_key = app.config['ADMIN_CLIENT_SECRET'] + + def create_invite(self, invite_from_id, org_id, email_address): + data = { + 'email_address': email_address, + 'invited_by': invite_from_id, + 'invite_link_host': self.admin_url, + } + data = _attach_current_user(data) + resp = self.post(url='/organisation/{}/invite'.format(org_id), data=data) + return InvitedOrgUser(**resp['data']) + + def get_invites_for_organisation(self, org_id): + endpoint = '/organisation/{}/invite'.format(org_id) + resp = self.get(endpoint) + invites = resp['data'] + invited_users = self._get_invited_org_users(invites) + return invited_users + + def check_token(self, token): + resp = self.get(url='/organisation-invitation/{}'.format(token)) + return InvitedOrgUser(**resp['data']) + + def cancel_invited_user(self, org_id, invited_user_id): + data = {'status': 'cancelled'} + data = _attach_current_user(data) + self.post(url='/organisation/{0}/invite/{1}'.format(org_id, invited_user_id), + data=data) + + def accept_invite(self, org_id, invited_user_id): + data = {'status': 'accepted'} + self.post(url='/organisation/{0}/invite/{1}'.format(org_id, invited_user_id), + data=data) + + def _get_invited_org_users(self, invites): + invited_users = [] + for invite in invites: + invited_user = InvitedOrgUser(**invite) + invited_users.append(invited_user) + return invited_users diff --git a/app/notify_client/organisations_api_client.py b/app/notify_client/organisations_api_client.py index 1d9d876c8..f6aeea383 100644 --- a/app/notify_client/organisations_api_client.py +++ b/app/notify_client/organisations_api_client.py @@ -1,4 +1,4 @@ -from app.notify_client import NotifyAdminAPIClient +from app.notify_client import _attach_current_user, NotifyAdminAPIClient class OrganisationsClient(NotifyAdminAPIClient): @@ -43,3 +43,10 @@ class OrganisationsClient(NotifyAdminAPIClient): def get_organisation_services(self, org_id): return self.get(url="/organisations/{}/services".format(org_id)) + + def remove_user_from_organisation(self, org_id, user_id): + endpoint = '/organisations/{}/users/{}'.format( + org_id=org_id, + user_id=user_id) + data = _attach_current_user({}) + return self.delete(endpoint, data) diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 57d9d4260..342976ad0 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -131,12 +131,21 @@ class UserApiClient(NotifyAdminAPIClient): resp = self.get(endpoint) return [User(data) for data in resp['data']] + def get_users_for_organisation(self, org_id): + endpoint = '/organisations/{}/users'.format(org_id) + resp = self.get(endpoint) + return [User(data) for data in resp['data']] + def add_user_to_service(self, service_id, user_id, permissions): endpoint = '/service/{}/users/{}'.format(service_id, user_id) data = [{'permission': x} for x in permissions] resp = self.post(endpoint, data=data) return User(resp['data'], max_failed_login_count=self.max_failed_login_count) + def add_user_to_organisation(self, org_id, user_id): + resp = self.post('/organisations/{}/users/{}'.format(org_id, user_id), data={}) + return User(resp['data'], max_failed_login_count=self.max_failed_login_count) + def set_user_permissions(self, user_id, service_id, permissions): data = [{'permission': x} for x in permissions] endpoint = '/user/{}/service/{}/permission'.format(user_id, service_id) @@ -147,10 +156,10 @@ class UserApiClient(NotifyAdminAPIClient): data = {'email': email_address} self.post(endpoint, data=data) - def is_email_unique(self, email_address): + def is_email_already_in_use(self, email_address): if self.get_user_by_email_or_none(email_address): - return False - return True + return True + return False def activate_user(self, user): if user.state == 'pending': diff --git a/app/templates/org_nav.html b/app/templates/org_nav.html index d27081d09..a8df3392c 100644 --- a/app/templates/org_nav.html +++ b/app/templates/org_nav.html @@ -2,7 +2,7 @@ diff --git a/app/templates/views/cancelled-invitation.html b/app/templates/views/cancelled-invitation.html index 9a9e82cc9..d817ecaa9 100644 --- a/app/templates/views/cancelled-invitation.html +++ b/app/templates/views/cancelled-invitation.html @@ -10,7 +10,7 @@ {{ from_user }} decided to cancel this invitation.

- If you need access to {{ service_name }}, you’ll have to ask them to invite you again. + If you need access to {{ service_name or organisation_name }}, you’ll have to ask them to invite you again.

diff --git a/app/templates/views/organisations/organisation/users/index.html b/app/templates/views/organisations/organisation/users/index.html index ddfe159d8..d2edfbfee 100644 --- a/app/templates/views/organisations/organisation/users/index.html +++ b/app/templates/views/organisations/organisation/users/index.html @@ -1,4 +1,8 @@ {% extends "org_template.html" %} +{% from "components/table.html" import list_table, row, field, hidden_field_heading %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/tick-cross.html" import tick_cross %} +{% from "components/textbox.html" import textbox %} {% block org_page_title %} Team members @@ -12,6 +16,54 @@ Team members + + + + {% if show_search_box %} +
+ +
+ {% endif %} + +
+ {% for user in users %} +
+

+ {%- if user.name -%} + {{ user.name }}  + {%- endif -%} + + {%- if user.status == 'pending' -%} + {{ user.email_address }} (invited) + {%- elif user.status == 'cancelled' -%} + {{ user.email_address }} (cancelled invite) + {%- elif user.id == current_user.id -%} + (you) + {% else %} + {{ user.email_address }} + {% endif %} + +

+ +
+ {% endfor %}
{% endblock %} + + diff --git a/app/templates/views/organisations/organisation/users/invite-org-user.html b/app/templates/views/organisations/organisation/users/invite-org-user.html new file mode 100644 index 000000000..8e7ee3645 --- /dev/null +++ b/app/templates/views/organisations/organisation/users/invite-org-user.html @@ -0,0 +1,33 @@ +{% extends "org_template.html" %} + +{% from "components/textbox.html" import textbox %} +{% from "components/page-footer.html" import page_footer %} + +{% block service_page_title %} + Invite a team member +{% endblock %} + +{% block maincolumn_content %} + +

+ Invite a team member +

+
+
+ {{ textbox(form.email_address, width='1-1', safe_error_message=True) }} + +
+

+ All team members can see: +

+
    +
  • dashboard
  • +
  • who the other team members are
  • +
+
+ + {{ page_footer('Send invitation email') }} +
+
+ +{% endblock %} diff --git a/app/templates/views/organisations/organisation/users/user/index.html b/app/templates/views/organisations/organisation/users/user/index.html new file mode 100644 index 000000000..2d7615141 --- /dev/null +++ b/app/templates/views/organisations/organisation/users/user/index.html @@ -0,0 +1,28 @@ +{% extends "withnav_template.html" %} +{% from "components/textbox.html" import textbox %} +{% from "components/page-footer.html" import page_footer %} + +{% block service_page_title %} + {{ user.name or user.email_localpart }} +{% endblock %} + +{% block maincolumn_content %} + +

+ {{ user.name or user.email_localpart }} +

+ +

+ {{ user.email_address }} +

+
+ {{ page_footer( + 'Save', + back_link=url_for('.manage_org_users', org_id=current_org.id), + back_link_text="Back", + delete_link=url_for('.remove_user_from_organisation', org_id=current_org.id, user_id=user.id) if user or None, + delete_link_text='Remove user from organisation' + ) }} +
+ +{% endblock %} diff --git a/app/templates/views/organisations/organisation/users/user/remove-org-user.html b/app/templates/views/organisations/organisation/users/user/remove-org-user.html new file mode 100644 index 000000000..e69de29bb diff --git a/app/templates/views/register-from-org-invite.html b/app/templates/views/register-from-org-invite.html new file mode 100644 index 000000000..717e19992 --- /dev/null +++ b/app/templates/views/register-from-org-invite.html @@ -0,0 +1,28 @@ +{% extends "withoutnav_template.html" %} +{% from "components/textbox.html" import textbox %} +{% from "components/page-footer.html" import page_footer %} + +{% block per_page_title %} +Create an account +{% endblock %} + +{% block maincolumn_content %} + +
+
+

Create an account

+

Your account will be created with this email: {{invited_org_user.email_address}}

+
+ {{ textbox(form.name, width='3-4') }} +
+ {{ textbox(form.mobile_number, width='3-4', hint='We’ll send you a security code by text message') }} +
+ {{ textbox(form.password, hint="At least 8 characters", width='3-4') }} + {{ page_footer("Continue") }} + {{form.organisation}} + {{form.email_address}} +
+
+
+ +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py index ad095fdc6..20ca78664 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,6 +5,9 @@ from datetime import datetime, timedelta, timezone from flask.testing import FlaskClient from flask import url_for from flask_login import login_user +from app.notify_client.models import ( + InvitedOrgUser, +) class TestClient(FlaskClient): @@ -37,6 +40,85 @@ def created_by_json(id_, name='', email_address=''): return {'id': id_, 'name': name, 'email_address': email_address} +def user_json( + id_='1234', + name='Test User', + email_address='test@gov.uk', + mobile_number='+447700900986', + password_changed_at=None, + permissions={generate_uuid(): [ + 'send_texts', + 'send_emails', + 'send_letters', + 'manage_users', + 'manage_templates', + 'manage_settings', + 'manage_api_keys'] + }, + auth_type='sms_auth', + failed_login_count=0, + state='active', + max_failed_login_count=3, + platform_admin=False, + current_session_id='1234', + organisations=[], + +): + return { + 'id': id_, + 'name': name, + 'email_address': email_address, + 'mobile_number': mobile_number, + 'password_changed_at': password_changed_at, + 'permissions': permissions, + 'auth_type': auth_type, + 'failed_login_count': failed_login_count, + 'state': state, + 'max_failed_login_count': max_failed_login_count, + 'platform_admin': platform_admin, + 'current_session_id': current_session_id, + 'organisations': organisations + } + + +def invited_user( + _id='1234', + service=None, + from_user='1234', + email_address='testinviteduser@gov.uk', + permissions=None, + status='pending', + created_at=datetime.utcnow(), + auth_type='sms_auth', + organisation=None +): + org_user = organisation is not None + data = { + 'id': _id, + 'from_user': from_user, + 'email_address': email_address, + 'status': status, + 'created_at': created_at, + 'auth_type': auth_type, + } + if service: + data['service'] = service + if permissions: + data['permissions'] = permissions + if organisation: + data['organisation'] = organisation + + if org_user: + return InvitedOrgUser( + data['id'], + data['organisation'], + data['from_user'], + data['email_address'], + data['status'], + data['created_at'] + ) + + def service_json( id_='1234', name='Test Service', @@ -88,6 +170,28 @@ def service_json( } +def organisation_json( + id_='1234', + name='Test Organisation', + users=None, + active=True, + created_at=None, + services=None +): + if users is None: + users = [] + if services is None: + services = [] + return { + 'id': id_, + 'name': name, + 'active': active, + 'users': users, + 'services': services, + 'created_at': created_at or str(datetime.utcnow()) + } + + def template_json(service_id, id_, name="sample template", @@ -166,6 +270,17 @@ def invite_json(id_, from_user, service_id, email_address, permissions, created_ } +def org_invite_json(id_, invited_by, org_id, email_address, created_at, status): + return { + 'id': id_, + 'invited_by': invited_by, + 'organisation': org_id, + 'email_address': email_address, + 'status': status, + 'created_at': created_at, + } + + TEST_USER_EMAIL = 'test@user.gov.uk' diff --git a/tests/app/main/test_errorhandlers.py b/tests/app/main/test_errorhandlers.py index e5c2f7487..202559cdd 100644 --- a/tests/app/main/test_errorhandlers.py +++ b/tests/app/main/test_errorhandlers.py @@ -26,7 +26,6 @@ def test_load_service_before_request_handles_404(client_request, mocker): @pytest.mark.parametrize('url', [ - '/invitation/MALFORMED_TOKEN', '/new-password/MALFORMED_TOKEN', '/user-profile/email/confirm/MALFORMED_TOKEN', '/verify-email/MALFORMED_TOKEN' diff --git a/tests/app/main/views/test_accept_invite.py b/tests/app/main/views/test_accept_invite.py index 0ff67f30b..277bef460 100644 --- a/tests/app/main/views/test_accept_invite.py +++ b/tests/app/main/views/test_accept_invite.py @@ -1,7 +1,6 @@ from flask import url_for from bs4 import BeautifulSoup from unittest.mock import ANY -from itsdangerous import SignatureExpired import app from app.notify_client.models import InvitedUser @@ -22,8 +21,6 @@ def test_existing_user_accept_invite_calls_api_and_redirects_to_dashboard( mock_get_service, mocker, ): - mocker.patch('app.main.views.invites.check_token') - expected_service = service_one['id'] expected_permissions = ['send_messages', 'manage_service', 'manage_api_keys'] @@ -50,8 +47,6 @@ def test_existing_user_with_no_permissions_accept_invite( mock_add_user_to_service, mock_get_service, ): - mocker.patch('app.main.views.invites.check_token') - expected_service = service_one['id'] sample_invite['permissions'] = '' expected_permissions = [] @@ -69,8 +64,6 @@ def test_if_existing_user_accepts_twice_they_redirect_to_sign_in( sample_invite, mock_get_service, ): - mocker.patch('app.main.views.invites.check_token') - sample_invite['status'] = 'accepted' invite = InvitedUser(**sample_invite) mocker.patch('app.invite_api_client.check_token', return_value=invite) @@ -96,7 +89,6 @@ def test_existing_user_of_service_get_redirected_to_signin( mock_get_user_by_email, mock_accept_invite, ): - mocker.patch('app.main.views.invites.check_token') sample_invite['email_address'] = api_user_active.email_address invite = InvitedUser(**sample_invite) mocker.patch('app.invite_api_client.check_token', return_value=invite) @@ -128,8 +120,6 @@ def test_existing_signed_out_user_accept_invite_redirects_to_sign_in( mock_get_service, mocker, ): - mocker.patch('app.main.views.invites.check_token') - expected_service = service_one['id'] expected_permissions = ['send_messages', 'manage_service', 'manage_api_keys'] @@ -161,8 +151,6 @@ def test_new_user_accept_invite_calls_api_and_redirects_to_registration( mock_get_service, mocker, ): - mocker.patch('app.main.views.invites.check_token') - expected_redirect_location = 'http://localhost/register-from-invite' response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) @@ -184,8 +172,6 @@ def test_new_user_accept_invite_calls_api_and_views_registration_page( mock_get_service, mocker, ): - mocker.patch('app.main.views.invites.check_token') - response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'), follow_redirects=True) mock_check_invite_token.assert_called_with('thisisnotarealtoken') @@ -219,7 +205,6 @@ def test_cancelled_invited_user_accepts_invited_redirect_to_cancelled_invitation mock_get_user, mock_get_service, ): - mocker.patch('app.main.views.invites.check_token') cancelled_invitation = create_sample_invite(mocker, service_one, status='cancelled') mock_check_token_invite(mocker, cancelled_invitation) response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) @@ -237,7 +222,7 @@ def test_new_user_accept_invite_completes_new_registration_redirects_to_verify( api_user_active, mock_check_invite_token, mock_dont_get_user_by_email, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_register_user, mock_send_verify_code, mock_accept_invite, @@ -246,8 +231,6 @@ def test_new_user_accept_invite_completes_new_registration_redirects_to_verify( mock_get_service, mocker, ): - mocker.patch('app.main.views.invites.check_token') - expected_service = service_one['id'] expected_email = sample_invite['email_address'] expected_from_user = service_one['users'][0] @@ -297,7 +280,6 @@ def test_signed_in_existing_user_cannot_use_anothers_invite( mock_accept_invite, mock_get_service, ): - mocker.patch('app.main.views.invites.check_token') invite = InvitedUser(**sample_invite) mocker.patch('app.invite_api_client.check_token', return_value=invite) mocker.patch('app.user_api_client.get_users_for_service', return_value=[api_user_active]) @@ -324,8 +306,6 @@ def test_accept_invite_does_not_treat_email_addresses_as_case_sensitive( mock_accept_invite, mock_get_user_by_email ): - mocker.patch('app.main.views.invites.check_token') - # the email address of api_user_active is 'test@user.gov.uk' sample_invite['email_address'] = 'TEST@user.gov.uk' invite = InvitedUser(**sample_invite) @@ -345,7 +325,7 @@ def test_new_invited_user_verifies_and_added_to_service( api_user_active, mock_check_invite_token, mock_dont_get_user_by_email, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_register_user, mock_send_verify_code, mock_check_verify_code, @@ -363,8 +343,6 @@ def test_new_invited_user_verifies_and_added_to_service( mock_get_usage, mocker, ): - mocker.patch('app.main.views.invites.check_token') - # visit accept token page response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) assert response.status_code == 302 @@ -404,24 +382,6 @@ def test_new_invited_user_verifies_and_added_to_service( assert page.find('h1').text == 'Dashboard' -def test_gives_message_if_token_has_expired( - app_, - client, - mock_check_invite_token, - mocker, -): - check_token = mocker.patch('app.main.views.invites.check_token', side_effect=SignatureExpired('this is too old')) - - response = client.get(url_for('main.accept_invite', token='a really old token')) - raw_html = response.data.decode('utf-8') - page = BeautifulSoup(raw_html, 'html.parser') - - check_token.assert_called_once_with(ANY, ANY, ANY, 3600 * 24 * 2) - assert response.status_code == 400 - assert 'Your invitation to GOV.UK Notify has expired' in page.find('h1').text - assert not mock_check_invite_token.called - - def test_existing_user_accepts_and_sets_email_auth( client_request, api_user_active, @@ -434,7 +394,6 @@ def test_existing_user_accepts_and_sets_email_auth( mock_add_user_to_service, mocker ): - mocker.patch('app.main.views.invites.check_token') sample_invite['email_address'] = api_user_active.email_address service_one['permissions'].append('email_auth') @@ -465,7 +424,6 @@ def test_existing_user_doesnt_get_auth_changed_by_service_without_permission( mock_add_user_to_service, mocker ): - mocker.patch('app.main.views.invites.check_token') sample_invite['email_address'] = api_user_active.email_address assert 'email_auth' not in service_one['permissions'] @@ -495,7 +453,6 @@ def test_existing_email_auth_user_without_phone_cannot_set_sms_auth( mock_add_user_to_service, mocker ): - mocker.patch('app.main.views.invites.check_token') sample_invite['email_address'] = api_user_active.email_address service_one['permissions'].append('email_auth') @@ -529,7 +486,6 @@ def test_existing_email_auth_user_with_phone_can_set_sms_auth( mock_add_user_to_service, mocker ): - mocker.patch('app.main.views.invites.check_token') sample_invite['email_address'] = api_user_active.email_address service_one['permissions'].append('email_auth') diff --git a/tests/app/main/views/test_organisations.py b/tests/app/main/views/test_organisations.py index 4a9383276..5a14fa2f8 100644 --- a/tests/app/main/views/test_organisations.py +++ b/tests/app/main/views/test_organisations.py @@ -1,9 +1,20 @@ +import pytest + from bs4 import BeautifulSoup -from flask import url_for +from flask import url_for, Response + +from datetime import ( + datetime, + timedelta +) from tests.conftest import ( normalize_spaces ) +from unittest.mock import ANY +from app.notify_client.models import InvitedOrgUser + +from notifications_python_client.errors import HTTPError def test_organisation_page_shows_all_organisations( @@ -151,3 +162,376 @@ def test_organisation_services_show( assert normalize_spaces( page.select('.browse-list-item a')[i]['href'] ) == '/services/{}'.format(service_id) + + +def test_view_team_members( + logged_in_platform_admin_client, + mocker, + mock_get_organisation, + mock_get_users_for_organisation, + mock_get_invited_users_for_organisation, + fake_uuid +): + response = logged_in_platform_admin_client.get( + url_for('.manage_org_users', org_id=fake_uuid), + ) + + assert response.status_code == 200 + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + + for i in range(0, 2): + assert normalize_spaces( + page.select('.user-list-item .heading-small')[i].text + ) == 'Test User {}'.format(i + 1) + + assert normalize_spaces( + page.select('.tick-cross-list-edit-link')[1].text + ) == 'Cancel invitation' + + +def test_invite_org_user( + logged_in_platform_admin_client, + mocker, + mock_get_organisation, + sample_org_invite, + fake_uuid +): + + mock_invite_org_user = mocker.patch( + 'app.org_invite_api_client.create_invite', + return_value=InvitedOrgUser(**sample_org_invite) + ) + + logged_in_platform_admin_client.post( + url_for('.invite_org_user', org_id=mock_get_organisation['id']), + data={'email_address': 'test@example.gov.uk'} + ) + + mock_invite_org_user.assert_called_once_with( + sample_org_invite['invited_by'], + '{}'.format(mock_get_organisation['id']), + 'test@example.gov.uk', + ) + + +def test_invite_org_user_errors_when_same_email_as_inviter( + logged_in_platform_admin_client, + mocker, + mock_get_organisation, + sample_org_invite, + fake_uuid +): + new_org_user_data = { + 'email_address': 'platform@admin.gov.uk', + } + + mock_invite_org_user = mocker.patch( + 'app.org_invite_api_client.create_invite', + return_value=InvitedOrgUser(**sample_org_invite) + ) + + response = logged_in_platform_admin_client.post( + url_for('.invite_org_user', org_id=mock_get_organisation['id']), + data=new_org_user_data + ) + + assert response.status_code == 200 + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + + assert mock_invite_org_user.called is False + assert normalize_spaces(page.select_one('.error-message').text) == 'You can’t send an invitation to yourself' + +# Broken + + +def test_accept_invite_with_invalid_token( + client, + mocker +): + mocker.patch( + 'app.org_invite_api_client.check_token', + side_effect=HTTPError(Response(status=400), {'result': 'error', 'message': 'default error message'}) + ) + + response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken')) + assert response.status_code == 400 + + +def test_accepted_invite_when_user_already_logged_in( + logged_in_client, + mock_check_org_invite_token +): + response = logged_in_client.get( + url_for('main.accept_org_invite', token='thisisnotarealtoken'), + follow_redirects=True + ) + + assert response.status_code == 403 + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + + assert 'This invite is for another email address.' in normalize_spaces(page.select_one('.banner-dangerous').text) + + +def test_cancelled_invite_opened_by_user( + client, + mock_check_org_cancelled_invite_token, + mock_get_organisation, + mock_get_user, + fake_uuid +): + response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'), follow_redirects=True) + + assert response.status_code == 200 + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + + assert normalize_spaces( + page.select_one('h1').text + ) == 'The invitation you were sent has been cancelled' + assert normalize_spaces( + page.select('main p')[0].text + ) == 'Test User decided to cancel this invitation.' + assert normalize_spaces( + page.select('main p')[1].text + ) == 'If you need access to Org 1, you’ll have to ask them to invite you again.' + + mock_get_user.assert_called_once_with(fake_uuid) + mock_get_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af') + + +def test_user_invite_already_accepted( + client, + mock_check_org_accepted_invite_token +): + response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken')) + + assert response.status_code == 302 + assert response.location == url_for( + 'main.organisation_dashboard', + org_id='596364a0-858e-42c8-9062-a8fe822260af', + _external=True + ) + + +def test_existing_user_invite_already_is_member_of_organisation( + client, + mock_check_org_invite_token, + mock_get_user_by_email, + mock_get_users_for_organisation, + mock_accept_org_invite, + fake_uuid +): + response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken')) + + assert response.status_code == 302 + assert response.location == url_for( + 'main.organisation_dashboard', + org_id='596364a0-858e-42c8-9062-a8fe822260af', + _external=True + ) + + mock_accept_org_invite.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af', fake_uuid) + mock_get_user_by_email.assert_called_once_with('invited_user@test.gov.uk') + mock_get_users_for_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af') + + +def test_exisiting_user_invite_not_a_member_of_organisation( + client, + mock_check_org_invite_token, + mock_get_user_by_email, + mock_get_users_for_organisation, + mock_accept_org_invite, + mock_add_user_to_organisation, + fake_uuid +): + response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken')) + + assert response.status_code == 302 + assert response.location == url_for( + 'main.organisation_dashboard', + org_id='596364a0-858e-42c8-9062-a8fe822260af', + _external=True + ) + + mock_accept_org_invite.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af', ANY) + mock_get_user_by_email.assert_called_once_with('invited_user@test.gov.uk') + mock_get_users_for_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af') + mock_add_user_to_organisation.assert_called_once_with( + '596364a0-858e-42c8-9062-a8fe822260af', + '6ce466d0-fd6a-11e5-82f5-e0accb9d11a6' + ) + + +def test_user_accepts_invite( + client, + mock_check_org_invite_token, + mock_dont_get_user_by_email, + mock_get_users_for_organisation, +): + response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken')) + + assert response.status_code == 302 + assert response.location == url_for('main.register_from_org_invite', _external=True) + + mock_check_org_invite_token.assert_called_once_with('thisisnotarealtoken') + mock_dont_get_user_by_email.assert_called_once_with('invited_user@test.gov.uk') + mock_get_users_for_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af') + + +def test_registration_from_org_invite_404s_if_user_not_in_session( + client, +): + response = client.get(url_for('main.register_from_org_invite')) + assert response.status_code == 404 + + +@pytest.mark.parametrize('data, error', [ + [{ + 'name': 'Bad Mobile', + 'mobile_number': 'not good', + 'password': 'validPassword!' + }, 'Must not contain letters or symbols'], + [{ + 'name': 'Bad Password', + 'mobile_number': '+44123412345', + 'password': 'password' + }, 'Choose a password that’s harder to guess'], +]) +def test_registration_from_org_invite_has_bad_data( + client, + sample_org_invite, + data, + error +): + invited_org_user = InvitedOrgUser(**sample_org_invite) + with client.session_transaction() as session: + session['invited_org_user'] = invited_org_user.serialize() + + response = client.post(url_for('main.register_from_org_invite'), data=data) + + assert response.status_code == 200 + assert error in response.get_data(as_text=True) + + +@pytest.mark.parametrize('diff_data', [ + ['email_address'], + ['organisation'], + ['email_address', 'organisation'] +]) +def test_registration_from_org_invite_has_different_email_or_organisation( + client, + sample_org_invite, + diff_data +): + invited_org_user = InvitedOrgUser(**sample_org_invite) + with client.session_transaction() as session: + session['invited_org_user'] = invited_org_user.serialize() + + for data in diff_data: + session['invited_org_user'][data] = 'different' + + response = client.post(url_for('main.register_from_org_invite'), data={ + 'name': 'Test User', + 'mobile_number': '+4407700900460', + 'password': 'validPassword!', + 'email_address': session['invited_org_user']['email_address'], + 'organisation': session['invited_org_user']['organisation'] + }) + + assert response.status_code == 400 + + +def test_org_user_registers_with_email_already_in_use( + client, + sample_org_invite, + mock_email_is_already_in_use, + mock_get_user_by_email, + mock_accept_org_invite, + mock_add_user_to_organisation, + mock_send_already_registered_email, + mock_register_user +): + invited_org_user = InvitedOrgUser(**sample_org_invite) + with client.session_transaction() as session: + session['invited_org_user'] = invited_org_user.serialize() + + response = client.post(url_for('main.register_from_org_invite'), data={ + 'name': 'Test User', + 'mobile_number': '+4407700900460', + 'password': 'validPassword!', + 'email_address': session['invited_org_user']['email_address'], + 'organisation': session['invited_org_user']['organisation'] + }) + + assert response.status_code == 302 + assert response.location == url_for('main.verify', _external=True) + + mock_get_user_by_email.assert_called_once_with( + session['invited_org_user']['email_address'] + ) + assert mock_register_user.called is False + assert mock_send_already_registered_email.called is False + + +def test_org_user_registration( + client, + sample_org_invite, + mock_email_is_not_already_in_use, + mock_register_user, + mock_send_verify_code, + mock_get_user_by_email, + mock_send_verify_email, + mock_accept_org_invite, + mock_add_user_to_organisation, +): + invited_org_user = InvitedOrgUser(**sample_org_invite) + with client.session_transaction() as session: + session['invited_org_user'] = invited_org_user.serialize() + + response = client.post(url_for('main.register_from_org_invite'), data={ + 'name': 'Test User', + 'email_address': session['invited_org_user']['email_address'], + 'mobile_number': '+4407700900460', + 'password': 'validPassword!', + 'organisation': session['invited_org_user']['organisation'] + }) + + assert response.status_code == 302 + assert response.location == url_for('main.verify', _external=True) + + mock_get_user_by_email.called is False + mock_register_user.assert_called_once_with( + 'Test User', + session['invited_org_user']['email_address'], + '+4407700900460', + 'validPassword!', + 'sms_auth' + ) + mock_send_verify_code.assert_called_once_with( + '6ce466d0-fd6a-11e5-82f5-e0accb9d11a6', + 'sms', + '+4407700900460', + ) + + +def test_verified_org_user_redirects_to_dashboard( + client, + sample_org_invite, + mock_check_verify_code, + mock_get_user, + mock_activate_user, + mock_login, +): + invited_org_user = InvitedOrgUser(**sample_org_invite).serialize() + with client.session_transaction() as session: + session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1)) + session['user_details'] = {"email": invited_org_user['email_address'], "id": invited_org_user['id']} + session['organisation_id'] = invited_org_user['organisation'] + + response = client.post(url_for('main.verify'), data={'sms_code': '12345'}) + + assert response.status_code == 302 + assert response.location == url_for( + 'main.organisation_dashboard', + org_id=invited_org_user['organisation'], + _external=True + ) diff --git a/tests/app/main/views/test_register.py b/tests/app/main/views/test_register.py index b5ba1d508..2cd9d7c43 100644 --- a/tests/app/main/views/test_register.py +++ b/tests/app/main/views/test_register.py @@ -48,7 +48,7 @@ def test_register_creates_new_user_and_redirects_to_continue_page( mock_send_verify_code, mock_register_user, mock_get_user_by_email_not_found, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_send_verify_email, mock_login, phone_number_to_register_with, @@ -122,7 +122,7 @@ def test_should_add_user_details_to_session( mock_register_user, mock_get_user, mock_get_user_by_email_not_found, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_send_verify_email, mock_login, ): @@ -176,7 +176,7 @@ def test_register_with_existing_email_sends_emails( def test_register_from_invite( client, fake_uuid, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_register_user, mock_send_verify_code, mock_accept_invite, @@ -237,7 +237,7 @@ def test_register_from_invite_when_user_registers_in_another_browser( def test_register_from_email_auth_invite( client, sample_invite, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_register_user, mock_get_user, mock_send_verify_email, @@ -285,7 +285,7 @@ def test_register_from_email_auth_invite( def test_can_register_email_auth_without_phone_number( client, sample_invite, - mock_is_email_unique, + mock_email_is_not_already_in_use, mock_register_user, mock_get_user, mock_send_verify_email, diff --git a/tests/app/main/views/test_user_profile.py b/tests/app/main/views/test_user_profile.py index 30b1e07c4..6af454bdf 100644 --- a/tests/app/main/views/test_user_profile.py +++ b/tests/app/main/views/test_user_profile.py @@ -30,6 +30,7 @@ def test_should_redirect_after_name_change( logged_in_client, api_user_active, mock_update_user_attribute, + mock_email_is_not_already_in_use ): new_name = 'New Name' data = {'new_name': new_name} @@ -54,7 +55,7 @@ def test_should_show_email_page( def test_should_redirect_after_email_change( logged_in_client, mock_login, - mock_is_email_unique, + mock_email_is_not_already_in_use, ): data = {'email_address': 'new_notify@notify.gov.uk'} response = logged_in_client.post( diff --git a/tests/conftest.py b/tests/conftest.py index 819df1fa3..4e300e69e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,11 +11,15 @@ from bs4 import BeautifulSoup from app import create_app from app.notify_client.models import ( User, - InvitedUser + InvitedUser, + InvitedOrgUser ) from . import ( service_json, + organisation_json, + user_json, + invited_user, TestClient, template_json, template_version_json, @@ -25,7 +29,8 @@ from . import ( invite_json, sample_uuid, generate_uuid, - single_notification_json + single_notification_json, + org_invite_json ) from notifications_utils.url_safe_token import generate_token @@ -1050,7 +1055,8 @@ def api_user_pending(fake_uuid): 'mobile_number': '07700 900762', 'state': 'pending', 'failed_login_count': 0, - 'permissions': {} + 'permissions': {}, + 'organisations': [] } user = User(user_data) return user @@ -1075,7 +1081,8 @@ def platform_admin_user(fake_uuid): 'manage_api_keys', 'view_activity']}, 'platform_admin': True, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1094,7 +1101,8 @@ def api_user_active(fake_uuid, email_address='test@user.gov.uk'): 'permissions': {}, 'platform_admin': False, 'auth_type': 'sms_auth', - 'password_changed_at': str(datetime.utcnow()) + 'password_changed_at': str(datetime.utcnow()), + 'organisations': [] } user = User(user_data) return user @@ -1113,7 +1121,8 @@ def api_user_active_email_auth(fake_uuid, email_address='test@user.gov.uk'): 'permissions': {}, 'platform_admin': False, 'auth_type': 'email_auth', - 'password_changed_at': str(datetime.utcnow()) + 'password_changed_at': str(datetime.utcnow()), + 'organisations': [] } user = User(user_data) return user @@ -1132,7 +1141,8 @@ def api_nongov_user_active(fake_uuid): 'permissions': {}, 'platform_admin': False, 'auth_type': 'sms_auth', - 'password_changed_at': str(datetime.utcnow()) + 'password_changed_at': str(datetime.utcnow()), + 'organisations': [] } user = User(user_data) return user @@ -1159,7 +1169,8 @@ def active_user_with_permissions(fake_uuid): 'manage_api_keys', 'view_activity']}, 'platform_admin': False, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1186,7 +1197,8 @@ def active_user_no_mobile(fake_uuid): 'manage_api_keys', 'view_activity']}, 'platform_admin': False, - 'auth_type': 'email_auth' + 'auth_type': 'email_auth', + 'organisations': [] } user = User(user_data) return user @@ -1206,7 +1218,8 @@ def active_user_view_permissions(fake_uuid): 'failed_login_count': 0, 'permissions': {SERVICE_ONE_ID: ['view_activity']}, 'platform_admin': False, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1230,7 +1243,8 @@ def active_user_manage_template_permission(fake_uuid): 'view_activity', ]}, 'platform_admin': False, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1255,7 +1269,8 @@ def active_user_no_api_key_permission(fake_uuid): 'view_activity', ]}, 'platform_admin': False, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1272,7 +1287,8 @@ def api_user_locked(fake_uuid): 'state': 'active', 'failed_login_count': 5, 'permissions': {}, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1290,7 +1306,8 @@ def api_user_request_password_reset(fake_uuid): 'failed_login_count': 5, 'permissions': {}, 'password_changed_at': None, - 'auth_type': 'sms_auth' + 'auth_type': 'sms_auth', + 'organisations': [] } user = User(user_data) return user @@ -1308,7 +1325,8 @@ def api_user_changed_password(fake_uuid): 'failed_login_count': 5, 'permissions': {}, 'auth_type': 'sms_auth', - 'password_changed_at': str(datetime.utcnow() + timedelta(minutes=1)) + 'password_changed_at': str(datetime.utcnow() + timedelta(minutes=1)), + 'organisations': [] } user = User(user_data) return user @@ -1495,13 +1513,13 @@ def mock_activate_user(mocker): @pytest.fixture(scope='function') -def mock_is_email_unique(mocker): - return mocker.patch('app.user_api_client.is_email_unique', return_value=True) +def mock_email_is_already_in_use(mocker): + return mocker.patch('app.user_api_client.is_email_already_in_use', return_value=True) @pytest.fixture(scope='function') -def mock_is_email_not_unique(mocker): - return mocker.patch('app.user_api_client.is_email_unique', return_value=False) +def mock_email_is_not_already_in_use(mocker): + return mocker.patch('app.user_api_client.is_email_already_in_use', return_value=False) @pytest.fixture(scope='function') @@ -1900,14 +1918,14 @@ def mock_get_users_by_service(mocker): 'manage_users', 'manage_templates', 'manage_settings', - 'manage_api_keys', - 'access_developer_docs']}, + 'manage_api_keys']}, 'state': 'active', 'password_changed_at': None, 'name': 'Test User', 'email_address': 'notify@digital.cabinet-office.gov.uk', 'auth_type': 'sms_auth', - 'failed_login_count': 0}] + 'failed_login_count': 0, + 'organisations': []}] return [User(data[0])] return mocker.patch('app.user_api_client.get_users_for_service', side_effect=_get_users_for_service, autospec=True) @@ -2633,6 +2651,11 @@ def mock_update_service_callback_api(mocker): return mocker.patch('app.service_api_client.update_service_callback_api', side_effect=_update_service_callback_api) +@pytest.fixture(scope='function') +def organisation_one(api_user_active): + return organisation_json('596364a0-858e-42c8-9062-a8fe822260af', 'organisation one', [api_user_active.id]) + + @pytest.fixture(scope='function') def mock_get_organisations(mocker): def _get_organisations(): @@ -2659,10 +2682,10 @@ def mock_get_organisations(mocker): @pytest.fixture(scope='function') def mock_get_organisation(mocker): - def _get_organisation(organisation_id): + def _get_organisation(org_id): return { 'name': 'Org 1', - 'id': organisation_id, + 'id': org_id, 'active': True } @@ -2705,3 +2728,85 @@ def mock_get_organisation_services(mocker): 'app.organisations_client.get_organisation_services', side_effect=_get_organisation_services ) + + +@pytest.fixture(scope='function') +def mock_get_users_for_organisation(mocker): + def _get_users_for_organisation(org_id): + return [ + User(user_json(id_='1234', name='Test User 1')), + User(user_json(id_='5678', name='Test User 2', email_address='testt@gov.uk')) + ] + + return mocker.patch( + 'app.user_api_client.get_users_for_organisation', + side_effect=_get_users_for_organisation + ) + + +@pytest.fixture(scope='function') +def mock_get_invited_users_for_organisation(mocker): + def _get_invited_invited_users_for_organisation(org_id): + return [ + invited_user(organisation='1234') + ] + + return mocker.patch( + 'app.org_invite_api_client.get_invites_for_organisation', + side_effect=_get_invited_invited_users_for_organisation + ) + + +@pytest.fixture(scope='function') +def sample_org_invite(mocker, organisation_one, status='pending'): + id_ = str(generate_uuid()) + invited_by = organisation_one['users'][0] + email_address = 'invited_user@test.gov.uk' + organisation = organisation_one['id'] + created_at = str(datetime.utcnow()) + + return org_invite_json(id_, invited_by, organisation, email_address, created_at, status) + + +@pytest.fixture(scope='function') +def mock_check_org_invite_token(mocker, sample_org_invite): + def _check_org_token(token): + return InvitedOrgUser(**sample_org_invite) + + return mocker.patch('app.org_invite_api_client.check_token', side_effect=_check_org_token) + + +@pytest.fixture(scope='function') +def mock_check_org_cancelled_invite_token(mocker, sample_org_invite): + sample_org_invite['status'] = 'cancelled' + + def _check_org_token(token): + return InvitedOrgUser(**sample_org_invite) + + return mocker.patch('app.org_invite_api_client.check_token', side_effect=_check_org_token) + + +@pytest.fixture(scope='function') +def mock_check_org_accepted_invite_token(mocker, sample_org_invite): + sample_org_invite['status'] = 'accepted' + + def _check_org_token(token): + return InvitedOrgUser(**sample_org_invite) + + return mocker.patch('app.org_invite_api_client.check_token', side_effect=_check_org_token) + + +@pytest.fixture(scope='function') +def mock_accept_org_invite(mocker, sample_org_invite): + def _accept(organisation_id, invite_id): + return InvitedOrgUser(**sample_org_invite) + + return mocker.patch('app.org_invite_api_client.accept_invite', side_effect=_accept) + + +@pytest.fixture(scope='function') +def mock_add_user_to_organisation(mocker, organisation_one, api_user_active): + def _add_user(organisation_id, user_id): + return api_user_active + + return mocker.patch('app.user_api_client.add_user_to_organisation', side_effect=_add_user)