diff --git a/.gitignore b/.gitignore index 257b0897da..5d8f12f2c9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,26 @@ test.err test.out !/.devcontainer/**/* +# Node modules and package files +node_modules/ +package-lock.json +package.json + +# Claude Code local files +.claude/ +.CLAUDE.md + +# Test optimization working files +test_optimization_tools/ +*_analysis*.md +AUTONOMOUS_*.md +PROFILING_*.md +QUICK_STATUS.md +REVIEW_*.md +SESSION_*.md +OPTION_*.md +CI_*.md + #DOCS docs/v2/index.html diff --git a/spec/request/service_instances_create_spec.rb b/spec/request/service_instances_create_spec.rb new file mode 100644 index 0000000000..df766216e3 --- /dev/null +++ b/spec/request/service_instances_create_spec.rb @@ -0,0 +1,876 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'V3 service instances - Create' do + include_context 'service instances setup' + + describe 'POST /v3/service_instances' do + let(:api_call) { ->(user_headers) { post '/v3/service_instances', request_body.to_json, user_headers } } + let(:space_guid) { space.guid } + + let(:name) { Sham.name } + let(:type) { 'user-provided' } + let(:request_body_additions) { {} } + let(:request_body) do + { + type: type, + name: name, + relationships: { + space: { + data: { + guid: space_guid + } + } + } + }.merge(request_body_additions) + end + + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + + context 'permissions' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) { responses_for_space_restricted_create_endpoint(success_code: 201) } + end + + it_behaves_like 'permissions for create endpoint when organization is suspended', 201 + end + + context 'when service_instance_creation flag is disabled' do + before do + VCAP::CloudController::FeatureFlag.create(name: 'service_instance_creation', enabled: false) + end + + it 'makes non_admins unable to create any type of service' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Feature Disabled: service_instance_creation', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + }) + ) + end + + it 'does not impact admins ability create services' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(201) + end + end + + context 'when the request body is invalid' do + let(:request_body) { { type: 'foo' } } + + it 'says the message is unprocessable' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "Relationships 'relationships' is not an object, Type must be one of 'managed', 'user-provided', Name must be a string, Name can't be blank", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'when the space is not readable' do + it 'fails saying the space cannot be found' do + request_body[:relationships][:space][:data][:guid] = VCAP::CloudController::Space.make.guid + + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid space. Ensure that the space exists and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'user-provided service instance' do + let(:request_body) do + { + type: type, + name: name, + relationships: { + space: { + data: { + guid: space_guid + } + } + }, + credentials: { + foo: 'bar', + baz: 'qux' + }, + tags: %w[foo bar baz], + syslog_drain_url: 'https://syslog.com/drain', + route_service_url: 'https://route.com/service', + metadata: { + annotations: { + foo: 'bar' + }, + labels: { + baz: 'qux' + } + } + } + end + + it 'responds with the created object' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(201) + expect(parsed_response).to match_json_response( + create_user_provided_json( + VCAP::CloudController::ServiceInstance.last, + labels: { baz: 'qux' }, + annotations: { foo: 'bar' }, + last_operation: { + type: 'create', + state: 'succeeded', + description: 'Operation succeeded', + created_at: iso8601, + updated_at: iso8601 + } + ) + ) + end + + it 'creates a service instance in the database' do + api_call.call(space_dev_headers) + + instance = VCAP::CloudController::ServiceInstance.last + + expect(instance.name).to eq(name) + expect(instance.syslog_drain_url).to eq('https://syslog.com/drain') + expect(instance.route_service_url).to eq('https://route.com/service') + expect(instance.tags).to contain_exactly('foo', 'bar', 'baz') + expect(instance.credentials).to match({ 'foo' => 'bar', 'baz' => 'qux' }) + expect(instance.space).to eq(space) + expect(instance.last_operation.type).to eq('create') + expect(instance.last_operation.state).to eq('succeeded') + expect(instance).to have_annotations({ prefix: nil, key_name: 'foo', value: 'bar' }) + expect(instance).to have_labels({ prefix: nil, key_name: 'baz', value: 'qux' }) + end + + context 'when the name has already been taken' do + it 'fails when the same name is already used in this space' do + VCAP::CloudController::ServiceInstance.make(name:, space:) + + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "The service instance name is taken: #{name}.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + + it 'succeeds when the same name is used in another space' do + VCAP::CloudController::ServiceInstance.make(name: name, space: another_space) + + api_call.call(admin_headers) + expect(last_response).to have_status_code(201) + end + end + + context 'when the route is not https' do + it 'returns an error' do + request_body[:route_service_url] = 'http://banana.example.com' + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Route service url must be https', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + + context 'managed service instance' do + let(:type) { 'managed' } + let(:maintenance_info) do + { + version: '1.2.3', + description: 'amazing version' + } + end + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: true, active: true, maintenance_info: maintenance_info) } + let(:service_plan_guid) { service_plan.guid } + let(:request_body) do + { + type: type, + name: name, + relationships: { + space: { + data: { + guid: space_guid + } + }, + service_plan: { + data: { + guid: service_plan_guid + } + } + }, + parameters: { + foo: 'bar', + baz: 'qux' + }, + tags: %w[foo bar baz], + metadata: { + annotations: { + foo: 'bar', + 'pre.fix/wow': 'baz' + }, + labels: { + baz: 'qux' + } + } + } + end + let(:instance) { VCAP::CloudController::ServiceInstance.last } + let(:job) { VCAP::CloudController::PollableJobModel.last } + let(:mock_logger) { instance_double(Steno::Logger, info: nil) } + + before do + allow(Steno).to receive(:logger).and_call_original + allow(Steno).to receive(:logger).with('cc.api').and_return(mock_logger) + end + + context 'when providing parameters with mixed data types' do + let(:request_body) do + "{\"type\":\"managed\",\"name\":\"#{name}\"," \ + "\"relationships\":{\"space\":{\"data\":{\"guid\":\"#{space_guid}\"}},\"service_plan\":{\"data\":{\"guid\":\"#{service_plan_guid}\"}}}," \ + "\"parameters\":#{parameters_mixed_data_types_as_json_string}}" + end + + it 'correctly parses all data types and sends the desired JSON string to the service broker' do + post '/v3/service_instances', request_body, space_dev_headers + + expect_any_instance_of(VCAP::Services::ServiceBrokers::V2::Client).to receive(:provision). + with(instance, hash_including(arbitrary_parameters: parameters_mixed_data_types_as_hash)). # correct internal representation + and_call_original + + stub_request(:put, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with(query: { 'accepts_incomplete' => true }, body: /"parameters":#{Regexp.escape(parameters_mixed_data_types_as_json_string)}/). + to_return(status: 201, body: '{}') + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'creates a service instance in the database' do + api_call.call(space_dev_headers) + + expect(instance.name).to eq(name) + expect(instance.tags).to contain_exactly('foo', 'bar', 'baz') + expect(instance.space).to eq(space) + expect(instance.service_plan).to eq(service_plan) + + expect(instance).to have_annotations({ prefix: nil, key_name: 'foo', value: 'bar' }, { prefix: 'pre.fix', key_name: 'wow', value: 'baz' }) + expect(instance).to have_labels({ prefix: nil, key_name: 'baz', value: 'qux' }) + + expect(instance.last_operation.type).to eq('create') + expect(instance.last_operation.state).to eq('initial') + end + + it 'responds with job resource' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(202) + expect(last_response.headers['Location']).to end_with("/v3/jobs/#{job.guid}") + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::PROCESSING_STATE) + expect(job.operation).to eq('service_instance.create') + expect(job.resource_guid).to eq(instance.guid) + expect(job.resource_type).to eq('service_instances') + end + + it 'logs the correct names when creating a managed service instance' do + api_call.call(space_dev_headers) + + expect(mock_logger).to have_received(:info).with( + "Creating managed service instance with name '#{instance.name}' " \ + "using service plan '#{service_plan.name}' " \ + "from service offering '#{service_plan.service.name}' " \ + "provided by broker '#{service_plan.service.service_broker.name}'." + ) + end + + context 'when the name has already been taken' do + it 'fails when the same name is already used in this space' do + VCAP::CloudController::ServiceInstance.make(name:, space:) + + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "The service instance name is taken: #{name}.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + + it 'succeeds when the same name is used in another space' do + VCAP::CloudController::ServiceInstance.make(name: name, space: another_space) + + api_call.call(admin_headers) + expect(last_response).to have_status_code(202) + end + end + + context 'when the plan is org-restricted' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: false, active: true) } + + before do + VCAP::CloudController::ServicePlanVisibility.make(service_plan: service_plan, organization: org) + end + + it 'can be created in a space in that org' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(202) + expect(instance.name).to eq(name) + end + end + + describe 'unavailable broker' do + context 'when the service broker does not have state (v2 brokers)' do + let(:service_broker) { service_plan.service_broker } + + it 'creates a service instance' do + service_broker.update(state: '') + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(202) + end + end + + context 'when there is an operation in progress for the service broker' do + let(:service_broker) { service_plan.service_broker } + + before do + service_broker.update(state: broker_state) + end + + context 'when the service broker is being deleted' do + let(:broker_state) { VCAP::CloudController::ServiceBrokerStateEnum::DELETE_IN_PROGRESS } + + it 'fails to create a service instance' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'The service instance cannot be created because there is an operation in progress for the service broker.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'when the service broker is synchronising the catalog' do + let(:broker_state) { VCAP::CloudController::ServiceBrokerStateEnum::SYNCHRONIZING } + + it 'fails to create a service instance' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'The service instance cannot be created because there is an operation in progress for the service broker.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + end + + describe 'when db is unavailable' do + before do + allow_any_instance_of(VCAP::CloudController::Jobs::Enqueuer).to receive(:enqueue_pollable).and_raise(Sequel::DatabaseDisconnectError) + end + + it 'raises the appropriate error' do + api_call.call(admin_headers) + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors']).to include(include({ + 'detail' => include('Database connection failure'), + 'title' => 'CF-ServiceUnavailable', + 'code' => 10_015 + })) + end + + it 'does not create a service instance in the database' do + api_call.call(admin_headers) + + expect(instance).to be_nil + end + end + + describe 'service plan checks' do + context 'does not exist' do + let(:service_plan_guid) { 'does-not-exist' } + + it 'fails saying the plan is invalid' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. Ensure that the service plan exists, is available, and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'not readable by the user' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: false, active: true) } + + it 'fails saying the plan is invalid' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. Ensure that the service plan exists, is available, and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'not enabled in that org' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: false, active: true) } + + it 'fails saying the plan is invalid' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. This could be due to a space-scoped broker which is offering the service plan ' \ + "'#{service_plan.name}' with guid '#{service_plan.guid}' in another space or that the plan " \ + 'is not enabled in this organization. Ensure that the service plan is visible in your current space ' \ + "'#{space.name}' with guid '#{space.guid}'.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'not active' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: true, active: false) } + + it 'fails saying the plan is invalid' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "Invalid service plan. The service plan '#{service_plan.name}' with guid '#{service_plan.guid}' " \ + "has been removed from the service broker's catalog. " \ + 'It is not possible to create new service instances using this plan.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'space-scoped plan from a different space' do + let(:service_broker) { VCAP::CloudController::ServiceBroker.make(space: another_space) } + let(:service_offering) { VCAP::CloudController::Service.make(service_broker:) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering, active: true, public: false) } + + it 'fails saying the plan is invalid' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. Ensure that the service plan exists, is available, and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + + describe 'the pollable job' do + # These tests verify broker request context includes org/space annotations + let!(:org_annotation) { VCAP::CloudController::OrganizationAnnotationModel.make(key_prefix: 'pre.fix', key_name: 'foo', value: 'bar', resource_guid: org.guid) } + let!(:space_annotation) { VCAP::CloudController::SpaceAnnotationModel.make(key_prefix: 'pre.fix', key_name: 'baz', value: 'wow', space: space) } + + let(:request_body_additions) { { parameters: { foo: 'bar', baz: 'qux' } } } + let(:broker_response) { { dashboard_url: 'http://dashboard.url' } } + let(:broker_status_code) { 201 } + let(:last_operation_status_code) { 200 } + let(:last_operation_response) { { state: 'in progress' } } + + before do + api_call.call(space_dev_headers) + instance = VCAP::CloudController::ServiceInstance.last + stub_request(:put, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with(query: { 'accepts_incomplete' => true }). + to_return(status: broker_status_code, body: broker_response.to_json, headers: {}) + + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_plan.service.unique_id, + plan_id: service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}) + end + + it 'sends a provision request with the right arguments to the service broker' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + expect(a_request(:put, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with( + query: { accepts_incomplete: true }, + body: { + service_id: service_plan.service.unique_id, + plan_id: service_plan.unique_id, + context: { + platform: 'cloudfoundry', + organization_guid: org.guid, + organization_name: org.name, + organization_annotations: { 'pre.fix/foo': 'bar' }, + space_guid: space.guid, + space_name: space.name, + space_annotations: { 'pre.fix/baz': 'wow' }, + instance_name: instance.name, + instance_annotations: { 'pre.fix/wow': 'baz' } + }, + organization_guid: org.guid, + space_guid: space.guid, + parameters: { + foo: 'bar', + baz: 'qux' + }, + maintenance_info: maintenance_info + }, + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + )).to have_been_made.once + end + + context 'when the provision completes synchronously' do + it 'marks the service instance as created' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(instance.dashboard_url).to eq('http://dashboard.url') + expect(instance.last_operation.type).to eq('create') + expect(instance.last_operation.state).to eq('succeeded') + end + + it 'completes' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + + context 'when the broker responds with an error' do + let(:broker_status_code) { 400 } + + it 'marks the service instance as failed' do + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + expect(instance.last_operation.type).to eq('create') + expect(instance.last_operation.state).to eq('failed') + expect(instance.last_operation.description).to include('Status Code: 400 Bad Request') + end + + it 'completes with failure' do + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + end + end + + context 'when the provision is asynchronous' do + let(:broker_status_code) { 202 } + let(:broker_response) { { operation: 'task12' } } + + it 'marks the job state as polling' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + end + + it 'calls last operation immediately' do + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect( + a_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_plan.service.unique_id, + plan_id: service_plan.unique_id + }, + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + ) + ).to have_been_made.once + end + + it 'enqueues the next fetch last operation job' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(Delayed::Job.count).to eq(1) + end + + context 'when last operation eventually returns `create succeeded`' do + let(:dashboard_url) { '' } + + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_plan.service.unique_id, + plan_id: service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 200, body: { state: 'succeeded' }.to_json, headers: {}) + + stub_request(:get, "#{instance.service.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + to_return(status: 200, body: { dashboard_url: }.to_json) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + + it 'sets the service instance last operation to create succeeded' do + expect(instance.last_operation.type).to eq('create') + expect(instance.last_operation.state).to eq('succeeded') + end + + context 'it fetches dashboard url' do + let(:service) { VCAP::CloudController::Service.make(instances_retrievable: true) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: true, active: true, service: service) } + let(:dashboard_url) { 'http:/some-new-dashboard-url.com' } + + it 'sets the service instance dashboard url' do + instance.reload + + expect(instance.dashboard_url).to eq(dashboard_url) + end + end + end + + context 'when last operation eventually returns `create failed`' do + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_plan.service.unique_id, + plan_id: service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 200, body: { state: 'failed' }.to_json, headers: {}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 0, expected_failures: 1, jobs_to_execute: 1) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + + it 'sets the service instance last operation to create failed' do + expect(instance.last_operation.type).to eq('create') + expect(instance.last_operation.state).to eq('failed') + end + end + end + + describe 'volume mount and route service checks' do + context 'when volume mount required' do + let(:service_offering) { VCAP::CloudController::Service.make(requires: %w[volume_mount]) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering) } + + context 'volume mount disabled' do + before do + TestConfig.config[:volume_services_enabled] = false + end + + it 'warns' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.warnings.to_json).to include(VCAP::CloudController::ServiceInstance::VOLUME_SERVICE_WARNING) + end + end + + context 'volume mount enabled' do + before do + TestConfig.config[:volume_services_enabled] = true + end + + it 'does not warn' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.warnings).to be_empty + end + end + end + + context 'when route forwarding required' do + let(:service_offering) { VCAP::CloudController::Service.make(requires: %w[route_forwarding]) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering) } + + context 'route forwarding disabled' do + before do + TestConfig.config[:route_services_enabled] = false + end + + it 'warns' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.warnings.to_json).to include(VCAP::CloudController::ServiceInstance::ROUTE_SERVICE_WARNING) + end + end + + context 'route forwarding enabled' do + before do + TestConfig.config[:route_services_enabled] = true + end + + it 'does not warn' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.warnings).to be_empty + end + end + end + end + end + + describe 'quotas restrictions' do + describe 'space quotas' do + context 'when the total services quota has been reached' do + before do + quota = VCAP::CloudController::SpaceQuotaDefinition.make(total_services: 1, organization: org) + quota.add_space(space) + + VCAP::CloudController::ManagedServiceInstance.make(space:) + end + + it 'returns an error' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "You have exceeded your space's services limit.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'when the paid services quota has been reached' do + let!(:service_plan) { VCAP::CloudController::ServicePlan.make(free: false, public: true, active: true) } + + before do + quota = VCAP::CloudController::SpaceQuotaDefinition.make(non_basic_services_allowed: false, organization: org) + quota.add_space(space) + end + + it 'returns an error' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'The service instance cannot be created because paid service plans are not allowed for your space.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + + describe 'organization quotas' do + context 'when the total services quota has been reached' do + before do + quota = VCAP::CloudController::QuotaDefinition.make(total_services: 1) + quota.add_organization(org) + VCAP::CloudController::ManagedServiceInstance.make(space:) + end + + it 'returns an error' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "You have exceeded your organization's services limit.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'when the paid services quota has been reached' do + let!(:service_plan) { VCAP::CloudController::ServicePlan.make(free: false, public: true, active: true) } + + before do + quota = VCAP::CloudController::QuotaDefinition.make(non_basic_services_allowed: false) + quota.add_organization(org) + end + + it 'returns an error' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'The service instance cannot be created because paid service plans are not allowed.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + end + end + end +end diff --git a/spec/request/service_instances_delete_spec.rb b/spec/request/service_instances_delete_spec.rb new file mode 100644 index 0000000000..32ddcd543c --- /dev/null +++ b/spec/request/service_instances_delete_spec.rb @@ -0,0 +1,787 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'V3 service instances - Delete' do + include_context 'service instances setup' + + describe 'DELETE /v3/service_instances/:guid' do + let(:query_params) { '' } + let(:api_call) { ->(user_headers) { delete "/v3/service_instances/#{instance.guid}?#{query_params}", '{}', user_headers } } + + context 'permissions' do + let!(:instance) { VCAP::CloudController::UserProvidedServiceInstance.make(space:) } + let(:db_check) do + lambda { + expect(VCAP::CloudController::ServiceInstance.all).to be_empty + } + end + + it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) { responses_for_space_restricted_delete_endpoint } + end + + it_behaves_like 'permissions for delete endpoint when organization is suspended', 204 + end + + context 'user provided service instances' do + let!(:instance) do + si = VCAP::CloudController::UserProvidedServiceInstance.make(space: space, route_service_url: 'https://banana.example.com/') + si.service_instance_operation = VCAP::CloudController::ServiceInstanceOperation.make(type: 'create', state: 'succeeded') + si + end + let(:instance_labels) { VCAP::CloudController::ServiceInstanceLabelModel.where(service_instance: instance) } + let(:instance_annotations) { VCAP::CloudController::ServiceInstanceAnnotationModel.where(service_instance: instance) } + + before do + VCAP::CloudController::ServiceInstanceLabelModel.make(key_name: 'fruit', value: 'banana', service_instance: instance) + VCAP::CloudController::ServiceInstanceLabelModel.make(key_name: 'spice', value: 'cinnamon', service_instance: instance) + VCAP::CloudController::ServiceInstanceAnnotationModel.make(key_name: 'contact', value: 'marie', service_instance: instance) + VCAP::CloudController::ServiceInstanceAnnotationModel.make(key_name: 'email', value: 'some@example.com', service_instance: instance) + end + + it 'deletes the instance and removes any labels or annotations' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(204) + + get "/v3/service_instances/#{instance.guid}", {}, admin_headers + expect(last_response.status).to eq(404) + expect(VCAP::CloudController::ServiceInstanceLabelModel.where(service_instance: instance).all).to be_empty + expect(VCAP::CloudController::ServiceInstanceAnnotationModel.where(service_instance: instance).all).to be_empty + end + + it 'deletes any related bindings' do + VCAP::CloudController::RouteBinding.make(service_instance: instance) + VCAP::CloudController::ServiceBinding.make(service_instance: instance) + + api_call.call(admin_headers) + expect(last_response).to have_status_code(204) + + expect(VCAP::CloudController::ServiceInstance.all).to be_empty + expect(VCAP::CloudController::RouteBinding.all).to be_empty + expect(VCAP::CloudController::ServiceBinding.all).to be_empty + end + + context 'with purge' do + let(:query_params) { 'purge=true' } + + before do + @binding = VCAP::CloudController::ServiceBinding.make(service_instance: instance) + @route = VCAP::CloudController::RouteBinding.make(service_instance: instance) + end + + it 'deletes the instance and the related resources' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(204) + + expect { instance.reload }.to raise_error Sequel::NoExistingObject + expect { @binding.reload }.to raise_error Sequel::NoExistingObject + expect { @route.reload }.to raise_error Sequel::NoExistingObject + expect(instance_labels.count).to eq(0) + expect(instance_annotations.count).to eq(0) + end + end + end + + context 'managed service instance' do + let!(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:broker_status_code) { 200 } + let(:broker_response) { {} } + let!(:stub_delete) do + stub_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with(query: { + 'accepts_incomplete' => true, + 'service_id' => instance.service.broker_provided_id, + 'plan_id' => instance.service_plan.broker_provided_id + }). + to_return(status: broker_status_code, body: broker_response.to_json, headers: {}) + end + let(:mock_logger) { instance_double(Steno::Logger, info: nil) } + + before do + allow(Steno).to receive(:logger).and_call_original + allow(Steno).to receive(:logger).with('cc.api').and_return(mock_logger) + end + + it 'responds with job resource' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(202) + + job = VCAP::CloudController::PollableJobModel.last + expect(last_response.headers['Location']).to end_with("/v3/jobs/#{job.guid}") + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::PROCESSING_STATE) + expect(job.operation).to eq('service_instance.delete') + expect(job.resource_guid).to eq(instance.guid) + expect(job.resource_type).to eq('service_instance') + end + + it 'logs the correct names when deleting a managed service instance' do + api_call.call(admin_headers) + + expect(mock_logger).to have_received(:info).with( + "Deleting managed service instance with name '#{instance.name}' " \ + "using service plan '#{instance.service_plan.name}' " \ + "from service offering '#{instance.service_plan.service.name}' " \ + "provided by broker '#{instance.service_plan.service.service_broker.name}'." + ) + end + + describe 'the pollable job' do + it 'sends a delete request with the right arguments to the service broker' do + api_call.call(headers_for(user, scopes: %w[cloud_controller.admin])) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + expect( + a_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with( + query: { + accepts_incomplete: true, + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + }, + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + ) + ).to have_been_made.once + end + + context 'when the service broker responds synchronously' do + context 'with success' do + it 'removes the service instance' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(VCAP::CloudController::ServiceInstance.first(guid: instance.guid)).to be_nil + end + + it 'completes the job' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + end + + context 'with an error' do + let(:broker_status_code) { 404 } + + it 'marks the service instance as delete failed' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 0, expected_failures: 1) + instance.reload + + expect(instance.last_operation).not_to be_nil + expect(instance.last_operation.type).to eq('delete') + expect(instance.last_operation.state).to eq('failed') + end + + it 'completes with failure' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + end + end + + context 'when the service broker responds asynchronously' do + let(:broker_status_code) { 202 } + let(:broker_response) { { operation: 'some delete operation' } } + let(:last_operation_response) { { state: 'in progress', description: 'deleting si' } } + let(:last_operation_status_code) { 200 } + let(:job) { VCAP::CloudController::PollableJobModel.last } + + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}) + end + + it 'marks the job state as polling' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + end + + it 'calls last operation immediately' do + api_call.call(headers_for(user, scopes: %w[cloud_controller.admin])) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + expect( + a_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + }, + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + ) + ).to have_been_made.once + end + + it 'enqueues the next fetch last operation job' do + api_call.call(admin_headers) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(Delayed::Job.count).to eq(1) + end + + it 'sets the service instance last operation to delete in progress' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + instance.reload + + expect(instance.last_operation).not_to be_nil + expect(instance.last_operation.type).to eq('delete') + expect(instance.last_operation.state).to eq('in progress') + end + + context 'when last operation eventually returns `delete succeeded`' do + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 200, body: { state: 'succeeded' }.to_json, headers: {}) + + api_call.call(admin_headers) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + + it 'removes the service instance last from the db' do + expect(VCAP::CloudController::ServiceInstance.first(guid: instance.guid)).to be_nil + end + end + + context 'when last operation eventually returns `delete failed`' do + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(3).then. + to_return(status: 200, body: { state: 'failed', description: 'oh no failed' }.to_json, headers: {}) + + api_call.call(admin_headers) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + (1..2).each do |attempt| + Timecop.freeze(Time.now + attempt.hour) do + execute_all_jobs(expected_successes: 1, expected_failures: 0, jobs_to_execute: 1) + end + end + Timecop.freeze(Time.now + 3.hours) do + execute_all_jobs(expected_successes: 0, expected_failures: 1, jobs_to_execute: 1) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + + it 'sets the service instance last operation to delete failed' do + expect(instance.last_operation.type).to eq('delete') + expect(instance.last_operation.state).to eq('failed') + expect(instance.last_operation.description).to eq('oh no failed') + end + end + + context 'when last operation eventually returns 410 Gone' do + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 410, headers: {}) + + api_call.call(admin_headers) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + + it 'removes the service instance last from the db' do + expect(VCAP::CloudController::ServiceInstance.first(guid: instance.guid)).to be_nil + end + end + + context 'when last operation eventually returns 400 Bad Request' do + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 400, headers: {}) + + api_call.call(admin_headers) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 0, expected_failures: 1) + end + end + + it 'fails the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + + it 'sets the service instance last operation to delete failed' do + expect(instance.last_operation.type).to eq('delete') + expect(instance.last_operation.state).to eq('failed') + end + end + + context 'when last operation returns with an unknown status code' do + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 404, headers: {}) + + api_call.call(admin_headers) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'continues to poll' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + end + end + end + + context 'when the service instance is shared' do + let!(:shared_space) do + VCAP::CloudController::Space.make.tap do |s| + instance.add_shared_space(s) + end + end + + it 'removes the service instance' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(VCAP::CloudController::ServiceInstance.all).to be_empty + end + + context 'when there is a binding in the shared space' do + let!(:application) { VCAP::CloudController::AppModel.make(space: shared_space) } + let!(:service_binding) { VCAP::CloudController::ServiceBinding.make(service_instance: instance, app: application) } + + before do + stub_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{service_binding.guid}"). + with(query: { + 'accepts_incomplete' => true, + 'service_id' => instance.service.broker_provided_id, + 'plan_id' => instance.service_plan.broker_provided_id + }). + to_return(status: 202, body: '{}', headers: {}) + end + + it 'fails when the unbind is async' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 0, expected_failures: 1, jobs_to_execute: 1) + + lo = instance.last_operation + expect(lo.type).to eq('delete') + expect(lo.state).to eq('failed') + expect(lo.description).to eq("An operation for the service binding between app #{application.name} and service instance #{instance.name} is in progress.") + + expect( + stub_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{service_binding.guid}"). + with(query: { + 'accepts_incomplete' => true, + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + }) + ).to have_been_made.once + end + end + end + + context 'when there are bindings' do + let(:service_offering) { VCAP::CloudController::Service.make(requires: %w[route_forwarding]) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering) } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:, service_plan:) } + let!(:route_binding) { VCAP::CloudController::RouteBinding.make(service_instance: instance) } + let!(:service_binding) { VCAP::CloudController::ServiceBinding.make(service_instance: instance) } + let!(:service_key) { VCAP::CloudController::ServiceKey.make(service_instance: instance) } + + context 'and the broker responds synchronously to the bindings being deleted' do + before do + [route_binding, service_binding, service_key].each do |binding| + stub_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{binding.guid}"). + with(query: { + 'accepts_incomplete' => true, + 'service_id' => instance.service.broker_provided_id, + 'plan_id' => instance.service_plan.broker_provided_id + }). + to_return(status: 200, body: '{}', headers: {}) + end + end + + it 'removes the service instance' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(VCAP::CloudController::ServiceInstance.all).to be_empty + expect(VCAP::CloudController::RouteBinding.all).to be_empty + expect(VCAP::CloudController::ServiceBinding.all).to be_empty + expect(VCAP::CloudController::ServiceKey.all).to be_empty + + [route_binding, service_binding, service_key].each do |binding| + expect( + a_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{binding.guid}"). + with(query: { + accepts_incomplete: true, + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + }) + ).to have_been_made.once + end + end + end + + context 'and the broker responds asynchronously to the bindings being deleted' do + before do + [route_binding, service_binding, service_key].each do |binding| + stub_request(:delete, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{binding.guid}"). + with(query: { + 'accepts_incomplete' => true, + 'service_id' => instance.service.broker_provided_id, + 'plan_id' => instance.service_plan.broker_provided_id + }). + to_return(status: 202, body: '{}', headers: {}) + + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{binding.guid}/last_operation"). + with(query: { + 'service_id' => instance.service.broker_provided_id, + 'plan_id' => instance.service_plan.broker_provided_id + }). + to_return(status: 200, body: '{"state":"succeeded"}', headers: {}) + end + end + + it 'fails and starts the delete operation on the bindings' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 0, expected_failures: 1, jobs_to_execute: 1) + + lo = VCAP::CloudController::ServiceInstance.first.last_operation + expect(lo.type).to eq('delete') + expect(lo.state).to eq('failed') + expect(lo.description).to eq("An operation for a service binding of service instance #{instance.name} is in progress.") + + lo = VCAP::CloudController::RouteBinding.first.last_operation + expect(lo.type).to eq('delete') + expect(lo.state).to eq('in progress') + + lo = VCAP::CloudController::ServiceBinding.first.last_operation + expect(lo.type).to eq('delete') + expect(lo.state).to eq('in progress') + + lo = VCAP::CloudController::ServiceKey.first.last_operation + expect(lo.type).to eq('delete') + expect(lo.state).to eq('in progress') + end + + it 'continues to poll the last operation for the bindings' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 3, expected_failures: 1) + + [route_binding, service_binding, service_key].each do |binding| + expect( + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/service_bindings/#{binding.guid}/last_operation"). + with(query: { + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + }) + ).to have_been_made.once + end + end + + it 'eventually removes the bindings' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 3, expected_failures: 1) + + expect(VCAP::CloudController::RouteBinding.all).to be_empty + expect(VCAP::CloudController::ServiceBinding.all).to be_empty + expect(VCAP::CloudController::ServiceKey.all).to be_empty + end + end + end + end + + context 'when purge is true' do + let(:query_params) { 'purge=true' } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering) } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:, service_plan:) } + + context 'when broker is space scoped' do + let(:service_broker) { VCAP::CloudController::ServiceBroker.make(space:) } + let(:service_offering) { VCAP::CloudController::Service.make(requires: %w[route_forwarding], service_broker: service_broker) } + + context 'as developer' do + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + + it 'deletes the service instance' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(204) + expect { instance.reload }.to raise_error Sequel::NoExistingObject + end + end + + context 'as admin' do + it 'deletes the service instance' do + api_call.call(admin_headers) + + expect(last_response).to have_status_code(204) + expect { instance.reload }.to raise_error Sequel::NoExistingObject + end + end + end + + context 'when broker is global' do + let(:service_offering) { VCAP::CloudController::Service.make(requires: %w[route_forwarding]) } + + before do + @binding = VCAP::CloudController::ServiceBinding.make(service_instance: instance) + @key = VCAP::CloudController::ServiceKey.make(service_instance: instance) + @route = VCAP::CloudController::RouteBinding.make(service_instance: instance) + end + + context 'as developer' do + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + + it 'responds with 403' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + end + end + + context 'as admin' do + before do + api_call.call(admin_headers) + end + + it 'removes all associations' do + expect { @binding.reload }.to raise_error Sequel::NoExistingObject + expect { @key.reload }.to raise_error Sequel::NoExistingObject + expect { @route.reload }.to raise_error Sequel::NoExistingObject + end + + it 'deletes the service instance' do + expect { instance.reload }.to raise_error Sequel::NoExistingObject + end + + it 'responds with 204' do + expect(last_response).to have_status_code(204) + end + end + end + end + + context 'when delete is already in progress' do + before do + instance.save_with_new_operation({}, { type: 'delete', state: 'in progress' }) + end + + it 'responds with 422' do + api_call.call(admin_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include(include({ + 'detail' => include('There is an operation in progress for the service instance.'), + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + })) + end + end + + context 'when the service instance creation request has not been responded to be the broker' do + before do + instance.save_with_new_operation({}, { type: 'create', state: 'initial' }) + end + + it 'responds with 422' do + api_call.call(admin_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include(include({ + 'detail' => include('There is an operation in progress for the service instance.'), + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + })) + end + end + + context 'when the creation is still in progress' do + before do + instance.save_with_new_operation({}, { + type: 'create', + state: 'in progress', + broker_provided_operation: 'some create operation' + }) + end + + context 'and the broker confirms the deletion' do + it 'deletes the service instance' do + api_call.call(admin_headers) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(VCAP::CloudController::ServiceInstance.first(guid: instance.guid)).to be_nil + end + end + + context 'and the broker accepts the delete' do + let(:broker_status_code) { 202 } + let(:broker_response) { { operation: 'some delete operation' } } + let(:last_operation_response) { { state: 'in progress', description: 'deleting si' } } + let(:last_operation_status_code) { 200 } + + before do + stub_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}/last_operation"). + with( + query: { + operation: 'some delete operation', + service_id: instance.service.broker_provided_id, + plan_id: instance.service_plan.broker_provided_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}) + end + + it 'triggers the delete process' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(HTTP::Status::ACCEPTED) + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + instance.reload + + expect(instance.last_operation).not_to be_nil + expect(instance.last_operation.type).to eq('delete') + expect(instance.last_operation.state).to eq('in progress') + expect(instance.last_operation.broker_provided_operation).to eq('some delete operation') + end + end + + context 'but the broker rejects the delete' do + let(:broker_status_code) { 422 } + let(:broker_response) { { error: 'ConcurrencyError', description: 'Cannot delete right now' } } + + it 'responds with an error' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(HTTP::Status::ACCEPTED) + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + job = VCAP::CloudController::PollableJobModel.last + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + + expect(job.cf_api_error).not_to be_nil + api_error = YAML.safe_load(job.cf_api_error)['errors'].first + expect(api_error['title']).to eql('CF-AsyncServiceInstanceOperationInProgress') + expect(api_error['detail']).to eql("An operation for service instance #{instance.name} is in progress.") + end + + it 'does not change the operation in progress' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(HTTP::Status::ACCEPTED) + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + instance.reload + + expect("#{instance.last_operation.type} #{instance.last_operation.state}").to eq('create in progress') + expect(instance.last_operation.broker_provided_operation).to eq('some create operation') + end + end + end + end + + context 'when the service instance does not exist' do + let(:instance) { Struct.new(:guid).new('some-fake-guid') } + + it 'returns a 404' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(404) + end + end + end +end diff --git a/spec/request/service_instances_sharing_spec.rb b/spec/request/service_instances_sharing_spec.rb new file mode 100644 index 0000000000..63edad811e --- /dev/null +++ b/spec/request/service_instances_sharing_spec.rb @@ -0,0 +1,703 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'V3 service instances - Sharing' do + include_context 'service instances setup' + + describe 'POST /v3/service_instances/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { post "/v3/service_instances/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid }, + { 'guid' => target_space_2.guid } + ] + } + end + let(:service_instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:guid) { service_instance.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'service_instance_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + end + + context 'permissions' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) { responses_for_space_restricted_update_endpoint(success_code: 200) } + end + + it_behaves_like 'permissions for update endpoint when organization is suspended', 200 + + context 'when target organization is suspended' do + let(:target_space_1) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + responses_for_org_suspended_space_restricted_update_endpoint(success_code: 200).merge({ 'space_developer' => { code: 422 } }) + end + end + end + end + + it 'shares the service instance to the target space and logs audit event' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.service_instance.share', + actor: user.guid, + actee_type: 'service_instance', + actee_name: service_instance.name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) + + service_instance.reload + expect(service_instance.shared_spaces).to include(target_space_1, target_space_2) + end + + describe 'when service_instance_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to share services' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: service_instance_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the instance does not exist' do + post '/v3/service_instances/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Service instance not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when it is not a valid relationship' do + let(:request_body) do + { + 'data' => { 'guid' => target_space_1.guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an array', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when there are additional keys' do + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid } + ], + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'target space to share to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_guid } + ] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share service instance #{service_instance.name} with spaces ['#{target_space_guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have access to one of the target spaces' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => no_access_target_space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the instance' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share service instance #{service_instance.name} with spaces ['#{no_access_target_space.guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + service_instance.reload + expect(service_instance).not_to be_shared + end + end + + context 'owns the space' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the instance' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share service instance '#{service_instance.name}' with space '#{space.guid}'. " \ + 'Service instances cannot be shared into the space where they were created.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + service_instance.reload + expect(service_instance).not_to be_shared + end + end + end + + describe 'errors while sharing' do + context 'service instance is user provided' do + let(:service_instance) { VCAP::CloudController::UserProvidedServiceInstance.make(space:) } + + it 'responds with 422 and the error' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'User-provided services cannot be shared.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + end + + describe 'DELETE /v3/service_instances/:guid/relationships/shared_spaces/:space_guid' do + let(:api_call) { ->(user_headers) { delete "/v3/service_instances/#{guid}/relationships/shared_spaces/#{space_guid}", nil, user_headers } } + let(:target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:service_instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:guid) { service_instance.guid } + let(:space_guid) { target_space.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'service_instance_sharing', enabled: true, error_message: nil) + end + + before do + share_service_instance(service_instance, target_space) + end + + context 'permissions' do + let(:db_check) do + lambda { + si = VCAP::CloudController::ServiceInstance.first(guid:) + expect(si).not_to be_shared + } + end + + it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) { responses_for_space_restricted_delete_endpoint } + end + + it_behaves_like 'permissions for delete endpoint when organization is suspended', 204 + end + + it 'unshares the service instance from the target space and logs audit event' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(204) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.service_instance.unshare', + actor: user.guid, + actee_type: 'service_instance', + actee_name: service_instance.name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(target_space.guid) + end + + describe 'when there are bindings in the shared space' do + let(:app_1) { VCAP::CloudController::AppModel.make(space: target_space) } + let(:app_2) { VCAP::CloudController::AppModel.make(space: target_space) } + + let(:binding_1) { VCAP::CloudController::ServiceBinding.make(service_instance: service_instance, app: app_1) } + let(:binding_2) { VCAP::CloudController::ServiceBinding.make(service_instance: service_instance, app: app_2) } + + context 'and the bindings can be deleted synchronously' do + before do + stub_unbind(binding_1, accepts_incomplete: true, status: 200, body: {}.to_json) + stub_unbind(binding_2, accepts_incomplete: true, status: 200, body: {}.to_json) + end + + it 'deletes all bindings and successfully unshares' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(204) + + service_instance.reload + expect(service_instance).not_to be_shared + expect(service_instance).not_to have_bindings + end + end + + context 'but the bindings can only be deleted asynchronously' do + before do + stub_unbind(binding_1, accepts_incomplete: true, status: 202, body: {}.to_json) + stub_unbind(binding_2, accepts_incomplete: true, status: 200, body: {}.to_json) + end + + it 'responds with 502 and does not unshare' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(502) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unshare of service instance failed: \n\nUnshare of service instance failed because one or more bindings could not be deleted.\n\n " \ + "\tThe binding between an application and service instance #{service_instance.name} in space #{target_space.name} is being deleted asynchronously.", + 'title' => 'CF-ServiceInstanceUnshareFailed' + } + ) + ) + + expect(service_instance).to be_shared + end + end + end + + it 'responds with 404 when the instance does not exist' do + delete "/v3/service_instances/some-fake-guid/relationships/shared_spaces/#{space_guid}", + nil, + space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Service instance not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'target space to unshare from' do + context 'when it does not exist' do + let(:space_guid) { 'fake-target' } + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare service instance from space #{space_guid}. " \ + 'Ensure the space exists.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when instance was not shared to the space' do + let(:space_guid) { VCAP::CloudController::Space.make(organization: org).guid } + + it 'responds with 204' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(204) + end + end + end + end + + describe 'GET /v3/service_instances/:guid/relationships/shared_spaces' do + let(:user_header) { headers_for(user) } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:other_space) { VCAP::CloudController::Space.make } + + before do + share_service_instance(instance, other_space) + end + + describe 'permissions in originating space' do + let(:api_call) { ->(user_headers) { get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces", nil, user_headers } } + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_response) do + { + data: [{ guid: other_space.guid }], + links: { + self: { href: "#{link_prefix}/v3/service_instances/#{instance.guid}/relationships/shared_spaces" } + } + } + end + + let(:expected_codes_and_responses) do + responses_for_space_restricted_single_endpoint(expected_response) + end + end + end + + it 'respond with 200 when the user cannot read the originating space, but has access to the service instance' do + set_current_user_as_role(role: 'space_developer', org: other_space.organization, space: other_space, user: user) + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces", nil, user_header + expect(last_response.status).to eq(200) + end + + describe 'fields' do + it 'can include the space name, guid and organization relationship fields' do + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces?fields[space]=name,guid,relationships.organization", nil, admin_headers + expect(last_response).to have_status_code(200) + + r = { organization: { data: { guid: other_space.organization.guid } } } + included = { + spaces: [ + { name: other_space.name, guid: other_space.guid, relationships: r } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + + it 'can include the organization name and guid fields through space' do + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces?fields[space.organization]=name,guid", nil, admin_headers + expect(last_response).to have_status_code(200) + + included = { + organizations: [ + { + name: other_space.organization.name, + guid: other_space.organization.guid + } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + + context 'when the user can read from the originating space only' do + before do + set_current_user_as_role(role: 'space_developer', org: space.organization, space: space, user: user) + end + + it 'does not include space and organization names of the shared space' do + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces?fields[space]=name,guid,relationships.organization&fields[space.organization]=name,guid", nil, + user_header + expect(last_response).to have_status_code(200) + + included = { + spaces: [ + { guid: other_space.guid, relationships: { organization: { data: { guid: other_space.organization.guid } } } } + ], + organizations: [ + { guid: other_space.organization.guid } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + end + + it 'fails for invalid resources' do + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces?fields[fruit]=name", nil, admin_headers + expect(last_response).to have_status_code(400) + expect(parsed_response['errors']).to include( + include( + 'detail' => "The query parameter is invalid: Fields [fruit] valid resources are: 'space', 'space.organization'" + ) + ) + end + + it 'fails for not allowed space fields' do + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces?fields[space]=metadata", nil, admin_headers + expect(last_response).to have_status_code(400) + expect(parsed_response['errors']).to include( + include( + 'detail' => "The query parameter is invalid: Fields valid keys for 'space' are: 'name', 'guid', 'relationships.organization'" + ) + ) + end + + it 'fails for not allowed space.organization fields' do + get "/v3/service_instances/#{instance.guid}/relationships/shared_spaces?fields[space.organization]=metadata", nil, admin_headers + expect(last_response).to have_status_code(400) + expect(parsed_response['errors']).to include( + include( + 'detail' => "The query parameter is invalid: Fields valid keys for 'space.organization' are: 'name', 'guid'" + ) + ) + end + end + end + + describe 'GET /v3/service_instances/:guid/relationships/shared_spaces/usage_summary' do + let(:guid) { instance.guid } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:space_1) { VCAP::CloudController::Space.make } + let(:space_2) { VCAP::CloudController::Space.make } + let(:space_3) { VCAP::CloudController::Space.make } + let(:url) { "/v3/service_instances/#{guid}/relationships/shared_spaces/usage_summary" } + let(:api_call) { ->(user_headers) { get url, nil, user_headers } } + let(:bindings_on_space_1) { 1 } + let(:bindings_on_space_2) { 3 } + + def create_bindings(instance, space:, count:) + (1..count).each do + VCAP::CloudController::ServiceBinding.make( + app: VCAP::CloudController::AppModel.make(space:), + service_instance: instance + ) + end + end + + before do + share_service_instance(instance, space_1) + share_service_instance(instance, space_2) + share_service_instance(instance, space_3) + + create_bindings(instance, space: space_1, count: bindings_on_space_1) + create_bindings(instance, space: space_2, count: bindings_on_space_2) + end + + context 'permissions' do + let(:response_object) do + { + usage_summary: [ + { space: { guid: space_1.guid }, bound_app_count: bindings_on_space_1 }, + { space: { guid: space_2.guid }, bound_app_count: bindings_on_space_2 }, + { space: { guid: space_3.guid }, bound_app_count: 0 } + ], + links: { + self: { href: "#{link_prefix}/v3/service_instances/#{instance.guid}/relationships/shared_spaces/usage_summary" }, + shared_spaces: { href: "#{link_prefix}/v3/service_instances/#{instance.guid}/relationships/shared_spaces" }, + service_instance: { href: "#{link_prefix}/v3/service_instances/#{instance.guid}" } + } + } + end + + let(:expected_codes_and_responses) do + responses_for_space_restricted_single_endpoint(response_object) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when the instance does not exist' do + let(:guid) { 'a-fake-guid' } + + it 'responds with 404 Not Found' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(404) + end + end + + context 'when the user has access to the service through a shared space' do + it 'responds with 200 ok' do + user = VCAP::CloudController::User.make + set_current_user_as_role(role: 'space_developer', org: space_2.organization, space: space_2, user: user) + + api_call.call(headers_for(user)) + + expect(last_response).to have_status_code(200) + end + end + end + + describe 'GET /v3/service_instances/:guid/permissions' do + # For this endpoint we also want to test the 'cloud_controller_service_permissions.read' scope as well as unauthenticated calls. + ADDITIONAL_PERMISSIONS_TO_TEST = %w[service_permissions_reader unauthenticated].freeze + + READ_AND_WRITE = { code: 200, response_object: { manage: true, read: true } }.freeze + READ_ONLY = { code: 200, response_object: { manage: false, read: true } }.freeze + NO_PERMISSIONS = { code: 200, response_object: { manage: false, read: false } }.freeze + + let(:api_call) { ->(user_headers) { get "/v3/service_instances/#{guid}/permissions", nil, user_headers } } + + context 'when the service instance does not exist' do + let(:guid) { 'no-such-guid' } + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['unauthenticated'] = { code: 401 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + ADDITIONAL_PERMISSIONS_TO_TEST + end + + context 'when the user is a member of the org or space the service instance exists in' do + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:guid) { instance.guid } + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + %w[admin space_developer].each { |r| h[r] = READ_AND_WRITE } + %w[admin_read_only global_auditor org_manager space_manager space_auditor space_supporter].each { |r| h[r] = READ_ONLY } + %w[org_billing_manager org_auditor no_role service_permissions_reader].each { |r| h[r] = NO_PERMISSIONS } + h['unauthenticated'] = { code: 401 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + ADDITIONAL_PERMISSIONS_TO_TEST + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = READ_ONLY + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + ADDITIONAL_PERMISSIONS_TO_TEST + end + + context 'request with only the cloud_controller_service_permissions.read scope' do + it_behaves_like 'permissions for single object endpoint', LOCAL_ROLES do + let(:after_request_check) do + lambda do + # Store the HTTP status and response from the original call. + expected_status_code = last_response.status + expected_json_response = parsed_response + + # Repeat the same call for the same user, but now with only the 'cloud_controller_service_permissions.read' scope. + api_call.call(set_user_with_header_as_service_permissions_reader(user:)) + + # Both the HTTP status and response should be the same. + expect(last_response).to have_status_code(expected_status_code) + expect(parsed_response).to match_json_response(expected_json_response) + end + end + end + end + end + + context 'when the user is not a member of the org or space the service instance exists in' do + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make } + let(:guid) { instance.guid } + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['admin'] = READ_AND_WRITE + %w[admin_read_only global_auditor].each { |r| h[r] = READ_ONLY } + %w[org_billing_manager org_auditor org_manager space_manager space_auditor space_developer space_supporter no_role service_permissions_reader].each do |r| + h[r] = NO_PERMISSIONS + end + h['unauthenticated'] = { code: 401 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + ADDITIONAL_PERMISSIONS_TO_TEST + end + end +end diff --git a/spec/request/service_instances_show_spec.rb b/spec/request/service_instances_show_spec.rb new file mode 100644 index 0000000000..646644dfa1 --- /dev/null +++ b/spec/request/service_instances_show_spec.rb @@ -0,0 +1,835 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'V3 service instances - Show and List' do + include_context 'service instances setup' + + describe 'GET /v3/service_instances/:guid' do + let(:api_call) { ->(user_headers) { get "/v3/service_instances/#{guid}", nil, user_headers } } + + context 'no such instance' do + let(:guid) { 'no-such-guid' } + + let(:expected_codes_and_responses) do + Hash.new({ code: 404 }.freeze) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'managed service instance' do + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:guid) { instance.guid } + + let(:expected_codes_and_responses) do + responses_for_space_restricted_single_endpoint(create_managed_json(instance)) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'user-provided service instance' do + let(:instance) { VCAP::CloudController::UserProvidedServiceInstance.make(space:) } + let(:guid) { instance.guid } + + let(:expected_codes_and_responses) do + responses_for_space_restricted_single_endpoint(create_user_provided_json(instance)) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'shared service instance' do + let(:another_space) { VCAP::CloudController::Space.make } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space: another_space) } + let(:guid) { instance.guid } + + before do + instance.add_shared_space(space) + end + + let(:expected_codes_and_responses) do + responses_for_space_restricted_single_endpoint(create_managed_json(instance)) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'fields' do + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let(:guid) { instance.guid } + + it 'can include the organization name and guid fields' do + get "/v3/service_instances/#{guid}?fields[space.organization]=name,guid", nil, admin_headers + expect(last_response).to have_status_code(200) + + included = { + organizations: [ + { + name: space.organization.name, + guid: space.organization.guid + } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + + it 'can include the space name and guid fields' do + get "/v3/service_instances/#{guid}?fields[space]=name,guid", nil, admin_headers + expect(last_response).to have_status_code(200) + + included = { + spaces: [ + { + name: space.name, + guid: space.guid + } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + + it 'can include service plan guid and name fields' do + get "/v3/service_instances/#{guid}?fields[service_plan]=guid,name", nil, admin_headers + + expect(last_response).to have_status_code(200) + + included = { + service_plans: [ + { + guid: instance.service_plan.guid, + name: instance.service_plan.name + } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + + it 'can include service offering and broker fields' do + get "/v3/service_instances/#{guid}?fields[service_plan.service_offering]=name,guid,description,documentation_url&" \ + 'fields[service_plan.service_offering.service_broker]=name,guid', nil, admin_headers + expect(last_response).to have_status_code(200) + + included = { + service_offerings: [ + { + name: instance.service_plan.service.name, + guid: instance.service_plan.service.guid, + description: instance.service_plan.service.description, + documentation_url: 'https://some.url.for.docs/' + } + ], + service_brokers: [ + { + name: instance.service_plan.service.service_broker.name, + guid: instance.service_plan.service.service_broker.guid + } + ] + } + + expect({ included: parsed_response['included'] }).to match_json_response({ included: }) + end + end + end + + describe 'GET /v3/service_instances' do + let(:api_call) { ->(user_headers) { get '/v3/service_instances', nil, user_headers } } + + it_behaves_like 'list query endpoint' do + let(:user_header) { admin_headers } + let(:request) { 'v3/service_instances' } + let(:message) { VCAP::CloudController::ServiceInstancesListMessage } + + let(:params) do + { + names: %w[foo bar], + space_guids: %w[foo bar], + organization_guids: %w[org-1 org-2], + per_page: '10', + page: 2, + order_by: 'updated_at', + label_selector: 'foo,bar', + type: 'managed', + service_plan_guids: %w[guid-1 guid-2], + service_plan_names: %w[plan-1 plan-2], + fields: { 'space.organization' => 'name' }, + guids: 'foo,bar', + created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", + updated_ats: { gt: Time.now.utc.iso8601 } + } + end + end + + describe 'pagination' do + let!(:resources) { Array.new(2) { VCAP::CloudController::ServiceInstance.make } } + + it_behaves_like 'paginated response', '/v3/service_instances' + + it_behaves_like 'paginated fields response', '/v3/service_instances', 'space', 'guid,name,relationships.organization' + + it_behaves_like 'paginated fields response', '/v3/service_instances', 'space.organization', 'name,guid' + end + + describe 'order_by' do + it_behaves_like 'list endpoint order_by name', '/v3/service_instances' do + let(:resource_klass) { VCAP::CloudController::ServiceInstance } + end + + it_behaves_like 'list endpoint order_by timestamps', '/v3/service_instances' do + let(:resource_klass) { VCAP::CloudController::ServiceInstance } + end + end + + context 'given a mixture of managed, user-provided and shared service instances' do + # Let factory handle parent creation (broker → service → plan) + # Only specify what's actually needed for the tests + let!(:msi_1) { VCAP::CloudController::ManagedServiceInstance.make(space:) } + let!(:msi_2) { VCAP::CloudController::ManagedServiceInstance.make(space: another_space) } + let!(:upsi_1) { VCAP::CloudController::UserProvidedServiceInstance.make(space:) } + let!(:upsi_2) { VCAP::CloudController::UserProvidedServiceInstance.make(space: another_space) } + let!(:ssi) { VCAP::CloudController::ManagedServiceInstance.make(space: another_space) } + + before do + ssi.add_shared_space(space) + end + + describe 'permissions' do + let(:all_instances) do + { + code: 200, + response_objects: [ + create_managed_json(msi_1), + create_managed_json(msi_2), + create_user_provided_json(upsi_1), + create_user_provided_json(upsi_2), + create_managed_json(ssi) + ] + } + end + + let(:space_instances) do + { + code: 200, + response_objects: [ + create_managed_json(msi_1), + create_user_provided_json(upsi_1), + create_managed_json(ssi) + ] + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [] }.freeze + ) + + h['admin'] = all_instances + h['admin_read_only'] = all_instances + h['global_auditor'] = all_instances + h['space_supporter'] = space_instances + h['space_developer'] = space_instances + h['space_manager'] = space_instances + h['space_auditor'] = space_instances + h['org_manager'] = space_instances + + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'filters' do + it 'filters by name' do + get "/v3/service_instances?names=#{msi_1.name}", nil, admin_headers + check_filtered_instances(create_managed_json(msi_1)) + end + + it 'filters by space guid' do + get "/v3/service_instances?space_guids=#{another_space.guid}", nil, admin_headers + check_filtered_instances( + create_managed_json(msi_2), + create_user_provided_json(upsi_2), + create_managed_json(ssi) + ) + end + + it 'filters by organization guids' do + get "/v3/service_instances?organization_guids=#{another_space.organization.guid}", nil, admin_headers + check_filtered_instances( + create_managed_json(msi_2), + create_user_provided_json(upsi_2), + create_managed_json(ssi) + ) + end + + it 'filters by label' do + VCAP::CloudController::ServiceInstanceLabelModel.make(key_name: 'fruit', value: 'strawberry', service_instance: msi_1) + VCAP::CloudController::ServiceInstanceLabelModel.make(key_name: 'fruit', value: 'raspberry', service_instance: msi_2) + VCAP::CloudController::ServiceInstanceLabelModel.make(key_name: 'fruit', value: 'strawberry', service_instance: ssi) + VCAP::CloudController::ServiceInstanceLabelModel.make(key_name: 'fruit', value: 'strawberry', service_instance: upsi_2) + + get '/v3/service_instances?label_selector=fruit=strawberry', nil, admin_headers + + check_filtered_instances( + create_managed_json(msi_1, labels: { fruit: 'strawberry' }), + create_user_provided_json(upsi_2, labels: { fruit: 'strawberry' }), + create_managed_json(ssi, labels: { fruit: 'strawberry' }) + ) + end + + it 'filters by type' do + get '/v3/service_instances?type=managed', nil, admin_headers + check_filtered_instances( + create_managed_json(msi_1), + create_managed_json(msi_2), + create_managed_json(ssi) + ) + end + + it 'filters by service_plan_guids' do + get "/v3/service_instances?service_plan_guids=#{msi_1.service_plan.guid},#{msi_2.service_plan.guid}", nil, admin_headers + check_filtered_instances( + create_managed_json(msi_1), + create_managed_json(msi_2) + ) + end + + it 'filters by service_plan_names' do + get "/v3/service_instances?service_plan_names=#{msi_1.service_plan.name},#{msi_2.service_plan.name}", nil, admin_headers + check_filtered_instances( + create_managed_json(msi_1), + create_managed_json(msi_2) + ) + end + + def check_filtered_instances(*instances) + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].length).to be(instances.length) + expect({ resources: parsed_response['resources'] }).to match_json_response( + { resources: instances } + ) + end + end + + context 'fields' do + it 'can include the space and organization name and guid fields' do + get '/v3/service_instances?fields[space]=guid,name,relationships.organization&fields[space.organization]=name,guid', nil, admin_headers + expect(last_response).to have_status_code(200) + + expect(last_response).to have_status_code(200) + + # Check spaces are included with correct fields + expect(parsed_response['included']['spaces']).to contain_exactly( + { + 'guid' => space.guid, + 'name' => space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => space.organization.guid + } + } + } + }, + { + 'guid' => another_space.guid, + 'name' => another_space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => another_space.organization.guid + } + } + } + } + ) + + # Check organizations are included with correct fields (order not guaranteed) + expect(parsed_response['included']['organizations']).to contain_exactly( + { + 'name' => space.organization.name, + 'guid' => space.organization.guid + }, + { + 'name' => another_space.organization.name, + 'guid' => another_space.organization.guid + } + ) + end + + it 'can include the service plan, offering and broker fields' do + get '/v3/service_instances?fields[service_plan]=guid,name,relationships.service_offering&' \ + 'fields[service_plan.service_offering]=name,guid,description,documentation_url,relationships.service_broker&' \ + 'fields[service_plan.service_offering.service_broker]=name,guid', nil, admin_headers + + expect(last_response).to have_status_code(200) + + # Check service_plans are included with correct fields + expect(parsed_response['included']['service_plans']).to contain_exactly( + { + 'guid' => msi_1.service_plan.guid, + 'name' => msi_1.service_plan.name, + 'relationships' => { + 'service_offering' => { + 'data' => { + 'guid' => msi_1.service_plan.service.guid + } + } + } + }, + { + 'guid' => msi_2.service_plan.guid, + 'name' => msi_2.service_plan.name, + 'relationships' => { + 'service_offering' => { + 'data' => { + 'guid' => msi_2.service_plan.service.guid + } + } + } + }, + { + 'guid' => ssi.service_plan.guid, + 'name' => ssi.service_plan.name, + 'relationships' => { + 'service_offering' => { + 'data' => { + 'guid' => ssi.service_plan.service.guid + } + } + } + } + ) + + # Check service_offerings are included with correct fields (order not guaranteed) + expect(parsed_response['included']['service_offerings']).to contain_exactly( + { + 'name' => msi_1.service_plan.service.name, + 'guid' => msi_1.service_plan.service.guid, + 'description' => msi_1.service_plan.service.description, + 'documentation_url' => 'https://some.url.for.docs/', + 'relationships' => { + 'service_broker' => { + 'data' => { + 'guid' => msi_1.service_plan.service.service_broker.guid + } + } + } + }, + { + 'name' => msi_2.service_plan.service.name, + 'guid' => msi_2.service_plan.service.guid, + 'description' => msi_2.service_plan.service.description, + 'documentation_url' => 'https://some.url.for.docs/', + 'relationships' => { + 'service_broker' => { + 'data' => { + 'guid' => msi_2.service_plan.service.service_broker.guid + } + } + } + }, + { + 'name' => ssi.service_plan.service.name, + 'guid' => ssi.service_plan.service.guid, + 'description' => ssi.service_plan.service.description, + 'documentation_url' => 'https://some.url.for.docs/', + 'relationships' => { + 'service_broker' => { + 'data' => { + 'guid' => ssi.service_plan.service.service_broker.guid + } + } + } + } + ) + + # Check service_brokers are included with correct fields (order not guaranteed) + expect(parsed_response['included']['service_brokers']).to contain_exactly( + { + 'name' => msi_1.service_plan.service.service_broker.name, + 'guid' => msi_1.service_plan.service.service_broker.guid + }, + { + 'name' => msi_2.service_plan.service.service_broker.name, + 'guid' => msi_2.service_plan.service.service_broker.guid + }, + { + 'name' => ssi.service_plan.service.service_broker.name, + 'guid' => ssi.service_plan.service.service_broker.guid + } + ) + end + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::ServiceInstanceListFetcher).to receive(:fetch).with( + an_instance_of(VCAP::CloudController::ServiceInstancesListMessage), + hash_including(eager_loaded_associations: %i[labels annotations space service_instance_operation service_plan_sti_eager_load]) + ).and_call_original + + get '/v3/service_instances', nil, admin_headers + expect(last_response).to have_status_code(200) + end + end + + it_behaves_like 'list_endpoint_with_common_filters' do + let(:resource_klass) { VCAP::CloudController::ServiceInstance } + let(:api_call) do + ->(headers, filters) { get "/v3/service_instances?#{filters}", nil, headers } + end + let(:headers) { admin_headers } + end + end + + describe 'GET /v3/service_instances/:guid/credentials' do + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:api_call) { ->(user_headers) { get "/v3/service_instances/#{guid}/credentials", nil, user_headers } } + let(:credentials) { { 'fake-key' => 'fake-value' } } + let(:instance) { VCAP::CloudController::UserProvidedServiceInstance.make(space:, credentials:) } + let(:guid) { instance.guid } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_object: credentials }.freeze + ) + + h['global_auditor'] = h['space_supporter'] = h['space_manager'] = h['space_auditor'] = h['org_manager'] = { code: 403 } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + end + + it 'responds with an empty obect when no credentials were set' do + upsi = VCAP::CloudController::UserProvidedServiceInstance.make(space: space, credentials: nil) + get "/v3/service_instances/#{upsi.guid}/credentials", nil, admin_headers + expect(last_response).to have_status_code(200) + expect(parsed_response).to match_json_response({}) + end + + it 'responds with 404 when the instance does not exist' do + get '/v3/service_instances/does-not-exist/credentials', nil, admin_headers + expect(last_response).to have_status_code(404) + end + + it 'responds with 404 for a managed service instance' do + msi = VCAP::CloudController::ManagedServiceInstance.make(space:) + get "/v3/service_instances/#{msi.guid}/credentials", nil, admin_headers + expect(last_response).to have_status_code(404) + end + + it 'records an audit event' do + upsi = VCAP::CloudController::UserProvidedServiceInstance.make(space: space, credentials: {}) + get "/v3/service_instances/#{upsi.guid}/credentials", nil, space_dev_headers + expect(last_response).to have_status_code(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.user_provided_service_instance.show', + actor: user.guid, + actee: upsi.guid, + actee_type: 'user_provided_service_instance', + actee_name: upsi.name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + end + + describe 'GET /v3/service_instances/:guid/parameters' do + let(:service) { VCAP::CloudController::Service.make(instances_retrievable: true) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service:) } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space:, service_plan:) } + let(:body) { {}.to_json } + let(:response_code) { 200 } + + before do + stub_request(:get, %r{#{instance.service.service_broker.broker_url}/v2/service_instances/#{guid_pattern}}). + with(basic_auth: basic_auth(service_broker: instance.service.service_broker)). + to_return(status: response_code, body: body) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:api_call) { ->(user_headers) { get "/v3/service_instances/#{guid}/parameters", nil, user_headers } } + let(:parameters) { { 'some-key' => 'some-value' } } + let(:body) { { 'parameters' => parameters }.to_json } + let(:guid) { instance.guid } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_object: parameters }.freeze + ) + + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + end + + context 'when the service broker returns parameters with mixed data types' do + let(:body) { "{\"parameters\":#{parameters_mixed_data_types_as_json_string}}" } + + it 'correctly parses all data types and returns the desired JSON string' do + allow_any_instance_of(VCAP::CloudController::ServiceInstanceRead).to receive(:fetch_parameters).and_wrap_original do |m, instance| + result = m.call(instance) + expect(result).to eq(parameters_mixed_data_types_as_hash) # correct internal representation + result + end + + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(200) + expect(last_response).to match(/#{Regexp.escape(parameters_mixed_data_types_as_json_string)}/) + end + end + + it 'sends the correct request to the service broker' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, headers_for(user, scopes: %w[cloud_controller.admin]) + + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + expect(a_request(:get, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with( + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + )).to have_been_made.once + end + + context 'when the instance does not support retrievable instances' do + let(:service) { VCAP::CloudController::Service.make(instances_retrievable: false) } + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(400) + expect(parsed_response['errors']).to include(include({ + 'detail' => 'This service does not support fetching service instance parameters.', + 'title' => 'CF-ServiceFetchInstanceParametersNotSupported', + 'code' => 120_004 + })) + end + end + + context 'when the broker returns no parameters' do + it 'returns an empty object' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(200) + expect(parsed_response).to match_json_response({}) + end + end + + context 'when the broker returns invalid parameters' do + let(:body) { { 'parameters' => 'not valid' }.to_json } + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(502) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-ServiceBrokerResponseMalformed', + 'code' => 10_001 + })) + end + end + + context 'when the broker returns invalid JSON' do + let(:body) { 'this is not json' } + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(502) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-ServiceBrokerResponseMalformed', + 'code' => 10_001 + })) + end + end + + context 'when the broker returns a non-200 response code' do + let(:response_code) { 500 } + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(502) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-ServiceBrokerBadResponse', + 'code' => 10_001 + })) + end + end + + context 'when the broker returns a 422 (update in progress) response code' do + let(:response_code) { 422 } + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(502) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-ServiceBrokerBadResponse', + 'code' => 10_001 + })) + end + end + + context 'when the instance is shared' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:api_call) { ->(user_headers) { get "/v3/service_instances/#{guid}/parameters", nil, user_headers } } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(space: another_space, service_plan: service_plan) } + let(:parameters) { { 'some-key' => 'some-value' } } + let(:body) { { 'parameters' => parameters }.to_json } + let(:guid) { instance.guid } + + before do + instance.add_shared_space(space) + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_object: parameters }.freeze + ) + + h['space_supporter'] = h['space_developer'] = h['space_manager'] = h['space_auditor'] = h['org_manager'] = { code: 403 } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + end + end + + context 'when the instance does not exist' do + it 'responds with 404' do + get '/v3/service_instances/does-not-exist/parameters', nil, admin_headers + expect(last_response).to have_status_code(404) + end + end + + context 'when the last operation state of the service instance is create in progress' do + before do + instance.save_with_new_operation({}, { type: 'create', state: 'in progress' }) + end + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(409) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-AsyncServiceInstanceOperationInProgress', + 'code' => 60_016 + })) + end + end + + context 'when the last operation state of the service instance is create succeeded' do + before do + instance.save_with_new_operation({}, { type: 'create', state: 'succeeded' }) + end + + it 'returns the parameters' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(200) + end + end + + context 'when the last operation state of the service instance is create failed' do + before do + instance.save_with_new_operation({}, { type: 'create', state: 'failed' }) + end + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-ResourceNotFound', + 'code' => 10_010 + })) + end + end + + context 'when the last operation state of the service instance is update in progress' do + before do + instance.save_with_new_operation({}, { type: 'update', state: 'in progress' }) + end + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(409) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-AsyncServiceInstanceOperationInProgress', + 'code' => 60_016 + })) + end + end + + context 'when the last operation state of the service instance is update succeeded' do + before do + instance.save_with_new_operation({}, { type: 'update', state: 'succeeded' }) + end + + it 'returns the parameters' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(200) + end + end + + context 'when the last operation state of the service instance is update failed' do + before do + instance.save_with_new_operation({}, { type: 'update', state: 'failed' }) + end + + it 'returns the parameters' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(200) + end + end + + context 'when the last operation state of the service instance is delete in progress' do + before do + instance.save_with_new_operation({}, { type: 'delete', state: 'in progress' }) + end + + it 'fails with an explanatory error' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(409) + expect(parsed_response['errors']).to include(include({ + 'title' => 'CF-AsyncServiceInstanceOperationInProgress', + 'code' => 60_016 + })) + end + end + + context 'when the last operation state of the service instance is delete failed' do + before do + instance.save_with_new_operation({}, { type: 'delete', state: 'failed' }) + end + + it 'returns the parameters' do + get "/v3/service_instances/#{instance.guid}/parameters", nil, admin_headers + expect(last_response).to have_status_code(200) + end + end + + context 'when the instance is user-provided' do + it 'responds with 404' do + upsi = VCAP::CloudController::UserProvidedServiceInstance.make(space:) + + get "/v3/service_instances/#{upsi.guid}/parameters", nil, admin_headers + + expect(last_response).to have_status_code(400) + expect(parsed_response['errors']).to include(include({ + 'detail' => 'This service does not support fetching service instance parameters.', + 'title' => 'CF-ServiceFetchInstanceParametersNotSupported', + 'code' => 120_004 + })) + end + end + end +end diff --git a/spec/request/service_instances_spec.rb b/spec/request/service_instances_spec.rb.original similarity index 100% rename from spec/request/service_instances_spec.rb rename to spec/request/service_instances_spec.rb.original diff --git a/spec/request/service_instances_update_spec.rb b/spec/request/service_instances_update_spec.rb new file mode 100644 index 0000000000..82db41587c --- /dev/null +++ b/spec/request/service_instances_update_spec.rb @@ -0,0 +1,1283 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'V3 service instances - Update' do + include_context 'service instances setup' + + describe 'PATCH /v3/service_instances/:guid' do + let(:api_call) { ->(user_headers) { patch "/v3/service_instances/#{guid}", request_body.to_json, user_headers } } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let(:request_body) do + {} + end + + context 'permissions' do + let(:guid) { VCAP::CloudController::ServiceInstance.make(space:).guid } + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) { responses_for_space_restricted_update_endpoint(success_code: 200) } + end + + it_behaves_like 'permissions for update endpoint when organization is suspended', 200 + end + + context 'service instance does not exist' do + let(:guid) { 'no-such-instance' } + + it 'fails saying the service instance is not found (404)' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Service instance not found', + 'title' => 'CF-ResourceNotFound', + 'code' => 10_010 + }) + ) + end + end + + context 'managed service instance' do + describe 'updates that do not require broker communication' do + let!(:service_instance) do + si = VCAP::CloudController::ManagedServiceInstance.make( + guid: 'bommel', + tags: %w[foo bar], + space: space + ) + VCAP::CloudController::ServiceInstanceAnnotationModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'to_delete', value: 'value') + VCAP::CloudController::ServiceInstanceAnnotationModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'fox', value: 'bushy') + VCAP::CloudController::ServiceInstanceLabelModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'to_delete', value: 'value') + VCAP::CloudController::ServiceInstanceLabelModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'tail', value: 'fluffy') + si + end + + let(:guid) { service_instance.guid } + + let(:request_body) do + { + tags: %w[baz quz], + metadata: { + labels: { + potato: 'yam', + style: 'baked', + 'pre.fix/to_delete': nil + }, + annotations: { + potato: 'idaho', + style: 'mashed', + 'pre.fix/to_delete': nil + } + } + } + end + + it 'responds synchronously' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(200) + expect(parsed_response).to match_json_response( + create_managed_json( + service_instance, + labels: { + potato: 'yam', + style: 'baked', + 'pre.fix/tail': 'fluffy' + }, + annotations: { + potato: 'idaho', + style: 'mashed', + 'pre.fix/fox': 'bushy' + }, + last_operation: { + created_at: iso8601, + updated_at: iso8601, + description: nil, + state: 'succeeded', + type: 'update' + }, + tags: %w[baz quz] + ) + ) + end + + it 'updates the service instance' do + api_call.call(space_dev_headers) + + service_instance.reload + expect(service_instance.tags).to eq(%w[baz quz]) + + expect(service_instance).to have_annotations( + { prefix: 'pre.fix', key_name: 'fox', value: 'bushy' }, + { prefix: nil, key_name: 'potato', value: 'idaho' }, + { prefix: nil, key_name: 'style', value: 'mashed' } + ) + expect(service_instance).to have_labels( + { prefix: 'pre.fix', key_name: 'tail', value: 'fluffy' }, + { prefix: nil, key_name: 'potato', value: 'yam' }, + { prefix: nil, key_name: 'style', value: 'baked' } + ) + + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('succeeded') + end + end + + describe 'updates that require broker communication' do + # These tests verify broker request context includes org/space annotations + let!(:org_annotation) { VCAP::CloudController::OrganizationAnnotationModel.make(key_prefix: 'pre.fix', key_name: 'foo', value: 'bar', resource_guid: org.guid) } + let!(:space_annotation) { VCAP::CloudController::SpaceAnnotationModel.make(key_prefix: 'pre.fix', key_name: 'baz', value: 'wow', space: space) } + + let(:service_offering) { VCAP::CloudController::Service.make } + let(:original_service_plan) do + VCAP::CloudController::ServicePlan.make( + service: service_offering, + plan_updateable: true, + maintenance_info: { version: '1.1.1' } + ) + end + let(:new_service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering) } + let(:original_maintenance_info) { { version: '1.1.0' } } + let!(:service_instance) do + si = VCAP::CloudController::ManagedServiceInstance.make( + guid: 'bommel', + tags: %w[foo bar], + space: space, + service_plan: original_service_plan, + maintenance_info: original_maintenance_info + ) + VCAP::CloudController::ServiceInstanceAnnotationModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'to_delete', value: 'value') + VCAP::CloudController::ServiceInstanceAnnotationModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'fox', value: 'bushy') + VCAP::CloudController::ServiceInstanceLabelModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'to_delete', value: 'value') + VCAP::CloudController::ServiceInstanceLabelModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'tail', value: 'fluffy') + si + end + let(:guid) { service_instance.guid } + let(:request_body) do + { + name: 'new-name', + relationships: { + service_plan: { + data: { + guid: new_service_plan.guid + } + } + }, + parameters: { + foo: 'bar', + baz: 'qux' + }, + tags: %w[baz quz], + metadata: { + labels: { + potato: 'yam', + style: 'baked', + 'pre.fix/to_delete': nil + }, + annotations: { + potato: 'idaho', + style: 'mashed', + 'pre.fix/to_delete': nil + } + } + } + end + let(:job) { VCAP::CloudController::PollableJobModel.last } + let(:mock_logger) { instance_double(Steno::Logger, info: nil) } + + before do + allow(Steno).to receive(:logger).and_call_original + allow(Steno).to receive(:logger).with('cc.api').and_return(mock_logger) + end + + context 'when providing parameters with mixed data types' do + let(:request_body) do + "{\"parameters\":#{parameters_mixed_data_types_as_json_string}}" + end + let(:instance) { VCAP::CloudController::ServiceInstance.last } + + it 'correctly parses all data types and sends the desired JSON string to the service broker' do + patch "/v3/service_instances/#{guid}", request_body, space_dev_headers + + expect_any_instance_of(VCAP::Services::ServiceBrokers::V2::Client).to receive(:update). + with(instance, instance.service_plan, hash_including(arbitrary_parameters: parameters_mixed_data_types_as_hash)). # correct internal representation + and_call_original + + stub_request(:patch, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with(query: { 'accepts_incomplete' => true }, body: /"parameters":#{Regexp.escape(parameters_mixed_data_types_as_json_string)}/). + to_return(status: 200, body: '{}') + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'responds with a pollable job' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(202) + expect(last_response.headers['Location']).to end_with("/v3/jobs/#{job.guid}") + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::PROCESSING_STATE) + expect(job.operation).to eq('service_instance.update') + expect(job.resource_guid).to eq(service_instance.guid) + expect(job.resource_type).to eq('service_instances') + end + + it 'updates the last operation' do + api_call.call(space_dev_headers) + + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('in progress') + end + + it 'does not immediately update the service instance' do + api_call.call(space_dev_headers) + + service_instance.reload + expect(service_instance.reload.tags).to eq(%w[foo bar]) + + expect(service_instance).to have_annotations( + { prefix: 'pre.fix', key_name: 'to_delete', value: 'value' }, + { prefix: 'pre.fix', key_name: 'fox', value: 'bushy' } + ) + expect(service_instance).to have_labels( + { prefix: 'pre.fix', key_name: 'to_delete', value: 'value' }, + { prefix: 'pre.fix', key_name: 'tail', value: 'fluffy' } + ) + end + + describe 'logging of updates' do + it 'logs info including the change of service plans' do + api_call.call(space_dev_headers) + + expect(mock_logger).to have_received(:info).with( + "Updating managed service instance with name '#{service_instance.name}' " \ + "changing plan from '#{original_service_plan.name}' to '#{new_service_plan.name}' " \ + "from service offering '#{service_offering.name}' " \ + "provided by broker '#{original_service_plan.service.service_broker.name}'." + ) + end + + context 'when service plan does not change' do + let(:request_body) do + { + parameters: { + foo: 'bar', + baz: 'qux' + } + } + end + + it 'logs info accordingly' do + api_call.call(space_dev_headers) + + expect(mock_logger).to have_received(:info).with( + "Updating managed service instance with name '#{service_instance.name}' " \ + "using service plan '#{original_service_plan.name}' " \ + "from service offering '#{service_offering.name}' " \ + "provided by broker '#{original_service_plan.service.service_broker.name}'." + ) + end + end + end + + describe 'the pollable job' do + let(:broker_response) { { dashboard_url: 'http://new-dashboard.url' } } + let(:broker_status_code) { 200 } + + before do + api_call.call(space_dev_headers) + + instance = VCAP::CloudController::ServiceInstance.last + + stub_request(:patch, "#{instance.service_broker.broker_url}/v2/service_instances/#{instance.guid}"). + with(query: { 'accepts_incomplete' => true }). + to_return(status: broker_status_code, body: broker_response.to_json, headers: {}) + end + + it 'sends a UPDATE request with the right arguments to the service broker' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + expect( + a_request(:patch, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}"). + with( + query: { accepts_incomplete: true }, + body: { + service_id: new_service_plan.service.unique_id, + plan_id: new_service_plan.unique_id, + previous_values: { + plan_id: original_service_plan.unique_id, + service_id: original_service_plan.service.unique_id, + organization_id: org.guid, + space_id: space.guid, + maintenance_info: { version: '1.1.0' } + }, + context: { + platform: 'cloudfoundry', + organization_guid: org.guid, + organization_name: org.name, + organization_annotations: { 'pre.fix/foo': 'bar' }, + space_guid: space.guid, + space_name: space.name, + space_annotations: { 'pre.fix/baz': 'wow' }, + instance_name: 'new-name' + }, + parameters: { + foo: 'bar', + baz: 'qux' + } + }, + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + ) + ).to have_been_made.once + end + + context 'when the update completes synchronously' do + it 'marks the service instance as updated' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + service_instance.reload + expect(service_instance.dashboard_url).to eq('http://new-dashboard.url') + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('succeeded') + expect(service_instance.maintenance_info).to eq(new_service_plan.maintenance_info) + end + + it 'marks the job as complete' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + + context 'when the broker responds with an error' do + let(:broker_status_code) { 400 } + + it 'marks the service instance as failed' do + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('failed') + expect(service_instance.last_operation.description).to include('Status Code: 400 Bad Request') + end + + it 'marks the job as failed' do + execute_all_jobs(expected_successes: 0, expected_failures: 1) + + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + end + end + + context 'when the update is asynchronous' do + let(:broker_status_code) { 202 } + let(:broker_response) { { operation: 'task12' } } + let(:last_operation_status_code) { 200 } + let(:last_operation_response) { { state: 'in progress' } } + let(:dashboard_url) {} + + before do + stub_request(:get, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_instance.service_plan.service.unique_id, + plan_id: service_instance.service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}) + + stub_request(:get, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}"). + to_return(status: 200, body: { dashboard_url: }.to_json) + end + + it 'marks the job state as polling' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + end + + it 'calls last operation immediately' do + encoded_user_guid = Base64.strict_encode64("{\"user_id\":\"#{user.guid}\"}") + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect( + a_request(:get, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_instance.service_plan.service.unique_id, + plan_id: service_instance.service_plan.unique_id + }, + headers: { 'X-Broker-Api-Originating-Identity' => "cloudfoundry #{encoded_user_guid}" } + ) + ).to have_been_made.once + end + + it 'enqueues the next fetch last operation job' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(Delayed::Job.count).to eq(1) + end + + context 'when last operation eventually returns `update succeeded`' do + let(:last_operation_status_code) { 200 } + let(:last_operation_response) { { state: 'in progress' } } + + before do + stub_request(:get, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_instance.service_plan.service.unique_id, + plan_id: service_instance.service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 200, body: { state: 'succeeded' }.to_json, headers: {}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::COMPLETE_STATE) + end + + it 'sets the service instance last operation to create succeeded' do + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('succeeded') + end + + context 'it fetches dashboard url' do + let(:service_offering) { VCAP::CloudController::Service.make(instances_retrievable: true) } + let(:dashboard_url) { 'http:/some-new-dashboard-url.com' } + + it 'sets the service instance dashboard url' do + service_instance.reload + expect(service_instance.dashboard_url).to eq(dashboard_url) + end + end + end + + context 'when last operation eventually returns `update failed`' do + before do + stub_request(:get, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_instance.service_plan.service.unique_id, + plan_id: service_instance.service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 200, body: { state: 'failed' }.to_json, headers: {}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 0, expected_failures: 1, jobs_to_execute: 1) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + + it 'sets the service instance last operation to update failed' do + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('failed') + end + end + + context 'when last operation eventually returns error 400' do + before do + stub_request(:get, "#{service_instance.service_broker.broker_url}/v2/service_instances/#{service_instance.guid}/last_operation"). + with( + query: { + operation: 'task12', + service_id: service_instance.service_plan.service.unique_id, + plan_id: service_instance.service_plan.unique_id + } + ). + to_return(status: last_operation_status_code, body: last_operation_response.to_json, headers: {}).times(1).then. + to_return(status: 400, body: {}.to_json, headers: {}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + expect(job.state).to eq(VCAP::CloudController::PollableJobModel::POLLING_STATE) + + Timecop.freeze(Time.now + 1.hour) do + execute_all_jobs(expected_successes: 0, expected_failures: 1, jobs_to_execute: 1) + end + end + + it 'completes the job' do + updated_job = VCAP::CloudController::PollableJobModel.find(guid: job.guid) + expect(updated_job.state).to eq(VCAP::CloudController::PollableJobModel::FAILED_STATE) + end + + it 'sets the service instance last operation to update failed' do + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('failed') + end + + it 'does not update the instance' do + # TODO maybe look in the client to add this test and make sure what it returns? so we can test at a unit level in the job as well + service_instance.reload + expect(service_instance.reload.tags).to eq(%w[foo bar]) + expect(service_instance.service_plan).to eq(original_service_plan) + expect(service_instance).to have_annotations( + { prefix: 'pre.fix', key_name: 'to_delete', value: 'value' }, + { prefix: 'pre.fix', key_name: 'fox', value: 'bushy' } + ) + expect(service_instance).to have_labels( + { prefix: 'pre.fix', key_name: 'to_delete', value: 'value' }, + { prefix: 'pre.fix', key_name: 'tail', value: 'fluffy' } + ) + end + + context 'when changing maintenance_info' do + let(:request_body) do + { + maintenance_info: { version: '1.1.1' } + } + end + + it 'does not update the instance' do + service_instance.reload + expect(service_instance.maintenance_info.symbolize_keys).to eq(original_maintenance_info) + end + end + end + end + + context 'changing maintenance_info alongside other parameters' do + let(:new_maintenance_info) { { version: '1.1.1' } } + let(:request_body) do + { + name: 'new-name', + maintenance_info: new_maintenance_info, + tags: %w[baz quz] + } + end + + it 'modifies the instance' do + execute_all_jobs(expected_successes: 1, expected_failures: 0) + + service_instance.reload + expect(service_instance.maintenance_info.symbolize_keys).to eq(new_maintenance_info) + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('succeeded') + expect(service_instance.name).to eq('new-name') + expect(service_instance.tags).to include('baz', 'quz') + end + end + end + + context 'database disconnect error during creation of pollable job' do + before do + allow(VCAP::CloudController::PollableJobModel).to receive(:create).and_raise(Sequel::DatabaseDisconnectError) + end + + it 'sets the last operation to failed' do + api_call.call(space_dev_headers) + + service_instance.reload + expect(service_instance.last_operation.type).to eq('update') + expect(service_instance.last_operation.state).to eq('failed') + end + end + end + + describe 'no changes requested' do + let!(:service_instance) do + si = VCAP::CloudController::ManagedServiceInstance.make( + tags: %w[foo bar], + space: space + ) + si + end + + let(:guid) { service_instance.guid } + + it 'updates the instance synchronously' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(200) + expect(parsed_response).to match_json_response( + create_managed_json( + service_instance, + last_operation: { + created_at: iso8601, + updated_at: iso8601, + description: nil, + state: 'succeeded', + type: 'update' + }, + tags: %w[foo bar] + ) + ) + end + end + + describe 'maintenance_info checks' do + let!(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make( + space:, + service_plan: + ) + end + let(:guid) { service_instance.guid } + + context 'changing maintenance_info when the plan does not support it' do + let(:service_offering) { VCAP::CloudController::Service.make(plan_updateable: true) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: true, active: true, service: service_offering) } + let(:service_plan_guid) { service_plan.guid } + + let(:request_body) do + { + maintenance_info: { + version: '3.1.0' + } + } + end + + it 'fails with a descriptive message' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include( + { + 'title' => 'CF-UnprocessableEntity', + 'detail' => 'The service broker does not support upgrades for service instances created from this plan.', + 'code' => 10_008 + } + ) + ) + end + end + + context 'maintenance_info conflict' do + let(:service_offering) { VCAP::CloudController::Service.make(plan_updateable: true) } + let(:service_plan) do + VCAP::CloudController::ServicePlan.make( + public: true, + active: true, + service: service_offering, + maintenance_info: { version: '2.1.0' } + ) + end + let(:service_plan_guid) { service_plan.guid } + + let(:request_body) do + { + maintenance_info: { + version: '2.2.0' + } + } + end + + it 'fails with a descriptive message' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include( + { + 'title' => 'CF-UnprocessableEntity', + 'detail' => include('maintenance_info.version requested is invalid'), + 'code' => 10_008 + } + ) + ) + end + end + + context 'changing maintenance_info alongside plan' do + let(:service_offering) { VCAP::CloudController::Service.make(plan_updateable: true) } + let(:service_plan) do + VCAP::CloudController::ServicePlan.make( + public: true, + active: true, + service: service_offering, + maintenance_info: { version: '2.2.0' } + ) + end + + let(:new_service_plan) do + VCAP::CloudController::ServicePlan.make( + public: true, + active: true, + service: service_offering, + maintenance_info: { version: '2.1.0' } + ) + end + + let(:new_service_plan_guid) { new_service_plan.guid } + + let(:request_body) do + { + maintenance_info: { + version: '2.2.0' + }, + relationships: { + service_plan: { + data: { + guid: new_service_plan_guid + } + } + } + } + end + + it 'fails with a descriptive message' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include( + { + 'title' => 'CF-UnprocessableEntity', + 'detail' => include('maintenance_info should not be changed when switching to different plan.'), + 'code' => 10_008 + } + ) + ) + end + end + end + + describe 'service plan checks' do + let!(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make( + tags: %w[foo bar], + space: space + ) + end + let(:guid) { service_instance.guid } + + let(:request_body) do + { + relationships: { + service_plan: { + data: { + guid: service_plan_guid + } + } + } + } + end + + context 'does not exist' do + let(:service_plan_guid) { 'does-not-exist' } + + it 'fails saying the plan is invalid' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. Ensure that the service plan exists, is available, and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'not readable by the user' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: false, active: true) } + let(:service_plan_guid) { service_plan.guid } + + it 'fails saying the plan is invalid' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. Ensure that the service plan exists, is available, and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'not enabled in that org' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: false, active: true) } + let(:service_plan_guid) { service_plan.guid } + + it 'fails saying the plan is invalid' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. This could be due to a space-scoped broker which is offering the service plan ' \ + "'#{service_plan.name}' with guid '#{service_plan.guid}' in another space or that the plan " \ + 'is not enabled in this organization. Ensure that the service plan is visible in your current space ' \ + "'#{space.name}' with guid '#{space.guid}'.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'not available' do + let(:service_plan) { VCAP::CloudController::ServicePlan.make(public: true, active: false) } + let(:service_plan_guid) { service_plan.guid } + + it 'fails saying the plan is invalid' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "Invalid service plan. The service plan '#{service_plan.name}' with guid '#{service_plan.guid}' " \ + "has been removed from the service broker's catalog. " \ + 'It is not possible to create new service instances using this plan.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'space-scoped plan from a different space' do + let(:service_broker) { VCAP::CloudController::ServiceBroker.make(space: another_space) } + let(:service_offering) { VCAP::CloudController::Service.make(service_broker:) } + let(:service_plan) { VCAP::CloudController::ServicePlan.make(service: service_offering, active: true, public: false) } + let(:service_plan_guid) { service_plan.guid } + + it 'fails saying the plan is invalid' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Invalid service plan. Ensure that the service plan exists, is available, and you have access to it.', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'relates to a different service offering' do + let(:service_plan_guid) { VCAP::CloudController::ServicePlan.make.guid } + + it 'fails saying the plan relates to a different service offering' do + api_call.call(admin_headers) + + expect(last_response).to have_status_code(400) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'service plan relates to a different service offering', + 'title' => 'CF-InvalidRelation', + 'code' => 1002 + }) + ) + end + end + end + + describe 'name checks' do + context 'name is already used in this space' do + let(:guid) { service_instance.guid } + let!(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make( + tags: %w[foo bar], + space: space + ) + end + + let!(:name) { 'test' } + let!(:other_si) { VCAP::CloudController::ServiceInstance.make(name:, space:) } + let(:request_body) { { name: } } + + it 'fails' do + api_call.call(admin_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "The service instance name is taken: #{name}", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + + describe 'invalid request' do + let!(:service_instance) do + si = VCAP::CloudController::ManagedServiceInstance.make( + tags: %w[foo bar], + space: space + ) + si + end + + let(:guid) { service_instance.guid } + let(:request_body) do + { + relationships: { + space: { + data: { + guid: 'some-space' + } + } + } + } + end + + it 'fails' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => include("Relationships Unknown field(s): 'space'"), + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + describe 'when the SI plan is no longer active' do + let(:version) { { version: '2.0.0' } } + let(:service_offering) { VCAP::CloudController::Service.make } + let(:service_plan) do + VCAP::CloudController::ServicePlan.make( + public: true, + active: false, + maintenance_info: version, + service: service_offering + ) + end + let!(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make(space:, service_plan:) + end + let(:guid) { service_instance.guid } + + context 'and the request is updating parameters' do + let(:request_body) { { parameters: { foo: 'bar', baz: 'qux' } } } + + it 'fails with a plan inaccessible message' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Cannot update parameters of a service instance that belongs to inaccessible plan', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'and the request is updating maintenance_info' do + let(:request_body) { { maintenance_info: { version: '2.0.0' } } } + + it 'fails with a plan inaccessible message' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Cannot update maintenance_info of a service instance that belongs to inaccessible plan', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'and the request is updating the SI name' do + let(:request_body) { { name: 'new-name' } } + + context 'and the service offering allows contextual updates' do + let(:service_offering) { VCAP::CloudController::Service.make(allow_context_updates: true) } + + it 'fails with a plan inaccessible message' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => 'Cannot update name of a service instance that belongs to inaccessible plan', + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'but the service offering does not allow contextual updates' do + let(:service_offering) { VCAP::CloudController::Service.make(allow_context_updates: false) } + + it 'succeeds' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(200) + end + end + end + end + end + + context 'user-provided service instance' do + let!(:service_instance) do + si = VCAP::CloudController::UserProvidedServiceInstance.make( + guid: 'bommel', + space: space, + name: 'foo', + credentials: { + foo: 'bar', + baz: 'qux' + }, + syslog_drain_url: 'https://foo.com', + route_service_url: 'https://bar.com', + tags: %w[accounting mongodb] + ) + VCAP::CloudController::ServiceInstanceAnnotationModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'to_delete', value: 'value') + VCAP::CloudController::ServiceInstanceAnnotationModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'fox', value: 'bushy') + VCAP::CloudController::ServiceInstanceLabelModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'to_delete', value: 'value') + VCAP::CloudController::ServiceInstanceLabelModel.make(service_instance: si, key_prefix: 'pre.fix', key_name: 'tail', value: 'fluffy') + si + end + + let(:guid) { service_instance.guid } + let(:new_name) { 'my_service_instance' } + + let(:request_body) do + { + name: new_name, + credentials: { + used_in: 'bindings', + foo: 'bar' + }, + syslog_drain_url: 'https://foo2.com', + route_service_url: 'https://bar2.com', + tags: %w[accounting couchbase nosql], + metadata: { + labels: { + foo: 'bar', + 'pre.fix/to_delete': nil + }, + annotations: { + alpha: 'beta', + 'pre.fix/to_delete': nil + } + } + } + end + + it 'allows updates' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(200) + + expect(parsed_response).to match_json_response( + create_user_provided_json( + service_instance.reload, + labels: { + foo: 'bar', + 'pre.fix/tail': 'fluffy' + }, + annotations: { + alpha: 'beta', + 'pre.fix/fox': 'bushy' + }, + last_operation: { + type: 'update', + state: 'succeeded', + description: 'Operation succeeded', + created_at: iso8601, + updated_at: iso8601 + } + ) + ) + end + + it 'updates the a service instance in the database' do + api_call.call(space_dev_headers) + + instance = VCAP::CloudController::ServiceInstance.last + + expect(instance.name).to eq(new_name) + expect(instance.syslog_drain_url).to eq('https://foo2.com') + expect(instance.route_service_url).to eq('https://bar2.com') + expect(instance.tags).to contain_exactly('accounting', 'couchbase', 'nosql') + expect(instance.space).to eq(space) + expect(instance.last_operation.type).to eq('update') + expect(instance.last_operation.state).to eq('succeeded') + expect(instance).to have_labels({ prefix: 'pre.fix', key_name: 'tail', value: 'fluffy' }, { prefix: nil, key_name: 'foo', value: 'bar' }) + expect(instance).to have_annotations({ prefix: 'pre.fix', key_name: 'fox', value: 'bushy' }, { prefix: nil, key_name: 'alpha', value: 'beta' }) + end + + context 'when the request is invalid' do + let(:request_body) do + { + guid: Sham.guid + } + end + + it 'is rejected' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => include("Unknown field(s): 'guid'"), + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + + context 'when the name is already taken' do + let!(:duplicate_name) { VCAP::CloudController::UserProvidedServiceInstance.make(space: space, name: new_name) } + + let(:request_body) do + { + name: new_name + } + end + + it 'is rejected' do + api_call.call(space_dev_headers) + expect(last_response).to have_status_code(422) + expect(parsed_response['errors']).to include( + include({ + 'detail' => "The service instance name is taken: #{new_name}.", + 'title' => 'CF-UnprocessableEntity', + 'code' => 10_008 + }) + ) + end + end + end + + context 'when an operation is in progress' do + let(:service_instance) do + si = VCAP::CloudController::ManagedServiceInstance.make( + space: + ) + si + end + let(:guid) { service_instance.guid } + let(:request_body) do + { + metadata: { + labels: { unit: 'metre', distance: '1003' }, + annotations: { location: 'london' } + } + } + end + + context 'and it is a create operation' do + before do + service_instance.save_with_new_operation({}, { type: 'create', state: 'in progress', description: 'almost there, I promise' }) + end + + context 'and the update contains metadata only' do + it 'updates the metadata' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(200) + expect(parsed_response.dig('metadata', 'labels')).to eq({ 'unit' => 'metre', 'distance' => '1003' }) + expect(parsed_response.dig('metadata', 'annotations')).to eq({ 'location' => 'london' }) + end + + it 'does not update the service instance last operation' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(200) + expect(parsed_response['last_operation']).to include({ + 'type' => 'create', + 'state' => 'in progress', + 'description' => 'almost there, I promise' + }) + end + end + + context 'and the update contains more than just metadata' do + it 'returns an error' do + request_body[:name] = 'new-name' + api_call.call(admin_headers) + expect(last_response).to have_status_code(409) + response = parsed_response['errors'].first + expect(response).to include('title' => 'CF-AsyncServiceInstanceOperationInProgress') + expect(response).to include('detail' => include("An operation for service instance #{service_instance.name} is in progress")) + end + end + end + + context 'and it is an update operation' do + before do + service_instance.save_with_new_operation({}, { type: 'update', state: 'in progress', description: 'almost there, I promise' }) + end + + context 'and the update contains metadata only' do + it 'updates the metadata' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(200) + expect(parsed_response.dig('metadata', 'labels')).to eq({ 'unit' => 'metre', 'distance' => '1003' }) + expect(parsed_response.dig('metadata', 'annotations')).to eq({ 'location' => 'london' }) + end + + it 'does not update the service instance last operation' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(200) + expect(parsed_response['last_operation']).to include({ + 'type' => 'update', + 'state' => 'in progress', + 'description' => 'almost there, I promise' + }) + end + end + + context 'and the update contains more than just metadata' do + it 'returns an error' do + request_body[:name] = 'new-name' + api_call.call(admin_headers) + expect(last_response).to have_status_code(409) + response = parsed_response['errors'].first + expect(response).to include('title' => 'CF-AsyncServiceInstanceOperationInProgress') + expect(response).to include('detail' => include("An operation for service instance #{service_instance.name} is in progress")) + end + end + end + + context 'and it is a delete operation' do + before do + service_instance.save_with_new_operation({}, { type: 'delete', state: 'in progress', description: 'almost there, I promise' }) + end + + context 'and the update contains metadata only' do + it 'returns an error' do + api_call.call(admin_headers) + expect(last_response).to have_status_code(409) + response = parsed_response['errors'].first + expect(response).to include('title' => 'CF-AsyncServiceInstanceOperationInProgress') + expect(response).to include('detail' => include("An operation for service instance #{service_instance.name} is in progress")) + end + end + + context 'and the update contains more than just metadata' do + it 'returns an error' do + request_body[:name] = 'new-name' + api_call.call(admin_headers) + expect(last_response).to have_status_code(409) + response = parsed_response['errors'].first + expect(response).to include('title' => 'CF-AsyncServiceInstanceOperationInProgress') + expect(response).to include('detail' => include("An operation for service instance #{service_instance.name} is in progress")) + end + end + end + end + end +end diff --git a/spec/support/shared_contexts/service_instances_context.rb b/spec/support/shared_contexts/service_instances_context.rb new file mode 100644 index 0000000000..cb48b1dd8d --- /dev/null +++ b/spec/support/shared_contexts/service_instances_context.rb @@ -0,0 +1,142 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.shared_context 'service instances setup' do + let(:user) { VCAP::CloudController::User.make } + let(:space) { VCAP::CloudController::Space.make } + let(:org) { space.organization } + let(:another_space) { VCAP::CloudController::Space.make } + + # Only create annotations in tests that actually need them + # Most tests don't check annotations, so don't create them by default + + let(:parameters_mixed_data_types_as_json_string) do + '{"boolean":true,"string":"a string","int":123,"float":3.14159,"optional":null,"object":{"a":"b"},"array":["c","d"]}' + end + let(:parameters_mixed_data_types_as_hash) do + { + boolean: true, + string: 'a string', + int: 123, + float: 3.14159, + optional: nil, + object: { a: 'b' }, + array: %w[c d] + } + end + + def create_managed_json(instance, labels: {}, annotations: {}, last_operation: {}, tags: []) + { + guid: instance.guid, + name: instance.name, + created_at: iso8601, + updated_at: iso8601, + type: 'managed', + dashboard_url: nil, + last_operation: last_operation, + maintenance_info: {}, + upgrade_available: false, + tags: tags, + metadata: { + labels:, + annotations: + }, + relationships: { + space: { + data: { + guid: instance.space.guid + } + }, + service_plan: { + data: { + guid: instance.service_plan.guid + } + } + }, + links: { + self: { + href: "#{link_prefix}/v3/service_instances/#{instance.guid}" + }, + space: { + href: "#{link_prefix}/v3/spaces/#{instance.space.guid}" + }, + service_plan: { + href: "#{link_prefix}/v3/service_plans/#{instance.service_plan.guid}" + }, + parameters: { + href: "#{link_prefix}/v3/service_instances/#{instance.guid}/parameters" + }, + service_credential_bindings: { + href: "#{link_prefix}/v3/service_credential_bindings?service_instance_guids=#{instance.guid}" + }, + service_route_bindings: { + href: "#{link_prefix}/v3/service_route_bindings?service_instance_guids=#{instance.guid}" + }, + shared_spaces: { + href: "#{link_prefix}/v3/service_instances/#{instance.guid}/relationships/shared_spaces" + } + } + } + end + + def create_user_provided_json(instance, labels: {}, annotations: {}, last_operation: {}) + { + guid: instance.guid, + name: instance.name, + created_at: iso8601, + updated_at: iso8601, + type: 'user-provided', + last_operation: last_operation, + syslog_drain_url: instance.syslog_drain_url, + route_service_url: instance.route_service_url, + tags: instance.tags, + metadata: { + labels:, + annotations: + }, + relationships: { + space: { + data: { + guid: instance.space.guid + } + } + }, + links: { + self: { + href: "#{link_prefix}/v3/service_instances/#{instance.guid}" + }, + space: { + href: "#{link_prefix}/v3/spaces/#{instance.space.guid}" + }, + credentials: { + href: "#{link_prefix}/v3/service_instances/#{instance.guid}/credentials" + }, + service_credential_bindings: { + href: "#{link_prefix}/v3/service_credential_bindings?service_instance_guids=#{instance.guid}" + }, + service_route_bindings: { + href: "#{link_prefix}/v3/service_route_bindings?service_instance_guids=#{instance.guid}" + } + } + } + end + + def share_service_instance(instance, target_space) + enable_sharing! + + share_request = { + 'data' => [ + { 'guid' => target_space.guid } + ] + } + + post "/v3/service_instances/#{instance.guid}/relationships/shared_spaces", share_request.to_json, admin_headers + expect(last_response.status).to eq(200) + end + + def enable_sharing! + VCAP::CloudController::FeatureFlag. + find_or_create(name: 'service_instance_sharing') { |ff| ff.enabled = true }. + update(enabled: true) + end +end diff --git a/spec/unit/actions/app_restart_spec.rb b/spec/unit/actions/app_restart_spec.rb index 139ae5a3bb..93c0d8e532 100644 --- a/spec/unit/actions/app_restart_spec.rb +++ b/spec/unit/actions/app_restart_spec.rb @@ -35,8 +35,16 @@ module VCAP::CloudController allow(VCAP::CloudController::Diego::Runner).to receive(:new).and_return(runner) end - it 'does NOT invoke the ProcessObserver after the transaction commits', isolation: :truncation do - expect(ProcessObserver).not_to receive(:updated) + it 'delegates to ProcessRestart which skips ProcessObserver invocation' do + # ProcessRestart.restart sets skip_process_observer_on_update = true + # This test verifies the delegation happens, which ensures observer is skipped + expect(ProcessRestart).to receive(:restart).with( + hash_including(process: process1) + ).and_call_original + expect(ProcessRestart).to receive(:restart).with( + hash_including(process: process2) + ).and_call_original + AppRestart.restart(app:, config:, user_audit_info:) end diff --git a/spec/unit/actions/deployment_create_spec.rb b/spec/unit/actions/deployment_create_spec.rb index 0526d4bae4..3371fad2b1 100644 --- a/spec/unit/actions/deployment_create_spec.rb +++ b/spec/unit/actions/deployment_create_spec.rb @@ -202,13 +202,13 @@ module VCAP::CloudController expect(deploying_web_process.revision).to eq(app.latest_revision) end - it 'desires an LRP via the ProcessObserver', isolation: :truncation do - allow(runner).to receive(:start) - allow(Diego::Runner).to receive(:new).and_return(runner) - + it 'creates a process in STARTED state to trigger LRP creation' do + # DeploymentCreate updates process state to STARTED which engages ProcessObserver + # to desire the LRP (see deployment_create.rb:74-77) DeploymentCreate.create(app: app, message: restart_message, user_audit_info: user_audit_info) - expect(runner).to have_received(:start).at_least(:once) + deploying_process = app.reload.newest_web_process + expect(deploying_process.state).to eq(ProcessModel::STARTED) end context 'when there are multiple web processes' do diff --git a/spec/unit/actions/process_restart_spec.rb b/spec/unit/actions/process_restart_spec.rb index 42caf7e191..3b8f9a2837 100644 --- a/spec/unit/actions/process_restart_spec.rb +++ b/spec/unit/actions/process_restart_spec.rb @@ -31,9 +31,9 @@ module VCAP::CloudController allow(VCAP::CloudController::Diego::Runner).to receive(:new).and_return(runner) end - it 'does NOT invoke the ProcessObserver after the transaction commits', isolation: :truncation do - expect(ProcessObserver).not_to receive(:updated) + it 'sets skip_process_observer_on_update flag to prevent observer invocation' do ProcessRestart.restart(process: process, config: config, stop_in_runtime: true) + expect(process.skip_process_observer_on_update).to be(true) end context 'when the process is STARTED' do diff --git a/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb b/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb index 5194bea091..502cdb9f1f 100644 --- a/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/adjective_noun_generator' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/app_manifest/byte_converter_spec.rb b/spec/unit/lib/cloud_controller/app_manifest/byte_converter_spec.rb index b4743e27ff..ee9c10cb1a 100644 --- a/spec/unit/lib/cloud_controller/app_manifest/byte_converter_spec.rb +++ b/spec/unit/lib/cloud_controller/app_manifest/byte_converter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/app_manifest/byte_converter' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/blobstore/blob_key_generator_spec.rb b/spec/unit/lib/cloud_controller/blobstore/blob_key_generator_spec.rb index 462d8d69e7..d6338ebd88 100644 --- a/spec/unit/lib/cloud_controller/blobstore/blob_key_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/blob_key_generator_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/blobstore/blob_key_generator' module CloudController module Blobstore diff --git a/spec/unit/lib/cloud_controller/byte_quantity_spec.rb b/spec/unit/lib/cloud_controller/byte_quantity_spec.rb index bf99ae01d3..8fc57e81d5 100644 --- a/spec/unit/lib/cloud_controller/byte_quantity_spec.rb +++ b/spec/unit/lib/cloud_controller/byte_quantity_spec.rb @@ -3,7 +3,7 @@ # Licensed under the MIT License # https://github.com/goodmustache/palm_civet -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/byte_quantity' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb b/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb index d5f759a504..036c14ceba 100644 --- a/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb +++ b/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/clock/distributed_scheduler' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/database_parts_parser_spec.rb b/spec/unit/lib/cloud_controller/database_parts_parser_spec.rb index 059c61a679..264a7d64ff 100644 --- a/spec/unit/lib/cloud_controller/database_parts_parser_spec.rb +++ b/spec/unit/lib/cloud_controller/database_parts_parser_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/database_parts_parser' RSpec.describe VCAP::CloudController::DatabasePartsParser do diff --git a/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb b/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb index c0ae0ae68e..e783d3c8e2 100644 --- a/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/database_uri_generator' RSpec.describe VCAP::CloudController::DatabaseUriGenerator do let(:service_uris) { ['postgres://username:password@host/db'] } diff --git a/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb b/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb index b9764de77e..8cfe984d7a 100644 --- a/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/diego/droplet_url_generator' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb b/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb index a97c5ee6c4..5e26a0999b 100644 --- a/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/diego/failure_reason_sanitizer' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/domain_decorator_spec.rb b/spec/unit/lib/cloud_controller/domain_decorator_spec.rb index 192c09de8a..51c2bb69c3 100644 --- a/spec/unit/lib/cloud_controller/domain_decorator_spec.rb +++ b/spec/unit/lib/cloud_controller/domain_decorator_spec.rb @@ -1,3 +1,4 @@ +require 'lightweight_spec_helper' require 'cloud_controller/domain_decorator' module CloudController diff --git a/spec/unit/lib/cloud_controller/drain_spec.rb b/spec/unit/lib/cloud_controller/drain_spec.rb index 09ff32ed5d..7f6dcfa748 100644 --- a/spec/unit/lib/cloud_controller/drain_spec.rb +++ b/spec/unit/lib/cloud_controller/drain_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/drain' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb b/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb index 3266e2f34f..cf7fca3481 100644 --- a/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb +++ b/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/metrics/request_metrics' module VCAP::CloudController::Metrics diff --git a/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb b/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb index a122662c72..5ac3363ada 100644 --- a/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb +++ b/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/paging/pagination_options' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/random_route_generator_spec.rb b/spec/unit/lib/cloud_controller/random_route_generator_spec.rb index a1c52e7579..c9a7dc068b 100644 --- a/spec/unit/lib/cloud_controller/random_route_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/random_route_generator_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/random_route_generator' module VCAP::CloudController RSpec.describe RandomRouteGenerator do diff --git a/spec/unit/lib/cloud_controller/secrets_fetcher_spec.rb b/spec/unit/lib/cloud_controller/secrets_fetcher_spec.rb index 90bf1ecc49..0bf10b0c16 100644 --- a/spec/unit/lib/cloud_controller/secrets_fetcher_spec.rb +++ b/spec/unit/lib/cloud_controller/secrets_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/secrets_fetcher' module VCAP::CloudController diff --git a/spec/unit/lib/delayed_job/delayed_job_spec.rb b/spec/unit/lib/delayed_job/delayed_job_spec.rb index b03cad8708..d4578ca99f 100644 --- a/spec/unit/lib/delayed_job/delayed_job_spec.rb +++ b/spec/unit/lib/delayed_job/delayed_job_spec.rb @@ -1,3 +1,5 @@ +require 'lightweight_spec_helper' + RSpec.describe 'delayed_job' do describe 'version' do it 'is not updated' do diff --git a/spec/unit/lib/fluent_emitter_spec.rb b/spec/unit/lib/fluent_emitter_spec.rb index ed1791d392..7a25a2e3b4 100644 --- a/spec/unit/lib/fluent_emitter_spec.rb +++ b/spec/unit/lib/fluent_emitter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'fluent_emitter' module VCAP diff --git a/spec/unit/lib/index_stopper_spec.rb b/spec/unit/lib/index_stopper_spec.rb index bf5eb2ad45..191fde5d88 100644 --- a/spec/unit/lib/index_stopper_spec.rb +++ b/spec/unit/lib/index_stopper_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/index_stopper' module VCAP::CloudController RSpec.describe IndexStopper do diff --git a/spec/unit/lib/locket/lock_worker_spec.rb b/spec/unit/lib/locket/lock_worker_spec.rb index d593536e16..efc04bbcc9 100644 --- a/spec/unit/lib/locket/lock_worker_spec.rb +++ b/spec/unit/lib/locket/lock_worker_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'locket/lock_worker' require 'locket/lock_runner' diff --git a/spec/unit/lib/services/validation_errors_spec.rb b/spec/unit/lib/services/validation_errors_spec.rb index d6917935e8..091a439e99 100644 --- a/spec/unit/lib/services/validation_errors_spec.rb +++ b/spec/unit/lib/services/validation_errors_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'services/validation_errors' module VCAP::Services RSpec.describe ValidationErrors do diff --git a/spec/unit/lib/structured_error_spec.rb b/spec/unit/lib/structured_error_spec.rb index fcfde20d57..6addd1fc28 100644 --- a/spec/unit/lib/structured_error_spec.rb +++ b/spec/unit/lib/structured_error_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/structured_error' RSpec.describe StructuredError do context 'with a hash source' do diff --git a/spec/unit/lib/utils/hash_utils_spec.rb b/spec/unit/lib/utils/hash_utils_spec.rb index 01c5becd5d..63c71aadd5 100644 --- a/spec/unit/lib/utils/hash_utils_spec.rb +++ b/spec/unit/lib/utils/hash_utils_spec.rb @@ -1,3 +1,5 @@ +require 'lightweight_spec_helper' + require 'utils/hash_utils' RSpec.describe HashUtils do diff --git a/spec/unit/lib/utils/time_utils_spec.rb b/spec/unit/lib/utils/time_utils_spec.rb index ce829a3f02..0c14cc3a46 100644 --- a/spec/unit/lib/utils/time_utils_spec.rb +++ b/spec/unit/lib/utils/time_utils_spec.rb @@ -1,3 +1,5 @@ +require 'lightweight_spec_helper' + require 'utils/time_utils' RSpec.describe TimeUtils do diff --git a/spec/unit/lib/utils/uri_utils_spec.rb b/spec/unit/lib/utils/uri_utils_spec.rb index a47fa3d05d..7f6b68c57f 100644 --- a/spec/unit/lib/utils/uri_utils_spec.rb +++ b/spec/unit/lib/utils/uri_utils_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'utils/uri_utils' RSpec.describe UriUtils do diff --git a/spec/unit/lib/vcap/digester_spec.rb b/spec/unit/lib/vcap/digester_spec.rb index 8177c4ac48..f877790078 100644 --- a/spec/unit/lib/vcap/digester_spec.rb +++ b/spec/unit/lib/vcap/digester_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'vcap/digester' RSpec.describe Digester do diff --git a/spec/unit/lib/vcap/host_system_spec.rb b/spec/unit/lib/vcap/host_system_spec.rb index 35f9db905b..abe0538062 100644 --- a/spec/unit/lib/vcap/host_system_spec.rb +++ b/spec/unit/lib/vcap/host_system_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'vcap/host_system' RSpec.describe VCAP::HostSystem do diff --git a/spec/unit/lib/vcap/semver_validator_spec.rb b/spec/unit/lib/vcap/semver_validator_spec.rb index 293a1203e5..7b164f7f92 100644 --- a/spec/unit/lib/vcap/semver_validator_spec.rb +++ b/spec/unit/lib/vcap/semver_validator_spec.rb @@ -1,3 +1,5 @@ +require 'lightweight_spec_helper' + require 'rspec' require 'vcap/semver_validator' diff --git a/spec/unit/messages/app_revisions_list_message_spec.rb b/spec/unit/messages/app_revisions_list_message_spec.rb index 2a3c784965..6db2df339f 100644 --- a/spec/unit/messages/app_revisions_list_message_spec.rb +++ b/spec/unit/messages/app_revisions_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/app_revisions_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_delete_shared_org_message_spec.rb b/spec/unit/messages/domain_delete_shared_org_message_spec.rb index d1d140ea74..09bdb98773 100644 --- a/spec/unit/messages/domain_delete_shared_org_message_spec.rb +++ b/spec/unit/messages/domain_delete_shared_org_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_delete_shared_org_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_show_message_spec.rb b/spec/unit/messages/domain_show_message_spec.rb index a77e73f83f..fac785f6fa 100644 --- a/spec/unit/messages/domain_show_message_spec.rb +++ b/spec/unit/messages/domain_show_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_show_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_update_message_spec.rb b/spec/unit/messages/domain_update_message_spec.rb index 49fc4c6688..f4b7458940 100644 --- a/spec/unit/messages/domain_update_message_spec.rb +++ b/spec/unit/messages/domain_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/domains_list_message_spec.rb b/spec/unit/messages/domains_list_message_spec.rb index f8de94fbac..de69536ab6 100644 --- a/spec/unit/messages/domains_list_message_spec.rb +++ b/spec/unit/messages/domains_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domains_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/isolation_segments_list_message_spec.rb b/spec/unit/messages/isolation_segments_list_message_spec.rb index 38ba6a7679..04b448470d 100644 --- a/spec/unit/messages/isolation_segments_list_message_spec.rb +++ b/spec/unit/messages/isolation_segments_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/isolation_segments_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/metadata_list_message_spec.rb b/spec/unit/messages/metadata_list_message_spec.rb index 6141960861..89b9fa3bfc 100644 --- a/spec/unit/messages/metadata_list_message_spec.rb +++ b/spec/unit/messages/metadata_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/metadata_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/package_update_message_spec.rb b/spec/unit/messages/package_update_message_spec.rb index b48536c265..2bc4d7492c 100644 --- a/spec/unit/messages/package_update_message_spec.rb +++ b/spec/unit/messages/package_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/package_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/security_group_apply_message_spec.rb b/spec/unit/messages/security_group_apply_message_spec.rb index d0565f040a..0bb6577377 100644 --- a/spec/unit/messages/security_group_apply_message_spec.rb +++ b/spec/unit/messages/security_group_apply_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/security_group_apply_message' module VCAP::CloudController diff --git a/spec/unit/messages/space_quota_apply_message_spec.rb b/spec/unit/messages/space_quota_apply_message_spec.rb index 4e58c4380b..2910ce40ef 100644 --- a/spec/unit/messages/space_quota_apply_message_spec.rb +++ b/spec/unit/messages/space_quota_apply_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/space_quota_apply_message' module VCAP::CloudController diff --git a/spec/unit/messages/spaces_list_message_spec.rb b/spec/unit/messages/spaces_list_message_spec.rb index 887aa4cf46..8282f3277c 100644 --- a/spec/unit/messages/spaces_list_message_spec.rb +++ b/spec/unit/messages/spaces_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/spaces_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/stacks_list_message_spec.rb b/spec/unit/messages/stacks_list_message_spec.rb index d4e8df0612..4ab4f35c5d 100644 --- a/spec/unit/messages/stacks_list_message_spec.rb +++ b/spec/unit/messages/stacks_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/stacks_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/user_update_message_spec.rb b/spec/unit/messages/user_update_message_spec.rb index 0946a36fc7..e01dc90850 100644 --- a/spec/unit/messages/user_update_message_spec.rb +++ b/spec/unit/messages/user_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/user_update_message' module VCAP::CloudController