spec/unit/controllers/runtime/apps_controller_spec.rb
require 'spec_helper'
## NOTICE: Prefer request specs over controller specs as per ADR #0003 ##
module VCAP::CloudController
RSpec.describe VCAP::CloudController::AppsController do
let(:admin_user) { User.make }
let(:non_admin_user) { User.make }
let(:app_event_repository) { Repositories::AppEventRepository.new }
before do
set_current_user(non_admin_user)
TestConfig.override(kubernetes: { host_url: nil })
CloudController::DependencyLocator.instance.register(:app_event_repository, app_event_repository)
end
describe 'Query Parameters' do
it { expect(VCAP::CloudController::AppsController).to be_queryable_by(:name) }
it { expect(VCAP::CloudController::AppsController).to be_queryable_by(:space_guid) }
it { expect(VCAP::CloudController::AppsController).to be_queryable_by(:organization_guid) }
it { expect(VCAP::CloudController::AppsController).to be_queryable_by(:diego) }
it { expect(VCAP::CloudController::AppsController).to be_queryable_by(:stack_guid) }
end
describe 'query by org_guid' do
let(:process) { ProcessModelFactory.make }
it 'filters apps by org_guid' do
set_current_user_as_admin
get "/v2/apps?q=organization_guid:#{process.organization.guid}"
expect(last_response.status).to eq(200)
expect(decoded_response['resources'][0]['entity']['name']).to eq(process.name)
end
end
describe 'querying by stack guid' do
let(:stack1) { Stack.make }
let(:stack2) { Stack.make }
let(:process1) { ProcessModel.make }
let(:process2) { ProcessModel.make }
before do
process1.app.lifecycle_data.update(stack: stack1.name)
process2.app.lifecycle_data.update(stack: stack2.name)
end
it 'filters apps by stack guid' do
set_current_user_as_admin
get "/v2/apps?q=stack_guid:#{stack1.guid}"
expect(last_response.status).to eq(200)
expect(decoded_response['resources'].length).to eq(1)
expect(decoded_response['resources'][0]['entity']['name']).to eq(process1.name)
end
end
describe 'Attributes' do
it do
expect(VCAP::CloudController::AppsController).to have_creatable_attributes(
{
enable_ssh: { type: 'bool' },
buildpack: { type: 'string' },
command: { type: 'string' },
console: { type: 'bool', default: false },
debug: { type: 'string' },
disk_quota: { type: 'integer' },
log_rate_limit: { type: 'integer' },
environment_json: { type: 'hash', default: {} },
health_check_http_endpoint: { type: 'string' },
health_check_timeout: { type: 'integer' },
health_check_type: { type: 'string', default: 'port' },
instances: { type: 'integer', default: 1 },
memory: { type: 'integer' },
name: { type: 'string', required: true },
production: { type: 'bool', default: false },
state: { type: 'string', default: 'STOPPED' },
space_guid: { type: 'string', required: true },
stack_guid: { type: 'string' },
diego: { type: 'bool' },
docker_image: { type: 'string', required: false },
docker_credentials: { type: 'hash', default: {} },
ports: { type: '[integer]', default: nil }
}
)
end
it do
expect(VCAP::CloudController::AppsController).to have_updatable_attributes(
{
enable_ssh: { type: 'bool' },
buildpack: { type: 'string' },
command: { type: 'string' },
console: { type: 'bool' },
debug: { type: 'string' },
disk_quota: { type: 'integer' },
log_rate_limit: { type: 'integer' },
environment_json: { type: 'hash' },
health_check_http_endpoint: { type: 'string' },
health_check_timeout: { type: 'integer' },
health_check_type: { type: 'string' },
instances: { type: 'integer' },
memory: { type: 'integer' },
name: { type: 'string' },
production: { type: 'bool' },
state: { type: 'string' },
space_guid: { type: 'string' },
stack_guid: { type: 'string' },
diego: { type: 'bool' },
docker_image: { type: 'string' },
docker_credentials: { type: 'hash' },
ports: { type: '[integer]' }
}
)
end
end
describe 'Associations' do
it do
expect(VCAP::CloudController::AppsController).to have_nested_routes(
{
events: %i[get put delete],
service_bindings: [:get],
routes: [:get],
route_mappings: [:get]
}
)
end
describe 'events associations (via AppEvents)' do
before { set_current_user_as_admin }
it 'does not return events with inline-relations-depth=0' do
process = ProcessModel.make
get "/v2/apps/#{process.app.guid}?inline-relations-depth=0"
expect(entity).to have_key('events_url')
expect(entity).not_to have_key('events')
end
it 'does not return events with inline-relations-depth=1 since app_events dataset is relatively expensive to query' do
process = ProcessModel.make
get "/v2/apps/#{process.app.guid}?inline-relations-depth=1"
expect(entity).to have_key('events_url')
expect(entity).not_to have_key('events')
end
end
end
describe 'create app' do
let(:space) { Space.make }
let(:space_guid) { space.guid.to_s }
let(:initial_hash) do
{
name: 'maria',
space_guid: space_guid
}
end
let(:decoded_response) { Oj.load(last_response.body) }
let(:user_audit_info) { UserAuditInfo.from_context(SecurityContext) }
describe 'events' do
before do
allow(UserAuditInfo).to receive(:from_context).and_return(user_audit_info)
end
it 'records app create' do
set_current_user(admin_user, admin: true)
expected_attrs = AppsController::CreateMessage.decode(initial_hash.to_json).extract(stringify_keys: true)
allow(app_event_repository).to receive(:record_app_create).and_call_original
post '/v2/apps', Oj.dump(initial_hash)
process = ProcessModel.last
expect(app_event_repository).to have_received(:record_app_create).with(process, process.space, user_audit_info, expected_attrs)
end
end
context 'when the org is suspended' do
before do
space.organization.update(status: 'suspended')
end
it 'does not allow user to create new app (spot check)' do
post '/v2/apps', Oj.dump(initial_hash)
expect(last_response.status).to eq(403)
end
end
context 'when allow_ssh is enabled globally' do
before do
TestConfig.override(allow_app_ssh_access: true)
end
context 'when allow_ssh is enabled on the space' do
before do
space.allow_ssh = true
space.save
end
it 'allows enable_ssh to be set to true' do
set_current_user_as_admin
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: true))
expect(last_response.status).to eq(201)
end
it 'allows enable_ssh to be set to false' do
set_current_user_as_admin
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: false))
expect(last_response.status).to eq(201)
end
end
context 'when allow_ssh is disabled on the space' do
before do
space.allow_ssh = false
space.save
end
it 'allows enable_ssh to be set to false' do
set_current_user_as_admin
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: false))
expect(last_response.status).to eq(201)
end
context 'and the user is an admin' do
it 'allows enable_ssh to be set to true' do
set_current_user_as_admin
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: true))
expect(last_response.status).to eq(201)
end
end
context 'and the user is not an admin' do
it 'errors when attempting to set enable_ssh to true' do
set_current_user(non_admin_user)
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: true))
expect(last_response.status).to eq(400)
end
end
end
end
context 'when allow_ssh is disabled globally' do
before do
set_current_user_as_admin
TestConfig.override(allow_app_ssh_access: false)
end
context 'when allow_ssh is enabled on the space' do
before do
space.allow_ssh = true
space.save
end
it 'errors when attempting to set enable_ssh to true' do
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: true))
expect(last_response.status).to eq(400)
end
it 'allows enable_ssh to be set to false' do
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: false))
expect(last_response.status).to eq(201)
end
end
context 'when allow_ssh is disabled on the space' do
before do
space.allow_ssh = false
space.save
end
it 'errors when attempting to set enable_ssh to true' do
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: true))
expect(last_response.status).to eq(400)
end
it 'allows enable_ssh to be set to false' do
post '/v2/apps', Oj.dump(initial_hash.merge(enable_ssh: false))
expect(last_response.status).to eq(201)
end
end
context 'when diego is set to true' do
context 'when no custom ports are specified' do
it 'sets the ports to 8080' do
post '/v2/apps', Oj.dump(initial_hash.merge(diego: true))
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([8080])
expect(decoded_response['entity']['diego']).to be true
end
end
context 'when custom ports are specified' do
it 'sets the ports to as specified in the request' do
post '/v2/apps', Oj.dump(initial_hash.merge(diego: true, ports: [9090, 5222]))
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([9090, 5222])
expect(decoded_response['entity']['diego']).to be true
end
end
context 'when the custom port is not in the valid range 1024-65535' do
it 'return an error' do
post '/v2/apps', Oj.dump(initial_hash.merge(diego: true, ports: [9090, 500]))
expect(last_response.status).to eq(400)
expect(decoded_response['description']).to include('Ports must be in the 1024-65535 range.')
end
end
end
end
it 'creates the app' do
request = {
name: 'maria',
space_guid: space.guid,
environment_json: { 'KEY' => 'val' },
buildpack: 'http://example.com/buildpack',
health_check_http_endpoint: '/healthz',
health_check_type: 'http'
}
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump(request)
v2_app = ProcessModel.last
expect(v2_app.health_check_type).to eq('http')
expect(v2_app.health_check_http_endpoint).to eq('/healthz')
end
it 'creates the app' do
request = {
name: 'maria',
space_guid: space.guid,
environment_json: { 'KEY' => 'val' },
buildpack: 'http://example.com/buildpack'
}
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump(request)
v2_app = ProcessModel.last
expect(v2_app.name).to eq('maria')
expect(v2_app.space).to eq(space)
expect(v2_app.environment_json).to eq({ 'KEY' => 'val' })
expect(v2_app.stack).to eq(Stack.default)
expect(v2_app.buildpack.url).to eq('http://example.com/buildpack')
v3_app = v2_app.app
expect(v3_app.name).to eq('maria')
expect(v3_app.space).to eq(space)
expect(v3_app.environment_variables).to eq({ 'KEY' => 'val' })
expect(v3_app.lifecycle_type).to eq(BuildpackLifecycleDataModel::LIFECYCLE_TYPE)
expect(v3_app.lifecycle_data.stack).to eq(Stack.default.name)
expect(v3_app.lifecycle_data.buildpacks).to eq(['http://example.com/buildpack'])
expect(v3_app.desired_state).to eq(v2_app.state)
expect(v3_app.guid).to eq(v2_app.guid)
end
context 'creating a buildpack app' do
it 'creates the app correctly' do
stack = Stack.make(name: 'stack-name')
request = {
name: 'maria',
space_guid: space.guid,
stack_guid: stack.guid,
buildpack: 'http://example.com/buildpack'
}
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump(request)
v2_app = ProcessModel.last
expect(v2_app.stack).to eq(stack)
expect(v2_app.buildpack.url).to eq('http://example.com/buildpack')
end
context 'when custom buildpacks are disabled and the buildpack attribute is being changed' do
before do
TestConfig.override(disable_custom_buildpacks: true)
set_current_user(admin_user, admin: true)
end
let(:request) do
{
name: 'maria',
space_guid: space.guid
}
end
it 'does NOT allow a public git url' do
post '/v2/apps', Oj.dump(request.merge(buildpack: 'http://example.com/buildpack'))
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
it 'does NOT allow a public http url' do
post '/v2/apps', Oj.dump(request.merge(buildpack: 'http://example.com/foo'))
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
it 'does allow a buildpack name' do
admin_buildpack = Buildpack.make
post '/v2/apps', Oj.dump(request.merge(buildpack: admin_buildpack.name))
expect(last_response.status).to eq(201)
end
it 'does not allow a private git url' do
post '/v2/apps', Oj.dump(request.merge(buildpack: 'https://username:password@github.com/johndoe/my-buildpack.git'))
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
it 'does not allow a private git url with ssh schema' do
post '/v2/apps', Oj.dump(request.merge(buildpack: 'ssh://git@example.com:foo.git'))
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
end
end
context 'creating a docker app' do
it 'creates the app correctly' do
request = {
name: 'maria',
space_guid: space.guid,
docker_image: 'some-image:latest'
}
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump(request)
v2_app = ProcessModel.last
expect(v2_app.docker_image).to eq('some-image:latest')
expect(v2_app.package_hash).to eq('some-image:latest')
package = v2_app.latest_package
expect(package.image).to eq('some-image:latest')
end
context 'when the package is invalid' do
before do
allow(VCAP::CloudController::PackageCreate).to receive(:create_without_event).
and_raise(VCAP::CloudController::PackageCreate::InvalidPackage.new('oops'))
end
it 'returns an UnprocessableEntity error' do
request = {
name: 'maria',
space_guid: space.guid,
docker_image: 'some-image:latest'
}
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump(request)
expect(last_response.status).to eq(422)
expect(last_response.body).to match(/UnprocessableEntity/)
expect(last_response.body).to match(/oops/)
end
end
end
context 'when starting an app without a package' do
it 'raises an error' do
request = {
name: 'maria',
space_guid: space.guid,
state: 'STARTED'
}
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump(request)
expect(last_response.status).to eq(400)
expect(last_response.body).to include('bits have not been uploaded')
end
end
context 'when the space does not exist' do
it 'returns 404' do
set_current_user(admin_user, admin: true)
post '/v2/apps', Oj.dump({ name: 'maria', space_guid: 'no-existy' })
expect(last_response.status).to eq(404)
end
end
end
describe 'docker image credentials' do
let(:space) { Space.make }
let(:space_guid) { space.guid.to_s }
let(:initial_hash) do
{
name: 'maria',
space_guid: space_guid
}
end
let(:decoded_response) { Oj.load(last_response.body) }
let(:user) { 'user' }
let(:password) { 'password' }
let(:docker_credentials) do
{
username: user,
password: password
}
end
let(:body) do
Oj.dump(initial_hash.merge(docker_image: 'someimage', docker_credentials: docker_credentials))
end
let(:redacted_message) { '***' }
def create_app
post '/v2/apps', body
expect(last_response).to have_status_code(201)
decoded_response['metadata']['guid']
end
def read_app
app_guid = create_app
get "/v2/apps/#{app_guid}"
expect(last_response).to have_status_code(200)
end
def update_app
app_guid = create_app
put "/v2/apps/#{app_guid}", body
expect(last_response).to have_status_code(201)
end
before do
set_current_user_as_admin
end
context 'create app' do
it 'redacts the credentials' do
create_app
expect(decoded_response['entity']['docker_credentials']['password']).to eq redacted_message
end
end
context 'read app' do
it 'redacts the credentials' do
read_app
expect(decoded_response['entity']['docker_credentials']['password']).to eq redacted_message
end
end
context 'update app' do
it 'redacts the credentials' do
update_app
expect(decoded_response['entity']['docker_credentials']['password']).to eq redacted_message
end
end
end
describe 'read app' do
let(:process) { ProcessModelFactory.make(instances: 1) }
let(:app_model) { process.app }
let(:developer) { make_developer_for_space(process.space) }
let(:app_guid) { process.app_guid }
before do
set_current_user(developer)
allow_any_instance_of(V2::AppStage).to receive(:stage).and_return(nil)
end
it 'returns the app in question' do
get "/v2/apps/#{app_guid}"
expect(decoded_response['metadata']).to include({
'guid' => app_guid.to_s,
'url' => "/v2/apps/#{app_guid}",
'created_at' => anything,
'updated_at' => anything
})
expect(decoded_response['entity']).to include({
'name' => process.app.name,
'production' => false,
'space_guid' => process.space.guid.to_s,
'stack_guid' => process.stack.guid.to_s,
'buildpack' => nil,
'detected_buildpack' => nil,
'detected_buildpack_guid' => nil,
'environment_json' => nil,
'memory' => 1024,
'instances' => 1,
'disk_quota' => 1024,
'state' => 'STOPPED',
'version' => anything,
'command' => nil,
'console' => false,
'debug' => nil,
'staging_task_id' => anything,
'package_state' => 'STAGED',
'health_check_type' => 'port',
'health_check_timeout' => nil,
'health_check_http_endpoint' => nil,
'staging_failed_reason' => nil,
'staging_failed_description' => nil,
'diego' => true,
'docker_image' => nil,
'docker_credentials' => {
'username' => nil,
'password' => nil
},
'package_updated_at' => anything,
'detected_start_command' => '$HOME/boot.sh',
'enable_ssh' => true,
'ports' => [8080],
'space_url' => "/v2/spaces/#{process.space.guid}",
'stack_url' => "/v2/stacks/#{process.stack.guid}",
'routes_url' => "/v2/apps/#{app_guid}/routes",
'events_url' => "/v2/apps/#{app_guid}/events",
'service_bindings_url' => "/v2/apps/#{app_guid}/service_bindings",
'route_mappings_url' => "/v2/apps/#{app_guid}/route_mappings"
})
end
context 'when the app has rolled to a new web process' do
before do
process.destroy
end
let!(:new_process) { ProcessModel.make(type: ProcessTypes::WEB, app: app_model) }
it 'returns the app with the appropriate app guid' do
expect(app_guid).not_to eq(new_process.guid)
get "/v2/apps/#{app_guid}"
expect(decoded_response['metadata']).to include({
'guid' => app_guid.to_s,
'url' => "/v2/apps/#{app_guid}",
'created_at' => anything,
'updated_at' => anything
})
expect(decoded_response['entity']).to include({
'name' => process.app.name,
'production' => false,
'space_guid' => process.space.guid.to_s,
'stack_guid' => process.stack.guid.to_s,
'buildpack' => nil,
'detected_buildpack' => nil,
'detected_buildpack_guid' => nil,
'environment_json' => nil,
'memory' => 1024,
'instances' => 1,
'disk_quota' => 1024,
'state' => 'STOPPED',
'version' => anything,
'command' => nil,
'console' => false,
'debug' => nil,
'staging_task_id' => anything,
'package_state' => 'STAGED',
'health_check_type' => 'port',
'health_check_timeout' => nil,
'health_check_http_endpoint' => nil,
'staging_failed_reason' => nil,
'staging_failed_description' => nil,
'diego' => true,
'docker_image' => nil,
'docker_credentials' => {
'username' => nil,
'password' => nil
},
'package_updated_at' => anything,
'detected_start_command' => '$HOME/boot.sh',
'enable_ssh' => true,
'ports' => [8080],
'space_url' => "/v2/spaces/#{process.space.guid}",
'stack_url' => "/v2/stacks/#{process.stack.guid}",
'routes_url' => "/v2/apps/#{app_guid}/routes",
'events_url' => "/v2/apps/#{app_guid}/events",
'service_bindings_url' => "/v2/apps/#{app_guid}/service_bindings",
'route_mappings_url' => "/v2/apps/#{app_guid}/route_mappings"
})
end
end
end
describe 'update app' do
let(:update_hash) { {} }
let(:process) { ProcessModelFactory.make(diego: false, instances: 1) }
let(:developer) { make_developer_for_space(process.space) }
before do
set_current_user(developer)
allow_any_instance_of(V2::AppStage).to receive(:stage).and_return(nil)
end
describe 'app_scaling feature flag' do
context 'when the flag is enabled' do
before { FeatureFlag.make(name: 'app_scaling', enabled: true) }
it 'allows updating memory' do
put "/v2/apps/#{process.app.guid}", '{ "memory": 2 }'
expect(last_response.status).to eq(201)
end
end
context 'when the flag is disabled' do
before { FeatureFlag.make(name: 'app_scaling', enabled: false, error_message: nil) }
it 'fails with the proper error code and message' do
put "/v2/apps/#{process.app.guid}", '{ "memory": 2 }'
expect(last_response.status).to eq(403)
expect(decoded_response['error_code']).to match(/FeatureDisabled/)
expect(decoded_response['description']).to match(/app_scaling/)
end
end
end
context 'switch from dea to diego' do
let(:process) { ProcessModelFactory.make(instances: 1, diego: false, type: 'web') }
let(:developer) { make_developer_for_space(process.space) }
let(:route) { Route.make(space: process.space) }
let(:route_mapping) { RouteMappingModel.make(app: process.app, route: route) }
it 'sets ports to 8080' do
expect(process.ports).to be_nil
put "/v2/apps/#{process.app.guid}", '{ "diego": true }'
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([8080])
expect(decoded_response['entity']['diego']).to be true
end
end
context 'switch from diego to dea' do
let(:process) { ProcessModelFactory.make(instances: 1, diego: true, ports: [8080, 5222]) }
it 'updates the backend of the app and returns 201 with warning' do
put "/v2/apps/#{process.app.guid}", '{ "diego": false}'
expect(last_response).to have_status_code(201)
expect(decoded_response['entity']['diego']).to be false
warning = CGI.unescape(last_response.headers['X-Cf-Warnings'])
expect(warning).to include('App ports have changed but are unknown. The app should now listen on the port specified by environment variable PORT')
end
end
context 'when app is diego app' do
let(:process) { ProcessModelFactory.make(instances: 1, diego: true, ports: [9090, 5222]) }
it 'sets ports to user specified values' do
put "/v2/apps/#{process.app.guid}", '{ "ports": [1883,5222] }'
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([1883, 5222])
expect(decoded_response['entity']['diego']).to be true
end
context 'when not updating ports' do
it 'keeps previously specified custom ports' do
put "/v2/apps/#{process.app.guid}", '{ "instances":2 }'
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([9090, 5222])
expect(decoded_response['entity']['diego']).to be true
end
end
context 'when the user sets ports to an empty array' do
it 'keeps previously specified custom ports' do
put "/v2/apps/#{process.app.guid}", '{ "ports":[] }'
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([9090, 5222])
expect(decoded_response['entity']['diego']).to be true
end
end
context 'when updating an app with existing route mapping' do
let(:route) { Route.make(space: process.space) }
let!(:route_mapping) { RouteMappingModel.make(app: process.app, route: route, app_port: 9090) }
let!(:route_mapping2) { RouteMappingModel.make(app: process.app, route: route, app_port: 5222) }
context 'when new app ports contains all existing route port mappings' do
it 'updates the ports' do
put "/v2/apps/#{process.app.guid}", '{ "ports":[9090, 5222, 1234] }'
expect(last_response.status).to eq(201)
expect(decoded_response['entity']['ports']).to match([9090, 5222, 1234])
end
end
context 'when new app ports partially contains existing route port mappings' do
it 'returns 400' do
put "/v2/apps/#{process.app.guid}", '{ "ports":[5222, 1234] }'
expect(last_response.status).to eq(400)
expect(decoded_response['description']).to include('App ports may not be removed while routes are mapped to them.')
end
end
context 'when new app ports do not contain existing route mapping port' do
it 'returns 400' do
put "/v2/apps/#{process.app.guid}", '{ "ports":[1234] }'
expect(last_response.status).to eq(400)
expect(decoded_response['description']).to include('App ports may not be removed while routes are mapped to them.')
end
end
end
end
describe 'events' do
let(:update_hash) { { instances: 2, foo: 'foo_value' } }
context 'when the update succeeds' do
it 'records app update with whitelisted attributes' do
allow_any_instance_of(ErrorPresenter).to receive(:raise_500?).and_return(false)
allow(app_event_repository).to receive(:record_app_update).and_call_original
expect(app_event_repository).to receive(:record_app_update) do |recorded_app, _recorded_space, user_audit_info, attributes|
expect(recorded_app.guid).to eq(process.app.guid)
expect(recorded_app.instances).to eq(2)
expect(user_audit_info.user_guid).to eq(SecurityContext.current_user.guid)
expect(user_audit_info.user_name).to eq(SecurityContext.current_user_email)
expect(attributes).to eq({ 'instances' => 2 })
end
put "/v2/apps/#{process.app.guid}", Oj.dump(update_hash)
end
end
context 'when the update fails' do
before do
allow_any_instance_of(ProcessModel).to receive(:save).and_raise('Error saving')
allow(app_event_repository).to receive(:record_app_update)
end
it 'does not record app update' do
expect do
put "/v2/apps/#{process.app.guid}", Oj.dump(update_hash)
end.to raise_error RuntimeError, /Error saving/
expect(app_event_repository).not_to have_received(:record_app_update)
end
end
end
it 'updates the app' do
v2_app = ProcessModel.make
v3_app = v2_app.app
stack = Stack.make(name: 'stack-name')
request = {
name: 'maria',
environment_json: { 'KEY' => 'val' },
stack_guid: stack.guid,
buildpack: 'http://example.com/buildpack'
}
set_current_user(admin_user, admin: true)
put "/v2/apps/#{v2_app.app.guid}", Oj.dump(request)
expect(last_response.status).to eq(201)
v2_app.reload
v3_app.reload
expect(v2_app.name).to eq('maria')
expect(v2_app.environment_json).to eq({ 'KEY' => 'val' })
expect(v2_app.stack).to eq(stack)
expect(v2_app.buildpack.url).to eq('http://example.com/buildpack')
expect(v3_app.name).to eq('maria')
expect(v3_app.environment_variables).to eq({ 'KEY' => 'val' })
expect(v3_app.lifecycle_type).to eq(BuildpackLifecycleDataModel::LIFECYCLE_TYPE)
expect(v3_app.lifecycle_data.stack).to eq('stack-name')
expect(v3_app.lifecycle_data.buildpacks).to eq(['http://example.com/buildpack'])
end
context 'when custom buildpacks are disabled and the buildpack attribute is being changed' do
before do
TestConfig.override(disable_custom_buildpacks: true)
set_current_user(admin_user, admin: true)
process.app.lifecycle_data.update(buildpacks: [Buildpack.make.name])
end
let(:process) { ProcessModel.make }
it 'does NOT allow a public git url' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ buildpack: 'http://example.com/buildpack' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
it 'does NOT allow a public http url' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ buildpack: 'http://example.com/foo' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
it 'does allow a buildpack name' do
admin_buildpack = Buildpack.make
put "/v2/apps/#{process.app.guid}", Oj.dump({ buildpack: admin_buildpack.name })
expect(last_response.status).to eq(201)
end
it 'does not allow a private git url' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ buildpack: 'git@example.com:foo.git' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
it 'does not allow a private git url with ssh schema' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ buildpack: 'ssh://git@example.com:foo.git' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Custom buildpacks are disabled')
end
end
describe 'setting stack' do
let(:new_stack) { Stack.make }
it 'changes the stack' do
set_current_user(admin_user, admin: true)
process = ProcessModelFactory.make
expect(process.stack).not_to eq(new_stack)
put "/v2/apps/#{process.app.guid}", Oj.dump({ stack_guid: new_stack.guid })
expect(last_response.status).to eq(201)
expect(process.reload.stack).to eq(new_stack)
end
context 'when the app is already staged' do
let(:process) do
ProcessModelFactory.make(
instances: 1,
state: 'STARTED'
)
end
it 'marks the app for re-staging' do
expect(process.needs_staging?).to be(false)
put "/v2/apps/#{process.app.guid}", Oj.dump({ stack_guid: new_stack.guid })
expect(last_response.status).to eq(201)
process.reload
expect(process.needs_staging?).to be(true)
expect(process.staged?).to be(false)
end
end
context 'when the app needs staged' do
let(:process) { ProcessModelFactory.make(state: 'STARTED') }
before do
PackageModel.make(app: process.app, package_hash: 'some-hash', state: PackageModel::READY_STATE)
process.reload
end
it 'keeps app as needs staging' do
expect(process.staged?).to be false
expect(process.needs_staging?).to be true
put "/v2/apps/#{process.app.guid}", Oj.dump({ stack_guid: new_stack.guid })
expect(last_response.status).to eq(201)
process.reload
expect(process.staged?).to be false
expect(process.needs_staging?).to be true
end
end
context 'when the app was never staged' do
let(:process) { ProcessModel.make }
it 'does not mark the app for staging' do
expect(process).not_to be_staged
expect(process.needs_staging?).to be false
put "/v2/apps/#{process.app.guid}", Oj.dump({ stack_guid: new_stack.guid })
expect(last_response.status).to eq(201)
process.reload
expect(process).not_to be_staged
expect(process.needs_staging?).to be false
end
end
end
describe 'changing lifecycle types' do
context 'when changing from docker to buildpack' do
let(:process) { ProcessModel.make(app: AppModel.make(:docker)) }
it 'raises an error setting buildpack' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ buildpack: 'https://buildpack.example.com' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Lifecycle type cannot be changed')
end
it 'raises an error setting stack' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ stack_guid: 'phat-stackz' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Lifecycle type cannot be changed')
end
end
context 'when changing from buildpack to docker' do
let(:process) { ProcessModel.make(app: AppModel.make(:buildpack)) }
it 'raises an error' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ docker_image: 'repo/great-image' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Lifecycle type cannot be changed')
end
end
end
describe 'updating docker_image' do
before do
set_current_user(admin_user, admin: true)
end
it 'creates a new docker package' do
process = ProcessModelFactory.make(app: AppModel.make(:docker), docker_image: 'repo/original-image')
original_package = process.latest_package
expect(process.docker_image).not_to eq('repo/new-image')
put "/v2/apps/#{process.app.guid}", Oj.dump({ docker_image: 'repo/new-image' })
expect(last_response.status).to eq(201)
parsed_response = Oj.load(last_response.body)
expect(parsed_response['entity']['docker_image']).to eq('repo/new-image')
expect(parsed_response['entity']['docker_credentials']).to eq({
'username' => nil,
'password' => nil
})
expect(process.reload.docker_image).to eq('repo/new-image')
expect(process.latest_package).not_to eq(original_package)
end
context 'when credentials are requested' do
let(:docker_credentials) do
{ 'username' => 'fred', 'password' => 'derf' }
end
it 'creates a new docker package with those credentials' do
process = ProcessModelFactory.make(app: AppModel.make(:docker), docker_image: 'repo/original-image')
original_package = process.latest_package
expect(process.docker_image).not_to eq('repo/new-image')
put "/v2/apps/#{process.app.guid}", Oj.dump({ docker_image: 'repo/new-image', docker_credentials: docker_credentials })
expect(last_response.status).to eq(201)
parsed_response = Oj.load(last_response.body)
expect(parsed_response['entity']['docker_image']).to eq('repo/new-image')
expect(parsed_response['entity']['docker_credentials']).to eq({
'username' => 'fred',
'password' => '***'
})
expect(process.reload.docker_image).to eq('repo/new-image')
expect(process.latest_package).not_to eq(original_package)
end
end
context 'when the package is invalid' do
before do
allow(VCAP::CloudController::PackageCreate).to receive(:create_without_event).
and_raise(VCAP::CloudController::PackageCreate::InvalidPackage.new('oops'))
end
it 'returns an UnprocessableEntity error' do
set_current_user(admin_user, admin: true)
process = ProcessModelFactory.make(app: AppModel.make(:docker), docker_image: 'repo/original-image')
put "/v2/apps/#{process.app.guid}", Oj.dump({ docker_credentials: { username: 'username', password: 'foo' } })
expect(last_response.status).to eq(422)
expect(last_response.body).to match(/UnprocessableEntity/)
expect(last_response.body).to match(/oops/)
end
end
end
describe 'staging' do
let(:app_stage) { instance_double(V2::AppStage, stage: nil) }
let(:process) { ProcessModelFactory.make }
before do
allow(V2::AppStage).to receive(:new).and_return(app_stage)
process.update(state: 'STARTED')
end
context 'when a state change is requested' do
let(:req) { '{ "state": "STARTED" }' }
context 'when the app needs staging' do
before do
process.app.update(droplet: nil)
process.reload
end
it 'requests to be staged' do
put "/v2/apps/#{process.app.guid}", req
expect(last_response.status).to eq(201)
expect(app_stage).to have_received(:stage)
end
end
context 'when the app does not need staging' do
it 'does not request to be staged' do
put "/v2/apps/#{process.app.guid}", req
expect(last_response.status).to eq(201)
expect(app_stage).not_to have_received(:stage)
end
end
end
context 'when a state change is NOT requested' do
let(:req) { '{ "name": "some-name" }' }
context 'when the app needs staging' do
before do
process.app.update(droplet: nil)
process.reload
end
it 'does not request to be staged' do
put "/v2/apps/#{process.app.guid}", req
expect(last_response.status).to eq(201)
expect(app_stage).not_to have_received(:stage)
end
end
context 'when the app does not need staging' do
it 'does not request to be staged' do
put "/v2/apps/#{process.app.guid}", req
expect(last_response.status).to eq(201)
expect(app_stage).not_to have_received(:stage)
end
end
end
end
context 'when starting an app without a package' do
let(:process) { ProcessModel.make(instances: 1) }
it 'raises an error' do
put "/v2/apps/#{process.app.guid}", Oj.dump({ state: 'STARTED' })
expect(last_response.status).to eq(400)
expect(last_response.body).to include('bits have not been uploaded')
end
end
describe 'starting and stopping' do
let(:parent_app) { process.app }
let(:process) { ProcessModelFactory.make(instances: 1, state: state) }
let(:sibling) { ProcessModel.make(instances: 1, state: state, app: parent_app, type: 'worker') }
context 'starting' do
let(:state) { 'STOPPED' }
it 'is reflected in the parent app and all sibling processes' do
expect(parent_app.desired_state).to eq('STOPPED')
expect(process.state).to eq('STOPPED')
expect(sibling.state).to eq('STOPPED')
put "/v2/apps/#{process.app.guid}", '{ "state": "STARTED" }'
expect(last_response.status).to eq(201)
expect(parent_app.reload.desired_state).to eq('STARTED')
expect(process.reload.state).to eq('STARTED')
expect(sibling.reload.state).to eq('STARTED')
end
end
context 'stopping' do
let(:state) { 'STARTED' }
it 'is reflected in the parent app and all sibling processes' do
expect(parent_app.desired_state).to eq('STARTED')
expect(process.state).to eq('STARTED')
expect(sibling.state).to eq('STARTED')
put "/v2/apps/#{process.app.guid}", '{ "state": "STOPPED" }'
expect(last_response.status).to eq(201)
expect(parent_app.reload.desired_state).to eq('STOPPED')
expect(process.reload.state).to eq('STOPPED')
expect(sibling.reload.state).to eq('STOPPED')
end
end
context 'invalid state' do
let(:state) { 'STOPPED' }
it 'raises an error' do
put "/v2/apps/#{process.app.guid}", '{ "state": "ohio" }'
expect(last_response.status).to eq(400)
expect(last_response.body).to include('Invalid app state')
end
end
end
end
describe 'delete an app' do
let(:process) { ProcessModelFactory.make }
let(:developer) { make_developer_for_space(process.space) }
let(:decoded_response) { Oj.load(last_response.body) }
let(:parent_app) { process.app }
before do
set_current_user(developer)
end
def delete_app
delete "/v2/apps/#{process.app.guid}"
end
it 'deletes the app' do
expect(process).to exist
expect(parent_app).to exist
delete_app
expect(last_response.status).to eq(204)
expect(process).not_to exist
expect(parent_app).not_to exist
end
context 'when the app disappears after the find_validate_access_check' do
before do
allow_any_instance_of(AppDelete).to receive(:delete_without_event).and_raise(Sequel::NoExistingObject)
end
it 'throws a not_found_exception' do
delete_app
expect(last_response.status).to eq(404)
expect(parsed_response['description']).to eq("The app could not be found: #{parent_app.guid}")
end
end
describe 'recursive deletion' do
let!(:svc_instance) { ManagedServiceInstance.make(space: process.space) }
let!(:service_binding) { ServiceBinding.make(app: process.app, service_instance: svc_instance) }
let(:guid_pattern) { '[[:alnum:]-]+' }
let(:broker_response_code) { 200 }
before do
service_broker = svc_instance.service.service_broker
uri = URI(service_broker.broker_url)
broker_url = uri.host + uri.path
stub_request(
:delete,
%r{https://#{broker_url}/v2/service_instances/#{guid_pattern}/service_bindings/#{guid_pattern}}
).
with(basic_auth: basic_auth(service_broker:)).
to_return(status: broker_response_code, body: '{}')
end
context 'when recursive=false is set' do
before do
delete_app
end
it 'raises an error' do
expect(last_response.status).to eq(400)
expect(decoded_response['description']).to match(/service_bindings/i)
end
end
context 'when recursive=true is set' do
it 'succeeds on a recursive delete' do
delete "/v2/apps/#{process.app.guid}?recursive=true"
expect(last_response).to have_status_code(204)
end
context 'when the service binding unbind is asynchronous' do
let(:broker_response_code) { 202 }
it 'returns an error' do
delete "/v2/apps/#{process.app.guid}?recursive=true"
expect(last_response).to have_status_code(502)
body = Oj.load(last_response.body)
expect(body['error_code']).to include 'CF-AppRecursiveDeleteFailed'
err_msg = body['description']
expect(err_msg).to match "^Deletion of app #{process.app.name} failed because one or more associated resources could not be deleted.\n\n"
end
end
context 'when the error is a SubResource error' do
before do
errs = [StandardError.new('oops-1'), StandardError.new('oops-2')]
allow_any_instance_of(AppDelete).to receive(:delete_without_event).and_raise(VCAP::CloudController::AppDelete::SubResourceError.new(errs))
end
it 'returns all errors contained within it from the action' do
delete "/v2/apps/#{process.guid}?recursive=true"
expect(last_response).to have_status_code(502)
body = Oj.load(last_response.body)
err_msg = body['description']
expect(err_msg).to match 'oops-1'
expect(err_msg).to match 'oops-2'
end
end
end
end
describe 'events' do
it 'records an app delete-request' do
delete_app
event = Event.find(type: 'audit.app.delete-request', actee_type: 'app')
expect(event.type).to eq('audit.app.delete-request')
expect(event.metadata).to eq({ 'request' => { 'recursive' => false } })
expect(event.actor).to eq(developer.guid)
expect(event.actor_type).to eq('user')
expect(event.actee).to eq(process.app.guid)
expect(event.actee_type).to eq('app')
end
it 'records the recursive query parameter when recursive' do
delete "/v2/apps/#{process.app.guid}?recursive=true"
event = Event.find(type: 'audit.app.delete-request', actee_type: 'app')
expect(event.type).to eq('audit.app.delete-request')
expect(event.metadata).to eq({ 'request' => { 'recursive' => true } })
expect(event.actor).to eq(developer.guid)
expect(event.actor_type).to eq('user')
expect(event.actee).to eq(process.app.guid)
expect(event.actee_type).to eq('app')
end
it 'does not record when the destroy fails' do
allow_any_instance_of(ProcessModel).to receive(:destroy).and_raise('Error saving')
expect do
delete_app
end.to raise_error RuntimeError, /Error saving/
expect(Event.where(type: 'audit.app.delete-request').count).to eq(0)
end
end
end
describe 'route mapping' do
let!(:process) { ProcessModelFactory.make(instances: 1, diego: true) }
let!(:developer) { make_developer_for_space(process.space) }
let!(:route) { Route.make(space: process.space) }
let!(:route_mapping) { RouteMappingModel.make(app: process.app, route: route, process_type: process.type) }
before do
set_current_user(developer)
end
context 'GET' do
it 'returns the route mapping' do
get "/v2/apps/#{process.app.guid}/route_mappings"
expect(last_response.status).to be(200)
parsed_body = parse(last_response.body)
expect(parsed_body['resources'].first['entity']['route_guid']).to eq(route.guid)
expect(parsed_body['resources'].first['entity']['app_guid']).to eq(process.app.guid)
end
end
context 'POST' do
it 'returns 404' do
post "/v2/apps/#{process.app.guid}/route_mappings", '{}'
expect(last_response.status).to be(404)
end
end
context 'PUT' do
it 'returns 404' do
put "/v2/apps/#{process.app.guid}/route_mappings/#{route_mapping.guid}", '{}'
expect(last_response.status).to be(404)
end
end
context 'DELETE' do
it 'returns 404' do
delete "/v2/apps/#{process.app.guid}/route_mappings/#{route_mapping.guid}"
expect(last_response.status).to be(404)
end
end
end
describe "read an app's env" do
let(:space) { process.space }
let(:developer) { make_developer_for_space(space) }
let(:auditor) { make_auditor_for_space(space) }
let(:process) { ProcessModelFactory.make(detected_buildpack: 'buildpack-name') }
let(:decoded_response) { Oj.load(last_response.body) }
before do
set_current_user(developer)
end
context 'when the user is a member of the space this app exists in' do
context 'when the user is not a space developer' do
before do
set_current_user(User.make)
end
it 'returns a JSON payload indicating they do not have permission to read this endpoint' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(403)
expect(Oj.load(last_response.body)['description']).to eql('You are not authorized to perform the requested action')
end
end
context 'when the user has only the cloud_controller.read scope' do
before do
set_current_user(developer, { scopes: ['cloud_controller.read'] })
end
it 'returns successfully' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(200)
parsed_body = parse(last_response.body)
expect(parsed_body).to have_key('staging_env_json')
expect(parsed_body).to have_key('running_env_json')
expect(parsed_body).to have_key('environment_json')
expect(parsed_body).to have_key('system_env_json')
expect(parsed_body).to have_key('application_env_json')
end
end
context 'environment variable' do
it 'returns application environment with VCAP_APPLICATION' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(200)
expect(decoded_response['application_env_json']).to have_key('VCAP_APPLICATION')
expect(decoded_response['application_env_json']).to match({
'VCAP_APPLICATION' => {
'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}",
'limits' => {
'mem' => process.memory,
'disk' => process.disk_quota,
'fds' => 16_384
},
'application_id' => process.app.guid,
'application_name' => process.name,
'name' => process.name,
'application_uris' => [],
'uris' => [],
'application_version' => /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/,
'version' => /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/,
'space_name' => process.space.name,
'space_id' => process.space.guid,
'organization_id' => process.organization.guid,
'organization_name' => process.organization.name,
'process_id' => process.guid,
'process_type' => process.type,
'users' => nil
}
})
end
end
context 'when the user is space dev and has service instance bound to application' do
let!(:service_instance) { ManagedServiceInstance.make(space: process.space) }
let!(:service_binding) { ServiceBinding.make(app: process.app, service_instance: service_instance) }
it 'returns system environment with VCAP_SERVICES' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(200)
expect(decoded_response['system_env_json']['VCAP_SERVICES']).not_to eq({})
end
context 'when the service binding is being asynchronously created' do
let(:operation) { ServiceBindingOperation.make(state: 'in progress') }
before do
service_binding.service_binding_operation = operation
end
it 'does not include the binding in VCAP_SERVICES' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(200)
expect(decoded_response['system_env_json']['VCAP_SERVICES']).to eq({})
end
end
end
context 'when the staging env variable group is set' do
before do
staging_group = EnvironmentVariableGroup.staging
staging_group.environment_json = { POTATO: 'delicious' }
staging_group.save
end
it 'returns staging_env_json with those variables' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(200)
expect(decoded_response['staging_env_json'].size).to eq(1)
expect(decoded_response['staging_env_json']).to have_key('POTATO')
expect(decoded_response['staging_env_json']['POTATO']).to eq('delicious')
end
end
context 'when the running env variable group is set' do
before do
running_group = EnvironmentVariableGroup.running
running_group.environment_json = { PIE: 'sweet' }
running_group.save
end
it 'returns staging_env_json with those variables' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(200)
expect(decoded_response['running_env_json'].size).to eq(1)
expect(decoded_response['running_env_json']).to have_key('PIE')
expect(decoded_response['running_env_json']['PIE']).to eq('sweet')
end
end
context 'when the user does not have the necessary scope' do
before do
set_current_user(developer, { scopes: ['cloud_controller.write'] })
end
it 'returns InsufficientScope' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(403)
expect(Oj.load(last_response.body)['description']).to eql('Your token lacks the necessary scopes to access this resource.')
end
end
end
context 'when the user is a global auditor' do
before do
set_current_user_as_global_auditor
end
it 'is not able to read environment variables' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(403)
expect(Oj.load(last_response.body)['description']).to eql('You are not authorized to perform the requested action')
end
end
context 'when the user reads environment variables from the app endpoint using inline-relations-depth=2' do
let!(:test_environment_json) { { 'environ_key' => 'value' } }
let(:parent_app) { AppModel.make(environment_variables: test_environment_json) }
let!(:process) do
ProcessModelFactory.make(
detected_buildpack: 'buildpack-name',
app: parent_app
)
end
let!(:service_instance) { ManagedServiceInstance.make(space: process.space) }
let!(:service_binding) { ServiceBinding.make(app: process.app, service_instance: service_instance) }
context 'when the user is a space developer' do
it 'returns non-redacted environment values' do
get '/v2/apps?inline-relations-depth=2'
expect(last_response.status).to be(200)
expect(decoded_response['resources'].first['entity']['environment_json']).to eq(test_environment_json)
expect(decoded_response).not_to have_key('system_env_json')
end
end
context 'when the user is not a space developer' do
before do
set_current_user(auditor)
end
it 'returns redacted values' do
get '/v2/apps?inline-relations-depth=2'
expect(last_response.status).to be(200)
expect(decoded_response['resources'].first['entity']['environment_json']).to eq({ 'redacted_message' => '[PRIVATE DATA HIDDEN]' })
expect(decoded_response).not_to have_key('system_env_json')
end
end
end
context 'when the user is NOT a member of the space this instance exists in' do
let(:process) { ProcessModelFactory.make(detected_buildpack: 'buildpack-name') }
before do
set_current_user(User.make)
end
it 'returns access denied' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(403)
end
end
context 'when the user has not authenticated with Cloud Controller' do
let(:developer) { nil }
it 'returns an error saying that the user is not authenticated' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(401)
end
end
context 'when the app does not exist' do
it 'returns not found' do
get '/v2/apps/nonexistentappguid/env'
expect(last_response.status).to be 404
end
end
context 'when the space_developer_env_var_visibility feature flag is disabled' do
before do
VCAP::CloudController::FeatureFlag.make(name: 'space_developer_env_var_visibility', enabled: false, error_message: nil)
end
it 'raises 403 for non-admins' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(403)
expect(last_response.body).to include('FeatureDisabled')
expect(last_response.body).to include('space_developer_env_var_visibility')
end
it 'succeeds for admins' do
set_current_user_as_admin
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(200)
end
it 'succeeds for admin_read_onlys' do
set_current_user_as_admin_read_only
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(200)
end
context 'when the user is not a space developer' do
before do
set_current_user(auditor)
end
it 'indicates they do not have permission rather than that the feature flag is disabled' do
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to be(403)
expect(Oj.load(last_response.body)['description']).to eql('You are not authorized to perform the requested action')
end
end
end
context 'when the env_var_visibility feature flag is disabled' do
before do
VCAP::CloudController::FeatureFlag.make(name: 'env_var_visibility', enabled: false, error_message: nil)
end
it 'raises 403 all user' do
set_current_user_as_admin
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(403)
expect(last_response.body).to include('Feature Disabled: env_var_visibility')
end
context 'when the space_developer_env_var_visibility feature flag is enabled' do
before do
VCAP::CloudController::FeatureFlag.make(name: 'space_developer_env_var_visibility', enabled: true, error_message: nil)
end
it 'raises 403 for non-admins' do
set_current_user(developer)
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(403)
expect(last_response.body).to include('Feature Disabled: env_var_visibility')
end
end
end
context 'when the env_var_visibility feature flag is enabled' do
before do
VCAP::CloudController::FeatureFlag.make(name: 'env_var_visibility', enabled: true, error_message: nil)
end
it 'continues to show 403 for roles that never had access to envs' do
set_current_user(auditor)
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(403)
expect(last_response.body).to include('NotAuthorized')
end
it 'show envs for admins' do
set_current_user_as_admin
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(200)
expect(decoded_response['application_env_json']).to match({
'VCAP_APPLICATION' => {
'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}",
'limits' => {
'mem' => process.memory,
'disk' => process.disk_quota,
'fds' => 16_384
},
'application_id' => process.app.guid,
'application_name' => process.name,
'name' => process.name,
'application_uris' => [],
'uris' => [],
'application_version' => /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/,
'version' => /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/,
'space_name' => process.space.name,
'space_id' => process.space.guid,
'organization_id' => process.organization.guid,
'organization_name' => process.organization.name,
'process_id' => process.guid,
'process_type' => process.type,
'users' => nil
}
})
end
context 'when the space_developer_env_var_visibility feature flag is disabled' do
before do
VCAP::CloudController::FeatureFlag.make(name: 'space_developer_env_var_visibility', enabled: false, error_message: nil)
end
it 'raises 403 for space developers' do
set_current_user(developer)
get "/v2/apps/#{process.app.guid}/env"
expect(last_response.status).to eq(403)
expect(last_response.body).to include('Feature Disabled: space_developer_env_var_visibility')
end
end
end
end
describe 'staging' do
let(:developer) { make_developer_for_space(process.space) }
before do
set_current_user(developer)
Buildpack.make
end
context 'when app will be staged', isolation: :truncation do
let(:process) do
ProcessModelFactory.make(diego: false, state: 'STOPPED', instances: 1).tap do |p|
p.desired_droplet.destroy
p.reload
end
end
let(:stager_response) do
double('StagingResponse', streaming_log_url: 'streaming-log-url')
end
let(:app_stager_task) do
double(Diego::Stager, stage: stager_response)
end
before do
allow(Diego::Stager).to receive(:new).and_return(app_stager_task)
end
it 'returns X-App-Staging-Log header with staging log url' do
put "/v2/apps/#{process.app.guid}", Oj.dump(state: 'STARTED')
expect(last_response.status).to eq(201), last_response.body
expect(last_response.headers['X-App-Staging-Log']).to eq('streaming-log-url')
end
end
context 'when app will not be staged' do
let(:process) { ProcessModelFactory.make(state: 'STOPPED') }
it 'does not add X-App-Staging-Log' do
put "/v2/apps/#{process.app.guid}", Oj.dump({})
expect(last_response.status).to eq(201)
expect(last_response.headers).not_to have_key('X-App-Staging-Log')
end
end
end
describe 'downloading the droplet' do
let(:process) { ProcessModelFactory.make }
let(:blob) { instance_double(CloudController::Blobstore::FogBlob) }
let(:developer) { make_developer_for_space(process.space) }
before do
set_current_user(developer)
allow(blob).to receive(:public_download_url).and_return('http://example.com/somewhere/else')
allow_any_instance_of(CloudController::Blobstore::Client).to receive(:blob).and_return(blob)
end
it 'lets the user download the droplet' do
get "/v2/apps/#{process.app.guid}/droplet/download", Oj.dump({})
expect(last_response).to be_redirect
expect(last_response.header['Location']).to eq('http://example.com/somewhere/else')
end
it 'returns an error for non-existent apps' do
get '/v2/apps/bad/droplet/download', Oj.dump({})
expect(last_response.status).to eq(404)
end
it 'returns an error for an app without a droplet' do
process.desired_droplet.destroy
get "/v2/apps/#{process.app.guid}/droplet/download", Oj.dump({})
expect(last_response.status).to eq(404)
end
end
describe 'uploading the droplet' do
before do
TestConfig.override(directories: { tmpdir: File.dirname(valid_zip.path) })
end
let(:process) { ProcessModel.make }
let(:tmpdir) { Dir.mktmpdir }
after { FileUtils.rm_rf(tmpdir) }
let(:valid_zip) do
zip_name = File.join(tmpdir, 'file.zip')
TestZip.create(zip_name, 1, 1024)
zip_file = File.new(zip_name)
Rack::Test::UploadedFile.new(zip_file)
end
context 'as an admin' do
let(:req_body) { { droplet: valid_zip } }
it 'is allowed' do
set_current_user(User.make, admin: true)
put "/v2/apps/#{process.app.guid}/droplet/upload", req_body
expect(last_response.status).to eq(201)
end
end
context 'as a developer' do
let(:user) { make_developer_for_space(process.space) }
context 'with an empty request' do
it 'fails to upload' do
set_current_user(user)
put "/v2/apps/#{process.app.guid}/droplet/upload", {}
expect(last_response.status).to eq(400)
expect(Oj.load(last_response.body)['description']).to include('missing :droplet_path')
end
end
context 'with valid request' do
let(:req_body) { { droplet: valid_zip } }
it 'creates a delayed job' do
set_current_user(user)
expect do
put "/v2/apps/#{process.app.guid}/droplet/upload", req_body
expect(last_response.status).to eq 201
end.to change(Delayed::Job, :count).by(1)
job = Delayed::Job.last
expect(job.handler).to include('V2::UploadDropletFromUser')
end
end
end
context 'as a non-developer' do
let(:req_body) { { droplet: valid_zip } }
it 'returns 403' do
put "/v2/apps/#{process.app.guid}/droplet/upload", req_body
expect(last_response.status).to eq(403)
end
end
end
describe 'on route change', isolation: :truncation do
let(:space) { process.space }
let(:domain) do
PrivateDomain.make(name: 'jesse.cloud', owning_organization: space.organization)
end
let(:process) { ProcessModelFactory.make(diego: false, state: 'STARTED') }
before do
FeatureFlag.create(name: 'diego_docker', enabled: true)
set_current_user(make_developer_for_space(space))
end
it 'creates a route mapping when we add one url through PUT /v2/apps/:guid' do
route = domain.add_route(
host: 'app',
space: space
)
fake_route_mapping_create = instance_double(V2::RouteMappingCreate)
allow(V2::RouteMappingCreate).to receive(:new).with(anything, route, process, anything, instance_of(Steno::Logger)).and_return(fake_route_mapping_create)
expect(fake_route_mapping_create).to receive(:add)
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response.status).to eq(201)
end
context 'with Docker app' do
let(:space) { docker_process.space }
let(:route) { domain.add_route(host: 'app', space: space) }
let(:pre_mapped_route) { domain.add_route(host: 'pre_mapped_route', space: space) }
let(:docker_process) do
ProcessModelFactory.make(
state: 'STARTED',
diego: true,
docker_image: 'some-image'
)
end
context 'when Docker is disabled' do
before do
allow_any_instance_of(Diego::Messenger).to receive(:send_desire_request)
FeatureFlag.find(name: 'diego_docker').update(enabled: false)
end
context 'and a route is mapped' do
it 'succeeds' do
put "/v2/apps/#{docker_process.app.guid}/routes/#{route.guid}", nil
expect(last_response.status).to eq(201)
end
end
context 'and a previously mapped route is unmapped' do
it 'succeeds' do
delete "/v2/apps/#{docker_process.app.guid}/routes/#{pre_mapped_route.guid}", nil
expect(last_response.status).to eq(204)
end
end
end
end
end
describe 'on instance number change' do
before do
FeatureFlag.create(name: 'diego_docker', enabled: true)
end
context 'when docker is disabled' do
let!(:started_process) do
ProcessModelFactory.make(state: 'STARTED', docker_image: 'docker-image')
end
before do
FeatureFlag.find(name: 'diego_docker').update(enabled: false)
set_current_user(make_developer_for_space(started_process.space))
end
it 'does not return docker disabled message' do
put "/v2/apps/#{started_process.app.guid}", Oj.dump(instances: 2)
expect(last_response.status).to eq(201)
end
end
end
describe 'on state change' do
before do
FeatureFlag.create(name: 'diego_docker', enabled: true)
end
context 'when docker is disabled' do
let!(:stopped_process) do
ProcessModelFactory.make(:docker, state: 'STOPPED', docker_image: 'docker-image', type: 'web')
end
let!(:started_process) do
ProcessModelFactory.make(:docker, state: 'STARTED', docker_image: 'docker-image', type: 'web')
end
before do
FeatureFlag.find(name: 'diego_docker').update(enabled: false)
end
it 'returns docker disabled message on start' do
set_current_user(make_developer_for_space(stopped_process.space))
put "/v2/apps/#{stopped_process.app.guid}", Oj.dump(state: 'STARTED')
expect(last_response.status).to eq(400)
expect(last_response.body).to match(/Docker support has not been enabled/)
expect(decoded_response['code']).to eq(320_003)
end
it 'does not return docker disabled message on stop' do
set_current_user(make_developer_for_space(started_process.space))
put "/v2/apps/#{started_process.app.guid}", Oj.dump(state: 'STOPPED')
expect(last_response.status).to eq(201)
end
end
end
describe 'Permissions' do
include_context 'permissions'
before do
@obj_a = ProcessModelFactory.make(app: AppModel.make(space: @space_a))
@obj_b = ProcessModelFactory.make(app: AppModel.make(space: @space_b))
end
describe 'Org Level Permissions' do
describe 'OrgManager' do
let(:member_a) { @org_a_manager }
let(:member_b) { @org_b_manager }
include_examples 'permission enumeration', 'OrgManager',
name: 'app',
path: '/v2/apps',
enumerate: 1
end
describe 'OrgUser' do
let(:member_a) { @org_a_member }
let(:member_b) { @org_b_member }
include_examples 'permission enumeration', 'OrgUser',
name: 'app',
path: '/v2/apps',
enumerate: 0
end
describe 'BillingManager' do
let(:member_a) { @org_a_billing_manager }
let(:member_b) { @org_b_billing_manager }
include_examples 'permission enumeration', 'BillingManager',
name: 'app',
path: '/v2/apps',
enumerate: 0
end
describe 'Auditor' do
let(:member_a) { @org_a_auditor }
let(:member_b) { @org_b_auditor }
include_examples 'permission enumeration', 'Auditor',
name: 'app',
path: '/v2/apps',
enumerate: 0
end
end
describe 'App Space Level Permissions' do
describe 'SpaceManager' do
let(:member_a) { @space_a_manager }
let(:member_b) { @space_b_manager }
include_examples 'permission enumeration', 'SpaceManager',
name: 'app',
path: '/v2/apps',
enumerate: 1
end
describe 'Developer' do
let(:member_a) { @space_a_developer }
let(:member_b) { @space_b_developer }
include_examples 'permission enumeration', 'Developer',
name: 'app',
path: '/v2/apps',
enumerate: 1
end
describe 'SpaceAuditor' do
let(:member_a) { @space_a_auditor }
let(:member_b) { @space_b_auditor }
include_examples 'permission enumeration', 'SpaceAuditor',
name: 'app',
path: '/v2/apps',
enumerate: 1
end
end
end
describe 'Validation messages' do
let(:space) { process.space }
let!(:process) { ProcessModelFactory.make(state: 'STARTED') }
before do
set_current_user(make_developer_for_space(space))
end
it 'returns duplicate app name message correctly' do
existing_process = ProcessModel.make(app: AppModel.make(space:))
put "/v2/apps/#{process.app.guid}", Oj.dump(name: existing_process.name)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(100_002)
end
it 'returns organization quota memory exceeded message correctly' do
space.organization.quota_definition = QuotaDefinition.make(memory_limit: 0)
space.organization.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 128)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(100_005)
end
it 'returns space quota memory exceeded message correctly' do
space.space_quota_definition = SpaceQuotaDefinition.make(memory_limit: 0)
space.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 128)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(310_003)
end
it 'validates space quota memory limit before organization quotas' do
space.organization.quota_definition = QuotaDefinition.make(memory_limit: 0)
space.organization.save(validate: false)
space.space_quota_definition = SpaceQuotaDefinition.make(memory_limit: 0)
space.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 128)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(310_003)
end
it 'returns memory invalid message correctly' do
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 0)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(100_006)
end
it 'returns instance memory limit exceeded error correctly' do
space.organization.quota_definition = QuotaDefinition.make(instance_memory_limit: 100)
space.organization.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 128)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(100_007)
end
it 'returns space instance memory limit exceeded error correctly' do
space.space_quota_definition = SpaceQuotaDefinition.make(instance_memory_limit: 100)
space.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 128)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(310_004)
end
it 'returns app instance limit exceeded error correctly' do
space.organization.quota_definition = QuotaDefinition.make(app_instance_limit: 4)
space.organization.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(instances: 5)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(100_008)
end
it 'validates space quota instance memory limit before organization quotas' do
space.organization.quota_definition = QuotaDefinition.make(instance_memory_limit: 100)
space.organization.save(validate: false)
space.space_quota_definition = SpaceQuotaDefinition.make(instance_memory_limit: 100)
space.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(memory: 128)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(310_004)
end
it 'returns instances invalid message correctly' do
put "/v2/apps/#{process.app.guid}", Oj.dump(instances: -1)
expect(last_response.status).to eq(400)
expect(last_response.body).to match(/instances less than 0/)
expect(decoded_response['code']).to eq(100_001)
end
it 'returns state invalid message correctly' do
put "/v2/apps/#{process.app.guid}", Oj.dump(state: 'mississippi')
expect(last_response.status).to eq(400)
expect(last_response.body).to match(/Invalid app state provided/)
expect(decoded_response['code']).to eq(100_001)
end
it 'validates space quota app instance limit' do
space.space_quota_definition = SpaceQuotaDefinition.make(app_instance_limit: 2)
space.save(validate: false)
put "/v2/apps/#{process.app.guid}", Oj.dump(instances: 3)
expect(last_response.status).to eq(400)
expect(decoded_response['code']).to eq(310_008)
end
end
describe 'enumerate' do
let!(:web_process) { ProcessModel.make(type: 'web') }
let!(:other_app) { ProcessModel.make(type: 'other') }
before do
set_current_user_as_admin
end
it 'displays processes with type web' do
get '/v2/apps'
expect(decoded_response['total_results']).to eq(1)
expect(decoded_response['resources'][0]['metadata']['guid']).to eq(web_process.app.guid)
end
end
describe 'PUT /v2/apps/:app_guid/routes/:route_guid' do
let(:space) { Space.make }
let(:process) { ProcessModelFactory.make(space:) }
let(:route) { Route.make(space:) }
let(:developer) { make_developer_for_space(space) }
before do
set_current_user(developer)
end
it 'adds the route to the app' do
expect(process.reload.routes).to be_empty
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response).to have_status_code(201)
expect(process.reload.routes).to contain_exactly(route)
route_mapping = RouteMappingModel.last
expect(route_mapping.app_port).to eq(8080)
expect(route_mapping.process_type).to eq('web')
end
context 'when the app does not exist' do
it 'returns 404' do
put "/v2/apps/not-real/routes/#{route.guid}", nil
expect(last_response).to have_status_code(404)
expect(last_response.body).to include('AppNotFound')
end
end
context 'when the route does not exist' do
it 'returns 404' do
put "/v2/apps/#{process.app.guid}/routes/not-real", nil
expect(last_response).to have_status_code(404)
expect(last_response.body).to include('RouteNotFound')
end
end
context 'when the route is already mapped to the app' do
before do
RouteMappingModel.make(app: process.app, route: route, process_type: process.type)
end
it 'succeeds' do
expect(process.reload.routes).to contain_exactly(route)
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response).to have_status_code(201)
end
end
context 'when the user is not a developer in the apps space' do
before do
set_current_user(User.make)
end
it 'returns 403' do
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response).to have_status_code(403)
end
end
context 'when the route is in a different space' do
let(:route) { Route.make }
it 'raises an error' do
expect(process.reload.routes).to be_empty
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response.status).to eq(400)
expect(last_response.body).to include('InvalidRelation')
expect(decoded_response['description']).to include(
'The app cannot be mapped to this route because the route is not in this space. Apps must be mapped to routes in the same space'
)
expect(process.reload.routes).to be_empty
end
end
context 'when the app has multiple ports' do
let(:process) { ProcessModelFactory.make(diego: true, space: route.space, ports: [9797, 7979]) }
it 'uses the first port for the app as the app_port' do
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response.status).to eq(201)
mapping = RouteMappingModel.last
expect(mapping.app_port).to eq(9797)
end
end
describe 'routes from tcp router groups' do
let(:domain) { SharedDomain.make(name: 'tcp.com', router_group_guid: 'router-group-guid') }
let(:route) { Route.make(space: process.space, domain: domain, port: 9090, host: '') }
let(:routing_api_client) { double('routing_api_client', router_group:) }
let(:router_group) { double('router_group', type: 'tcp', guid: 'router-group-guid') }
before do
allow_any_instance_of(RouteValidator).to receive(:validate)
allow(VCAP::CloudController::RoutingApi::Client).to receive(:new).and_return(routing_api_client)
end
it 'adds the route to the app' do
expect(process.reload.routes).to be_empty
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response).to have_status_code(201)
expect(process.reload.routes).to contain_exactly(route)
route_mapping = RouteMappingModel.last
expect(route_mapping.app_port).to eq(8080)
expect(route_mapping.process_type).to eq('web')
end
context 'when routing api is disabled' do
before do
route
TestConfig.override(routing_api: nil)
end
it 'existing routes with router groups return 403 when mapped to apps' do
put "/v2/apps/#{process.app.guid}/routes/#{route.guid}", nil
expect(last_response).to have_status_code(403)
expect(decoded_response['description']).to include('Routing API is disabled')
end
end
end
end
describe 'DELETE /v2/apps/:app_guid/routes/:route_guid' do
let(:space) { Space.make }
let(:process) { ProcessModelFactory.make(space:) }
let(:route) { Route.make(space:) }
let!(:route_mapping) { RouteMappingModel.make(app: process.app, route: route, process_type: process.type) }
let(:developer) { make_developer_for_space(space) }
before do
set_current_user(developer)
end
it 'removes the association' do
expect(process.reload.routes).to contain_exactly(route)
delete "/v2/apps/#{process.app.guid}/routes/#{route.guid}"
expect(last_response.status).to eq(204)
expect(process.reload.routes).to be_empty
end
context 'when the app does not exist' do
it 'returns 404' do
delete "/v2/apps/not-found/routes/#{route.guid}"
expect(last_response).to have_status_code(404)
expect(last_response.body).to include('AppNotFound')
end
end
context 'when the route does not exist' do
it 'returns 404' do
delete "/v2/apps/#{process.app.guid}/routes/not-found"
expect(last_response).to have_status_code(404)
expect(last_response.body).to include('RouteNotFound')
end
end
context 'when the route is not mapped to the app' do
before do
route_mapping.destroy
end
it 'succeeds' do
expect(process.reload.routes).to be_empty
delete "/v2/apps/#{process.app.guid}/routes/#{route.guid}"
expect(last_response).to have_status_code(204)
end
end
context 'when the user is not a developer in the apps space' do
before do
set_current_user(User.make)
end
it 'returns 403' do
delete "/v2/apps/#{process.app.guid}/routes/#{route.guid}"
expect(last_response).to have_status_code(403)
end
end
end
describe 'GET /v2/apps/:app_guid/service_bindings' do
let(:space) { Space.make }
let(:managed_service_instance) { ManagedServiceInstance.make(space:) }
let(:developer) { make_developer_for_space(space) }
let(:process1) { ProcessModelFactory.make(space: space, name: 'process1') }
let(:process2) { ProcessModelFactory.make(space: space, name: 'process2') }
let(:process3) { ProcessModelFactory.make(space: space, name: 'process3') }
before do
set_current_user(developer)
ServiceBinding.make(service_instance: managed_service_instance, app: process1.app, name: 'guava')
ServiceBinding.make(service_instance: managed_service_instance, app: process2.app, name: 'peach')
ServiceBinding.make(service_instance: managed_service_instance, app: process3.app, name: 'cilantro')
end
it "queries apps' service_bindings by name" do
# process1 has no peach bindings
get "/v2/apps/#{process1.app.guid}/service_bindings?q=name:peach"
expect(last_response.status).to eql(200), last_response.body
service_bindings = decoded_response['resources']
expect(service_bindings.size).to eq(0)
get "/v2/apps/#{process1.app.guid}/service_bindings?q=name:guava"
expect(last_response.status).to eql(200), last_response.body
service_bindings = decoded_response['resources']
expect(service_bindings.size).to eq(1)
entity = service_bindings[0]['entity']
expect(entity['app_guid']).to eq(process1.app.guid)
expect(entity['service_instance_guid']).to eq(managed_service_instance.guid)
expect(entity['name']).to eq('guava')
[[process1, 'guava'], [process2, 'peach'], [process3, 'cilantro']].each do |process, fruit|
get "/v2/apps/#{process.app.guid}/service_bindings?q=name:#{fruit}"
expect(last_response.status).to be(200)
service_bindings = decoded_response['resources']
expect(service_bindings.size).to eq(1)
entity = service_bindings[0]['entity']
expect(entity['app_guid']).to eq(process.app.guid)
expect(entity['service_instance_guid']).to eq(managed_service_instance.guid)
expect(entity['name']).to eq(fruit)
end
end
# This is why there isn't much point testing lookup by name with this endpoint --
# These tests show we can have at most one hit per name in the
# apps/APPGUID/service_bindings endpoint.
context 'when there are multiple services' do
let(:si1) { ManagedServiceInstance.make(space:) }
let(:si2) { ManagedServiceInstance.make(space:) }
let(:developer) { make_developer_for_space(space) }
let(:process1) { ProcessModelFactory.make(space: space, name: 'process1') }
let(:process2) { ProcessModelFactory.make(space: space, name: 'process2') }
before do
set_current_user(developer)
ServiceBinding.make(service_instance: si1, app: process1.app, name: 'out')
ServiceBinding.make(service_instance: si2, app: process2.app, name: 'free')
end
it 'binding si2 to process1 with a name in use by process1 is not ok' do
expect do
ServiceBinding.make(service_instance: si2, app: process1.app, name: 'out')
end.to raise_error(Sequel::ValidationFailed, /App binding names must be unique\./)
end
it 'binding si1 to process1 with a new name is not ok' do
expect do
ServiceBinding.make(service_instance: si1, app: process1.app, name: 'gravy')
end.to raise_error(Sequel::ValidationFailed, 'The app is already bound to the service.')
end
it 'binding si2 to process1 with a name in use by process2 is ok' do
ServiceBinding.make(service_instance: si2, app: process1.app, name: 'free')
get "/v2/apps/#{process1.app.guid}/service_bindings?results-per-page=2&page=1&q=name:free"
expect(last_response.status).to eq(200), last_response.body
end
end
end
describe 'DELETE /v2/apps/:app_guid/service_bindings/:service_binding_guid' do
let(:space) { Space.make }
let(:process) { ProcessModelFactory.make(space:) }
let(:instance) { ManagedServiceInstance.make(space:) }
let!(:service_binding) { ServiceBinding.make(app: process.app, service_instance: instance) }
let(:developer) { make_developer_for_space(space) }
before do
set_current_user(developer)
allow_any_instance_of(VCAP::Services::ServiceBrokers::V2::Client).to receive(:unbind).and_return({ async: false })
end
it 'removes the association' do
expect(process.reload.service_bindings).to contain_exactly(service_binding)
delete "/v2/apps/#{process.app.guid}/service_bindings/#{service_binding.guid}"
expect(last_response.status).to eq(204)
expect(process.reload.service_bindings).to be_empty
end
it 'has the deprecated warning header' do
delete "/v2/apps/not-found/service_bindings/#{service_binding.guid}"
expect(last_response).to be_a_deprecated_response
end
context 'when the app does not exist' do
it 'returns 404' do
delete "/v2/apps/not-found/service_bindings/#{service_binding.guid}"
expect(last_response).to have_status_code(404)
expect(last_response.body).to include('AppNotFound')
end
end
context 'when the service binding does not exist' do
it 'returns 404' do
delete "/v2/apps/#{process.app.guid}/service_bindings/not-found"
expect(last_response).to have_status_code(404)
expect(last_response.body).to include('ServiceBindingNotFound')
end
end
context 'when the user is not a developer in the apps space' do
before do
set_current_user(User.make)
end
it 'returns 403' do
delete "/v2/apps/#{process.app.guid}/service_bindings/#{service_binding.guid}"
expect(last_response).to have_status_code(403)
end
end
end
describe 'GET /v2/apps/:guid/permissions' do
let(:process) { ProcessModelFactory.make(space:) }
let(:space) { Space.make }
let(:user) { User.make }
before do
space.organization.add_user(user)
end
context 'when the user is a SpaceDeveloper' do
before do
space.add_developer(user)
set_current_user(user, { scopes: ['cloud_controller.user'] })
end
it 'succeeds and present data reading permissions' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(200)
expect(parsed_response['read_sensitive_data']).to be(true)
expect(parsed_response['read_basic_data']).to be(true)
end
end
context 'when the user is a OrgManager' do
before do
process.organization.add_manager(user)
set_current_user(user, { scopes: ['cloud_controller.user'] })
end
it 'succeeds and present data reading permissions' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(200)
expect(parsed_response['read_sensitive_data']).to be(false)
expect(parsed_response['read_basic_data']).to be(true)
end
end
context 'when the user is a BillingManager' do
before do
space.organization.add_billing_manager(user)
set_current_user(user, { scopes: ['cloud_controller.user'] })
end
it 'fails with a 403' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(403)
expect(decoded_response['code']).to eq(10_003)
expect(decoded_response['error_code']).to eq('CF-NotAuthorized')
expect(decoded_response['description']).to include('You are not authorized to perform the requested action')
end
end
context 'when the user is a OrgAuditor' do
before do
space.organization.add_auditor(user)
set_current_user(user, { scopes: ['cloud_controller.user'] })
end
it 'fails with a 403' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(403)
expect(decoded_response['code']).to eq(10_003)
expect(decoded_response['error_code']).to eq('CF-NotAuthorized')
expect(decoded_response['description']).to include('You are not authorized to perform the requested action')
end
end
context 'when the user is a SpaceManager' do
before do
space.add_manager(user)
set_current_user(user, { scopes: ['cloud_controller.user'] })
end
it 'succeeds and present data reading permissions' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(200)
expect(parsed_response['read_sensitive_data']).to be(false)
expect(parsed_response['read_basic_data']).to be(true)
end
end
context 'when the user is a SpaceAuditor' do
before do
space.add_auditor(user)
set_current_user(user, { scopes: ['cloud_controller.user'] })
end
it 'succeeds and present data reading permissions' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(200)
expect(parsed_response['read_sensitive_data']).to be(false)
expect(parsed_response['read_basic_data']).to be(true)
end
end
context 'when the user is a read-only admin' do
before do
set_current_user_as_admin_read_only
end
it 'returns 200' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(200)
expect(parsed_response['read_sensitive_data']).to be(true)
expect(parsed_response['read_basic_data']).to be(true)
end
end
context 'when the user is a global auditor' do
before do
set_current_user_as_global_auditor
end
it 'returns 200 but false for sensitive data' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(200)
expect(parsed_response['read_sensitive_data']).to be(false)
expect(parsed_response['read_basic_data']).to be(true)
end
end
context 'when missing cloud_controller.user scope' do
let(:user) { make_developer_for_space(space) }
before do
set_current_user(user, { scopes: [] })
end
it 'returns 403' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(403)
end
end
context 'when the user is not part of the org or space' do
before do
new_user = User.make
set_current_user(new_user)
end
it 'returns 403' do
get "/v2/apps/#{process.app.guid}/permissions"
expect(last_response.status).to eq(403)
expect(decoded_response['code']).to eq(10_003)
expect(decoded_response['error_code']).to eq('CF-NotAuthorized')
expect(decoded_response['description']).to include('You are not authorized to perform the requested action')
end
end
context 'when the app does not exist' do
it 'returns 404' do
get '/v2/apps/made-up-guid/permissions'
expect(last_response.status).to eq(404)
end
end
end
end
end