Projet

Général

Profil

0001-esirius-add-e-sirius-connector-51365.patch

Nicolas Roche, 26 février 2021 18:08

Télécharger (22,9 ko)

Voir les différences:

Subject: [PATCH] esirius: add e-sirius connector (#51365)

 passerelle/apps/esirius/__init__.py           |   0
 .../apps/esirius/migrations/0001_initial.py   |  78 +++++
 .../apps/esirius/migrations/__init__.py       |   0
 passerelle/apps/esirius/models.py             | 213 +++++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_esirius.py                         | 286 ++++++++++++++++++
 6 files changed, 578 insertions(+)
 create mode 100644 passerelle/apps/esirius/__init__.py
 create mode 100644 passerelle/apps/esirius/migrations/0001_initial.py
 create mode 100644 passerelle/apps/esirius/migrations/__init__.py
 create mode 100644 passerelle/apps/esirius/models.py
 create mode 100644 tests/test_esirius.py
passerelle/apps/esirius/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2021-02-26 14:14
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    initial = True
11

  
12
    dependencies = [
13
        ('base', '0029_auto_20210202_1627'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='ESirius',
19
            fields=[
20
                (
21
                    'id',
22
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
23
                ),
24
                ('title', models.CharField(max_length=50, verbose_name='Title')),
25
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
26
                ('description', models.TextField(verbose_name='Description')),
27
                (
28
                    'basic_auth_username',
29
                    models.CharField(
30
                        blank=True, max_length=128, verbose_name='Basic authentication username'
31
                    ),
32
                ),
33
                (
34
                    'basic_auth_password',
35
                    models.CharField(
36
                        blank=True, max_length=128, verbose_name='Basic authentication password'
37
                    ),
38
                ),
39
                (
40
                    'client_certificate',
41
                    models.FileField(
42
                        blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
43
                    ),
44
                ),
45
                (
46
                    'trusted_certificate_authorities',
47
                    models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
48
                ),
49
                ('verify_cert', models.BooleanField(default=True, verbose_name='TLS verify certificates')),
50
                (
51
                    'http_proxy',
52
                    models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
53
                ),
54
                (
55
                    'secret_id',
56
                    models.CharField(blank=True, max_length=128, verbose_name='Application identifier'),
57
                ),
58
                ('secret_key', models.CharField(blank=True, max_length=128, verbose_name='Secret Key')),
59
                (
60
                    'base_url',
61
                    models.CharField(
62
                        help_text='example: https://HOST/ePlanning/webservices/api/',
63
                        max_length=256,
64
                        verbose_name='ePlanning webservices URL',
65
                    ),
66
                ),
67
                (
68
                    'users',
69
                    models.ManyToManyField(
70
                        blank=True, related_name='_esirius_users_+', related_query_name='+', to='base.ApiUser'
71
                    ),
72
                ),
73
            ],
74
            options={
75
                'verbose_name': 'eSirius',
76
            },
77
        ),
78
    ]
passerelle/apps/esirius/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021  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

  
18
import base64
19
from datetime import datetime
20
from urllib.parse import urljoin
21

  
22
from Cryptodome.Cipher import DES
23
from Cryptodome.Util.Padding import pad
24

  
25
from django.db import models
26
from django.utils.encoding import force_bytes
27
from django.utils.translation import ugettext_lazy as _
28

  
29
from passerelle.base.models import BaseResource, HTTPResource
30
from passerelle.utils.api import endpoint
31
from passerelle.utils.jsonresponse import APIError
32

  
33

  
34
CREATE_APPOINTMENT_SCHEMA = {
35
    '$schema': 'http://json-schema.org/draft-04/schema#',
36
    "type": "object",
37
    'properties': {
38
        'idSys': {'type': 'string', 'pattern': '^[0-9]*$'},
39
        'CodeRDV': {'type': 'string'},
40
        'beginDate': {'type': 'string', 'pattern': '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'},
41
        'beginTime': {'type': 'string', 'pattern': '^[0-9]{2}:[0-9]{2}$'},
42
        'endDate': {'type': 'string', 'pattern': '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'},
43
        'endTime': {'type': 'string', 'pattern': '^[0-9]{2}:[0-9]{2}$'},
44
        'comment': {'type': 'string'},
45
        'isoLanguage': {'description': 'ex: fr', 'type': 'string'},
46
        'needsConfirmation': {'description': 'boolean expected', 'type': 'string'},
47
        'rdvChannel': {'description': 'ex: EAPP0', 'type': 'string'},
48
        'receptionChannel': {'type': 'string'},
49
        'owner': {'type': 'object', 'properties': {'key': {'type': 'string'}, 'value': {'type': 'string'}}},
50
        'user': {
51
            'type': 'object',
52
            'properties': {
53
                'idSys': {'type': 'string', 'pattern': '^[0-9]*$'},
54
                'personalIdentity': {'type': 'string'},
55
                'additionalPersonalIdentity': {"type": "array", "items": {'type': 'string'}},
56
                'lastName': {'type': 'string'},
57
                'civility': {'type': 'string'},
58
                'firstName': {'type': 'string'},
59
                'birthday': {'type': 'string'},
60
                'email': {'type': 'string'},
61
                'fixPhone': {'type': 'string'},
62
                'phone': {'type': 'string'},
63
                'address': {
64
                    'type': 'object',
65
                    'properties': {
66
                        'line1': {'type': 'string'},
67
                        'line2': {'type': 'string'},
68
                        'zipCode': {'type': 'string'},
69
                        'city': {'type': 'string'},
70
                        'country': {'type': 'string'},
71
                    },
72
                },
73
            },
74
        },
75
        'serviceId': {'type': 'string'},
76
        'siteCode': {'type': 'string'},
77
        "resources": {
78
            'type': 'object',
79
            'properties': {
80
                'id': {'type': 'string', 'pattern': '^[0-9]*$'},
81
                'key': {'type': 'string'},
82
                'type': {'type': 'string'},
83
                'name': {'type': 'string'},
84
                'station': {
85
                    'type': 'object',
86
                    'properties': {
87
                        'id': {'type': 'string', 'pattern': '^[0-9]*$'},
88
                        'key': {'type': 'string'},
89
                        'name': {'type': 'string'},
90
                    },
91
                },
92
            },
93
        },
94
        'motives': {
95
            "type": "array",
96
            "items": {
97
                'type': 'object',
98
                'properties': {
99
                    'id': {'type': 'string', 'pattern': '^[0-9]*$'},
100
                    'name': {'type': 'string'},
101
                    'shortName': {'type': 'string'},
102
                    'processingTime': {'type': 'string', 'pattern': '^[0-9]*$'},
103
                    'externalModuleAccess': {'type': 'string', 'pattern': '^[0-9]*$'},
104
                    'quantity': {'type': 'string', 'pattern': '^[0-9]*$'},
105
                    'usePremotiveQuantity': {'description': 'boolean expected', 'type': 'string'},
106
                },
107
            },
108
        },
109
    },
110
    'unflatten': True,
111
}
112

  
113

  
114
class ESirius(BaseResource, HTTPResource):
115
    secret_id = models.CharField(max_length=128, verbose_name=_('Application identifier'), blank=True)
116
    secret_key = models.CharField(max_length=128, verbose_name=_('Secret Key'), blank=True)
117
    base_url = models.CharField(
118
        max_length=256,
119
        blank=False,
120
        verbose_name=_('ePlanning webservices URL'),
121
        help_text=_('example: https://HOST/ePlanning/webservices/api/'),
122
    )
123

  
124
    category = _('Business Process Connectors')
125

  
126
    class Meta:
127
        verbose_name = _('eSirius')
128

  
129
    def request(self, uri, method='get', payload=None):
130
        url = urljoin(self.base_url, uri)
131

  
132
        des_key = pad(force_bytes(self.secret_key), 8)[:8]
133
        cipher = DES.new(des_key, DES.MODE_ECB)
134
        epoch = int(datetime.now().strftime('%s%f')[:-3])
135
        plaintext = '{"caller":"%s","createInfo":%i}' % (self.secret_id, epoch)
136
        msg = cipher.encrypt(pad(force_bytes(plaintext), 8))
137
        headers = {
138
            'Accept': 'application/json; charset=utf-8',
139
            'token_info_caller': base64.b64encode(msg),
140
        }
141

  
142
        if method == 'get':
143
            response = self.requests.get(url, headers=headers, params=payload)
144
        elif method == 'post':
145
            response = self.requests.post(url, headers=headers, json=payload)
146
        else:
147
            response = self.requests.delete(url, headers=headers)
148
            if response.status_code == 304:
149
                raise APIError('Booking not found')
150

  
151
        if response.status_code != 200:
152
            try:
153
                json_content = response.json()
154
            except ValueError:
155
                json_content = None
156
            raise APIError(
157
                'error status:%s %r, content:%r'
158
                % (response.status_code, response.reason, response.text[:1024]),
159
                data={'status_code': response.status_code, 'json_content': json_content},
160
            )
161
        return response
162

  
163
    def check_status(self):
164
        """
165
        Raise an exception if something goes wrong.
166
        """
167
        self.request('sites/')
168

  
169
    @endpoint(
170
        display_category=_('Appointment'),
171
        name='create-appointment',
172
        perm='can_access',
173
        methods=['post'],
174
        description=_('Create appointment'),
175
        post={'request_body': {'schema': {'application/json': CREATE_APPOINTMENT_SCHEMA}}},
176
    )
177
    def create_appointment(self, request, post_data):
178
        # address dict is required
179
        if not post_data.get('user'):
180
            post_data['user'] = {}
181
        if not post_data['user'].get('address'):
182
            post_data['user']['address'] = {}
183

  
184
        response = self.request('appointments/', 'post', payload=post_data)
185
        return {'data': {'booking_id': response.text}}
186

  
187
    @endpoint(
188
        display_category=_('Appointment'),
189
        name='get-appointment',
190
        perm='can_access',
191
        methods=['get'],
192
        description=_('Get appointment'),
193
        parameters={
194
            'booking_id': {'description': _('id returned by create-appointment endpoint')},
195
        },
196
    )
197
    def get_appointment(self, request, booking_id):
198
        response = self.request('appointments/%s/' % booking_id)
199
        return {'data': response.json()}
200

  
201
    @endpoint(
202
        display_category=_('Appointment'),
203
        name='delete-appointment',
204
        perm='can_access',
205
        methods=['post'],
206
        description=_('Delete appointment'),
207
        parameters={
208
            'booking_id': {'description': _('id returned by create-appointment endpoint')},
209
        },
210
    )
211
    def delete_appointment(self, request, booking_id):
212
        response = self.request('appointments/%s/' % booking_id, 'delete')
213
        return {'data': {}}
passerelle/settings.py
131 131
    'passerelle.apps.bdp',
132 132
    'passerelle.apps.cartads_cs',
133 133
    'passerelle.apps.choosit',
134 134
    'passerelle.apps.cityweb',
135 135
    'passerelle.apps.clicrdv',
136 136
    'passerelle.apps.cmis',
137 137
    'passerelle.apps.cryptor',
138 138
    'passerelle.apps.csvdatasource',
139
    'passerelle.apps.esirius',
139 140
    'passerelle.apps.family',
140 141
    'passerelle.apps.feeds',
141 142
    'passerelle.apps.gdc',
142 143
    'passerelle.apps.gesbac',
143 144
    'passerelle.apps.jsondatastore',
144 145
    'passerelle.apps.sp_fr',
145 146
    'passerelle.apps.maelis',
146 147
    'passerelle.apps.mdel',
tests/test_esirius.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021  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 json
18
import httmock
19
import pytest
20

  
21
from passerelle.apps.esirius.models import ESirius
22
from passerelle.utils.jsonresponse import APIError
23

  
24
import utils
25

  
26

  
27
CREATE_APPOINTMENT_PAYLOAD = {
28
    'beginDate': '2021-02-24',
29
    'beginTime': '16:40',
30
    'endDate': '2021-02-24',
31
    'endTime': '17:00',
32
    'comment': 'commentaire',
33
    'isoLanguage': 'fr',
34
    'needsConfirmation': 'False',
35
    'rdvChannel': 'WEBSERVICES',
36
    'receptionChannel': 'WS',
37
    'serviceId': '9',
38
    'siteCode': 'site1',
39
    'resources': {
40
        'id': '1',
41
        'key': '17',
42
        'type': 'STATION',
43
    },
44
}
45

  
46
GET_APPOINTMENT_RESPONSE = '''
47
{
48
   "beginDate" : "2021-02-26",
49
   "beginTime" : "16:40",
50
   "codeRDV" : "943A98",
51
   "comment" : "coucou",
52
   "endDate" : "2021-02-26",
53
   "endTime" : "17:00",
54
   "idSys" : 108840,
55
   "isoLanguage" : "fr",
56
   "motives" : [],
57
   "needsConfirmation" : false,
58
   "rdvChannel" : "EAPP0",
59
   "receptionChannel" : "WS",
60
   "resources" : {
61
      "id" : 29,
62
      "key" : "46",
63
      "name" : "C1",
64
      "type" : "STATION"
65
   },
66
   "serviceId" : "39",
67
   "siteCode" : "site1",
68
   "siteIdSys" : 5,
69
   "user" : {
70
      "additionalPersonalIdentity" : [],
71
      "address" : {},
72
      "civility" : "",
73
      "idSys" : "95897"
74
   }
75
}
76
'''
77

  
78

  
79
@pytest.fixture
80
def connector(db):
81
    return utils.setup_access_rights(
82
        ESirius.objects.create(
83
            slug='test', secret_id='xxx', secret_key='yyy', base_url='https://dummy-server.org'
84
        )
85
    )
86

  
87

  
88
def get_endpoint(name):
89
    return utils.generic_endpoint_url('esirius', name)
90

  
91

  
92
@pytest.mark.freeze_time('2021-01-26 15:13:6.880')
93
def test_token(connector):
94
    connector.secret_id = 'eAppointment'
95
    connector.secret_key = 'ES2I Info Caller Http Encryption Key'
96
    connector.save()
97

  
98
    @httmock.all_requests
99
    def sigerly_mock(url, request):
100
        assert (
101
            request.headers['token_info_caller']
102
            == b'yM4zYAxT67Qvjd20riG3j0eu0t0Ku+HLlttj17Gul7zkruFaXX1J8BJ6sV2Ldgw40axfWh+ESAY='
103
        )
104
        return httmock.response(200)
105

  
106
    with httmock.HTTMock(sigerly_mock):
107
        connector.request('an/uri/', payload="somes")
108

  
109

  
110
def test_pre_request(connector):
111
    @httmock.urlmatch(netloc='dummy-server.org', path='/an/uri/', method='GET')
112
    def sigerly_mock(url, request):
113
        assert request.headers['Accept'] == 'application/json; charset=utf-8'
114
        assert request.headers['token_info_caller'][:42] == b'f3G6sjRZETBam6vcdrAxmvJQTX5hh6OjZ8XlUO6SMo'
115
        return httmock.response(200)
116

  
117
    with httmock.HTTMock(sigerly_mock):
118
        connector.request('an/uri/', payload="somes")
119

  
120

  
121
@pytest.mark.parametrize(
122
    'status_code, content, a_dict',
123
    [
124
        (400, '{"message": "help"}', {'message': 'help'}),
125
        (500, 'not json', None),
126
    ],
127
)
128
def test_post_request(connector, status_code, content, a_dict):
129
    @httmock.urlmatch(netloc='dummy-server.org', path='/an/uri/', method='GET')
130
    def sigerly_mock(url, request):
131
        return httmock.response(status_code, content)
132

  
133
    with pytest.raises(APIError) as exc:
134
        with httmock.HTTMock(sigerly_mock):
135
            connector.request('an/uri/', payload="somes")
136

  
137
    assert exc.value.err
138
    assert exc.value.data['status_code'] == status_code
139
    assert exc.value.data['json_content'] == a_dict
140

  
141

  
142
@pytest.mark.parametrize(
143
    'status_code, content, is_up',
144
    [
145
        (200, 'wathever', True),
146
        (500, '{"message": "help"}', False),
147
    ],
148
)
149
def test_check_status(app, connector, status_code, content, is_up):
150
    @httmock.all_requests
151
    def sigerly_mock(url, request):
152
        return httmock.response(status_code, content)
153

  
154
    if is_up:
155
        with httmock.HTTMock(sigerly_mock):
156
            connector.check_status()
157
    else:
158
        with pytest.raises(APIError):
159
            with httmock.HTTMock(sigerly_mock):
160
                connector.check_status()
161

  
162

  
163
def test_create_appointment(app, connector):
164
    endpoint = get_endpoint('create-appointment')
165

  
166
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='POST')
167
    def sigerly_mock(url, request):
168
        return httmock.response(200, b'94PEP4')
169

  
170
    with httmock.HTTMock(sigerly_mock):
171
        resp = app.post_json(endpoint, params=CREATE_APPOINTMENT_PAYLOAD)
172

  
173
    assert not resp.json['err']
174
    assert resp.json['data'] == {'booking_id': '94PEP4'}
175

  
176

  
177
def test_create_appointment_error_404(app, connector):
178
    endpoint = get_endpoint('create-appointment')
179

  
180
    # payload not providing or probiding an unconfigured serviceId
181
    payload = CREATE_APPOINTMENT_PAYLOAD
182
    del payload['serviceId']
183

  
184
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='POST')
185
    def sigerly_mock(url, request):
186
        return httmock.response(
187
            404,
188
            {
189
                'code': 'Not Found',
190
                'type': 'com.es2i.planning.api.exception.NoService4RDVException',
191
                'message': "Le rendez-vous {0} n'a pas créé",
192
            },
193
        )
194

  
195
    with httmock.HTTMock(sigerly_mock):
196
        resp = app.post_json(endpoint, params=payload)
197

  
198
    assert resp.json['err']
199
    assert resp.json['data']['status_code'] == 404
200
    assert resp.json['data']['json_content'] == {
201
        'code': 'Not Found',
202
        'type': 'com.es2i.planning.api.exception.NoService4RDVException',
203
        'message': "Le rendez-vous {0} n'a pas créé",
204
    }
205

  
206

  
207
def test_create_appointment_error_500(app, connector):
208
    endpoint = get_endpoint('create-appointment')
209

  
210
    # payload not providing beginTime
211
    payload = {'beginDate': '2021-02-23'}
212

  
213
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='POST')
214
    def sigerly_mock(url, request):
215
        return httmock.response(500, 'java stack')
216

  
217
    with httmock.HTTMock(sigerly_mock):
218
        resp = app.post_json(endpoint, params=payload)
219

  
220
    assert resp.json['err']
221
    assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
222
    assert resp.json['err_desc'] == "error status:500 None, content:'java stack'"
223
    assert resp.json['data']['status_code'] == 500
224
    assert resp.json['data']['json_content'] is None
225

  
226

  
227
def test_get_appointment(app, connector):
228
    endpoint = get_endpoint('get-appointment')
229

  
230
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='GET')
231
    def sigerly_mock(url, request):
232
        return httmock.response(200, GET_APPOINTMENT_RESPONSE)
233

  
234
    with httmock.HTTMock(sigerly_mock):
235
        resp = app.get(endpoint + '?booking_id=94PEP4')
236

  
237
    assert not resp.json['err']
238
    assert resp.json['data']['codeRDV']
239
    assert resp.json['data'] == json.loads(GET_APPOINTMENT_RESPONSE)
