Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/scaffold UI test user on demand #2286

Merged
merged 43 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9b3c5d1
initial commit
andrewleith Jul 18, 2024
7352db5
feat: create and destroy test users on demand
andrewleith Jul 22, 2024
de7d5b9
Merge branch 'main' into feat/scaffold-ui-test-user-on-demand
andrewleith Sep 13, 2024
5f5eb9f
chore(config): add some hardcoded UUIDs for cypress related items
andrewleith Sep 13, 2024
cfe8719
feat(cypress api): update create_test_user route:
andrewleith Sep 13, 2024
00dc686
feat(cypress api): update cleanup_stale_users route:
andrewleith Sep 13, 2024
6d1acba
chore: formatting
andrewleith Sep 13, 2024
7704b89
chore(format): formatting files I haven't touched :(
andrewleith Sep 13, 2024
0dd0721
chore: formatting
andrewleith Sep 13, 2024
7417e0e
chore: formatting
andrewleith Sep 13, 2024
9bb10ae
chore: formatting
andrewleith Sep 13, 2024
33e2ca7
chore: update docstrings; enhance exception handling
andrewleith Sep 16, 2024
490e666
fix(cypress api): use service id from config
andrewleith Sep 17, 2024
ae3be90
chore: add more cypress values to config
andrewleith Sep 17, 2024
17b4a2b
feat(cypress api): dont pass password around since its already a secr…
andrewleith Sep 23, 2024
425e156
feat(cypress data): migration to create cypress service, permissions,…
andrewleith Sep 23, 2024
3be6c6c
Merge branch 'main' into feat/scaffold-ui-test-user-on-demand
andrewleith Sep 27, 2024
f4c313a
feat(create_test_user): update delete logic to be more complete; para…
andrewleith Sep 28, 2024
74dd85d
chore: formatting
andrewleith Sep 28, 2024
0b1584a
Merge branch 'main' into feat/scaffold-ui-test-user-on-demand
andrewleith Oct 4, 2024
9608861
chore: remove unreachable code
andrewleith Oct 4, 2024
b3f9d5d
task: add config values for testing
andrewleith Oct 25, 2024
09d027c
feat(auth): add separate auth mechanism for ui testing
andrewleith Oct 25, 2024
dd93318
chore: add tests
andrewleith Oct 25, 2024
e4ed50a
chore: formatting
andrewleith Oct 25, 2024
b38cb7a
chore: fix tests
andrewleith Oct 25, 2024
0afb435
chore: remove unused code
andrewleith Oct 25, 2024
4e4027d
Merge branch 'main' into feat/scaffold-ui-test-user-on-demand
andrewleith Oct 25, 2024
350649c
chore: fix migration due to incoming migrations
andrewleith Oct 28, 2024
1cdb7ea
Merge branch 'main' into feat/scaffold-ui-test-user-on-demand
andrewleith Nov 8, 2024
0a31d2c
chore: re-number migrations due to merge
andrewleith Nov 8, 2024
c0f4456
task: pass new secret `CYPRESS_USER_PW_SECRET` into CI
andrewleith Nov 8, 2024
6518911
chore: add some debugging to the workflow
andrewleith Nov 8, 2024
56f8cb2
task: debug workflow
andrewleith Nov 8, 2024
911b957
fix(workflow): put the env statement in the right place
andrewleith Nov 8, 2024
bbfdb84
chore: fix workflow
andrewleith Nov 8, 2024
baeb377
chore: add __init__.py in `tests/app/cypress` folder to make tests work
andrewleith Nov 8, 2024
7dc741f
Plz [review] !
andrewleith Nov 8, 2024
1bf62de
chore: fix incorrect descriptions
andrewleith Nov 8, 2024
18458ad
fix: correct typo in function name
andrewleith Nov 8, 2024
a566d52
fix: use cds domain instead of gov.uk
andrewleith Nov 8, 2024
d3d877c
chore: fix failing test
andrewleith Nov 8, 2024
15d248a
Merge branch 'main' into feat/scaffold-ui-test-user-on-demand
andrewleith Nov 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def register_blueprint(application):
from app.billing.rest import billing_blueprint
from app.cache.rest import cache_blueprint
from app.complaint.complaint_rest import complaint_blueprint
from app.cypress.rest import cypress_blueprint
from app.email_branding.rest import email_branding_blueprint
from app.events.rest import events as events_blueprint
from app.inbound_number.rest import inbound_number_blueprint
Expand Down Expand Up @@ -270,6 +271,8 @@ def register_blueprint(application):

