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