cloudfoundry/cloud_controller_ng

View on GitHub
spec/unit/controllers/runtime/app_bits_upload_controller_spec.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require 'spec_helper'

## NOTICE: Prefer request specs over controller specs as per ADR #0003 ##

module VCAP::CloudController
  RSpec.describe AppBitsUploadController do
    let(:app_event_repository) { Repositories::AppEventRepository.new }

    before { CloudController::DependencyLocator.instance.register(:app_event_repository, app_event_repository) }

    describe 'PUT /v2/app/:id/bits' do
      before do
        config_override.merge!(
          directories: { tmpdir: File.dirname(valid_zip.path) },
          kubernetes: {}
        )
        TestConfig.override(**config_override)
      end

      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

      let(:config_override) { {} }
      let(:headers) { headers_for(user) }
      let(:process) { ProcessModel.make }

      def make_request
        put "/v2/apps/#{process.app.guid}/bits", req_body, form_headers(headers)
      end

      context 'as an admin' do
        let(:headers) { admin_headers }
        let(:req_body) { { resources: '[]', application: valid_zip } }

        it 'allows upload even if app_bits_upload flag is disabled' do
          FeatureFlag.make(name: 'app_bits_upload', enabled: false)
          make_request
          expect(last_response.status).to eq(201)
        end
      end

      context 'as a developer' do
        let(:user) { make_developer_for_space(process.space) }

        context 'when the app_bits_upload feature flag is disabled' do
          let(:req_body) { { resources: '[]', application: valid_zip } }

          before do
            FeatureFlag.make(name: 'app_bits_upload', enabled: false, error_message: nil)
            make_request
            process.refresh
          end

          it 'returns FeatureDisabled and does not upload' do
            expect(last_response.status).to eq(403)
            expect(decoded_response['error_code']).to match(/FeatureDisabled/)
            expect(decoded_response['description']).to match(/app_bits_upload/)

            expect(process.package_hash).to be_nil
          end

          it 'does not modify the package state' do
            expect(process.package_state).not_to eq 'FAILED'
          end
        end

        context 'when the app_bits_upload feature flag is enabled' do
          before do
            FeatureFlag.make(name: 'app_bits_upload', enabled: true)
          end

          context 'with an empty request' do
            let(:req_body) { {} }

            before do
              # rack_test overrides 'CONTENT_TYPE' header with 'boundary' which causes errors when the request does not contain an application
              headers[:multipart] = false
            end

            it 'fails to upload' do
              make_request

              expect(last_response.status).to eq(400)
              expect(Oj.load(last_response.body)['description']).to include('missing :resources')

              process.refresh
              expect(process.package_hash).to be_nil
              expect(process.package_state).to eq 'FAILED'
            end
          end

          context 'with empty resources and no application' do
            let(:req_body) { { resources: '[]' } }

            before do
              # rack_test overrides 'CONTENT_TYPE' header with 'boundary' which causes errors when the request does not contain an application
              headers[:multipart] = false
            end

            it 'fails to upload' do
              make_request

              expect(last_response.status).to eq(400)
              expect(Oj.load(last_response.body)['description']).to include('Invalid zip')

              process.refresh
              expect(process.package_hash).to be_nil
              expect(process.package_state).to eq 'FAILED'
            end
          end

          context 'with at least one resource and no application' do
            let(:req_body) { { resources: Oj.dump([{ 'fn' => 'lol', 'sha1' => 'abc', 'size' => 2048 }]) } }

            before do
              # rack_test overrides 'CONTENT_TYPE' header with 'boundary' which causes errors when the request does not contain an application
              headers[:multipart] = false
            end

            it 'succeeds to upload' do
              make_request
              expect(last_response.status).to eq(201)
              expect(process.refresh.package_hash).not_to be_nil
            end
          end

          context 'with at least one resource and an application' do
            let(:req_body) { { resources: Oj.dump([{ 'fn' => 'lol', 'sha1' => 'abc', 'size' => 2048 }]), application: valid_zip } }

            it 'succeeds to upload' do
              make_request
              expect(last_response.status).to eq(201)
              expect(process.refresh.package_hash).not_to be_nil
            end
          end

          context 'with no resources and application' do
            let(:req_body) { { application: valid_zip } }

            it 'fails to upload' do
              make_request

              expect(last_response.status).to eq(400)
              expect(Oj.load(last_response.body)['description']).to include('missing :resources')

              process.refresh
              expect(process.package_hash).to be_nil
              expect(process.package_state).to eq 'FAILED'
            end
          end

          context 'with empty resources' do
            let(:req_body) do
              { resources: '[]', application: valid_zip }
            end

            it 'succeeds to upload' do
              make_request
              expect(last_response.status).to eq(201)
              expect(process.refresh.package_hash).not_to be_nil
            end
          end

          context 'with invalid resources' do
            let(:req_body) do
              { resources: '[abcddf]', application: valid_zip }
            end

            it 'fails to upload' do
              make_request

              expect(last_response.status).to eq(400)
              expect(decoded_response['error_code']).to match(/AppBitsUploadInvalid/)
            end
          end

          context 'with a bad zip file' do
            let(:bad_zip) { Rack::Test::UploadedFile.new(Tempfile.new('bad_zip')) }
            let(:req_body) do
              { resources: '[]', application: bad_zip }
            end

            it 'fails to upload' do
              make_request

              expect(last_response.status).to eq(400)
              expect(Oj.load(last_response.body)['description']).to include('Invalid zip')

              process.refresh
              expect(process.package_hash).to be_nil
              expect(process.package_state).to eq 'FAILED'
            end
          end

          context 'with a valid zip file' do
            let(:req_body) do
              { resources: '[]', application: valid_zip }
            end

            it 'succeeds to upload' do
              make_request
              expect(last_response.status).to eq(201)
              expect(process.refresh.package_hash).not_to be_nil
            end

            it 'records audit events' do
              expect { make_request }.to change(Event, :count).by(1)
              event = Event.find(type: 'audit.app.upload-bits')
              expect(event.actee).to eq(process.app.guid)
            end

            context 'when the upload will finish after the auth token expires' do
              let(:config_override) do
                { app_bits_upload_grace_period_in_seconds: 200 }
              end

              context 'but the upload will finish inside the grace period' do
                it 'succeeds' do
                  headers = headers_for(user)

                  Timecop.travel(Time.now.utc + 1.week + 100.seconds) do
                    put "/v2/apps/#{process.app.guid}/bits", req_body, headers
                  end
                  expect(last_response.status).to eq(201)
                end
              end

              context 'and the upload will finish after the grace period' do
                it 'fails to authorize the upload' do
                  headers = headers_for(user)

                  Timecop.travel(Time.now.utc + 1.week + 10_000.seconds) do
                    put "/v2/apps/#{process.app.guid}/bits", req_body, headers
                  end
                  expect(last_response.status).to eq(401)
                end
              end
            end
          end

          describe 'resources' do
            context 'with a bad file path' do
              let(:req_body) { { resources: Oj.dump([{ 'fn' => '../../lol', 'sha1' => 'abc', 'size' => 2048 }]), application: valid_zip } }

              it 'fails to upload' do
                make_request

                expect(last_response.status).to eq(400)
                expect(Oj.load(last_response.body)['description']).to include("'../../lol' is not safe")

                process.refresh
                expect(process.package_hash).to be_nil
                expect(process.package_state).to eq 'FAILED'
              end
            end

            context 'with a bad file mode' do
              context 'when the file is not readable by owner' do
                let(:req_body) { { resources: Oj.dump([{ 'fn' => 'lol', 'sha1' => 'abc', 'size' => 2048, 'mode' => '377' }]), application: valid_zip } }

                it 'fails to upload' do
                  make_request
                  expect(last_response.status).to eq(400)
                  expect(Oj.load(last_response.body)['description']).to include("'377' with path 'lol' is invalid")

                  process.refresh
                  expect(process.package_hash).to be_nil
                  expect(process.package_state).to eq 'FAILED'
                end
              end

              context 'when the file is not writable by owner' do
                let(:req_body) { { resources: Oj.dump([{ 'fn' => 'lol', 'sha1' => 'abc', 'size' => 2048, 'mode' => '577' }]), application: valid_zip } }

                it 'fails to upload' do
                  make_request
                  expect(last_response.status).to eq(400)
                  expect(Oj.load(last_response.body)['description']).to include("'577' with path 'lol' is invalid")

                  process.refresh
                  expect(process.package_hash).to be_nil
                  expect(process.package_state).to eq 'FAILED'
                end
              end
            end
          end
        end
      end

      context 'as a non-developer' do
        let(:user) { make_user_for_space(process.space) }
        let(:req_body) do
          { resources: '[]', application: valid_zip }
        end

        it 'returns 403' do
          make_request
          expect(last_response.status).to eq(403)
        end
      end

      context 'when running async=true' do
        let(:user) { make_developer_for_space(process.space) }
        let(:req_body) do
          { resources: '[]', application: valid_zip }
        end
        let(:config_override) do
          { index: 99, name: 'api_z1' }
        end

        it 'creates a delayed job' do
          expect do
            put "/v2/apps/#{process.app.guid}/bits?async=true", req_body, headers_for(user)
          end.to change(Delayed::Job, :count).by(1)

          response_body = Oj.load(last_response.body, symbolize_names: true)
          job = Delayed::Job.last
          expect(job.handler).to include(process.reload.latest_package.guid)
          expect(job.queue).to eq('cc-api_z1-99')
          expect(job.guid).not_to be_nil
          expect(last_response.status).to eq 201
          expect(response_body).to eq({
                                        metadata: {
                                          guid: job.guid,
                                          created_at: job.created_at.iso8601,
                                          url: "/v2/jobs/#{job.guid}"
                                        },
                                        entity: {
                                          guid: job.guid,
                                          status: 'queued'
                                        }
                                      })
        end

        describe 'resources' do
          context 'with a bad file path' do
            let(:req_body) { { resources: Oj.dump([{ 'fn' => '../../lol', 'sha1' => 'abc', 'size' => 2048 }]), application: valid_zip } }

            it 'fails to upload' do
              expect do
                put "/v2/apps/#{process.app.guid}/bits?async=true", req_body, form_headers(headers_for(user))
              end.to change(Delayed::Job, :count).by(1)

              execute_all_jobs(expected_successes: 0, expected_failures: 1)

              process.refresh
              expect(process.package_hash).to be_nil
              expect(process.package_state).to eq 'FAILED'
            end
          end

          context 'with a bad file mode' do
            context 'when the file is not readable by owner' do
              let(:req_body) { { resources: Oj.dump([{ 'fn' => 'lol', 'sha1' => 'abc', 'size' => 2048, 'mode' => '300' }]), application: valid_zip } }

              before do
                FeatureFlag.make(name: 'app_bits_upload', enabled: true)
              end

              it 'fails to upload' do
                expect do
                  put "/v2/apps/#{process.app.guid}/bits?async=true", req_body, form_headers(headers_for(user))
                end.to change(Delayed::Job, :count).by(1)

                execute_all_jobs(expected_successes: 0, expected_failures: 1)

                process.refresh
                expect(process.package_hash).to be_nil
                expect(process.package_state).to eq 'FAILED'
              end
            end

            context 'when the file is not writable by owner' do
              let(:req_body) { { resources: Oj.dump([{ 'fn' => 'lol', 'sha1' => 'abc', 'size' => 2048, 'mode' => '577' }]), application: valid_zip } }

              it 'fails to upload' do
                expect do
                  put "/v2/apps/#{process.app.guid}/bits?async=true", req_body, form_headers(headers_for(user))
                end.to change(Delayed::Job, :count).by(1)

                execute_all_jobs(expected_successes: 0, expected_failures: 1)

                process.refresh
                expect(process.package_hash).to be_nil
                expect(process.package_state).to eq 'FAILED'
              end
            end
          end
        end
      end

      context 'when the app is a docker app' do
        let(:process) { ProcessModel.make(app: AppModel.make(:docker)) }
        let(:req_body) { { resources: '[]', application: valid_zip } }
        let(:headers) { admin_headers }

        it 'raises an error' do
          make_request

          expect(last_response.status).to eq(422)
          expect(decoded_response['error_code']).to match(/UnprocessableEntity/)
          expect(decoded_response['description']).to match(/cannot upload bits to a docker app/)
        end
      end
    end

    describe 'POST /v2/apps/:guid/copy_bits' do
      let(:dest_process) { ProcessModel.make }
      let(:src_process) { ProcessModelFactory.make }
      let(:json_payload) { { 'source_app_guid' => src_process.app.guid }.to_json }

      class FakeCopier
        def initialize(src_process, dest_process, app_event_repo, user, email)
          @src_process = src_process
          @dest_process = dest_process
          @app_event_repo = app_event_repo
          @user = user
          @email = email
        end

        def perform
          FakeCopier.copies << [@src_process, @dest_process, @app_event_repo, @user, @email]
        end

        class << self
          attr_accessor :copies
        end
        self.copies = []
      end

      context 'when no source guid is sent' do
        let(:json_payload) { '{}' }

        it 'fails to copy application bits' do
          post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, admin_headers

          expect(last_response.status).to eq(400)

          expect(decoded_response['error_code']).to match(/AppBitsCopyInvalid/)
          expect(decoded_response['description']).to match(/missing source_app_guid/)
        end
      end

      context 'when a source guid is supplied' do
        it 'returns a delayed job' do
          expect do
            post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, admin_headers
          end.to change(Delayed::Job, :count).by(1)

          job = Delayed::Job.last
          expected_response = {
            'metadata' => {
              'guid' => job.guid,
              'created_at' => job.created_at.iso8601,
              'url' => "/v2/jobs/#{job.guid}"
            },
            'entity' => {
              'guid' => job.guid,
              'status' => 'queued'
            }
          }

          expect(job.queue).to eq(Jobs::Queues.generic)
          expect(last_response.status).to eq(201)
          expect(decoded_response).to eq(expected_response)
        end

        it 'records audit events on the source and destination apps' do
          expect do
            post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, admin_headers
          end.to change(Event, :count).by(2)

          source_event = Event.find(actee: src_process.app.guid)
          dest_event = Event.find(actee: dest_process.app.guid)

          expect(source_event.type).to eq('audit.app.copy-bits')
          expect(dest_event.type).to eq('audit.app.copy-bits')
        end

        context 'validation permissions' do
          it 'allows an admin' do
            stub_const('VCAP::CloudController::Jobs::Runtime::AppBitsCopier', FakeCopier)
            post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, admin_headers

            expect(last_response.status).to eq(201)
          end

          it 'disallows when not a developer of destination space' do
            stub_const('VCAP::CloudController::Jobs::Runtime::AppBitsCopier', FakeCopier)
            user = make_developer_for_space(src_process.space)

            post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, headers_for(user)

            expect(last_response.status).to eq(403)
          end

          it 'disallows when not a developer of source space' do
            stub_const('VCAP::CloudController::Jobs::Runtime::AppBitsCopier', FakeCopier)
            user = make_developer_for_space(dest_process.space)

            post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, headers_for(user)

            expect(last_response.status).to eq(403)
          end

          it 'allows when a developer of both spaces' do
            stub_const('VCAP::CloudController::Jobs::Runtime::AppBitsCopier', FakeCopier)
            user = make_developer_for_space(dest_process.space)
            src_process.organization.add_user(user)
            src_process.space.add_developer(user)

            post "/v2/apps/#{dest_process.app.guid}/copy_bits", json_payload, headers_for(user)
            expect(last_response.status).to eq(201)
          end
        end
      end
    end
  end
end