register_notify_blueprint(application, template_category_blueprint, requires_admin_auth)

register_notify_blueprint(application, cypress_blueprint, requires_admin_auth, "/cypress")

register_notify_blueprint(application, cache_blueprint, requires_cache_clear_auth)


Expand Down
8 changes: 8 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@ class Config(object):
DEFAULT_TEMPLATE_CATEGORY_MEDIUM = "f75d6706-21b7-437e-b93a-2c0ab771e28e"
DEFAULT_TEMPLATE_CATEGORY_HIGH = "c4f87d7c-a55b-4c0f-91fe-e56c65bb1871"

# UUIDs for Cypress tests
CYPRESS_SERVICE_ID = "d4e8a7f4-2b8a-4c9a-8b3f-9c2d4e8a7f4b"
CYPRESS_TEST_USER_ID = "e5f9d8c7-3a9b-4d8c-9b4f-8d3e5f9d8c7a"
CYPRESS_TEST_USER_ADMIN_ID = "4f8b8b1e-9c4f-4d8b-8b1e-4f8b8b1e9c4f"
CYPRESS_SMOKE_TEST_EMAIL_TEMPLATE_ID = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
CYPRESS_SMOKE_TEST_SMS_TEMPLATE_ID = "e4b8f8d0-6a3b-4b9e-8c2b-1f2d3e4a5b6c"
CYPRESS_USER_PW_SECRET = os.getenv("CYPRESS_USER_PW_SECRET")

# Allowed service IDs able to send HTML through their templates.
ALLOW_HTML_SERVICE_IDS: List[str] = [id.strip() for id in os.getenv("ALLOW_HTML_SERVICE_IDS", "").split(",")]

Expand Down
Empty file added app/cypress/__init__.py
Empty file.
206 changes: 206 additions & 0 deletions app/cypress/rest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this addition + the Cypress auth, I'll be sure to move the utility method from that PR in here too!

This module will be used by the cypress tests to create users on the fly whenever a test suite is run, and clean
them up periodically to keep the data footprint small.
"""

import hashlib
import re
import uuid
from datetime import datetime, timedelta

from flask import Blueprint, current_app, jsonify

from app import db
from app.dao.services_dao import dao_add_user_to_service
from app.dao.users_dao import save_model_user
from app.errors import register_errors
from app.models import (
AnnualBilling,
LoginEvent,
Permission,
Service,
ServicePermission,
ServiceUser,
Template,
TemplateHistory,
TemplateRedacted,
User,
VerifyCode,
)

cypress_blueprint = Blueprint("cypress", __name__)
register_errors(cypress_blueprint)

EMAIL_PREFIX = "notify-ui-tests+ag_"


@cypress_blueprint.route("/create_user/<email_name>", methods=["POST"])
def create_test_user(email_name):
"""
Create a test user for Notify UI testing.

Args:
email_name (str): The name to be used in the email address of the test user.

