spec/unit/controllers/runtime/stagings_controller_spec.rb
require 'spec_helper'
## NOTICE: Prefer request specs over controller specs as per ADR #0003 ##
module VCAP::CloudController
RSpec.shared_examples 'check content digest' do
context 'when a content-md5 is specified' do
it 'returns a 400 if the value does not match the md5 of the body' do
post url, upload_req, 'HTTP_CONTENT_MD5' => 'the-wrong-md5'
expect(last_response.status).to eq(400)
end
it 'succeeds if the value matches the md5 of the body' do
content_md5 = digester_md5.digest(file_content)
post url, upload_req, 'HTTP_CONTENT_MD5' => content_md5
expect(last_response.status).to eq(200)
end
end
context 'when a content digest sha1 is specified' do
it 'returns a 400 if the value does not match the sha1 of the body' do
post url, upload_req, 'HTTP_CONTENT_DIGEST' => 'sha=:the+wrong+sha1:'
expect(last_response.status).to eq(400)
end
it 'succeeds if the value matches the sha1 of the body' do
content_sha1 = digester_sha1.digest(file_content)
post url, upload_req, 'HTTP_CONTENT_DIGEST' => "sha=:#{content_sha1}:"
expect(last_response.status).to eq(200)
end
end
context 'when a content digest sha256 is specified' do
it 'returns a 400 if the value does not match the sha256 of the body' do
post url, upload_req, 'HTTP_CONTENT_DIGEST' => 'sha-256=:the+wrong+sha256:'
expect(last_response.status).to eq(400)
end
it 'succeeds if the value matches the sha256 of the body' do
content_sha256 = digester_sha256.digest(file_content)
post url, upload_req, 'HTTP_CONTENT_DIGEST' => "sha-256=:#{content_sha256}:"
expect(last_response.status).to eq(200)
end
end
context 'when a content digest sha512 is specified' do
it 'returns a 400 if the value does not match the sha512 of the body' do
post url, upload_req, 'HTTP_CONTENT_DIGEST' => 'sha-512=:the+wrong+sha512:'
expect(last_response.status).to eq(400)
end
it 'succeeds if the value matches the sha512 of the body' do
content_sha512 = digester_sha512.digest(file_content)
post url, upload_req, 'HTTP_CONTENT_DIGEST' => "sha-512=:#{content_sha512}:"
expect(last_response.status).to eq(200)
end
end
context 'when an invalid content digest format is specified' do
it 'returns a 400' do
post url, upload_req, 'HTTP_CONTENT_DIGEST' => 'D/I/G/E/S/T:'
expect(last_response.status).to eq(400)
expect(last_response.body).to include('invalid content digest header format')
end
end
context 'when an unsupported content digest algorithm is specified' do
it 'returns a 400' do
post url, upload_req, 'HTTP_CONTENT_DIGEST' => 'sha-42=:D/I/G/E/S/T:'
expect(last_response.status).to eq(400)
expect(last_response.body).to include('unsupported digest algorithm')
end
end
end
RSpec.shared_examples 'droplet staging error handling' do
include_examples 'check content digest'
context 'with an invalid app' do
it 'returns 404' do
post bad_droplet_url, upload_req
expect(last_response.status).to eq(404)
end
it 'does not add a job' do
expect do
post bad_droplet_url, upload_req
end.not_to(change(Delayed::Job, :count))
end
context 'when the upload path is nil' do
let(:upload_req) do
{ upload: { droplet: nil } }
end
it 'does not add a job' do
expect do
post url, upload_req
end.not_to(change(Delayed::Job, :count))
end
end
end
end
RSpec.shared_examples 'a Build to Droplet stager' do
it 'schedules a job to upload the droplet to the blobstore' do
expect do
post url, upload_req
end.to change {
[Delayed::Job.count, DropletModel.count]
}.by([1, 1])
droplet = DropletModel.last
expect(droplet.app_guid).to eq(process.guid)
expect(droplet.package_guid).to eq(package.guid)
expect(droplet.state).to eq(DropletModel::STAGING_STATE)
expect(droplet.build).to eq(build)
expect(last_response.status).to eq 200
job = Delayed::Job.last
expect(job).to be_a_fully_wrapped_job_of VCAP::CloudController::Jobs::V3::DropletUpload
inner_job = job.payload_object.handler.handler
expect(inner_job.droplet_guid).to eq(droplet.guid)
end
it 'creates an audit.app.droplet.create event' do
expect do
post url, upload_req
end.to change(Event, :count).by(1)
event = Event.last
droplet = DropletModel.last
expect(event.type).to eq('audit.app.droplet.create')
expect(event.actor).to eq('1234')
expect(event.actor_type).to eq('user')
expect(event.actor_name).to eq('joe@joe.com')
expect(event.actor_username).to eq('briggs')
expect(event.actee).to eq(process.guid)
expect(event.actee_type).to eq('app')
expect(event.actee_name).to eq(process.name)
expect(event.timestamp).to be
expect(event.space_guid).to eq(process.space_guid)
expect(event.organization_guid).to eq(process.space.organization.guid)
expect(event.metadata).to eq({
'droplet_guid' => droplet.guid,
'package_guid' => package.guid
})
end
end
RSpec.shared_examples 'a legacy Droplet stager to support rolling deploys' do
it 'schedules a job to upload the existing droplet to the blobstore' do
expect do
post url, upload_req
end.to change(Delayed::Job, :count).by(1)
expect(last_response.status).to eq 200
job = Delayed::Job.last
expect(job).to be_a_fully_wrapped_job_of VCAP::CloudController::Jobs::V3::DropletUpload
inner_job = job.payload_object.handler.handler
expect(inner_job.droplet_guid).to eq(droplet.guid)
end
end
RSpec.describe StagingsController do
let(:timeout_in_seconds) { 120 }
let(:cc_addr) { '1.2.3.4' }
let(:cc_port) { 5678 }
let(:tls_port) { 5679 }
let(:staging_user) { 'user' }
let(:staging_password) { 'pass[%3a]word' }
let(:blobstore) do
CloudController::DependencyLocator.instance.droplet_blobstore
end
let(:digester_md5) { Digester.new(algorithm: OpenSSL::Digest::MD5, type: :base64digest) }
let(:digester_sha1) { Digester.new(algorithm: OpenSSL::Digest::SHA1, type: :base64digest) }
let(:digester_sha256) { Digester.new(algorithm: OpenSSL::Digest::SHA256, type: :base64digest) }
let(:digester_sha512) { Digester.new(algorithm: OpenSSL::Digest::SHA512, type: :base64digest) }
let(:buildpack_cache_blobstore) do
CloudController::DependencyLocator.instance.buildpack_cache_blobstore
end
let(:workspace) { Dir.mktmpdir }
let(:original_staging_config) do
{
external_host: cc_addr,
tls_port: tls_port,
staging: {
auth: {
user: staging_user,
password: staging_password
}
},
nginx: { use_nginx: true },
resource_pool: {
resource_directory_key: 'cc-resources',
fog_connection: {
provider: 'Local',
local_root: Dir.mktmpdir('resourse_pool', workspace)
}
},
packages: {
fog_connection: {
provider: 'Local',
local_root: Dir.mktmpdir('packages', workspace)
},
app_package_directory_key: 'cc-packages'
},
droplets: {
droplet_directory_key: 'cc-droplets',
fog_connection: {
provider: 'Local',
local_root: Dir.mktmpdir('droplets', workspace)
}
},
directories: {
tmpdir: Dir.mktmpdir('tmpdir', workspace)
},
index: 99,
name: 'api_z1'
}
end
let(:staging_config) { original_staging_config }
# explicitly unstaged app
let(:process) do
ProcessModelFactory.make.tap do |p|
p.desired_droplet.destroy
p.reload
end
end
before do
Fog.unmock!
TestConfig.override(**staging_config)
set_current_user_as_admin(user: User.make(guid: '1234'), email: 'joe@joe.com', user_name: 'briggs')
end
after { FileUtils.rm_rf(workspace) }
shared_examples 'staging bad auth' do |verb, path|
it 'returns 401 for bad credentials' do
authorize 'hacker', 'sw0rdf1sh'
send(verb, "/staging/#{path}/#{process.guid}")
expect(last_response.status).to eq(401)
end
end
describe 'GET /internal/v4/staging_jobs/:guid' do
let(:job) { Delayed::Job.enqueue double(perform: nil) }
let(:job_guid) { job.guid }
it 'returns the job' do
get "/internal/v4/staging_jobs/#{job_guid}"
expect(last_response.status).to eq(200)
expect(decoded_response(symbolize_keys: true)).to eq(StagingJobPresenter.new(job, 'https').to_hash)
expect(decoded_response['metadata']['guid']).to eq(job_guid)
end
end
describe 'GET /staging/packages/:guid' do
let(:package_without_bits) { PackageModel.make }
let(:package) { PackageModel.make }
before { authorize(staging_user, staging_password) }
def create_test_blob
tmpdir = Dir.mktmpdir
file = File.new(File.join(tmpdir, 'afile.txt'), 'w')
file.print('test blob contents')
file.close
CloudController::Blobstore::FogBlob.new(file, nil)
end
context 'when using with nginx' do
before do
TestConfig.override(**staging_config)
blob = create_test_blob
allow(blob).to receive(:internal_download_url).and_return("/cc-packages/gu/id/#{package.guid}")
package_blobstore = instance_double(CloudController::Blobstore::Client, blob: blob, local?: true)
allow(CloudController::DependencyLocator.instance).to receive(:package_blobstore).and_return(package_blobstore)
end
it 'succeeds for valid packages' do
get "/staging/packages/#{package.guid}"
expect(last_response.status).to eq(200)
expect(last_response.headers['X-Accel-Redirect']).to eq("/cc-packages/gu/id/#{package.guid}")
end
end
context 'when not using with nginx' do
let(:staging_config) do
original_staging_config.merge({ nginx: { use_nginx: false } })
end
before do
package_blobstore = instance_double(CloudController::Blobstore::Client, blob: create_test_blob, local?: true)
allow(CloudController::DependencyLocator.instance).to receive(:package_blobstore).and_return(package_blobstore)
end
it 'succeeds for valid packages' do
get "/staging/packages/#{package.guid}"
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('test blob contents')
end
end
it 'fails if blobstore is not local' do
allow_any_instance_of(CloudController::Blobstore::FogClient).to receive(:local?).and_return(false)
get '/staging/packages/some-guid'
expect(last_response.status).to eq(400)
end
it 'returns an error for non-existent packages' do
get '/staging/packages/bad-guid'
expect(last_response.status).to eq(404)
end
it 'returns an error for an package without bits' do
get "/staging/packages/#{package_without_bits.guid}"
expect(last_response.status).to eq(404)
end
include_examples 'staging bad auth', :get, 'packages'
end
describe 'POST /internal/v4/droplets/:guid/upload' do
include TempFileCreator
it_behaves_like 'a Build to Droplet stager' do
let(:file_content) { 'droplet content' }
let(:package) { PackageModel.make(app: process) }
let(:build) do
BuildModel.make(
package: package,
app: process,
created_by_user_guid: '1234',
created_by_user_email: 'joe@joe.com',
created_by_user_name: 'briggs'
)
end
let(:droplet) { nil }
let(:upload_req) do
{ upload: { droplet: Rack::Test::UploadedFile.new(temp_file_with_content(file_content)) } }
end
let(:url) { "/internal/v4/droplets/#{build.guid}/upload" }
let(:bad_droplet_url) { '/internal/v4/droplets/bad-build/upload' }
it "returns a JSON body with full url and basic auth to query for job's status" do
post url, upload_req
job = Delayed::Job.last
config = VCAP::CloudController::Config.config.config_hash
polling_url = "https://#{config[:internal_service_hostname]}:#{config[:tls_port]}/internal/v4/staging_jobs/#{job.guid}"
expect(decoded_response.fetch('metadata').fetch('url')).to eql(polling_url)
end
end
it_behaves_like 'a legacy Droplet stager to support rolling deploys' do
let(:droplet) { DropletModel.make }
let(:file_content) { 'droplet content' }
let(:upload_req) do
{ upload: { droplet: Rack::Test::UploadedFile.new(temp_file_with_content(file_content)) } }
end
let(:url) { "/internal/v4/droplets/#{droplet.guid}/upload" }
let(:bad_droplet_url) { '/internal/v4/droplets/bad-droplet/upload' }
end
it_behaves_like 'droplet staging error handling' do
let(:file_content) { 'droplet content' }
let(:package) { PackageModel.make(app: process) }
let(:build) { BuildModel.make(package: package, app: process) }
let(:droplet) { nil }
let(:upload_req) do
{ upload: { droplet: Rack::Test::UploadedFile.new(temp_file_with_content(file_content)) } }
end
let(:url) { "/internal/v4/droplets/#{build.guid}/upload" }
let(:bad_droplet_url) { '/internal/v4/droplets/bad-build/upload' }
end
end
describe 'POST /internal/v4/buildpack_cache/:stack/:app_guid/upload' do
include TempFileCreator
let(:file_content) { 'the-file-content' }
let(:upload_req) do
{ upload: { droplet: Rack::Test::UploadedFile.new(temp_file_with_content(file_content)) } }
end
let(:app_model) { AppModel.make }
let(:stack) { Sham.name }
context 'with a valid app' do
it 'returns 200' do
post "/internal/v4/buildpack_cache/#{stack}/#{app_model.guid}/upload", upload_req
expect(last_response.status).to eq(200)
end
it 'stores file path in handle.buildpack_cache_upload_path' do
expect do
post "/internal/v4/buildpack_cache/#{stack}/#{app_model.guid}/upload", upload_req
end.to change(Delayed::Job, :count).by(1)
job = Delayed::Job.last
expect(job.handler).to include(app_model.guid)
expect(job.handler).to include(stack)
expect(job.handler).to include('ngx.uploads')
expect(job.queue).to eq('cc-api_z1-99')
expect(job.guid).not_to be_nil
expect(last_response.status).to eq 200
end
include_examples 'check content digest' do
let(:url) { "/internal/v4/buildpack_cache/#{stack}/#{app_model.guid}/upload" }
end
end
context 'with an invalid package' do
it 'returns 404' do
post '/internal/v4/buildpack_cache/bad-stack-app/upload', upload_req
expect(last_response.status).to eq(404)
end
context 'when the upload path is nil' do
let(:upload_req) do
{ upload: { droplet: nil } }
end
it 'does not create an upload job' do
expect do
post "/internal/v4/buildpack_cache/#{stack}/#{app_model.guid}/upload", upload_req
end.not_to(change(Delayed::Job, :count))
end
end
end
end
describe 'GET /staging/v3/buildpack_cache/:stack/:app_guid/download' do
let(:app_model) { AppModel.make }
let(:buildpack_cache) { Tempfile.new(app_model.guid) }
let(:stack) { Sham.name }
before do
buildpack_cache.write('droplet contents')
buildpack_cache.close
authorize staging_user, staging_password
end
after { FileUtils.rm(buildpack_cache.path) }
def make_request(guid=app_model.guid)
get "/staging/v3/buildpack_cache/#{stack}/#{guid}/download"
end
context 'with a valid buildpack cache' do
context 'when nginx is enabled' do
it 'redirects nginx to serve staged droplet' do
buildpack_cache_blobstore.cp_to_blobstore(
buildpack_cache.path,
"#{app_model.guid}/#{stack}"
)
make_request
expect(last_response.status).to eq(200)
expect(last_response.headers['X-Accel-Redirect']).to match("/cc-droplets/.*/#{app_model.guid}/#{stack}")
end
end
context 'when nginx is disabled' do
let(:staging_config) do
original_staging_config.merge({ nginx: { use_nginx: false } })
end
it 'returns the buildpack cache' do
buildpack_cache_blobstore.cp_to_blobstore(
buildpack_cache.path,
"#{app_model.guid}/#{stack}"
)
make_request
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('droplet contents')
end
end
end
context 'with a valid buildpack cache but no file' do
it 'returns an error' do
make_request
expect(last_response.status).to eq(400)
end
end
context 'with an invalid buildpack cache' do
it 'returns an error' do
make_request('bad_guid')
expect(last_response.status).to eq(404)
end
end
end
describe 'GET /staging/v3/droplets/:guid/download' do
let(:droplet) { DropletModel.make }
before { authorize(staging_user, staging_password) }
def upload_droplet
tmpdir = Dir.mktmpdir
zipname = File.join(tmpdir, 'test.zip')
TestZip.create(zipname, 10, 1024)
file_contents = File.read(zipname)
Jobs::V3::DropletUpload.new(zipname, droplet.guid, skip_state_transition: false).perform
FileUtils.rm_rf(tmpdir)
file_contents
end
context 'when using with nginx' do
before { TestConfig.override(**staging_config) }
it 'succeeds for valid droplets' do
upload_droplet
get "/staging/v3/droplets/#{droplet.guid}/download"
expect(last_response.status).to eq(200)
droplet.reload
expect(last_response.headers['X-Accel-Redirect']).to match("/cc-droplets/.*/#{droplet.blobstore_key}")
end
end
context 'when not using with nginx' do
let(:staging_config) do
original_staging_config.merge({ nginx: { use_nginx: false } })
end
it 'succeeds for valid droplets' do
encoded_expected_body = Base64.encode64(upload_droplet)
get "/staging/v3/droplets/#{droplet.guid}/download"
expect(last_response.status).to eq(200)
encoded_actual_body = Base64.encode64(last_response.body)
expect(encoded_actual_body).to eq(encoded_expected_body)
end
end
it 'fails if blobstore is not local' do
allow_any_instance_of(CloudController::Blobstore::FogClient).to receive(:local?).and_return(false)
get '/staging/v3/droplets/some-guid/download'
expect(last_response.status).to eq(400)
end
it 'returns an error for non-existent droplets' do
get '/staging/v3/droplets/bad-guid/download'
expect(last_response.status).to eq(404)
end
include_examples 'staging bad auth', :get, 'droplets'
end
end
end