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 test
10from autotest_lib.client.bin import utils
11from autotest_lib.client.common_lib import error
12from autotest_lib.client.common_lib.cros import chrome
13from autotest_lib.client.cros import cryptohome
14from autotest_lib.client.cros import httpd
15from autotest_lib.client.cros.enterprise import enterprise_fake_dmserver
16
17CROSQA_FLAGS = [
18    '--gaia-url=https://gaiastaging.corp.google.com',
19    '--lso-url=https://gaiastaging.corp.google.com',
20    '--google-apis-url=https://www-googleapis-test.sandbox.google.com',
21    '--oauth2-client-id=236834563817.apps.googleusercontent.com',
22    '--oauth2-client-secret=RsKv5AwFKSzNgE0yjnurkPVI',
23    ('--cloud-print-url='
24     'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
25    '--ignore-urlfetcher-cert-requests']
26CROSALPHA_FLAGS = [
27    ('--cloud-print-url='
28     'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
29    '--ignore-urlfetcher-cert-requests']
30TESTDMS_FLAGS = [
31    '--ignore-urlfetcher-cert-requests',
32    '--disable-policy-key-verification']
33FLAGS_DICT = {
34    'prod': [],
35    'crosman-qa': CROSQA_FLAGS,
36    'crosman-alpha': CROSALPHA_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    'crosman-qa':
43        'https://crosman-qa.sandbox.google.com/devicemanagement/data/api',
44    'crosman-alpha':
45        'https://crosman-alpha.sandbox.google.com/devicemanagement/data/api',
46    'dm-test': 'http://chromium-dm-test.appspot.com/d/%s',
47    'dm-fake': 'http://127.0.0.1:%d/'
48}
49DMSERVER = '--device-management-url=%s'
50# Username and password for the fake dm server can be anything, since
51# they are not used to authenticate against GAIA.
52USERNAME = 'fake-user@managedchrome.com'
53PASSWORD = 'fakepassword'
54
55
56class EnterprisePolicyTest(test.test):
57    """Base class for Enterprise Policy Tests."""
58
59    WEB_PORT = 8080
60    WEB_HOST = 'http://localhost:%d' % WEB_PORT
61    CHROME_SETTINGS_PAGE = 'chrome://settings'
62    CHROME_POLICY_PAGE = 'chrome://policy'
63    SETTING_LABEL = 0
64    SETTING_CHECKED = 1
65    SETTING_DISABLED = 2
66
67    def setup(self):
68        os.chdir(self.srcdir)
69        utils.make()
70
71
72    def initialize(self, **kwargs):
73        self._initialize_enterprise_policy_test(**kwargs)
74
75
76    def _initialize_enterprise_policy_test(
77            self, case, env='dm-fake', dms_name=None,
78            username=USERNAME, password=PASSWORD):
79        """Initialize test parameters, fake DM Server, and Chrome flags.
80
81        @param case: String name of the test case to run.
82        @param env: String environment of DMS and Gaia servers.
83        @param username: String user name login credential.
84        @param password: String password login credential.
85        @param dms_name: String name of test DM Server.
86        """
87        self.case = case
88        self.env = env
89        self.username = username
90        self.password = password
91        self.dms_name = dms_name
92        self.dms_is_fake = (env == 'dm-fake')
93        self._enforce_variable_restrictions()
94
95        # Initialize later variables to prevent error after an early failure.
96        self._web_server = None
97        self.cr = None
98
99        # Start AutoTest DM Server if using local fake server.
100        if self.dms_is_fake:
101            self.fake_dm_server = enterprise_fake_dmserver.FakeDMServer(
102                self.srcdir)
103            self.fake_dm_server.start(self.tmpdir, self.debugdir)
104
105        # Get enterprise directory of shared resources.
106        client_dir = os.path.dirname(os.path.dirname(self.bindir))
107        self.enterprise_dir = os.path.join(client_dir, 'cros/enterprise')
108
109        # Log the test context parameters.
110        logging.info('Test Context Parameters:')
111        logging.info('  Case: %r', self.case)
112        logging.info('  Environment: %r', self.env)
113        logging.info('  Username: %r', self.username)
114        logging.info('  Password: %r', self.password)
115        logging.info('  Test DMS Name: %r', self.dms_name)
116
117
118    def cleanup(self):
119        # Clean up AutoTest DM Server if using local fake server.
120        if self.dms_is_fake:
121            self.fake_dm_server.stop()
122
123        # Stop web server if it was started.
124        if self._web_server:
125            self._web_server.stop()
126
127        # Close Chrome instance if opened.
128        if self.cr:
129            self.cr.close()
130
131
132    def start_webserver(self):
133        """Set up HTTP Server to serve pages from enterprise directory."""
134        self._web_server = httpd.HTTPListener(
135                self.WEB_PORT, docroot=self.enterprise_dir)
136        self._web_server.run()
137
138
139    def _enforce_variable_restrictions(self):
140        """Validate class-level test context parameters.
141
142        @raises error.TestError if context parameter has an invalid value,
143                or a combination of parameters have incompatible values.
144        """
145        # Verify |env| is a valid environment.
146        if self.env not in FLAGS_DICT:
147            raise error.TestError('Environment is invalid: %s' % self.env)
148
149        # Verify test |dms_name| is given iff |env| is 'dm-test'.
150        if self.env == 'dm-test' and not self.dms_name:
151            raise error.TestError('dms_name must be given when using '
152                                  'env=dm-test.')
153        if self.env != 'dm-test' and self.dms_name:
154            raise error.TestError('dms_name must not be given when not using '
155                                  'env=dm-test.')
156
157
158    def setup_case(self, policy_name, policy_value, mandatory_policies={},
159                   suggested_policies={}, policy_name_is_suggested=False,
160                   skip_policy_value_verification=False):
161        """Set up and confirm the preconditions of a test case.
162
163        If the AutoTest fake DM Server is used, make a JSON policy blob
164        and upload it to the fake DM server.
165
166        Launch Chrome and sign in to Chrome OS. Examine the user's
167        cryptohome vault, to confirm user is signed in successfully.
168
169        Open the Policies page, and confirm that it shows the specified
170        |policy_name| and has the correct |policy_value|.
171
172        @param policy_name: Name of the policy under test.
173        @param policy_value: Expected value for the policy under test.
174        @param mandatory_policies: optional dict of mandatory policies
175                (not policy_name) in name -> value format.
176        @param suggested_policies: optional dict of suggested policies
177                (not policy_name) in name -> value format.
178        @param policy_name_is_suggested: True if policy_name a suggested policy.
179        @param skip_policy_value_verification: True if setup_case should not
180                verify that the correct policy value shows on policy page.
181
182        @raises error.TestError if cryptohome vault is not mounted for user.
183        @raises error.TestFail if |policy_name| and |policy_value| are not
184                shown on the Policies page.
185        """
186        logging.info('Setting up case: (%s: %s)', policy_name, policy_value)
187        logging.info('Mandatory policies: %s', mandatory_policies)
188        logging.info('Suggested policies: %s', suggested_policies)
189
190        if self.dms_is_fake:
191            if policy_name_is_suggested:
192                suggested_policies[policy_name] = policy_value
193            else:
194                mandatory_policies[policy_name] = policy_value
195            self.fake_dm_server.setup_policy(self._make_json_blob(
196                mandatory_policies, suggested_policies))
197
198        self._launch_chrome_browser()
199        if not cryptohome.is_vault_mounted(user=self.username,
200                                           allow_fail=True):
201            raise error.TestError('Expected to find a mounted vault for %s.'
202                                  % self.username)
203        if not skip_policy_value_verification:
204            self.verify_policy_value(policy_name, policy_value)
205
206
207    def _make_json_blob(self, mandatory_policies, suggested_policies):
208        """Create JSON policy blob from mandatory and suggested policies.
209
210        For the status of a policy to be shown as "Not set" on the
211        chrome://policy page, the policy dictionary must contain no NVP for
212        for that policy. Remove policy NVPs if value is None.
213
214        @param mandatory_policies: dict of mandatory policies -> values.
215        @param suggested_policies: dict of suggested policies -> values.
216
217        @returns: JSON policy blob to send to the fake DM server.
218        """
219        # Remove "Not set" policies and json-ify dicts because the
220        # FakeDMServer expects "policy": "{value}" not "policy": {value}.
221        for policies_dict in [mandatory_policies, suggested_policies]:
222            policies_to_pop = []
223            for policy in policies_dict:
224                value = policies_dict[policy]
225                if value is None:
226                    policies_to_pop.append(policy)
227                elif isinstance(value, dict):
228                    policies_dict[policy] = encode_json_string(value)
229                elif isinstance(value, list):
230                    if len(value) > 0 and isinstance(value[0], dict):
231                        policies_dict[policy] = encode_json_string(value)
232            for policy in policies_to_pop:
233                policies_dict.pop(policy)
234
235        modes_dict = {}
236        if mandatory_policies:
237            modes_dict['mandatory'] = mandatory_policies
238        if suggested_policies:
239            modes_dict['suggested'] = suggested_policies
240
241        device_management_dict = {
242            'google/chromeos/user': modes_dict,
243            'managed_users': ['*'],
244            'policy_user': self.username,
245            'current_key_index': 0,
246            'invalidation_source': 16,
247            'invalidation_name': 'test_policy'
248        }
249
250        logging.info('Created policy blob: %s', device_management_dict)
251        return encode_json_string(device_management_dict)
252
253
254    def _get_policy_value_shown(self, policy_tab, policy_name):
255        """Get the value shown for |policy_name| from the |policy_tab| page.
256
257        Return the policy value for the policy given by |policy_name|, from
258        from the chrome://policy page given by |policy_tab|.
259
260        CAVEAT: the policy page does not display proper JSON. For example, lists
261        are generally shown without the [ ] and cannot be distinguished from
262        strings.  This function decodes what it can and returns the string it
263        found when in doubt.
264
265        @param policy_tab: Tab displaying the Policies page.
266        @param policy_name: The name of the policy.
267
268        @returns: The decoded value shown for the policy on the Policies page,
269                with the aforementioned caveat.
270        """
271        row_values = policy_tab.EvaluateJavaScript('''
272            var section = document.getElementsByClassName(
273                    "policy-table-section")[0];
274            var table = section.getElementsByTagName('table')[0];
275            rowValues = '';
276            for (var i = 1, row; row = table.rows[i]; i++) {
277               if (row.className !== 'expanded-value-container') {
278                  var name_div = row.getElementsByClassName('name elide')[0];
279                  var name = name_div.textContent;
280                  if (name === '%s') {
281                     var value_span = row.getElementsByClassName('value')[0];
282                     var value = value_span.textContent;
283                     var status_div = row.getElementsByClassName(
284                            'status elide')[0];
285                     var status = status_div.textContent;
286                     rowValues = [name, value, status];
287                     break;
288                  }
289               }
290            }
291            rowValues;
292        ''' % policy_name)
293
294        value_shown = row_values[1].encode('ascii', 'ignore')
295        status_shown = row_values[2].encode('ascii', 'ignore')
296        logging.debug('Policy %s row: %s', policy_name, row_values)
297
298        if status_shown == 'Not set.':
299            return None
300        return decode_json_string(value_shown)
301
302
303    def _get_policy_value_from_new_tab(self, policy_name):
304        """Get the policy value for |policy_name| from the Policies page.
305
306        @param policy_name: string of policy name.
307
308        @returns: decoded value of the policy as shown on chrome://policy.
309        """
310        values = self._get_policy_values_from_new_tab([policy_name])
311        return values[policy_name]
312
313
314    def _get_policy_values_from_new_tab(self, policy_names):
315        """Get a given policy value by opening a new tab then closing it.
316
317        @param policy_names: list of strings of policy names.
318
319        @returns: dict of policy name mapped to decoded values of the policy as
320                  shown on chrome://policy.
321        """
322        values = {}
323        tab = self.navigate_to_url(self.CHROME_POLICY_PAGE)
324        for policy_name in policy_names:
325          values[policy_name] = self._get_policy_value_shown(tab, policy_name)
326        tab.Close()
327
328        return values
329
330
331    def verify_policy_value(self, policy_name, expected_value):
332        """
333        Verify that the correct policy values shows in chrome://policy.
334
335        @param policy_name: the policy we are checking.
336        @param expected_value: the expected value for policy_name.
337
338        @raises error.TestError if value does not match expected.
339
340        """
341        value_shown = self._get_policy_value_from_new_tab(policy_name)
342        logging.info('Value decoded from chrome://policy: %s', value_shown)
343
344        # If we expect a list and don't have a list, modify the value_shown.
345        if isinstance(expected_value, list):
346            if isinstance(value_shown, str):
347                if '{' in value_shown: # List of dicts.
348                    value_shown = decode_json_string('[%s]' % value_shown)
349                elif ',' in value_shown: # List of strs.
350                    value_shown = value_shown.split(',')
351                else: # List with one str.
352                    value_shown = [value_shown]
353            elif not isinstance(value_shown, list): # List with one element.
354                value_shown = [value_shown]
355
356        if not expected_value == value_shown:
357            raise error.TestError('chrome://policy shows the incorrect value '
358                                  'for %s!  Expected %s, got %s.' % (
359                                          policy_name, expected_value,
360                                          value_shown))
361
362
363    def _initialize_chrome_extra_flags(self):
364        """
365        Initialize flags used to create Chrome instance.
366
367        @returns: list of extra Chrome flags.
368
369        """
370        # Construct DM Server URL flags if not using production server.
371        env_flag_list = []
372        if self.env != 'prod':
373            if self.dms_is_fake:
374                # Use URL provided by the fake AutoTest DM server.
375                dmserver_str = (DMSERVER % self.fake_dm_server.server_url)
376            else:
377                # Use URL defined in the DMS URL dictionary.
378                dmserver_str = (DMSERVER % (DMS_URL_DICT[self.env]))
379                if self.env == 'dm-test':
380                    dmserver_str = (dmserver_str % self.dms_name)
381
382            # Merge with other flags needed by non-prod enviornment.
383            env_flag_list = ([dmserver_str] + FLAGS_DICT[self.env])
384
385        return env_flag_list
386
387
388    def _launch_chrome_browser(self):
389        """Launch Chrome browser and sign in."""
390        extra_flags = self._initialize_chrome_extra_flags()
391
392        logging.info('Chrome Browser Arguments:')
393        logging.info('  extra_browser_args: %s', extra_flags)
394        logging.info('  username: %s', self.username)
395        logging.info('  password: %s', self.password)
396        logging.info('  gaia_login: %s', not self.dms_is_fake)
397
398        self.cr = chrome.Chrome(extra_browser_args=extra_flags,
399                                username=self.username,
400                                password=self.password,
401                                gaia_login=not self.dms_is_fake,
402                                disable_gaia_services=False,
403                                autotest_ext=True)
404
405
406    def navigate_to_url(self, url, tab=None):
407        """Navigate tab to the specified |url|. Create new tab if none given.
408
409        @param url: URL of web page to load.
410        @param tab: browser tab to load (if any).
411        @returns: browser tab loaded with web page.
412        """
413        logging.info('Navigating to URL: %r', url)
414        if not tab:
415            tab = self.cr.browser.tabs.New()
416            tab.Activate()
417        tab.Navigate(url, timeout=8)
418        tab.WaitForDocumentReadyStateToBeComplete()
419        return tab
420
421
422    def get_elements_from_page(self, tab, cmd):
423        """Get collection of page elements that match the |cmd| filter.
424
425        @param tab: tab containing the page to be scraped.
426        @param cmd: JavaScript command to evaluate on the page.
427        @returns object containing elements on page that match the cmd.
428        @raises: TestFail if matching elements are not found on the page.
429        """
430        try:
431            elements = tab.EvaluateJavaScript(cmd)
432        except Exception as err:
433            raise error.TestFail('Unable to find matching elements on '
434                                 'the test page: %s\n %r' %(tab.url, err))
435        return elements
436
437
438    def _get_settings_checkbox_properties(self, pref):
439        """Get properties of the |pref| check box on the settings page.
440
441        @param pref: pref attribute value of the check box setting.
442        @returns: element properties of the check box setting.
443        """
444        js_cmd_get_props = ('''
445        settings = document.getElementsByClassName(
446                'checkbox controlled-setting-with-label');
447        settingValues = '';
448        for (var i = 0, setting; setting = settings[i]; i++) {
449           var setting_label = setting.getElementsByTagName("label")[0];
450           var label_input = setting_label.getElementsByTagName("input")[0];
451           var input_pref = label_input.getAttribute("pref");
452           if (input_pref == '%s') {
453              settingValues = [setting_label.innerText.trim(),
454                               label_input.checked, label_input.disabled];
455              break;
456           }
457        }
458        settingValues;
459        ''' % pref)
460        tab = self.navigate_to_url(self.CHROME_SETTINGS_PAGE)
461        checkbox_props = self.get_elements_from_page(tab, js_cmd_get_props)
462        tab.Close()
463        return checkbox_props
464
465
466    def run_once(self):
467        """The run_once() method is required by all AutoTest tests.
468
469        run_once() is defined herein to automatically determine which test
470        case in the test class to run. The test class must have a public
471        run_test_case() method defined. Note: The test class may override
472        run_once() if it determines which test case to run.
473        """
474        logging.info('Running test case: %s', self.case)
475        self.run_test_case(self.case)
476
477
478def encode_json_string(object_value):
479    """Convert given value to JSON format string.
480
481    @param object_value: object to be converted.
482
483    @returns: string in JSON format.
484    """
485    return json.dumps(object_value)
486
487
488def decode_json_string(json_string):
489    """Convert given JSON format string to an object.
490
491    If no object is found, return json_string instead.  This is to allow
492    us to "decode" items off the policy page that aren't real JSON.
493
494    @param json_string: the JSON string to be decoded.
495
496    @returns: Python object represented by json_string or json_string.
497    """
498    def _decode_list(json_list):
499        result = []
500        for value in json_list:
501            if isinstance(value, unicode):
502                value = value.encode('ascii')
503            if isinstance(value, list):
504                value = _decode_list(value)
505            if isinstance(value, dict):
506                value = _decode_dict(value)
507            result.append(value)
508        return result
509
510    def _decode_dict(json_dict):
511        result = {}
512        for key, value in json_dict.iteritems():
513            if isinstance(key, unicode):
514                key = key.encode('ascii')
515            if isinstance(value, unicode):
516                value = value.encode('ascii')
517            elif isinstance(value, list):
518                value = _decode_list(value)
519            result[key] = value
520        return result
521
522    try:
523        # Decode JSON turning all unicode strings into ascii.
524        # object_hook will get called on all dicts, so also handle lists.
525        result = json.loads(json_string, encoding='ascii',
526                            object_hook=_decode_dict)
527        if isinstance(result, list):
528            result = _decode_list(result)
529        return result
530    except ValueError as e:
531        # Input not valid, e.g. '1, 2, "c"' instead of '[1, 2, "c"]'.
532        logging.warning('Could not unload: %s (%s)', json_string, e)
533        return json_string
534