Returns:
dict: A dictionary containing the serialized user information.
"""
if current_app.config["NOTIFY_ENVIRONMENT"] == "production":
return jsonify(message="Forbidden"), 403

# Sanitize email_name to allow only alphanumeric characters
if not re.match(r"^[a-z0-9]+$", email_name):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using re.fullmatch instead of re.match to ensure the entire string is matched against the regex pattern.

return jsonify(message="Invalid email name"), 400

try:
# Create the users
user_regular = {
"id": uuid.uuid4(),
"name": "Notify UI testing account",
"email_address": f"{EMAIL_PREFIX}{email_name}@cds-snc.ca",
"password": hashlib.sha256(
(current_app.config["CYPRESS_USER_PW_SECRET"] + current_app.config["DANGEROUS_SALT"]).encode("utf-8")
).hexdigest(),
"mobile_number": "9025555555",
"state": "active",
"blocked": False,
}

user = User(**user_regular)
save_model_user(user)

# Create the users
user_admin = {
"id": uuid.uuid4(),
"name": "Notify UI testing account",
"email_address": f"{EMAIL_PREFIX}{email_name}[email protected]",
"password": hashlib.sha256(
(current_app.config["CYPRESS_USER_PW_SECRET"] + current_app.config["DANGEROUS_SALT"]).encode("utf-8")
).hexdigest(),
"mobile_number": "9025555555",
"state": "active",
"blocked": False,
"platform_admin": True,
}

user2 = User(**user_admin)
save_model_user(user2)

# add user to cypress service w/ full permissions
service = Service.query.filter_by(id=current_app.config["CYPRESS_SERVICE_ID"]).first()
permissions_reg = []
for p in [
"manage_users",
"manage_templates",
"manage_settings",
"send_texts",
"send_emails",
"send_letters",
"manage_api_keys",
"view_activity",
]:
permissions_reg.append(Permission(permission=p))

dao_add_user_to_service(service, user, permissions=permissions_reg)

permissions_admin = []
for p in [
"manage_users",
"manage_templates",
"manage_settings",
"send_texts",
"send_emails",
"send_letters",
"manage_api_keys",
"view_activity",
]:
permissions_admin.append(Permission(permission=p))
dao_add_user_to_service(service, user2, permissions=permissions_admin)

current_app.logger.info(f"Created test user {user.email_address} and {user2.email_address}")
except Exception:
return jsonify(message="Error creating user"), 400
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider logging the exception details here to help with debugging.


users = {"regular": user.serialize(), "admin": user2.serialize()}

return jsonify(users), 201


def _destroy_test_user(email_name):
user = User.query.filter_by(email_address=f"{EMAIL_PREFIX}{email_name}@cds-snc.ca").first()

if not user:
current_app.logger.error(f"Error destroying test user {user.email_address}: no user found")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message here references user.email_address, which will raise an AttributeError if user is None. Consider updating the log message to avoid this issue.

return

try:
# update the cypress service's created_by to be the main cypress user
# this value gets changed when updating branding (and possibly other updates to service)
# and is a bug
cypress_service = Service.query.filter_by(id=current_app.config["CYPRESS_SERVICE_ID"]).first()
cypress_service.created_by_id = current_app.config["CYPRESS_TEST_USER_ID"]

# cycle through all the services created by this user, remove associated entities
services = Service.query.filter_by(created_by=user).filter(Service.id != current_app.config["CYPRESS_SERVICE_ID"])
for service in services.all():
TemplateHistory.query.filter_by(service_id=service.id).delete()

Template.query.filter_by(service_id=service.id).delete()
AnnualBilling.query.filter_by(service_id=service.id).delete()
ServicePermission.query.filter_by(service_id=service.id).delete()
Permission.query.filter_by(service_id=service.id).delete()

services.delete()

# remove all entities related to the user itself
TemplateRedacted.query.filter_by(updated_by=user).delete()
TemplateHistory.query.filter_by(created_by=user).delete()
Template.query.filter_by(created_by=user).delete()
Permission.query.filter_by(user=user).delete()
LoginEvent.query.filter_by(user=user).delete()
ServiceUser.query.filter_by(user_id=user.id).delete()
VerifyCode.query.filter_by(user=user).delete()
User.query.filter_by(email_address=f"{EMAIL_PREFIX}{email_name}@cds-snc.ca").delete()

db.session.commit()

except Exception as e:
current_app.logger.error(f"Error destroying test user {user.email_address}: {str(e)}")
db.session.rollback()


@cypress_blueprint.route("/cleanup", methods=["GET"])
def cleanup_stale_users():
"""
Method for cleaning up stale users. This method will only be used internally by the Cypress tests.

This method is responsible for removing stale testing users from the database.
Stale users are identified as users whose email addresses match the pattern "%notify-ui-tests+ag_%@cds-snc.ca%" and whose creation time is older than three hours ago.

If this is accessed from production, it will return a 403 Forbidden response.

