# Copyright 2019 Intel, Inc.
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

from http import HTTPStatus
import unittest
from unittest import mock

from oslo_serialization import jsonutils
from oslo_utils.fixture import uuidsentinel as uuids

from cyborg.api.controllers import base
from cyborg.api.controllers.v2 import arqs
from cyborg.common import exception
from cyborg import context as cyborg_context
from cyborg.tests.unit.api.controllers.v2 import base as v2_test
from cyborg.tests.unit import fake_device_profile
from cyborg.tests.unit import fake_extarq


class TestARQsController(v2_test.APITestV2):

    ARQ_URL = '/accelerator_requests'

    def setUp(self):
        super().setUp()
        self.context.project_id = str(uuids.project_id)
        self.headers = self.gen_headers(self.context)
        self.fake_extarqs = fake_extarq.get_fake_extarq_objs()
        self.fake_bind_extarqs = fake_extarq.get_fake_extarq_bind_objs()
        self.fake_resolved_extarqs = (
            fake_extarq.get_fake_extarq_resolved_objs())
        self.arqs_controller = arqs.ARQsController()

    def _validate_links(self, links, arq_uuid):
        has_self_link = False
        for link in links:
            if link['rel'] == 'self':
                has_self_link = True
                url = link['href']
                components = url.split('/')
                self.assertEqual(components[-1], arq_uuid)
        self.assertTrue(has_self_link)

    def _validate_arq(self, in_arq, out_arq):
        for field in in_arq.keys():
            if field != 'id':
                self.assertEqual(in_arq[field], out_arq[field])

        # Check that the link is properly set up
        self._validate_links(out_arq['links'], in_arq['uuid'])

    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_get_one_by_uuid(self, mock_extarq):
        in_extarq = self.fake_extarqs[0]
        in_arq = in_extarq.arq
        mock_extarq.return_value = in_extarq
        uuid = in_arq['uuid']

        url = self.ARQ_URL + '/%s'
        out_arq = self.get_json(url % uuid, headers=self.headers)
        mock_extarq.assert_called_once()
        self._validate_arq(in_arq, out_arq)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all(self, mock_extarqs):
        # test get_all of any bind_state
        mock_extarqs.return_value = self.fake_extarqs
        data = self.get_json(self.ARQ_URL, headers=self.headers)
        out_arqs = data['arqs']

        result = isinstance(out_arqs, list)
        self.assertTrue(result)
        self.assertEqual(len(out_arqs), len(self.fake_extarqs))
        for in_extarq, out_arq in zip(self.fake_extarqs, out_arqs):
            self._validate_arq(in_extarq.arq, out_arq)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all_with_instance(self, mock_extarqs):
        # test get_all with instance
        mock_extarqs.return_value = self.fake_bind_extarqs
        instance_uuid = self.fake_bind_extarqs[0].arq.instance_uuid
        url = '%s?instance=%s' % (self.ARQ_URL, instance_uuid)
        data = self.get_json(url, headers=self.headers)
        out_arqs = data['arqs']

        result = isinstance(out_arqs, list)
        self.assertTrue(result)
        self.assertEqual(len(out_arqs), len(self.fake_bind_extarqs[:3]))
        for in_extarq, out_arq in zip(self.fake_bind_extarqs[:2], out_arqs):
            self._validate_arq(in_extarq.arq, out_arq)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all_with_bind_state(self, mock_extarqs):
        # test get_all with valid bind_state(resolved)
        mock_extarqs.return_value = self.fake_resolved_extarqs
        url = '%s?bind_state=resolved' % self.ARQ_URL
        data = self.get_json(url, headers=self.headers)
        out_arqs = data['arqs']

        result = isinstance(out_arqs, list)
        self.assertTrue(result)
        self.assertEqual(len(out_arqs), len(self.fake_resolved_extarqs[1:]))
        for in_extarq, out_arq in zip(self.fake_resolved_extarqs[1:],
                                      out_arqs):
            self._validate_arq(in_extarq.arq, out_arq)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all_with_instance_and_bind_state(self, mock_extarqs):
        # test get_all with instance and valid bind_state(resolved)
        mock_extarqs.return_value = self.fake_bind_extarqs[:3]
        instance_uuid = self.fake_bind_extarqs[0].arq.instance_uuid
        url = '%s?instance=%s&bind_state=resolved' % (
            self.ARQ_URL, instance_uuid)
        data = self.get_json(url, headers=self.headers)
        out_arqs = data['arqs']

        result = isinstance(out_arqs, list)
        self.assertTrue(result)
        self.assertEqual(len(out_arqs), len(self.fake_bind_extarqs[:2]))
        for in_extarq, out_arq in zip(self.fake_bind_extarqs[:2], out_arqs):
            self._validate_arq(in_extarq.arq, out_arq)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all_with_http_client_LOCKED(self, mock_extarqs):
        # test get_all if not all ARQs are in bound state
        mock_extarqs.return_value = self.fake_bind_extarqs
        instance_uuid = self.fake_bind_extarqs[0].arq.instance_uuid
        url = '%s?instance=%s&bind_state=resolved' % (
            self.ARQ_URL, instance_uuid)
        try:
            self.get_json(url, headers=self.headers)
        except Exception as e:
            exc = e
        self.assertIn('423 Locked', exc.args[0])

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all_with_invalid_bind_state(self, mock_extarqs):
        # test get_all with bind_state=started
        mock_extarqs.return_value = self.fake_extarqs
        instance_uuid = self.fake_extarqs[0].arq.instance_uuid
        url = '%s?instance=%s&bind_state=started' % (
            self.ARQ_URL, instance_uuid)
        exc = None
        try:
            self.get_json(url, headers=self.headers)
        except Exception as e:
            exc = e
        # TODO(all) Cyborg does not have fake HTTPRequest Object now, so just
        # use assertIn here, improve this case with assertRaises later.
        self.assertIn(
            "Bad state: started for ARQ: None. Expected state(s): "
            "[\\\'resolved\\\']", exc.args[0])

        url = '%s?bind_state=started' % (self.ARQ_URL)
        exc = None
        try:
            self.get_json(url, headers=self.headers)
        except Exception as e:
            exc = e
        # TODO(all) Cyborg does not have fake HTTPRequest Object now, so just
        # use assertIn here, improve this case with assertRaises later.
        self.assertIn(
            "Bad state: started for ARQ: None. Expected state(s): "
            "[\\\'resolved\\\']", exc.args[0])

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_get_all_with_invalid_arq_state(self, mock_extarqs):
        # test get_all response "423 Locked"
        # set ARQ state to 'BindStarted'
        self.fake_extarqs[0].arq.state = 'BindStarted'
        mock_extarqs.return_value = self.fake_extarqs
        instance_uuid = self.fake_extarqs[0].arq.instance_uuid
        url = '%s?instance=%s&bind_state=resolved' % (
            self.ARQ_URL, instance_uuid)
        response = self.get_json(url, headers=self.headers, expect_errors=True)
        self.assertEqual(HTTPStatus.LOCKED, response.status_int)

    @mock.patch('cyborg.objects.DeviceProfile.get_by_name')
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_create')
    def test_create(self, mock_obj_extarq, mock_obj_dp):
        dp_list = fake_device_profile.get_obj_devprofs()
        mock_obj_dp.return_value = dp = dp_list[0]
        mock_obj_extarq.side_effect = self.fake_extarqs
        params = {'device_profile_name': dp['name']}
        response = self.post_json(self.ARQ_URL, params, headers=self.headers)
        data = jsonutils.loads(response.__dict__['controller_output'])
        out_arqs = data['arqs']

        self.assertEqual(HTTPStatus.CREATED, response.status_int)
        self.assertEqual(len(out_arqs), 3)
        for in_extarq, out_arq in zip(self.fake_extarqs, out_arqs):
            self._validate_arq(in_extarq.arq, out_arq)
        for idx, out_arq in enumerate(out_arqs):
            dp_group_id = idx
            self.assertEqual(dp_group_id, out_arq['device_profile_group_id'])

    @mock.patch('cyborg.objects.DeviceProfile.get_by_name')
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_create')
    def test_create_with_xilinx_fpga(self, mock_obj_extarq, mock_obj_dp):
        xilinx_fpga_dp = fake_device_profile.get_xilinx_fpga_devprof()
        mock_obj_dp.return_value = dp = xilinx_fpga_dp
        fake_xilinx_fpga_objs = fake_extarq.get_fake_xilinx_fpga_extarq_objs()
        mock_obj_extarq.side_effect = fake_xilinx_fpga_objs
        params = {'device_profile_name': dp['name']}
        response = self.post_json(self.ARQ_URL, params, headers=self.headers)
        data = jsonutils.loads(response.__dict__['controller_output'])
        out_arqs = data['arqs']

        self.assertEqual(HTTPStatus.CREATED, response.status_int)
        self.assertEqual(len(out_arqs), 2)
        for in_extarq, out_arq in zip(fake_xilinx_fpga_objs, out_arqs):
            self._validate_arq(in_extarq.arq, out_arq)
        for idx, out_arq in enumerate(out_arqs):
            dp_group_id = idx
            self.assertEqual(dp_group_id, out_arq['device_profile_group_id'])

    @mock.patch('cyborg.objects.DeviceProfile.get_by_name')
    @mock.patch('cyborg.objects.ExtARQ.create')
    def test_create_with_wrong_dp(self, mock_obj_extarq, mock_obj_dp):
        params = {'device_profile_name': 'wrong_device_profile_name'}
        mock_obj_dp.side_effect = exception.ResourceNotFound(
            resource='Device Profile',
            msg='with name=%s' % params.get('device_profile_name'))
        mock_obj_extarq.side_effect = self.fake_extarqs
        exc = None
        try:
            self.post_json(self.ARQ_URL, params, headers=self.headers)
        except Exception as e:
            exc = e
        self.assertIn(
            "Device Profile not found with "
            "name=wrong_device_profile_name", exc.args[0])

    @mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
    @mock.patch('cyborg.objects.ExtARQ.get')
    @mock.patch('cyborg.common.service_token_utils.is_service_request')
    def test_delete_by_arq(self, mock_is_svc, mock_get, mock_delete):
        mock_is_svc.return_value = True
        url = self.ARQ_URL
        arq = self.fake_extarqs[0].arq
        mock_get.return_value = self.fake_extarqs[0]
        args = '?' + "arqs=" + str(arq['uuid'])
        response = self.delete(url + args, headers=self.headers)
        self.assertEqual(HTTPStatus.NO_CONTENT, response.status_int)

    @mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
    @mock.patch('cyborg.common.service_token_utils.is_service_request')
    def test_delete_by_instance(self, mock_is_svc, mock_delete):
        mock_is_svc.return_value = True
        url = self.ARQ_URL
        arq = self.fake_extarqs[0].arq
        instance = arq.instance_uuid
        args = '?' + "instance=" + instance
        response = self.delete(url + args, headers=self.headers)
        self.assertEqual(HTTPStatus.NO_CONTENT, response.status_int)

    @unittest.skip("Need more code to implement _get_resource in rbac")
    def test_delete_with_non_default(self):
        value = {"is_admin": False, "roles": "user", "is_admin_project": False}
        ct = self.gen_context(value)
        headers = self.gen_headers(ct)
        url = self.ARQ_URL
        arq = self.fake_extarqs[0].arq
        args = '?' + "arqs=" + str(arq['uuid'])
        exc = None
        try:
            self.delete(url + args, headers=headers)
        except Exception as e:
            exc = e
        # Cyborg does not raise different exception when policy check failed
        # now, improve this case with assertRaises later.
        self.assertIn("Bad response: 403 Forbidden", exc.args[0])

    @mock.patch(
        'cyborg.api.controllers.v2.arqs.service_token_utils.is_service_request'
    )
    @mock.patch.object(arqs.ARQsController, '_check_if_already_bound')
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
    def test_apply_patch(
        self, mock_apply_patch, mock_check_if_bound, mock_is_svc
    ):
        """Test the happy path for ARQ bind (patch)."""
        mock_is_svc.return_value = True
        patch_list, device_rp_uuid = fake_extarq.get_patch_list()
        arq_uuids = list(patch_list.keys())
        obj_extarq = self.fake_extarqs[0]
        valid_fields = {
            arq_uuid: {
                'hostname': obj_extarq.arq.hostname,
                'device_rp_uuid': device_rp_uuid,
                'instance_uuid': obj_extarq.arq.instance_uuid,
                'project_id': self.context.project_id,
            }
            for arq_uuid in arq_uuids
        }

        self.patch_json(self.ARQ_URL, params=patch_list,
                        headers=self.headers)

        mock_apply_patch.assert_called_once_with(mock.ANY, patch_list,
                                                 valid_fields)
        mock_check_if_bound.assert_called_once_with(mock.ANY, valid_fields)

    @mock.patch(
        'cyborg.api.controllers.v2.arqs.service_token_utils.is_service_request'
    )
    @mock.patch.object(arqs.ARQsController, '_check_if_already_bound')
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
    def test_apply_patch_allow_project_id(
        self, mock_apply_patch, mock_check_if_bound, mock_is_svc
    ):
        mock_is_svc.return_value = True
        patch_list, _ = fake_extarq.get_patch_list()
        explicit_pid = str(uuids.explicit_project)
        for arq_uuid, patch in patch_list.items():
            patch.append(
                {
                    'path': '/project_id',
                    'op': 'add',
                    'value': explicit_pid,
                }
            )
        arq_uuids = list(patch_list.keys())
        valid_fields = {
            arq_uuid: {
                'hostname': 'myhost',
                'device_rp_uuid': 'fb16c293-5739-4c84-8590-926f9ab16669',
                'instance_uuid': '5922a70f-1e06-4cfd-88dd-a332120d7144',
                'project_id': explicit_pid,
            }
            for arq_uuid in arq_uuids
        }

        # Admin headers with v2.1 microversion to allow
        # setting a different project_id
        headers = self.gen_headers(self.context)
        headers[base.Version.current_api_version] = '2.1'
        self.patch_json(
            self.ARQ_URL,
            params=patch_list,
            headers=headers,
        )
        mock_apply_patch.assert_called_once_with(
            mock.ANY, patch_list, valid_fields
        )
        mock_check_if_bound.assert_called_once_with(mock.ANY, valid_fields)

    def test_apply_patch_not_allow_project_id(self):
        patch_list, _ = fake_extarq.get_patch_list()
        for arq_uuid, patch in patch_list.items():
            patch.append({'path': '/project_id', 'op': 'add',
                          'value': 'b1c76756ac2e482789a8e1c5f4bf065e'})
        response = self.patch_json(self.ARQ_URL, params=patch_list,
                                   headers=self.headers,
                                   expect_errors=True)
        self.assertEqual(HTTPStatus.NOT_ACCEPTABLE, response.status_code)
        self.assertTrue(response.json['error_message'])

    # TODO(all): Add exception test cases for apply_patch.

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_check_if_bound(self, mock_extarq_list):
        """Test the happy path."""
        extarqs = fake_extarq.get_fake_extarq_objs()
        mock_extarq_list.return_value = extarqs

        # Not the instance UUID in extarqs above
        instance_uuid = 'ffbb66f6-99f6-4a85-a90c-fd8e8fb35f16'
        valid_fields = {
            extarq.arq['uuid']: {
                'hostname': 'myhost',
                'device_rp_uuid': 'fb16c293-5739-4c84-8590-926f9ab16669',
                'instance_uuid': instance_uuid}
            for extarq in extarqs}

        self.arqs_controller._check_if_already_bound(
            self.context, valid_fields)
        mock_extarq_list.assert_called_once_with(self.context)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_check_if_bound_exception(self, mock_extarq_list):
        """Test that an exception is raised if binding request specifies
           an instance that already has ARQs.
        """
        extarqs = fake_extarq.get_fake_extarq_objs()
        mock_extarq_list.return_value = extarqs

        # Same instance UUID as in extarqs above, thus triggering exception
        instance_uuid = extarqs[0].arq['instance_uuid']
        valid_fields = {
            extarq.arq['uuid']: {
                'hostname': 'myhost',
                'device_rp_uuid': 'fb16c293-5739-4c84-8590-926f9ab16669',
                'instance_uuid': instance_uuid}
            for extarq in extarqs}

        expected_err = ('Instance %s already has accelerator requests. '
                        'Cannot bind additional ARQs.') % instance_uuid

        self.assertRaisesRegex(
            exception.PatchError, expected_err,
            self.arqs_controller._check_if_already_bound,
            self.context,
            valid_fields,
        )


