diff --git a/backend/src/appointment/controller/data.py b/backend/src/appointment/controller/data.py index 1a62d8110..71e066c93 100644 --- a/backend/src/appointment/controller/data.py +++ b/backend/src/appointment/controller/data.py @@ -3,7 +3,7 @@ from io import StringIO, BytesIO from zipfile import ZipFile -from ..database import repo +from ..database import repo, models from ..database.schemas import Subscriber from ..exceptions.account_api import AccountDeletionPartialFail, AccountDeletionSubscriberFail from ..l10n import l10n @@ -45,6 +45,8 @@ def download(db, subscriber: Subscriber): external_connections = subscriber.external_connections schedules = repo.schedule.get_by_subscriber(db, subscriber.id) availability = [repo.schedule.get_availability(db, schedule.id) for schedule in schedules] + invite = repo.invite.get_by_subscriber(db, subscriber.id) + waiting_list = invite.waiting_list # Convert models to csv attendee_buffer = model_to_csv_buffer(attendees) @@ -54,6 +56,8 @@ def download(db, subscriber: Subscriber): slot_buffer = model_to_csv_buffer(slots) external_connections_buffer = model_to_csv_buffer(external_connections) schedules_buffer = model_to_csv_buffer(schedules) + invite_buffer = model_to_csv_buffer([invite]) + waiting_list_buffer = model_to_csv_buffer([waiting_list]) # Unique behaviour because we can have lists of lists..too annoying to not do it this way. availability_buffer = '' @@ -71,6 +75,8 @@ def download(db, subscriber: Subscriber): data_zip.writestr('external_connection.csv', external_connections_buffer.getvalue()) data_zip.writestr('schedules.csv', schedules_buffer.getvalue()) data_zip.writestr('availability.csv', availability_buffer) + data_zip.writestr('invite.csv', invite_buffer.getvalue()) + data_zip.writestr('waiting_list.csv', waiting_list_buffer.getvalue()) data_zip.writestr( 'readme.txt', l10n('account-data-readme', {'download_time': datetime.datetime.now(datetime.UTC)}) ) @@ -90,12 +96,18 @@ def delete_account(db, subscriber: Subscriber): l10n('account-delete-fail'), ) + # A list of connected account data, if any value is True then we've failed empty_check = [ len(repo.attendee.get_by_subscriber(db, subscriber.id)), len(repo.slot.get_by_subscriber(db, subscriber.id)), len(repo.appointment.get_by_subscriber(db, subscriber.id)), len(repo.calendar.get_by_subscriber(db, subscriber.id)), len(repo.schedule.get_by_subscriber(db, subscriber.id)), + len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.fxa)), + len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.google)), + len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.zoom)), + repo.invite.get_by_subscriber(db, subscriber.id), + repo.invite.get_waiting_list_entry_by_email(db, subscriber.email) ] # Check if we have any left-over subscriber data diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index d9d10b0eb..d97d29931 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -273,3 +273,26 @@ def text(self): def html(self): return get_template('new_account.jinja2').render(homepage_url=os.getenv('FRONTEND_URL')) + + +class ConfirmYourEmailMail(Mailer): + def __init__(self, confirm_url, decline_url, *args, **kwargs): + default_kwargs = {'subject': l10n('confirm-email-mail-subject')} + self.confirm_url = confirm_url + self.decline_url = decline_url + super(ConfirmYourEmailMail, self).__init__(*args, **default_kwargs, **kwargs) + + def text(self): + return l10n( + 'confirm-email-mail-plain', + { + 'confirm_email_url': self.confirm_url, + 'decline_email_url': self.decline_url, + }, + ) + + def html(self): + return get_template('confirm_email.jinja2').render( + confirm_email_url=self.confirm_url, + decline_email_url=self.decline_url + ) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index ebe15d580..578297d5e 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -142,7 +142,7 @@ class Subscriber(HasSoftDelete, Base): calendars = relationship('Calendar', cascade='all,delete', back_populates='owner') slots = relationship('Slot', cascade='all,delete', back_populates='subscriber') external_connections = relationship('ExternalConnections', cascade='all,delete', back_populates='owner') - invite: Mapped['Invite'] = relationship('Invite', back_populates='subscriber', uselist=False) + invite: Mapped['Invite'] = relationship('Invite', cascade='all,delete', back_populates='subscriber', uselist=False) def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections': """Retrieves the first found external connection by type or returns None if not found""" @@ -341,6 +341,7 @@ class Invite(Base): status = Column(Enum(InviteStatus), index=True) subscriber: Mapped['Subscriber'] = relationship('Subscriber', back_populates='invite', single_parent=True) + waiting_list: Mapped['WaitingList'] = relationship('WaitingList', cascade='all,delete', back_populates='invite', uselist=False) @property def is_used(self) -> bool: @@ -356,3 +357,15 @@ def is_revoked(self) -> bool: def is_available(self) -> bool: """True if the invite code is not assigned nor revoked""" return self.subscriber_id is None and self.status == InviteStatus.active + + +class WaitingList(Base): + """Holds a list of hopefully future-Appointment users""" + __tablename__ = 'waiting_list' + + id = Column(Integer, primary_key=True, index=True) + email = Column(encrypted_type(String), unique=True, index=True, nullable=False) + email_verified = Column(Boolean, nullable=False, index=True, default=False) + invite_id = Column(Integer, ForeignKey('invites.id'), nullable=True, index=True) + + invite: Mapped['Invite'] = relationship('Invite', back_populates='waiting_list', single_parent=True) diff --git a/backend/src/appointment/database/repo/invite.py b/backend/src/appointment/database/repo/invite.py index 201ceeff4..3617b76b1 100644 --- a/backend/src/appointment/database/repo/invite.py +++ b/backend/src/appointment/database/repo/invite.py @@ -9,7 +9,11 @@ from .. import models, schemas -def get_by_code(db: Session, code: str): +def get_by_subscriber(db: Session, subscriber_id: int) -> models.Invite: + return db.query(models.Invite).filter(models.Invite.subscriber_id == subscriber_id).first() + + +def get_by_code(db: Session, code: str) -> models.Invite: """retrieve invite by code""" return db.query(models.Invite).filter(models.Invite.code == code).first() @@ -69,3 +73,47 @@ def revoke_code(db: Session, code: str): db.commit() db.refresh(db_invite) return True + + +def get_waiting_list_entry_by_email(db: Session, email: str) -> models.WaitingList: + return db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + +def add_to_waiting_list(db: Session, email: str): + """Add a given email to the invite bucket""" + # Check if they're already in the invite bucket + bucket = get_waiting_list_entry_by_email(db, email) + if bucket: + # Already in waiting list + return False + + bucket = models.WaitingList(email=email) + db.add(bucket) + db.commit() + db.refresh(bucket) + return True + + +def confirm_waiting_list_email(db: Session, email: str): + """Flip the email_verified field to True""" + bucket = get_waiting_list_entry_by_email(db, email) + if not bucket: + return False + + bucket.email_verified = True + db.add(bucket) + db.commit() + db.refresh(bucket) + return True + + +def remove_waiting_list_email(db: Session, email: str): + """Remove an existing email from the waiting list""" + bucket = get_waiting_list_entry_by_email(db, email) + # Already done, lol! + if not bucket: + return True + + db.delete(bucket) + db.commit() + return True diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 8e9f0f578..82ab41f7d 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -8,7 +8,7 @@ from datetime import datetime, date, time from typing import Annotated, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, EmailStr from .models import ( AppointmentStatus, @@ -384,3 +384,15 @@ class TokenData(BaseModel): class SendInviteEmailIn(BaseModel): email: str = Field(title='Email', min_length=1) + + +class JoinTheWaitingList(BaseModel): + email: str = Field(title='Email', min_length=1) + + +class TokenForWaitingList(BaseModel): + token: str = Field(title='Token') + + +class CheckEmail(BaseModel): + email: EmailStr = Field(title='Email', min_length=1) diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index 11628cd9b..d22c96221 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -278,3 +278,14 @@ class SubscriberSelfDeleteException(APIException): def get_msg(self): return l10n('subscriber-self-delete') + + +class WaitingListActionFailed(APIException): + """Raise if the waiting list link was valid but failed for some reason""" + + id_code = 'WAITING_LIST_FAIL' + status_code = 400 + + def get_msg(self): + return l10n('unknown-error') + diff --git a/backend/src/appointment/l10n/de/email.ftl b/backend/src/appointment/l10n/de/email.ftl index aa0c73a3b..a4cbe4e77 100644 --- a/backend/src/appointment/l10n/de/email.ftl +++ b/backend/src/appointment/l10n/de/email.ftl @@ -55,6 +55,7 @@ reject-mail-plain = { $owner_name } hat deine Buchungsanfrage für dieses Zeitfe {-brand-footer} ## Pending Appointment + pending-mail-subject = deine Buchungsanfrage wartet auf Bestätigung # Variables: # $owner_name (String) - Name of the person who owns the schedule @@ -102,3 +103,32 @@ support-mail-plain = { $requestee_name } ({ $requestee_email }) hat folgende Sup Thema: { $topic } Inhalt: { $details } {-brand-footer} + +## New/Invited Account Email +new-account-mail-subject = Du wurdest zu Thunderbird Appointment eingeladen +new-account-mail-action = Weiter zu Thunderbird Appointment +new-account-mail-html-heading = Du wurdest zu Thunderbird Appointment eingeladen. Logge dich mit dieser E-Mail-Adresse ein um fortzufahren. +# Variables: +# $homepage_url (String) - URL to Thunderbird Appointment +new-account-mail-plain = Du wurdest zu Thunderbird Appointment eingeladen. + Logge dich mit dieser E-Mail-Adresse ein um fortzufahren. + { $homepage_url } + {-brand-footer} + +## Confirm Email for waiting list +confirm-email-mail-subject = Bestätige deine E-Mail-Adresse um der Warteliste beizutreten! +confirm-email-mail-confirm-action = Bestätige deine E-Mail-Adresse +confirm-email-mail-decline-action = Entferne deine E-Mail-Adresse +confirm-email-mail-html-body = Danke für Dein Interesse an Thunderbird Appointment. + Bevor wir Dich auf unsere Warteliste setzen, musst Du Deine E-Mail-Adresse unten bestätigen. +confirm-email-mail-html-body-2 = Hast Du diese E-Mail irrtümlich erhalten, oder bist nicht mehr interessiert? +# Variables: +# $confirm_email_url (String) - URL to confirm your email +# $decline_email_url (String) - URL to remove the email from the waiting list +confirm-email-mail-plain = Danke für Dein Interesse an Thunderbird Appointment. + Bevor wir Dich auf unsere Warteliste setzen, musst Du Deine E-Mail-Adresse über den unten stehenden Link bestätigen. + { $confirm_email_url } + + Hast Du diese E-Mail irrtümlich erhalten, oder bist nicht mehr interessiert? Folge einfach diesem Link, um Deine E-Mail-Adresse von der Warteliste zu löschen. + { $decline_email_url } + {-brand-footer} diff --git a/backend/src/appointment/l10n/en/email.ftl b/backend/src/appointment/l10n/en/email.ftl index 4f2e0ccf0..4ecb73759 100644 --- a/backend/src/appointment/l10n/en/email.ftl +++ b/backend/src/appointment/l10n/en/email.ftl @@ -114,3 +114,21 @@ new-account-mail-plain = You've been invited to Thunderbird Appointment. Login with this email address to continue. { $homepage_url } {-brand-footer} + +## Confirm Email for waiting list +confirm-email-mail-subject = Confirm your email to join the waiting list! +confirm-email-mail-confirm-action = Confirm your email +confirm-email-mail-decline-action = Remove your email +confirm-email-mail-html-body = Thank you for your interest in Thunderbird Appointment. + Before we add you to our waiting list we need you to confirm your email address below. +confirm-email-mail-html-body-2 = Did you receive this email in error, or are you no longer interested? +# Variables: +# $confirm_email_url (String) - URL to confirm your email +# $decline_email_url (String) - URL to remove the email from the waiting list +confirm-email-mail-plain = Thank you for your interest in Thunderbird Appointment. + Before we add you to our waiting list we need you to confirm your email address at the link below. + { $confirm_email_url } + + Did you receive this email in error, or are you no longer interested? Just follow this link to remove your email from our waiting list. + { $decline_email_url } + {-brand-footer} diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index c4506c463..df3c5bc3a 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -121,6 +121,7 @@ def server(): from .routes import invite from .routes import subscriber from .routes import zoom + from .routes import waiting_list from .routes import webhooks # Hide openapi url (which will also hide docs/redoc) if we're not dev @@ -180,6 +181,7 @@ async def catch_google_refresh_errors(request, exc): app.include_router(schedule.router, prefix='/schedule') app.include_router(invite.router, prefix='/invite') app.include_router(subscriber.router, prefix='/subscriber') + app.include_router(waiting_list.router, prefix='/waiting-list') app.include_router(webhooks.router, prefix='/webhooks') if os.getenv('ZOOM_API_ENABLED'): app.include_router(zoom.router, prefix='/zoom') diff --git a/backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py b/backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py new file mode 100644 index 000000000..7f3a9ec38 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py @@ -0,0 +1,35 @@ +"""create waiting list table + +Revision ID: a9ca5a4325ec +Revises: f732d6e597fe +Create Date: 2024-06-26 22:02:19.851617 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import ForeignKey, func + +from appointment.database.models import encrypted_type + +# revision identifiers, used by Alembic. +revision = 'a9ca5a4325ec' +down_revision = 'f732d6e597fe' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'waiting_list', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('email', encrypted_type(sa.String), unique=True, index=True, nullable=False), + sa.Column('email_verified', sa.Boolean, nullable=False, index=True, default=False), + sa.Column('invite_id', sa.Integer, ForeignKey('invites.id'), nullable=True, index=True), + sa.Column('time_created', sa.DateTime, server_default=func.now()), + sa.Column('time_updated', sa.DateTime, server_default=func.now()), + ) + + +def downgrade() -> None: + op.drop_table('waiting_list') diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 893c4c08a..3dc4ca560 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -1,7 +1,6 @@ -import enum +import logging import os import secrets -from enum import Enum import requests.exceptions import sentry_sdk diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 524f95142..1a1084345 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -10,7 +10,7 @@ from sentry_sdk import capture_exception from sqlalchemy.orm import Session -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks from fastapi.responses import RedirectResponse from .. import utils @@ -26,6 +26,7 @@ from ..exceptions import validation from ..exceptions.fxa_api import NotInAllowListException from ..l10n import l10n +from ..tasks.emails import send_confirm_email router = APIRouter() @@ -41,6 +42,21 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt +@router.post('/can-login') +def can_login( + data: schemas.CheckEmail, + db: Session = Depends(get_db), + fxa_client: FxaClient = Depends(get_fxa_client) +): + """Determines if a user can go through the login flow""" + if os.getenv('AUTH_SCHEME') == 'fxa': + # This checks if a subscriber exists, or is in allowed list + return fxa_client.is_in_allow_list(db, data.email) + + # There's no waiting list setting on password login + return True + + @router.get('/fxa_login') def fxa_login( request: Request, diff --git a/backend/src/appointment/routes/waiting_list.py b/backend/src/appointment/routes/waiting_list.py new file mode 100644 index 000000000..ff75d38b8 --- /dev/null +++ b/backend/src/appointment/routes/waiting_list.py @@ -0,0 +1,81 @@ +import os + +import sentry_sdk +from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, BackgroundTasks +from ..database import repo, schemas + +from ..dependencies.database import get_db +from ..exceptions import validation +from ..tasks.emails import send_confirm_email +from itsdangerous import URLSafeSerializer, BadSignature +from secrets import token_bytes +from enum import Enum + +router = APIRouter() + + +class WaitingListAction(Enum): + CONFIRM_EMAIL = 1 + LEAVE = 2 + + +@router.post('/join') +def join_the_waiting_list( + data: schemas.JoinTheWaitingList, background_tasks: BackgroundTasks, db: Session = Depends(get_db) +): + """Join the waiting list!""" + added = repo.invite.add_to_waiting_list(db, data.email) + + # TODO: Replace our signed functionality with this + # Not timed since we don't have a mechanism to re-send these... + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': data.email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + decline_token = serializer.dumps({'email': data.email, 'action': WaitingListAction.LEAVE.value}) + + # If they were added, send the email + if added: + background_tasks.add_task(send_confirm_email, to=data.email, confirm_token=confirm_token, decline_token=decline_token) + + return added + + +@router.post('/action') +def act_on_waiting_list( + data: schemas.TokenForWaitingList, + db: Session = Depends(get_db) +): + """Perform a waiting list action from a signed token""" + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + + try: + token_data = serializer.loads(data.token) + except BadSignature: + raise validation.InvalidLinkException() + + action = token_data.get('action') + email = token_data.get('email') + + if action is None or email is None: + raise validation.InvalidLinkException() + + if action == WaitingListAction.CONFIRM_EMAIL.value: + success = repo.invite.confirm_waiting_list_email(db, email) + elif action == WaitingListAction.LEAVE.value: + success = repo.invite.remove_waiting_list_email(db, email) + else: + raise validation.InvalidLinkException() + + # This shouldn't happen, but just in case! + if not success: + exception = validation.WaitingListActionFailed() + + # Capture this issue in sentry + sentry_sdk.capture_exception(exception) + + raise exception + + return { + 'action': action, + 'success': success, + } diff --git a/backend/src/appointment/tasks/emails.py b/backend/src/appointment/tasks/emails.py index a6a68a49f..125cc4b65 100644 --- a/backend/src/appointment/tasks/emails.py +++ b/backend/src/appointment/tasks/emails.py @@ -1,3 +1,6 @@ +import os +import urllib.parse + from appointment.controller.mailer import ( PendingRequestMail, ConfirmationMail, @@ -5,7 +8,7 @@ ZoomMeetingFailedMail, RejectionMail, SupportRequestMail, - InviteAccountMail, + InviteAccountMail, ConfirmYourEmailMail, ) @@ -48,3 +51,12 @@ def send_support_email(requestee_name, requestee_email, topic, details): def send_invite_account_email(to): mail = InviteAccountMail(to=to) mail.send() + + +def send_confirm_email(to, confirm_token, decline_token): + base_url = f"{os.getenv('FRONTEND_URL')}/waiting-list" + confirm_url = f"{base_url}/{confirm_token}" + decline_url = f"{base_url}/{decline_token}" + + mail = ConfirmYourEmailMail(to=to, confirm_url=confirm_url, decline_url=decline_url) + mail.send() diff --git a/backend/src/appointment/templates/email/confirm_email.jinja2 b/backend/src/appointment/templates/email/confirm_email.jinja2 new file mode 100644 index 000000000..d5169900b --- /dev/null +++ b/backend/src/appointment/templates/email/confirm_email.jinja2 @@ -0,0 +1,37 @@ + +
++ {{ l10n('confirm-email-mail-html-body') }} +
+ ++ {{ l10n('confirm-email-mail-html-body-2') }} +
+ + {% include 'includes/footer.jinja2' %} + + diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 30380a72c..9b5826869 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -21,6 +21,7 @@ from factory.slot_factory import make_appointment_slot # noqa: F401 from factory.subscriber_factory import make_subscriber, make_basic_subscriber, make_pro_subscriber # noqa: F401 from factory.invite_factory import make_invite # noqa: F401 +from factory.waiting_list_factory import make_waiting_list # noqa: F401 # Load our env load_dotenv(find_dotenv('.env.test'), override=True) diff --git a/backend/test/factory/waiting_list_factory.py b/backend/test/factory/waiting_list_factory.py new file mode 100644 index 000000000..851e2e899 --- /dev/null +++ b/backend/test/factory/waiting_list_factory.py @@ -0,0 +1,23 @@ +import pytest +from faker import Faker +from appointment.database import models +from defines import FAKER_RANDOM_VALUE, factory_has_value + + +@pytest.fixture +def make_waiting_list(with_db): + fake = Faker() + + def _make_waiting_list(invite_id=None, email=FAKER_RANDOM_VALUE, email_verified=False) -> models.WaitingList: + with with_db() as db: + invite = models.WaitingList( + email=email if factory_has_value(email) else fake.email(), + email_verified=email_verified, + invite_id=invite_id, + ) + db.add(invite) + db.commit() + db.refresh(invite) + return invite + + return _make_waiting_list diff --git a/backend/test/integration/test_waiting_list.py b/backend/test/integration/test_waiting_list.py new file mode 100644 index 000000000..eef94af36 --- /dev/null +++ b/backend/test/integration/test_waiting_list.py @@ -0,0 +1,191 @@ +import os +import pytest +from itsdangerous import URLSafeSerializer +from sqlalchemy.exc import InvalidRequestError + +from appointment.database import models +from unittest.mock import patch + +from appointment.routes.waiting_list import WaitingListAction + + +class TestJoinWaitingList: + def test_success(self, with_db, with_client): + email = 'hello@example.org' + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + with patch('fastapi.BackgroundTasks.add_task') as mock: + response = with_client.post('/waiting-list/join', json={'email': email}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() is True + + # Ensure our email was inserted + with with_db() as db: + assert db.query(models.WaitingList).filter(models.WaitingList.email == email).first() is not None + + # Ensure we sent out an email + mock.assert_called_once() + + def test_already_in_list(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + make_waiting_list(email=email) + + with patch('fastapi.BackgroundTasks.add_task') as mock: + response = with_client.post('/waiting-list/join', json={'email': email}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() is False + + # Ensure we did not send out an email + mock.assert_not_called() + + +class TestWaitingListActionConfirm: + def assert_email_verified(self, db, waiting_list, success=True): + assert not waiting_list.email_verified + + db.add(waiting_list) + db.refresh(waiting_list) + + if success: + assert waiting_list.email_verified + else: + assert not waiting_list.email_verified + + def test_success(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() == { "action": WaitingListAction.CONFIRM_EMAIL.value, "success": True } + + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=True) + + def test_bad_secret(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer('wow-a-fake-secret', 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=False) + + def test_bad_token_data_invalid_action(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': 999}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=False) + + def test_bad_token_data_missing_email(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=False) + + def test_bad_token_data_email_not_in_list(self, with_db, with_client): + email = 'hello@example.org' + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + +class TestWaitingListActionLeave: + def assert_waiting_list_exists(self, db, waiting_list, success=True): + assert waiting_list + email = waiting_list.email + + db.add(waiting_list) + if success: + with pytest.raises(InvalidRequestError): + db.refresh(waiting_list) + + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + else: + db.refresh(waiting_list) + assert waiting_list is not None + + def test_success(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.LEAVE.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() == {"action": WaitingListAction.LEAVE.value, "success": True} + + with with_db() as db: + self.assert_waiting_list_exists(db, waiting_list, success=True) + + def test_bad_token_data_email_not_in_list(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.LEAVE.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() == { "action": WaitingListAction.LEAVE.value, "success": True } + + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() diff --git a/backend/test/unit/test_data.py b/backend/test/unit/test_data.py index 84bb6b8cb..32667eae0 100644 --- a/backend/test/unit/test_data.py +++ b/backend/test/unit/test_data.py @@ -1,6 +1,7 @@ from argon2 import PasswordHasher from appointment.controller.data import model_to_csv_buffer, delete_account +from appointment.database import models class TestData: @@ -31,19 +32,36 @@ def test_delete_account( make_schedule, make_caldav_calendar, make_external_connections, + make_invite, + make_waiting_list, ): """Test that our delete account functionality actually deletes everything""" subscriber = make_pro_subscriber() calendar = make_caldav_calendar(subscriber_id=subscriber.id) appointment = make_appointment(calendar_id=calendar.id) schedule = make_schedule(calendar_id=calendar.id) - external_connection = make_external_connections(subscriber_id=subscriber.id) + external_connections = [ + make_external_connections(subscriber_id=subscriber.id, type=models.ExternalConnectionType.fxa), + make_external_connections(subscriber_id=subscriber.id, type=models.ExternalConnectionType.google), + make_external_connections(subscriber_id=subscriber.id, type=models.ExternalConnectionType.zoom), + ] + invite = make_invite(subscriber_id=subscriber.id) + waiting_list = make_waiting_list(email=subscriber.email, invite_id=invite.id) # Get some relationships slots = appointment.slots # Bunch them together into a list. They must have an id field, otherwise assert them manually. - models_to_check = [subscriber, external_connection, calendar, appointment, schedule, *slots] + models_to_check = [ + subscriber, + calendar, + appointment, + schedule, + invite, + waiting_list, + *external_connections, + *slots, + ] with with_db() as db: ret = delete_account(db, subscriber) diff --git a/backend/test/unit/test_models.py b/backend/test/unit/test_models.py index 48e90bebc..d6ca35ed9 100644 --- a/backend/test/unit/test_models.py +++ b/backend/test/unit/test_models.py @@ -1,3 +1,5 @@ +import pytest +from sqlalchemy.exc import IntegrityError from appointment.database import models, repo, schemas @@ -26,3 +28,41 @@ def get_data(): for appointment in appointments: assert len(db.query(models.Appointment).filter(models.Appointment.uuid == appointment.uuid).all()) == 1 + + +class TestWaitingList: + def test_successful_relationship(self, with_db, make_pro_subscriber, make_invite, make_waiting_list): + subscriber = make_pro_subscriber() + invite = make_invite(subscriber_id=subscriber.id) + waiting_list = make_waiting_list(invite_id=invite.id) + + with with_db() as db: + db.add(subscriber) + db.add(invite) + db.add(waiting_list) + + assert waiting_list.invite == invite + assert waiting_list.invite.subscriber == subscriber + + assert invite.waiting_list == waiting_list + assert subscriber.invite.waiting_list == waiting_list + + def test_empty_relationship(self, with_db, make_waiting_list): + waiting_list = make_waiting_list() + + with with_db() as db: + db.add(waiting_list) + + assert not waiting_list.invite + + def test_email_is_unique(self, make_waiting_list): + email = 'greg@example.org' + + waiting_list = make_waiting_list(email=email) + assert waiting_list + + # Raises integrity error due to unique constraint failure + with pytest.raises(IntegrityError): + waiting_list_2 = make_waiting_list(email=email) + assert not waiting_list_2 + diff --git a/frontend/package.json b/frontend/package.json index eaa9d61c5..18762b5fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,7 @@ "@babel/core": "^7.12.16", "@babel/eslint-parser": "^7.12.16", "autoprefixer": "^10.4.12", - "eslint": "^9.5.0", + "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.25.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 22a19e39f..24bcbffb5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,7 +14,7 @@ :class="{ 'mx-4 min-h-full py-32 lg:mx-8': !routeIsHome && !routeIsPublic, '!pt-24': routeIsHome || isAuthenticated, - 'min-h-full pb-32 pt-8': routeIsPublic, + 'min-h-full pb-32 pt-8': routeIsPublic && !routeHasModal, }" >