diff --git a/backend/src/appointment/controller/data.py b/backend/src/appointment/controller/data.py index 233211ebc..cb261be8d 100644 --- a/backend/src/appointment/controller/data.py +++ b/backend/src/appointment/controller/data.py @@ -3,7 +3,7 @@ from zipfile import ZipFile from ..database import repo -from ..database.schemas import Subscriber +from ..database.models import Subscriber from ..download_readme import get_download_readme from ..exceptions.account_api import AccountDeletionPartialFail, AccountDeletionSubscriberFail @@ -13,10 +13,13 @@ def model_to_csv_buffer(models): if len(models) == 0: return StringIO() + # Don't write out these columns + scrub_columns = ['password', 'google_tkn', 'google_state', 'google_state_expires_at', 'token'] + string_buffer = StringIO() writer = csv.writer(string_buffer) - columns = models[0].__table__.c + columns = list(filter(lambda c: c.name not in scrub_columns, models[0].__table__.c)) writer.writerow(columns) for model in models: @@ -38,9 +41,9 @@ def download(db, subscriber: Subscriber): calendars = repo.get_calendars_by_subscriber(db, subscriber_id=subscriber.id) subscribers = [subscriber] slots = repo.get_slots_by_subscriber(db, subscriber_id=subscriber.id) - - # Clear out the google token (?) - subscribers[0].google_tkn = None + external_connections = subscriber.external_connections + schedules = repo.get_schedules_by_subscriber(db, subscriber.id) + availability = [repo.get_availability_by_schedule(db, schedule.id) for schedule in schedules] # Convert models to csv attendee_buffer = model_to_csv_buffer(attendees) @@ -48,6 +51,13 @@ def download(db, subscriber: Subscriber): calendar_buffer = model_to_csv_buffer(calendars) subscriber_buffer = model_to_csv_buffer(subscribers) slot_buffer = model_to_csv_buffer(slots) + external_connections_buffer = model_to_csv_buffer(external_connections) + schedules_buffer = model_to_csv_buffer(schedules) + + # Unique behaviour because we can have lists of lists..too annoying to not do it this way. + availability_buffer = '' + for avail in availability: + availability_buffer += model_to_csv_buffer(avail).getvalue() # Create an in-memory zip and append our csvs zip_buffer = BytesIO() @@ -57,6 +67,9 @@ def download(db, subscriber: Subscriber): data_zip.writestr("calendar.csv", calendar_buffer.getvalue()) data_zip.writestr("subscriber.csv", subscriber_buffer.getvalue()) data_zip.writestr("slot.csv", slot_buffer.getvalue()) + 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("readme.txt", get_download_readme()) # Return our zip buffer @@ -64,33 +77,29 @@ def download(db, subscriber: Subscriber): def delete_account(db, subscriber: Subscriber): - # Ok nuke everything - repo.delete_attendees_by_subscriber(db, subscriber.id) - repo.delete_appointment_slots_by_subscriber_id(db, subscriber.id) - repo.delete_calendar_appointments_by_subscriber_id(db, subscriber.id) - repo.delete_subscriber_calendar_by_subscriber_id(db, subscriber.id) + # Ok nuke everything (thanks cascade=all,delete) + repo.delete_subscriber(db, subscriber) + + # Make sure we actually nuked the subscriber + if repo.get_subscriber(db, subscriber.id) is not None: + raise AccountDeletionSubscriberFail( + subscriber.id, + "There was a problem deleting your data. This incident has been logged and your data will manually be removed.", + ) empty_check = [ len(repo.get_attendees_by_subscriber(db, subscriber.id)), len(repo.get_slots_by_subscriber(db, subscriber.id)), len(repo.get_appointments_by_subscriber(db, subscriber.id)), len(repo.get_calendars_by_subscriber(db, subscriber.id)), + len(repo.get_schedules_by_subscriber(db, subscriber.id)) ] - # Check if we have any left-over subscriber data before we nuke the subscriber + # Check if we have any left-over subscriber data if any(empty_check) > 0: raise AccountDeletionPartialFail( subscriber.id, "There was a problem deleting your data. This incident has been logged and your data will manually be removed.", ) - repo.delete_subscriber(db, subscriber) - - # Make sure we actually nuked the subscriber - if repo.get_subscriber(db, subscriber.id) is not None: - raise AccountDeletionSubscriberFail( - subscriber.id, - "There was a problem deleting your data. This incident has been logged and your data will manually be removed.", - ) - return True diff --git a/backend/src/appointment/download_readme.py b/backend/src/appointment/download_readme.py index d15831df0..9d51dbe35 100644 --- a/backend/src/appointment/download_readme.py +++ b/backend/src/appointment/download_readme.py @@ -31,9 +31,12 @@ def get_download_readme(): The following files are included: - appointments.csv : A list of Appointments from our database - attendees.csv : A list of Appointment Slot Attendees from our database + - availability.csv : Not used right now - calendars.csv : A list of Calendars from our database - - slots.csv : A list of Appointment Slots from our database. - - subscriber.csv : The personal information we store about you from our database. + - external_connections.csv : A list of external services you've connected to your account + - slots.csv : A list of Appointment Slots from our database + - schedules.csv : Your general availability schedule + - subscriber.csv : The personal information we store about you from our database - readme.txt : This file! """.format( download_time=datetime.datetime.now(datetime.UTC) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index a7d5198bc..d4424be43 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -13,6 +13,7 @@ from factory.attendee_factory import make_attendee # noqa: F401 from factory.appointment_factory import make_appointment # noqa: F401 from factory.calendar_factory import make_caldav_calendar # noqa: F401 +from factory.external_connection_factory import make_external_connections # noqa: F401 from factory.schedule_factory import make_schedule # noqa: F401 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 diff --git a/backend/test/factory/external_connection_factory.py b/backend/test/factory/external_connection_factory.py new file mode 100644 index 000000000..e3b0273c6 --- /dev/null +++ b/backend/test/factory/external_connection_factory.py @@ -0,0 +1,26 @@ +import pytest +from faker import Faker +from backend.src.appointment.database import repo, schemas, models +from defines import FAKER_RANDOM_VALUE, factory_has_value + + +@pytest.fixture +def make_external_connections(with_db): + fake = Faker() + + def _make_external_connections(subscriber_id, + name=FAKER_RANDOM_VALUE, + type=FAKER_RANDOM_VALUE, + type_id=FAKER_RANDOM_VALUE, + token=FAKER_RANDOM_VALUE): + with with_db() as db: + return repo.create_subscriber_external_connection(db, schemas.ExternalConnection( + owner_id=subscriber_id, + name=name if factory_has_value(name) else fake.name(), + type=type if factory_has_value(type) else fake.random_element( + (models.ExternalConnectionType.zoom.value, models.ExternalConnectionType.google.value, models.ExternalConnectionType.fxa.value)), + type_id=type_id if factory_has_value(type_id) else fake.uuid4(), + token=token if factory_has_value(token) else fake.password(), + )) + + return _make_external_connections diff --git a/backend/test/unit/test_data.py b/backend/test/unit/test_data.py new file mode 100644 index 000000000..8603d9b29 --- /dev/null +++ b/backend/test/unit/test_data.py @@ -0,0 +1,46 @@ +from argon2 import PasswordHasher + +from backend.src.appointment.controller.data import model_to_csv_buffer, delete_account + + +class TestData: + def test_model_to_csv_buffer(self, make_pro_subscriber): + """Make sure our model to csv buffer is working, scrubbers and all!""" + ph = PasswordHasher() + + password = "cool beans" + subscriber = make_pro_subscriber(password=password) + + buffer = model_to_csv_buffer([subscriber]) + csv_data = buffer.getvalue() + + assert csv_data + # Check if our scrubber is working as intended + assert 'subscriber.password' not in csv_data + assert password not in csv_data + assert ph.hash(password) not in csv_data + # Ensure that some of our subscriber data is there + assert subscriber.email in csv_data + assert subscriber.username in csv_data + + def test_delete_account(self, with_db, make_pro_subscriber, make_appointment, make_schedule, make_caldav_calendar, make_external_connections): + """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) + + # 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] + + with with_db() as db: + ret = delete_account(db, subscriber) + assert ret is True + + for model in models_to_check: + check = db.get(model.__class__, model.id) + assert check is None, f"Ensuring {model.__class__} is None" diff --git a/frontend/src/components/ConfirmationModal.vue b/frontend/src/components/ConfirmationModal.vue index 3a1b9d0e4..4d6f1c3b1 100644 --- a/frontend/src/components/ConfirmationModal.vue +++ b/frontend/src/components/ConfirmationModal.vue @@ -18,7 +18,8 @@
- + +
@@ -26,6 +27,7 @@