Exemple #1
0
def test_email_address_get_canonical(clean_db):
    """EmailAddress.get_canonical returns all matching records"""
    db = clean_db
    ea1 = EmailAddress('*****@*****.**')
    ea2 = EmailAddress('*****@*****.**')
    ea3 = EmailAddress('*****@*****.**')
    db.session.add_all([ea1, ea2, ea3])
    db.session.commit()

    assert set(EmailAddress.get_canonical('*****@*****.**')) == {ea1, ea2}
Exemple #2
0
def test_email_address_add(clean_db):
    """Using EmailAddress.add will auto-add to session and return existing instances"""
    ea1 = EmailAddress.add('*****@*****.**')
    assert isinstance(ea1, EmailAddress)
    assert ea1.email == '*****@*****.**'

    ea2 = EmailAddress.add('*****@*****.**')
    ea3 = EmailAddress.add('*****@*****.**')
    ea4 = EmailAddress.add('*****@*****.**')

    assert ea2 is not None
    assert ea3 is not None
    assert ea4 is not None

    assert ea2 != ea1
    assert ea3 != ea1
    assert ea4 == ea1

    # Email casing will not be amended by the call to EmailAddress.add
    assert ea1.email == '*****@*****.**'

    # A forgotten email address will be restored by calling EmailAddress.add
    # Since it was forgotten, email casing will also be amended (we don't have a choice)
    ea3.email = None
    assert ea3.email is None
    ea5 = EmailAddress.add('*****@*****.**')
    assert ea5 == ea3
    assert ea5.email == ea3.email == '*****@*****.**'

    # Adding an invalid email address will raise an error
    with pytest.raises(ValueError):
        EmailAddress.add('invalid')

    with pytest.raises(ValueError):
        EmailAddress.add(None)
Exemple #3
0
def test_email_address_init_error():
    """EmailAddress constructor will reject various forms of bad input"""
    with pytest.raises(ValueError):
        # Must be a string
        EmailAddress(None)
    with pytest.raises(ValueError):
        # Must not be blank
        EmailAddress('')
    with pytest.raises(ValueError):
        # Must be syntactically valid
        EmailAddress('invalid')
    with pytest.raises(ValueError):
        # Must be syntactically valid (caught elsewhere internally)
        EmailAddress('@invalid')
Exemple #4
0
def test_email_address_conflict_integrity_error(clean_db):
    """A conflicting EmailAddress cannot be committed to db"""
    db = clean_db
    ea1 = EmailAddress('*****@*****.**')
    db.session.add(ea1)
    db.session.commit()
    ea2 = EmailAddress('*****@*****.**')
    db.session.add(ea2)
    db.session.commit()
    ea3 = EmailAddress('*****@*****.**')
    db.session.add(ea3)
    db.session.commit()
    # Conflicts with ea1 as email addresses are case preserving but not sensitive
    ea4 = EmailAddress('*****@*****.**')
    db.session.add(ea4)
    with pytest.raises(IntegrityError):
        db.session.commit()
Exemple #5
0
def test_email_address_validate_for_check_dns(email_models, clean_mixin_db):
    """Validate_for with check_dns=True. Separate test as DNS lookup may fail."""
    db = clean_mixin_db
    models = email_models

    user1 = models.EmailUser()
    user2 = models.EmailUser()
    anon_user = None
    db.session.add_all([user1, user2])

    # A domain without MX records is invalid if check_dns=True
    # This uses hsgk.in, a known domain without MX.
    # example.* use null MX as per RFC 7505, but the underlying validator in pyIsEmail
    # does not support this.
    assert EmailAddress.validate_for(user1, '*****@*****.**',
                                     check_dns=True) == 'nomx'
    assert EmailAddress.validate_for(user2, '*****@*****.**',
                                     check_dns=True) == 'nomx'
    assert (EmailAddress.validate_for(anon_user,
                                      '*****@*****.**',
                                      check_dns=True) == 'nomx')
