Skip to content

Commit 66b6730

Browse files
authored
feat(admin): Project Quarantine (#16179)
1 parent 1775af7 commit 66b6730

File tree

16 files changed

+620
-16
lines changed

16 files changed

+620
-16
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
@@ -243,6 +243,29 @@ def test_no_summary_errors(self):
243243
]
244244

245245

246+
class TestProjectQuarantine:
247+
def test_remove_from_quarantine(self, db_request):
248+
project = ProjectFactory.create(lifecycle_status="quarantine-enter")
249+
db_request.route_path = pretend.call_recorder(
250+
lambda *a, **kw: "/admin/projects/"
251+
)
252+
db_request.session = pretend.stub(
253+
flash=pretend.call_recorder(lambda *a, **kw: None)
254+
)
255+
db_request.user = UserFactory.create()
256+
db_request.matchdict["project_name"] = project.normalized_name
257+
258+
views.remove_from_quarantine(project, db_request)
259+
260+
assert db_request.session.flash.calls == [
261+
pretend.call(
262+
f"Project {project.name} quarantine cleared.\n"
263+
"Please update related Help Scout conversations.",
264+
queue="success",
265+
)
266+
]
267+
268+
246269
class TestProjectReleasesList:
247270
def test_no_query(self, db_request):
248271
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ <h3 class="card-title">
4646
<dt class="col-sm-2">Reported By</dt>
4747
<dd class="col-sm-10">
4848
<a href="{{ request.route_path('admin.user.detail', username=report.observer.parent.username) }}">{{ report.observer.parent.username }}</a>
49+
{% if report.observer.parent.is_observer %}<span class="badge badge-info">Observer</span>{% endif %}
4950
</dd>
5051
<dt class="col-sm-2">Reported At</dt>
5152
<dd class="col-sm-10">
@@ -78,6 +79,12 @@ <h3 class="card-title">
7879
data-toggle="modal"
7980
data-target="#modal-not-malware">Not Malware</button>
8081
</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>
8188
<div class="col">
8289
<button type="button"
8390
class="btn btn-block btn-outline-danger"
@@ -143,6 +150,39 @@ <h4 class="modal-title">Confirm Not Malware</h4>
143150
</div>
144151
</div>
145152
<!-- /.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 -->
146186
<div class="modal fade" id="modal-remove-malware">
147187
<div class="modal-dialog modal-remove-malware">
148188
<form id="remove-malware"

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ <h3 class="timeline-header">
4646
<dt class="col-sm-2">Reported By</dt>
4747
<dd class="col-sm-10">
4848
<a href="{{ request.route_path('admin.user.detail', username=report.observer.parent.username) }}">{{ report.observer.parent.username }}</a>
49+
{% if report.observer.parent.is_observer %}<span class="badge badge-info">Observer</span>{% endif %}
4950
</dd>
5051
<dt class="col-sm-2">Origin</dt>
5152
<dd class="col-sm-10">
@@ -123,6 +124,12 @@ <h3 class="card-title">Take Action on Project</h3>
123124
<strong>Not Malware</strong> will add an entry to each Observation
124125
that it was reviewed, and no malware was found. The Project will remain active.
125126
</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>
126133
<p>
127134
<strong>Remove Malware</strong> will remove the Project,
128135
freeze the Owner's account, prohibit the Project name from being reused,
@@ -138,6 +145,12 @@ <h3 class="card-title">Take Action on Project</h3>
138145
data-toggle="modal"
139146
data-target="#modal-not-malware">Not Malware</button>
140147
</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>
141154
<div class="col">
142155
<button type="button"
143156
class="btn btn-block btn-outline-danger"
@@ -187,6 +200,39 @@ <h4 class="modal-title">Confirm Not Malware</h4>
187200
</div>
188201
</div>
189202
<!-- /.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 -->
190236
<div class="modal fade" id="modal-remove-malware">
191237
<div class="modal-dialog modal-remove-malware">
192238
<form id="remove-malware"

0 commit comments

Comments
 (0)