Projet

Général

Profil

0007-add-utilities-to-access-SFTP-servers-31595.patch

Benjamin Dauvergne, 11 avril 2019 16:20

Télécharger (20,6 ko)

Voir les différences:

Subject: [PATCH 07/10] add utilities to access SFTP servers (#31595)

Disclaimer: paramiko does not work with recent OpenSSH key format (PKCS8
or RFC4716), only the legacy PEM format is supported.
 .../templates/passerelle/widgets/sftp.html    |  16 ++
 passerelle/utils/sftp.py                      | 225 ++++++++++++++++++
 setup.py                                      |   1 +
 tests/ssh_key                                 |  27 +++
 tests/ssh_key_with_password                   |  30 +++
 tests/test_utils_sftp.py                      | 185 ++++++++++++++
 tox.ini                                       |   1 +
 7 files changed, 485 insertions(+)
 create mode 100644 passerelle/base/templates/passerelle/widgets/sftp.html
 create mode 100644 passerelle/utils/sftp.py
 create mode 100644 tests/ssh_key
 create mode 100644 tests/ssh_key_with_password
 create mode 100644 tests/test_utils_sftp.py
passerelle/base/templates/passerelle/widgets/sftp.html
1
{% load i18n %}
2
<div class="sftp-widget-url">
3
    <label for="{{ widget.subwidgets.0.attrs.id }}">{% trans "URL" %}</label>
4
    {% include widget.subwidgets.0.template_name with widget=widget.subwidgets.0 %}
5
</div>
6
<div class="sftp-widget-private-key">
7
    <label for="{{ widget.subwidgets.1.attrs.id }}">{% trans "SSH private key" %}</label>
8
    {% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
9
</div>
10
<div class="sftp-widget-private-key-text">
11
    {% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
12
</div>
13
<div class="sftp-widget-private-key-password">
14
    <label for="{{ widget.subwidgets.3.attrs.id }}">{% trans "SSH private key password" %}</label>
15
    {% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
16
</div>
passerelle/utils/sftp.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import os
18
import re
19
import io
20
import json
21
import contextlib
22

  
23
from django import forms
24
from django.core import validators
25
from django.db import models
26
from django.utils import six
27
from django.utils.translation import ugettext_lazy as _
28
from django.utils.six.moves.urllib import parse as urlparse
29
from django.utils.encoding import force_bytes
30

  
31
import paramiko
32
from paramiko.dsskey import DSSKey
33
from paramiko.ecdsakey import ECDSAKey
34
from paramiko.ed25519key import Ed25519Key
35
from paramiko.rsakey import RSAKey
36

  
37

  
38
def _load_private_key(content_or_file, password=None):
39
    if not hasattr(content_or_file, 'read'):
40
        content_or_file = io.BytesIO(force_bytes(content_or_file))
41
    for pkey_class in RSAKey, DSSKey, Ed25519Key, ECDSAKey:
42
        try:
43
            return pkey_class.from_private_key(
44
                content_or_file,
45
                password=password)
46
        except paramiko.PasswordRequiredException:
47
            raise
48
        except paramiko.SSHException:
49
            pass
50

  
51

  
52
@six.python_2_unicode_compatible
53
class SFTP(object):
54
    def __init__(self, url, private_key_content=None, private_key_password=None):
55
        self.url = url
56
        parsed = urlparse.urlparse(url)
57
        if not parsed.scheme == 'sftp':
58
            raise ValueError('invalid scheme %s' % parsed.scheme)
59
        if not parsed.hostname:
60
            raise ValueError('missing hostname')
61
        self.username = parsed.username or None
62
        self.password = parsed.password or None
63
        self.hostname = parsed.hostname
64
        self.port = parsed.port or 22
65
        self.path = parsed.path.strip('/')
66
        self.private_key_content = private_key_content
67
        self.private_key_password = private_key_password
68
        if private_key_content:
69
            self.private_key = _load_private_key(private_key_content, private_key_password)
70
        else:
71
            self.private_key = None
72
        self._client = None
73
        self._transport = None
74

  
75
    def __json__(self):
76
        return {
77
            'url': self.url,
78
            'private_key_content': self.private_key_content,
79
            'private_key_password': self.private_key_password,
80
        }
81

  
82
    def __str__(self):
83
        return re.sub(r'://([^/]*:[^/]*?)@', '://***:***@', self.url)
84

  
85
    # Paramiko can hang processes if not closed, it's important to use it as a
86
    # contextmanager
87
    @contextlib.contextmanager
88
    def client(self):
89
        ssh = paramiko.SSHClient()
90
        try:
91
            ssh.set_missing_host_key_policy(paramiko.client.AutoAddPolicy)
92
            ssh.connect(
93
                hostname=self.hostname,
94
                port=self.port,
95
                timeout=5,
96
                pkey=self.private_key,
97
                look_for_keys=False,
98
                allow_agent=False,
99
                username=self.username,
100
                password=self.password)
101
            client = ssh.open_sftp()
102
            try:
103
                if self.path:
104
                    client.chdir(self.path)
105
                    base_cwd = str(client._cwd)
106
                    old_adjust_cwd = client._adjust_cwd
107

  
108
                    def _adjust_cwd(path):
109
                        path = old_adjust_cwd(path)
110
                        if not os.path.normpath(path).startswith(base_cwd):
111
                            raise ValueError('all paths must be under base path %s: %s' % (base_cwd, path))
112
                        return path
113
                    client._adjust_cwd = _adjust_cwd
114
                yield client
115
            finally:
116
                client.close()
117
        finally:
118
            ssh.close()
119

  
120

  
121
class SFTPURLField(forms.URLField):
122
    default_validators = [validators.URLValidator(schemes=['sftp'])]
123

  
124

  
125
class SFTPWidget(forms.MultiWidget):
126
    template_name = 'passerelle/widgets/sftp.html'
127

  
128
    def __init__(self, **kwargs):
129
        widgets = [
130
            forms.TextInput,
131
            forms.FileInput,
132
            forms.Textarea,
133
            forms.TextInput,
134
        ]
135
        super(SFTPWidget, self).__init__(widgets=widgets, **kwargs)
136

  
137
    def decompress(self, value):
138
        if not value:
139
            return [None, None, None, None]
140
        if hasattr(value, '__json__'):
141
            value = value.__json__()
142
        return [
143
            value['url'],
144
            None,
145
            value.get('private_key_content'),
146
            value.get('private_key_password'),
147
        ]
148

  
149
    # XXX: bug in Django https://code.djangoproject.com/ticket/29205
150
    # required_attribute is initialized from the parent.field required
151
    # attribute and not from each sub-field attribute
152
    def use_required_attribute(self, initial):
153
        return False
154

  
155

  
156
class SFTPFormField(forms.MultiValueField):
157
    widget = SFTPWidget
158

  
159
    def __init__(self, **kwargs):
160
        fields = [
161
            SFTPURLField(),
162
            forms.FileField(required=False),
163
            forms.CharField(required=False),
164
            forms.CharField(required=False),
165
        ]
166
        super(SFTPFormField, self).__init__(
167
            fields=fields,
168
            require_all_fields=False, **kwargs)
169

  
170
#    def clean(self, value):
171
#        import pdb
172
#        pdb.set_trace()
173
#        return super(SFTPFormField, self).clean(value)
174

  
175
    def compress(self, data_list):
176
        url, private_key_file, private_key_content, private_key_password = data_list
177
        if private_key_file:
178
            private_key_content = private_key_file.read().decode('ascii')
179
        if private_key_content:
180
            try:
181
                pkey = _load_private_key(private_key_content, private_key_password)
182
            except paramiko.PasswordRequiredException:
183
                raise forms.ValidationError(_('SSH private key needs a password'))
184
            if not pkey:
185
                raise forms.ValidationError(_('SSH private key invalid'))
186
        return SFTP(
187
            url=url,
188
            private_key_content=private_key_content,
189
            private_key_password=private_key_password)
190

  
191

  
192
class SFTPField(models.Field):
193
    description = 'A SFTP connection'
194

  
195
    def __init__(self, **kwargs):
196
        kwargs.setdefault('default', None)
197
        super(SFTPField, self).__init__(**kwargs)
198

  
199
    def get_internal_type(self):
200
        return 'TextField'
201

  
202
    def from_db_value(self, value, *args, **kwargs):
203
        return self.to_python(value)
204

  
205
    def to_python(self, value):
206
        if not value:
207
            return None
208
        if isinstance(value, SFTP):
209
            return value
210
        if isinstance(value, six.string_types):
211
            return SFTP(url=value)
212
        return SFTP(**json.loads(value))
213

  
214
    def get_prep_value(self, value):
215
        if not value:
216
            return ''
217
        return json.dumps(value.__json__())
218

  
219
    def formfield(self, **kwargs):
220
        defaults = {
221
            'form_class': SFTPFormField,
222
        }
223
        defaults.update(**kwargs)
224
        return super(SFTPField, self).formfield(**defaults)
225

  
setup.py
108 108
            'zeep < 3.0',
109 109
            'pycrypto',
110 110
            'unidecode',
111
            'paramiko',
111 112
        ],
112 113
        cmdclass={
113 114
            'build': build,
tests/ssh_key
1
-----BEGIN RSA PRIVATE KEY-----
2
MIIEpAIBAAKCAQEAqH/MTAXMKqz3uQDcliK9udOXlGNUlPN/gVMdxsw0kwmrKdrW
3
svuIa3UhrHI/6bPnTTniKvXmccAIm8aAdEg8rAgcksAada6qUYbc0aZbR0WyC+3/
4
fvaLbD9WutD05frrIEwjIUkcXavWSlaHrEzV2T3mGGI8amqR//bjwksRkClx2vvW
5
apc5FkzGtxKsc0tA6ZwyA3riqCeEVwOx+ezV8B/631q62h6AAt0j4cb+THzD1t3w
6
6KAsxt72lNw6Bp55uR5Uubin3OpijEVL4Gruy1sSJO6pQ5YHpDFTS9ZAHLLldUea
7
w+hNha+fGM/EvQJEGZsZSA/urEAuC6Xly8T/JwIDAQABAoIBAGjMe4s4+9/7DmQB
8
VjEG0IvYP2mqUfwGamJMCLQRZA2jsNJqaqiNay6yfkwcDwZSv2S3wKRJppdPAcup
9
LVGlcB7rOKJJWuugxAvK3mKCnjj47yEeWI9l1hdwWYf92KOFaWIAGMVmDH9yFejM
10
YrvWWhcwuYCm8L6bI81YiBXazMSlD+UXB9WOityCc5DayXhTvKiP6mzVQHJJ4p4o
11
q/LtBI4jOI7ZEnlI+lYNBvCfZLaPIi7vvLpOXG263ZRlwYr+IjED+7Ex6zjL3YEd
12
VFfp2MX5uY3IsXmjyhHokm2f2JRH1JtigjkRysT4AYHk76XktrsczfY6Hgs6Lwz8
13
n6/o2sECgYEA09ErhKlObtWfWdp0HXd/HRubvBQBQ+oYPxNcb6QdWOaXzG8jDBuN
14
bxOFXdqknkWzG6pNlurjVW4njeoovts4Wmza5U4Ju9IL2lrUNXqRkopbdcPCw0v6
15
kdJqbui3DwOA/gmphO802jCXu+xASSdoVK8/sCC/itOcAzi8E0YUHCECgYEAy6V+
16
3ivJczRT9xM7hOJA1pJDpLbbuE6XTQBn9QfoP90zAC6Tbr5k7dh0Uyc4JcTOPK7C
17
aMmr8ScRL9UxZKsDwxFQPJwz4/R3VCcmJt0j0vgilwm2ujiqWmgqgvM4YBg+dzlU
18
J05PenMgzYAp9HD9a8B6d6lOvioF0jx1nbZa8kcCgYEAqJhhDyLDryyRva9HpPys
19
TLrg5n710tzNl8cNWD9ErLI+ORZsywJTPQpIqT+Sr/fCbE7Nm0Yy1JjtGuQ6sk9D
20
N5ZVVRccYEb78D1Dk52PqRg/XCkJKPGc69yTotvQeT7MuWdvasQLSXBMFeQh9xhK
21
zrz+8G3gh9uO3nGWIbEx6IECgYBUW73uMp1Eh8ywcNsa9M5/FB/JP6ZM9uFeGGj3
22
68qdiffyf1i7a0tL63pkZ76uhpQYNxx5Y/FB+Dj6Y4oOdXkdeTKPqPUl3MMBrSX0
23
u253mipZ/sAe7BJFWRkjHbWguOpHYQwnLB1oUACqoAjBJX0VAaq5nvzrcWTv7fOa
24
3UtXSQKBgQC8un5szEXQth2viUe5P7FyMsz+34XRKL7qCaCv1/4kGgPhlSbkYHao
25
PZ8u5hue/hnmUfpbXNJfc+zazTIojuZ047Z5rJYpKCfs5JozuGOGVOVIf5hunWfJ
26
FU3c4umawKvh5tUUeyrXilzEWnVECGYDyzj9BumGT+YkG94yll7iZw==
27
-----END RSA PRIVATE KEY-----
tests/ssh_key_with_password
1
-----BEGIN RSA PRIVATE KEY-----
2
Proc-Type: 4,ENCRYPTED
3
DEK-Info: AES-128-CBC,AE8F07BCBA597172BBC8D034B3C24C30
4

  
5
eg/EgNvQzSeH+W7GP23U2XIbzqg3UQgnSqi+3GbZcZUnqYmsmChiMZy+Wb0b1BIY
6
QOFLvOkaiP3hSnfFvx8oSD7/J2h633uSvhtGrq9wIMVUCXLLgBL2k2pzbWHPz1WG
7
6eJRAdWQ8c7jCCVkLdFf077ebJXL3tCU/1gP7RJ2wLhkfRXtWARgYPrmw2+ZY6l1
8
tre1O0AS+yjI6ZAsVjPCPqq9WrP4CggtQ3W33/AERGrAWi9PJZFjQUE0cMj6J+R0
9
uciudN+PgO3SRKc+uWX3YrUc5zb+4SMvRO2uMiULyVMahJnnpHSO5qB+dcn6vjcU
10
F6ogpfyJpNgm4dCvEwtEiuva1HKlzCQRpWVlHhsRM6l7/faxON8QNND2ZKgO5XS6
11
QFvBbshOLssM7XQQIfdk4yNjx7lWA63W0E+t6sQqTMTeLbWePIq5IlvazFlSqkIS
12
I2B3e0Dd3emStwhZhEDncPhysh0P0WOCqnXiql3uGZGkL6bl47qRD2Ft0DnWoNoJ
13
0LUTClhInvgi9K50iMup/pq4HN8bo3fAgAcMePF3AqktFTzyIzKK7/pt0jjdX+7N
14
MBU9YkrDA3BzwupT71M/hQJIoqGBD68RGFR2Wmxc73xy6Pmv5DDv0e/fGpo0dnHC
15
IuhKMs24uRn85OWIGUi+GAbwp9OlJ6Vlh59ZAl4KVSySAnaGCZiAHfWpkWrGMlkd
16
wMi1V/L/NGXyzaereK4PUT3IKPFglRr2bTfwrhX83zO5fU4vMuMy22KNQpoX9agG
17
J2IkyhMsd56x2JmAhQKLi3rLagzEdx2a4pRJyoU8n95/NhLYnPBBl73weJKtZj/t
18
8KNhqjgerPPXdgYua2TE+4e81XrYWhbqdepG0GiYZ23XBIAIor2jgGjswENUw2TZ
19
TEFrs9MAx/0r1isz4UurXvyA4IX8TANfJzEqZwimNcn9Eehj6drnvO5GRKCbVRhe
20
p0mqMqXnTZGdJyT4EXSNnbRxW5jkaLbDSaiJ+6U0fLawqGRR0VazT4JlOAVz8i3i
21
BJxRpYZ/PvRXevh1QrUpgS/wds7+b6nyf4XgV1RsvRS2uuyqx6smE5d+sW9qVZsU
22
kuTgI99zr7JSXUO1IDqfBXldnfMVJyGvYMGXdd3UGOis23wDlAd61H6YJAwp75oR
23
pOv45Jn8JVQ0w1yFZXTZz2PyDztVpH46RQLxHMPjKber7qN8Lfhc97+01tisV0UB
24
k6cWuAXZo61uBr35jr2BRHS1xnrK8Ul4sJPq9LUUaJU2+oZeKNmytTbMRel6w156
25
d64QU75OsT5tZHKKV/NQDLuAWY1ZB+useZNHSkqz860zNzRA+gyb6pLL88Z/Q2YS
26
z0OJMXNHxBZhNKk6SPZQTIo6Q0bn8dxY8BwCLSwJGi0NMEugm5f+9+GTjYNplGTm
27
z0itYZ8dyhk+rc/48eUg/ctCGM7FbvBaLGkBGqSUrPDFzZjvqASLtKD2IqUyU1bW
28
EDlz9PHSX+SGP1A3Y5IKd1L3dbq7lO0SlJnkAOReaagjwNJ+f3dxkDikkBRO+/SB
29
V7zuK8XIc1D0Hy13AGdGsiiBF8BXCW7hrhnY+7hKHvtw4nljQ5CGKWhPBauohMdG
30
-----END RSA PRIVATE KEY-----
tests/test_utils_sftp.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import os
18

  
19
import pytest
20

  
21
from django.core.files.uploadedfile import SimpleUploadedFile
22
from django.db import models
23

  
24
from passerelle.utils.sftp import SFTP, SFTPFormField, SFTPField
25

  
26

  
27
@pytest.fixture
28
def ssh_key():
29
    with open(
30
        os.path.join(
31
            os.path.dirname(__file__),
32
            'ssh_key'), 'rb') as fd:
33
        yield fd.read()
34

  
35

  
36
@pytest.fixture
37
def ssh_key_with_password():
38
    with open(
39
        os.path.join(
40
            os.path.dirname(__file__),
41
            'ssh_key_with_password'), 'rb') as fd:
42
        yield fd.read()
43

  
44

  
45
def test_http_url(sftpserver):
46
    with pytest.raises(ValueError):
47
        SFTP('https://coin.org/')
48

  
49

  
50
def test_missing_hostname(sftpserver):
51
    with pytest.raises(ValueError):
52
        SFTP('sftp://a:x@/hop/')
53

  
54

  
55
def test_sftp_ok(sftpserver):
56
    with sftpserver.serve_content({'DILA': {'a.zip': 'a'}}):
57
        with SFTP('sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver)).client() as sftp:
58
            assert sftp.listdir() == ['a.zip']
59

  
60

  
61
def test_sftp_bad_paths(sftpserver):
62
    with sftpserver.serve_content({'DILA': {'a.zip': 'a'}}):
63
        with SFTP('sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver)).client() as sftp:
64
            with pytest.raises(ValueError):
65
                sftp.chdir('..')
66
            with pytest.raises(ValueError):
67
                sftp.chdir('/')
68
            with pytest.raises(ValueError):
69
                sftp.chdir('/coin')
70

  
71

  
72

  
73
def test_form_field(sftpserver, ssh_key, ssh_key_with_password):
74
    from django import forms
75

  
76
    class Form(forms.Form):
77
        sftp = SFTPFormField(label='sftp')
78

  
79
    with sftpserver.serve_content({'DILA': {'a.zip': 'a'}}):
80
        url = 'sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver)
81

  
82
        form = Form(data={'sftp_0': 'http://coin.org'})
83
        assert not form.is_valid()
84
        assert 'Enter a valid URL.' in str(form.errors)
85

  
86
        form = Form(data={'sftp_0': url})
87
        assert form.is_valid()
88
        sftp = form.cleaned_data['sftp']
89
        assert isinstance(sftp, SFTP)
90
        assert sftp.url == url
91
        assert sftp.username == 'john'
92
        assert sftp.password == 'doe'
93
        assert sftp.hostname == sftpserver.host
94
        assert sftp.port == sftpserver.port
95
        assert sftp.path == 'DILA'
96
        assert not sftp.private_key
97
        with form.cleaned_data['sftp'].client() as sftp:
98
            assert sftp.listdir() == ['a.zip']
99

  
100
        form = Form(data={'sftp_0': url, 'sftp_2': ssh_key.decode('ascii')})
101
        assert form.is_valid()
102
        sftp = form.cleaned_data['sftp']
103
        assert isinstance(sftp, SFTP)
104
        assert sftp.url == url
105
        assert sftp.username == 'john'
106
        assert sftp.password == 'doe'
107
        assert sftp.hostname == sftpserver.host
108
        assert sftp.port == sftpserver.port
109
        assert sftp.path == 'DILA'
110
        assert sftp.private_key
111
        with form.cleaned_data['sftp'].client() as sftp:
112
            assert sftp.listdir() == ['a.zip']
113

  
114
        form = Form(
115
            data={'sftp_0': url},
116
            files={'sftp_1': SimpleUploadedFile('ssh_key', ssh_key, 'application/octet-stream')})
117
        assert form.is_valid()
118
        sftp = form.cleaned_data['sftp']
119
        assert isinstance(sftp, SFTP)
120
        assert sftp.url == url
121
        assert sftp.username == 'john'
122
        assert sftp.password == 'doe'
123
        assert sftp.hostname == sftpserver.host
124
        assert sftp.port == sftpserver.port
125
        assert sftp.path == 'DILA'
126
        assert sftp.private_key
127
        with form.cleaned_data['sftp'].client() as sftp:
128
            assert sftp.listdir() == ['a.zip']
129

  
130
        form = Form(data={'sftp_0': url, 'sftp_2': ssh_key_with_password.decode('ascii')})
131
        assert not form.is_valid()
132
        assert 'key invalid' in str(form.errors)
133

  
134
        form = Form(data={
135
            'sftp_0': url,
136
            'sftp_2': ssh_key_with_password.decode('ascii'),
137
            'sftp_3': 'coucou',
138
        })
139
        assert form.is_valid()
140
        with form.cleaned_data['sftp'].client() as sftp:
141
            assert sftp.listdir() == ['a.zip']
142

  
143

  
144
@pytest.fixture
145
def temp_model(db):
146
    from django.db import connection
147

  
148
    class Model(models.Model):
149
        sftp = SFTPField(blank=True)
150

  
151
        class Meta:
152
            app_label = 'test'
153

  
154
    with connection.schema_editor() as editor:
155
        editor.create_model(Model)
156
        yield Model
157
        editor.delete_model(Model)
158

  
159

  
160
def test_model_field(temp_model, ssh_key_with_password):
161
    instance = temp_model.objects.create()
162
    assert instance.sftp is None
163

  
164
    url = 'sftp://john:doe@example.com:45/a/b'
165
    sftp = SFTP(url=url, private_key_content=ssh_key_with_password, private_key_password='coucou')
166
    instance.sftp = sftp
167
    instance.save()
168

  
169
    instance = temp_model.objects.get()
170
    assert instance.sftp is not None
171
    assert instance.sftp.url == url
172
    assert instance.sftp.private_key is not None
173
    instance.sftp = None
174
    instance.save()
175

  
176
    instance = temp_model.objects.get()
177
    assert instance.sftp is None
178
    instance.delete()
179

  
180
    temp_model.objects.create(sftp=sftp)
181

  
182
    instance = temp_model.objects.get()
183
    assert instance.sftp is not None
184
    assert instance.sftp.url == url
185
    assert instance.sftp.private_key is not None
tox.ini
31 31
  pytest-freezegun
32 32
  pytest-httpbin
33 33
  pytest-localserver
34
  pytest-sftpserver
34 35
commands =
35 36
  django18: py.test {posargs: {env:FAST:} --junitxml=test_{envname}_results.xml --cov-report xml --cov-report html --cov=passerelle/ --cov-config .coveragerc tests/}
36 37
  django18: ./pylint.sh passerelle/
37
-