Exemple #6
0
def test_email_address_init():
    """EmailAddress instances can be created using a string email address"""

    # Ordinary use constructor passes without incident
    ea1 = EmailAddress('*****@*****.**')
    assert ea1.email == '*****@*****.**'
    assert ea1.email_normalized == '*****@*****.**'
    assert ea1.domain == 'example.com'
    assert ea1.blake2b160 == hash_map['*****@*****.**']
    assert ea1.email_canonical == '*****@*****.**'
    assert ea1.blake2b160_canonical == hash_map['*****@*****.**']
    assert str(ea1) == '*****@*****.**'
    assert repr(ea1) == "EmailAddress('*****@*****.**')"
    # Public hash (for URLs)
    assert ea1.email_hash == '2EGz72jxcsYjvXxF7r5rqfAgikor'
    # Aliased hash attribute for notifications framework
    assert ea1.transport_hash == '2EGz72jxcsYjvXxF7r5rqfAgikor'

    # Case is preserved but disregarded for hashes
    ea2 = EmailAddress('*****@*****.**')
    assert ea2.email == '*****@*****.**'
    assert ea2.email_normalized == '*****@*****.**'
    assert ea2.domain == 'example.com'
    assert ea2.blake2b160 == hash_map['*****@*****.**']
    assert ea1.email_canonical == '*****@*****.**'
    assert ea2.blake2b160_canonical == hash_map['*****@*****.**']
    assert str(ea2) == '*****@*****.**'
    assert repr(ea2) == "EmailAddress('*****@*****.**')"

    # Canonical representation's hash can be distinct from regular hash
    ea3 = EmailAddress('*****@*****.**')
    assert ea3.email == '*****@*****.**'
    assert ea3.email_normalized == '*****@*****.**'
    assert ea3.domain == 'example.com'
    assert ea3.blake2b160 == hash_map['*****@*****.**']
    assert ea1.email_canonical == '*****@*****.**'
    assert ea3.blake2b160_canonical == hash_map['*****@*****.**']
    assert str(ea3) == '*****@*****.**'
    assert repr(ea3) == "EmailAddress('*****@*****.**')"
Exemple #7
0
def test_email_address_blocked(clean_db):
    """A blocked email address cannot be used via EmailAddress.add"""
    ea1 = EmailAddress.add('*****@*****.**')
    ea2 = EmailAddress.add('*****@*****.**')
    ea3 = EmailAddress.add('*****@*****.**')

    EmailAddress.mark_blocked(ea2.email)

    assert ea1.is_blocked is True
    assert ea2.is_blocked is True
    assert ea3.is_blocked is False

    with pytest.raises(EmailAddressBlockedError):
        EmailAddress.add('*****@*****.**')
Exemple #8
0
def test_email_address_get(clean_db):
    """Email addresses can be loaded using EmailAddress.get"""
    db = clean_db
    ea1 = EmailAddress('*****@*****.**')
    ea2 = EmailAddress('*****@*****.**')
    ea3 = EmailAddress('*****@*****.**')
    db.session.add_all([ea1, ea2, ea3])
    db.session.commit()

    get1 = EmailAddress.get('*****@*****.**')
    assert get1 == ea1
    get2 = EmailAddress.get('*****@*****.**')
    assert get2 == ea2
    # Can also get by hash
    get3 = EmailAddress.get(blake2b160=hash_map['*****@*****.**'])
    assert get3 == ea1
    # Or by Base58 representation of hash
    get4 = EmailAddress.get(email_hash='2EGz72jxcsYjvXxF7r5rqfAgikor')
    assert get4 == ea1

    # Will return nothing if given garbage input, or a non-existent email address
    assert EmailAddress.get('invalid') is None
    assert EmailAddress.get('*****@*****.**') is None
Exemple #9
0
def test_email_address_refcount_drop(email_models, clean_mixin_db,
                                     refcount_data):
    """Test that EmailAddress.refcount drop events are fired"""
    db = clean_mixin_db
    models = email_models

    # The refcount changing signal handler will have received events for every email
    # address in this test. A request teardown processor can use this to determine
    # which email addresses need to be forgotten (preferably in a background job)

    # We have an empty set at the start of this test
    assert isinstance(refcount_data, set)
    assert refcount_data == set()

    ea = EmailAddress.add('*****@*****.**')
    assert refcount_data == set()

    user = models.EmailUser()
    doc = models.EmailDocument()
    link = models.EmailLink(emailuser=user, email_address=ea)
    db.session.add_all([ea, user, doc, link])

    assert refcount_data == set()

    doc.email_address = ea
    assert refcount_data == set()
    assert ea.refcount() == 2

    doc.email_address = None
    assert refcount_data == {ea}
    assert ea.refcount() == 1

    refcount_data.remove(ea)
    assert refcount_data == set()
    db.session.commit()  # Persist before deleting
    db.session.delete(link)
    db.session.commit()
    assert refcount_data == {ea}
    assert ea.refcount() == 0
