Skip to content

Commit b7ead57

Browse files
committed
feat(admin): quarantine routes and views
Signed-off-by: Mike Fiedler <[email protected]>
1 parent 1601b40 commit b7ead57

File tree

12 files changed

+465
-4
lines changed

12 files changed

+465
-4
lines changed

tests/unit/admin/test_routes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ def test_includeme():
176176
traverse="/{project_name}/{version}",
177177
domain=warehouse,
178178
),
179+
pretend.call(
180+
"admin.project.remove_from_quarantine",
181+
"/admin/projects/{project_name}/remove_from_quarantine/",
182+
factory="warehouse.packaging.models:ProjectFactory",
183+
traverse="/{project_name}",
184+
domain=warehouse,
185+
),
179186
pretend.call(
180187
"admin.project.journals",
181188
"/admin/projects/{project_name}/journals/",
@@ -275,6 +282,13 @@ def test_includeme():
275282
traverse="/{project_name}",
276283
domain=warehouse,
277284
),
285+
pretend.call(
286+
"admin.malware_reports.project.verdict_quarantine",
287+
"/admin/projects/{project_name}/malware_reports/quarantine/",
288+
factory="warehouse.packaging.models:ProjectFactory",
289+
traverse="/{project_name}",
290+
domain=warehouse,
291+
),
278292
pretend.call(
279293
"admin.malware_reports.project.verdict_remove_malware",
280294
"/admin/projects/{project_name}/malware_reports/remove_malware/",
@@ -292,6 +306,11 @@ def test_includeme():
292306
"/admin/malware_reports/{observation_id}/not_malware/",
293307
domain=warehouse,
294308
),
309+
pretend.call(
310+
"admin.malware_reports.detail.verdict_quarantine",
311+
"/admin/malware_reports/{observation_id}/quarantine/",
312+
domain=warehouse,
313+
),
295314
pretend.call(
296315
"admin.malware_reports.detail.verdict_remove_malware",
297316
"/admin/malware_reports/{observation_id}/remove_malware/",

tests/unit/admin/views/test_malware_reports.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pyramid.httpexceptions import HTTPSeeOther
1717

1818
from warehouse.admin.views import malware_reports as views
19-
from warehouse.packaging.models import Project
19+
from warehouse.packaging.models import LifecycleStatus, Project
2020

2121
from ....common.db.accounts import UserFactory
2222
from ....common.db.packaging import (
@@ -100,6 +100,36 @@ def test_malware_reports_project_verdict_not_malware(self, db_request):
100100
assert isinstance(datetime.fromisoformat(action_record["created_at"]), datetime)
101101
assert action_record["reason"] == "This is a test"
102102

103+
def test_malware_reports_project_verdict_quarantine(self, db_request):
104+
project = ProjectFactory.create()
105+
report = ProjectObservationFactory.create(kind="is_malware", related=project)
106+
107+
db_request.route_path = lambda a: "/admin/malware_reports/"
108+
db_request.session = pretend.stub(
109+
flash=pretend.call_recorder(lambda *a, **kw: None)
110+
)
111+
db_request.user = UserFactory.create()
112+
113+
result = views.malware_reports_project_verdict_quarantine(project, db_request)
114+
115+
assert isinstance(result, HTTPSeeOther)
116+
assert result.headers["Location"] == "/admin/malware_reports/"
117+
assert db_request.session.flash.calls == [
118+
pretend.call(
119+
f"Project {project.name} quarantined.\n"
120+
"Please update related Help Scout conversations.",
121+
queue="success",
122+
)
123+
]
124+
125+
assert project.lifecycle_status == LifecycleStatus.QuarantineEnter
126+
assert project.lifecycle_status_changed is not None
127+
assert (
128+
project.lifecycle_status_note
129+
== f"Quarantined by {db_request.user.username}."
130+
)
131+
assert len(report.actions) == 0
132+
103133
def test_malware_reports_project_verdict_remove_malware(self, db_request):
104134
owner_user = UserFactory.create(is_frozen=False)
105135
project = ProjectFactory.create()
@@ -173,6 +203,34 @@ def test_detail_not_malware_for_project(self, db_request):
173203
assert isinstance(datetime.fromisoformat(action_record["created_at"]), datetime)
174204
assert action_record["reason"] == "This is a test"
175205

206+
def test_detail_verdict_quarantine_project(self, db_request):
207+
report = ProjectObservationFactory.create(kind="is_malware")
208+
db_request.matchdict["observation_id"] = str(report.id)
209+
db_request.route_path = lambda a: "/admin/malware_reports/"
210+
db_request.session = pretend.stub(
211+
flash=pretend.call_recorder(lambda *a, **kw: None)
212+
)
213+
db_request.user = UserFactory.create()
214+
215+
result = views.verdict_quarantine_project(db_request)
216+
217+
assert isinstance(result, HTTPSeeOther)
218+
assert result.headers["Location"] == "/admin/malware_reports/"
219+
assert db_request.session.flash.calls == [
220+
pretend.call(
221+
f"Project {report.related.name} quarantined.\n"
222+
"Please update related Help Scout conversations.",
223+
queue="success",
224+
)
225+
]
226+
227+
assert report.related.lifecycle_status == LifecycleStatus.QuarantineEnter
228+
assert report.related.lifecycle_status_changed is not None
229+
assert report.related.lifecycle_status_note == (
230+
f"Quarantined by {db_request.user.username}."
231+
)
232+
assert len(report.actions) == 0
233+
176234
def test_detail_remove_malware_for_project(self, db_request):
177235
owner_user = UserFactory.create(is_frozen=False)
178236
project = ProjectFactory.create()

tests/unit/admin/views/test_projects.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,29 @@ def test_no_summary_errors(self):
241241
]
242242

243243

244+
class TestProjectQuarantine:
245+
def test_remove_from_quarantine(self, db_request):
246+
project = ProjectFactory.create(lifecycle_status="quarantine-enter")
247+
db_request.route_path = pretend.call_recorder(
248+
lambda *a, **kw: "/admin/projects/"
249+
)
250+
db_request.session = pretend.stub(
251+
flash=pretend.call_recorder(lambda *a, **kw: None)
252+
)
253+
db_request.user = UserFactory.create()
254+
db_request.matchdict["project_name"] = project.normalized_name
255+
256+
views.remove_from_quarantine(project, db_request)
257+
258+
assert db_request.session.flash.calls == [
259+
pretend.call(
260+
f"Project {project.name} quarantine cleared.\n"
261+
"Please update related Help Scout conversations.",
262+
queue="success",
263+
)
264+
]
265+
266+
244267
class TestProjectReleasesList:
245268
def test_no_query(self, db_request):
246269
project = ProjectFactory.create()

tests/unit/utils/test_project.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@
2020
Dependency,
2121
File,
2222
JournalEntry,
23+
LifecycleStatus,
2324
Project,
2425
Release,
2526
Role,
2627
)
2728
from warehouse.utils.project import (
29+
clear_project_quarantine,
2830
confirm_project,
2931
destroy_docs,
32+
quarantine_project,
3033
remove_documentation,
3134
remove_project,
3235
)
@@ -92,6 +95,54 @@ def test_confirm_incorrect_input():
9295
]
9396