class TestARQProjectIdOnCreate(v2_test.APITestV2):
    """Tests for Bug #2144056: project_id must be set from context."""

    ARQ_URL = '/accelerator_requests'

    def setUp(self):
        super().setUp()
        self.fake_extarqs = fake_extarq.get_fake_extarq_objs()

    def _make_member_context(self, project_id=None):
        pid = project_id or str(uuids.member_project)
        return cyborg_context.RequestContext(
            user_id=str(uuids.member_user),
            project_id=pid,
            is_admin=False,
        )

    def _make_admin_context(self, project_id=None):
        pid = project_id or str(uuids.admin_project)
        return cyborg_context.RequestContext(
            user_id=str(uuids.admin_user),
            project_id=pid,
            is_admin=True,
        )

    def _make_bind_patch(self, extra_entries=None):
        """Build a standard bind patch list."""
        patch = [
            {'path': '/hostname', 'op': 'add', 'value': 'host1'},
            {
                'path': '/device_rp_uuid',
                'op': 'add',
                'value': str(uuids.device_rp),
            },
            {
                'path': '/instance_uuid',
                'op': 'add',
                'value': str(uuids.instance),
            },
        ]
        if extra_entries:
            patch.extend(extra_entries)
        return patch

    @mock.patch('cyborg.objects.DeviceProfile.get_by_name')
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_create')
    def test_post_sets_project_id_from_context(
        self, mock_arq_create, mock_get_dp
    ):
        dp_list = fake_device_profile.get_obj_devprofs()
        mock_get_dp.return_value = dp_list[0]
        mock_arq_create.side_effect = self.fake_extarqs

        member_ctx = self._make_member_context()
        headers = self.gen_headers(member_ctx)
        headers['X-Roles'] = 'member'
        params = {'device_profile_name': dp_list[0]['name']}
        self.post_json(self.ARQ_URL, params, headers=headers)

        for call_args in mock_arq_create.call_args_list:
            obj_extarq = call_args[0][1]
            self.assertEqual(member_ctx.project_id, obj_extarq.arq.project_id)

    @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id')
    def test_validate_arq_patch_defaults_project_id_v20(self, mock_allow):
        """At API v2.0, project_id is always set from context."""
        mock_allow.return_value = False
        controller = arqs.ARQsController()
        member_ctx = self._make_member_context()
        mock_request = mock.MagicMock()
        mock_request.context = member_ctx
        mock_request.version.minor = 0
        with mock.patch.object(arqs, 'pecan') as mock_pecan:
            mock_pecan.request = mock_request
            result = controller._validate_arq_patch(self._make_bind_patch())
        self.assertEqual(member_ctx.project_id, result['project_id'])

    @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id')
    def test_validate_arq_patch_defaults_project_id_v21_no_explicit(
        self, mock_allow
    ):
        """At API v2.1, project_id defaults to context when absent."""
        mock_allow.return_value = True
        controller = arqs.ARQsController()
        member_ctx = self._make_member_context()
        mock_request = mock.MagicMock()
        mock_request.context = member_ctx
        mock_request.version.minor = 1
        with mock.patch.object(arqs, 'pecan') as mock_pecan:
            mock_pecan.request = mock_request
            result = controller._validate_arq_patch(self._make_bind_patch())
        self.assertEqual(member_ctx.project_id, result['project_id'])

    @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id')
    def test_validate_arq_patch_v21_admin_can_set_different_project_id(
        self, mock_allow
    ):
        """Admin can set a project_id that differs from token."""
        mock_allow.return_value = True
        controller = arqs.ARQsController()
        other_project = str(uuids.other_project)
        patch = self._make_bind_patch(
            [
                {'path': '/project_id', 'op': 'add', 'value': other_project},
            ]
        )
        admin_ctx = self._make_admin_context()
        mock_request = mock.MagicMock()
        mock_request.context = admin_ctx
        mock_request.version.minor = 1
        with mock.patch.object(arqs, 'pecan') as mock_pecan:
            mock_pecan.request = mock_request
            result = controller._validate_arq_patch(patch)
        self.assertEqual(other_project, result['project_id'])

    @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id')
    def test_validate_arq_patch_v21_non_admin_cannot_spoof_project_id(
        self, mock_allow
    ):
        """Non-admin cannot set a different project_id (spoofing)."""
        mock_allow.return_value = True
        controller = arqs.ARQsController()
        other_project = str(uuids.other_project)
        patch = self._make_bind_patch(
            [
                {'path': '/project_id', 'op': 'add', 'value': other_project},
            ]
        )
        member_ctx = self._make_member_context()
        mock_request = mock.MagicMock()
        mock_request.context = member_ctx
        mock_request.version.minor = 1
        with mock.patch.object(arqs, 'pecan') as mock_pecan:
            mock_pecan.request = mock_request
            self.assertRaises(
                exception.HTTPForbidden,
                controller._validate_arq_patch,
                patch,
            )

    @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id')
    def test_validate_arq_patch_v21_non_admin_rejects_project_id(
        self, mock_allow
    ):
        """Non-admin cannot include project_id in a patch."""
        mock_allow.return_value = True
        controller = arqs.ARQsController()
        member_ctx = self._make_member_context()
        patch = self._make_bind_patch(
            [
                {
                    'path': '/project_id',
                    'op': 'add',
                    'value': member_ctx.project_id,
                },
            ]
        )
        mock_request = mock.MagicMock()
        mock_request.context = member_ctx
        mock_request.version.minor = 1
        with mock.patch.object(arqs, 'pecan') as mock_pecan:
            mock_pecan.request = mock_request
            self.assertRaises(
                exception.HTTPForbidden,
                controller._validate_arq_patch,
                patch,
            )


