Merge lp:~maxiberta/canonical-identity-provider/readonly-improvements into lp:canonical-identity-provider/release

Proposed by Maximiliano Bertacchini
Status: Merged
Approved by: Maximiliano Bertacchini
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~maxiberta/canonical-identity-provider/readonly-improvements
Merge into: lp:canonical-identity-provider/release
Diff against target: 1396 lines (+170/-738)
19 files modified
django_project/settings_base.py (+0/-2)
src/identityprovider/management/commands/readonly.py (+11/-49)
src/identityprovider/readonly.py (+1/-92)
src/identityprovider/templates/admin/readonly.html (+0/-66)
src/identityprovider/templates/admin/readonly.txt (+0/-8)
src/identityprovider/templates/admin/readonly_confirm.html (+0/-70)
src/identityprovider/tests/test_auth.py (+4/-8)
src/identityprovider/tests/test_command_readonly.py (+3/-52)
src/identityprovider/tests/test_models_account.py (+5/-10)
src/identityprovider/tests/test_models_openidmodels.py (+5/-14)
src/identityprovider/tests/test_readonly.py (+2/-253)
src/identityprovider/tests/test_signals.py (+10/-19)
src/identityprovider/tests/utils.py (+13/-1)
src/identityprovider/urls.py (+0/-12)
src/identityprovider/views/readonly.py (+0/-64)
src/webui/tests/test_templates.py (+3/-6)
src/webui/tests/test_views_account.py (+64/-3)
src/webui/tests/test_views_registration.py (+32/-2)
src/webui/tests/test_views_ui.py (+17/-7)
To merge this branch: bzr merge lp:~maxiberta/canonical-identity-provider/readonly-improvements
Reviewer Review Type Date Requested Status
Natalia Bidart (community) Approve
Daniel Manrique (community) Approve
Review via email: mp+374313@code.launchpad.net

Commit message

Readonly cleanup: drop API for remote management of readonly flag (now handled via juju); simplify the 'readonly' management command.

Description of the change

Adds a number of tests around readonly mode that were missing.

Additionally, fix/workaround the readonly management command which was basically broken due to django's arg parser prefix matching "--set" to "--settings", which resulted in `manage readonly --set` crashing with AttributeError. This command is now intended for local development only (`manage readonly [set|clear]`).

To post a comment you must log in.
Revision history for this message
Daniel Manrique (roadmr) wrote :

LGTM, maybe worth checking with Natalia to see if she can spot any remaining needs for that remote readonly API, but it definitely seems redundand in the Juju world which can go into each unit and run the command for us (fwiw other tools such as ansible could also do this so definitely an API for this feels overkill).

