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