diff --git a/conntrackt/static/conntrackt.js b/conntrackt/static/conntrackt.js --- a/conntrackt/static/conntrackt.js +++ b/conntrackt/static/conntrackt.js @@ -37,7 +37,7 @@ // Show the search suggestions only if the user has provided 2 or more // characters. if (searchField.val().length >= 2) { - request = $.getJSON(conntrackt_api_url + "/search/" + searchField.val()) + request = $.getJSON(conntrackt_api_url + "/search/" + searchField.val(), {"limit": 4}) .done(function(data) { // Process the retrieved JSON response, setting-up list of diff --git a/conntrackt/tests/test_views.py b/conntrackt/tests/test_views.py --- a/conntrackt/tests/test_views.py +++ b/conntrackt/tests/test_views.py @@ -20,6 +20,7 @@ # Standard library imports. +import json from StringIO import StringIO from zipfile import ZipFile, ZIP_DEFLATED @@ -27,6 +28,7 @@ from zipfile import ZipFile, ZIP_DEFLATE import mock # Django imports. +from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.http import Http404 from django.test import RequestFactory @@ -36,7 +38,7 @@ from django.utils.http import urlquote # Application imports from conntrackt.models import Project, Location, Entity, Interface, Communication -from conntrackt.views import IndexView, SearchView +from conntrackt.views import IndexView, SearchView, APISearchView from conntrackt.views import entity_iptables, project_iptables, project_diagram from conntrackt.views import ProjectView, ProjectCreateView, ProjectUpdateView, ProjectDeleteView @@ -1604,3 +1606,156 @@ class SearchViewTest(PermissionTestMixin self.assertNotIn("search_term", response.context_data) # Only the "view" context variable should be present. self.assertEqual(1, len(response.context_data)) + + +class APISearchViewTest(PermissionTestMixin, TestCase): + + sufficient_permissions = ("view",) + view_class = APISearchView + + def setUp(self): + """ + Set-up some test data. + """ + + setup_test_data() + + def test_limit_negative(self): + """ + Test if an exception is raised in case a negative limit is requested. + """ + + # Get the view. + view = APISearchView.as_view() + + # Generate the request. + request = RequestFactory().get("/fake-path?limit=-1") + request.user = mock.Mock() + request._dont_enforce_csrf_checks = True + + # Validate the response. + self.assertRaisesRegexp(ValidationError, "Limit may not be a negative value.", view, request, search_term="test") + + def test_empty_query(self): + """ + Test that the response is empty if empty query was sent. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="") + + self.assertEqual(response['Content-Type'], "application/json") + self.assertEqual(response.content, "[]") + + def test_strip_search_term(self): + """ + Verifies that the search term is stripped when search is performed. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="Test Entity 1") + + # Validate the response. + expected_content = """[{"project": "Test Project 1", "url": "/conntrackt/entity/1/", "type": "entity", "name": "Test Entity 1"}]""" + self.assertEqual(response.content, expected_content) + + def test_no_items(self): + """ + Test the response if no items are found. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="string that does not exist") + + self.assertEqual(response['Content-Type'], "application/json") + self.assertEqual(response.content, "[]") + + def test_entity_found(self): + """ + Test the response if a single entity is found. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="Test Entity 1") + + expected_content = """[{"project": "Test Project 1", "url": "/conntrackt/entity/1/", "type": "entity", "name": "Test Entity 1"}]""" + + self.assertEqual(response['Content-Type'], "application/json") + self.assertEqual(response.content, expected_content) + + def test_project_found(self): + """ + Test the response if a single project is found. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="Test Project 1") + + expected_content = """[{"project": "Test Project 1", "url": "/conntrackt/project/1/", "type": "project", "name": "Test Project 1"}]""" + + self.assertEqual(response['Content-Type'], "application/json") + self.assertEqual(response.content, expected_content) + + def test_multiple_items_found(self): + """ + Test the response if multiple items are found. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="Test") + + # Verify that the JSON reply is valid. + try: + items = json.loads(response.content) + except ValueError: + self.fail("Parsing of resulting JSON has failed") + + # Verify that a list of items was returned. + self.assertTrue(isinstance(items, list)) + + # Verify each item. + for item in items: + # Every item must be a dictionary. + self.assertTrue(isinstance(item, dict)) + keys = item.keys() + # Verify that 4 specific keys are present in dictionary (project, + # url, name, type). + self.assertEqual(len(keys), 4) + self.assertIn("project", keys) + self.assertIn("name", keys) + self.assertIn("url", keys) + self.assertIn("type", keys) + # Verify the type associated with item. + self.assertIn(item["type"], ["project", "entity"]) + + def test_content_type(self): + """ + Test if correct content type is being returned by the response. + """ + + # Get the view. + view = APISearchView.as_view() + + # Get the response. + response = generate_get_response(view, search_term="test") + + self.assertEqual(response['Content-Type'], "application/json") + diff --git a/conntrackt/views.py b/conntrackt/views.py --- a/conntrackt/views.py +++ b/conntrackt/views.py @@ -27,6 +27,7 @@ import json # Django imports. from django.contrib.auth.decorators import permission_required from django.contrib import messages +from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse, reverse_lazy from django.db.models import Q from django.http import HttpResponse @@ -1039,7 +1040,10 @@ class APISearchView(MultiplePermissionsR "all": ("conntrackt.view",), } - def get(self, request, search_term): + # Raise authorisation denied exception for unmet permissions. + raise_exception = True + + def get(self, request, search_term=""): """ Implements response handling for a GET request. """ @@ -1051,6 +1055,11 @@ class APISearchView(MultiplePermissionsR # Set-up a list that will contain found items. items = [] + # Fetch the maximum number of items that should be returned. + limit = int(request.GET.get("limit", 0)) + if limit < 0: + raise ValidationError("Limit may not be a negative value.") + # Don't perform search with empty search term. if search_term != "": @@ -1058,6 +1067,11 @@ class APISearchView(MultiplePermissionsR entities = Entity.objects.search(search_term).select_related("project") projects = Project.objects.search(search_term) + # If maximum number of items was provided, narrow-down the results. + if limit > 0: + entities = entities[:limit] + projects = projects[:limit] + # Add found entities. for entity in entities: items.append({"name": entity.name,