review: Approve
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Looks great, thanks for the added tests.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'django_project/settings_base.py'
--- django_project/settings_base.py 2019-09-25 20:39:50 +0000
+++ django_project/settings_base.py 2019-10-22 14:04:10 +0000
@@ -41,7 +41,6 @@
41 API_HOST += ':%s' % parsed_url.port41 API_HOST += ':%s' % parsed_url.port
42API_URL = '/api/v2'42API_URL = '/api/v2'
43APPEND_SLASH = True43APPEND_SLASH = True
44APP_SERVERS = []
45AUTHENTICATION_BACKENDS = [44AUTHENTICATION_BACKENDS = [
46 'identityprovider.auth.LaunchpadBackend',45 'identityprovider.auth.LaunchpadBackend',
47 # restore ModelBackend until admin login gets properly fixed46 # restore ModelBackend until admin login gets properly fixed
@@ -480,7 +479,6 @@
480 'canonical_raven.processors.SanitizeTimelineDjangoProcessor',479 'canonical_raven.processors.SanitizeTimelineDjangoProcessor',
481 ],480 ],
482}481}
483READONLY_SECRET = 'CHANGEME'
484READ_ONLY_MODE = False482READ_ONLY_MODE = False
485ROOT_URLCONF = 'django_project.urls'483ROOT_URLCONF = 'django_project.urls'
486SAML2IDP_CONFIG = {484SAML2IDP_CONFIG = {
487485
=== modified file 'src/identityprovider/management/commands/readonly.py'
--- src/identityprovider/management/commands/readonly.py 2018-01-29 01:46:19 +0000
+++ src/identityprovider/management/commands/readonly.py 2019-10-22 14:04:10 +0000
@@ -1,66 +1,28 @@
1# Copyright 2010-2018 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4from __future__ import print_function4from __future__ import print_function
55
6from django.conf import settings
7from django.core.management.base import BaseCommand, CommandError6from django.core.management.base import BaseCommand, CommandError
8from django.template import loader
97
10from identityprovider.readonly import get_server_atts, update_server8from identityprovider.readonly import ReadOnlyManager
119
1210
13class Command(BaseCommand):11class Command(BaseCommand):
14 help = ('Manage readonly mode.\n\n'12 help = ('Manage readonly mode.\n\n'
15 'options --set and --clear are all mutually exclusive.\n'13 'Actions "set" and "clear" are all mutually exclusive.\n'
16 'You can only choose one at a time.')14 'You can only choose one at a time.')
1715
18 def add_arguments(self, parser):16 def add_arguments(self, parser):
19 parser.add_argument(17 parser.add_argument('action', help='One of "set" or "clear".')
20 '--list', action='store_true', dest='list_servers', default=False,
21 help='List available application servers.')
22 parser.add_argument(
23 '--all', action='store_true', dest='all_servers',
24 default=False, help='Select all servers.')
25 parser.add_argument(
26 '--set', action='store_const', const='set',
27 dest='action', help='Set server to read-only.')
28 parser.add_argument(
29 '--clear', action='store_const', const='clear',
30 dest='action', help='Set server to read-write.')
31 parser.add_argument('servers', metavar='server', nargs='*')
3218
33 def handle(self, *args, **options):19 def handle(self, *args, **options):
34 servers = options.get('servers')20 if options['action'] == 'set':
35 list_servers = options.get('list_servers')21 ReadOnlyManager().set_readonly()
36 all_servers = options.get('all_servers')22 self.stdout.write('Readonly mode set')
37 action = options.get('action')23 elif options['action'] == 'clear':
3824 ReadOnlyManager().clear_readonly()
39 # determine servers to act upon25 self.stdout.write('Readonly mode cleared')
40 if all_servers:
41 servers = [app['SERVER_ID'] for app in settings.APP_SERVERS]
42 servers.sort()
43 else:26 else:
44 msgs = (27 msg = 'Enter one of "set" or "clear".'
45 'Enter at least one server, or specify the --all option.',
46 'Use --list to get a list of configured servers.',
47 )
48 if not servers and not list_servers:
49 raise CommandError('\n '.join(msgs))
50
51 # determine action to perform
52 if action is not None:
53 for server in servers:
54 update_server(action, server)
55 elif not list_servers:
56 msg = 'Enter one of --set or --clear.'
57 raise CommandError(msg)28 raise CommandError(msg)
58
59 # list action is special as it can be combined with the other actions
60 if list_servers:
61 self.show_servers()
62
63 def show_servers(self):
64 """Provides a report about readonly status of all app servers."""
65 atts = get_server_atts(settings.APP_SERVERS)
66 print(loader.render_to_string('admin/readonly.txt', atts))
6729
=== modified file 'src/identityprovider/readonly.py'
--- src/identityprovider/readonly.py 2018-01-28 12:43:01 +0000
+++ src/identityprovider/readonly.py 2019-10-22 14:04:10 +0000
@@ -1,11 +1,9 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import json
5import os4import os
6import stat5import stat
76
8import requests
9from django.conf import settings7from django.conf import settings
108
11# When a request arrives, the middleware will:9# When a request arrives, the middleware will:
@@ -31,95 +29,6 @@
31# automatic=True, as they manage manual overrides.29# automatic=True, as they manage manual overrides.
3230
3331
34def _remote_req(host, port=None, scheme=None, server_id=None,
35 virtual_host=None, post=None):
36 """Makes a request to a specific appserver.
37
38 The first five arguments are the same as the keys for each dictionary in
39 the APP_SERVERS setting:
40
41 * host: The host at which we can reach the app server (can be an IP)
42 * port: The port that should be used (optional)
43 * scheme: The scheme to use to contact this app server (http/https)
44 (defaults to http)
45 * server_id: Some canonical name for this app server (optional)
46 * virtual_host: The virtual host that should be used, for app servers that
47 serve multiple sites (optional).
48
49 If post is provided, it should be a sequence of 2-tuples that will be
50 encoded in to POST data.
51 """
52 if post is None:
53 post = []
54 post.append(('secret', settings.READONLY_SECRET))
55
56 if scheme is None:
57 scheme = 'http'
58 if port is None:
59 portstr = ''
60 else:
61 portstr = ':' + port
62 url = '%s://%s%s/readonlydata' % (scheme, host, portstr)
63 headers = {}
64 if virtual_host is not None:
65 headers['Host'] = virtual_host
66 try:
67 response = requests.post(url, headers=headers, data=post, timeout=5)
68 data = response.content
69 except requests.RequestException:
70 data = None
71 return data
72
73
74def lowercase_keys(dicts):
75 """ Convert all keys in a list of dicts to lowercase. """
76 return [dict((k.lower(), v) for (k, v) in d.items()) for d in dicts]
77
78
79def get_server_atts(servers):
80 """Provides a report about readonly status of all app servers."""
81 appservers = []
82 set_all_readonly = False
83 clear_all_readonly = False
84 for server in lowercase_keys(servers):
85 datastr = _remote_req(**server)
86 if datastr is None:
87 data = {'reachable': False}
88 else:
89 try:
90 data = json.loads(datastr)
91 except ValueError:
92 # This is probably caused by the remote database being out
93 # of sync with our current database
94 data = {'reachable': False}
95 else:
96 data['reachable'] = True
97 if data.get('readonly'):
98 clear_all_readonly = True
99 else:
100 set_all_readonly = True
101 data['name'] = server['server_id']
102 appservers.append(data)
103 atts = {
104 'appservers': appservers,
105 'clear_all_readonly': clear_all_readonly,
106 'set_all_readonly': set_all_readonly,
107 }
108 return atts
109
110
111def update_server(action, appserver=None):
112 if appserver is None:
113 appservers = settings.APP_SERVERS
114 else:
115 appservers = [server for server in settings.APP_SERVERS
116 if server['SERVER_ID'] == appserver]
117 appservers = lowercase_keys(appservers)
118 for server in appservers:
119 post = [('action', action)]
120 _remote_req(post=post, **server)
121
122
123class ReadOnlyManager(object):32class ReadOnlyManager(object):
12433
125 @property34 @property
12635
=== removed file 'src/identityprovider/templates/admin/readonly.html'
--- src/identityprovider/templates/admin/readonly.html 2013-06-20 15:37:44 +0000
+++ src/identityprovider/templates/admin/readonly.html 1970-01-01 00:00:00 +0000
@@ -1,66 +0,0 @@
1{% extends "admin/index.html" %}
2{% load i18n staticfiles %}
3
4{% comment %}
5Copyright 2010 Canonical Ltd. This software is licensed under the
6GNU Affero General Public License version 3 (see the file LICENSE).
7{% endcomment %}
8
9{% block breadcrumbs %}
10<div class="breadcrumbs"><a href="/admin/">
11{% trans "Home" %}</a> &rsaquo;
12<a href="/admin/identityprovider/">Identityprovider</a> &rsaquo;
13{% trans "Readonly Admin" %}
14</div>
15{% endblock %}
16
17{% block content %}
18<div id="content-main">
19 <h1>{% trans "Readonly status per application server" %}</h1>
20 {% for server in appservers %}
21 <div class="module">
22 <table summary="DB connection on {{server.name}}.">
23 <caption>{{server.name}}</caption>
24 <tr><th scope="row">
25 {{server.state}}
26{% if server.reachable %}
27 {% if server.readonly %}{% trans "In readonly mode" %}<ul>
28 <li>{% trans "Manually disabled" %}</li>
29 </ul>
30 </th>
31 <td>
32 <a href="/readonly/{{server.name}}/clear/" class="addlink">
33 {% trans "Leave readonly" %}
34 </a>
35 </td>
36 {% else %}
37 {% trans "Operating normally" %}
38 </th>
39 <td>
40 <a href="/readonly/{{server.name}}/set/" class="deletelink">
41 {% trans "Set readonly" %}
42 </a>
43 </td>
44 {% endif %}
45 {% else %}{% trans "Server is unreachable or out of sync" %}
46 </th><td></td>
47 {% endif %}
48 </tr>
49 </table>
50 </div>
51 {% endfor %}
52 {% if clear_all_readonly %}<p>
53 <a href="/readonly/clear/" class="addlink">
54 {% trans "Leave readonly on all appservers" %}
55 </a>
56 </p>{% endif %}
57 {% if set_all_readonly %}<p>
58 <a href="/readonly/set/" class="deletelink">
59 {% trans "Set readonly on all appservers" %}
60 </a>
61 </p>{% endif %}
62</div>
63
64{% endblock %}
65
66{% block sidebar %}{% endblock %}
670
=== removed file 'src/identityprovider/templates/admin/readonly.txt'
--- src/identityprovider/templates/admin/readonly.txt 2013-06-21 11:31:17 +0000
+++ src/identityprovider/templates/admin/readonly.txt 1970-01-01 00:00:00 +0000
@@ -1,8 +0,0 @@
1{% comment %}
2Copyright 2010 Canonical Ltd. This software is licensed under the
3GNU Affero General Public License version 3 (see the file LICENSE).
4
5{% endcomment %}Readonly status per application server
6-------------------------------------------------------------------------------
7{% for server in appservers %}
8 {{server.name}} -- {% if server.reachable %}{% if server.readonly %}In readonly mode{% else %}Operating normally{% endif %}{% else %}Server is unreachable or out of sync{% endif %}{% endfor %}
90
=== removed file 'src/identityprovider/templates/admin/readonly_confirm.html'
--- src/identityprovider/templates/admin/readonly_confirm.html 2013-06-20 15:41:21 +0000
+++ src/identityprovider/templates/admin/readonly_confirm.html 1970-01-01 00:00:00 +0000
@@ -1,70 +0,0 @@
1{% extends "admin/index.html" %}
2{% load i18n %}
3
4{% comment %}
5Copyright 2010 Canonical Ltd. This software is licensed under the
6GNU Affero General Public License version 3 (see the file LICENSE).
7{% endcomment %}
8
9{% block breadcrumbs %}
10<div class="breadcrumbs"><a href="/admin/">
11{% trans "Home" %}</a> &rsaquo;
12<a href="/admin/identityprovider/">Identityprovider</a> &rsaquo;
13<a href="/readonly">{% trans "Readonly Admin" %}</a> &rsaquo;
14{% trans "Confirm" %}
15</div>
16{% endblock %}
17
18{% block content %}
19<pre>{{ appservers }}</pre>
20<div id="content" class="colM">
21 <h1>{% trans "Are you sure?" %}</h1>
22 {% if appserver %}
23 <div class="system-message">
24 <p class="system-message-title">
25 {% blocktrans %}You're not operating on all appservers{% endblocktrans %}
26 </p>
27 <p>
28 {% blocktrans %}Changing readonly mode on a single application server can lead to inconsistent states and unexpected behaviour.{% endblocktrans %}
29 </p>
30 </div>
31 {% else %}
32 {% ifequal action "clear" %}
33 <div class="system-message">
34 <p class="system-message-title">
35 {% blocktrans %}Make sure the master database connection is enabled on all app servers <b>before</b> leaving readonly mode!{% endblocktrans %}
36 </p>
37 </div>
38 {% endifequal %}
39 {% endif %}
40
41 <p>
42 {% if action == "set" %}
43 {% if appserver %}
44 {% blocktrans %}You are about to enable readonly mode on appserver <b>{{ appserver }}</b>.{% endblocktrans %}
45 {% else %}
46 {% blocktrans %}You are about to enable readonly mode globally.{% endblocktrans %}</p><p>
47 {% blocktrans %}All appservers will be passed to read-only mode if you confirm.{% endblocktrans %}
48 {% endif %}
49 {% elif action == "clear" %}
50 {% if appserver %}
51 {% blocktrans %}You are about to clear readonly mode on appserver <b>{{ appserver }}</b>.{% endblocktrans %}</p><p>
52 {% blocktrans %}If you confirm, <b>{{ appserver }}</b> will attempt to resume normal operation with its master database connection.{% endblocktrans %}</p>
53 {% else %}
54 {% blocktrans %}You are about to clear readonly mode globally.{% endblocktrans %}</p><p>
55 {% blocktrans %}If you confirm, all appservers will attempt to resume normal operation.{% endblocktrans %}
56 {% endif %}
57 {% endif %}
58 </p>
59 <form method="POST" action="">
60 {% csrf_token %}
61 <input type="hidden" name="action" value="{{action}}" />
62 <input type="hidden" name="appserver" value="{{appserver}}" />
63 <input type="submit" value="{% trans "Yes, I'm sure" %}" />
64 {% trans "or" %} <a href="/readonly">{% trans "Cancel" %}</a>
65 </form>
66</div>
67
68{% endblock %}
69
70{% block sidebar %}{% endblock %}
710
=== modified file 'src/identityprovider/tests/test_auth.py'
--- src/identityprovider/tests/test_auth.py 2018-11-29 12:02:42 +0000
+++ src/identityprovider/tests/test_auth.py 2019-10-22 14:04:10 +0000
@@ -50,9 +50,8 @@
50 EmailStatus,50 EmailStatus,
51 TokenScope,51 TokenScope,
52)52)
53from identityprovider.readonly import ReadOnlyManager
54from identityprovider.tests import DEFAULT_API_PASSWORD, DEFAULT_USER_PASSWORD53from identityprovider.tests import DEFAULT_API_PASSWORD, DEFAULT_USER_PASSWORD
55from identityprovider.tests.utils import SSOBaseTestCase54from identityprovider.tests.utils import SSOBaseTestCase, readonly_enabled
56from identityprovider.utils import (55from identityprovider.utils import (
57 encrypt_launchpad_password,56 encrypt_launchpad_password,
58 get_cypher_migration_status,57 get_cypher_migration_status,
@@ -350,12 +349,9 @@
350 assert leak.password != self.account.encrypted_password349 assert leak.password != self.account.encrypted_password
351 last_leak_check = self.account.accountpassword.last_leak_check350 last_leak_check = self.account.accountpassword.last_leak_check
352351
353 rm = ReadOnlyManager()352 with readonly_enabled():
354 rm.set_readonly()353 account = self.backend.authenticate(
355 self.addCleanup(rm.clear_readonly)354 None, self.email, DEFAULT_USER_PASSWORD)
356
357 account = self.backend.authenticate(
358 None, self.email, DEFAULT_USER_PASSWORD)
359 # verify account was NOT flagged for password reset355 # verify account was NOT flagged for password reset
360 self.assertFalse(account.need_password_reset)356 self.assertFalse(account.need_password_reset)
361 # Verify the last_leak_check was NOT updated357 # Verify the last_leak_check was NOT updated
362358
=== modified file 'src/identityprovider/tests/test_command_readonly.py'
--- src/identityprovider/tests/test_command_readonly.py 2018-02-09 20:56:16 +0000
+++ src/identityprovider/tests/test_command_readonly.py 2019-10-22 14:04:10 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010-2018 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import shutil4import shutil
@@ -7,13 +7,7 @@
7import tempfile7import tempfile
88
9from django.conf import settings9from django.conf import settings
10from django.core.handlers.wsgi import WSGIHandler
11from django.core.management import CommandError, call_command10from django.core.management import CommandError, call_command
12from wsgi_intercept import (
13 add_wsgi_intercept,
14 remove_wsgi_intercept,
15 requests_intercept,
16)
1711
18from identityprovider.readonly import ReadOnlyManager12from identityprovider.readonly import ReadOnlyManager
19from identityprovider.tests.utils import SSOBaseTestCase13from identityprovider.tests.utils import SSOBaseTestCase
@@ -31,64 +25,21 @@
31 self.patch(sys, 'stdout', new=StringIO.StringIO())25 self.patch(sys, 'stdout', new=StringIO.StringIO())
32 self.patch(sys, 'stderr', new=StringIO.StringIO())26 self.patch(sys, 'stderr', new=StringIO.StringIO())
3327
34 self.servers = [
35 {'SERVER_ID': 'localhost', 'HOST': 'localhost', 'PORT': '8000'},
36 {'SERVER_ID': 'otherhost', 'HOST': 'localhost', 'PORT': '8001'},
37 ]
38 _APP_SERVERS = settings.APP_SERVERS
39 settings.APP_SERVERS = self.servers
40 self.addCleanup(setattr, settings, 'APP_SERVERS', _APP_SERVERS)
41
42 _DBFAILOVER_FLAG_DIR = getattr(settings, 'DBFAILOVER_FLAG_DIR', None)28 _DBFAILOVER_FLAG_DIR = getattr(settings, 'DBFAILOVER_FLAG_DIR', None)
43 settings.DBFAILOVER_FLAG_DIR = tempfile.mkdtemp()29 settings.DBFAILOVER_FLAG_DIR = tempfile.mkdtemp()
44 self.addCleanup(shutil.rmtree, settings.DBFAILOVER_FLAG_DIR, True)30 self.addCleanup(shutil.rmtree, settings.DBFAILOVER_FLAG_DIR, True)
45 self.addCleanup(setattr, settings, 'DBFAILOVER_FLAG_DIR',31 self.addCleanup(setattr, settings, 'DBFAILOVER_FLAG_DIR',
46 _DBFAILOVER_FLAG_DIR)32 _DBFAILOVER_FLAG_DIR)
4733
48 # setup wsgi intercept mechanism to simulate wsgi server
49 self.unset_env('http_proxy')
50 self.unset_env('https_proxy')
51 requests_intercept.install()
52 self.addCleanup(requests_intercept.uninstall)
53 for server in self.servers:
54 add_wsgi_intercept(server['HOST'], int(server['PORT']),
55 WSGIHandler)
56 self.addCleanup(remove_wsgi_intercept,
57 server['HOST'], int(server['PORT']))
58
59 def get_status(self):
60 call_command('readonly', list_servers=True)
61 sys.stdout.seek(0)
62 output = sys.stdout.read()
63 return output
64
65 def test_readonly(self):34 def test_readonly(self):
66 self.assertRaises(CommandError, call_command, 'readonly')35 self.assertRaises(CommandError, call_command, 'readonly')
6736
68 def test_readonly_list_all(self):
69 output = self.get_status()
70 self.assertTrue(self.servers[0]['SERVER_ID'] in output)
71
72 def test_readonly_set(self):37 def test_readonly_set(self):
73 call_command('readonly', self.servers[0]['SERVER_ID'], action='set')38 call_command('readonly', 'set')
74 self.rm.check_readonly()39 self.rm.check_readonly()
75 self.assertTrue(settings.READ_ONLY_MODE)40 self.assertTrue(settings.READ_ONLY_MODE)
7641
77 def test_readonly_clear(self):42 def test_readonly_clear(self):
78 call_command('readonly', self.servers[0]['SERVER_ID'], action='clear')43 call_command('readonly', 'clear')
79 self.rm.check_readonly()44 self.rm.check_readonly()
80 self.assertFalse(settings.READ_ONLY_MODE)45 self.assertFalse(settings.READ_ONLY_MODE)
81
82 def test_readonly_set_all(self):
83 call_command('readonly', action='set', all_servers=True)
84 output = self.get_status()
85 for server in settings.APP_SERVERS:
86 expected = "%s -- In readonly mode" % server['SERVER_ID']
87 self.assertTrue(expected in output)
88
89 def test_readonly_clear_all(self):
90 call_command('readonly', action='clear', all_servers=True)
91 output = self.get_status()
92 for server in settings.APP_SERVERS:
93 expected = "%s -- Operating normally" % server['SERVER_ID']
94 self.assertTrue(expected in output)
9546
=== modified file 'src/identityprovider/tests/test_models_account.py'
--- src/identityprovider/tests/test_models_account.py 2018-02-14 14:05:59 +0000
+++ src/identityprovider/tests/test_models_account.py 2019-10-22 14:04:10 +0000
@@ -36,9 +36,8 @@
36 TokenScope,36 TokenScope,
37)37)
38from identityprovider.models.emailaddress import EmailAddress38from identityprovider.models.emailaddress import EmailAddress
39from identityprovider.readonly import ReadOnlyManager
40from identityprovider.tests import DEFAULT_USER_PASSWORD39from identityprovider.tests import DEFAULT_USER_PASSWORD
41from identityprovider.tests.utils import SSOBaseTestCase40from identityprovider.tests.utils import SSOBaseTestCase, readonly_enabled
42from identityprovider.views.testing import MockLaunchpad41from identityprovider.views.testing import MockLaunchpad
4342
4443
@@ -307,16 +306,14 @@
307 self.assertTrue(account.last_login is not None)306 self.assertTrue(account.last_login is not None)
308307
309 def test_set_last_login_when_readonly(self):308 def test_set_last_login_when_readonly(self):
310 readonly_manager = ReadOnlyManager()
311 account = self.factory.make_account()309 account = self.factory.make_account()
312 account.last_login = last_login = datetime(2010, 01, 01)310 account.last_login = last_login = datetime(2010, 01, 01)
313 self.assertEqual(account.last_login, last_login)311 self.assertEqual(account.last_login, last_login)
314312
315 assert not settings.READ_ONLY_MODE313 assert not settings.READ_ONLY_MODE
316 readonly_manager.set_readonly()
317 self.addCleanup(readonly_manager.clear_readonly)
318314
319 account.last_login = the_now = now()315 with readonly_enabled():
316 account.last_login = the_now = now()
320 self.assertEqual(account.last_login, last_login)317 self.assertEqual(account.last_login, last_login)
321 self.assertNotEqual(account.last_login, the_now)318 self.assertNotEqual(account.last_login, the_now)
322319
@@ -515,15 +512,13 @@
515 self.assertEqual(account_password.password, 'invalid')512 self.assertEqual(account_password.password, 'invalid')
516513
517 def test_save_when_readonly(self):514 def test_save_when_readonly(self):
518 readonly_manager = ReadOnlyManager()
519 account = self.factory.make_account()515 account = self.factory.make_account()
520 assert account.status == AccountStatus.ACTIVE516 assert account.status == AccountStatus.ACTIVE
521517
522 assert not settings.READ_ONLY_MODE518 assert not settings.READ_ONLY_MODE
523 readonly_manager.set_readonly()
524 self.addCleanup(readonly_manager.clear_readonly)
525519
526 account.suspend()520 with readonly_enabled():
521 account.suspend()
527 # refresh account from db522 # refresh account from db
528 account = Account.objects.get(id=account.id)523 account = Account.objects.get(id=account.id)
529 self.assertEqual(account.status, AccountStatus.ACTIVE)524 self.assertEqual(account.status, AccountStatus.ACTIVE)
530525
=== modified file 'src/identityprovider/tests/test_models_openidmodels.py'
--- src/identityprovider/tests/test_models_openidmodels.py 2018-02-09 20:56:16 +0000
+++ src/identityprovider/tests/test_models_openidmodels.py 2019-10-22 14:04:10 +0000
@@ -19,8 +19,7 @@
19 OpenIDRPConfig,19 OpenIDRPConfig,
20 OpenIDRPSummary,20 OpenIDRPSummary,
21)21)
22from identityprovider.readonly import ReadOnlyManager22from identityprovider.tests.utils import SSOBaseTestCase, readonly_enabled
23from identityprovider.tests.utils import SSOBaseTestCase
2423
2524
26class DjangoOpenIDStoreTestCase(SSOBaseTestCase):25class DjangoOpenIDStoreTestCase(SSOBaseTestCase):
@@ -192,13 +191,10 @@
192 trust_root=self.trust_root)191 trust_root=self.trust_root)
193192
194 def test_authorize_when_readonly(self):193 def test_authorize_when_readonly(self):
195 rm = ReadOnlyManager()
196 rm.set_readonly()
197 self.addCleanup(rm.clear_readonly)
198
199 expires = now()194 expires = now()
200 OpenIDAuthorization.objects.authorize(self.account, self.trust_root,195 with readonly_enabled():
201 expires)196 OpenIDAuthorization.objects.authorize(
197 self.account, self.trust_root, expires)
202 self.assertRaises(198 self.assertRaises(
203 OpenIDAuthorization.DoesNotExist,199 OpenIDAuthorization.DoesNotExist,
204 OpenIDAuthorization.objects.get, account=self.account,200 OpenIDAuthorization.objects.get, account=self.account,
@@ -273,10 +269,7 @@
273 return summary269 return summary
274270
275 def test_record_when_readonly(self):271 def test_record_when_readonly(self):
276 rm = ReadOnlyManager()272 with readonly_enabled():
277 rm.set_readonly()
278
279 try:
280 summary = OpenIDRPSummary.objects.record(273 summary = OpenIDRPSummary.objects.record(
281 self.account, self.trust_root, self.openid_identity_url)274 self.account, self.trust_root, self.openid_identity_url)
282 self.assertEqual(summary, None)275 self.assertEqual(summary, None)
@@ -284,8 +277,6 @@
284 OpenIDRPSummary.DoesNotExist,277 OpenIDRPSummary.DoesNotExist,
285 OpenIDRPSummary.objects.get, account=self.account,278 OpenIDRPSummary.objects.get, account=self.account,
286 trust_root=self.trust_root, openid_identifier=None)279 trust_root=self.trust_root, openid_identifier=None)
287 finally:
288 rm.clear_readonly()
289280
290 def test_record_with_existing_and_no_openid_identifier(self):281 def test_record_with_existing_and_no_openid_identifier(self):
291 summary1 = self.create_summary()282 summary1 = self.create_summary()
292283
=== modified file 'src/identityprovider/tests/test_readonly.py'
--- src/identityprovider/tests/test_readonly.py 2018-08-15 14:28:08 +0000
+++ src/identityprovider/tests/test_readonly.py 2019-10-22 14:04:10 +0000
@@ -1,23 +1,14 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import json
5import os4import os
6import shutil5import shutil
7import stat6import stat
8import tempfile7import tempfile
98
10import requests
11import responses
12from django.conf import settings9from django.conf import settings
13from django.urls import reverse
1410
15from identityprovider.readonly import (11from identityprovider.readonly import ReadOnlyManager
16 ReadOnlyManager,
17 _remote_req,
18 get_server_atts,
19 update_server,
20)
21from identityprovider.tests import DEFAULT_USER_PASSWORD12from identityprovider.tests import DEFAULT_USER_PASSWORD
22from identityprovider.tests.utils import SSOBaseTestCase13from identityprovider.tests.utils import SSOBaseTestCase
2314
@@ -32,7 +23,6 @@
32 # readonlymode stays False23 # readonlymode stays False
33 overrides = self.settings(24 overrides = self.settings(
34 READ_ONLY_MODE=False,25 READ_ONLY_MODE=False,
35 READONLY_SECRET='testsecret',
36 DBFAILOVER_FLAG_DIR=tempfile.mkdtemp(),26 DBFAILOVER_FLAG_DIR=tempfile.mkdtemp(),
37 )27 )
38 overrides.enable()28 overrides.enable()
@@ -42,57 +32,6 @@
42 self.rm = ReadOnlyManager()32 self.rm = ReadOnlyManager()
4333
4434
45class RemoteRequestTestCase(SSOBaseTestCase):
46 msg = 'hello'
47 host = 'myhost'
48 scheme = 'https'
49 vhost = 'http://foobar.baz'
50
51 @responses.activate
52 def test_plain_remote_req(self):
53 responses.add(
54 responses.POST, 'http://%s/readonlydata' % self.host,
55 body=self.msg)
56 server = {'host': self.host}
57 self.assertEqual(self.msg, _remote_req(**server))
58 # This tests the PreparedRequest, which is before urllib3 inserts a
59 # default Host header.
60 self.assertNotIn('Host', responses.calls[0].request.headers)
61
62 @responses.activate
63 def test_https_remote_req(self):
64 responses.add(
65 responses.POST, '%s://%s/readonlydata' % (self.scheme, self.host),
66 body=self.msg)
67 server = {'host': self.host, 'scheme': self.scheme}
68 self.assertEqual(self.msg, _remote_req(**server))
69 # This tests the PreparedRequest, which is before urllib3 inserts a
70 # default Host header.
71 self.assertNotIn('Host', responses.calls[0].request.headers)
72
73 @responses.activate
74 def test_vhost_remote_req(self):
75 responses.add(
76 responses.POST, '%s://%s/readonlydata' % (self.scheme, self.host),
77 body=self.msg)
78 server = {
79 'host': self.host,
80 'scheme': self.scheme,
81 'virtual_host': self.vhost,
82 }
83 self.assertEqual(self.msg, _remote_req(**server))
84 self.assertEqual(
85 self.vhost, responses.calls[0].request.headers['Host'])
86
87 @responses.activate
88 def test_remote_req_error(self):
89 responses.add(
90 responses.POST, 'http://%s/readonlydata' % self.host,
91 body=requests.RequestException('error'))
92 server = {'host': self.host}
93 self.assertEqual(None, _remote_req(**server))
94
95
96class ReadOnlyFlagFilesTestCase(ReadOnlyBaseTestCase):35class ReadOnlyFlagFilesTestCase(ReadOnlyBaseTestCase):
9736
98 def test_flag_files_in_right_directory(self):37 def test_flag_files_in_right_directory(self):
@@ -107,62 +46,6 @@
107 self.assertTrue(mode & stat.S_IWGRP)46 self.assertTrue(mode & stat.S_IWGRP)
10847
10948
110class ReadOnlyDataTestCase(ReadOnlyBaseTestCase):
111
112 url = reverse('readonly-data')
113
114 def setUp(self):
115 super(ReadOnlyDataTestCase, self).setUp()
116 self.mock_logger = self.patch(
117 'identityprovider.views.readonly.logger')
118
119 def test_requires_post(self):
120 response = self.client.get(self.url)
121 self.assertEqual(response.status_code, 405)
122
123 response = self.client.delete(self.url)
124 self.assertEqual(response.status_code, 405)
125
126 response = self.client.put(self.url)
127 self.assertEqual(response.status_code, 405)
128
129 def test_missing_secret(self):
130 response = self.client.post(self.url)
131
132 self.assertEqual(response.status_code, 404)
133 self.mock_logger.warning.assert_called_once_with(
134 'readonly_data request received with incorrect secret %r', None)
135
136 def test_incorrect_secret(self):
137 assert len(settings.READONLY_SECRET) > 0
138 secret = settings.READONLY_SECRET * 2
139 response = self.client.post(self.url, data=dict(secret=secret))
140
141 self.assertEqual(response.status_code, 404)
142 self.mock_logger.warning.assert_called_once_with(
143 'readonly_data request received with incorrect secret %r', secret)
144
145 def test_correct_secret(self):
146 secret = settings.READONLY_SECRET
147 response = self.client.post(self.url, data=dict(secret=secret))
148
149 self.assertEqual(response.status_code, 200)
150 response = json.loads(response.content)
151 self.assertEqual(response, dict(readonly=False))
152
153 def test_readonly_set_readonly(self):
154 post = {'secret': settings.READONLY_SECRET, 'action': 'set'}
155 response = self.client.post('/readonlydata', post)
156 data = json.loads(response.content)
157 self.assertTrue(data['readonly'])
158
159 def test_readonly_clear_readonly(self):
160 post = {'secret': settings.READONLY_SECRET, 'action': 'clear'}
161 response = self.client.post('/readonlydata', post)
162 data = json.loads(response.content)
163 self.assertFalse(data['readonly'])
164
165
166class ReadOnlyViewsTestCase(ReadOnlyBaseTestCase):49class ReadOnlyViewsTestCase(ReadOnlyBaseTestCase):
16750
168 def login_with_staff(self):51 def login_with_staff(self):
@@ -171,140 +54,6 @@
171 assert self.client.login(54 assert self.client.login(
172 username=account.preferredemail.email, password='password')55 username=account.preferredemail.email, password='password')
17356
174 def test_readonly_admin(self):
175 self.login_with_staff()
176
177 new_setting = [
178 {'SERVER_ID': 'localhost', 'SCHEME': 'http',
179 'HOST': 'localhost', 'VIRTUAL_HOST': '',
180 'PORT': '8000'}
181 ]
182 with self.settings(APP_SERVERS=new_setting):
183 r = self.client.get('/readonly')
184
185 self.assertTemplateUsed(r, 'admin/readonly.html')
186 expected = {'appservers': [{'name': 'localhost', 'reachable': False}],
187 'clear_all_readonly': False,
188 'set_all_readonly': False}
189 for item in r.context:
190 for key, value in expected.items():
191 self.assertEqual(item[key], value)
192
193 def test_get_server_atts_server_unreachable(self):
194 servers = [{'SERVER_ID': 'localhost', 'SCHEME': 'http',
195 'HOST': 'localhost', 'VIRTUAL_HOST': '', 'PORT': '8000'}]
196 expected = {'appservers': [{'name': 'localhost', 'reachable': False}],
197 'clear_all_readonly': False,
198 'set_all_readonly': False}
199 atts = get_server_atts(servers)
200 self.assertEqual(atts, expected)
201
202 @responses.activate
203 def test_get_server_atts_data_error(self):
204 responses.add(
205 responses.POST, 'http://localhost:8000/readonlydata', body='{')
206
207 servers = [{'SERVER_ID': 'localhost', 'SCHEME': 'http',
208 'HOST': 'localhost', 'VIRTUAL_HOST': '', 'PORT': '8000'}]
209 expected = {'appservers': [{'name': 'localhost', 'reachable': False}],
210 'clear_all_readonly': False,
211 'set_all_readonly': True}
212 atts = get_server_atts(servers)
213 self.assertEqual(atts, expected)
214
215 @responses.activate
216 def test_get_server_atts_readonly(self):
217 responses.add(
218 responses.POST, 'http://localhost:8000/readonlydata',
219 json={'readonly': True})
220
221 servers = [{'SERVER_ID': 'localhost', 'SCHEME': 'http',
222 'HOST': 'localhost', 'VIRTUAL_HOST': '', 'PORT': '8000'}]
223 expected = {'appservers': [{'name': 'localhost', 'reachable': True,
224 'readonly': True}],
225 'clear_all_readonly': True,
226 'set_all_readonly': False}
227 atts = get_server_atts(servers)
228 self.assertEqual(atts, expected)
229
230 def test_readonly_confirm_get(self):
231 self.login_with_staff()
232
233 r = self.client.get('/readonly/localhost/set')
234 self.assertTemplateUsed(r, 'admin/readonly_confirm.html')
235 self.assertEqual(r.context['appserver'], 'localhost')
236 self.assertEqual(r.context['action'], 'set')
237
238 @responses.activate
239 def test_readonly_confirm_post(self):
240 responses.add(
241 responses.POST, 'http://localhost:8000/readonlydata', json={})
242 self.login_with_staff()
243
244 new_setting = [
245 {'SERVER_ID': 'localhost', 'SCHEME': 'http',
246 'HOST': 'localhost', 'VIRTUAL_HOST': '',
247 'PORT': '8000'}
248 ]
249 with self.settings(APP_SERVERS=new_setting):
250 r = self.client.post('/readonly/localhost/set')
251 data = [call.request.body for call in responses.calls]
252 self.assertEqual(data, [
253 "action=set&secret=%s" % settings.READONLY_SECRET
254 ])
255
256 self.assertRedirects(r, '/readonly')
257
258 @responses.activate
259 def test_update_server_all_appservers(self):
260 responses.add(
261 responses.POST, 'http://localhost:8000/readonlydata', json={})
262 new_setting = [
263 {'SERVER_ID': 'localhost', 'SCHEME': 'http',
264 'HOST': 'localhost', 'VIRTUAL_HOST': '', 'PORT': '8000'},
265 {'SERVER_ID': 'otherhost', 'SCHEME': 'http',
266 'HOST': 'otherhost', 'VIRTUAL_HOST': '', 'PORT': '8000'},
267 ]
268 with self.settings(APP_SERVERS=new_setting):
269 update_server('set')
270 data = [call.request.body for call in responses.calls]
271 self.assertEqual(data, [
272 "action=set&secret=%s" % settings.READONLY_SECRET,
273 "action=set&secret=%s" % settings.READONLY_SECRET
274 ])
275
276 @responses.activate
277 def test_update_server_one_appserver(self):
278 responses.add(
279 responses.POST, 'http://localhost:8000/readonlydata', json={})
280 new_setting = [
281 {'SERVER_ID': 'localhost', 'SCHEME': 'http',
282 'HOST': 'localhost', 'VIRTUAL_HOST': '', 'PORT': '8000'},
283 {'SERVER_ID': 'otherhost', 'SCHEME': 'http',
284 'HOST': 'otherhost', 'VIRTUAL_HOST': '', 'PORT': '8000'},
285 ]
286 with self.settings(APP_SERVERS=new_setting):
287 update_server('set', 'localhost')
288 data = [call.request.body for call in responses.calls]
289 self.assertEqual(data, [
290 "action=set&secret=%s" % settings.READONLY_SECRET
291 ])
292
293 @responses.activate
294 def test_update_server_one_connection(self):
295 responses.add(
296 responses.POST, 'http://localhost:8000/readonlydata', json={})
297 new_setting = [
298 {'SERVER_ID': 'localhost', 'SCHEME': 'http',
299 'HOST': 'localhost', 'VIRTUAL_HOST': '', 'PORT': '8000'},
300 ]
301 with self.settings(APP_SERVERS=new_setting):
302 update_server('set', 'localhost')
303 data = [call.request.body for call in responses.calls]
304 self.assertEqual(data, [
305 "action=set&secret=%s" % settings.READONLY_SECRET,
306 ])
307
308 def test_nop_db_in_readonly_mode(self):57 def test_nop_db_in_readonly_mode(self):
309 account = self.factory.make_account()58 account = self.factory.make_account()
31059
31160
=== modified file 'src/identityprovider/tests/test_signals.py'
--- src/identityprovider/tests/test_signals.py 2018-05-28 19:44:56 +0000
+++ src/identityprovider/tests/test_signals.py 2019-10-22 14:04:10 +0000
@@ -21,7 +21,6 @@
21 TokenScope,21 TokenScope,
22)22)
23from identityprovider.models.emailaddress import EmailAddress23from identityprovider.models.emailaddress import EmailAddress
24from identityprovider.readonly import ReadOnlyManager
25from identityprovider.signals import (24from identityprovider.signals import (
26 invalidate_account_oauth_tokens,25 invalidate_account_oauth_tokens,
27 invalidate_preferredemail,26 invalidate_preferredemail,
@@ -34,7 +33,7 @@
34)33)
35from identityprovider.tests import DEFAULT_USER_PASSWORD34from identityprovider.tests import DEFAULT_USER_PASSWORD
36from identityprovider.tests.test_auth import AuthLogTestCaseMixin35from identityprovider.tests.test_auth import AuthLogTestCaseMixin
37from identityprovider.tests.utils import SSOBaseTestCase36from identityprovider.tests.utils import SSOBaseTestCase, readonly_enabled
3837
3938
40class SessionTokenOnLoginTestCase(SSOBaseTestCase):39class SessionTokenOnLoginTestCase(SSOBaseTestCase):
@@ -107,34 +106,26 @@
107 self.client.session.get(SESSION_TOKEN_KEY), token.key)106 self.client.session.get(SESSION_TOKEN_KEY), token.key)
108107
109 def test_listener_read_only_mode_no_token(self):108 def test_listener_read_only_mode_no_token(self):
110 rm = ReadOnlyManager()109 with readonly_enabled():
111 rm.set_readonly()110 self.client.login(
112 self.addCleanup(rm.clear_readonly)111 username=self.email, password=DEFAULT_USER_PASSWORD)
113
114 self.client.login(username=self.email, password=DEFAULT_USER_PASSWORD)
115 self.assertEqual(self.account.token_set.all().count(), 0)112 self.assertEqual(self.account.token_set.all().count(), 0)
116 self.assertEqual(self.client.session.get(SESSION_TOKEN_KEY), '')113 self.assertEqual(self.client.session.get(SESSION_TOKEN_KEY), '')
117114
118 def test_listener_read_only_mode_previous_token_no_web_login(self):115 def test_listener_read_only_mode_previous_token_no_web_login(self):
119 self.account.create_oauth_token(token_name=SESSION_TOKEN_NAME)116 self.account.create_oauth_token(token_name=SESSION_TOKEN_NAME)
120117 with readonly_enabled():
121 rm = ReadOnlyManager()118 self.client.login(
122 rm.set_readonly()119 username=self.email, password=DEFAULT_USER_PASSWORD)
123 self.addCleanup(rm.clear_readonly)
124
125 self.client.login(username=self.email, password=DEFAULT_USER_PASSWORD)
126 self.assertEqual(120 self.assertEqual(
127 self.client.session.get(SESSION_TOKEN_KEY), '')121 self.client.session.get(SESSION_TOKEN_KEY), '')
128122
129 def test_listener_read_only_mode_previous_token(self):123 def test_listener_read_only_mode_previous_token(self):
130 token = self.account.create_oauth_token(124 token = self.account.create_oauth_token(
131 token_name=SESSION_TOKEN_NAME, scope=TokenScope.WEB_LOGIN)125 token_name=SESSION_TOKEN_NAME, scope=TokenScope.WEB_LOGIN)
132126 with readonly_enabled():
133 rm = ReadOnlyManager()127 self.client.login(
134 rm.set_readonly()128 username=self.email, password=DEFAULT_USER_PASSWORD)
135 self.addCleanup(rm.clear_readonly)
136
137 self.client.login(username=self.email, password=DEFAULT_USER_PASSWORD)
138 self.assertEqual(129 self.assertEqual(
139 self.client.session.get(SESSION_TOKEN_KEY), token.key)130 self.client.session.get(SESSION_TOKEN_KEY), token.key)
140131
141132
=== modified file 'src/identityprovider/tests/utils.py'
--- src/identityprovider/tests/utils.py 2018-05-25 21:49:46 +0000
+++ src/identityprovider/tests/utils.py 2019-10-22 14:04:10 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010-2013 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import base644import base64
@@ -10,6 +10,7 @@
10import threading10import threading
11import urllib11import urllib
12import urllib212import urllib2
13from contextlib import contextmanager
13from cStringIO import StringIO14from cStringIO import StringIO
14from datetime import timedelta15from datetime import timedelta
15from importlib import import_module16from importlib import import_module
@@ -33,6 +34,7 @@
33# same logic as prod systems34# same logic as prod systems
34from identityprovider import crypto, signed, signals # noqa35from identityprovider import crypto, signed, signals # noqa
35from identityprovider.macaroon import MacaroonRequest36from identityprovider.macaroon import MacaroonRequest
37from identityprovider.readonly import ReadOnlyManager
36from identityprovider.tests import DEFAULT_USER_PASSWORD38from identityprovider.tests import DEFAULT_USER_PASSWORD
37from identityprovider.tests.factory import SSOObjectFactory39from identityprovider.tests.factory import SSOObjectFactory
38from identityprovider.utils import generate_random_string40from identityprovider.utils import generate_random_string
@@ -532,3 +534,13 @@
532 self.assertIsInstance(thrown_exceptions[0], OperationalError)534 self.assertIsInstance(thrown_exceptions[0], OperationalError)
533 self.assertIn(535 self.assertIn(
534 "could not obtain lock on row", str(thrown_exceptions[0]))536 "could not obtain lock on row", str(thrown_exceptions[0]))
537
538
539@contextmanager
540def readonly_enabled():
541 rm = ReadOnlyManager()
542 rm.set_readonly()
543 try:
544 yield
545 finally:
546 rm.clear_readonly()
535547
=== modified file 'src/identityprovider/urls.py'
--- src/identityprovider/urls.py 2018-02-15 11:27:21 +0000
+++ src/identityprovider/urls.py 2019-10-22 14:04:10 +0000
@@ -5,11 +5,6 @@
5from django.conf.urls import include, url5from django.conf.urls import include, url
6from django.views.generic import RedirectView6from django.views.generic import RedirectView
77
8from identityprovider.views.readonly import (
9 readonly_admin,
10 readonly_confirm,
11 readonly_data,
12)
13from identityprovider.views.server import (8from identityprovider.views.server import (
14 cancel,9 cancel,
15 decide,10 decide,
@@ -55,13 +50,6 @@
55 RedirectView.as_view(url='/+id/%(identifier)s', permanent=False)),50 RedirectView.as_view(url='/+id/%(identifier)s', permanent=False)),
56]51]
5752
58urlpatterns += [
59 url(r'^readonly$', readonly_admin),
60 url(r'^readonly/((?P<appserver>[A-Za-z0-9\-_.:]+)/)?'
61 r'(?P<action>set|clear)', readonly_confirm),
62 url(r'^readonlydata$', readonly_data, name='readonly-data'),
63]
64
65if settings.DEBUG:53if settings.DEBUG:
66 urlpatterns += [54 urlpatterns += [
67 url(r'^i18n/', include('django.conf.urls.i18n')),55 url(r'^i18n/', include('django.conf.urls.i18n')),
6856
=== removed file 'src/identityprovider/views/readonly.py'
--- src/identityprovider/views/readonly.py 2018-02-09 20:56:16 +0000
+++ src/identityprovider/views/readonly.py 1970-01-01 00:00:00 +0000
@@ -1,64 +0,0 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4import json
5import logging
6
7from django.conf import settings
8from django.contrib.admin.views.decorators import staff_member_required
9from django.http import Http404, HttpResponse, HttpResponseRedirect
10from django.shortcuts import render
11from django.views.decorators.csrf import csrf_exempt
12from django.views.decorators.http import require_POST
13
14from identityprovider.readonly import (
15 ReadOnlyManager,
16 get_server_atts,
17 update_server,
18)
19
20
21logger = logging.getLogger(__name__)
22
23
24@staff_member_required
25def readonly_admin(request):
26 atts = get_server_atts(settings.APP_SERVERS)
27 return render(request, 'admin/readonly.html', atts)
28
29
30@staff_member_required
31def readonly_confirm(request, action, appserver=None):
32 if request.method == 'POST':
33 update_server(action, appserver)
34 return HttpResponseRedirect('/readonly')
35 context = {
36 'appserver': appserver,
37 'action': action,
38 }
39 return render(request, 'admin/readonly_confirm.html', context)
40
41
42@csrf_exempt
43@require_POST
44def readonly_data(request):
45 """Provides data about the readonly status of this app server."""
46
47 secret = request.POST.get('secret')
48 if secret != settings.READONLY_SECRET:
49 logger.warning(
50 'readonly_data request received with incorrect secret %r', secret)
51 raise Http404()
52
53 action = request.POST.get('action')
54 romanager = ReadOnlyManager()
55
56 if action == 'set':
57 romanager.set_readonly()
58 elif action == 'clear':
59 romanager.clear_readonly()
60
61 result = {
62 'readonly': settings.READ_ONLY_MODE,
63 }
64 return HttpResponse(json.dumps(result))
650
=== modified file 'src/webui/tests/test_templates.py'
--- src/webui/tests/test_templates.py 2018-05-28 20:15:33 +0000
+++ src/webui/tests/test_templates.py 2019-10-22 14:04:10 +0000
@@ -6,10 +6,10 @@
6from pyquery import PyQuery6from pyquery import PyQuery
77
8from identityprovider.models.openidmodels import OpenIDRPConfig8from identityprovider.models.openidmodels import OpenIDRPConfig
9from identityprovider.readonly import ReadOnlyManager
10from identityprovider.tests.utils import (9from identityprovider.tests.utils import (
11 AuthenticatedTestCase,10 AuthenticatedTestCase,
12 SSOBaseTestCase,11 SSOBaseTestCase,
12 readonly_enabled,
13)13)
1414
1515
@@ -99,11 +99,8 @@
99 self.assertContains(response, "data-qa-id=\"create_account_form\"")99 self.assertContains(response, "data-qa-id=\"create_account_form\"")
100100
101 def test_login_without_create_account_form(self):101 def test_login_without_create_account_form(self):
102 rm = ReadOnlyManager()102 with readonly_enabled():
103 rm.set_readonly()103 response = self.client.get('/+login')
104 self.addCleanup(rm.clear_readonly)
105
106 response = self.client.get('/+login')
107 self.assertNotContains(response, "data-qa-id=\"create_account_form\"")104 self.assertNotContains(response, "data-qa-id=\"create_account_form\"")
108105
109106
110107
=== modified file 'src/webui/tests/test_views_account.py'
--- src/webui/tests/test_views_account.py 2019-06-18 20:13:31 +0000
+++ src/webui/tests/test_views_account.py 2019-10-22 14:04:10 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010-2017 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4from __future__ import unicode_literals4from __future__ import unicode_literals
@@ -41,6 +41,7 @@
41 AuthenticatedTestCase,41 AuthenticatedTestCase,
42 SSOBaseTestCase,42 SSOBaseTestCase,
43 assert_exhausted_warning,43 assert_exhausted_warning,
44 readonly_enabled,
44)45)
45from identityprovider.views.testing import MockLaunchpad46from identityprovider.views.testing import MockLaunchpad
46from webui import decorators47from webui import decorators
@@ -268,6 +269,17 @@
268 r = self.client.post(self.url, data)269 r = self.client.post(self.url, data)
269 self.assertEqual(r.status_code, 302)270 self.assertEqual(r.status_code, 302)
270271
272 def test_index_edit_displayname_disabled_when_readonly(self):
273 old_displayname = self.account.displayname
274 data = {'displayname': "New Display Name",
275 'preferred_email': self.account.preferredemail.id,
276 'oldpassword': DEFAULT_USER_PASSWORD}
277 with readonly_enabled():
278 r = self.client.post(self.url, data)
279 self.account.refresh_from_db()
280 self.assertEqual(r.status_code, 200)
281 self.assertEqual(old_displayname, self.account.displayname)
282
271 def test_index_edit_displayname_no_email(self):283 def test_index_edit_displayname_no_email(self):
272 data = {'displayname': "New Display Name",284 data = {'displayname': "New Display Name",
273 'preferred_email': self.account.preferredemail.id,285 'preferred_email': self.account.preferredemail.id,
@@ -427,6 +439,14 @@
427 r = self.client.get(self.url)439 r = self.client.get(self.url)
428 self.assertContains(r, 'value="%s"' % bad_username)440 self.assertContains(r, 'value="%s"' % bad_username)
429441
442 @switches(USERNAME_UI=True)
443 def test_edit_username_disabled_when_readonly(self):
444 with readonly_enabled():
445 r = self.post_username_change('new-username')
446 self.account.refresh_from_db()
447 self.assertEqual(r.status_code, 200)
448 self.assertIsNone(self.account.person)
449
430 def test_index_edit_password(self):450 def test_index_edit_password(self):
431 oauth_tokens = self.account.token_set.all()451 oauth_tokens = self.account.token_set.all()
432 # web login token should be there452 # web login token should be there
@@ -548,6 +568,23 @@
548 {'newemail': "very-new-email@example.com"})568 {'newemail': "very-new-email@example.com"})
549 self.assertEqual(r.status_code, 200)569 self.assertEqual(r.status_code, 200)
550570
571 def test_new_email_get_disabled_when_readonly(self):
572 with readonly_enabled():
573 r = self.client.get(reverse('new_email'))
574
575 self.assertEqual(r.status_code, 403)
576 self.assertTemplateUsed(r, 'readonly.html')
577
578 def test_new_email_post_disabled_when_readonly(self):
579 with readonly_enabled():
580 r = self.client.post(
581 reverse('new_email'),
582 {'newemail': "very-new-email@example.com"},
583 )
584
585 self.assertEqual(r.status_code, 403)
586 self.assertTemplateUsed(r, 'readonly.html')
587
551 def test_new_email_post_with_token(self):588 def test_new_email_post_with_token(self):
552 url = reverse('new_email', kwargs=dict(token='thisissuperrando'))589 url = reverse('new_email', kwargs=dict(token='thisissuperrando'))
553 self.client.post(url, {'newemail': "very-new-email@example.com"})590 self.client.post(url, {'newemail': "very-new-email@example.com"})
@@ -695,6 +732,13 @@
695 self.assertContains(732 self.assertContains(
696 response, 'Your Ubuntu One account has now been deleted')733 response, 'Your Ubuntu One account has now been deleted')
697734
735 def test_delete_view_disabled_when_readonly(self):
736 data = {'password': DEFAULT_USER_PASSWORD}
737 with readonly_enabled():
738 self.client.post('/+delete', data)
739 account = Account.objects.get(id=self.account.id)
740 self.assertNotEqual(account.status, AccountStatus.DELETED)
741
698 def test_confirm_password_before_changing(self):742 def test_confirm_password_before_changing(self):
699 oauth_tokens = self.account.token_set.all()743 oauth_tokens = self.account.token_set.all()
700 # web login token should be there744 # web login token should be there
@@ -1158,7 +1202,24 @@
1158 r = self.client.get(reverse('applications'))1202 r = self.client.get(reverse('applications'))
1159 self.assertTemplateUsed(r, 'account/applications.html')1203 self.assertTemplateUsed(r, 'account/applications.html')
11601204
1161 def test_account_without_applications_does_not_renders_token_table(self):1205 def test_applications_get_disabled_when_readonly(self):
1206 with readonly_enabled():
1207 r = self.client.get(reverse('applications'))
1208
1209 self.assertEqual(r.status_code, 403)
1210 self.assertTemplateUsed(r, 'readonly.html')
1211
1212 def test_applications_post_disabled_when_readonly(self):
1213 token = self.account.create_v1_oauth_token("Token-1")
1214
1215 with readonly_enabled():
1216 r = self.client.post(
1217 reverse('applications'), {'token_id': token.pk}, follow=True)
1218
1219 self.assertEqual(r.status_code, 403)
1220 self.assertTemplateUsed(r, 'readonly.html')
1221
1222 def test_account_without_applications_does_not_render_token_table(self):
1162 r = self.client.get(reverse('applications'))1223 r = self.client.get(reverse('applications'))
1163 # since user is logged in, the web login token exists, but we don't1224 # since user is logged in, the web login token exists, but we don't
1164 # want to list it1225 # want to list it
@@ -1181,7 +1242,7 @@
1181 reverse('applications'), {'token_id': t.pk}, follow=True)1242 reverse('applications'), {'token_id': t.pk}, follow=True)
1182 self.assertNotContains(r, t.pk)1243 self.assertNotContains(r, t.pk)
11831244
1184 def test_revoking_token_which_does_not_belogs_to_an_account(self):1245 def test_revoking_token_which_does_not_belong_to_an_account(self):
1185 account = self.factory.make_account(email="foo@x.com")1246 account = self.factory.make_account(email="foo@x.com")
1186 token = account.create_oauth_token("Token")1247 token = account.create_oauth_token("Token")
11871248
11881249
=== modified file 'src/webui/tests/test_views_registration.py'
--- src/webui/tests/test_views_registration.py 2019-06-18 20:13:31 +0000
+++ src/webui/tests/test_views_registration.py 2019-10-22 14:04:10 +0000
@@ -1,6 +1,6 @@
1# coding: utf-81# coding: utf-8
22
3# Copyright 2010 Canonical Ltd. This software is licensed under the3# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6from __future__ import unicode_literals6from __future__ import unicode_literals
@@ -33,7 +33,11 @@
33 AuthenticationDevice,33 AuthenticationDevice,
34)34)
35from identityprovider.tests import DEFAULT_USER_PASSWORD35from identityprovider.tests import DEFAULT_USER_PASSWORD
36from identityprovider.tests.utils import SSOBaseTestCase, TimelineActionMixin36from identityprovider.tests.utils import (
37 SSOBaseTestCase,
38 TimelineActionMixin,
39 readonly_enabled,
40)
3741
38from webui.constants import (42from webui.constants import (
39 EMAIL_EXISTS_ERROR,43 EMAIL_EXISTS_ERROR,
@@ -153,6 +157,13 @@
153 'Please tell us your full name and choose a username and password:'157 'Please tell us your full name and choose a username and password:'
154 )158 )
155159
160 def test_get_when_readonly(self):
161 with readonly_enabled():
162 response = self.client.get(self.URL)
163
164 self.assertEqual(response.status_code, 403)
165 self.assertTemplateUsed(response, 'readonly.html')
166
156 def test_head(self):167 def test_head(self):
157 request = self.factory.make_request(168 request = self.factory.make_request(
158 self.URL, method='HEAD', user=AnonymousUser(),169 self.URL, method='HEAD', user=AnonymousUser(),
@@ -216,6 +227,13 @@
216 self.assertEqual(227 self.assertEqual(
217 response.request.session['token_email'], self.TESTDATA['email'])228 response.request.session['token_email'], self.TESTDATA['email'])
218229
230 def test_post_disabled_when_readonly(self):
231 with readonly_enabled():
232 response = self.client.post(self.URL, **self.TESTDATA)
233
234 self.assertEqual(response.status_code, 403)
235 self.assertTemplateUsed(response, 'readonly.html')
236
219 @switches(USERNAME_UI=True)237 @switches(USERNAME_UI=True)
220 @override_settings(LP_API_URL='test.com')238 @override_settings(LP_API_URL='test.com')
221 def test_post_success_with_username_with_feature_flag_enabled(self):239 def test_post_success_with_username_with_feature_flag_enabled(self):
@@ -502,6 +520,12 @@
502 self.assertEqual(ctx['form']['email'].value(), 'test@test.com')520 self.assertEqual(ctx['form']['email'].value(), 'test@test.com')
503 self.assertTemplateUsed(response, 'registration/forgot_password.html')521 self.assertTemplateUsed(response, 'registration/forgot_password.html')
504522
523 def test_when_readonly(self):
524 with readonly_enabled():
525 response = self.get()
526 self.assertEqual(response.status_code, 403)
527 self.assertTemplateUsed(response, 'readonly.html')
528
505 def test_post_with_initial_data(self):529 def test_post_with_initial_data(self):
506 data = dict(email='test@test.com', forgot_password='')530 data = dict(email='test@test.com', forgot_password='')
507 response = self.post(data=data)531 response = self.post(data=data)
@@ -624,6 +648,12 @@
624 self.assert_form_displayed(response, __all__=ERROR_TOO_MANY_REQUESTS)648 self.assert_form_displayed(response, __all__=ERROR_TOO_MANY_REQUESTS)
625 self.assert_stat_calls(['error.too_many_requests'])649 self.assert_stat_calls(['error.too_many_requests'])
626650
651 def test_post_disabled_when_readonly(self):
652 with readonly_enabled():
653 response = self.post(data=self.data)
654 self.assertEqual(response.status_code, 403)
655 self.assertTemplateUsed(response, 'readonly.html')
656
627 def test_method_not_allowed(self):657 def test_method_not_allowed(self):
628 response = self.put(data=self.data)658 response = self.put(data=self.data)
629 self.assertEqual(response.status_code, 405)659 self.assertEqual(response.status_code, 405)
630660
=== modified file 'src/webui/tests/test_views_ui.py'
--- src/webui/tests/test_views_ui.py 2019-05-10 19:53:55 +0000
+++ src/webui/tests/test_views_ui.py 2019-10-22 14:04:10 +0000
@@ -1,6 +1,6 @@
1# coding: utf-81# coding: utf-8
22
3# Copyright 2010 Canonical Ltd. This software is licensed under the3# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6from __future__ import unicode_literals6from __future__ import unicode_literals
@@ -55,12 +55,12 @@
55 AuthTokenType,55 AuthTokenType,
56 EmailStatus,56 EmailStatus,
57)57)
58from identityprovider.readonly import ReadOnlyManager
59from identityprovider.tests import DEFAULT_USER_PASSWORD58from identityprovider.tests import DEFAULT_USER_PASSWORD
60from identityprovider.tests.test_auth import AuthLogTestCaseMixin59from identityprovider.tests.test_auth import AuthLogTestCaseMixin
61from identityprovider.tests.utils import (60from identityprovider.tests.utils import (
62 SSOBaseTestCase,61 SSOBaseTestCase,
63 TimelineActionMixin,62 TimelineActionMixin,
63 readonly_enabled,
64)64)
65from identityprovider.utils import generate_random_string65from identityprovider.utils import generate_random_string
66from identityprovider.validators import PASSWORD_LEAKED66from identityprovider.validators import PASSWORD_LEAKED
@@ -136,6 +136,20 @@
136 self.assertEqual(len(mail.outbox), 1)136 self.assertEqual(len(mail.outbox), 1)
137137
138138
139class ReadOnlyViewsTestCase(BaseTestCase):
140
141 def test_new_account_disabled_when_readonly(self):
142 self.account.status = AccountStatus.ACTIVE
143 self.account.save()
144
145 with readonly_enabled():
146 r = self.post_new_account(email=self.email)
147
148 self.assertEqual(r.status_code, 403)
149 self.assertEqual(len(mail.outbox), 0)
150 self.assertTemplateUsed(r, 'readonly.html')
151
152
139class UIViewsBaseTestCase(BaseTestCase):153class UIViewsBaseTestCase(BaseTestCase):
140154
141 def setUp(self):155 def setUp(self):
@@ -1518,11 +1532,7 @@
1518 def test_twofactor_login_disabled_when_readonly(self):1532 def test_twofactor_login_disabled_when_readonly(self):
1519 self.mock_site.return_value = False1533 self.mock_site.return_value = False
15201534
1521 rm = ReadOnlyManager()1535 with switches(TWOFACTOR=True), readonly_enabled():
1522 rm.set_readonly()
1523 self.addCleanup(rm.clear_readonly)
1524
1525 with switches(TWOFACTOR=True):
1526 self.do_login()1536 self.do_login()
1527 response = self.client.get(self.url)1537 response = self.client.get(self.url)
1528 self.assertEqual(response.status_code, 404)1538 self.assertEqual(response.status_code, 404)