9497

98+
@pytest.mark.parametrize("flash", [True, False])
99+
def test_quarantine_project(db_request, flash):
100+
user = UserFactory.create()
101+
project = ProjectFactory.create(name="foo")
102+
RoleFactory.create(user=user, project=project)
103+
104+
db_request.user = user
105+
db_request.session = stub(flash=call_recorder(lambda *a, **kw: stub()))
106+
107+
quarantine_project(project, db_request, flash=flash)
108+
109+
assert (
110+
db_request.db.query(Project).filter(Project.name == project.name).count() == 1
111+
)
112+
assert (
113+
db_request.db.query(Project)
114+
.filter(Project.name == project.name)
115+
.filter(Project.lifecycle_status == LifecycleStatus.QuarantineEnter)
116+
.first()
117+
)
118+
assert bool(db_request.session.flash.calls) == flash
119+
120+
121+
@pytest.mark.parametrize("flash", [True, False])
122+
def test_clear_project_quarantine(db_request, flash):
123+
user = UserFactory.create()
124+
project = ProjectFactory.create(
125+
name="foo", lifecycle_status=LifecycleStatus.QuarantineEnter
126+
)
127+
RoleFactory.create(user=user, project=project)
128+
129+
db_request.user = user
130+
db_request.session = stub(flash=call_recorder(lambda *a, **kw: stub()))
131+
132+
clear_project_quarantine(project, db_request, flash=flash)
133+
134+
assert (
135+
db_request.db.query(Project).filter(Project.name == project.name).count() == 1
136+
)
137+
assert (
138+
db_request.db.query(Project)
139+
.filter(Project.name == project.name)
140+
.filter(Project.lifecycle_status == LifecycleStatus.QuarantineExit)
141+
.first()
142+
)
143+
assert bool(db_request.session.flash.calls) == flash
144+
145+
95146
@pytest.mark.parametrize("flash", [True, False])
96147
def test_remove_project(db_request, flash):
97148
user = UserFactory.create()

