439 lines
18 KiB
Python
439 lines
18 KiB
Python
# lint-amnesty, pylint: disable=missing-module-docstring
|
|
|
|
import base64
|
|
from datetime import datetime, timedelta
|
|
from unittest import mock
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import ddt
|
|
import requests.exceptions
|
|
import simplejson as json
|
|
from django.conf import settings
|
|
from django.utils.timezone import now
|
|
from freezegun import freeze_time
|
|
|
|
from common.djangoapps.student.tests.factories import UserFactory
|
|
from common.test.utils import MockS3BotoMixin
|
|
from lms.djangoapps.verify_student.models import (
|
|
ManualVerification,
|
|
PhotoVerification,
|
|
SoftwareSecurePhotoVerification,
|
|
SSOVerification,
|
|
VerificationException
|
|
)
|
|
from lms.djangoapps.verify_student.tests import TestVerificationBase
|
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
|
|
|
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",
|
|
"CERT_VERIFICATION_PATH": False,
|
|
},
|
|
"DAYS_GOOD_FOR": 10,
|
|
}
|
|
|
|
|
|
def mock_software_secure_post(url, headers=None, data=None, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
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 data_dict.get(key)
|
|
|
|
# The keys should be stored as Base64 strings, i.e. this should not explode
|
|
data_dict["PhotoIDKey"] = base64.b64decode(data_dict["PhotoIDKey"])
|
|
data_dict["UserPhotoKey"] = base64.b64decode(data_dict["UserPhotoKey"])
|
|
|
|
response = requests.Response()
|
|
response.status_code = 200
|
|
|
|
return response
|
|
|
|
|
|
def mock_software_secure_post_error(url, headers=None, data=None, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
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, and HTTP posting
|
|
@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
|
|
@patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post)
|
|
@ddt.ddt
|
|
class TestPhotoVerification(TestVerificationBase, MockS3BotoMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
|
|
|
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 attempt.status == PhotoVerification.STATUS.created
|
|
|
|
# These should all fail because we're in the wrong starting state.
|
|
pytest.raises(VerificationException, attempt.submit)
|
|
pytest.raises(VerificationException, attempt.approve)
|
|
pytest.raises(VerificationException, attempt.deny)
|
|
pytest.raises(VerificationException, attempt.mark_must_retry)
|
|
pytest.raises(VerificationException, attempt.mark_submit)
|
|
|
|
# Now let's fill in some values so that we can pass the mark_ready() call
|
|
attempt.mark_ready()
|
|
assert attempt.status == PhotoVerification.STATUS.ready
|
|
|
|
# ready (can't approve or deny unless it's "submitted")
|
|
pytest.raises(VerificationException, attempt.approve)
|
|
pytest.raises(VerificationException, attempt.deny)
|
|
attempt.mark_must_retry()
|
|
attempt.mark_submit()
|
|
|
|
DENY_ERROR_MSG = '[{"photoIdReasons": ["Not provided"]}]'
|
|
|
|
# must_retry
|
|
attempt.status = PhotoVerification.STATUS.must_retry
|
|
attempt.system_error("System error")
|
|
attempt.mark_must_retry() # no-op
|
|
attempt.mark_submit()
|
|
attempt.approve()
|
|
|
|
attempt.status = PhotoVerification.STATUS.must_retry
|
|
attempt.deny(DENY_ERROR_MSG)
|
|
|
|
# submitted
|
|
attempt.status = PhotoVerification.STATUS.submitted
|
|
attempt.deny(DENY_ERROR_MSG)
|
|
|
|
attempt.status = PhotoVerification.STATUS.submitted
|
|
attempt.mark_must_retry()
|
|
|
|
attempt.status = PhotoVerification.STATUS.submitted
|
|
attempt.approve()
|
|
|
|
# approved
|
|
pytest.raises(VerificationException, attempt.submit)
|
|
pytest.raises(VerificationException, attempt.mark_must_retry)
|
|
pytest.raises(VerificationException, attempt.mark_submit)
|
|
attempt.approve() # no-op
|
|
attempt.system_error("System error") # no-op, something processed it without error
|
|
attempt.deny(DENY_ERROR_MSG)
|
|
|
|
# denied
|
|
pytest.raises(VerificationException, attempt.submit)
|
|
pytest.raises(VerificationException, attempt.mark_must_retry)
|
|
pytest.raises(VerificationException, attempt.mark_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 = "Jack \u01B4" # gratuious non-ASCII char to test encodings
|
|
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
user.profile.name = "Clyde \u01B4"
|
|
attempt.mark_ready()
|
|
|
|
user.profile.name = "Rusty \u01B4"
|
|
|
|
assert 'Clyde ƴ' == attempt.name
|
|
|
|
def test_submissions(self):
|
|
"""Test that we set our status correctly after a submission."""
|
|
# Basic case, things go well.
|
|
attempt = self.create_upload_and_submit_attempt_for_user()
|
|
assert attempt.status == PhotoVerification.STATUS.submitted
|
|
|
|
# We post, but Software Secure doesn't like what we send for some reason
|
|
with patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_error):
|
|
attempt = self.create_upload_and_submit_attempt_for_user()
|
|
assert attempt.status == PhotoVerification.STATUS.must_retry
|
|
|
|
# We try to post, but run into an error (in this case a network connection error)
|
|
with patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_unavailable):
|
|
attempt = self.create_upload_and_submit_attempt_for_user()
|
|
assert attempt.status == PhotoVerification.STATUS.must_retry
|
|
|
|
@mock.patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
|
|
def test_submission_while_testing_flag_is_true(self):
|
|
""" Test that a fake value is set for field 'photo_id_key' of user's
|
|
initial verification when the feature flag 'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'
|
|
is enabled.
|
|
"""
|
|
attempt = self.create_upload_and_submit_attempt_for_user()
|
|
assert attempt.photo_id_key == 'fake-photo-id-key'
|
|
|
|
# pylint: disable=line-too-long
|
|
def test_parse_error_msg_success(self):
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = PhotoVerification.STATUS.denied
|
|
attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}, {"photoIdReasons": ["Photo hidden/No photo", "ID name not provided"]}]'
|
|
parsed_error_msg = attempt.parsed_error_msg()
|
|
assert sorted(parsed_error_msg) == sorted(['id_image_missing_name', 'user_image_not_clear', 'id_image_not_clear'])
|
|
|
|
@ddt.data(
|
|
'Not Provided',
|
|
'{"IdReasons": ["Not provided"]}',
|
|
'[{"ïḋṚëäṡöṅṡ": ["Ⓝⓞⓣ ⓟⓡⓞⓥⓘⓓⓔⓓ "]}]',
|
|
)
|
|
def test_parse_error_msg_failure(self, msg):
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification.objects.create(user=user, status='denied', error_msg=msg)
|
|
assert attempt.parsed_error_msg() == []
|
|
|
|
def test_active_at_datetime(self):
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification.objects.create(user=user)
|
|
self.verification_active_at_datetime(attempt)
|
|
|
|
def test_initial_verification_for_user(self):
|
|
"""Test that method 'get_initial_verification' of model
|
|
'SoftwareSecurePhotoVerification' always returns the initial
|
|
verification with field 'photo_id_key' set against a user.
|
|
"""
|
|
user = UserFactory.create()
|
|
|
|
# No initial verification for the user
|
|
result = SoftwareSecurePhotoVerification.get_initial_verification(user=user)
|
|
assert result is None
|
|
|
|
# Make an initial verification with 'photo_id_key'
|
|
attempt = SoftwareSecurePhotoVerification(user=user, photo_id_key="dummy_photo_id_key")
|
|
attempt.status = PhotoVerification.STATUS.approved
|
|
attempt.save()
|
|
|
|
# Check that method 'get_initial_verification' returns the correct
|
|
# initial verification attempt
|
|
first_result = SoftwareSecurePhotoVerification.get_initial_verification(user=user)
|
|
assert first_result is not None
|
|
|
|
# Now create a second verification without 'photo_id_key'
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = PhotoVerification.STATUS.submitted
|
|
attempt.save()
|
|
|
|
# Test method 'get_initial_verification' still returns the correct
|
|
# initial verification attempt which have 'photo_id_key' set
|
|
second_result = SoftwareSecurePhotoVerification.get_initial_verification(user=user)
|
|
assert second_result is not None
|
|
assert second_result == first_result
|
|
|
|
# Test method 'get_initial_verification' returns None after expiration
|
|
expired_future = now() + timedelta(days=(FAKE_SETTINGS['DAYS_GOOD_FOR'] + 1))
|
|
with freeze_time(expired_future):
|
|
third_result = SoftwareSecurePhotoVerification.get_initial_verification(user)
|
|
assert third_result is None
|
|
|
|
# Test method 'get_initial_verification' returns correct attempt after system expiration,
|
|
# but within earliest allowed override.
|
|
expired_future = now() + timedelta(days=(FAKE_SETTINGS['DAYS_GOOD_FOR'] + 1))
|
|
earliest_allowed = now() - timedelta(days=1)
|
|
with freeze_time(expired_future):
|
|
fourth_result = SoftwareSecurePhotoVerification.get_initial_verification(user, earliest_allowed)
|
|
assert fourth_result is not None
|
|
assert fourth_result == first_result
|
|
|
|
def test_retire_user(self):
|
|
"""
|
|
Retire user with record(s) in table
|
|
"""
|
|
user = UserFactory.create()
|
|
user.profile.name = "Enrique"
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
|
|
# Populate Record
|
|
attempt.mark_ready()
|
|
attempt.status = PhotoVerification.STATUS.submitted
|
|
attempt.photo_id_image_url = "https://example.com/test/image/img.jpg"
|
|
attempt.face_image_url = "https://example.com/test/face/img.jpg"
|
|
attempt.photo_id_key = 'there_was_an_attempt'
|
|
attempt.approve()
|
|
|
|
# Validate data before retirement
|
|
assert attempt.name == user.profile.name
|
|
assert attempt.photo_id_image_url == 'https://example.com/test/image/img.jpg'
|
|
assert attempt.face_image_url == 'https://example.com/test/face/img.jpg'
|
|
assert attempt.photo_id_key == 'there_was_an_attempt'
|
|
|
|
# Retire User
|
|
attempt_again = SoftwareSecurePhotoVerification(user=user)
|
|
assert attempt_again.retire_user(user_id=user.id)
|
|
|
|
# Validate data after retirement
|
|
assert attempt_again.name == ''
|
|
assert attempt_again.face_image_url == ''
|
|
assert attempt_again.photo_id_image_url == ''
|
|
assert attempt_again.photo_id_key == ''
|
|
|
|
def test_retire_nonuser(self):
|
|
"""
|
|
Attempt to Retire User with no records in table
|
|
"""
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
|
|
# User with no records in table
|
|
assert not attempt.retire_user(user_id=user.id)
|
|
|
|
# No user
|
|
assert not attempt.retire_user(user_id=47)
|
|
|
|
def test_get_recent_verification(self):
|
|
"""Test that method 'get_recent_verification' of model
|
|
'SoftwareSecurePhotoVerification' always returns the most
|
|
recent 'approved' verification based on updated_at set
|
|
against a user.
|
|
"""
|
|
user = UserFactory.create()
|
|
attempt = None
|
|
|
|
for _ in range(2):
|
|
# Make an approved verification
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = PhotoVerification.STATUS.approved
|
|
attempt.expiration_date = datetime.now()
|
|
attempt.save()
|
|
|
|
# Test method 'get_recent_verification' returns the most recent
|
|
# verification attempt based on updated_at
|
|
recent_verification = SoftwareSecurePhotoVerification.get_recent_verification(user=user)
|
|
assert recent_verification is not None
|
|
assert recent_verification.id == attempt.id
|
|
|
|
def test_no_approved_verification(self):
|
|
"""Test that method 'get_recent_verification' of model
|
|
'SoftwareSecurePhotoVerification' returns None if no
|
|
'approved' verification are found
|
|
"""
|
|
user = UserFactory.create()
|
|
SoftwareSecurePhotoVerification(user=user)
|
|
|
|
result = SoftwareSecurePhotoVerification.get_recent_verification(user=user)
|
|
assert result is None
|
|
|
|
def test_update_expiry_email_date_for_user(self):
|
|
"""Test that method update_expiry_email_date_for_user of
|
|
model 'SoftwareSecurePhotoVerification' set expiry_email_date
|
|
if the most recent approved verification is expired.
|
|
"""
|
|
email_config = getattr(settings, 'VERIFICATION_EXPIRY_EMAIL', {'DAYS_RANGE': 1, 'RESEND_DAYS': 15})
|
|
user = UserFactory.create()
|
|
verification = SoftwareSecurePhotoVerification(user=user)
|
|
verification.expiration_date = now() - timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR'])
|
|
verification.status = PhotoVerification.STATUS.approved
|
|
verification.save()
|
|
|
|
assert verification.expiry_email_date is None
|
|
|
|
SoftwareSecurePhotoVerification.update_expiry_email_date_for_user(user, email_config)
|
|
result = SoftwareSecurePhotoVerification.get_recent_verification(user=user)
|
|
|
|
assert result.expiry_email_date is not None
|
|
|
|
def test_expiration_date_null(self):
|
|
"""
|
|
Test if the `expiration_date` field is null, `expiration_datetime` returns a
|
|
default expiration date based on the time the entry was created.
|
|
"""
|
|
user = UserFactory.create()
|
|
verification = SoftwareSecurePhotoVerification(user=user)
|
|
verification.expiration_date = None
|
|
verification.save()
|
|
|
|
assert verification.expiration_datetime == (verification.created_at + timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR']))
|
|
|
|
def test_deprecated_expiry_date(self):
|
|
"""
|
|
Test `expiration_datetime` returns `expiry_date` if it is not null.
|
|
"""
|
|
user = UserFactory.create()
|
|
with freeze_time(now()):
|
|
verification = SoftwareSecurePhotoVerification(user=user)
|
|
# First, assert that expiration_date is set correctly
|
|
assert verification.expiration_datetime == (now() + timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR']))
|
|
verification.expiry_date = now() + timedelta(days=10)
|
|
# Then, assert that expiration_datetime favors expiry_date's value if set
|
|
assert verification.expiration_datetime == (now() + timedelta(days=10))
|
|
|
|
def test_get_verification_from_receipt(self):
|
|
result = SoftwareSecurePhotoVerification.get_verification_from_receipt('')
|
|
assert result is None
|
|
|
|
user = UserFactory.create()
|
|
attempt = SoftwareSecurePhotoVerification(user=user)
|
|
attempt.status = PhotoVerification.STATUS.submitted
|
|
attempt.save()
|
|
receipt_id = attempt.receipt_id
|
|
result = SoftwareSecurePhotoVerification.get_verification_from_receipt(receipt_id)
|
|
assert result is not None
|
|
|
|
|
|
class SSOVerificationTest(TestVerificationBase):
|
|
"""
|
|
Tests for the SSOVerification model
|
|
"""
|
|
|
|
def test_active_at_datetime(self):
|
|
user = UserFactory.create()
|
|
attempt = SSOVerification.objects.create(user=user)
|
|
self.verification_active_at_datetime(attempt)
|
|
|
|
|
|
class ManualVerificationTest(TestVerificationBase):
|
|
"""
|
|
Tests for the ManualVerification model
|
|
"""
|
|
|
|
def test_active_at_datetime(self):
|
|
user = UserFactory.create()
|
|
verification = ManualVerification.objects.create(user=user)
|
|
self.verification_active_at_datetime(verification)
|