Compare commits

..

3 Commits

Author SHA1 Message Date
a62b43b7c4 4.56.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m37s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 24s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-25 12:00:05 +01:00
44fda2d94e 4.56.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m24s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m34s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-24 12:00:05 +01:00
bc48198bb1 4.55.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m28s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m31s
Build-Release-Image / Merge-Images (push) Successful in 16s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-10-19 12:00:05 +01:00
27 changed files with 458 additions and 94 deletions

View File

@ -112,6 +112,7 @@ def ensure_partner_user_exists_for_user(
partner_email=link_request.email, partner_email=link_request.email,
external_user_id=link_request.external_user_id, external_user_id=link_request.external_user_id,
) )
Session.commit() Session.commit()
LOG.i( LOG.i(
f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}" f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}"

View File

@ -16,6 +16,8 @@ from flask_admin.contrib import sqla
from flask_login import current_user from flask_login import current_user
from app.db import Session from app.db import Session
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.models import ( from app.models import (
User, User,
ManualSubscription, ManualSubscription,
@ -39,6 +41,7 @@ from app.models import (
UserAuditLog, UserAuditLog,
) )
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def _admin_action_formatter(view, context, model, name): def _admin_action_formatter(view, context, model, name):
@ -115,7 +118,7 @@ class SLAdminIndexView(AdminIndexView):
if not current_user.is_authenticated or not current_user.is_admin: if not current_user.is_authenticated or not current_user.is_admin:
return redirect(url_for("auth.login", next=request.url)) return redirect(url_for("auth.login", next=request.url))
return redirect("/admin/user") return redirect("/admin/email_search")
class UserAdmin(SLModelView): class UserAdmin(SLModelView):
@ -351,15 +354,40 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
manual_sub.end_at = manual_sub.end_at.shift(years=1) manual_sub.end_at = manual_sub.end_at.shift(years=1)
else: else:
manual_sub.end_at = arrow.now().shift(years=1, days=1) manual_sub.end_at = arrow.now().shift(years=1, days=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription to user {user.email}",
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success") flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success")
continue else:
emit_user_audit_log(
ManualSubscription.create( user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} created manual subscription to user {user.email}",
)
manual_sub = ManualSubscription.create(
user_id=user.id, user_id=user.id,
end_at=arrow.now().shift(years=1, days=1), end_at=arrow.now().shift(years=1, days=1),
comment=way, comment=way,
is_giveaway=is_giveaway, is_giveaway=is_giveaway,
) )
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
flash(f"New {way} manual subscription for {user} is created", "success") flash(f"New {way} manual subscription for {user} is created", "success")
Session.commit() Session.commit()
@ -453,14 +481,7 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 year more?", "Extend 1 year more?",
) )
def extend_1y(self, ids): def extend_1y(self, ids):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): self.__extend_manual_subscription(ids, msg="1 year", years=1)
ms.end_at = ms.end_at.shift(years=1)
flash(f"Extend subscription for 1 year for {ms.user}", "success")
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 year"
)
Session.commit()
@action( @action(
"extend_1m", "extend_1m",
@ -468,11 +489,26 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 month more?", "Extend 1 month more?",
) )
def extend_1m(self, ids): def extend_1m(self, ids):
self.__extend_manual_subscription(ids, msg="1 month", months=1)
def __extend_manual_subscription(self, ids: List[int], msg: str, **kwargs):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
ms.end_at = ms.end_at.shift(months=1) sub: ManualSubscription = ms
flash(f"Extend subscription for 1 month for {ms.user}", "success") sub.end_at = sub.end_at.shift(**kwargs)
flash(f"Extend subscription for {msg} for {sub.user}", "success")
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription for {msg} for {sub.user}",
)
AdminAuditLog.extend_subscription( AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 month" current_user.id, sub.user.id, sub.end_at, msg
)
EventDispatcher.send_event(
user=sub.user,
content=EventContent(
user_plan_change=UserPlanChanged(plan_end_time=sub.end_at.timestamp)
),
) )
Session.commit() Session.commit()
@ -743,13 +779,17 @@ class EmailSearchResult:
mailbox: List[Mailbox] = [] mailbox: List[Mailbox] = []
mailbox_count: int = 0 mailbox_count: int = 0
deleted_alias: Optional[DeletedAlias] = None deleted_alias: Optional[DeletedAlias] = None
deleted_custom_alias: Optional[DomainDeletedAlias] = None deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None
domain_deleted_alias: Optional[DomainDeletedAlias] = None
domain_deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None
user: Optional[User] = None user: Optional[User] = None
user_audit_log: Optional[List[UserAuditLog]] = None user_audit_log: Optional[List[UserAuditLog]] = None
query: str
@staticmethod @staticmethod
def from_email(email: str) -> EmailSearchResult: def from_email(email: str) -> EmailSearchResult:
output = EmailSearchResult() output = EmailSearchResult()
output.query = email
alias = Alias.get_by(email=email) alias = Alias.get_by(email=email)
if alias: if alias:
output.alias = alias output.alias = alias
@ -768,6 +808,15 @@ class EmailSearchResult:
.all() .all()
) )
output.no_match = False output.no_match = False
user_audit_log = (
UserAuditLog.filter_by(user_email=email)
.order_by(UserAuditLog.created_at.desc())
.all()
)
if user_audit_log:
output.user_audit_log = user_audit_log
output.no_match = False
mailboxes = ( mailboxes = (
Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all() Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all()
) )
@ -778,10 +827,20 @@ class EmailSearchResult:
deleted_alias = DeletedAlias.get_by(email=email) deleted_alias = DeletedAlias.get_by(email=email)
if deleted_alias: if deleted_alias:
output.deleted_alias = deleted_alias output.deleted_alias = deleted_alias
output.deleted_alias_audit_log = (
AliasAuditLog.filter_by(alias_email=deleted_alias.email)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False output.no_match = False
domain_deleted_alias = DomainDeletedAlias.get_by(email=email) domain_deleted_alias = DomainDeletedAlias.get_by(email=email)
if domain_deleted_alias: if domain_deleted_alias:
output.domain_deleted_alias = domain_deleted_alias output.domain_deleted_alias = domain_deleted_alias
output.domain_deleted_alias_audit_log = (
AliasAuditLog.filter_by(alias_email=domain_deleted_alias.email)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False output.no_match = False
return output return output

View File

@ -419,9 +419,8 @@ def create_contact_route(alias_id):
if not data: if not data:
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
alias: Alias = Alias.get(alias_id) alias: Optional[Alias] = Alias.get_by(id=alias_id, user_id=g.user.id)
if not alias:
if alias.user_id != g.user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
contact_address = data.get("contact") contact_address = data.get("contact")

View File

@ -23,6 +23,7 @@ from app.events.auth_event import LoginEvent, RegisterEvent
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User, ApiKey, SocialAuth, AccountActivation from app.models import User, ApiKey, SocialAuth, AccountActivation
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email, canonicalize_email from app.utils import sanitize_email, canonicalize_email
@ -187,6 +188,11 @@ def auth_activate():
LOG.d("activate user %s", user) LOG.d("activate user %s", user)
user.activated = True user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ActivateUser,
message=f"User has been activated: {user.email}",
)
AccountActivation.delete(account_activation.id) AccountActivation.delete(account_activation.id)
Session.commit() Session.commit()

View File

@ -38,7 +38,11 @@ def create_mailbox():
the new mailbox dict the new mailbox dict
""" """
user = g.user user = g.user
mailbox_email = sanitize_email(request.get_json().get("email")) email = request.get_json().get("email")
if not email:
return jsonify(error="Invalid email"), 400
mailbox_email = sanitize_email(email)
try: try:
new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox

View File

@ -7,6 +7,7 @@ from app.db import Session
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import ActivationCode from app.models import ActivationCode
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_next_url from app.utils import sanitize_next_url
@ -47,6 +48,11 @@ def activate():
user = activation_code.user user = activation_code.user
user.activated = True user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ActivateUser,
message=f"User has been activated: {user.email}",
)
login_user(user) login_user(user)
# activation code is to be used only once # activation code is to be used only once

