Skip to content

Commit afb2f99

Browse files
diewdurbin
authored andcommitted
Admin feature: Nuke user (#2977)
* Make remove_project flash optional * Flush at end of remove_project * Fix a bad test * Admin view to nuke user and associated projects * Add 'nuke user' button and modal * Add title to edit user section * Confirm username * Add a JournalEntry when nuking user
1 parent e310460 commit afb2f99

File tree

7 files changed

+247
-25
lines changed

7 files changed

+247
-25
lines changed

tests/unit/admin/test_routes.py

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,87 @@
1616

1717

1818
def test_includeme():
19+
warehouse = "w.local"
1920
config = pretend.stub(
2021
add_route=pretend.call_recorder(lambda *a, **k: None),
21-
get_settings=lambda: {"warehouse.domain": "w.local"},
22+
get_settings=lambda: {"warehouse.domain": warehouse},
2223
)
2324

2425
includeme(config)
2526

26-
config.add_route.calls == [
27-
pretend.call("admin.dashboard", "/admin/", domain="w.local"),
28-
pretend.call("admin.login", "/admin/login/", domain="w.local"),
29-
pretend.call("admin.logout", "/admin/logout/", domain="w.local"),
27+
assert config.add_route.calls == [
28+
pretend.call("admin.dashboard", "/admin/", domain=warehouse),
29+
pretend.call("admin.login", "/admin/login/", domain=warehouse),
30+
pretend.call("admin.logout", "/admin/logout/", domain=warehouse),
31+
pretend.call("admin.user.list", "/admin/users/", domain=warehouse),
32+
pretend.call(
33+
"admin.user.detail",
34+
"/admin/users/{user_id}/",
35+
domain=warehouse,
36+
),
37+
pretend.call(
38+
"admin.user.delete",
39+
"/admin/users/{user_id}/delete/",
40+
domain=warehouse,
41+
),
42+
pretend.call(
43+
"admin.project.list",
44+
"/admin/projects/",
45+
domain=warehouse,
46+
),
47+
pretend.call(
48+
"admin.project.detail",
49+
"/admin/projects/{project_name}/",
50+
factory="warehouse.packaging.models:ProjectFactory",
51+
traverse="/{project_name}/",
52+
domain=warehouse,
53+
),
54+
pretend.call(
55+
"admin.project.releases",
56+
"/admin/projects/{project_name}/releases/",
57+
factory="warehouse.packaging.models:ProjectFactory",
58+
traverse="/{project_name}",
59+
domain=warehouse,
60+
),
61+
pretend.call(
62+
"admin.project.journals",
63+
"/admin/projects/{project_name}/journals/",
64+
factory="warehouse.packaging.models:ProjectFactory",
65+
traverse="/{project_name}",
66+
domain=warehouse,
67+
),
68+
pretend.call(
69+
"admin.project.set_upload_limit",
70+
"/admin/projects/{project_name}/set_upload_limit/",
71+
factory="warehouse.packaging.models:ProjectFactory",
72+
traverse="/{project_name}",
73+
domain=warehouse,
74+
),
75+
pretend.call(
76+
"admin.project.delete",
77+
"/admin/projects/{project_name}/delete/",
78+
factory="warehouse.packaging.models:ProjectFactory",
79+
traverse="/{project_name}",
80+
domain=warehouse,
81+
),
82+
pretend.call(
83+
"admin.journals.list",
84+
"/admin/journals/",
85+
domain=warehouse,
86+
),
87+
pretend.call(
88+
"admin.blacklist.list",
89+
"/admin/blacklist/",
90+
domain=warehouse,
91+
),
92+
pretend.call(
93+
"admin.blacklist.add",
94+
"/admin/blacklist/add/",
95+
domain=warehouse,
96+
),
97+
pretend.call(
98+
"admin.blacklist.remove",
99+
"/admin/blacklist/remove/",
100+
domain=warehouse,
101+
),
30102
]

tests/unit/admin/views/test_users.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from warehouse.admin.views import users as views
2121

22-
from ....common.db.accounts import UserFactory, EmailFactory
22+
from ....common.db.accounts import User, UserFactory, EmailFactory
2323
from ....common.db.packaging import ProjectFactory, RoleFactory
2424

2525

@@ -169,3 +169,56 @@ def test_updates_user(self, db_request):
169169
assert resp.status_code == 303
170170
assert resp.location == "/admin/users/{}/".format(user.id)
171171
assert user.name == "Jane Doe"
172+
173+
174+
class TestUserDelete:
175+
176+
def test_deletes_user(self, db_request, monkeypatch):
177+
user = UserFactory.create()
178+
project = ProjectFactory.create()
179+
RoleFactory(project=project, user=user, role_name='Owner')
180+
181+
db_request.matchdict['user_id'] = str(user.id)
182+
db_request.params = {'username': user.username}
183+
db_request.route_path = pretend.call_recorder(lambda a: '/foobar')
184+
db_request.user = UserFactory.create()
185+
db_request.remote_addr = '10.10.10.10'
186+
187+
remove_project = pretend.call_recorder(lambda *a, **kw: None)
188+
monkeypatch.setattr(views, 'remove_project', remove_project)
189+
190+
result = views.user_delete(db_request)
191+
192+
db_request.db.flush()
193+
194+
assert not db_request.db.query(User).get(user.id)
195+
assert remove_project.calls == [
196+
pretend.call(project, db_request, flash=False),
197+
]
198+
assert db_request.route_path.calls == [pretend.call('admin.user.list')]
199+
assert result.status_code == 303
200+
assert result.location == '/foobar'
201+
202+
def test_deletes_user_bad_confirm(self, db_request, monkeypatch):
203+
user = UserFactory.create()
204+
project = ProjectFactory.create()
205+
RoleFactory(project=project, user=user, role_name='Owner')
206+
207+
db_request.matchdict['user_id'] = str(user.id)
208+
db_request.params = {'username': 'wrong'}
209+
db_request.route_path = pretend.call_recorder(lambda a, **k: '/foobar')
210+
211+
remove_project = pretend.call_recorder(lambda *a, **kw: None)
212+
monkeypatch.setattr(views, 'remove_project', remove_project)
213+
214+
result = views.user_delete(db_request)
215+
216+
db_request.db.flush()
217+
218+
assert db_request.db.query(User).get(user.id)
219+
assert remove_project.calls == []
220+
assert db_request.route_path.calls == [
221+
pretend.call('admin.user.detail', user_id=user.id),
222+
]
223+
assert result.status_code == 303
224+
assert result.location == '/foobar'

tests/unit/utils/test_project.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ def test_confirm_incorrect_input():
8383
]
8484

8585

86-
def test_remove_project(db_request):
86+
@pytest.mark.parametrize(
87+
'flash',
88+
[True, False]
89+
)
90+
def test_remove_project(db_request, flash):
8791
user = UserFactory.create()
8892
project = ProjectFactory.create(name="foo")
8993
release = ReleaseFactory.create(project=project)
@@ -99,14 +103,17 @@ def test_remove_project(db_request):
99103
db_request.remote_addr = "192.168.1.1"
100104
db_request.session = stub(flash=call_recorder(lambda *a, **kw: stub()))
101105

102-
remove_project(project, db_request)
103-
104-
assert db_request.session.flash.calls == [
105-
call(
106-
"Successfully deleted the project 'foo'.",
107-
queue="success"
108-
),
109-
]
106+
remove_project(project, db_request, flash=flash)
107+
108+
if flash:
109+
assert db_request.session.flash.calls == [
110+
call(
111+
"Successfully deleted the project 'foo'.",
112+
queue="success"
113+
),
114+
]
115+
else:
116+
assert db_request.session.flash.calls == []
110117

111118
assert not (db_request.db.query(Role)
112119
.filter(Role.project == project).count())

warehouse/admin/routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ def includeme(config):
2929
"/admin/users/{user_id}/",
3030
domain=warehouse,
3131
)
32+
config.add_route(
33+
"admin.user.delete",
34+
"/admin/users/{user_id}/delete/",
35+
domain=warehouse,
36+
)
3237

