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 copy
6import json
7import logging
8import os
9
10from autotest_lib.client.bin import test
11from autotest_lib.client.bin import utils
12from autotest_lib.client.common_lib import error
13from autotest_lib.client.common_lib.cros import chrome
14from autotest_lib.client.common_lib.cros import enrollment
15from autotest_lib.client.cros import cryptohome
16from autotest_lib.client.cros import httpd
17from autotest_lib.client.cros.enterprise import enterprise_fake_dmserver
18
19CROSQA_FLAGS = [
20    '--gaia-url=https://gaiastaging.corp.google.com',
21    '--lso-url=https://gaiastaging.corp.google.com',
22    '--google-apis-url=https://www-googleapis-test.sandbox.google.com',
23    '--oauth2-client-id=236834563817.apps.googleusercontent.com',
24    '--oauth2-client-secret=RsKv5AwFKSzNgE0yjnurkPVI',
25    ('--cloud-print-url='
26     'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
27    '--ignore-urlfetcher-cert-requests']
28CROSALPHA_FLAGS = [
29    ('--cloud-print-url='
30     'https://cloudprint-nightly-ps.sandbox.google.com/cloudprint'),
31    '--ignore-urlfetcher-cert-requests']
32TESTDMS_FLAGS = [
33    '--ignore-urlfetcher-cert-requests',
34    '--disable-policy-key-verification']
35FLAGS_DICT = {
36    'prod': [],
37    'crosman-qa': CROSQA_FLAGS,
38    'crosman-alpha': CROSALPHA_FLAGS,
39    'dm-test': TESTDMS_FLAGS,
40    'dm-fake': TESTDMS_FLAGS
41}
42DMS_URL_DICT = {
43    'prod': 'http://m.google.com/devicemanagement/data/api',
44    'crosman-qa':
45        'https://crosman-qa.sandbox.google.com/devicemanagement/data/api',
46    'crosman-alpha':
47        'https://crosman-alpha.sandbox.google.com/devicemanagement/data/api',
48    'dm-test': 'http://chromium-dm-test.appspot.com/d/%s',
49    'dm-fake': 'http://127.0.0.1:%d/'
50}
51DMSERVER = '--device-management-url=%s'
52# Username and password for the fake dm server can be anything, since
53# they are not used to authenticate against GAIA.
54USERNAME = 'fake-user@managedchrome.com'
55PASSWORD = 'fakepassword'
56GAIA_ID = 'fake-gaia-id'
57
58
59class EnterprisePolicyTest(test.test):
60    """Base class for Enterprise Policy Tests."""
61
62    WEB_PORT = 8080
63    WEB_HOST = 'http://localhost:%d' % WEB_PORT
64    CHROME_POLICY_PAGE = 'chrome://policy'
65
66    def setup(self):
67        """Make the files needed for fake-dms."""
68        os.chdir(self.srcdir)
69        utils.make()
70
71
72    def initialize(self, **kwargs):
73        """Initialize test parameters."""
74        self._initialize_enterprise_policy_test(**kwargs)
75
76
77    def _initialize_enterprise_policy_test(
78            self, case='', env='dm-fake', dms_name=None,
79            username=USERNAME, password=PASSWORD, gaia_id=GAIA_ID):
80        """Initialize test parameters and fake DM Server.
81
82        @param case: String name of the test case to run.
83        @param env: String environment of DMS and Gaia servers.
84        @param username: String user name login credential.
85        @param password: String password login credential.
86        @param gaia_id: String gaia_id login credential.
87        @param dms_name: String name of test DM Server.
88        """
89        self.case = case
90        self.env = env
91        self.username = username
92        self.password = password
93        self.gaia_id = gaia_id
94        self.dms_name = dms_name
95        self.dms_is_fake = (env == 'dm-fake')
96        self._enforce_variable_restrictions()
97
98        # Initialize later variables to prevent error after an early failure.
99        self._web_server = None
100        self.cr = None
101
102        # Start AutoTest DM Server if using local fake server.
103        if self.dms_is_fake:
104            self.fake_dm_server = enterprise_fake_dmserver.FakeDMServer(
105                self.srcdir)
106            self.fake_dm_server.start(self.tmpdir, self.debugdir)
107
108        # Get enterprise directory of shared resources.
109        client_dir = os.path.dirname(os.path.dirname(self.bindir))
110        self.enterprise_dir = os.path.join(client_dir, 'cros/enterprise')
111
112        # Log the test context parameters.
113        logging.info('Test Context Parameters:')
114        logging.info('  Case: %r', self.case)
115        logging.info('  Environment: %r', self.env)
116        logging.info('  Username: %r', self.username)
117        logging.info('  Password: %r', self.password)
118        logging.info('  Test DMS Name: %r', self.dms_name)
119
120
121    def cleanup(self):
122        """Close out anything used by this test."""
123        # Clean up AutoTest DM Server if using local fake server.
124        if self.dms_is_fake:
125            self.fake_dm_server.stop()
126
127        # Stop web server if it was started.
128        if self._web_server:
129            self._web_server.stop()
130
131        # Close Chrome instance if opened.
132        if self.cr and self._auto_logout:
133            self.cr.close()
134
135
136    def start_webserver(self):
137        """Set up HTTP Server to serve pages from enterprise directory."""
138        self._web_server = httpd.HTTPListener(
139                self.WEB_PORT, docroot=self.enterprise_dir)
140        self._web_server.run()
141
142
143    def _enforce_variable_restrictions(self):
144        """Validate class-level test context parameters.
145
146        @raises error.TestError if context parameter has an invalid value,
147                or a combination of parameters have incompatible values.
148        """
149        # Verify |env| is a valid environment.
150        if self.env not in FLAGS_DICT:
151            raise error.TestError('Environment is invalid: %s' % self.env)
152
153        # Verify test |dms_name| is given iff |env| is 'dm-test'.
154        if self.env == 'dm-test' and not self.dms_name:
155            raise error.TestError('dms_name must be given when using '
156                                  'env=dm-test.')
157        if self.env != 'dm-test' and self.dms_name:
158            raise error.TestError('dms_name must not be given when not using '
159                                  'env=dm-test.')
160
161
162    def setup_case(self, user_policies={}, suggested_user_policies={},
163                   device_policies={}, skip_policy_value_verification=False,
164                   enroll=False, auto_login=True, auto_logout=True,
165                   extra_chrome_flags=[], init_network_controller=False):
166        """Set up DMS, log in, and verify policy values.
167
168        If the AutoTest fake DM Server is used, make a JSON policy blob
169        and upload it to the fake DM server.
170
171        Launch Chrome and sign in to Chrome OS. Examine the user's
172        cryptohome vault, to confirm user is signed in successfully.
173
174        @param user_policies: dict of mandatory user policies in
175                name -> value format.
176        @param suggested_user_policies: optional dict of suggested policies
177                in name -> value format.
178        @param device_policies: dict of device policies in
179                name -> value format.
180        @param skip_policy_value_verification: True if setup_case should not
181                verify that the correct policy value shows on policy page.
182        @param enroll: True for enrollment instead of login.
183        @param auto_login: Sign in to chromeos.
184        @param auto_logout: Sign out of chromeos when test is complete.
185        @param extra_chrome_flags: list of flags to add to Chrome.
186        @param init_network_controller: whether to init network controller.
187
188        @raises error.TestError if cryptohome vault is not mounted for user.
189        @raises error.TestFail if |policy_name| and |policy_value| are not
190                shown on the Policies page.
191        """
192        self._auto_logout = auto_logout
193
194        if self.dms_is_fake:
195            self.fake_dm_server.setup_policy(self._make_json_blob(
196                user_policies, suggested_user_policies, device_policies))
197
198        self._create_chrome(enroll=enroll, auto_login=auto_login,
199                            extra_chrome_flags=extra_chrome_flags,
200                            init_network_controller=init_network_controller)
201
202        # Skip policy check upon request or if we enroll but don't log in.
203        skip_policy_value_verification = (
204                skip_policy_value_verification or not auto_login)
205        if not skip_policy_value_verification:
206            self.verify_policy_stats(user_policies, suggested_user_policies,
207                                     device_policies)
208
209
210    def _make_json_blob(self, user_policies={}, suggested_user_policies={},
211                        device_policies={}):
212        """Create JSON policy blob from mandatory and suggested policies.
213
214        For the status of a policy to be shown as "Not set" on the
215        chrome://policy page, the policy dictionary must contain no NVP for
216        for that policy. Remove policy NVPs if value is None.
217
218        @param user_policies: mandatory user policies -> values.
219        @param suggested user_policies: suggested user policies -> values.
220        @param device_policies: mandatory device policies -> values.
221
222        @returns: JSON policy blob to send to the fake DM server.
223        """
224
225        user_p = copy.deepcopy(user_policies)
226        s_user_p = copy.deepcopy(suggested_user_policies)
227        device_p = copy.deepcopy(device_policies)
228
229        # Remove "Not set" policies and json-ify dicts because the
230        # FakeDMServer expects "policy": "{value}" not "policy": {value}
231        # or "policy": ["{value}"] not "policy": [{value}].
232        for policies_dict in [user_p, s_user_p, device_p]:
233            policies_to_pop = []
234            for policy in policies_dict:
235                value = policies_dict[policy]
236                if value is None:
237                    policies_to_pop.append(policy)
238                elif isinstance(value, dict):
239                    policies_dict[policy] = encode_json_string(value)
240                elif isinstance(value, list):
241                    if len(value) > 0 and isinstance(value[0], dict):
242                        for i in xrange(len(value)):
243                            value[i] = encode_json_string(value[i])
244                        policies_dict[policy] = value
245            for policy in policies_to_pop:
246                policies_dict.pop(policy)
247
248        management_dict = {
249            'managed_users': ['*'],
250            'policy_user': self.username,
251            'current_key_index': 0,
252            'invalidation_source': 16,
253            'invalidation_name': 'test_policy'
254        }
255
256        if user_p or s_user_p:
257            user_modes_dict = {}
258            if user_p:
259                user_modes_dict['mandatory'] = user_p
260            if suggested_user_policies:
261                user_modes_dict['recommended'] = s_user_p
262            management_dict['google/chromeos/user'] = user_modes_dict
263
264        if device_p:
265            management_dict['google/chromeos/device'] = device_p
266
267
268        logging.info('Created policy blob: %s', management_dict)
269        return encode_json_string(management_dict)
270
271
272    def _get_policy_stats_shown(self, policy_tab, policy_name):
273        """Get the info shown for |policy_name| from the |policy_tab| page.
274
275        Return a dict of stats for the policy given by |policy_name|, from
276        from the chrome://policy page given by |policy_tab|.
277
278        CAVEAT: the policy page does not display proper JSON. For example, lists
279        are generally shown without the [ ] and cannot be distinguished from
280        strings.  This function decodes what it can and returns the string it
281        found when in doubt.
282
283        @param policy_tab: Tab displaying the Policies page.
284        @param policy_name: The name of the policy.
285
286        @returns: A dict of stats, including JSON decode 'value' (see caveat).
287                  Also included are 'name', 'status', 'level', 'scope',
288                  and 'source'.
289        """
290        stats = {'name': policy_name}
291
292        row_values = policy_tab.EvaluateJavaScript('''
293            var section = document.getElementsByClassName(
294                    "policy-table-section")[0];
295            var table = section.getElementsByTagName('table')[0];
296            rowValues = {};
297            for (var i = 1, row; row = table.rows[i]; i++) {
298                if (row.className !== 'expanded-value-container') {
299                    var name_div = row.getElementsByClassName('name elide')[0];
300                    var name_links = name_div.getElementsByClassName(
301                            'name-link');
302                    var name = (name_links.length > 0) ?
303                            name_links[0].textContent : name_div.textContent;
304                    rowValues['name'] = name;
305                    if (name === '%s') {
306                        var value_span = row.getElementsByClassName('value')[0];
307                        rowValues['value'] = value_span.textContent;
308                        stat_names = ['status', 'level', 'scope', 'source'];
309                        stat_names.forEach(function(entry) {
310                            var entry_div = row.getElementsByClassName(
311                                    entry+' elide')[0];
312                            rowValues[entry] = entry_div.textContent;
313                        });
314                        break;
315                    }
316               }
317            }
318            rowValues;
319        ''' % policy_name)
320
321        logging.debug('Policy %s row: %s', policy_name, row_values)
322        if not row_values or len(row_values) < 6:
323            raise error.TestError(
324                    'Could not get policy info for %s!' % policy_name)
325
326        entries = ['value', 'status', 'level', 'scope', 'source']
327        for v in entries:
328            stats[v] = row_values[v].encode('ascii', 'ignore')
329
330        if stats['status'] == 'Not set.':
331            for v in entries:
332                stats[v] = None
333        else: stats['value'] = decode_json_string(stats['value'])
334
335        return stats
336
337
338    def _get_policy_value_from_new_tab(self, policy_name):
339        """Get the policy value for |policy_name| from the Policies page.
340
341        Information comes from the policy page.  A single new tab is opened
342        and then closed to check this info, so device must be logged in.
343
344        @param policy_name: string of policy name.
345
346        @returns: decoded value of the policy as shown on chrome://policy.
347        """
348        values = self._get_policy_stats_from_new_tab([policy_name])
349        return values[policy_name]['value']
350
351
352    def _get_policy_values_from_new_tab(self, policy_names):
353        """Get the policy values of the given policies.
354
355        Information comes from the policy page.  A single new tab is opened
356        and then closed to check this info, so device must be logged in.
357
358        @param policy_names: list of strings of policy names.
359
360        @returns: dict of policy name mapped to decoded values of the policy as
361                  shown on chrome://policy.
362        """
363        values = {}
364        tab = self.navigate_to_url(self.CHROME_POLICY_PAGE)
365        for policy_name in policy_names:
366          values[policy_name] = (
367                  self._get_policy_stats_shown(tab, policy_name)['value'])
368        tab.Close()
369
370        return values
371
372
373    def _get_policy_stats_from_new_tab(self, policy_names):
374        """Get policy info about the given policy names.
375
376        Information comes from the policy page.  A single new tab is opened
377        and then closed to check this info, so device must be logged in.
378
379        @param policy_name: list of policy names (strings).
380
381        @returns: dict of policy names mapped to dicts containing policy info.
382                  Values are decoded JSON.
383        """
384        stats = {}
385        tab = self.navigate_to_url(self.CHROME_POLICY_PAGE)
386        for policy_name in policy_names:
387            stats[policy_name] = self._get_policy_stats_shown(tab, policy_name)
388        tab.Close()
389
390        return stats
391
392
393    def _compare_values(self, policy_name, expected_value, value_shown):
394        """Pass if an expected value and the chrome://policy version match.
395
396        Handles some of the inconsistencies in the chrome://policy JSON format.
397
398        @raises: error.TestError if policy values do not match.
399
400        """
401        # If we expect a list and don't have a list, modify the value_shown.
402        if isinstance(expected_value, list):
403            if isinstance(value_shown, str):
404                if '{' in value_shown: # List of dicts.
405                    value_shown = decode_json_string('[%s]' % value_shown)
406                elif ',' in value_shown: # List of strs.
407                    value_shown = value_shown.split(',')
408                else: # List with one str.
409                    value_shown = [value_shown]
410            elif not isinstance(value_shown, list): # List with one element.
411                value_shown = [value_shown]
412
413        if not expected_value == value_shown:
414            raise error.TestError('chrome://policy shows the incorrect value '
415                                  'for %s!  Expected %s, got %s.' % (
416                                          policy_name, expected_value,
417                                          value_shown))
418
419
420    def verify_policy_value(self, policy_name, expected_value):
421        """
422        Verify that the a single policy correctly shows in chrome://policy.
423
424        @param policy_name: the policy we are checking.
425        @param expected_value: the expected value for policy_name.
426
427        @raises error.TestError if value does not match expected.
428
429        """
430        value_shown = self._get_policy_value_from_new_tab(policy_name)
431        self._compare_values(policy_name, expected_value, value_shown)
432
433
434    def verify_policy_stats(self, user_policies={}, suggested_user_policies={},
435                            device_policies={}):
436        """Verify that the correct policy values show in chrome://policy.
437
438        @param policy_dict: the policies we are checking.
439
440        @raises error.TestError if value does not match expected.
441        """
442        def _compare_stat(stat, desired, name, stats):
443            """ Raise error if a stat doesn't match."""
444            err_str = 'Incorrect '+stat+' for '+name+': expected %s, got %s!'
445            shown = stats[name][stat]
446            # If policy is not set, there are no stats to match.
447            if stats[name]['status'] == None:
448                if not shown == None:
449                    raise error.TestError(err_str % (None, shown))
450                else:
451                    return
452            if not desired == shown:
453                raise error.TestError(err_str % (desired, shown))
454
455        keys = (user_policies.keys() + suggested_user_policies.keys() +
456                device_policies.keys())
457
458        # If no policies were modified from default, return.
459        if len(keys) == 0:
460            return
461
462        stats = self._get_policy_stats_from_new_tab(keys)
463
464        for policy in user_policies:
465            self._compare_values(policy, user_policies[policy],
466                                 stats[policy]['value'])
467            _compare_stat('level', 'Mandatory', policy, stats)
468            _compare_stat('scope', 'Current user', policy, stats)
469        for policy in suggested_user_policies:
470            self._compare_values(policy, suggested_user_policies[policy],
471                                 stats[policy]['value'])
472            _compare_stat('level', 'Recommended', policy, stats)
473            _compare_stat('scope', 'Current user', policy, stats)
474        for policy in device_policies:
475            self._compare_values(policy, device_policies[policy],
476                                 stats[policy]['value'])
477            _compare_stat('level', 'Mandatory', policy, stats)
478            _compare_stat('scope', 'Device', policy, stats)
479
480
481    def _initialize_chrome_extra_flags(self):
482        """
483        Initialize flags used to create Chrome instance.
484
485        @returns: list of extra Chrome flags.
486
487        """
488        # Construct DM Server URL flags if not using production server.
489        env_flag_list = []
490        if self.env != 'prod':
491            if self.dms_is_fake:
492                # Use URL provided by the fake AutoTest DM server.
493                dmserver_str = (DMSERVER % self.fake_dm_server.server_url)
494            else:
495                # Use URL defined in the DMS URL dictionary.
496                dmserver_str = (DMSERVER % (DMS_URL_DICT[self.env]))
497                if self.env == 'dm-test':
498                    dmserver_str = (dmserver_str % self.dms_name)
499
500            # Merge with other flags needed by non-prod enviornment.
501            env_flag_list = ([dmserver_str] + FLAGS_DICT[self.env])
502
503        return env_flag_list
504
505
506    def _create_chrome(self, enroll=False, auto_login=True,
507                       extra_chrome_flags=[], init_network_controller=False):
508        """
509        Create a Chrome object. Enroll and/or sign in.
510
511        Function results in self.cr set as the Chrome object.
512
513        @param enroll: enroll the device.
514        @param auto_login: sign in to chromeos.
515        @param extra_chrome_flags: list of flags to add.
516        @param init_network_controller: whether to init network controller.
517        """
518        extra_flags = self._initialize_chrome_extra_flags() + extra_chrome_flags
519
520        logging.info('Chrome Browser Arguments:')
521        logging.info('  extra_browser_args: %s', extra_flags)
522        logging.info('  username: %s', self.username)
523        logging.info('  password: %s', self.password)
524        logging.info('  gaia_login: %s', not self.dms_is_fake)
525
526        if enroll:
527            self.cr = chrome.Chrome(auto_login=False,
528                                    extra_browser_args=extra_flags,
529                                    expect_policy_fetch=True)
530            if self.dms_is_fake:
531                enrollment.EnterpriseFakeEnrollment(
532                    self.cr.browser, self.username, self.password, self.gaia_id,
533                    auto_login=auto_login)
534            else:
535                enrollment.EnterpriseEnrollment(
536                    self.cr.browser, self.username, self.password,
537                    auto_login=auto_login)
538
539        elif auto_login:
540            self.cr = chrome.Chrome(extra_browser_args=extra_flags,
541                                    username=self.username,
542                                    password=self.password,
543                                    gaia_login=not self.dms_is_fake,
544                                    disable_gaia_services=self.dms_is_fake,
545                                    autotest_ext=True,
546                                    init_network_controller=init_network_controller,
547                                    expect_policy_fetch=True)
548        else:
549            self.cr = chrome.Chrome(auto_login=False,
550                                    extra_browser_args=extra_flags,
551                                    disable_gaia_services=self.dms_is_fake,
552                                    autotest_ext=True,
553                                    expect_policy_fetch=True)
554
555        if auto_login:
556            if not cryptohome.is_vault_mounted(user=self.username,
557                                               allow_fail=True):
558                raise error.TestError('Expected to find a mounted vault for %s.'
559                                      % self.username)
560
561
562    def navigate_to_url(self, url, tab=None):
563        """Navigate tab to the specified |url|. Create new tab if none given.
564
565        @param url: URL of web page to load.
566        @param tab: browser tab to load (if any).
567        @returns: browser tab loaded with web page.
568        @raises: telemetry TimeoutException if document ready state times out.
569        """
570        logging.info('Navigating to URL: %r', url)
571        if not tab:
572            tab = self.cr.browser.tabs.New()
573            tab.Activate()
574        tab.Navigate(url, timeout=8)
575        tab.WaitForDocumentReadyStateToBeComplete()
576        return tab
577
578
579    def get_elements_from_page(self, tab, cmd):
580        """Get collection of page elements that match the |cmd| filter.
581
582        @param tab: tab containing the page to be scraped.
583        @param cmd: JavaScript command to evaluate on the page.
584        @returns object containing elements on page that match the cmd.
585        @raises: TestFail if matching elements are not found on the page.
586        """
587        try:
588            elements = tab.EvaluateJavaScript(cmd)
589        except Exception as err:
590            raise error.TestFail('Unable to find matching elements on '
591                                 'the test page: %s\n %r' %(tab.url, err))
592        return elements
593
594
595def encode_json_string(object_value):
596    """Convert given value to JSON format string.
597
598    @param object_value: object to be converted.
599
600    @returns: string in JSON format.
601    """
602    return json.dumps(object_value)
603
604
605def decode_json_string(json_string):
606    """Convert given JSON format string to an object.
607
608    If no object is found, return json_string instead.  This is to allow
609    us to "decode" items off the policy page that aren't real JSON.
610
611    @param json_string: the JSON string to be decoded.
612
613    @returns: Python object represented by json_string or json_string.
614    """
615    def _decode_list(json_list):
616        result = []
617        for value in json_list:
618            if isinstance(value, unicode):
619                value = value.encode('ascii')
620            if isinstance(value, list):
621                value = _decode_list(value)
622            if isinstance(value, dict):
623                value = _decode_dict(value)
624            result.append(value)
625        return result
626
627    def _decode_dict(json_dict):
628        result = {}
629        for key, value in json_dict.iteritems():
630            if isinstance(key, unicode):
631                key = key.encode('ascii')
632            if isinstance(value, unicode):
633                value = value.encode('ascii')
634            elif isinstance(value, list):
635                value = _decode_list(value)
636            result[key] = value
637        return result
638
639    try:
640        # Decode JSON turning all unicode strings into ascii.
641        # object_hook will get called on all dicts, so also handle lists.
642        result = json.loads(json_string, encoding='ascii',
643                            object_hook=_decode_dict)
644        if isinstance(result, list):
645            result = _decode_list(result)
646        return result
647    except ValueError as e:
648        # Input not valid, e.g. '1, 2, "c"' instead of '[1, 2, "c"]'.
649        logging.warning('Could not unload: %s (%s)', json_string, e)
650        return json_string
651