blob: 1253fdb3c1dbae7ae6555ee0f69da88c8f8cb6e3 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2018 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""Tests for MonorailServicer."""
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import time
11import unittest
12import mock
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020013try:
14 from mox3 import mox
15except ImportError:
16 import mox
Copybara854996b2021-09-07 19:36:02 +000017
Copybara854996b2021-09-07 19:36:02 +000018from components.prpc import codes
19from components.prpc import context
20from google.appengine.ext import testbed
21from google.protobuf import json_format
22
23import settings
24from api import monorail_servicer
25from framework import authdata
26from framework import exceptions
27from framework import framework_constants
28from framework import monorailcontext
29from framework import permissions
30from framework import ratelimiter
31from framework import xsrf
32from services import cachemanager_svc
33from services import config_svc
34from services import service_manager
35from services import features_svc
36from testing import fake
37from testing import testing_helpers
38
39
40class MonorailServicerFunctionsTest(unittest.TestCase):
41
42 def testConvertPRPCStatusToHTTPStatus(self):
43 """We can convert pRPC status codes to http codes for monitoring."""
44 prpc_context = context.ServicerContext()
45
46 prpc_context.set_code(codes.StatusCode.OK)
47 self.assertEqual(
48 200, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
49
50 prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
51 self.assertEqual(
52 400, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
53
54 prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
55 self.assertEqual(
56 403, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
57
58 prpc_context.set_code(codes.StatusCode.NOT_FOUND)
59 self.assertEqual(
60 404, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
61
62 prpc_context.set_code(codes.StatusCode.INTERNAL)
63 self.assertEqual(
64 500, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
65
66
67class UpdateSomethingRequest(testing_helpers.Blank):
68 """A fake request that would do a write."""
69 pass
70
71
72class ListSomethingRequest(testing_helpers.Blank):
73 """A fake request that would do a read."""
74 pass
75
76
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010077class _TestableServicer(monorail_servicer.MonorailServicer):
Copybara854996b2021-09-07 19:36:02 +000078 """Fake servicer class."""
79
80 def __init__(self, services):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010081 super(_TestableServicer, self).__init__(services)
Copybara854996b2021-09-07 19:36:02 +000082 self.was_called = False
83 self.seen_mc = None
84 self.seen_request = None
85
86 @monorail_servicer.PRPCMethod
87 def CalcSomething(self, mc, request):
88 """Raise the test exception, or return what we got for verification."""
89 self.was_called = True
90 self.seen_mc = mc
91 self.seen_request = request
92 assert mc
93 assert request
94 if request.exc_class:
95 raise request.exc_class()
96 else:
97 return 'fake response proto'
98
99
100class MonorailServicerTest(unittest.TestCase):
101
102 def setUp(self):
103 self.mox = mox.Mox()
104 self.testbed = testbed.Testbed()
105 self.testbed.activate()
106 self.testbed.init_memcache_stub()
107 self.testbed.init_datastore_v3_stub()
108 self.testbed.init_user_stub()
109
110 self.cnxn = fake.MonorailConnection()
111 self.services = service_manager.Services(
112 user=fake.UserService(),
113 usergroup=fake.UserGroupService(),
114 project=fake.ProjectService(),
115 cache_manager=fake.CacheManager())
116 self.project = self.services.project.TestAddProject(
117 'proj', project_id=789, owner_ids=[111])
118 # allowlisted_bot's email is allowlisted in testing/api_clients.cfg.
119 self.allowlisted_bot = self.services.user.TestAddUser(
120 '123456789@developer.gserviceaccount.com', 999)
121 # allowlisted_client_id_user is used to test accounts that are only
122 # allowlisted with the client_id.
123 self.allowlisted_client_id_user = self.services.user.TestAddUser(
124 'allowlisted-with-client-id@developer.gserviceaccount.com', 888)
125 self.non_member = self.services.user.TestAddUser(
126 'nonmember@example.com', 222)
127 self.allowed_domain_user = self.services.user.TestAddUser(
128 'chickenchicken@google.com', 333)
129 self.test_user = self.services.user.TestAddUser('test@example.com', 420)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100130 self.svcr = _TestableServicer(self.services)
Copybara854996b2021-09-07 19:36:02 +0000131 self.nonmember_token = xsrf.GenerateToken(222, xsrf.XHR_SERVLET_PATH)
132 self.request = UpdateSomethingRequest(exc_class=None)
133 self.prpc_context = context.ServicerContext()
134 self.prpc_context.set_code(codes.StatusCode.OK)
135 self.prpc_context._invocation_metadata = [
136 (monorail_servicer.XSRF_TOKEN_HEADER, self.nonmember_token)]
137
138 self.oauth_patcher = mock.patch(
139 'google.appengine.api.oauth.get_current_user')
140 self.mock_oauth_gcu = self.oauth_patcher.start()
141 self.mock_oauth_gcu.return_value = None
142
143 self.oauth_client_id_patcher = mock.patch(
144 'google.appengine.api.oauth.get_client_id')
145 self.mock_oauth_gcid = self.oauth_client_id_patcher.start()
146 self.mock_oauth_gcid.return_value = "1234common.clientid"
147
148 # TODO(b/144508063): remove this workaround.
149 self.oauth_authorized_scopes_patcher = mock.patch(
150 'google.appengine.api.oauth.get_authorized_scopes')
151 self.mock_oauth_gas = self.oauth_authorized_scopes_patcher.start()
152 self.mock_oauth_gas.return_value = [framework_constants.MONORAIL_SCOPE]
153
154 def tearDown(self):
155 self.mox.UnsetStubs()
156 self.mox.ResetAll()
157 self.testbed.deactivate()
158
159 def SetUpRecordMonitoringStats(self):
160 self.mox.StubOutWithMock(json_format, 'MessageToJson')
161 json_format.MessageToJson(self.request).AndReturn('json of request')
162 json_format.MessageToJson('fake response proto').AndReturn(
163 'json of response')
164 self.mox.ReplayAll()
165
166 def testRun_SiteWide_Normal(self):
167 """Calling the handler through the decorator."""
168 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
169 self.SetUpRecordMonitoringStats()
170 # pylint: disable=unexpected-keyword-arg
171 response = self.svcr.CalcSomething(
172 self.request, self.prpc_context, cnxn=self.cnxn)
173 self.assertIsNone(self.svcr.seen_mc.cnxn) # Because of CleanUp().
174 self.assertEqual(self.svcr.seen_mc.auth.email, self.non_member.email)
175 self.assertIn(permissions.CREATE_HOTLIST.lower(),
176 self.svcr.seen_mc.perms.perm_names)
177 self.assertNotIn(permissions.ADMINISTER_SITE.lower(),
178 self.svcr.seen_mc.perms.perm_names)
179 self.assertEqual(self.request, self.svcr.seen_request)
180 self.assertEqual('fake response proto', response)
181 self.assertEqual(codes.StatusCode.OK, self.prpc_context._code)
182
183 def testRun_RequesterBanned(self):
184 """If we reject the request, give PERMISSION_DENIED."""
185 self.non_member.banned = 'Spammer'
186 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
187 self.SetUpRecordMonitoringStats()
188 # pylint: disable=unexpected-keyword-arg
189 self.svcr.CalcSomething(
190 self.request, self.prpc_context, cnxn=self.cnxn)
191 self.assertFalse(self.svcr.was_called)
192 self.assertEqual(
193 codes.StatusCode.PERMISSION_DENIED, self.prpc_context._code)
194
195 def testRun_AnonymousRequester(self):
196 """Test we properly process anonymous users with valid tokens."""
197 self.prpc_context._invocation_metadata = [
198 (monorail_servicer.XSRF_TOKEN_HEADER,
199 xsrf.GenerateToken(0, xsrf.XHR_SERVLET_PATH))]
200 self.SetUpRecordMonitoringStats()
201 # pylint: disable=unexpected-keyword-arg
202 response = self.svcr.CalcSomething(
203 self.request, self.prpc_context, cnxn=self.cnxn)
204 self.assertIsNone(self.svcr.seen_mc.cnxn) # Because of CleanUp().
205 self.assertIsNone(self.svcr.seen_mc.auth.email)
206 self.assertNotIn(permissions.CREATE_HOTLIST.lower(),
207 self.svcr.seen_mc.perms.perm_names)
208 self.assertNotIn(permissions.ADMINISTER_SITE.lower(),
209 self.svcr.seen_mc.perms.perm_names)
210 self.assertEqual(self.request, self.svcr.seen_request)
211 self.assertEqual('fake response proto', response)
212 self.assertEqual(codes.StatusCode.OK, self.prpc_context._code)
213
214 def testRun_DistributedInvalidation(self):
215 """The Run method must call DoDistributedInvalidation()."""
216 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
217 self.SetUpRecordMonitoringStats()
218 # pylint: disable=unexpected-keyword-arg
219 self.svcr.CalcSomething(
220 self.request, self.prpc_context, cnxn=self.cnxn)
221 self.assertIsNotNone(self.services.cache_manager.last_call)
222
223 def testRun_HandlerErrorResponse(self):
224 """An expected exception in the method causes an error status."""
225 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
226 self.SetUpRecordMonitoringStats()
227 # pylint: disable=attribute-defined-outside-init
228 self.request.exc_class = exceptions.NoSuchUserException
229 # pylint: disable=unexpected-keyword-arg
230 response = self.svcr.CalcSomething(
231 self.request, self.prpc_context, cnxn=self.cnxn)
232 self.assertTrue(self.svcr.was_called)
233 self.assertIsNone(self.svcr.seen_mc.cnxn) # Because of CleanUp().
234 self.assertEqual(self.svcr.seen_mc.auth.email, self.non_member.email)
235 self.assertEqual(self.request, self.svcr.seen_request)
236 self.assertIsNone(response)
237 self.assertEqual(codes.StatusCode.NOT_FOUND, self.prpc_context._code)
238
239 def testRun_HandlerProgrammingError(self):
240 """An unexception in the handler method is re-raised."""
241 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
242 self.SetUpRecordMonitoringStats()
243 # pylint: disable=attribute-defined-outside-init
244 self.request.exc_class = NotImplementedError
245 self.assertRaises(
246 NotImplementedError,
247 self.svcr.CalcSomething,
248 self.request, self.prpc_context, cnxn=self.cnxn)
249 self.assertTrue(self.svcr.was_called)
250 self.assertIsNone(self.svcr.seen_mc.cnxn) # Because of CleanUp().
251
252 def testGetAndAssertRequesterAuth_Cookie_Anon(self):
253 """We get and allow requests from anon user using cookie auth."""
254 metadata = {
255 monorail_servicer.XSRF_TOKEN_HEADER: xsrf.GenerateToken(
256 0, xsrf.XHR_SERVLET_PATH)}
257 # Signed out.
258 self.assertIsNone(self.svcr.GetAndAssertRequesterAuth(
259 self.cnxn, metadata, self.services).email)
260
261 def testGetAndAssertRequesterAuth_Cookie_SignedIn(self):
262 """We get and allow requests from signed in users using cookie auth."""
263 metadata = dict(self.prpc_context.invocation_metadata())
264 # Signed in with cookie auth.
265 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
266 user_auth = self.svcr.GetAndAssertRequesterAuth(
267 self.cnxn, metadata, self.services)
268 self.assertEqual(self.non_member.email, user_auth.email)
269
270 def testGetAndAssertRequester_Anon_BadToken(self):
271 """We get the email address of the signed in user using oauth."""
272 metadata = {}
273 # Anonymous user has invalid token.
274 with self.assertRaises(permissions.PermissionException):
275 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
276
277 def testGetAndAssertRequester_Oauth_AllowedDomain_NoMonorailScope(self):
278 """We reject users with allowed domains but no monorail scope."""
279 metadata = {}
280 self.mock_oauth_gcu.return_value = None
281
282 with self.assertRaises(permissions.PermissionException):
283 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
284
285 def testGetAndAssertRequester_Oauth_BadDomain_MonorailScope(self):
286 """We reject users with bad domains using the monorail scope."""
287 metadata = {}
288 def side_effect(scope=None):
289 if scope == framework_constants.MONORAIL_SCOPE:
290 return testing_helpers.Blank(
291 email=lambda: 'testchicken@chicken.com', client_id=lambda: 7899)
292 return None
293 self.mock_oauth_gcu.side_effect = side_effect
294
295 with self.assertRaises(permissions.PermissionException):
296 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
297
298 def testGetAndAssertRequester_Oauth_AllowedDomain_MonorailScope(self):
299 """We get and allow users with allowed domains using the monorail scope."""
300 metadata = {}
301 def side_effect(scope=None):
302 if scope == framework_constants.MONORAIL_SCOPE:
303 return testing_helpers.Blank(
304 email=lambda: self.allowed_domain_user.email,
305 client_id=lambda: 7899)
306 return None
307 self.mock_oauth_gcu.side_effect = side_effect
308
309 user_auth = self.svcr.GetAndAssertRequesterAuth(
310 self.cnxn, metadata, self.services)
311 self.assertEqual(user_auth.email, self.allowed_domain_user.email)
312
313 def testGetAndAssertRequesterAuth_Oauth_Allowlisted(self):
314 metadata = {}
315 # Signed in with oauth.
316 self.mock_oauth_gcu.return_value = testing_helpers.Blank(
317 email=lambda: self.allowlisted_bot.email)
318
319 bot_auth = self.svcr.GetAndAssertRequesterAuth(
320 self.cnxn, metadata, self.services)
321 self.assertEqual(bot_auth.email, self.allowlisted_bot.email)
322
323 def testGetAndAssertRequesterAuth_Oauth_NotAllowlisted(self):
324 metadata = {}
325 # Signed in with oauth.
326 self.mock_oauth_gcu.return_value = testing_helpers.Blank(
327 email=lambda: 'who-is-this@test.com')
328
329 with self.assertRaises(permissions.PermissionException):
330 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
331
332 def testGetAndAssertRequesterAuth_Oauth_ClientIDOnly(self):
333 """We get and allow accounts that only have their client_id allowlisted."""
334 metadata = {}
335 self.mock_oauth_gcu.return_value = testing_helpers.Blank(
336 email=lambda: self.allowlisted_client_id_user.email)
337 self.mock_oauth_gcid.return_value = "98723764876"
338 both_auth = self.svcr.GetAndAssertRequesterAuth(
339 self.cnxn, metadata, self.services)
340 self.assertEqual(both_auth.email, self.allowlisted_client_id_user.email)
341
342 def testGetAndAssertRequesterAuth_Banned(self):
343 self.non_member.banned = 'Spammer'
344 metadata = dict(self.prpc_context.invocation_metadata())
345 # Signed in with cookie auth.
346 self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
347 with self.assertRaises(permissions.BannedUserException):
348 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
349
350 def testGetRequester_TestAccountOnAppspot(self):
351 """Specifying test_account is ignored on deployed server."""
352 # pylint: disable=attribute-defined-outside-init
353 metadata = {'x-test-account': 'test@example.com'}
354 with self.assertRaises(exceptions.InputException):
355 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
356
357 def testGetRequester_TestAccountOnDev(self):
358 """For integration testing, we can set test_account on dev_server."""
359 try:
360 orig_local_mode = settings.local_mode
361 settings.local_mode = True
362
363 # pylint: disable=attribute-defined-outside-init
364 metadata = {'x-test-account': 'test@example.com'}
365 test_auth = self.svcr.GetAndAssertRequesterAuth(
366 self.cnxn, metadata, self.services)
367 self.assertEqual('test@example.com', test_auth.email)
368
369 # pylint: disable=attribute-defined-outside-init
370 metadata = {'x-test-account': 'test@anythingelse.com'}
371 with self.assertRaises(exceptions.InputException):
372 self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
373 finally:
374 settings.local_mode = orig_local_mode
375
376 def testAssertBaseChecks_SiteIsReadOnly_Write(self):
377 """We reject writes and allow reads when site is read-only."""
378 orig_read_only = settings.read_only
379 try:
380 settings.read_only = True
381 metadata = {}
382 self.assertRaises(
383 permissions.PermissionException,
384 self.svcr.AssertBaseChecks, self.request, metadata)
385 finally:
386 settings.read_only = orig_read_only
387
388 def testAssertBaseChecks_SiteIsReadOnly_Read(self):
389 """We reject writes and allow reads when site is read-only."""
390 orig_read_only = settings.read_only
391 try:
392 settings.read_only = True
393 metadata = {monorail_servicer.XSRF_TOKEN_HEADER: self.nonmember_token}
394
395 # Our default request is an update.
396 with self.assertRaises(permissions.PermissionException):
397 self.svcr.AssertBaseChecks(self.request, metadata)
398
399 # A method name starting with "List" or "Get" will run OK.
400 self.request = ListSomethingRequest(exc_class=None)
401 self.svcr.AssertBaseChecks(self.request, metadata)
402 finally:
403 settings.read_only = orig_read_only
404
405 def testGetRequestProject(self):
406 """We get a project specified by request field project_name."""
407 # No project specified.
408 self.assertIsNone(self.svcr.GetRequestProject(self.cnxn, self.request))
409
410 # Existing project specified.
411 # pylint: disable=attribute-defined-outside-init
412 self.request.project_name = 'proj'
413 self.assertEqual(
414 self.project, self.svcr.GetRequestProject(self.cnxn, self.request))
415
416 # Bad project specified.
417 # pylint: disable=attribute-defined-outside-init
418 self.request.project_name = 'not-a-proj'
419 self.assertIsNone(self.svcr.GetRequestProject(self.cnxn, self.request))
420
421 def CheckExceptionStatus(self, e, expected_code, details=None):
422 mc = monorailcontext.MonorailContext(self.services)
423 self.prpc_context.set_code(codes.StatusCode.OK)
424 processed = self.svcr.ProcessException(e, self.prpc_context, mc)
425 if expected_code:
426 self.assertTrue(processed)
427 self.assertEqual(expected_code, self.prpc_context._code)
428 else:
429 self.assertFalse(processed)
430 # Uncaught exceptions should indicate an error.
431 self.assertEqual(codes.StatusCode.INTERNAL, self.prpc_context._code)
432 if details is not None:
433 self.assertEqual(details, self.prpc_context._details)
434
435 def testProcessException(self):
436 """Expected exceptions are converted to pRPC codes, expected not."""
437 self.CheckExceptionStatus(
438 exceptions.NoSuchUserException(), codes.StatusCode.NOT_FOUND)
439 self.CheckExceptionStatus(
440 exceptions.NoSuchProjectException(), codes.StatusCode.NOT_FOUND)
441 self.CheckExceptionStatus(
442 exceptions.NoSuchIssueException(), codes.StatusCode.NOT_FOUND)
443 self.CheckExceptionStatus(
444 exceptions.NoSuchComponentException(), codes.StatusCode.NOT_FOUND)
445 self.CheckExceptionStatus(
446 permissions.BannedUserException(), codes.StatusCode.PERMISSION_DENIED)
447 self.CheckExceptionStatus(
448 permissions.PermissionException(), codes.StatusCode.PERMISSION_DENIED)
449 self.CheckExceptionStatus(
450 exceptions.GroupExistsException(), codes.StatusCode.ALREADY_EXISTS)
451 self.CheckExceptionStatus(
452 exceptions.InvalidComponentNameException(),
453 codes.StatusCode.INVALID_ARGUMENT)
454 self.CheckExceptionStatus(
455 exceptions.InputException('echoed values'),
456 codes.StatusCode.INVALID_ARGUMENT,
457 details='Invalid arguments: echoed values')
458 self.CheckExceptionStatus(
459 exceptions.FilterRuleException(),
460 codes.StatusCode.INVALID_ARGUMENT,
461 details='Violates filter rule that should error.')
462 self.CheckExceptionStatus(
463 ratelimiter.ApiRateLimitExceeded('client_id', 'email'),
464 codes.StatusCode.PERMISSION_DENIED)
465 self.CheckExceptionStatus(
466 features_svc.HotlistAlreadyExists(), codes.StatusCode.ALREADY_EXISTS)
467 self.CheckExceptionStatus(NotImplementedError(), None)
468
469 def testProcessException_ErrorMessageEscaped(self):
470 """If we ever echo user input in error messages, it is escaped.."""
471 self.CheckExceptionStatus(
472 exceptions.InputException('echoed <script>"code"</script>'),
473 codes.StatusCode.INVALID_ARGUMENT,
474 details=('Invalid arguments: echoed '
475 '&lt;script&gt;&quot;code&quot;&lt;/script&gt;'))
476
477 def testRecordMonitoringStats_RequestClassDoesNotEndInRequest(self):
478 """We cope with request proto class names that do not end in 'Request'."""
479 self.request = 'this is a string'
480 self.SetUpRecordMonitoringStats()
481 start_time = 1522559788.939511
482 now = 1522569311.892738
483 self.svcr.RecordMonitoringStats(
484 start_time, self.request, 'fake response proto', self.prpc_context,
485 now=now)