View File

@ -9,6 +9,7 @@ from app.auth.views.login_utils import after_login
from app.db import Session from app.db import Session
from app.extensions import limiter from app.extensions import limiter
from app.models import ResetPasswordCode from app.models import ResetPasswordCode
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class ResetPasswordForm(FlaskForm): class ResetPasswordForm(FlaskForm):
@ -59,6 +60,11 @@ def reset_password():
# this can be served to activate user too # this can be served to activate user too
user.activated = True user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ResetPassword,
message="User has reset their password",
)
# remove all reset password codes # remove all reset password codes
ResetPasswordCode.filter_by(user_id=user.id).delete() ResetPasswordCode.filter_by(user_id=user.id).delete()

View File

@ -309,6 +309,7 @@ JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report" JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1" JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events" JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
JOB_SEND_EVENT_TO_WEBHOOK = "send-event-to-webhook"
# for pagination # for pagination
PAGE_LIMIT = 20 PAGE_LIMIT = 20

View File

@ -16,6 +16,7 @@ from app.utils import sanitize_email
class ContactCreateError(Enum): class ContactCreateError(Enum):
InvalidEmail = "Invalid email" InvalidEmail = "Invalid email"
NotAllowed = "Your plan does not allow to create contacts" NotAllowed = "Your plan does not allow to create contacts"
Unknown = "Unknown error when trying to create contact"
@dataclass @dataclass
@ -87,6 +88,7 @@ def create_contact(
return __update_contact_if_needed(contact, name, mail_from) return __update_contact_if_needed(contact, name, mail_from)
# Create the contact # Create the contact
reply_email = generate_reply_email(email, alias) reply_email = generate_reply_email(email, alias)
alias_id = alias.id
try: try:
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0 flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
contact = Contact.create( contact = Contact.create(
@ -114,11 +116,21 @@ def create_contact(
LOG.d( LOG.d(
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}" f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
) )
return ContactCreateResult(contact, created=True, error=None)
except IntegrityError: except IntegrityError:
Session.rollback() Session.rollback()
LOG.info( LOG.info(
f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB" f"Contact with email {email} for alias_id {alias_id} already existed, fetching from DB"
) )
contact = Contact.get_by(alias_id=alias.id, website_email=email) contact: Optional[Contact] = Contact.get_by(
alias_id=alias_id, website_email=email
)
if contact:
return __update_contact_if_needed(contact, name, mail_from) return __update_contact_if_needed(contact, name, mail_from)
return ContactCreateResult(contact, created=True, error=None) else:
LOG.warning(
f"Could not find contact with email {email} for alias_id {alias_id} and it should exist"
)
return ContactCreateResult(
None, created=False, error=ContactCreateError.Unknown
)

View File

@ -121,10 +121,16 @@ def mailbox_route():
@login_required @login_required
def mailbox_verify(): def mailbox_verify():
mailbox_id = request.args.get("mailbox_id") mailbox_id = request.args.get("mailbox_id")
if not mailbox_id:
LOG.i("Missing mailbox_id")
flash("You followed an invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
code = request.args.get("code") code = request.args.get("code")
if not code: if not code:
# Old way # Old way
return verify_with_signed_secret(mailbox_id) return verify_with_signed_secret(mailbox_id)
try: try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code) mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
except mailbox_utils.MailboxError as e: except mailbox_utils.MailboxError as e:

View File

@ -0,0 +1,70 @@
from __future__ import annotations
import base64
from typing import Optional
import arrow
from app import config
from app.errors import ProtonPartnerNotSetUp
from app.events.generated import event_pb2
from app.events.generated.event_pb2 import EventContent
from app.models import (
User,
Job,
PartnerUser,
)
from app.proton.utils import get_proton_partner
from events.event_sink import EventSink
class SendEventToWebhookJob:
def __init__(self, user: User, event: EventContent):
self._user: User = user
self._event: EventContent = event
def run(self, sink: EventSink) -> bool:
# Check if the current user has a partner_id
try:
proton_partner_id = get_proton_partner().id
except ProtonPartnerNotSetUp:
return False
# It has. Retrieve the information for the PartnerUser
partner_user = PartnerUser.get_by(
user_id=self._user.id, partner_id=proton_partner_id
)
if partner_user is None:
return True
event = event_pb2.Event(
user_id=self._user.id,
external_user_id=partner_user.external_user_id,
partner_id=partner_user.partner_id,
content=self._event,
)
serialized = event.SerializeToString()
return sink.send_data_to_webhook(serialized)
@staticmethod
def create_from_job(job: Job) -> Optional[SendEventToWebhookJob]:
user = User.get(job.payload["user_id"])
if not user:
return None
event_data = base64.b64decode(job.payload["event"])
event = event_pb2.EventContent()
event.ParseFromString(event_data)
return SendEventToWebhookJob(user=user, event=event)
def store_job_in_db(self, run_at: Optional[arrow.Arrow]) -> Job:
stub = self._event.SerializeToString()
return Job.create(
name=config.JOB_SEND_EVENT_TO_WEBHOOK,
payload={
"user_id": self._user.id,
"event": base64.b64encode(stub).decode("utf-8"),
},
run_at=run_at if run_at is not None else arrow.now(),
commit=True,
)

View File

@ -37,8 +37,9 @@ class OnlyPaidError(MailboxError):
class CannotVerifyError(MailboxError): class CannotVerifyError(MailboxError):
def __init__(self, msg: str): def __init__(self, msg: str, deleted_activation_code: bool = False):
self.msg = msg self.msg = msg
self.deleted_activation_code = deleted_activation_code
MAX_ACTIVATION_TRIES = 3 MAX_ACTIVATION_TRIES = 3
@ -171,17 +172,17 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
f"User {user} failed to verify mailbox {mailbox_id} because it does not exist" f"User {user} failed to verify mailbox {mailbox_id} because it does not exist"
) )
raise MailboxError("Invalid mailbox") raise MailboxError("Invalid mailbox")
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
if mailbox.verified: if mailbox.verified:
LOG.i( LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified" f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
) )
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
return mailbox return mailbox
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
activation = ( activation = (
MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id) MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id)
@ -196,7 +197,10 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
if activation.tries >= MAX_ACTIVATION_TRIES: if activation.tries >= MAX_ACTIVATION_TRIES:
LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times") LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times")
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
raise CannotVerifyError("Invalid activation code. Please request another code.") raise CannotVerifyError(
"Invalid activation code. Please request another code.",
deleted_activation_code=True,
)
if activation.created_at < arrow.now().shift(minutes=-15): if activation.created_at < arrow.now().shift(minutes=-15):
LOG.i( LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because code is too old" f"User {user} failed to verify mailbox {mailbox_id} because code is too old"

View File

@ -24,6 +24,7 @@ from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import deferred from sqlalchemy.orm import deferred
from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import and_ from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
@ -616,6 +617,15 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if "alternative_id" not in kwargs: if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
trail = ". Created from partner" if from_partner else ""
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateUser,
message=f"Created user {email}{trail}",
)
# If the user is created from partner, do not notify # If the user is created from partner, do not notify
# nor give a trial # nor give a trial
if from_partner: if from_partner:
@ -3772,6 +3782,7 @@ class SyncEvent(Base, ModelMixin):
) )
def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool: def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool:
try:
taken_condition = ["taken_time IS NULL"] taken_condition = ["taken_time IS NULL"]
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id} args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
if allow_taken_older_than: if allow_taken_older_than:
@ -3781,6 +3792,8 @@ class SyncEvent(Base, ModelMixin):
sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}" sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}"
res = Session.execute(sql, args) res = Session.execute(sql, args)
Session.commit() Session.commit()
except ObjectDeletedError:
return False
return res.rowcount > 0 return res.rowcount > 0

