| # Copyright 2018 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import contextlib |
| import copy |
| import os |
| import datetime |
| |
| from google.appengine.ext import ndb |
| from google.protobuf import field_mask_pb2 |
| from google.protobuf import timestamp_pb2 |
| from google.protobuf import text_format |
| from google.rpc import status_pb2 |
| |
| from components import auth |
| from components import prpc |
| from components import protoutil |
| from components import utils |
| from components.prpc import context as prpc_context |
| from testing_utils import testing |
| import mock |
| |
| from third_party import annotations_pb2 |
| |
| from proto import build_pb2 |
| from proto import common_pb2 |
| from proto import rpc_pb2 |
| from proto import step_pb2 |
| from test import test_util |
| from v2 import api |
| from v2 import tokens |
| from v2 import validation |
| import annotations |
| import buildtags |
| import model |
| import search |
| import service |
| |
| future = test_util.future |
| |
| THIS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| |
| |
| class BaseTestCase(testing.AppengineTestCase): |
| """Base class for api.py tests.""" |
| |
| def setUp(self): |
| super(BaseTestCase, self).setUp() |
| |
| self.patch('user.can_async', return_value=future(True)) |
| self.patch( |
| 'user.get_accessible_buckets_async', |
| autospec=True, |
| return_value=future({'chromium/try'}), |
| ) |
| |
| self.now = datetime.datetime(2015, 1, 1) |
| self.patch('components.utils.utcnow', side_effect=lambda: self.now) |
| |
| self.api = api.BuildsApi() |
| |
| def call( |
| self, |
| method, |
| req, |
| ctx=None, |
| expected_code=prpc.StatusCode.OK, |
| expected_details=None |
| ): |
| ctx = ctx or prpc_context.ServicerContext() |
| res = method(req, ctx) |
| self.assertEqual(ctx.code, expected_code) |
| if expected_details is not None: |
| self.assertEqual(ctx.details, expected_details) |
| if expected_code != prpc.StatusCode.OK: |
| self.assertIsNone(res) |
| return res |
| |
| def new_build_v1(self, builder_name='linux-try', **kwargs): |
| build_kwargs = dict( |
| id=model.create_build_ids(self.now, 1)[0], |
| bucket_id='chromium/try', |
| parameters={ |
| model.BUILDER_PARAMETER: builder_name, |
| }, |
| status=model.BuildStatus.COMPLETED, |
| result=model.BuildResult.SUCCESS, |
| created_by=auth.Identity('user', 'johndoe@example.com'), |
| ) |
| build_kwargs['parameters'].update(kwargs.pop('parameters', {})) |
| build_kwargs.update(kwargs) |
| return model.Build(**build_kwargs) |
| |
| |
| class RpcImplTests(BaseTestCase): |
| |
| def error_handling_test(self, ex, expected_code, expected_details): |
| |
| @api.rpc_impl_async('GetBuild') |
| @ndb.tasklet |
| def get_build_async(_req, _ctx, _mask): |
| raise ex |
| |
| ctx = prpc_context.ServicerContext() |
| req = rpc_pb2.GetBuildRequest(id=1) |
| # pylint: disable=no-value-for-parameter |
| get_build_async(req, ctx).get_result() |
| self.assertEqual(ctx.code, expected_code) |
| self.assertEqual(ctx.details, expected_details) |
| |
| def test_authorization_error_handling(self): |
| self.error_handling_test( |
| auth.AuthorizationError(), prpc.StatusCode.NOT_FOUND, 'not found' |
| ) |
| |
| def test_status_code_error_handling(self): |
| self.error_handling_test( |
| api.invalid_argument('bad'), prpc.StatusCode.INVALID_ARGUMENT, 'bad' |
| ) |
| |
| def test_invalid_field_mask(self): |
| req = rpc_pb2.GetBuildRequest( |
| fields=field_mask_pb2.FieldMask(paths=['invalid']) |
| ) |
| self.call( |
| self.api.GetBuild, |
| req, |
| expected_code=prpc.StatusCode.INVALID_ARGUMENT, |
| expected_details=( |
| 'invalid fields: invalid path "invalid": ' |
| 'field "invalid" does not exist in message ' |
| 'buildbucket.v2.Build' |
| ) |
| ) |
| |
| @mock.patch('service.get_async', autospec=True) |
| def test_trimming_exclude(self, get_async): |
| get_async.return_value = future( |
| self.new_build_v1(parameters={model.PROPERTIES_PARAMETER: {'a': 'b'}}) |
| ) |
| req = rpc_pb2.GetBuildRequest(id=1) |
| res = self.call(self.api.GetBuild, req) |
| self.assertFalse(res.input.HasField('properties')) |
| |
| @mock.patch('service.get_async', autospec=True) |
| def test_trimming_include(self, get_async): |
| get_async.return_value = future( |
| self.new_build_v1(parameters={ |
| model.PROPERTIES_PARAMETER: {'a': 'b'}, |
| }), |
| ) |
| req = rpc_pb2.GetBuildRequest( |
| id=1, fields=field_mask_pb2.FieldMask(paths=['input.properties']) |
| ) |
| res = self.call(self.api.GetBuild, req) |
| self.assertEqual(res.input.properties.items(), [('a', 'b')]) |
| |
| |
| class ToBuildMessagesTests(BaseTestCase): |
| |
| def test_steps(self): |
| build_v1 = self.new_build_v1() |
| steps = [ |
| step_pb2.Step(name='a', status=common_pb2.SUCCESS), |
| step_pb2.Step(name='b', status=common_pb2.STARTED), |
| ] |
| model.BuildSteps( |
| key=model.BuildSteps.key_for(build_v1.key), |
| step_container=build_pb2.Build(steps=steps), |
| ).put() |
| |
| mask = protoutil.Mask.from_field_mask( |
| field_mask_pb2.FieldMask(paths=['steps']), |
| build_pb2.Build.DESCRIPTOR, |
| ) |
| actual = api.builds_to_v2_async([build_v1], mask).get_result() |
| |
| self.assertEqual(len(actual), 1) |
| self.assertEqual(list(actual[0].steps), steps) |
| |
| |
| class GetBuildTests(BaseTestCase): |
| """Tests for GetBuild RPC.""" |
| |
| @mock.patch('service.get_async', autospec=True) |
| def test_by_id(self, get_async): |
| get_async.return_value = future(self.new_build_v1(id=54)) |
| req = rpc_pb2.GetBuildRequest(id=54) |
| res = self.call(self.api.GetBuild, req) |
| self.assertEqual(res.id, 54) |
| get_async.assert_called_once_with(54) |
| |
| @mock.patch('search.search_async', autospec=True) |
| def test_by_number(self, search_async): |
| build_v1 = self.new_build_v1( |
| bucket_id='chromium/try', |
| builder_name='linux-try', |
| tags=[ |
| buildtags.build_address_tag('luci.chromium.try', 'linux-try', 2), |
| ], |
| ) |
| search_async.return_value = future(([build_v1], None)) |
| builder_id = build_pb2.BuilderID( |
| project='chromium', bucket='try', builder='linux-try' |
| ) |
| req = rpc_pb2.GetBuildRequest(builder=builder_id, build_number=2) |
| res = self.call(self.api.GetBuild, req) |
| self.assertEqual(res.id, build_v1.key.id()) |
| self.assertEqual(res.builder, builder_id) |
| self.assertEqual(res.number, 2) |
| |
| search_async.assert_called_once_with( |
| search.Query( |
| bucket_ids=['chromium/try'], |
| tags=['build_address:luci.chromium.try/linux-try/2'], |
| ) |
| ) |
| |
| def test_not_found_by_id(self): |
| req = rpc_pb2.GetBuildRequest(id=54) |
| self.call(self.api.GetBuild, req, expected_code=prpc.StatusCode.NOT_FOUND) |
| |
| def test_not_found_by_number(self): |
| builder_id = build_pb2.BuilderID( |
| project='chromium', bucket='try', builder='linux-try' |
| ) |
| req = rpc_pb2.GetBuildRequest(builder=builder_id, build_number=2) |
| self.call(self.api.GetBuild, req, expected_code=prpc.StatusCode.NOT_FOUND) |
| |
| def test_empty_request(self): |
| req = rpc_pb2.GetBuildRequest() |
| self.call( |
| self.api.GetBuild, req, expected_code=prpc.StatusCode.INVALID_ARGUMENT |
| ) |
| |
| def test_id_with_number(self): |
| req = rpc_pb2.GetBuildRequest(id=1, build_number=1) |
| self.call( |
| self.api.GetBuild, req, expected_code=prpc.StatusCode.INVALID_ARGUMENT |
| ) |
| |
| |
| class SearchTests(BaseTestCase): |
| |
| @mock.patch('search.search_async', autospec=True) |
| def test_basic(self, search_async): |
| builds_v1 = [self.new_build_v1(id=54), self.new_build_v1(id=55)] |
| search_async.return_value = future((builds_v1, 'next page token')) |
| |
| req = rpc_pb2.SearchBuildsRequest( |
| predicate=rpc_pb2.BuildPredicate( |
| builder=build_pb2.BuilderID( |
| project='chromium', bucket='try', builder='linux-try' |
| ), |
| ), |
| page_size=10, |
| page_token='page token', |
| ) |
| res = self.call(self.api.SearchBuilds, req) |
| |
| search_async.assert_called_once_with( |
| search.Query( |
| bucket_ids=['chromium/try'], |
| tags=['builder:linux-try'], |
| include_experimental=False, |
| status=common_pb2.STATUS_UNSPECIFIED, |
| max_builds=10, |
| start_cursor='page token', |
| ) |
| ) |
| self.assertEqual(len(res.builds), 2) |
| self.assertEqual(res.builds[0].id, 54) |
| self.assertEqual(res.builds[1].id, 55) |
| self.assertEqual(res.next_page_token, 'next page token') |
| |
| |
| class UpdateBuildTests(BaseTestCase): |
| |
| def setUp(self): |
| super(UpdateBuildTests, self).setUp() |
| self.validate_build_token = self.patch( |
| 'v2.tokens.validate_build_token', |
| autospec=True, |
| return_value=None, |
| ) |
| |
| self.can_update_build_async = self.patch( |
| 'user.can_update_build_async', |
| autospec=True, |
| return_value=future(True), |
| ) |
| |
| def _mk_update_req(self, build, token='token'): |
| build_req = rpc_pb2.UpdateBuildRequest(build=build) |
| build_req.update_mask.paths[:] = ['build.steps'] |
| ctx = prpc_context.ServicerContext() |
| if token: |
| metadata = ctx.invocation_metadata() |
| metadata.append((api.BUILD_TOKEN_HEADER, token)) |
| return build_req, ctx |
| |
| @contextlib.contextmanager |
| def mock_build(self, build_id): |
| with mock.patch('service.get_async', autospec=True) as mock_get_async: |
| build = model.Build( |
| id=build_id, |
| status=model.BuildStatus.STARTED, |
| bucket_id='chromium/try', |
| created_by=auth.Identity('user', 'foo@google.com'), |
| create_time=utils.utcnow(), |
| start_time=utils.utcnow(), |
| parameters_actual=dict(), |
| ) |
| mock_get_async.return_value = future(build) |
| yield build |
| |
| def test_update_steps(self): |
| with self.mock_build(build_id=123) as build: |
| build_proto = build_pb2.Build(id=123) |
| with open(os.path.join(THIS_DIR, 'steps.pb.txt')) as f: |
| text = protoutil.parse_multiline(f.read()) |
| text_format.Merge(text, build_proto) |
| |
| req, ctx = self._mk_update_req(build_proto) |
| req.fields.paths[:] = ['id', 'steps'] |
| actual = self.call(self.api.UpdateBuild, req, ctx=ctx) |
| self.assertEqual(actual, build_proto) |
| |
| persisted = model.BuildSteps.key_for(build.key).get() |
| self.assertEqual(persisted.step_container.steps, build_proto.steps) |
| |
| def test_update_properties(self): |
| with self.mock_build(build_id=123) as build: |
| expected_props = {'a': 1} |
| build_steps = model.BuildSteps( |
| key=model.BuildSteps.key_for(build.key), |
| step_container=build_pb2.Build( |
| steps=[step_pb2.Step(name='bot_update')], |
| ), |
| ) |
| build_steps.put() |
| |
| build_proto = build_pb2.Build(id=123) |
| build_proto.output.properties.update(expected_props) |
| |
| req, ctx = self._mk_update_req(build_proto) |
| req.update_mask.paths[:] = ['build.output.properties'] |
| req.fields.paths[:] = ['id', 'steps', 'output.properties'] |
| actual = self.call(self.api.UpdateBuild, req, ctx=ctx) |
| expected = copy.deepcopy(build_proto) |
| expected.MergeFrom(build_steps.step_container) |
| self.assertEqual(actual, expected) |
| |
| build = build.key.get() |
| self.assertEqual( |
| build.result_details[model.PROPERTIES_PARAMETER], expected_props |
| ) |
| |
| def test_missing_token(self): |
| build = build_pb2.Build( |
| id=123, |
| status=common_pb2.STARTED, |
| ) |
| req, ctx = self._mk_update_req(build, token=None) |
| self.call( |
| self.api.UpdateBuild, |
| req, |
| ctx=ctx, |
| expected_code=prpc.StatusCode.UNAUTHENTICATED, |
| expected_details='missing token in build update request', |
| ) |
| |
| def test_invalid_token(self): |
| self.validate_build_token.side_effect = auth.InvalidTokenError |
| |
| build = build_pb2.Build( |
| id=123, |
| status=common_pb2.STARTED, |
| ) |
| |
| req, ctx = self._mk_update_req(build) |
| self.call( |
| self.api.UpdateBuild, |
| req, |
| ctx=ctx, |
| expected_code=prpc.StatusCode.UNAUTHENTICATED, |
| ) |
| |
| @mock.patch('v2.validation.validate_update_build_request', autospec=True) |
| def test_invalid_build_proto(self, mock_validation): |
| mock_validation.side_effect = validation.Error('invalid build proto') |
| |
| build = build_pb2.Build(id=123) |
| |
| req, ctx = self._mk_update_req(build) |
| self.call( |
| self.api.UpdateBuild, |
| req, |
| ctx=ctx, |
| expected_code=prpc.StatusCode.INVALID_ARGUMENT, |
| expected_details='invalid build proto', |
| ) |
| |
| @mock.patch('service.get_async', autospec=True) |
| def test_invalid_id(self, mock_get_async): |
| mock_get_async.return_value = future(None) |
| |
| build = build_pb2.Build( |
| id=123, |
| status=common_pb2.STARTED, |
| ) |
| |
| req, ctx = self._mk_update_req(build) |
| self.call( |
| self.api.UpdateBuild, |
| req, |
| ctx=ctx, |
| expected_code=prpc.StatusCode.NOT_FOUND, |
| expected_details='Cannot update nonexisting build with id 123', |
| ) |
| |
| def test_invalid_user(self): |
| self.can_update_build_async.return_value = future(False) |
| |
| build = build_pb2.Build( |
| id=123, |
| status=common_pb2.STARTED, |
| ) |
| |
| req, ctx = self._mk_update_req(build) |
| self.call( |
| self.api.UpdateBuild, |
| req, |
| ctx=ctx, |
| expected_code=prpc.StatusCode.PERMISSION_DENIED, |
| expected_details='user not permitted to update build', |
| ) |
| |
| |
| class BatchTests(BaseTestCase): |
| |
| @mock.patch('service.get_async', autospec=True) |
| @mock.patch('search.search_async', autospec=True) |
| def test_get_and_search(self, search_async, get_async): |
| search_async.return_value = future(([ |
| self.new_build_v1(id=1), |
| self.new_build_v1(id=2) |
| ], '')) |
| get_async.return_value = future(self.new_build_v1(id=3)) |
| |
| req = rpc_pb2.BatchRequest( |
| requests=[ |
| rpc_pb2.BatchRequest.Request( |
| search_builds=rpc_pb2.SearchBuildsRequest( |
| predicate=rpc_pb2.BuildPredicate( |
| builder=build_pb2.BuilderID( |
| project='chromium', |
| bucket='try', |
| builder='linux-rel', |
| ), |
| ), |
| ), |
| ), |
| rpc_pb2.BatchRequest.Request( |
| get_build=rpc_pb2.GetBuildRequest(id=3), |
| ), |
| ], |
| ) |
| res = self.call(self.api.Batch, req) |
| search_async.assert_called_once_with( |
| search.Query( |
| bucket_ids=['chromium/try'], |
| tags=['builder:linux-rel'], |
| status=common_pb2.STATUS_UNSPECIFIED, |
| include_experimental=False, |
| start_cursor='', |
| ), |
| ) |
| get_async.assert_called_once_with(3) |
| self.assertEqual(len(res.responses), 2) |
| self.assertEqual(len(res.responses[0].search_builds.builds), 2) |
| self.assertEqual(res.responses[0].search_builds.builds[0].id, 1L) |
| self.assertEqual(res.responses[0].search_builds.builds[1].id, 2L) |
| self.assertEqual(res.responses[1].get_build.id, 3L) |
| |
| @mock.patch('service.get_async', autospec=True) |
| def test_errors(self, get_async): |
| get_async.return_value = future(None) |
| |
| req = rpc_pb2.BatchRequest( |
| requests=[ |
| rpc_pb2.BatchRequest.Request( |
| get_build=rpc_pb2.GetBuildRequest(id=1), |
| ), |
| rpc_pb2.BatchRequest.Request(), |
| ], |
| ) |
| res = self.call(self.api.Batch, req) |
| self.assertEqual( |
| res, |
| rpc_pb2.BatchResponse( |
| responses=[ |
| rpc_pb2.BatchResponse.Response( |
| error=status_pb2.Status( |
| code=prpc.StatusCode.NOT_FOUND.value, |
| message='not found', |
| ), |
| ), |
| rpc_pb2.BatchResponse.Response( |
| error=status_pb2.Status( |
| code=prpc.StatusCode.INVALID_ARGUMENT.value, |
| message='request is not specified', |
| ), |
| ), |
| ] |
| ) |
| ) |
| |
| |
| class BuildPredicateToSearchQueryTests(BaseTestCase): |
| |
| def test_project(self): |
| predicate = rpc_pb2.BuildPredicate( |
| builder=build_pb2.BuilderID(project='chromium'), |
| ) |
| q = api.build_predicate_to_search_query(predicate) |
| self.assertEqual(q.project, 'chromium') |
| self.assertFalse(q.bucket_ids) |
| self.assertFalse(q.tags) |
| |
| def test_project_bucket(self): |
| predicate = rpc_pb2.BuildPredicate( |
| builder=build_pb2.BuilderID(project='chromium', bucket='try'), |
| ) |
| q = api.build_predicate_to_search_query(predicate) |
| self.assertFalse(q.project) |
| self.assertEqual(q.bucket_ids, ['chromium/try']) |
| self.assertFalse(q.tags) |
| |
| def test_project_bucket_builder(self): |
| predicate = rpc_pb2.BuildPredicate( |
| builder=build_pb2.BuilderID( |
| project='chromium', bucket='try', builder='linux-rel' |
| ), |
| ) |
| q = api.build_predicate_to_search_query(predicate) |
| self.assertFalse(q.project) |
| self.assertEqual(q.bucket_ids, ['chromium/try']) |
| self.assertEqual(q.tags, ['builder:linux-rel']) |
| |
| def test_create_time(self): |
| predicate = rpc_pb2.BuildPredicate() |
| predicate.create_time.start_time.FromDatetime(datetime.datetime(2018, 1, 1)) |
| predicate.create_time.end_time.FromDatetime(datetime.datetime(2018, 1, 2)) |
| q = api.build_predicate_to_search_query(predicate) |
| self.assertEqual(q.create_time_low, datetime.datetime(2018, 1, 1)) |
| self.assertEqual(q.create_time_high, datetime.datetime(2018, 1, 2)) |
| |
| def test_build_range(self): |
| predicate = rpc_pb2.BuildPredicate( |
| build=rpc_pb2.BuildRange(start_build_id=100, end_build_id=90), |
| ) |
| q = api.build_predicate_to_search_query(predicate) |
| self.assertEqual(q.build_low, 100) |
| self.assertEqual(q.build_high, 90) |