3338
# Project related Admin pages
3439
config.add_route(

warehouse/admin/templates/admin/users/detail.html

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,62 @@ <h2 class="box-title">Actions</h2>
6767
</div>
6868
<div class="box-body">
6969
<ul class="list-unstyled">
70-
{% if not user.is_active %}
71-
<li><a href="#TODO">Resend Confirmation Email</a></li>
72-
{% endif %}
73-
<li><a href="#TODO">Reset Password</a></li>
70+
<li>
71+
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#nukeModal">
72+
<i class="icon fa fa-bomb"></i> Nuke user
73+
</button>
74+
<div class="modal fade" id="nukeModal" tabindex="-1" role="dialog">
75+
<form method="POST" action="{{ request.route_path('admin.user.delete', user_id=user.id) }}">
76+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
77+
<div class="modal-dialog" role="document">
78+
<div class="modal-content">
79+
<div class="modal-header">
80+
<h4 class="modal-title" id="exampleModalLabel">Nuke user {{ user.username }}?</h4>
81+
<button type="button" class="close" data-dismiss="modal">
82+
<span>&times;</span>
83+
</button>
84+
</div>
85+
<div class="modal-body">
86+
<p>
87+
This will permanently destroy the user and cannot be undone.
88+
</p>
89+
<p>
90+
This will also delete the following projects and their respective releases:
91+
</p>
92+
<ul>
93+
{% for project in user.projects %}
94+
<li>
95+
<a href="{{ request.route_path('admin.project.detail', project_name=project.normalized_name) }}">
96+
{{ project.name }}
97+
</a> ({{ project.releases|length }} releases)
98+
</li>
99+
{% endfor %}
100+
</ul>
101+
<hr>
102+
<p>
103+
Type the username '{{ user.username }}' to confirm:
104+
</p>
105+
<input type="text" name="username" placeholder="{{ user.username }}">
106+
</div>
107+
<div class="modal-footer">
108+
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
109+
<button type="submit" class="btn btn-danger">Nuke 'em</button>
110+
</div>
111+
</div>
112+
</form>
113+
</div>
114+
</div>
115+
</li>
74116
</ul>
75117
</div>
76118
</div>
77119
</div>
78120

79121
<div class="col-md-9">
80122
<div class="box">
123+
<div class="box-header with-border">
124+
<h3 class="box-title">Edit User</h3>
125+
</div>
81126
<div class="box-body">
82127
<form class="form-horizontal" method="POST" action="{{ request.current_route_path() }}">
83128
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">

warehouse/admin/views/users.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323

2424
from warehouse import forms
2525
from warehouse.accounts.models import User, Email
26-
from warehouse.packaging.models import Role
26+
from warehouse.packaging.models import JournalEntry, Role
2727
from warehouse.utils.paginate import paginate_url_factory
28+
from warehouse.utils.project import remove_project
2829

2930

3031
@view_config(
@@ -125,3 +126,38 @@ def user_detail(request):
125126
return HTTPSeeOther(location=request.current_route_path())
126127

127128
return {"user": user, "form": form, "roles": roles}
129+
130+
131+
@view_config(
132+
route_name='admin.user.delete',
133+
require_methods=['POST'],
134+
permission='admin',
135+
uses_session=True,
136+
require_csrf=True,
137+
)
138+
def user_delete(request):
139+
user = request.db.query(User).get(request.matchdict['user_id'])
140+
141+
if user.username != request.params.get('username'):
142+
print(user.username)
143+
print(request.params.get('username'))
144+
request.session.flash(f'Wrong confirmation input.', queue='error')
145+
return HTTPSeeOther(
146+
request.route_path('admin.user.detail', user_id=user.id)
147+
)
148+
149+
# Delete projects one by one so they are purged from the cache
150+
for project in user.projects:
151+
remove_project(project, request, flash=False)
152+
153+
request.db.delete(user)
154+
request.db.add(
155+
JournalEntry(
156+
name=f'user:{user.username}',
157+
action=f'nuke user',
158+
submitted_by=request.user,
159+
submitted_from=request.remote_addr,
160+
)
161+
)
162+
request.session.flash(f'Nuked user {user.username!r}.', queue='success')
163+
return HTTPSeeOther(request.route_path('admin.user.list'))

warehouse/utils/project.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def confirm_project(project, request, fail_route):
4040
)
4141

4242

43-
def remove_project(project, request):
43+
def remove_project(project, request, flash=True):
4444
# TODO: We don't actually delete files from the data store. We should add
4545
# some kind of garbage collection at some point.
4646

@@ -74,7 +74,11 @@ def remove_project(project, request):
7474
# Finally, delete the project
7575
request.db.delete(project)
7676

77-
request.session.flash(
78-
f"Successfully deleted the project {project.name!r}.",
79-
queue="success",
80-
)
77+
# Flush so we can repeat this multiple times if necessary
78+
request.db.flush()
79+
80+
if flash:
81+
request.session.flash(
82+
f"Successfully deleted the project {project.name!r}.",
83+
queue="success",
84+
)

0 commit comments

Comments
 (0)