Exemple #10
0
def test_email_address_delivery_state(clean_db):
    """An email address can have the last known delivery state set on it"""
    db = clean_db
    ea = EmailAddress.add('*****@*****.**')
    assert ea.delivery_state.UNKNOWN

    # Calling a transition will change state and set timestamp to update on commit
    ea.mark_sent()
    # An email was sent. Nothing more is known
    assert ea.delivery_state.SENT
    assert str(ea.delivery_state_at) == str(db.func.utcnow())

    # mark_sent() can be called each time an email is sent
    ea.mark_sent()

    # Recipient is known to be interacting with email (viewing or opening links)
    # This sets a timestamp but does not change state
    assert ea.active_at is None
    ea.mark_active()
    assert ea.delivery_state.SENT
    assert str(ea.active_at) == str(db.func.utcnow())

    # This can be "downgraded" to SENT, as we only record the latest status
    ea.mark_sent()
    assert ea.delivery_state.SENT
    assert str(ea.delivery_state_at) == str(db.func.utcnow())

    # Email address is soft bouncing (typically mailbox full)
    ea.mark_soft_fail()
    assert ea.delivery_state.SOFT_FAIL
    assert str(ea.delivery_state_at) == str(db.func.utcnow())

    # Email address is hard bouncing (typically mailbox invalid)
    ea.mark_hard_fail()
    assert ea.delivery_state.HARD_FAIL
    assert str(ea.delivery_state_at) == str(db.func.utcnow())
Exemple #11
0
def test_email_address_validate_for(email_models, clean_mixin_db):
    """EmailAddress.validate_for can be used to determine availability"""
    db = clean_mixin_db
    models = email_models

    user1 = models.EmailUser()
    user2 = models.EmailUser()
    anon_user = None
    db.session.add_all([user1, user2])

    # A new email address is available to all
    assert EmailAddress.validate_for(user1, '*****@*****.**') is True
    assert EmailAddress.validate_for(user2, '*****@*****.**') is True
    assert EmailAddress.validate_for(anon_user, '*****@*****.**') is True

    # Once it's assigned to a user, availability changes
    link = models.EmailLink(emailuser=user1, email='*****@*****.**')
    db.session.add(link)

    assert EmailAddress.validate_for(user1, '*****@*****.**') is True
    assert EmailAddress.validate_for(user2, '*****@*****.**') is False
    assert EmailAddress.validate_for(anon_user, '*****@*****.**') is False

    # An address in use is not available to claim as new
    assert (EmailAddress.validate_for(user1, '*****@*****.**',
                                      new=True) == 'not_new')
    assert EmailAddress.validate_for(user2, '*****@*****.**',
                                     new=True) is False
    assert (EmailAddress.validate_for(
        anon_user, '*****@*****.**', new=True) is False)

    # When delivery state changes, validate_for's result changes too
    ea = link.email_address
    assert ea.delivery_state.UNKNOWN

    ea.mark_sent()
    assert ea.delivery_state.SENT
    assert EmailAddress.validate_for(user1, '*****@*****.**') is True
    assert EmailAddress.validate_for(user2, '*****@*****.**') is False
    assert EmailAddress.validate_for(anon_user, '*****@*****.**') is False

    ea.mark_soft_fail()
    assert ea.delivery_state.SOFT_FAIL
    assert EmailAddress.validate_for(user1,
                                     '*****@*****.**') == 'soft_fail'
    assert EmailAddress.validate_for(user2, '*****@*****.**') is False
    assert EmailAddress.validate_for(anon_user, '*****@*****.**') is False

    ea.mark_hard_fail()
    assert ea.delivery_state.HARD_FAIL
    assert EmailAddress.validate_for(user1,
                                     '*****@*****.**') == 'hard_fail'
    assert EmailAddress.validate_for(user2, '*****@*****.**') is False
    assert EmailAddress.validate_for(anon_user, '*****@*****.**') is False

    # A blocked address is available to no one
    db.session.add(EmailAddress('*****@*****.**'))
    EmailAddress.mark_blocked('*****@*****.**')
    assert EmailAddress.validate_for(user1, '*****@*****.**') is False
    assert EmailAddress.validate_for(user2, '*****@*****.**') is False
    assert EmailAddress.validate_for(anon_user, '*****@*****.**') is False

    # An invalid address is available to no one
    assert EmailAddress.validate_for(user1, 'invalid') == 'invalid'
    assert EmailAddress.validate_for(user2, 'invalid') == 'invalid'
    assert EmailAddress.validate_for(anon_user, 'invalid') == 'invalid'
