diff --git a/tests/common/db/malware.py b/tests/common/db/malware.py
index 8c365f4abb66..b6e1bf387b90 100644
--- a/tests/common/db/malware.py
+++ b/tests/common/db/malware.py
@@ -51,6 +51,12 @@ class Meta:
check = factory.SubFactory(MalwareCheckFactory)
release_file = factory.SubFactory(FileFactory)
+ release = None
+ project = None
+ manually_reviewed = True
+ administrator_verdict = factory.fuzzy.FuzzyChoice(list(VerdictClassification))
classification = factory.fuzzy.FuzzyChoice(list(VerdictClassification))
confidence = factory.fuzzy.FuzzyChoice(list(VerdictConfidence))
message = factory.fuzzy.FuzzyText(length=80)
+ full_report_link = None
+ details = None
diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py
index 2c55099d0242..ada7eca60831 100644
--- a/tests/common/db/packaging.py
+++ b/tests/common/db/packaging.py
@@ -83,6 +83,7 @@ class Meta:
release = factory.SubFactory(ReleaseFactory)
python_version = "source"
+ filename = factory.fuzzy.FuzzyText(length=12)
md5_digest = factory.LazyAttribute(
lambda o: hashlib.md5(o.filename.encode("utf8")).hexdigest()
)
diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py
index e10962ac54dc..425d50167529 100644
--- a/tests/unit/admin/test_routes.py
+++ b/tests/unit/admin/test_routes.py
@@ -132,4 +132,8 @@ def test_includeme():
"/admin/checks/{check_name}/change_state",
domain=warehouse,
),
+ pretend.call("admin.verdicts.list", "/admin/verdicts/", domain=warehouse),
+ pretend.call(
+ "admin.verdicts.detail", "/admin/verdicts/{verdict_id}", domain=warehouse
+ ),
]
diff --git a/tests/unit/admin/views/test_verdicts.py b/tests/unit/admin/views/test_verdicts.py
new file mode 100644
index 000000000000..7d28820ca9cf
--- /dev/null
+++ b/tests/unit/admin/views/test_verdicts.py
@@ -0,0 +1,63 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import uuid
+
+from random import randint
+
+import pretend
+import pytest
+
+from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
+
+from warehouse.admin.views import verdicts as views
+
+from ....common.db.malware import MalwareVerdictFactory
+
+
+class TestListVerdicts:
+ def test_none(self, db_request):
+ assert views.get_verdicts(db_request) == {"verdicts": []}
+
+ def test_some(self, db_request):
+ verdicts = [MalwareVerdictFactory.create() for _ in range(10)]
+
+ assert views.get_verdicts(db_request) == {"verdicts": verdicts}
+
+ def test_some_with_multipage(self, db_request):
+ verdicts = [MalwareVerdictFactory.create() for _ in range(60)]
+
+ db_request.GET["page"] = "2"
+
+ assert views.get_verdicts(db_request) == {"verdicts": verdicts[25:50]}
+
+ def test_with_invalid_page(self):
+ request = pretend.stub(params={"page": "not an integer"})
+
+ with pytest.raises(HTTPBadRequest):
+ views.get_verdicts(request)
+
+
+class TestGetVerdict:
+ def test_found(self, db_request):
+ verdicts = [MalwareVerdictFactory.create() for _ in range(10)]
+ index = randint(0, 9)
+ lookup_id = verdicts[index].id
+ db_request.matchdict["verdict_id"] = lookup_id
+
+ assert views.get_verdict(db_request) == {"verdict": verdicts[index]}
+
+ def test_not_found(self, db_request):
+ db_request.matchdict["verdict_id"] = uuid.uuid4()
+
+ with pytest.raises(HTTPNotFound):
+ views.get_verdict(db_request)
diff --git a/tests/unit/malware/test_tasks.py b/tests/unit/malware/test_tasks.py
index 5c89cc5fd562..234d05dba5bb 100644
--- a/tests/unit/malware/test_tasks.py
+++ b/tests/unit/malware/test_tasks.py
@@ -266,13 +266,14 @@ def test_no_verdicts(self, db_session):
log=pretend.stub(info=pretend.call_recorder(lambda *args, **kwargs: None),),
)
task = pretend.stub()
- remove_verdicts(task, request, check.name)
+ removed = remove_verdicts(task, request, check.name)
assert request.log.info.calls == [
pretend.call(
"Removing 0 malware verdicts associated with %s version 1." % check.name
),
]
+ assert removed == 0
@pytest.mark.parametrize(("check_with_verdicts"), [True, False])
def test_many_verdicts(self, db_session, check_with_verdicts):
@@ -286,6 +287,8 @@ def test_many_verdicts(self, db_session, check_with_verdicts):
for i in range(num_verdicts):
MalwareVerdictFactory.create(check=check1, release_file=file0)
+ assert db_session.query(MalwareVerdict).count() == num_verdicts
+
request = pretend.stub(
db=db_session,
log=pretend.stub(info=pretend.call_recorder(lambda *args, **kwargs: None),),
@@ -299,7 +302,7 @@ def test_many_verdicts(self, db_session, check_with_verdicts):
wiped_out_check = check0
num_verdicts = 0
- remove_verdicts(task, request, wiped_out_check.name)
+ removed = remove_verdicts(task, request, wiped_out_check.name)
assert request.log.info.calls == [
pretend.call(
@@ -307,3 +310,5 @@ def test_many_verdicts(self, db_session, check_with_verdicts):
% (num_verdicts, wiped_out_check.name)
),
]
+
+ assert removed == num_verdicts
diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py
index 4180df8188a6..284932c29190 100644
--- a/warehouse/admin/routes.py
+++ b/warehouse/admin/routes.py
@@ -139,3 +139,7 @@ def includeme(config):
"/admin/checks/{check_name}/change_state",
domain=warehouse,
)
+ config.add_route("admin.verdicts.list", "/admin/verdicts/", domain=warehouse)
+ config.add_route(
+ "admin.verdicts.detail", "/admin/verdicts/{verdict_id}", domain=warehouse
+ )
diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html
index cc74b4675b81..fffa7ccfcec5 100644
--- a/warehouse/admin/templates/admin/base.html
+++ b/warehouse/admin/templates/admin/base.html
@@ -130,6 +130,11 @@
Checks
+
+
+ Verdicts
+
+
diff --git a/warehouse/admin/templates/admin/malware/verdicts/detail.html b/warehouse/admin/templates/admin/malware/verdicts/detail.html
new file mode 100644
index 000000000000..7702943e8692
--- /dev/null
+++ b/warehouse/admin/templates/admin/malware/verdicts/detail.html
@@ -0,0 +1,80 @@
+{#
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+-#}
+{% extends "admin/base.html" %}
+
+{% block title %}Verdict {{ verdict.id }}{% endblock %}
+
+{% block breadcrumb %}
+ Verdicts
+ {{ verdict.id }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ Message |
+ {{ verdict.message }} |
+
+
+ Run Date |
+ {{ verdict.run_date }} |
+
+
+ Check |
+
+
+ {{ verdict.check.name }} v{{ verdict.check.version }}
+
+ |
+
+
+ Object |
+ {% include 'object_link.html' %} |
+
+
+ Verdict Classification |
+ {{ verdict.classification.value }} |
+
+
+ Verdict Confidence |
+ {{ verdict.confidence.value }} |
+
+
+ Manually Reviewed |
+ {{ verdict.manually_reviewed }} |
+
+ {% if verdict.manually_reviewed %}
+
+ Administrator Verdict |
+ {{ verdict.administrator_verdict }} |
+
+ {% endif %}
+ {% if verdict.full_report_link %}
+
+ Full Report Link |
+ {{ verdict.full_report_link }} |
+
+ {% endif %}
+ {% if verdict.details %}
+
+ Details |
+ {{ verdict.details|tojson(indent=4) }} |
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/warehouse/admin/templates/admin/malware/verdicts/index.html b/warehouse/admin/templates/admin/malware/verdicts/index.html
new file mode 100644
index 000000000000..d6ab7ef6b028
--- /dev/null
+++ b/warehouse/admin/templates/admin/malware/verdicts/index.html
@@ -0,0 +1,93 @@
+{#
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+-#}
+{% extends "admin/base.html" %}
+
+{% import "admin/utils/pagination.html" as pagination %}
+
+{% block title %}Malware Verdicts{% endblock %}
+
+{% block breadcrumb %}
+ Verdicts
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ Object |
+ Check |
+ Classification |
+ Confidence |
+ Detail |
+
+ {% for verdict in verdicts %}
+
+ {% include 'object_link.html' %} |
+
+
+ {{ verdict.check.name }} v{{ verdict.check.version }}
+
+ |
+
+
+
+ {% if verdict.classification.value == 'indeterminate' %}
+
+ {% elif verdict.classification.value == 'threat' %}
+
+
+ {% endif %}
+
+ |
+
+
+
+ {% if verdict.confidence.value == 'medium' %}
+
+ {% elif verdict.confidence.value == 'high' %}
+
+
+ {% endif %}
+
+ |
+
+
+ Detail
+
+ |
+
+ {% else %}
+
+
+
+ No verdicts!
+
+ |
+
+ {% endfor %}
+
+
+
+
+{% endblock content %}
diff --git a/warehouse/admin/templates/admin/malware/verdicts/object_link.html b/warehouse/admin/templates/admin/malware/verdicts/object_link.html
new file mode 100644
index 000000000000..c31678ce419c
--- /dev/null
+++ b/warehouse/admin/templates/admin/malware/verdicts/object_link.html
@@ -0,0 +1,21 @@
+{#
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+-#}
+
+{% if verdict.project %}
+ {{ verdict.project.name }}
+{% elif verdict.release %}
+ {{ verdict.release.project.name }}-{{ verdict.release.version }}
+{% else %}
+ {{ verdict.release_file.filename}}
+{% endif %}
diff --git a/warehouse/admin/views/verdicts.py b/warehouse/admin/views/verdicts.py
new file mode 100644
index 000000000000..bd9c2eae68ca
--- /dev/null
+++ b/warehouse/admin/views/verdicts.py
@@ -0,0 +1,61 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage
+from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
+from pyramid.view import view_config
+
+from warehouse.malware.models import MalwareVerdict
+from warehouse.utils.paginate import paginate_url_factory
+
+
+@view_config(
+ route_name="admin.verdicts.list",
+ renderer="admin/malware/verdicts/index.html",
+ permission="moderator",
+ request_method="GET",
+ uses_session=True,
+)
+def get_verdicts(request):
+ try:
+ page_num = int(request.params.get("page", 1))
+ except ValueError:
+ raise HTTPBadRequest("'page' must be an integer.") from None
+
+ verdicts_query = request.db.query(MalwareVerdict).order_by(
+ MalwareVerdict.run_date.desc()
+ )
+
+ verdicts = SQLAlchemyORMPage(
+ verdicts_query,
+ page=page_num,
+ items_per_page=25,
+ url_maker=paginate_url_factory(request),
+ )
+
+ return {"verdicts": verdicts}
+
+
+@view_config(
+ route_name="admin.verdicts.detail",
+ renderer="admin/malware/verdicts/detail.html",
+ permission="moderator",
+ request_method="GET",
+ uses_session=True,
+)
+def get_verdict(request):
+ verdict = request.db.query(MalwareVerdict).get(request.matchdict["verdict_id"])
+
+ if verdict:
+ return {"verdict": verdict}
+
+ raise HTTPNotFound
diff --git a/warehouse/malware/tasks.py b/warehouse/malware/tasks.py
index 0d2f570c436f..a70b7ea344d6 100644
--- a/warehouse/malware/tasks.py
+++ b/warehouse/malware/tasks.py
@@ -95,7 +95,7 @@ def remove_verdicts(task, request, check_name):
.filter(MalwareCheck.name == check_name)
.all()
)
-
+ total_deleted = 0
for check_id, check_version in check_ids:
query = request.db.query(MalwareVerdict).filter(
MalwareVerdict.check_id == check_id
@@ -105,4 +105,7 @@ def remove_verdicts(task, request, check_name):
"Removing %d malware verdicts associated with %s version %d."
% (num_verdicts, check_name, check_version)
)
- query.delete(synchronize_session=False)
+ total_deleted += query.delete(synchronize_session=False)
+
+ # This returned value is only relevant for testing.
+ return total_deleted