diff --git a/README.md b/README.md index 52383326..3d2a9707 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ the following section will show a step-by-step guide on how to get your own emai [3. Contributing Guide](#contributing) +[4. API](#api) + +[5. OAuth2/OpenID Connect](#oauth) + ## General Architecture @@ -615,7 +619,49 @@ then open http://localhost:7777, you should be able to login with the following john@wick.com / password ``` -### API +### Database migration + +The database migration is handled by `alembic` + +Whenever the model changes, a new migration has to be created. + +If you have Docker installed, you can create the migration by the following script: + +```bash +sh new_migration.sh +``` + +Make sure to review the migration script before committing it. +Sometimes (very rarely though), the automatically generated script can be incorrect. + +We cannot use the local database to generate migration script as the local database doesn't use migration. +It is created via `db.create_all()` (cf `fake_data()` method). This is convenient for development and +unit tests as we don't have to wait for the migration. + +### Code structure + +The repo consists of the three following entry points: + +- wsgi.py and server.py: the webapp. +- email_handler.py: the email handler. +- cron.py: the cronjob. + +Here are the small sum-ups of the directory structures and their roles: + +- app/: main Flask app. It is structured into different packages representing different features like oauth, api, dashboard, etc. +- local_data/: contains files to facilitate the local development. They are replaced during the deployment. +- migrations/: generated by flask-migrate. Edit these files will be only edited when you spot (very rare) errors on the database migration files. +- static/: files available at `/static` url. +- templates/: contains both html and email templates. +- tests/: tests. We don't really distinguish unit, functional or integration test. A test is simply here to make sure a feature works correctly. + +The code is formatted using https://github.com/psf/black, to format the code, simply run + +``` +black . +``` + +## API SimpleLogin current API clients are Chrome/Firefox/Safari extension and mobile (iOS/Android) app. These clients rely on `API Code` for authentication. @@ -638,6 +684,90 @@ Some errors should be fixed during development however: for example error like ` All following endpoint return `401` status code if the API Key is incorrect. +### Authentication endpoints + +#### POST /api/auth/login + +Input: +- email +- password +- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. + +Output: +- name: user name, could be an empty string +- mfa_enabled: boolean +- mfa_key: only useful when user enables MFA. In this case, user needs to enter their OTP token in order to login. +- api_key: if MFA is not enabled, the `api key` is returned right away. + +The `api_key` is used in all subsequent requests. It's empty if MFA is enabled. +If user hasn't enabled MFA, `mfa_key` is empty. + +Return 403 if user has enabled FIDO. The client can display a message to suggest user to use the `API Key` instead. + +#### POST /api/auth/mfa + +Input: +- mfa_token: OTP token that user enters +- mfa_key: MFA key obtained in previous auth request, e.g. /api/auth/login +- device: the device name, used to create an ApiKey associated with this device + +Output: +- name: user name, could be an empty string +- api_key: if MFA is not enabled, the `api key` is returned right away. + +The `api_key` is used in all subsequent requests. It's empty if MFA is enabled. +If user hasn't enabled MFA, `mfa_key` is empty. + +#### POST /api/auth/facebook + +Input: +- facebook_token: Facebook access token +- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. + +Output: Same output as for `/api/auth/login` endpoint + +#### POST /api/auth/google + +Input: +- google_token: Google access token +- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. + +Output: Same output as for `/api/auth/login` endpoint + +#### POST /api/auth/register + +Input: +- email +- password + +Output: 200 means user is going to receive an email that contains an *activation code*. User needs to enter this code to confirm their account -> next endpoint. + +#### POST /api/auth/activate + +Input: +- email +- code: the activation code + +Output: +- 200: account is activated. User can login now +- 400: wrong email, code +- 410: wrong code too many times. User needs to ask for an reactivation -> next endpoint + +#### POST /api/auth/reactivate + +Input: +- email + +Output: +- 200: user is going to receive an email that contains the activation code. + +#### POST /api/auth/forgot_password + +Input: +- email + +Output: always return 200, even if email doesn't exist. User need to enter correctly their email. + #### GET /api/user_info Given the API Key, return user name and whether user is premium. @@ -659,6 +789,7 @@ Output: if api key is correct, return a json with user name and whether user is If api key is incorrect, return 401. +### Alias endpoints #### GET /api/v4/alias/options @@ -751,115 +882,6 @@ If success, 201 with the new alias, for example } ``` -#### POST /api/auth/login - -Input: -- email -- password -- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. - -Output: -- name: user name, could be an empty string -- mfa_enabled: boolean -- mfa_key: only useful when user enables MFA. In this case, user needs to enter their OTP token in order to login. -- api_key: if MFA is not enabled, the `api key` is returned right away. - -The `api_key` is used in all subsequent requests. It's empty if MFA is enabled. -If user hasn't enabled MFA, `mfa_key` is empty. - -Return 403 if user has enabled FIDO. The client can display a message to suggest user to use the `API Key` instead. - -#### POST /api/auth/mfa - -Input: -- mfa_token: OTP token that user enters -- mfa_key: MFA key obtained in previous auth request, e.g. /api/auth/login -- device: the device name, used to create an ApiKey associated with this device - -Output: -- name: user name, could be an empty string -- api_key: if MFA is not enabled, the `api key` is returned right away. - -The `api_key` is used in all subsequent requests. It's empty if MFA is enabled. -If user hasn't enabled MFA, `mfa_key` is empty. - -#### POST /api/auth/facebook - -Input: -- facebook_token: Facebook access token -- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. - -Output: Same output as for `/api/auth/login` endpoint - - -#### POST /api/auth/google - -Input: -- google_token: Google access token -- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. - -Output: Same output as for `/api/auth/login` endpoint - -#### POST /api/auth/register - -Input: -- email -- password - -Output: 200 means user is going to receive an email that contains an *activation code*. User needs to enter this code to confirm their account -> next endpoint. - - -#### POST /api/auth/activate - -Input: -- email -- code: the activation code - -Output: -- 200: account is activated. User can login now -- 400: wrong email, code -- 410: wrong code too many times. User needs to ask for an reactivation -> next endpoint - -#### POST /api/auth/reactivate - -Input: -- email - -Output: -- 200: user is going to receive an email that contains the activation code. - -#### POST /api/auth/forgot_password - -Input: -- email - -Output: always return 200, even if email doesn't exist. User need to enter correctly their email. - -#### GET /api/mailboxes - -Get user verified mailboxes. - -Input: -- `Authentication` header that contains the api key - -Output: -List of mailboxes. Each mailbox has id, email field. - -```json -{ - "mailboxes": [ - { - "email": "a@b.c", - "id": 1 - }, - { - "email": "m1@example.com", - "id": 2 - } - ] -} -``` - #### GET /api/v2/aliases Get user aliases. @@ -1087,7 +1109,6 @@ If success, 200 with the list of contacts, for example: Please note that last_email_sent_timestamp and last_email_sent_date can be null. - #### POST /api/aliases/:alias_id/contacts Create a new contact for an alias. @@ -1113,6 +1134,35 @@ Return 409 if contact is already added. } ``` +### Mailbox endpoints + +#### GET /api/mailboxes + +Get user verified mailboxes. + +Input: +- `Authentication` header that contains the api key + +Output: +List of mailboxes. Each mailbox has id, email field. + +```json +{ + "mailboxes": [ + { + "email": "a@b.c", + "id": 1 + }, + { + "email": "m1@example.com", + "id": 2 + } + ] +} +``` + +### Contact endpoints + #### DELETE /api/contacts/:contact_id Delete a contact @@ -1131,6 +1181,7 @@ If success, 200. } ``` +### Misc endpoints #### POST /api/apple/process_payment Process payment receipt @@ -1144,49 +1195,8 @@ Output: 200 if user is upgraded successfully 4** if any error. -### Database migration -The database migration is handled by `alembic` - -Whenever the model changes, a new migration has to be created. - -If you have Docker installed, you can create the migration by the following script: - -```bash -sh new_migration.sh -``` - -Make sure to review the migration script before committing it. -Sometimes (very rarely though), the automatically generated script can be incorrect. - -We cannot use the local database to generate migration script as the local database doesn't use migration. -It is created via `db.create_all()` (cf `fake_data()` method). This is convenient for development and -unit tests as we don't have to wait for the migration. - -### Code structure - -The repo consists of the three following entry points: - -- wsgi.py and server.py: the webapp. -- email_handler.py: the email handler. -- cron.py: the cronjob. - -Here are the small sum-ups of the directory structures and their roles: - -- app/: main Flask app. It is structured into different packages representing different features like oauth, api, dashboard, etc. -- local_data/: contains files to facilitate the local development. They are replaced during the deployment. -- migrations/: generated by flask-migrate. Edit these files will be only edited when you spot (very rare) errors on the database migration files. -- static/: files available at `/static` url. -- templates/: contains both html and email templates. -- tests/: tests. We don't really distinguish unit, functional or integration test. A test is simply here to make sure a feature works correctly. - -The code is formatted using https://github.com/psf/black, to format the code, simply run - -``` -black . -``` - -### OAuth flow +## OAuth SL currently supports code and implicit flow. diff --git a/app/alias_utils.py b/app/alias_utils.py index 5111da11..b3087da7 100644 --- a/app/alias_utils.py +++ b/app/alias_utils.py @@ -16,6 +16,7 @@ from app.models import ( Directory, User, DeletedAlias, + DomainDeletedAlias, ) @@ -130,15 +131,27 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]: def delete_alias(alias: Alias, user: User): - email = alias.email Alias.delete(alias.id) db.session.commit() - # try to save deleted alias - try: - DeletedAlias.create(email=email) - db.session.commit() - # this can happen when a previously deleted alias is re-created via catch-all or directory feature - except IntegrityError: - LOG.error("alias %s has been added before to DeletedAlias", email) - db.session.rollback() + # save deleted alias to either global or domain trash + if alias.custom_domain_id: + try: + DomainDeletedAlias.create( + user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id + ) + db.session.commit() + except IntegrityError: + LOG.error( + "alias %s domain %s has been added before to DeletedAlias", + alias.email, + alias.custom_domain_id, + ) + db.session.rollback() + else: + try: + DeletedAlias.create(email=alias.email) + db.session.commit() + except IntegrityError: + LOG.error("alias %s has been added before to DeletedAlias", alias.email) + db.session.rollback() diff --git a/app/api/views/new_custom_alias.py b/app/api/views/new_custom_alias.py index 61408698..afca282a 100644 --- a/app/api/views/new_custom_alias.py +++ b/app/api/views/new_custom_alias.py @@ -9,7 +9,14 @@ from app.config import MAX_NB_EMAIL_FREE_PLAN from app.dashboard.views.custom_alias import verify_prefix_suffix, signer from app.extensions import db from app.log import LOG -from app.models import Alias, AliasUsedOn, User, CustomDomain, DeletedAlias +from app.models import ( + Alias, + AliasUsedOn, + User, + CustomDomain, + DeletedAlias, + DomainDeletedAlias, +) from app.utils import convert_to_id @@ -137,15 +144,25 @@ def new_custom_alias_v2(): LOG.d("full alias already used %s", full_alias) return jsonify(error=f"alias {full_alias} already exists"), 409 - alias = Alias.create( - user_id=user.id, email=full_alias, mailbox_id=user.default_mailbox_id, note=note - ) - + custom_domain_id = None if alias_suffix.startswith("@"): alias_domain = alias_suffix[1:] domain = CustomDomain.get_by(domain=alias_domain) - LOG.d("set alias %s to domain %s", full_alias, domain) - alias.custom_domain_id = domain.id + + # check if the alias is currently in the domain trash + if domain and DomainDeletedAlias.get_by(domain_id=domain.id, email=full_alias): + LOG.d(f"Alias {full_alias} is currently in the {domain.domain} trash. ") + return jsonify(error=f"alias {full_alias} in domain trash"), 409 + + custom_domain_id = domain.id + + alias = Alias.create( + user_id=user.id, + email=full_alias, + mailbox_id=user.default_mailbox_id, + note=note, + custom_domain_id=custom_domain_id, + ) db.session.commit() diff --git a/app/dashboard/templates/dashboard/domain_detail/base.html b/app/dashboard/templates/dashboard/domain_detail/base.html index 81484eaa..a2cd6cc7 100644 --- a/app/dashboard/templates/dashboard/domain_detail/base.html +++ b/app/dashboard/templates/dashboard/domain_detail/base.html @@ -15,6 +15,11 @@ class="list-group-item list-group-item-action {{ 'active' if domain_detail_page == 'dns' }}"> DNS + + + Deleted Alias + diff --git a/app/dashboard/templates/dashboard/domain_detail/trash.html b/app/dashboard/templates/dashboard/domain_detail/trash.html new file mode 100644 index 00000000..a4931a54 --- /dev/null +++ b/app/dashboard/templates/dashboard/domain_detail/trash.html @@ -0,0 +1,50 @@ +{% extends 'dashboard/domain_detail/base.html' %} + +{% set domain_detail_page = "trash" %} + +{% block title %} + {{ custom_domain.domain }} deleted aliases +{% endblock %} + +{% block domain_detail_content %} +

{{ custom_domain.domain }} deleted alias (aka Trash) + +

+ + + + {% if domain_deleted_aliases | length > 0 %} +
+ + +
+ Remove all deleted aliases from the trash, allowing them to be re-created. + That operation is irreversible. +
+
+ {% else %} + There's no deleted alias recorded for this domain. + {% endif %} + + + {% for deleted_alias in domain_deleted_aliases %} +
+ {{ deleted_alias.email }} - + deleted {{ deleted_alias.created_at | dt }} +
+ + + +
+ {% endfor %} + + +{% endblock %} diff --git a/app/dashboard/views/custom_alias.py b/app/dashboard/views/custom_alias.py index 3474032b..c2e1bf3b 100644 --- a/app/dashboard/views/custom_alias.py +++ b/app/dashboard/views/custom_alias.py @@ -11,7 +11,15 @@ from app.dashboard.base import dashboard_bp from app.email_utils import email_belongs_to_alias_domains from app.extensions import db from app.log import LOG -from app.models import Alias, CustomDomain, DeletedAlias, Mailbox, User, AliasMailbox +from app.models import ( + Alias, + CustomDomain, + DeletedAlias, + Mailbox, + User, + AliasMailbox, + DomainDeletedAlias, +) from app.utils import convert_to_id, random_word, word_exist signer = TimestampSigner(CUSTOM_ALIAS_SECRET) @@ -101,11 +109,31 @@ def custom_alias(): "warning", ) else: + custom_domain_id = None + # get the custom_domain_id if alias is created with a custom domain + if alias_suffix.startswith("@"): + alias_domain = alias_suffix[1:] + domain = CustomDomain.get_by(domain=alias_domain) + + # check if the alias is currently in the domain trash + if domain and DomainDeletedAlias.get_by( + domain_id=domain.id, email=full_alias + ): + flash( + f"Alias {full_alias} is currently in the {domain.domain} trash. " + f"Please remove it from the trash in order to re-create it.", + "warning", + ) + return redirect(url_for("dashboard.custom_alias")) + + custom_domain_id = domain.id + alias = Alias.create( user_id=current_user.id, email=full_alias, note=alias_note, mailbox_id=mailboxes[0].id, + custom_domain_id=custom_domain_id, ) db.session.flush() @@ -114,13 +142,6 @@ def custom_alias(): alias_id=alias.id, mailbox_id=mailboxes[i].id, ) - # get the custom_domain_id if alias is created with a custom domain - if alias_suffix.startswith("@"): - alias_domain = alias_suffix[1:] - domain = CustomDomain.get_by(domain=alias_domain) - LOG.d("Set alias %s domain to %s", full_alias, domain) - alias.custom_domain_id = domain.id - db.session.commit() flash(f"Alias {full_alias} has been created", "success") diff --git a/app/dashboard/views/domain_detail.py b/app/dashboard/views/domain_detail.py index 497b3471..5e00f18d 100644 --- a/app/dashboard/views/domain_detail.py +++ b/app/dashboard/views/domain_detail.py @@ -10,7 +10,7 @@ from app.dns_utils import ( get_cname_record, ) from app.extensions import db -from app.models import CustomDomain, Alias +from app.models import CustomDomain, Alias, DomainDeletedAlias @dashboard_bp.route("/domains//dns", methods=["GET", "POST"]) @@ -171,3 +171,57 @@ def domain_detail(custom_domain_id): nb_alias = Alias.filter_by(custom_domain_id=custom_domain.id).count() return render_template("dashboard/domain_detail/info.html", **locals()) + + +@dashboard_bp.route("/domains//trash", methods=["GET", "POST"]) +@login_required +def domain_detail_trash(custom_domain_id): + custom_domain = CustomDomain.get(custom_domain_id) + if not custom_domain or custom_domain.user_id != current_user.id: + flash("You cannot see this page", "warning") + return redirect(url_for("dashboard.index")) + + if request.method == "POST": + if request.form.get("form-name") == "empty-all": + DomainDeletedAlias.filter_by(domain_id=custom_domain.id).delete() + db.session.commit() + + flash("All deleted aliases can now be re-created", "success") + return redirect( + url_for( + "dashboard.domain_detail_trash", custom_domain_id=custom_domain.id + ) + ) + elif request.form.get("form-name") == "remove-single": + deleted_alias_id = request.form.get("deleted-alias-id") + deleted_alias = DomainDeletedAlias.get(deleted_alias_id) + if not deleted_alias or deleted_alias.domain_id != custom_domain.id: + flash("Unknown error, refresh the page", "warning") + return redirect( + url_for( + "dashboard.domain_detail_trash", + custom_domain_id=custom_domain.id, + ) + ) + + DomainDeletedAlias.delete(deleted_alias.id) + db.session.commit() + flash( + f"{deleted_alias.email} can now be re-created", "success", + ) + + return redirect( + url_for( + "dashboard.domain_detail_trash", custom_domain_id=custom_domain.id + ) + ) + + domain_deleted_aliases = DomainDeletedAlias.filter_by( + domain_id=custom_domain.id + ).all() + + return render_template( + "dashboard/domain_detail/trash.html", + domain_deleted_aliases=domain_deleted_aliases, + custom_domain=custom_domain, + ) diff --git a/app/models.py b/app/models.py index 20be7ac2..7e8bf6e5 100644 --- a/app/models.py +++ b/app/models.py @@ -1088,6 +1088,9 @@ class DeletedAlias(db.Model, ModelMixin): email = db.Column(db.String(256), unique=True, nullable=False) + def __repr__(self): + return f"" + class EmailChange(db.Model, ModelMixin): """Used when user wants to update their email""" @@ -1179,6 +1182,20 @@ class CustomDomain(db.Model, ModelMixin): return f"" +class DomainDeletedAlias(db.Model, ModelMixin): + """Store all deleted alias for a domain""" + + __table_args__ = ( + db.UniqueConstraint("domain_id", "email", name="uq_domain_trash"), + ) + + email = db.Column(db.String(256), nullable=False) + domain_id = db.Column( + db.ForeignKey("custom_domain.id", ondelete="cascade"), nullable=False + ) + user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False) + + class LifetimeCoupon(db.Model, ModelMixin): code = db.Column(db.String(128), nullable=False, unique=True) nb_used = db.Column(db.Integer, nullable=False) diff --git a/migrations/versions/2020_052312_0e08145f0499_.py b/migrations/versions/2020_052312_0e08145f0499_.py new file mode 100644 index 00000000..0da945d3 --- /dev/null +++ b/migrations/versions/2020_052312_0e08145f0499_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 0e08145f0499 +Revises: ce15cf3467b4 +Create Date: 2020-05-23 12:06:25.707402 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0e08145f0499' +down_revision = 'ce15cf3467b4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('domain_deleted_alias', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('email', sa.String(length=256), nullable=False), + sa.Column('domain_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['domain_id'], ['custom_domain.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('domain_id', 'email', name='uq_domain_trash') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('domain_deleted_alias') + # ### end Alembic commands ### diff --git a/shell.py b/shell.py index ff61e143..1c31bbb9 100644 --- a/shell.py +++ b/shell.py @@ -2,8 +2,8 @@ import flask_migrate from IPython import embed from sqlalchemy_utils import create_database, database_exists, drop_database -from app.config import DB_URI -from app.email_utils import send_email, render +from app.config import DB_URI, ALIAS_DOMAINS +from app.email_utils import send_email, render, get_email_domain_part from app.models import * from server import create_app from time import sleep @@ -95,6 +95,25 @@ def send_mobile_newsletter(): sleep(1) +def migrate_domain_trash(): + """Move aliases from global trash to domain trash if applicable""" + for deleted_alias in DeletedAlias.query.all(): + alias_domain = get_email_domain_part(deleted_alias.email) + if alias_domain not in ALIAS_DOMAINS: + domain = CustomDomain.get_by(domain=alias_domain) + if domain: + LOG.d("move %s to domain %s trash", deleted_alias, domain) + DomainDeletedAlias.create( + user_id=domain.user_id, + email=deleted_alias.email, + domain_id=domain.id, + created_at=deleted_alias.created_at, + ) + DeletedAlias.delete(deleted_alias.id) + + db.session.commit() + + app = create_app() with app.app_context(): diff --git a/tests/api/test_new_custom_alias.py b/tests/api/test_new_custom_alias.py index 091f837c..473def92 100644 --- a/tests/api/test_new_custom_alias.py +++ b/tests/api/test_new_custom_alias.py @@ -1,9 +1,10 @@ from flask import url_for +from app.alias_utils import delete_alias from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN from app.dashboard.views.custom_alias import signer from app.extensions import db -from app.models import User, ApiKey, Alias +from app.models import User, ApiKey, Alias, CustomDomain from app.utils import random_word @@ -139,3 +140,45 @@ def test_success_v2(flask_client): new_ge = Alias.get_by(email=r.json["alias"]) assert new_ge.note == "test note" + + +def test_cannot_create_alias_in_trash(flask_client): + user = User.create( + email="a@b.c", password="password", name="Test User", activated=True + ) + db.session.commit() + + # create api_key + api_key = ApiKey.create(user.id, "for test") + db.session.commit() + + # create a custom domain + CustomDomain.create(user_id=user.id, domain="ab.cd", verified=True) + db.session.commit() + + # create new alias with note + suffix = f"@ab.cd" + suffix = signer.sign(suffix).decode() + + r = flask_client.post( + url_for("api.new_custom_alias_v2", hostname="www.test.com"), + headers={"Authentication": api_key.code}, + json={"alias_prefix": "prefix", "signed_suffix": suffix, "note": "test note",}, + ) + + # assert alias creation is successful + assert r.status_code == 201 + assert r.json["alias"] == f"prefix@ab.cd" + + # delete alias: it's going to be moved to ab.cd trash + alias = Alias.get_by(email="prefix@ab.cd") + assert alias.custom_domain_id + delete_alias(alias, user) + + # try to create the same alias, will fail as the alias is in trash + r = flask_client.post( + url_for("api.new_custom_alias_v2", hostname="www.test.com"), + headers={"Authentication": api_key.code}, + json={"alias_prefix": "prefix", "signed_suffix": suffix, "note": "test note",}, + ) + assert r.status_code == 409