class TestARQProjectIsolation(v2_test.APITestV2):
    """Tests for Bug #2144056: project-scoped ARQ access control.

    The authorize_wsgi decorator uses need_target=False for ARQ
    endpoints because ARQsController has no _get_resource method.
    With need_target=True and no _get_resource the policy target is
    an empty dict, causing ``project_id:%(project_id)s`` to never
    match — which blocks ALL non-admin users, even for their own
    ARQs.  With need_target=False the target is populated from the
    request context so the role check passes; cross-project
    isolation is then enforced in the object/DB layer via
    project_id filtering.
    """

    ARQ_URL = '/accelerator_requests'

    def setUp(self):
        super().setUp()
        self.fake_extarqs = fake_extarq.get_fake_extarq_objs()

    def _member_headers(self, project_id):
        headers = self.gen_headers(
            cyborg_context.RequestContext(
                user_id=str(uuids.member_user),
                project_id=project_id,
                is_admin=False,
            )
        )
        headers['X-Roles'] = 'member'
        return headers

    def _admin_headers(self):
        return self.gen_headers(self.context)

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_list_as_member_returns_only_own_project(self, mock_list):
        mock_list.return_value = self.fake_extarqs[:2]
        headers = self._member_headers(str(uuids.project_a))
        data = self.get_json(self.ARQ_URL, headers=headers)
        out_arqs = data['arqs']
        self.assertEqual(2, len(out_arqs))
        mock_list.assert_called_once()

    @mock.patch('cyborg.objects.ExtARQ.list')
    def test_list_as_admin_returns_all_projects(self, mock_list):
        mock_list.return_value = self.fake_extarqs
        headers = self._admin_headers()
        data = self.get_json(self.ARQ_URL, headers=headers)
        out_arqs = data['arqs']
        self.assertEqual(len(self.fake_extarqs), len(out_arqs))

    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_get_one_own_project_succeeds(self, mock_get):
        extarq = self.fake_extarqs[0]
        mock_get.return_value = extarq
        headers = self._member_headers(str(uuids.project_a))
        uuid = extarq.arq['uuid']
        out = self.get_json(self.ARQ_URL + '/%s' % uuid, headers=headers)
        self.assertEqual(uuid, out['uuid'])

    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_get_one_other_project_returns_404(self, mock_get):
        mock_get.side_effect = exception.ResourceNotFound(
            resource='ExtArq', msg='not found'
        )
        headers = self._member_headers(str(uuids.project_b))
        uuid = self.fake_extarqs[0].arq['uuid']
        response = self.get_json(
            self.ARQ_URL + '/%s' % uuid,
            headers=headers,
            expect_errors=True,
            return_json=False,
        )
        self.assertEqual(404, response.status_int)

    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_get_one_as_admin_any_project(self, mock_get):
        extarq = self.fake_extarqs[0]
        mock_get.return_value = extarq
        headers = self._admin_headers()
        uuid = extarq.arq['uuid']
        out = self.get_json(self.ARQ_URL + '/%s' % uuid, headers=headers)
        self.assertEqual(uuid, out['uuid'])


