Commit 8ea3e204 by David Ormsbee

Merge pull request #1231 from edx/ormsbee/verify_tests3

Model tests for verify_student flow.
parents 31980150 02fc6a40
......@@ -177,7 +177,7 @@ class PhotoVerification(StatusModel):
@classmethod
def user_is_verified(cls, user, earliest_allowed_date=None):
"""
Returns whether or not a user has satisfactorily proved their
Return whether or not a user has satisfactorily proved their
identity. Depending on the policy, this can expire after some period of
time, so a user might have to renew periodically.
"""
......@@ -194,7 +194,11 @@ class PhotoVerification(StatusModel):
@classmethod
def user_has_valid_or_pending(cls, user, earliest_allowed_date=None):
"""
TODO: eliminate duplication with user_is_verified
Return whether the user has a complete verification attempt that is or
*might* be good. This means that it's approved, been submitted, or would
have been submitted but had an non-user error when it was being
submitted. It's basically any situation in which the user has signed off
on the contents of the attempt, and we have not yet received a denial.
"""
valid_statuses = ['must_retry', 'submitted', 'approved']
earliest_allowed_date = (
......@@ -210,11 +214,8 @@ class PhotoVerification(StatusModel):
@classmethod
def active_for_user(cls, user):
"""
Return all PhotoVerifications that are still active (i.e. not
approved or denied).
Should there only be one active at any given time for a user? Enforced
at the DB level?
Return the most recent PhotoVerification that is marked ready (i.e. the
user has said they're set, but we haven't submitted anything yet).
"""
# This should only be one at the most, but just in case we create more
# by mistake, we'll grab the most recently created one.
......@@ -361,7 +362,9 @@ class PhotoVerification(StatusModel):
reviewing_service=""):
"""
Mark that this attempt could not be completed because of a system error.
Status should be moved to `must_retry`.
Status should be moved to `must_retry`. For example, if Software Secure
reported to us that they couldn't process our submission because they
couldn't decrypt the image we sent.
"""
if self.status in ["approved", "denied"]:
return # If we were already approved or denied, just leave it.
......@@ -480,6 +483,8 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
self.save()
except Exception as error:
log.exception(error)
self.status = "must_retry"
self.save()
def image_url(self, name):
"""
......@@ -549,7 +554,13 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return headers, body
def request_message_txt(self):
"""This is the body of the request we send across."""
"""
This is the body of the request we send across. This is never actually
used in the code, but exists for debugging purposes -- you can call
`print attempt.request_message_txt()` on the console and get a readable
rendering of the request that would be sent across, without actually
sending anything.
"""
headers, body = self.create_request()
header_txt = "\n".join(
......
# -*- coding: utf-8 -*-
from datetime import timedelta
import json
from nose.tools import (
assert_in, assert_is_none, assert_equals, assert_raises, assert_not_equals
assert_in, assert_is_none, assert_equals, assert_not_equals, assert_raises,
assert_true, assert_false
)
from mock import MagicMock, patch
from django.test import TestCase
from django.conf import settings
import requests
import requests.exceptions
from student.tests.factories import UserFactory
from verify_student.models import SoftwareSecurePhotoVerification, VerificationException
from util.testing import UrlResetMixin
import verify_student.models
FAKE_SETTINGS = {
"SOFTWARE_SECURE" : {
"FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"API_ACCESS_KEY" : "BBBBBBBBBBBBBBBBBBBB",
"API_SECRET_KEY" : "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
"RSA_PUBLIC_KEY" : """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/
rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o
7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa
tX8ErZ9D7ieOJ8/0hEiphHpCZh4TTgGuHgjon6vMV8THtq3AQMaAQ/y5R3V7Lezw
dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d
9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz
iwIDAQAB
-----END PUBLIC KEY-----""",
"API_URL" : "http://localhost/verify_student/fake_endpoint",
"AWS_ACCESS_KEY" : "FAKEACCESSKEY",
"AWS_SECRET_KEY" : "FAKESECRETKEY",
"S3_BUCKET" : "fake-bucket"
}
}
class MockKey(object):
"""
Mocking a boto S3 Key object. It's a really dumb mock because once we
write data to S3, we never read it again. We simply generate a link to it
and pass that to Software Secure. Because of that, we don't even implement
the ability to pull back previously written content in this mock.
Testing that the encryption/decryption roundtrip on the data works is in
test_ssencrypt.py
"""
def __init__(self, bucket):
self.bucket = bucket
def set_contents_from_string(self, contents):
self.contents = contents
def generate_url(self, duration):
return "http://fake-edx-s3.edx.org/"
class MockBucket(object):
"""Mocking a boto S3 Bucket object."""
def __init__(self, name):
self.name = name
class MockS3Connection(object):
"""Mocking a boto S3 Connection"""
def __init__(self, access_key, secret_key):
pass
def get_bucket(self, bucket_name):
return MockBucket(bucket_name)
def mock_software_secure_post(url, headers=None, data=None, **kwargs):
"""
Mocks our interface when we post to Software Secure. Does basic assertions
on the fields we send over to make sure we're not missing headers or giving
total garbage.
"""
data_dict = json.loads(data)
# Basic sanity checking on the keys
EXPECTED_KEYS = [
"EdX-ID", "ExpectedName", "PhotoID", "PhotoIDKey", "SendResponseTo",
"UserPhoto", "UserPhotoKey",
]
for key in EXPECTED_KEYS:
assert_true(
data_dict.get(key),
"'{}' must be present and not blank in JSON submitted to Software Secure".format(key)
)
# The keys should be stored as Base64 strings, i.e. this should not explode
photo_id_key = data_dict["PhotoIDKey"].decode("base64")
user_photo_key = data_dict["UserPhotoKey"].decode("base64")
response = requests.Response()
response.status_code = 200
return response
def mock_software_secure_post_error(url, headers=None, data=None, **kwargs):
"""
Simulates what happens if our post to Software Secure is rejected, for
whatever reason.
"""
response = requests.Response()
response.status_code = 400
return response
def mock_software_secure_post_unavailable(url, headers=None, data=None, **kwargs):
"""Simulates a connection failure when we try to submit to Software Secure."""
raise requests.exceptions.ConnectionError
# Lots of patching to stub in our own settings, S3 substitutes, and HTTP posting
@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
@patch('verify_student.models.S3Connection', new=MockS3Connection)
@patch('verify_student.models.Key', new=MockKey)
@patch('verify_student.models.requests.post', new=mock_software_secure_post)
class TestPhotoVerification(TestCase):
def test_state_transitions(self):
"""Make sure we can't make unexpected status transitions.
"""
Make sure we can't make unexpected status transitions.
The status transitions we expect are::
→ → → must_retry
↑ ↑ ↓
created → ready → submitted → approved
↑ ↓
→ denied
↑ ↓
↓ → → denied
"""
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification(user=user)
assert_equals(attempt.status, SoftwareSecurePhotoVerification.STATUS.created)
assert_equals(attempt.status, "created")
# These should all fail because we're in the wrong starting state.
......@@ -29,29 +141,169 @@ class TestPhotoVerification(TestCase):
assert_raises(VerificationException, attempt.deny)
# Now let's fill in some values so that we can pass the mark_ready() call
attempt.face_image_url = "http://fake.edx.org/face.jpg"
attempt.photo_id_image_url = "http://fake.edx.org/photo_id.jpg"
attempt.mark_ready()
assert_equals(attempt.name, user.profile.name) # Move this to another test
assert_equals(attempt.status, "ready")
# Once again, state transitions should fail here. We can't approve or
# deny anything until it's been placed into the submitted state -- i.e.
# the user has clicked on whatever agreements, or given payment, or done
# whatever the application requires before it agrees to process their
# attempt.
# ready (can't approve or deny unless it's "submitted")
assert_raises(VerificationException, attempt.approve)
assert_raises(VerificationException, attempt.deny)
# Now we submit
#attempt.submit()
#assert_equals(attempt.status, "submitted")
DENY_ERROR_MSG = '[{"photoIdReasons": ["Not provided"]}]'
# must_retry
attempt.status = "must_retry"
attempt.system_error("System error")
attempt.approve()
attempt.status = "must_retry"
attempt.deny(DENY_ERROR_MSG)
# submitted
attempt.status = "submitted"
attempt.deny(DENY_ERROR_MSG)
attempt.status = "submitted"
attempt.approve()
# approved
assert_raises(VerificationException, attempt.submit)
attempt.approve() # no-op
attempt.system_error("System error") # no-op, something processed it without error
attempt.deny(DENY_ERROR_MSG)
# denied
assert_raises(VerificationException, attempt.submit)
attempt.deny(DENY_ERROR_MSG) # no-op
attempt.system_error("System error") # no-op, something processed it without error
attempt.approve()
def test_name_freezing(self):
"""
You can change your name prior to marking a verification attempt ready,
but changing your name afterwards should not affect the value in the
in the attempt record. Basically, we want to always know what your name
was when you submitted it.
"""
user = UserFactory.create()
user.profile.name = u"Jack \u01B4" # gratuious non-ASCII char to test encodings
attempt = SoftwareSecurePhotoVerification(user=user)
user.profile.name = u"Clyde \u01B4"
attempt.mark_ready()
# So we should be able to both approve and deny
#attempt.approve()
#assert_equals(attempt.status, "approved")
user.profile.name = u"Rusty \u01B4"
assert_equals(u"Clyde \u01B4", attempt.name)
def create_and_submit(self):
"""Helper method to create a generic submission and send it."""
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification(user=user)
user.profile.name = u"Rust\u01B4"
attempt.upload_face_image("Just pretend this is image data")
attempt.upload_photo_id_image("Hey, we're a photo ID")
attempt.mark_ready()
attempt.submit()
return attempt
def test_submissions(self):
"""Test that we set our status correctly after a submission."""
# Basic case, things go well.
attempt = self.create_and_submit()
assert_equals(attempt.status, "submitted")
# We post, but Software Secure doesn't like what we send for some reason
with patch('verify_student.models.requests.post', new=mock_software_secure_post_error):
attempt = self.create_and_submit()
assert_equals(attempt.status, "must_retry")
# We try to post, but run into an error (in this case a newtork connection error)
with patch('verify_student.models.requests.post', new=mock_software_secure_post_unavailable):
attempt = self.create_and_submit()
assert_equals(attempt.status, "must_retry")
def test_active_for_user(self):
"""
Make sure we can retrive a user's active (in progress) verification
attempt.
"""
user = UserFactory.create()
# This user has no active at the moment...
assert_is_none(SoftwareSecurePhotoVerification.active_for_user(user))
# Create an attempt and mark it ready...
attempt = SoftwareSecurePhotoVerification(user=user)
attempt.mark_ready()
assert_equals(attempt, SoftwareSecurePhotoVerification.active_for_user(user))
# A new user won't see this...
user2 = UserFactory.create()
user2.save()
assert_is_none(SoftwareSecurePhotoVerification.active_for_user(user2))
# If it's got a different status, it doesn't count
for status in ["submitted", "must_retry", "approved", "denied"]:
attempt.status = status
attempt.save()
assert_is_none(SoftwareSecurePhotoVerification.active_for_user(user))
# But if we create yet another one and mark it ready, it passes again.
attempt_2 = SoftwareSecurePhotoVerification(user=user)
attempt_2.mark_ready()
assert_equals(attempt_2, SoftwareSecurePhotoVerification.active_for_user(user))
# And if we add yet another one with a later created time, we get that
# one instead. We always want the most recent attempt marked ready()
attempt_3 = SoftwareSecurePhotoVerification(
user=user,
created_at=attempt_2.created_at + timedelta(days=1)
)
attempt_3.save()
# We haven't marked attempt_3 ready yet, so attempt_2 still wins
assert_equals(attempt_2, SoftwareSecurePhotoVerification.active_for_user(user))
# Now we mark attempt_3 ready and expect it to come back
attempt_3.mark_ready()
assert_equals(attempt_3, SoftwareSecurePhotoVerification.active_for_user(user))
def test_user_is_verified(self):
"""
Test to make sure we correctly answer whether a user has been verified.
"""
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification(user=user)
attempt.save()
# If it's any of these, they're not verified...
for status in ["created", "ready", "denied", "submitted", "must_retry"]:
attempt.status = status
attempt.save()
assert_false(SoftwareSecurePhotoVerification.user_is_verified(user), status)
attempt.status = "approved"
attempt.save()
assert_true(SoftwareSecurePhotoVerification.user_is_verified(user), status)
def test_user_has_valid_or_pending(self):
"""
Determine whether we have to prompt this user to verify, or if they've
already at least initiated a verification submission.
"""
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification(user=user)
#attempt.deny("Could not read name on Photo ID")
#assert_equals(attempt.status, "denied")
# If it's any of these statuses, they don't have anything outstanding
for status in ["created", "ready", "denied"]:
attempt.status = status
attempt.save()
assert_false(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status)
# Any of these, and we are. Note the benefit of the doubt we're giving
# -- must_retry, and submitted both count until we hear otherwise
for status in ["submitted", "must_retry", "approved"]:
attempt.status = status
attempt.save()
assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment