0001-start-apiaccess-infrastructure-63523.patch
debian/debian_config_common.py | ||
---|---|---|
398 | 398 |
if 'rest_framework' in INSTALLED_APPS: |
399 | 399 |
if 'REST_FRAMEWORK' not in globals(): |
400 | 400 |
REST_FRAMEWORK = {} |
401 |
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = ('hobo.rest_authentication.PublikAuthentication',) |
|
401 |
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = ( |
|
402 |
'hobo.rest_authentication.PublikAuthentication', |
|
403 |
'hobo.rest_authentication.APIAccessAuthentication', |
|
404 |
) |
|
402 | 405 |
REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',) |
403 | 406 |
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ('rest_framework.renderers.JSONRenderer',) |
404 | 407 |
debian/server/uwsgi.ini | ||
---|---|---|
16 | 16 |
spooler-python-import = hobo.provisionning.spooler |
17 | 17 |
spooler-max-tasks = 20 |
18 | 18 | |
19 |
# hourly |
|
20 |
unique-cron = 1 -1 -1 -1 -1 /usr/bin/hobo-manage tenant_command provision_apiaccess --all-tenants |
|
21 | ||
19 | 22 |
master = true |
20 | 23 |
enable-threads = true |
21 | 24 |
harakiri = 120 |
hobo/agent/common/migrations/0005_apiaccess.py | ||
---|---|---|
1 |
# Generated by Django 2.2.26 on 2022-06-01 10:19 |
|
2 | ||
3 |
from django.db import migrations, models |
|
4 | ||
5 | ||
6 |
class Migration(migrations.Migration): |
|
7 | ||
8 |
dependencies = [ |
|
9 |
('common', '0004_alter_role_uuid'), |
|
10 |
] |
|
11 | ||
12 |
operations = [ |
|
13 |
migrations.CreateModel( |
|
14 |
name='APIAccess', |
|
15 |
fields=[ |
|
16 |
( |
|
17 |
'id', |
|
18 |
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), |
|
19 |
), |
|
20 |
('name', models.CharField(max_length=128, unique=True, verbose_name='name')), |
|
21 |
('identifier', models.CharField(max_length=128, unique=True, verbose_name='identifier')), |
|
22 |
('key', models.CharField(max_length=128, verbose_name='key')), |
|
23 |
('description', models.CharField(blank=True, max_length=128, verbose_name='description')), |
|
24 |
], |
|
25 |
), |
|
26 |
] |
hobo/agent/common/models.py | ||
---|---|---|
1 | 1 |
from django.contrib.auth.models import Group |
2 | 2 |
from django.contrib.postgres.fields import ArrayField |
3 | 3 |
from django.db import models |
4 |
from django.utils.translation import ugettext_lazy as _ |
|
4 | 5 | |
5 | 6 | |
6 | 7 |
class Role(Group): |
... | ... | |
11 | 12 |
emails_to_members = models.BooleanField(default=True) |
12 | 13 | |
13 | 14 |
objects = models.Manager() |
15 | ||
16 | ||
17 |
class APIAccess(models.Model): |
|
18 |
name = models.CharField(max_length=128, unique=True, verbose_name=_('name')) |
|
19 |
identifier = models.CharField(max_length=128, unique=True, verbose_name=_('identifier')) |
|
20 |
key = models.CharField(max_length=128, verbose_name=_('key')) |
|
21 |
description = models.CharField(max_length=128, blank=True, verbose_name=_('description')) |
hobo/apiaccess/management/commands/add_apiaccess.py | ||
---|---|---|
1 |
# hobo - portal to configure and deploy applications |
|
2 |
# Copyright (C) 2015-2022 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 uuid |
|
18 | ||
19 |
from django.core.management.base import BaseCommand, CommandError |
|
20 | ||
21 |
from hobo.agent.common.models import APIAccess |
|
22 |
from hobo.apiaccess.provisionning import notify_agents |
|
23 | ||
24 | ||
25 |
class Command(BaseCommand): |
|
26 |
help = 'Add an API Access entry' |
|
27 | ||
28 |
def add_arguments(self, parser): |
|
29 |
parser.add_argument('name', type=str) |
|
30 | ||
31 |
def handle(self, *args, **options): |
|
32 |
name = options['name'] |
|
33 |
try: |
|
34 |
apiaccess = APIAccess.objects.get(name=name) |
|
35 |
self.stdout.write(self.style.WARNING('An API Access entry with this name already exists.')) |
|
36 |
except APIAccess.DoesNotExist: |
|
37 |
apiaccess = APIAccess.objects.create( |
|
38 |
identifier=str(uuid.uuid4()), name=name, key=str(uuid.uuid4()) |
|
39 |
) |
|
40 |
notify_agents([apiaccess]) |
|
41 |
self.stdout.write(self.style.SUCCESS('API Access created.')) |
|
42 |
self.stdout.write(self.style.SUCCESS('Identifier: %s' % apiaccess.identifier)) |
|
43 |
self.stdout.write(self.style.SUCCESS('Key: %s' % apiaccess.key)) |
hobo/apiaccess/management/commands/provision_apiaccess.py | ||
---|---|---|
1 |
# hobo - portal to configure and deploy applications |
|
2 |
# Copyright (C) 2015-2022 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 uuid |
|
18 | ||
19 |
from django.core.management.base import BaseCommand, CommandError |
|
20 | ||
21 |
from hobo.agent.common.models import APIAccess |
|
22 |
from hobo.apiaccess.provisionning import notify_agents |
|
23 | ||
24 | ||
25 |
class Command(BaseCommand): |
|
26 |
help = 'Provision api access' |
|
27 | ||
28 |
def handle(self, *args, **options): |
|
29 |
notify_agents((apiaccess for apiaccess in APIAccess.objects.all()), full=True) |
hobo/apiaccess/provisionning.py | ||
---|---|---|
1 |
# hobo - portal to configure and deploy applications |
|
2 |
# Copyright (C) 2015-2022 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 requests |
|
18 |
from django.conf import settings |
|
19 | ||
20 |
from hobo.environment.utils import get_installed_services |
|
21 |
from hobo.signature import sign_url |
|
22 | ||
23 | ||
24 |
def notify_agents(apiaccesses, mode='provision', full=False, sync=False): |
|
25 |
data = { |
|
26 |
'@type': mode, |
|
27 |
'full': full, |
|
28 |
'objects': { |
|
29 |
'@type': 'apiaccess', |
|
30 |
'data': [ |
|
31 |
{ |
|
32 |
'identifier': apiaccess.identifier, |
|
33 |
'key': apiaccess.key, |
|
34 |
'name': apiaccess.name, |
|
35 |
'description': apiaccess.description, |
|
36 |
} |
|
37 |
for apiaccess in apiaccesses |
|
38 |
], |
|
39 |
}, |
|
40 |
} |
|
41 |
known_services = getattr(settings, 'KNOWN_SERVICES', {}) |
|
42 |
for service_id, services in known_services.items(): |
|
43 |
if service_id in ('hobo', 'wcs'): |
|
44 |
continue |
|
45 |
for service in services.values(): |
|
46 |
if service['secondary']: |
|
47 |
continue |
|
48 |
provisionning_url = service.get('provisionning-url') |
|
49 |
if not provisionning_url: |
|
50 |
continue |
|
51 |
url = service['provisionning-url'] + '?orig=%s' % service['orig'] |
|
52 |
if sync: |
|
53 |
url += '&sync=1' |
|
54 |
try: |
|
55 |
response = requests.put(sign_url(url, service['secret']), json=data) |
|
56 |
response.raise_for_status() |
|
57 |
except requests.RequestException as e: |
|
58 |
pass |
hobo/provisionning/utils.py | ||
---|---|---|
22 | 22 |
from django.db.models.query import Q |
23 | 23 |
from django.db.transaction import atomic |
24 | 24 | |
25 |
from hobo.agent.common.models import Role |
|
25 |
from hobo.agent.common.models import APIAccess, Role
|
|
26 | 26 |
from hobo.multitenant.utils import provision_user_groups |
27 | 27 | |
28 | 28 |
logger = logging.getLogger(__name__) |
... | ... | |
250 | 250 |
) |
251 | 251 |
qs.delete() |
252 | 252 | |
253 |
@classmethod |
|
254 |
def provision_apiaccess(cls, issuer, action, data, full=False): |
|
255 |
if action == 'provision': |
|
256 |
identifiers = [] |
|
257 |
for record in data: |
|
258 |
try: |
|
259 |
apiaccess = APIAccess.objects.get(identifier=record['identifier']) |
|
260 |
apiaccess.name = record['name'] |
|
261 |
apiaccess.key = record['key'] |
|
262 |
apiaccess.description = record['description'] |
|
263 |
apiaccess.save() |
|
264 |
except APIAccess.DoesNotExist: |
|
265 |
APIAccess.objects.create( |
|
266 |
identifier=record['identifier'], |
|
267 |
key=record['key'], |
|
268 |
name=record['name'], |
|
269 |
description=record['description'], |
|
270 |
) |
|
271 |
identifiers.append(record['identifier']) |
|
272 |
if full: |
|
273 |
APIAccess.objects.exclude(identifier__in=identifiers).delete() |
|
274 | ||
275 |
if action == 'deprovision': |
|
276 |
for record in data: |
|
277 |
try: |
|
278 |
apiacess = APIAccess.objects.get(key=record['key']).delete() |
|
279 |
except APIAccess.DoesNotExist: |
|
280 |
pass |
|
281 | ||
253 | 282 |
@classmethod |
254 | 283 |
def provision(cls, object_type, issuer, action, data, full): |
255 | 284 |
for i in range(20): |
hobo/rest_authentication.py | ||
---|---|---|
8 | 8 |
from rest_framework import authentication, exceptions, status |
9 | 9 | |
10 | 10 |
from hobo import signature |
11 |
from hobo.agent.common.models import APIAccess |
|
11 | 12 | |
12 | 13 |
try: |
13 | 14 |
from mellon.models import UserSAMLIdentifier |
... | ... | |
125 | 126 |
user = self.resolve_user(request) |
126 | 127 |
self.logger.info('user authenticated with signature %s', user) |
127 | 128 |
return (user, None) |
129 | ||
130 | ||
131 |
class APIAccessAuthenticationFailed(exceptions.APIException): |
|
132 |
status_code = status.HTTP_401_UNAUTHORIZED |
|
133 | ||
134 |
def __init__(self, err_desc): |
|
135 |
self.detail = {'err': 1, 'err_desc': err_desc} |
|
136 | ||
137 | ||
138 |
class APIAccessAuthentication(authentication.BasicAuthentication): |
|
139 |
def authenticate_credentials(self, identifier, key, request=None): |
|
140 |
try: |
|
141 |
APIAccess.objects.get(identifier=identifier, key=key) |
|
142 |
except APIAccess.DoesNotExist: |
|
143 |
raise APIAccessAuthenticationFailed('apiaccess-not-found') |
|
144 | ||
145 |
if hasattr(settings, 'HOBO_ANONYMOUS_SERVICE_USER_CLASS'): |
|
146 |
klass = import_string(settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS) |
|
147 |
return (klass(), None) |
|
148 |
raise APIAccessAuthenticationFailed('apiaccess-configuration-error') |
hobo/settings.py | ||
---|---|---|
42 | 42 |
'rest_framework', |
43 | 43 |
'mellon', |
44 | 44 |
'gadjo', |
45 |
'hobo.apiaccess', |
|
45 | 46 |
'hobo.applications', |
46 | 47 |
'hobo.debug', |
47 | 48 |
'hobo.environment', |
tests/test_apiaccess.py | ||
---|---|---|
1 |
import base64 |
|
2 |
from io import StringIO |
|
3 | ||
4 |
import mock |
|
5 |
import pytest |
|
6 |
import responses |
|
7 |
from django.core.management import call_command |
|
8 |
from django.test import RequestFactory |
|
9 | ||
10 |
import hobo.apiaccess.management.commands.add_apiaccess |
|
11 |
from hobo.agent.common.models import APIAccess |
|
12 |
from hobo.apiaccess.provisionning import notify_agents |
|
13 |
from hobo.rest_authentication import APIAccessAuthentication, APIAccessAuthenticationFailed |
|
14 |
from hobo.signature import sign_url |
|
15 | ||
16 | ||
17 |
def test_add_apiaccess_command(db, monkeypatch): |
|
18 |
assert APIAccess.objects.count() == 0 |
|
19 |
notify_agents = mock.Mock() |
|
20 |
monkeypatch.setattr(hobo.apiaccess.management.commands.add_apiaccess, 'notify_agents', notify_agents) |
|
21 |
out = StringIO() |
|
22 |
call_command('add_apiaccess', 'foo', stdout=out) |
|
23 |
assert APIAccess.objects.count() == 1 |
|
24 |
apiaccess = APIAccess.objects.get(name='foo') |
|
25 |
notify_agents.assert_called_once_with([apiaccess]) |
|
26 |
stdout = out.getvalue() |
|
27 |
assert 'API Access created.' in stdout |
|
28 |
assert 'Identifier: %s' % apiaccess.identifier in stdout |
|
29 |
assert 'Key: %s' % apiaccess.key in stdout |
|
30 | ||
31 |
notify_agents.reset_mock() |
|
32 |
out = StringIO() |
|
33 |
call_command('add_apiaccess', 'foo', stdout=out) |
|
34 |
assert APIAccess.objects.count() == 1 |
|
35 |
notify_agents.assert_not_called() |
|
36 |
assert 'An API Access entry with this name already exists.' in out.getvalue() |
|
37 | ||
38 | ||
39 |
def test_apiaccess_authentication(db, settings): |
|
40 |
settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS = 'hobo.rest_authentication.AnonymousAdminServiceUser' |
|
41 |
URL = '/api/' |
|
42 |
auth_headers = { |
|
43 |
'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('foo:bar'.encode('utf-8')).decode('utf-8'), |
|
44 |
} |
|
45 |
factory = RequestFactory() |
|
46 |
request = factory.get('/api/', **auth_headers) |
|
47 |
apiaccess_authentication = APIAccessAuthentication() |
|
48 | ||
49 |
with pytest.raises(APIAccessAuthenticationFailed) as excinfo: |
|
50 |
apiaccess_authentication.authenticate(request) |
|
51 |
assert 'apiaccess-not-found' in str(excinfo.value) |
|
52 | ||
53 |
APIAccess.objects.create(identifier='foo', key='bar') |
|
54 |
result = apiaccess_authentication.authenticate(request) |
|
55 |
assert isinstance(result, tuple) |
|
56 |
assert len(result) == 2 |
|
57 |
assert result[1] is None |
|
58 |
user = result[0] |
|
59 |
assert user.is_staff |
|
60 |
assert user.is_anonymous |
|
61 |
assert user.is_authenticated |
|
62 | ||
63 | ||
64 |
def test_apiaccess_notify_agents(db, settings): |
|
65 |
settings.KNOWN_SERVICES = { |
|
66 |
'chrono': { |
|
67 |
'foo': { |
|
68 |
'title': 'Foo', |
|
69 |
'url': 'https://chrono.example.invalid/', |
|
70 |
'orig': 'example.org', |
|
71 |
'secret': 'xxx', |
|
72 |
'provisionning-url': 'https://chrono.example.invalid/__provision__/', |
|
73 |
'secondary': False, |
|
74 |
} |
|
75 |
}, |
|
76 |
'combo': { |
|
77 |
'bar': { |
|
78 |
'title': 'Bar', |
|
79 |
'url': 'https://combo.example.invalid/', |
|
80 |
'orig': 'example.org', |
|
81 |
'secret': 'xxx', |
|
82 |
'provisionning-url': 'https://combo.example.invalid/__provision__/', |
|
83 |
'secondary': False, |
|
84 |
} |
|
85 |
}, |
|
86 |
} |
|
87 |
with responses.RequestsMock() as rsps: |
|
88 |
rsps.put( |
|
89 |
'https://chrono.example.invalid/__provision__/', |
|
90 |
status=200, |
|
91 |
match=[responses.matchers.query_param_matcher({}, strict_match=False)], |
|
92 |
) |
|
93 |
rsps.put( |
|
94 |
'https://combo.example.invalid/__provision__/', |
|
95 |
status=200, |
|
96 |
match=[responses.matchers.query_param_matcher({}, strict_match=False)], |
|
97 |
) |
|
98 |
apiaccess = APIAccess.objects.create(identifier='foo', key='bar', name='Foo') |
|
99 |
notify_agents([apiaccess]) |
|
100 |
assert len(rsps.calls) == 2 |
|
101 | ||
102 | ||
103 |
def test_apiaccess_provision(db, app, settings): |
|
104 |
settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS = 'hobo.rest_authentication.AnonymousAdminServiceUser' |
|
105 |
settings.KNOWN_SERVICES = { |
|
106 |
'chrono': { |
|
107 |
'foo': { |
|
108 |
'title': 'Foo', |
|
109 |
'url': 'https://chrono.example.invalid/', |
|
110 |
'verif_orig': 'chrono.example.invalid', |
|
111 |
'secret': 'xxx', |
|
112 |
'provisionning-url': 'https://chrono.example.invalid/__provision__/', |
|
113 |
'secondary': False, |
|
114 |
} |
|
115 |
}, |
|
116 |
'authentic': { |
|
117 |
'bar': { |
|
118 |
'title': 'Bar', |
|
119 |
'url': 'https://authentic.example.invalid/', |
|
120 |
'verif_orig': 'authentic.example.invalid', |
|
121 |
'secret': 'xxx', |
|
122 |
'provisionning-url': 'https://authentic.example.invalid/__provision__/', |
|
123 |
'secondary': False, |
|
124 |
} |
|
125 |
}, |
|
126 |
'hobo': { |
|
127 |
'hobo': { |
|
128 |
'title': 'Hobo', |
|
129 |
'url': 'https://hobo.example.invalid/', |
|
130 |
'verif_orig': 'hobo.example.invalid', |
|
131 |
'secret': 'xxx', |
|
132 |
'provisionning-url': 'https://hobo.example.invalid/__provision__/', |
|
133 |
'secondary': False, |
|
134 |
} |
|
135 |
}, |
|
136 |
} |
|
137 | ||
138 |
# create one api access |
|
139 |
notification = { |
|
140 |
'@type': 'provision', |
|
141 |
'objects': { |
|
142 |
'@type': 'apiaccess', |
|
143 |
'data': [{'key': 'key', 'identifier': 'id', 'name': 'name', 'description': 'description'}], |
|
144 |
}, |
|
145 |
} |
|
146 |
assert APIAccess.objects.count() == 0 |
|
147 |
app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification) |
|
148 |
assert APIAccess.objects.count() == 1 |
|
149 |
apiaccess = APIAccess.objects.get(identifier='id') |
|
150 |
assert apiaccess.key == 'key' |
|
151 |
assert apiaccess.name == 'name' |
|
152 |
assert apiaccess.description == 'description' |
|
153 | ||
154 |
# update the api access |
|
155 |
notification = { |
|
156 |
'@type': 'provision', |
|
157 |
'objects': { |
|
158 |
'@type': 'apiaccess', |
|
159 |
'data': [ |
|
160 |
{'key': 'new-key', 'identifier': 'id', 'name': 'new-name', 'description': 'new-description'} |
|
161 |
], |
|
162 |
}, |
|
163 |
} |
|
164 |
app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification) |
|
165 |
assert APIAccess.objects.count() == 1 |
|
166 |
apiaccess = APIAccess.objects.get(identifier='id') |
|
167 |
assert apiaccess.key == 'new-key' |
|
168 |
assert apiaccess.name == 'new-name' |
|
169 |
assert apiaccess.description == 'new-description' |
|
170 | ||
171 |
# delete the api access |
|
172 |
notification = { |
|
173 |
'@type': 'deprovision', |
|
174 |
'objects': { |
|
175 |
'@type': 'apiaccess', |
|
176 |
'data': [ |
|
177 |
{'key': 'new-key', 'identifier': 'id', 'name': 'new-name', 'description': 'new-description'} |
|
178 |
], |
|
179 |
}, |
|
180 |
} |
|
181 |
app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification) |
|
182 |
assert APIAccess.objects.count() == 0 |
|
183 | ||
184 |
# create three api access |
|
185 |
notification = { |
|
186 |
'@type': 'provision', |
|
187 |
'objects': { |
|
188 |
'@type': 'apiaccess', |
|
189 |
'data': [ |
|
190 |
{'key': 'key1', 'identifier': 'id1', 'name': 'name1', 'description': 'description1'}, |
|
191 |
{'key': 'key2', 'identifier': 'id2', 'name': 'name2', 'description': 'description2'}, |
|
192 |
{'key': 'key3', 'identifier': 'id3', 'name': 'name3', 'description': 'description3'}, |
|
193 |
], |
|
194 |
}, |
|
195 |
} |
|
196 |
assert APIAccess.objects.count() == 0 |
|
197 |
app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification) |
|
198 |
assert APIAccess.objects.count() == 3 |
|
199 |
assert APIAccess.objects.get(identifier='id1') |
|
200 |
assert APIAccess.objects.get(identifier='id2') |
|
201 |
assert APIAccess.objects.get(identifier='id3') |
|
202 | ||
203 |
# full provisionning |
|
204 |
notification = { |
|
205 |
'@type': 'provision', |
|
206 |
'full': True, |
|
207 |
'objects': { |
|
208 |
'@type': 'apiaccess', |
|
209 |
'data': [ |
|
210 |
{'key': 'key1', 'identifier': 'id1', 'name': 'name1', 'description': 'description1'}, |
|
211 |
{'key': 'key2', 'identifier': 'id2', 'name': 'name2', 'description': 'description2'}, |
|
212 |
], |
|
213 |
}, |
|
214 |
} |
|
215 |
app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification) |
|
216 |
assert APIAccess.objects.count() == 2 |
|
217 |
assert APIAccess.objects.get(key='key1') |
|
218 |
assert APIAccess.objects.get(key='key2') |
tox.ini | ||
---|---|---|
56 | 56 |
enum34<=1.1.6 |
57 | 57 |
psycopg2<2.9 |
58 | 58 |
psycopg2-binary<2.9 |
59 |
responses |
|
59 | 60 |
black: pre-commit |
60 | 61 |
commands = |
61 | 62 |
./getlasso3.sh |
62 |
- |