1#! /usr/bin/env python3
2#
3# BitBake Toaster Implementation
4#
5# Copyright (C) 2013-2015 Intel Corporation
6#
7# SPDX-License-Identifier: GPL-2.0-only
8#
9
10"""Test cases for Toaster GUI and ReST."""
11
12from django.test import TestCase
13from django.test.client import RequestFactory
14from django.urls import reverse
15from django.db.models import Q
16
17from orm.models import Project, Package
18from orm.models import Layer_Version, Recipe
19from orm.models import CustomImageRecipe
20from orm.models import CustomImagePackage
21
22import inspect
23import toastergui
24
25from toastergui.tables import SoftwareRecipesTable
26import json
27from bs4 import BeautifulSoup
28import string
29
30PROJECT_NAME = "test project"
31PROJECT_NAME2 = "test project 2"
32CLI_BUILDS_PROJECT_NAME = 'Command line builds'
33
34
35class ViewTests(TestCase):
36    """Tests to verify view APIs."""
37
38    fixtures = ['toastergui-unittest-data']
39
40    def setUp(self):
41
42        self.project = Project.objects.first()
43        self.recipe1 = Recipe.objects.get(pk=2)
44        self.customr = CustomImageRecipe.objects.first()
45        self.cust_package = CustomImagePackage.objects.first()
46        self.package = Package.objects.first()
47        self.lver = Layer_Version.objects.first()
48
49    def test_get_base_call_returns_html(self):
50        """Basic test for all-projects view"""
51        response = self.client.get(reverse('all-projects'), follow=True)
52        self.assertEqual(response.status_code, 200)
53        self.assertTrue(response['Content-Type'].startswith('text/html'))
54        self.assertTemplateUsed(response, "projects-toastertable.html")
55
56    def test_get_json_call_returns_json(self):
57        """Test for all projects output in json format"""
58        url = reverse('all-projects')
59        response = self.client.get(url, {"format": "json"}, follow=True)
60        self.assertEqual(response.status_code, 200)
61        self.assertTrue(response['Content-Type'].startswith(
62            'application/json'))
63
64        data = json.loads(response.content.decode('utf-8'))
65
66        self.assertTrue("error" in data)
67        self.assertEqual(data["error"], "ok")
68        self.assertTrue("rows" in data)
69
70        name_found = False
71        for row in data["rows"]:
72            name_found = row['name'].find(self.project.name)
73
74        self.assertTrue(name_found,
75                        "project name not found in projects table")
76
77    def test_typeaheads(self):
78        """Test typeahead ReST API"""
79        layers_url = reverse('xhr_layerstypeahead', args=(self.project.id,))
80        prj_url = reverse('xhr_projectstypeahead')
81
82        urls = [layers_url,
83                prj_url,
84                reverse('xhr_recipestypeahead', args=(self.project.id,)),
85                reverse('xhr_machinestypeahead', args=(self.project.id,))]
86
87        def basic_reponse_check(response, url):
88            """Check data structure of http response."""
89            self.assertEqual(response.status_code, 200)
90            self.assertTrue(response['Content-Type'].startswith(
91                'application/json'))
92
93            data = json.loads(response.content.decode('utf-8'))
94
95            self.assertTrue("error" in data)
96            self.assertEqual(data["error"], "ok")
97            self.assertTrue("results" in data)
98
99            # We got a result so now check the fields
100            if len(data['results']) > 0:
101                result = data['results'][0]
102
103                self.assertTrue(len(result['name']) > 0)
104                self.assertTrue("detail" in result)
105                self.assertTrue(result['id'] > 0)
106
107                # Special check for the layers typeahead's extra fields
108                if url == layers_url:
109                    self.assertTrue(len(result['layerdetailurl']) > 0)
110                    self.assertTrue(len(result['vcs_url']) > 0)
111                    self.assertTrue(len(result['vcs_reference']) > 0)
112                # Special check for project typeahead extra fields
113                elif url == prj_url:
114                    self.assertTrue(len(result['projectPageUrl']) > 0)
115
116                return True
117
118            return False
119
120        for url in urls:
121            results = False
122
123            for typeing in list(string.ascii_letters):
124                response = self.client.get(url, {'search': typeing})
125                results = basic_reponse_check(response, url)
126                if results:
127                    break
128
129            # After "typeing" the alpabet we should have result true
130            # from each of the urls
131            self.assertTrue(results)
132
133    def test_xhr_add_layer(self):
134        """Test xhr_add API"""
135        # Test for importing an already existing layer
136        api_url = reverse('xhr_layer', args=(self.project.id,))
137
138        layer_data = {'vcs_url': "git://git.example.com/test",
139                      'name': "base-layer",
140                      'git_ref': "c12b9596afd236116b25ce26dbe0d793de9dc7ce",
141                      'project_id': self.project.id,
142                      'local_source_dir': "",
143                      'add_to_project': True,
144                      'dir_path': "/path/in/repository"}
145
146        layer_data_json = json.dumps(layer_data)
147
148        response = self.client.put(api_url, layer_data_json)
149        data = json.loads(response.content.decode('utf-8'))
150        self.assertEqual(response.status_code, 200)
151        self.assertEqual(data["error"], "ok")
152
153        self.assertTrue(
154            layer_data['name'] in
155            self.project.get_all_compatible_layer_versions().values_list(
156                'layer__name',
157                flat=True),
158            "Could not find imported layer in project's all layers list"
159        )
160
161        # Empty data passed
162        response = self.client.put(api_url, "{}")
163        data = json.loads(response.content.decode('utf-8'))
164        self.assertNotEqual(data["error"], "ok")
165
166    def test_custom_ok(self):
167        """Test successful return from ReST API xhr_customrecipe"""
168        url = reverse('xhr_customrecipe')
169        params = {'name': 'custom', 'project': self.project.id,
170                  'base': self.recipe1.id}
171        response = self.client.post(url, params)
172        self.assertEqual(response.status_code, 200)
173        data = json.loads(response.content.decode('utf-8'))
174        self.assertEqual(data['error'], 'ok')
175        self.assertTrue('url' in data)
176        # get recipe from the database
177        recipe = CustomImageRecipe.objects.get(project=self.project,
178                                               name=params['name'])
179        args = (self.project.id, recipe.id,)
180        self.assertEqual(reverse('customrecipe', args=args), data['url'])
181
182    def test_custom_incomplete_params(self):
183        """Test not passing all required parameters to xhr_customrecipe"""
184        url = reverse('xhr_customrecipe')
185        for params in [{}, {'name': 'custom'},
186                       {'name': 'custom', 'project': self.project.id}]:
187            response = self.client.post(url, params)
188            self.assertEqual(response.status_code, 200)
189            data = json.loads(response.content.decode('utf-8'))
190            self.assertNotEqual(data["error"], "ok")
191
192    def test_xhr_custom_wrong_project(self):
193        """Test passing wrong project id to xhr_customrecipe"""
194        url = reverse('xhr_customrecipe')
195        params = {'name': 'custom', 'project': 0, "base": self.recipe1.id}
196        response = self.client.post(url, params)
197        self.assertEqual(response.status_code, 200)
198        data = json.loads(response.content.decode('utf-8'))
199        self.assertNotEqual(data["error"], "ok")
200
201    def test_xhr_custom_wrong_base(self):
202        """Test passing wrong base recipe id to xhr_customrecipe"""
203        url = reverse('xhr_customrecipe')
204        params = {'name': 'custom', 'project': self.project.id, "base": 0}
205        response = self.client.post(url, params)
206        self.assertEqual(response.status_code, 200)
207        data = json.loads(response.content.decode('utf-8'))
208        self.assertNotEqual(data["error"], "ok")
209
210    def test_xhr_custom_details(self):
211        """Test getting custom recipe details"""
212        url = reverse('xhr_customrecipe_id', args=(self.customr.id,))
213        response = self.client.get(url)
214        self.assertEqual(response.status_code, 200)
215        expected = {"error": "ok",
216                    "info": {'id': self.customr.id,
217                             'name': self.customr.name,
218                             'base_recipe_id': self.recipe1.id,
219                             'project_id': self.project.id}}
220        self.assertEqual(json.loads(response.content.decode('utf-8')),
221                         expected)
222
223    def test_xhr_custom_del(self):
224        """Test deleting custom recipe"""
225        name = "to be deleted"
226        recipe = CustomImageRecipe.objects.create(
227                     name=name, project=self.project,
228                     base_recipe=self.recipe1,
229                     file_path="/tmp/testing",
230                     layer_version=self.customr.layer_version)
231        url = reverse('xhr_customrecipe_id', args=(recipe.id,))
232        response = self.client.delete(url)
233        self.assertEqual(response.status_code, 200)
234
235        gotoUrl = reverse('projectcustomimages', args=(self.project.pk,))
236
237        self.assertEqual(json.loads(response.content.decode('utf-8')),
238                         {"error": "ok",
239                          "gotoUrl": gotoUrl})
240
241        # try to delete not-existent recipe
242        url = reverse('xhr_customrecipe_id', args=(recipe.id,))
243        response = self.client.delete(url)
244        self.assertEqual(response.status_code, 200)
245        self.assertNotEqual(json.loads(
246            response.content.decode('utf-8'))["error"], "ok")
247
248    def test_xhr_custom_packages(self):
249        """Test adding and deleting package to a custom recipe"""
250        # add self.package to recipe
251        response = self.client.put(reverse('xhr_customrecipe_packages',
252                                           args=(self.customr.id,
253                                                 self.cust_package.id)))
254
255        self.assertEqual(response.status_code, 200)
256        self.assertEqual(json.loads(response.content.decode('utf-8')),
257                         {"error": "ok"})
258        self.assertEqual(self.customr.appends_set.first().name,
259                         self.cust_package.name)
260        # delete it
261        to_delete = self.customr.appends_set.first().pk
262        del_url = reverse('xhr_customrecipe_packages',
263                          args=(self.customr.id, to_delete))
264
265        response = self.client.delete(del_url)
266        self.assertEqual(response.status_code, 200)
267        self.assertEqual(json.loads(response.content.decode('utf-8')),
268                         {"error": "ok"})
269        all_packages = self.customr.get_all_packages().values_list('pk',
270                                                                   flat=True)
271
272        self.assertFalse(to_delete in all_packages)
273        # delete invalid package to test error condition
274        del_url = reverse('xhr_customrecipe_packages',
275                          args=(self.customr.id,
276                                99999))
277
278        response = self.client.delete(del_url)
279        self.assertEqual(response.status_code, 200)
280        self.assertNotEqual(json.loads(
281            response.content.decode('utf-8'))["error"], "ok")
282
283    def test_xhr_custom_packages_err(self):
284        """Test error conditions of xhr_customrecipe_packages"""
285        # test calls with wrong recipe id and wrong package id
286        for args in [(0, self.package.id), (self.customr.id, 0)]:
287            url = reverse('xhr_customrecipe_packages', args=args)
288            # test put and delete methods
289            for method in (self.client.put, self.client.delete):
290                response = method(url)
291                self.assertEqual(response.status_code, 200)
292                self.assertNotEqual(json.loads(
293                    response.content.decode('utf-8')),
294                    {"error": "ok"})
295
296    def test_download_custom_recipe(self):
297        """Download the recipe file generated for the custom image"""
298
299        # Create a dummy recipe file for the custom image generation to read
300        open("/tmp/a_recipe.bb", 'a').close()
301        response = self.client.get(reverse('customrecipedownload',
302                                           args=(self.project.id,
303                                                 self.customr.id)))
304
305        self.assertEqual(response.status_code, 200)
306
307    def test_software_recipes_table(self):
308        """Test structure returned for Software RecipesTable"""
309        table = SoftwareRecipesTable()
310        request = RequestFactory().get('/foo/', {'format': 'json'})
311        response = table.get(request, pid=self.project.id)
312        data = json.loads(response.content.decode('utf-8'))
313
314        recipes = Recipe.objects.filter(Q(is_image=False))
315        self.assertTrue(len(recipes) > 1,
316                        "Need more than one software recipe to test "
317                        "SoftwareRecipesTable")
318
319        recipe1 = recipes[0]
320        recipe2 = recipes[1]
321
322        rows = data['rows']
323        row1 = next(x for x in rows if x['name'] == recipe1.name)
324        row2 = next(x for x in rows if x['name'] == recipe2.name)
325
326        self.assertEqual(response.status_code, 200, 'should be 200 OK status')
327
328        # check other columns have been populated correctly
329        self.assertTrue(recipe1.name in row1['name'])
330        self.assertTrue(recipe1.version in row1['version'])
331        self.assertTrue(recipe1.description in
332                        row1['get_description_or_summary'])
333
334        self.assertTrue(recipe1.layer_version.layer.name in
335                        row1['layer_version__layer__name'])
336
337        self.assertTrue(recipe2.name in row2['name'])
338        self.assertTrue(recipe2.version in row2['version'])
339        self.assertTrue(recipe2.description in
340                        row2['get_description_or_summary'])
341
342        self.assertTrue(recipe2.layer_version.layer.name in
343                        row2['layer_version__layer__name'])
344
345    def test_toaster_tables(self):
346        """Test all ToasterTables instances"""
347
348        def get_data(table, options={}):
349            """Send a request and parse the json response"""
350            options['format'] = "json"
351            options['nocache'] = "true"
352            request = RequestFactory().get('/', options)
353
354            # This is the image recipe needed for a package list for
355            # PackagesTable do this here to throw a non exist exception
356            image_recipe = Recipe.objects.get(pk=4)
357
358            # Add any kwargs that are needed by any of the possible tables
359            args = {'pid': self.project.id,
360                    'layerid': self.lver.pk,
361                    'recipeid': self.recipe1.pk,
362                    'recipe_id': image_recipe.pk,
363                    'custrecipeid': self.customr.pk,
364                    'build_id': 1,
365                    'target_id': 1}
366
367            response = table.get(request, **args)
368            return json.loads(response.content.decode('utf-8'))
369
370        def get_text_from_td(td):
371            """If we have html in the td then extract the text portion"""
372            # just so we don't waste time parsing non html
373            if "<" not in td:
374                ret = td
375            else:
376                ret = BeautifulSoup(td, "html.parser").text
377
378            if len(ret):
379                return "0"
380            else:
381                return ret
382
383        # Get a list of classes in tables module
384        tables = inspect.getmembers(toastergui.tables, inspect.isclass)
385        tables.extend(inspect.getmembers(toastergui.buildtables,
386                                         inspect.isclass))
387
388        for name, table_cls in tables:
389            # Filter out the non ToasterTables from the tables module
390            if not issubclass(table_cls, toastergui.widgets.ToasterTable) or \
391                table_cls == toastergui.widgets.ToasterTable or \
392               'Mixin' in name:
393                continue
394
395            # Get the table data without any options, this also does the
396            # initialisation of the table i.e. setup_columns,
397            # setup_filters and setup_queryset that we can use later
398            table = table_cls()
399            all_data = get_data(table)
400
401            self.assertTrue(len(all_data['rows']) > 1,
402                            "Cannot test on a %s table with < 1 row" % name)
403
404            if table.default_orderby:
405                row_one = get_text_from_td(
406                    all_data['rows'][0][table.default_orderby.strip("-")])
407                row_two = get_text_from_td(
408                    all_data['rows'][1][table.default_orderby.strip("-")])
409
410                if '-' in table.default_orderby:
411                    self.assertTrue(row_one >= row_two,
412                                    "Default ordering not working on %s"
413                                    " '%s' should be >= '%s'" %
414                                    (name, row_one, row_two))
415                else:
416                    self.assertTrue(row_one <= row_two,
417                                    "Default ordering not working on %s"
418                                    " '%s' should be <= '%s'" %
419                                    (name, row_one, row_two))
420
421            # Test the column ordering and filtering functionality
422            for column in table.columns:
423                if column['orderable']:
424                    # If a column is orderable test it in both order
425                    # directions ordering on the columns field_name
426                    ascending = get_data(table_cls(),
427                                         {"orderby": column['field_name']})
428
429                    row_one = get_text_from_td(
430                        ascending['rows'][0][column['field_name']])
431                    row_two = get_text_from_td(
432                        ascending['rows'][1][column['field_name']])
433
434                    self.assertTrue(row_one <= row_two,
435                                    "Ascending sort applied but row 0: \"%s\""
436                                    " is less than row 1: \"%s\" "
437                                    "%s %s " %
438                                    (row_one, row_two,
439                                     column['field_name'], name))
440
441                    descending = get_data(table_cls(),
442                                          {"orderby":
443                                           '-'+column['field_name']})
444
445                    row_one = get_text_from_td(
446                        descending['rows'][0][column['field_name']])
447                    row_two = get_text_from_td(
448                        descending['rows'][1][column['field_name']])
449
450                    self.assertTrue(row_one >= row_two,
451                                    "Descending sort applied but row 0: %s"
452                                    "is greater than row 1: %s"
453                                    "field %s table %s" %
454                                    (row_one,
455                                     row_two,
456                                     column['field_name'], name))
457
458                    # If the two start rows are the same we haven't actually
459                    # changed the order
460                    self.assertNotEqual(ascending['rows'][0],
461                                        descending['rows'][0],
462                                        "An orderby %s has not changed the "
463                                        "order of the data in table %s" %
464                                        (column['field_name'], name))
465
466                if column['filter_name']:
467                    # If a filter is available for the column get the filter
468                    # info. This contains what filter actions are defined.
469                    filter_info = get_data(table_cls(),
470                                           {"cmd": "filterinfo",
471                                            "name": column['filter_name']})
472                    self.assertTrue(len(filter_info['filter_actions']) > 0,
473                                    "Filter %s was defined but no actions "
474                                    "added to it" % column['filter_name'])
475
476                    for filter_action in filter_info['filter_actions']:
477                        # filter string to pass as the option
478                        # This is the name of the filter:action
479                        # e.g. project_filter:not_in_project
480                        filter_string = "%s:%s" % (
481                            column['filter_name'],
482                            filter_action['action_name'])
483                        # Now get the data with the filter applied
484                        filtered_data = get_data(table_cls(),
485                                                 {"filter": filter_string})
486
487                        # date range filter actions can't specify the
488                        # number of results they return, so their count is 0
489                        if filter_action['count'] is not None:
490                            self.assertEqual(
491                                len(filtered_data['rows']),
492                                int(filter_action['count']),
493                                "We added a table filter for %s but "
494                                "the number of rows returned was not "
495                                "what the filter info said there "
496                                "would be" % name)
497
498            # Test search functionality on the table
499            something_found = False
500            for search in list(string.ascii_letters):
501                search_data = get_data(table_cls(), {'search': search})
502
503                if len(search_data['rows']) > 0:
504                    something_found = True
505                    break
506
507            self.assertTrue(something_found,
508                            "We went through the whole alphabet and nothing"
509                            " was found for the search of table %s" % name)
510
511            # Test the limit functionality on the table
512            limited_data = get_data(table_cls(), {'limit': "1"})
513            self.assertEqual(len(limited_data['rows']),
514                             1,
515                             "Limit 1 set on table %s but not 1 row returned"
516                             % name)
517
518            # Test the pagination functionality on the table
519            page_one_data = get_data(table_cls(), {'limit': "1",
520                                                   "page": "1"})['rows'][0]
521
522            page_two_data = get_data(table_cls(), {'limit': "1",
523                                                   "page": "2"})['rows'][0]
524
525            self.assertNotEqual(page_one_data,
526                                page_two_data,
527                                "Changed page on table %s but first row is"
528                                " the same as the previous page" % name)
529