class TestARQServiceTokenProtection(v2_test.APITestV2):
    """Tests for Bug #2144056: service token requirement for bound ARQs.

    Binding (add), unbinding (remove), and deleting a bound ARQ all
    require a service token so that only Nova can perform these
    operations on behalf of the user.
    """

    ARQ_URL = '/accelerator_requests'

    def setUp(self):
        super().setUp()
        resolved = fake_extarq.get_fake_extarq_resolved_objs()
        self.unbound_extarq = resolved[0]
        self.bound_extarq = resolved[1]

    def _member_headers(self):
        headers = self.gen_headers(
            cyborg_context.RequestContext(
                user_id=str(uuids.member_user),
                project_id=str(uuids.member_project),
                is_admin=False,
            )
        )
        headers['X-Roles'] = 'member'
        return headers

    def _service_token_headers(self):
        headers = self._member_headers()
        headers['X-Service-Roles'] = 'service'
        headers['X-Service-Token'] = 'fake-service-token'
        return headers

    # -- DELETE tests ------------------------------------------------

    @mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_delete_bound_arq_without_service_token_rejected(
        self, mock_get, mock_delete
    ):
        mock_get.return_value = self.bound_extarq
        headers = self._member_headers()
        uuid = self.bound_extarq.arq['uuid']
        response = self.delete(
            self.ARQ_URL + '?arqs=%s' % uuid,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
        mock_delete.assert_not_called()

    @mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_delete_bound_arq_with_service_token_succeeds(
        self, mock_get, mock_delete
    ):
        mock_get.return_value = self.bound_extarq
        headers = self._service_token_headers()
        uuid = self.bound_extarq.arq['uuid']
        response = self.delete(
            self.ARQ_URL + '?arqs=%s' % uuid,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(204, response.status_int)

    @mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
    @mock.patch('cyborg.objects.ExtARQ.get')
    def test_delete_unbound_arq_without_service_token_succeeds(
        self, mock_get, mock_delete
    ):
        mock_get.return_value = self.unbound_extarq
        headers = self._member_headers()
        uuid = self.unbound_extarq.arq['uuid']
        response = self.delete(
            self.ARQ_URL + '?arqs=%s' % uuid,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(204, response.status_int)

    @mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
    def test_delete_by_instance_without_service_token_rejected(
        self, mock_delete
    ):
        headers = self._member_headers()
        response = self.delete(
            self.ARQ_URL + '?instance=%s' % str(uuids.instance1),
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
        mock_delete.assert_not_called()

    @mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
    def test_delete_by_instance_with_service_token_succeeds(self, mock_delete):
        headers = self._service_token_headers()
        response = self.delete(
            self.ARQ_URL + '?instance=%s' % str(uuids.instance1),
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(204, response.status_int)

    @mock.patch('cyborg.common.service_token_utils.is_service_request')
    @mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
    def test_service_token_check_not_bypassable_by_policy(
        self, mock_delete, mock_is_service
    ):
        """Even with admin role, bound ARQ delete requires service token."""
        mock_is_service.return_value = False
        headers = self.gen_headers(self.context)
        response = self.delete(
            self.ARQ_URL + '?instance=%s' % str(uuids.instance1),
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
        mock_delete.assert_not_called()

    # -- PATCH (bind / unbind) tests ---------------------------------

    @mock.patch(
        'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
    )
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
    def test_bind_without_service_token_rejected(
        self, mock_apply, mock_validate
    ):
        """Binding (setting instance_uuid) requires a service token."""
        mock_validate.return_value = {
            'hostname': 'fake-host',
            'device_rp_uuid': str(uuids.device_rp),
            'instance_uuid': str(uuids.instance1),
        }
        arq_uuid = self.unbound_extarq.arq['uuid']
        patch_list = {
            arq_uuid: [
                {'path': '/hostname', 'op': 'add', 'value': 'fake-host'},
                {
                    'path': '/device_rp_uuid',
                    'op': 'add',
                    'value': str(uuids.device_rp),
                },
                {
                    'path': '/instance_uuid',
                    'op': 'add',
                    'value': str(uuids.instance1),
                },
            ]
        }
        headers = self._member_headers()
        headers['Content-Type'] = 'application/json'
        response = self.patch_json(
            self.ARQ_URL,
            params=patch_list,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
        mock_apply.assert_not_called()

    @mock.patch(
        'cyborg.api.controllers.v2.arqs.ARQsController._check_if_already_bound'
    )
    @mock.patch(
        'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
    )
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
    def test_bind_with_service_token_succeeds(
        self, mock_apply, mock_validate, mock_check_bound
    ):
        mock_validate.return_value = {
            'hostname': 'fake-host',
            'device_rp_uuid': str(uuids.device_rp),
            'instance_uuid': str(uuids.instance1),
        }
        arq_uuid = self.unbound_extarq.arq['uuid']
        patch_list = {
            arq_uuid: [
                {'path': '/hostname', 'op': 'add', 'value': 'fake-host'},
                {
                    'path': '/device_rp_uuid',
                    'op': 'add',
                    'value': str(uuids.device_rp),
                },
                {
                    'path': '/instance_uuid',
                    'op': 'add',
                    'value': str(uuids.instance1),
                },
            ]
        }
        headers = self._service_token_headers()
        headers['Content-Type'] = 'application/json'
        response = self.patch_json(
            self.ARQ_URL,
            params=patch_list,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(202, response.status_int)

    @mock.patch(
        'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
    )
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
    def test_unbind_without_service_token_rejected(
        self, mock_apply, mock_validate
    ):
        mock_validate.return_value = {
            'hostname': None,
            'device_rp_uuid': None,
            'instance_uuid': None,
        }
        arq_uuid = self.bound_extarq.arq['uuid']
        patch_list = {
            arq_uuid: [
                {'path': '/hostname', 'op': 'remove', 'value': ''},
                {'path': '/device_rp_uuid', 'op': 'remove', 'value': ''},
                {'path': '/instance_uuid', 'op': 'remove', 'value': ''},
            ]
        }
        headers = self._member_headers()
        headers['Content-Type'] = 'application/json'
        response = self.patch_json(
            self.ARQ_URL,
            params=patch_list,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
        mock_apply.assert_not_called()

    @mock.patch(
        'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
    )
    @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
    def test_unbind_with_service_token_succeeds(
        self, mock_apply, mock_validate
    ):
        mock_validate.return_value = {
            'hostname': None,
            'device_rp_uuid': None,
            'instance_uuid': None,
        }
        arq_uuid = self.bound_extarq.arq['uuid']
        patch_list = {
            arq_uuid: [
                {'path': '/hostname', 'op': 'remove', 'value': ''},
                {'path': '/device_rp_uuid', 'op': 'remove', 'value': ''},
                {'path': '/instance_uuid', 'op': 'remove', 'value': ''},
            ]
        }
        headers = self._service_token_headers()
        headers['Content-Type'] = 'application/json'
        response = self.patch_json(
            self.ARQ_URL,
            params=patch_list,
            headers=headers,
            expect_errors=True,
        )
        self.assertEqual(202, response.status_int)