Exemple #12
0
def test_email_address_mixin(email_models, clean_mixin_db):
    """The EmailAddressMixin class adds safety checks for using an email address"""
    db = clean_mixin_db
    models = email_models

    blocked_email = EmailAddress('*****@*****.**')

    user1 = models.EmailUser()
    user2 = models.EmailUser()

    doc1 = models.EmailDocument()
    doc2 = models.EmailDocument()

    db.session.add_all([user1, user2, doc1, doc2, blocked_email])

    EmailAddress.mark_blocked('*****@*****.**')

    # Mixin-based classes can simply specify an email parameter to link to an
    # EmailAddress instance
    link1 = models.EmailLink(emailuser=user1, email='*****@*****.**')
    db.session.add(link1)
    ea1 = EmailAddress.get('*****@*****.**')
    assert link1.email == '*****@*****.**'
    assert link1.email_address == ea1
    assert link1.transport_hash == ea1.transport_hash
    assert bool(link1.transport_hash)

    # Link an unrelated email address to another user to demonstrate that it works
    link2 = models.EmailLink(emailuser=user2, email='*****@*****.**')
    db.session.add(link2)
    ea2 = EmailAddress.get('*****@*****.**')
    assert link2.email == '*****@*****.**'
    assert link2.email_address == ea2
    assert link2.transport_hash == ea2.transport_hash
    assert bool(link1.transport_hash)

    db.session.commit()

    # '*****@*****.**' is now exclusive to user2. Attempting it to assign it to
    # user1 will raise an exception, even if the case is changed.
    with pytest.raises(EmailAddressInUseError):
        models.EmailLink(emailuser=user1, email='*****@*****.**')

    # This safety catch works even if the email_address column is used:
    with pytest.raises(EmailAddressInUseError):
        models.EmailLink(emailuser=user1, email_address=ea2)

    db.session.rollback()

    # Blocked addresses cannot be used either
    with pytest.raises(EmailAddressBlockedError):
        models.EmailLink(emailuser=user1, email='*****@*****.**')

    with pytest.raises(EmailAddressBlockedError):
        models.EmailLink(emailuser=user1, email_address=blocked_email)

    db.session.rollback()

    # Attempting to assign '*****@*****.**' to user2 a second time will cause a
    # SQL integrity error because EmailLink.__email_unique__ is True.
    link3 = models.EmailLink(emailuser=user2, email='*****@*****.**')
    db.session.add(link3)
    with pytest.raises(IntegrityError):
        db.session.flush()

    del link3
    db.session.rollback()

    # The EmailDocument model, in contrast, has no requirement of availability to a
    # specific user, so it won't be blocked here despite being exclusive to user1
    assert doc1.email is None
    assert doc2.email is None
    assert doc1.email_address is None
    assert doc2.email_address is None

    doc1.email = '*****@*****.**'
    doc2.email = '*****@*****.**'

    assert doc1.email == '*****@*****.**'
    assert doc2.email == '*****@*****.**'
    assert doc1.email_address == ea1
    assert doc2.email_address == ea1

    # ea1 now has three references, while ea2 has 1
    assert ea1.refcount() == 3
    assert ea2.refcount() == 1

    # Setting the email property on EmailDocument will mutate
    # EmailDocument.email_address and not EmailDocument.email_address.email
    assert ea1.email == '*****@*****.**'
    doc1.email = None
    assert ea1.email == '*****@*****.**'
    assert doc1.email_address is None
    doc2.email = '*****@*****.**'
    assert ea1.email == '*****@*****.**'
    assert doc2.email_address == ea2

    # EmailLinkedDocument takes the complexity up a notch

    # A document linked to a user can use any email linked to that user
    ldoc1 = models.EmailLinkedDocument(emailuser=user1,
                                       email='*****@*****.**')
    db.session.add(ldoc1)
    assert ldoc1.emailuser == user1
    assert ldoc1.email_address == ea1

    # But another user can't use this email address
    with pytest.raises(EmailAddressInUseError):
        models.EmailLinkedDocument(emailuser=user2,
                                   email='*****@*****.**')

    # This restriction also applies when user is not specified. Here, this email is
    # claimed by user2 above
    with pytest.raises(EmailAddressInUseError):
        models.EmailLinkedDocument(emailuser=None, email='*****@*****.**')

    # But it works with an unaffiliated email address
    ldoc2 = models.EmailLinkedDocument(email='*****@*****.**')
    db.session.add(ldoc2)
    assert ldoc2.emailuser is None
    assert ldoc2.email == '*****@*****.**'

    ldoc3 = models.EmailLinkedDocument(emailuser=user2,
                                       email='*****@*****.**')
    db.session.add(ldoc3)
    assert ldoc3.emailuser is user2
    assert ldoc3.email == '*****@*****.**'

    # Setting the email to None on the document removes the link to the EmailAddress,
    # but does not blank out the EmailAddress

    assert ldoc1.email_address == ea1
    assert ea1.email == '*****@*****.**'
    ldoc1.email = None
    assert ldoc1.email_address is None
    assert ea1.email == '*****@*****.**'
