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}
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)
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')
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()
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')
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('*****@*****.**')"
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('*****@*****.**')
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
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
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())
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'
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 == '*****@*****.**'
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()
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
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
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 = ''