1# (c) 2020, NetApp, Inc
2# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
3
4''' unit test template for ONTAP Ansible module '''
5
6from __future__ import (absolute_import, division, print_function)
7__metaclass__ = type
8
9import json
10import pytest
11
12from ansible.module_utils import basic
13from ansible.module_utils._text import to_bytes
14from ansible_collections.netapp.ontap.tests.unit.compat import unittest
15from ansible_collections.netapp.ontap.tests.unit.compat.mock import patch, Mock
16import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils
17
18from ansible_collections.netapp.ontap.plugins.modules.na_ontap_volume \
19    import NetAppOntapVolume as volume_module  # module under test
20
21# needed for get and modify/delete as they still use ZAPI
22if not netapp_utils.has_netapp_lib():
23    pytestmark = pytest.mark.skip('skipping as missing required netapp_lib')
24
25# REST API canned responses when mocking send_request
26SRR = {
27    # common responses
28    'is_rest': (200, dict(version=dict(generation=9, major=8, minor=0, full='dummy')), None),
29    'is_zapi': (400, {}, "Unreachable"),
30    'empty_good': (200, {}, None),
31    'no_record': (200, {'num_records': 0, 'records': []}, None),
32    'end_of_sequence': (500, None, "Unexpected call to send_request"),
33    'generic_error': (400, None, "Expected error"),
34    # module specific responses
35    'svm_record': (200,
36                   {'records': [{"uuid": "09e9fd5e-8ebd-11e9-b162-005056b39fe7",
37                                 "name": "test_svm",
38                                 "subtype": "default",
39                                 "language": "c.utf_8",
40                                 "aggregates": [{"name": "aggr_1",
41                                                 "uuid": "850dd65b-8811-4611-ac8c-6f6240475ff9"},
42                                                {"name": "aggr_2",
43                                                 "uuid": "850dd65b-8811-4611-ac8c-6f6240475ff9"}],
44                                 "comment": "new comment",
45                                 "ipspace": {"name": "ansible_ipspace",
46                                             "uuid": "2b760d31-8dfd-11e9-b162-005056b39fe7"},
47                                 "snapshot_policy": {"uuid": "3b611707-8dfd-11e9-b162-005056b39fe7",
48                                                     "name": "old_snapshot_policy"},
49                                 "nfs": {"enabled": True},
50                                 "cifs": {"enabled": False},
51                                 "iscsi": {"enabled": False},
52                                 "fcp": {"enabled": False},
53                                 "nvme": {"enabled": False}}]}, None),
54    # module specific responses
55    'nas_app_record': (200,
56                       {'records': [{"uuid": "09e9fd5e-8ebd-11e9-b162-005056b39fe7",
57                                     "name": "test_app",
58                                     "nas": {
59                                             "application_components": [{'xxx': 1}]}
60                                     }]}, None)
61}
62
63
64def set_module_args(args):
65    """prepare arguments so that they will be picked up during module creation"""
66    args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
67    basic._ANSIBLE_ARGS = to_bytes(args)  # pylint: disable=protected-access
68
69
70class AnsibleExitJson(Exception):
71    """Exception class to be raised by module.exit_json and caught by the test case"""
72
73
74class AnsibleFailJson(Exception):
75    """Exception class to be raised by module.fail_json and caught by the test case"""
76
77
78def exit_json(*args, **kwargs):  # pylint: disable=unused-argument
79    """function to patch over exit_json; package return data into an exception"""
80    if 'changed' not in kwargs:
81        kwargs['changed'] = False
82    raise AnsibleExitJson(kwargs)
83
84
85def fail_json(*args, **kwargs):  # pylint: disable=unused-argument
86    """function to patch over fail_json; package return data into an exception"""
87    kwargs['failed'] = True
88    raise AnsibleFailJson(kwargs)
89
90
91class MockONTAPConnection(object):
92    ''' mock server connection to ONTAP host '''
93
94    def __init__(self, kind=None, data=None, get_volume=None):
95        ''' save arguments '''
96        self.type = kind
97        self.params = data
98        self.xml_in = None
99        self.xml_out = None
100        self.get_volume = get_volume
101        self.zapis = list()
102
103    def invoke_successfully(self, xml, enable_tunneling):  # pylint: disable=unused-argument
104        ''' mock invoke_successfully returning xml data '''
105        self.xml_in = xml
106        zapi = xml.get_name()
107        self.zapis.append(zapi)
108        request = xml.to_string().decode('utf-8')
109        if self.type == 'error':
110            raise OSError('unexpected call to %s' % self.params)
111        print('request:', request)
112        if request.startswith('<volume-get-iter>'):
113            what = None
114            if self.get_volume:
115                what = self.get_volume.pop(0)
116            if what is None:
117                xml = self.build_empty_response()
118            else:
119                xml = self.build_get_response(what)
120        self.xml_out = xml
121        print('response:', xml.to_string())
122        return xml
123
124    @staticmethod
125    def build_response(data):
126        ''' build xml data for vserser-info '''
127        xml = netapp_utils.zapi.NaElement('xml')
128        xml.translate_struct(data)
129        return xml
130
131    def build_empty_response(self):
132        data = {'num-records': '0'}
133        return self.build_response(data)
134
135    def build_get_response(self, name):
136        ''' build xml data for vserser-info '''
137        if name is None:
138            return self.build_empty_response()
139        data = {'num-records': 1,
140                'attributes-list': [{
141                    'volume-attributes': {
142                        'volume-id-attributes': {
143                            'name': name,
144                            'instance-uuid': '123',
145                            'junction-path': 'jpath',
146                            'style-extended': 'flexvol'
147                        },
148                        'volume-performance-attributes': {
149                            'is-atime-update-enabled': 'true'
150                        },
151                        'volume-security-attributes': {
152                            'volume-security-unix-attributes': {
153                                'permissions': 777
154                            }
155                        },
156                        'volume-snapshot-attributes': {
157                            'snapshot-policy': 'default'
158                        },
159                        'volume-snapshot-autodelete-attributes': {
160                            'is-autodelete-enabled': 'true'
161                        },
162                        'volume-space-attributes': {
163                            'size': 10737418240     # 10 GB
164                        },
165                        'volume-state-attributes': {
166                            'state': 'online'
167                        },
168                    }
169                }]}
170        return self.build_response(data)
171
172
173class TestMyModule(unittest.TestCase):
174    ''' a group of related Unit Tests '''
175
176    def setUp(self):
177        self.mock_module_helper = patch.multiple(basic.AnsibleModule,
178                                                 exit_json=exit_json,
179                                                 fail_json=fail_json)
180        self.mock_module_helper.start()
181        self.addCleanup(self.mock_module_helper.stop)
182        # self.server = MockONTAPConnection()
183        self.mock_vserver = {
184            'name': 'test_svm',
185            'root_volume': 'ansible_vol',
186            'root_volume_aggregate': 'ansible_aggr',
187            'aggr_list': 'aggr_1,aggr_2',
188            'ipspace': 'ansible_ipspace',
189            'subtype': 'default',
190            'language': 'c.utf_8',
191            'snapshot_policy': 'old_snapshot_policy',
192            'comment': 'new comment'
193        }
194
195    @staticmethod
196    def mock_args():
197        return {'name': 'test_volume',
198                'vserver': 'ansibleSVM',
199                'nas_application_template': dict(
200                    tiering=None
201                ),
202                # 'aggregate_name': 'whatever',       # not used for create when using REST application/applications
203                'size': 10,
204                'size_unit': 'gb',
205                'hostname': 'test',
206                'username': 'test_user',
207                'password': 'test_pass!'}
208
209    def get_volume_mock_object(self, **kwargs):
210        volume_obj = volume_module()
211        netapp_utils.ems_log_event = Mock(return_value=None)
212        volume_obj.server = MockONTAPConnection(**kwargs)
213        volume_obj.cluster = MockONTAPConnection(kind='error', data='cluster ZAPI.')
214        return volume_obj
215
216    def test_module_fail_when_required_args_missing(self):
217        ''' required arguments are reported as errors '''
218        with pytest.raises(AnsibleFailJson) as exc:
219            set_module_args({})
220            volume_module()
221        print('Info: %s' % exc.value.args[0]['msg'])
222
223    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
224    def test_fail_if_aggr_is_set(self, mock_request):
225        data = dict(self.mock_args())
226        data['aggregate_name'] = 'should_fail'
227        set_module_args(data)
228        mock_request.side_effect = [
229            SRR['is_rest'],
230            SRR['end_of_sequence']
231        ]
232        with pytest.raises(AnsibleFailJson) as exc:
233            self.get_volume_mock_object().apply()
234        error = 'Conflict: aggregate_name is not supported when application template is enabled.  Found: aggregate_name: should_fail'
235        assert exc.value.args[0]['msg'] == error
236
237    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
238    def test_missing_size(self, mock_request):
239        data = dict(self.mock_args())
240        data.pop('size')
241        set_module_args(data)
242        mock_request.side_effect = [
243            SRR['is_rest'],
244            SRR['no_record'],       # GET application/applications
245            SRR['end_of_sequence']
246        ]
247        with pytest.raises(AnsibleFailJson) as exc:
248            self.get_volume_mock_object().apply()
249        error = 'Error: "size" is required to create nas application.'
250        assert exc.value.args[0]['msg'] == error
251
252    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
253    def test_mismatched_tiering_policies(self, mock_request):
254        data = dict(self.mock_args())
255        data['tiering_policy'] = 'none'
256        data['nas_application_template'] = dict(
257            tiering=dict(policy='auto')
258        )
259        set_module_args(data)
260        mock_request.side_effect = [
261            SRR['is_rest'],
262            SRR['end_of_sequence']
263        ]
264        with pytest.raises(AnsibleFailJson) as exc:
265            self.get_volume_mock_object().apply()
266        error = 'Conflict: if tiering_policy and nas_application_template tiering policy are both set, they must match.'
267        error += '  Found "none" and "auto".'
268        assert exc.value.args[0]['msg'] == error
269
270    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
271    def test_rest_error(self, mock_request):
272        data = dict(self.mock_args())
273        set_module_args(data)
274        mock_request.side_effect = [
275            SRR['is_rest'],
276            SRR['no_record'],           # GET application/applications
277            SRR['generic_error'],       # POST application/applications
278            SRR['end_of_sequence']
279        ]
280        with pytest.raises(AnsibleFailJson) as exc:
281            self.get_volume_mock_object().apply()
282        msg = 'Error in create_nas_application: calling: application/applications: got %s.' % SRR['generic_error'][2]
283        assert exc.value.args[0]['msg'] == msg
284
285    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
286    def test_rest_successfully_created(self, mock_request):
287        data = dict(self.mock_args())
288        set_module_args(data)
289        mock_request.side_effect = [
290            SRR['is_rest'],
291            SRR['no_record'],        # GET application/applications
292            SRR['empty_good'],       # POST application/applications
293            SRR['end_of_sequence']
294        ]
295        with pytest.raises(AnsibleExitJson) as exc:
296            self.get_volume_mock_object().apply()
297        assert exc.value.args[0]['changed']
298
299    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
300    def test_rest_create_idempotency(self, mock_request):
301        data = dict(self.mock_args())
302        set_module_args(data)
303        mock_request.side_effect = [
304            SRR['is_rest'],
305            SRR['no_record'],        # GET application/applications
306            SRR['end_of_sequence']
307        ]
308        with pytest.raises(AnsibleExitJson) as exc:
309            self.get_volume_mock_object(get_volume=['test']).apply()
310        assert not exc.value.args[0]['changed']
311
312    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
313    def test_rest_successfully_created_with_modify(self, mock_request):
314        ''' since language is not supported in application, the module is expected to:
315            1. create the volume using application REST API
316            2. immediately modify the volume to update options which not available in the nas template.
317        '''
318        data = dict(self.mock_args())
319        data['language'] = 'fr'     # TODO: apparently language is not supported for modify
320        data['unix_permissions'] = '---rw-rx-r--'
321        set_module_args(data)
322        mock_request.side_effect = [
323            SRR['is_rest'],
324            SRR['no_record'],        # GET application/applications
325            SRR['empty_good'],       # POST application/applications
326            SRR['end_of_sequence']
327        ]
328        my_volume = self.get_volume_mock_object(get_volume=[None, 'test'])
329        with pytest.raises(AnsibleExitJson) as exc:
330            my_volume.apply()
331        assert exc.value.args[0]['changed']
332        print(exc.value.args[0])
333        assert 'unix_permissions' in exc.value.args[0]['modify_after_create']
334        assert 'language' not in exc.value.args[0]['modify_after_create']        # eh!
335        assert 'volume-modify-iter' in my_volume.server.zapis
336
337    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
338    def test_rest_successfully_resized(self, mock_request):
339        ''' make sure resize if using RESP API if sizing_method is present
340        '''
341        data = dict(self.mock_args())
342        data['sizing_method'] = 'add_new_resources'
343        data['size'] = 20737418240
344        set_module_args(data)
345        mock_request.side_effect = [
346            SRR['is_rest'],
347            SRR['no_record'],        # GET application/applications
348            SRR['empty_good'],       # PATCH application/applications
349            SRR['end_of_sequence']
350        ]
351        my_volume = self.get_volume_mock_object(get_volume=['test'])
352        with pytest.raises(AnsibleExitJson) as exc:
353            my_volume.apply()
354        assert exc.value.args[0]['changed']
355        print(exc.value.args[0])
356        assert 'volume-size' not in my_volume.server.zapis
357        print(mock_request.call_args)
358        query = {'return_timeout': 30, 'sizing_method': 'add_new_resources'}
359        mock_request.assert_called_with('PATCH', 'storage/volumes/123', query, json={'size': 22266633286068469760})
360
361    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
362    def test_rest_successfully_deleted(self, mock_request):
363        ''' delete volume using REST - no app
364        '''
365        data = dict(self.mock_args())
366        data['state'] = 'absent'
367        set_module_args(data)
368        mock_request.side_effect = [
369            SRR['is_rest'],
370            SRR['no_record'],        # GET application/applications
371            SRR['empty_good'],       # PATCH storage/volumes - unmount
372            SRR['empty_good'],       # DELETE storage/volumes
373            SRR['end_of_sequence']
374        ]
375        my_volume = self.get_volume_mock_object(get_volume=['test'])
376        with pytest.raises(AnsibleExitJson) as exc:
377            my_volume.apply()
378        assert exc.value.args[0]['changed']
379        print(exc.value.args[0])
380        assert 'volume-size' not in my_volume.server.zapis
381        print(mock_request.call_args)
382        print(mock_request.mock_calls)
383        query = {'return_timeout': 30}
384        mock_request.assert_called_with('DELETE', 'storage/volumes/123', query, json=None)
385
386    @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request')
387    def test_rest_successfully_deleted_with_app(self, mock_request):
388        ''' delete app
389        '''
390        data = dict(self.mock_args())
391        data['state'] = 'absent'
392        set_module_args(data)
393        mock_request.side_effect = [
394            SRR['is_rest'],
395            SRR['nas_app_record'],      # GET application/applications
396            SRR['nas_app_record'],      # GET application/applications/uuid
397            SRR['empty_good'],          # PATCH storage/volumes - unmount
398            SRR['empty_good'],          # DELETE storage/volumes
399            SRR['end_of_sequence']
400        ]
401        my_volume = self.get_volume_mock_object(get_volume=['test'])
402        with pytest.raises(AnsibleExitJson) as exc:
403            my_volume.apply()
404        assert exc.value.args[0]['changed']
405        print(exc.value.args[0])
406        assert 'volume-size' not in my_volume.server.zapis
407        print(mock_request.call_args)
408        print(mock_request.mock_calls)
409        query = {'return_timeout': 30}
410        mock_request.assert_called_with('DELETE', 'storage/volumes/123', query, json=None)
411