|
1 | 1 | """Test safe_exec.py""" |
2 | 2 |
|
3 | 3 |
|
| 4 | +import copy |
4 | 5 | import hashlib |
5 | 6 | import os |
6 | 7 | import os.path |
7 | 8 | import textwrap |
8 | 9 | import unittest |
9 | | -from unittest.mock import patch |
| 10 | +from unittest.mock import call, patch |
10 | 11 |
|
11 | 12 | import pytest |
12 | 13 | import random2 as random |
@@ -132,62 +133,167 @@ class TestCodeJailDarkLaunch(unittest.TestCase): |
132 | 133 | """ |
133 | 134 | @patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') |
134 | 135 | @patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') |
135 | | - def test_default_code_execution(self, local_exec, remote_exec): |
| 136 | + def test_default_code_execution(self, mock_local_exec, mock_remote_exec): |
136 | 137 |
|
137 | 138 | # Test default only runs local exec. |
138 | 139 | g = {} |
139 | 140 | safe_exec('a=1', g) |
140 | | - assert local_exec.called |
141 | | - assert not remote_exec.called |
| 141 | + assert mock_local_exec.called |
| 142 | + assert not mock_remote_exec.called |
142 | 143 |
|
143 | 144 | @override_settings(ENABLE_CODEJAIL_REST_SERVICE=True) |
144 | 145 | @patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') |
145 | 146 | @patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') |
146 | | - def test_code_execution_only_codejail_service(self, local_exec, remote_exec): |
| 147 | + def test_code_execution_only_codejail_service(self, mock_local_exec, mock_remote_exec): |
147 | 148 | # Set return values to empty values to indicate no error. |
148 | | - remote_exec.return_value = (None, None) |
| 149 | + mock_remote_exec.return_value = (None, None) |
149 | 150 | # Test with only the service enabled. |
150 | 151 | g = {} |
151 | 152 | safe_exec('a=1', g) |
152 | | - assert not local_exec.called |
153 | | - assert remote_exec.called |
| 153 | + assert not mock_local_exec.called |
| 154 | + assert mock_remote_exec.called |
154 | 155 |
|
155 | 156 | @override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True) |
156 | 157 | @patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') |
157 | 158 | @patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') |
158 | | - def test_code_execution_darklaunch(self, local_exec, remote_exec): |
159 | | - # Set return values to empty values to indicate no error. |
160 | | - remote_exec.return_value = (None, None) |
161 | | - g = {} |
| 159 | + def test_code_execution_darklaunch_misconfig(self, mock_local_exec, mock_remote_exec): |
| 160 | + """Test that darklaunch doesn't run when remote service is generally enabled.""" |
| 161 | + mock_remote_exec.return_value = (None, None) |
162 | 162 |
|
163 | | - # Verify that incorrect config runs only remote and not both. |
164 | 163 | with override_settings(ENABLE_CODEJAIL_REST_SERVICE=True): |
165 | | - safe_exec('a=1', g) |
166 | | - assert not local_exec.called |
167 | | - assert remote_exec.called |
| 164 | + safe_exec('a=1', {}) |
| 165 | + |
| 166 | + assert not mock_local_exec.called |
| 167 | + assert mock_remote_exec.called |
168 | 168 |
|
169 | | - local_exec.reset_mock() |
170 | | - remote_exec.reset_mock() |
| 169 | + @override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True) |
| 170 | + def run_dark_launch( |
| 171 | + self, globals_dict, local, remote, |
| 172 | + expect_attr_calls, expect_log_info_calls, expect_globals_contains, |
| 173 | + ): |
| 174 | + """ |
| 175 | + Run a darklaunch scenario with mocked out local and remote execution. |
171 | 176 |
|
172 | | - # Set up side effects to mimic the real behavior of modifying the globals_dict. |
173 | | - def local_side_effect(*args, **kwargs): |
174 | | - test_globals = args[1] |
175 | | - test_globals['test'] = 'local_test' |
| 177 | + Asserts set_custom_attribute and log.info calls and (partial) contents |
| 178 | + of globals dict. |
176 | 179 |
|
177 | | - def remote_side_effect(*args, **kwargs): |
178 | | - test_globals = args[0]['globals_dict'] |
179 | | - test_globals['test'] = 'remote_test' |
| 180 | + Return value is a dictionary of: |
180 | 181 |
|
181 | | - local_exec.side_effect = local_side_effect |
182 | | - remote_exec.side_effect = remote_side_effect |
| 182 | + - 'raised': Exception that safe_exec raised, or None. |
| 183 | + """ |
183 | 184 |
|
184 | 185 | assert is_codejail_in_darklaunch() |
185 | | - safe_exec('a=1', g) |
186 | 186 |
|
187 | | - assert local_exec.called |
188 | | - assert remote_exec.called |
189 | | - # Verify that the local/default behavior currently wins out. |
190 | | - assert g['test'] == 'local_test' |
| 187 | + with ( |
| 188 | + patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') as mock_local_exec, |
| 189 | + patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') as mock_remote_exec, |
| 190 | + patch('xmodule.capa.safe_exec.safe_exec.set_custom_attribute') as mock_set_custom_attribute, |
| 191 | + patch('xmodule.capa.safe_exec.safe_exec.log.info') as mock_log_info, |
| 192 | + ): |
| 193 | + mock_local_exec.side_effect = local |
| 194 | + mock_remote_exec.side_effect = remote |
| 195 | + |
| 196 | + try: |
| 197 | + safe_exec("<IGNORED BY MOCKS>", globals_dict) |
| 198 | + except BaseException as e: |
| 199 | + safe_exec_e = e |
| 200 | + else: |
| 201 | + safe_exec_e = None |
| 202 | + |
| 203 | + # Always want both sides to be called |
| 204 | + assert mock_local_exec.called |
| 205 | + assert mock_remote_exec.called |
| 206 | + |
| 207 | + mock_set_custom_attribute.assert_has_calls(expect_attr_calls, any_order=True) |
| 208 | + mock_log_info.assert_has_calls(expect_log_info_calls, any_order=True) |
| 209 | + |
| 210 | + for (k, v) in expect_globals_contains.items(): |
| 211 | + assert globals_dict[k] == v |
| 212 | + |
| 213 | + return {'raised': safe_exec_e} |
| 214 | + |
| 215 | + def test_separate_globals(self): |
| 216 | + """Test that local and remote globals are isolated from each other's side effects.""" |
| 217 | + # Both will attempt to read and write the 'overwrite' key. |
| 218 | + globals_dict = {'overwrite': 'original'} |
| 219 | + |
| 220 | + local_globals = None |
| 221 | + remote_globals = None |
| 222 | + |
| 223 | + def local_exec(code, globals_dict, **kwargs): |
| 224 | + # Preserve what local exec saw |
| 225 | + nonlocal local_globals |
| 226 | + local_globals = copy.deepcopy(globals_dict) |
| 227 | + |
| 228 | + globals_dict['overwrite'] = 'mock local' |
| 229 | + |
| 230 | + def remote_exec(data): |
| 231 | + # Preserve what remote exec saw |
| 232 | + nonlocal remote_globals |
| 233 | + remote_globals = copy.deepcopy(data['globals_dict']) |
| 234 | + |
| 235 | + data['globals_dict']['overwrite'] = 'mock remote' |
| 236 | + return (None, None) |
| 237 | + |
| 238 | + results = self.run_dark_launch( |
| 239 | + globals_dict=globals_dict, local=local_exec, remote=remote_exec, |
| 240 | + expect_attr_calls=[ |
| 241 | + call('local_emsg_exists', False), |
| 242 | + call('remote_emsg_exists', False), |
| 243 | + call('dark_launch_emsg_match', True), |
| 244 | + ], |
| 245 | + expect_log_info_calls=[ |
| 246 | + call( |
| 247 | + "Local execution in darklaunch mode produces globals={'overwrite': 'mock local'}, " |
| 248 | + "emsg=None, exception=None" |
| 249 | + ), |
| 250 | + call( |
| 251 | + "Remote execution in darklaunch mode produces globals={'overwrite': 'mock remote'}, " |
| 252 | + "emsg=None, exception=None" |
| 253 | + ), |
| 254 | + ], |
| 255 | + # Should only see behavior of local exec |
| 256 | + expect_globals_contains={'overwrite': 'mock local'}, |
| 257 | + ) |
| 258 | + assert results['raised'] is None |
| 259 | + |
| 260 | + # Both arms should have only seen the original globals object, untouched |
| 261 | + # by the other arm. |
| 262 | + assert local_globals == {'overwrite': 'original'} |
| 263 | + assert remote_globals == {'overwrite': 'original'} |
| 264 | + |
| 265 | + def test_remote_runs_even_if_local_raises(self): |
| 266 | + """Test that remote exec runs even if local raises.""" |
| 267 | + def local_exec(code, globals_dict, **kwargs): |
| 268 | + # Raise something other than a SafeExecException. |
| 269 | + raise BaseException("unexpected") |
| 270 | + |
| 271 | + def remote_exec(data): |
| 272 | + return (None, None) |
| 273 | + |
| 274 | + results = self.run_dark_launch( |
| 275 | + globals_dict={}, local=local_exec, remote=remote_exec, |
| 276 | + expect_attr_calls=[ |
| 277 | + call('local_emsg_exists', True), |
| 278 | + call('remote_emsg_exists', False), |
| 279 | + call('dark_launch_emsg_match', False), |
| 280 | + ], |
| 281 | + expect_log_info_calls=[ |
| 282 | + call( |
| 283 | + "Remote execution in darklaunch mode produces globals={}, " |
| 284 | + "emsg=None, exception=None" |
| 285 | + ), |
| 286 | + call( |
| 287 | + "Local execution in darklaunch mode produces globals={}, " |
| 288 | + "emsg='unexpected', exception=BaseException('unexpected')" |
| 289 | + ), |
| 290 | + ], |
| 291 | + expect_globals_contains={}, |
| 292 | + ) |
| 293 | + |
| 294 | + # Unexpected errors from local safe_exec propagate up. |
| 295 | + assert isinstance(results['raised'], BaseException) |
| 296 | + assert 'unexpected' in repr(results['raised']) |
191 | 297 |
|
192 | 298 |
|
193 | 299 | class TestLimitConfiguration(unittest.TestCase): |
|
0 commit comments