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