warehouse/admin/routes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ def includeme(config):
180180
traverse="/{project_name}/{version}",
181181
domain=warehouse,
182182
)
183+
config.add_route(
184+
"admin.project.remove_from_quarantine",
185+
"/admin/projects/{project_name}/remove_from_quarantine/",
186+
factory="warehouse.packaging.models:ProjectFactory",
187+
traverse="/{project_name}",
188+
domain=warehouse,
189+
)
183190
config.add_route(
184191
"admin.project.journals",
185192
"/admin/projects/{project_name}/journals/",
@@ -283,6 +290,13 @@ def includeme(config):
283290
traverse="/{project_name}",
284291
domain=warehouse,
285292
)
293+
config.add_route(
294+
"admin.malware_reports.project.verdict_quarantine",
295+
"/admin/projects/{project_name}/malware_reports/quarantine/",
296+
factory="warehouse.packaging.models:ProjectFactory",
297+
traverse="/{project_name}",
298+
domain=warehouse,
299+
)
286300
config.add_route(
287301
"admin.malware_reports.project.verdict_remove_malware",
288302
"/admin/projects/{project_name}/malware_reports/remove_malware/",
@@ -300,6 +314,11 @@ def includeme(config):
300314
"/admin/malware_reports/{observation_id}/not_malware/",
301315
domain=warehouse,
302316
)
317+
config.add_route(
318+
"admin.malware_reports.detail.verdict_quarantine",
319+
"/admin/malware_reports/{observation_id}/quarantine/",
320+
domain=warehouse,
321+
)
303322
config.add_route(
304323
"admin.malware_reports.detail.verdict_remove_malware",
305324
"/admin/malware_reports/{observation_id}/remove_malware/",

warehouse/admin/templates/admin/malware_reports/detail.html

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ <h3 class="card-title">
7979
data-toggle="modal"
8080
data-target="#modal-not-malware">Not Malware</button>
8181
</div>
82+
<div class="col">
83+
<button type="button"
84+
class="btn btn-block btn-outline-warning"
85+
data-toggle="modal"
86+
data-target="#modal-quarantine">Quarantine Project</button>
87+
</div>
8288
<div class="col">
8389
<button type="button"
8490
class="btn btn-block btn-outline-danger"
@@ -144,6 +150,39 @@ <h4 class="modal-title">Confirm Not Malware</h4>
144150
</div>
145151
</div>
146152
<!-- /.modal -->
153+
<div class="modal fade" id="modal-quarantine">
154+
<div class="modal-dialog modal-quarantine">
155+
<form id="quarantine"
156+
action="{{ request.route_path('admin.malware_reports.detail.verdict_quarantine', observation_id=report.id) }}"
157+
method="post">
158+
<input name="csrf_token"
159+
type="hidden"
160+
value="{{ request.session.get_csrf_token() }}">
161+
<div class="modal-content">
162+
<div class="modal-header bg-warning">
163+
<h4 class="modal-title">Quarantine Project</h4>
164+
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
165+
<span aria-hidden="true">×</span>
166+
</button>
167+
</div>
168+
<div class="modal-body">
169+
<p>
170+
Confirming that <code>{{ report.related.name }}</code> needs further examination.
171+
</p>
172+
<p>
173+
This will remove the Project from being installable,
174+
and prohibit the Project from being changed by the Owner.
175+
</p>
176+
</div>
177+
<div class="modal-footer justify-content-between">
178+
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
179+
<button type="submit" class="btn btn-warning">Verdict: Quarantine Project</button>
180+
</div>
181+
</div>
182+
</form>
183+
</div>
184+
</div>
185+
<!-- /.modal -->
147186
<div class="modal fade" id="modal-remove-malware">
148187
<div class="modal-dialog modal-remove-malware">
149188
<form id="remove-malware"

warehouse/admin/templates/admin/malware_reports/project_list.html

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ <h3 class="card-title">Take Action on Project</h3>
124124
<strong>Not Malware</strong> will add an entry to each Observation
125125
that it was reviewed, and no malware was found. The Project will remain active.
126126
</p>
127+
<p>
128+
<strong>Quarantine</strong> will remove the Project from being installable,
129+
and prohibit the Project from being changed by the Owner.
130+
The Owner's account will remain active.
131+
No Observations will be changed, so it will remain in the list.
132+
</p>
127133
<p>
128134
<strong>Remove Malware</strong> will remove the Project,
129135
freeze the Owner's account, prohibit the Project name from being reused,
@@ -139,6 +145,12 @@ <h3 class="card-title">Take Action on Project</h3>
139145
data-toggle="modal"
140146
data-target="#modal-not-malware">Not Malware</button>
141147
</div>
148+
<div class="col">
149+
<button type="button"
150+
class="btn btn-block btn-outline-warning"
151+
data-toggle="modal"
152+
data-target="#modal-quarantine">Quarantine Project</button>
153+
</div>
142154
<div class="col">
143155
<button type="button"
144156
class="btn btn-block btn-outline-danger"
@@ -188,6 +200,39 @@ <h4 class="modal-title">Confirm Not Malware</h4>
188200
</div>
189201
</div>
190202
<!-- /.modal -->
203+
<div class="modal fade" id="modal-quarantine">
204+
<div class="modal-dialog modal-quarantine">
205+
<form id="quarantine"
206+
action="{{ request.route_path('admin.malware_reports.project.verdict_quarantine', project_name=project.name) }}"
207+
method="post">
208+
<input name="csrf_token"
209+
type="hidden"
210+
value="{{ request.session.get_csrf_token() }}">
211+
<div class="modal-content">
212+
<div class="modal-header bg-warning">
213+
<h4 class="modal-title">Quarantine Project</h4>
214+
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
215+
<span aria-hidden="true">×</span>
216+
</button>
217+
</div>
218+
<div class="modal-body">
219+
<p>
220+
Confirming that <code>{{ project.name }}</code> needs further examination.
221+
</p>
222+
<p>
223+
This will remove the Project from being installable,
224+
and prohibit the Project from being changed by the Owner.
225+
</p>
226+
</div>
227+
<div class="modal-footer justify-content-between">
228+
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
229+
<button type="submit" class="btn btn-warning">Verdict: Quarantine</button>
230+
</div>
231+
</div>
232+
</form>
233+
</div>
234+
</div>
235+
<!-- /.modal -->
191236
<div class="modal fade" id="modal-remove-malware">
192237
<div class="modal-dialog modal-remove-malware">
193238
<form id="remove-malware"

0 commit comments

Comments
 (0)