invite-team-members

This commit is contained in:
chrisw
2018-02-23 11:43:13 +00:00
parent b7b25c7d16
commit 22bbc0d6d8
26 changed files with 1132 additions and 132 deletions
+3
View File
@@ -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.email_branding_client import EmailBrandingClient
from app.notify_client.models import AnonymousUser from app.notify_client.models import AnonymousUser
from app.notify_client.organisations_api_client import OrganisationsClient 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.letter_jobs_client import LetterJobsClient
from app.notify_client.inbound_number_client import InboundNumberClient from app.notify_client.inbound_number_client import InboundNumberClient
from app.notify_client.billing_api_client import BillingAPIClient from app.notify_client.billing_api_client import BillingAPIClient
@@ -74,6 +75,7 @@ events_api_client = EventsApiClient()
provider_client = ProviderClient() provider_client = ProviderClient()
email_branding_client = EmailBrandingClient() email_branding_client = EmailBrandingClient()
organisations_client = OrganisationsClient() organisations_client = OrganisationsClient()
org_invite_api_client = OrgInviteApiClient()
asset_fingerprinter = AssetFingerprinter() asset_fingerprinter = AssetFingerprinter()
statsd_client = StatsdClient() statsd_client = StatsdClient()
deskpro_client = DeskproClient() deskpro_client = DeskproClient()
@@ -109,6 +111,7 @@ def create_app(application):
notification_api_client.init_app(application) notification_api_client.init_app(application)
status_api_client.init_app(application) status_api_client.init_app(application)
invite_api_client.init_app(application) invite_api_client.init_app(application)
org_invite_api_client.init_app(application)
template_statistics_client.init_app(application) template_statistics_client.init_app(application)
events_api_client.init_app(application) events_api_client.init_app(application)
provider_client.init_app(application) provider_client.init_app(application)
+1 -1
View File
@@ -48,7 +48,7 @@ class Config(object):
HTTP_PROTOCOL = 'http' HTTP_PROTOCOL = 'http'
MAX_FAILED_LOGIN_COUNT = 10 MAX_FAILED_LOGIN_COUNT = 10
NOTIFY_APP_NAME = 'admin' NOTIFY_APP_NAME = 'admin'
NOTIFY_LOG_LEVEL = 'DEBUG' NOTIFY_LOG_LEVEL = 'ERROR'
PERMANENT_SESSION_LIFETIME = 20 * 60 * 60 # 20 hours PERMANENT_SESSION_LIFETIME = 20 * 60 * 60 # 20 hours
SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
+32 -1
View File
@@ -237,6 +237,25 @@ class RegisterUserFromInviteForm(StripWhitespaceForm):
raise ValidationError('Cant be empty') raise ValidationError('Cant 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='Cant be empty')]
)
mobile_number = InternationalPhoneNumber('Mobile number', validators=[DataRequired(message='Cant be empty')])
password = password()
organisation = HiddenField('organisation')
email_address = HiddenField('email_address')
auth_type = HiddenField('auth_type', validators=[DataRequired()])
class PermissionsForm(StripWhitespaceForm): class PermissionsForm(StripWhitespaceForm):
send_messages = BooleanField("Send messages from existing templates") send_messages = BooleanField("Send messages from existing templates")
manage_templates = BooleanField("Add and edit templates") manage_templates = BooleanField("Add and edit templates")
@@ -264,6 +283,18 @@ class InviteUserForm(PermissionsForm):
raise ValidationError("You cant send an invitation to yourself") raise ValidationError("You cant 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 cant send an invitation to yourself")
class TwoFactorForm(StripWhitespaceForm): class TwoFactorForm(StripWhitespaceForm):
def __init__(self, validate_code_func, *args, **kwargs): def __init__(self, validate_code_func, *args, **kwargs):
''' '''
@@ -419,7 +450,7 @@ class ChangeEmailForm(StripWhitespaceForm):
def validate_email_address(self, field): def validate_email_address(self, field):
is_valid = self.validate_email_func(field.data) is_valid = self.validate_email_func(field.data)
if not is_valid: if is_valid:
raise ValidationError("The email address is already in use") raise ValidationError("The email address is already in use")
+44 -18
View File
@@ -4,38 +4,23 @@ from flask import (
session, session,
flash, flash,
render_template, render_template,
abort, abort
current_app
) )
from itsdangerous import SignatureExpired
from markupsafe import Markup from markupsafe import Markup
from notifications_utils.url_safe_token import check_token
from flask_login import current_user from flask_login import current_user
from app.main import main from app.main import main
from app import ( from app import (
invite_api_client, invite_api_client,
org_invite_api_client,
user_api_client, user_api_client,
organisations_client,
service_api_client service_api_client
) )
@main.route("/invitation/<token>") @main.route("/invitation/<token>")
def accept_invite(token): 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) 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(): 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)) return redirect(url_for('main.service_dashboard', service_id=invited_user.service))
else: else:
return redirect(url_for('main.register_from_invite')) return redirect(url_for('main.register_from_invite'))
@main.route("/organisation-invitation/<token>")
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("""
Youre signed in as {}.
This invite is for another email address.
<a href={}>Sign out</a> 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'))
+112 -7
View File
@@ -1,11 +1,31 @@
from flask import redirect, render_template, url_for from flask import (
from flask_login import login_required 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 import main
from app.main.forms import CreateOrUpdateOrganisation from app.main.forms import CreateOrUpdateOrganisation
from app.utils import user_has_permissions from app.utils import user_has_permissions
from notifications_python_client.errors import HTTPError
from werkzeug.exceptions import abort
@main.route("/organisations", methods=['GET']) @main.route("/organisations", methods=['GET'])
@login_required @login_required
@@ -38,7 +58,7 @@ def add_organisation():
) )
@main.route("/organisation/<org_id>", methods=['GET']) @main.route("/organisations/<org_id>", methods=['GET'])
@login_required @login_required
@user_has_permissions(admin_override=True) @user_has_permissions(admin_override=True)
def organisation_dashboard(org_id): def organisation_dashboard(org_id):
@@ -50,7 +70,7 @@ def organisation_dashboard(org_id):
) )
@main.route("/organisation/<org_id>/edit", methods=['GET', 'POST']) @main.route("/organisations/<org_id>/edit", methods=['GET', 'POST'])
@login_required @login_required
@user_has_permissions(admin_override=True) @user_has_permissions(admin_override=True)
def update_organisation(org_id): def update_organisation(org_id):
@@ -75,11 +95,96 @@ def update_organisation(org_id):
) )
@main.route("/organisation/<org_id>/users", methods=['GET']) @main.route("/organisations/<org_id>/users", methods=['GET'])
@login_required @login_required
@user_has_permissions(admin_override=True) @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( return render_template(
'views/organisations/organisation/users/index.html', 'views/organisations/organisation/users/index.html',
users=users,
show_search_box=(len(users) > 7),
form=SearchUsersForm(),
) )
@main.route("/organisations/<org_id>/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/<org_id>/users/<user_id>", 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/<org_id>/users/<user_id>/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/<org_id>/cancel-invited-user/<invited_user_id>", 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))
+37 -17
View File
@@ -17,13 +17,15 @@ from app.main import main
from app.main.forms import ( from app.main.forms import (
RegisterUserForm, RegisterUserForm,
RegisterUserFromInviteForm RegisterUserFromInviteForm,
RegisterUserFromOrgInviteForm
) )
from app.main.views.verify import activate_user from app.main.views.verify import activate_user
from app import ( from app import (
user_api_client, 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) return render_template('views/register-from-invite.html', invited_user=invited_user, form=form)
def _do_registration(form, send_sms=True, send_email=True): @main.route('/register-from-org-invite', methods=['GET', 'POST'])
if user_api_client.is_email_unique(form.email_address.data): 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, user = user_api_client.register_user(form.name.data,
form.email_address.data, form.email_address.data,
form.mobile_number.data or None, form.mobile_number.data or None,
form.password.data, form.password.data,
form.auth_type.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: if send_email:
user_api_client.send_verify_email(user.id, user.email_address) 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) user_api_client.send_verify_code(user.id, 'sms', user.mobile_number)
session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1)) session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1))
session['user_details'] = {"email": user.email_address, "id": user.id} session['user_details'] = {"email": user.email_address, "id": user.id}
else: if organisation_id:
user = user_api_client.get_user_by_email(form.email_address.data) session['organisation_id'] = organisation_id
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}
@main.route('/registration-continue') @main.route('/registration-continue')
+3 -3
View File
@@ -64,9 +64,9 @@ def user_profile_email():
if not is_gov_user(current_user.email_address): if not is_gov_user(current_user.email_address):
abort(403) abort(403)
def _is_email_unique(email): def _is_email_already_in_use(email):
return user_api_client.is_email_unique(email) return user_api_client.is_email_already_in_use(email)
form = ChangeEmailForm(_is_email_unique, form = ChangeEmailForm(_is_email_already_in_use,
email_address=current_user.email_address) email_address=current_user.email_address)
if form.validate_on_submit(): if form.validate_on_submit():
+5 -1
View File
@@ -74,6 +74,10 @@ def activate_user(user_id):
user = user_api_client.get_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 # 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 session['current_session_id'] = user.current_session_id
organisation_id = session.get('organisation_id', None)
activated_user = user_api_client.activate_user(user) activated_user = user_api_client.activate_user(user)
login_user(activated_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'))
+42
View File
@@ -27,6 +27,7 @@ class User(UserMixin):
self.max_failed_login_count = max_failed_login_count self.max_failed_login_count = max_failed_login_count
self.platform_admin = fields.get('platform_admin') self.platform_admin = fields.get('platform_admin')
self.current_session_id = fields.get('current_session_id') self.current_session_id = fields.get('current_session_id')
self.organisations = fields.get('organisations', [])
def get_id(self): def get_id(self):
return self.id return self.id
@@ -155,6 +156,7 @@ class User(UserMixin):
"state": self.state, "state": self.state,
"failed_login_count": self.failed_login_count, "failed_login_count": self.failed_login_count,
"permissions": [x for x in self._permissions], "permissions": [x for x in self._permissions],
"organisations": self.organisations,
"current_session_id": self.current_session_id "current_session_id": self.current_session_id
} }
if hasattr(self, '_password'): if hasattr(self, '_password'):
@@ -164,6 +166,14 @@ class User(UserMixin):
def set_password(self, pwd): def set_password(self, pwd):
self._password = pwd self._password = pwd
@property
def organisations(self):
return self._organisations
@organisations.setter
def organisations(self, organisations):
self._organisations = organisations
class InvitedUser(object): class InvitedUser(object):
@@ -217,6 +227,38 @@ class InvitedUser(object):
return data 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): class AnonymousUser(AnonymousUserMixin):
# set the anonymous user so that if a new browser hits us we don't error http://stackoverflow.com/a/19275188 # 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): def logged_in_elsewhere(self):
@@ -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
@@ -1,4 +1,4 @@
from app.notify_client import NotifyAdminAPIClient from app.notify_client import _attach_current_user, NotifyAdminAPIClient
class OrganisationsClient(NotifyAdminAPIClient): class OrganisationsClient(NotifyAdminAPIClient):
@@ -43,3 +43,10 @@ class OrganisationsClient(NotifyAdminAPIClient):
def get_organisation_services(self, org_id): def get_organisation_services(self, org_id):
return self.get(url="/organisations/{}/services".format(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)
+12 -3
View File
@@ -131,12 +131,21 @@ class UserApiClient(NotifyAdminAPIClient):
resp = self.get(endpoint) resp = self.get(endpoint)
return [User(data) for data in resp['data']] 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): def add_user_to_service(self, service_id, user_id, permissions):
endpoint = '/service/{}/users/{}'.format(service_id, user_id) endpoint = '/service/{}/users/{}'.format(service_id, user_id)
data = [{'permission': x} for x in permissions] data = [{'permission': x} for x in permissions]
resp = self.post(endpoint, data=data) resp = self.post(endpoint, data=data)
return User(resp['data'], max_failed_login_count=self.max_failed_login_count) 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): def set_user_permissions(self, user_id, service_id, permissions):
data = [{'permission': x} for x in permissions] data = [{'permission': x} for x in permissions]
endpoint = '/user/{}/service/{}/permission'.format(user_id, service_id) endpoint = '/user/{}/service/{}/permission'.format(user_id, service_id)
@@ -147,10 +156,10 @@ class UserApiClient(NotifyAdminAPIClient):
data = {'email': email_address} data = {'email': email_address}
self.post(endpoint, data=data) 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): if self.get_user_by_email_or_none(email_address):
return False return True
return True return False
def activate_user(self, user): def activate_user(self, user):
if user.state == 'pending': if user.state == 'pending':
+1 -1
View File
@@ -2,7 +2,7 @@
<ul> <ul>
<li><a href="{{ url_for('.organisation_dashboard', org_id=current_org.id) }}">Dashboard</a></li> <li><a href="{{ url_for('.organisation_dashboard', org_id=current_org.id) }}">Dashboard</a></li>
{% if current_user.has_permissions(admin_override=True) %} {% if current_user.has_permissions(admin_override=True) %}
<li><a href="{{ url_for('.organisation_users', org_id=current_org.id) }}">Team members</a></li> <li><a href="{{ url_for('.manage_org_users', org_id=current_org.id) }}">Team members</a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
@@ -10,7 +10,7 @@
{{ from_user }} decided to cancel this invitation. {{ from_user }} decided to cancel this invitation.
</p> </p>
<p> <p>
If you need access to {{ service_name }}, youll have to ask them to invite you again. If you need access to {{ service_name or organisation_name }}, youll have to ask them to invite you again.
</p> </p>
</div> </div>
</div> </div>
@@ -1,4 +1,8 @@
{% extends "org_template.html" %} {% 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 %} {% block org_page_title %}
Team members Team members
@@ -12,6 +16,54 @@
Team members Team members
</h1> </h1>
</div> </div>
<div class="column-one-third">
<a href="{{ url_for('.invite_org_user', org_id=current_org.id) }}" class="button align-with-heading">Invite team member</a>
</div>
</div>
{% if show_search_box %}
<div data-module="autofocus">
<div class="live-search" data-module="live-search" data-targets=".user-list-item">
{{ textbox(
form.search,
width='1-1'
) }}
</div>
</div>
{% endif %}
<div class="user-list">
{% for user in users %}
<div class="user-list-item">
<h3>
{%- if user.name -%}
<span class="heading-small">{{ user.name }}</span>&ensp;
{%- endif -%}
<span class="hint">
{%- 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 %}
</span>
</h3>
<ul class="tick-cross-list">
<li class="tick-cross-list-edit-link">
{% if user.status == 'pending' %}
<a href="{{ url_for('.cancel_invited_org_user', org_id=current_org.id, invited_user_id=user.id)}}">Cancel invitation</a>
{% elif user.state == 'active' and current_user.id != user.id %}
<a href="{{ url_for('.edit_user_org_permissions', org_id=current_org.id, user_id=user.id)}}">Edit permissions</a>
{% endif %}
</li>
</ul>
</div>
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}
@@ -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 %}
<h1 class="heading-large">
Invite a team member
</h1>
<div class="grid-row">
<form method="post" class="column-three-quarters" novalidate>
{{ textbox(form.email_address, width='1-1', safe_error_message=True) }}
<div class="bottom-gutter">
<p class="form-label">
All team members can see:
</p>
<ul class="list list-bullet">
<li>dashboard</li>
<li>who the other team members are</li>
</ul>
</div>
{{ page_footer('Send invitation email') }}
</form>
</div>
{% endblock %}
@@ -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 %}
<h1 class="heading-large">
{{ user.name or user.email_localpart }}
</h1>
<p>
{{ user.email_address }}
</p>
<form method="post" class="column-three-quarters">
{{ 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'
) }}
</form>
{% endblock %}
@@ -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 %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-large">Create an account</h1>
<p>Your account will be created with this email: {{invited_org_user.email_address}}</p>
<form method="post" autocomplete="off">
{{ textbox(form.name, width='3-4') }}
<div class="extra-tracking">
{{ textbox(form.mobile_number, width='3-4', hint='Well send you a security code by text message') }}
</div>
{{ textbox(form.password, hint="At least 8 characters", width='3-4') }}
{{ page_footer("Continue") }}
{{form.organisation}}
{{form.email_address}}
</form>
</div>
</div>
{% endblock %}
+115
View File
@@ -5,6 +5,9 @@ from datetime import datetime, timedelta, timezone
from flask.testing import FlaskClient from flask.testing import FlaskClient
from flask import url_for from flask import url_for
from flask_login import login_user from flask_login import login_user
from app.notify_client.models import (
InvitedOrgUser,
)
class TestClient(FlaskClient): class TestClient(FlaskClient):
@@ -37,6 +40,85 @@ def created_by_json(id_, name='', email_address=''):
return {'id': id_, 'name': name, 'email_address': 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( def service_json(
id_='1234', id_='1234',
name='Test Service', 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, def template_json(service_id,
id_, id_,
name="sample template", 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' TEST_USER_EMAIL = 'test@user.gov.uk'
-1
View File
@@ -26,7 +26,6 @@ def test_load_service_before_request_handles_404(client_request, mocker):
@pytest.mark.parametrize('url', [ @pytest.mark.parametrize('url', [
'/invitation/MALFORMED_TOKEN',
'/new-password/MALFORMED_TOKEN', '/new-password/MALFORMED_TOKEN',
'/user-profile/email/confirm/MALFORMED_TOKEN', '/user-profile/email/confirm/MALFORMED_TOKEN',
'/verify-email/MALFORMED_TOKEN' '/verify-email/MALFORMED_TOKEN'
+2 -46
View File
@@ -1,7 +1,6 @@
from flask import url_for from flask import url_for
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from unittest.mock import ANY from unittest.mock import ANY
from itsdangerous import SignatureExpired
import app import app
from app.notify_client.models import InvitedUser 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, mock_get_service,
mocker, mocker,
): ):
mocker.patch('app.main.views.invites.check_token')
expected_service = service_one['id'] expected_service = service_one['id']
expected_permissions = ['send_messages', 'manage_service', 'manage_api_keys'] 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_add_user_to_service,
mock_get_service, mock_get_service,
): ):
mocker.patch('app.main.views.invites.check_token')
expected_service = service_one['id'] expected_service = service_one['id']
sample_invite['permissions'] = '' sample_invite['permissions'] = ''
expected_permissions = [] expected_permissions = []
@@ -69,8 +64,6 @@ def test_if_existing_user_accepts_twice_they_redirect_to_sign_in(
sample_invite, sample_invite,
mock_get_service, mock_get_service,
): ):
mocker.patch('app.main.views.invites.check_token')
sample_invite['status'] = 'accepted' sample_invite['status'] = 'accepted'
invite = InvitedUser(**sample_invite) invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=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_get_user_by_email,
mock_accept_invite, mock_accept_invite,
): ):
mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address sample_invite['email_address'] = api_user_active.email_address
invite = InvitedUser(**sample_invite) invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=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, mock_get_service,
mocker, mocker,
): ):
mocker.patch('app.main.views.invites.check_token')
expected_service = service_one['id'] expected_service = service_one['id']
expected_permissions = ['send_messages', 'manage_service', 'manage_api_keys'] 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, mock_get_service,
mocker, mocker,
): ):
mocker.patch('app.main.views.invites.check_token')
expected_redirect_location = 'http://localhost/register-from-invite' expected_redirect_location = 'http://localhost/register-from-invite'
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) 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, mock_get_service,
mocker, mocker,
): ):
mocker.patch('app.main.views.invites.check_token')
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'), follow_redirects=True) response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'), follow_redirects=True)
mock_check_invite_token.assert_called_with('thisisnotarealtoken') 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_user,
mock_get_service, mock_get_service,
): ):
mocker.patch('app.main.views.invites.check_token')
cancelled_invitation = create_sample_invite(mocker, service_one, status='cancelled') cancelled_invitation = create_sample_invite(mocker, service_one, status='cancelled')
mock_check_token_invite(mocker, cancelled_invitation) mock_check_token_invite(mocker, cancelled_invitation)
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) 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, api_user_active,
mock_check_invite_token, mock_check_invite_token,
mock_dont_get_user_by_email, mock_dont_get_user_by_email,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_register_user, mock_register_user,
mock_send_verify_code, mock_send_verify_code,
mock_accept_invite, mock_accept_invite,
@@ -246,8 +231,6 @@ def test_new_user_accept_invite_completes_new_registration_redirects_to_verify(
mock_get_service, mock_get_service,
mocker, mocker,
): ):
mocker.patch('app.main.views.invites.check_token')
expected_service = service_one['id'] expected_service = service_one['id']
expected_email = sample_invite['email_address'] expected_email = sample_invite['email_address']
expected_from_user = service_one['users'][0] 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_accept_invite,
mock_get_service, mock_get_service,
): ):
mocker.patch('app.main.views.invites.check_token')
invite = InvitedUser(**sample_invite) invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=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]) 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_accept_invite,
mock_get_user_by_email 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' # the email address of api_user_active is 'test@user.gov.uk'
sample_invite['email_address'] = 'TEST@user.gov.uk' sample_invite['email_address'] = 'TEST@user.gov.uk'
invite = InvitedUser(**sample_invite) invite = InvitedUser(**sample_invite)
@@ -345,7 +325,7 @@ def test_new_invited_user_verifies_and_added_to_service(
api_user_active, api_user_active,
mock_check_invite_token, mock_check_invite_token,
mock_dont_get_user_by_email, mock_dont_get_user_by_email,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_register_user, mock_register_user,
mock_send_verify_code, mock_send_verify_code,
mock_check_verify_code, mock_check_verify_code,
@@ -363,8 +343,6 @@ def test_new_invited_user_verifies_and_added_to_service(
mock_get_usage, mock_get_usage,
mocker, mocker,
): ):
mocker.patch('app.main.views.invites.check_token')
# visit accept token page # visit accept token page
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken')) response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
assert response.status_code == 302 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' 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( def test_existing_user_accepts_and_sets_email_auth(
client_request, client_request,
api_user_active, api_user_active,
@@ -434,7 +394,6 @@ def test_existing_user_accepts_and_sets_email_auth(
mock_add_user_to_service, mock_add_user_to_service,
mocker mocker
): ):
mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address sample_invite['email_address'] = api_user_active.email_address
service_one['permissions'].append('email_auth') 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, mock_add_user_to_service,
mocker mocker
): ):
mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address sample_invite['email_address'] = api_user_active.email_address
assert 'email_auth' not in service_one['permissions'] 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, mock_add_user_to_service,
mocker mocker
): ):
mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address sample_invite['email_address'] = api_user_active.email_address
service_one['permissions'].append('email_auth') 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, mock_add_user_to_service,
mocker mocker
): ):
mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address sample_invite['email_address'] = api_user_active.email_address
service_one['permissions'].append('email_auth') service_one['permissions'].append('email_auth')
+385 -1
View File
@@ -1,9 +1,20 @@
import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from flask import url_for from flask import url_for, Response
from datetime import (
datetime,
timedelta
)
from tests.conftest import ( from tests.conftest import (
normalize_spaces 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( def test_organisation_page_shows_all_organisations(
@@ -151,3 +162,376 @@ def test_organisation_services_show(
assert normalize_spaces( assert normalize_spaces(
page.select('.browse-list-item a')[i]['href'] page.select('.browse-list-item a')[i]['href']
) == '/services/{}'.format(service_id) ) == '/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 cant 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, youll 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 thats 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
)
+5 -5
View File
@@ -48,7 +48,7 @@ def test_register_creates_new_user_and_redirects_to_continue_page(
mock_send_verify_code, mock_send_verify_code,
mock_register_user, mock_register_user,
mock_get_user_by_email_not_found, mock_get_user_by_email_not_found,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_send_verify_email, mock_send_verify_email,
mock_login, mock_login,
phone_number_to_register_with, phone_number_to_register_with,
@@ -122,7 +122,7 @@ def test_should_add_user_details_to_session(
mock_register_user, mock_register_user,
mock_get_user, mock_get_user,
mock_get_user_by_email_not_found, mock_get_user_by_email_not_found,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_send_verify_email, mock_send_verify_email,
mock_login, mock_login,
): ):
@@ -176,7 +176,7 @@ def test_register_with_existing_email_sends_emails(
def test_register_from_invite( def test_register_from_invite(
client, client,
fake_uuid, fake_uuid,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_register_user, mock_register_user,
mock_send_verify_code, mock_send_verify_code,
mock_accept_invite, 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( def test_register_from_email_auth_invite(
client, client,
sample_invite, sample_invite,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_register_user, mock_register_user,
mock_get_user, mock_get_user,
mock_send_verify_email, mock_send_verify_email,
@@ -285,7 +285,7 @@ def test_register_from_email_auth_invite(
def test_can_register_email_auth_without_phone_number( def test_can_register_email_auth_without_phone_number(
client, client,
sample_invite, sample_invite,
mock_is_email_unique, mock_email_is_not_already_in_use,
mock_register_user, mock_register_user,
mock_get_user, mock_get_user,
mock_send_verify_email, mock_send_verify_email,
+2 -1
View File
@@ -30,6 +30,7 @@ def test_should_redirect_after_name_change(
logged_in_client, logged_in_client,
api_user_active, api_user_active,
mock_update_user_attribute, mock_update_user_attribute,
mock_email_is_not_already_in_use
): ):
new_name = 'New Name' new_name = 'New Name'
data = {'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( def test_should_redirect_after_email_change(
logged_in_client, logged_in_client,
mock_login, mock_login,
mock_is_email_unique, mock_email_is_not_already_in_use,
): ):
data = {'email_address': 'new_notify@notify.gov.uk'} data = {'email_address': 'new_notify@notify.gov.uk'}
response = logged_in_client.post( response = logged_in_client.post(
+129 -24
View File
@@ -11,11 +11,15 @@ from bs4 import BeautifulSoup
from app import create_app from app import create_app
from app.notify_client.models import ( from app.notify_client.models import (
User, User,
InvitedUser InvitedUser,
InvitedOrgUser
) )
from . import ( from . import (
service_json, service_json,
organisation_json,
user_json,
invited_user,
TestClient, TestClient,
template_json, template_json,
template_version_json, template_version_json,
@@ -25,7 +29,8 @@ from . import (
invite_json, invite_json,
sample_uuid, sample_uuid,
generate_uuid, generate_uuid,
single_notification_json single_notification_json,
org_invite_json
) )
from notifications_utils.url_safe_token import generate_token from notifications_utils.url_safe_token import generate_token
@@ -1050,7 +1055,8 @@ def api_user_pending(fake_uuid):
'mobile_number': '07700 900762', 'mobile_number': '07700 900762',
'state': 'pending', 'state': 'pending',
'failed_login_count': 0, 'failed_login_count': 0,
'permissions': {} 'permissions': {},
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1075,7 +1081,8 @@ def platform_admin_user(fake_uuid):
'manage_api_keys', 'manage_api_keys',
'view_activity']}, 'view_activity']},
'platform_admin': True, 'platform_admin': True,
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1094,7 +1101,8 @@ def api_user_active(fake_uuid, email_address='test@user.gov.uk'):
'permissions': {}, 'permissions': {},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'sms_auth', 'auth_type': 'sms_auth',
'password_changed_at': str(datetime.utcnow()) 'password_changed_at': str(datetime.utcnow()),
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1113,7 +1121,8 @@ def api_user_active_email_auth(fake_uuid, email_address='test@user.gov.uk'):
'permissions': {}, 'permissions': {},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'email_auth', 'auth_type': 'email_auth',
'password_changed_at': str(datetime.utcnow()) 'password_changed_at': str(datetime.utcnow()),
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1132,7 +1141,8 @@ def api_nongov_user_active(fake_uuid):
'permissions': {}, 'permissions': {},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'sms_auth', 'auth_type': 'sms_auth',
'password_changed_at': str(datetime.utcnow()) 'password_changed_at': str(datetime.utcnow()),
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1159,7 +1169,8 @@ def active_user_with_permissions(fake_uuid):
'manage_api_keys', 'manage_api_keys',
'view_activity']}, 'view_activity']},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1186,7 +1197,8 @@ def active_user_no_mobile(fake_uuid):
'manage_api_keys', 'manage_api_keys',
'view_activity']}, 'view_activity']},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'email_auth' 'auth_type': 'email_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1206,7 +1218,8 @@ def active_user_view_permissions(fake_uuid):
'failed_login_count': 0, 'failed_login_count': 0,
'permissions': {SERVICE_ONE_ID: ['view_activity']}, 'permissions': {SERVICE_ONE_ID: ['view_activity']},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1230,7 +1243,8 @@ def active_user_manage_template_permission(fake_uuid):
'view_activity', 'view_activity',
]}, ]},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1255,7 +1269,8 @@ def active_user_no_api_key_permission(fake_uuid):
'view_activity', 'view_activity',
]}, ]},
'platform_admin': False, 'platform_admin': False,
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1272,7 +1287,8 @@ def api_user_locked(fake_uuid):
'state': 'active', 'state': 'active',
'failed_login_count': 5, 'failed_login_count': 5,
'permissions': {}, 'permissions': {},
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1290,7 +1306,8 @@ def api_user_request_password_reset(fake_uuid):
'failed_login_count': 5, 'failed_login_count': 5,
'permissions': {}, 'permissions': {},
'password_changed_at': None, 'password_changed_at': None,
'auth_type': 'sms_auth' 'auth_type': 'sms_auth',
'organisations': []
} }
user = User(user_data) user = User(user_data)
return user return user
@@ -1308,7 +1325,8 @@ def api_user_changed_password(fake_uuid):
'failed_login_count': 5, 'failed_login_count': 5,
'permissions': {}, 'permissions': {},
'auth_type': 'sms_auth', '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) user = User(user_data)
return user return user
@@ -1495,13 +1513,13 @@ def mock_activate_user(mocker):
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def mock_is_email_unique(mocker): def mock_email_is_already_in_use(mocker):
return mocker.patch('app.user_api_client.is_email_unique', return_value=True) return mocker.patch('app.user_api_client.is_email_already_in_use', return_value=True)
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def mock_is_email_not_unique(mocker): def mock_email_is_not_already_in_use(mocker):
return mocker.patch('app.user_api_client.is_email_unique', return_value=False) return mocker.patch('app.user_api_client.is_email_already_in_use', return_value=False)
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
@@ -1900,14 +1918,14 @@ def mock_get_users_by_service(mocker):
'manage_users', 'manage_users',
'manage_templates', 'manage_templates',
'manage_settings', 'manage_settings',
'manage_api_keys', 'manage_api_keys']},
'access_developer_docs']},
'state': 'active', 'state': 'active',
'password_changed_at': None, 'password_changed_at': None,
'name': 'Test User', 'name': 'Test User',
'email_address': 'notify@digital.cabinet-office.gov.uk', 'email_address': 'notify@digital.cabinet-office.gov.uk',
'auth_type': 'sms_auth', 'auth_type': 'sms_auth',
'failed_login_count': 0}] 'failed_login_count': 0,
'organisations': []}]
return [User(data[0])] return [User(data[0])]
return mocker.patch('app.user_api_client.get_users_for_service', side_effect=_get_users_for_service, autospec=True) 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) 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') @pytest.fixture(scope='function')
def mock_get_organisations(mocker): def mock_get_organisations(mocker):
def _get_organisations(): def _get_organisations():
@@ -2659,10 +2682,10 @@ def mock_get_organisations(mocker):
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def mock_get_organisation(mocker): def mock_get_organisation(mocker):
def _get_organisation(organisation_id): def _get_organisation(org_id):
return { return {
'name': 'Org 1', 'name': 'Org 1',
'id': organisation_id, 'id': org_id,
'active': True 'active': True
} }
@@ -2705,3 +2728,85 @@ def mock_get_organisation_services(mocker):
'app.organisations_client.get_organisation_services', 'app.organisations_client.get_organisation_services',
side_effect=_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)