1# Copyright 2015 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import json
6import logging
7import os
8
9from autotest_lib.client.bin import utils
10from autotest_lib.client.common_lib import error
11from autotest_lib.client.common_lib.cros import chrome
12from autotest_lib.client.cros import cryptohome
13from autotest_lib.client.cros import enterprise_base
14from autotest_lib.client.cros import httpd
15
16CROSDEV_FLAGS = [
17    '--gaia-url=https://gaiastaging.corp.google.com',
18    '--lso-url=https://gaiastaging.corp.google.com',
19    '--google-apis-url=https://www-googleapis-test.sandbox.google.com',
20    '--oauth2-client-id=236834563817.apps.googleusercontent.com',
21    '--oauth2-client-secret=RsKv5AwFKSzNgE0yjnurkPVI',
22    ('--cloud-print-url='
23     'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
24    '--ignore-urlfetcher-cert-requests']
25CROSAUTO_FLAGS = [
26    ('--cloud-print-url='
27     'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
28    '--ignore-urlfetcher-cert-requests']
29TESTDMS_FLAGS = [
30    '--ignore-urlfetcher-cert-requests',
31    '--enterprise-enrollment-skip-robot-auth',
32    '--disable-policy-key-verification']
33FLAGS_DICT = {
34    'prod': [],
35    'cr-dev': CROSDEV_FLAGS,
36    'cr-auto': CROSAUTO_FLAGS,
37    'dm-test': TESTDMS_FLAGS,
38    'dm-fake': TESTDMS_FLAGS
39}
40DMS_URL_DICT = {
41    'prod': 'http://m.google.com/devicemanagement/data/api',
42    'cr-dev': 'https://cros-dev.sandbox.google.com/devicemanagement/data/api',
43    'cr-auto': 'https://cros-auto.sandbox.google.com/devicemanagement/data/api',
44    'dm-test': 'http://chromium-dm-test.appspot.com/d/%s',
45    'dm-fake': 'http://127.0.0.1:%d/'
46}
47DMSERVER = '--device-management-url=%s'
48
49
50class EnterprisePolicyTest(enterprise_base.EnterpriseTest):
51    """Base class for Enterprise Policy Tests."""
52
53    def setup(self):
54        os.chdir(self.srcdir)
55        utils.make()
56
57    def initialize(self, args=()):
58        self._initialize_test_context(args)
59
60        # Start AutoTest DM Server if using local fake server.
61        if self.env == 'dm-fake':
62            self.import_dmserver(self.srcdir)
63            self.start_dmserver()
64        self._initialize_chrome_extra_flags()
65        self._web_server = None
66
67    def cleanup(self):
68        # Clean up AutoTest DM Server if using local fake server.
69        if self.env == 'dm-fake':
70            super(EnterprisePolicyTest, self).cleanup()
71
72        # Stop web server if it was started.
73        if self._web_server:
74            self._web_server.stop()
75
76        # Close Chrome instance if opened.
77        if self.cr:
78            self.cr.close()
79
80    def start_webserver(self, port):
81        """Set up an HTTP Server to serve pages from localhost.
82
83        @param port: Port used by HTTP server.
84
85        """
86        if self.mode != 'list':
87            self._web_server = httpd.HTTPListener(port, docroot=self.bindir)
88            self._web_server.run()
89
90    def _initialize_test_context(self, args=()):
91        """Initialize class-level test context parameters.
92
93        @raises error.TestError if an arg is given an invalid value or some
94                combination of args is given incompatible values.
95
96        """
97        # Extract local parameters from command line args.
98        args_dict = utils.args_to_dict(args)
99        self.mode = args_dict.get('mode', 'all')
100        self.case = args_dict.get('case')
101        self.value = args_dict.get('value')
102        self.env = args_dict.get('env', 'dm-fake')
103        self.username = args_dict.get('username')
104        self.password = args_dict.get('password')
105        self.dms_name = args_dict.get('dms_name')
106
107        # If |mode| is 'list', set |env| to generic 'prod', and blank out
108        # the other key parameters: case, value.
109        if self.mode == 'list':
110            self.env = 'prod'
111            self.case = None
112            self.value = None
113
114        # If |case| is given then set |mode| to 'single'.
115        if self.case:
116            self.mode = 'single'
117
118        # If |mode| is 'all', then |env| must be 'dm-fake', and
119        # the |case| and |value| args must not be given.
120        if self.mode == 'all':
121            if self.env != 'dm-fake':
122                raise error.TestError('env must be "dm-fake" '
123                                      'when mode=all.')
124            if self.case:
125                raise error.TestError('case must not be given '
126                                      'when mode=all.')
127            if self.value:
128                raise error.TestError('value must not be given '
129                                      'when mode=all.')
130
131        # If |value| is given, set |is_value_given| flag to True. If it
132        # was given as 'none', 'null', or '', then set |value| to 'null'.
133        if self.value is not None:
134            self.is_value_given = True
135            if (self.value.lower() == 'none' or
136                self.value.lower() == 'null' or
137                self.value == ''):
138                self.value = 'null'
139        else:
140            self.is_value_given = False
141
142        # Verify |env| is a valid environment.
143        if self.env is not None and self.env not in FLAGS_DICT:
144            raise error.TestError('env=%s is invalid.' % self.env)
145
146        # If |env| is 'dm-fake', ensure value and credentials are not given.
147        if self.env == 'dm-fake':
148            if self.is_value_given:
149                raise error.TestError('value must not be given when using '
150                                      'the fake DM Server.')
151            if self.username or self.password:
152                raise error.TestError('user credentials must not be given '
153                                      'when using the fake DM Server.')
154
155        # If either credential is not given, set both to default.
156        if self.username is None or self.password is None:
157            self.username = self.USERNAME
158            self.password = self.PASSWORD
159
160        # Verify |case| is given if |mode|==single.
161        if self.mode == 'single' and not self.case:
162            raise error.TestError('case must be given when mode is single.')
163
164        # Verify |case| is given if a |value| is given.
165        if self.is_value_given and self.case is None:
166            raise error.TestError('value must not be given without also '
167                                  'giving a test case.')
168
169        # Verify |dms_name| is given iff |env|==dm-test.
170        if self.env == 'dm-test' and not self.dms_name:
171            raise error.TestError('dms_name must be given when using '
172                                  'env=dm-test.')
173        if self.env != 'dm-test' and self.dms_name:
174            raise error.TestError('dms_name must not be given when not using '
175                                  'env=dm-test.')
176
177        # Log the test context parameters.
178        logging.info('Test Context Parameters:')
179        logging.info('  Run Mode: %r', self.mode)
180        logging.info('  Test Case: %r', self.case)
181        logging.info('  Expected Value: %r', self.value)
182        logging.info('  Environment: %r', self.env)
183        logging.info('  Username: %r', self.username)
184        logging.info('  Password: %r', self.password)
185        logging.info('  Test DMS Name: %r', self.dms_name)
186
187    def _initialize_chrome_extra_flags(self):
188        """Initialize flags used to create Chrome instance."""
189        # Construct DM Server URL flags.
190        env_flag_list = []
191        if self.env != 'prod':
192            if self.env == 'dm-fake':
193                # Use URL provided by AutoTest DM server.
194                dmserver_str = (DMSERVER % self.dm_server_url)
195            else:
196                # Use URL defined in DMS URL dictionary.
197                dmserver_str = (DMSERVER % (DMS_URL_DICT[self.env]))
198                if self.env == 'dm-test':
199                    dmserver_str = (dmserver_str % self.dms_name)
200
201            # Merge with other flags needed by non-prod enviornment.
202            env_flag_list = ([dmserver_str] + FLAGS_DICT[self.env])
203
204        self.extra_flags = env_flag_list
205        self.cr = None
206
207    def setup_case(self, policy_name, policy_value, policies_json):
208        """Set up and confirm the preconditions of a test case.
209
210        If the AutoTest fake DM Server is initialized, make a policy blob
211        from |policies_json|, and upload it to the fake server.
212
213        Launch a chrome browser, and sign in to Chrome OS. Examine the user's
214        cryptohome vault, to confirm it signed in successfully.
215
216        Open the Policies page, and confirm that it shows the specified
217        |policy_name| and has the correct |policy_value|.
218
219        @param policy_name: Name of the policy under test.
220        @param policy_value: Expected value to appear on chrome://policy page.
221        @param policies_json: JSON string to set up the fake DMS policy value.
222
223        @raises error.TestError if cryptohome vault is not mounted for user.
224        @raises error.TestFail if |policy_name| and |policy_value| are not
225                shown on the Policies page.
226
227        """
228        # Set up policy on AutoTest DM Server only if initialized.
229        if self.env == 'dm-fake':
230            self.setup_policy(self._make_json_blob(policies_json))
231
232        self._launch_chrome_browser()
233        tab = self.navigate_to_url('chrome://policy')
234        if not cryptohome.is_vault_mounted(user=self.username,
235                                           allow_fail=True):
236            raise error.TestError('Expected to find a mounted vault for %s.'
237                                  % self.username)
238        value_shown = self._get_policy_value_shown(tab, policy_name)
239        if not self._policy_value_matches_shown(policy_value, value_shown):
240            raise error.TestFail('Policy value shown is not correct: %s '
241                                 '(expected: %s)' %
242                                 (value_shown, policy_value))
243        tab.Close()
244
245    def _launch_chrome_browser(self):
246        """Launch Chrome browser and sign in."""
247        logging.info('Chrome Browser Arguments:')
248        logging.info('  extra_browser_args: %s', self.extra_flags)
249        logging.info('  username: %s', self.username)
250        logging.info('  password: %s', self.password)
251
252        self.cr = chrome.Chrome(extra_browser_args=self.extra_flags,
253                                username=self.username,
254                                password=self.password,
255                                gaia_login=True,
256                                disable_gaia_services=False,
257                                autotest_ext=True)
258
259    def navigate_to_url(self, url, tab=None):
260        """Navigate tab to the specified |url|. Create new tab if none given.
261
262        @param url: URL of web page to load.
263        @param tab: browser tab to load (if any).
264        @returns: browser tab loaded with web page.
265
266        """
267        logging.info('Navigating to URL: %r', url)
268        if not tab:
269            tab = self.cr.browser.tabs.New()
270            tab.Activate()
271        tab.Navigate(url, timeout=5)
272        tab.WaitForDocumentReadyStateToBeComplete()
273        return tab
274
275    def _policy_value_matches_shown(self, policy_value, value_shown):
276        """Compare |policy_value| to |value_shown| with whitespace removed.
277
278        Compare the expected policy value with the value actually shown on the
279        chrome://policies page. Before comparing, convert both values to JSON
280        formatted strings, and remove all whitespace. Whitespace is removed
281        because Chrome processes some policy values to show them in a more
282        human readable format.
283
284        @param policy_value: Expected value to appear on chrome://policy page.
285        @param value_shown: Value as it appears on chrome://policy page.
286        @param policies_json: JSON string to set up the fake DMS policy value.
287
288        @returns: True if the strings match after removing all whitespace.
289
290        """
291        # Convert Python None or '' to JSON formatted 'null' string.
292        if value_shown is None or value_shown == '':
293            value_shown = 'null'
294        if policy_value is None or policy_value == '':
295            policy_value = 'null'
296
297        # Remove whitespace.
298        trimmed_value = ''.join(policy_value.split())
299        trimmed_shown = ''.join(value_shown.split())
300        logging.info('Trimmed policy value shown: %r (expected: %r)',
301                     trimmed_shown, trimmed_value)
302        return trimmed_value == trimmed_shown
303
304    def _make_json_blob(self, policies_json):
305        """Create policy blob from policies JSON object.
306
307        @param policies_json: Policies JSON object (name-value pairs).
308        @returns: Policy blob to be used to setup the policy server.
309
310        """
311        policies_json = self._move_modeless_to_mandatory(policies_json)
312        policies_json = self._remove_null_policies(policies_json)
313
314        policy_blob = """{
315            "google/chromeos/user": %s,
316            "managed_users": ["*"],
317            "policy_user": "%s",
318            "current_key_index": 0,
319            "invalidation_source": 16,
320            "invalidation_name": "test_policy"
321        }""" % (json.dumps(policies_json), self.USERNAME)
322        return policy_blob
323
324    def _move_modeless_to_mandatory(self, policies_json):
325        """Add the 'mandatory' mode if a policy's mode was omitted.
326
327        The AutoTest fake DM Server requires that every policy be contained
328        within either a 'mandatory' or 'recommended' dictionary, to indicate
329        the mode of the policy. This function moves modeless policies into
330        the 'mandatory' dictionary.
331
332        @param policies_json: The policy JSON data (name-value pairs).
333        @returns: dict of policies grouped by mode keys.
334
335        """
336        mandatory_policies = {}
337        recommended_policies = {}
338        collated_json = {}
339
340        # Extract mandatory and recommended mode dicts.
341        if 'mandatory' in policies_json:
342            mandatory_policies = policies_json['mandatory']
343            del policies_json['mandatory']
344        if 'recommended' in policies_json:
345            recommended_policies = policies_json['recommended']
346            del policies_json['recommended']
347
348        # Move any remaining modeless policies into mandatory dict.
349        if policies_json:
350            mandatory_policies.update(policies_json)
351
352        # Collate all policies into mandatory & recommended dicts.
353        if recommended_policies:
354            collated_json.update({'recommended': recommended_policies})
355        if mandatory_policies:
356            collated_json.update({'mandatory': mandatory_policies})
357
358        return collated_json
359
360    def _remove_null_policies(self, policies_json):
361        """Remove policy dict data that is set to None or ''.
362
363        For the status of a policy to be shown as "Not set" on the
364        chrome://policy page, the policy blob must contain no dictionary entry
365        for that policy. This function removes policy NVPs from a copy of the
366        |policies_json| dictionary that the test case had set to None or ''.
367
368        @param policies_json: setup policy JSON data (name-value pairs).
369        @returns: setup policy JSON data with all 'Not set' policies removed.
370
371        """
372        policies_json_copy = policies_json.copy()
373        for policies in policies_json_copy.values():
374            for policy_data in policies.items():
375                if policy_data[1] is None or policy_data[1] == '':
376                    policies.pop(policy_data[0])
377        return policies_json_copy
378
379    def _get_policy_value_shown(self, policy_tab, policy_name):
380        """Get the value shown for the named policy on the Policies page.
381
382        Takes |policy_name| as a parameter and returns the corresponding
383        policy value shown on the chrome://policy page.
384
385        @param policy_tab: Tab displaying the chrome://policy page.
386        @param policy_name: The name of the policy.
387        @returns: The value shown for the policy on the Policies page.
388
389        """
390        row_values = policy_tab.EvaluateJavaScript('''
391            var section = document.getElementsByClassName("policy-table-section")[0];
392            var table = section.getElementsByTagName('table')[0];
393            rowValues = '';
394            for (var i = 1, row; row = table.rows[i]; i++) {
395               if (row.className !== 'expanded-value-container') {
396                  var name_div = row.getElementsByClassName('name elide')[0];
397                  var name = name_div.textContent;
398                  if (name === '%s') {
399                     var value_span = row.getElementsByClassName('value')[0];
400                     var value = value_span.textContent;
401                     var status_div = row.getElementsByClassName('status elide')[0];
402                     var status = status_div.textContent;
403                     rowValues = [name, value, status];
404                     break;
405                  }
406               }
407            }
408            rowValues;
409        ''' % policy_name)
410
411        value_shown = row_values[1].encode('ascii', 'ignore')
412        status_shown = row_values[2].encode('ascii', 'ignore')
413        if status_shown == 'Not set.':
414            return None
415        return value_shown
416
417    def get_elements_from_page(self, tab, cmd):
418        """Get collection of page elements that match the |cmd| filter.
419
420        @param tab: tab containing the page to be scraped.
421        @param cmd: JavaScript command to evaluate on the page.
422        @returns object containing elements on page that match the cmd.
423        @raises: TestFail if matching elements are not found on the page.
424
425        """
426        try:
427            elements = tab.EvaluateJavaScript(cmd)
428        except Exception as err:
429            raise error.TestFail('Unable to find matching elements on '
430                                 'the test page: %s\n %r' %(tab.url, err))
431        return elements
432
433    def json_string(self, policy_value):
434         """Convert policy value to a JSON formatted string.
435
436         @param policy_value: object containing a policy value.
437         @returns: string in JSON format.
438         """
439         return json.dumps(policy_value)
440
441    def _validate_and_run_test_case(self, test_case, run_test):
442        """Validate test case and call the test runner in the test class.
443
444        @param test_case: name of the test case to run.
445        @param run_test: method in test class that runs a test case.
446
447        """
448        if test_case not in self.TEST_CASES:
449            raise error.TestError('Test case is not valid: %s' % test_case)
450        logging.info('Running test case: %s', test_case)
451        run_test(test_case)
452
453    def run_once_impl(self, run_test):
454        """Dispatch the common run modes for all child test classes.
455
456        @param run_test: method in test class that runs a test case.
457
458        """
459        if self.mode == 'all':
460            for test_case in sorted(self.TEST_CASES):
461                self._validate_and_run_test_case(test_case, run_test)
462        elif self.mode == 'single':
463            self._validate_and_run_test_case(self.case, run_test)
464        elif self.mode == 'list':
465            logging.info('List Test Cases:')
466            for test_case, value in sorted(self.TEST_CASES.items()):
467                logging.info('  case=%s, value="%s"', test_case, value)
468        else:
469            raise error.TestError('Run mode is not valid: %s' % self.mode)
470
471    def run_once(self):
472        # The run_once() method is core to all autotest tests. We define it
473        # herein to support tests that do not define their own override.
474        self.run_once_impl(self.run_test_case)
475