Returns:
A JSON response with a success message if the cleanup is successful, or an error message if an exception occurs during the cleanup process.
"""
if current_app.config["NOTIFY_ENVIRONMENT"] == "production":
return jsonify(message="Forbidden"), 403
raise Exception("")
Fixed Show fixed Hide fixed

try:
three_hours_ago = datetime.utcnow() - timedelta(hours=3)
users = User.query.filter(
User.email_address.like(f"%{EMAIL_PREFIX}%@cds-snc.ca%"), User.created_at < three_hours_ago
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The like pattern here has an extra % at the end. It should be User.email_address.like(f"%{EMAIL_PREFIX}%@cds-snc.ca").

).all()

# loop through users and call destroy_user on each one
for user in users:
user_email = user.email_address.split("+ag_")[1].split("@")[0]
_destroy_test_user(user_email)

db.session.commit()
except Exception:
current_app.logger.error("[cleanup_stale_users]: error cleaning up test users")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider logging the exception details here to help with debugging.

return jsonify(message="Error cleaning up"), 500

current_app.logger.info("[cleanup_stale_users]: Cleaned up stale test users")
return jsonify(message="Clean up ccomplete"), 201
2 changes: 1 addition & 1 deletion application.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

app = create_app(application)

xray_recorder.configure(service='Notify-API', context=NotifyContext())
xray_recorder.configure(service="Notify-API", context=NotifyContext())
XRayMiddleware(app, xray_recorder)

apig_wsgi_handler = make_lambda_handler(app, binary_support=True)
Expand Down
140 changes: 140 additions & 0 deletions migrations/versions/0461_add_cypress_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""empty message

Revision ID: 0461_add_cypress_data
Revises: 0460_new_service_columns
Create Date: 2016-06-01 14:17:01.963181

"""

import hashlib
import uuid

# revision identifiers, used by Alembic.
from datetime import datetime

from alembic import op
from flask import current_app

from app.dao.date_util import get_current_financial_year_start_year
from app.encryption import hashpw
from app.models import PERMISSION_LIST

revision = "0461_add_cypress_data"
down_revision = "0460_new_service_columns"

user_id = current_app.config["CYPRESS_TEST_USER_ID"]
admin_user_id = current_app.config["CYPRESS_TEST_USER_ADMIN_ID"]
service_id = current_app.config["CYPRESS_SERVICE_ID"]
email_template_id = current_app.config["CYPRESS_SMOKE_TEST_EMAIL_TEMPLATE_ID"]
sms_template_id = current_app.config["CYPRESS_SMOKE_TEST_SMS_TEMPLATE_ID"]
default_category_id = current_app.config["DEFAULT_TEMPLATE_CATEGORY_LOW"]


def upgrade():
password = hashpw(
hashlib.sha256(
(current_app.config["CYPRESS_USER_PW_SECRET"] + current_app.config["DANGEROUS_SALT"]).encode("utf-8")
).hexdigest()
)
current_year = get_current_financial_year_start_year()
default_limit = 250000

op.get_bind()

# insert test user
user_insert = """INSERT INTO users (id, name, email_address, created_at, failed_login_count, _password, mobile_number, password_changed_at, state, platform_admin, auth_type)
VALUES ('{}', 'Notify UI test user', '[email protected]', '{}', 0,'{}', '+441234123412', '{}', 'active', False, 'email_auth')
"""
op.execute(user_insert.format(user_id, datetime.utcnow(), password, datetime.utcnow()))
# insert test user thats platform admin
user_insert = """INSERT INTO users (id, name, email_address, created_at, failed_login_count, _password, mobile_number, password_changed_at, state, platform_admin, auth_type)
VALUES ('{}', 'Notify UI test user', '[email protected]', '{}', 0,'{}', '+441234123412', '{}', 'active', True, 'email_auth')
"""
op.execute(user_insert.format(admin_user_id, datetime.utcnow(), password, datetime.utcnow()))

# insert test service
service_history_insert = """INSERT INTO services_history (id, name, created_at, active, message_limit, restricted, research_mode, email_from, created_by_id, sms_daily_limit, prefix_sms, organisation_type, version)
VALUES ('{}', 'Cypress UI Testing Service', '{}', True, 10000, False, False, '[email protected]',
'{}', 10000, True, 'central', 1)
"""
op.execute(service_history_insert.format(service_id, datetime.utcnow(), user_id))
service_insert = """INSERT INTO services (id, name, created_at, active, message_limit, restricted, research_mode, email_from, created_by_id, sms_daily_limit, prefix_sms, organisation_type, version)
VALUES ('{}', 'Cypress UI Testing Service', '{}', True, 10000, False, False, '[email protected]',
'{}', 10000, True, 'central', 1)
"""
op.execute(service_insert.format(service_id, datetime.utcnow(), user_id))

