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