View File

@ -1,8 +1,10 @@
from typing import Optional from typing import Optional
import arrow
from arrow import Arrow from arrow import Arrow
from app.models import PartnerUser, PartnerSubscription, User from app import config
from app.models import PartnerUser, PartnerSubscription, User, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@ -15,6 +17,11 @@ def create_partner_user(
partner_email=partner_email, partner_email=partner_email,
external_user_id=external_user_id, external_user_id=external_user_id,
) )
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
)
emit_user_audit_log( emit_user_audit_log(
user=user, user=user,
action=UserAuditLogAction.LinkAccount, action=UserAuditLogAction.LinkAccount,

View File

@ -2,11 +2,9 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from flask import url_for from flask import url_for
from typing import Optional from typing import Optional
import arrow
from app import config
from app.errors import LinkException from app.errors import LinkException
from app.models import User, Partner, Job from app.models import User, Partner
from app.proton.proton_client import ProtonClient, ProtonUser from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import ( from app.account_linking import (
process_login_case, process_login_case,
@ -43,21 +41,12 @@ class ProtonCallbackHandler:
def __init__(self, proton_client: ProtonClient): def __init__(self, proton_client: ProtonClient):
self.proton_client = proton_client self.proton_client = proton_client
def _initial_alias_sync(self, user: User):
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
commit=True,
)
def handle_login(self, partner: Partner) -> ProtonCallbackResult: def handle_login(self, partner: Partner) -> ProtonCallbackResult:
try: try:
user = self.__get_partner_user() user = self.__get_partner_user()
if user is None: if user is None:
return generate_account_not_allowed_to_log_in() return generate_account_not_allowed_to_log_in()
res = process_login_case(user, partner) res = process_login_case(user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult( return ProtonCallbackResult(
redirect_to_login=False, redirect_to_login=False,
flash_message=None, flash_message=None,
@ -86,7 +75,6 @@ class ProtonCallbackHandler:
if user is None: if user is None:
return generate_account_not_allowed_to_log_in() return generate_account_not_allowed_to_log_in()
res = process_link_case(user, current_user, partner) res = process_link_case(user, current_user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult( return ProtonCallbackResult(
redirect_to_login=False, redirect_to_login=False,
flash_message="Account successfully linked", flash_message="Account successfully linked",

View File

@ -4,6 +4,10 @@ from app.models import User, UserAuditLog
class UserAuditLogAction(Enum): class UserAuditLogAction(Enum):
CreateUser = "create_user"
ActivateUser = "activate_user"
ResetPassword = "reset_password"
Upgrade = "upgrade" Upgrade = "upgrade"
SubscriptionExtended = "subscription_extended" SubscriptionExtended = "subscription_extended"
SubscriptionCancelled = "subscription_cancelled" SubscriptionCancelled = "subscription_cancelled"

View File

@ -12,6 +12,10 @@ class EventSink(ABC):
def process(self, event: SyncEvent) -> bool: def process(self, event: SyncEvent) -> bool:
pass pass
@abstractmethod
def send_data_to_webhook(self, data: bytes) -> bool:
pass
class HttpEventSink(EventSink): class HttpEventSink(EventSink):
def process(self, event: SyncEvent) -> bool: def process(self, event: SyncEvent) -> bool:
@ -21,9 +25,16 @@ class HttpEventSink(EventSink):
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}") LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
if self.send_data_to_webhook(event.content):
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
return False
def send_data_to_webhook(self, data: bytes) -> bool:
res = requests.post( res = requests.post(
url=EVENT_WEBHOOK, url=EVENT_WEBHOOK,
data=event.content, data=data,
headers={"Content-Type": "application/x-protobuf"}, headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL, verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
) )
@ -36,7 +47,6 @@ class HttpEventSink(EventSink):
) )
return False return False
else: else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True return True
@ -44,3 +54,7 @@ class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent) -> bool: def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}") LOG.info(f"Handling event {event.id}")
return True return True
def send_data_to_webhook(self, data: bytes) -> bool:
LOG.info(f"Sending {len(data)} bytes to webhook")
return True

View File

@ -18,6 +18,7 @@ from app.events.event_dispatcher import PostgresDispatcher
from app.import_utils import handle_batch_import from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob from app.jobs.export_user_data_job import ExportUserDataJob
from app.jobs.send_event_job import SendEventToWebhookJob
from app.log import LOG from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@ -300,6 +301,10 @@ def process_job(job: Job):
send_alias_creation_events_for_user( send_alias_creation_events_for_user(
user, dispatcher=PostgresDispatcher.get() user, dispatcher=PostgresDispatcher.get()
) )
elif job.name == config.JOB_SEND_EVENT_TO_WEBHOOK:
send_job = SendEventToWebhookJob.create_from_job(job)
if send_job:
send_job.run()
else: else:
LOG.e("Unknown job name %s", job.name) LOG.e("Unknown job name %s", job.name)

View File

@ -442,10 +442,10 @@ def init_admin(app):
admin = Admin(name="SimpleLogin", template_mode="bootstrap4") admin = Admin(name="SimpleLogin", template_mode="bootstrap4")
admin.init_app(app, index_view=SLAdminIndexView()) admin.init_app(app, index_view=SLAdminIndexView())
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
admin.add_view(UserAdmin(User, Session)) admin.add_view(UserAdmin(User, Session))
admin.add_view(AliasAdmin(Alias, Session)) admin.add_view(AliasAdmin(Alias, Session))
admin.add_view(MailboxAdmin(Mailbox, Session)) admin.add_view(MailboxAdmin(Mailbox, Session))
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
admin.add_view(CouponAdmin(Coupon, Session)) admin.add_view(CouponAdmin(Coupon, Session))
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session)) admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
admin.add_view(CustomDomainAdmin(CustomDomain, Session)) admin.add_view(CustomDomainAdmin(CustomDomain, Session))

View File

@ -8,6 +8,7 @@
<tr> <tr>
<th scope="col">User ID</th> <th scope="col">User ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Verified</th>
<th scope="col">Status</th> <th scope="col">Status</th>
<th scope="col">Paid</th> <th scope="col">Paid</th>
<th>Subscription</th> <th>Subscription</th>
@ -20,8 +21,12 @@
<tr> <tr>
<td>{{ user.id }}</td> <td>{{ user.id }}</td>
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td> <td><a href="?email={{ user.email }}">{{ user.email }}</a></td>
{% if user.activated %}
<td class="text-success">Activated</td>
{% else %}
<td class="text-warning">Pending</td>
{% endif %}
{% if user.disabled %} {% if user.disabled %}
<td class="text-danger">Disabled</td> <td class="text-danger">Disabled</td>
{% else %} {% else %}
<td class="text-success">Enabled</td> <td class="text-success">Enabled</td>
@ -239,6 +244,11 @@
{{ show_user(data.user) }} {{ show_user(data.user) }}
{{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }} {{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }}
{{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }} {{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
</div>
{% endif %}
{% if data.user_audit_log %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">Audit log entries for user {{ data.query }}</h3>
{{ list_user_audit_log(data.user_audit_log) }} {{ list_user_audit_log(data.user_audit_log) }}
</div> </div>
{% endif %} {% endif %}
@ -260,6 +270,7 @@
<div class="border border-dark mt-1 mb-2 p-3"> <div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3> <h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3>
{{ show_deleted_alias(data.deleted_alias) }} {{ show_deleted_alias(data.deleted_alias) }}
{{ list_alias_audit_log(data.deleted_alias_audit_log) }}
</div> </div>
{% endif %} {% endif %}
{% if data.domain_deleted_alias %} {% if data.domain_deleted_alias %}
@ -267,6 +278,7 @@
<div class="border border-dark mt-1 mb-2 p-3"> <div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3> <h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3>
{{ show_domain_deleted_alias(data.domain_deleted_alias) }} {{ show_domain_deleted_alias(data.domain_deleted_alias) }}
{{ list_alias_audit_log(data.domain_deleted_alias_audit_log) }}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -511,6 +511,19 @@ def test_create_contact_route_invalid_alias(flask_client):
assert r.status_code == 403 assert r.status_code == 403
def test_create_contact_route_non_existing_alias(flask_client):
user, api_key = get_new_user_and_api_key()
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=99999999),
headers={"Authentication": api_key.code},
json={"contact": "First Last <first@example.com>"},
)
assert r.status_code == 403
def test_create_contact_route_free_users(flask_client): def test_create_contact_route_free_users(flask_client):
user, api_key = get_new_user_and_api_key() user, api_key = get_new_user_and_api_key()

View File

@ -5,7 +5,7 @@ from app.models import Mailbox
from tests.utils import login from tests.utils import login
def test_create_mailbox(flask_client): def test_create_mailbox_valid(flask_client):
login(flask_client) login(flask_client)
r = flask_client.post( r = flask_client.post(
@ -21,10 +21,34 @@ def test_create_mailbox(flask_client):
assert r.json["default"] is False assert r.json["default"] is False
assert r.json["nb_alias"] == 0 assert r.json["nb_alias"] == 0
# invalid email address
def test_create_mailbox_invalid_email(flask_client):
login(flask_client)
r = flask_client.post( r = flask_client.post(
"/api/mailboxes", "/api/mailboxes",
json={"email": "gmail.com"}, json={"email": "gmail.com"}, # not an email address
)
assert r.status_code == 400
assert r.json == {"error": "Invalid email"}
def test_create_mailbox_empty_payload(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={},
)
assert r.status_code == 400
assert r.json == {"error": "Invalid email"}
def test_create_mailbox_empty_email(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={"email": ""},
) )
assert r.status_code == 400 assert r.status_code == 400

View File

@ -0,0 +1,40 @@
import arrow
from app import config
from app.events.generated.event_pb2 import EventContent, AliasDeleted
from app.jobs.send_event_job import SendEventToWebhookJob
from app.models import PartnerUser
from app.proton.utils import get_proton_partner
from events.event_sink import ConsoleEventSink
from tests.utils import create_new_user, random_token
def test_serialize_and_deserialize_job():
user = create_new_user()
alias_id = 34
alias_email = "a@b.c"
event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email))
run_at = arrow.now().shift(hours=10)
db_job = SendEventToWebhookJob(user, event).store_job_in_db(run_at=run_at)
assert db_job.run_at == run_at
assert db_job.name == config.JOB_SEND_EVENT_TO_WEBHOOK
job = SendEventToWebhookJob.create_from_job(db_job)
assert job._user.id == user.id
assert job._event.alias_deleted.id == alias_id
assert job._event.alias_deleted.email == alias_email
def test_send_event_to_webhook():
user = create_new_user()
PartnerUser.create(
user_id=user.id,
partner_id=get_proton_partner().id,
external_user_id=random_token(10),
flush=True,
)
alias_id = 34
alias_email = "a@b.c"
event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email))
job = SendEventToWebhookJob(user, event)
sink = ConsoleEventSink()
assert job.run(sink)

View File

@ -25,15 +25,17 @@ class MockProtonClient(ProtonClient):
return self.user return self.user
def check_initial_sync_job(user: User): def check_initial_sync_job(user: User, expected: bool):
found = False
for job in Job.yield_per_query(10).filter_by( for job in Job.yield_per_query(10).filter_by(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS, name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
state=JobState.ready.value, state=JobState.ready.value,
): ):
if job.payload.get("user_id") == user.id: if job.payload.get("user_id") == user.id:
found = True
Job.delete(job.id) Job.delete(job.id)
return break
assert False assert expected == found
def test_proton_callback_handler_unexistant_sl_user(): def test_proton_callback_handler_unexistant_sl_user():
@ -69,10 +71,9 @@ def test_proton_callback_handler_unexistant_sl_user():
) )
assert partner_user is not None assert partner_user is not None
assert partner_user.external_user_id == external_id assert partner_user.external_user_id == external_id
check_initial_sync_job(res.user)
def test_proton_callback_handler_existant_sl_user(): def test_proton_callback_handler_existing_sl_user():
email = random_email() email = random_email()
sl_user = User.create(email, commit=True) sl_user = User.create(email, commit=True)
@ -98,7 +99,43 @@ def test_proton_callback_handler_existant_sl_user():
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id) sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None assert sa is not None
assert sa.partner_email == user.email assert sa.partner_email == user.email
check_initial_sync_job(res.user) check_initial_sync_job(res.user, True)
def test_proton_callback_handler_linked_sl_user():
email = random_email()
external_id = random_string()
sl_user = User.create(email, commit=True)
PartnerUser.create(
user_id=sl_user.id,
partner_id=get_proton_partner().id,
external_user_id=external_id,
partner_email=email,
commit=True,
)
user = UserInformation(
email=email,
name=random_string(),
id=external_id,
plan=SLPlan(type=SLPlanType.Premium, expiration=Arrow.utcnow().shift(hours=2)),
)
handler = ProtonCallbackHandler(MockProtonClient(user=user))
res = handler.handle_login(get_proton_partner())
assert res.user is not None
assert res.user.id == sl_user.id
# Ensure the user is not marked as created from partner
assert User.FLAG_CREATED_FROM_PARTNER != (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.notification is True
assert res.user.trial_end is not None
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None
assert sa.partner_email == user.email
check_initial_sync_job(res.user, False)
def test_proton_callback_handler_none_user_login(): def test_proton_callback_handler_none_user_login():

View File

@ -94,10 +94,12 @@ def test_login_case_from_partner():
) )
assert res.user.activated is True assert res.user.activated is True
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1 assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id assert audit_logs[0].user_id == res.user.id
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
def test_login_case_from_partner_with_uppercase_email(): def test_login_case_from_partner_with_uppercase_email():
@ -133,7 +135,10 @@ def test_login_case_from_web():
assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER) assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER)
assert res.user.activated is True assert res.user.activated is True
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1 assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id assert audit_logs[0].user_id == res.user.id
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
@ -218,7 +223,10 @@ def test_link_account_with_proton_account_same_address(flask_client):
) )
assert partner_user.partner_id == get_proton_partner().id assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id assert partner_user.external_user_id == partner_user_id
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1 assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id assert audit_logs[0].user_id == res.user.id
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
@ -246,7 +254,10 @@ def test_link_account_with_proton_account_different_address(flask_client):
assert partner_user.partner_id == get_proton_partner().id assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id assert partner_user.external_user_id == partner_user_id
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1 assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id assert audit_logs[0].user_id == res.user.id
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
@ -304,19 +315,19 @@ def test_link_account_with_proton_account_same_address_but_linked_to_other_user(
# Ensure audit logs for sl_user_1 show the link action # Ensure audit logs for sl_user_1 show the link action
sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_1.id user_id=sl_user_1.id,
action=UserAuditLogAction.LinkAccount.value,
).all() ).all()
assert len(sl_user_1_audit_logs) == 1 assert len(sl_user_1_audit_logs) == 1
assert sl_user_1_audit_logs[0].user_id == sl_user_1.id assert sl_user_1_audit_logs[0].user_id == sl_user_1.id
assert sl_user_1_audit_logs[0].action == UserAuditLogAction.LinkAccount.value
# Ensure audit logs for sl_user_2 show the unlink action # Ensure audit logs for sl_user_2 show the unlink action
sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_2.id user_id=sl_user_2.id,
action=UserAuditLogAction.UnlinkAccount.value,
).all() ).all()
assert len(sl_user_2_audit_logs) == 1 assert len(sl_user_2_audit_logs) == 1
assert sl_user_2_audit_logs[0].user_id == sl_user_2.id assert sl_user_2_audit_logs[0].user_id == sl_user_2.id
assert sl_user_2_audit_logs[0].action == UserAuditLogAction.UnlinkAccount.value
def test_link_account_with_proton_account_different_address_and_linked_to_other_user( def test_link_account_with_proton_account_different_address_and_linked_to_other_user(
@ -356,19 +367,19 @@ def test_link_account_with_proton_account_different_address_and_linked_to_other_
# Ensure audit logs for sl_user_1 show the link action # Ensure audit logs for sl_user_1 show the link action
sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_1.id user_id=sl_user_1.id,
action=UserAuditLogAction.LinkAccount.value,
).all() ).all()
assert len(sl_user_1_audit_logs) == 1 assert len(sl_user_1_audit_logs) == 1
assert sl_user_1_audit_logs[0].user_id == sl_user_1.id assert sl_user_1_audit_logs[0].user_id == sl_user_1.id
assert sl_user_1_audit_logs[0].action == UserAuditLogAction.LinkAccount.value
# Ensure audit logs for sl_user_2 show the unlink action # Ensure audit logs for sl_user_2 show the unlink action
sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_2.id user_id=sl_user_2.id,
action=UserAuditLogAction.UnlinkAccount.value,
).all() ).all()
assert len(sl_user_2_audit_logs) == 1 assert len(sl_user_2_audit_logs) == 1
assert sl_user_2_audit_logs[0].user_id == sl_user_2.id assert sl_user_2_audit_logs[0].user_id == sl_user_2.id
assert sl_user_2_audit_logs[0].action == UserAuditLogAction.UnlinkAccount.value
def test_cannot_create_instance_of_base_strategy(): def test_cannot_create_instance_of_base_strategy():

