Home | History | Annotate | Download | only in enterprise
      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 
      5 import json
      6 import logging
      7 import os
      8 
      9 from autotest_lib.client.bin import test
     10 from autotest_lib.client.bin import utils
     11 from autotest_lib.client.common_lib import error
     12 from autotest_lib.client.common_lib.cros import chrome
     13 from autotest_lib.client.cros import cryptohome
     14 from autotest_lib.client.cros import httpd
     15 from autotest_lib.client.cros.enterprise import enterprise_fake_dmserver
     16 
     17 CROSQA_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']
     26 CROSALPHA_FLAGS = [
     27     ('--cloud-print-url='
     28      'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
     29     '--ignore-urlfetcher-cert-requests']
     30 TESTDMS_FLAGS = [
     31     '--ignore-urlfetcher-cert-requests',
     32     '--disable-policy-key-verification']
     33 FLAGS_DICT = {
     34     'prod': [],
     35     'crosman-qa': CROSQA_FLAGS,
     36     'crosman-alpha': CROSALPHA_FLAGS,
     37     'dm-test': TESTDMS_FLAGS,
     38     'dm-fake': TESTDMS_FLAGS
     39 }
     40 DMS_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 }
     49 DMSERVER = '--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.
     52 USERNAME = 'fake-user (at] managedchrome.com'
     53 PASSWORD = 'fakepassword'
     54 
     55 
     56 class 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 
    478 def 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 
    488 def 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