for send_type in ("sms", "email"):
service_perms_insert = """INSERT INTO service_permissions (service_id, permission, created_at)
VALUES ('{}', '{}', '{}')"""
op.execute(service_perms_insert.format(service_id, send_type, datetime.utcnow()))

insert_row_if_not_exist = """INSERT INTO annual_billing (id, service_id, financial_year_start, free_sms_fragment_limit, created_at, updated_at)
VALUES ('{}', '{}', {}, {}, '{}', '{}')
"""
op.execute(
insert_row_if_not_exist.format(
uuid.uuid4(), service_id, current_year, default_limit, datetime.utcnow(), datetime.utcnow()
)
)

user_to_service_insert = """INSERT INTO user_to_service (user_id, service_id) VALUES ('{}', '{}')"""
op.execute(user_to_service_insert.format(user_id, service_id))

for permission in PERMISSION_LIST:
perms_insert = (
"""INSERT INTO permissions (id, service_id, user_id, permission, created_at) VALUES ('{}', '{}', '{}', '{}', '{}')"""
)
op.execute(perms_insert.format(uuid.uuid4(), service_id, user_id, permission, datetime.utcnow()))

# insert test email template
_insert_template(email_template_id, "SMOKE_TEST_EMAIL", "SMOKE_TEST_EMAIL", "email", "SMOKE_TEST_EMAIL", default_category_id)

# insert test SMS template
_insert_template(sms_template_id, "SMOKE_TEST_SMS", "SMOKE_TEST_SMS", "sms", None, default_category_id)

# insert 10 random email templates
for i in range(10):
_insert_template(
uuid.uuid4(), "Template {}".format(i), "Template {}".format(i), "email", "Template {}".format(i), default_category_id
)

# insert 1 random sms template
_insert_template(uuid.uuid4(), "Template 11", "Template 11", "sms", "Template 11", "b6c42a7e-2a26-4a07-802b-123a5c3198a9")


def downgrade():
op.get_bind()
op.execute("delete from permissions where service_id = '{}'".format(service_id))
op.execute("delete from annual_billing where service_id = '{}'".format(service_id))
op.execute("delete from service_permissions where service_id = '{}'".format(service_id))
op.execute("delete from login_events where user_id = '{}'".format(user_id))
op.execute("delete from verify_codes where user_id = '{}'".format(user_id))
op.execute("delete from login_events where user_id = '{}'".format(admin_user_id))
op.execute("delete from verify_codes where user_id = '{}'".format(admin_user_id))
op.execute("delete from templates where service_id = '{}'".format(service_id))
op.execute("delete from templates_history where service_id = '{}'".format(service_id))
op.execute("delete from user_to_service where service_id = '{}'".format(service_id))
op.execute("delete from services_history where id = '{}'".format(service_id))
op.execute("delete from services where id = '{}'".format(service_id))
op.execute("delete from users where id = '{}'".format(user_id))
op.execute("delete from users where id = '{}'".format(admin_user_id))


def _insert_template(id, name, content, type, subject, category_id):
template_history_insert = """INSERT INTO templates_history (id, name, template_type, created_at,
content, archived, service_id,
subject, created_by_id, hidden, template_category_id, version)
VALUES ('{}', '{}', '{}', '{}', '{}', False, '{}', '{}', '{}', False, '{}', 1)
"""
template_insert = """INSERT INTO templates (id, name, template_type, created_at,
content, archived, service_id, subject, created_by_id, hidden, template_category_id, version)
VALUES ('{}', '{}', '{}', '{}', '{}', False, '{}', '{}', '{}', False, '{}', 1)
"""

op.execute(
template_history_insert.format(
uuid.uuid4(), name, type, datetime.utcnow(), content, service_id, subject, user_id, category_id
)
)
op.execute(template_insert.format(id, name, type, datetime.utcnow(), content, service_id, subject, user_id, category_id))
2 changes: 1 addition & 1 deletion run_celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
application = Flask("celery")
create_app(application)

xray_recorder.configure(service='Notify', context=NotifyContext())
xray_recorder.configure(service="Notify", context=NotifyContext())
XRayMiddleware(application, xray_recorder)

application.app_context().push()
Loading