View File

@ -286,6 +286,15 @@ def test_verify_other_users_mailbox():
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999") mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
def test_verify_other_users_already_verified_mailbox():
other = create_new_user()
mailbox = Mailbox.create(
user_id=other.id, email=random_email(), verified=True, commit=True
)
with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
def test_verify_fail(): def test_verify_fail():
output = mailbox_utils.create_mailbox(user, random_email()) output = mailbox_utils.create_mailbox(user, random_email())
@ -305,10 +314,13 @@ def test_verify_too_may():
output = mailbox_utils.create_mailbox(user, random_email()) output = mailbox_utils.create_mailbox(user, random_email())
output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES
Session.commit() Session.commit()
with pytest.raises(mailbox_utils.CannotVerifyError): try:
mailbox_utils.verify_mailbox_code( mailbox_utils.verify_mailbox_code(
user, output.mailbox.id, output.activation.code user, output.mailbox.id, output.activation.code
) )
assert False
except mailbox_utils.CannotVerifyError as e:
assert e.deleted_activation_code
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
@ -351,7 +363,9 @@ def test_perform_mailbox_email_change_valid_id_not_new_email():
res = mailbox_utils.perform_mailbox_email_change(mb.id) res = mailbox_utils.perform_mailbox_email_change(mb.id)
assert res.error == MailboxEmailChangeError.InvalidId assert res.error == MailboxEmailChangeError.InvalidId
assert res.message_category == "error" assert res.message_category == "error"
audit_log_entries = UserAuditLog.filter_by(user_id=user.id).count() audit_log_entries = UserAuditLog.filter_by(
user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
).count()
assert audit_log_entries == 0 assert audit_log_entries == 0
@ -374,7 +388,9 @@ def test_perform_mailbox_email_change_valid_id_email_already_used():
res = mailbox_utils.perform_mailbox_email_change(mb_to_change.id) res = mailbox_utils.perform_mailbox_email_change(mb_to_change.id)
assert res.error == MailboxEmailChangeError.EmailAlreadyUsed assert res.error == MailboxEmailChangeError.EmailAlreadyUsed
assert res.message_category == "error" assert res.message_category == "error"
audit_log_entries = UserAuditLog.filter_by(user_id=user.id).count() audit_log_entries = UserAuditLog.filter_by(
user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
).count()
assert audit_log_entries == 0 assert audit_log_entries == 0
@ -398,6 +414,7 @@ def test_perform_mailbox_email_change_success():
assert db_mailbox.email == new_email assert db_mailbox.email == new_email
assert db_mailbox.new_email is None assert db_mailbox.new_email is None
audit_log_entries = UserAuditLog.filter_by(user_id=user.id).all() audit_log_entries = UserAuditLog.filter_by(
assert len(audit_log_entries) == 1 user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
assert audit_log_entries[0].action == UserAuditLogAction.UpdateMailbox.value ).count()
assert audit_log_entries == 1

View File

@ -27,7 +27,9 @@ def test_emit_alias_audit_log_for_random_data():
commit=True, commit=True,
) )
logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(user_id=user.id).all() logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=user.id, action=action.value
).all()
assert len(logs_for_user) == 1 assert len(logs_for_user) == 1
assert logs_for_user[0].user_id == user.id assert logs_for_user[0].user_id == user.id
assert logs_for_user[0].user_email == user.email assert logs_for_user[0].user_email == user.email
@ -41,7 +43,10 @@ def test_emit_audit_log_on_mailbox_creation():
user=user, email=random_email(), verified=True user=user, email=random_email(), verified=True
) )
logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(user_id=user.id).all() logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=user.id,
action=UserAuditLogAction.CreateMailbox.value,
).all()
assert len(logs_for_user) == 1 assert len(logs_for_user) == 1
assert logs_for_user[0].user_id == user.id assert logs_for_user[0].user_id == user.id
assert logs_for_user[0].user_email == user.email assert logs_for_user[0].user_email == user.email