240

  
241

  
242
def test_get_appointment_error(app, connector):
243
    endpoint = get_endpoint('get-appointment')
244

  
245
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='GET')
246
    def sigerly_mock(url, request):
247
        return httmock.response(404, '{"code":"Not Found","message":"Le rendez-vous {0} n\'existe pas"}')
248

  
249
    with httmock.HTTMock(sigerly_mock):
250
        resp = app.get(endpoint + '?booking_id=94PEP4')
251

  
252
    assert resp.json['err']
253
    assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
254
    assert resp.json['data']['status_code'] == 404
255
    assert resp.json['data']['json_content'] == {
256
        "code": "Not Found",
257
        "message": "Le rendez-vous {0} n'existe pas",
258
    }
259

  
260

  
261
def test_delete_appointment(app, connector):
262
    endpoint = get_endpoint('delete-appointment')
263

  
264
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='DELETE')
265
    def sigerly_mock(url, request):
266
        return httmock.response(200, b'')
267

  
268
    with httmock.HTTMock(sigerly_mock):
269
        resp = app.post_json(endpoint + '?booking_id=94PEP4')
270

  
271
    assert not resp.json['err']
272
    assert resp.json['data'] == {}
273

  
274

  
275
def test_delete_appointment_error(app, connector):
276
    endpoint = get_endpoint('delete-appointment')
277

  
278
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='DELETE')
279
    def sigerly_mock(url, request):
280
        return httmock.response(304, b'')
281

  
282
    with httmock.HTTMock(sigerly_mock):
283
        resp = app.post_json(endpoint + '?booking_id=94PEP4')
284

  
285
    assert resp.json['err']
286
    assert resp.json['err_desc'] == 'Booking not found'
0
-