* make `user_status` more intelligent * remove some logic from the templates * rename `parse_error_msg` to `parsed_error_msg` * fix up and add more tests LMS-1387
365 lines
14 KiB
Python
365 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
from datetime import timedelta
|
|
import json
|
|
from nose.tools import (
|
|
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.
|
|
|
|
The status transitions we expect are::
|
|
|
|
→ → → must_retry
|
|
↑ ↑ ↓
|
|
created → ready → submitted → approved
|
|
↓ ↑ ↓
|
|
↓ → → denied
|
|
"""
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
assert_equals(attempt.status, "created")
|
|
|
|
# These should all fail because we're in the wrong starting state.
|
|
assert_raises(VerificationException, attempt.submit)
|
|
assert_raises(VerificationException, attempt.approve)
|
|
assert_raises(VerificationException, attempt.deny)
|
|
|
|
# Now let's fill in some values so that we can pass the mark_ready() call
|
|
attempt.mark_ready()
|
|
assert_equals(attempt.status, "ready")
|
|
|
|
# ready (can't approve or deny unless it's "submitted")
|
|
assert_raises(VerificationException, attempt.approve)
|
|
assert_raises(VerificationException, attempt.deny)
|
|
|
|
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()
|
|
|
|
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)
|
|
|
|
# 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)
|
|
|
|
def test_user_status(self):
|
|
# test for correct status when no error returned
|
|
user = UserFactory.create()
|
|
status = SoftwareSecurePhotoVerification.user_status(user)
|
|
self.assertEquals(status, ('none', ''))
|
|
|
|
# test for when one has been created
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = 'approved'
|
|
attempt.save()
|
|
|
|
status = SoftwareSecurePhotoVerification.user_status(user)
|
|
self.assertEquals(status, ('approved', ''))
|
|
|
|
# create another one for the same user, make sure the right one is
|
|
# returned
|
|
attempt2 = SoftwareSecurePhotoVerification(user=user)
|
|
attempt2.status = 'denied'
|
|
attempt2.error_msg = '[{"photoIdReasons": ["Not provided"]}]'
|
|
attempt2.save()
|
|
|
|
status = SoftwareSecurePhotoVerification.user_status(user)
|
|
self.assertEquals(status, ('approved', ''))
|
|
|
|
# now delete the first one and verify that the denial is being handled
|
|
# properly
|
|
attempt.delete()
|
|
status = SoftwareSecurePhotoVerification.user_status(user)
|
|
self.assertEquals(status, ('must_reverify', "No photo ID was provided."))
|
|
|
|
def test_parse_error_msg_success(self):
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = 'denied'
|
|
attempt.error_msg = '[{"photoIdReasons": ["Not provided"]}]'
|
|
parsed_error_msg = attempt.parsed_error_msg()
|
|
self.assertEquals("No photo ID was provided.", parsed_error_msg)
|
|
|
|
def test_parse_error_msg_failure(self):
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = 'denied'
|
|
# when we can't parse into json
|
|
bad_messages = {
|
|
'Not Provided',
|
|
'[{"IdReasons": ["Not provided"]}]',
|
|
'{"IdReasons": ["Not provided"]}',
|
|
u'[{"ïḋṚëäṡöṅṡ": ["Ⓝⓞⓣ ⓟⓡⓞⓥⓘⓓⓔⓓ "]}]',
|
|
}
|
|
for msg in bad_messages:
|
|
attempt.error_msg = msg
|
|
parsed_error_msg = attempt.parsed_error_msg()
|
|
self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.")
|