Projet

Général

Profil

0002-update-and-cache-metadata-from-URL-and-path-10196.patch

Benjamin Dauvergne, 07 juin 2019 21:49

Télécharger (33,5 ko)

Voir les différences:

Subject: [PATCH 2/2] update and cache metadata from URL and path (#10196)

 README                        |  34 ++++-
 debian/control                |   6 +-
 mellon/adapters.py            | 245 +++++++++++++++++++++++++++-------
 mellon/app_settings.py        |   2 +
 mellon/utils.py               |  16 ++-
 mellon/views.py               |   6 +-
 setup.py                      |   1 +
 tests/conftest.py             |  26 +++-
 tests/test_default_adapter.py |  96 ++++++++++++-
 tests/test_utils.py           | 116 +---------------
 tox.ini                       |   4 +-
 11 files changed, 366 insertions(+), 186 deletions(-)
README
76 76
MELLON_IDENTITY_PROVIDERS
77 77
-------------------------
78 78

  
79
A list of dictionaries, only one key is mandatory in those
80
dictionaries `METADATA` it should contain the UTF-8 content of the
81
metadata file of the identity provider or if it starts with a slash
82
the absolute path toward a metadata file. All other keys are override
83
of generic settings.
79
A list of dictionaries, they must contain at least one of the keys `METADATA`
80
(inline copy of the identity provider metadata), `METADATA_URL` URL of the IdP
81
metadata file, or `METADATA_PATH` an absolute path to the IdP metadata file..
82
All other keys are override of generic settings.
83

  
84
When using an URL, the URL is automatically cached in the `MEDIA_ROOT`
85
directory of your application in the directory named `mellon_metadata_cache`.
86
If you restart the application and the URL is unavailable, the file cache will
87
be used. The cache will be refreshed every `MELLON_METADATA_CACHE_TIME` seconds.
88
If the HTTP retrieval of the metadata URL takes longer thant
89
`METTON_METADATA_HTTP_TIMEOUT` seconds, retrieval will be skipped.
90

  
91
When the cache is already loaded, retrievals are done in the background by a
92
thread.
93

  
94
When using a local absolute path, the metadata is reloaded each time the
95
modification time of the file is superior to the last time it was loaded.
84 96

  
85 97
MELLON_PUBLIC_KEYS
86 98
------------------
......
261 273
Should be post or artifact. Default is post. You can refer to the SAML 2.0
262 274
specification to learn the difference.
263 275

  
276
MELLON_METADATA_CACHE_TIME
277
--------------------------
278

  
279
When using METADATA_URL to reference a metadata file, it's the duration in
280
secondes between refresh of the metadata file. Default is 3600 seconds, 1 hour.
281

  
282
METTON_METADATA_HTTP_TIMEOUT
283
---------------------------
284

  
285
Timeout in seconds for HTTP call made to retrieve metadata files. Default is 10
286
seconds.
287

  
264 288
Tests
265 289
=====
266 290

  
debian/control
15 15
    python (>= 2.7),
16 16
    python-django (>= 1.5),
17 17
    python-isodate,
18
    python-lasso
18
    python-lasso,
19
    python-atomicwrites
19 20
Breaks: python-hobo (<< 0.34.5)
20 21
Description: SAML authentication for Django
21 22

  
......
24 25
Depends: ${misc:Depends}, ${python:Depends},
25 26
    python3-django (>= 1.5),
26 27
    python3-isodate,
27
    python3-lasso
28
    python3-lasso,
29
    python3-atomicwrites
28 30
Description: SAML authentication for Django
mellon/adapters.py
13 13
# You should have received a copy of the GNU Affero General Public License
14 14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 15

  
16
from xml.etree import ElementTree as ET
17
import hashlib
16 18
import logging
19
import os
20
import threading
21
import time
17 22
import uuid
18
from xml.etree import ElementTree as ET
19 23

  
20 24
import lasso
21 25
import requests
22 26
import requests.exceptions
27
from atomicwrites import atomic_write
23 28

  
24 29
from django.core.exceptions import PermissionDenied
30
from django.core.files.storage import default_storage
25 31
from django.contrib import auth
26 32
from django.contrib.auth.models import Group
27 33
from django.utils import six
28 34
from django.utils.encoding import force_text
35
from django.utils.six.moves.urllib.parse import urlparse
29 36

  
30 37
from . import utils, app_settings, models
31 38

  
39
logger = logging.getLogger(__name__)
40

  
32 41

  
33 42
class UserCreationError(Exception):
34 43
    pass
35 44

  
36 45

  
37 46
class DefaultAdapter(object):
38
    def __init__(self, *args, **kwargs):
39
        self.logger = logging.getLogger(__name__)
40

  
41 47
    def get_idp(self, entity_id):
42 48
        '''Find the first IdP definition matching entity_id'''
43 49
        for idp in self.get_idps():
......
49 55

  
50 56
    def get_idps(self):
51 57
        for i, idp in enumerate(self.get_identity_providers_setting()):
52
            if 'METADATA_URL' in idp and 'METADATA' not in idp:
58
            if self.load_idp(idp, i):
59
                yield idp
60

  
61
    def load_metadata_path(self, idp, i):
62
        path = idp['METADATA_PATH']
63
        if not os.path.exists(path):
64
            logger.warning('metadata path %s does not exist', path)
65
            return
66
        last_update = idp.get('METADATA_PATH_LAST_UPDATE', 0)
67
        try:
68
            mtime = os.stat(path).st_mtime
69
        except OSError as e:
70
            logger.warning('metadata path %s : stat() call failed, %s', path, e)
71
            return
72
        if last_update == 0 or mtime >= last_update:
73
            idp['METADATA_PATH_LAST_UPDATE'] = time.time()
74
            try:
75
                with open(path) as fd:
76
                    metadata = fd.read()
77
            except OSError as e:
78
                logger.warning('metadata path %s : open()/read() call failed, %s', path, e)
79
                return
80
            entity_id = self.load_entity_id(metadata, i)
81
            if not entity_id:
82
                logger.error('invalid metadata file retrieved from %s', path)
83
                return
84
            if 'ENTITY_ID' in idp and idp['ENTITY_ID'] != entity_id:
85
                logger.error('metadata path %s : entityID changed %r != %r', path, entity_id, idp['ENTITY_ID'])
86
                del idp['ENTITY_ID']
87
            idp['METADATA'] = metadata
88

  
89
    def load_metadata_url(self, idp, i):
90
        url = idp['METADATA_URL']
91
        metadata_cache_time = utils.get_setting(idp, 'METADATA_CACHE_TIME')
92
        timeout = utils.get_setting(idp, 'METADATA_HTTP_TIMEOUT')
93

  
94
        warning = logger.warning
95
        if 'METADATA' not in idp:
96
            # if we have no metadata in cache, we must emit errors
97
            warning = logger.error
98

  
99
        try:
100
            hostname = urlparse(url).hostname
101
        except (ValueError, TypeError) as e:
102
            warning('invalid METADATA_URL %r: %s', url, e)
103
            return
104
        if not hostname:
105
            warning('no hostname in METADATA_URL %r: %s', url)
106
            return
107

  
108
        last_update = idp.get('METADATA_URL_LAST_UPDATE', 0)
109
        now = time.time()
110

  
111
        try:
112
            url_fingerprint = hashlib.md5(url.encode('ascii')).hexdigest()
113
            file_cache_key = '%s_%s.xml' % (hostname, url_fingerprint)
114
        except (UnicodeError, TypeError, ValueError):
115
            warning('unable to compute file_cache_key')
116
            return
117

  
118
        cache_directory = default_storage.path('mellon_metadata_cache')
119
        file_cache_path = os.path.join(cache_directory, file_cache_key)
120

  
121
        if metadata_cache_time:
122
            # METADATA_CACHE_TIME == 0 disable the file cache
123
            if not os.path.exists(cache_directory):
124
                os.makedirs(cache_directory)
125

  
126
            if os.path.exists(file_cache_path) and 'METADATA' not in idp:
127
                try:
128
                    with open(file_cache_path) as fd:
129
                        idp['METADATA'] = fd.read()
130
                    # use file cache mtime as last_update time, prevent too many loading from different workers
131
                    last_update = max(last_update, os.stat(file_cache_path).st_mtime)
132
                except OSError:
133
                    warning('metadata url %s : error when loading the file cache %s', url, file_cache_path)
134

  
135
        # fresh cache, skip loading
136
        if last_update and 'METADATA' in idp and (now - last_update) < metadata_cache_time:
137
            return
138

  
139
        def __http_get():
140
            try:
53 141
                verify_ssl_certificate = utils.get_setting(
54 142
                    idp, 'VERIFY_SSL_CERTIFICATE')
55 143
                try:
56
                    response = requests.get(idp['METADATA_URL'], verify=verify_ssl_certificate)
144
                    response = requests.get(url, verify=verify_ssl_certificate, timeout=timeout)
57 145
                    response.raise_for_status()
58 146
                except requests.exceptions.RequestException as e:
59
                    self.logger.error(
60
                        u'retrieval of metadata URL %r failed with error %s for %d-th idp',
61
                        idp['METADATA_URL'], e, i)
62
                    continue
147
                    warning('metadata url %s : HTTP request failed %s', url, e)
148
                    return
149

  
150
                entity_id = self.load_entity_id(response.text, i)
151
                if not entity_id:
152
                    warning('invalid metadata file retrieved from %s', url)
153
                    return
154

  
155
                if 'ENTITY_ID' in idp and idp['ENTITY_ID'] != entity_id:
156
                    # entityID change is always en error
157
                    logger.error('metadata url %s : entityID changed %r != %r', url, entity_id, idp['ENTITY_ID'])
158
                    del idp['ENTITY_ID']
159

  
63 160
                idp['METADATA'] = response.text
64
            elif 'METADATA' in idp:
65
                if idp['METADATA'].startswith('/'):
66
                    idp['METADATA'] = open(idp['METADATA']).read()
67
            else:
68
                self.logger.error(u'missing METADATA or METADATA_URL in %d-th idp', i)
69
                continue
161
                idp['METADATA_URL_LAST_UPDATE'] = now
162
                if metadata_cache_time:
163
                    try:
164
                        with atomic_write(file_cache_path, mode='wb', overwrite=True) as fd:
165
                            fd.write(response.text.encode('utf-8'))
166
                    except OSError as e:
167
                        logger.error('metadata url %s : could not write file cache %s, %s', url, file_cache_path, e)
168
                idp['METADATA_PATH'] = file_cache_path
169
                # prevent reloading of the file cache immediately
170
                idp['METADATA_PATH_LAST_UPDATE'] = time.time() + 1
171
                logger.debug('metadata url %s : update throught HTTP', url)
172
            finally:
173
                # release thread object
174
                idp.pop('METADATA_URL_UPDATE_THREAD', None)
175
                # emit an error if cache is too old
176
                stale_timeout = 24 * metadata_cache_time
177
                if last_update and (now - idp['METADATA_URL_LAST_UPDATE']) > stale_timeout:
178
                    logger.error('metadata url %s : not updated since %.1f hours',
179
                                 stale_timeout / 3600.0)
180

  
181
        # we have cache, update in background
182
        if last_update and 'METADATA' in idp:
183
            t = threading.Thread(target=__http_get)
184
            t.start()
185
            # store thread in idp for tests
186
            idp['METADATA_URL_UPDATE_THREAD'] = t
187
            # suspend updates for HTTP timeout + 5 seconds
188
            idp['METADATA_URL_LAST_UPDATE'] = last_update + timeout + 5
189
        else:
190
            # synchronous update
191
            __http_get()
192

  
193
    def load_metadata(self, idp, i):
194
        # legacy support
195
        if 'METADATA' in idp and idp['METADATA'].startswith('/'):
196
            idp['METADATA_PATH'] = idp['METADATA']
197
            del idp['METADATA']
198

  
199
        if 'METADATA_PATH' in idp:
200
            self.load_metadata_path(idp, i)
201

  
202
        if 'METADATA_URL' in idp:
203
            self.load_metadata_url(idp, i)
204

  
205
        if 'METADATA' in idp:
70 206
            if 'ENTITY_ID' not in idp:
71
                try:
72
                    doc = ET.fromstring(idp['METADATA'])
73
                except (TypeError, ET.ParseError):
74
                    self.logger.error(u'METADATA of %d-th idp is invalid', i)
75
                    continue
76
                if doc.tag != '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF:
77
                    self.logger.error(u'METADATA of %d-th idp has no EntityDescriptor root tag', i)
78
                    continue
79

  
80
                if 'entityID' not in doc.attrib:
81
                    self.logger.error(
82
                        u'METADATA of %d-th idp has no entityID attribute on its root tag', i)
83
                    continue
84
                idp['ENTITY_ID'] = doc.attrib['entityID']
85
            yield idp
207
                entity_id = self.load_entity_id(idp['METADATA'], i)
208
                if entity_id:
209
                    idp['ENTITY_ID'] = entity_id
210

  
211
        if 'ENTITY_ID' in idp:
212
            return idp['METADATA']
213

  
214
    def load_entity_id(self, metadata, i):
215
        try:
216
            doc = ET.fromstring(metadata)
217
        except (TypeError, ET.ParseError):
218
            logger.error(u'METADATA of %d-th idp is invalid', i)
219
            return None
220
        if doc.tag != '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF:
221
            logger.error(u'METADATA of %d-th idp has no EntityDescriptor root tag', i)
222
            return None
223

  
224
        if 'entityID' not in doc.attrib:
225
            logger.error(
226
                u'METADATA of %d-th idp has no entityID attribute on its root tag', i)
227
            return None
228
        return doc.attrib['entityID']
229

  
230
    def load_idp(self, idp, i):
231
        self.load_metadata(idp, i)
232
        return 'ENTITY_ID' in idp
86 233

  
87 234
    def authorize(self, idp, saml_attributes):
88 235
        if not idp:
......
102 249
            username = force_text(username_template).format(
103 250
                realm=realm, attributes=saml_attributes, idp=idp)[:30]
104 251
        except ValueError:
105
            self.logger.error(u'invalid username template %r', username_template)
252
            logger.error(u'invalid username template %r', username_template)
106 253
        except (AttributeError, KeyError, IndexError) as e:
107
            self.logger.error(
254
            logger.error(
108 255
                u'invalid reference in username template %r: %s', username_template, e)
109 256
        except Exception:
110
            self.logger.exception(u'unknown error when formatting username')
257
            logger.exception(u'unknown error when formatting username')
111 258
        else:
112 259
            return username
113 260

  
......
117 264
    def finish_create_user(self, idp, saml_attributes, user):
118 265
        username = self.format_username(idp, saml_attributes)
119 266
        if not username:
120
            self.logger.warning('could not build a username, login refused')
267
            logger.warning('could not build a username, login refused')
121 268
            raise UserCreationError
122 269
        user.username = username
123 270
        user.save()
......
133 280
                    if len(name_id) == 1:
134 281
                        name_id = name_id[0]
135 282
                    else:
136
                        self.logger.warning('more than one value for attribute %r, cannot federate',
137
                                            transient_federation_attribute)
283
                        logger.warning('more than one value for attribute %r, cannot federate',
284
                                       transient_federation_attribute)
138 285
                        return None
139 286
            else:
140 287
                return None
......
146 293
                                    saml_identifiers__issuer=issuer)
147 294
        except User.DoesNotExist:
148 295
            if not utils.get_setting(idp, 'PROVISION'):
149
                self.logger.warning('provisionning disabled, login refused')
296
                logger.warning('provisionning disabled, login refused')
150 297
                return None
151 298
            user = self.create_user(User)
152 299
            saml_id, created = models.UserSAMLIdentifier.objects.get_or_create(
......
157 304
                except UserCreationError:
158 305
                    user.delete()
159 306
                    return None
160
                self.logger.info('created new user %s with name_id %s from issuer %s',
161
                                 user, name_id, issuer)
307
                logger.info('created new user %s with name_id %s from issuer %s', user, name_id, issuer)
162 308
            else:
163 309
                user.delete()
164 310
                user = saml_id.user
165
                self.logger.info('looked up user %s with name_id %s from issuer %s',
166
                                 user, name_id, issuer)
311
                logger.info('looked up user %s with name_id %s from issuer %s', user, name_id, issuer)
167 312
        return user
168 313

  
169 314
    def provision(self, user, idp, saml_attributes):
......
179 324
            try:
180 325
                value = force_text(tpl).format(realm=realm, attributes=saml_attributes, idp=idp)
181 326
            except ValueError:
182
                self.logger.warning(u'invalid attribute mapping template %r', tpl)
327
                logger.warning(u'invalid attribute mapping template %r', tpl)
183 328
            except (AttributeError, KeyError, IndexError, ValueError) as e:
184
                self.logger.warning(
329
                logger.warning(
185 330
                    u'invalid reference in attribute mapping template %r: %s', tpl, e)
186 331
            else:
187 332
                model_field = user._meta.get_field(field)
......
191 336
                    old_value = getattr(user, field)
192 337
                    setattr(user, field, value)
193 338
                    attribute_set = True
194
                    self.logger.info(u'set field %s of user %s to value %r (old value %r)', field,
195
                                     user, value, old_value)
339
                    logger.info(u'set field %s of user %s to value %r (old value %r)', field, user, value, old_value)
196 340
        if attribute_set:
197 341
            user.save()
198 342

  
......
215 359
                        user.is_staff = True
216 360
                        user.is_superuser = True
217 361
                        attribute_set = True
218
                        self.logger.info('flag is_staff and is_superuser added to user %s', user)
362
                        logger.info('flag is_staff and is_superuser added to user %s', user)
219 363
                    break
220 364
        else:
221 365
            if user.is_superuser or user.is_staff:
222 366
                user.is_staff = False
223 367
                user.is_superuser = False
224
                self.logger.info('flag is_staff and is_superuser removed from user %s', user)
368
                logger.info('flag is_staff and is_superuser removed from user %s', user)
225 369
                attribute_set = True
226 370
        if attribute_set:
227 371
            user.save()
......
245 389
                        continue
246 390
                groups.append(group)
247 391
            for group in Group.objects.filter(pk__in=[g.pk for g in groups]).exclude(user=user):
248
                self.logger.info(
392
                logger.info(
249 393
                    u'adding group %s (%s) to user %s (%s)', group, group.pk, user, user.pk)
250 394
                User.groups.through.objects.get_or_create(group=group, user=user)
251 395
            qs = User.groups.through.objects.exclude(
252 396
                group__pk__in=[g.pk for g in groups]).filter(user=user)
253 397
            for rel in qs:
254
                self.logger.info(u'removing group %s (%s) from user %s (%s)', rel.group,
255
                                 rel.group.pk, rel.user, rel.user.pk)
398
                logger.info(u'removing group %s (%s) from user %s (%s)', rel.group, rel.group.pk, rel.user, rel.user.pk)
256 399
            qs.delete()
mellon/app_settings.py
40 40
        'ARTIFACT_RESOLVE_TIMEOUT': 10.0,
41 41
        'LOGIN_HINTS': [],
42 42
        'SIGNATURE_METHOD': 'RSA-SHA256',
43
        'METADATA_CACHE_TIME': 3600,
44
        'METADATA_HTTP_TIMEOUT': 10,
43 45
    }
44 46

  
45 47
    @property
mellon/utils.py
95 95
                key = key[0]
96 96
            server.setEncryptionPrivateKeyWithPassword(key, password)
97 97
        for idp in get_idps():
98
            try:
99
                server.addProviderFromBuffer(lasso.PROVIDER_ROLE_IDP, idp['METADATA'])
100
            except lasso.Error as e:
101
                logger.error(u'bad metadata in idp %r', idp['ENTITY_ID'])
102
                logger.debug(u'lasso error: %s', e)
103
                continue
98
            if idp and idp.get('METADATA'):
99
                try:
100
                    server.addProviderFromBuffer(lasso.PROVIDER_ROLE_IDP, idp['METADATA'])
101
                except lasso.Error as e:
102
                    logger.error(u'bad metadata in idp %s, %s', idp['ENTITY_ID'], e)
104 103
        cache[root] = server
105 104
        settings._MELLON_SERVER_CACHE = cache
106 105
    return settings._MELLON_SERVER_CACHE.get(root)
......
276 275
        xml_encoding[0] = encoding
277 276
    parser = expat.ParserCreate()
278 277
    parser.XmlDeclHandler = xmlDeclHandler
279
    parser.Parse(content, True)
278
    try:
279
        parser.Parse(content, True)
280
    except expat.ExpatError as e:
281
        raise ValueError('invalid XML %s' % e)
280 282
    return xml_encoding[0]
281 283

  
282 284

  
mellon/views.py
169 169
        '''show error message to user after a login failure'''
170 170
        login = self.profile
171 171
        idp = utils.get_idp(login.remoteProviderId)
172
        if not idp:
173
            self.log.warning('entity id %r is unknown', login.remoteProviderId)
174
            return HttpResponseBadRequest(
175
                'entity id %r is unknown' % login.remoteProviderId)
172 176
        error_url = utils.get_setting(idp, 'ERROR_URL')
173 177
        error_redirect_after_timeout = utils.get_setting(idp, 'ERROR_REDIRECT_AFTER_TIMEOUT')
174 178
        if error_url:
......
391 395

  
392 396
        next_url = check_next_url(self.request, request.GET.get(REDIRECT_FIELD_NAME))
393 397
        idp = self.get_idp(request)
394
        if idp is None:
398
        if not idp:
395 399
            return HttpResponseBadRequest('no idp found')
396 400
        self.profile = login = utils.create_login(request)
397 401
        self.log.debug('authenticating to %r', idp['ENTITY_ID'])
setup.py
94 94
          'django>=1.5,<2.0',
95 95
          'requests',
96 96
          'isodate',
97
          'atomicwrites',
97 98
      ],
98 99
      setup_requires=[
99 100
          'django>=1.5,<2.0',
tests/conftest.py
13 13
# You should have received a copy of the GNU Affero General Public License
14 14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 15

  
16
import os
16 17
import logging
18

  
17 19
import pytest
18 20
import django_webtest
19 21

  
20 22

  
23
@pytest.fixture(autouse=True)
24
def settings(settings, tmpdir):
25
    settings.MEDIA_ROOT = str(tmpdir.mkdir('media'))
26
    return settings
27

  
28

  
21 29
@pytest.fixture
22
def app(request):
30
def app(request, settings):
23 31
    wtm = django_webtest.WebTestMixin()
24 32
    wtm._patch_settings()
25 33
    request.addfinalizer(wtm._unpatch_settings)
......
38 46

  
39 47

  
40 48
@pytest.fixture
41
def private_settings(request):
49
def private_settings(request, tmpdir):
42 50
    import django.conf
43 51
    from django.conf import UserSettingsHolder
44 52
    old = django.conf.settings._wrapped
......
57 65
    caplog.handler.stream = py.io.TextIO()
58 66
    caplog.handler.records = []
59 67
    return caplog
68

  
69

  
70
@pytest.fixture(scope='session')
71
def metadata():
72
    with open(os.path.join(os.path.dirname(__file__), 'metadata.xml')) as fd:
73
        yield fd.read()
74

  
75

  
76
@pytest.fixture
77
def metadata_path(tmpdir, metadata):
78
    metadata_path = tmpdir / 'metadata.xml'
79
    with metadata_path.open('w') as fd:
80
        fd.write(metadata)
81
    yield str(metadata_path)
tests/test_default_adapter.py
13 13
# You should have received a copy of the GNU Affero General Public License
14 14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 15

  
16
import pytest
16
import datetime
17 17
import re
18 18
import lasso
19
import time
19 20
from multiprocessing.pool import ThreadPool
20 21

  
22
import pytest
23

  
21 24
from django.contrib import auth
22 25
from django.db import connection
23 26

  
......
149 152
    user = SAMLBackend().authenticate(saml_attributes=saml_attributes)
150 153
    assert user.is_superuser is True
151 154
    assert user.is_staff is True
152
    assert not 'flag is_staff and is_superuser removed' in caplog.text
155
    assert 'flag is_staff and is_superuser removed' not in caplog.text
153 156

  
154 157

  
155 158
def test_provision_absent_attribute(settings, django_user_model, caplog):
......
211 214
    user = adapter.lookup_user(idp, saml_attributes)
212 215
    assert user is None
213 216
    assert User.objects.count() == 0
217

  
218

  
219
@pytest.fixture
220
def adapter():
221
    return DefaultAdapter()
222

  
223

  
224
def test_load_metadata_simple(adapter, metadata):
225
    idp = {'METADATA': metadata}
226
    assert adapter.load_metadata(idp, 0) == metadata
227

  
228

  
229
def test_load_metadata_legacy(adapter, metadata_path, metadata):
230
    idp = {'METADATA': metadata_path}
231
    assert adapter.load_metadata(idp, 0) == metadata
232
    assert idp['METADATA'] == metadata
233

  
234

  
235
def test_load_metadata_path(adapter, metadata_path, metadata, freezer):
236
    now = time.time()
237
    idp = {'METADATA_PATH': str(metadata_path)}
238
    assert adapter.load_metadata(idp, 0) == metadata
239
    assert idp['METADATA'] == metadata
240
    assert idp['METADATA_PATH_LAST_UPDATE'] == now
241

  
242

  
243
def test_load_metadata_url(settings, adapter, metadata, httpserver, freezer, caplog):
244
    now = time.time()
245
    httpserver.serve_content(content=metadata, headers={'Content-Type': 'application/xml'})
246
    idp = {'METADATA_URL': httpserver.url}
247
    assert adapter.load_metadata(idp, 0) == metadata
248
    assert idp['METADATA'] == metadata
249
    assert idp['METADATA_URL_LAST_UPDATE'] == now
250
    assert 'METADATA_PATH' in idp
251
    assert idp['METADATA_PATH'].startswith(settings.MEDIA_ROOT)
252
    with open(idp['METADATA_PATH']) as fd:
253
        assert fd.read() == metadata
254
    assert idp['METADATA_PATH_LAST_UPDATE'] == now + 1
255
    httpserver.serve_content(content=metadata.replace('idp5', 'idp6'),
256
                             headers={'Content-Type': 'application/xml'})
257
    assert adapter.load_metadata(idp, 0) == metadata
258

  
259
    freezer.move_to(datetime.timedelta(seconds=3601))
260
    caplog.clear()
261
    assert adapter.load_metadata(idp, 0) == metadata
262
    # wait for update thread to finish
263
    try:
264
        idp['METADATA_URL_UPDATE_THREAD'].join()
265
    except KeyError:
266
        pass
267
    new_meta = adapter.load_metadata(idp, 0)
268
    assert new_meta != metadata
269
    assert new_meta == metadata.replace('idp5', 'idp6')
270
    assert 'entityID changed' in caplog.records[-1].message
271
    assert caplog.records[-1].levelname == 'ERROR'
272
    # test load from file cache
273
    del idp['METADATA']
274
    del idp['METADATA_PATH']
275
    del idp['METADATA_PATH_LAST_UPDATE']
276
    httpserver.serve_content(content='', headers={'Content-Type': 'application/xml'})
277
    assert adapter.load_metadata(idp, 0) == metadata.replace('idp5', 'idp6')
278

  
279

  
280
def test_load_metadata_url_stale_timeout(settings, adapter, metadata, httpserver, freezer, caplog):
281
    httpserver.serve_content(content=metadata, headers={'Content-Type': 'application/xml'})
282
    idp = {'METADATA_URL': httpserver.url}
283
    assert adapter.load_metadata(idp, 0) == metadata
284
    httpserver.serve_content(content='', headers={'Content-Type': 'application/xml'})
285
    assert adapter.load_metadata(idp, 0) == metadata
286

  
287
    freezer.move_to(datetime.timedelta(seconds=24 * 3600 - 1))
288
    assert adapter.load_metadata(idp, 0) == metadata
289

  
290
    # wait for update thread to finish
291
    try:
292
        idp['METADATA_URL_UPDATE_THREAD'].join()
293
    except KeyError:
294
        pass
295
    assert caplog.records[-1].levelname == 'WARNING'
296

  
297
    freezer.move_to(datetime.timedelta(seconds=3601))
298
    assert adapter.load_metadata(idp, 0) == metadata
299

  
300
    # wait for update thread to finish
301
    try:
302
        idp['METADATA_URL_UPDATE_THREAD'].join()
303
    except KeyError:
304
        pass
305
    assert caplog.records[-1].levelname == 'ERROR'
tests/test_utils.py
13 13
# You should have received a copy of the GNU Affero General Public License
14 14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 15

  
16
import re
17 16
import datetime
18 17

  
19 18
import mock
20 19
import lasso
21
import requests.exceptions
22
from httmock import HTTMock
23 20

  
24
from mellon.utils import create_server, create_metadata, iso8601_to_datetime, flatten_datetime
25
import mellon.utils
21
from mellon.utils import create_metadata, iso8601_to_datetime, flatten_datetime
26 22
from xml_utils import assert_xml_constraints
27 23

  
28
from utils import error_500, metadata_response
29

  
30

  
31
def test_create_server_connection_error(mocker, rf, private_settings, caplog):
32
    mocker.patch('requests.get',
33
                 side_effect=requests.exceptions.ConnectionError('connection error'))
34
    private_settings.MELLON_IDENTITY_PROVIDERS = [
35
        {
36
            'METADATA_URL': 'http://example.com/metadata',
37
        }
38
    ]
39
    request = rf.get('/')
40
    create_server(request)
41
    assert 'connection error' in caplog.text
42

  
43

  
44
def test_create_server_internal_server_error(mocker, rf, private_settings, caplog):
45
    private_settings.MELLON_IDENTITY_PROVIDERS = [
46
        {
47
            'METADATA_URL': 'http://example.com/metadata',
48
        }
49
    ]
50
    request = rf.get('/')
51
    assert not 'failed with error' in caplog.text
52
    with HTTMock(error_500):
53
        create_server(request)
54
    assert 'failed with error' in caplog.text
55

  
56

  
57
def test_create_server_invalid_metadata(mocker, rf, private_settings, caplog):
58
    private_settings.MELLON_IDENTITY_PROVIDERS = [
59
        {
60
            'METADATA': 'xxx',
61
        }
62
    ]
63
    request = rf.get('/')
64
    assert not 'failed with error' in caplog.text
65
    with HTTMock(error_500):
66
        create_server(request)
67
    assert len(caplog.records) == 1
68
    assert re.search('METADATA.*is invalid', caplog.text)
69

  
70

  
71
def test_create_server_invalid_metadata_file(mocker, rf, private_settings, caplog):
72
    private_settings.MELLON_IDENTITY_PROVIDERS = [
73
        {
74
            'METADATA': '/xxx',
75
        }
76
    ]
77
    request = rf.get('/')
78
    assert not 'failed with error' in caplog.text
79
    with mock.patch('mellon.adapters.open', mock.mock_open(read_data='yyy'), create=True):
80
        with HTTMock(error_500):
81
            server = create_server(request)
82
    assert len(server.providers) == 0
83

  
84

  
85
def test_create_server_good_metadata_file(mocker, rf, private_settings, caplog):
86
    private_settings.MELLON_IDENTITY_PROVIDERS = [
87
        {
88
            'METADATA': '/xxx',
89
        }
90
    ]
91
    request = rf.get('/')
92
    with mock.patch(
93
        'mellon.adapters.open', mock.mock_open(read_data=open('tests/metadata.xml').read()),
94
            create=True):
95
        server = create_server(request)
96
    assert 'ERROR' not in caplog.text
97
    assert len(server.providers) == 1
98

  
99

  
100
def test_create_server_good_metadata(mocker, rf, private_settings, caplog):
101
    private_settings.MELLON_IDENTITY_PROVIDERS = [
102
        {
103
            'METADATA': open('tests/metadata.xml').read(),
104
        }
105
    ]
106
    request = rf.get('/')
107
    assert not 'failed with error' in caplog.text
108
    server = create_server(request)
109
    assert 'ERROR' not in caplog.text
110
    assert len(server.providers) == 1
111

  
112

  
113
def test_create_server_invalid_idp_dict(mocker, rf, private_settings, caplog):
114
    private_settings.MELLON_IDENTITY_PROVIDERS = [
115
        {
116
        }
117
    ]
118
    request = rf.get('/')
119
    assert not 'failed with error' in caplog.text
120
    create_server(request)
121
    assert 'missing METADATA' in caplog.text
122

  
123

  
124
def test_create_server_good_metadata_url(mocker, rf, private_settings, caplog):
125
    private_settings.MELLON_IDENTITY_PROVIDERS = [
126
        {
127
            'METADATA_URL': 'http://example.com/metadata',
128
        }
129
    ]
130

  
131
    request = rf.get('/')
132
    assert not 'failed with error' in caplog.text
133
    with HTTMock(metadata_response):
134
        server = create_server(request)
135
    assert 'ERROR' not in caplog.text
136
    assert len(server.providers) == 1
137

  
138 24

  
139 25
def test_create_metadata(rf, private_settings, caplog):
140 26
    ns = {
tox.ini
1 1
[tox]
2
envlist = {coverage-,}py2-{dj18,dj111}-{pg,sqlite},py3-dj111-{pg,sqlite}
2
envlist = coverage-py2-{dj18,dj111}-{pg,sqlite},coverage-py3-dj111-{pg,sqlite}
3 3
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/django-mellon/
4 4

  
5 5
[testenv]
......
24 24
  pytest-random
25 25
  pytest-mock
26 26
  pytest-django
27
  pytest-freezegun
28
  pytest-localserver
27 29
  pytz
28 30
  lxml
29 31
  cssselect
30
-