Exemple #13
0
def test_email_address_can_commit(clean_db):
    """An EmailAddress can be committed to db"""
    db = clean_db
    ea = EmailAddress('*****@*****.**')
    db.session.add(ea)
    db.session.commit()
Exemple #14
0
def test_email_address_is_blocked_flag():
    """EmailAddress has a read-only is_blocked flag that is normally False"""
    ea = EmailAddress('*****@*****.**')
    assert ea.is_blocked is False
    with pytest.raises(AttributeError):
        ea.is_blocked = True
Exemple #15
0
def test_email_address_md5():
    """EmailAddress has an MD5 method for legacy applications"""
    ea = EmailAddress('*****@*****.**')
    assert ea.md5() == '23463b99b62a72f26ed677cc556c44e8'
    ea.email = None
    assert ea.md5() is None
Exemple #16
0
def test_email_address_mutability():
    """EmailAddress can be mutated to change casing or delete the address only"""
    ea = EmailAddress('*****@*****.**')
    assert ea.email == '*****@*****.**'
    assert ea.domain == 'example.com'
    assert ea.blake2b160 == hash_map['*****@*****.**']

    # Case changes allowed, hash remains the same
    ea.email = '*****@*****.**'
    assert ea.email == '*****@*****.**'
    assert ea.domain == 'example.com'
    assert ea.blake2b160 == hash_map['*****@*****.**']

    # Setting it to existing value is allowed
    ea.email = '*****@*****.**'
    assert ea.email == '*****@*****.**'
    assert ea.domain == 'example.com'
    assert ea.blake2b160 == hash_map['*****@*****.**']

    # Nulling allowed, hash remains intact
    ea.email = None
    assert ea.email is None
    assert ea.domain is None
    assert ea.blake2b160 == hash_map['*****@*****.**']

    # Restoring allowed (case insensitive)
    ea.email = '*****@*****.**'
    assert ea.email == '*****@*****.**'
    assert ea.domain == 'example.com'
    assert ea.blake2b160 == hash_map['*****@*****.**']

    # But changing to another email address is not allowed
    with pytest.raises(ValueError):
        ea.email = '*****@*****.**'

    # Change is also not allowed by blanking and then setting to another
    ea.email = None
    with pytest.raises(ValueError):
        ea.email = '*****@*****.**'

    # Changing the domain is also not allowed
    with pytest.raises(AttributeError):
        ea.domain = 'gmail.com'

    # Setting to an invalid value is not allowed
    with pytest.raises(ValueError):
        ea.email = ''